Skip to content

Commit 024411d

Browse files
author
Maksym Shtembuliak
committed
fix: recognize Anenji Protocol 4 hardware revision
1 parent 86bb435 commit 024411d

14 files changed

Lines changed: 406 additions & 84 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ the GitHub release body should be rendered from the matching version section her
5555

5656
### Fixed
5757

58+
- Restored exact Anenji ANJ-11KW-48V-WIFI-P identification for the captured
59+
Protocol 4 fingerprint `layout=4`, `model=0x8401`. Unlike the broad legacy
60+
heuristic, the new catalog entry affects only this proven fingerprint; it
61+
uses the documented Protocol 4 telemetry map while keeping all writes
62+
untested and Full-Control-only until hardware confirmation.
63+
- Fixed cancellation cleanup for an in-progress inverter driver sweep on
64+
Python 3.14. Expected late probe failures are now consumed by an owned
65+
no-fail outcome relay instead of being reported as unhandled event-loop
66+
exceptions after Home Assistant cancels or replaces the refresh.
5867
- Fixed AT-text collector restart verification by separating the vendor's
5968
dedicated soft-reset command from its staged-settings apply command. A command
6069
acknowledgement still does not count as recovery until the old session drops
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"schema_version": 1,
3-
"catalog_version": "2026.08.1",
3+
"catalog_version": "2026.08.2",
44
"description": "Commercial inverter model support catalog"
55
}

‎catalog/inverter_models/models/anenji_anj_11kw_48v_wifi_p.json‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@
1313
"anenji_anj_11kw"
1414
],
1515
"known_firmware": []
16+
},
17+
{
18+
"variant_key": "layout4_model33793",
19+
"label": "Anenji 11kW model-code 0x8401 variant",
20+
"device_descriptor_keys": [
21+
"anenji_anj_11kw_8401"
22+
],
23+
"validation": {
24+
"hardware": "captured",
25+
"telemetry": "confirmed",
26+
"controls": "partial"
27+
},
28+
"known_firmware": []
1629
}
1730
],
1831
"validation": {
@@ -27,6 +40,7 @@
2740
"notes": [
2841
"Device-specific runtime profile is user-confirmed.",
2942
"Communication Protocol No. 3-10 documentation defines the protocol-4 telemetry projection used by this model.",
43+
"The captured 0x8401 hardware fingerprint uses the documented Protocol 4 telemetry surface; its write controls remain untested and require explicit Full Control.",
3044
"Register 602 secondary-output priority is documented and hardware-read-correlated; local write behavior remains conditional pending a device retest."
3145
]
3246
},
@@ -38,6 +52,7 @@
3852
"corpus_anenji_anj_11kw",
3953
"private_message_anenji_anj_11kw_validation",
4054
"documentation_anenji_protocol_3_10",
41-
"support_archive_anenji_anj_11kw_secondary_controls_20260707"
55+
"support_archive_anenji_anj_11kw_secondary_controls_20260707",
56+
"github_issue_13_anenji_8401_beta4"
4257
]
4358
}

‎catalog/inverter_models/schemas/model.schema.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@
4646
"items": {"type": "string"},
4747
"description": "Keys of devices[] entries in the runtime inverter catalog."
4848
},
49+
"validation": {
50+
"type": "object",
51+
"additionalProperties": false,
52+
"required": ["hardware", "telemetry", "controls"],
53+
"description": "Optional hardware-variant validation override. Omit when the model-level validation applies unchanged.",
54+
"properties": {
55+
"hardware": {"type": "string", "enum": ["none", "reported", "captured", "confirmed"]},
56+
"telemetry": {"type": "string", "enum": ["unknown", "partial", "confirmed"]},
57+
"controls": {"type": "string", "enum": ["none", "partial", "confirmed"]}
58+
}
59+
},
4960
"known_firmware": {"type": "array", "items": {"type": "string"}}
5061
}
5162
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"schema_version": 1,
3+
"source_key": "github_issue_13_anenji_8401_beta4",
4+
"kind": "github_issue",
5+
"title": "Anenji ANJ-11KW-48V-WIFI-P 0x8401 beta.4 support archive",
6+
"reference": "project-issue:13#support-archive-20260830",
7+
"captured_at": "2026-08-30",
8+
"visibility": "public",
9+
"summary": "A beta.4 support archive captured the exact Modbus fingerprint layout 4 and model code 0x8401 for an Anenji ANJ-11KW-48V-WIFI-P. The same collector and inverter identity had previously been classified by the cloud as devcode 6544 and had operated through the Anenji Protocol 4 map on v0.2.0. The new fingerprint therefore receives exact commercial identity and Protocol 4 telemetry, while its local write controls remain explicitly untested.",
10+
"assertions": [
11+
"commercial_identity",
12+
"fingerprint",
13+
"runtime_compatibility",
14+
"telemetry_validation"
15+
]
16+
}

