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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebus-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python SDK for Homie MQTT Convention (eBus)
5
5
  Author: Clark Communications Corporation
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ebus-sdk"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Python SDK for Homie MQTT Convention (eBus)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.0"
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
- for child in self._children:
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
- for node in self._nodes.values():
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebus-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python SDK for Homie MQTT Convention (eBus)
5
5
  Author: Clark Communications Corporation
6
6
  License-Expression: MIT
@@ -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