updates2mqtt 1.4.1__py3-none-any.whl → 1.5.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.
updates2mqtt/app.py CHANGED
@@ -14,7 +14,7 @@ import structlog
14
14
  import updates2mqtt
15
15
  from updates2mqtt.model import Discovery, ReleaseProvider
16
16
 
17
- from .config import Config, load_app_config, load_package_info
17
+ from .config import Config, PackageUpdateInfo, load_app_config, load_package_info
18
18
  from .integrations.docker import DockerProvider
19
19
  from .mqtt import MqttPublisher
20
20
 
@@ -44,9 +44,9 @@ class App:
44
44
  sys.exit(1)
45
45
  self.cfg: Config = app_config
46
46
 
47
- structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, self.cfg.log.level)))
47
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, str(self.cfg.log.level))))
48
48
  log.debug("Logging initialized", level=self.cfg.log.level)
49
- self.common_pkg = load_package_info(PKG_INFO_FILE)
49
+ self.common_pkg: dict[str, PackageUpdateInfo] = load_package_info(PKG_INFO_FILE)
50
50
 
51
51
  self.publisher = MqttPublisher(self.cfg.mqtt, self.cfg.node, self.cfg.homeassistant)
52
52
 
@@ -62,25 +62,29 @@ class App:
62
62
  "App configured",
63
63
  node=self.cfg.node.name,
64
64
  scan_interval=self.cfg.scan_interval,
65
+ healthcheck_topic=self.healthcheck_topic,
65
66
  )
66
67
 
67
68
  async def scan(self) -> None:
68
69
  session = uuid.uuid4().hex
69
70
  for scanner in self.scanners:
70
- log.info("Cleaning topics before scan", source_type=scanner.source_type)
71
+ slog = log.bind(source_type=scanner.source_type, session=session)
72
+ slog.info("Cleaning topics before scan")
71
73
  if self.scan_count == 0:
72
74
  await self.publisher.clean_topics(scanner, None, force=True)
73
75
  if self.stopped.is_set():
74
76
  break
75
- log.info("Scanning", source=scanner.source_type, session=session)
77
+ slog.info("Scanning ...")
76
78
  async with asyncio.TaskGroup() as tg:
77
- async for discovery in scanner.scan(session): # xtype: ignore[attr-defined]
79
+ # xtype: ignore[attr-defined]
80
+ async for discovery in scanner.scan(session):
78
81
  tg.create_task(self.on_discovery(discovery), name=f"discovery-{discovery.name}")
79
82
  if self.stopped.is_set():
83
+ slog.debug("Breaking scan loop on stopped event")
80
84
  break
81
85
  await self.publisher.clean_topics(scanner, session, force=False)
82
86
  self.scan_count += 1
83
- log.info("Scan complete", source_type=scanner.source_type)
87
+ slog.info(f"Scan #{self.scan_count} complete")
84
88
  self.last_scan_timestamp = datetime.now(UTC).isoformat()
85
89
 
86
90
  async def main_loop(self) -> None:
@@ -170,13 +174,15 @@ class App:
170
174
  async def healthcheck(self) -> None:
171
175
  if not self.publisher.is_available():
172
176
  return