‎custom_components/eybond_local/protocol_catalogs/inverter_catalog.json‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema_version": 2,
3-
"catalog_version": "2026.08.7",
3+
"catalog_version": "2026.08.8",
44
"description": "Universal offline inverter catalog with reusable protocol actions, runtime surfaces, and deterministic identity evidence.",
55
"protocols": [
66
{
@@ -1925,6 +1925,48 @@
19251925
"confidence": "capture-derived"
19261926
}
19271927
},
1928+
{
1929+
"entry_key": "anenji_anj_11kw_8401",
1930+
"fingerprint": {
1931+
"layout_code": 4,
1932+
"model_code": 33793
1933+
},
1934+
"structural": [
1935+
"serial_ascii_plausible"
1936+
],
1937+
"model_name": "Anenji ANJ-11KW-48V-WIFI-P",
1938+
"surface_key": "smg_protocol_4_family_fallback",
1939+
"runtime_probe": {
1940+
"validation": [
1941+
{
1942+
"key": "output_rating_voltage",
1943+
"min": 1
1944+
},
1945+
{
1946+
"key": "output_rating_frequency",
1947+
"min": 1
1948+
},
1949+
{
1950+
"key": "protocol_number",
1951+
"one_of": [
1952+
4
1953+
]
1954+
}
1955+
]
1956+
},
1957+
"cloud_hints": {
1958+
"devcodes": [
1959+
6544
1960+
]
1961+
},
1962+
"provenance": {
1963+
"sources": [
1964+
"project-issue:13 support archive (2026-08-30): layout_code=4, model_code=0x8401, stable serial and responsive Protocol 4 telemetry",
1965+
"project-issue:13 historical evidence: the same collector/inverter identity was cloud-classified as devcode 6544 and operated through the Anenji Protocol 4 map on v0.2.0"
1966+
],
1967+
"confidence": "capture-derived"
1968+
}
1969+
},
19281970
{
19291971
"entry_key": "sandisolar_sd_11kp48v",
19301972
"fingerprint": {

‎custom_components/eybond_local/runtime/hub/detection.py‎

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,33 @@
2626
)
2727

2828

29-
async def _async_cancel_and_drain_task(task: asyncio.Task) -> bool:
29+
async def _async_capture_task_outcome(task: asyncio.Task) -> tuple[bool, object]:
30+
"""Capture one owned task outcome without failing the relay task."""
31+
32+
try:
33+
return True, await task
34+
except asyncio.CancelledError as exc:
35+
return False, exc
36+
except Exception as exc:
37+
return False, exc
38+
39+
40+
async def _async_await_captured_task(outcome_task: asyncio.Task) -> object:
41+
"""Await a no-fail outcome relay and restore the captured semantics."""
42+
43+
succeeded, outcome = await asyncio.shield(outcome_task)
44+
if succeeded:
45+
return outcome
46+
if isinstance(outcome, BaseException):
47+
raise outcome
48+
raise RuntimeError("invalid_task_outcome")
49+
50+
51+
async def _async_cancel_and_drain_task(
52+
task: asyncio.Task,
53+
*,
54+
outcome_task: asyncio.Task | None = None,
55+
) -> bool:
3056
"""Cancel one owned child task and retrieve its terminal outcome.
3157
3258
Driver detection races the wire probe against an owned-session guard. If
@@ -37,25 +63,29 @@ async def _async_cancel_and_drain_task(task: asyncio.Task) -> bool:
3763
interrupting mandatory cleanup.
3864
"""
3965

40-
parent_cancel_interrupted = False
4166
if not task.done():
4267
task.cancel()
68+
69+
# Python 3.14 deliberately reports an exception from the inner future when
70+
# a shield wrapper is cancelled. Shield a no-fail outcome relay instead of
71+
# the owned wire task itself. Callers that already use such a relay pass it
72+
# in so the wire task is never directly wrapped by shield.
73+
drain_task = outcome_task or asyncio.create_task(
74+
_async_capture_task_outcome(task),
75+
name=f"{task.get_name()}_drain",
76+
)
77+
parent_cancel_interrupted = False
4378
while True:
4479
try:
45-
await asyncio.shield(task)
80+
await asyncio.shield(drain_task)
4681
return parent_cancel_interrupted
4782
except asyncio.CancelledError:
48-
if task.done():
83+
if drain_task.done():
4984
return parent_cancel_interrupted
50-
# Shield keeps the owned child alive, so a cancellation observed
85+
# Shield keeps the outcome consumer alive, so cancellation observed
5186
# while it is still pending belongs to this parent task. Remember
5287
# it and finish draining before the caller re-raises it.
5388
parent_cancel_interrupted = True
54-
except Exception:
55-
# The caller already consumed normal-path failures. A failure that
56-
# races parent cancellation is still retrieved here and remains an
57-
# ordinary detection outcome rather than an orphan task exception.
58-
return parent_cancel_interrupted
5989

6090

6191
class HubDetectionMixin:
@@ -241,6 +271,10 @@ async def _async_detect_driver(self) -> str:
241271
),
242272
name="eybond_inverter_detection",
243273
)
274+
detection_outcome_task = asyncio.create_task(
275+
_async_capture_task_outcome(detection_task),
276+
name="eybond_inverter_detection_outcome",
277+
)
244278
wait_for_session_change = getattr(
245279
self._link_manager,
246280
"async_wait_for_owned_session_change",
@@ -255,19 +289,29 @@ async def _async_detect_driver(self) -> str:
255289
parent_cancel_requested = False
256290
try:
257291
if session_change_task is None:
258-
detection_result = await asyncio.shield(detection_task)
292+
detection_result = await _async_await_captured_task(
293+
detection_outcome_task
294+
)
259295
else:
260296
done, _pending = await asyncio.wait(
261-
(detection_task, session_change_task),
297+
(detection_outcome_task, session_change_task),
262298
return_when=asyncio.FIRST_COMPLETED,
263299
)
264-
if session_change_task in done and detection_task not in done:
265-
await _async_cancel_and_drain_task(detection_task)
300+
if (
301+
session_change_task in done
302+
and detection_outcome_task not in done
303+
):
304+
await _async_cancel_and_drain_task(
305+
detection_task,
306+
outcome_task=detection_outcome_task,
307+
)
266308
logger.info(
267309
"Discarding inverter detection after owned collector session changed"
268310
)
269311
return "collector_session_changed"
270-
detection_result = await asyncio.shield(detection_task)
312+
detection_result = await _async_await_captured_task(
313+
detection_outcome_task
314+
)
271315
except asyncio.CancelledError:
272316
parent_cancel_requested = True
273317
raise
@@ -288,7 +332,10 @@ async def _async_detect_driver(self) -> str:
288332
return str(exc)
289333
finally:
290334
parent_cancel_requested = (
291-
await _async_cancel_and_drain_task(detection_task)
335+
await _async_cancel_and_drain_task(
336+
detection_task,
337+
outcome_task=detection_outcome_task,
338+
)
292339
or parent_cancel_requested
293340
)
294341
if session_change_task is not None:

‎docs/generated/INVERTER_MODEL_CATALOG.generated.md‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
Generated by `tools/model_catalog.py`. Do not edit by hand.
44

5-
- Model catalog version: 2026.08.1
6-
- Runtime inverter catalog version: 2026.08.7
7-
- Models: 17 | Variants: 18
5+
- Model catalog version: 2026.08.2
6+
- Runtime inverter catalog version: 2026.08.8
7+
- Models: 17 | Variants: 19
88

99
## How To Read This Catalog
1010

@@ -21,7 +21,7 @@ This page describes what has been confirmed for a specific inverter model or mod
2121

2222
| Manufacturer | Model | Protocol | Detection | Runtime Tier | Telemetry | Controls | Hardware |
2323
|---|---|---|---|---|---|---|---|
24-
| Anenji | ANJ-11KW-48V-WIFI-P | modbus_smg | fingerprint | full | confirmed | confirmed | captured |
24+
| Anenji | ANJ-11KW-48V-WIFI-P — Anenji 11kW variant | modbus_smg | fingerprint | full | confirmed | confirmed | captured |
2525
| PowMr | 4.2KW — PI30 VMII-NXPW5KW raw-model variant | pi30 | anchors | full | confirmed | confirmed | captured |
2626
| PowMr | 4.2KW — SmartESS 0925 local Modbus alternate runtime | pi30 | anchors | full | confirmed | confirmed | captured |
2727
| Sandisolar | SD-HYM-4862HWP | modbus_smg | fingerprint | full | confirmed | confirmed | confirmed |
@@ -31,6 +31,7 @@ This page describes what has been confirmed for a specific inverter model or mod
3131
| Manufacturer | Model | Protocol | Detection | Runtime Tier | Telemetry | Controls | Hardware |
3232
|---|---|---|---|---|---|---|---|
3333
| Anenji | 6200 (single output) | modbus_smg | fingerprint | full | confirmed | partial | captured |
34+
| Anenji | ANJ-11KW-48V-WIFI-P — Anenji 11kW model-code 0x8401 variant | modbus_smg | fingerprint | full | confirmed | partial | captured |
3435
| Anenji | ANJ-4000W-24V | modbus_smg | fingerprint | full | confirmed | partial | captured |
3536
| Anenji | ANJ-6200-48PL | modbus_smg | fingerprint | full | confirmed | partial | captured |
3637
| Anenji | HHS-11kW-WIFI (without parallel) | modbus_smg | fingerprint | full | partial | partial | captured |
@@ -107,6 +108,7 @@ Runtime descriptors with no specific commercial model record. These are generic
107108
- Coverage notes:
108109
- Device-specific runtime profile is user-confirmed.
109110
- Communication Protocol No. 3-10 documentation defines the protocol-4 telemetry projection used by this model.
111+
- The captured 0x8401 hardware fingerprint uses the documented Protocol 4 telemetry surface; its write controls remain untested and require explicit Full Control.
110112
- Register 602 secondary-output priority is documented and hardware-read-correlated; local write behavior remains conditional pending a device retest.
111113
- Summary: Anenji 11kW unit with a model-specific control profile over the shared protocol-4 register projection, backed by donor captures, user-confirmed controls, and the Protocol No. 3-10 documentation.
112114
- Variants:
@@ -117,9 +119,17 @@ Runtime descriptors with no specific commercial model record. These are generic
117119
- Protocol: modbus_smg | Detection: fingerprint (layout 4, model 32768, rated —)
118120
- Tier: full | Read-only: no | Profile: modbus_smg/models/anenji_anj_11kw_48v_wifi_p.json | Schema: modbus_smg/models/anenji_anj_11kw_48v_wifi_p.json
119121
- Capabilities: 63 (tested 50, untested 13); support tiers: blocked 2, conditional 52, standard 9 | Telemetry: 117 measurements, 18 binary sensors
122+
- `layout4_model33793` — Anenji 11kW model-code 0x8401 variant
123+
- Validation override: hardware captured, telemetry confirmed, controls partial
124+
- Descriptors: anenji_anj_11kw_8401
125+
- Known firmware: —
126+
- `anenji_anj_11kw_8401` → surface `smg_protocol_4_family_fallback` (driver modbus_smg, variant protocol_4_family_fallback)
127+
- Protocol: modbus_smg | Detection: fingerprint (layout 4, model 33793, rated —)
128+
- Tier: full | Read-only: no | Profile: modbus_smg/protocols/communication_protocol_4.json | Schema: modbus_smg/protocols/communication_protocol_4.json
129+
- Capabilities: 60 (untested 60); support tiers: blocked 2, conditional 58 | Telemetry: 117 measurements, 18 binary sensors
120130
- Known limitations:
121131
- Secondary Output Priority writes are available only in Full Control until register 602 is write-tested on hardware.
122-
- Evidence: 4 source(s)
132+
- Evidence: 5 source(s)
123133

124134
### Anenji — ANJ-4000W-24V (`anenji_4200_protocol_1`)
125135

‎tests/test_catalog_identity.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,25 @@ async def test_point_identity_reads_restore_confirmed_anenji_11kw(self) -> None:
130130
("modbus_smg.identity.171", "modbus_smg.identity.184"),
131131
)
132132

