updates2mqtt 1.4.2__py3-none-any.whl → 1.5.1__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}"
@@ -16,7 +25,7 @@ class MqttConfig:
16
25
  password: str = f"${{oc.env:MQTT_PASS,{MISSING}}}"
17
26
  port: int = "${oc.decode:${oc.env:MQTT_PORT,1883}}" # type: ignore[assignment]
18
27
  topic_root: str = "updates2mqtt"
19
- protocol: str = "3.11"
28
+ protocol: str = "${oc.env:MQTT_VERSION,3.11}"
20
29
 
21
30
 
22
31
  @dataclass
@@ -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
  )
@@ -50,6 +60,7 @@ class HomeAssistantConfig:
50
60
  discovery: HomeAssistantDiscoveryConfig = field(default_factory=HomeAssistantDiscoveryConfig)
51
61
  state_topic_suffix: str = "state"
52
62
  device_creation: bool = True
63
+ force_command_topic: bool = False
53
64
  area: str | None = None
54
65
 
55
66
 
@@ -69,14 +80,14 @@ class NodeConfig:
69
80
 
70
81
  @dataclass
71
82
  class LogConfig:
72
- level: str = "INFO"
83
+ level: LogLevel = "${oc.decode:${oc.env:U2M_LOG_LEVEL,INFO}}" # type: ignore[assignment] # pyright: ignore[reportAssignmentType]
73
84
 
74
85
 
75
86
  @dataclass
76
87
  class Config:
77
- log: LogConfig = field(default_factory=LogConfig)
88
+ log: LogConfig = field(default_factory=LogConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
78
89
  node: NodeConfig = field(default_factory=NodeConfig)
79
- mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[reportCallIssue, reportArgumentType]
90
+ mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
80
91
  homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
81
92
  docker: DockerConfig = field(default_factory=DockerConfig)
82
93
  scan_interval: int = 60 * 60 * 3
@@ -103,33 +114,47 @@ class IncompleteConfigException(BaseException):
103
114
  pass
104
115
 
105
116
 
106
- def load_package_info(pkginfo_file_path: Path) -> UpdateInfoConfig:
117
+ def load_package_info(pkginfo_file_path: Path) -> dict[str, PackageUpdateInfo]:
107
118
  if pkginfo_file_path.exists():
108
119
  log.debug("Loading common package update info", path=pkginfo_file_path)
109
120
  cfg = OmegaConf.load(pkginfo_file_path)
110
121
  else:
111
122
  log.warn("No common package update info found", path=pkginfo_file_path)
112
123
  cfg = OmegaConf.structured(UpdateInfoConfig)
113
- OmegaConf.set_readonly(cfg, True)
114
- 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
133
+
134
+
135
+ def is_autogen_config() -> bool:
136
+ env_var: str | None = os.environ.get("U2M_AUTOGEN_CONFIG")
137
+ return not (env_var and env_var.lower() in ("no", "0", "false"))
115
138
 
116
139
 
117
140
  def load_app_config(conf_file_path: Path, return_invalid: bool = False) -> Config | None:
118
141
  base_cfg: DictConfig = OmegaConf.structured(Config)
119
142
  if conf_file_path.exists():
120
143
  cfg: DictConfig = typing.cast("DictConfig", OmegaConf.merge(base_cfg, OmegaConf.load(conf_file_path)))
121
- else:
144
+ elif is_autogen_config():
122
145
  if not conf_file_path.parent.exists():
123
146
  try:
124
147
  log.debug(f"Creating config directory {conf_file_path.parent} if not already present")
125
148
  conf_file_path.parent.mkdir(parents=True, exist_ok=True)
126
149
  except Exception:
127
- log.exception("Unable to create config directory", path=conf_file_path.parent)
150
+ log.warning("Unable to create config directory", path=conf_file_path.parent)
128
151
  try:
129
152
  conf_file_path.write_text(OmegaConf.to_yaml(base_cfg))
130
153
  log.info(f"Auto-generated a new config file at {conf_file_path}")
131
154
  except Exception:
132
- log.exception("Unable to write config file", path=conf_file_path)
155
+ log.warning("Unable to write config file", path=conf_file_path)
156
+ cfg = base_cfg
157
+ else:
133
158
  cfg = base_cfg
134
159
 
135
160
  try:
@@ -21,60 +21,58 @@ HASS_UPDATE_SCHEMA = [
21
21
  def hass_format_config(
22
22
  discovery: Discovery,
23
23
  object_id: str,
24
- node_name: str,
25
24
  state_topic: str,
26
25
  command_topic: str | None,
26
+ force_command_topic: bool | None,
27
27
  device_creation: bool = True,
28
28
  area: str | None = None,
29
29
  session: str | None = None,
30
30
  ) -> dict[str, Any]:
31
31
  config: dict[str, Any] = {
32
- "name": f"{discovery.name} {discovery.source_type} on {node_name}",
32
+ "name": discovery.title,
33
33
  "device_class": None, # not firmware, so defaults to null
34
34
  "unique_id": object_id,
35
35
  "state_topic": state_topic,
36
36
  "source_session": session,
37
37
  "supported_features": discovery.features,
38
- "entity_picture": discovery.entity_picture_url,
39
- "icon": discovery.device_icon,
40
38
  "can_update": discovery.can_update,
41
39
  "can_build": discovery.can_build,
42
40
  "can_restart": discovery.can_restart,
43
41
  "update_policy": discovery.update_policy,
44
- "latest_version_topic": state_topic,
45
- "latest_version_template": "{{value_json.latest_version}}",
46
42
  "origin": {
47
- "name": f"{node_name} updates2mqtt Agent",
43
+ "name": f"{discovery.node} updates2mqtt",
48
44
  "sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
49
45
  "support_url": "https://github.com/rhizomatics/updates2mqtt/issues",
50
46
  },
51
47
  }
48
+ if discovery.entity_picture_url:
49
+ config["entity_picture"] = discovery.entity_picture_url
50
+ if discovery.device_icon:
51
+ config["icon"] = discovery.device_icon
52
52
  if device_creation:
53
53
  config["device"] = {
54
- "name": f"{node_name} updates2mqtt Agent",
54
+ "name": f"{discovery.node} updates2mqtt",
55
55
  "sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
56
56
  "manufacturer": "rhizomatics",
57
- "identifiers": [f"{node_name}.updates2mqtt"],
57
+ "identifiers": [f"{discovery.node}.updates2mqtt"],
58
58
  }
59
59
  if area:
60
60
  config["device"]["suggested_area"] = area
61
- if command_topic:
61
+ if command_topic and (discovery.can_update or force_command_topic):
62
62
  config["command_topic"] = command_topic
63
- config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
63
+ if discovery.can_update:
64
+ config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
64
65
  if discovery.custom.get("git_repo_path"):
65
66
  config["git_repo_path"] = discovery.custom["git_repo_path"]
66
67
  config.update(discovery.provider.hass_config_format(discovery))
67
68
  return config
68
69
 
69
70
 
70
- def hass_format_state(discovery: Discovery, node_name: str, session: str, in_progress: bool = False) -> dict[str, Any]: # noqa: ARG001
71
- title: str = (
72
- discovery.title_template.format(name=discovery.name, node=node_name) if discovery.title_template else discovery.name
73
- )
71
+ def hass_format_state(discovery: Discovery, session: str, in_progress: bool = False) -> dict[str, Any]: # noqa: ARG001
74
72
  state = {
75
73
  "installed_version": discovery.current_version,
76
74
  "latest_version": discovery.latest_version,
77
- "title": title,
75
+ "title": discovery.title,
78
76
  "in_progress": in_progress,
79
77
  }
80
78
  if discovery.release_summary:
@@ -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,22 +87,36 @@ 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
+
97
105
  cmd: str = "docker-compose" if self.cfg.compose_version == "v1" else "docker compose"
106
+ logger.info(f"Executing {cmd} {command} {args} {service}")
98
107
  cmd = cmd + " " + command.value
99
108
  if args:
100
109
  cmd = cmd + " " + args
110
+ if service:
111
+ cmd = cmd + " " + service
101
112
 
102
- proc = subprocess.run(cmd, check=False, shell=True, cwd=cwd)
113
+ proc: subprocess.CompletedProcess[str] = subprocess.run(cmd, check=False, shell=True, cwd=cwd, text=True)
103
114
  if proc.returncode == 0:
104
115
  logger.info(f"{command} via compose successful")
105
116
  return True
117
+ if proc.stderr and "unknown command: docker compose" in proc.stderr:
118
+ logger.warning("docker compose set to wrong version, seems like v1 installed")
119
+ self.cfg.compose_version = "v1"
106
120
  logger.warn(
107
121
  f"{command} failed: %s",
108
122
  proc.returncode,
@@ -112,7 +126,10 @@ class DockerProvider(ReleaseProvider):
112
126
  def restart(self, discovery: Discovery) -> bool:
113
127
  logger = self.log.bind(container=discovery.name, action="restart")
114
128
  compose_path = discovery.custom.get("compose_path")
115
- return self.execute_compose(DockerComposeCommand.UP, "--detach --yes", compose_path, logger)
129
+ compose_service: str | None = discovery.custom.get("compose_service")
130
+ return self.execute_compose(
131
+ command=DockerComposeCommand.UP, args="--detach --yes", service=compose_service, cwd=compose_path, logger=logger
132
+ )
116
133
 
117
134
  def rescan(self, discovery: Discovery) -> Discovery | None:
118
135
  logger = self.log.bind(container=discovery.name, action="rescan")
@@ -172,7 +189,7 @@ class DockerProvider(ReleaseProvider):
172
189
  logger.warn("RepoDigests=%s", image.attrs.get("RepoDigests"))
173
190
 
174
191
  platform: str = "Unknown"
175
- pkg_info: PackageUpdateInfo = self.default_metadata(image_name)
192
+ pkg_info: PackageUpdateInfo = self.default_metadata(image_name, image_ref=image_ref)
176
193
 
177
194
  try:
178
195
  picture_url = env_override("UPD2MQTT_PICTURE", pkg_info.logo_url)
@@ -197,6 +214,7 @@ class DockerProvider(ReleaseProvider):
197
214
  retries_left = 3
198
215
  while reg_data is None and retries_left > 0 and not self.stopped.is_set():
199
216
  try:
217
+ logger.debug("Fetching registry data", image_ref=image_ref)
200
218
  reg_data = self.client.images.get_registry_data(image_ref)
201
219
  latest_version = reg_data.short_id[7:] if reg_data else None
202
220
  except docker.errors.APIError as e:
@@ -221,6 +239,7 @@ class DockerProvider(ReleaseProvider):
221
239
  custom["image_ref"] = image_ref
222
240
  save_if_set("compose_path", c.labels.get("com.docker.compose.project.working_dir"))
223
241
  save_if_set("compose_version", c.labels.get("com.docker.compose.version"))
242
+ save_if_set("compose_service", c.labels.get("com.docker.compose.service"))
224
243
  save_if_set("git_repo_path", c_env.get("UPD2MQTT_GIT_REPO_PATH"))
225
244
  save_if_set("apt_pkgs", c_env.get("UPD2MQTT_APT_PKGS"))
226
245
 
@@ -260,21 +279,29 @@ class DockerProvider(ReleaseProvider):
260
279
  logger.info(f"Update not available, can_pull:{can_pull}, can_build:{can_build},can_restart{can_restart}")
261
280
  if relnotes_url:
262
281
  features.append("RELEASE_NOTES")
282
+ if can_pull:
283
+ update_type: str = "Docker Image"
284
+ elif can_build:
285
+ update_type = "Docker Build"
286
+ else:
287
+ update_type = "Unavailable"
263
288
  custom["can_pull"] = can_pull
264
289
 
290
+ logger.debug("Analyze generated discovery", discovery_name=c.name, current_version=local_version)
265
291
  return Discovery(
266
292
  self,
267
293
  c.name,
268
294
  session,
295
+ node=self.node_cfg.name,
269
296
  entity_picture_url=picture_url,
270
297
  release_url=relnotes_url,
271
298
  current_version=local_version,
272
299
  update_policy=update_policy,
273
300
  update_last_attempt=(original_discovery and original_discovery.update_last_attempt) or None,
274
301
  latest_version=latest_version if latest_version != NO_KNOWN_IMAGE else local_version,
275
- title_template="Docker image update for {name} on {node}",
276
302
  device_icon=self.cfg.device_icon,
277
303
  can_update=can_update,
304
+ update_type=update_type,
278
305
  can_build=can_build,
279
306
  can_restart=can_restart,
280
307
  status=(c.status == "running" and "on") or "off",
@@ -283,21 +310,27 @@ class DockerProvider(ReleaseProvider):
283
310
  )
284
311
  except Exception:
285
312
  logger.exception("Docker Discovery Failure", container_attrs=c.attrs)
313
+ logger.debug("Analyze returned empty discovery")
286
314
  return None
287
315
 
288
316
  async def scan(self, session: str) -> AsyncGenerator[Discovery]:
289
- logger = self.log.bind(session=session, action="scan")
317
+ logger = self.log.bind(session=session, action="scan", source=self.source_type)
290
318
  containers = results = 0
319
+ logger.debug("Starting container scan loop")
291
320
  for c in self.client.containers.list():
321
+ logger.debug("Analyzing container", container=c.name)
292
322
  if self.stopped.is_set():
293
323
  logger.info(f"Shutdown detected, aborting scan at {c}")
294
324
  break
295
325
  containers = containers + 1
296
- result = self.analyze(c, session)
326
+ result: Discovery | None = self.analyze(c, session)
297
327
  if result:
328
+ logger.debug("Analyzed container", result_name=result.name, custom=result.custom)
298
329
  self.discoveries[result.name] = result
299
330
  results = results + 1
300
331
  yield result
332
+ else:
333
+ logger.debug("No result from analysis", container=c.name)
301
334
  logger.info("Completed", container_count=containers, result_count=results)
302
335
 
303
336
  def command(self, discovery_name: str, command: str, on_update_start: Callable, on_update_end: Callable) -> bool:
@@ -348,27 +381,40 @@ class DockerProvider(ReleaseProvider):
348
381
  # "platform": discovery.custom.get("platform"),
349
382
  }
350
383
 
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
384
+ def default_metadata(self, image_name: str | None, image_ref: str | None) -> PackageUpdateInfo:
385
+ def match(pkg: PackageUpdateInfo) -> bool:
386
+ if pkg is not None and pkg.docker is not None and pkg.docker.image_name is not None:
387
+ if image_name is not None and image_name == pkg.docker.image_name:
388
+ return True
389
+ if image_ref is not None and image_ref == pkg.docker.image_name:
390
+ return True
391
+ return False
354
392
 
355
- if image_name is not None:
393
+ if image_name is not None and image_ref is not None:
356
394
  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:
395
+ if match(pkg):
358
396
  self.log.debug(
359
- "Found common package", pkg=pkg.docker.image_name, logo_url=picture_url, relnotes_url=relnotes_url
397
+ "Found common package",
398
+ image_name=pkg.docker.image_name, # type: ignore [union-attr]
399
+ logo_url=pkg.logo_url,
400
+ relnotes_url=pkg.release_notes_url,
360
401
  )
361
402
  return pkg
362
403
  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:
404
+ if match(pkg):
364
405
  self.log.debug(
365
- "Found discovered package", pkg=pkg.docker.image_name, logo_url=picture_url, relnotes_url=relnotes_url
406
+ "Found discovered package",
407
+ pkg=pkg.docker.image_name, # type: ignore [union-attr]
408
+ logo_url=pkg.logo_url,
409
+ relnotes_url=pkg.release_notes_url,
366
410
  )
367
411
  return pkg
368
412
 
369
413
  self.log.debug("No common or discovered package found", image_name=image_name)
370
414
  return PackageUpdateInfo(
371
- DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE), logo_url=picture_url, release_notes_url=relnotes_url
415
+ DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE),
416
+ logo_url=self.cfg.default_entity_picture_url,
417
+ release_notes_url=None,
372
418
  )
