Skip to content

Commit b08a29f

Browse files
author
Maksym Shtembuliak
committed
Release v0.1.53
1 parent eb2979d commit b08a29f

16 files changed

Lines changed: 595 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ the GitHub release body should be rendered from the matching version section her
2323

2424
- Nothing yet.
2525

26+
## [0.1.53] - 2026-04-25
27+
28+
### Added
29+
30+
- Collector diagnostics now track connection churn, dropped pending requests, disconnect reasons, and discovery restarts to make local write-path contention easier to diagnose.
31+
32+
### Changed
33+
34+
- Declarative SMG runtime gates now surface advisory warnings instead of locally hiding or hard-blocking controls; the inverter response and immediate readback confirmation are now the final authority for non-action writes.
35+
36+
### Fixed
37+
38+
- Non-action writes no longer report silent success when the refreshed value stays unchanged; EyeBond Local now raises an explicit `write_not_confirmed` error after readback.
39+
- Temperature sensors backed by the affected PI18 and Anenji schemas now report `°C` instead of plain `C`.
40+
41+
### Docs
42+
43+
- Updated the English and Ukrainian READMEs plus the SMG support matrix to describe advisory runtime warnings, explicit readback confirmation, and the new collector contention diagnostics.
44+
2645
## [0.1.52] - 2026-04-23
2746

2847
### Added

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ If your inverter's stock monitoring already works through the SmartESS app, that
2525
- **Guided setup wizard** with auto-discovery and a manual fallback.
2626
- **Safe by default** — controls stay read-only until detection is confident.
2727
- **Optional SmartESS cloud assist** — collects reusable cloud evidence for diagnostics and metadata hints without bypassing local safety gates.
28+
- **Explicit write confirmation** — non-action writes must confirm by immediate readback instead of reporting silent success when the value did not stick.
2829
- **Configurable polling interval** — from `2` to `3600` seconds, so updates can be much more frequent than the stock SmartESS refresh.
2930
- **Energy dashboard ready** — derived totals for PV, load, battery, and, on supported models, grid import/export.
3031
- **Open architecture** — JSON-first profiles and register schemas.
@@ -155,8 +156,8 @@ After setup, the integration adds a single device with:
155156
- **Polling control** — the sensor refresh interval is configurable from `2` to `3600` seconds in the integration options.
156157
- **Energy totals** — derived `kWh` totals for PV production, load consumption, and battery charge/discharge, plus grid import/export on models that expose grid power. Drop them straight into the **Energy dashboard**.
157158
- **Binary sensors** — operating mode, faults, alarms, charging state.
158-
- **Controls** — `number`, `select`, `switch`, and `button` entities for supported settings (charge limits, output mode, beep, etc.). On variants that ship model-specific tooling, this can also include dedicated actions such as **Sync Inverter Clock**. Control exposure is still gated by detection confidence and validation state.
159-
- **Diagnostics** — connection state, driver match, support level, optional SmartESS cloud evidence export, explicit read-only fallback markers where applicable, and a one-click **Support Archive** export.
159+
- **Controls** — `number`, `select`, `switch`, and `button` entities for supported settings (charge limits, output mode, beep, etc.). On variants that ship model-specific tooling, this can also include dedicated actions such as **Sync Inverter Clock**. Control exposure is still gated by detection confidence and validation state, but runtime-only SMG conditions now surface as warnings instead of hiding or locally hard-blocking the control.
160+
- **Diagnostics** — connection state, driver match, support level, collector connection-churn and discovery-restart markers, optional SmartESS cloud evidence export, explicit read-only fallback markers where applicable, and a one-click **Support Archive** export.
160161

161162
<p align="center"><img src="docs/images/sensors.png" alt="Live sensors after setup" width="320"></p>
162163

