updates2mqtt 1.6.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.
@@ -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,233 @@
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, PackageUpdateInfo, load_app_config, load_package_info
18
+ from .integrations.docker import DockerProvider
19
+ from .mqtt import MqttPublisher
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}")
43
+ log.error("Edit config to fix missing or invalid values and restart")
44
+ log.error("Alternately supply correct MQTT_HOST,MQTT_USER,MQTT_PASSWORD environment variables")
45
+ log.error("Exiting app")
46
+ sys.exit(1)
47
+ self.cfg: Config = app_config
48
+ self.self_bounce: Event = Event()
49
+
50
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, str(self.cfg.log.level))))
51
+ log.debug("Logging initialized", level=self.cfg.log.level)
52
+ self.common_pkg: dict[str, PackageUpdateInfo] = load_package_info(PKG_INFO_FILE)
53
+
54
+ self.publisher = MqttPublisher(self.cfg.mqtt, self.cfg.node, self.cfg.homeassistant)
55
+
56
+ self.scanners: list[ReleaseProvider] = []
57
+ self.scan_count: int = 0
58
+ self.last_scan: str | None = None
59
+ if self.cfg.docker.enabled:
60
+ self.scanners.append(DockerProvider(self.cfg.docker, self.common_pkg, self.cfg.node, self.self_bounce))
61
+ self.stopped = Event()
62
+ self.healthcheck_topic = self.cfg.node.healthcheck.topic_template.format(node_name=self.cfg.node.name)
63
+
64
+ log.info(
65
+ "App configured",
66
+ node=self.cfg.node.name,
67
+ scan_interval=self.cfg.scan_interval,
68
+ healthcheck_topic=self.healthcheck_topic,
69
+ )
70
+
71
+ async def scan(self) -> None:
72
+ session = uuid.uuid4().hex
73
+ for scanner in self.scanners:
74
+ slog = log.bind(source_type=scanner.source_type, session=session)
75
+ slog.info("Cleaning topics before scan")
76
+ if self.scan_count == 0:
77
+ await self.publisher.clean_topics(scanner, None, force=True)
78
+ if self.stopped.is_set():
79
+ break
80
+ slog.info("Scanning ...")
81
+ async with asyncio.TaskGroup() as tg:
82
+ # xtype: ignore[attr-defined]
83
+ async for discovery in scanner.scan(session):
84
+ tg.create_task(self.on_discovery(discovery), name=f"discovery-{discovery.name}")
85
+ if self.stopped.is_set():
86
+ slog.debug("Breaking scan loop on stopped event")
87
+ break
88
+ await self.publisher.clean_topics(scanner, session, force=False)
89
+ self.scan_count += 1
90
+ slog.info(f"Scan #{self.scan_count} complete")
91
+ self.last_scan_timestamp = datetime.now(UTC).isoformat()
92
+
93
+ async def main_loop(self) -> None:
94
+ log.debug("Starting run loop")
95
+ self.publisher.start()
96
+
97
+ if self.cfg.node.healthcheck.enabled:
98
+ await self.healthcheck() # initial eager healthcheck
99
+ log.info(
100
+ f"Setting up healthcheck every {self.cfg.node.healthcheck.interval} seconds to topic {self.healthcheck_topic}"
101
+ )
102
+ self.healthcheck_loop_task = asyncio.create_task(
103
+ repeated_call(self.healthcheck, interval=self.cfg.node.healthcheck.interval), name="healthcheck"
104
+ )
105
+
106
+ for scanner in self.scanners:
107
+ self.publisher.subscribe_hass_command(scanner)
108
+
109
+ while not self.stopped.is_set() and self.publisher.is_available():
110
+ await self.scan()
111
+ if not self.stopped.is_set() and self.publisher.is_available():
112
+ await asyncio.sleep(self.cfg.scan_interval)
113
+ else:
114
+ log.info("Stop requested, exiting run loop and skipping sleep")
115
+
116
+ if not self.publisher.is_available():
117
+ log.error("MQTT fatal connection error - check host,port,user,password in config")
118
+ self.shutdown(exit_code=1)
119
+
120
+ log.debug("Exiting run loop")
121
+
122
+ async def on_discovery(self, discovery: Discovery) -> None:
123
+ dlog = log.bind(name=discovery.name)
124
+ try:
125
+ if self.cfg.homeassistant.discovery.enabled:
126
+ self.publisher.publish_hass_config(discovery)
127
+
128
+ self.publisher.publish_hass_state(discovery)
129
+ if (
130
+ discovery.update_policy == "Auto"
131
+ and discovery.can_update
132
+ and discovery.latest_version != discovery.current_version
133
+ ):
134
+ # TODO: review auto update, trigger by version, use update interval as throttle
135
+ elapsed: float = (
136
+ time.time() - discovery.update_last_attempt if discovery.update_last_attempt is not None else -1
137
+ )
138
+ if elapsed == -1 or elapsed > UPDATE_INTERVAL:
139
+ dlog.info(
140
+ "Initiate auto update (last:%s, elapsed:%s, max:%s)",
141
+ discovery.update_last_attempt,
142
+ elapsed,
143
+ UPDATE_INTERVAL,
144
+ )
145
+ self.publisher.local_message(discovery, "install")
146
+ else:
147
+ dlog.info("Skipping auto update")
148
+ except asyncio.CancelledError:
149
+ dlog.info("Discovery handling cancelled")
150
+ except Exception:
151
+ dlog.exception("Discovery handling failed")
152
+ raise
153
+
154
+ async def interrupt_tasks(self) -> None:
155
+ running_tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
156
+ log.info(f"Cancelling {len(running_tasks)} tasks")
157
+ for t in running_tasks:
158
+ log.debug("Cancelling task", task=t.get_name())
159
+ if t.get_name() == "healthcheck" or t.get_name().startswith("discovery-"):
160
+ t.cancel()
161
+ await asyncio.gather(*running_tasks, return_exceptions=True)
162
+ log.debug("Cancellation task completed")
163
+
164
+ def shutdown(self, *args, exit_code: int = 143) -> None: # noqa: ANN002, ARG002
165
+ if self.self_bounce.is_set():
166
+ exit_code = 1
167
+ log.info("Self bouncing, overriding exit_code: %s", exit_code)
168
+ else:
169
+ log.info("Shutting down, exit_code: %s", exit_code)
170
+ self.stopped.set()
171
+ for scanner in self.scanners:
172
+ scanner.stop()
173
+ interrupt_task = asyncio.get_event_loop().create_task(
174
+ self.interrupt_tasks(),
175
+ eager_start=True, # type: ignore[call-arg] # pyright: ignore[reportCallIssue]
176
+ name="interrupt",
177
+ )
178
+ for t in asyncio.all_tasks():
179
+ log.debug("Tasks waiting = %s", t)
180
+ self.publisher.stop()
181
+ log.debug("Interrupt: %s", interrupt_task.done())
182
+ log.info("Shutdown handling complete")
183
+ sys.exit(exit_code) # SIGTERM Graceful Exit = 143
184
+
185
+ async def healthcheck(self) -> None:
186
+ if not self.publisher.is_available():
187
+ return
188
+ heartbeat_stamp: str = datetime.now(UTC).isoformat()
189
+ log.debug("Publishing health check", heartbeat_stamp=heartbeat_stamp)
190
+ self.publisher.publish(
191
+ topic=self.healthcheck_topic,
192
+ payload={
193
+ "version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
194
+ "node": self.cfg.node.name,
195
+ "heartbeat_raw": time.time(),
196
+ "heartbeat_stamp": heartbeat_stamp,
197
+ "startup_stamp": self.startup_timestamp,
198
+ "last_scan_stamp": self.last_scan_timestamp,
199
+ "scan_count": self.scan_count,
200
+ },
201
+ )
202
+
203
+
204
+ async def repeated_call(func: Callable, interval: int = 60, *args: Any, **kwargs: Any) -> None:
205
+ # run a task periodically indefinitely
206
+ while True:
207
+ try:
208
+ await func(*args, **kwargs)
209
+ await asyncio.sleep(interval)
210
+ except asyncio.CancelledError:
211
+ log.debug("Periodic task cancelled", func=func)
212
+ except Exception:
213
+ log.exception("Periodic task failed")
214
+
215
+
216
+ def run() -> None:
217
+ import asyncio
218
+ import signal
219
+
220
+ # pyright: ignore[reportAttributeAccessIssue]
221
+ log.debug(f"Starting updates2mqtt v{updates2mqtt.version}") # pyright: ignore[reportAttributeAccessIssue]
222
+ app = App()
223
+
224
+ signal.signal(signal.SIGTERM, app.shutdown)
225
+ try:
226
+ asyncio.run(app.main_loop(), debug=False)
227
+ log.debug("App exited gracefully")
228
+ except asyncio.CancelledError:
229
+ log.debug("App exited on cancelled task")
230
+
231
+
232
+ if __name__ == "__main__":
233
+ run()
updates2mqtt/config.py ADDED
@@ -0,0 +1,176 @@
1
+ import os
2
+ import typing
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+
7
+ import structlog
8
+ from omegaconf import MISSING, DictConfig, MissingMandatoryValue, OmegaConf, ValidationError
9
+
10
+ log = structlog.get_logger()
11
+
12
+
13
+ class LogLevel(StrEnum):
14
+ DEBUG = "DEBUG"
15
+ INFO = "INFO"
16
+ WARNING = "WARNING"
17
+ ERROR = "ERROR"
18
+ CRITICAL = "CRITICAL"
19
+
20
+
21
+ @dataclass
22
+ class MqttConfig:
23
+ host: str = "${oc.env:MQTT_HOST,localhost}"
24
+ user: str = f"${{oc.env:MQTT_USER,{MISSING}}}"
25
+ password: str = f"${{oc.env:MQTT_PASS,{MISSING}}}"
26
+ port: int = "${oc.decode:${oc.env:MQTT_PORT,1883}}" # type: ignore[assignment]
27
+ topic_root: str = "updates2mqtt"
28
+ protocol: str = "${oc.env:MQTT_VERSION,3.11}"
29
+
30
+
31
+ @dataclass
32
+ class MetadataSourceConfig:
33
+ enabled: bool = True
34
+ cache_ttl: int = 60 * 60 * 24 * 7 # 1 week
35
+
36
+
37
+ @dataclass
38
+ class DockerConfig:
39
+ enabled: bool = True
40
+ allow_pull: bool = True
41
+ allow_restart: bool = True
42
+ allow_build: bool = True
43
+ compose_version: str = "v2"
44
+ default_entity_picture_url: str = "https://www.docker.com/wp-content/uploads/2022/03/Moby-logo.png"
45
+ # Icon to show when browsing entities in Home Assistant
46
+ device_icon: str = "mdi:docker"
47
+ discover_metadata: dict[str, MetadataSourceConfig] = field(
48
+ default_factory=lambda: {"linuxserver.io": MetadataSourceConfig(enabled=True)}
49
+ )
50
+ api_throttle_wait: int = 60 * 15
51
+
52
+
53
+ @dataclass
54
+ class HomeAssistantDiscoveryConfig:
55
+ prefix: str = "homeassistant"
56
+ enabled: bool = True
57
+
58
+
59
+ @dataclass
60
+ class HomeAssistantConfig:
61
+ discovery: HomeAssistantDiscoveryConfig = field(default_factory=HomeAssistantDiscoveryConfig)
62
+ state_topic_suffix: str = "state"
63
+ device_creation: bool = True
64
+ force_command_topic: bool = False
65
+ area: str | None = None
66
+
67
+
68
+ @dataclass
69
+ class HealthCheckConfig:
70
+ enabled: bool = True
71
+ interval: int = 300 # Interval in seconds to publish healthcheck message, 0 to disable
72
+ topic_template: str = "healthcheck/{node_name}/updates2mqtt"
73
+
74
+
75
+ @dataclass
76
+ class NodeConfig:
77
+ name: str = field(default_factory=lambda: os.uname().nodename.replace(".local", ""))
78
+ git_path: str = "/usr/bin/git"
79
+ healthcheck: HealthCheckConfig = field(default_factory=HealthCheckConfig)
80
+
81
+
82
+ @dataclass
83
+ class LogConfig:
84
+ level: LogLevel = "${oc.decode:${oc.env:U2M_LOG_LEVEL,INFO}}" # type: ignore[assignment] # pyright: ignore[reportAssignmentType]
85
+
86
+
87
+ @dataclass
88
+ class Config:
89
+ log: LogConfig = field(default_factory=LogConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
90
+ node: NodeConfig = field(default_factory=NodeConfig)
91
+ mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
92
+ homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
93
+ docker: DockerConfig = field(default_factory=DockerConfig)
94
+ scan_interval: int = 60 * 60 * 3
95
+
96
+
97
+ @dataclass
98
+ class DockerPackageUpdateInfo:
99
+ image_name: str = MISSING
100
+
101
+
102
+ @dataclass
103
+ class PackageUpdateInfo:
104
+ docker: DockerPackageUpdateInfo | None = field(default_factory=DockerPackageUpdateInfo)
105
+ logo_url: str | None = None
106
+ release_notes_url: str | None = None
107
+
108
+
109
+ @dataclass
110
+ class UpdateInfoConfig:
111
+ common_packages: dict[str, PackageUpdateInfo] = field(default_factory=lambda: {})
112
+
113
+
114
+ class IncompleteConfigException(BaseException):
115
+ pass
116
+
117
+
118
+ def load_package_info(pkginfo_file_path: Path) -> dict[str, PackageUpdateInfo]:
119
+ if pkginfo_file_path.exists():
120
+ log.debug("Loading common package update info", path=pkginfo_file_path)
121
+ cfg = OmegaConf.load(pkginfo_file_path)
122
+ else:
123
+ log.warn("No common package update info found", path=pkginfo_file_path)
124
+ cfg = OmegaConf.structured(UpdateInfoConfig)
125
+ try:
126
+ # omegaconf broken-ness on optional fields and converting to backclasses
127
+ pkg_conf: dict[str, PackageUpdateInfo] = {
128
+ pkg: PackageUpdateInfo(**pkg_cfg) for pkg, pkg_cfg in cfg.common_packages.items()
129
+ }
130
+ return pkg_conf
131
+ except (MissingMandatoryValue, ValidationError) as e:
132
+ log.error("Configuration error %s", e, path=pkginfo_file_path.as_posix())
133
+ raise
134
+
135
+
136
+ def is_autogen_config() -> bool:
137
+ env_var: str | None = os.environ.get("U2M_AUTOGEN_CONFIG")
138
+ return not (env_var and env_var.lower() in ("no", "0", "false"))
139
+
140
+
141
+ def load_app_config(conf_file_path: Path, return_invalid: bool = False) -> Config | None:
142
+ base_cfg: DictConfig = OmegaConf.structured(Config)
143
+ if conf_file_path.exists():
144
+ cfg: DictConfig = typing.cast("DictConfig", OmegaConf.merge(base_cfg, OmegaConf.load(conf_file_path)))
145
+ elif is_autogen_config():
146
+ if not conf_file_path.parent.exists():
147
+ try:
148
+ log.debug(f"Creating config directory {conf_file_path.parent} if not already present")
149
+ conf_file_path.parent.mkdir(parents=True, exist_ok=True)
150
+ except Exception:
151
+ log.warning("Unable to create config directory", path=conf_file_path.parent)
152
+ try:
153
+ conf_file_path.write_text(OmegaConf.to_yaml(base_cfg))
154
+ log.info(f"Auto-generated a new config file at {conf_file_path}")
155
+ except Exception:
156
+ log.warning("Unable to write config file", path=conf_file_path)
157
+ cfg = base_cfg
158
+ else:
159
+ cfg = base_cfg
160
+
161
+ try:
162
+ # Validate that all required fields are present, throw exception now rather than when config first used
163
+ OmegaConf.to_container(cfg, throw_on_missing=True)
164
+ OmegaConf.set_readonly(cfg, True)
165
+ config: Config = typing.cast("Config", cfg)
166
+
167
+ if config.mqtt.user in ("", MISSING) or config.mqtt.password in ("", MISSING):
168
+ log.info("The config has place holders for MQTT user and/or password")
169
+ if not return_invalid:
170
+ return None
171
+ return config
172
+ except (MissingMandatoryValue, ValidationError) as e:
173
+ log.error("Configuration error %s", e, path=conf_file_path.as_posix())
174
+ if return_invalid and cfg is not None:
175
+ return typing.cast("Config", cfg)
176
+ raise
@@ -0,0 +1,89 @@
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,
23
+ object_id: str,
24
+ state_topic: str,
25
+ command_topic: str | None,
26
+ force_command_topic: bool | None,
27
+ device_creation: bool = True,
28
+ area: str | None = None,
29
+ session: str | None = None,
30
+ ) -> dict[str, Any]:
31
+ config: dict[str, Any] = {
32
+ "name": discovery.title,
33
+ "device_class": None, # not firmware, so defaults to null
34
+ "unique_id": object_id,
35
+ "state_topic": state_topic,
36
+ "source_session": session,
37
+ "supported_features": discovery.features,
38
+ "can_update": discovery.can_update,
39
+ "can_build": discovery.can_build,
40
+ "can_restart": discovery.can_restart,
41
+ "update_policy": discovery.update_policy,
42
+ "origin": {
43
+ "name": f"{discovery.node} updates2mqtt",
44
+ "sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
45
+ "support_url": "https://github.com/rhizomatics/updates2mqtt/issues",
46
+ },
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
+ if device_creation:
53
+ config["device"] = {
54
+ "name": f"{discovery.node} updates2mqtt",
55
+ "sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
56
+ "manufacturer": "rhizomatics",
57
+ "identifiers": [f"{discovery.node}.updates2mqtt"],
58
+ }
59
+ if area:
60
+ config["device"]["suggested_area"] = area
61
+ if command_topic and (discovery.can_update or force_command_topic):
62
+ config["command_topic"] = command_topic
63
+ if discovery.can_update:
64
+ config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
65
+ config["custom"] = {}
66
+ config["custom"][discovery.source_type] = discovery.custom
67
+ config.update(discovery.provider.hass_config_format(discovery))
68
+ return config
69
+
70
+
71
+ def hass_format_state(discovery: Discovery, session: str, in_progress: bool = False) -> dict[str, Any]: # noqa: ARG001
72
+ state = {
73
+ "installed_version": discovery.current_version,
74
+ "latest_version": discovery.latest_version,
75
+ "title": discovery.title,
76
+ "in_progress": in_progress,
77
+ }
78
+ if discovery.release_summary:
79
+ state["release_summary"] = discovery.release_summary
80
+ if discovery.release_url:
81
+ state["release_url"] = discovery.release_url
82
+ custom_state = discovery.provider.hass_state_format(discovery)
83
+ if custom_state:
84
+ state.update(custom_state)
85
+ invalid_keys = [k for k in state if k not in HASS_UPDATE_SCHEMA]
86
+ if invalid_keys:
87
+ log.warning(f"Invalid keys in state: {invalid_keys}")
88
+ state = {k: v for k, v in state.items() if k in HASS_UPDATE_SCHEMA}
89
+ return state
@@ -0,0 +1 @@
1
+ """System integrations to detect component version availability and optionally execute update and restart"""