Coverage for src/updates2mqtt/mqtt.py: 76%

335 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-09 06:17 +0000

1import asyncio 

2import json 

3import re 

4import time 

5from collections.abc import Callable 

6from dataclasses import dataclass, field 

7from threading import Event 

8from typing import Any, Literal, cast 

9 

10import paho.mqtt.client as mqtt 

11import paho.mqtt.subscribeoptions 

12import structlog 

13from paho.mqtt.client import MQTT_CLEAN_START_FIRST_ONLY, MQTTMessage, MQTTMessageInfo 

14from paho.mqtt.enums import CallbackAPIVersion, MQTTErrorCode, MQTTProtocolVersion 

15from paho.mqtt.properties import Properties 

16from paho.mqtt.reasoncodes import ReasonCode 

17 

18from updates2mqtt.model import Discovery, ReleaseProvider 

19 

20from .config import HomeAssistantConfig, MqttConfig, NodeConfig, PublishPolicy, TlsMode 

21from .hass_formatter import hass_format_config, hass_format_state 

22 

23log = structlog.get_logger() 

24 

25MQTT_NAME = r"[A-Za-z0-9_\-\.]+" 

26 

27 

28@dataclass 

29class LocalMessage: 

30 topic: str | None = field(default=None) 

31 payload: str | None = field(default=None) 

32 

33 

34class MqttPublisher: 

35 def __init__(self, cfg: MqttConfig, node_cfg: NodeConfig, hass_cfg: HomeAssistantConfig) -> None: 

36 self.cfg: MqttConfig = cfg 

37 self.node_cfg: NodeConfig = node_cfg 

38 self.hass_cfg: HomeAssistantConfig = hass_cfg 

39 self.providers_by_topic: dict[str, ReleaseProvider] = {} 

40 self.providers_by_type: dict[str, ReleaseProvider] = {} 

41 self.event_loop: asyncio.AbstractEventLoop | None = None 

42 self.client: mqtt.Client | None = None 

43 self.fatal_failure = Event() 

44 self.connected = Event() 

45 self.commands_in_progress: set[tuple[str, str]] = set() 

46 self.log = structlog.get_logger().bind(host=cfg.host, integration="mqtt") 

47 

48 def start(self, event_loop: asyncio.AbstractEventLoop | None = None) -> None: 

49 logger = self.log.bind(action="start") 

50 try: 

51 protocol: MQTTProtocolVersion 

52 if self.cfg.protocol in ("3", "3.11"): 

53 protocol = MQTTProtocolVersion.MQTTv311 

54 elif self.cfg.protocol == "3.1": 

55 protocol = MQTTProtocolVersion.MQTTv31 

56 elif self.cfg.protocol in ("5", "5.0"): 

57 protocol = MQTTProtocolVersion.MQTTv5 

58 else: 

59 logger.info("No valid MQTT protocol version found (%s), setting to default v3.11", self.cfg.protocol) 

60 protocol = MQTTProtocolVersion.MQTTv311 

61 logger.debug("MQTT protocol set to %r", protocol) 

62 

63 self.event_loop = event_loop or asyncio.get_event_loop() 

64 self.client = mqtt.Client( 

65 callback_api_version=CallbackAPIVersion.VERSION2, 

66 client_id=f"updates2mqtt_{self.node_cfg.name}", 

67 clean_session=True if protocol != MQTTProtocolVersion.MQTTv5 else None, 

68 protocol=protocol, 

69 transport=cast(Literal["tcp", "websockets", "unix"], self.cfg.transport), 

70 ) 

71 self.client.username_pw_set(self.cfg.user, password=self.cfg.password) 

72 

73 if self.cfg.tls_mode != TlsMode.OFF: 

74 logger.debug("Configuring TLS, ca_certs=%s,cert_reqs=%s", self.cfg.ca_certs, self.cfg.cert_reqs) 

75 # https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html#paho.mqtt.client.Client.tls_set 

76 self.client.tls_set( 

77 ca_certs=self.cfg.ca_certs or None, 

78 certfile=self.cfg.client_cert or None, 

79 keyfile=self.cfg.client_key or None, 

80 keyfile_password=self.cfg.client_key_password or None, 

81 cert_reqs=self.cfg.cert_reqs or None, 

82 ) 

83 if self.cfg.tls_mode == TlsMode.INSECURE: 

84 self.client.tls_insecure_set(True) 

85 logger.warning( 

86 "TLS set to insecure mode, broker host name will not be validated, do not use for production." 

87 ) 