@@ -204,6 +205,7 @@ The Support Archive contains an anonymized snapshot of your inverter's state, re
204205
| Device stays on **EyeBond Setup Pending** | A Pending Device is a saved intermediate state, not a hard failure. Wait briefly, retry scan or manual probe, and then create a Support Archive if the collector callback or local match still does not complete. |
205206
| Stuck on "Collector only" | The collector responded, but the integration still can't confidently identify the protocol, profile, or exact inverter model. Submit a Support Archive. |
206207
| Sensors stay unavailable | Check that the collector is on the same subnet as Home Assistant, and that nothing is blocking TCP `8899` / UDP `58899`. |
208+
| A write was accepted but the value immediately reverted | EyeBond Local now treats that as an explicit readback failure instead of silent success. Check the device diagnostics for collector disconnect/restart counters, pause competing SmartESS or vendor-app writes, and retry after the collector is stable. |
207209
| Remote collector replies but never connects back | Check **Advertised callback IP** and **Advertised callback TCP port** first. They must match the address and forwarded TCP port that the collector can really reach. |
208210
| Remote setup is flaky over the public internet | Prefer VPN over raw NAT if either side is behind CGNAT or if UDP/TCP forwarding is unreliable. |
209211
| Controls are missing | In **Auto** mode, controls only appear when detection confidence is high and the relevant capabilities are marked as tested. Some runtime paths are intentionally read-only, such as the SMG family fallback. If monitoring works for a PI30-family inverter but the exact model was not matched, you can open **Runtime settings** and switch to **Full control**. This is a manual safety override that exposes every write command, so use it only at your own risk and preferably after exporting a Support Archive. |

‎README.uk.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
- **Майстер налаштування** з автопошуком і ручним резервним сценарієм.
2626
- **Безпечно за замовчуванням** — керування лишається тільки для читання, доки система не впевнена в розпізнаванні.
2727
- **Необов'язкова звірка через SmartESS** — збирає повторно використовувані хмарні дані для діагностики та підказок щодо метаданих, не обходячи локальні запобіжники безпеки.
28+
- **Явне підтвердження запису** — зміни не-дійових параметрів тепер мають підтвердитися негайним readback, а не завершуються мовчазним успіхом, якщо значення не закріпилося.
2829
- **Налаштовуваний інтервал опитування** — від `2` до `3600` секунд, тож дані можна оновлювати помітно частіше, ніж у штатному SmartESS.
2930
- **Готово для панелі енергії Home Assistant** — обчислені лічильники для PV, навантаження, батареї та, на підтримуваних моделях, імпорту/експорту з мережі.
3031
- **Відкрита архітектура** — описи профілів і регістрів у форматі JSON.
@@ -155,8 +156,8 @@ EyeBond Local працює з інверторами, для яких штатн
155156
- **Налаштування частоти оновлення** — інтервал опитування можна змінити в межах від `2` до `3600` секунд у параметрах інтеграції.
156157
- **Лічильники енергії** — обчислені значення в `kWh` для PV, навантаження та заряду/розряду батареї, а також імпорту/експорту з мережі на моделях, де доступний показник потужності мережі. Їх можна одразу додати до **панелі енергії Home Assistant**.
157158
- **Бінарні сенсори** — режим роботи, помилки, аварії, стан заряджання.
158-
- **Керування** — `number`, `select`, `switch`, `button` для підтримуваних налаштувань: ліміти заряду, вихідний режим, звуковий сигнал тощо. На деяких моделях це може також включати окремі службові дії, наприклад **Sync Inverter Clock**. Показ керування все одно залежить від упевненості розпізнавання та стану перевірки.
159-
- **Діагностика** — стан з'єднання, відповідність драйвера, рівень підтримки, окремий експорт хмарних даних SmartESS, явні позначки режиму лише для читання за потреби й кнопка експорту **архіву підтримки**.
159+
- **Керування** — `number`, `select`, `switch`, `button` для підтримуваних налаштувань: ліміти заряду, вихідний режим, звуковий сигнал тощо. На деяких моделях це може також включати окремі службові дії, наприклад **Sync Inverter Clock**. Показ керування все одно залежить від упевненості розпізнавання та стану перевірки, але суто runtime-умови SMG тепер показуються як попередження замість прихованого або локально жорстко заблокованого керування.
160+
- **Діагностика** — стан з'єднання, відповідність драйвера, рівень підтримки, лічильники перепідключень колектора та перезапусків discovery, окремий експорт хмарних даних SmartESS, явні позначки режиму лише для читання за потреби й кнопка експорту **архіву підтримки**.
160161

