updates2mqtt 1.4.2__py3-none-any.whl → 1.5.1__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 +36 -11
- updates2mqtt/hass_formatter.py +14 -16
- updates2mqtt/integrations/docker.py +67 -21
- updates2mqtt/model.py +11 -1
- updates2mqtt/mqtt.py +19 -15
- {updates2mqtt-1.4.2.dist-info → updates2mqtt-1.5.1.dist-info}/METADATA +6 -4
- updates2mqtt-1.5.1.dist-info/RECORD +16 -0
- updates2mqtt-1.4.2.dist-info/RECORD +0 -16
- {updates2mqtt-1.4.2.dist-info → updates2mqtt-1.5.1.dist-info}/WHEEL +0 -0
- {updates2mqtt-1.4.2.dist-info → updates2mqtt-1.5.1.dist-info}/entry_points.txt +0 -0
- {updates2mqtt-1.4.2.dist-info → updates2mqtt-1.5.1.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}"
|
|
@@ -16,7 +25,7 @@ class MqttConfig:
|
|
|
16
25
|
password: str = f"${{oc.env:MQTT_PASS,{MISSING}}}"
|
|
17
26
|
port: int = "${oc.decode:${oc.env:MQTT_PORT,1883}}" # type: ignore[assignment]
|
|
18
27
|
topic_root: str = "updates2mqtt"
|
|
19
|
-
protocol: str = "3.11"
|
|
28
|
+
protocol: str = "${oc.env:MQTT_VERSION,3.11}"
|
|
20
29
|
|
|
21
30
|
|
|
22
31
|
@dataclass
|
|
@@ -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
|
)
|
|
@@ -50,6 +60,7 @@ class HomeAssistantConfig:
|
|
|
50
60
|
discovery: HomeAssistantDiscoveryConfig = field(default_factory=HomeAssistantDiscoveryConfig)
|
|
51
61
|
state_topic_suffix: str = "state"
|
|
52
62
|
device_creation: bool = True
|
|
63
|
+
force_command_topic: bool = False
|
|
53
64
|
area: str | None = None
|
|
54
65
|
|
|
55
66
|
|
|
@@ -69,14 +80,14 @@ class NodeConfig:
|
|
|
69
80
|
|
|
70
81
|
@dataclass
|
|
71
82
|
class LogConfig:
|
|
72
|
-
level:
|
|
83
|
+
level: LogLevel = "${oc.decode:${oc.env:U2M_LOG_LEVEL,INFO}}" # type: ignore[assignment] # pyright: ignore[reportAssignmentType]
|
|
73
84
|
|
|
74
85
|
|
|
75
86
|
@dataclass
|
|
76
87
|
class Config:
|
|
77
|
-
log: LogConfig = field(default_factory=LogConfig)
|
|
88
|
+
log: LogConfig = field(default_factory=LogConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
|
|
78
89
|
node: NodeConfig = field(default_factory=NodeConfig)
|
|
79
|
-
mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[
|
|
90
|
+
mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
|
|
80
91
|
homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
|
|
81
92
|
docker: DockerConfig = field(default_factory=DockerConfig)
|
|
82
93
|
scan_interval: int = 60 * 60 * 3
|
|
@@ -103,33 +114,47 @@ class IncompleteConfigException(BaseException):
|
|
|
103
114
|
pass
|
|
104
115
|
|
|
105
116
|
|
|
106
|
-
def load_package_info(pkginfo_file_path: Path) ->
|
|
117
|
+
def load_package_info(pkginfo_file_path: Path) -> dict[str, PackageUpdateInfo]:
|
|
107
118
|
if pkginfo_file_path.exists():
|
|
108
119
|
log.debug("Loading common package update info", path=pkginfo_file_path)
|
|
109
120
|
cfg = OmegaConf.load(pkginfo_file_path)
|
|
110
121
|
else:
|
|
111
122
|
log.warn("No common package update info found", path=pkginfo_file_path)
|
|
112
123
|
cfg = OmegaConf.structured(UpdateInfoConfig)
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_autogen_config() -> bool:
|
|
136
|
+
env_var: str | None = os.environ.get("U2M_AUTOGEN_CONFIG")
|
|
137
|
+
return not (env_var and env_var.lower() in ("no", "0", "false"))
|
|
115
138
|
|
|
116
139
|
|
|
117
140
|
def load_app_config(conf_file_path: Path, return_invalid: bool = False) -> Config | None:
|
|
118
141
|
base_cfg: DictConfig = OmegaConf.structured(Config)
|
|
119
142
|
if conf_file_path.exists():
|
|
120
143
|
cfg: DictConfig = typing.cast("DictConfig", OmegaConf.merge(base_cfg, OmegaConf.load(conf_file_path)))
|
|
121
|
-
|
|
144
|
+
elif is_autogen_config():
|
|
122
145
|
if not conf_file_path.parent.exists():
|
|
123
146
|
try:
|
|
124
147
|
log.debug(f"Creating config directory {conf_file_path.parent} if not already present")
|
|
125
148
|
conf_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
149
|
except Exception:
|
|
127
|
-
log.
|
|
150
|
+
log.warning("Unable to create config directory", path=conf_file_path.parent)
|
|
128
151
|
try:
|
|
129
152
|
conf_file_path.write_text(OmegaConf.to_yaml(base_cfg))
|
|
130
153
|
log.info(f"Auto-generated a new config file at {conf_file_path}")
|
|
131
154
|
except Exception:
|
|
132
|
-
log.
|
|
155
|
+
log.warning("Unable to write config file", path=conf_file_path)
|
|
156
|
+
cfg = base_cfg
|
|
157
|
+
else:
|
|
133
158
|
cfg = base_cfg
|
|
134
159
|
|
|
135
160
|
try:
|
updates2mqtt/hass_formatter.py
CHANGED
|
@@ -21,60 +21,58 @@ HASS_UPDATE_SCHEMA = [
|
|
|
21
21
|
def hass_format_config(
|
|
22
22
|
discovery: Discovery,
|
|
23
23
|
object_id: str,
|
|
24
|
-
node_name: str,
|
|
25
24
|
state_topic: str,
|
|
26
25
|
command_topic: str | None,
|
|
26
|
+
force_command_topic: bool | None,
|
|
27
27
|
device_creation: bool = True,
|
|
28
28
|
area: str | None = None,
|
|
29
29
|
session: str | None = None,
|
|
30
30
|
) -> dict[str, Any]:
|
|
31
31
|
config: dict[str, Any] = {
|
|
32
|
-
"name":
|
|
32
|
+
"name": discovery.title,
|
|
33
33
|
"device_class": None, # not firmware, so defaults to null
|
|
34
34
|
"unique_id": object_id,
|
|
35
35
|
"state_topic": state_topic,
|
|
36
36
|
"source_session": session,
|
|
37
37
|
"supported_features": discovery.features,
|
|
38
|
-
"entity_picture": discovery.entity_picture_url,
|
|
39
|
-
"icon": discovery.device_icon,
|
|
40
38
|
"can_update": discovery.can_update,
|
|
41
39
|
"can_build": discovery.can_build,
|
|
42
40
|
"can_restart": discovery.can_restart,
|
|
43
41
|
"update_policy": discovery.update_policy,
|
|
44
|
-
"latest_version_topic": state_topic,
|
|
45
|
-
"latest_version_template": "{{value_json.latest_version}}",
|
|
46
42
|
"origin": {
|
|
47
|
-
"name": f"{
|
|
43
|
+
"name": f"{discovery.node} updates2mqtt",
|
|
48
44
|
"sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
|
|
49
45
|
"support_url": "https://github.com/rhizomatics/updates2mqtt/issues",
|
|
50
46
|
},
|
|
51
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
52
|
if device_creation:
|
|
53
53
|
config["device"] = {
|
|
54
|
-
"name": f"{
|
|
54
|
+
"name": f"{discovery.node} updates2mqtt",
|
|
55
55
|
"sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
|
|
56
56
|
"manufacturer": "rhizomatics",
|
|
57
|
-
"identifiers": [f"{
|
|
57
|
+
"identifiers": [f"{discovery.node}.updates2mqtt"],
|
|
58
58
|
}
|
|
59
59
|
if area:
|
|
60
60
|
config["device"]["suggested_area"] = area
|
|
61
|
-
if command_topic:
|
|
61
|
+
if command_topic and (discovery.can_update or force_command_topic):
|
|
62
62
|
config["command_topic"] = command_topic
|
|
63
|
-
|
|
63
|
+
if discovery.can_update:
|
|
64
|
+
config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
|
|
64
65
|
if discovery.custom.get("git_repo_path"):
|
|
65
66
|
config["git_repo_path"] = discovery.custom["git_repo_path"]
|
|
66
67
|
config.update(discovery.provider.hass_config_format(discovery))
|
|
67
68
|
return config
|
|
68
69
|
|
|
69
70
|
|
|
70
|
-
def hass_format_state(discovery: Discovery,
|
|
71
|
-
title: str = (
|
|
72
|
-
discovery.title_template.format(name=discovery.name, node=node_name) if discovery.title_template else discovery.name
|
|
73
|
-
)
|
|
71
|
+
def hass_format_state(discovery: Discovery, session: str, in_progress: bool = False) -> dict[str, Any]: # noqa: ARG001
|
|
74
72
|
state = {
|
|
75
73
|
"installed_version": discovery.current_version,
|
|
76
74
|
"latest_version": discovery.latest_version,
|
|
77
|
-
"title": title,
|
|
75
|
+
"title": discovery.title,
|
|
78
76
|
"in_progress": in_progress,
|
|
79
77
|
}
|
|
80
78
|
if discovery.release_summary:
|
|
@@ -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,22 +87,36 @@ 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
|
-
|
|
104
|
+
|
|
97
105
|
cmd: str = "docker-compose" if self.cfg.compose_version == "v1" else "docker compose"
|
|
106
|
+
logger.info(f"Executing {cmd} {command} {args} {service}")
|
|
98
107
|
cmd = cmd + " " + command.value
|
|
99
108
|
if args:
|
|
100
109
|
cmd = cmd + " " + args
|
|
110
|
+
if service:
|
|
111
|
+
cmd = cmd + " " + service
|
|
101
112
|
|
|
102
|
-
proc = subprocess.run(cmd, check=False, shell=True, cwd=cwd)
|
|
113
|
+
proc: subprocess.CompletedProcess[str] = subprocess.run(cmd, check=False, shell=True, cwd=cwd, text=True)
|
|
103
114
|
if proc.returncode == 0:
|
|
104
115
|
logger.info(f"{command} via compose successful")
|
|
105
116
|
return True
|
|
117
|
+
if proc.stderr and "unknown command: docker compose" in proc.stderr:
|
|
118
|
+
logger.warning("docker compose set to wrong version, seems like v1 installed")
|
|
119
|
+
self.cfg.compose_version = "v1"
|
|
106
120
|
logger.warn(
|
|
107
121
|
f"{command} failed: %s",
|
|
108
122
|
proc.returncode,
|
|
@@ -112,7 +126,10 @@ class DockerProvider(ReleaseProvider):
|
|
|
112
126
|
def restart(self, discovery: Discovery) -> bool:
|
|
113
127
|
logger = self.log.bind(container=discovery.name, action="restart")
|
|
114
128
|
compose_path = discovery.custom.get("compose_path")
|
|
115
|
-
|
|
129
|
+
compose_service: str | None = discovery.custom.get("compose_service")
|
|
130
|
+
return self.execute_compose(
|
|
131
|
+
command=DockerComposeCommand.UP, args="--detach --yes", service=compose_service, cwd=compose_path, logger=logger
|
|
132
|
+
)
|
|
116
133
|
|
|
117
134
|
def rescan(self, discovery: Discovery) -> Discovery | None:
|
|
118
135
|
logger = self.log.bind(container=discovery.name, action="rescan")
|
|
@@ -172,7 +189,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
172
189
|
logger.warn("RepoDigests=%s", image.attrs.get("RepoDigests"))
|
|
173
190
|
|
|
174
191
|
platform: str = "Unknown"
|
|
175
|
-
pkg_info: PackageUpdateInfo = self.default_metadata(image_name)
|
|
192
|
+
pkg_info: PackageUpdateInfo = self.default_metadata(image_name, image_ref=image_ref)
|
|
176
193
|
|
|
177
194
|
try:
|
|
178
195
|
picture_url = env_override("UPD2MQTT_PICTURE", pkg_info.logo_url)
|
|
@@ -197,6 +214,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
197
214
|
retries_left = 3
|
|
198
215
|
while reg_data is None and retries_left > 0 and not self.stopped.is_set():
|
|
199
216
|
try:
|
|
217
|
+
logger.debug("Fetching registry data", image_ref=image_ref)
|
|
200
218
|
reg_data = self.client.images.get_registry_data(image_ref)
|
|
201
219
|
latest_version = reg_data.short_id[7:] if reg_data else None
|
|
202
220
|
except docker.errors.APIError as e:
|
|
@@ -221,6 +239,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
221
239
|
custom["image_ref"] = image_ref
|
|
222
240
|
save_if_set("compose_path", c.labels.get("com.docker.compose.project.working_dir"))
|
|
223
241
|
save_if_set("compose_version", c.labels.get("com.docker.compose.version"))
|
|
242
|
+
save_if_set("compose_service", c.labels.get("com.docker.compose.service"))
|
|
224
243
|
save_if_set("git_repo_path", c_env.get("UPD2MQTT_GIT_REPO_PATH"))
|
|
225
244
|
save_if_set("apt_pkgs", c_env.get("UPD2MQTT_APT_PKGS"))
|
|
226
245
|
|
|
@@ -260,21 +279,29 @@ class DockerProvider(ReleaseProvider):
|
|
|
260
279
|
logger.info(f"Update not available, can_pull:{can_pull}, can_build:{can_build},can_restart{can_restart}")
|
|
261
280
|
if relnotes_url:
|
|
262
281
|
features.append("RELEASE_NOTES")
|
|
282
|
+
if can_pull:
|
|
283
|
+
update_type: str = "Docker Image"
|
|
284
|
+
elif can_build:
|
|
285
|
+
update_type = "Docker Build"
|
|
286
|
+
else:
|
|
287
|
+
update_type = "Unavailable"
|
|
263
288
|
custom["can_pull"] = can_pull
|
|
264
289
|
|
|
290
|
+
logger.debug("Analyze generated discovery", discovery_name=c.name, current_version=local_version)
|
|
265
291
|
return Discovery(
|
|
266
292
|
self,
|
|
267
293
|
c.name,
|
|
268
294
|
session,
|
|
295
|
+
node=self.node_cfg.name,
|
|
269
296
|
entity_picture_url=picture_url,
|
|
270
297
|
release_url=relnotes_url,
|
|
271
298
|
current_version=local_version,
|
|
272
299
|
update_policy=update_policy,
|
|
273
300
|
update_last_attempt=(original_discovery and original_discovery.update_last_attempt) or None,
|
|
274
301
|
latest_version=latest_version if latest_version != NO_KNOWN_IMAGE else local_version,
|
|
275
|
-
title_template="Docker image update for {name} on {node}",
|
|
276
302
|
device_icon=self.cfg.device_icon,
|
|
277
303
|
can_update=can_update,
|
|
304
|
+
update_type=update_type,
|
|
278
305
|
can_build=can_build,
|
|
279
306
|
can_restart=can_restart,
|
|
280
307
|
status=(c.status == "running" and "on") or "off",
|
|
@@ -283,21 +310,27 @@ class DockerProvider(ReleaseProvider):
|
|
|
283
310
|
)
|
|
284
311
|
except Exception:
|
|
285
312
|
logger.exception("Docker Discovery Failure", container_attrs=c.attrs)
|
|
313
|
+
logger.debug("Analyze returned empty discovery")
|
|
286
314
|
return None
|
|
287
315
|
|
|
288
316
|
async def scan(self, session: str) -> AsyncGenerator[Discovery]:
|
|
289
|
-
logger = self.log.bind(session=session, action="scan")
|
|
317
|
+
logger = self.log.bind(session=session, action="scan", source=self.source_type)
|
|
290
318
|
containers = results = 0
|
|
319
|
+
logger.debug("Starting container scan loop")
|
|
291
320
|
for c in self.client.containers.list():
|
|
321
|
+
logger.debug("Analyzing container", container=c.name)
|
|
292
322
|
if self.stopped.is_set():
|
|
293
323
|
logger.info(f"Shutdown detected, aborting scan at {c}")
|
|
294
324
|
break
|
|
295
325
|
containers = containers + 1
|
|
296
|
-
result = self.analyze(c, session)
|
|
326
|
+
result: Discovery | None = self.analyze(c, session)
|
|
297
327
|
if result:
|
|
328
|
+
logger.debug("Analyzed container", result_name=result.name, custom=result.custom)
|
|
298
329
|
self.discoveries[result.name] = result
|
|
299
330
|
results = results + 1
|
|
300
331
|
yield result
|
|
332
|
+
else:
|
|
333
|
+
logger.debug("No result from analysis", container=c.name)
|
|
301
334
|
logger.info("Completed", container_count=containers, result_count=results)
|
|
302
335
|
|
|
303
336
|
def command(self, discovery_name: str, command: str, on_update_start: Callable, on_update_end: Callable) -> bool:
|
|
@@ -348,27 +381,40 @@ class DockerProvider(ReleaseProvider):
|
|
|
348
381
|
# "platform": discovery.custom.get("platform"),
|
|
349
382
|
}
|
|
350
383
|
|
|
351
|
-
def default_metadata(self, image_name: str | None) -> PackageUpdateInfo:
|
|
352
|
-
|
|
353
|
-
|
|
384
|
+
def default_metadata(self, image_name: str | None, image_ref: str | None) -> PackageUpdateInfo:
|
|
385
|
+
def match(pkg: PackageUpdateInfo) -> bool:
|
|
386
|
+
if pkg is not None and pkg.docker is not None and pkg.docker.image_name is not None:
|
|
387
|
+
if image_name is not None and image_name == pkg.docker.image_name:
|
|
388
|
+
return True
|
|
389
|
+
if image_ref is not None and image_ref == pkg.docker.image_name:
|
|
390
|
+
return True
|
|
391
|
+
return False
|
|
354
392
|
|
|
355
|
-
if image_name is not None:
|
|
393
|
+
if image_name is not None and image_ref is not None:
|
|
356
394
|
for pkg in self.common_pkgs.values():
|
|
357
|
-
if pkg
|
|
395
|
+
if match(pkg):
|
|
358
396
|
self.log.debug(
|
|
359
|
-
"Found common package",
|
|
397
|
+
"Found common package",
|
|
398
|
+
image_name=pkg.docker.image_name, # type: ignore [union-attr]
|
|
399
|
+
logo_url=pkg.logo_url,
|
|
400
|
+
relnotes_url=pkg.release_notes_url,
|
|
360
401
|
)
|
|
361
402
|
return pkg
|
|
362
403
|
for pkg in self.discovered_pkgs.values():
|
|
363
|
-
if pkg
|
|
404
|
+
if match(pkg):
|
|
364
405
|
self.log.debug(
|
|
365
|
-
"Found discovered package",
|
|
406
|
+
"Found discovered package",
|
|
407
|
+
pkg=pkg.docker.image_name, # type: ignore [union-attr]
|
|
408
|
+
logo_url=pkg.logo_url,
|
|
409
|
+
relnotes_url=pkg.release_notes_url,
|
|
366
410
|
)
|
|
367
411
|
return pkg
|
|
368
412
|
|
|
369
413
|
self.log.debug("No common or discovered package found", image_name=image_name)
|
|
370
414
|
return PackageUpdateInfo(
|
|
371
|
-
DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE),
|
|
415
|
+
DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE),
|
|
416
|
+
logo_url=self.cfg.default_entity_picture_url,
|
|
417
|
+
release_notes_url=None,
|
|
372
418
|
)
|
|
373
419
|
|
|
374
420
|
def discover_metadata(self) -> dict[str, PackageUpdateInfo]:
|
updates2mqtt/model.py
CHANGED
|
@@ -14,6 +14,7 @@ class Discovery:
|
|
|
14
14
|
provider: "ReleaseProvider",
|
|
15
15
|
name: str,
|
|
16
16
|
session: str,
|
|
17
|
+
node: str,
|
|
17
18
|
entity_picture_url: str | None = None,
|
|
18
19
|
current_version: str | None = None,
|
|
19
20
|
latest_version: str | None = None,
|
|
@@ -21,11 +22,12 @@ class Discovery:
|
|
|
21
22
|
can_build: bool = False,
|
|
22
23
|
can_restart: bool = False,
|
|
23
24
|
status: str = "on",
|
|
25
|
+
update_type: str | None = "Update",
|
|
24
26
|
update_policy: str | None = None,
|
|
25
27
|
update_last_attempt: float | None = None,
|
|
26
28
|
release_url: str | None = None,
|
|
27
29
|
release_summary: str | None = None,
|
|
28
|
-
title_template: str = "
|
|
30
|
+
title_template: str = "{discovery.update_type} for {discovery.name} on {discovery.node}",
|
|
29
31
|
device_icon: str | None = None,
|
|
30
32
|
custom: dict[str, Any] | None = None,
|
|
31
33
|
features: list[str] | None = None,
|
|
@@ -34,6 +36,7 @@ class Discovery:
|
|
|
34
36
|
self.source_type: str = provider.source_type
|
|
35
37
|
self.session: str = session
|
|
36
38
|
self.name: str = name
|
|
39
|
+
self.node: str = node
|
|
37
40
|
self.entity_picture_url: str | None = entity_picture_url
|
|
38
41
|
self.current_version: str | None = current_version
|
|
39
42
|
self.latest_version: str | None = latest_version
|
|
@@ -44,6 +47,7 @@ class Discovery:
|
|
|
44
47
|
self.release_summary: str | None = release_summary
|
|
45
48
|
self.title_template: str | None = title_template
|
|
46
49
|
self.device_icon: str | None = device_icon
|
|
50
|
+
self.update_type: str | None = update_type
|
|
47
51
|
self.status: str = status
|
|
48
52
|
self.update_policy: str | None = update_policy
|
|
49
53
|
self.update_last_attempt: float | None = update_last_attempt
|
|
@@ -54,6 +58,12 @@ class Discovery:
|
|
|
54
58
|
"""Build a custom string representation"""
|
|
55
59
|
return f"Discovery('{self.name}','{self.source_type}',current={self.current_version},latest={self.latest_version})"
|
|
56
60
|
|
|
61
|
+
@property
|
|
62
|
+
def title(self) -> str:
|
|
63
|
+
if self.title_template:
|
|
64
|
+
return self.title_template.format(discovery=self)
|
|
65
|
+
return self.name
|
|
66
|
+
|
|
57
67
|
|
|
58
68
|
class ReleaseProvider:
|
|
59
69
|
"""Abstract base class for release providers, such as container scanners or package managers API calls"""
|
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")
|
|
@@ -303,7 +309,6 @@ class MqttPublisher:
|
|
|
303
309
|
self.state_topic(discovery),
|
|
304
310
|
hass_format_state(
|
|
305
311
|
discovery,
|
|
306
|
-
self.node_cfg.name,
|
|
307
312
|
discovery.session,
|
|
308
313
|
in_progress=in_progress,
|
|
309
314
|
),
|
|
@@ -311,16 +316,15 @@ class MqttPublisher:
|
|
|
311
316
|
|
|
312
317
|
def publish_hass_config(self, discovery: Discovery) -> None:
|
|
313
318
|
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
319
|
self.publish(
|
|
316
320
|
self.config_topic(discovery),
|
|
317
321
|
hass_format_config(
|
|
318
322
|
discovery=discovery,
|
|
319
323
|
object_id=object_id,
|
|
320
|
-
node_name=self.node_cfg.name,
|
|
321
324
|
area=self.hass_cfg.area,
|
|
322
325
|
state_topic=self.state_topic(discovery),
|
|
323
|
-
command_topic=command_topic,
|
|
326
|
+
command_topic=self.command_topic(discovery.provider),
|
|
327
|
+
force_command_topic=self.hass_cfg.force_command_topic,
|
|
324
328
|
device_creation=self.hass_cfg.device_creation,
|
|
325
329
|
session=discovery.session,
|
|
326
330
|
),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: updates2mqtt
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.1
|
|
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
|
-
{ align=left }
|
|
39
39
|
|
|
40
40
|
# updates2mqtt
|
|
41
41
|
|
|
@@ -91,15 +91,17 @@ Presently only Docker containers are supported, although others are planned, pro
|
|
|
91
91
|
|-----------|-------------|----------------------------------------------------------------------------------------------------|
|
|
92
92
|
| Docker | Scan. Fetch | Fetch is ``docker pull`` only. Restart support only for ``docker-compose`` image based containers. |
|
|
93
93
|
|
|
94
|
-
##
|
|
94
|
+
## Heartbeat
|
|
95
95
|
|
|
96
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.
|
|
97
97
|
|
|
98
|
+
## Healthcheck
|
|
99
|
+
|
|
98
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.
|
|
99
101
|
|
|
100
102
|
!!! tip
|
|
101
103
|
|
|
102
|
-
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)
|
|
103
105
|
|
|
104
106
|
Another approach is using a restarter service directly in Docker Compose to force a restart, in this case once a day:
|
|
105
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=nlAszykZa7t9nu99RB8g_tIzQxmdfWyilTZJY3Fl4e4,6099
|
|
5
|
+
updates2mqtt/hass_formatter.py,sha256=bj6qpElMqt1DqKlxp4ZjwaCJAD-ed3xsq5ZOg4FbeC8,3216
|
|
6
|
+
updates2mqtt/model.py,sha256=KNsLflgWaRvGrNdq1Vy2QnDLfVSLPWLSt4gBXj9COa4,4170
|
|
7
|
+
updates2mqtt/mqtt.py,sha256=j7nlSka-LqRethZk_rc4Z8iOV7ey28TQFjSfrPeTjv4,14995
|
|
8
|
+
updates2mqtt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
updates2mqtt/integrations/__init__.py,sha256=KmNTUxvVWvqI7rl4I0xZg7XaCmcMS2O4OSv-ClsWM4Q,109
|
|
10
|
+
updates2mqtt/integrations/docker.py,sha256=Mi3tFjVDbCIsHBgH6UQ3yC5Y7HQk9JgEyH6PQuNJtFU,21263
|
|
11
|
+
updates2mqtt/integrations/git_utils.py,sha256=bPCmQiZpKpMcrGI7xAVmePXHFn8WwjcPNkf7xqDsGQA,2319
|
|
12
|
+
updates2mqtt-1.5.1.dist-info/METADATA,sha256=iPMKAgEFTFZ98pouv3SnTTStP74SEPBuV8jN3QbBpX8,9392
|
|
13
|
+
updates2mqtt-1.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
updates2mqtt-1.5.1.dist-info/entry_points.txt,sha256=Hc6NZ2dBevYSUKTJU6NOs8Mw7Vt0S-9lq5FuKb76NCc,54
|
|
15
|
+
updates2mqtt-1.5.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
16
|
+
updates2mqtt-1.5.1.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=G7_5L7Ypc7dl35nnd5dP2AobSA08ZuyEM_ETCTPg_co,5144
|
|
5
|
-
updates2mqtt/hass_formatter.py,sha256=GrVtxqpaXNFaQWXjmOFhOxYwXVjeNjzUwAjFdJdsrwI,3337
|
|
6
|
-
updates2mqtt/model.py,sha256=O6GQFhfQvwQDxlZScFHrpQgFNkUrUAeaGGP6AqNua78,3827
|
|
7
|
-
updates2mqtt/mqtt.py,sha256=I8g-SekY-AIVRcSCZyb7fTGji5a8zT1PJ7d34l7LGyY,14832
|
|
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.2.dist-info/METADATA,sha256=1wqWYIbcNbC-SQxk2FiI_SPmw9F-RFwdoOE_vviSXzw,9290
|
|
13
|
-
updates2mqtt-1.4.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
-
updates2mqtt-1.4.2.dist-info/entry_points.txt,sha256=Hc6NZ2dBevYSUKTJU6NOs8Mw7Vt0S-9lq5FuKb76NCc,54
|
|
15
|
-
updates2mqtt-1.4.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
16
|
-
updates2mqtt-1.4.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|