88 rc: MQTTErrorCode = self.client.connect( 

89 host=self.cfg.host, 

90 port=self.cfg.port, 

91 keepalive=self.cfg.keepalive, 

92 clean_start=MQTT_CLEAN_START_FIRST_ONLY, 

93 ) 

94 logger.info("Client connection requested", result_code=rc) 

95 

96 self.client.on_connect = self.on_connect 

97 self.client.on_disconnect = self.on_disconnect 

98 self.client.on_message = self.on_message 

99 self.client.on_subscribe = self.on_subscribe 

100 self.client.on_unsubscribe = self.on_unsubscribe 

101 

102 self.client.loop_start() 

103 

104 if not self.connected.wait(timeout=self.cfg.connect_timeout) and not self.fatal_failure.is_set(): 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true

105 logger.warning("Timed out waiting for broker connection, continuing anyway", timeout=self.cfg.connect_timeout) 

106 

107 logger.debug("MQTT Publisher loop started", host=self.cfg.host, port=self.cfg.port) 

108 except Exception as e: 

109 logger.error("Failed to connect to broker", host=self.cfg.host, port=self.cfg.port, error=str(e)) 

110 raise OSError(f"Connection Failure to {self.cfg.host}:{self.cfg.port} as {self.cfg.user} -- {e}") from e 

111 

112 def stop(self) -> None: 

113 if self.client: 

114 self.client.loop_stop() 

115 self.client.disconnect() 

116 self.client = None 

117 self.connected.clear() 

118 

119 def is_available(self) -> bool: 

120 return self.client is not None and not self.fatal_failure.is_set() 

121 

122 def on_connect( 

123 self, _client: mqtt.Client, _userdata: Any, _flags: mqtt.ConnectFlags, rc: ReasonCode, _props: Properties | None 

124 ) -> None: 

125 if not self.client or self.fatal_failure.is_set(): 

126 self.log.warning("No client, check if started and authorized") 

127 return 

128 if rc.getName() == "Not authorized": 

129 self.fatal_failure.set() 

130 log.error("Invalid MQTT credentials", result_code=rc) 

131 return 

132 if rc != 0: 

133 self.log.warning("Connection failed to broker", result_code=rc) 

134 else: 

135 self.log.debug("Connected successfully to MQTT broker") 

136 self.connected.set() 

137 for topic, provider in self.providers_by_topic.items(): 

138 self.log.debug("(Re)subscribing", topic=topic, provider=provider.source_type) 

139 self.client.subscribe(topic) 

140 

141 def on_disconnect( 

142 self, 

143 _client: mqtt.Client, 

144 _userdata: Any, 

145 _disconnect_flags: mqtt.DisconnectFlags, 

146 rc: ReasonCode, 

147 _props: Properties | None, 

148 ) -> None: 

149 self.connected.clear() 

150 if rc == 0: 

151 self.log.debug("Disconnected from broker", result_code=rc) 

152 else: 

153 self.log.warning("Disconnect failure from broker", result_code=rc) 

154 

155 async def clean_topics( 

156 self, provider: ReleaseProvider, wait_time: int = 5, max_time: int = 120, initial: bool = False 

157 ) -> None: 

158 logger = self.log.bind(action="clean") 

159 

160 if self.fatal_failure.is_set(): 

161 return 

162 try: 

163 logger.info("Starting clean cycle, wait time: %s, max time: %s, initial: %s", wait_time, max_time, initial) 

164 cutoff_time: float = time.time() + max_time 

165 cleaner = mqtt.Client( 

166 callback_api_version=CallbackAPIVersion.VERSION1, 

167 client_id=f"updates2mqtt_clean_{self.node_cfg.name}", 

168 clean_session=True, 

169 ) 

170 results = {"cleaned": 0, "matched": 0, "discovered": 0, "last_timestamp": time.time()} 

171 cleaner.username_pw_set(self.cfg.user, password=self.cfg.password) 

172 cleaner.connect(host=self.cfg.host, port=self.cfg.port, keepalive=self.cfg.keepalive) 

173 

174 def cleanup(_client: mqtt.Client, _userdata: Any, msg: mqtt.MQTTMessage) -> None: 

175 discovery: Discovery | None = None 

176 if msg.topic.startswith( 

177 f"{self.hass_cfg.discovery.prefix}/update/{self.node_cfg.name}_{provider.source_type}_" 

178 ): 