161162
<p align="center"><img src="docs/images/sensors.png" alt="Сенсори після налаштування" width="320"></p>
162163

@@ -204,6 +205,7 @@ EyeBond Local працює з інверторами, для яких штатн
204205
| Пристрій зависає в **EyeBond Setup Pending** | Це тимчасовий збережений стан, а не жорстка помилка. Зачекайте трохи, повторіть пошук або ручну перевірку, а якщо підтвердження інвертора чи зворотне підключення колектора так і не завершуються, створіть архів підтримки. |
205206
| Зависає на статусі **Лише колектор** | Колектор відповів, але інтеграція ще не змогла впевнено визначити протокол, профіль або точну модель інвертора. Надішліть архів підтримки. |
206207
| Сенсори лишаються недоступними | Переконайтесь, що колектор у тій самій підмережі, що й Home Assistant, і що TCP `8899` / UDP `58899` не заблоковані. |
208+
| Запис наче прийнявся, але значення одразу повернулося назад | EyeBond Local тепер вважає це явною помилкою readback, а не мовчазним успіхом. Перевірте в діагностиці лічильники перепідключень і restart'ів collector/discovery, тимчасово зупиніть паралельні записи зі SmartESS або штатного застосунку і повторіть спробу після стабілізації колектора. |
207209
| Віддалений колектор відповідає, але не підключається назад | Насамперед перевірте **Advertised callback IP** і **Advertised callback TCP port**. Вони мають збігатися з реально досяжною для колектора адресою та переадресованим TCP-портом. |
208210
| Віддалене підключення через NAT працює нестабільно | Якщо можливо, використовуйте VPN замість "чистого" NAT, особливо коли одна зі сторін за CGNAT або маршрутизатори ненадійно перенаправляють UDP чи TCP. |
209211
| Немає елементів керування | У режимі **Авто** керування з'являється лише за високої впевненості розпізнавання і для перевірених можливостей. Деякі внутрішні профілі роботи навмисно лишаються тільки для читання, наприклад `modbus_smg/family_fallback`. Якщо, наприклад, інвертор працює за протоколом PI30 і моніторинг уже є, але точну модель не розпізнано, можна в параметрах інтеграції відкрити **Параметри роботи** і вибрати режим **Повний контроль**. Це ручний обхід захисту, який відкриває всі команди без додаткової перевірки, тому використовуйте його лише на власний ризик і бажано після створення архіву підтримки. |

