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.
- updates2mqtt/__init__.py +5 -0
- updates2mqtt/__main__.py +6 -0
- updates2mqtt/app.py +233 -0
- updates2mqtt/config.py +176 -0
- updates2mqtt/hass_formatter.py +89 -0
- updates2mqtt/integrations/__init__.py +1 -0
- updates2mqtt/integrations/docker.py +607 -0
- updates2mqtt/integrations/git_utils.py +123 -0
- updates2mqtt/model.py +128 -0
- updates2mqtt/mqtt.py +349 -0
- updates2mqtt/py.typed +0 -0
- updates2mqtt-1.6.0.dist-info/METADATA +211 -0
- updates2mqtt-1.6.0.dist-info/RECORD +15 -0
- updates2mqtt-1.6.0.dist-info/WHEEL +4 -0
- updates2mqtt-1.6.0.dist-info/entry_points.txt +3 -0
updates2mqtt/__init__.py
ADDED
updates2mqtt/__main__.py
ADDED
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"""
|