179 discovery = self.reverse_config_topic(msg.topic, provider.source_type) 

180 elif msg.topic.startswith( 180 ↛ 183line 180 didn't jump to line 183 because the condition on line 180 was never true

181 f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}/" 

182 ) and msg.topic.endswith("/state"): 

183 discovery = self.reverse_state_topic(msg.topic, provider.source_type) 

184 elif msg.topic.startswith(f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}/"): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 discovery = self.reverse_general_topic(msg.topic, provider.source_type) 

186 else: 

187 logger.debug("Ignoring other topic ", topic=msg.topic) 

188 return 

189 results["discovered"] += 1 

190 if not initial and discovery is None: 

191 logger.debug("Removing unknown discovery", topic=msg.topic) 

192 cleaner.publish(msg.topic, "", retain=True) 

193 results["cleaned"] += 1 

194 elif discovery is not None: 194 ↛ 197line 194 didn't jump to line 197 because the condition on line 194 was always true

195 results["matched"] += 1 

196 

197 try: 

198 if msg.payload: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 payload = json.loads(msg.payload) 

200 update_section = payload.get("update") if isinstance(payload.get("update"), dict) else None 

201 lingering_in_progress = payload.get("in_progress") or ( 

202 update_section and update_section.get("in_progress") 

203 ) 

204 if lingering_in_progress and initial: 

205 logger.info("Clearing lingering in-progress state at %s", msg.topic) 

206 payload["in_progress"] = False 

207 if update_section is not None: 

208 update_section["in_progress"] = False 

209 cleaner.publish(msg.topic, json.dumps(payload), retain=True) 

210 results["cleaned"] += 1 

211 elif ( 

212 initial 

213 and msg.topic.endswith("/state") 

214 and (payload.get("installed_version") is None or payload.get("latest_version") is None) 

215 ): 

216 # Stale/incomplete state message (e.g. from an older schema) leaves HA showing 

217 # "unknown" forever, since it has nothing to compare against. Clear it so the 

218 # upcoming scan can publish a complete replacement. 

219 logger.info("Clearing stale incomplete state at %s", msg.topic) 

220 cleaner.publish(msg.topic, "", retain=True) 

221 results["cleaned"] += 1 

222 except Exception as e: 

223 logger.warning("Invalid payload at %s: %s", msg.topic, e) 

224 cleaner.publish(msg.topic, "", retain=True) 

225 results["cleaned"] += 1 

226 

227 results["last_timestamp"] = time.time() 

228 

229 cleaner.on_message = cleanup 

230 options = paho.mqtt.subscribeoptions.SubscribeOptions(noLocal=True) 

231 cleaner.subscribe(f"{self.hass_cfg.discovery.prefix}/update/#", options=options) 

232 cleaner.subscribe(f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}/#", options=options) 

233 

234 while time.time() - results["last_timestamp"] <= wait_time and time.time() <= cutoff_time: 

235 cleaner.loop(0.5) 

236 

237 logger.info( 

238 f"Cleaned - discovered:{results['discovered']}, matched:{results['matched']}, cleaned:{results['cleaned']}" 

239 ) 

240 except Exception: 

241 logger.exception("Cleaning topics of stale entries failed") 

242 

243 def safe_json_decode(self, jsonish: str | bytes | None) -> dict: 

244 if jsonish is None: 

245 return {} 

246 try: 

247 return json.loads(jsonish) 

248 except Exception: 

249 log.exception("JSON decode fail (%s)", jsonish) 

250 try: 

251 return json.loads(jsonish[1:-1]) 

252 except Exception: 

253 log.exception("JSON decode fail (%s)", jsonish[1:-1]) 

254 return {} 

255 

256 def validate_command(self, msg: MQTTMessage | LocalMessage) -> tuple[ReleaseProvider, str, str] | None: 

257 

258 logger = self.log.bind(topic=msg.topic, payload=msg.payload) 

259 comp_name: str | None = None 

260 command: str | None = None 

261 try: 

262 logger.info("Command received for %s", msg.topic) 

263 source_type: str | None = None 

264 

265 payload: str | None = None 

266 if isinstance(msg.payload, bytes): 

267 payload = msg.payload.decode("utf-8") 

268 elif isinstance(msg.payload, str): 268 ↛ 270line 268 didn't jump to line 270 because the condition on line 268 was always true

269 payload = msg.payload 

270 if payload and "|" in payload: 

271 source_type, comp_name, command = payload.split("|") 

