Coverage for custom_components/supernotify/transports/lametric.py: 100%
85 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
1"""LaMetric native transport for SuperNotify.
3Sends messages and charts to LaMetric smart displays using the
4HA lametric integration services (lametric.message, lametric.chart).
6Requires: Home Assistant lametric integration (core, auto-discovered via mDNS/SSDP).
7No action: required in delivery config — uses lametric.message or lametric.chart
8based on presence of lametric_chart_data in envelope data.
10Targets are the `notify` entity the integration creates for each device, whose device is
11looked up, or a fixed device_id in the delivery config data:
13 delivery:
14 lametric:
15 transport: lametric
16 data:
17 device_id: "49b6e2186ef37e164818aacb9cea1f53"
19There's no room for a title on the display, so only the message is shown.
21New data keys (all optional unless noted):
22 device_id str LaMetric device UUID from HA device registry, used when
23 no LaMetric notify entity is targeted.
24 Obtain from: HA Settings → Devices → LaMetric → device ID.
25 Example: "49b6e2186ef37e164818aacb9cea1f53"
26 lametric_sound str Built-in sound name. Overrides priority default.
27 Built-in sounds: alarm1, alarm2, ..., alarm13,
28 bicycle, car, cash, cat, dog, dog2, energy,
29 knock-knock, letter_email, lose1, lose2,
30 negative1, negative2, negative3, negative4, negative5,
31 notification, notification2, notification3, notification4,
32 open_door, positive1, ..., positive6,
33 statistic, thunder, water1, water2,
34 win, win2, wind, wind_short.
35 Omit or set None for silent notification.
36 lametric_icon str Icon ID override (e.g. "i2867", "a1784").
37 Overrides priority-based default icon.
38 Full icon list: https://developer.lametric.com/icons
39 lametric_cycles int Display cycles override.
40 0 = permanent (stays until dismissed manually).
41 1+ = number of scroll cycles, then auto-dismiss.
42 Overrides priority default.
43 lametric_icon_type str Icon style: "none", "info", "alert".
44 "alert" produces a red flashing icon.
45 Overrides priority default.
46 lametric_chart_data list[int] If present, sends lametric.chart instead of lametric.message.
47 List of integers representing bar chart values.
48 Example: [10, 30, 50, 80, 60, 20]
49 lametric_simplify bool If True, apply simplify() to message text
50 (strips URLs, shortens for small physical display).
51 Default: False.
53Priority defaults (auto-applied when keys not specified):
54 critical → priority=critical, cycles=0 (permanent), icon_type=alert, sound=alarm1, icon=a1784
55 high → priority=warning, cycles=2, icon_type=alert, sound=knock-knock, icon=i140
56 medium → priority=info, cycles=1, icon_type=info, sound=notification, icon=i2867
57 low → priority=info, cycles=1, icon_type=none, sound=None (silent), icon=i2867
58 minimum → priority=info, cycles=1, icon_type=none, sound=None (silent), icon=None
59"""
61from __future__ import annotations
63import logging
64from typing import TYPE_CHECKING, Any
66from homeassistant.helpers.typing import ConfigType
68from custom_components.supernotify.common import boolify
69from custom_components.supernotify.const import TRANSPORT_LAMETRIC
70from custom_components.supernotify.model import (
71 DebugTrace,
72 SuppressionReason,
73 TargetRequired,
74 TransportConfig,
75 TransportFeature,
76)
77from custom_components.supernotify.target import TargetEntityCategory
78from custom_components.supernotify.transport import Transport
80if TYPE_CHECKING:
81 from custom_components.supernotify.envelope import Envelope
82 from custom_components.supernotify.hass_api import HomeAssistantAPI
84_LOGGER = logging.getLogger(__name__)
86HA_LAMETRIC_DOMAIN = "lametric"
88# Priority mapping: SuperNotify string → LaMetric priority string
89_PRIORITY_MAP: dict[str, str] = {
90 "critical": "critical",
91 "high": "warning",
92 "medium": "info",
93 "low": "info",
94 "minimum": "info",
95}
97# Default display cycles per priority (0 = permanent until dismissed)
98_CYCLES_MAP: dict[str, int] = {
99 "critical": 0, # stays on display until manually dismissed
100 "high": 2,
101 "medium": 1,
102 "low": 1,
103 "minimum": 1,
104}
106# Icon type per priority ("alert" = red flashing, "info" = blue, "none" = no highlight)
107_ICON_TYPE_MAP: dict[str, str] = {
108 "critical": "alert",
109 "high": "alert",
110 "medium": "info",
111 "low": "none",
112 "minimum": "none",
113}
115# Default sound per priority (None = silent)
116_SOUND_MAP: dict[str, str | None] = {
117 "critical": "alarm1",
118 "high": "knock-knock",
119 "medium": "notification",
120 "low": None,
121 "minimum": None,
122}
124# Default icon ID per priority (None = no icon)
125_ICON_MAP: dict[str, str | None] = {
126 "critical": "a1784", # animated alert icon (red)
127 "high": "i140", # exclamation mark
128 "medium": "i2867", # bell icon (already used by Lollo)
129 "low": "i2867", # bell icon
130 "minimum": None, # text only
131}
134class LaMetricTransport(Transport):
135 """LaMetric smart display transport for SuperNotify.
137 Delivers notifications to LaMetric TIME devices via the HA lametric
138 integration. Supports text messages and bar charts with full priority
139 mapping (sound, icon, cycles, icon_type auto-selected per priority level).
140 """
142 name = TRANSPORT_LAMETRIC
143 integration_domain = HA_LAMETRIC_DOMAIN
145 def __init__(self, *args: Any, **kwargs: Any) -> None:
146 super().__init__(*args, **kwargs)
148 @property
149 def supported_features(self) -> TransportFeature:
150 return TransportFeature.MESSAGE
152 @property
153 def default_config(self) -> TransportConfig:
154 config = super().default_config
155 # a delivery may instead fix its display with device_id in data, with no target - like
156 # mqtt's topic, Notification._target_required() stops the standard delivery firing unprompted
157 config.delivery_defaults.target_required = TargetRequired.OPTIONAL
158 return config
160 @property
161 def unique_target_categories(self) -> list[str | TargetEntityCategory]:
162 # the notify entity for each display is the integration's own, claimed ahead of the
163 # generic notify_entity transport, like ntfy's
164 return [TargetEntityCategory(domain="notify", platform=HA_LAMETRIC_DOMAIN)]
166 def device_ids(self, envelope: Envelope, device_id: str | None) -> list[str]:
167 """The device of each targeted LaMetric notify entity, or else the fixed device_id, like mqtt's topic"""
168 device_ids: list[str] = []
169 for entity_id in envelope.target.domain_entity_ids("notify") if envelope.target else []:
170 entity_device_id: str | None = self.hass_api.device_for_entity(entity_id)
171 if entity_device_id is None:
172 _LOGGER.warning("SUPERNOTIFY lametric: no device found for %s", entity_id)
173 elif entity_device_id not in device_ids:
174 device_ids.append(entity_device_id)
175 if not device_ids and device_id:
176 device_ids.append(device_id)
177 return device_ids
179 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
180 return hass_api.find_config_entry_data(HA_LAMETRIC_DOMAIN) is not None
182 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
183 return {self.name: {}}
185 def validate_action(self, action: str | None) -> bool:
186 # No external action required - transport uses lametric.message / lametric.chart directly
187 return True
189 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
190 _LOGGER.debug("SUPERNOTIFY lametric %s", envelope.message)
192 # 1. Extract raw data (flat dict — rule #6)
193 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
195 # 2. Displays to send to, from device_id in data or LaMetric notify entity targets
196 device_ids: list[str] = self.device_ids(envelope, raw_data.pop("device_id", None))
197 if not device_ids:
198 _LOGGER.warning("SUPERNOTIFY lametric: no LaMetric notify entity target or device_id in delivery data, skipping")
199 envelope.skipped = 1
200 envelope.skip_reason = SuppressionReason.NO_TARGET
201 return False
203 # 3. Pop all lametric-specific keys (must NOT reach the HA service)
204 lametric_sound = raw_data.pop("lametric_sound", None)
205 lametric_icon = raw_data.pop("lametric_icon", None)
206 lametric_cycles = raw_data.pop("lametric_cycles", None)
207 lametric_icon_type = raw_data.pop("lametric_icon_type", None)
208 lametric_chart_data = raw_data.pop("lametric_chart_data", None)
209 lametric_simplify = boolify(raw_data.pop("lametric_simplify", False), default=False)
211 # 4. Resolve priority → default values
212 sn_priority = envelope.priority or "medium"
213 final_priority = _PRIORITY_MAP.get(sn_priority, "info")
214 final_cycles = lametric_cycles if lametric_cycles is not None else _CYCLES_MAP.get(sn_priority, 1)
215 final_icon_type = lametric_icon_type if lametric_icon_type is not None else _ICON_TYPE_MAP.get(sn_priority, "none")
216 final_sound = lametric_sound if lametric_sound is not None else _SOUND_MAP.get(sn_priority)
217 final_icon = lametric_icon if lametric_icon is not None else _ICON_MAP.get(sn_priority)
219 # 5. Optionally simplify message text for small physical display
220 message = (self.simplify(envelope.message, strip_urls=True) if lametric_simplify else envelope.message) or ""
222 # 6A. CHART path — if lametric_chart_data is provided
223 if lametric_chart_data is not None:
224 if not isinstance(lametric_chart_data, list):
225 _LOGGER.warning(
226 "SUPERNOTIFY lametric: lametric_chart_data must be a list of ints, got %s",
227 type(lametric_chart_data).__name__,
228 )
229 return False
231 qualified_action = "lametric.chart"
232 action_data: dict[str, Any] = {
233 "data": lametric_chart_data, # field name is "data" for lametric.chart
234 "cycles": final_cycles,
235 "priority": final_priority,
236 "icon_type": final_icon_type,
237 }
238 else:
239 # 6B. MESSAGE path (default)
240 qualified_action = "lametric.message"
241 action_data = {
242 "message": message,
243 "cycles": final_cycles,
244 "priority": final_priority,
245 "icon_type": final_icon_type,
246 }
247 if final_icon:
248 action_data["icon"] = final_icon
249 if final_sound:
250 action_data["sound"] = final_sound
252 # the actions take a single device_id, so one call per display
253 results: list[bool] = [
254 await self.call_action(
255 envelope,
256 qualified_action=qualified_action,
257 action_data={"device_id": device_id, **action_data},
258 implied_target=True,
259 )
260 for device_id in device_ids
261 ]
262 return all(results)