133+
async def test_point_identity_reads_match_issue_13_anenji_8401_variant(self) -> None:
134+
session = _PointIdentityOnlySession({171: 0x8401, 184: 4})
135+
136+
probe = await async_probe_catalog_identity(session)
137+
138+
self.assertIsNotNone(probe)
139+
assert probe is not None
140+
self.assertEqual(probe.match.kind, MATCH_DEVICE)
141+
self.assertEqual(probe.match.entry.entry_key, "anenji_anj_11kw_8401")
142+
self.assertEqual(probe.match.entry.model_name, "Anenji ANJ-11KW-48V-WIFI-P")
143+
self.assertEqual(probe.match.entry.binding.variant_key, "protocol_4_family_fallback")
144+
self.assertEqual(
145+
probe.match.entry.binding.profile_name,
146+
"modbus_smg/protocols/communication_protocol_4.json",
147+
)
148+
self.assertEqual(probe.layout_code, 4)
149+
self.assertEqual(probe.model_code, 0x8401)
150+
self.assertEqual(session.reads, [(171, 1), (184, 1)])
151+
133152
async def test_point_identity_reads_match_hhs_11kw_protocol_3_variant(self) -> None:
134153
session = _PointIdentityOnlySession({171: 29440, 184: 3})
135154

0 commit comments

Comments
 (0)