373
419
 
374
420
  def discover_metadata(self) -> dict[str, PackageUpdateInfo]:
updates2mqtt/model.py CHANGED
@@ -14,6 +14,7 @@ class Discovery:
14
14
  provider: "ReleaseProvider",
15
15
  name: str,
16
16
  session: str,
17
+ node: str,
17
18
  entity_picture_url: str | None = None,
18
19
  current_version: str | None = None,
19
20
  latest_version: str | None = None,
@@ -21,11 +22,12 @@ class Discovery:
21
22
  can_build: bool = False,
22
23
  can_restart: bool = False,
23
24
  status: str = "on",
25
+ update_type: str | None = "Update",
24
26
  update_policy: str | None = None,
25
27
  update_last_attempt: float | None = None,
26
28
  release_url: str | None = None,
27
29
  release_summary: str | None = None,
28
- title_template: str = "Update for {name} on {node}",
30
+ title_template: str = "{discovery.update_type} for {discovery.name} on {discovery.node}",
29
31
  device_icon: str | None = None,
30
32
  custom: dict[str, Any] | None = None,
31
33
  features: list[str] | None = None,
@@ -34,6 +36,7 @@ class Discovery:
34
36
  self.source_type: str = provider.source_type
35
37
  self.session: str = session
36
38
  self.name: str = name