177
+ heartbeat_stamp: str = datetime.now(UTC).isoformat()
178
+ log.debug("Publishing health check", heartbeat_stamp=heartbeat_stamp)
173
179
  self.publisher.publish(
174
180
  topic=self.healthcheck_topic,
175
181
  payload={
176
182
  "version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
177
183
  "node": self.cfg.node.name,
178
184
  "heartbeat_raw": time.time(),
179
- "heartbeat_stamp": datetime.now(UTC).isoformat(),
185
+ "heartbeat_stamp": heartbeat_stamp,
180
186
  "startup_stamp": self.startup_timestamp,
181
187
  "last_scan_stamp": self.last_scan_timestamp,
182
188
  "scan_count": self.scan_count,
@@ -191,7 +197,7 @@ async def repeated_call(func: Callable, interval: int = 60, *args: Any, **kwargs
191
197
  await func(*args, **kwargs)
192
198
  await asyncio.sleep(interval)
193
199
  except asyncio.CancelledError:
194
- log.debug("Periodic task cancelled")
200
+ log.debug("Periodic task cancelled", func=func)
195
201
  except Exception:
196
202
  log.exception("Periodic task failed")
197
203
 
@@ -202,6 +208,7 @@ def run() -> None:
202
208
 
203
209
  from .app import App
204
210
 
211
+ # pyright: ignore[reportAttributeAccessIssue]
205
212
  log.debug(f"Starting updates2mqtt v{updates2mqtt.version}") # pyright: ignore[reportAttributeAccessIssue]
206
213
  app = App()
207
214
 
updates2mqtt/config.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import typing
3
3
  from dataclasses import dataclass, field
4
+ from enum import StrEnum
4
5
  from pathlib import Path
5
6
 
6
7
  import structlog
@@ -9,6 +10,14 @@ from omegaconf import MISSING, DictConfig, MissingMandatoryValue, OmegaConf, Val
9
10
  log = structlog.get_logger()
10
11
 
11
12
 
13
+ class LogLevel(StrEnum):
14
+ DEBUG = "DEBUG"
15
+ INFO = "INFO"
16
+ WARNING = "WARNING"
17
+ ERROR = "ERROR"
18
+ CRITICAL = "CRITICAL"
19
+
20
+
12
21
  @dataclass
13
22
  class MqttConfig:
14
23
  host: str = "${oc.env:MQTT_HOST,localhost}"
@@ -33,7 +42,8 @@ class DockerConfig:
33
42
  allow_build: bool = True
34
43
  compose_version: str = "v2"
35
44
  default_entity_picture_url: str = "https://www.docker.com/wp-content/uploads/2022/03/Moby-logo.png"
36
- device_icon: str = "mdi:docker" # Icon to show when browsing entities in Home Assistant
45
+ # Icon to show when browsing entities in Home Assistant
46
+ device_icon: str = "mdi:docker"
37
47
  discover_metadata: dict[str, MetadataSourceConfig] = field(
38
48
  default_factory=lambda: {"linuxserver.io": MetadataSourceConfig(enabled=True)}
39
49
  )
@@ -49,6 +59,9 @@ class HomeAssistantDiscoveryConfig:
49
59
  class HomeAssistantConfig:
50
60
  discovery: HomeAssistantDiscoveryConfig = field(default_factory=HomeAssistantDiscoveryConfig)
51
61
  state_topic_suffix: str = "state"
62
+ device_creation: bool = True
63
+ force_command_topic: bool = False
64
+ area: str | None = None
52
65
 
53
66
 
54
67
  @dataclass
@@ -67,14 +80,14 @@ class NodeConfig:
67
80
 
68
81
  @dataclass
69
82
  class LogConfig:
70
- level: str = "INFO"
83
+ level: LogLevel = LogLevel.INFO
71
84
 
72
85
 
73
86
  @dataclass
74
87
  class Config:
75
88
  log: LogConfig = field(default_factory=LogConfig)
76
89
  node: NodeConfig = field(default_factory=NodeConfig)
77
- mqtt: MqttConfig = field(default_factory=MqttConfig)
90
+ mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
78
91
  homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
79
92
  docker: DockerConfig = field(default_factory=DockerConfig)
80
93
  scan_interval: int = 60 * 60 * 3
@@ -101,15 +114,22 @@ class IncompleteConfigException(BaseException):
101
114
  pass
102
115
 
103
116
 
104
- def load_package_info(pkginfo_file_path: Path) -> UpdateInfoConfig:
117
+ def load_package_info(pkginfo_file_path: Path) -> dict[str, PackageUpdateInfo]:
105
118
  if pkginfo_file_path.exists():
106
119
  log.debug("Loading common package update info", path=pkginfo_file_path)
107
120
  cfg = OmegaConf.load(pkginfo_file_path)
108
121
  else:
109
122
  log.warn("No common package update info found", path=pkginfo_file_path)
110
123
  cfg = OmegaConf.structured(UpdateInfoConfig)
111
- OmegaConf.set_readonly(cfg, True)
112
- return typing.cast("UpdateInfoConfig", cfg)
124
+ try:
125
+ # omegaconf broken-ness on optional fields and converting to backclasses
126
+ pkg_conf: dict[str, PackageUpdateInfo] = {
127
+ pkg: PackageUpdateInfo(**pkg_cfg) for pkg, pkg_cfg in cfg.common_packages.items()
128
+ }
129
+ return pkg_conf
130
+ except (MissingMandatoryValue, ValidationError) as e:
131
+ log.error("Configuration error %s", e, path=pkginfo_file_path.as_posix())
132
+ raise
113
133
 
114
134
 
115
135
  def load_app_config(conf_file_path: Path, return_invalid: bool = False) -> Config | None:
@@ -19,32 +19,55 @@ HASS_UPDATE_SCHEMA = [
19
19
 
20
20
 
21
21
  def hass_format_config(
22
- discovery: Discovery, object_id: str, node_name: str, state_topic: str, command_topic: str | None, session: str
22
+ discovery: Discovery,
23
+ object_id: str,
24
+ node_name: str,
25
+ state_topic: str,
26
+ command_topic: str | None,
27
+ force_command_topic: bool | None,
28
+ device_creation: bool = True,
29
+ area: str | None = None,
30
+ session: str | None = None,
23
31
  ) -> dict[str, Any]:
24
- config = {
25
- "name": f"{discovery.name} {discovery.source_type} on {node_name}",
32
+ if device_creation:
33
+ # avoid duplication, since Home Assistant will concatenate device and entity name on update
34
+ name: str = f"{discovery.name} {discovery.source_type}"
35
+ else:
36
+ name = f"{discovery.name} {discovery.source_type} on {node_name}"
37
+ config: dict[str, Any] = {
38
+ "name": name,
26
39
  "device_class": None, # not firmware, so defaults to null
27
40
  "unique_id": object_id,
28
41
  "state_topic": state_topic,
29
42
  "source_session": session,
30
43
  "supported_features": discovery.features,
31
- "entity_picture": discovery.entity_picture_url,
32
- "icon": discovery.device_icon,
33
44
  "can_update": discovery.can_update,
34
45
  "can_build": discovery.can_build,
35
46
  "can_restart": discovery.can_restart,
36
47
  "update_policy": discovery.update_policy,
37
- "latest_version_topic": state_topic,
38
- "latest_version_template": "{{value_json.latest_version}}",
39
48
  "origin": {
40
- "name": "updates2mqtt",
41
- "sw_version": updates2mqtt.version,
49
+ "name": f"{node_name} updates2mqtt",
50
+ "sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
42
51
  "support_url": "https://github.com/rhizomatics/updates2mqtt/issues",
43
52
  },
44
53
  }
45
- if command_topic:
54
+ if discovery.entity_picture_url:
55
+ config["entity_picture"] = discovery.entity_picture_url
56
+ if discovery.device_icon:
57
+ config["icon"] = discovery.device_icon
58
+ if device_creation:
59
+ config["device"] = {
60
+ "name": f"{node_name} updates2mqtt",
61
+ "sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
62
+ "manufacturer": "rhizomatics",
63
+ "identifiers": [f"{node_name}.updates2mqtt"],
64
+ }
65
+ if area:
66
+ config["device"]["suggested_area"] = area
67
+ if command_topic and (discovery.can_update or force_command_topic):
46
68
  config["command_topic"] = command_topic
47
- config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
69
+ if discovery.can_update:
70
+ config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
48
71
  if discovery.custom.get("git_repo_path"):
49
72
  config["git_repo_path"] = discovery.custom["git_repo_path"]
50
73
  config.update(discovery.provider.hass_config_format(discovery))
@@ -13,7 +13,7 @@ import structlog
13
13
  from docker.models.containers import Container
14
14
  from hishel.httpx import SyncCacheClient
15
15
 
16
- from updates2mqtt.config import DockerConfig, DockerPackageUpdateInfo, NodeConfig, PackageUpdateInfo, UpdateInfoConfig
16
+ from updates2mqtt.config import DockerConfig, DockerPackageUpdateInfo, NodeConfig, PackageUpdateInfo
17
17
  from updates2mqtt.model import Discovery, ReleaseProvider
18
18
 
19
19
  from .git_utils import git_check_update_available, git_pull, git_timestamp, git_trust
@@ -37,12 +37,12 @@ def safe_json_dt(t: float | None) -> str | None:
37
37
 
38
38
 
39
39
  class DockerProvider(ReleaseProvider):
40
- def __init__(self, cfg: DockerConfig, common_pkg_cfg: UpdateInfoConfig, node_cfg: NodeConfig) -> None:
40
+ def __init__(self, cfg: DockerConfig, common_pkg_cfg: dict[str, PackageUpdateInfo], node_cfg: NodeConfig) -> None:
41
41
  super().__init__("docker")
42
42
  self.client: docker.DockerClient = docker.from_env()
43
43
  self.cfg: DockerConfig = cfg
44
44
  self.node_cfg: NodeConfig = node_cfg
45
- self.common_pkgs: dict[str, PackageUpdateInfo] = common_pkg_cfg.common_packages if common_pkg_cfg else {}
45
+ self.common_pkgs: dict[str, PackageUpdateInfo] = common_pkg_cfg if common_pkg_cfg else {}
46
46
  # TODO: refresh discovered packages periodically
47
47
  self.discovered_pkgs: dict[str, PackageUpdateInfo] = self.discover_metadata()
48
48
 
@@ -87,17 +87,27 @@ class DockerProvider(ReleaseProvider):
87
87
  def build(self, discovery: Discovery, compose_path: str) -> bool:
88
88
  logger = self.log.bind(container=discovery.name, action="build")
89
89
  logger.info("Building")
90
- return self.execute_compose(DockerComposeCommand.BUILD, "", compose_path, logger)
90
+ return self.execute_compose(
91
+ command=DockerComposeCommand.BUILD,
92
+ args="",
93
+ service=discovery.custom.get("compose_service"),
94
+ cwd=compose_path,
95
+ logger=logger,
96
+ )
91
97
 
92
- def execute_compose(self, command: DockerComposeCommand, args: str, cwd: str | None, logger: structlog.BoundLogger) -> bool:
98
+ def execute_compose(
99
+ self, command: DockerComposeCommand, args: str, service: str | None, cwd: str | None, logger: structlog.BoundLogger
100
+ ) -> bool:
93
101
  if not cwd or not Path(cwd).is_dir():
94
102
  logger.warn("Invalid compose path, skipped %s", command)
95
103
  return False
96
- logger.info(f"Executing compose {command} {args}")
104
+ logger.info(f"Executing compose {command} {args} {service}")
97
105
  cmd: str = "docker-compose" if self.cfg.compose_version == "v1" else "docker compose"
98
106
  cmd = cmd + " " + command.value
99
107
  if args:
100
108
  cmd = cmd + " " + args
109
+ if service:
110
+ cmd = cmd + " " + service
101
111
 
102
112
  proc = subprocess.run(cmd, check=False, shell=True, cwd=cwd)
103
113
  if proc.returncode == 0:
@@ -112,7 +122,10 @@ class DockerProvider(ReleaseProvider):
112
122
  def restart(self, discovery: Discovery) -> bool:
113
123
  logger = self.log.bind(container=discovery.name, action="restart")
114
124
  compose_path = discovery.custom.get("compose_path")
115
- return self.execute_compose(DockerComposeCommand.UP, "--detach --yes", compose_path, logger)
125
+ compose_service: str | None = discovery.custom.get("compose_service")
126
+ return self.execute_compose(
127
+ command=DockerComposeCommand.UP, args="--detach --yes", service=compose_service, cwd=compose_path, logger=logger
128
+ )
116
129
 
117
130
  def rescan(self, discovery: Discovery) -> Discovery | None:
118
131
  logger = self.log.bind(container=discovery.name, action="rescan")
@@ -172,7 +185,7 @@ class DockerProvider(ReleaseProvider):
172
185
  logger.warn("RepoDigests=%s", image.attrs.get("RepoDigests"))
173
186
 
174
187
  platform: str = "Unknown"
175
- pkg_info: PackageUpdateInfo = self.default_metadata(image_name)
188
+ pkg_info: PackageUpdateInfo = self.default_metadata(image_name, image_ref=image_ref)
176
189
 
177
190
  try:
178
191
  picture_url = env_override("UPD2MQTT_PICTURE", pkg_info.logo_url)
@@ -197,6 +210,7 @@ class DockerProvider(ReleaseProvider):
197
210
  retries_left = 3
198
211
  while reg_data is None and retries_left > 0 and not self.stopped.is_set():
199
212
  try:
213
+ logger.debug("Fetching registry data", image_ref=image_ref)
200
214
  reg_data = self.client.images.get_registry_data(image_ref)
201
215
  latest_version = reg_data.short_id[7:] if reg_data else None
202
216
  except docker.errors.APIError as e:
@@ -221,6 +235,7 @@ class DockerProvider(ReleaseProvider):
221
235
  custom["image_ref"] = image_ref
222
236
  save_if_set("compose_path", c.labels.get("com.docker.compose.project.working_dir"))
223
237
  save_if_set("compose_version", c.labels.get("com.docker.compose.version"))
238
+ save_if_set("compose_service", c.labels.get("com.docker.compose.service"))
224
239
  save_if_set("git_repo_path", c_env.get("UPD2MQTT_GIT_REPO_PATH"))
225
240
  save_if_set("apt_pkgs", c_env.get("UPD2MQTT_APT_PKGS"))
226
241
 
@@ -262,6 +277,7 @@ class DockerProvider(ReleaseProvider):
262
277
  features.append("RELEASE_NOTES")
263
278
  custom["can_pull"] = can_pull
264
279
 
280
+ logger.debug("Analyze generated discovery", discovery_name=c.name, current_version=local_version)
265
281
  return Discovery(
266
282
  self,
267
283
  c.name,
@@ -283,21 +299,27 @@ class DockerProvider(ReleaseProvider):
283
299
  )
284
300
  except Exception:
285
301
  logger.exception("Docker Discovery Failure", container_attrs=c.attrs)
302
+ logger.debug("Analyze returned empty discovery")
286
303
  return None
287
304
 
288
305
  async def scan(self, session: str) -> AsyncGenerator[Discovery]:
289
- logger = self.log.bind(session=session, action="scan")
306
+ logger = self.log.bind(session=session, action="scan", source=self.source_type)
290
307
  containers = results = 0
308
+ logger.debug("Starting container scan loop")
291
309
  for c in self.client.containers.list():
310
+ logger.debug("Analyzing container", container=c.name)
292
311
  if self.stopped.is_set():
293
312
  logger.info(f"Shutdown detected, aborting scan at {c}")
294
313
  break
295
314
  containers = containers + 1
296
- result = self.analyze(c, session)
315
+ result: Discovery | None = self.analyze(c, session)
297
316
  if result:
317
+ logger.debug("Analyzed container", result_name=result.name, custom=result.custom)
298
318
  self.discoveries[result.name] = result
299
319
  results = results + 1
300
320
  yield result
321
+ else:
322
+ logger.debug("No result from analysis", container=c.name)
301
323
  logger.info("Completed", container_count=containers, result_count=results)
302
324
 
303
325
  def command(self, discovery_name: str, command: str, on_update_start: Callable, on_update_end: Callable) -> bool:
@@ -348,27 +370,40 @@ class DockerProvider(ReleaseProvider):
348
370
  # "platform": discovery.custom.get("platform"),
349
371
  }
350
372
 
351
- def default_metadata(self, image_name: str | None) -> PackageUpdateInfo:
352
- relnotes_url: str | None = None
353
- picture_url: str | None = self.cfg.default_entity_picture_url
373
+ def default_metadata(self, image_name: str | None, image_ref: str | None) -> PackageUpdateInfo:
374
+ def match(pkg: PackageUpdateInfo) -> bool:
375
+ if pkg is not None and pkg.docker is not None and pkg.docker.image_name is not None:
376
+ if image_name is not None and image_name == pkg.docker.image_name:
377
+ return True
378
+ if image_ref is not None and image_ref == pkg.docker.image_name:
379
+ return True
380
+ return False
354
381
 
355
- if image_name is not None:
382
+ if image_name is not None and image_ref is not None:
356
383
  for pkg in self.common_pkgs.values():
357
- if pkg.docker is not None and pkg.docker.image_name is not None and pkg.docker.image_name == image_name:
384
+ if match(pkg):
358
385
  self.log.debug(
359
- "Found common package", pkg=pkg.docker.image_name, logo_url=picture_url, relnotes_url=relnotes_url
386
+ "Found common package",
387
+ image_name=pkg.docker.image_name, # type: ignore [union-attr]
388
+ logo_url=pkg.logo_url,
389
+ relnotes_url=pkg.release_notes_url,
360
390
  )
361
391
  return pkg
362
392
  for pkg in self.discovered_pkgs.values():
363
- if pkg.docker is not None and pkg.docker.image_name is not None and pkg.docker.image_name == image_name:
393
+ if match(pkg):
364
394
  self.log.debug(
365
- "Found discovered package", pkg=pkg.docker.image_name, logo_url=picture_url, relnotes_url=relnotes_url
395
+ "Found discovered package",
396
+ pkg=pkg.docker.image_name, # type: ignore [union-attr]
397
+ logo_url=pkg.logo_url,
398
+ relnotes_url=pkg.release_notes_url,
366
399
  )
367
400
  return pkg
368
401
 
369
402
  self.log.debug("No common or discovered package found", image_name=image_name)
370
403
  return PackageUpdateInfo(
371
- DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE), logo_url=picture_url, release_notes_url=relnotes_url
404
+ DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE),
405
+ logo_url=self.cfg.default_entity_picture_url,
406
+ release_notes_url=None,
372
407
  )
373
408
 
374
409
  def discover_metadata(self) -> dict[str, PackageUpdateInfo]:
updates2mqtt/mqtt.py CHANGED
@@ -50,9 +50,9 @@ class MqttPublisher:
50
50
  elif self.cfg.protocol in ("5", "5.0"):
51
51
  protocol = MQTTProtocolVersion.MQTTv5
52
52
  else:
53
- self.log.info("No valid MQTT protocol version found (%s), setting to default v3.11", self.cfg.protocol)
53
+ logger.info("No valid MQTT protocol version found (%s), setting to default v3.11", self.cfg.protocol)
54
54
  protocol = MQTTProtocolVersion.MQTTv311
55
- self.log.debug("MQTT protocol set to %r", protocol)
55
+ logger.debug("MQTT protocol set to %r", protocol)
56
56
 
57
57
  self.event_loop = event_loop or asyncio.get_event_loop()
58
58
  self.client = mqtt.Client(
@@ -68,7 +68,7 @@ class MqttPublisher:
68
68
  keepalive=60,
69
69
  clean_start=MQTT_CLEAN_START_FIRST_ONLY,
70
70
  )
71
- self.log.info("Client connection requested", result_code=rc)
71
+ logger.info("Client connection requested", result_code=rc)
72
72
 
73
73
  self.client.on_connect = self.on_connect
74
74
  self.client.on_disconnect = self.on_disconnect
@@ -78,7 +78,7 @@ class MqttPublisher:
78
78
 
79
79
  self.client.loop_start()
80
80
 
81
- logger.info("Connected to broker", host=self.cfg.host, port=self.cfg.port)
81
+ logger.debug("MQTT Publisher loop started", host=self.cfg.host, port=self.cfg.port)
82
82
  except Exception as e:
83
83
  logger.error("Failed to connect to broker", host=self.cfg.host, port=self.cfg.port, error=str(e))
84
84
  raise OSError(f"Connection Failure to {self.cfg.host}:{self.cfg.port} as {self.cfg.user} -- {e}") from e
@@ -101,11 +101,14 @@ class MqttPublisher:
101
101
  if rc.getName() == "Not authorized":
102
102
  self.fatal_failure.set()
103
103
  log.error("Invalid MQTT credentials", result_code=rc)
104
-
105
- self.log.info("Connected to broker", result_code=rc)
106
- for topic, provider in self.providers_by_topic.items():
107
- self.log.info("(Re)subscribing", topic=topic, provider=provider.source_type)
108
- self.client.subscribe(topic)
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)
109
112
 
110
113
  def on_disconnect(
111
114
  self,
@@ -115,7 +118,10 @@ class MqttPublisher:
115
118
  rc: ReasonCode,
116
119
  _props: Properties | None,
117
120
  ) -> None:
118
- self.log.info("Disconnected from broker", result_code=rc)
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)
119
125
 
