ebus-sdk 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ebus_sdk-0.2.0/src/ebus_sdk.egg-info → ebus_sdk-0.2.2}/PKG-INFO +1 -1
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/pyproject.toml +1 -1
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/src/ebus_sdk/__init__.py +3 -1
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/src/ebus_sdk/homie.py +43 -2
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2/src/ebus_sdk.egg-info}/PKG-INFO +1 -1
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/tests/test_homie_device.py +102 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/LICENSE +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/README.md +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/setup.cfg +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/setup.py +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/src/ebus_sdk/property.py +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/src/ebus_sdk.egg-info/SOURCES.txt +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/src/ebus_sdk.egg-info/dependency_links.txt +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/src/ebus_sdk.egg-info/requires.txt +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/src/ebus_sdk.egg-info/top_level.txt +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/tests/test_controller.py +0 -0
- {ebus_sdk-0.2.0 → ebus_sdk-0.2.2}/tests/test_property.py +0 -0
|
@@ -28,6 +28,7 @@ from .homie import EBUS_HOMIE_MQTT_QOS, HOMIE_EFFECTIVE_STATE_TABLE
|
|
|
28
28
|
from .homie import (
|
|
29
29
|
datatype_from_type,
|
|
30
30
|
ebus_cfg_add_auth,
|
|
31
|
+
sanitize_homie_id,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
# Property abstractions
|
|
@@ -42,7 +43,7 @@ from .property import (
|
|
|
42
43
|
# MQTT client
|
|
43
44
|
from ebus_mqtt_client import MqttClient
|
|
44
45
|
|
|
45
|
-
__version__ = "0.2.
|
|
46
|
+
__version__ = "0.2.2"
|
|
46
47
|
|
|
47
48
|
__all__ = [
|
|
48
49
|
# Homie classes
|
|
@@ -62,6 +63,7 @@ __all__ = [
|
|
|
62
63
|
# Utilities
|
|
63
64
|
"datatype_from_type",
|
|
64
65
|
"ebus_cfg_add_auth",
|
|
66
|
+
"sanitize_homie_id",
|
|
65
67
|
# Property abstractions
|
|
66
68
|
"ObservableProperty",
|
|
67
69
|
"GroupedPropertyDict",
|
|
@@ -39,6 +39,7 @@ import asyncio
|
|
|
39
39
|
import json
|
|
40
40
|
import logging
|
|
41
41
|
import os
|
|
42
|
+
import re
|
|
42
43
|
import time
|
|
43
44
|
import uuid
|
|
44
45
|
from enum import Enum
|
|
@@ -134,6 +135,40 @@ class PropertyDatatype(StrEnum):
|
|
|
134
135
|
JSON = "json"
|
|
135
136
|
|
|
136
137
|
|
|
138
|
+
def sanitize_homie_id(value: Optional[str]) -> str:
|
|
139
|
+
"""Coerce an arbitrary string to a Homie-legal id segment (a-z, 0-9, -).
|
|
140
|
+
|
|
141
|
+
The Homie 5 spec requires device-ids and node/property ids to match
|
|
142
|
+
``[a-z][a-z0-9-]*``. This helper coerces vendor-supplied strings
|
|
143
|
+
(serial numbers, model names, etc.) to a legal form by:
|
|
144
|
+
|
|
145
|
+
1. lowercasing
|
|
146
|
+
2. replacing underscores, whitespace, and dots with hyphens
|
|
147
|
+
3. dropping any other character outside ``[a-z0-9-]``
|
|
148
|
+
4. collapsing runs of hyphens
|
|
149
|
+
5. stripping leading/trailing hyphens
|
|
150
|
+
|
|
151
|
+
Empty input (``None`` or empty string) returns an empty string. The
|
|
152
|
+
caller is responsible for handling the empty-string case — e.g. by
|
|
153
|
+
falling back to a synthesized id when the sanitized form is empty,
|
|
154
|
+
or by raising if the value was required to be non-empty.
|
|
155
|
+
|
|
156
|
+
Composing device-ids from multiple vendor-supplied segments (e.g.
|
|
157
|
+
``f"{panel_serial}-{bess_serial}"``) MUST apply this helper to each
|
|
158
|
+
segment independently, so that publisher and consumer always agree
|
|
159
|
+
on the resulting Homie-legal id. Composing first and sanitizing the
|
|
160
|
+
composite is not equivalent — a hyphen-joiner can be collapsed if an
|
|
161
|
+
adjacent segment ends/begins with characters that drop out.
|
|
162
|
+
"""
|
|
163
|
+
if not value:
|
|
164
|
+
return ""
|
|
165
|
+
result = value.lower()
|
|
166
|
+
result = re.sub(r"[_\s.]+", "-", result)
|
|
167
|
+
result = re.sub(r"[^a-z0-9-]", "", result)
|
|
168
|
+
result = re.sub(r"-+", "-", result)
|
|
169
|
+
return result.strip("-")
|
|
170
|
+
|
|
171
|
+
|
|
137
172
|
def datatype_from_type(type: Type) -> Optional[PropertyDatatype]:
|
|
138
173
|
"""
|
|
139
174
|
Returns Homie PropertyDatatype from Python type
|
|
@@ -1386,7 +1421,9 @@ class Device:
|
|
|
1386
1421
|
f"nodeCount={len(self._nodes)},childCount={len(self._children)}"
|
|
1387
1422
|
)
|
|
1388
1423
|
self._publish_self()
|
|
1389
|
-
|
|
1424
|
+
# Snapshot — main thread may construct child devices (which append
|
|
1425
|
+
# to self._children) while this runs on the MQTT loop thread.
|
|
1426
|
+
for child in list(self._children):
|
|
1390
1427
|
child.refresh_tree()
|
|
1391
1428
|
|
|
1392
1429
|
def publish(self, attribute: str = "", value: Optional[Any] = None) -> None:
|
|
@@ -1463,7 +1500,11 @@ class Device:
|
|
|
1463
1500
|
self.publish("$description")
|
|
1464
1501
|
|
|
1465
1502
|
def publish_nodes(self) -> None:
|
|
1466
|
-
|
|
1503
|
+
# Snapshot — invoked from on_connect() on the MQTT loop thread while
|
|
1504
|
+
# the main thread may be inside state_transition() calling add_node().
|
|
1505
|
+
# Without the snapshot, dict-size-changed-during-iteration crashes the
|
|
1506
|
+
# MQTT thread on initial connect.
|
|
1507
|
+
for node in list(self._nodes.values()):
|
|
1467
1508
|
node.publish()
|
|
1468
1509
|
|
|
1469
1510
|
def on_connect(self) -> None:
|
|
@@ -15,6 +15,7 @@ from ebus_sdk.homie import (
|
|
|
15
15
|
Unit,
|
|
16
16
|
datatype_from_type,
|
|
17
17
|
ebus_cfg_add_auth,
|
|
18
|
+
sanitize_homie_id,
|
|
18
19
|
EBUS_HOMIE_DOMAIN,
|
|
19
20
|
EBUS_HOMIE_MQTT_QOS,
|
|
20
21
|
EBUS_HOMIE_VERSION_MAJOR,
|
|
@@ -93,6 +94,61 @@ class TestDatatypeFromType:
|
|
|
93
94
|
assert datatype_from_type(list) is None
|
|
94
95
|
|
|
95
96
|
|
|
97
|
+
# ── sanitize_homie_id ────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestSanitizeHomieId:
|
|
101
|
+
def test_empty_string(self):
|
|
102
|
+
assert sanitize_homie_id("") == ""
|
|
103
|
+
|
|
104
|
+
def test_none(self):
|
|
105
|
+
assert sanitize_homie_id(None) == ""
|
|
106
|
+
|
|
107
|
+
def test_already_legal(self):
|
|
108
|
+
assert sanitize_homie_id("device-1") == "device-1"
|
|
109
|
+
|
|
110
|
+
def test_lowercases(self):
|
|
111
|
+
# Tesla Powerwall serial — the real bug from G3P-23496
|
|
112
|
+
assert sanitize_homie_id("TG121153003K7G") == "tg121153003k7g"
|
|
113
|
+
|
|
114
|
+
def test_underscore_to_hyphen(self):
|
|
115
|
+
assert sanitize_homie_id("my_device_id") == "my-device-id"
|
|
116
|
+
|
|
117
|
+
def test_whitespace_to_hyphen(self):
|
|
118
|
+
assert sanitize_homie_id("my device id") == "my-device-id"
|
|
119
|
+
|
|
120
|
+
def test_dot_to_hyphen(self):
|
|
121
|
+
assert sanitize_homie_id("v1.2.3") == "v1-2-3"
|
|
122
|
+
|
|
123
|
+
def test_drops_illegal_chars(self):
|
|
124
|
+
# Slashes, plus signs, etc. drop out entirely
|
|
125
|
+
assert sanitize_homie_id("a/b+c") == "abc"
|
|
126
|
+
|
|
127
|
+
def test_collapses_runs_of_hyphens(self):
|
|
128
|
+
assert sanitize_homie_id("a---b") == "a-b"
|
|
129
|
+
|
|
130
|
+
def test_collapses_mixed_separators(self):
|
|
131
|
+
# Underscore + space + dot all become hyphens, then collapsed
|
|
132
|
+
assert sanitize_homie_id("a_ .b") == "a-b"
|
|
133
|
+
|
|
134
|
+
def test_strips_leading_trailing_hyphens(self):
|
|
135
|
+
assert sanitize_homie_id("-abc-") == "abc"
|
|
136
|
+
|
|
137
|
+
def test_strips_leading_trailing_from_separators(self):
|
|
138
|
+
assert sanitize_homie_id(" abc ") == "abc"
|
|
139
|
+
|
|
140
|
+
def test_all_illegal_collapses_to_empty(self):
|
|
141
|
+
# Caller is responsible for handling empty result
|
|
142
|
+
assert sanitize_homie_id("///+++") == ""
|
|
143
|
+
|
|
144
|
+
def test_only_separators_collapses_to_empty(self):
|
|
145
|
+
assert sanitize_homie_id("___") == ""
|
|
146
|
+
|
|
147
|
+
def test_complex_composition(self):
|
|
148
|
+
# Vendor-supplied composite: capitals, underscore, dot, illegal char
|
|
149
|
+
assert sanitize_homie_id("My_Device.v1/2") == "my-device-v12"
|
|
150
|
+
|
|
151
|
+
|
|
96
152
|
# ── Unit enum ────────────────────────────────────────────────────────────
|
|
97
153
|
|
|
98
154
|
|
|
@@ -1168,6 +1224,25 @@ class TestDevicePublish:
|
|
|
1168
1224
|
device.publish_nodes()
|
|
1169
1225
|
mock_node.publish.assert_called_once()
|
|
1170
1226
|
|
|
1227
|
+
def test_publish_nodes_snapshots_against_concurrent_add(self, mock_paho):
|
|
1228
|
+
"""SDK-e3k: publish_nodes() must snapshot self._nodes so the main
|
|
1229
|
+
thread adding a node mid-iteration doesn't raise
|
|
1230
|
+
'dictionary changed size during iteration' on the MQTT loop thread."""
|
|
1231
|
+
device, _ = _make_device(mock_paho)
|
|
1232
|
+
|
|
1233
|
+
# Simulate the race: while iterating, one node's publish() mutates
|
|
1234
|
+
# the underlying dict (as the main thread's add_node would).
|
|
1235
|
+
racing_node = MagicMock()
|
|
1236
|
+
|
|
1237
|
+
def mutate_during_publish():
|
|
1238
|
+
device._nodes["late-arrival"] = MagicMock()
|
|
1239
|
+
|
|
1240
|
+
racing_node.publish.side_effect = mutate_during_publish
|
|
1241
|
+
device._nodes = {"core": racing_node}
|
|
1242
|
+
|
|
1243
|
+
# Without the list() snapshot fix, this raises RuntimeError.
|
|
1244
|
+
device.publish_nodes()
|
|
1245
|
+
|
|
1171
1246
|
|
|
1172
1247
|
class TestDeviceOnConnect:
|
|
1173
1248
|
def test_initial_connection(self, mock_paho):
|
|
@@ -1302,6 +1377,33 @@ class TestDeviceRefreshTree:
|
|
|
1302
1377
|
)
|
|
1303
1378
|
assert any(f"/{device_id}/$state" in t for t in topics), f"missing $state for {device_id} in {topics}"
|
|
1304
1379
|
|
|
1380
|
+
def test_refresh_tree_snapshots_against_concurrent_child_add(self, mock_paho):
|
|
1381
|
+
"""SDK-e3k: refresh_tree() must snapshot self._children so a child
|
|
1382
|
+
appended by the main thread mid-cascade isn't pulled into the current
|
|
1383
|
+
republish on the MQTT loop thread. (Lists don't raise on
|
|
1384
|
+
mutation-during-iteration the way dicts do, but processing a
|
|
1385
|
+
half-constructed child is its own correctness hazard.)"""
|
|
1386
|
+
root, _ = _make_device(mock_paho, device_id="panel-1")
|
|
1387
|
+
existing_child = Device(id="circuit-a", parent=root)
|
|
1388
|
+
late_arrival = MagicMock(spec=Device)
|
|
1389
|
+
|
|
1390
|
+
original_refresh = existing_child.refresh_tree
|
|
1391
|
+
|
|
1392
|
+
def mutate_during_refresh():
|
|
1393
|
+
# Simulate the main thread appending a new child while the MQTT
|
|
1394
|
+
# thread is mid-cascade.
|
|
1395
|
+
root._children.append(late_arrival)
|
|
1396
|
+
original_refresh()
|
|
1397
|
+
|
|
1398
|
+
existing_child.refresh_tree = mutate_during_refresh
|
|
1399
|
+
|
|
1400
|
+
root.refresh_tree()
|
|
1401
|
+
|
|
1402
|
+
# Snapshot semantics: late_arrival was appended after iteration began,
|
|
1403
|
+
# so it must NOT be touched by this refresh cycle. Without the
|
|
1404
|
+
# list() snapshot, CPython's list iterator picks it up.
|
|
1405
|
+
late_arrival.refresh_tree.assert_not_called()
|
|
1406
|
+
|
|
1305
1407
|
def test_refresh_tree_three_levels(self, mock_paho):
|
|
1306
1408
|
"""S2 + S6: grandchildren also republish on reconnect."""
|
|
1307
1409
|
root, mock_client = _make_device(mock_paho, device_id="panel-1")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|