272 else: 

273 logger.warning("Invalid command format, expecting `source_type|comp_name|command`") 

274 return None 

275 logger.debug("Validating %s:%s:%s", source_type, comp_name, command) 

276 

277 provider: ReleaseProvider | None = self.providers_by_topic.get(msg.topic) if msg.topic else None 

278 

279 if not provider: 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true

280 logger.warning("Unexpected provider type %s", msg.topic) 

281 return None 

282 if source_type is None or provider.source_type != source_type: 

283 logger.warning("Unexpected source type %s", source_type) 

284 return None 

285 if command != "install": 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 logger.warning("Unknown command: %s", command) 

287 return None 

288 if not comp_name: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 logger.warning("Missing comp_name in command message: %s", msg.payload) 

290 return None 

291 

292 in_progress_key: tuple[str, str] = (source_type, comp_name) 

293 if in_progress_key in self.commands_in_progress: 

294 logger.warning("Ignoring duplicate %s command for %s, already in progress", command, comp_name) 

295 else: 

296 self.commands_in_progress.add(in_progress_key) 

297 return (provider, comp_name, command) 

298 except Exception: 

299 logger.error("Unexpected error validating command") 

300 return None 

301 

302 async def execute_command( 

303 self, provider: ReleaseProvider, comp_name: str, command: str, on_update_start: Callable, on_update_end: Callable 

304 ) -> None: 

305 # TODO: defer handling of commands where repository is throttled 

306 logger = self.log.bind(source_type=provider.source_type, comp_name=comp_name, command=command) 

307 try: 

308 logger.info("Execution starting for %s %s", command, comp_name) 

309 

310 in_progress_key: tuple[str, str] = (provider.source_type, comp_name) 

311 logger.info( 

312 "Passing %s command to %s scanner for %s", 

313 command, 

314 provider.source_type, 

315 comp_name, 

316 ) 

317 try: 

318 updated: bool = provider.command(comp_name, command, on_update_start, on_update_end) 

319 discovery = provider.resolve(comp_name) 

320 if updated and discovery: 320 ↛ 328line 320 didn't jump to line 328 because the condition on line 320 was always true

321 if discovery.publish_policy == PublishPolicy.HOMEASSISTANT and self.hass_cfg.discovery.enabled: 

322 self.publish_hass_config(discovery) 

323 if discovery.publish_policy in (PublishPolicy.HOMEASSISTANT, PublishPolicy.MQTT): 

324 self.publish_discovery(discovery) 

325 if discovery and discovery.publish_policy == PublishPolicy.HOMEASSISTANT: 

326 self.publish_hass_state(discovery) 

327 else: 

328 logger.debug("No change to republish after execution") 

329 finally: 

330 if in_progress_key in self.commands_in_progress: 330 ↛ 332line 330 didn't jump to line 332 because the condition on line 330 was always true

331 self.commands_in_progress.discard(in_progress_key) 

332 logger.info("Execution ended") 

333 except Exception: 

334 logger.exception("Execution failed") 

335 

336 def local_message(self, discovery: Discovery, command: str) -> None: 

337 """Simulate an incoming MQTT message for local commands""" 

338 msg = LocalMessage( 

339 topic=self.command_topic(discovery.provider), payload=f"{discovery.source_type}|{discovery.name}|{command}" 

340 ) 

341 self.handle_message(msg) 

342 

343 def on_subscribe( 

344 self, 

345 _client: mqtt.Client, 

346 userdata: Any, 

347 mid: int, 

348 reason_code_list: list[ReasonCode], 

349 properties: Properties | None = None, 

350 ) -> None: 

351 self.log.debug( 

352 "on_subscribe, userdata=%s, mid=%s, reasons=%s, properties=%s", userdata, mid, reason_code_list, properties 

353 ) 

354 

355 def on_unsubscribe( 

356 self, 

357 _client: mqtt.Client, 

358 userdata: Any, 

359 mid: int, 

360 reason_code_list: list[ReasonCode], 

361 properties: Properties | None = None, 

362 ) -> None: 

363 self.log.debug( 

364 "on_unsubscribe, userdata=%s, mid=%s, reasons=%s, properties=%s", userdata, mid, reason_code_list, properties 

365 ) 

366 

367 def on_message(self, _client: mqtt.Client, _userdata: Any, msg: mqtt.MQTTMessage) -> None: 

368 """Callback for incoming MQTT messages""" 

369 if msg.topic in self.providers_by_topic: 

