Coverage for custom_components/supernotify/transports/mqtt.py: 100%
58 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +0000
1from __future__ import annotations
3import json
4import logging
5from typing import TYPE_CHECKING, Any
7from homeassistant.helpers.typing import ConfigType
9from custom_components.supernotify.const import ATTR_TOPIC, TRANSPORT_MQTT
10from custom_components.supernotify.model import DebugTrace, TargetRequired, TransportConfig, TransportFeature
11from custom_components.supernotify.target import Target, TargetEntityCategory
12from custom_components.supernotify.transport import (
13 Transport,
14)
16if TYPE_CHECKING:
17 from custom_components.supernotify.envelope import Envelope
18 from custom_components.supernotify.hass_api import HomeAssistantAPI
20HA_MQTT_DOMAIN = "mqtt"
22_LOGGER = logging.getLogger(__name__)
25class MQTTTransport(Transport):
26 name = TRANSPORT_MQTT
28 def __init__(self, *args: Any, **kwargs: Any) -> None:
29 super().__init__(*args, **kwargs)
31 @property
32 def supported_features(self) -> TransportFeature:
33 return TransportFeature.MESSAGE
35 @property
36 def default_config(self) -> TransportConfig:
37 config = TransportConfig()
38 config.delivery_defaults.action = "mqtt.publish"
39 config.delivery_defaults.target_required = TargetRequired.OPTIONAL
40 config.delivery_defaults.options = {}
41 config.delivery_defaults.inclusion = self.inclusion_mode
42 return config
44 @property
45 def target_categories(self) -> list[str | TargetEntityCategory]:
46 # `topic` is a clean, dedicated category name for the mapping form (`target: {topic:
47 # ...}`), distinct from overloading the transport's own name (`target: {mqtt: ...}`,
48 # still handled separately by Delivery.select_targets()). A bare, unqualified
49 # `target: <topic>` set directly on the mqtt delivery block also reaches here - not
50 # via this list, but because it's the sole plain-string entry `Delivery.
51 # reclassify_unqualified_target()` falls back to for a delivery-scoped value with no
52 # shape a validator recognises.
53 return [ATTR_TOPIC]
55 def validate_action(self, action: str | None) -> bool:
56 """Override in subclass if transport has fixed action or doesn't require one"""
57 return action == self.delivery_defaults.action
59 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
60 return hass_api.find_config_entry_data(HA_MQTT_DOMAIN) is not None
62 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
63 return {self.name: {}}
65 def recipient_target(self, recipient: dict[str, Any]) -> Target | None:
66 return None
68 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
69 _LOGGER.debug("SUPERNOTIFY notify_mqtt: %s", envelope.delivery_name)
71 data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
72 # envelope.target is already scoped to this transport by delivery.select_targets()
73 # (see target_categories above), so any resolved target here is a topic
74 topics: list[str] = envelope.target.resolved_targets() if envelope.target else []
75 if topics:
76 data.pop(ATTR_TOPIC, None)
77 else:
78 topic = data.pop(ATTR_TOPIC, None)
79 if topic:
80 topics = [topic]
82 if not topics:
83 _LOGGER.warning("SUPERNOTIFY notify_mqtt: No topic for publication")
84 return False
86 if isinstance(data.get("payload"), dict):
87 data["payload"] = json.dumps(data["payload"])
88 else:
89 data["payload"] = envelope.message
91 success = True
92 for topic in topics:
93 action_data: dict[str, Any] = dict(data)
94 action_data[ATTR_TOPIC] = topic
95 success = await self.call_action(envelope, action_data=action_data) and success
96 return success