39
+ self.node: str = node
37
40
  self.entity_picture_url: str | None = entity_picture_url
38
41
  self.current_version: str | None = current_version
39
42
  self.latest_version: str | None = latest_version
@@ -44,6 +47,7 @@ class Discovery:
44
47
  self.release_summary: str | None = release_summary
45
48
  self.title_template: str | None = title_template
46
49
  self.device_icon: str | None = device_icon
50
+ self.update_type: str | None = update_type
47
51
  self.status: str = status
48
52
  self.update_policy: str | None = update_policy
49
53
  self.update_last_attempt: float | None = update_last_attempt
@@ -54,6 +58,12 @@ class Discovery:
54
58
  """Build a custom string representation"""
55
59
  return f"Discovery('{self.name}','{self.source_type}',current={self.current_version},latest={self.latest_version})"
56
60
 
61
+ @property
62
+ def title(self) -> str:
63
+ if self.title_template:
64
+ return self.title_template.format(discovery=self)
65
+ return self.name
66
+
57
67
 
58
68
  class ReleaseProvider:
59
69
  """Abstract base class for release providers, such as container scanners or package managers API calls"""
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")
@@ -303,7 +309,6 @@ class MqttPublisher:
303
309
  self.state_topic(discovery),
304
310
  hass_format_state(
305
311
  discovery,
306
- self.node_cfg.name,
307
312
  discovery.session,
308
313
  in_progress=in_progress,
309
314
  ),