370 self.handle_message(msg) 

371 else: 

372 # apparently the root non-wildcard sub sometimes brings in child topics 

373 self.log.debug("Unhandled message #%s on %s:%s", msg.mid, msg.topic, msg.payload) 

374 

375 def handle_message(self, msg: mqtt.MQTTMessage | LocalMessage) -> None: 

376 def update_start(discovery: Discovery) -> None: 

377 self.log.debug("on_update_start: %s", topic=msg.topic) 

378 if discovery.publish_policy == PublishPolicy.HOMEASSISTANT: 

379 self.publish_hass_state(discovery, in_progress=True) 

380 if discovery.publish_policy in (PublishPolicy.HOMEASSISTANT, PublishPolicy.MQTT): 

381 self.publish_discovery(discovery, in_progress=True) 

382 

383 def update_end(discovery: Discovery) -> None: 

384 self.log.debug("on_update_end: %s", topic=msg.topic) 

385 if discovery.publish_policy == PublishPolicy.HOMEASSISTANT: 

386 self.publish_hass_state(discovery, in_progress=False) 

387 if discovery.publish_policy in (PublishPolicy.HOMEASSISTANT, PublishPolicy.MQTT): 

388 self.publish_discovery(discovery, in_progress=False) 

389 

390 # TODO: fix double publish on callback and in command exec 

391 if self.event_loop is not None: 391 ↛ 407line 391 didn't jump to line 407 because the condition on line 391 was always true

392 self.log.debug("Executing command topic", topic=msg.topic) 

393 parsed: tuple[ReleaseProvider, str, str] | None = self.validate_command(msg=msg) 

394 if parsed is not None: 394 ↛ exitline 394 didn't return from function 'handle_message' because the condition on line 394 was always true

395 provider, comp_name, command = parsed 

396 asyncio.run_coroutine_threadsafe( 

397 self.execute_command( 

398 provider=provider, 

399 comp_name=comp_name, 

400 command=command, 

401 on_update_start=update_start, 

402 on_update_end=update_end, 

403 ), 

404 loop=self.event_loop, 

405 ) 

406 else: 

407 self.log.error("No event loop to handle message", topic=msg.topic) 

408 

409 def config_topic(self, discovery: Discovery) -> str: 

410 prefix = self.hass_cfg.discovery.prefix 

411 return f"{prefix}/update/{self.node_cfg.name}_{discovery.source_type}_{discovery.name}/update/config" 

412 

413 def reverse_config_topic(self, topic: str, source_type: str) -> Discovery | None: 

414 match = re.fullmatch( 

415 f"{self.hass_cfg.discovery.prefix}/update/{self.node_cfg.name}_{source_type}_({MQTT_NAME})/update/config", 

416 topic, 

417 ) 

418 if match and len(match.groups()) == 1: 418 ↛ 423line 418 didn't jump to line 423 because the condition on line 418 was always true

419 discovery_name: str = match.group(1) 

420 if source_type in self.providers_by_type and discovery_name in self.providers_by_type[source_type].discoveries: 

421 return self.providers_by_type[source_type].discoveries[discovery_name] 

422 

423 self.log.debug("MQTT CONFIG no match for %s", topic) 

424 return None 

425 

426 def state_topic(self, discovery: Discovery) -> str: 

427 return f"{self.cfg.topic_root}/{self.node_cfg.name}/{discovery.source_type}/{discovery.name}/state" 

428 

429 def reverse_state_topic(self, topic: str, source_type: str) -> Discovery | None: 

430 match = re.fullmatch( 

431 f"{self.cfg.topic_root}/{self.node_cfg.name}/{source_type}/({MQTT_NAME})/state", 

432 topic, 

433 ) 

434 if match and len(match.groups()) == 1: 

435 discovery_name: str = match.group(1) 

436 if discovery_name in self.providers_by_type[source_type].discoveries: 

437 return self.providers_by_type[source_type].discoveries[discovery_name] 

438 

439 self.log.debug("MQTT STATE no match for %s", topic) 

440 return None 

441 

442 def general_topic(self, discovery: Discovery) -> str: 

443 return f"{self.cfg.topic_root}/{self.node_cfg.name}/{discovery.source_type}/{discovery.name}" 

444 

445 def reverse_general_topic(self, topic: str, source_type: str) -> Discovery | None: 

446 match = re.fullmatch(f"{self.cfg.topic_root}/{self.node_cfg.name}/{source_type}/({MQTT_NAME})", topic) 

