diff --git a/CHANGELOG.md b/CHANGELOG.md index 011daf2..817a1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,24 +10,55 @@ the GitHub release body should be rendered from the matching version section her ### Added - Added an offline MPPT-frame inspector for maintainers, with strict runtime - decoding and explicit warnings about overlapping wire formats. This does not - enable live PV polling or create additional Home Assistant entities. + decoding and explicit warnings about overlapping wire formats. That tool does + not poll devices or create Home Assistant entities; live optional PV is a + separate admission path below. - Added a generic read-only **EyeBond Short-ASCII family** profile for the captured MP/Q1/MD dialect seen on some Anern and Maxinn devices (#45). It provides grid/output voltage, load percentage, output frequency, temperature and status diagnostics. Battery Reference Voltage is shown - separately from full-pack voltage. No retail model, serial number, PV power - or inverter controls are inferred. + separately from full-pack voltage. Identity is family-only: no retail model + (including no Anern SPI-12000W claim), serial number or inverter controls are + inferred. + +- Short-ASCII also publishes labelled **Estimated AC Load Power** + (`estimated_ac_load_power` = load% × rated VA from F). It is an estimate, not + measured active power; wire `load_percent` stays separate. Output frequency is + not aliased as grid frequency (#45). - Short-ASCII devices can also expose documented BMS and rated readings through - optional read-only RB/F queries. These entities are disabled by default. + optional read-only RB/F/RH queries. These entities are disabled by default. Missing, invalid or expired BMS samples remove their old values without - interrupting basic telemetry. BMS current scaling and PV remain unqualified; - this is not full device or control support (#45). + interrupting basic telemetry. Impossible V/SoC/I/P fields are hard-rejected; + the known RB link-loss signature (V=0 ∧ SoC=0) may hold last-good BMS values + for up to 180 s without refreshing sample age. Diagnostic counters track + link-loss transitions and hard rejects; confirmation-hold pending stays unused + in this release. Charge/discharge currents and measured `battery_power` + publish at 19B4 segment-7 `multiply=0.1` only while optional RH reports BMS + current display accuracy `1` (with decimals) and F ratings are present for + I/P bounds; RH `0`, unread, expired or failed RH omits those keys. RH=1 is the + discriminator (no retail-model gate). One live family member has SmartValue + correlation for that path; other members (e.g. Maxinn) are not separately + live-proven. This is not full device or control support (#45). + +- Optional short-ASCII **live PV/MPPT** uses the shared framed + `async_send_auxiliary_read` facade for the documented 21-byte `0200` runtime + query only (settings `0202` is not live telemetry; no tip AABB harvest or + EyeBond TID `0xAABB` as aux). Values follow the same OptionalSample pattern as + RB (30 s solicit / 60 s TTL), at most one optional read per successful Q1 + cycle. Nine matching schema sensors (`pv_*`, `mppt_*`, `dc_load_current`) stay + **disabled by default**; `mppt_error_code` is diagnostic and quiet. Offline + `decode_short_ascii_mppt` remains a maintainer capture tool, not this live path. + Site live-qualification / Support Archive RECEIPT for the family cutover is + still pending (#45). ### Fixed +- When both are due in one Q1 cycle, short-ASCII optional reads prefer FC4 + RB/F/RH over the aux MPPT solicit so a virgin MPPT due time cannot starve BMS + or rated refreshes (#45). + - Support archives preserve numeric-looking hex sequences in explicitly typed wire-evidence fields instead of replacing their bytes with identifier masks. Embedded ASCII identifiers remain masked. Short-ASCII archive creation and diff --git a/README.md b/README.md index 12f6a07..11edae5 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,10 @@ If your inverter is not listed, it may still work. Add it, create a Support Arch The unreleased test code also includes a limited **EyeBond Short-ASCII family** profile for a protocol observed on some Anern and Maxinn units. It includes -basic telemetry and optional BMS/rated readings where the device answers them. -This identifies the protocol, not the commercial model. See its +basic telemetry, a labelled AC-load estimate, optional BMS/rated readings, and +opt-in PV/MPPT entities (disabled by default) where the device answers them. +Inverter controls are absent. This identifies the protocol family, not a retail +commercial model. See its [available readings and limits](docs/user/RUNTIME_AND_INVERTER.md#eyebond-short-ascii-family). ### No factory collector? diff --git a/README.uk.md b/README.uk.md index 4521283..4accd03 100644 --- a/README.uk.md +++ b/README.uk.md @@ -108,9 +108,10 @@ PI30 та SRNE. Рівень підтримки залежить від моде Тестовий код, який ще не увійшов до релізу, також містить обмежений профіль **EyeBond Short-ASCII family** для протоколу деяких Anern і Maxinn. Він надає -базові показання, а на пристроях із відповідними відповідями — також дані BMS -та номінальні параметри. Це визначення протоколу, не комерційної моделі. -Додаткові сутності вимкнені за замовчуванням. Дивіться +базові показання, оцінку потужності навантаження та, на пристроях із +відповідними відповідями, дані BMS і номінальні параметри, а також +опційні сенсори PV/MPPT (вимкнені за замовчуванням). Керування відсутнє. Це +визначення сімейства протоколу, не роздрібної комерційної моделі. Дивіться [доступні показання й обмеження](docs/user/RUNTIME_AND_INVERTER.md#eyebond-short-ascii-family). ### Немає заводського колектора? diff --git a/catalog/inverter_models/catalog.json b/catalog/inverter_models/catalog.json index ddc1928..91177d9 100644 --- a/catalog/inverter_models/catalog.json +++ b/catalog/inverter_models/catalog.json @@ -1,5 +1,5 @@ { "schema_version": 1, - "catalog_version": "2026.08.2", + "catalog_version": "2026.09.1", "description": "Commercial inverter model support catalog" } diff --git a/catalog/inverter_models/models/eybond_short_ascii_family.json b/catalog/inverter_models/models/eybond_short_ascii_family.json new file mode 100644 index 0000000..54276fb --- /dev/null +++ b/catalog/inverter_models/models/eybond_short_ascii_family.json @@ -0,0 +1,52 @@ +{ + "schema_version": 1, + "model_key": "eybond_short_ascii_family", + "manufacturer": "EyeBond", + "model": "Short-ASCII family", + "aliases": [ + "EyeBond Short-ASCII family" + ], + "lifecycle": "experimental", + "variants": [ + { + "variant_key": "urtu1920_checksum", + "label": "EyeBond Short-ASCII read-only family (URTU1920 checksum dialect)", + "device_descriptor_keys": [ + "eybond_short_ascii_family" + ], + "known_firmware": [] + } + ], + "validation": { + "hardware": "captured", + "telemetry": "partial", + "controls": "none" + }, + "coverage": { + "runtime_control_surface": "read_only", + "smartess_control_surface": "not_applicable", + "vendor_register_map": "partial", + "notes": [ + "Runtime support is family-only and read-only. Compatible devices appear as EyeBond Short-ASCII family; retail model and serial are not inferred.", + "Optional RB/F/RH and aux-0200 MPPT/PV entities are disabled by default and expire on TTL or invalid replies." + ] + }, + "known_limitations": [ + "Family identity only: no retail commercial model (including no Anern SPI-12000W claim) and no inverter serial from MP/Q1/MD.", + "Read-only: no inverter controls; Full Control does not invent settings.", + "Optional live PV/MPPT uses the documented auxiliary 0200 runtime read only (not tip AABB harvest, not settings 0202). Matching schema sensors stay disabled by default; mppt_error_code is diagnostic and quiet. Samples use 30 s solicit / 60 s TTL; at most one optional read per Q1 cycle, with FC4 RB/F/RH preferred when both are due.", + "Offline decode_short_ascii_mppt is a maintainer capture inspector only; it is not the live optional admission path and does not create entities by itself.", + "Live PV/MPPT admission is code-complete but not yet live-qualified on site HA (family cutover / Support Archive RECEIPT still pending).", + "Quiet fault/diagnostic sensors beyond Q1 flags and the quiet mppt_error_code (Q2 extras) remain deferred.", + "estimated_ac_load_power is load% × rated VA from F — an estimate, not measured active power; load_percent stays separate. Output frequency is not grid frequency.", + "Optional BMS currents and battery_power publish only when RH reports BMS current display accuracy=1 (decimals) and F ratings are available for I/P hard-reject bounds; RH=0, unread, expired or failed RH omits those keys. RH=1 is the discriminator (no retail-model gate).", + "Impossible RB V/SoC/I/P values are hard-rejected. Link-loss (V=0 ∧ SoC=0) may hold last-good BMS for up to 180 s without refreshing sample age; normal RB TTL remains 60 s.", + "Diagnostic counters: bms_link_loss_count and rb_hard_reject_count are runtime-scoped; reading_hold_pending_count stays 0 (physics confirmation-hold deferred).", + "BMS current / battery_power path is SmartValue-correlated on one live family member; other members (e.g. Maxinn) are not separately live-proven and only light up if RH=1." + ], + "knowledge_summary": "EyeBond Short-ASCII is a capture-qualified read-only family for the fixed MP/Q1/MD dialect shared by some Anern and Maxinn sites. Optional F/RH/RB add rated values and BMS telemetry with TTL expiry, hard-reject/link-loss hold, and an RH+F gate for currents. Optional live PV/MPPT uses aux 0200 with opt-in entities and TTL; offline MPPT decode stays maintainer-only. Labelled AC-load estimate is separate from measured battery DC power. Retail-model identity remains out of scope; site live-qualification for the PV path is still pending.", + "source_keys": [ + "documentation_vendor_19b4_urtu1920_short_ascii", + "support_archive_short_ascii_family_anern_maxinn" + ] +} diff --git a/catalog/inverter_models/sources/documentation_vendor_19b4_urtu1920_short_ascii.json b/catalog/inverter_models/sources/documentation_vendor_19b4_urtu1920_short_ascii.json new file mode 100644 index 0000000..9ef6ffa --- /dev/null +++ b/catalog/inverter_models/sources/documentation_vendor_19b4_urtu1920_short_ascii.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "source_key": "documentation_vendor_19b4_urtu1920_short_ascii", + "kind": "documentation", + "title": "Vendor 19B4 / DevDataUrtu1920 Short-ASCII layout", + "reference": "private:vendor-19b4-urtu1920-short-ascii", + "captured_at": "2026-09-14", + "visibility": "private", + "summary_publishable": true, + "summary": "Vendor 19B4.xml / DevDataUrtu1920 documents fixed MP/Q1/MD widths, Q1 columns, a 20-byte firmware field without inverter serial, optional F/RH/RB layouts, and segment-7 charge/discharge multiply=0.1. It qualifies the EyeBond Short-ASCII family dialect, not a retail commercial model.", + "assertions": [ + "runtime_compatibility", + "telemetry_validation", + "register_map" + ] +} diff --git a/catalog/inverter_models/sources/support_archive_short_ascii_family_anern_maxinn.json b/catalog/inverter_models/sources/support_archive_short_ascii_family_anern_maxinn.json new file mode 100644 index 0000000..3f6472b --- /dev/null +++ b/catalog/inverter_models/sources/support_archive_short_ascii_family_anern_maxinn.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "source_key": "support_archive_short_ascii_family_anern_maxinn", + "kind": "support_archive", + "title": "Short-ASCII family FC4 captures (Anern and Maxinn sites)", + "reference": "private:short-ascii-family-anern-maxinn-captures", + "captured_at": "2026-09-16", + "visibility": "private", + "summary_publishable": true, + "summary": "Read-only FC4 MP/Q1/MD plus optional F/RH/RB exchanges from Anern- and Maxinn-site support evidence share one fixed-length Short-ASCII dialect. Evidence qualifies the family runtime path only; it is not proof of any retail model name. One live member correlates RH=1 BMS current decimals with SmartValue; Maxinn currents remain unproven separately. Live PV and controls are absent from these captures.", + "assertions": [ + "runtime_compatibility", + "telemetry_validation" + ] +} diff --git a/custom_components/eybond_local/collector/transport/shared_framed.py b/custom_components/eybond_local/collector/transport/shared_framed.py index a6dd734..c415999 100644 --- a/custom_components/eybond_local/collector/transport/shared_framed.py +++ b/custom_components/eybond_local/collector/transport/shared_framed.py @@ -424,6 +424,26 @@ async def async_send_forward( request_timeout=self._request_timeout, ) + async def async_send_auxiliary_read( + self, + payload: bytes, + *, + request_timeout: float, + ) -> bytes: + """Delegate the internal read-only side channel; never infer from magic. + + Ownership, documented-query gating, and framing stay on the socket + connection. This facade only resolves the active connection so callers + need not touch ``connections.py``. Driver/catalog admission for live PV + remains separate work. + """ + + connection = await self._active_connection_for_send() + return await connection.async_send_auxiliary_read( + payload, + request_timeout=request_timeout, + ) + async def async_send_payload( self, payload: bytes, diff --git a/custom_components/eybond_local/drivers/eybond_short_ascii.py b/custom_components/eybond_local/drivers/eybond_short_ascii.py index 3999e17..f63b14e 100644 --- a/custom_components/eybond_local/drivers/eybond_short_ascii.py +++ b/custom_components/eybond_local/drivers/eybond_short_ascii.py @@ -19,6 +19,8 @@ from .catalog_probe import async_probe_ascii_catalog, catalog_model_name from .read_result import DriverReadMode, DriverReadResult from .support_marker import DriverSupportMarker +from .short_ascii_battery_dc import battery_dc_power_values +from .short_ascii_estimates import estimated_ac_load_values from .short_ascii_optional import optional_reads_for @@ -124,7 +126,12 @@ async def async_read_values( raise # FULL absence invalidates expired/failed optional fields in the hub. # Q1 and RB have distinct owners: reference V is never pack/BMS V. - return DriverReadResult(values=values | extra, mode=DriverReadMode.FULL, diagnostics=diagnostics) + # Measured battery DC needs RB currents; estimate needs F ratings. + # Keep the two power keys separate (MX2) — never substitute one for the other. + merged = values | extra + merged.update(battery_dc_power_values(merged)) + merged.update(estimated_ac_load_values(merged)) + return DriverReadResult(values=merged, mode=DriverReadMode.FULL, diagnostics=diagnostics) async def async_capture_support_evidence(self, transport, inverter): session = self._session(transport, inverter.probe_target) diff --git a/custom_components/eybond_local/drivers/short_ascii_battery_dc.py b/custom_components/eybond_local/drivers/short_ascii_battery_dc.py new file mode 100644 index 0000000..cafa829 --- /dev/null +++ b/custom_components/eybond_local/drivers/short_ascii_battery_dc.py @@ -0,0 +1,22 @@ +"""Measured battery-side DC power from qualified BMS voltage × currents. + +MX2: this is a measurement (when currents are published), never an estimate. +Do not confuse with ``estimated_ac_load_power`` (load% × rated VA). +Sign: positive ⇒ net charging, negative ⇒ net discharging (HA convention). +""" + +from __future__ import annotations + + +def battery_dc_power_values(values: dict[str, object]) -> dict[str, object]: + """Return ``battery_power`` from BMS V × (Icharge − Idischarge), or omit.""" + voltage = values.get("bms_total_voltage") + charge_i = values.get("bms_charging_current") + discharge_i = values.get("bms_discharging_current") + if not isinstance(voltage, (int, float)): + return {} + if not isinstance(charge_i, (int, float)) or not isinstance(discharge_i, (int, float)): + return {} + return { + "battery_power": round(float(voltage) * (float(charge_i) - float(discharge_i)), 1), + } diff --git a/custom_components/eybond_local/drivers/short_ascii_estimates.py b/custom_components/eybond_local/drivers/short_ascii_estimates.py new file mode 100644 index 0000000..da9fa00 --- /dev/null +++ b/custom_components/eybond_local/drivers/short_ascii_estimates.py @@ -0,0 +1,21 @@ +"""Labelled short-ASCII estimates derived from wire measurements + ratings. + +MX2: %×VA watts are an estimate, never measured active power. Keep wire load% +intact; do not publish tip-style ``load_power`` / source swaps here. +""" + +from __future__ import annotations + + +def estimated_ac_load_values(values: dict[str, object]) -> dict[str, object]: + """Return ``estimated_ac_load_power`` from load% × rated VA, or omit.""" + percent = values.get("load_percent") + rated_v = values.get("short_ascii_rated_voltage") + rated_a = values.get("short_ascii_rated_current") + if not isinstance(percent, (int, float)): + return {} + if not isinstance(rated_v, (int, float)) or not isinstance(rated_a, (int, float)): + return {} + return { + "estimated_ac_load_power": round(float(rated_v) * float(rated_a) * float(percent) / 100.0, 1), + } diff --git a/custom_components/eybond_local/drivers/short_ascii_mppt_optional.py b/custom_components/eybond_local/drivers/short_ascii_mppt_optional.py new file mode 100644 index 0000000..1b216fd --- /dev/null +++ b/custom_components/eybond_local/drivers/short_ascii_mppt_optional.py @@ -0,0 +1,57 @@ +"""Optional live MPPT via the documented auxiliary 0200 read only. + +Solicits through ``link_transport.async_auxiliary_read`` (framed/AT facade). +Does not harvest tip AABB, wait on EyeBond TID ambiguity, or merge settings +0202. Register-schema entities for these keys are opt-in +(``enabled_default: false``) in ``eybond_short_ascii/base.json``. +""" + +from __future__ import annotations + +from ..link_transport import async_auxiliary_read +from ..payload.short_ascii_mppt import parse_mppt_runtime_wire + +# Exact 21-byte read allow-listed by AuxiliaryReadSession._READ_QUERIES. +RUNTIME_QUERY_0200 = b"\x5a\xa5\x02\x00" + bytes(16) + b"\x02" +COMMAND = "MPPT" +REQUEST_TIMEOUT = 4.0 +# Live PV cadence matches RB: refresh often, expire before the next RB window. +INTERVAL = 30.0 +TTL = 60.0 + +# Keys admitted as opt-in schema sensors; distinct owners stay distinct. +_VALUE_KEYS = ( + ("pv_voltage", "pv_voltage_v"), + ("pv_power", "pv_power_w"), + ("mppt_battery_voltage", "mppt_battery_voltage_v"), + ("mppt_temperature", "mppt_temperature_c"), + ("dc_load_current", "dc_load_current_a"), + ("mppt_work_mode_code", "work_mode_code"), + ("mppt_daily_energy", "daily_energy_kwh"), + ("mppt_total_energy", "total_energy_kwh"), + ("mppt_error_code", "fault_code"), +) + + +def assert_runtime_query_only(payload: bytes) -> None: + """Refuse settings 0202 (and any other subtype) as live telemetry.""" + + if payload != RUNTIME_QUERY_0200: + raise ValueError("mppt_optional_query_not_runtime_0200") + + +def values_from_reply(wire: bytes) -> dict[str, object]: + """Decode one AABB/0200 reply into optional sample values.""" + + sample = parse_mppt_runtime_wire(wire) + return {key: getattr(sample, attr) for key, attr in _VALUE_KEYS} + + +async def request_runtime_sample(transport: object) -> dict[str, object]: + """Solicit documented 0200 only; never settings 0202.""" + + assert_runtime_query_only(RUNTIME_QUERY_0200) + wire = await async_auxiliary_read( + transport, RUNTIME_QUERY_0200, request_timeout=REQUEST_TIMEOUT, + ) + return values_from_reply(wire) diff --git a/custom_components/eybond_local/drivers/short_ascii_optional.py b/custom_components/eybond_local/drivers/short_ascii_optional.py index 4909f33..964c68a 100644 --- a/custom_components/eybond_local/drivers/short_ascii_optional.py +++ b/custom_components/eybond_local/drivers/short_ascii_optional.py @@ -6,11 +6,20 @@ from dataclasses import dataclass, field from typing import Callable -from ..payload.short_ascii import ShortAsciiError, ShortAsciiSession, parse_f, parse_rb +from ..payload.short_ascii import ( + RB_CURRENT_POWER_KEYS, ShortAsciiError, ShortAsciiSession, + parse_f, parse_rb, parse_rh, +) from .command_support import ( command_skipped_as_unsupported, commit_cycle_failures, record_command_failure, record_command_success, unsupported_commands, ) +from .short_ascii_battery_dc import battery_dc_power_values +from .short_ascii_mppt_optional import ( + COMMAND as MPPT_COMMAND, INTERVAL as MPPT_INTERVAL, TTL as MPPT_TTL, + request_runtime_sample, +) +from .short_ascii_rb_filter import RbPublishFilter STATE_KEY = "short_ascii_optional_reads" _PREFIX = "short_ascii:" @@ -31,10 +40,14 @@ def clear(self) -> None: self.values.clear() self.sampled_at = None - def fresh_values(self, now: float) -> dict[str, object]: - if self.sampled_at is not None and not 0 <= now - self.sampled_at < self.ttl: - self.clear() - self.outcome = "expired" + def fresh_values(self, now: float, *, hold_until: float | None = None) -> dict[str, object]: + if self.sampled_at is not None: + age = now - self.sampled_at + within_ttl = 0 <= age < self.ttl + within_hold = hold_until is not None and now < hold_until and age >= 0 + if not within_ttl and not within_hold: + self.clear() + self.outcome = "expired" return dict(self.values) @@ -48,13 +61,105 @@ class OptionalReads: samples: tuple[OptionalSample, ...] = field(default_factory=lambda: ( OptionalSample("RB", interval=30, ttl=60, parser=parse_rb), OptionalSample("F", interval=900, ttl=900, parser=parse_f), + # Settings block; same cadence as F. Gate for RB current ÷10 publish. + OptionalSample("RH", interval=900, ttl=900, parser=parse_rh), + # Aux 0200 only; parser unused — request path is special-cased below. + OptionalSample(MPPT_COMMAND, interval=MPPT_INTERVAL, ttl=MPPT_TTL, parser=dict), )) + rb_filter: RbPublishFilter = field(default_factory=RbPublishFilter) def clear(self) -> None: for sample in self.samples: sample.clear() sample.next_due = 0 sample.outcome = "not_checked" + self.rb_filter.clear() + + def _sample(self, command: str) -> OptionalSample | None: + for sample in self.samples: + if sample.command == command: + return sample + return None + + def _fresh_sample(self, command: str, now: float) -> OptionalSample | None: + sample = self._sample(command) + if sample is None or sample.sampled_at is None: + return None + if not 0 <= now - sample.sampled_at < sample.ttl: + return None + return sample + + def _f_ratings(self, now: float) -> tuple[float | None, float | None, float | None]: + sample = self._fresh_sample("F", now) + if sample is None: + return None, None, None + rated_v = sample.values.get("short_ascii_rated_voltage") + rated_a = sample.values.get("short_ascii_rated_current") + rated_bat = sample.values.get("short_ascii_rated_battery_voltage") + return ( + float(rated_v) if isinstance(rated_v, (int, float)) else None, + float(rated_a) if isinstance(rated_a, (int, float)) else None, + float(rated_bat) if isinstance(rated_bat, (int, float)) else None, + ) + + def _f_ratings_complete(self, now: float) -> bool: + rated_v, rated_a, rated_bat = self._f_ratings(now) + return ( + rated_v is not None and rated_v > 0 + and rated_a is not None and rated_a > 0 + and rated_bat is not None and rated_bat > 0 + ) + + def _rh_decimals_enabled(self, now: float) -> bool: + """True only while a fresh RH sample reports accuracy == 1 (with decimals).""" + sample = self._fresh_sample("RH", now) + if sample is None: + return False + return sample.values.get("short_ascii_bms_current_display_accuracy") == 1 + + def _currents_publishable(self, now: float) -> bool: + # T5.S3.W: RH=1 is the scale discriminator. F2: I/P need F for hard-reject. + return self._rh_decimals_enabled(now) and self._f_ratings_complete(now) + + def _strip_ungated_currents( + self, values: dict[str, object], now: float, + ) -> dict[str, object]: + if self._currents_publishable(now): + return values + out = dict(values) + for key in RB_CURRENT_POWER_KEYS: + out.pop(key, None) + return out + + async def _request_mppt(self) -> dict[str, object]: + """Solicit documented 0200 only via the shared facade; never 0202.""" + + return await request_runtime_sample(self.transport) + + def _apply_rb_parse(self, sample: OptionalSample, parsed: dict[str, object], now: float) -> None: + rated_v, rated_a, rated_bat = self._f_ratings(now) + candidate = self._strip_ungated_currents(dict(parsed), now) + # Derive measured DC only when I keys remain (RH=1 + F present). + candidate.update(battery_dc_power_values(candidate)) + decision = self.rb_filter.decide( + candidate, now=now, + rated_voltage=rated_v, rated_current=rated_a, rated_battery_voltage=rated_bat, + ) + sample.outcome = decision.outcome + sample.next_due = now + sample.interval + if decision.keep_previous: + # Never restore held values without a real sampled_at (zombie guard). + if ( + decision.outcome == "link_loss_hold" + and decision.values is not None + and sample.sampled_at is not None + and not sample.values + ): + sample.values = dict(decision.values) + return + sample.values = dict(decision.values or ()) + if decision.refresh_sampled_at: + sample.sampled_at = now async def refresh_one( self, session: ShortAsciiSession, runtime_state: dict, @@ -72,24 +177,37 @@ async def refresh_one( sample.outcome = "not_checked" due = [sample for sample in self.samples if sample.outcome != "unsupported" and now >= sample.next_due] - # Oldest scheduled group first; an unsupported RB cannot starve F. - sample = min(due, key=lambda item: item.next_due) if due else None + # Oldest due first within the chosen set. Prefer FC4 (RB/F/RH) over aux + # MPPT when both are due so a virgin MPPT next_due=0 cannot starve an + # intentional RB refresh; MPPT still runs when it is the only due sample. + fc4_due = [sample for sample in due if sample.command != MPPT_COMMAND] + candidates = fc4_due or due + sample = min(candidates, key=lambda item: item.next_due) if candidates else None if sample is not None: key = _PREFIX + sample.command try: - parsed = sample.parser(await session.request(sample.command)) - except (ShortAsciiError, asyncio.TimeoutError) as exc: + if sample.command == MPPT_COMMAND: + parsed = await self._request_mppt() + else: + parsed = sample.parser(await session.request(sample.command)) + except (ShortAsciiError, asyncio.TimeoutError, ValueError, TypeError) as exc: + # Envelope/transport failure: drop; do not start 180 s hold. sample.clear() + if sample.command == "RB": + self.rb_filter.clear() sample.outcome = "timeout" if isinstance(exc, asyncio.TimeoutError) else "invalid_response" sample.next_due = clock() + 30 if not session.transport.connected: raise ConnectionError("short_ascii_optional_connection_lost") from None record_command_failure(runtime_state, key) else: - sample.values = dict(parsed) - sample.sampled_at = clock() - sample.next_due = sample.sampled_at + sample.interval - sample.outcome = "no_data" if parsed.get("short_ascii_bms_data_available") is False else "ok" + if sample.command == "RB": + self._apply_rb_parse(sample, parsed, clock()) + else: + sample.values = dict(parsed) + sample.sampled_at = clock() + sample.next_due = sample.sampled_at + sample.interval + sample.outcome = "ok" record_command_success(runtime_state, key) # No staged strike survives a failed/cancelled cycle. Q1 was confirmed # by our caller, and from here to commit there are no suspension points. @@ -99,15 +217,31 @@ async def refresh_one( self.last_clock = now values, diagnostics = {}, {} for sample in self.samples: - values.update(sample.fresh_values(now)) + hold_until = self.rb_filter.hold_until if sample.command == "RB" else None + fresh = sample.fresh_values(now, hold_until=hold_until) + # TTL/expiry cleared the sample: drop last_good so a later link-loss + # cannot resurrect values with sampled_at is None. + if sample.command == "RB" and sample.sampled_at is None: + self.rb_filter.clear() + if sample.command == "RB": + # Re-gate every cycle: RH/F expiry must strip I/P from held RB. + fresh = self._strip_ungated_currents(fresh, now) + elif sample.command == "RH": + # Settings gate only — do not publish RH fields as sensors. + fresh = {} if sample.sampled_at is not None: - diagnostics[f"short_ascii_{sample.command.lower()}_age_seconds"] = round(now - sample.sampled_at, 3) + diagnostics[f"short_ascii_{sample.command.lower()}_age_seconds"] = round( + now - sample.sampled_at, 3, + ) + values.update(fresh) diagnostics["short_ascii_optional_status"] = "; ".join( f"{sample.command}={sample.outcome}" for sample in self.samples ) diagnostics["driver_unsupported_commands"] = ", ".join( key for key in unsupported_commands(runtime_state) if key.startswith(_PREFIX) ) + # Q1.C1: publish every cycle (hub replaces diagnostics wholesale). + diagnostics.update(self.rb_filter.diagnostic_counters()) return values, diagnostics diff --git a/custom_components/eybond_local/drivers/short_ascii_rb_filter.py b/custom_components/eybond_local/drivers/short_ascii_rb_filter.py new file mode 100644 index 0000000..1d67b50 --- /dev/null +++ b/custom_components/eybond_local/drivers/short_ascii_rb_filter.py @@ -0,0 +1,226 @@ +"""RB field hard-rejects and link-loss last-good hold (ADR 0003). + +Order: envelope (``parse_rb``) → field hard-rejects here → publish. +Checksum/length failures never reach this module; optional clears them and must +not start the 180 s hold. + +Dual clock with ``short_ascii_optional``: normal RB TTL stays ~60 s; link-loss +signature (V=0 ∧ SoC=0) alone may keep last-good publishable while +``age_from_last_good < 180 s``. Hold is capped from last-good time — continuous +link-loss frames must not refresh ``hold_until``. Held values never look fresh +(``sampled_at`` stays on the last good sample). + +Q1.C1 counters (runtime-scoped, not persisted): +- ``bms_link_loss_count`` — increments on each *transition into* the link-loss + signature (V=0 ∧ SoC=0), not on every subsequent poll while still in that + streak. Hold-start is the usual case after a good sample; link-loss with no + last-good still counts once per streak. Streak ends only on true ``ok`` + recovery (hard-reject / envelope ``clear()`` mid-dropout must not re-count). +- ``rb_hard_reject_count`` — increments on each field hard-reject outcome + (ADR 0001 reject observability). +- ``reading_hold_pending_count`` — always 0 in this PR (ADR 0001 / Q3.P1: no + physics confirmation-hold). Published so the entity contract exists. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +# Weak universal pack window until a tighter F/RB-derived band exists (Q7.A). +PACK_VOLTAGE_MIN_V = 30.0 +PACK_VOLTAGE_MAX_V = 70.0 +LINK_LOSS_HOLD_S = 180.0 +# Q4.O2: spec surge + real inrush — reject only above 3× rated VA. +POWER_OVERLOAD_FACTOR = 3.0 + +_CURRENT_KEYS = ( + "battery_current", + "bms_charging_current", + "bms_discharging_current", + "bms_discharge_current", + "bms_charge_current", +) +_POWER_KEYS = ("battery_power", "bms_battery_power") + + +def is_link_loss_signature(parsed: dict[str, object]) -> bool: + """Good-envelope RB with primary V/SoC withdrawn (parse_rb link-loss path).""" + return ( + parsed.get("short_ascii_bms_data_available") is False + and "bms_total_voltage" not in parsed + and "battery_soc" not in parsed + ) + + +def hard_reject_reason( + values: dict[str, object], + *, + rated_voltage: float | None = None, + rated_current: float | None = None, + rated_battery_voltage: float | None = None, +) -> str | None: + """Return a short reject tag, or None if values may publish. + + SoC 0 is allowed when pack V is present and sane. Power rejects only above + 3× F VA so 1×–3× inrush still publishes (Q4.O2). + """ + soc = values.get("battery_soc") + if soc is not None: + if not isinstance(soc, (int, float)) or not 0 <= float(soc) <= 100: + return "soc" + + voltage = values.get("bms_total_voltage") + if voltage is not None: + if not isinstance(voltage, (int, float)): + return "pack_voltage" + volts = float(voltage) + if not PACK_VOLTAGE_MIN_V <= volts <= PACK_VOLTAGE_MAX_V: + return "pack_voltage" + + rated_va: float | None = None + if ( + isinstance(rated_voltage, (int, float)) + and isinstance(rated_current, (int, float)) + and float(rated_voltage) > 0 + and float(rated_current) > 0 + ): + rated_va = float(rated_voltage) * float(rated_current) + + max_current: float | None = None + if ( + rated_va is not None + and isinstance(rated_battery_voltage, (int, float)) + and float(rated_battery_voltage) > 0 + ): + max_current = rated_va / float(rated_battery_voltage) + + if max_current is not None: + for key in _CURRENT_KEYS: + amps = values.get(key) + if amps is None: + continue + if not isinstance(amps, (int, float)) or abs(float(amps)) > max_current: + return "current" + + if rated_va is not None: + power_ceiling = POWER_OVERLOAD_FACTOR * rated_va + for key in _POWER_KEYS: + watts = values.get(key) + if watts is None: + continue + if not isinstance(watts, (int, float)) or abs(float(watts)) > power_ceiling: + return "power" + + return None + + +@dataclass +class RbFilterDecision: + """How optional RB storage should treat one successful parse.""" + + outcome: str + values: dict[str, object] | None = None + refresh_sampled_at: bool = False + keep_previous: bool = False + + +@dataclass +class RbPublishFilter: + """Runtime last-good + link-loss hold clock (not persisted).""" + + last_good: dict[str, object] = field(default_factory=dict) + last_good_at: float | None = None + hold_until: float | None = None + # Q1.C1 — cumulative for this OptionalReads lifetime; clear() keeps them. + bms_link_loss_count: int = 0 + rb_hard_reject_count: int = 0 + reading_hold_pending_count: int = 0 + _in_link_loss: bool = False + + def clear(self) -> None: + self.last_good.clear() + self.last_good_at = None + self.hold_until = None + # Counters and link-loss streak survive envelope drops / TTL clears so + # one dropout is not double-counted after a checksum fail mid-streak. + + def clear_hold(self) -> None: + self.hold_until = None + + def holding(self, now: float) -> bool: + return self.hold_until is not None and now < self.hold_until + + def diagnostic_counters(self) -> dict[str, int]: + """Quiet MX2 counters published every optional refresh (diagnostics).""" + return { + "bms_link_loss_count": self.bms_link_loss_count, + "rb_hard_reject_count": self.rb_hard_reject_count, + # ADR 0001 / Q3.P1: confirmation-hold deferred; contract stays at 0. + "reading_hold_pending_count": self.reading_hold_pending_count, + } + + def decide( + self, + parsed: dict[str, object], + *, + now: float, + rated_voltage: float | None = None, + rated_current: float | None = None, + rated_battery_voltage: float | None = None, + ) -> RbFilterDecision: + if is_link_loss_signature(parsed): + if not self._in_link_loss: + self.bms_link_loss_count += 1 + self._in_link_loss = True + # Q1.H5: hold at most 180 s from last good — never refresh on each + # link-loss frame. + if ( + self.last_good + and self.last_good_at is not None + and (now - self.last_good_at) < LINK_LOSS_HOLD_S + ): + self.hold_until = self.last_good_at + LINK_LOSS_HOLD_S + return RbFilterDecision( + outcome="link_loss_hold", + values=dict(self.last_good), + keep_previous=True, + ) + self.clear() + return RbFilterDecision( + outcome="no_data", + values={"short_ascii_bms_data_available": False}, + refresh_sampled_at=True, + ) + + reason = hard_reject_reason( + parsed, + rated_voltage=rated_voltage, + rated_current=rated_current, + rated_battery_voltage=rated_battery_voltage, + ) + if reason is not None: + # Corrupt/impossible after a good envelope: drop this frame only. + # Do not start 180 s hold; leave prior sample for the ~60 s TTL. + # Do not exit link-loss streak — junk mid-dropout must not re-count. + self.rb_hard_reject_count += 1 + return RbFilterDecision(outcome="rejected", keep_previous=True) + + self.clear_hold() + if parsed.get("short_ascii_bms_data_available") is False: + self.last_good.clear() + self.last_good_at = None + return RbFilterDecision( + outcome="no_data", + values=dict(parsed), + refresh_sampled_at=True, + ) + + # True recovery only — ends link-loss streak for Q1.C1 counting. + self._in_link_loss = False + self.last_good = dict(parsed) + self.last_good_at = now + return RbFilterDecision( + outcome="ok", + values=dict(parsed), + refresh_sampled_at=True, + ) diff --git a/custom_components/eybond_local/link_transport.py b/custom_components/eybond_local/link_transport.py index 18924fc..4fedb7e 100644 --- a/custom_components/eybond_local/link_transport.py +++ b/custom_components/eybond_local/link_transport.py @@ -77,3 +77,24 @@ def select_payload_route( if isinstance(selected, LinkRoute): return selected return route + + +async def async_auxiliary_read( + transport: Any, + payload: bytes, + *, + request_timeout: float, +) -> bytes: + """Send one documented auxiliary read through the framed/AT facade. + + Drivers call this helper instead of naming the transport method, so grammar + ownership stays on the collector session. Missing facade support is a hard + error — never invent AABB from payload magic. + """ + + sender = getattr(transport, "async_send_auxiliary_read", None) + if not callable(sender): + raise TypeError( + f"unsupported_auxiliary_transport:{type(transport).__name__}" + ) + return await sender(payload, request_timeout=float(request_timeout)) diff --git a/custom_components/eybond_local/metadata/local_metadata.py b/custom_components/eybond_local/metadata/local_metadata.py index bc103b9..dbd5b76 100644 --- a/custom_components/eybond_local/metadata/local_metadata.py +++ b/custom_components/eybond_local/metadata/local_metadata.py @@ -270,11 +270,7 @@ def _ensure_can_write(path: Path, root: Path, *, overwrite: bool) -> None: def _is_within_root(path: Path, root: Path) -> bool: - try: - path.relative_to(root.resolve()) - except ValueError: - return False - return True + return path.resolve().is_relative_to(root.resolve()) def _local_override_details( @@ -303,7 +299,11 @@ def _local_override_details( return { "exists": False, "path": str(path), - "status": f"No active local override. Create {path.relative_to(root.parent)} to override the built-in {kind}.", + "status": ( + "No active local override. Create " + f"{path.resolve().relative_to(root.parent.resolve())} " + f"to override the built-in {kind}." + ), } diff --git a/custom_components/eybond_local/metadata/profile_loader.py b/custom_components/eybond_local/metadata/profile_loader.py index 9a3623d..e6c6f6a 100644 --- a/custom_components/eybond_local/metadata/profile_loader.py +++ b/custom_components/eybond_local/metadata/profile_loader.py @@ -536,11 +536,7 @@ def _optional_str(value: Any) -> str | None: def _is_within_root(path: Path, root: Path) -> bool: - try: - path.relative_to(root.resolve()) - except ValueError: - return False - return True + return path.resolve().is_relative_to(root.resolve()) def _merge_raw_profile( diff --git a/custom_components/eybond_local/metadata/register_schema_loader.py b/custom_components/eybond_local/metadata/register_schema_loader.py index c5955cf..47fa1cc 100644 --- a/custom_components/eybond_local/metadata/register_schema_loader.py +++ b/custom_components/eybond_local/metadata/register_schema_loader.py @@ -419,11 +419,7 @@ def _optional_str(value: Any) -> str | None: def _is_within_root(path: Path, root: Path) -> bool: - try: - path.relative_to(root.resolve()) - except ValueError: - return False - return True + return path.resolve().is_relative_to(root.resolve()) def _merge_raw_schema( diff --git a/custom_components/eybond_local/payload/short_ascii.py b/custom_components/eybond_local/payload/short_ascii.py index f564a05..8bb509a 100644 --- a/custom_components/eybond_local/payload/short_ascii.py +++ b/custom_components/eybond_local/payload/short_ascii.py @@ -16,9 +16,12 @@ BASE_READ_COMMANDS = ("MP", "Q1", "MD") -READ_COMMANDS = (*BASE_READ_COMMANDS, "F", "RB") +READ_COMMANDS = (*BASE_READ_COMMANDS, "F", "RH", "RB") PROTOCOL_ID = "EYBOND_SHORT_ASCII" WIRE_DIALECT = "urtu1920_checksum" +# RB charge/discharge keys published only when optional RH reports decimals (RH=1). +RB_CURRENT_KEYS = ("bms_charging_current", "bms_discharging_current") +RB_CURRENT_POWER_KEYS = (*RB_CURRENT_KEYS, "battery_power") class ShortAsciiError(ValueError): @@ -76,12 +79,33 @@ def parse_f(frame: bytes) -> dict[str, object]: } +def parse_rh(frame: bytes) -> dict[str, object]: + """Qualified RH settings block: 27-byte body, sum8, trailing CR. + + Live proxy captures match vendor 19B4 segment-5 length/checksum. Only the + BMS current-display-accuracy enum (``rh_crtu_39``) is decoded: ``0`` = + without decimals, ``1`` = with decimals. Other RH fields stay unpublished. + """ + _envelope(frame, length=30, status=True) + body = frame[1:-2] + if sum(body) & 0xFF != frame[-2]: + raise ShortAsciiError("short_ascii_rh_checksum") + accuracy = body[8] + if accuracy not in (0, 1): + raise ShortAsciiError("short_ascii_rh_field") + return {"short_ascii_bms_current_display_accuracy": accuracy} + + def parse_rb(frame: bytes) -> dict[str, object]: """Qualified RB dialect: 25 documented bytes, 12 zero padding bytes, sum8. This checksum differs from Q1. Only this exact captured layout is accepted; the XML describes the first 25 bytes, not arbitrary future extensions. - Currents remain raw evidence: RH's precision setting has separate authority. + + Charge/discharge raw words use 19B4 segment-7 ``multiply=0.1`` (``/10``). + Optional runtime code publishes those keys only when RH reports decimals + (accuracy ``1``) and F ratings are available for I/P hard-reject bounds. + Battery DC watts are derived outside this parser (MX2). """ _envelope(frame, length=40, status=True) body = frame[1:-2] @@ -107,6 +131,8 @@ def parse_rb(frame: bytes) -> dict[str, object]: "short_ascii_bms_discharge_path_enabled": body[24] == 1, } for key, offset, divisor in ( + ("bms_charging_current", 3, 10), + ("bms_discharging_current", 5, 10), ("bms_cell_temperature", 7, 10), ("bms_cycle_count", 9, 1), ("bms_internal_temperature", 11, 10), diff --git a/custom_components/eybond_local/payload/short_ascii_mppt.py b/custom_components/eybond_local/payload/short_ascii_mppt.py index 98be7ab..d0bc723 100644 --- a/custom_components/eybond_local/payload/short_ascii_mppt.py +++ b/custom_components/eybond_local/payload/short_ascii_mppt.py @@ -3,7 +3,8 @@ This module cannot find a TCP boundary, admit a session or send a query. AABB and EyeBond can overlap on the wire; the caller must establish the grammar independently. Offline tooling may explicitly assume it and label that choice. -No fields here are merged into live inverter telemetry yet. +An optional driver module may merge decoded keys into runtime FULL results; +this payload module itself still does not solicit, admit, or send. """ from __future__ import annotations @@ -102,3 +103,11 @@ def word(offset: int) -> int: total_energy_kwh=word(17) / 10, fault_code=wire[19], ) + + +def parse_mppt_runtime_wire(wire: bytes) -> MpptRuntimeSample: + """Decode raw AABB/0200 reply bytes without drivers naming the grammar.""" + + if type(wire) is not bytes: + raise ValueError("mppt_frame_contract_invalid") + return parse_mppt_runtime(BinaryFrame(BinaryGrammar.AABB, wire)) diff --git a/custom_components/eybond_local/protocol_catalogs/register_schemas/eybond_short_ascii/base.json b/custom_components/eybond_local/protocol_catalogs/register_schemas/eybond_short_ascii/base.json index 886385a..c4ab5ef 100644 --- a/custom_components/eybond_local/protocol_catalogs/register_schemas/eybond_short_ascii/base.json +++ b/custom_components/eybond_local/protocol_catalogs/register_schemas/eybond_short_ascii/base.json @@ -15,6 +15,7 @@ {"key": "grid_voltage", "name": "Grid Voltage", "unit": "V", "device_class": "voltage", "state_class": "measurement"}, {"key": "output_voltage", "name": "Output Voltage", "unit": "V", "device_class": "voltage", "state_class": "measurement"}, {"key": "load_percent", "name": "Load Percent", "unit": "%", "state_class": "measurement", "icon": "mdi:gauge"}, + {"key": "estimated_ac_load_power", "name": "Estimated AC Load Power", "unit": "W", "device_class": "power", "state_class": "measurement", "icon": "mdi:flash", "enabled_default": true}, {"key": "output_frequency", "name": "Output Frequency", "unit": "Hz", "device_class": "frequency", "state_class": "measurement"}, {"key": "battery_reference_voltage", "name": "Battery Reference Voltage", "unit": "V", "device_class": "voltage", "state_class": "measurement", "diagnostic": true}, {"key": "temperature", "name": "Inverter Temperature", "unit": "°C", "device_class": "temperature", "state_class": "measurement"}, @@ -26,6 +27,9 @@ {"key": "short_ascii_rated_frequency", "name": "Rated Frequency", "unit": "Hz", "device_class": "frequency", "diagnostic": true, "enabled_default": false}, {"key": "bms_total_voltage", "name": "BMS Battery Voltage", "unit": "V", "device_class": "voltage", "state_class": "measurement", "enabled_default": false}, {"key": "battery_soc", "name": "Battery State of Charge", "unit": "%", "device_class": "battery", "state_class": "measurement", "enabled_default": false}, + {"key": "bms_charging_current", "name": "BMS Charging Current", "unit": "A", "device_class": "current", "state_class": "measurement", "enabled_default": false}, + {"key": "bms_discharging_current", "name": "BMS Discharging Current", "unit": "A", "device_class": "current", "state_class": "measurement", "enabled_default": false}, + {"key": "battery_power", "name": "Battery DC Power", "unit": "W", "device_class": "power", "state_class": "measurement", "enabled_default": false, "icon": "mdi:battery-charging"}, {"key": "bms_cell_temperature", "name": "BMS Cell Temperature", "unit": "°C", "device_class": "temperature", "state_class": "measurement", "diagnostic": true, "enabled_default": false}, {"key": "bms_cycle_count", "name": "BMS Cycle Count", "state_class": "measurement", "diagnostic": true, "enabled_default": false}, {"key": "bms_internal_temperature", "name": "BMS Ambient Temperature", "unit": "°C", "device_class": "temperature", "state_class": "measurement", "diagnostic": true, "enabled_default": false}, @@ -34,9 +38,21 @@ {"key": "bms_min_cell_voltage", "name": "BMS Minimum Cell Voltage", "unit": "V", "device_class": "voltage", "state_class": "measurement", "diagnostic": true, "enabled_default": false}, {"key": "bms_low_protection_voltage", "name": "BMS Low Voltage Protection", "unit": "V", "device_class": "voltage", "diagnostic": true, "enabled_default": false}, {"key": "bms_charge_cutoff_voltage", "name": "BMS Charge Cutoff Voltage", "unit": "V", "device_class": "voltage", "diagnostic": true, "enabled_default": false}, + {"key": "pv_voltage", "name": "PV Voltage", "unit": "V", "device_class": "voltage", "state_class": "measurement", "enabled_default": false, "icon": "mdi:solar-power"}, + {"key": "pv_power", "name": "PV Power", "unit": "W", "device_class": "power", "state_class": "measurement", "enabled_default": false, "icon": "mdi:solar-power"}, + {"key": "mppt_battery_voltage", "name": "MPPT Battery Voltage", "unit": "V", "device_class": "voltage", "state_class": "measurement", "enabled_default": false}, + {"key": "mppt_temperature", "name": "MPPT Temperature", "unit": "°C", "device_class": "temperature", "state_class": "measurement", "enabled_default": false}, + {"key": "dc_load_current", "name": "MPPT DC Load Current", "unit": "A", "device_class": "current", "state_class": "measurement", "enabled_default": false, "icon": "mdi:current-dc"}, + {"key": "mppt_work_mode_code", "name": "MPPT Work Mode Code", "diagnostic": true, "enabled_default": false}, + {"key": "mppt_daily_energy", "name": "MPPT Daily Energy", "unit": "kWh", "device_class": "energy", "state_class": "total_increasing", "enabled_default": false, "icon": "mdi:solar-power"}, + {"key": "mppt_total_energy", "name": "MPPT Total Energy", "unit": "kWh", "device_class": "energy", "state_class": "total_increasing", "enabled_default": false, "icon": "mdi:solar-power"}, + {"key": "mppt_error_code", "name": "MPPT Error Code", "diagnostic": true, "enabled_default": false}, {"key": "short_ascii_optional_status", "name": "Optional Read Status", "diagnostic": true, "enabled_default": false}, {"key": "short_ascii_rb_age_seconds", "name": "BMS Sample Age", "unit": "s", "diagnostic": true, "enabled_default": false}, - {"key": "short_ascii_f_age_seconds", "name": "Rated Values Sample Age", "unit": "s", "diagnostic": true, "enabled_default": false} + {"key": "short_ascii_f_age_seconds", "name": "Rated Values Sample Age", "unit": "s", "diagnostic": true, "enabled_default": false}, + {"key": "bms_link_loss_count", "name": "BMS Link Loss Count", "state_class": "total_increasing", "diagnostic": true, "enabled_default": true, "icon": "mdi:counter"}, + {"key": "rb_hard_reject_count", "name": "RB Hard Reject Count", "state_class": "total_increasing", "diagnostic": true, "enabled_default": true, "icon": "mdi:counter"}, + {"key": "reading_hold_pending_count", "name": "Reading Hold Pending Count", "state_class": "total_increasing", "diagnostic": true, "enabled_default": false, "icon": "mdi:counter"} ], "binary_sensor_descriptions": [ {"key": "inverter_fault", "name": "Inverter Fault", "device_class": "problem", "diagnostic": true}, @@ -52,9 +68,16 @@ "Baseline FC4 payloads: MP 38 bytes, Q1 51 bytes, MD 24 bytes; no retail-model or serial-number inference.", "Q1 has fixed columns and an unsigned additive 16-bit checksum. Missing columns, truncation and shifted replies are rejected.", "Battery Reference Voltage is the vendor's single-battery value, not an inferred whole-pack voltage.", - "Output frequency is not grid frequency. The Q1 trailing output word has unresolved semantics and stays in raw support evidence.", - "Optional F and qualified 40-byte RB replies are sampled after detection, with bounded cache lifetime and immediate invalidation on failure. They are disabled-by-default entities because support varies by device.", - "RB current scaling depends on RH configuration and is not inferred. RB zero voltage plus zero SOC withdraws all BMS measurements, including trailing retained fields.", - "Auxiliary AABB/PV fields and all controls remain absent. Ratings are not a source of measured power." + "Output frequency is not grid frequency; do not alias the two. The Q1 trailing output word has unresolved semantics and stays in raw support evidence.", + "Optional F, RH and qualified 40-byte RB replies are sampled after detection, with bounded cache lifetime and immediate invalidation on failure. They are disabled-by-default entities because support varies by device.", + "RB charge/discharge currents use 19B4 segment-7 multiply=0.1 only when optional RH reports BMS current display accuracy=1 (with decimals). RH=0, unread, expired or failed RH omits those keys and battery_power — do not publish ÷10 blindly. RH=1 is the discriminator (no retail-model gate). SmartValue-correlated on one live family member; other members (e.g. Maxinn) are not separately live-proven and only light up if RH=1.", + "Currents and battery_power are also withheld until F ratings are available so I/P hard-reject bounds can run; ungated absurd currents must not become last-good.", + "battery_power is measured pack DC watts: BMS V × (Icharge − Idischarge), positive while charging. It is not estimated_ac_load_power and must not replace load_percent.", + "RB zero voltage plus zero SOC withdraws all BMS measurements, including trailing retained fields.", + "Optional MPPT/PV entities come from the documented auxiliary 0200 runtime read only (not tip harvest, not settings 0202). All are disabled by default; mppt_error_code is diagnostic and quiet. Distinct owners: pv_* = PV array; mppt_battery_voltage ≠ BMS/reference voltage; mppt_temperature ≠ inverter/BMS temps; dc_load_current ≠ AC load. Live-qualify with Support Archive before claiming device support; family identity only — no retail Anern model claim. Controls remain absent. Ratings are not a source of measured power.", + "estimated_ac_load_power is load_percent × (rated voltage × rated current) from F. It is an estimate, not measured active power; wire load_percent stays published separately.", + "bms_link_loss_count increments once per transition into the RB link-loss signature (V=0 ∧ SoC=0), including hold-start after a good sample; continuous polls in the same streak do not re-count. Streak ends only on a true ok recovery; hard-reject or envelope clear mid-dropout does not re-count. Runtime-scoped, not persisted.", + "rb_hard_reject_count increments on each post-envelope field hard-reject (impossible V/SoC/I/P). ADR 0001 reject observability.", + "reading_hold_pending_count stays 0: physics confirmation-hold is deferred (ADR 0001 / Q3.P1). Entity exists for the MX2 counter contract (disabled by default). Held ≠ live is via sample age / dual TTL, not this counter." ] } diff --git a/docs/generated/INVERTER_MODEL_CATALOG.generated.md b/docs/generated/INVERTER_MODEL_CATALOG.generated.md index 18224e4..0381659 100644 --- a/docs/generated/INVERTER_MODEL_CATALOG.generated.md +++ b/docs/generated/INVERTER_MODEL_CATALOG.generated.md @@ -2,9 +2,9 @@ Generated by `tools/model_catalog.py`. Do not edit by hand. -- Model catalog version: 2026.08.2 +- Model catalog version: 2026.09.1 - Runtime inverter catalog version: 2026.08.8 -- Models: 17 | Variants: 19 +- Models: 18 | Variants: 20 ## How To Read This Catalog @@ -37,6 +37,7 @@ This page describes what has been confirmed for a specific inverter model or mod | 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 | +| EyeBond | Short-ASCII family | eybond_short_ascii | anchors | partial | partial | none | captured | | Gootu | GT-H2436M14P5 | eybond_g_ascii | anchors | full | confirmed | partial | captured | | Kevolt | PD0080G-TPM-EU | modbus_catalog | anchors | full | partial | partial | confirmed | | LVYUAN | TY-SIC-3.6KBE-W1 | eybond_g_ascii | anchors | full | confirmed | partial | captured | @@ -60,7 +61,6 @@ Runtime descriptors with no specific commercial model record. These are generic | `aohai_fsa_family` | modbus_catalog | aohai_fsa_read_only | partial | yes | | `deye_lv_family` | modbus_catalog | deye_lv_untested | full | no | | `eybond_g_ascii_family` | eybond_g_ascii | eybond_g_ascii_read_only | partial | yes | -| `eybond_short_ascii_family` | eybond_short_ascii | eybond_short_ascii_read_only | partial | yes | | `growatt_spf_family` | modbus_catalog | growatt_spf_untested | full | no | | `modbus_smg.protocol_11_family_fallback` | modbus_smg | smg_protocol_11_family_fallback | full | no | | `modbus_smg.protocol_1_family_fallback` | modbus_smg | smg_protocol_1_family_fallback | full | no | @@ -255,6 +255,38 @@ Runtime descriptors with no specific commercial model record. These are generic - The full control surface is not yet reflected as a hardware-write-validated catalog assertion (validation.controls stays partial pending a backing write-validation source). - Evidence: 3 source(s) +### EyeBond — Short-ASCII family (`eybond_short_ascii_family`) + +- Lifecycle: experimental +- Aliases: EyeBond Short-ASCII family +- Validation: hardware captured, telemetry partial, controls none +- Coverage: runtime read_only, cloud not_applicable, vendor map partial + - Coverage notes: + - Runtime support is family-only and read-only. Compatible devices appear as EyeBond Short-ASCII family; retail model and serial are not inferred. + - Optional RB/F/RH and aux-0200 MPPT/PV entities are disabled by default and expire on TTL or invalid replies. +- Summary: EyeBond Short-ASCII is a capture-qualified read-only family for the fixed MP/Q1/MD dialect shared by some Anern and Maxinn sites. Optional F/RH/RB add rated values and BMS telemetry with TTL expiry, hard-reject/link-loss hold, and an RH+F gate for currents. Optional live PV/MPPT uses aux 0200 with opt-in entities and TTL; offline MPPT decode stays maintainer-only. Labelled AC-load estimate is separate from measured battery DC power. Retail-model identity remains out of scope; site live-qualification for the PV path is still pending. +- Variants: + - `urtu1920_checksum` — EyeBond Short-ASCII read-only family (URTU1920 checksum dialect) + - Descriptors: eybond_short_ascii_family + - Known firmware: — + - `eybond_short_ascii_family` → surface `eybond_short_ascii_read_only` (driver eybond_short_ascii, variant urtu1920_checksum) + - Protocol: eybond_short_ascii | Detection: anchors (protocol.protocol_id=EYBOND_SHORT_ASCII; protocol.wire_dialect=urtu1920_checksum; shape.q1_length=51; shape.mp_length=38; shape.md_length=24) + - Tier: partial | Read-only: yes | Profile: — | Schema: eybond_short_ascii/base.json + - Capabilities: no model-specific profile | Telemetry: 43 measurements, 8 binary sensors +- Known limitations: + - Family identity only: no retail commercial model (including no Anern SPI-12000W claim) and no inverter serial from MP/Q1/MD. + - Read-only: no inverter controls; Full Control does not invent settings. + - Optional live PV/MPPT uses the documented auxiliary 0200 runtime read only (not tip AABB harvest, not settings 0202). Matching schema sensors stay disabled by default; mppt_error_code is diagnostic and quiet. Samples use 30 s solicit / 60 s TTL; at most one optional read per Q1 cycle, with FC4 RB/F/RH preferred when both are due. + - Offline decode_short_ascii_mppt is a maintainer capture inspector only; it is not the live optional admission path and does not create entities by itself. + - Live PV/MPPT admission is code-complete but not yet live-qualified on site HA (family cutover / Support Archive RECEIPT still pending). + - Quiet fault/diagnostic sensors beyond Q1 flags and the quiet mppt_error_code (Q2 extras) remain deferred. + - estimated_ac_load_power is load% × rated VA from F — an estimate, not measured active power; load_percent stays separate. Output frequency is not grid frequency. + - Optional BMS currents and battery_power publish only when RH reports BMS current display accuracy=1 (decimals) and F ratings are available for I/P hard-reject bounds; RH=0, unread, expired or failed RH omits those keys. RH=1 is the discriminator (no retail-model gate). + - Impossible RB V/SoC/I/P values are hard-rejected. Link-loss (V=0 ∧ SoC=0) may hold last-good BMS for up to 180 s without refreshing sample age; normal RB TTL remains 60 s. + - Diagnostic counters: bms_link_loss_count and rb_hard_reject_count are runtime-scoped; reading_hold_pending_count stays 0 (physics confirmation-hold deferred). + - BMS current / battery_power path is SmartValue-correlated on one live family member; other members (e.g. Maxinn) are not separately live-proven and only light up if RH=1. +- Evidence: 2 source(s) + ### Gootu — GT-H2436M14P5 (`gootu_gt_h2436m14p5`) - Lifecycle: experimental diff --git a/docs/maintainer/ADDING_DRIVERS.md b/docs/maintainer/ADDING_DRIVERS.md index 73fc0c3..9009c79 100644 --- a/docs/maintainer/ADDING_DRIVERS.md +++ b/docs/maintainer/ADDING_DRIVERS.md @@ -4,10 +4,13 @@ This project is designed to grow through transport-aware payload drivers plus de ### Internal auxiliary-channel foundation -The short-ASCII auxiliary channel is not yet exposed by a driver, catalog entry -or discovery route. Its socket-level implementation is shared by the framed and -AT connections. Do not enable it merely because an incoming packet starts with -`AA BB`, or because a collector name or endpoint looks familiar. +The short-ASCII auxiliary channel is admitted as an optional driver read for the +documented `0200` runtime query, with matching register-schema entities that stay +**disabled by default** (opt-in). Socket-level `async_send_auxiliary_read` is +shared by the framed and AT connections; `SharedEybondTransport` exposes the +same method as a thin facade so optional modules call it without reaching into +`connections.py`. Do not enable it merely because an incoming packet starts +with `AA BB`, or because a collector name or endpoint looks familiar. The internal `async_send_auxiliary_read` accepts only the two documented 21-byte read queries (subtypes `0200` and `0202`), without a UART bootstrap or @@ -15,16 +18,19 @@ device-setting write. A socket-scoped owner keeps mixed binary framing enabled after a caller finishes; it never derives the grammar from a live future. Only the request present at the start of a frame can receive that frame. Cancellation or timeout after sending closes that exact socket, since these -replies have no transaction identifier. Reconnection starts a new owner. +replies have no transaction identifier. Reconnection starts a new owner. The +framed facade forwards ownership, timeout and query gating to that connection +method; it does not reimplement framing. Integrity or boundary failures close the session. In particular, a valid EyeBond frame with transaction ID `0xAABB` can overlap the auxiliary grammar: neither a valid checksum nor an absent waiter resolves that ambiguity. The current foundation refuses such a frame; it does **not** guarantee auxiliary -availability for every possible payload. Driver-level admission, truthful -model/field semantics and optional-data expiry remain separate work before -user-facing support. Normal connections keep their existing grammar until an -explicit auxiliary read is requested. +availability for every possible payload. Catalog `known_limitations` record the +opt-in aux-0200 PV path and still forbid retail-model claims. Live-qualify with +Support Archive evidence before treating PV as device-proven. +Normal connections keep their existing grammar until an explicit auxiliary read +is requested. Ordinary framed, AT-management and raw-payload sends also pin their physical writer and run epoch before waiting for request/write locks. `SocketSendOwner` @@ -48,22 +54,34 @@ parser awaits inside that guard; do not wrap only the inner socket read of a `payload/short_ascii_mppt.py` now decodes an explicitly framed AABB/0200 sample into an immutable, unit-labelled value object. It does not choose the grammar, -send requests, supply a register schema or populate live telemetry. Settings +send requests, supply a register schema or populate HA entities. Settings 0202 are rejected. MPPT voltage/temperature/DC load current remain distinct from BMS/reference voltage, main-inverter temperature and AC load power. Unknown enums remain raw codes; zero/maximum words are decoded wire values, not a proved availability or sentinel policy. The sample has no timestamp or freshness claim. See the [offline inspector](../../tools/README.md#inspect-a-short-ascii-mppt-frame-offline) -for capture analysis; enabling live PV still requires the admission contract -and per-session optional-sample expiry/invalidation described above. +for capture analysis. + +`drivers/short_ascii_mppt_optional.py` solicits the documented 21-byte `0200` +read through `link_transport.async_auxiliary_read` (framed/AT +`async_send_auxiliary_read` facade), decodes via `parse_mppt_runtime_wire`, and +holds values with the same OptionalSample TTL pattern as RB (30 s interval / +60 s TTL). It is paced inside `short_ascii_optional` at most once per successful +Q1 cycle and clears on lost connection, binding change or failed mandatory +reads. Matching register-schema sensors (`pv_*`, `mppt_*`, `dc_load_current`) +are **opt-in** (`enabled_default: false`); `mppt_error_code` is diagnostic and +quiet. Do not force-enable them from catalog overlays. ### Qualified short-ASCII baseline -`eybond_short_ascii` is a separate read-only FC4 payload driver. It does not call -the auxiliary API above. It uses the existing catalog probe DAG and requires -all three replies: MP (38 bytes), Q1 (51 bytes with unsigned additive checksum) -and MD (24 bytes including fixed padding). Every query has a fixed timeout; -there is no UART-mode change or fallback to a raw-serial route. +`eybond_short_ascii` is a separate read-only FC4 payload driver. Optional live +PV uses the auxiliary facade above for the documented `0200` read only; it does +not harvest tip AABB or admit settings `0202`. Schema entities for those PV/MPPT +keys exist but stay disabled by default. The driver still uses the existing +catalog probe DAG and requires all three mandatory replies: MP (38 bytes), Q1 +(51 bytes with unsigned additive checksum) and MD (24 bytes including fixed +padding). Every query has a fixed timeout; there is no UART-mode change or +fallback to a raw-serial route. The field layout follows vendor 19B4 segment 1 and saved exchanges from two devices. Fixed widths, status bits, checksum and envelope are validated before @@ -80,35 +98,56 @@ candidate revisions, resolution and evidence fingerprint. Reload must restore that schema without borrowing default driver controls. Unqualified schema-only hints remain invalid. -Optional F (22-byte fixed text) and RB (40 bytes, unsigned **8-bit** body sum, -not Q1's 16-bit checksum) are runtime reads, not additional detection probes. -The documented 25-byte RB field layout and captured 12 zero padding bytes are -required; unknown extensions are rejected. Vendor 19B4 segments 6/7 and the -correlated saved exchanges qualify these fields. Segment 5 also describes a -BMS precision setting, but only one variant was captured: RB currents remain -raw evidence until their scaling is established for both variants. +Optional F (22-byte fixed text), RH (30 bytes, unsigned **8-bit** body sum) and +RB (40 bytes, unsigned **8-bit** body sum, not Q1's 16-bit checksum) are runtime +reads, not additional detection probes. The documented 25-byte RB field layout +and captured 12 zero padding bytes are required; unknown extensions are +rejected. Vendor 19B4 segments 6/7 and the correlated saved exchanges qualify +the non-current RB fields. Segment 7 documents charge/discharge +``multiply=0.1``; those currents and measured ``battery_power`` +(V × (Icharge − Idischarge)) publish only while optional RH reports BMS current +display accuracy ``1`` (with decimals) and F ratings are available for I/P +hard-reject bounds. RH ``0``, unread, expired or failed RH omits the keys — +do not publish ÷10 blindly. RH=1 is the discriminator (no retail-model gate). +That RH=1 path is SmartValue-correlated on one live family member; other +members (e.g. Maxinn) are not separately live-proven and only light up if +RH=1. `short_ascii_optional` owns per-runtime samples, scoped to the transport and inverter binding. The hub discards this state on recovery; samples are never persisted as identity. Each successful Q1 cycle performs at most one optional -request (4-second bound), oldest due group first: RB every 30 seconds with a -60-second TTL, F every 900 seconds with a 900-second TTL. Freshness is checked -after the await. Invalid/timeout replies clear that group immediately, and -FULL-result omission removes it from the hub. A failed or cancelled mandatory -cycle, lost connection, changed binding or clock rollback clears all samples. -Only optional failures alongside a successful Q1 count towards the shared -four-strike command cache; the existing re-check action re-enables requests. +request (4-second bound): RB every 30 seconds with a 60-second TTL, F and RH +every 900 seconds with a 900-second TTL, and optional MPPT (`0200` aux) every +30 seconds with a 60-second TTL. When both FC4 (RB/F/RH) and MPPT are due, +prefer FC4 (oldest due within that set); MPPT runs only when it is the sole due +sample. Freshness is checked after the await. Invalid/timeout replies clear +that group immediately, and FULL-result omission removes it from the hub. A +failed or cancelled mandatory cycle, lost connection, changed binding or clock +rollback clears all samples. Only optional failures alongside a successful Q1 +count towards the shared four-strike command cache; the existing re-check +action re-enables requests. An RB reply with zero voltage and zero SOC explicitly withdraws **all** BMS measurements/path flags, including nonzero trailing fields seen in the capture. Positive voltage with zero SOC remains valid. Data availability is not a -physical connection detector. Reference voltage, BMS voltage and ratings have -separate owners; no current scaling, VA-to-watts or pack-voltage inference is -allowed. Optional entities are disabled by default. Support evidence capture -can read MP/Q1/MD/F/RB without changing command-support state. - -AABB/PV admission and inverter controls remain separate work. Do not report -full PR/device support based on these fields or a saved-wire replay alone. +physical connection detector. After envelope parse, ``short_ascii_rb_filter`` +hard-rejects impossible V/SoC/I/P (pack window, SoC 0–100, current above +1× ``rated_va / rated_battery_voltage``, power only above 3× ``rated_va``). +Link-loss alone may keep last-good publishable while +``age_from_last_good < 180 s`` without refreshing ``sampled_at``; normal RB TTL +stays 60 s. Counters ``bms_link_loss_count`` / ``rb_hard_reject_count`` are +runtime-scoped; ``reading_hold_pending_count`` stays 0 (physics confirmation-hold +deferred). Reference voltage, BMS voltage and ratings have separate owners; +pack-voltage is never inferred from reference voltage. Measured battery DC +watts use published BMS currents; the labelled AC-load estimate uses +load% × rated VA — never substitute one for the other. Optional entities are +disabled by default. Support evidence capture can read MP/Q1/MD/F/RH/RB without +changing command-support state. + +Optional MPPT/PV schema entities are disabled by default and must be enabled +explicitly in HA. Live-qualify with Support Archive before claiming device +support; do not report full PR/device support from a saved-wire replay alone. +Family identity only — no retail-model catalog claim. Controls remain absent. The preferred workflow is: diff --git a/docs/user/RUNTIME_AND_INVERTER.md b/docs/user/RUNTIME_AND_INVERTER.md index 605f41b..ba867a8 100644 --- a/docs/user/RUNTIME_AND_INVERTER.md +++ b/docs/user/RUNTIME_AND_INVERTER.md @@ -83,39 +83,61 @@ device appears as **EyeBond Short-ASCII family** because the available replies do not reliably identify its commercial model or serial number. Available readings are grid voltage, output voltage, load percentage, output -frequency and inverter temperature. Diagnostics also include firmware, fault -and connection flags, and **Battery Reference Voltage**. That last reading is -the protocol's single-block reference, not the voltage of the complete battery -pack; do not use it as a replacement for a 24/48 V pack measurement. +frequency and inverter temperature. When rated values (F) are known, a labelled +**Estimated AC Load Power** may also appear (load percentage × rated VA). That +estimate is not measured active power; load percentage stays published on its +own. Diagnostics also include firmware, fault and connection flags, and +**Battery Reference Voltage**. That last reading is the protocol's single-block +reference, not the voltage of the complete battery pack; do not use it as a +replacement for a 24/48 V pack measurement. Output frequency is not grid +frequency. Some compatible devices also answer optional requests for **BMS Battery Voltage**, **Battery State of Charge**, BMS temperatures, cell voltages, cycle count, -protection limits, charge/discharge path flags and rated values. These entities -are disabled by default: open the inverter's entity list and enable the ones -you need. A charge-path flag means the path is enabled, not that the battery is -currently charging. BMS voltage is separate from Battery Reference Voltage; -neither reading is calculated from the other. - -BMS is requested no more often than every 30 seconds, rated values every -15 minutes, with at most one extra request per normal poll. A longer poll -interval can delay them further. Failed or invalid responses immediately -remove the old values for that group. At each refresh, BMS samples aged -60 seconds or more and rated values aged 15 minutes or more are discarded. -If the device returns the known -no-data BMS reply, **BMS Data Available** turns off and its measurements become -unavailable, even if some fields still contain old numbers. This does not -prove the physical battery is disconnected. +protection limits, charge/discharge path flags and rated values. When the +device's RH settings report BMS current display accuracy **with decimals** and +rated F values are available for current/power bounds, +**BMS Charging Current**, **BMS Discharging Current** and **Battery DC Power** +may also appear (scaled per the vendor decimals path). If RH reports without +decimals, RH has not been read successfully yet, or F ratings are missing, +those current/power entities stay unavailable rather than guessing a scale or +skipping bounds checks. These entities are disabled by default: open the +inverter's entity list and enable the ones you need. A charge-path flag means +the path is enabled, not that the battery is currently charging. BMS voltage is +separate from Battery Reference Voltage; neither reading is calculated from the +other. Battery DC Power is measured from BMS voltage and currents (positive +while charging); it is not Estimated AC Load Power. + +BMS is requested no more often than every 30 seconds, rated values and RH +settings every 15 minutes, with at most one extra request per normal poll. A +longer poll interval can delay them further. Failed or invalid responses +immediately remove the old values for that group. At each refresh, BMS samples +aged 60 seconds or more and rated/RH values aged 15 minutes or more are +discarded. Impossible pack voltage, SoC, current or power samples are rejected +instead of published. When the device returns the known no-data BMS reply +(voltage and SoC both zero), **BMS Data Available** turns off; last-good BMS +values may remain visible for up to about three minutes without looking fresh, +then expire. That hold does not prove the physical battery is disconnected. +Diagnostic counters can show how often link-loss and hard rejects occurred; +they reset when Home Assistant reloads the entry. + +Some devices can also answer an optional auxiliary **PV / MPPT** read +(documented `0200` runtime query). Matching entities such as PV voltage/power +and MPPT diagnostics stay disabled by default — enable them only if you need +them. Those samples refresh on a similar ~30 s cadence with a ~60 s expiry, and +share the one-extra-request-per-poll budget with BMS/rated reads (BMS and rated +queries take priority when both are due). This is not the offline maintainer +MPPT frame inspector, and it is not a retail Anern model claim. After four failed optional requests while basic telemetry still responds, that request is skipped. Use **Re-check supported commands** to try it again, for example after connecting a BMS. Missing optional data does not prevent basic inverter monitoring. -This profile does not provide PV power, BMS currents, battery power, grid -frequency or inverter controls. Selecting **Full Control** does not add -undocumented settings. If readings are missing or implausible, create a -Support Archive for review; it can include the optional raw replies. Do not -select a similar retail model by guesswork. +This profile does not provide grid frequency or inverter controls. Selecting +**Full Control** does not add undocumented settings. If readings are missing or +implausible, create a Support Archive for review; it can include the optional +raw replies. Do not select a similar retail model by guesswork. ## Control mode diff --git a/docs/user/SUPPORT_ARCHIVE.md b/docs/user/SUPPORT_ARCHIVE.md index f6e1f03..3325890 100644 --- a/docs/user/SUPPORT_ARCHIVE.md +++ b/docs/user/SUPPORT_ARCHIVE.md @@ -66,12 +66,14 @@ Depending on what is available for this entry, one archive can include: - device-learning or cloud evidence that already belongs to this entry. For the **EyeBond Short-ASCII family** in the test code, creating an archive -also requests the basic `MP/Q1/MD` replies and optional `F/RB` replies. It keeps -the returned bytes, including malformed responses, and records failed requests. -It does not enable PV polling or inverter controls. This is a new diagnostic -snapshot, not a continuous recording: if a reading jumps, also report when it -happened and which sensor was affected. A later archive may no longer contain -the response that caused that jump. +also requests the basic `MP/Q1/MD` replies and optional `F/RH/RB` replies. It +keeps the returned bytes, including malformed responses, and records failed +requests. Archive creation does not solicit the optional auxiliary PV/MPPT +(`0200`) read, force-enable opt-in PV entities, or enable inverter controls — +those remain a separate runtime path. This is a new diagnostic snapshot, not a +continuous recording: if a reading jumps, also report when it happened and +which sensor was affected. A later archive may no longer contain the response +that caused that jump. The archive helps answer questions such as: diff --git a/tests/test_dessmonitor_learning.py b/tests/test_dessmonitor_learning.py index 133ee92..2cc6b15 100644 --- a/tests/test_dessmonitor_learning.py +++ b/tests/test_dessmonitor_learning.py @@ -31,6 +31,25 @@ ) from custom_components.eybond_local.support.dessmonitor_learning import ( # noqa: E402 DessMonitorReadOnlyEvidenceOperation, + _FETCH_DETAIL_WINDOWS, + _FETCH_PROGRESS, +) + +# Named stages the mock emits via report(); omits queryDeviceSoleChartEs which +# production can emit but this fixture does not. +_MOCK_REPORT_STAGES = ( + "authSource", + "webQueryDeviceEs", + "querySPDeviceLastData", + "queryDeviceChartField", + "querySPKeyParameters", + "queryDeviceCtrlField", + "queryDeviceLastRawData", + "queryDeviceCtrlValue", + "metadata_bundle", + "queryDeviceInfo", + "queryDeviceKeyParameterOneDay", + "history_complete", ) @@ -83,25 +102,11 @@ async def executor(operation): def fetch_bundle(**kwargs): report = kwargs["progress"] detail = kwargs["progress_detail"] - for stage in ( - "authSource", - "webQueryDeviceEs", - "querySPDeviceLastData", - "queryDeviceChartField", - "querySPKeyParameters", - "queryDeviceCtrlField", - "queryDeviceLastRawData", - ): + for stage in _MOCK_REPORT_STAGES: + if stage in _FETCH_DETAIL_WINDOWS: + for completed in range(1, 5): + detail(stage, completed, 4) report(stage) - for completed in range(1, 5): - detail("queryDeviceCtrlValue", completed, 4) - report("queryDeviceCtrlValue") - report("metadata_bundle") - report("queryDeviceInfo") - for completed in range(1, 5): - detail("queryDeviceKeyParameterOneDay", completed, 4) - report("queryDeviceKeyParameterOneDay") - report("history_complete") return bundle, history_collection with patch( @@ -126,6 +131,12 @@ def fetch_bundle(**kwargs): on_learning=on_learning, ) + # Executor awaits to_thread, so the sync fetch has joined before we + # continue; call_soon_threadsafe progress callbacks can still be queued. + # One sleep(0) drains them. Under suite load a late drain may still + # append fetching after building — invariants below tolerate that. + await asyncio.sleep(0) + start_route.assert_not_awaited() on_learning.assert_not_called() self.assertEqual(fetch.call_count, 1) @@ -166,33 +177,42 @@ def fetch_bundle(**kwargs): "unproven", ) self.assertNotIn("register", str(semantic_report).casefold()) - self.assertEqual( - progress, - [ - (0.10, "fetching"), - (0.16, "fetching"), - (0.23, "fetching"), - (0.30, "fetching"), - (0.36, "fetching"), - (0.42, "fetching"), - (0.48, "fetching"), - (0.54, "fetching"), - (0.5525, "fetching"), - (0.565, "fetching"), - (0.5775, "fetching"), - (0.59, "fetching"), - (0.60, "fetching"), - (0.64, "fetching"), - (0.68, "fetching"), - (0.70, "fetching"), - (0.72, "fetching"), - (0.74, "fetching"), - (0.76, "fetching"), - (0.77, "fetching"), - (0.80, "fetching"), - (0.82, "building"), - ], + # Progress invariants (not a brittle exact timeline): start bookend, + # single building stage, all mock-emitted _FETCH_PROGRESS waypoints, + # and non-decreasing fractions on the pre-building prefix. + self.assertGreaterEqual(len(progress), 3) + self.assertEqual(progress[0], (0.10, "fetching")) + stages = [stage for _fraction, stage in progress] + self.assertTrue(set(stages) <= {"fetching", "building"}) + self.assertEqual(stages.count("building"), 1) + building_index = stages.index("building") + self.assertEqual(progress[building_index], (0.82, "building")) + pre_building = progress[:building_index] + self.assertTrue(all(stage == "fetching" for _fraction, stage in pre_building)) + pre_fractions = [fraction for fraction, _stage in pre_building] + self.assertEqual(pre_fractions, sorted(pre_fractions)) + self.assertTrue( + all(stage == "fetching" for _fraction, stage in progress[building_index + 1 :]) ) + fractions = [fraction for fraction, _stage in progress] + expected_waypoints = {_FETCH_PROGRESS[stage] for stage in _MOCK_REPORT_STAGES} + self.assertTrue( + expected_waypoints <= set(fractions), + msg=f"missing fetch waypoints: {sorted(expected_waypoints - set(fractions))}", + ) + for stage, (start, _end) in _FETCH_DETAIL_WINDOWS.items(): + named = _FETCH_PROGRESS[stage] + detail_fractions = [ + fraction for fraction in fractions if start < fraction < named + ] + self.assertGreaterEqual( + len(detail_fractions), + 2, + msg=( + f"expected ≥2 {stage} detail fractions in ({start}, {named}); " + f"got {detail_fractions!r}" + ), + ) if __name__ == "__main__": diff --git a/tests/test_eybond_short_ascii.py b/tests/test_eybond_short_ascii.py index f5d2300..2e50740 100644 --- a/tests/test_eybond_short_ascii.py +++ b/tests/test_eybond_short_ascii.py @@ -41,9 +41,11 @@ class _Transport: connected = True collector_info = CollectorInfo(collector_pn="I30000200000000001") - def __init__(self, responses=None): + def __init__(self, responses=None, *, aux_responses=None): self.responses = _responses() if responses is None else responses self.requests = [] + self.aux_requests = [] + self.aux_responses = {} if aux_responses is None else aux_responses async def async_send_payload(self, payload, *, route, request_timeout=None): assert type(route) is EybondLinkRoute @@ -55,17 +57,24 @@ async def async_send_payload(self, payload, *, route, request_timeout=None): raise result return result + async def async_send_auxiliary_read(self, payload, *, request_timeout): + self.aux_requests.append(payload) + result = self.aux_responses.get(payload, self.aux_responses.get(None, b"")) + if isinstance(result, BaseException): + raise result + return result + def select_payload_route(self, *args, **kwargs): raise AssertionError("A qualified FC4 command must not fall back to raw UART") class ShortAsciiPayloadTests(unittest.TestCase): def test_only_documented_read_queries_and_exact_address(self): - for command in ("MP", "Q1", "MD", "F", "RB"): + for command in ("MP", "Q1", "MD", "F", "RH", "RB"): for address in (0, 1, 255): self.assertEqual(build_short_ascii_request(command, address), command.encode() + bytes([address]) + b"\r") - for command in ("", "QPI", "RH", "SON", "SOFF", "W", "Q1\r", "Q1\x01", None): + for command in ("", "QPI", "SON", "SOFF", "W", "Q1\r", "Q1\x01", None): with self.assertRaises(ShortAsciiError): build_short_ascii_request(command, 1) for address in (-1, 256, "1", 1.0, True, None): @@ -278,7 +287,7 @@ async def test_support_capture_keeps_binary_trailers_and_only_bounded_read_queri self.assertEqual(capture["responses_hex"]["MP"], _responses()["MP"].hex()) self.assertEqual(capture["responses_hex"]["Q1"], _q1().hex()) self.assertEqual(capture["failures"], {}) - self.assertEqual(self.transport.requests, [b"MP\x01\r", b"Q1\x01\r", b"MD\x01\r", b"F\x01\r", b"RB\x01\r"]) + self.assertEqual(self.transport.requests, [b"MP\x01\r", b"Q1\x01\r", b"MD\x01\r", b"F\x01\r", b"RH\x01\r", b"RB\x01\r"]) # Generic support sweeps select raw routes on some AT devices; this # driver deliberately doesn't advertise a query through that API. self.assertEqual(self.driver.support_probe_plan(), ()) @@ -300,7 +309,7 @@ async def test_raw_uart_route_and_unqualified_query_are_not_selected(self): self.assertEqual(self.transport.requests, []) session = ShortAsciiSession(self.transport, self.target.link_route, 1) with self.assertRaises(ShortAsciiError): - await session.request("RH") + await session.request("SON") self.assertEqual(self.transport.requests, []) def test_registration_preserves_existing_driver_order(self): diff --git a/tests/test_local_metadata.py b/tests/test_local_metadata.py index e9f9c7f..369173b 100644 --- a/tests/test_local_metadata.py +++ b/tests/test_local_metadata.py @@ -14,6 +14,7 @@ from custom_components.eybond_local.metadata.local_metadata import ( + _is_within_root as local_metadata_is_within_root, clear_local_metadata_loader_caches, create_local_profile_draft, create_local_schema_draft, @@ -24,6 +25,12 @@ resolve_local_metadata_rollback_paths, rollback_local_metadata_overrides, ) +from custom_components.eybond_local.metadata.profile_loader import ( + _is_within_root as profile_loader_is_within_root, +) +from custom_components.eybond_local.metadata.register_schema_loader import ( + _is_within_root as register_schema_loader_is_within_root, +) from custom_components.eybond_local.metadata.smartess_draft import ( create_smartess_known_family_draft, resolve_smartess_known_family_draft_plan, @@ -153,6 +160,38 @@ def _find_item(raw_items: list[dict[str, object]], key: str) -> dict[str, object class LocalMetadataTests(unittest.TestCase): + def test_is_within_root_accepts_symlink_alias_forms(self) -> None: + """Containment must survive mixed symlink forms (e.g. /var vs /private/var).""" + helpers = ( + local_metadata_is_within_root, + profile_loader_is_within_root, + register_schema_loader_is_within_root, + ) + with tempfile.TemporaryDirectory() as temp_dir: + base = Path(temp_dir) + real_root = base / "real" + real_root.mkdir() + alias_root = base / "alias" + alias_root.symlink_to(real_root.resolve()) + child = real_root / "nested" / "item.json" + child.parent.mkdir(parents=True) + child.write_text("{}", encoding="utf-8") + alias_child = alias_root / "nested" / "item.json" + outside = base / "outside.json" + outside.write_text("{}", encoding="utf-8") + + for helper in helpers: + with self.subTest(helper=helper.__module__): + self.assertTrue(helper(child, real_root)) + self.assertTrue(helper(child, alias_root)) + self.assertTrue(helper(alias_child, real_root)) + self.assertTrue(helper(alias_child, alias_root)) + # Mixed: unresolved alias path vs resolved real root (and reverse). + self.assertTrue(helper(alias_child, real_root.resolve())) + self.assertTrue(helper(child.resolve(), alias_root)) + self.assertFalse(helper(outside, real_root)) + self.assertFalse(helper(outside, alias_root)) + def test_detects_when_one_draft_name_overrides_builtin_metadata(self) -> None: self.assertTrue(draft_activates_automatically("smg_modbus.json", None)) self.assertTrue(draft_activates_automatically("smg_modbus.json", "smg_modbus.json")) diff --git a/tests/test_short_ascii_battery_dc.py b/tests/test_short_ascii_battery_dc.py new file mode 100644 index 0000000..0b10768 --- /dev/null +++ b/tests/test_short_ascii_battery_dc.py @@ -0,0 +1,205 @@ +"""Measured BMS currents + battery DC watts (T5.S3.W), separate from AC-load estimate.""" + +from __future__ import annotations + +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "tests")] + +from test_eybond_short_ascii import _Transport, _responses +from test_short_ascii_optional import _rb, _rh +from custom_components.eybond_local.drivers.eybond_short_ascii import EybondShortAsciiDriver +from custom_components.eybond_local.drivers.short_ascii_battery_dc import battery_dc_power_values +from custom_components.eybond_local.drivers.short_ascii_estimates import estimated_ac_load_values +from custom_components.eybond_local.drivers.short_ascii_optional import STATE_KEY +from custom_components.eybond_local.drivers.short_ascii_rb_filter import hard_reject_reason +from custom_components.eybond_local.metadata.register_schema_loader import ( + clear_register_schema_loader_cache, + load_register_schema, +) +from custom_components.eybond_local.models import ProbeTarget +from custom_components.eybond_local.payload.short_ascii import parse_rb + + +class BatteryDcUnitTests(unittest.TestCase): + def test_charge_positive_discharge_negative(self): + self.assertEqual( + battery_dc_power_values({ + "bms_total_voltage": 52.0, + "bms_charging_current": 3.5, + "bms_discharging_current": 0.0, + }), + {"battery_power": 182.0}, + ) + self.assertEqual( + battery_dc_power_values({ + "bms_total_voltage": 52.8, + "bms_charging_current": 0.0, + "bms_discharging_current": 29.0, + }), + {"battery_power": -1531.2}, + ) + + def test_omit_without_voltage_or_either_current(self): + self.assertEqual(battery_dc_power_values({ + "bms_charging_current": 1.0, "bms_discharging_current": 0.0, + }), {}) + self.assertEqual(battery_dc_power_values({ + "bms_total_voltage": 52.0, "bms_charging_current": 1.0, + }), {}) + self.assertEqual(battery_dc_power_values({}), {}) + + def test_estimate_and_battery_dc_are_independent_keys(self): + values = { + "load_percent": 13, + "short_ascii_rated_voltage": 115, + "short_ascii_rated_current": 105, + "bms_total_voltage": 52.0, + "bms_charging_current": 0.0, + "bms_discharging_current": 29.0, + } + estimate = estimated_ac_load_values(values) + measured = battery_dc_power_values(values) + self.assertEqual(estimate["estimated_ac_load_power"], 1569.8) + self.assertEqual(measured["battery_power"], -1508.0) + self.assertNotEqual( + estimate["estimated_ac_load_power"], measured["battery_power"], + ) + + +class BatteryDcDriverTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + clear_register_schema_loader_cache() + self.driver = EybondShortAsciiDriver() + responses = _responses() | { + "RB": _rb(charge_raw=0, discharge_raw=290), + "F": b"#115.0 105 48.00 60.0\r", + "RH": _rh(accuracy=1), + } + self.transport = _Transport(responses) + self.inverter = await self.driver.async_probe(self.transport, ProbeTarget(767, 255, 1)) + self.state = {} + self.transport.requests.clear() + + async def read(self, now): + return await self.driver.async_read_values( + self.transport, self.inverter, runtime_state=self.state, now_monotonic=now, + ) + + async def _arm_rh1_and_f(self): + """RB first (stripped), then F, then RH=1; force a gated RB refresh.""" + await self.read(0) # RB without I (F/RH not ready) + await self.read(1) # F + await self.read(2) # RH=1 + rb = next(sample for sample in self.state[STATE_KEY].samples if sample.command == "RB") + rb.next_due = 3 + return await self.read(3) + + async def test_currents_and_battery_dc_publish_only_when_rh1_and_f(self): + gated = await self._arm_rh1_and_f() + self.assertEqual(gated.values["bms_charging_current"], 0.0) + self.assertEqual(gated.values["bms_discharging_current"], 29.0) + self.assertEqual(gated.values["bms_total_voltage"], 52.0) + # 52.0 V × (0 − 29) = −1508.0 W measured DC + self.assertEqual(gated.values["battery_power"], -1508.0) + self.assertEqual(gated.values["estimated_ac_load_power"], 1569.8) + self.assertNotIn("load_power", gated.values) + self.assertNotEqual( + gated.values["battery_power"], gated.values["estimated_ac_load_power"], + ) + + # Keep RB from refreshing so the ~60 s OptionalSample TTL can fire alone. + rb = next(sample for sample in self.state[STATE_KEY].samples if sample.command == "RB") + rb.next_due = 10_000 + expired = await self.read(64) + self.assertNotIn("battery_power", expired.values) + self.assertNotIn("bms_charging_current", expired.values) + self.assertNotIn("bms_discharging_current", expired.values) + self.assertNotIn("battery_soc", expired.values) + # F still fresh → estimate remains; MX2 split preserved after BMS expiry. + self.assertEqual(expired.values["estimated_ac_load_power"], 1569.8) + self.assertEqual(expired.values["load_percent"], 13) + + async def test_rh0_and_unread_rh_omit_currents(self): + # Before RH: first RB must omit I/P even with F later. + first = await self.read(0) + self.assertIn("bms_total_voltage", first.values) + self.assertNotIn("bms_charging_current", first.values) + self.assertNotIn("battery_power", first.values) + await self.read(1) # F + # RH=0: still omit after RB refresh. + self.transport.responses["RH"] = _rh(accuracy=0) + await self.read(2) # RH=0 + rb = next(sample for sample in self.state[STATE_KEY].samples if sample.command == "RB") + rb.next_due = 3 + omitted = await self.read(3) + self.assertEqual(omitted.values["bms_total_voltage"], 52.0) + self.assertNotIn("bms_charging_current", omitted.values) + self.assertNotIn("bms_discharging_current", omitted.values) + self.assertNotIn("battery_power", omitted.values) + + async def test_f2_absurd_currents_without_f_never_poison_last_good(self): + # First optional is RB before F/RH. Absurd discharge must not publish or + # stick as keep_previous / last-good once F later arrives. + self.transport.responses["RB"] = _rb(charge_raw=0, discharge_raw=19968) # 1996.8 A + t0 = await self.read(0) + self.assertEqual(t0.values.get("bms_total_voltage"), 52.0) + self.assertNotIn("bms_discharging_current", t0.values) + self.assertNotIn("battery_power", t0.values) + await self.read(1) # F + await self.read(2) # RH=1 + # Held RB sample still has no I (stripped at parse). Force RB refresh: + # with F+RH present, absurd current must hard-reject and leave no poison. + self.transport.responses["RB"] = _rb(charge_raw=0, discharge_raw=19968) + rb = next(sample for sample in self.state[STATE_KEY].samples if sample.command == "RB") + rb.next_due = 3 + rejected = await self.read(3) + self.assertIn("RB=rejected", rejected.diagnostics["short_ascii_optional_status"]) + self.assertNotIn("bms_discharging_current", rejected.values) + self.assertNotIn("battery_power", rejected.values) + # Prior good sample had V/SoC only — still no I/P poison. + self.assertEqual(rejected.values.get("bms_total_voltage"), 52.0) + self.assertNotIn("battery_power", self.state[STATE_KEY].rb_filter.last_good) + + async def test_hard_reject_absurd_current_when_keys_and_ratings_present(self): + await self._arm_rh1_and_f() + # Sane V, absurd discharge (~384.4 A after /10) above F VA/Vbat ≈ 251.6 A. + self.transport.responses["RB"] = _rb(charge_raw=0, discharge_raw=3844) + rb = next(sample for sample in self.state[STATE_KEY].samples if sample.command == "RB") + rb.next_due = 33 + rejected = await self.read(33) + self.assertIn("RB=rejected", rejected.diagnostics["short_ascii_optional_status"]) + self.assertEqual(rejected.diagnostics["rb_hard_reject_count"], 1) + # Prior good gated sample retained for remaining ~60 s TTL (not 180 s hold). + self.assertEqual(rejected.values["battery_power"], -1508.0) + self.assertEqual(rejected.values["bms_discharging_current"], 29.0) + # Direct gate still names the live key. + parsed = parse_rb(_rb(charge_raw=0, discharge_raw=3844)) + parsed.update(battery_dc_power_values(parsed)) + self.assertEqual( + hard_reject_reason( + parsed, + rated_voltage=115.0, rated_current=105.0, rated_battery_voltage=48.0, + ), + "current", + ) + + async def test_schema_entities_disabled_by_default_and_estimate_untouched(self): + schema = load_register_schema(self.driver.register_schema_name) + for key in ("bms_charging_current", "bms_discharging_current", "battery_power"): + description = schema.measurement_description(key) + self.assertFalse(description.enabled_default) + self.assertEqual(description.unit, "A" if "current" in key else "W") + estimate = schema.measurement_description("estimated_ac_load_power") + self.assertTrue(estimate.enabled_default) + self.assertEqual(estimate.name, "Estimated AC Load Power") + keys = {item.key for item in schema.measurement_descriptions} + self.assertNotIn("load_power", keys) + self.assertNotIn("load_power_source", keys) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_short_ascii_estimates.py b/tests/test_short_ascii_estimates.py new file mode 100644 index 0000000..0a5f329 --- /dev/null +++ b/tests/test_short_ascii_estimates.py @@ -0,0 +1,115 @@ +"""Labelled AC-load estimate: load% × rated VA, never measured active power.""" + +from __future__ import annotations + +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "tests")] + +from test_eybond_short_ascii import _Transport, _responses +from test_short_ascii_optional import _rb, _rh +from custom_components.eybond_local.drivers.eybond_short_ascii import EybondShortAsciiDriver +from custom_components.eybond_local.drivers.short_ascii_estimates import estimated_ac_load_values +from custom_components.eybond_local.metadata.register_schema_loader import load_register_schema +from custom_components.eybond_local.models import ProbeTarget +from custom_components.eybond_local.payload.short_ascii import parse_q1 + + +class EstimatedAcLoadUnitTests(unittest.TestCase): + def test_estimate_is_percent_times_rated_va(self): + self.assertEqual( + estimated_ac_load_values({ + "load_percent": 13, + "short_ascii_rated_voltage": 115, + "short_ascii_rated_current": 105, + }), + {"estimated_ac_load_power": 1569.8}, + ) + + def test_estimate_omitted_without_ratings_or_load_percent(self): + self.assertEqual(estimated_ac_load_values({"load_percent": 13}), {}) + self.assertEqual( + estimated_ac_load_values({ + "short_ascii_rated_voltage": 115, "short_ascii_rated_current": 105, + }), + {}, + ) + self.assertEqual(estimated_ac_load_values({}), {}) + + def test_zero_load_percent_is_zero_estimate_not_missing(self): + self.assertEqual( + estimated_ac_load_values({ + "load_percent": 0, + "short_ascii_rated_voltage": 115, + "short_ascii_rated_current": 105, + }), + {"estimated_ac_load_power": 0.0}, + ) + + +class EstimatedAcLoadDriverTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.driver = EybondShortAsciiDriver() + responses = _responses() | { + "RB": _rb(), "F": b"#115.0 105 48.00 60.0\r", "RH": _rh(accuracy=1), + } + self.transport = _Transport(responses) + self.inverter = await self.driver.async_probe(self.transport, ProbeTarget(767, 255, 1)) + self.state = {} + self.transport.requests.clear() + + async def read(self, now): + return await self.driver.async_read_values( + self.transport, self.inverter, runtime_state=self.state, now_monotonic=now, + ) + + async def test_estimate_labelled_load_percent_kept_no_grid_freq_or_measured_load_power(self): + first = await self.read(0) + self.assertEqual(first.values["load_percent"], 13) + self.assertNotIn("estimated_ac_load_power", first.values) + self.assertNotIn("grid_frequency", first.values) + self.assertNotIn("load_power", first.values) + + second = await self.read(1) + self.assertEqual(second.values["load_percent"], 13) + self.assertEqual(second.values["estimated_ac_load_power"], 1569.8) + self.assertEqual(second.values["output_frequency"], 60) + self.assertNotIn("grid_frequency", second.values) + for key in ("load_power", "load_power_from_percent", "load_power_source", + "output_power", "output_active_power", "rated_apparent_power"): + self.assertNotIn(key, second.values) + + schema = load_register_schema(self.driver.register_schema_name) + description = schema.measurement_description("estimated_ac_load_power") + self.assertEqual(description.name, "Estimated AC Load Power") + self.assertTrue(description.enabled_default) + self.assertEqual(description.unit, "W") + self.assertEqual(description.device_class, "power") + keys = {item.key for item in schema.measurement_descriptions} + self.assertNotIn("grid_frequency", keys) + self.assertNotIn("load_power", keys) + self.assertIn("load_percent", keys) + self.assertIn("output_frequency", keys) + + async def test_q1_parser_still_does_not_alias_or_invent_power(self): + values = parse_q1(_responses()["Q1"]) + self.assertEqual(values["load_percent"], 13) + self.assertEqual(values["output_frequency"], 60) + for key in ("grid_frequency", "estimated_ac_load_power", "load_power"): + self.assertNotIn(key, values) + + async def test_estimate_withdraws_when_f_ratings_expire(self): + await self.read(0) + await self.read(1) + self.assertEqual((await self.read(2)).values["estimated_ac_load_power"], 1569.8) + expired = await self.read(902) + self.assertNotIn("short_ascii_rated_voltage", expired.values) + self.assertNotIn("estimated_ac_load_power", expired.values) + self.assertEqual(expired.values["load_percent"], 13) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_short_ascii_mppt_optional.py b/tests/test_short_ascii_mppt_optional.py new file mode 100644 index 0000000..8693ff4 --- /dev/null +++ b/tests/test_short_ascii_mppt_optional.py @@ -0,0 +1,226 @@ +"""Optional MPPT aux solicitation: documented 0200 only, OptionalSample TTL.""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "tests")] + +from test_eybond_short_ascii import _Transport, _responses +from test_short_ascii_mppt import runtime_frame +from test_short_ascii_optional import _rb, _rh +from custom_components.eybond_local.drivers.eybond_short_ascii import EybondShortAsciiDriver +from custom_components.eybond_local.drivers.short_ascii_mppt_optional import ( + RUNTIME_QUERY_0200, assert_runtime_query_only, values_from_reply, +) +from custom_components.eybond_local.drivers.short_ascii_optional import STATE_KEY +from custom_components.eybond_local.metadata.register_schema_loader import ( + clear_register_schema_loader_cache, + load_register_schema, +) +from custom_components.eybond_local.models import ProbeTarget + + +MPPT_SCHEMA_KEYS = ( + "pv_voltage", + "pv_power", + "mppt_battery_voltage", + "mppt_temperature", + "dc_load_current", + "mppt_work_mode_code", + "mppt_daily_energy", + "mppt_total_energy", + "mppt_error_code", +) + + +def _settings_query() -> bytes: + return b"\x5a\xa5\x02\x02" + bytes(16) + b"\x04" + + +class MpptOptionalHelpersTests(unittest.TestCase): + def test_only_documented_0200_query_is_accepted(self): + assert_runtime_query_only(RUNTIME_QUERY_0200) + self.assertEqual(len(RUNTIME_QUERY_0200), 21) + with self.assertRaises(ValueError): + assert_runtime_query_only(_settings_query()) + with self.assertRaises(ValueError): + assert_runtime_query_only(b"") + + def test_decode_maps_runtime_fields_for_later_schema_keys(self): + values = values_from_reply(runtime_frame().wire) + self.assertEqual(values["pv_voltage"], 120.0) + self.assertEqual(values["pv_power"], 370) + self.assertEqual(values["mppt_battery_voltage"], 51.2) + self.assertEqual(values["mppt_temperature"], 27.8) + self.assertEqual(values["dc_load_current"], 3.1) + self.assertEqual(values["mppt_work_mode_code"], 1) + self.assertEqual(values["mppt_daily_energy"], 2.3) + self.assertEqual(values["mppt_total_energy"], 42.0) + self.assertEqual(values["mppt_error_code"], 0) + for key in ("battery_voltage", "temperature", "load_power", "aabb_last_wire"): + self.assertNotIn(key, values) + for key in MPPT_SCHEMA_KEYS: + self.assertIn(key, values) + + def test_settings_0202_reply_is_not_live_telemetry(self): + with self.assertRaises(ValueError): + values_from_reply(runtime_frame(subtype=0x0202).wire) + + +class MpptSchemaAdmissionTests(unittest.TestCase): + def setUp(self): + clear_register_schema_loader_cache() + self.driver = EybondShortAsciiDriver() + self.schema = load_register_schema(self.driver.register_schema_name) + + def test_mppt_keys_present_and_opt_in(self): + keys = {item.key for item in self.schema.measurement_descriptions} + for key in MPPT_SCHEMA_KEYS: + with self.subTest(key=key): + self.assertIn(key, keys) + description = self.schema.measurement_description(key) + self.assertFalse(description.enabled_default) + + def test_mppt_units_device_classes_and_quiet_error(self): + expected = { + "pv_voltage": ("V", "voltage", "measurement", False), + "pv_power": ("W", "power", "measurement", False), + "mppt_battery_voltage": ("V", "voltage", "measurement", False), + "mppt_temperature": ("°C", "temperature", "measurement", False), + "dc_load_current": ("A", "current", "measurement", False), + "mppt_work_mode_code": (None, None, None, True), + "mppt_daily_energy": ("kWh", "energy", "total_increasing", False), + "mppt_total_energy": ("kWh", "energy", "total_increasing", False), + "mppt_error_code": (None, None, None, True), + } + for key, (unit, device_class, state_class, diagnostic) in expected.items(): + with self.subTest(key=key): + description = self.schema.measurement_description(key) + self.assertEqual(description.unit, unit) + self.assertEqual(description.device_class, device_class) + self.assertEqual(description.state_class, state_class) + self.assertEqual(description.diagnostic, diagnostic) + self.assertFalse(description.enabled_default) + + # Distinct owners: names must not collide with BMS / AC / inverter labels. + self.assertEqual( + self.schema.measurement_description("mppt_battery_voltage").name, + "MPPT Battery Voltage", + ) + self.assertEqual( + self.schema.measurement_description("dc_load_current").name, + "MPPT DC Load Current", + ) + self.assertEqual( + self.schema.measurement_description("mppt_temperature").name, + "MPPT Temperature", + ) + self.assertNotEqual( + self.schema.measurement_description("mppt_battery_voltage").name, + self.schema.measurement_description("bms_total_voltage").name, + ) + self.assertNotEqual( + self.schema.measurement_description("mppt_temperature").name, + self.schema.measurement_description("temperature").name, + ) + + +class MpptOptionalReadTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.driver = EybondShortAsciiDriver() + responses = _responses() | { + "RB": _rb(), "F": b"#115.0 105 48.00 60.0\r", "RH": _rh(accuracy=1), + } + self.transport = _Transport( + responses, aux_responses={RUNTIME_QUERY_0200: runtime_frame().wire}, + ) + self.inverter = await self.driver.async_probe(self.transport, ProbeTarget(767, 255, 1)) + self.state = {} + self.transport.requests.clear() + self.transport.aux_requests.clear() + + async def read(self, now): + return await self.driver.async_read_values( + self.transport, self.inverter, runtime_state=self.state, now_monotonic=now, + ) + + async def _prime_through_rh(self): + await self.read(0) + await self.read(1) + await self.read(2) + self.transport.aux_requests.clear() + + async def test_solicits_documented_0200_only_after_rb_f_rh_slot(self): + await self._prime_through_rh() + result = await self.read(3) + self.assertEqual(self.transport.aux_requests, [RUNTIME_QUERY_0200]) + self.assertNotIn(_settings_query(), self.transport.aux_requests) + self.assertEqual(result.values["pv_voltage"], 120.0) + self.assertEqual(result.values["pv_power"], 370) + self.assertIn("MPPT=ok", result.diagnostics["short_ascii_optional_status"]) + + async def test_ttl_expiry_clears_mppt_without_tip_harvest_fields(self): + await self._prime_through_rh() + await self.read(3) + expired = await self.read(64) + self.assertNotIn("pv_voltage", expired.values) + self.assertNotIn("pv_power", expired.values) + self.assertNotIn("short_ascii_mppt_age_seconds", expired.diagnostics) + self.assertIn("MPPT=expired", expired.diagnostics["short_ascii_optional_status"]) + for key in expired.values: + self.assertFalse(key.startswith("aabb_last_")) + + async def test_never_sends_settings_0202_even_when_aux_configured(self): + self.transport.aux_responses[_settings_query()] = runtime_frame(subtype=0x0202).wire + await self._prime_through_rh() + await self.read(3) + self.assertEqual(self.transport.aux_requests, [RUNTIME_QUERY_0200]) + + async def test_no_tip_harvest_or_waiter_state(self): + await self._prime_through_rh() + result = await self.read(3) + reads = self.state[STATE_KEY] + self.assertFalse(hasattr(reads, "aabb_last_wire")) + self.assertFalse(hasattr(reads, "aabb_waiter")) + for key in result.values: + self.assertFalse(key.startswith("aabb_last_")) + self.assertFalse(any("harvest" in key for key in result.diagnostics)) + + async def test_mandatory_failure_clears_mppt_sample(self): + await self._prime_through_rh() + await self.read(3) + self.assertIn("pv_voltage", (await self.read(4)).values) + self.transport.responses["Q1"] = b"NAK\r" + with self.assertRaises(Exception): + await self.read(5) + self.assertTrue(all(not sample.values for sample in self.state[STATE_KEY].samples)) + + async def test_binding_change_drops_mppt_without_reuse(self): + await self._prime_through_rh() + await self.read(3) + self.transport.requests.clear() + self.transport.aux_requests.clear() + self.inverter = replace(self.inverter) + result = await self.read(4) + self.assertNotIn("pv_voltage", result.values) + # New binding restarts optional schedule at FC4 RB; no MPPT aux yet. + self.assertIn(b"RB\x01\r", self.transport.requests) + self.assertEqual(self.transport.aux_requests, []) + + async def test_disconnect_during_mppt_is_link_failure_and_clears(self): + await self._prime_through_rh() + self.transport.aux_responses[RUNTIME_QUERY_0200] = asyncio.TimeoutError() + self.transport.connected = False + with self.assertRaises(ConnectionError): + await self.read(3) + self.assertTrue(all(not sample.values for sample in self.state[STATE_KEY].samples)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_short_ascii_optional.py b/tests/test_short_ascii_optional.py index 2f69fbc..303bdcc 100644 --- a/tests/test_short_ascii_optional.py +++ b/tests/test_short_ascii_optional.py @@ -12,19 +12,21 @@ sys.path[:0] = [str(ROOT), str(ROOT / "tests")] from test_eybond_short_ascii import _Transport, _responses +from test_short_ascii_mppt import runtime_frame from custom_components.eybond_local.drivers.command_support import ( clear_unsupported_commands, seed_unsupported_commands, unsupported_commands, ) from custom_components.eybond_local.drivers.eybond_short_ascii import EybondShortAsciiDriver from custom_components.eybond_local.drivers.read_result import DriverReadMode +from custom_components.eybond_local.drivers.short_ascii_mppt_optional import RUNTIME_QUERY_0200 from custom_components.eybond_local.drivers.short_ascii_optional import OptionalReads, OptionalSample, STATE_KEY from custom_components.eybond_local.models import ProbeTarget -from custom_components.eybond_local.payload.short_ascii import ShortAsciiError, parse_f, parse_rb +from custom_components.eybond_local.payload.short_ascii import ShortAsciiError, parse_f, parse_rb, parse_rh -def _rb(*, voltage=520, soc=80): +def _rb(*, voltage=520, soc=80, charge_raw=30, discharge_raw=0): body = bytearray(37) - for offset, value in ((0, voltage), (3, 30), (5, 0), (7, 250), (9, 13), + for offset, value in ((0, voltage), (3, charge_raw), (5, discharge_raw), (7, 250), (9, 13), (11, 300), (13, 270), (15, 335), (17, 330), (19, 448), (21, 560)): body[offset:offset + 2] = value.to_bytes(2, "big") body[2], body[23], body[24] = soc, 1, 1 @@ -35,6 +37,13 @@ def _rb_body(body): return b"\x01" + bytes(body) + bytes([sum(body) & 255]) + b"\r" +def _rh(*, accuracy=1): + """30-byte RH settings frame (live proxy + 19B4 segment-5); accuracy at body[8].""" + body = bytearray(27) + body[8] = accuracy + return b"\x01" + bytes(body) + bytes([sum(body) & 255]) + b"\r" + + class OptionalPayloadTests(unittest.TestCase): def test_f_has_ratings_not_instantaneous_power(self): self.assertEqual(parse_f(b"#115.0 105 48.00 60.0\r"), { @@ -46,10 +55,13 @@ def test_f_has_ratings_not_instantaneous_power(self): with self.assertRaises(ShortAsciiError): parse_f(wire) - def test_rb_documented_fields_without_guessing_current_scale_or_battery_power(self): + def test_rb_documented_current_scale_without_inventing_battery_power_in_parser(self): values = parse_rb(_rb()) self.assertEqual(values["bms_total_voltage"], 52) self.assertEqual(values["battery_soc"], 80) + # 19B4 multiply=0.1 — raw 30 / 10 = 3.0 A charge, 0 discharge. + self.assertEqual(values["bms_charging_current"], 3.0) + self.assertEqual(values["bms_discharging_current"], 0.0) self.assertEqual(values["bms_cell_temperature"], 25) self.assertEqual(values["bms_cycle_count"], 13) self.assertEqual(values["bms_internal_temperature"], 30) @@ -58,10 +70,41 @@ def test_rb_documented_fields_without_guessing_current_scale_or_battery_power(se self.assertEqual(values["bms_min_cell_voltage"], 3.30) self.assertEqual(values["bms_low_protection_voltage"], 44.8) self.assertEqual(values["bms_charge_cutoff_voltage"], 56) - for key in ("battery_voltage", "battery_current", "battery_power", "load_power", "bms_charging_current"): + # Measured DC watts are derived outside parse_rb (additive module). + for key in ("battery_voltage", "battery_current", "battery_power", "load_power"): self.assertNotIn(key, values) self.assertIs(values["short_ascii_bms_charge_path_enabled"], True) + def test_rh_parses_display_accuracy_and_rejects_corrupt_frames(self): + self.assertEqual(parse_rh(_rh(accuracy=1)), { + "short_ascii_bms_current_display_accuracy": 1, + }) + self.assertEqual(parse_rh(_rh(accuracy=0)), { + "short_ascii_bms_current_display_accuracy": 0, + }) + # Live proxy capture (RH=1, remaining settings zero). + live = bytes.fromhex( + "01000000000000000001000000000000000000000000000000000000010d" + ) + self.assertEqual(parse_rh(live)["short_ascii_bms_current_display_accuracy"], 1) + for wire in (_rh()[:-1], _rh() + b"\r", b"", b"NAK\r", bytearray(_rh())): + with self.assertRaises(ShortAsciiError): + parse_rh(wire) + bad_enum = bytearray(_rh()) + bad_enum[9] = 2 + bad_enum[-2] = sum(bad_enum[1:-2]) & 255 + with self.assertRaises(ShortAsciiError): + parse_rh(bytes(bad_enum)) + wire = bytearray(_rh()) + wire[-2] ^= 1 + with self.assertRaises(ShortAsciiError): + parse_rh(bytes(wire)) + + def test_rb_current_scale_matches_documented_decimal_fields(self): + values = parse_rb(_rb(charge_raw=294, discharge_raw=1510)) + self.assertEqual(values["bms_charging_current"], 29.4) + self.assertEqual(values["bms_discharging_current"], 151.0) + def test_rb_missing_primary_measurements_withdraws_even_nonzero_trailing_fields(self): self.assertEqual(parse_rb(_rb(voltage=0, soc=0)), {"short_ascii_bms_data_available": False}) self.assertEqual(parse_rb(_rb(voltage=520, soc=0))["battery_soc"], 0) @@ -103,35 +146,63 @@ def test_sample_ttl_is_exclusive_and_never_extends_on_read(self): class OptionalReadTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.driver = EybondShortAsciiDriver() - responses = _responses() | {"RB": _rb(), "F": b"#115.0 105 48.00 60.0\r"} - self.transport = _Transport(responses) + responses = _responses() | { + "RB": _rb(), "F": b"#115.0 105 48.00 60.0\r", "RH": _rh(accuracy=1), + } + self.transport = _Transport( + responses, aux_responses={RUNTIME_QUERY_0200: runtime_frame().wire}, + ) self.inverter = await self.driver.async_probe(self.transport, ProbeTarget(767, 255, 1)) self.state = {} self.transport.requests.clear() + self.transport.aux_requests.clear() async def read(self, now): return await self.driver.async_read_values( self.transport, self.inverter, runtime_state=self.state, now_monotonic=now, ) + async def _prime_rb_f_rh(self): + """RB→F→RH with MPPT suppressed so later RB refreshes stay on schedule.""" + seed_unsupported_commands(self.state, ("short_ascii:MPPT",)) + await self.read(0) + await self.read(1) + await self.read(2) + async def test_one_optional_query_per_cycle_fair_scheduling_and_full_snapshot(self): first = await self.read(0) self.assertEqual(first.mode, DriverReadMode.FULL) self.assertEqual(first.values["bms_total_voltage"], 52) self.assertEqual(first.values["battery_reference_voltage"], 13.2) self.assertNotIn("short_ascii_rated_voltage", first.values) + # Currents withheld until RH=1 and F (F2 / T5.S3.W). + self.assertNotIn("bms_charging_current", first.values) second = await self.read(1) self.assertEqual(second.values["short_ascii_rated_voltage"], 115) self.assertEqual(second.values["battery_soc"], 80) third = await self.read(2) self.assertEqual(third.values["battery_soc"], 80) - self.assertEqual(self.transport.requests, - [b"Q1\x01\r", b"RB\x01\r", b"Q1\x01\r", b"F\x01\r", b"Q1\x01\r"]) - self.assertGreater(third.diagnostics["short_ascii_rb_age_seconds"], 1.9) - self.assertLess(third.diagnostics["short_ascii_rb_age_seconds"], 2.1) + # RH is gate-only: accuracy must not appear as a sensor key. + self.assertNotIn("short_ascii_bms_current_display_accuracy", third.values) + fourth = await self.read(3) + self.assertEqual(fourth.values["battery_soc"], 80) + self.assertEqual(fourth.values["pv_voltage"], 120.0) + self.assertEqual(fourth.values["pv_power"], 370) + self.assertEqual( + self.transport.requests, + [ + b"Q1\x01\r", b"RB\x01\r", + b"Q1\x01\r", b"F\x01\r", + b"Q1\x01\r", b"RH\x01\r", + b"Q1\x01\r", + ], + ) + self.assertEqual(self.transport.aux_requests, [RUNTIME_QUERY_0200]) + self.assertGreater(fourth.diagnostics["short_ascii_rb_age_seconds"], 2.9) + self.assertLess(fourth.diagnostics["short_ascii_rb_age_seconds"], 3.1) async def test_rb_timeout_invalidates_immediately_but_retains_current_q1_and_f(self): - await self.read(0); await self.read(1) + await self._prime_rb_f_rh() self.transport.responses["RB"] = asyncio.TimeoutError() result = await self.read(31) self.assertNotIn("battery_soc", result.values) @@ -139,34 +210,42 @@ async def test_rb_timeout_invalidates_immediately_but_retains_current_q1_and_f(s self.assertNotIn("short_ascii_bms_data_available", result.values) self.assertEqual(result.values["grid_voltage"], 230) self.assertEqual(result.values["short_ascii_rated_voltage"], 115) + self.assertNotIn("pv_voltage", result.values) self.assertIn("RB=timeout", result.diagnostics["short_ascii_optional_status"]) self.assertNotIn("short_ascii_rb_age_seconds", result.diagnostics) self.assertNotIn("battery_soc", (await self.read(32)).values) - async def test_no_data_reply_withdraws_all_bms_values_and_can_recover_to_zero_soc(self): - await self.read(0); await self.read(1) + async def test_link_loss_holds_last_good_then_recovers_to_zero_soc(self): + await self._prime_rb_f_rh() self.transport.responses["RB"] = _rb(voltage=0, soc=0) - result = await self.read(31) - self.assertFalse(result.values["short_ascii_bms_data_available"]) - self.assertFalse(any(key.startswith("bms_") for key in result.values)) - self.assertNotIn("battery_soc", result.values) + held = await self.read(31) + self.assertEqual(held.values["battery_soc"], 80) + self.assertEqual(held.values["bms_total_voltage"], 52) + self.assertTrue(held.values["short_ascii_bms_data_available"]) + self.assertIn("RB=link_loss_hold", held.diagnostics["short_ascii_optional_status"]) + self.assertGreater(held.diagnostics["short_ascii_rb_age_seconds"], 30) self.transport.responses["RB"] = _rb(soc=0) recovered = await self.read(62) self.assertEqual(recovered.values["battery_soc"], 0) self.assertTrue(recovered.values["short_ascii_bms_data_available"]) async def test_missing_rb_does_not_block_f_and_repeated_failures_use_existing_recheck(self): + seed_unsupported_commands(self.state, ("short_ascii:MPPT",)) self.transport.responses["RB"] = b"NAK\r" for now in (0, 1, 31, 62, 93, 124): result = await self.read(now) self.assertEqual(result.values["grid_voltage"], 230) self.assertEqual(self.transport.requests.count(b"RB\x01\r"), 4) self.assertEqual(self.transport.requests.count(b"F\x01\r"), 1) - self.assertEqual(unsupported_commands(self.state), ("short_ascii:RB",)) + self.assertEqual(self.transport.requests.count(b"RH\x01\r"), 1) + self.assertEqual(self.transport.aux_requests, []) + self.assertEqual(unsupported_commands(self.state), ("short_ascii:MPPT", "short_ascii:RB")) self.assertIn("short_ascii:RB", result.diagnostics["driver_unsupported_commands"]) + # One skipped cycle so outcome becomes unsupported; clear then re-enable. + await self.read(125) clear_unsupported_commands(self.state) self.transport.responses["RB"] = _rb() - self.assertEqual((await self.read(125)).values["battery_soc"], 80) + self.assertEqual((await self.read(126)).values["battery_soc"], 80) self.assertEqual(unsupported_commands(self.state), ()) async def test_mandatory_failure_or_cancellation_clears_samples_without_negative_strikes(self): @@ -185,11 +264,11 @@ async def test_mandatory_failure_or_cancellation_clears_samples_without_negative async def test_optional_cancellation_or_connection_loss_does_not_charge_an_unsupported_strike(self): for failure in (asyncio.CancelledError(), ConnectionError()): await self.asyncSetUp() - await self.read(0); await self.read(1) + await self._prime_rb_f_rh() self.transport.responses["RB"] = failure with self.assertRaises(type(failure)): await self.read(31) - self.assertEqual(unsupported_commands(self.state), ()) + self.assertEqual(unsupported_commands(self.state), ("short_ascii:MPPT",)) self.assertTrue(all(not sample.values for sample in self.state[STATE_KEY].samples)) async def test_runtime_state_rebinding_and_clock_rollback_never_reuse_samples(self): @@ -197,20 +276,31 @@ async def test_runtime_state_rebinding_and_clock_rollback_never_reuse_samples(se await self.asyncSetUp() await self.read(10); await self.read(11) if change == "transport": - self.transport = _Transport(_responses() | {"RB": b"NAK\r"}) + self.transport = _Transport( + _responses() | {"RB": b"NAK\r"}, + aux_responses={RUNTIME_QUERY_0200: runtime_frame().wire}, + ) elif change == "inverter": self.inverter = replace(self.inverter) self.transport.responses["RB"] = b"NAK\r" result = await self.read(0 if change == "clock" else 12) self.assertNotIn("battery_soc", result.values) self.assertNotIn("short_ascii_rated_voltage", result.values) + self.assertNotIn("pv_voltage", result.values) async def test_persisted_unsupported_group_is_not_queried_or_projected(self): - seed_unsupported_commands(self.state, ("short_ascii:RB", "short_ascii:F")) + seed_unsupported_commands(self.state, ( + "short_ascii:RB", "short_ascii:F", "short_ascii:RH", "short_ascii:MPPT", + )) result = await self.read(0) self.assertEqual(self.transport.requests, [b"Q1\x01\r"]) + self.assertEqual(self.transport.aux_requests, []) self.assertNotIn("battery_soc", result.values) - self.assertEqual(result.diagnostics["short_ascii_optional_status"], "RB=unsupported; F=unsupported") + self.assertNotIn("pv_voltage", result.values) + self.assertEqual( + result.diagnostics["short_ascii_optional_status"], + "RB=unsupported; F=unsupported; RH=unsupported; MPPT=unsupported", + ) async def test_failed_optional_request_on_disconnected_transport_is_a_link_failure(self): await self.read(0) @@ -222,7 +312,7 @@ async def test_failed_optional_request_on_disconnected_transport_is_a_link_failu self.assertTrue(all(not sample.values for sample in self.state[STATE_KEY].samples)) async def test_corrupt_optional_response_removes_previous_sample_without_partial_values(self): - await self.read(0); await self.read(1) + await self._prime_rb_f_rh() wire = bytearray(_rb()); wire[-2] ^= 1 self.transport.responses["RB"] = bytes(wire) result = await self.read(31) @@ -234,7 +324,7 @@ async def test_corrupt_optional_response_removes_previous_sample_without_partial async def test_sample_can_expire_while_another_group_is_awaited(self): clock = [59.0] reads = OptionalReads(self.transport, self.inverter, 59) - rb, rated = reads.samples + rb, rated, _rh_sample, _mppt = reads.samples rb.values, rb.sampled_at, rb.next_due = {"battery_soc": 80}, 0, 100 class Session: diff --git a/tests/test_short_ascii_rb_filter.py b/tests/test_short_ascii_rb_filter.py new file mode 100644 index 0000000..e330478 --- /dev/null +++ b/tests/test_short_ascii_rb_filter.py @@ -0,0 +1,406 @@ +"""ADR 0003: RB hard-rejects + dual TTL (60 s optional vs 180 s link-loss hold).""" + +from __future__ import annotations + +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "tests")] + +from test_eybond_short_ascii import _Transport, _responses +from test_short_ascii_optional import _rb, _rh +from custom_components.eybond_local.drivers.eybond_short_ascii import EybondShortAsciiDriver +from custom_components.eybond_local.drivers.short_ascii_optional import STATE_KEY +from custom_components.eybond_local.drivers.short_ascii_rb_filter import ( + LINK_LOSS_HOLD_S, + PACK_VOLTAGE_MAX_V, + PACK_VOLTAGE_MIN_V, + RbPublishFilter, + hard_reject_reason, + is_link_loss_signature, +) +from custom_components.eybond_local.models import ProbeTarget +from custom_components.eybond_local.payload.short_ascii import parse_rb + + +def _good_rb_values(**overrides): + values = { + "short_ascii_bms_data_available": True, + "bms_total_voltage": 52.0, + "battery_soc": 80, + } + values.update(overrides) + return values + + +class HardRejectUnitTests(unittest.TestCase): + def test_corrupt_pack_voltage_rejected(self): + self.assertEqual(hard_reject_reason(_good_rb_values(bms_total_voltage=16.0)), "pack_voltage") + self.assertEqual(hard_reject_reason(_good_rb_values(bms_total_voltage=1230.1)), "pack_voltage") + self.assertEqual( + hard_reject_reason(_good_rb_values(bms_total_voltage=PACK_VOLTAGE_MIN_V - 0.1)), + "pack_voltage", + ) + self.assertEqual( + hard_reject_reason(_good_rb_values(bms_total_voltage=PACK_VOLTAGE_MAX_V + 0.1)), + "pack_voltage", + ) + + def test_soc_zero_with_sane_voltage_publishes(self): + self.assertIsNone(hard_reject_reason(_good_rb_values(battery_soc=0, bms_total_voltage=52.0))) + + def test_soc_out_of_range_rejected(self): + self.assertEqual(hard_reject_reason(_good_rb_values(battery_soc=101)), "soc") + self.assertEqual(hard_reject_reason(_good_rb_values(battery_soc=-1)), "soc") + + def test_power_between_one_and_three_va_publishes_above_three_rejects(self): + ratings = dict(rated_voltage=115.0, rated_current=105.0, rated_battery_voltage=48.0) + rated_va = 115.0 * 105.0 + self.assertIsNone(hard_reject_reason( + _good_rb_values(battery_power=rated_va * 2.0), **ratings, + )) + self.assertIsNone(hard_reject_reason( + _good_rb_values(battery_power=-(rated_va * 2.9)), **ratings, + )) + self.assertEqual( + hard_reject_reason(_good_rb_values(battery_power=rated_va * 3.01), **ratings), + "power", + ) + + def test_current_above_inverter_limit_rejected_when_ratings_present(self): + ratings = dict(rated_voltage=115.0, rated_current=105.0, rated_battery_voltage=48.0) + # F VA / F Vbat ≈ 251.6 A + self.assertIsNone(hard_reject_reason( + _good_rb_values(bms_discharging_current=250.0), **ratings, + )) + self.assertEqual( + hard_reject_reason(_good_rb_values(bms_discharging_current=260.0), **ratings), + "current", + ) + self.assertEqual( + hard_reject_reason(_good_rb_values(bms_charging_current=300.0), **ratings), + "current", + ) + + def test_fingerprint_2203_dies_on_voltage_not_soc_or_power_band(self): + # SoC 0 + 16 V + |P|≈28.7 kW (< 3×12 kVA). Must die on V. + values = _good_rb_values( + battery_soc=0, bms_total_voltage=16.0, battery_power=-28740.8, + bms_charging_current=2048.0, bms_discharging_current=3844.3, + ) + ratings = dict(rated_voltage=115.0, rated_current=105.0, rated_battery_voltage=48.0) + self.assertEqual(hard_reject_reason(values, **ratings), "pack_voltage") + + +class LinkLossFilterUnitTests(unittest.TestCase): + def test_parse_rb_link_loss_is_signature(self): + parsed = parse_rb(_rb(voltage=0, soc=0)) + self.assertTrue(is_link_loss_signature(parsed)) + + def test_hold_caps_at_180s_from_last_good_without_refresh(self): + filt = RbPublishFilter() + good = parse_rb(_rb()) + first = filt.decide(good, now=10.0) + self.assertEqual(first.outcome, "ok") + self.assertTrue(first.refresh_sampled_at) + self.assertEqual(filt.last_good_at, 10.0) + + held = filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=40.0) + self.assertEqual(held.outcome, "link_loss_hold") + self.assertTrue(held.keep_previous) + self.assertFalse(held.refresh_sampled_at) + # Cap is from last-good time, not from this link-loss frame. + self.assertEqual(filt.hold_until, 10.0 + LINK_LOSS_HOLD_S) + self.assertEqual(held.values["battery_soc"], 80) + + later = filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=100.0) + self.assertEqual(later.outcome, "link_loss_hold") + self.assertEqual(filt.hold_until, 10.0 + LINK_LOSS_HOLD_S) + + past = filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=10.0 + LINK_LOSS_HOLD_S) + self.assertEqual(past.outcome, "no_data") + self.assertFalse(filt.last_good) + self.assertIsNone(filt.last_good_at) + self.assertIsNone(filt.hold_until) + + def test_link_loss_count_increments_on_streak_entry_not_each_poll(self): + filt = RbPublishFilter() + filt.decide(parse_rb(_rb()), now=0.0) + self.assertEqual(filt.bms_link_loss_count, 0) + self.assertEqual(filt.rb_hard_reject_count, 0) + self.assertEqual(filt.reading_hold_pending_count, 0) + + first = filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=10.0) + self.assertEqual(first.outcome, "link_loss_hold") + self.assertEqual(filt.bms_link_loss_count, 1) + # Same streak: continuous link-loss frames must not re-count. + for t in (40.0, 70.0, 100.0): + self.assertEqual( + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=t).outcome, + "link_loss_hold", + ) + self.assertEqual(filt.bms_link_loss_count, 1) + # Hold expired; still link-loss signature — same streak, still 1. + self.assertEqual( + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=0.0 + LINK_LOSS_HOLD_S).outcome, + "no_data", + ) + self.assertEqual(filt.bms_link_loss_count, 1) + # Recover then drop again → second transition. + filt.decide(parse_rb(_rb()), now=200.0) + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=210.0) + self.assertEqual(filt.bms_link_loss_count, 2) + self.assertEqual(filt.rb_hard_reject_count, 0) + self.assertEqual(filt.reading_hold_pending_count, 0) + self.assertEqual( + filt.diagnostic_counters(), + { + "bms_link_loss_count": 2, + "rb_hard_reject_count": 0, + "reading_hold_pending_count": 0, + }, + ) + + def test_link_loss_without_last_good_still_counts_once(self): + filt = RbPublishFilter() + self.assertEqual( + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=1.0).outcome, + "no_data", + ) + self.assertEqual(filt.bms_link_loss_count, 1) + self.assertEqual( + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=2.0).outcome, + "no_data", + ) + self.assertEqual(filt.bms_link_loss_count, 1) + + def test_hard_reject_increments_reject_count_not_link_loss_or_hold_pending(self): + filt = RbPublishFilter() + filt.decide(parse_rb(_rb()), now=0.0) + bad = parse_rb(_rb(voltage=160, soc=0)) # 16.0 V + self.assertEqual(filt.decide(bad, now=30.0).outcome, "rejected") + self.assertEqual(filt.bms_link_loss_count, 0) + self.assertEqual(filt.rb_hard_reject_count, 1) + self.assertEqual(filt.reading_hold_pending_count, 0) + # Second reject bumps again. + self.assertEqual(filt.decide(bad, now=31.0).outcome, "rejected") + self.assertEqual(filt.rb_hard_reject_count, 2) + + def test_hard_reject_mid_link_loss_does_not_re_count_streak(self): + filt = RbPublishFilter() + filt.decide(parse_rb(_rb()), now=0.0) + self.assertEqual( + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=10.0).outcome, + "link_loss_hold", + ) + self.assertEqual(filt.bms_link_loss_count, 1) + bad = parse_rb(_rb(voltage=160, soc=0)) # 16.0 V junk mid-dropout + self.assertEqual(filt.decide(bad, now=20.0).outcome, "rejected") + self.assertEqual(filt.rb_hard_reject_count, 1) + # Still same streak after junk — must not bump link-loss count. + self.assertEqual( + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=30.0).outcome, + "link_loss_hold", + ) + self.assertEqual(filt.bms_link_loss_count, 1) + + def test_clear_mid_link_loss_streak_does_not_double_count(self): + # Envelope/checksum path calls clear(); streak flag must survive. + filt = RbPublishFilter() + filt.decide(parse_rb(_rb()), now=0.0) + self.assertEqual( + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=10.0).outcome, + "link_loss_hold", + ) + self.assertEqual(filt.bms_link_loss_count, 1) + filt.clear() + self.assertFalse(filt.last_good) + self.assertIsNone(filt.hold_until) + # Continued link-loss after clear is still the same streak. + self.assertEqual( + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=20.0).outcome, + "no_data", + ) + self.assertEqual(filt.bms_link_loss_count, 1) + # True ok recovery ends the streak; next dropout is a new transition. + filt.decide(parse_rb(_rb()), now=30.0) + filt.decide(parse_rb(_rb(voltage=0, soc=0)), now=40.0) + self.assertEqual(filt.bms_link_loss_count, 2) + + def test_hard_reject_does_not_start_hold_or_replace_last_good(self): + filt = RbPublishFilter() + filt.decide(parse_rb(_rb()), now=0.0) + bad = parse_rb(_rb(voltage=160, soc=0)) # 16.0 V + self.assertEqual(bad["bms_total_voltage"], 16.0) + decision = filt.decide(bad, now=30.0) + self.assertEqual(decision.outcome, "rejected") + self.assertIsNone(filt.hold_until) + self.assertEqual(filt.last_good["bms_total_voltage"], 52.0) + + +class RbFilterDriverTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.driver = EybondShortAsciiDriver() + responses = _responses() | { + "RB": _rb(), "F": b"#115.0 105 48.00 60.0\r", "RH": _rh(accuracy=1), + } + self.transport = _Transport(responses) + self.inverter = await self.driver.async_probe(self.transport, ProbeTarget(767, 255, 1)) + self.state = {} + self.transport.requests.clear() + + async def read(self, now): + return await self.driver.async_read_values( + self.transport, self.inverter, runtime_state=self.state, now_monotonic=now, + ) + + async def test_impossible_voltage_after_good_envelope_is_not_published(self): + for raw_voltage, decoded in ((160, 16.0), (12301, 1230.1)): + with self.subTest(decoded_v=decoded): + await self.asyncSetUp() + await self.read(0); await self.read(1); await self.read(2) + self.assertEqual((await self.read(3)).values.get("bms_total_voltage"), 52) + self.transport.responses["RB"] = _rb(voltage=raw_voltage, soc=0 if raw_voltage == 160 else 1) + rejected = await self.read(31) + self.assertNotEqual(rejected.values.get("bms_total_voltage"), decoded) + self.assertEqual(rejected.values.get("bms_total_voltage"), 52) + self.assertEqual(rejected.values.get("battery_soc"), 80) + self.assertIn("RB=rejected", rejected.diagnostics["short_ascii_optional_status"]) + + async def test_soc_zero_with_sane_voltage_publishes(self): + await self.read(0); await self.read(1); await self.read(2) + self.transport.responses["RB"] = _rb(soc=0) + result = await self.read(31) + self.assertEqual(result.values["battery_soc"], 0) + self.assertEqual(result.values["bms_total_voltage"], 52) + self.assertTrue(result.values["short_ascii_bms_data_available"]) + + async def test_link_loss_hold_keeps_last_good_stale_until_180s(self): + await self.read(0); await self.read(1); await self.read(2) + self.transport.responses["RB"] = _rb(voltage=0, soc=0) + held = await self.read(31) + self.assertEqual(held.values["battery_soc"], 80) + self.assertGreaterEqual(held.diagnostics["short_ascii_rb_age_seconds"], 30) + # Avoid further RB polls; dual-clock only (hold capped from last-good ~0). + rb = self.state[STATE_KEY].samples[0] + rb.next_due = 10_000 + # Past optional 60 s TTL but inside 180 s hold: still present, still stale. + still = await self.read(100) + self.assertEqual(still.values["battery_soc"], 80) + self.assertGreater(still.diagnostics["short_ascii_rb_age_seconds"], 60) + # last_good_at ≈ 0 → hold_until 180; after that values must clear. + expired = await self.read(181) + self.assertNotIn("battery_soc", expired.values) + self.assertNotIn("bms_total_voltage", expired.values) + + async def test_ttl_expiry_clears_last_good_so_link_loss_cannot_resurrect(self): + # critical regression: TTL must kill last_good; later link-loss must not + # republish with sampled_at is None. + await self.read(0); await self.read(1); await self.read(2) + rb = self.state[STATE_KEY].samples[0] + rb.next_due = 10_000 + expired = await self.read(61) + self.assertNotIn("battery_soc", expired.values) + self.assertIsNone(rb.sampled_at) + self.assertFalse(self.state[STATE_KEY].rb_filter.last_good) + + self.transport.responses["RB"] = _rb(voltage=0, soc=0) + rb.next_due = 62 + resurrected = await self.read(62) + self.assertNotIn("battery_soc", resurrected.values) + self.assertNotIn("bms_total_voltage", resurrected.values) + # Never publish held BMS fields without a freshness clock. + self.assertNotIn("battery_soc", rb.values) + self.assertFalse(self.state[STATE_KEY].rb_filter.last_good) + self.assertNotIn("RB=link_loss_hold", resurrected.diagnostics["short_ascii_optional_status"]) + + async def test_continuous_link_loss_stops_after_180s_from_last_good(self): + # serious regression: every link-loss frame must not sticky-extend hold. + await self.read(0); await self.read(1); await self.read(2) + self.transport.responses["RB"] = _rb(voltage=0, soc=0) + self.assertEqual((await self.read(31)).values.get("battery_soc"), 80) + for t in (61, 91, 121, 151): + still = await self.read(t) + self.assertEqual(still.values.get("battery_soc"), 80, msg=f"t={t}") + self.assertIn("RB=link_loss_hold", still.diagnostics["short_ascii_optional_status"]) + gone = await self.read(181) + self.assertNotIn("battery_soc", gone.values) + self.assertNotIn("bms_total_voltage", gone.values) + + async def test_checksum_fail_drops_without_starting_hold(self): + await self.read(0); await self.read(1); await self.read(2) + wire = bytearray(_rb()) + wire[-2] ^= 1 + self.transport.responses["RB"] = bytes(wire) + result = await self.read(31) + self.assertNotIn("battery_soc", result.values) + self.assertIn("RB=invalid_response", result.diagnostics["short_ascii_optional_status"]) + # Still gone well inside a hypothetical 180 s window. + rb = self.state[STATE_KEY].samples[0] + rb.next_due = 10_000 + self.assertNotIn("battery_soc", (await self.read(100)).values) + reads = self.state[STATE_KEY] + self.assertIsNone(reads.rb_filter.hold_until) + self.assertFalse(reads.rb_filter.last_good) + + async def test_optional_rb_sixty_second_ttl_without_link_loss(self): + await self.read(0); await self.read(1); await self.read(2) + # Do not re-query RB; age past 60 s clears without link-loss hold. + self.state[STATE_KEY].samples[0].next_due = 10_000 + late = await self.read(61) + self.assertNotIn("battery_soc", late.values) + self.assertIn("RB=expired", late.diagnostics["short_ascii_optional_status"]) + + async def test_diagnostic_counters_publish_transitions_and_hold_pending_stays_zero(self): + await self.read(0); await self.read(1); await self.read(2) + baseline = await self.read(3) + self.assertEqual(baseline.diagnostics["bms_link_loss_count"], 0) + self.assertEqual(baseline.diagnostics["rb_hard_reject_count"], 0) + self.assertEqual(baseline.diagnostics["reading_hold_pending_count"], 0) + + self.transport.responses["RB"] = _rb(voltage=0, soc=0) + held = await self.read(31) + self.assertEqual(held.diagnostics["bms_link_loss_count"], 1) + self.assertEqual(held.diagnostics["rb_hard_reject_count"], 0) + self.assertEqual(held.diagnostics["reading_hold_pending_count"], 0) + # No RB re-poll; counters still published (hub replaces diagnostics). + rb = self.state[STATE_KEY].samples[0] + rb.next_due = 10_000 + idle = await self.read(32) + self.assertEqual(idle.diagnostics["bms_link_loss_count"], 1) + self.assertEqual(idle.diagnostics["rb_hard_reject_count"], 0) + self.assertEqual(idle.diagnostics["reading_hold_pending_count"], 0) + + for t in (61, 91, 121): + still = await self.read(t) + self.assertEqual(still.diagnostics["bms_link_loss_count"], 1) + self.assertEqual(still.diagnostics["rb_hard_reject_count"], 0) + self.assertEqual(still.diagnostics["reading_hold_pending_count"], 0) + + # Recover then second dropout. + self.transport.responses["RB"] = _rb() + rb.next_due = 150 + recovered = await self.read(150) + self.assertEqual(recovered.values["battery_soc"], 80) + self.assertEqual(recovered.diagnostics["bms_link_loss_count"], 1) + + self.transport.responses["RB"] = _rb(voltage=0, soc=0) + second = await self.read(181) + self.assertEqual(second.diagnostics["bms_link_loss_count"], 2) + self.assertEqual(second.diagnostics["rb_hard_reject_count"], 0) + self.assertEqual(second.diagnostics["reading_hold_pending_count"], 0) + + async def test_hard_reject_publishes_reject_counter(self): + await self.read(0); await self.read(1); await self.read(2) + self.transport.responses["RB"] = _rb(voltage=160, soc=0) # 16.0 V + rejected = await self.read(31) + self.assertIn("RB=rejected", rejected.diagnostics["short_ascii_optional_status"]) + self.assertEqual(rejected.diagnostics["rb_hard_reject_count"], 1) + self.assertEqual(rejected.diagnostics["bms_link_loss_count"], 0) + self.assertEqual(rejected.diagnostics["reading_hold_pending_count"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transport_module_boundaries.py b/tests/test_transport_module_boundaries.py index f610192..2a20d7e 100644 --- a/tests/test_transport_module_boundaries.py +++ b/tests/test_transport_module_boundaries.py @@ -65,8 +65,9 @@ def test_transport_root_is_a_small_definition_free_facade(self) -> None: def test_original_definition_multiset_is_preserved_exactly_once(self) -> None: definitions = [item for path in _FAMILY for item in _definitions(path)] # Explicit extension, not a relaxation of the decomposition baseline. + # Two socket owners + SharedEybondTransport facade delegate. extension = ("AsyncFunctionDef", "async_send_auxiliary_read") - self.assertEqual(definitions.count(extension), 2) + self.assertEqual(definitions.count(extension), 3) definitions = [item for item in definitions if item != extension] payload = "\n".join( f"{kind}:{name}" for kind, name in sorted(definitions) @@ -88,6 +89,46 @@ def test_auxiliary_reads_are_owned_by_both_socket_implementations(self) -> None: for forbidden in ("...models", "...drivers", "...metadata", "...payload"): self.assertNotIn(forbidden, source) + def test_framed_facade_exposes_auxiliary_read_as_delegate_only(self) -> None: + framed = _tree(_TRANSPORT / "shared_framed.py") + method = None + for node in framed.body: + if isinstance(node, ast.ClassDef) and node.name == "SharedEybondTransport": + for child in node.body: + if ( + isinstance(child, ast.AsyncFunctionDef) + and child.name == "async_send_auxiliary_read" + ): + method = child + break + self.assertIsNotNone(method) + assert method is not None + source = ast.unparse(method) + self.assertIn("async_send_auxiliary_read", source) + self.assertIn("_active_connection_for_send", source) + self.assertNotIn("BinaryGrammar", source) + self.assertNotIn("auxiliary_session", source) + self.assertNotIn("AA BB", source) + self.assertNotIn("\\xaa\\xbb", source) + + def test_drivers_must_not_infer_or_call_auxiliary_reads(self) -> None: + drivers_root = _COLLECTOR.parent / "drivers" + forbidden = ( + "async_send_auxiliary_read", + "auxiliary_session", + "BinaryGrammar.AABB", + "\\xaa\\xbb", + "AA BB", + ) + for path in sorted(drivers_root.glob("*.py")): + source = path.read_text(encoding="utf-8") + for token in forbidden: + self.assertNotIn( + token, + source, + msg=f"{path.name} must not infer or call aux via {token!r}", + ) + def test_concrete_authorities_have_one_owner_module(self) -> None: expected = { "_CollectorConnection": "connections.py", @@ -140,6 +181,36 @@ def test_facade_exports_the_exact_concrete_types(self) -> None: self.assertIs(transport.SharedProxyCaptureRoute, SharedProxyCaptureRoute) self.assertIs(transport.SharedCollectorAtTransport, SharedCollectorAtTransport) self.assertIs(transport.SharedEybondTransport, SharedEybondTransport) + self.assertTrue(callable(SharedEybondTransport.async_send_auxiliary_read)) + + +class FramedFacadeAuxiliaryAdmissionTests(unittest.IsolatedAsyncioTestCase): + async def test_facade_delegates_auxiliary_read_timeout_and_payload(self) -> None: + from unittest.mock import AsyncMock, MagicMock + + from custom_components.eybond_local.collector.transport.shared_framed import ( + SharedEybondTransport, + ) + + transport = SharedEybondTransport( + host="127.0.0.1", + port=18899, + request_timeout=3.0, + heartbeat_interval=60.0, + collector_ip="192.0.2.10", + ) + connection = MagicMock() + connection.async_send_auxiliary_read = AsyncMock(return_value=b"\xaa\xbb") + transport._active_connection_for_send = AsyncMock(return_value=connection) + + query = b"\x5a\xa5\x02\x00" + bytes(16) + bytes([0x02]) + reply = await transport.async_send_auxiliary_read(query, request_timeout=1.5) + + self.assertEqual(reply, b"\xaa\xbb") + connection.async_send_auxiliary_read.assert_awaited_once_with( + query, + request_timeout=1.5, + ) if __name__ == "__main__":