120
126
  async def clean_topics(
121
127
  self, provider: ReleaseProvider, last_scan_session: str | None, wait_time: int = 5, force: bool = False
@@ -229,7 +235,7 @@ class MqttPublisher:
229
235
  updated = provider.command(comp_name, command, on_update_start, on_update_end)
230
236
  discovery = provider.resolve(comp_name)
231
237
  if updated and discovery:
232
- self.publish_hass_state(discovery, updated)
238
+ self.publish_hass_state(discovery)
233
239
  else:
234
240
  logger.debug("No change to republish after execution")
235
241
  logger.info("Execution ended")
@@ -311,16 +317,18 @@ class MqttPublisher:
311
317
 
312
318
  def publish_hass_config(self, discovery: Discovery) -> None:
313
319
  object_id = f"{discovery.source_type}_{self.node_cfg.name}_{discovery.name}"
314
- command_topic: str | None = self.command_topic(discovery.provider) if discovery.can_update else None
315
320
  self.publish(
316
321
  self.config_topic(discovery),
317
322
  hass_format_config(
318
- discovery,
319
- object_id,
320
- self.node_cfg.name,
321
- self.state_topic(discovery),
322
- command_topic,
323
- discovery.session,
323
+ discovery=discovery,
324
+ object_id=object_id,
325
+ node_name=self.node_cfg.name,
326
+ area=self.hass_cfg.area,
327
+ state_topic=self.state_topic(discovery),
328
+ command_topic=self.command_topic(discovery.provider),
329
+ force_command_topic=self.hass_cfg.force_command_topic,
330
+ device_creation=self.hass_cfg.device_creation,
331
+ session=discovery.session,
324
332
  ),
325
333
  )
326
334
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: updates2mqtt
3
- Version: 1.4.1
3
+ Version: 1.5.0
4
4
  Summary: System update and docker image notification and execution over MQTT
5
5
  Project-URL: Homepage, https://updates2mqtt.rhizomatics.org.uk
6
6
  Project-URL: Repository, https://github.com/rhizomatics/updates2mqtt
@@ -35,7 +35,7 @@ Requires-Dist: structlog>=25.4.0
35
35
  Requires-Dist: usingversion>=0.1.2
36
36
  Description-Content-Type: text/markdown
37
37
 
38
- [![Rhizomatics Open Source](https://avatars.githubusercontent.com/u/162821163?s=96&v=4)](https://github.com/rhizomatics)
38
+ ![updates2mqtt](images/updates2mqtt-dark-256x256.png){ align=left }
39
39
 
40
40
  # updates2mqtt
41
41
 
@@ -43,14 +43,19 @@ Description-Content-Type: text/markdown
43
43
 
44
44
  [![PyPI - Version](https://img.shields.io/pypi/v/updates2mqtt)](https://pypi.org/project/updates2mqtt/)
45
45
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/rhizomatics/supernotify)
46
- ![Coverage](https://raw.githubusercontent.com/rhizomatics/updates2mqtt/ff84fbfac358820cf9c9e9e582c063cd1a137116/badges/coverage.svg)
47
- ![Tests](https://raw.githubusercontent.com/rhizomatics/updates2mqtt/ff84fbfac358820cf9c9e9e582c063cd1a137116/badges/tests.svg)
46
+ [![Coverage](https://raw.githubusercontent.com/rhizomatics/updates2mqtt/refs/heads/badges/badges/coverage.svg)](https://updates2mqtt.rhizomatics.org.uk/developer/coverage/)
47
+ ![Tests](https://raw.githubusercontent.com/rhizomatics/updates2mqtt/refs/heads/badges/badges/tests.svg)
48
48
  [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/rhizomatics/updates2mqtt/main.svg)](https://results.pre-commit.ci/latest/github/rhizomatics/updates2mqtt/main)
49
49
  [![Publish Python 🐍 distribution 📦 to PyPI and TestPyPI](https://github.com/rhizomatics/updates2mqtt/actions/workflows/pypi-publish.yml/badge.svg)](https://github.com/rhizomatics/updates2mqtt/actions/workflows/pypi-publish.yml)
50
50
  [![Github Deploy](https://github.com/rhizomatics/updates2mqtt/actions/workflows/python-package.yml/badge.svg?branch=main)](https://github.com/rhizomatics/updates2mqtt/actions/workflows/python-package.yml)
51
51
  [![CodeQL](https://github.com/rhizomatics/updates2mqtt/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/rhizomatics/updates2mqtt/actions/workflows/github-code-scanning/codeql)
52
52
  [![Dependabot Updates](https://github.com/rhizomatics/updates2mqtt/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/rhizomatics/updates2mqtt/actions/workflows/dependabot/dependabot-updates)
53
53
 
54
+
55
+ <br/>
56
+ <br/>
57
+
58
+
54
59
  ## Summary
55
60
 
56
61
  Let Home Assistant tell you about new updates to Docker images for your containers.
@@ -86,15 +91,17 @@ Presently only Docker containers are supported, although others are planned, pro
86
91
  |-----------|-------------|----------------------------------------------------------------------------------------------------|
87
92
  | Docker | Scan. Fetch | Fetch is ``docker pull`` only. Restart support only for ``docker-compose`` image based containers. |
88
93
 
89
- ## Healthcheck
94
+ ## Heartbeat
90
95
 
91
96
  A heartbeat JSON payload is optionally published periodically to a configurable MQTT topic, defaulting to `healthcheck/{node_name}/updates2mqtt`. It contains the current version of updates2mqtt, the node name, a timestamp, and some basic stats.
92
97
 
98
+ ## Healthcheck
99
+
93
100
  A `healthcheck.sh` script is included in the Docker image, and can be used as a Docker healthcheck, if the container environment variables are set for `MQTT_HOST`, `MQTT_PORT`, `MQTT_USER` and `MQTT_PASS`. It uses the `mosquitto-clients` Linux package which provides `mosquitto_sub` command to subscribe to topics.
94
101
 
95
102
  !!! tip
96
103
 
97
- Check healthcheck is working using `docker inspect --format "{{json .State.Health }}" updates2mqtt | jq`
104
+ Check healthcheck is working using `docker inspect --format "{{json .State.Health }}" updates2mqtt | jq` (can omit `| jq` if you don't have jsonquery installed, but much easier to read with it)
98
105
 
99
106
  Another approach is using a restarter service directly in Docker Compose to force a restart, in this case once a day:
100
107
 
@@ -0,0 +1,16 @@
1
+ updates2mqtt/__init__.py,sha256=gnmHrLOSYc-N1-c5VG46OpNpoXEybKzYhEvFMm955P8,237
2
+ updates2mqtt/__main__.py,sha256=HBF00oH5fhS33sI_CdbxNlaUvbIzuuGxwnRYdhHqx0M,194
3
+ updates2mqtt/app.py,sha256=pYcNprv7_htqD6wiXX5j24FhPglEeFNdqMfPclq5SfU,8887
4
+ updates2mqtt/config.py,sha256=TJakojUkPJ0xJ2aivJ9BIlDLw8glqJzTOsm1E__vJ9M,5706
5
+ updates2mqtt/hass_formatter.py,sha256=adopRRagQte7ok1ZBPcYBIkDAo9YLXcI2y_jtdNWZP8,3638
6
+ updates2mqtt/model.py,sha256=O6GQFhfQvwQDxlZScFHrpQgFNkUrUAeaGGP6AqNua78,3827
7
+ updates2mqtt/mqtt.py,sha256=yNpYlYdDr6S4ltLZqAbjUgxsJQZKzOfcFJoUr-fcNlI,15077
8
+ updates2mqtt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ updates2mqtt/integrations/__init__.py,sha256=KmNTUxvVWvqI7rl4I0xZg7XaCmcMS2O4OSv-ClsWM4Q,109
10
+ updates2mqtt/integrations/docker.py,sha256=dTbiIB2bjiGY46ovlkiB998ekhXX4M2KhvZqfzqV-Oo,20790
11
+ updates2mqtt/integrations/git_utils.py,sha256=bPCmQiZpKpMcrGI7xAVmePXHFn8WwjcPNkf7xqDsGQA,2319
12
+ updates2mqtt-1.5.0.dist-info/METADATA,sha256=oowFbgm4wTYWv3vlBTgiYe3prCI2UVTJztMT4GWLEN8,9392
13
+ updates2mqtt-1.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
+ updates2mqtt-1.5.0.dist-info/entry_points.txt,sha256=Hc6NZ2dBevYSUKTJU6NOs8Mw7Vt0S-9lq5FuKb76NCc,54
15
+ updates2mqtt-1.5.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
+ updates2mqtt-1.5.0.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- updates2mqtt/__init__.py,sha256=gnmHrLOSYc-N1-c5VG46OpNpoXEybKzYhEvFMm955P8,237
2
- updates2mqtt/__main__.py,sha256=HBF00oH5fhS33sI_CdbxNlaUvbIzuuGxwnRYdhHqx0M,194
3
- updates2mqtt/app.py,sha256=-jAe9HcSSRmq5SZCvlsb8bFq2yVBNZQ0alQ5iRjl3tY,8518
4
- updates2mqtt/config.py,sha256=SK6uhDyUb9C2JYVd0j6KBHzSAfaCFcOUbmmgsq6VSs0,5027
5
- updates2mqtt/hass_formatter.py,sha256=Ulfj8F0e_1QMmRuJzHsNM2WxHbz9sIkWOWjRq3kQZzs,2772
6
- updates2mqtt/model.py,sha256=O6GQFhfQvwQDxlZScFHrpQgFNkUrUAeaGGP6AqNua78,3827
7
- updates2mqtt/mqtt.py,sha256=WiGB2yFj2xUb-5LZh84zTPLilSvHn0CgUCn41FhC5Sk,14664
8
- updates2mqtt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- updates2mqtt/integrations/__init__.py,sha256=KmNTUxvVWvqI7rl4I0xZg7XaCmcMS2O4OSv-ClsWM4Q,109
10
- updates2mqtt/integrations/docker.py,sha256=Yb0XsrRyNZYAkw_ayd2iYLPFwgeA51Lz9HBVAGq3rs4,19273
11
- updates2mqtt/integrations/git_utils.py,sha256=bPCmQiZpKpMcrGI7xAVmePXHFn8WwjcPNkf7xqDsGQA,2319
12
- updates2mqtt-1.4.1.dist-info/METADATA,sha256=Q_yunyG90zU0B3uw2U5_FIImF28DHLNcK0otr6tTzVU,9310
13
- updates2mqtt-1.4.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
- updates2mqtt-1.4.1.dist-info/entry_points.txt,sha256=Hc6NZ2dBevYSUKTJU6NOs8Mw7Vt0S-9lq5FuKb76NCc,54
15
- updates2mqtt-1.4.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
- updates2mqtt-1.4.1.dist-info/RECORD,,