-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmqtt_write_latency_probe.py
More file actions
2179 lines (1940 loc) · 84.4 KB
/
Copy pathmqtt_write_latency_probe.py
File metadata and controls
2179 lines (1940 loc) · 84.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Hardware probe for Zendure MQTT power control (latency + effectiveness).
The command channel is MQTT using the device client's exact production command
builder (atomic model-specific property set, QoS 1, non-retained, iot/… topic);
the observation channel is the device's local HTTP API, polled fast. The HTTP
read sidesteps the ~30 s MQTT telemetry cadence, so the measured latency is the
real end-to-end time from "publish" to "setpoint visible on the device".
Probe modes:
``--dry-preview``
Resolve devices, print the operation plan (topic, QoS, properties, gates,
current mode/limit state) and write nothing.
``--confirm-writes`` (default test)
Setpoint landing test: alternate between two safe targets and measure the
publish-to-HTTP-visible latency. Distinguishes per sample whether the
setpoint landed, whether the commanded mode (acMode) landed, and — with
``--verify-output`` — whether the physical output reacted, was prevented by
conditions (SOC at minimum), or did not react.
``--mode-test`` (additionally requires ``--confirm-mode-changes``)
Only applicable when the device is currently NOT in smart AC output mode:
proves the corrected atomic command path switches the required mode and
lands the target. The probe never forces a device out of output mode.
Restore: the complete initial power state (smartMode/acMode/outputLimit/
inputLimit) is captured before any write and restored through the production
property-write path on success, failure, timeout and interruption, then
verified over HTTP.
This tool writes real power values to real hardware. It must never run in
parallel with the live EMS (single writer); a contention check aborts on a
foreign writer. It respects the installation's write gates and dry_run posture,
never mutates config.json and starts no control loop.
Usage (run on the device host, with the EMS stopped):
python3 scripts/mqtt_write_latency_probe.py --dry-preview
python3 scripts/mqtt_write_latency_probe.py --confirm-writes --samples 12
python3 scripts/mqtt_write_latency_probe.py --confirm-writes --verify-output
python3 scripts/mqtt_write_latency_probe.py --mode-test --confirm-writes \\
--confirm-mode-changes
"""
import argparse
import json
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from ems.mqtt_control.command_state import property_matches # noqa: E402
from ems.device_identity import normalize_physical_serial # noqa: E402
from ems.external_status import mask_mqtt_topic, mask_route_identifier # noqa: E402
def bootstrap_config(config_arg):
"""Load config like the EMS runtime bootstrap, but never write to disk.
Mirrors ``ems.config.load_config`` minus ``perform_startup_config_upgrade`` (which
can rewrite ``config.json`` in ``apply`` mode). ``dry_run`` and the write gates are
populated on the module the same way the running EMS sees them (``dry_run`` defaults
to true = no writes) so that ``cfg.control_writes_allowed`` respects the operator's
real safety posture: a ``dry_run: true`` install refuses writes here too.
"""
from ems import config as cfg
from ems.paths import BASE_DIR, resolve_config_path
base_dir = BASE_DIR or "."
path = str(resolve_config_path(config_arg, base_dir=base_dir))
raw = cfg._read_raw_config(path)
runtime_config = cfg.apply_runtime_config_defaults(raw)
config = cfg.apply_template_placeholder_safety(
runtime_config, emit_message=lambda *_a, **_k: None
)
system = config.get("system", {}) if isinstance(config, dict) else {}
cfg.CONFIG = config
cfg.BASE_DIR = base_dir
cfg.ARGS = argparse.Namespace(
config=path,
replay=False,
simulate=False,
self_test=False,
no_ha=True,
dry_run=False,
once=False,
preflight=False,
duration=None,
max_cycles=None,
)
cfg.DRY_RUN = bool(system.get("dry_run", True))
cfg.SIMULATION_MODE = False
cfg.ALLOW_HARDWARE_WRITES = bool(system.get("allow_hardware_writes", False))
cfg.ALLOW_MQTT_LOCAL_CONTROL_WRITES = bool(
system.get("allow_mqtt_local_control_writes", False)
)
cfg.ALLOW_MQTT_ZENDURE_CONTROL_WRITES = bool(
system.get("allow_mqtt_zendure_control_writes", False)
)
max_device_power = system.get("max_device_power")
if isinstance(max_device_power, (int, float)) and max_device_power > 0:
cfg.MAX_DEVICE_POWER = int(max_device_power)
return cfg, config, path
def _last4(value):
text = str(value or "")
return text[-4:] if text else ""
def _redact_candidate(dev):
"""A secret-free identity summary of one candidate device for an error list."""
return (
f"name={getattr(dev, 'name', None)} "
f"source={getattr(dev, 'source', None)} "
f"hardware_profile={getattr(dev, 'hardware_profile', None)} "
f"broker_ref={getattr(dev, 'broker_ref', None)} "
f"serial=…{_last4(getattr(dev, 'sn', None))} "
f"device_id={mask_route_identifier(getattr(dev, '_device_id', None))}"
)
def _candidate_list(devices):
return "; ".join(_redact_candidate(d) for d in devices)
def _display_mqtt_topic(dev, topic):
"""Return a display-safe topic, failing closed for unknown cloud shapes."""
cloud_scoped = getattr(dev, "source", None) == "zendure_cloud_mqtt"
masked = mask_mqtt_topic(topic, cloud_scoped=cloud_scoped)
if cloud_scoped and topic and masked == topic:
return "<redacted-cloud-topic>"
return masked
def _clean_serial(value):
text = str(value).strip() if value is not None else ""
return text or None
def _serial_match_key(value):
"""Shared case-insensitive serial key; ``None`` for masked/empty values."""
return normalize_physical_serial(value)
def select_mqtt_device(runtime, *, name=None, serial=None, device_id=None,
broker_ref=None):
"""Select exactly one configured MQTT control device by explicit selectors.
Selection uses only configured selectors (``--device-name``/``--serial``/
``--device-id``/``--broker-ref``). A live HTTP-reported serial is never a
selector here — a Cloud route id and a physical serial are different identity
domains, so that decision is made separately by the binding step. Zero matches
is an error; more than one aborts with a redacted candidate list — the probe
never silently writes to the first of several matching devices.
"""
devices = list(getattr(runtime, "devices", []) or [])
if not devices:
return None, "no MQTT control device is configured (no write-capable zendure_mqtt entry)"
def matches(dev):
if name is not None and getattr(dev, "name", None) != name:
return False
if serial is not None and str(getattr(dev, "sn", "")) != str(serial):
return False
if device_id is not None and str(getattr(dev, "_device_id", "")) != str(device_id):
return False
if broker_ref is not None and getattr(dev, "broker_ref", None) != broker_ref:
return False
return True
candidates = [d for d in devices if matches(d)]
if len(candidates) == 1:
return candidates[0], None
if not candidates:
return None, (
"no MQTT control device matches the given selectors "
f"(--device/--serial/--device-id/--broker-ref). Devices: {_candidate_list(devices)}"
)
return None, (
f"ambiguous device selection: {len(candidates)} devices match. Narrow with "
f"--device-name/--serial/--device-id/--broker-ref. Candidates: "
f"{_candidate_list(candidates)}"
)
def select_by_physical_serial(runtime, http_serial):
"""Select the one device whose configured physical serial equals the readback.
Only a device with a configured physical serial can be matched by an
HTTP-reported serial. A serial-less device (its ``sn`` falls back to the Cloud
route id, a different identity domain) is never auto-selected this way and must
be identified with explicit route selectors.
"""
devices = list(getattr(runtime, "devices", []) or [])
if not devices:
return None, "no MQTT control device is configured (no write-capable zendure_mqtt entry)"
# Serial matching folds case (shared identity rule): the physical serial is
# the same identity whether the HTTP readback reports it upper- or lower-case.
target = _serial_match_key(http_serial)
candidates = [
d for d in devices
if (key := _serial_match_key(getattr(d, "physical_serial", None))) is not None
and key == target
]
if len(candidates) == 1:
return candidates[0], None
if not candidates:
return None, (
"no MQTT control device has a trusted physical serial matching the HTTP "
f"readback {mask_route_identifier(http_serial)}. A serial-less Cloud "
"device must be selected explicitly with "
f"--device-name/--device-id/--broker-ref. Devices: {_candidate_list(devices)}"
)
return None, (
f"ambiguous device selection: {len(candidates)} devices share this trusted "
"serial. Narrow with --device-name/--device-id/--broker-ref. "
f"Candidates: {_candidate_list(candidates)}"
)
BINDING_VERIFIED = "verified"
BINDING_CONFLICT = "serial_conflict"
BINDING_UNBOUND = "unbound_readback"
BINDING_UNVERIFIED = "unverified_readback"
@dataclass(frozen=True)
class HttpBinding:
"""Cross-transport binding verdict between the MQTT device and HTTP readback."""
status: str
configured_serial: str | None
http_serial: str | None
@property
def verified(self) -> bool:
return self.status == BINDING_VERIFIED
def summary(self) -> str:
http = mask_route_identifier(self.http_serial)
if self.status == BINDING_VERIFIED:
return f"verified (configured serial matches HTTP readback {http})"
if self.status == BINDING_CONFLICT:
return (
"CONFLICT (configured serial "
f"{mask_route_identifier(self.configured_serial)} != HTTP readback "
f"{http})"
)
if self.status == BINDING_UNVERIFIED:
return "unverified (no HTTP readback serial available to confirm identity)"
return f"unverified (physical serial not stored; HTTP readback reports {http})"
def write_block_reason(self, *, acknowledged, exact_selectors) -> str | None:
"""Reason the binding forbids a hardware write, or ``None`` when allowed."""
if self.status == BINDING_VERIFIED:
return None
if self.status == BINDING_CONFLICT:
return (
"cross-transport identity conflict: configured physical serial "
f"{mask_route_identifier(self.configured_serial)} does not match the "
f"HTTP readback serial {mask_route_identifier(self.http_serial)}; "
"refusing to write to a device with a contradictory identity."
)
if self.status == BINDING_UNVERIFIED:
return (
"cross-transport binding unverified: no HTTP readback serial was "
"available to confirm the selected device before writing."
)
if not exact_selectors:
return (
"serial-less Cloud device: an unbound HTTP readback requires exact "
"--device-name, --device-id and --broker-ref selectors so the Cloud "
"route is identified explicitly before any write."
)
if not acknowledged:
return (
"serial-less Cloud device: the Cloud route and the HTTP readback "
f"serial {mask_route_identifier(self.http_serial)} are different "
"identity domains and are not proven to be the same inverter. Pass "
"--confirm-unbound-api-readback to accept this readback for this run "
"(never persisted), or bind the physical serial via Admin discovery "
"first."
)
return None
def evaluate_http_binding(dev, http_serial):
"""Classify the binding between a selected MQTT device and an HTTP serial."""
# Original values are kept for display; equality folds case via the shared
# serial key, so a case-only difference is a verified match, not a conflict.
configured = _clean_serial(getattr(dev, "physical_serial", None))
http = _clean_serial(http_serial)
configured_key = _serial_match_key(getattr(dev, "physical_serial", None))
http_key = _serial_match_key(http_serial)
if configured is not None:
if http is None:
return HttpBinding(BINDING_UNVERIFIED, configured, None)
if configured_key is not None and configured_key == http_key:
return HttpBinding(BINDING_VERIFIED, configured, http)
return HttpBinding(BINDING_CONFLICT, configured, http)
if http is None:
return HttpBinding(BINDING_UNVERIFIED, None, None)
return HttpBinding(BINDING_UNBOUND, None, http)
def resolve_mqtt_device(runtime, name, *, serial=None, device_id=None, broker_ref=None):
"""Pick the single MQTT control device to write to; fail closed on ambiguity."""
devices = list(getattr(runtime, "devices", []) or [])
if name is None and serial is None and device_id is None and broker_ref is None:
if len(devices) == 1:
return devices[0], None
if not devices:
return None, "no MQTT control device is configured (no write-capable zendure_mqtt entry)"
return None, (
"multiple MQTT control devices; pass --device-name/--serial/--device-id/"
f"--broker-ref. Candidates: {_candidate_list(devices)}"
)
return select_mqtt_device(
runtime, name=name, serial=serial, device_id=device_id, broker_ref=broker_ref
)
def resolve_http_reader(cfg, config, session, api_device, api_ip, mqtt_dev):
"""Build a ZendureClient that reads power state from the local HTTP API.
The read target is the same physical inverter as the MQTT write target. It is
resolved from ``--api-ip``, from ``--api-device`` (an HTTP device entry name), or
by matching the MQTT device's serial number against the HTTP device entries.
"""
from ems.clients import ZendureClient
http_configs = cfg.http_control_device_configs(config.get("devices"))
if api_ip:
return ZendureClient(
mqtt_dev.name if mqtt_dev else "api",
api_ip,
mqtt_dev.sn if mqtt_dev else None,
session,
0,
0,
1,
None,
), None
chosen = None
if api_device:
chosen = next((d for d in http_configs if d.get("name") == api_device), None)
if chosen is None:
names = ", ".join(sorted(str(d.get("name")) for d in http_configs))
return None, f"no HTTP device named {api_device!r} (available: {names})"
elif mqtt_dev is not None and mqtt_dev.sn:
chosen = next((d for d in http_configs if d.get("sn") == mqtt_dev.sn), None)
if chosen is None:
return None, (
"could not match an HTTP API device to the MQTT device; "
"pass --api-device NAME or --api-ip IP"
)
if not chosen.get("ip"):
return None, f"HTTP device {chosen.get('name')!r} has no ip"
return ZendureClient(
chosen.get("name"),
chosen.get("ip"),
chosen.get("sn"),
session,
chosen.get("min_soc", 0),
chosen.get("max_soc", 0),
chosen.get("smart_mode", 1),
chosen.get("grid_off_mode"),
chosen.get("max_power"),
), None
def fetch_api_report(ip, session):
"""GET the inverter's local HTTP ``/properties/report`` as raw JSON, or None."""
try:
response = session.get(f"http://{ip}/properties/report", timeout=3)
return response.json()
except Exception:
return None
def serial_from_report(report):
"""Extract the device serial number from an HTTP ``/properties/report`` payload."""
if not isinstance(report, dict):
return None
sources = [report]
properties = report.get("properties")
if isinstance(properties, dict):
sources.append(properties)
for source in sources:
for key in ("sn", "serialNumber", "deviceSn"):
value = source.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def resolve_by_api_ip(runtime, session, api_ip, device_name, *, serial=None,
device_id=None, broker_ref=None):
"""Anchor on the HTTP API IP: read its serial, then select the MQTT device.
Returns ``(dev, reader, resolved_serial, err)``. Explicit configured selectors
identify the device; absent any selector the HTTP serial may auto-select only a
device with a matching *trusted* physical serial. A serial-less Cloud device is
never auto-bound to the HTTP serial — that binding is decided (and, when
unverified, gated) separately by ``evaluate_http_binding``. Two devices sharing
a serial abort with a redacted candidate list rather than silently selecting
the first.
"""
from ems.clients import ZendureClient
report = fetch_api_report(api_ip, session)
if report is None:
return None, None, None, (
f"could not read http://{api_ip}/properties/report "
"(is the inverter's local HTTP API reachable at this IP?)"
)
reported_serial = serial_from_report(report)
if not reported_serial:
return None, None, None, (
f"no serial number in the API report from {api_ip}; cannot match a device"
)
explicit = any(
value is not None for value in (device_name, serial, device_id, broker_ref)
)
if explicit:
dev, err = select_mqtt_device(
runtime, name=device_name, serial=serial, device_id=device_id,
broker_ref=broker_ref,
)
else:
dev, err = select_by_physical_serial(runtime, reported_serial)
if err:
return None, None, None, err
reader = ZendureClient(dev.name, api_ip, reported_serial, session, 0, 0, 1, None)
return dev, reader, reported_serial, None
def read_power_state(reader):
"""Return the device's current power-relevant state as a dict, or None.
A writable property whose HTTP report value is absent (``None``) is omitted
rather than defaulted, so mode verification can honestly report a property the
device does not expose instead of confirming against a fabricated default.
"""
state = reader.fetch()
if state is None:
return None
result = {
"outputHomePower": int(state.output),
"soc": float(state.soc),
"minSoc": float(state.min_soc),
"solarInputPower": float(state.solar),
"gridState": int(state.grid_state),
}
for key, attr in (
("smartMode", "smart_mode"),
("acMode", "ac_mode"),
("outputLimit", "output_limit"),
("inputLimit", "input_limit_w"),
):
value = getattr(state, attr, None)
if value is not None:
result[key] = int(value)
return result
def read_output_limit(reader):
"""Return the device's current ``outputLimit`` in watts, or None on read failure."""
state = read_power_state(reader)
return None if state is None else state.get("outputLimit")
def initial_restore_state(ip, session, required_properties):
"""Capture every operation-derived property from the initial HTTP report.
Missing values stay missing. The live preflight treats each omission as a
hard failure; this reader must never invent a restorable value.
"""
report = fetch_api_report(ip, session)
if not isinstance(report, dict):
return None
properties = report.get("properties")
if not isinstance(properties, dict):
properties = report
captured = {}
for key in required_properties:
value = properties.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
continue
captured[key] = int(value)
return captured
def pick_target(current, values):
"""Choose the next setpoint: the configured value furthest from ``current``.
Alternating to the value with the largest delta keeps every sample a clear,
detectable change even if the device rounds or clamps the setpoint slightly.
"""
if current is None:
return values[0]
return max(values, key=lambda v: (abs(v - current), v))
def moved(observed, reference, tolerance):
"""Whether ``observed`` has moved away from ``reference`` beyond ``tolerance``."""
if observed is None or reference is None:
return False
return abs(observed - reference) > tolerance
@dataclass
class Sample:
index: int
target: int
baseline: int
command_started_monotonic: float
locally_accepted: bool
# Time spent handing the publish to the local MQTT client.
local_submit_duration_ms: float
# Observed broker PUBACK delivery (delivered/timeout/untracked/pending).
broker_delivery_status: str
broker_delivery_from_submit_ms: float | None
# HTTP-visible setpoint match relative to the same pre-submit origin.
setpoint_match_from_submit_ms: float | None
last_observed_output_limit: int | None
# Movement away from baseline is diagnostics only — never setpoint landing.
movement_observed: bool
matched_target: bool
timed_out: bool
polls: int
mode_ok: bool | None = None
physical: str | None = None
physical_reaction_from_submit_ms: float | None = None
physical_reaction_after_setpoint_ms: float | None = None
@dataclass(frozen=True)
class OperationContract:
"""Factual state-change contract extracted from a production-built command."""
target_w: int
operation: str | None
message_id: object
topic: str
qos: int
retain: bool
modified_properties: dict
expected_properties: dict
@property
def restorable_properties(self):
# The probe permits only an exact properties/write operation. Every field
# in that atomic write may change and therefore requires an initial value.
return tuple(self.modified_properties)
@dataclass
class WriteActivity:
"""Local transport truth used solely to decide whether restoration is needed."""
attempted: bool = False
locally_accepted: bool = False
accepted_command_ids: list[str] = field(default_factory=list)
accepted_modified_properties: list[str] = field(default_factory=list)
# True only when HTTP observed the latest accepted command transition to its
# target. A later accepted publish resets it until that command is observed.
latest_accepted_state_observed: bool = False
def record_submission(self, message_id, accepted, modified_properties=()):
self.attempted = True
if not accepted:
return
self.locally_accepted = True
self.latest_accepted_state_observed = False
command_id = str(message_id)
if command_id not in self.accepted_command_ids:
self.accepted_command_ids.append(command_id)
for key in modified_properties:
if key not in self.accepted_modified_properties:
self.accepted_modified_properties.append(key)
def record_latest_state_observed(self):
if self.locally_accepted:
self.latest_accepted_state_observed = True
@dataclass(frozen=True)
class TargetSubmission:
"""One local publish result and its common evidence-timeline origin."""
command_started_monotonic: float
locally_accepted: bool
local_submit_duration_ms: float
mid: int | None
delivery_reference: object | None
message_id: object
expected_properties: dict
modified_properties: tuple[str, ...]
@dataclass(frozen=True)
class CommandEvidence:
broker_delivery_status: str
broker_delivery_from_submit_ms: float | None
setpoint_match_from_submit_ms: float | None
last_observed_output_limit: int | None
movement_observed: bool
polls: int
mode_ok: bool | None
physical: str | None
physical_reaction_from_submit_ms: float | None
physical_reaction_after_setpoint_ms: float | None
last_power_state: dict | None
def wait_for_broker(dev, timeout_s):
"""Block until the device's broker service reports connected, or time out."""
deadline = time.monotonic() + timeout_s
service = dev._service
while time.monotonic() < deadline:
if bool(getattr(service, "connected", False)):
return True
time.sleep(0.2)
return bool(getattr(service, "connected", False))
def build_command(dev, target):
"""Build the model-correct production command without publishing it."""
message, message_id, operation, expected = dev._build_write(target)
return message, message_id, operation, expected
def _message_modified_properties(message):
"""Return the exact atomic ``properties`` mapping, or fail closed."""
try:
payload = json.loads(message.payload)
except (TypeError, ValueError, UnicodeDecodeError) as exc:
raise RuntimeError(
"production command has no inspectable property operation contract"
) from exc
properties = payload.get("properties") if isinstance(payload, dict) else None
if not isinstance(properties, dict) or not properties:
raise RuntimeError(
"production command does not expose the complete modified-property set"
)
if any(not isinstance(key, str) or not key for key in properties):
raise RuntimeError("production command contains an invalid property name")
return dict(properties)
def build_operation_contract(dev, target):
"""Extract the exact modified-property set from a production-built command.
The probe intentionally does not keep a parallel model/property registry. A
command is probe-safe only when the production builder emits an atomic
``properties`` mapping whose complete effects can be captured and restored.
Function/invoke or otherwise opaque operations fail closed here.
"""
message, message_id, operation, expected = build_command(dev, target)
properties = _message_modified_properties(message)
expected_properties = dict(expected) if isinstance(expected, dict) else {}
return OperationContract(
target_w=target,
operation=operation,
message_id=message_id,
topic=message.topic,
qos=message.qos,
retain=bool(message.retain),
modified_properties=properties,
expected_properties=expected_properties,
)
def build_operation_contracts(dev, targets):
"""Build de-duplicated exact contracts for every command the probe may emit."""
contracts = []
seen = set()
for target in targets:
if target in seen:
continue
seen.add(target)
contracts.append(build_operation_contract(dev, target))
if not contracts:
raise RuntimeError("no state-changing operation was planned")
return tuple(contracts)
def required_restore_properties(contracts):
"""Return the stable union of fields any planned operation may modify."""
return tuple(
sorted(
{
key
for contract in contracts
for key in contract.restorable_properties
}
)
)
def publish_target(dev, target, *, activity=None, now=time.monotonic):
"""Submit one production command and return explicit local transport truth.
The monotonic origin is captured immediately before ``_publish_message`` and
is shared by local-submit, PUBACK, HTTP setpoint and physical evidence. A
builder failure occurs before the activity is marked attempted. A locally
accepted publish requires restoration even when broker delivery is unknown.
"""
message, message_id, _operation, expected = build_command(dev, target)
if message.retain:
raise RuntimeError("refusing to publish a retained control command")
modified_properties = tuple(_message_modified_properties(message))
command_started = now()
try:
submission = dev._publish_message(message)
except Exception:
if activity is not None:
activity.record_submission(message_id, False, modified_properties)
raise
local_submit_ms = (now() - command_started) * 1000.0
accepted = bool(getattr(submission, "accepted", submission))
if activity is not None:
activity.record_submission(message_id, accepted, modified_properties)
mid = getattr(submission, "mid", None)
delivery_reference = getattr(submission, "delivery_token", None) or mid
return TargetSubmission(
command_started_monotonic=command_started,
locally_accepted=accepted,
local_submit_duration_ms=local_submit_ms,
mid=mid,
delivery_reference=delivery_reference,
message_id=message_id,
expected_properties=dict(expected) if isinstance(expected, dict) else {},
modified_properties=modified_properties,
)
def classify_physical(power_state, target, tolerance):
"""Classify the physical reaction after a landed setpoint (honest, bounded)."""
if power_state is None:
return "unknown"
output = power_state["outputHomePower"]
if target == 0:
return "output_reacted" if output <= tolerance else "not_reacted"
if abs(output - target) <= tolerance:
return "output_reacted"
if power_state["soc"] <= power_state["minSoc"]:
return "no_output_possible_soc_at_minimum"
return "not_reacted"
def _observe_command_evidence(
dev,
reader,
submission,
baseline_state,
target,
opts,
*,
sleep=time.sleep,
now=time.monotonic,
progress=None,
):
"""Interleave PUBACK and HTTP/physical observation on one bounded timeline."""
if progress is None:
progress = _no_progress
origin = submission.command_started_monotonic
service = getattr(dev, "_service", None)
confirmed = getattr(service, "delivery_confirmed", None)
delivery_state = getattr(service, "delivery_status", None)
if submission.delivery_reference is None or not callable(confirmed):
delivery_status = (
"pending"
if submission.delivery_reference is not None
and callable(delivery_state)
else "untracked"
)
else:
delivery_status = "pending"
delivery_ms = None
delivery_deadline = origin + opts.connect_timeout
setpoint_deadline = origin + opts.timeout
setpoint_done = False
setpoint_at = None
baseline_state = baseline_state if isinstance(baseline_state, dict) else {}
baseline = baseline_state.get("outputLimit")
observed = baseline
movement_observed = False
polls = 0
mode_ok = None
physical = None
physical_at = None
physical_deadline = None
last_physical_verdict = None
last_power_state = None
physical_target_was_absent = (
classify_physical(baseline_state, target, opts.output_tolerance)
!= "output_reacted"
)
while True:
current_time = now()
if delivery_status == "pending":
reported_status = None
if callable(delivery_state):
try:
reported_status = str(
delivery_state(submission.delivery_reference)
).strip().lower()
except Exception:
reported_status = None
if reported_status in {"delivered", "disconnected", "expired"}:
delivery_status = reported_status
if reported_status == "delivered":
delivery_ms = max(0.0, (current_time - origin) * 1000.0)
try:
delivered = bool(
delivery_status == "pending"
and callable(confirmed)
and confirmed(submission.delivery_reference)
)
except Exception:
delivered = False
delivery_status = "tracking_error"
if delivered:
delivery_status = "delivered"
delivery_ms = max(0.0, (current_time - origin) * 1000.0)
elif current_time >= delivery_deadline and delivery_status == "pending":
delivery_status = "timeout"
http_pending = not setpoint_done or (
opts.verify_output and setpoint_at is not None and physical is None
)
if http_pending:
power_state = read_power_state(reader)
observed_at = now()
polls += 1
if power_state is not None:
last_power_state = dict(power_state)
observed = power_state.get("outputLimit")
elapsed_s = max(0.0, observed_at - origin)
progress(f" t+{elapsed_s:.1f}s outputLimit={observed}W")
if moved(observed, baseline, opts.match_tolerance):
movement_observed = True
if (
not setpoint_done
and observed is not None
and abs(observed - target) <= opts.match_tolerance
):
setpoint_done = True
setpoint_at = observed_at
mode_ok = _mode_matches(
submission.expected_properties,
power_state,
opts.match_tolerance,
)
if opts.verify_output:
# Preserve the configured post-setpoint verification window,
# while the reported primary duration remains from submit.
physical_deadline = observed_at + opts.output_timeout
if opts.verify_output and physical is None:
last_physical_verdict = classify_physical(
power_state, target, opts.output_tolerance
)
progress(
" output check: "
f"outputHomePower={power_state['outputHomePower']}W -> "
f"{last_physical_verdict}"
)
if last_physical_verdict != "output_reacted":
physical_target_was_absent = True
if (
last_physical_verdict == "output_reacted"
and physical_target_was_absent
):
# Physical output can lead the HTTP outputLimit field.
# Preserve its first attributable observation on the
# same pre-submit timeline instead of delaying it until
# the setpoint endpoint catches up.
physical = "output_reacted"
physical_at = observed_at
elif (
setpoint_at is not None
and last_physical_verdict
== "no_output_possible_soc_at_minimum"
):
physical = last_physical_verdict
current_time = now()
# A match observed exactly at the deadline is evidence; timeout only after
# giving that observation a chance to settle the setpoint.
if not setpoint_done and current_time >= setpoint_deadline:
setpoint_done = True
if opts.verify_output and physical is None:
physical = "setpoint_not_matched"
if (
opts.verify_output
and setpoint_at is not None
and physical is None
and physical_deadline is not None
and current_time >= physical_deadline
):
if (
last_physical_verdict == "output_reacted"
and not physical_target_was_absent
):
physical = "baseline_already_at_target"
else:
physical = last_physical_verdict or "unknown"
physical_done = not opts.verify_output or physical is not None
if delivery_status != "pending" and setpoint_done and physical_done:
break
deadlines = []
if delivery_status == "pending":
deadlines.append(delivery_deadline)
if not setpoint_done:
deadlines.append(setpoint_deadline)
if opts.verify_output and setpoint_at is not None and physical is None:
deadlines.append(physical_deadline)
next_sleep = opts.poll_interval
if deadlines:
next_sleep = min(next_sleep, max(0.0, min(deadlines) - current_time))
# A zero interval at a deadline is settled at the top of the next turn.
if next_sleep <= 0:
continue
sleep(next_sleep)
setpoint_ms = (
None if setpoint_at is None else max(0.0, (setpoint_at - origin) * 1000.0)
)
physical_from_submit_ms = (
None if physical_at is None else max(0.0, (physical_at - origin) * 1000.0)
)
physical_after_setpoint_ms = None
if physical_at is not None and setpoint_at is not None and physical_at >= setpoint_at:
physical_after_setpoint_ms = (physical_at - setpoint_at) * 1000.0
return CommandEvidence(
broker_delivery_status=delivery_status,
broker_delivery_from_submit_ms=delivery_ms,
setpoint_match_from_submit_ms=setpoint_ms,
last_observed_output_limit=observed,
movement_observed=movement_observed,
polls=polls,
mode_ok=mode_ok,
physical=physical,
physical_reaction_from_submit_ms=physical_from_submit_ms,
physical_reaction_after_setpoint_ms=physical_after_setpoint_ms,
last_power_state=last_power_state,
)
def run_probe(
dev,
reader,
values,
opts,
*,
activity=None,