Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
7 changes: 4 additions & 3 deletions README.uk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

### Немає заводського колектора?
Expand Down
2 changes: 1 addition & 1 deletion catalog/inverter_models/catalog.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"catalog_version": "2026.08.2",
"catalog_version": "2026.09.1",
"description": "Commercial inverter model support catalog"
}
52 changes: 52 additions & 0 deletions catalog/inverter_models/models/eybond_short_ascii_family.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
}
21 changes: 21 additions & 0 deletions custom_components/eybond_local/drivers/short_ascii_estimates.py
Original file line number Diff line number Diff line change
@@ -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),
}
Original file line number Diff line number Diff line change
@@ -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)
Loading