‎custom_components/eybond_local/collector/discovery.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,16 @@ def __init__(
118118
self.last_reply: str = ""
119119
self.last_reply_from: str = ""
120120

121+
@property
122+
def running(self) -> bool:
123+
"""Return whether the background discovery loop is active."""
124+
125+
return self._task is not None and not self._task.done()
126+
121127
async def start(self) -> None:
122128
"""Start the background broadcast loop if it is not running yet."""
123129

124-
if self._task and not self._task.done():
130+
if self.running:
125131
return
126132
self._task = asyncio.create_task(self._run(), name="eybond_discovery")
127133

‎custom_components/eybond_local/collector/server.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ def collector_info(self) -> CollectorInfo:
7070
return apply_collector_profile(CollectorInfo(
7171
remote_ip=self._collector.remote_ip,
7272
remote_port=self._collector.remote_port,
73+
connection_count=self._collector.connection_count,
74+
connection_replace_count=self._collector.connection_replace_count,
75+
disconnect_count=self._collector.disconnect_count,
76+
pending_request_drop_count=self._collector.pending_request_drop_count,
77+
last_disconnect_reason=self._collector.last_disconnect_reason,
78+
discovery_restart_count=self._collector.discovery_restart_count,
79+
last_discovery_reason=self._collector.last_discovery_reason,
7380
collector_pn=self._collector.collector_pn,
7481
last_devcode=self._collector.last_devcode,
7582
heartbeat_devcode=self._collector.heartbeat_devcode,

‎custom_components/eybond_local/collector/transport.py‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,28 @@ def _is_default_broadcast_alias_candidate(expected_ip: str, remote_ip: str) -> b
5656
return expected == ipaddress.IPv4Address(int(remote) | 0xFF)
5757

5858

59+
def _disconnect_reason_from_exception(exc: BaseException) -> str:
60+
if isinstance(exc, ConnectionResetError):
61+
return "collector_connection_reset"
62+
if isinstance(exc, BrokenPipeError):
63+
return "collector_broken_pipe"
64+
if isinstance(exc, OSError):
65+
return "collector_os_error"
66+
return "collector_disconnected"
67+
68+
5969
def _copy_collector_info(collector: CollectorInfo) -> CollectorInfo:
6070
return apply_collector_profile(
6171
CollectorInfo(
6272
remote_ip=collector.remote_ip,
6373
remote_port=collector.remote_port,
74+
connection_count=collector.connection_count,
75+
connection_replace_count=collector.connection_replace_count,
76+
disconnect_count=collector.disconnect_count,
77+
pending_request_drop_count=collector.pending_request_drop_count,
78+
last_disconnect_reason=collector.last_disconnect_reason,
79+
discovery_restart_count=collector.discovery_restart_count,
80+
last_discovery_reason=collector.last_discovery_reason,
6481
collector_pn=collector.collector_pn,
6582
last_devcode=collector.last_devcode,
6683
heartbeat_devcode=collector.heartbeat_devcode,
@@ -275,12 +292,15 @@ async def run(
275292
writer: asyncio.StreamWriter,
276293
) -> None:
277294
if self.connected:
295+
self._collector.connection_replace_count += 1
278296
logger.warning("Replacing active collector connection for %s", self._collector.remote_ip)
279-
await self._disconnect()
297+
await self._disconnect(reason="replaced_active_connection")
280298

281299
peer = writer.get_extra_info("peername") or ("", None)
282300
self._collector.remote_ip = peer[0] or self._collector.remote_ip
283301
self._collector.remote_port = peer[1]
302+
self._collector.connection_count += 1
303+
self._collector.last_disconnect_reason = ""
284304
self._last_heartbeat_monotonic = None
285305
self._reader = reader
286306
self._writer = writer
@@ -363,16 +383,40 @@ async def _read_loop(self, reader: asyncio.StreamReader) -> None:
363383
payload.hex(),
364384
)
365385
except asyncio.IncompleteReadError:
386+
self._collector.last_disconnect_reason = "collector_eof"
366387
logger.info("Collector disconnected: %s", self._collector.remote_ip)
367388
except (ConnectionResetError, BrokenPipeError, OSError) as exc:
389+
self._collector.last_disconnect_reason = _disconnect_reason_from_exception(exc)
368390
logger.info("Collector disconnected %s: %s", self._collector.remote_ip, exc)
369391
except asyncio.CancelledError:
370392
raise
371393

372394
async def disconnect(self) -> None:
373-
await self._disconnect()
395+
await self._disconnect(reason="manual_disconnect")
396+
397+
async def _disconnect(
398+
self,
399+
skip_task: asyncio.Task[Any] | None = None,
400+
*,
401+
reason: str = "",
402+
) -> None:
403+
pending_drop_count = sum(1 for future in self._pending.values() if not future.done())
404+
had_session = (
405+
self._reader is not None
406+
or self._writer is not None
407+
or self._connected.is_set()
408+
or pending_drop_count > 0
409+
)
410+
if pending_drop_count:
411+
self._collector.pending_request_drop_count += pending_drop_count
412+
if had_session:
413+
self._collector.disconnect_count += 1
414+
self._collector.last_disconnect_reason = (
415+
reason
416+
or self._collector.last_disconnect_reason
417+
or "collector_disconnected"
418+
)
374419

375-
async def _disconnect(self, skip_task: asyncio.Task[Any] | None = None) -> None:
376420
heartbeat_task = self._heartbeat_task
377421
self._heartbeat_task = None
378422

‎custom_components/eybond_local/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@
1010
"iot_class": "local_polling",
1111
"issue_tracker": "https://github.com/groove-max/ha-eybond-local/issues",
1212
"requirements": [],
13-
"version": "0.1.52"
13+
"version": "0.1.53"
1414
}

0 commit comments

Comments
 (0)