@@ -311,16 +316,15 @@ class MqttPublisher:
311
316
 
312
317
  def publish_hass_config(self, discovery: Discovery) -> None:
313
318
  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
319
  self.publish(
316
320
  self.config_topic(discovery),
317
321
  hass_format_config(
318
322
  discovery=discovery,
319
323
  object_id=object_id,
320
- node_name=self.node_cfg.name,
321
324
  area=self.hass_cfg.area,
322
325
  state_topic=self.state_topic(discovery),
323
- command_topic=command_topic,
326
+ command_topic=self.command_topic(discovery.provider),
327
+ force_command_topic=self.hass_cfg.force_command_topic,
324
328
  device_creation=self.hass_cfg.device_creation,
325
329
  session=discovery.session,
326
330
  ),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: updates2mqtt
3
- Version: 1.4.2
3
+ Version: 1.5.1
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
- ![updates2mqtt](/images/updates2mqtt-dark-256x256.png){ align=left }
38
+ ![updates2mqtt](images/updates2mqtt-dark-256x256.png){ align=left }
39
39
 
40
40
  # updates2mqtt
41
41
 
@@ -91,15 +91,17 @@ Presently only Docker containers are supported, although others are planned, pro
91
91
  |-----------|-------------|----------------------------------------------------------------------------------------------------|
92
92
  | Docker | Scan. Fetch | Fetch is ``docker pull`` only. Restart support only for ``docker-compose`` image based containers. |
93
93
 
94
- ## Healthcheck
94
+ ## Heartbeat
95
95
 
96
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.
97
97
 
98
+ ## Healthcheck
99
+
98
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.
99
101
 
100
102
  !!! tip
101
103
 
102
- 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)
103
105
 
