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 +16 -9
- updates2mqtt/config.py +26 -6
- updates2mqtt/hass_formatter.py +34 -11
- updates2mqtt/integrations/docker.py +54 -19
- updates2mqtt/mqtt.py +26 -18
- {updates2mqtt-1.4.1.dist-info → updates2mqtt-1.5.0.dist-info}/METADATA +13 -6
- updates2mqtt-1.5.0.dist-info/RECORD +16 -0
- updates2mqtt-1.4.1.dist-info/RECORD +0 -16
- {updates2mqtt-1.4.1.dist-info → updates2mqtt-1.5.0.dist-info}/WHEEL +0 -0
- {updates2mqtt-1.4.1.dist-info → updates2mqtt-1.5.0.dist-info}/entry_points.txt +0 -0
- {updates2mqtt-1.4.1.dist-info → updates2mqtt-1.5.0.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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
|
-
|
|
77
|
+
slog.info("Scanning ...")
|
|
76
78
|
async with asyncio.TaskGroup() as tg:
|
|
77
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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:
|
|
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) ->
|
|
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
|
-
|
|
112
|
-
|
|
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:
|
updates2mqtt/hass_formatter.py
CHANGED
|
@@ -19,32 +19,55 @@ HASS_UPDATE_SCHEMA = [
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def hass_format_config(
|
|
22
|
-
discovery: Discovery,
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
384
|
+
if match(pkg):
|
|
358
385
|
self.log.debug(
|
|
359
|
-
"Found common package",
|
|
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
|
|
393
|
+
if match(pkg):
|
|
364
394
|
self.log.debug(
|
|
365
|
-
"Found discovered package",
|
|
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),
|
|
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
|
-
|
|
53
|
+
logger.info("No valid MQTT protocol version found (%s), setting to default v3.11", self.cfg.protocol)
|
|
54
54
|
protocol = MQTTProtocolVersion.MQTTv311
|
|
55
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
self.
|
|
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
|
-
|
|
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
|
|
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.
|
|
322
|
-
|
|
323
|
-
discovery.
|
|
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.
|
|
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
|
-
|
|
38
|
+
{ align=left }
|
|
39
39
|
|
|
40
40
|
# updates2mqtt
|
|
41
41
|
|
|
@@ -43,14 +43,19 @@ Description-Content-Type: text/markdown
|
|
|
43
43
|
|
|
44
44
|
[](https://pypi.org/project/updates2mqtt/)
|
|
45
45
|
[](https://github.com/rhizomatics/supernotify)
|
|
46
|
-
](https://updates2mqtt.rhizomatics.org.uk/developer/coverage/)
|
|
47
|
+

|
|
48
48
|
[](https://results.pre-commit.ci/latest/github/rhizomatics/updates2mqtt/main)
|
|
49
49
|
[](https://github.com/rhizomatics/updates2mqtt/actions/workflows/pypi-publish.yml)
|
|
50
50
|
[](https://github.com/rhizomatics/updates2mqtt/actions/workflows/python-package.yml)
|
|
51
51
|
[](https://github.com/rhizomatics/updates2mqtt/actions/workflows/github-code-scanning/codeql)
|
|
52
52
|
[](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
|
-
##
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|