Skip to content

Commit 13dc45b

Browse files
author
Maksym Shtembuliak
committed
fix: repair unidentified collectors and classify live measurements
Reuse explicit session-bound identity probes for in-place reconfigure, preserve verified route settings, and cover repair through real HA lifecycle tests. Keep SMG live measurements outside diagnostics without changing entity identity or enablement. Clarify short-ASCII reference voltage and temperature units. Refs #18, #26, #38, #46.
1 parent 74c29a2 commit 13dc45b

17 files changed

Lines changed: 462 additions & 53 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ the GitHub release body should be rendered from the matching version section her
2828

2929
### Fixed
3030

31+
- Older entries without a confirmed collector identity can now use the same
32+
explicit read-only framed/AT identity check as manual setup. Successful repair
33+
updates the existing entry and its selected connection settings in place;
34+
failed checks preserve it. Runtime timeout diagnostics no longer imply that
35+
no TCP connection arrived when identity was not confirmed (#18, #46).
36+
37+
- SMG live voltage, current, frequency, power and temperature measurements are
38+
ordinary sensors instead of diagnostics, including inherited model profiles
39+
and OP2 apparent power. Entity IDs, units and default enablement are unchanged;
40+
settings, protocol information and fault details remain diagnostic (#38).
41+
3142
- Support archives preserve numeric-looking hex sequences in explicitly typed
3243
wire-evidence fields instead of replacing their bytes with identifier masks.
3344
Embedded ASCII identifiers remain masked. Short-ASCII archive creation and

‎custom_components/eybond_local/config_entry.py‎

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .connection.admission_transaction import (
2727
CollectorAdmissionTransaction,
2828
)
29+
from .connection.callback_identity import OnboardingWireProbeIntent
2930
from .connection.connection_policy import (
3031
collector_identity_binding_required,
3132
resolve_connection_strategy,
@@ -55,6 +56,7 @@
5556
CONF_DETECTED_SERIAL,
5657
CONF_DETECTION_CONFIDENCE,
5758
CONF_DISCOVERY_TARGET,
59+
CONF_DRIVER_HINT,
5860
CONF_DRIVER_DETECTION_STRATEGY,
5961
CONF_POLL_INTERVAL,
6062
CONF_POLL_MODE,
@@ -133,6 +135,8 @@ async def async_step_reconfigure(
133135

134136
self._repair_entry_id = entry.entry_id
135137
await self._async_ensure_network_defaults()
138+
if not self._manual_config:
139+
self._manual_defaults = {**entry.data, **entry.options}
136140

137141
# A PN-less entry has no prior identity to match -> bind ANY freshly
138142
# triggered strong session. An entry that still carries a (short) PN must
@@ -152,24 +156,60 @@ async def async_step_reconfigure(
152156
)
153157
if not errors:
154158
self._manual_config = dict(flat_input)
155-
# Repair is an active callback attempt too: same shared lifecycle
156-
# (fresh baseline + ledger generation + probe + matcher + claim),
157-
# so a repeated repair can never reuse a previous attempt's proof.
158-
verification_error = await self._async_run_manual_callback_attempt(
159-
flat_input
160-
)
161-
if verification_error:
162-
errors["base"] = verification_error
163-
else:
164-
applied = self._async_apply_reconfigure(entry)
165-
if applied is not None:
166-
return applied
167-
# No durable strong PN was bound: refuse to "repair" into
168-
# another doomed PN-less entry; re-prompt.
169-
errors["base"] = "callback_identity_unverified"
159+
return await self._async_reconfigure_retry()
170160

171161
return self._async_show_reconfigure_form(user_input, errors)
172162

163+
async def async_step_reconfigure_confirm(
164+
self, user_input: dict[str, Any] | None = None,
165+
) -> ConfigFlowResult:
166+
"""Serve repair results with the shared explicit identity actions."""
167+
168+
del user_input
169+
if not self._repair_entry_id:
170+
return await self.async_step_reconfigure()
171+
return await self.async_step_manual_confirm()
172+
173+
async def _async_reconfigure_retry(
174+
self, protocol: str | None = None,
175+
) -> ConfigFlowResult:
176+
"""Retry identity repair; never enter new-entry save or reboot recovery."""
177+
178+
entry = self._reconfigure_target_entry()
179+
if entry is None or not collector_identity_binding_required(
180+
entry.data, entry.options,
181+
):
182+
self._replace_manual_callback_continuation()
183+
return self.async_abort(reason=(
184+
"reconfigure_entry_missing" if entry is None else "reconfigure_not_required"
185+
))
186+
if not self._manual_config:
187+
return await self.async_step_reconfigure()
188+
intent = None
189+
if protocol is not None:
190+
offer = self._callback_continuation.silent_bootstrap_offer
191+
if offer is None:
192+
return await self.async_step_manual_confirm()
193+
intent = OnboardingWireProbeIntent.for_offer(offer, protocol=protocol)
194+
error = await self._async_run_manual_callback_attempt(
195+
self._manual_config, bootstrap_probe=intent,
196+
)
197+
if not error:
198+
# Re-check after the await: removal or another repair may have won.
199+
current = self._reconfigure_target_entry()
200+
if current is None or not collector_identity_binding_required(
201+
current.data, current.options,
202+
):
203+
self._replace_manual_callback_continuation()
204+
return self.async_abort(reason=(
205+
"reconfigure_entry_missing" if current is None else "reconfigure_not_required"
206+
))
207+
applied = self._async_apply_reconfigure(current)
208+
if applied is not None:
209+
return applied
210+
error = "callback_identity_unverified"
211+
return await self._async_route_after_manual_callback_failure(error)
212+
173213
def _async_show_reconfigure_form(
174214
self,
175215
user_input: dict[str, Any] | None,
@@ -236,8 +276,22 @@ def _async_apply_reconfigure(self, entry: ConfigEntry) -> ConfigFlowResult | Non
236276
return self.async_abort(reason="already_configured")
237277

238278
new_data = dict(entry.data)
279+
new_options = dict(entry.options)
280+
# Retain the verified route, including an explicitly corrected listener
281+
# port. An old option override must not silently restore the broken route
282+
# after reload. Identity repair does not change driver/control policy.
283+
for key, value in build_manual_entry_settings(
284+
self._current_connection_type(), self._manual_config,
285+
).items():
286+
if key == CONF_DRIVER_HINT:
287+
continue
288+
new_data[key] = value
289+
if key in new_options:
290+
new_options[key] = value
239291
new_data[CONF_COLLECTOR_PN] = verified_full_pn
240292
new_data[CONF_COLLECTOR_IP] = collector_ip
293+
if CONF_COLLECTOR_IP in new_options:
294+
new_options[CONF_COLLECTOR_IP] = collector_ip
241295
# Identity repair re-binds the durable PN; it does NOT re-decide how the
242296
# collector connects. The entry's canonical strategy (the user's choice)
243297
# is preserved untouched. Only an entry from before the canonical axis
@@ -275,6 +329,7 @@ def _async_apply_reconfigure(self, entry: ConfigEntry) -> ConfigFlowResult | Non
275329
entry,
276330
unique_id=f"collector:{verified_full_pn}",
277331
data=new_data,
332+
options=new_options,
278333
),
279334
recovery=self._callback_continuation.terminal_input,
280335
)

‎custom_components/eybond_local/flows/config/manual.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,8 @@ async def _async_route_after_manual_callback_failure(
322322
cleared here as an additional terminal-path guard. The flow remains open
323323
with retry/edit actions; no PN-less entry is persisted.
324324
325-
Reconfigure does not call this helper and remains fail-closed on its own
326-
form.
325+
Identity repair shares this presentation, but keeps its own in-place
326+
commit/retry boundary and can never offer new-entry save or recovery.
327327
"""
328328

329329
reason = str(verification_error or "callback_timeout").strip()
@@ -569,7 +569,8 @@ async def async_step_manual_confirm(
569569
):
570570
# Advanced recovery for a genuinely SILENT device: the user (and
571571
# only the user) may pick the bootstrap protocol for exactly one
572-
# read-only identity query on the next attempt's new session.
572+
# read-only identity query on the exact offered session. A stale
573+
# offer must be renewed, never applied to a replacement socket.
573574
menu_options.append("manual_bootstrap_framed")
574575
menu_options.append("manual_bootstrap_at")
575576
menu_options.extend(
@@ -578,6 +579,16 @@ async def async_step_manual_confirm(
578579
MANUAL_CONFIRM_ACTION_EDIT_SETTINGS,
579580
]
580581
)
582+
if self._repair_entry_id:
583+
return self.async_show_menu(
584+
step_id="reconfigure_confirm",
585+
menu_options=menu_options,
586+
description_placeholders={
587+
"failure_explanation": _shared_recovery_failure_explanation(
588+
self._tr, last_error,
589+
),
590+
},
591+
)
581592
if self._can_offer_smartess_cloud_assist(self._manual_result):
582593
menu_options.append("manual_smartess_cloud_assist")
583594
if self._manual_entry_ready_to_save():
@@ -610,6 +621,8 @@ async def async_step_manual_probe_again(
610621
user_input: dict[str, Any] | None = None,
611622
) -> ConfigFlowResult:
612623
del user_input
624+
if self._repair_entry_id:
625+
return await self._async_reconfigure_retry()
613626
if not self._manual_config:
614627
return await self.async_step_manual()
615628

@@ -657,6 +670,8 @@ async def _async_manual_bootstrap_retry(self, protocol: str) -> ConfigFlowResult
657670
reader, prepared handoff) is the one shared identity transaction.
658671
"""
659672

673+
if self._repair_entry_id:
674+
return await self._async_reconfigure_retry(protocol)
660675
if not self._manual_config:
661676
return await self.async_step_manual()
662677
offer = self._callback_continuation.silent_bootstrap_offer
@@ -685,6 +700,8 @@ async def async_step_manual_edit_settings(
685700
self._callback_continuation.release_terminal_owner()
686701
self._manual_defaults = dict(self._manual_config)
687702
self._manual_result = None
703+
if self._repair_entry_id:
704+
return await self.async_step_reconfigure()
688705
return await self.async_step_manual()
689706

690707
async def async_step_manual_save(
@@ -694,6 +711,8 @@ async def async_step_manual_save(
694711
"""Create a normal collector entry only after durable identity proof."""
695712

696713
del user_input
714+
if self._repair_entry_id:
715+
return await self.async_step_reconfigure_confirm()
697716
if not self._manual_config:
698717
return await self.async_step_manual()
699718
if not self._manual_entry_ready_to_save():

0 commit comments

Comments
 (0)