Skip to content

Commit 8d62753

Browse files
tomquistclaude
andauthored
Add AstraMeter device connections and improve device naming (#311)
* HA discovery: brand meters as AstraMeter, link consumers via astrameter connection - CT002 / Shelly meter device names now include "AstraMeter". - Meter and consumer/battery devices share an `astrameter` connection tuple so Home Assistant can correlate consumers with their meter. * Revert changelog edit * Use via_device instead of astrameter connection to link consumers to meter * Link meter devices to add-on via_device using supervisor slug Resolve the add-on slug from the supervisor (`/store/addons/self`) in run.sh and forward it as `ADDON_SLUG` in the `[MQTT_INSIGHTS]` section. The MQTT Insights service then sets `via_device: <addon_slug>` on the CT002 and Shelly meter discovery payloads so Home Assistant nests them under the AstraMeter add-on device. * Address review nits on add-on slug handling - run.sh: query the canonical /addons/self/info endpoint and parse the slug with jq instead of relying on the /store/addons/self route. - config_loader: strip ADDON_SLUG before falling back to None so whitespace-only values do not become a bogus slug. - tests: assert addon_slug is None for default/empty configs and add a whitespace-only regression test. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent af3334a commit 8d62753

5 files changed

Lines changed: 83 additions & 16 deletions

File tree

ha_addon/run.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,27 @@ else
143143
echo "POWER_MULTIPLIER=$power_multiplier"
144144
fi
145145

146+
# Fetch this add-on's slug from the supervisor so MQTT discovery can
147+
# link discovered meter devices to the add-on device via_device.
148+
addon_slug=""
149+
addon_info_json=""
150+
if addon_info_json="$(bashio::api.supervisor GET '/addons/self/info' false)" && [ -n "$addon_info_json" ]; then
151+
addon_slug="$(echo "$addon_info_json" | jq -r '.slug // empty')"
152+
fi
153+
if [ -n "$addon_slug" ]; then
154+
bashio::log.info "Resolved add-on slug for HA discovery: $addon_slug"
155+
else
156+
bashio::log.warning "Failed to resolve add-on slug from supervisor; meter devices will not be linked via_device"
157+
addon_slug=""
158+
fi
159+
146160
if bashio::config.has_value 'mqtt_uri'; then
147161
bashio::log.info "Using custom MQTT broker URL from configuration"
148162
echo ""
149163
echo "[MQTT_INSIGHTS]"
150164
echo "URI=$(bashio::config 'mqtt_uri')"
151165
echo "HA_DISCOVERY=True"
166+
[ -n "$addon_slug" ] && echo "ADDON_SLUG=$addon_slug"
152167
elif bashio::services.available "mqtt"; then
153168
bashio::log.info "Using Home Assistant's internal MQTT broker"
154169
echo ""
@@ -159,6 +174,7 @@ else
159174
echo "PASSWORD=$(bashio::services 'mqtt' 'password')"
160175
echo "TLS=$(bashio::services 'mqtt' 'ssl')"
161176
echo "HA_DISCOVERY=True"
177+
[ -n "$addon_slug" ] && echo "ADDON_SLUG=$addon_slug"
162178
fi
163179
} > "$CONFIG"
164180
fi

src/astrameter/config/config_loader.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,5 +570,8 @@ def read_mqtt_insights_config(
570570
section, "HA_DISCOVERY_PREFIX", fallback=""
571571
)
572572
or "homeassistant",
573+
addon_slug=(
574+
config.get(section, "ADDON_SLUG", fallback="").strip() or None
575+
),
573576
)
574577
return None

src/astrameter/mqtt_insights/discovery.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def build_ct002_consumer_discovery(
4848
state_topic = f"{base_topic}/ct002/{device_id}/consumer/{consumer_id}"
4949
avail_topic = f"{state_topic}/availability"
5050
uid_prefix = f"astrameter_ct002_{safe_dev}_{safe_cid}"
51+
meter_identifier = f"astrameter_ct002_{safe_dev}"
5152

5253
components: dict[str, dict] = {}
5354

@@ -194,6 +195,7 @@ def build_ct002_consumer_discovery(
194195
if device_type
195196
else f"AstraMeter Consumer {mac_slug}",
196197
"manufacturer": "Marstek",
198+
"via_device": meter_identifier,
197199
}
198200
connections: list[list[str]] = []
199201
if re.fullmatch(r"[0-9a-f]{12}", mac_slug):
@@ -237,6 +239,7 @@ def build_ct002_device_discovery(
237239
base_topic: str,
238240
device_id: str,
239241
ha_prefix: str,
242+
addon_slug: str | None = None,
240243
) -> tuple[str, dict]:
241244
safe_dev = _sanitize_id(device_id)
242245
node_id = f"astrameter_ct002_{safe_dev}"
@@ -281,12 +284,16 @@ def build_ct002_device_discovery(
281284
},
282285
}
283286

