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

1"""LaMetric native transport for SuperNotify. 

2 

3Sends messages and charts to LaMetric smart displays using the 

4HA lametric integration services (lametric.message, lametric.chart). 

5 

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. 

9 

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: 

12 

13 delivery: 

14 lametric: 

15 transport: lametric 

16 data: 

17 device_id: "49b6e2186ef37e164818aacb9cea1f53" 

18 

19There's no room for a title on the display, so only the message is shown. 

20 

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. 

52 

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""" 

60 

61from __future__ import annotations 

62 

63import logging 

64from typing import TYPE_CHECKING, Any 

65 

66from homeassistant.helpers.typing import ConfigType 

67 

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 

79 

80if TYPE_CHECKING: 

81 from custom_components.supernotify.envelope import Envelope 

82 from custom_components.supernotify.hass_api import HomeAssistantAPI 

83 

84_LOGGER = logging.getLogger(__name__) 

85 

86HA_LAMETRIC_DOMAIN = "lametric" 

87 

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} 

96 

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} 

105 

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} 

114 

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} 

123 

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} 

132 

133 

134class LaMetricTransport(Transport): 

135 """LaMetric smart display transport for SuperNotify. 

136 

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 """ 

141 

142 name = TRANSPORT_LAMETRIC 

143 integration_domain = HA_LAMETRIC_DOMAIN 

144 

145 def __init__(self, *args: Any, **kwargs: Any) -> None: 

146 super().__init__(*args, **kwargs) 

147 

148 @property 

149 def supported_features(self) -> TransportFeature: 

150 return TransportFeature.MESSAGE 

151 

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 

159 

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)] 

165 

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 

178 

179 def is_viable(self, hass_api: HomeAssistantAPI) -> bool: 

180 return hass_api.find_config_entry_data(HA_LAMETRIC_DOMAIN) is not None 

181 

182 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]: 

183 return {self.name: {}} 

184 

185 def validate_action(self, action: str | None) -> bool: 

186 # No external action required - transport uses lametric.message / lametric.chart directly 

187 return True 

188 

189 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: 

190 _LOGGER.debug("SUPERNOTIFY lametric %s", envelope.message) 

191 

192 # 1. Extract raw data (flat dict — rule #6) 

193 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {} 

194 

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 

202 

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) 

210 

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) 

218 

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 "" 

221 

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 

230 

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 

251 

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)