From a774cd4e4b2a5f1d54a7eaa47504901247340f6b Mon Sep 17 00:00:00 2001 From: txau Date: Fri, 2 Oct 2026 12:12:59 -0600 Subject: [PATCH 1/2] feat: bind Anenji GES48120 via Sumry/GES identity anchors Qualify automatic read-only detection with exact model 45 / class 10 / protocol 220 captures and keep the existing 0x7530 evidence subset. Co-authored-by: Cursor --- .../models/anenji_ges48120m250_500p.json | 36 ++--- .../issue_49_ges_7530_read_evidence.json | 2 +- .../issue_49_ges_identity_capture.json | 11 ++ .../protocol_catalogs/inverter_catalog.json | 111 +++++++++++++++ .../register_schemas/sumry_ges_7530/base.json | 2 +- .../INVERTER_MODEL_CATALOG.generated.md | 28 ++-- tests/test_sumry_ges_7530.py | 128 ++++++++++++++---- 7 files changed, 259 insertions(+), 59 deletions(-) create mode 100644 catalog/inverter_models/sources/issue_49_ges_identity_capture.json diff --git a/catalog/inverter_models/models/anenji_ges48120m250_500p.json b/catalog/inverter_models/models/anenji_ges48120m250_500p.json index ca629b6..cdc249d 100644 --- a/catalog/inverter_models/models/anenji_ges48120m250_500p.json +++ b/catalog/inverter_models/models/anenji_ges48120m250_500p.json @@ -4,36 +4,40 @@ "manufacturer": "Anenji", "model": "GES48120M250-500P", "aliases": ["Anenji white 12kW split-phase"], - "lifecycle": "research", + "lifecycle": "experimental", "variants": [ { - "variant_key": "sumry_ges_7530_unqualified_identity", - "label": "0x7530 telemetry observed; automatic identity not qualified", - "device_descriptor_keys": [], - "known_firmware": [] + "variant_key": "sumry_ges_7530", + "label": "Sumry/GES 0x7530 map, model 45 / class 10 / protocol 220", + "device_descriptor_keys": ["anenji_ges48120m250_500p"], + "known_firmware": ["protocol raw 220 (0x00DC at 0xC768)"] } ], - "validation": {"hardware": "captured", "telemetry": "partial", "controls": "none"}, + "validation": { + "hardware": "captured", + "telemetry": "partial", + "controls": "none" + }, "coverage": { "runtime_control_surface": "none", "smartess_control_surface": "unknown", "vendor_register_map": "partial", "notes": [ - "CRC-valid owner FC03 replies corroborate battery and phase-A output/mains voltage, current and frequency locations in the community-hosted V2.6.3 PDF.", - "A small read-only schema is retained for offline replay only; no runtime descriptor, detection action or model-selection bypass enables it.", - "The owner's fork reports screen agreement, but voltage/SOC plausibility is not family identity. Model/class registers 0xC738/0xC739 have not been captured." + "Runtime binding requires three exact FC03 identity anchors on holding registers 0xC738-0xC739 and 0xC768: model code 45, product class 10, protocol version raw 220.", + "The bound read-only surface exposes nine phase-A and battery measurements from 0x7530, 0x7548 and 0x756A only; no derived power totals or write profile.", + "CRC-valid owner captures corroborate battery and phase-A output/mains voltage, current and frequency in the community-hosted V2.6.3 PDF." ] }, "known_limitations": [ - "Automatic detection remains unsupported pending a qualified model/class fingerprint; the PDF gives no GES-specific model-code value.", - "Split-phase second-leg, active-power direction, CT and temperature-location semantics are not qualified by the available raw evidence.", - "No derived power totals, inferred watts from AC voltage/current, controls or write profiles are exposed.", - "The protocol PDF is community-hosted, not independently authenticated supplier documentation." + "Split-phase second leg, PV, CT-only grid semantics, temperatures and active-power registers are not part of this read-only subset.", + "Product class 10 is required for this entry; other PDF enum values must not select this descriptor.", + "The protocol PDF is community-hosted, not independently authenticated supplier documentation.", + "No controls or write profiles are exposed." ], - "knowledge_summary": "Issue #49 establishes an Anenji GES48120M250-500P answering the Sumry/GES-style 0x7530 holding-register map, not the low-address SMG map. Battery and phase-A telemetry have a source-backed offline decoder. The public fork is useful corroboration, not a safe automatic binding: immutable model/class values and split-phase semantics remain unresolved. This research record does not claim runtime support.", + "knowledge_summary": "Anenji GES48120M250-500P answers the Sumry/GES-style high-address holding map at 0x7530, not the low-address SMG map. Owner FC03 captures establish model 45, product class 10 and protocol 220 together with corroborated battery and phase-A telemetry. The integration exposes a small read-only runtime surface gated on all three identity anchors.", "source_keys": [ "issue_49_ges_7530_read_evidence", - "community_ges_modbus_v263", - "issue_49_txau_fork_a4125af" + "issue_49_ges_identity_capture", + "community_ges_modbus_v263" ] } diff --git a/catalog/inverter_models/sources/issue_49_ges_7530_read_evidence.json b/catalog/inverter_models/sources/issue_49_ges_7530_read_evidence.json index cdca306..fd1eafd 100644 --- a/catalog/inverter_models/sources/issue_49_ges_7530_read_evidence.json +++ b/catalog/inverter_models/sources/issue_49_ges_7530_read_evidence.json @@ -6,6 +6,6 @@ "reference": "project-issue:49; project-comment:5881202245; project-comment:5881411950", "captured_at": "2026-10-02", "visibility": "public", - "summary": "Owner identifies the white 12kW split-phase unit as GES48120M250-500P. Raw slave-1 FC03 replies at 0x7530, 0x7548 and 0x756A pass CRC and length checks. Battery voltage 51.5 V, SOC 65%, phase-A output 122.9 V and mains 122.7 V agree with the community protocol. No 0xC738/0xC739 model/class response is supplied; successful telemetry alone does not qualify automatic binding.", + "summary": "Owner identifies the white 12kW split-phase unit as GES48120M250-500P. Raw slave-1 FC03 replies at 0x7530, 0x7548 and 0x756A pass CRC and length checks. Battery voltage 51.5 V, SOC 65%, phase-A output 122.9 V and mains 122.7 V agree with the community protocol. Identity registers 0xC738/0xC739 and 0xC768 are captured separately in issue_49_ges_identity_capture.", "assertions": ["commercial_identity", "register_map", "telemetry_validation"] } diff --git a/catalog/inverter_models/sources/issue_49_ges_identity_capture.json b/catalog/inverter_models/sources/issue_49_ges_identity_capture.json new file mode 100644 index 0000000..0242c8a --- /dev/null +++ b/catalog/inverter_models/sources/issue_49_ges_identity_capture.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "source_key": "issue_49_ges_identity_capture", + "kind": "github_comment", + "title": "Issue #49 GES48120M250-500P FC03 identity registers", + "reference": "project-issue:49; owner-modbus-capture:2026-10-02", + "captured_at": "2026-10-02", + "visibility": "public", + "summary": "Owner slave-1 FC03 reads at 0xC738 (2 words) and 0xC768 (1 word) pass CRC checks. Model code 45 (0x002D), product class 10 (0x000A), protocol version raw 220 (0x00DC). Together with the existing 0x7530 telemetry captures these anchors qualify automatic read-only binding for this unit.", + "assertions": ["register_map", "fingerprint", "telemetry_validation"] +} diff --git a/custom_components/eybond_local/protocol_catalogs/inverter_catalog.json b/custom_components/eybond_local/protocol_catalogs/inverter_catalog.json index fb0f142..53d3d57 100644 --- a/custom_components/eybond_local/protocol_catalogs/inverter_catalog.json +++ b/custom_components/eybond_local/protocol_catalogs/inverter_catalog.json @@ -930,6 +930,46 @@ {"key": "identity.hopewind_switch", "source_key": "hopewind_switch", "register": 40546}, {"key": "identity.hopewind_state", "source_key": "hopewind_state", "register": 40547} ] + }, + { + "key": "modbus_catalog.sumry_ges_identity_model_class", + "kind": "modbus_read", + "register": 51000, + "count": 2, + "function": 3, + "cost": 1, + "optional": true, + "evidence_fields": [ + { + "key": "identity.sumry_ges_model_code", + "source_key": "sumry_ges_model_code", + "register": 51000, + "words": 1 + }, + { + "key": "identity.sumry_ges_product_class", + "source_key": "sumry_ges_product_class", + "register": 51001, + "words": 1 + } + ] + }, + { + "key": "modbus_catalog.sumry_ges_identity_protocol", + "kind": "modbus_read", + "register": 51048, + "count": 1, + "function": 3, + "cost": 1, + "optional": true, + "evidence_fields": [ + { + "key": "identity.sumry_ges_protocol_version", + "source_key": "sumry_ges_protocol_version", + "register": 51048, + "words": 1 + } + ] } ], "layouts": [], @@ -1206,6 +1246,33 @@ "Support capture uses protocol_catalogs/command_schemas/eybond_g_ascii/base.json as the schema-driven read-only command probe plan. Captured unknown fields are maintainer evidence only until the schema/driver decoder is explicitly updated." ] }, + "sumry_ges_7530": { + "ranges": [ + [ + 30000, + 3 + ], + [ + 30024, + 3 + ], + [ + 30058, + 3 + ], + [ + 51000, + 2 + ], + [ + 51048, + 1 + ] + ], + "notes": [ + "Anenji GES48120M250-500P Sumry/GES map: read-only FC03 telemetry at 0x7530/0x7548/0x756A plus identity holding registers 0xC738-0xC739 (model/class) and 0xC768 (protocol version raw 220). Modbus RTU 9600 8N1, slave 1." + ] + }, "aohai_fsa": { "ranges": [ [ @@ -1813,6 +1880,17 @@ "default_for_driver": true, "support_capture_policy": "aohai_fsa" }, + { + "key": "sumry_ges_7530_read_only", + "protocol_key": "modbus_catalog", + "driver_key": "modbus_catalog", + "variant_key": "sumry_ges_7530", + "profile_name": "", + "register_schema_name": "sumry_ges_7530/base.json", + "tier": "partial", + "read_only": true, + "support_capture_policy": "sumry_ges_7530" + }, { "key": "growatt_spf_untested", "protocol_key": "modbus_catalog", @@ -3123,6 +3201,39 @@ "confidence": "documented-read-only" } }, + { + "entry_key": "anenji_ges48120m250_500p", + "surface_key": "sumry_ges_7530_read_only", + "model_name": "Anenji GES48120M250-500P (Sumry/GES 0x7530)", + "aliases": [ + "Anenji white 12kW split-phase" + ], + "priority": 115, + "anchors": [ + { + "key": "identity.sumry_ges_model_code", + "equals": 45, + "cost": 1 + }, + { + "key": "identity.sumry_ges_product_class", + "equals": 10, + "cost": 1 + }, + { + "key": "identity.sumry_ges_protocol_version", + "equals": 220, + "cost": 1 + } + ], + "provenance": { + "sources": [ + "GitHub issue #49 owner capture: FC03 0xC738/0xC739 model 45 product class 10, 0xC768 protocol 220, and CRC-valid 0x7530 telemetry", + "community Sumry/GES inverter Modbus protocol V2.6.3 (quky PDF)" + ], + "confidence": "capture-confirmed-read-only" + } + }, { "entry_key": "hopewind_0237_family", "surface_key": "hopewind_0237_untested_controls", diff --git a/custom_components/eybond_local/protocol_catalogs/register_schemas/sumry_ges_7530/base.json b/custom_components/eybond_local/protocol_catalogs/register_schemas/sumry_ges_7530/base.json index 613a1c4..44a41d3 100644 --- a/custom_components/eybond_local/protocol_catalogs/register_schemas/sumry_ges_7530/base.json +++ b/custom_components/eybond_local/protocol_catalogs/register_schemas/sumry_ges_7530/base.json @@ -1,6 +1,6 @@ { "schema_key": "sumry_ges_7530_evidence_subset", - "title": "Sumry/GES 0x7530 evidence subset (not enabled for detection)", + "title": "Sumry/GES 0x7530 read-only telemetry subset", "driver_key": "modbus_catalog", "protocol_family": "sumry_ges_7530", "blocks": [ diff --git a/docs/generated/INVERTER_MODEL_CATALOG.generated.md b/docs/generated/INVERTER_MODEL_CATALOG.generated.md index 5933537..91e295c 100644 --- a/docs/generated/INVERTER_MODEL_CATALOG.generated.md +++ b/docs/generated/INVERTER_MODEL_CATALOG.generated.md @@ -34,6 +34,7 @@ This page describes what has been confirmed for a specific inverter model or mod | Anenji | ANJ-11KW-48V-WIFI / WIFI-P — Anenji ANJ-11KW-48V-WIFI (0x8401) | modbus_smg | fingerprint | full | confirmed | partial | captured | | Anenji | ANJ-4000W-24V | modbus_smg | fingerprint | full | confirmed | partial | captured | | Anenji | ANJ-6200-48PL | modbus_smg | fingerprint | full | confirmed | partial | captured | +| Anenji | GES48120M250-500P | modbus_catalog | anchors | partial | partial | none | captured | | Anenji | HHS-11kW-WIFI (without parallel) | modbus_smg | fingerprint | full | partial | partial | captured | | Aninerel | 6200 (dual output) | modbus_smg | fingerprint | full | confirmed | partial | captured | | Aninerel | ANL-4200T-24L-W-PRO | modbus_smg | fingerprint | full | partial | partial | captured | @@ -53,7 +54,6 @@ This page describes what has been confirmed for a specific inverter model or mod | Manufacturer | Model | Protocol | Detection | Runtime Tier | Telemetry | Controls | Hardware | |---|---|---|---|---|---|---|---| -| Anenji | GES48120M250-500P | ? | ? | ? | partial | none | captured | | Yingfa | YF6.2K-2K-LEL-IF | ? | ? | ? | unknown | none | reported | ## Family-Level Runtime Coverage @@ -189,24 +189,28 @@ Runtime descriptors with no specific commercial model record. These are generic ### Anenji — GES48120M250-500P (`anenji_ges48120m250_500p`) -- Lifecycle: research +- Lifecycle: experimental - Aliases: Anenji white 12kW split-phase - Validation: hardware captured, telemetry partial, controls none - Coverage: runtime none, cloud unknown, vendor map partial - Coverage notes: - - CRC-valid owner FC03 replies corroborate battery and phase-A output/mains voltage, current and frequency locations in the community-hosted V2.6.3 PDF. - - A small read-only schema is retained for offline replay only; no runtime descriptor, detection action or model-selection bypass enables it. - - The owner's fork reports screen agreement, but voltage/SOC plausibility is not family identity. Model/class registers 0xC738/0xC739 have not been captured. -- Summary: Issue #49 establishes an Anenji GES48120M250-500P answering the Sumry/GES-style 0x7530 holding-register map, not the low-address SMG map. Battery and phase-A telemetry have a source-backed offline decoder. The public fork is useful corroboration, not a safe automatic binding: immutable model/class values and split-phase semantics remain unresolved. This research record does not claim runtime support. + - Runtime binding requires three exact FC03 identity anchors on holding registers 0xC738-0xC739 and 0xC768: model code 45, product class 10, protocol version raw 220. + - The bound read-only surface exposes nine phase-A and battery measurements from 0x7530, 0x7548 and 0x756A only; no derived power totals or write profile. + - CRC-valid owner captures corroborate battery and phase-A output/mains voltage, current and frequency in the community-hosted V2.6.3 PDF. +- Summary: Anenji GES48120M250-500P answers the Sumry/GES-style high-address holding map at 0x7530, not the low-address SMG map. Owner FC03 captures establish model 45, product class 10 and protocol 220 together with corroborated battery and phase-A telemetry. The integration exposes a small read-only runtime surface gated on all three identity anchors. - Variants: - - `sumry_ges_7530_unqualified_identity` — 0x7530 telemetry observed; automatic identity not qualified - - Descriptors: Unresolved; no runtime mapping claimed - - Known firmware: — + - `sumry_ges_7530` — Sumry/GES 0x7530 map, model 45 / class 10 / protocol 220 + - Descriptors: anenji_ges48120m250_500p + - Known firmware: protocol raw 220 (0x00DC at 0xC768) + - `anenji_ges48120m250_500p` → surface `sumry_ges_7530_read_only` (driver modbus_catalog, variant sumry_ges_7530) + - Protocol: modbus_catalog | Detection: anchors (identity.sumry_ges_model_code=45; identity.sumry_ges_product_class=10; identity.sumry_ges_protocol_version=220) + - Tier: partial | Read-only: yes | Profile: — | Schema: sumry_ges_7530/base.json + - Capabilities: no model-specific profile | Telemetry: 9 measurements, 0 binary sensors - Known limitations: - - Automatic detection remains unsupported pending a qualified model/class fingerprint; the PDF gives no GES-specific model-code value. - - Split-phase second-leg, active-power direction, CT and temperature-location semantics are not qualified by the available raw evidence. - - No derived power totals, inferred watts from AC voltage/current, controls or write profiles are exposed. + - Split-phase second leg, PV, CT-only grid semantics, temperatures and active-power registers are not part of this read-only subset. + - Product class 10 is required for this entry; other PDF enum values must not select this descriptor. - The protocol PDF is community-hosted, not independently authenticated supplier documentation. + - No controls or write profiles are exposed. - Evidence: 3 source(s) ### Anenji — HHS-11kW-WIFI (without parallel) (`anenji_hhs_11kw_wifi_no_parallel`) diff --git a/tests/test_sumry_ges_7530.py b/tests/test_sumry_ges_7530.py index 7778170..021a990 100644 --- a/tests/test_sumry_ges_7530.py +++ b/tests/test_sumry_ges_7530.py @@ -37,6 +37,20 @@ TARGET = ProbeTarget(devcode=1, collector_addr=255, device_addr=1) # Public telemetry only, issue #49 comment 5881411950. These are separate # samples, not a simultaneous snapshot and not a model/class fingerprint. +IDENTITY_CAPTURES = ( + ( + 0xC738, + 2, + "01 03 C7 38 00 02 78 B2", + "01 03 04 00 2D 00 0A EA 3D", + ), + ( + 0xC768, + 1, + "01 03 C7 68 00 01 38 A2", + "01 03 02 00 DC B9 DD", + ), +) CAPTURES = ( ( 0x7530, @@ -64,6 +78,14 @@ def _captured_registers() -> dict[int, int]: return registers +def _qualified_identity_registers() -> dict[int, int]: + registers = {} + for start, count, _, response in IDENTITY_CAPTURES: + words = parse_read_holding_response(bytes.fromhex(response), slave_id=1, count=count) + registers.update({start + index: word for index, word in enumerate(words)}) + return registers + + class ReadOnlyTransport(FixtureTransport): """Make accidental writes in an offline replay fail loudly.""" @@ -126,45 +148,64 @@ def test_read_plan_is_small_and_contains_no_identity_guess_or_power(self) -> Non self.assertIn("Phase A", schema.measurement_description(key).name) self.assertEqual(schema.measurement_description("output_frequency").suggested_display_precision, 2) - def test_catalog_records_research_without_runtime_binding(self) -> None: + def test_catalog_records_experimental_read_only_binding(self) -> None: model = next(model for model in load_models() if model["model_key"] == "anenji_ges48120m250_500p") - self.assertEqual(model["lifecycle"], "research") + self.assertEqual(model["lifecycle"], "experimental") self.assertEqual(model["coverage"]["runtime_control_surface"], "none") self.assertEqual(model["validation"]["telemetry"], "partial") self.assertEqual(model["validation"]["controls"], "none") - self.assertTrue(all(not variant["device_descriptor_keys"] for variant in model["variants"])) + variant = model["variants"][0] + self.assertEqual(variant["variant_key"], "sumry_ges_7530") + self.assertEqual(variant["device_descriptor_keys"], ["anenji_ges48120m250_500p"]) sources = {source["source_key"]: source for source in load_sources()} self.assertTrue(set(model["source_keys"]).issubset(sources)) - self.assertFalse(any("fingerprint" in sources[key]["assertions"] for key in model["source_keys"])) + self.assertIn("fingerprint", sources["issue_49_ges_identity_capture"]["assertions"]) catalog = load_compiled_detection_catalog() - self.assertFalse(any(surface.register_schema_name == SCHEMA_NAME for surface in catalog.surfaces.values())) + surface = next(surface for surface in catalog.surfaces.values() + if surface.register_schema_name == SCHEMA_NAME) + self.assertEqual(surface.key, "sumry_ges_7530_read_only") + self.assertTrue(surface.read_only) + device = catalog.devices["anenji_ges48120m250_500p"] + self.assertEqual(device.surface_key, "sumry_ges_7530_read_only") + self.assertEqual( + {anchor.key: anchor.equals for anchor in device.anchors if anchor.equals is not None}, + { + "identity.sumry_ges_model_code": 45, + "identity.sumry_ges_product_class": 10, + "identity.sumry_ges_protocol_version": 220, + }, + ) - def test_metadata_startup_does_not_implicitly_load_unbound_schema(self) -> None: + def test_metadata_startup_loads_catalog_bound_schema_without_telemetry_probes(self) -> None: with patch("custom_components.eybond_local.drivers.registry.load_register_schema", wraps=load_register_schema) as loader: prime_metadata_caches() self.assertGreater(loader.call_count, 0) - self.assertFalse(any(call.args[0].removeprefix("builtin:") == SCHEMA_NAME - for call in loader.call_args_list)) + self.assertTrue(any(call.args[0].removeprefix("builtin:") == SCHEMA_NAME + for call in loader.call_args_list)) for protocol in load_compiled_detection_catalog().protocols.values(): for action in protocol.probe_actions: if action.register is None or action.count is None: continue addresses = range(action.register, action.register + action.count) with self.subTest(protocol=protocol.key, action=action.key): + # Telemetry blocks are runtime-only; identity probes may be catalog actions. self.assertFalse(any(address in addresses for address in - (0x7530, 0x7548, 0x756A, 0xC738, 0xC739, 0xC768))) + (0x7530, 0x7548, 0x756A))) - def test_documented_diagnostic_frames_are_exact_read_only_requests(self) -> None: - # Validation of the handoff commands only; these are not probe actions - # and no responses/identity constants are invented for them. - for start, count, request in ( - (0xC738, 2, "01 03 C7 38 00 02 78 B2"), - (0xC768, 1, "01 03 C7 68 00 01 38 A2"), - (0xC764, 5, "01 03 C7 64 00 05 F9 62"), - ): + def test_owner_identity_frames_validate_crc_and_decode(self) -> None: + for start, count, request, response in IDENTITY_CAPTURES: with self.subTest(start=hex(start)): self.assertEqual(build_read_holding_request(1, start, count), bytes.fromhex(request)) + words = parse_read_holding_response(bytes.fromhex(response), slave_id=1, count=count) + if start == 0xC738: + self.assertEqual(words, [45, 10]) + else: + self.assertEqual(words, [220]) + + def test_documented_diagnostic_frames_are_exact_read_only_requests(self) -> None: + start, count, request = 0xC764, 5, "01 03 C7 64 00 05 F9 62" + self.assertEqual(build_read_holding_request(1, start, count), bytes.fromhex(request)) class SumryGesReadTests(unittest.IsolatedAsyncioTestCase): @@ -204,20 +245,49 @@ async def async_send_payload(self, payload, *, route): with self.assertRaises(ModbusError): await read_spec_set_values(session, load_register_schema(SCHEMA_NAME)) - async def test_telemetry_and_unqualified_class_values_do_not_auto_bind(self) -> None: - # Class 0/10/20 occurs in the PDF, but none is proven for this GES. - # A made-up nonzero model plus a class enum must not become an anchor. - for product_class in (None, 0, 10, 20): - with self.subTest(product_class=product_class): - registers = _captured_registers() - if product_class is not None: - registers.update({0xC738: 1234, 0xC739: product_class}) + async def test_qualified_identity_binds_read_only_surface_without_telemetry_probe(self) -> None: + transport = ReadOnlyTransport(_qualified_identity_registers()) + inverter = await ModbusCatalogDriver().async_probe(transport, TARGET) + self.assertIsNotNone(inverter) + self.assertEqual(inverter.variant_key, "sumry_ges_7530") + self.assertEqual(inverter.register_schema_name, SCHEMA_NAME) + self.assertEqual(inverter.profile_name, "") + self.assertFalse(any(0x7530 <= int.from_bytes(req[2:4], "big") <= 0x756A + for req in transport.requests)) + + async def test_telemetry_without_identity_does_not_bind(self) -> None: + transport = ReadOnlyTransport(_captured_registers()) + self.assertIsNone(await ModbusCatalogDriver().async_probe(transport, TARGET)) + + async def test_wrong_or_partial_identity_does_not_bind(self) -> None: + base = _qualified_identity_registers() + cases = ( + "missing", + {0xC738: 44}, + {0xC739: 20}, + {0xC768: 219}, + {0xC738: 45, 0xC739: 10}, + ) + for case in cases: + with self.subTest(case=case): + registers = dict(base) if case != "missing" else {} + if isinstance(case, dict): + registers.update(case) + if 0xC768 not in case and 0xC738 in case and 0xC739 in case: + del registers[0xC768] transport = ReadOnlyTransport(registers) self.assertIsNone(await ModbusCatalogDriver().async_probe(transport, TARGET)) - # This change adds no automatic requests to the candidate map. - self.assertFalse(any(0x7530 <= int.from_bytes(req[2:4], "big") <= 0x7596 - or 0xC738 <= int.from_bytes(req[2:4], "big") <= 0xC768 - for req in transport.requests)) + + async def test_identity_bad_crc_does_not_bind(self) -> None: + class BadCrcIdentityTransport(ReadOnlyTransport): + async def async_send_payload(self, payload, *, route): + response = await super().async_send_payload(payload, route=route) + if payload[2:4] in (b"\xC7\x38", b"\xC7\x68"): + return response[:-1] + return response + + transport = BadCrcIdentityTransport(_qualified_identity_registers()) + self.assertIsNone(await ModbusCatalogDriver().async_probe(transport, TARGET)) if __name__ == "__main__": From 5f85a01c9838791eda59d352a23f7f04d8b5f065 Mon Sep 17 00:00:00 2001 From: txau Date: Fri, 2 Oct 2026 16:13:54 -0600 Subject: [PATCH 2/2] docs: describe exact-match Sumry/GES read-only support Address PR review: refresh changelog and architecture docs, split truncated vs bad-CRC identity tests, and point the identity source at comment 5953299167. Co-authored-by: Cursor --- CHANGELOG.md | 8 +- .../issue_49_ges_identity_capture.json | 2 +- docs/README.md | 2 +- docs/architecture/SUMRY_GES_7530.md | 113 +++++++----------- tests/test_sumry_ges_7530.py | 21 +++- 5 files changed, 70 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d62619..52d23a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,10 @@ the GitHub release body should be rendered from the matching version section her ### Added -- Recorded Anenji GES48120M250-500P / Sumry-style register evidence and an - offline-tested read-only subset (#49). This is a research catalog entry, - not automatic device support: model/class identification still needs a - confirmed response. No additional live probes or controls are enabled. +- Anenji GES48120M250-500P auto-binds through exact Sumry/GES identity anchors + (model 45, product class 10, protocol raw 220) and exposes the existing + nine-field read-only 0x7530 subset (#49, #55). Broader telemetry, derived + power totals and controls remain out of scope. - Victor NM-PRO-6.2KW in the supported model catalog, backed by the owner's PI30 archive and specifically reported working controls. It reuses the diff --git a/catalog/inverter_models/sources/issue_49_ges_identity_capture.json b/catalog/inverter_models/sources/issue_49_ges_identity_capture.json index 0242c8a..de5562d 100644 --- a/catalog/inverter_models/sources/issue_49_ges_identity_capture.json +++ b/catalog/inverter_models/sources/issue_49_ges_identity_capture.json @@ -3,7 +3,7 @@ "source_key": "issue_49_ges_identity_capture", "kind": "github_comment", "title": "Issue #49 GES48120M250-500P FC03 identity registers", - "reference": "project-issue:49; owner-modbus-capture:2026-10-02", + "reference": "project-issue:49; project-comment:5953299167", "captured_at": "2026-10-02", "visibility": "public", "summary": "Owner slave-1 FC03 reads at 0xC738 (2 words) and 0xC768 (1 word) pass CRC checks. Model code 45 (0x002D), product class 10 (0x000A), protocol version raw 220 (0x00DC). Together with the existing 0x7530 telemetry captures these anchors qualify automatic read-only binding for this unit.", diff --git a/docs/README.md b/docs/README.md index be94dd6..f777a45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,7 @@ If you are extending or maintaining the project, use [../CONTRIBUTING.md](../CON ## Architecture and design references - [SMG Protocol Maps](architecture/SMG_PROTOCOL_MAPS.md) — evidence, independent protocol branches, generic controls, and model-specific overrides. -- [Sumry/GES 0x7530 Qualification](architecture/SUMRY_GES_7530.md) — offline read-only evidence subset, blocked automatic identity, and the specific missing diagnostic reads. +- [Sumry/GES 0x7530 Qualification](architecture/SUMRY_GES_7530.md) — exact-match read-only binding for Anenji GES48120, nine-field subset, and remaining telemetry/control limits. - [Collector Connection Architecture](architecture/CONNECTION_ARCHITECTURE.md) — maintainer reference for the connection axes, session/PN ownership, `callback_on_demand`, endpoint ownership rules, and what must not be reintroduced. - [Cloud Learning Architecture](architecture/CLOUD_LEARNING_ARCHITECTURE.md) — provider-neutral learning methods, typed API sources, trust boundaries, and read-only metadata evidence. - [Typed Telemetry Migration](architecture/TYPED_TELEMETRY.md) — staged replacement of the broad runtime value mapping with a strict measurement contract. diff --git a/docs/architecture/SUMRY_GES_7530.md b/docs/architecture/SUMRY_GES_7530.md index 14724e1..9047e3a 100644 --- a/docs/architecture/SUMRY_GES_7530.md +++ b/docs/architecture/SUMRY_GES_7530.md @@ -1,11 +1,11 @@ # Sumry/GES 0x7530 qualification -Status on 2026-10-02: evidence-backed offline subset, **not automatic runtime -support**. The Anenji GES48120M250-500P commercial record remains `research` with -no runtime descriptor. The schema `sumry_ges_7530/base.json` is intentionally not -referenced by a detection action, surface or profile. It adds no device I/O, -controls, selection bypass or change to SMG probing. Collector-menu work is -separate from inverter-map qualification. +Status on 2026-10-02: **exact-match automatic detection** with a **read-only** +runtime surface for Anenji GES48120M250-500P. The commercial record is +`experimental`. Detection uses three immutable holding-register anchors through +the generic `modbus_catalog` driver and binds +`sumry_ges_7530/base.json` (nine phase-A and battery measurements). No write +profile, SMG probing change or collector-menu work is part of this map. ## Sources and verification @@ -13,6 +13,9 @@ separate from inverter-map qualification. and [raw read-only frames](https://github.com/groove-max/ha-eybond-local/issues/49#issuecomment-5881411950). The successful FC03 responses corroborate a different map from SMG. Tests retain the public telemetry frames without collector/account identifiers. +- [Owner identity capture](https://github.com/groove-max/ha-eybond-local/issues/49#issuecomment-5953299167): + CRC-valid FC03 replies at `0xC738` (model 45, product class 10) and `0xC768` + (protocol raw 220). These exact values are the catalog anchors. - [Protocol PDF at community repository commit 76ef3db](https://github.com/quky/Anenji-12KW-48V-Hybrid-split-phas/blob/76ef3db5a5264bbaaa056db135e9ac9b5ad7fa08/documentation/Inverter%20Modbus%20ProtocolV2.6.3-Standard.pdf), *Off/ON-Grid Energy Storage Inverter MODBUS Protocol*, V2.6.3, dated 2025-08-08, 18 pages. A fresh download matched the September 29 copy: @@ -21,16 +24,28 @@ separate from inverter-map qualification. community-hosted evidence, not an authenticated supplier release. - [Owner's fork report](https://github.com/groove-max/ha-eybond-local/issues/49#issuecomment-5944903597) and [pinned fork head a4125af](https://github.com/txau/ha-eybond-local/commit/a4125afde16db21ee252da96d32dc966536e285e). - Static review only: no fork scripts, imports, tests or deployment executed, - and no raw cherry-pick. Display agreement is owner-reported, not independently - verified across firmware variants. + Useful corroboration for later telemetry expansion; this PR does not adopt the + broader fork map. -## Retained subset +## Identity anchors -The following meanings agree between the protocol table (PDF pages 7-8) and the -owner's successful address windows. Each read is FC03, three registers, below -the documented 32-register maximum (PDF page 3). Shorter requests are an offline -read-plan choice, not a claim that these exact lengths were captured live. +All three must match. Missing, truncated, bad-CRC or different values must not +select this entry. Protocol `220` is kept as the raw word; it does not imply the +whole V2.6.3 document applies. + +| Purpose | Start / count | Complete request hex | Expected words | +| --- | --- | --- | --- | +| Model code and product class | `0xC738` (51000) / 2 | `01 03 C7 38 00 02 78 B2` | `45`, `10` | +| Protocol version | `0xC768` (51048) / 1 | `01 03 C7 68 00 01 38 A2` | `220` | + +Optional firmware context (`0xC764`, count 5) remains a diagnostic read only; it +is not a detection anchor. + +## Retained runtime subset + +The bound surface exposes only the meanings that agree between the protocol +table (PDF pages 7-8) and the owner's successful address windows. Each runtime +read is FC03, three registers. | Addresses | Meaning | Decode | | --- | --- | --- | @@ -39,59 +54,26 @@ read-plan choice, not a claim that these exact lengths were captured live. | `0x756A` / `0x756B` / `0x756C` | Phase-A mains voltage / current / mains frequency | unsigned 0.1 V / signed 0.1 A / unsigned 0.01 Hz | Battery current preserves the documented native sign: positive discharge, -negative charge. The capture has zero battery current, so tests of either sign -are synthetic protocol checks, not field validation. AC values keep explicit -Phase A labels; they are not whole-device voltage/current or proof of a phase -count. Mains-current import/export direction is not normalized from the -ambiguous translated remarks. No power, energy, total, availability or status -is derived from this subset. Missing blocks stay absent, not zero-filled. - -## Why automatic detection is blocked - -PDF page 14 defines read-only `0xC738` as model code and `0xC739` as product -class. It does **not** enumerate model-code values. The class values 0, 10 and -20 have translated labels "Single Camera", "Multi-camera" and "Three-phase -camera"; assigning any one to this split-phase GES would be speculation. -The available issue frames and October 1 support archive contain no successful -model/class read. The community ESPHome configuration does not supply a -GES-specific model/class fingerprint either. - -Consequently neither plausible 40-70 V plus SOC 0-100 nor an invented -nonzero-model/class-enum test is accepted as identity. Manufacturer text at -`0xC73A` is writable according to the same PDF and is not an immutable anchor. -No model constants, probe action or runtime surface are wired up. - -The concrete next evidence is a successful slave-1 **FC03** read of -`0xC738`, count 2, with the original request and CRC-valid response associated -with the reported model. A separate read of `0xC764`, count 5, would preserve -firmware plus protocol-version context (`0xC768`, PDF page 15). These are -evidence requests, not operations performed by this change. Interpret the -returned values against supplier documentation or independently corroborated -model-bound captures before adding exact anchors; one arbitrary nonzero word -is insufficient. No serial read, password, write or collector reroute is needed -for those identity ranges. - -For the owner's existing SmartValue Debug raw-RTU path, these are the exact -slave-1 FC03 frames, including low-byte-first CRC. Addresses are raw zero-based -holding-register addresses, not 4xxxx notation: - -| Purpose | Start / count | Complete request hex | -| --- | --- | --- | -| Model code and product class | `0xC738` (51000) / 2 | `01 03 C7 38 00 02 78 B2` | -| Protocol version | `0xC768` (51048) / 1 | `01 03 C7 68 00 01 38 A2` | -| Optional firmware plus protocol, instead of the version-only read | `0xC764` (51044) / 5 | `01 03 C7 64 00 05 F9 62` | +negative charge. AC values keep explicit Phase A labels. No power, energy, +total, PV, second-leg, CT, temperature, availability or status is derived from +this subset. Missing blocks stay absent, not zero-filled. No controls are +exposed. + +## Still out of scope -Save the unchanged request/response pairs and displayed model/firmware, with -account and serial details hidden. Normal responses start `01 03 04`, -`01 03 02`, and `01 03 0A`, respectively; retain the entire frame and CRC, -including any exception response. A failed read is a remaining blocker, not a -reason for a larger scan. No repeat generic Support Archive is needed for this -missing evidence. These diagnostic requests have not been run against hardware. +- Split-phase second leg, PV strings, CT-only grid semantics, temperatures and + active-power registers (`0x7574`/`0x7575`, load totals, etc.). +- Derived mains or PV power totals, including one-leg fallbacks. +- Write profiles or Full Control settings. +- Binding from telemetry plausibility alone (battery voltage / SOC without the + three identity anchors). + +Broader telemetry needs separately recorded semantics and missing-data tests; a +working fork is not blanket write authority. ## Fork semantics not adopted -- The reviewed head already uses active-power registers `0x7574/0x7575`. - Criticism of earlier AC V×I-as-watts code does not describe that head. +- The reviewed fork head already uses active-power registers `0x7574/0x7575`. Its derived `mains_power_total` still accepts one available leg. A future total needs qualified topology and every required leg from the current read; missing, invalid or stale operands must not become a total. A direct total @@ -105,8 +87,3 @@ missing evidence. These diagnostic requests have not been run against hardware. `0x7579..0x757C` are model-dependent temperature sampling points, not proof of PV/inverter/transformer/ambient placement. CT installation and active-power direction also need variant-specific evidence. These fields are not retained. - -The next runtime implementation can reuse the generic catalog driver and this -tested subset once identity is qualified. It must remain read-only and add -positive/negative identity replays. Broader telemetry needs separately recorded -semantics and missing-data tests; a working fork is not blanket write authority. diff --git a/tests/test_sumry_ges_7530.py b/tests/test_sumry_ges_7530.py index 021a990..2f2df99 100644 --- a/tests/test_sumry_ges_7530.py +++ b/tests/test_sumry_ges_7530.py @@ -1,4 +1,9 @@ -"""Offline qualification only: this schema must not auto-bind an inverter.""" +"""Exact-match Sumry/GES read-only binding for Anenji GES48120M250-500P. + +Detection requires model 45, product class 10 and protocol raw 220. The bound +surface is the nine-field 0x7530 evidence subset only: no power totals, PV, +second-leg telemetry or write profile. +""" from __future__ import annotations @@ -278,12 +283,24 @@ async def test_wrong_or_partial_identity_does_not_bind(self) -> None: transport = ReadOnlyTransport(registers) self.assertIsNone(await ModbusCatalogDriver().async_probe(transport, TARGET)) + async def test_identity_truncated_response_does_not_bind(self) -> None: + class TruncatedIdentityTransport(ReadOnlyTransport): + async def async_send_payload(self, payload, *, route): + response = await super().async_send_payload(payload, route=route) + if payload[2:4] in (b"\xC7\x38", b"\xC7\x68"): + return response[:-1] + return response + + transport = TruncatedIdentityTransport(_qualified_identity_registers()) + self.assertIsNone(await ModbusCatalogDriver().async_probe(transport, TARGET)) + async def test_identity_bad_crc_does_not_bind(self) -> None: class BadCrcIdentityTransport(ReadOnlyTransport): async def async_send_payload(self, payload, *, route): response = await super().async_send_payload(payload, route=route) if payload[2:4] in (b"\xC7\x38", b"\xC7\x68"): - return response[:-1] + # Keep full length; flip the last CRC byte only. + return response[:-1] + bytes([response[-1] ^ 0xFF]) return response transport = BadCrcIdentityTransport(_qualified_identity_registers())