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.
- updates2mqtt/__init__.py +5 -0
- updates2mqtt/__main__.py +6 -0
- updates2mqtt/app.py +203 -0
- updates2mqtt/config.py +135 -0
- updates2mqtt/hass_formatter.py +75 -0
- updates2mqtt/integrations/__init__.py +1 -0
- updates2mqtt/integrations/docker.py +402 -0
- updates2mqtt/integrations/git_utils.py +64 -0
- updates2mqtt/model.py +98 -0
- updates2mqtt/mqtt.py +286 -0
- updates2mqtt/py.typed +0 -0
- updates2mqtt-1.3.4.dist-info/METADATA +236 -0
- updates2mqtt-1.3.4.dist-info/RECORD +16 -0
- updates2mqtt-1.3.4.dist-info/WHEEL +4 -0
- updates2mqtt-1.3.4.dist-info/entry_points.txt +2 -0
- updates2mqtt-1.3.4.dist-info/licenses/LICENSE +201 -0
updates2mqtt/__init__.py
ADDED
updates2mqtt/__main__.py
ADDED
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"""
|