104
106
  Another approach is using a restarter service directly in Docker Compose to force a restart, in this case once a day:
105
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=nlAszykZa7t9nu99RB8g_tIzQxmdfWyilTZJY3Fl4e4,6099
5
+ updates2mqtt/hass_formatter.py,sha256=bj6qpElMqt1DqKlxp4ZjwaCJAD-ed3xsq5ZOg4FbeC8,3216
6
+ updates2mqtt/model.py,sha256=KNsLflgWaRvGrNdq1Vy2QnDLfVSLPWLSt4gBXj9COa4,4170
7
+ updates2mqtt/mqtt.py,sha256=j7nlSka-LqRethZk_rc4Z8iOV7ey28TQFjSfrPeTjv4,14995
8
+ updates2mqtt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ updates2mqtt/integrations/__init__.py,sha256=KmNTUxvVWvqI7rl4I0xZg7XaCmcMS2O4OSv-ClsWM4Q,109
10
+ updates2mqtt/integrations/docker.py,sha256=Mi3tFjVDbCIsHBgH6UQ3yC5Y7HQk9JgEyH6PQuNJtFU,21263
11
+ updates2mqtt/integrations/git_utils.py,sha256=bPCmQiZpKpMcrGI7xAVmePXHFn8WwjcPNkf7xqDsGQA,2319
12
+ updates2mqtt-1.5.1.dist-info/METADATA,sha256=iPMKAgEFTFZ98pouv3SnTTStP74SEPBuV8jN3QbBpX8,9392
13
+ updates2mqtt-1.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
+ updates2mqtt-1.5.1.dist-info/entry_points.txt,sha256=Hc6NZ2dBevYSUKTJU6NOs8Mw7Vt0S-9lq5FuKb76NCc,54
15
+ updates2mqtt-1.5.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
+ updates2mqtt-1.5.1.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=G7_5L7Ypc7dl35nnd5dP2AobSA08ZuyEM_ETCTPg_co,5144
5
- updates2mqtt/hass_formatter.py,sha256=GrVtxqpaXNFaQWXjmOFhOxYwXVjeNjzUwAjFdJdsrwI,3337
6
- updates2mqtt/model.py,sha256=O6GQFhfQvwQDxlZScFHrpQgFNkUrUAeaGGP6AqNua78,3827
7
- updates2mqtt/mqtt.py,sha256=I8g-SekY-AIVRcSCZyb7fTGji5a8zT1PJ7d34l7LGyY,14832
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.2.dist-info/METADATA,sha256=1wqWYIbcNbC-SQxk2FiI_SPmw9F-RFwdoOE_vviSXzw,9290
13
- updates2mqtt-1.4.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
- updates2mqtt-1.4.2.dist-info/entry_points.txt,sha256=Hc6NZ2dBevYSUKTJU6NOs8Mw7Vt0S-9lq5FuKb76NCc,54
15
- updates2mqtt-1.4.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
- updates2mqtt-1.4.2.dist-info/RECORD,,