447 if match and len(match.groups()) == 1: 

448 discovery_name: str = match.group(1) 

449 if discovery_name in self.providers_by_type[source_type].discoveries: 

450 return self.providers_by_type[source_type].discoveries[discovery_name] 

451 

452 self.log.debug("MQTT ATTR no match for %s", topic) 

453 return None 

454 

455 def command_topic(self, provider: ReleaseProvider) -> str: 

456 return f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}" 

457 

458 def publish_discovery(self, discovery: Discovery, in_progress: bool = False) -> None: 

459 """Comprehensive, non Home Assistant specific, base publication""" 

460 if discovery.publish_policy not in (PublishPolicy.HOMEASSISTANT, PublishPolicy.MQTT): 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 return 

462 self.log.debug("Discovery publish: %s", discovery) 

463 payload: dict[str, Any] = discovery.as_dict() 

464 payload["update"]["in_progress"] = in_progress # ty:ignore[invalid-assignment] 

465 if payload.get("release", {}).get("summary") and self.hass_cfg.release_summary_max_size: 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true

466 payload["release"]["summary"] = payload["release"]["summary"][: self.hass_cfg.release_summary_max_size] 

467 self.publish(self.general_topic(discovery), payload) 

468 

469 def publish_hass_state(self, discovery: Discovery, in_progress: bool = False) -> None: 

470 if discovery.publish_policy != PublishPolicy.HOMEASSISTANT: 470 ↛ 471line 470 didn't jump to line 471 because the condition on line 470 was never true

471 return 

472 self.log.debug("HASS State update, in progress: %s, discovery: %s", in_progress, discovery) 

473 self.publish( 

474 self.state_topic(discovery), 

475 hass_format_state( 

476 discovery, in_progress=in_progress, release_summary_max_size=self.hass_cfg.release_summary_max_size 

477 ), 

478 ) 

479 

480 def publish_hass_config(self, discovery: Discovery) -> None: 

481 if discovery.publish_policy != PublishPolicy.HOMEASSISTANT: 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true

482 return 

483 object_id = f"{discovery.source_type}_{self.node_cfg.name}_{discovery.name}" 

484 self.log.debug("HASS Config: %s", object_id) 

485 

486 self.publish( 

487 self.config_topic(discovery), 

488 hass_format_config( 

489 discovery=discovery, 

490 object_id=object_id, 

491 area=self.hass_cfg.area, 

492 state_topic=self.state_topic(discovery), 

493 attrs_topic=self.general_topic(discovery) if self.hass_cfg.extra_attributes else None, 

494 command_topic=self.command_topic(discovery.provider), 

495 force_command_topic=self.hass_cfg.force_command_topic, 

496 device_creation=self.hass_cfg.device_creation, 

497 ), 

498 ) 

499 

500 def subscribe_hass_command(self, provider: ReleaseProvider): 

501 topic = self.command_topic(provider) 

502 if topic in self.providers_by_topic or self.client is None: 

503 self.log.debug("Skipping subscription", topic=topic) 

504 else: 

505 self.log.info("Handler subscribing", topic=topic) 

506 self.providers_by_topic[topic] = provider 

507 self.providers_by_type[provider.source_type] = provider 

508 self.client.subscribe(topic) 

509 return topic 

510 

511 def loop_once(self) -> None: 

512 if self.client: 

513 self.client.loop() 

514 

515 def publish(self, topic: str, payload: dict, qos: int = 1, retain: bool = True) -> None: 

516 if self.client: 

517 info: MQTTMessageInfo = self.client.publish(topic, payload=json.dumps(payload), qos=qos, retain=retain) 

518 if info.rc == MQTTErrorCode.MQTT_ERR_SUCCESS: 

519 self.log.debug( 

520 "Publish to %s, mid: %s, published: %s, qos: %s, rc: %s", topic, info.mid, info.is_published(), qos, info.rc 

521 ) 

522 elif info.rc == MQTTErrorCode.MQTT_ERR_NO_CONN and qos > 0: 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true

523 self.log.debug( 

524 "Not currently connected, queued for delivery on reconnect: %s, mid: %s, qos: %s", 

525 topic, 

526 info.mid, 

527 qos, 

528 ) 

529 else: 

530 self.log.warning("Problem publishing to %s, mid: %s, qos: %s, rc: %s", topic, info.mid, qos, info.rc) 

531 else: 

532 self.log.debug("No client to publish at %s", topic)