updates2mqtt 1.6.0__py3-none-any.whl

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.
@@ -0,0 +1,123 @@
1
+ import datetime
2
+ import re
3
+ import subprocess
4
+ from pathlib import Path
5
+ from re import Match
6
+
7
+ import structlog
8
+
9
+ log = structlog.get_logger()
10
+
11
+
12
+ def git_trust(repo_path: Path, git_path: Path) -> bool:
13
+ try:
14
+ subprocess.run(f"{git_path} config --global --add safe.directory {repo_path}", check=True, shell=True, cwd=repo_path)
15
+ return True
16
+ except Exception as e:
17
+ log.warn("GIT Unable to trust repo at %s: %s", repo_path, e, action="git_trust")
18
+ return False
19
+
20
+
21
+ def git_iso_timestamp(repo_path: Path, git_path: Path) -> str | None:
22
+ result = None
23
+ try:
24
+ result = subprocess.run(
25
+ str(git_path) + r" log -1 --format=%cI --no-show-signature",
26
+ cwd=repo_path,
27
+ shell=True,
28
+ text=True,
29
+ capture_output=True,
30
+ check=True,
31
+ )
32
+ # round-trip the iso format for pythony consistency
33
+ return datetime.datetime.fromisoformat(result.stdout.strip()).isoformat()
34
+ except subprocess.CalledProcessError as cpe:
35
+ log.warn("GIT No result from git log at %s: %s", repo_path, cpe, action="git_iso_timestamp")
36
+ except Exception as e:
37
+ log.error(
38
+ "GIT Unable to parse timestamp at %s - %s: %s",
39
+ repo_path,
40
+ result.stdout if result else "<NO RESULT>",
41
+ e,
42
+ action="git_iso_timestamp",
43
+ )
44
+ return None
45
+
46
+
47
+ def git_local_version(repo_path: Path, git_path: Path) -> str | None:
48
+ result = None
49
+ try:
50
+ result = subprocess.run(
51
+ f"{git_path} rev-parse HEAD",
52
+ cwd=repo_path,
53
+ shell=True,
54
+ text=True,
55
+ capture_output=True,
56
+ check=True,
57
+ )
58
+ if result.returncode == 0:
59
+ log.debug("Local git rev-parse", action="git_local_version", path=repo_path, version=result.stdout.strip())
60
+ return f"git:{result.stdout.strip()}"[:19]
61
+ except subprocess.CalledProcessError as cpe:
62
+ log.warn("GIT No result from git rev-parse at %s: %s", repo_path, cpe, action="git_local_version")
63
+ except Exception as e:
64
+ log.error(
65
+ "GIT Unable to retrieve version at %s - %s: %s",
66
+ repo_path,
67
+ result.stdout if result else "<NO RESULT>",
68
+ e,
69
+ action="git_local_version",
70
+ )
71
+ return None
72
+
73
+
74
+ def git_check_update_available(repo_path: Path, git_path: Path, timeout: int = 120) -> int:
75
+ result = None
76
+ try:
77
+ # check if remote repo ahead
78
+ result = subprocess.run(
79
+ f"{git_path} fetch;{git_path} status -uno",
80
+ capture_output=True,
81
+ text=True,
82
+ shell=True,
83
+ check=True,
84
+ cwd=repo_path,
85
+ timeout=timeout,
86
+ )
87
+ if result.returncode == 0:
88
+ count_match: Match[str] | None = re.search(
89
+ r"Your branch is behind.*by (\d+) commit", result.stdout, flags=re.MULTILINE
90
+ )
91
+ if count_match and count_match.groups():
92
+ log.debug(
93
+ "Local git repo update available: %s (%s)",
94
+ count_match.group(1),
95
+ result.stdout.strip(),
96
+ action="git_check",
97
+ path=repo_path,
98
+ )
99
+ return int(count_match.group(1))
100
+ log.debug("Local git repo no update available", action="git_check", path=repo_path, status=result.stdout.strip())
101
+ return 0
102
+
103
+ log.debug(
104
+ "No git update available",
105
+ action="git_check",
106
+ path=repo_path,
107
+ returncode=result.returncode,
108
+ stdout=result.stdout,
109
+ stderr=result.stderr,
110
+ )
111
+ except Exception as e:
112
+ log.warn("GIT Unable to check status %s: %s", result.stdout if result else "<NO RESULT>", e, action="git_check")
113
+ return 0
114
+
115
+
116
+ def git_pull(repo_path: Path, git_path: Path) -> bool:
117
+ log.info("GIT Pulling git at %s", repo_path, action="git_pull")
118
+ proc = subprocess.run(f"{git_path} pull", shell=True, check=False, cwd=repo_path, timeout=300)
119
+ if proc.returncode == 0:
120
+ log.info("GIT pull at %s successful", repo_path, action="git_pull")
121
+ return True
122
+ log.warn("GIT pull at %s failed: %s", repo_path, proc.returncode, action="git_pull", stdout=proc.stdout, stderr=proc.stderr)
123
+ return False
updates2mqtt/model.py ADDED
@@ -0,0 +1,128 @@
1
+ import json
2
+ from abc import abstractmethod
3
+ from collections.abc import AsyncGenerator, Callable
4
+ from threading import Event
5
+ from typing import Any
6
+
7
+ import structlog
8
+
9
+
10
+ class Discovery:
11
+ """Discovered component from a scan"""
12
+
13
+ def __init__(
14
+ self,
15
+ provider: "ReleaseProvider",
16
+ name: str,
17
+ session: str,
18
+ node: str,
19
+ entity_picture_url: str | None = None,
20
+ current_version: str | None = None,
21
+ latest_version: str | None = None,
22
+ can_update: bool = False,
23
+ can_build: bool = False,
24
+ can_restart: bool = False,
25
+ status: str = "on",
26
+ update_type: str | None = "Update",
27
+ update_policy: str | None = None,
28
+ update_last_attempt: float | None = None,
29
+ release_url: str | None = None,
30
+ release_summary: str | None = None,
31
+ title_template: str = "{discovery.update_type} for {discovery.name} on {discovery.node}",
32
+ device_icon: str | None = None,
33
+ custom: dict[str, Any] | None = None,
34
+ features: list[str] | None = None,
35
+ throttled: bool = False,
36
+ ) -> None:
37
+ self.provider: ReleaseProvider = provider
38
+ self.source_type: str = provider.source_type
39
+ self.session: str = session
40
+ self.name: str = name
41
+ self.node: str = node
42
+ self.entity_picture_url: str | None = entity_picture_url
43
+ self.current_version: str | None = current_version
44
+ self.latest_version: str | None = latest_version
45
+ self.can_update: bool = can_update
46
+ self.can_build: bool = can_build
47
+ self.can_restart: bool = can_restart
48
+ self.release_url: str | None = release_url
49
+ self.release_summary: str | None = release_summary
50
+ self.title_template: str | None = title_template
51
+ self.device_icon: str | None = device_icon
52
+ self.update_type: str | None = update_type
53
+ self.status: str = status
54
+ self.update_policy: str | None = update_policy
55
+ self.update_last_attempt: float | None = update_last_attempt
56
+ self.custom: dict[str, Any] = custom or {}
57
+ self.features: list[str] = features or []
58
+ self.throttled: bool = throttled
59
+
60
+ def __repr__(self) -> str:
61
+ """Build a custom string representation"""
62
+ return f"Discovery('{self.name}','{self.source_type}',current={self.current_version},latest={self.latest_version})"
63
+
64
+ def __str__(self) -> str:
65
+ """Dump the attrs"""
66
+
67
+ def stringify(v: Any) -> str | int | float | bool:
68
+ return str(v) if not isinstance(v, (str, int, float, bool)) else v
69
+
70
+ dump = {k: stringify(v) for k, v in self.__dict__.items()}
71
+ return json.dumps(dump)
72
+
73
+ @property
74
+ def title(self) -> str:
75
+ if self.title_template:
76
+ return self.title_template.format(discovery=self)
77
+ return self.name
78
+
79
+
80
+ class ReleaseProvider:
81
+ """Abstract base class for release providers, such as container scanners or package managers API calls"""
82
+
83
+ def __init__(self, source_type: str = "base") -> None:
84
+ self.source_type: str = source_type
85
+ self.discoveries: dict[str, Discovery] = {}
86
+ self.log: Any = structlog.get_logger().bind(integration=self.source_type)
87
+ self.stopped = Event()
88
+
89
+ def stop(self) -> None:
90
+ """Stop any loops or background tasks"""
91
+ self.log.info("Asking release provider to stop", source_type=self.source_type)
92
+ self.stopped.set()
93
+
94
+ def __str__(self) -> str:
95
+ """Stringify"""
96
+ return f"{self.source_type} Discovery"
97
+
98
+ @abstractmethod
99
+ def update(self, discovery: Discovery) -> bool:
100
+ """Attempt to update the component version"""
101
+
102
+ @abstractmethod
103
+ def rescan(self, discovery: Discovery) -> Discovery | None:
104
+ """Rescan a previously discovered component"""
105
+
106
+ @abstractmethod
107
+ async def scan(self, session: str) -> AsyncGenerator[Discovery]:
108
+ """Scan for components to monitor"""
109
+ raise NotImplementedError
110
+ # force recognition as an async generator
111
+ if False: # type: ignore[unreachable]
112
+ yield 0
113
+
114
+ def hass_config_format(self, discovery: Discovery) -> dict:
115
+ _ = discovery
116
+ return {}
117
+
118
+ def hass_state_format(self, discovery: Discovery) -> dict:
119
+ _ = discovery
120
+ return {}
121
+
122
+ @abstractmethod
123
+ def command(self, discovery_name: str, command: str, on_update_start: Callable, on_update_end: Callable) -> bool:
124
+ """Execute a command on a discovered component"""
125
+
126
+ @abstractmethod
127
+ def resolve(self, discovery_name: str) -> Discovery | None:
128
+ """Resolve a discovered component by name"""
updates2mqtt/mqtt.py ADDED
@@ -0,0 +1,349 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from threading import Event
7
+ from typing import Any
8
+
9
+ import paho.mqtt.client as mqtt
10
+ import paho.mqtt.subscribeoptions
11
+ import structlog
12
+ from paho.mqtt.client import MQTT_CLEAN_START_FIRST_ONLY, MQTTMessage
13
+ from paho.mqtt.enums import CallbackAPIVersion, MQTTErrorCode, MQTTProtocolVersion
14
+ from paho.mqtt.properties import Properties
15
+ from paho.mqtt.reasoncodes import ReasonCode
16
+
17
+ from updates2mqtt.model import Discovery, ReleaseProvider
18
+
19
+ from .config import HomeAssistantConfig, MqttConfig, NodeConfig
20
+ from .hass_formatter import hass_format_config, hass_format_state
21
+
22
+ log = structlog.get_logger()
23
+
24
+
25
+ @dataclass
26
+ class LocalMessage:
27
+ topic: str | None = field(default=None)
28
+ payload: str | None = field(default=None)
29
+
30
+
31
+ class MqttPublisher:
32
+ def __init__(self, cfg: MqttConfig, node_cfg: NodeConfig, hass_cfg: HomeAssistantConfig) -> None:
33
+ self.cfg: MqttConfig = cfg
34
+ self.node_cfg: NodeConfig = node_cfg
35
+ self.hass_cfg: HomeAssistantConfig = hass_cfg
36
+ self.providers_by_topic: dict[str, ReleaseProvider] = {}
37
+ self.event_loop: asyncio.AbstractEventLoop | None = None
38
+ self.client: mqtt.Client | None = None
39
+ self.fatal_failure = Event()
40
+ self.log = structlog.get_logger().bind(host=cfg.host, integration="mqtt")
41
+
42
+ def start(self, event_loop: asyncio.AbstractEventLoop | None = None) -> None:
43
+ logger = self.log.bind(action="start")
44
+ try:
45
+ protocol: MQTTProtocolVersion
46
+ if self.cfg.protocol in ("3", "3.11"):
47
+ protocol = MQTTProtocolVersion.MQTTv311
48
+ elif self.cfg.protocol == "3.1":
49
+ protocol = MQTTProtocolVersion.MQTTv31
50
+ elif self.cfg.protocol in ("5", "5.0"):
51
+ protocol = MQTTProtocolVersion.MQTTv5
52
+ else:
53
+ logger.info("No valid MQTT protocol version found (%s), setting to default v3.11", self.cfg.protocol)
54
+ protocol = MQTTProtocolVersion.MQTTv311
55
+ logger.debug("MQTT protocol set to %r", protocol)
56
+
57
+ self.event_loop = event_loop or asyncio.get_event_loop()
58
+ self.client = mqtt.Client(
59
+ callback_api_version=CallbackAPIVersion.VERSION2,
60
+ client_id=f"updates2mqtt_{self.node_cfg.name}",
61
+ clean_session=True if protocol != MQTTProtocolVersion.MQTTv5 else None,
62
+ protocol=protocol,
63
+ )
64
+ self.client.username_pw_set(self.cfg.user, password=self.cfg.password)
65
+ rc: MQTTErrorCode = self.client.connect(
66
+ host=self.cfg.host,
67
+ port=self.cfg.port,
68
+ keepalive=60,
69
+ clean_start=MQTT_CLEAN_START_FIRST_ONLY,
70
+ )
71
+ logger.info("Client connection requested", result_code=rc)
72
+
73
+ self.client.on_connect = self.on_connect
74
+ self.client.on_disconnect = self.on_disconnect
75
+ self.client.on_message = self.on_message
76
+ self.client.on_subscribe = self.on_subscribe
77
+ self.client.on_unsubscribe = self.on_unsubscribe
78
+
79
+ self.client.loop_start()
80
+
81
+ logger.debug("MQTT Publisher loop started", host=self.cfg.host, port=self.cfg.port)
82
+ except Exception as e:
83
+ logger.error("Failed to connect to broker", host=self.cfg.host, port=self.cfg.port, error=str(e))
84
+ raise OSError(f"Connection Failure to {self.cfg.host}:{self.cfg.port} as {self.cfg.user} -- {e}") from e
85
+
86
+ def stop(self) -> None:
87
+ if self.client:
88
+ self.client.loop_stop()
89
+ self.client.disconnect()
90
+ self.client = None
91
+
92
+ def is_available(self) -> bool:
93
+ return self.client is not None and not self.fatal_failure.is_set()
94
+
95
+ def on_connect(
96
+ self, _client: mqtt.Client, _userdata: Any, _flags: mqtt.ConnectFlags, rc: ReasonCode, _props: Properties | None
97
+ ) -> None:
98
+ if not self.client or self.fatal_failure.is_set():
99
+ self.log.warn("No client, check if started and authorized")
100
+ return
101
+ if rc.getName() == "Not authorized":
102
+ self.fatal_failure.set()
103
+ log.error("Invalid MQTT credentials", result_code=rc)
104
+ return
105
+ if rc != 0:
106
+ self.log.warning("Connection failed to broker", result_code=rc)
107
+ else:
108
+ self.log.debug("Connected to broker", result_code=rc)
109
+ for topic, provider in self.providers_by_topic.items():
110
+ self.log.debug("(Re)subscribing", topic=topic, provider=provider.source_type)
111
+ self.client.subscribe(topic)
112
+
113
+ def on_disconnect(
114
+ self,
115
+ _client: mqtt.Client,
116
+ _userdata: Any,
117
+ _disconnect_flags: mqtt.DisconnectFlags,
118
+ rc: ReasonCode,
119
+ _props: Properties | None,
120
+ ) -> None:
121
+ if rc == 0:
122
+ self.log.debug("Disconnected from broker", result_code=rc)
123
+ else:
124
+ self.log.warning("Disconnect failure from broker", result_code=rc)
125
+
126
+ async def clean_topics(
127
+ self, provider: ReleaseProvider, last_scan_session: str | None, wait_time: int = 5, force: bool = False
128
+ ) -> None:
129
+ logger = self.log.bind(action="clean")
130
+ if self.fatal_failure.is_set():
131
+ return
132
+ logger.info("Starting clean cycle")
133
+ cleaner = mqtt.Client(
134
+ callback_api_version=CallbackAPIVersion.VERSION1,
135
+ client_id=f"updates2mqtt_clean_{self.node_cfg.name}",
136
+ clean_session=True,
137
+ )
138
+ results = {"cleaned": 0, "handled": 0, "discovered": 0, "last_timestamp": time.time()}
139
+ cleaner.username_pw_set(self.cfg.user, password=self.cfg.password)
140
+ cleaner.connect(host=self.cfg.host, port=self.cfg.port, keepalive=60)
141
+ prefixes = [
142
+ f"{self.hass_cfg.discovery.prefix}/update/{self.node_cfg.name}_{provider.source_type}_",
143
+ f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}/",
144
+ ]
145
+
146
+ def cleanup(_client: mqtt.Client, _userdata: Any, msg: mqtt.MQTTMessage) -> None:
147
+ if msg.retain and any(msg.topic.startswith(prefix) for prefix in prefixes):
148
+ session = None
149
+ results["discovered"] += 1
150
+ try:
151
+ payload = self.safe_json_decode(msg.payload)
152
+ session = payload.get("source_session")
153
+ except Exception as e:
154
+ log.warn(
155
+ "Unable to handle payload for %s: %s",
156
+ msg.topic,
157
+ e,
158
+ exc_info=1,
159
+ )
160
+ results["handled"] += 1
161
+ results["last_timestamp"] = time.time()
162
+ if session is not None and last_scan_session is not None and session != last_scan_session:
163
+ log.debug("Removing stale msg", topic=msg.topic, session=session)
164
+ cleaner.publish(msg.topic, "", retain=True)
165
+ results["cleaned"] += 1
166
+ elif session is None and force:
167
+ log.debug("Removing untrackable msg", topic=msg.topic)
168
+ cleaner.publish(msg.topic, "", retain=True)
169
+ results["cleaned"] += 1
170
+ else:
171
+ log.debug(
172
+ "Retaining topic with current session: %s",
173
+ msg.topic,
174
+ )
175
+ else:
176
+ log.debug("Skipping clean of %s", msg.topic)
177
+
178
+ cleaner.on_message = cleanup
179
+ options = paho.mqtt.subscribeoptions.SubscribeOptions(noLocal=True)
180
+ cleaner.subscribe(f"{self.hass_cfg.discovery.prefix}/update/#", options=options)
181
+ cleaner.subscribe(f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}/#", options=options)
182
+
183
+ while time.time() - results["last_timestamp"] <= wait_time:
184
+ cleaner.loop(0.5)
185
+
186
+ log.info(
187
+ f"Clean completed, discovered:{results['discovered']}, handled:{results['handled']}, cleaned:{results['cleaned']}"
188
+ )
189
+
190
+ def safe_json_decode(self, jsonish: str | bytes | None) -> dict:
191
+ if jsonish is None:
192
+ return {}
193
+ try:
194
+ return json.loads(jsonish)
195
+ except Exception:
196
+ log.exception("JSON decode fail (%s)", jsonish)
197
+ try:
198
+ return json.loads(jsonish[1:-1])
199
+ except Exception:
200
+ log.exception("JSON decode fail (%s)", jsonish[1:-1])
201
+ return {}
202
+
203
+ async def execute_command(
204
+ self, msg: MQTTMessage | LocalMessage, on_update_start: Callable, on_update_end: Callable
205
+ ) -> None:
206
+ logger = self.log.bind(topic=msg.topic, payload=msg.payload)
207
+ comp_name: str | None = None
208
+ command: str | None = None
209
+ try:
210
+ logger.info("Execution starting")
211
+ source_type: str | None = None
212
+
213
+ payload: str | None = None
214
+ if isinstance(msg.payload, bytes):
215
+ payload = msg.payload.decode("utf-8")
216
+ elif isinstance(msg.payload, str):
217
+ payload = msg.payload
218
+ if payload and "|" in payload:
219
+ source_type, comp_name, command = payload.split("|")
220
+
221
+ provider: ReleaseProvider | None = self.providers_by_topic.get(msg.topic) if msg.topic else None
222
+ if not provider:
223
+ logger.warn("Unexpected provider type %s", msg.topic)
224
+ elif provider.source_type != source_type:
225
+ logger.warn("Unexpected source type %s", source_type)
226
+ elif command != "install" or not comp_name:
227
+ logger.warn("Invalid payload in command message: %s", msg.payload)
228
+ else:
229
+ logger.info(
230
+ "Passing %s command to %s scanner for %s",
231
+ command,
232
+ source_type,
233
+ comp_name,
234
+ )
235
+ updated = provider.command(comp_name, command, on_update_start, on_update_end)
236
+ discovery = provider.resolve(comp_name)
237
+ if updated and discovery:
238
+ self.publish_hass_state(discovery)
239
+ else:
240
+ logger.debug("No change to republish after execution")
241
+ logger.info("Execution ended")
242
+ except Exception:
243
+ logger.exception("Execution failed", component=comp_name, command=command)
244
+
245
+ def local_message(self, discovery: Discovery, command: str) -> None:
246
+ """Simulate an incoming MQTT message for local commands"""
247
+ msg = LocalMessage(
248
+ topic=self.command_topic(discovery.provider), payload="|".join([discovery.source_type, discovery.name, command])
249
+ )
250
+ self.handle_message(msg)
251
+
252
+ def on_subscribe(
253
+ self,
254
+ _client: mqtt.Client,
255
+ userdata: Any,
256
+ mid: int,
257
+ reason_code_list: list[ReasonCode],
258
+ properties: Properties | None = None,
259
+ ) -> None:
260
+ self.log.debug(
261
+ "on_subscribe, userdata=%s, mid=%s, reasons=%s, properties=%s", userdata, mid, reason_code_list, properties
262
+ )
263
+
264
+ def on_unsubscribe(
265
+ self,
266
+ _client: mqtt.Client,
267
+ userdata: Any,
268
+ mid: int,
269
+ reason_code_list: list[ReasonCode],
270
+ properties: Properties | None = None,
271
+ ) -> None:
272
+ self.log.debug(
273
+ "on_unsubscribe, userdata=%s, mid=%s, reasons=%s, properties=%s", userdata, mid, reason_code_list, properties
274
+ )
275
+
276
+ def on_message(self, _client: mqtt.Client, _userdata: Any, msg: mqtt.MQTTMessage) -> None:
277
+ """Callback for incoming MQTT messages""" # noqa: D401
278
+ if msg.topic in self.providers_by_topic:
279
+ self.handle_message(msg)
280
+ else:
281
+ # apparently the root non-wildcard sub sometimes brings in child topics
282
+ self.log.debug("Unhandled message #%s on %s:%s", msg.mid, msg.topic, msg.payload)
283
+
284
+ def handle_message(self, msg: mqtt.MQTTMessage | LocalMessage) -> None:
285
+ def update_start(discovery: Discovery) -> None:
286
+ self.publish_hass_state(discovery, in_progress=True)
287
+
288
+ def update_end(discovery: Discovery) -> None:
289
+ self.publish_hass_state(discovery, in_progress=False)
290
+
291
+ if self.event_loop is not None:
292
+ asyncio.run_coroutine_threadsafe(self.execute_command(msg, update_start, update_end), self.event_loop)
293
+ else:
294
+ self.log.error("No event loop to handle message", topic=msg.topic)
295
+
296
+ def config_topic(self, discovery: Discovery) -> str:
297
+ prefix = self.hass_cfg.discovery.prefix
298
+ return f"{prefix}/update/{self.node_cfg.name}_{discovery.source_type}_{discovery.name}/update/config"
299
+
300
+ def state_topic(self, discovery: Discovery) -> str:
301
+ return f"{self.cfg.topic_root}/{self.node_cfg.name}/{discovery.source_type}/{discovery.name}"
302
+
303
+ def command_topic(self, provider: ReleaseProvider) -> str:
304
+ return f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}"
305
+
306
+ def publish_hass_state(self, discovery: Discovery, in_progress: bool = False) -> None:
307
+ self.log.debug("HASS State update, in progress: %s, discovery: %s", in_progress, discovery)
308
+ self.publish(
309
+ self.state_topic(discovery),
310
+ hass_format_state(
311
+ discovery,
312
+ discovery.session,
313
+ in_progress=in_progress,
314
+ ),
315
+ )
316
+
317
+ def publish_hass_config(self, discovery: Discovery) -> None:
318
+ object_id = f"{discovery.source_type}_{self.node_cfg.name}_{discovery.name}"
319
+ self.publish(
320
+ self.config_topic(discovery),
321
+ hass_format_config(
322
+ discovery=discovery,
323
+ object_id=object_id,
324
+ area=self.hass_cfg.area,
325
+ state_topic=self.state_topic(discovery),
326
+ command_topic=self.command_topic(discovery.provider),
327
+ force_command_topic=self.hass_cfg.force_command_topic,
328
+ device_creation=self.hass_cfg.device_creation,
329
+ session=discovery.session,
330
+ ),
331
+ )
332
+
333
+ def subscribe_hass_command(self, provider: ReleaseProvider): # noqa: ANN201
334
+ topic = self.command_topic(provider)
335
+ if topic in self.providers_by_topic or self.client is None:
336
+ self.log.debug("Skipping subscription", topic=topic)
337
+ else:
338
+ self.log.info("Handler subscribing", topic=topic)
339
+ self.providers_by_topic[topic] = provider
340
+ self.client.subscribe(topic)
341
+ return topic
342
+
343
+ def loop_once(self) -> None:
344
+ if self.client:
345
+ self.client.loop()
346
+
347
+ def publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = True) -> None:
348
+ if self.client:
349
+ self.client.publish(topic, payload=json.dumps(payload), qos=qos, retain=retain)
updates2mqtt/py.typed ADDED
File without changes