Skip to content

Commit 86bb435

Browse files
author
Maksym Shtembuliak
committed
fix: use dedicated AT collector reboot command
1 parent 18ae55c commit 86bb435

6 files changed

Lines changed: 101 additions & 26 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ the GitHub release body should be rendered from the matching version section her
5555

5656
### Fixed
5757

58+
- Fixed AT-text collector restart verification by separating the vendor's
59+
dedicated soft-reset command from its staged-settings apply command. A command
60+
acknowledgement still does not count as recovery until the old session drops
61+
and the same collector reconnects on a new physical session.
5862
- Fixed canonical power-flow estimates that could count the same PV watts both
5963
as home consumption and battery charging when asynchronously sampled
6064
Protocol 3/4 registers disagreed. Instantaneous routes and their accumulated

‎custom_components/eybond_local/collector/management.py‎

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
ONLY thing that selects an implementation here.
88
99
Each adapter encapsulates its wire (framed FC=2/FC=3, or AT-text CLDSRVHOST1 /
10-
INTPARA) entirely; the parameter numbers and AT command strings live only inside
11-
the implementations. Every operation returns the SAME normalized model, and an
12-
operation a wire cannot confirm fails with a typed error under
10+
INTPARA / RESET) entirely; the parameter numbers and AT command strings live only
11+
inside the implementations. Every operation returns the SAME normalized model,
12+
and an operation a wire cannot confirm fails with a typed error under
1313
``CollectorManagementError`` -- it never simulates success. Adapters resolve the
1414
live transport lazily through a provider callable, so a reconnect/handover never
1515
leaves them holding a stale socket.
@@ -26,7 +26,7 @@
2626

2727
import asyncio
2828
from abc import ABC, abstractmethod
29-
from collections.abc import Callable, Mapping
29+
from collections.abc import Awaitable, Callable, Mapping
3030
from dataclasses import dataclass, field
3131