287+
device_info: dict = {
288+
"identifiers": node_id,
289+
"name": f"AstraMeter CT002 {device_id}",
290+
"manufacturer": "astrameter",
291+
}
292+
if addon_slug:
293+
device_info["via_device"] = addon_slug
294+
284295
payload = {
285-
"device": {
286-
"identifiers": node_id,
287-
"name": f"CT002 {device_id}",
288-
"manufacturer": "astrameter",
289-
},
296+
"device": device_info,
290297
"origin": _origin(),
291298
"components": components,
292299
"availability": [_system_availability(base_topic)],
@@ -372,8 +379,9 @@ def build_shelly_battery_discovery(
372379
payload = {
373380
"device": {
374381
"identifiers": node_id,
375-
"name": f"Shelly Battery {battery_ip}",
382+
"name": f"AstraMeter Shelly Battery {battery_ip}",
376383
"manufacturer": "astrameter",
384+
"via_device": f"astrameter_shelly_{safe_dev}",
377385
},
378386
"origin": _origin(),
379387
"components": components,
@@ -400,6 +408,7 @@ def build_shelly_device_discovery(
400408
base_topic: str,
401409
device_id: str,
402410
ha_prefix: str,
411+
addon_slug: str | None = None,
403412
) -> tuple[str, dict]:
404413
safe_dev = _sanitize_id(device_id)
405414
node_id = f"astrameter_shelly_{safe_dev}"
@@ -417,12 +426,16 @@ def build_shelly_device_discovery(
417426
},
418427
}
419428

429+
device_info: dict = {
430+
"identifiers": node_id,
431+
"name": f"AstraMeter Shelly {device_id}",
432+
"manufacturer": "astrameter",
433+
}
434+
if addon_slug:
435+
device_info["via_device"] = addon_slug
436+
420437
payload = {
421-
"device": {
422-
"identifiers": node_id,
423-
"name": f"Shelly {device_id}",
424-
"manufacturer": "astrameter",
425-
},
438+
"device": device_info,
426439
"origin": _origin(),
427440
"components": components,
428441
"availability": [_system_availability(base_topic)],

src/astrameter/mqtt_insights/mqtt_insights_test.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def test_ct002_consumer_discovery_structure():
6363
assert dev["manufacturer"] == "Marstek"
6464
assert dev["model_id"] == "HMJ-2"
6565
assert ["bluetooth", "AA:BB:CC:DD:EE:FF"] in dev["connections"]
66+
assert dev["via_device"] == "astrameter_ct002_dev1"
6667

6768
# Check two-level availability
6869
assert payload["availability_mode"] == "all"
@@ -125,11 +126,12 @@ def test_ct002_consumer_discovery_no_device_type():
125126

126127

127128
def test_ct002_consumer_discovery_non_mac_consumer():
128-
"""No connections field when consumer_id is not a 12-char hex MAC."""
129+
"""Non-MAC consumer_id has no connections but is still linked via via_device."""
129130
_, payload = build_ct002_consumer_discovery(
130131
"astrameter", "dev1", "192.168.1.1:12345", "homeassistant"
131132
)
132133
assert "connections" not in payload["device"]
134+
assert payload["device"]["via_device"] == "astrameter_ct002_dev1"
133135

134136

135137
def test_ct002_consumer_discovery_network_mac_and_ip():
@@ -146,11 +148,16 @@ def test_ct002_consumer_discovery_network_mac_and_ip():
146148
assert ["bluetooth", "AA:BB:CC:DD:EE:FF"] in conns
147149
assert ["mac", "11:22:33:44:55:66"] in conns
148150
assert ["ip", "192.168.1.10"] in conns
151+
assert payload["device"]["via_device"] == "astrameter_ct002_dev1"
149152

150153

151154
def test_ct002_device_discovery_structure():
152-
topic, payload = build_ct002_device_discovery("astrameter", "dev1", "homeassistant")
155+
topic, payload = build_ct002_device_discovery(
156+
"astrameter", "dev1", "homeassistant", addon_slug="34dea19a_astrameter"
157+
)
153158
_assert_discovery_structure(topic, payload)
159+
assert "AstraMeter" in payload["device"]["name"]
160+
assert payload["device"]["via_device"] == "34dea19a_astrameter"
154161
comps = payload["components"]
155162
assert "smooth_target" in comps
156163
assert "active_control" in comps
@@ -170,6 +177,8 @@ def test_shelly_battery_discovery_structure():
170177
"astrameter", "shelly1", "192.168.1.100", "homeassistant"
171178
)
172179
_assert_discovery_structure(topic, payload)
180+
assert "AstraMeter" in payload["device"]["name"]
181+
assert payload["device"]["via_device"] == "astrameter_shelly_shelly1"
173182
comps = payload["components"]
174183
assert "grid_power_total" in comps
175184
assert "active" in comps
@@ -184,12 +193,21 @@ def test_shelly_battery_discovery_structure():
184193

185194
def test_shelly_device_discovery_structure():
186195
topic, payload = build_shelly_device_discovery(
187-
"astrameter", "shelly1", "homeassistant"
196+
"astrameter", "shelly1", "homeassistant", addon_slug="34dea19a_astrameter"
188197
)
189198
_assert_discovery_structure(topic, payload)
199+
assert "AstraMeter" in payload["device"]["name"]
200+
assert payload["device"]["via_device"] == "34dea19a_astrameter"
190201
assert "battery_count" in payload["components"]
191202

192203

204+
def test_meter_device_discovery_omits_via_device_without_addon_slug():
205+
_, ct002 = build_ct002_device_discovery("astrameter", "dev1", "homeassistant")
206+
assert "via_device" not in ct002["device"]
207+
_, shelly = build_shelly_device_discovery("astrameter", "shelly1", "homeassistant")
208+
assert "via_device" not in shelly["device"]
209+
210+
193211
def test_unique_ids_are_unique():
194212
"""All unique_ids within a single discovery payload must be distinct."""
195213
_, payload = build_ct002_consumer_discovery(
@@ -288,6 +306,7 @@ def test_read_mqtt_insights_config_present():
288306
BASE_TOPIC = my_topic
289307
HA_DISCOVERY = true
290308
HA_DISCOVERY_PREFIX = ha
309+
ADDON_SLUG = 34dea19a_astrameter
291310
"""
292311
)
293312
result = read_mqtt_insights_config(cfg)
@@ -300,6 +319,7 @@ def test_read_mqtt_insights_config_present():
300319
assert result.base_topic == "my_topic"
301320
assert result.ha_discovery is True
302321
assert result.ha_discovery_prefix == "ha"
322+
assert result.addon_slug == "34dea19a_astrameter"
303323

304324

305325
def test_read_mqtt_insights_config_defaults():
@@ -317,6 +337,7 @@ def test_read_mqtt_insights_config_defaults():
317337
assert result.base_topic == "astrameter"
318338
assert result.ha_discovery is True
319339
assert result.ha_discovery_prefix == "homeassistant"
340+
assert result.addon_slug is None
320341

321342

322343
def test_read_mqtt_insights_config_empty_values():
@@ -332,6 +353,7 @@ def test_read_mqtt_insights_config_empty_values():
332353
BASE_TOPIC =
333354
HA_DISCOVERY =
334355
HA_DISCOVERY_PREFIX =
356+
ADDON_SLUG =
335357
"""
336358
)
337359
result = read_mqtt_insights_config(cfg)
@@ -344,6 +366,18 @@ def test_read_mqtt_insights_config_empty_values():
344366
assert result.base_topic == "astrameter"
345367
assert result.ha_discovery is True
346368
assert result.ha_discovery_prefix == "homeassistant"
369+
assert result.addon_slug is None
370+
371+
372+
def test_read_mqtt_insights_config_whitespace_addon_slug():
373+
"""Whitespace-only ADDON_SLUG values must be normalised to None."""
374+
cfg = configparser.ConfigParser()
375+
cfg.add_section("MQTT_INSIGHTS")
376+
cfg.set("MQTT_INSIGHTS", "BROKER", "localhost")
377+
cfg.set("MQTT_INSIGHTS", "ADDON_SLUG", " ")
378+
result = read_mqtt_insights_config(cfg)
379+
assert result is not None
380+
assert result.addon_slug is None
347381

348382

349383
def test_read_mqtt_insights_config_absent():

src/astrameter/mqtt_insights/service.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class MqttInsightsConfig:
5858
base_topic: str = "astrameter"
5959
ha_discovery: bool = True
6060
ha_discovery_prefix: str = "homeassistant"
61+
addon_slug: str | None = None
6162

6263

6364
@dataclass
@@ -341,7 +342,7 @@ async def _handle_ct002_event(
341342
if did not in self._discovered_ct002_devices:
342343
self._discovered_ct002_devices.add(did)
343344
topic, payload = build_ct002_device_discovery(
344-
base, did, cfg.ha_discovery_prefix
345+
base, did, cfg.ha_discovery_prefix, addon_slug=cfg.addon_slug
345346
)
346347
await client.publish(
347348
topic, payload=json.dumps(payload).encode(), retain=True
@@ -437,7 +438,7 @@ async def _handle_shelly_event(
437438
if did not in self._discovered_shelly_devices:
438439
self._discovered_shelly_devices.add(did)
439440
topic, payload = build_shelly_device_discovery(
440-
base, did, cfg.ha_discovery_prefix
441+
base, did, cfg.ha_discovery_prefix, addon_slug=cfg.addon_slug
441442
)
442443
await client.publish(
443444
topic, payload=json.dumps(payload).encode(), retain=True

0 commit comments

Comments
 (0)