updates2mqtt 1.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """Detect component version change availability and broadcast on MQTT topic, suitable for HomeAssistant autodetect"""
2
+
3
+ from usingversion import getattr_with_version
4
+
5
+ __getattr__ = getattr_with_version("updates2mqtt", __file__, __name__)
@@ -0,0 +1,6 @@
1
+ """Detect component version change availability and broadcast on MQTT topic, suitable for HomeAssistant autodetect"""
2
+
3
+ if __name__ == "__main__":
4
+ from updates2mqtt.app import run
5
+
6
+ run()
updates2mqtt/app.py ADDED
@@ -0,0 +1,203 @@
1
+ import asyncio
2
+ import logging
3
+ import sys
4
+ import time
5
+ import uuid
6
+ from collections.abc import Callable
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from threading import Event
10
+ from typing import Any
11
+
12
+ import structlog
13
+
14
+ import updates2mqtt
15
+ from updates2mqtt.model import Discovery, ReleaseProvider
16
+
17
+ from .config import Config, load_app_config, load_package_info
18
+ from .integrations.docker import DockerProvider
19
+ from .mqtt import MqttClient
20
+
21
+ log = structlog.get_logger()
22
+
23
+ CONF_FILE = Path("conf/config.yaml")
24
+ PKG_INFO_FILE = Path("./common_packages.yaml")
25
+ UPDATE_INTERVAL = 60 * 60 * 4
26
+
27
+ # #TODO:
28
+ # - Set install in progress
29
+ # - Support apt
30
+ # - Retry on registry fetch fail
31
+ # - Fetcher in subproc or thread
32
+ # - Clear command message after install
33
+ # - use git hash as alt to img ref for builds, or daily builds
34
+
35
+
36
+ class App:
37
+ def __init__(self) -> None:
38
+ self.startup_timestamp: str = datetime.now(UTC).isoformat()
39
+ self.last_scan_timestamp: str | None = None
40
+ app_config: Config | None = load_app_config(CONF_FILE)
41
+ if app_config is None:
42
+ log.error(f"Invalid configuration at {CONF_FILE}, exiting")
43
+ sys.exit(1)
44
+ self.cfg: Config = app_config
45
+
46
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, self.cfg.log.level)))
47
+ log.debug("Logging initialized", level=self.cfg.log.level)
48
+ self.common_pkg = load_package_info(PKG_INFO_FILE)
49
+
50
+ self.publisher = MqttClient(self.cfg.mqtt, self.cfg.node, self.cfg.homeassistant)
51
+
52
+ self.scanners: list[ReleaseProvider] = []
53
+ self.scan_count: int = 0
54
+ self.last_scan: str | None = None
55
+ if self.cfg.docker.enabled:
56
+ self.scanners.append(DockerProvider(self.cfg.docker, self.common_pkg, self.cfg.node))
57
+ self.stopped = Event()
58
+ self.healthcheck_topic = self.cfg.node.healthcheck.topic_template.format(node_name=self.cfg.node.name)
59
+
60
+ log.info(
61
+ "App configured",
62
+ node=self.cfg.node.name,
63
+ scan_interval=self.cfg.scan_interval,
64
+ )
65
+
66
+ async def scan(self) -> None:
67
+ session = uuid.uuid4().hex
68
+ for scanner in self.scanners:
69
+ log.info("Cleaning topics before scan", source_type=scanner.source_type)
70
+ if self.scan_count == 0:
71
+ await self.publisher.clean_topics(scanner, None, force=True)
72
+ if self.stopped.is_set():
73
+ break
74
+ log.info("Scanning", source=scanner.source_type, session=session)
75
+ async with asyncio.TaskGroup() as tg:
76
+ async for discovery in scanner.scan(session): # type: ignore[attr-defined]
77
+ tg.create_task(self.on_discovery(discovery), name=f"discovery-{discovery.name}")
78
+ if self.stopped.is_set():
79
+ break
80
+ await self.publisher.clean_topics(scanner, session, force=False)
81
+ self.scan_count += 1
82
+ log.info("Scan complete", source_type=scanner.source_type)
83
+ self.last_scan_timestamp = datetime.now(UTC).isoformat()
84
+
85
+ async def run(self) -> None:
86
+ log.debug("Starting run loop")
87
+ self.publisher.start()
88
+
89
+ if self.cfg.node.healthcheck.enabled:
90
+ await self.healthcheck() # initial eager healthcheck
91
+ log.info(
92
+ f"Setting up healthcheck every {self.cfg.node.healthcheck.interval} seconds to topic {self.healthcheck_topic}"
93
+ )
94
+ self.healthcheck_loop_task = asyncio.create_task(
95
+ repeated_call(self.healthcheck, interval=self.cfg.node.healthcheck.interval)
96
+ )
97
+
98
+ for scanner in self.scanners:
99
+ self.publisher.subscribe_hass_command(scanner)
100
+
101
+ while not self.stopped.is_set():
102
+ await self.scan()
103
+ if not self.stopped.is_set():
104
+ await asyncio.sleep(self.cfg.scan_interval)
105
+ else:
106
+ log.info("Stop requested, exiting run loop and skipping sleep")
107
+ log.debug("Exiting run loop")
108
+
109
+ async def on_discovery(self, discovery: Discovery) -> None:
110
+ dlog = log.bind(name=discovery.name)
111
+ try:
112
+ if self.cfg.homeassistant.discovery.enabled:
113
+ self.publisher.publish_hass_config(discovery)
114
+
115
+ self.publisher.publish_hass_state(discovery)
116
+ if discovery.update_policy == "Auto":
117
+ # TODO: review auto update, trigger by version, use update interval as throttle
118
+ elapsed: float = (
119
+ time.time() - discovery.update_last_attempt if discovery.update_last_attempt is not None else -1
120
+ )
121
+ if elapsed == -1 or elapsed > UPDATE_INTERVAL:
122
+ dlog.info(
123
+ "Initiate auto update (last:%s, elapsed:%s, max:%s)",
124
+ discovery.update_last_attempt,
125
+ elapsed,
126
+ UPDATE_INTERVAL,
127
+ )
128
+ self.publisher.local_message(discovery, "install")
129
+ else:
130
+ dlog.info("Skipping auto update")
131
+ except asyncio.CancelledError:
132
+ dlog.info("Discovery handling cancelled")
133
+ except Exception:
134
+ dlog.exception("Discovery handling failed")
135
+ raise
136
+
137
+ async def interrupt_tasks(self) -> None:
138
+ running_tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
139
+ log.info(f"Cancelling {len(running_tasks)} tasks")
140
+ for t in running_tasks:
141
+ log.debug("Cancelling task", task=t.get_name())
142
+ t.cancel()
143
+ await asyncio.gather(*running_tasks, return_exceptions=True)
144
+ log.debug("Cancellation task completed")
145
+
146
+ def shutdown(self, *args) -> None: # noqa: ANN002
147
+ log.info("Shutting down on SIGTERM: %s", args)
148
+ self.stopped.set()
149
+ for scanner in self.scanners:
150
+ scanner.stop()
151
+ interrupt_task = asyncio.get_event_loop().create_task(self.interrupt_tasks(), eager_start=True) # type: ignore[call-arg] # pyright: ignore[reportCallIssue]
152
+ for t in asyncio.all_tasks():
153
+ log.debug("Tasks waiting = %s", t)
154
+ self.publisher.stop()
155
+ log.debug("Interrupt: %s", interrupt_task.done())
156
+ log.info("Shutdown handling complete")
157
+
158
+ async def healthcheck(self) -> None:
159
+ self.publisher.publish(
160
+ topic=self.healthcheck_topic,
161
+ payload={
162
+ "version": updates2mqtt.version,
163
+ "node": self.cfg.node.name,
164
+ "heartbeat_raw": time.time(),
165
+ "heartbeat_stamp": datetime.now(UTC).isoformat(),
166
+ "startup_stamp": self.startup_timestamp,
167
+ "last_scan_stamp": self.last_scan_timestamp,
168
+ "scan_count": self.scan_count,
169
+ },
170
+ )
171
+
172
+
173
+ async def repeated_call(func: Callable, interval: int = 60, *args: Any, **kwargs: Any) -> None:
174
+ # run a task periodically indefinitely
175
+ while True:
176
+ try:
177
+ await func(*args, **kwargs)
178
+ await asyncio.sleep(interval)
179
+ except asyncio.CancelledError:
180
+ log.exception("Periodic task cancelled")
181
+ except Exception:
182
+ log.exception("Periodic task failed")
183
+
184
+
185
+ def run() -> None:
186
+ import asyncio
187
+ import signal
188
+
189
+ from .app import App
190
+
191
+ log.debug(f"Starting updates2mqtt v{updates2mqtt.version}")
192
+ app = App()
193
+
194
+ signal.signal(signal.SIGTERM, app.shutdown)
195
+ try:
196
+ asyncio.run(app.run(), debug=False)
197
+ log.debug("App exited gracefully")
198
+ except asyncio.CancelledError:
199
+ log.debug("App exited on cancelled task")
200
+
201
+
202
+ if __name__ == "__main__":
203
+ run()
updates2mqtt/config.py ADDED
@@ -0,0 +1,135 @@
1
+ import os
2
+ import typing
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import structlog
7
+ from omegaconf import MISSING, MissingMandatoryValue, OmegaConf, ValidationError
8
+
9
+ log = structlog.get_logger()
10
+
11
+
12
+ @dataclass
13
+ class MqttConfig:
14
+ host: str = "localhost"
15
+ user: str = MISSING
16
+ password: str = MISSING
17
+ port: int = 1883
18
+ topic_root: str = "updates2mqtt"
19
+
20
+
21
+ @dataclass
22
+ class MetadataSourceConfig:
23
+ enabled: bool = True
24
+ cache_ttl: int = 60 * 60 * 24 * 7 # 1 week
25
+
26
+
27
+ @dataclass
28
+ class DockerConfig:
29
+ enabled: bool = True
30
+ allow_pull: bool = True
31
+ allow_restart: bool = True
32
+ allow_build: bool = True
33
+ compose_version: str = "v2"
34
+ default_entity_picture_url: str = "https://www.docker.com/wp-content/uploads/2022/03/Moby-logo.png"
35
+ device_icon: str = "mdi:docker" # Icon to show when browsing entities in Home Assistant
36
+ discover_metadata: dict[str, MetadataSourceConfig] = field(
37
+ default_factory=lambda: {"linuxserver.io": MetadataSourceConfig(enabled=True)}
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class HomeAssistantDiscoveryConfig:
43
+ prefix: str = "homeassistant"
44
+ enabled: bool = True
45
+
46
+
47
+ @dataclass
48
+ class HomeAssistantConfig:
49
+ discovery: HomeAssistantDiscoveryConfig = field(default_factory=HomeAssistantDiscoveryConfig)
50
+ state_topic_suffix: str = "state"
51
+
52
+
53
+ @dataclass
54
+ class HealthCheckConfig:
55
+ enabled: bool = True
56
+ interval: int = 300 # Interval in seconds to publish healthcheck message, 0 to disable
57
+ topic_template: str = "healthcheck/{node_name}/updates2mqtt"
58
+
59
+
60
+ @dataclass
61
+ class NodeConfig:
62
+ name: str = field(default_factory=lambda: os.uname().nodename.replace(".local", ""))
63
+ git_path: str = "/usr/bin/git"
64
+ healthcheck: HealthCheckConfig = field(default_factory=HealthCheckConfig)
65
+
66
+
67
+ @dataclass
68
+ class LogConfig:
69
+ level: str = "INFO"
70
+
71
+
72
+ @dataclass
73
+ class Config:
74
+ log: LogConfig = field(default_factory=LogConfig)
75
+ node: NodeConfig = field(default_factory=NodeConfig)
76
+ mqtt: MqttConfig = field(default_factory=MqttConfig)
77
+ homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
78
+ docker: DockerConfig = field(default_factory=DockerConfig)
79
+ scan_interval: int = 60 * 60 * 3
80
+
81
+
82
+ @dataclass
83
+ class DockerPackageUpdateInfo:
84
+ image_name: str = MISSING
85
+
86
+
87
+ @dataclass
88
+ class PackageUpdateInfo:
89
+ docker: DockerPackageUpdateInfo | None = field(default_factory=DockerPackageUpdateInfo)
90
+ logo_url: str | None = None
91
+ release_notes_url: str | None = None
92
+
93
+
94
+ @dataclass
95
+ class UpdateInfoConfig:
96
+ common_packages: dict[str, PackageUpdateInfo] = field(default_factory=lambda: {})
97
+
98
+
99
+ def load_package_info(pkginfo_file_path: Path) -> UpdateInfoConfig:
100
+ if pkginfo_file_path.exists():
101
+ log.debug("Loading common package update info", path=pkginfo_file_path)
102
+ cfg = OmegaConf.load(pkginfo_file_path)
103
+ else:
104
+ log.warn("No common package update info found", path=pkginfo_file_path)
105
+ cfg = OmegaConf.structured(UpdateInfoConfig)
106
+ OmegaConf.set_readonly(cfg, True)
107
+ return typing.cast("UpdateInfoConfig", cfg)
108
+
109
+
110
+ def load_app_config(conf_file_path: Path) -> Config | None:
111
+ initializing: bool = False
112
+ base_cfg = OmegaConf.structured(Config)
113
+ if conf_file_path.exists():
114
+ cfg = OmegaConf.merge(base_cfg, OmegaConf.load(conf_file_path))
115
+ else:
116
+ initializing = True
117
+ try:
118
+ log.debug("Creating config directory if not already present", path=conf_file_path)
119
+ conf_file_path.parent.mkdir(parents=True, exist_ok=True)
120
+ except Exception:
121
+ log.exception("Unable to create config directory", path=conf_file_path.parent)
122
+ try:
123
+ conf_file_path.write_text(OmegaConf.to_yaml(base_cfg))
124
+ except Exception:
125
+ log.exception("Unable to write config file", path=conf_file_path)
126
+ cfg = base_cfg
127
+
128
+ try:
129
+ # Validate that all required fields are present, throw exception now rather than when config first used
130
+ OmegaConf.to_container(cfg, throw_on_missing=not initializing)
131
+ OmegaConf.set_readonly(cfg, True)
132
+ return typing.cast("Config", cfg)
133
+ except (MissingMandatoryValue, ValidationError) as e:
134
+ log.error("Configuration error: %s", e, path=conf_file_path)
135
+ return None
@@ -0,0 +1,75 @@
1
+ from typing import Any
2
+
3
+ import structlog
4
+
5
+ import updates2mqtt
6
+ from updates2mqtt.model import Discovery
7
+
8
+ log = structlog.get_logger()
9
+ HASS_UPDATE_SCHEMA = [
10
+ "installed_version",
11
+ "latest_version",
12
+ "title",
13
+ "release_summary",
14
+ "release_url",
15
+ "entity_picture",
16
+ "in_progress",
17
+ "update_percentage",
18
+ ]
19
+
20
+
21
+ def hass_format_config(
22
+ discovery: Discovery, object_id: str, node_name: str, state_topic: str, command_topic: str | None, session: str
23
+ ) -> dict[str, Any]:
24
+ config = {
25
+ "name": f"{discovery.name} {discovery.source_type} on {node_name}",
26
+ "device_class": None, # not firmware, so defaults to null
27
+ "unique_id": object_id,
28
+ "state_topic": state_topic,
29
+ "source_session": session,
30
+ "supported_features": discovery.features,
31
+ "entity_picture": discovery.entity_picture_url,
32
+ "icon": discovery.device_icon,
33
+ "can_update": discovery.can_update,
34
+ "can_build": discovery.can_build,
35
+ "can_restart": discovery.can_restart,
36
+ "update_policy": discovery.update_policy,
37
+ "latest_version_topic": state_topic,
38
+ "latest_version_template": "{{value_json.latest_version}}",
39
+ "origin": {
40
+ "name": "updates2mqtt",
41
+ "sw_version": updates2mqtt.version,
42
+ "support_url": "https://github.com/rhizomatics/updates2mqtt/issues",
43
+ },
44
+ }
45
+ if command_topic:
46
+ config["command_topic"] = command_topic
47
+ config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
48
+ if discovery.custom.get("git_repo_path"):
49
+ config["git_repo_path"] = discovery.custom["git_repo_path"]
50
+ config.update(discovery.provider.hass_config_format(discovery))
51
+ return config
52
+
53
+
54
+ def hass_format_state(discovery: Discovery, node_name: str, session: str, in_progress: bool = False) -> dict[str, Any]: # noqa: ARG001
55
+ title: str = (
56
+ discovery.title_template.format(name=discovery.name, node=node_name) if discovery.title_template else discovery.name
57
+ )
58
+ state = {
59
+ "installed_version": discovery.current_version,
60
+ "latest_version": discovery.latest_version,
61
+ "title": title,
62
+ "in_progress": in_progress,
63
+ }
64
+ if discovery.release_summary:
65
+ state["release_summary"] = discovery.release_summary
66
+ if discovery.release_url:
67
+ state["release_url"] = discovery.release_url
68
+ custom_state = discovery.provider.hass_state_format(discovery)
69
+ if custom_state:
70
+ state.update(custom_state)
71
+ invalid_keys = [k for k in state if k not in HASS_UPDATE_SCHEMA]
72
+ if invalid_keys:
73
+ log.warning(f"Invalid keys in state: {invalid_keys}")
74
+ state = {k: v for k, v in state.items() if k in HASS_UPDATE_SCHEMA}
75
+ return state
@@ -0,0 +1 @@
1
+ """System integrations to detect component version availability and optionally execute update and restart"""