3232
from ..connection.session_handle import (
@@ -51,6 +51,8 @@
5151
_AT_ENDPOINT_COMMAND = "CLDSRVHOST1"
5252
_AT_APPLY_COMMAND = "INTPARA"
5353
_AT_APPLY_VALUE = "29,1"
54+
_AT_REBOOT_COMMAND = "RESET"
55+
_AT_REBOOT_VALUE = "S"
5456
# The project's confirmed AT success status (see smartess_ble.extract_at_status_code).
5557
_AT_SUCCESS_STATUS = "W000"
5658

@@ -566,12 +568,13 @@ async def async_set_uart_baudrate(self, baudrate: str) -> str:
566568

567569

568570
class AtTextCollectorManagementAdapter(CollectorManagementAdapter):
569-
"""Collector management over the AT-text wire (CLDSRVHOST1 / INTPARA).
571+
"""Collector management over the AT-text wire.
570572
571-
The AT command strings live only inside this class. Endpoint read/write and
572-
apply/reboot are real and confirmed by the command response. The vendor's
573-
collector-restart flow uses ``AT+INTPARA=29,1``; a recovery transaction
574-
separately proves the resulting disconnect and same-identity reconnect.
573+
The AT command strings live only inside this class. ``AT+INTPARA=29,1``
574+
applies staged settings; ``AT+RESET=S`` requests a soft reboot. A ``W000``
575+
response confirms only that the collector accepted the selected command.
576+
Recovery transactions separately prove the physical disconnect and a new
577+
same-identity session before treating a reboot as completed.
575578
"""
576579

577580
adapter_id = ADAPTER_COLLECTOR_AT_COMMANDS
@@ -672,6 +675,22 @@ async def _apply(self, transport: object) -> str:
672675
)
673676
return status
674677

678+
async def _reboot(self, transport: object) -> str:
679+
"""Send RESET S and require a confirmed ``W000`` acceptance status."""
680+
681+
status = await self._send_write(
682+
transport, _AT_REBOOT_COMMAND, _AT_REBOOT_VALUE
683+
)
684+
if not status:
685+
raise CollectorManagementConfirmationError(
686+
f"collector_at_reboot_unconfirmed:command={_AT_REBOOT_COMMAND}"
687+
)
688+
if status != _AT_SUCCESS_STATUS:
689+
raise CollectorManagementCommandError(
690+
f"collector_at_reboot_rejected:command={_AT_REBOOT_COMMAND}:status={status}"
691+
)
692+
return status
693+
675694
async def async_read_endpoint_state(self) -> CollectorEndpointState:
676695
transport = self._resolve_transport()
677696
current = await self._query(transport, _AT_ENDPOINT_COMMAND)
@@ -716,28 +735,38 @@ async def async_write_endpoint(
716735
extra=extra,
717736
)
718737

719-
async def _system_action(self, action: str) -> CollectorSystemActionResult:
738+
async def _system_action(
739+
self,
740+
*,
741+
action: str,
742+
operation: Callable[[object], Awaitable[str]],
743+
warning: str,
744+
) -> CollectorSystemActionResult:
720745
transport = self._resolve_transport(needs_write=True)
721746
current = await self._query(transport, _AT_ENDPOINT_COMMAND)
722-
await self._apply(transport)
747+
await operation(transport)
723748
return CollectorSystemActionResult(
724749
action=action,
725750
current_endpoint=current,
726751
reboot_required_before="",
727752
performed=True,
728753
adapter_id=self.adapter_id,
729-
warnings=(
730-
self._REBOOT_ACTION_WARNING
731-
if action == "reboot"
732-
else self._APPLY_ACTION_WARNING,
733-
),
754+
warnings=(warning,),
734755
)
735756

736757
async def async_apply_changes(self) -> CollectorSystemActionResult:
737-
return await self._system_action("apply")
758+
return await self._system_action(
759+
action="apply",
760+
operation=self._apply,
761+
warning=self._APPLY_ACTION_WARNING,
762+
)
738763

739764
async def async_reboot(self) -> CollectorSystemActionResult:
740-
return await self._system_action("reboot")
765+
return await self._system_action(
766+
action="reboot",
767+
operation=self._reboot,
768+
warning=self._REBOOT_ACTION_WARNING,
769+
)
741770

742771
async def async_query_parameters(
743772
self,

‎docs/architecture/CONNECTION_ARCHITECTURE.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ session-pinned and its teardown preserves that socket; it cannot switch to a
165165
same-PN sibling and cannot create reconnect evidence by closing the socket
166166
itself.
167167

168+
The negotiated wire adapter owns the distinction between applying staged
169+
collector settings and requesting a reboot. On AT-text sessions these are
170+
separate vendor operations; an acknowledgement proves only command acceptance.
171+
The recovery authority still requires observable reset activity and a new
172+
same-PN session before it confirms a completed restart.
173+
168174
Reset activity is observed against the complete pre-action session cohort, not
169175
against one selected socket's EOF. Some collectors briefly keep several
170176
same-PN sockets alive: a baseline sibling may close, or a new socket may open,

‎tests/test_collector_management.py‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ async def async_write(self, command: str, value: str) -> CollectorAtResponse:
106106
if self._reject_intpara:
107107
return CollectorAtResponse(command="ERR", value="", raw="")
108108
return CollectorAtResponse(command="INTPARA", value="W000", raw="")
109+
if command == "RESET":
110+
return CollectorAtResponse(command="RESET", value="W000", raw="")
109111
return CollectorAtResponse(command=command, value="", raw="")
110112

111113

@@ -268,13 +270,13 @@ async def test_apply_rejected_raises_typed_and_never_applied(self) -> None:
268270
with self.assertRaises(CollectorManagementConfirmationError):
269271
await self._adapter(transport).async_apply_changes()
270272

271-
async def test_reboot_uses_vendor_intpara_restart_command(self) -> None:
273+
async def test_reboot_uses_dedicated_vendor_soft_reset_command(self) -> None:
272274
transport = _FakeAtTransport()
273275

274276
result = await self._adapter(transport).async_reboot()
275277

276278
self.assertEqual(transport.queries, ["CLDSRVHOST1"])
277-
self.assertEqual(transport.writes, [("INTPARA", "29,1")])
279+
self.assertEqual(transport.writes, [("RESET", "S")])
278280
self.assertEqual(result.action, "reboot")
279281
self.assertTrue(result.performed)
280282
self.assertEqual(result.current_endpoint, "iot.eybond.com,18899,TCP")
@@ -440,6 +442,26 @@ async def async_write(self, command, value):
440442

441443
return AtTextCollectorManagementAdapter(lambda: _At())
442444

445+
def _reboot_adapter(self, reset_command: str, reset_value: str):
446+
class _At:
447+
async def async_query(self, command):
448+
return CollectorAtResponse(
449+
command="CLDSRVHOST1",
450+
value="1.2.3.4,18899,TCP",
451+
raw="",
452+
)
453+
454+
async def async_write(self, command, value):
455+
if command == "RESET":
456+
return CollectorAtResponse(
457+
command=reset_command,
458+
value=reset_value,
459+
raw="",
460+
)
461+
return CollectorAtResponse(command=command, value=value, raw="")
462+
463+
return AtTextCollectorManagementAdapter(lambda: _At())
464+
443465
async def test_intpara_w000_apply_succeeds(self) -> None:
444466
result = await self._apply_adapter("INTPARA", "W000").async_apply_changes()
445467
self.assertTrue(result.performed)
@@ -456,6 +478,22 @@ async def test_intpara_foreign_command_is_confirmation_error(self) -> None:
456478
with self.assertRaises(CollectorManagementConfirmationError):
457479
await self._apply_adapter("SOMETHINGELSE", "W000").async_apply_changes()
458480

481+
async def test_reset_w000_reboot_succeeds(self) -> None:
482+
result = await self._reboot_adapter("RESET", "W000").async_reboot()
483+
self.assertTrue(result.performed)
484+
485+
async def test_reset_w001_reboot_rejected_is_command_error(self) -> None:
486+
with self.assertRaises(CollectorManagementCommandError):
487+
await self._reboot_adapter("RESET", "W001").async_reboot()
488+
489+
async def test_reset_empty_status_is_confirmation_error(self) -> None:
490+
with self.assertRaises(CollectorManagementConfirmationError):
491+
await self._reboot_adapter("RESET", "").async_reboot()
492+
493+
async def test_reset_foreign_command_is_confirmation_error(self) -> None:
494+
with self.assertRaises(CollectorManagementConfirmationError):
495+
await self._reboot_adapter("SOMETHINGELSE", "W000").async_reboot()
496+
459497
async def test_cldsrvhost1_w001_write_and_empty_readback_rejected(self) -> None:
460498
class _At:
461499
async def async_query(self, command):

‎tests/test_hub.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4621,7 +4621,7 @@ async def _run() -> None:
46214621

46224622
asyncio.run(_run())
46234623

4624-
def test_at_reboot_uses_intpara_and_returns_confirmed_result(self) -> None:
4624+
def test_at_reboot_uses_soft_reset_and_returns_confirmed_result(self) -> None:
46254625
async def _run() -> None:
46264626
hub, link = self._at_hub()
46274627

@@ -4630,9 +4630,7 @@ async def _run() -> None:
46304630
self.assertEqual(result["status"], "reboot_triggered")
46314631
self.assertEqual(result["action"], "reboot")
46324632
self.assertIn("CLDSRVHOST1", link.collector_at_transport.queries)
4633-
self.assertIn(
4634-
("INTPARA", "29,1"), link.collector_at_transport.writes
4635-
)
4633+
self.assertIn(("RESET", "S"), link.collector_at_transport.writes)
46364634

46374635
asyncio.run(_run())
46384636

‎tests/test_strategy_verification.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,7 +1205,7 @@ async def _exact_transport():
12051205
)
12061206
self.assertIn((FC_SET_COLLECTOR, bytes((29,)) + b"1"), wire.calls)
12071207

1208-
def test_at_adapter_reboot_uses_exact_session_vendor_command(self) -> None:
1208+
def test_at_adapter_reboot_uses_exact_session_soft_reset_command(self) -> None:
12091209
from custom_components.eybond_local.collector.at import CollectorAtResponse
12101210

12111211
class _AtWire:
@@ -1241,7 +1241,7 @@ async def _exact_transport():
12411241
receipt = asyncio.run(channel.async_send_restart())
12421242

12431243
self.assertEqual(wire.queries, ["CLDSRVHOST1"])
1244-
self.assertEqual(wire.writes, [("INTPARA", "29,1")])
1244+
self.assertEqual(wire.writes, [("RESET", "S")])
12451245
self.assertEqual(receipt["status"], "reboot_requested")
12461246
self.assertEqual(receipt["action"], "reboot")
12471247
self.assertTrue(receipt["performed"])

0 commit comments

Comments
 (0)