Skip to content

Commit 676aa5e

Browse files
author
Maksym Shtembuliak
committed
fix: preserve collector-only cloud analysis and clarify capture readiness
1 parent c57bd10 commit 676aa5e

11 files changed

Lines changed: 167 additions & 10 deletions

File tree

‎CHANGELOG.md‎

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

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Read-only cloud analysis now retains its results for collector-only entries
13+
with no local inverter driver. Recognized cloud readings no longer cause the
14+
result-building step to fail while checking local sensor coverage (#23).
15+
Failure artifacts now also include the selected source, workflow stage and
16+
exception category without exception messages or credentials.
17+
- Proxy-capture guidance now distinguishes the collector's configured server
18+
address from Home Assistant's callback address (#43). Capture still requires
19+
a connected collector and a known endpoint that can be safely restored.
20+
1021
## [0.3.0-beta.5] - 2026-09-09
1122

1223
### Added

‎custom_components/eybond_local/button.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ def _collector_action_availability_reason(self) -> str | None:
399399
if overview.blocking_reason == "target_endpoint_unavailable":
400400
return "Proxy target endpoint is not available."
401401
if overview.blocking_reason == "current_endpoint_unavailable":
402-
return "Current collector callback endpoint is not available yet."
402+
return "The collector's current server address has not been read yet."
403403
if overview.blocking_reason == "session_active":
404404
return "Proxy capture session is already active."
405405
return "Proxy capture is not ready."

‎custom_components/eybond_local/flow_translations/en.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121
"proxy_capture_blocking_collector_control_disabled": "collector changes are currently disabled by the selected control mode",
122122
"proxy_capture_blocking_collector_not_connected": "the collector is not connected",
123123
"proxy_capture_blocking_target_endpoint_unavailable": "Home Assistant capture address is not available yet",
124-
"proxy_capture_blocking_current_endpoint_unavailable": "the current collector callback address is not available yet",
124+
"proxy_capture_blocking_current_endpoint_unavailable": "the collector's current server address has not been read yet",
125125
"proxy_capture_blocking_session_active": "a capture session is already running",
126126
"proxy_capture_blocking_operating_profile_requires_cloud_and_ha": "switch to Cloud + Home Assistant before starting a cloud traffic operation",
127127
"proxy_capture_blocking_transparent_wire_incompatible": "transparent capture is unavailable because the collector and cloud use different protocols",

‎custom_components/eybond_local/flow_translations/ru.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121
"proxy_capture_blocking_collector_control_disabled": "избранный режим управления сейчас не разрешает изменения коллектора",
122122
"proxy_capture_blocking_collector_not_connected": "коллектор не подключен",
123123
"proxy_capture_blocking_target_endpoint_unavailable": "адрес Home Assistant для захвата пока недоступен",
124-
"proxy_capture_blocking_current_endpoint_unavailable": "текущий адрес обратного подключения коллектора пока неизвестен",
124+
"proxy_capture_blocking_current_endpoint_unavailable": "ещё не удалось прочитать текущий адрес сервера, к которому подключается коллектор",
125125
"proxy_capture_blocking_session_active": "сеанс захвата уже запущен",
126126
"proxy_capture_blocking_operating_profile_requires_cloud_and_ha": "перед запуском операции переключитесь в профиль «Облако + Home Assistant»",
127127
"proxy_capture_blocking_transparent_wire_incompatible": "прозрачный захват недоступен: коллектор и облако используют разные протоколы",

‎custom_components/eybond_local/flow_translations/uk.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121
"proxy_capture_blocking_collector_control_disabled": "обраний режим керування зараз не дозволяє зміни колектора",
122122
"proxy_capture_blocking_collector_not_connected": "колектор не підключений",
123123
"proxy_capture_blocking_target_endpoint_unavailable": "адреса Home Assistant для захоплення поки недоступна",
124-
"proxy_capture_blocking_current_endpoint_unavailable": "поточна адреса зворотного підключення колектора поки невідома",
124+
"proxy_capture_blocking_current_endpoint_unavailable": "ще не вдалося прочитати поточну адресу сервера, до якого підключається колектор",
125125
"proxy_capture_blocking_session_active": "сеанс захоплення вже запущено",
126126
"proxy_capture_blocking_operating_profile_requires_cloud_and_ha": "перед запуском операції перейдіть до профілю «Хмара + Home Assistant»",
127127
"proxy_capture_blocking_transparent_wire_incompatible": "прозоре захоплення недоступне: колектор і хмара використовують різні протоколи",

‎custom_components/eybond_local/flows/options/shadow_run.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def _metadata_with_local_coverage(
6868
"""Attach exact typed local evidence without minting cloud bindings."""
6969

7070
detached = dict(metadata_evidence)
71+
# Coverage belongs to the current local driver, not to the cloud source or
72+
# an earlier run. Collector-only entries have a valid empty telemetry frame
73+
# but no driver against which semantic presence can be checked.
74+
detached.pop("local_coverage", None)
7175
if (
7276
type(local_register_snapshot) is LocalRegisterSnapshot
7377
and type(expected_collector_pn) is str
@@ -94,7 +98,11 @@ def _metadata_with_local_coverage(
9498
semantic_report = CloudSemanticEvidenceReport.from_record(
9599
detached.get("semantic_report")
96100
)
97-
if semantic_report is None or type(telemetry) is not TypedTelemetryFrame:
101+
if (
102+
semantic_report is None
103+
or type(telemetry) is not TypedTelemetryFrame
104+
or not telemetry.driver_key
105+
):
98106
return detached
99107
detached["local_coverage"] = build_cloud_local_coverage_report(
100108
semantic_report,
@@ -617,6 +625,24 @@ async def _async_run_control_discovery(self) -> None:
617625
self._shadow_learning_state["discovery"] = {
618626
"status": "error",
619627
"reason": failure_reason,
628+
# Keep workflow-owned context in the archive too, not only in
629+
# the Core log. Never persist exception text, request URLs or
630+
# arbitrary provider attributes (which may contain credentials).
631+
"diagnostics": {
632+
"learning_source": source_id,
633+
"stage": str(progress.get("stage") or "unknown"),
634+
"exception_category": next(
635+
(
636+
error_type.__name__
637+
for error_type in (
638+
TimeoutError, ConnectionError, ValueError,
639+
TypeError, RuntimeError,
640+
)
641+
if isinstance(exc, error_type)
642+
),
643+
"Exception",
644+
),
645+
},
620646
}
621647
self._shadow_learning_state["status"] = self._tr(
622648
"common.dynamic.control_discovery_failed",

‎custom_components/eybond_local/flows/options/shadow_runtime.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,16 @@ def _publish_shadow_learning_artifacts(self, coordinator) -> dict[str, Any]:
395395
"reason": str(discovery.get("reason") or ""),
396396
"preflight": dict(state.get("preflight") or {}),
397397
}
398+
# A late failure must also be recorded when a successful provider
399+
# result was already published; retain that result as partial evidence.
400+
discovery = state.get("discovery")
401+
if isinstance(discovery, dict) and discovery.get("status") == "error":
402+
diagnostics = discovery.get("diagnostics")
403+
if isinstance(diagnostics, dict):
404+
orchestration["failure"] = {
405+
"reason": str(discovery.get("reason") or ""),
406+
**diagnostics,
407+
}
398408
correlation = orchestration.get("correlation")
399409
if not isinstance(correlation, dict):
400410
correlation = {}

‎custom_components/eybond_local/support/proxy_capture/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def build_proxy_capture_overview(
214214
return ProxyCaptureOverview(
215215
status="blocked",
216216
status_label=_status_label("blocked"),
217-
summary="The current collector callback endpoint is not available yet.",
217+
summary="The collector's current server address has not been read yet.",
218218
blocking_reason="current_endpoint_unavailable",
219219
can_start=False,
220220
can_stop=False,

‎docs/user/DEVICE_LEARNING.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ setting names and cloud field IDs alone do not prove local register addresses.
158158
Save the Support Archive after the check so the maintainer can review the data.
159159
SmartClient active control verification is not available yet.
160160

161+
You can finish this analysis even if Home Assistant has identified only the
162+
collector, not the inverter. Cloud readings and setting descriptions are kept
163+
for support; comparison with existing local sensors is skipped until a local
164+
driver is available. A failed check also records its source and workflow stage
165+
in the Support Archive, without saving your cloud password.
166+
161167
The goal is to learn what the device supports without permanently changing inverter settings.
162168

163169
If the safe learning path is not ready, the integration stops instead of continuing.

‎tests/test_config_flow.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9787,6 +9787,12 @@ async def test_smartclient_pipeline_keeps_exact_source_and_publishes_passive_evi
97879787
from custom_components.eybond_local.support import smartclient_learning
97889788

97899789
coordinator = self._RunnerCoordinator(ready=False)
9790+
# Real collector-only RuntimeSnapshot has a typed empty frame, not a
9791+
# missing telemetry attribute. Recognized cloud fields must not require
9792+
# a local driver (issue #23).
9793+
coordinator.effective_profile_name = ""
9794+
coordinator.effective_register_schema_name = ""
9795+
coordinator.data.telemetry = TypedTelemetryFrame(driver_key="", points=())
97909796
options = self._runner_options_flow(coordinator)
97919797
options._shadow_learning_state.update(
97929798
{"wizard_method": LEARNING_METHOD_READ_ONLY_EVIDENCE, "wizard_source": "smartclient"}
@@ -9804,10 +9810,61 @@ async def test_smartclient_pipeline_keeps_exact_source_and_publishes_passive_evi
98049810
self.assertTrue(coordinator.published)
98059811
self.assertEqual(options._shadow_learning_state["discovery"]["status"], "ok")
98069812
self.assertEqual(options._shadow_learning_state["cloud_metadata"]["source"], "smartclient")
9813+
self.assertNotIn("local_coverage", options._shadow_learning_state["cloud_metadata"])
9814+
self.assertGreater(
9815+
options._shadow_learning_state["cloud_metadata"]["semantic_report"]["read_candidate_count"], 0
9816+
)
98079817
self.assertNotIn("demo@example.com", str(coordinator.published))
98089818
self.assertNotIn("wizard_credentials", str(coordinator.published))
98099819
self.assertEqual(options._shadow_learning_state.get("overlay", {}), {})
98109820

9821+
def test_cloud_coverage_drops_stale_driver_report_without_local_driver(self) -> None:
9822+
from custom_components.eybond_local.flows.options.shadow_run import _metadata_with_local_coverage
9823+
9824+
evidence = {"source": "smartclient", "local_coverage": {"driver_key": "smg"}}
9825+
for telemetry in (None, TypedTelemetryFrame(driver_key="", points=())):
9826+
with self.subTest(telemetry=telemetry):
9827+
enriched = _metadata_with_local_coverage(evidence, telemetry)
9828+
self.assertNotIn("local_coverage", enriched)
9829+
self.assertEqual(enriched["source"], "smartclient")
9830+
self.assertIn("local_coverage", evidence)
9831+
9832+
async def test_cloud_learning_failure_exports_phase_without_exception_secrets(self) -> None:
9833+
for partial_result in (False, True):
9834+
with self.subTest(partial_result=partial_result):
9835+
coordinator = self._RunnerCoordinator(ready=False)
9836+
options = self._runner_options_flow(coordinator)
9837+
options._shadow_learning_state.update({
9838+
"wizard_method": LEARNING_METHOD_READ_ONLY_EVIDENCE,
9839+
"wizard_source": "smartclient",
9840+
"progress": {"stage": "building", "fraction": 0.82},
9841+
})
9842+
if partial_result:
9843+
options._shadow_learning_state["orchestration"] = {
9844+
"source": "smartclient", "metadata_only": True,
9845+
"metadata_evidence": {"telemetry_fields": [{"title": "PV Voltage"}]},
9846+
}
9847+
with (
9848+
patch.object(
9849+
options, "_async_execute_control_discovery",
9850+
new=AsyncMock(side_effect=ValueError("https://private.invalid/?password=cloud-secret")),
9851+
),
9852+
self.assertLogs("custom_components.eybond_local.flows.options.shadow_run", level="ERROR") as logs,
9853+
):
9854+
await options._async_run_control_discovery()
9855+
artifact = coordinator.published[-1]["orchestration"]
9856+
self.assertEqual(artifact["failure"], {
9857+
"reason": "control_discovery_failure_generic",
9858+
"learning_source": "smartclient", "stage": "building",
9859+
"exception_category": "ValueError",
9860+
})
9861+
if partial_result:
9862+
self.assertIn("metadata_evidence", artifact)
9863+
self.assertNotIn("private.invalid", str(coordinator.published) + str(logs.output))
9864+
self.assertNotIn("cloud-secret", str(coordinator.published) + str(logs.output))
9865+
self.assertEqual(coordinator.started, [])
9866+
self.assertEqual(coordinator.stopped, [])
9867+
98119868
async def test_dessmonitor_runner_records_typed_local_semantic_coverage(self) -> None:
98129869
coordinator = self._RunnerCoordinator(ready=False)
98139870
coordinator.data.telemetry = TypedTelemetryFrame(

0 commit comments

Comments
 (0)