updates2mqtt 1.4.2__tar.gz → 1.5.0__tar.gz
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-1.4.2 → updates2mqtt-1.5.0}/CHANGELOG.md +11 -1
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/PKG-INFO +6 -4
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/README.md +5 -3
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/common_packages.yaml +13 -2
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/conftest.py +1 -1
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/examples/config_maximal.md +1 -1
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/examples/config_minimal.md +1 -1
- updates2mqtt-1.5.0/docs/examples/docker_compose.md +9 -0
- updates2mqtt-1.5.0/docs/examples/env.md +7 -0
- updates2mqtt-1.5.0/docs/examples/index.md +5 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/home_assistant.md +13 -3
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/examples/config.yaml.maximal +1 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/pyproject.toml +1 -1
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/scripts/healthcheck.sh +5 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/app.py +16 -9
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/config.py +24 -6
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/hass_formatter.py +16 -9
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/integrations/docker.py +54 -19
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/mqtt.py +19 -13
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_config.py +9 -3
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_docker.py +4 -4
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_hass_formatter.py +33 -16
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_mqtt.py +4 -4
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/uv.lock +1 -1
- updates2mqtt-1.4.2/docs/examples/docker_compose.md +0 -7
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.dockerignore +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/dependabot.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/auto_assign_issue.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/auto_assign_pr.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/docker-publish.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/main.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/pypi-publish.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/python-package.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.gitignore +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.hintrc +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.pre-commit-config.yaml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.python-version +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.safety-project.ini +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/Dockerfile +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/LICENSE +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/SECURITY.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/configuration.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_entities.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_mqtt_discovery.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_update_detail.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_update_dialog.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_update_page.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/logo-blank-256x256.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/updates2mqtt-dark-256x256.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/index.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/installation.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/local_builds.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/robots.txt +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/troubleshooting.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/examples/config.yaml.minimal +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/examples/docker-compose.yaml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/mkdocs.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/no_config.yaml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/refresh-deps.sh +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/__init__.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/__main__.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/integrations/__init__.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/integrations/git_utils.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/model.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/py.typed +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/__init__.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_app.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_git_utils.py +0 -0
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
- Target specific service on docker compose commands, where available from `com.docker.compose.service` label
|
|
5
|
+
- Log level in config is now an enum, and forced to be upper case
|
|
6
|
+
- Removed unnecessary latest_version fields from config message, which also saves a redundant MQTT subscription
|
|
7
|
+
- Publication of `command_topic` for each discovery can now be forced with `force_command_topic` option
|
|
8
|
+
- More common packages: docker:cli
|
|
9
|
+
- Common packages can now match on the image ref rather than base name, for example `docker:cli`
|
|
10
|
+
- Reduced log noise in INFO and increased logging detail for DEBUG
|
|
11
|
+
- Common Packages now allow entries without all the values, initially `rtl_433` which lacks a logo
|
|
3
12
|
## 1.4.2
|
|
4
13
|
- Replace `origin` in config MQTT message with `device` for better HomeAssistant compatibility
|
|
5
14
|
- An `area` can be defined in the Home Assistant section of config and this will then be used as `suggested_area` for device
|
|
6
|
-
- Icon and release note info added for Owntone and Homarr
|
|
15
|
+
- Icon and release note info added for Owntone, Nextcloud, n8n, and Homarr
|
|
16
|
+
- More testcases
|
|
7
17
|
## 1.4.1
|
|
8
18
|
- More logging for Docker discovery on why Home Assistant doesn't show an update button
|
|
9
19
|
- More test cases
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: updates2mqtt
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
{ align=left }
|
|
2
2
|
|
|
3
3
|
# updates2mqtt
|
|
4
4
|
|
|
@@ -54,15 +54,17 @@ Presently only Docker containers are supported, although others are planned, pro
|
|
|
54
54
|
|-----------|-------------|----------------------------------------------------------------------------------------------------|
|
|
55
55
|
| Docker | Scan. Fetch | Fetch is ``docker pull`` only. Restart support only for ``docker-compose`` image based containers. |
|
|
56
56
|
|
|
57
|
-
##
|
|
57
|
+
## Heartbeat
|
|
58
58
|
|
|
59
59
|
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.
|
|
60
60
|
|
|
61
|
+
## Healthcheck
|
|
62
|
+
|
|
61
63
|
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.
|
|
62
64
|
|
|
63
65
|
!!! tip
|
|
64
66
|
|
|
65
|
-
Check healthcheck is working using `docker inspect --format "{{json .State.Health }}" updates2mqtt | jq`
|
|
67
|
+
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)
|
|
66
68
|
|
|
67
69
|
Another approach is using a restarter service directly in Docker Compose to force a restart, in this case once a day:
|
|
68
70
|
|
|
@@ -3,7 +3,7 @@ common_packages:
|
|
|
3
3
|
docker:
|
|
4
4
|
image_name: ghcr.io/rhizomatics/updates2mqtt
|
|
5
5
|
logo_url: https://avatars.githubusercontent.com/u/162821163?s=48&v=4
|
|
6
|
-
release_notes_url: https://github.com/rhizomatics/updates2mqtt/
|
|
6
|
+
release_notes_url: https://github.com/rhizomatics/updates2mqtt/releases
|
|
7
7
|
|
|
8
8
|
frigate:
|
|
9
9
|
docker:
|
|
@@ -105,4 +105,15 @@ common_packages:
|
|
|
105
105
|
docker:
|
|
106
106
|
image_name: docker.n8n.io/n8nio/n8n
|
|
107
107
|
logo_url: https://avatars.githubusercontent.com/u/45487711?s=48&v=4
|
|
108
|
-
release_notes_url: https://github.com/n8n-io/n8n/releases
|
|
108
|
+
release_notes_url: https://github.com/n8n-io/n8n/releases
|
|
109
|
+
|
|
110
|
+
docker_cli:
|
|
111
|
+
docker:
|
|
112
|
+
image_name: docker:cli
|
|
113
|
+
logo_url: https://www.docker.com/app/uploads/2023/05/cli_1@2x.png
|
|
114
|
+
release_notes_url: https://github.com/docker-library/docker/commits/master/29/cli
|
|
115
|
+
|
|
116
|
+
rtl_433:
|
|
117
|
+
docker:
|
|
118
|
+
image_name: hertzg/rtl_433
|
|
119
|
+
release_notes_url: https://github.com/merbanan/rtl_433/releases
|
|
@@ -35,7 +35,7 @@ def app_with_mocked_external_dependencies(
|
|
|
35
35
|
|
|
36
36
|
@pytest.fixture
|
|
37
37
|
def mock_discoveries(mock_provider: ReleaseProvider) -> list[Discovery]:
|
|
38
|
-
return [Discovery(mock_provider, "thing-1", "test001")]
|
|
38
|
+
return [Discovery(mock_provider, "thing-1", "test001", can_update=True)]
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
@pytest.fixture
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Docker Compose
|
|
2
|
+
|
|
3
|
+
Example Docker Compose configuration to run *updates2mqtt* as a container. This also
|
|
4
|
+
requires a [.env](env.md), or alternatively adding the environment variables directly into the
|
|
5
|
+
compose file.
|
|
6
|
+
|
|
7
|
+
``` yaml
|
|
8
|
+
--8<-- "examples/docker-compose.yaml"
|
|
9
|
+
```
|
|
@@ -27,12 +27,22 @@ a `suggested_area` for the device.
|
|
|
27
27
|
|
|
28
28
|
There are 3 separate types of MQTT topic used for HomeAssisstant integration:
|
|
29
29
|
|
|
30
|
-
- *Config* to support auto discovery.
|
|
31
|
-
-
|
|
32
|
-
-
|
|
30
|
+
- *Config* to support auto discovery.
|
|
31
|
+
- A topic is created per component, with a name like `homeassistant/update/dockernuc_docker_jellyfin/update/config`.
|
|
32
|
+
- This can be disabled in the config file, and the `homeassistant` topic prefix can also be configured.
|
|
33
|
+
- *State* to report the current version and the latest version available
|
|
34
|
+
- One topic per component, like `updates2mqtt/dockernuc/docker/jellyfin`.
|
|
35
|
+
- *Command* to support triggering an update.
|
|
36
|
+
- These will be created on the fly by HomeAssistant when an update is requested
|
|
37
|
+
- Updates2mqtt subscribes to pick up the changes, so you won't typically see these if browsing MQTT topics.
|
|
38
|
+
- Only one is needed per updates2mqtt agent, with a name like `updates2mqtt/dockernuc/docker`
|
|
33
39
|
|
|
34
40
|
If the package supports automated update, then *Skip* and *Install* buttons will appear on the Home Assistant interface, and the package can be remotely fetched and the component restarted.
|
|
35
41
|
|
|
42
|
+
If it doesn't support automated update, the `command_topic` won't be published with the configuration
|
|
43
|
+
message, unless `force_command_topic` is set to `true` in the `homeassistant` configuration section,
|
|
44
|
+
this will force the Home Assistant app to show the update, but with a do-nothing Update button.
|
|
45
|
+
|
|
36
46
|
## More Home Assistant information
|
|
37
47
|
|
|
38
48
|
- [MQTT Integration](https://www.home-assistant.io/integrations/mqtt/)
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# MQTT Health Check Script
|
|
4
4
|
# Checks if the heartbeat timestamp in MQTT payload is recent enough
|
|
5
|
+
# Requires MQTT connection details to be available as environment variables
|
|
6
|
+
# - MQTT_HOST (defaults to localhost)
|
|
7
|
+
# - MQTT_PORT (defaults to 1883)
|
|
8
|
+
# - MQTT_USER (no default)
|
|
9
|
+
# - MQTT_PASS (no default)
|
|
5
10
|
|
|
6
11
|
# Arguments
|
|
7
12
|
MQTT_TOPIC=$1
|
|
@@ -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
|
|
|
@@ -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}"
|
|
@@ -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 = LogLevel.INFO
|
|
73
84
|
|
|
74
85
|
|
|
75
86
|
@dataclass
|
|
76
87
|
class Config:
|
|
77
88
|
log: LogConfig = field(default_factory=LogConfig)
|
|
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,15 +114,22 @@ 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
|
|
115
133
|
|
|
116
134
|
|
|
117
135
|
def load_app_config(conf_file_path: Path, return_invalid: bool = False) -> Config | None:
|
|
@@ -24,43 +24,50 @@ def hass_format_config(
|
|
|
24
24
|
node_name: str,
|
|
25
25
|
state_topic: str,
|
|
26
26
|
command_topic: str | None,
|
|
27
|
+
force_command_topic: bool | None,
|
|
27
28
|
device_creation: bool = True,
|
|
28
29
|
area: str | None = None,
|
|
29
30
|
session: str | None = None,
|
|
30
31
|
) -> dict[str, Any]:
|
|
32
|
+
if device_creation:
|
|
33
|
+
# avoid duplication, since Home Assistant will concatenate device and entity name on update
|
|
34
|
+
name: str = f"{discovery.name} {discovery.source_type}"
|
|
35
|
+
else:
|
|
36
|
+
name = f"{discovery.name} {discovery.source_type} on {node_name}"
|
|
31
37
|
config: dict[str, Any] = {
|
|
32
|
-
"name":
|
|
38
|
+
"name": name,
|
|
33
39
|
"device_class": None, # not firmware, so defaults to null
|
|
34
40
|
"unique_id": object_id,
|
|
35
41
|
"state_topic": state_topic,
|
|
36
42
|
"source_session": session,
|
|
37
43
|
"supported_features": discovery.features,
|
|
38
|
-
"entity_picture": discovery.entity_picture_url,
|
|
39
|
-
"icon": discovery.device_icon,
|
|
40
44
|
"can_update": discovery.can_update,
|
|
41
45
|
"can_build": discovery.can_build,
|
|
42
46
|
"can_restart": discovery.can_restart,
|
|
43
47
|
"update_policy": discovery.update_policy,
|
|
44
|
-
"latest_version_topic": state_topic,
|
|
45
|
-
"latest_version_template": "{{value_json.latest_version}}",
|
|
46
48
|
"origin": {
|
|
47
|
-
"name": f"{node_name} updates2mqtt
|
|
49
|
+
"name": f"{node_name} updates2mqtt",
|
|
48
50
|
"sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
|
|
49
51
|
"support_url": "https://github.com/rhizomatics/updates2mqtt/issues",
|
|
50
52
|
},
|
|
51
53
|
}
|
|
54
|
+
if discovery.entity_picture_url:
|
|
55
|
+
config["entity_picture"] = discovery.entity_picture_url
|
|
56
|
+
if discovery.device_icon:
|
|
57
|
+
config["icon"] = discovery.device_icon
|
|
52
58
|
if device_creation:
|
|
53
59
|
config["device"] = {
|
|
54
|
-
"name": f"{node_name} updates2mqtt
|
|
60
|
+
"name": f"{node_name} updates2mqtt",
|
|
55
61
|
"sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
|
|
56
62
|
"manufacturer": "rhizomatics",
|
|
57
63
|
"identifiers": [f"{node_name}.updates2mqtt"],
|
|
58
64
|
}
|
|
59
65
|
if area:
|
|
60
66
|
config["device"]["suggested_area"] = area
|
|
61
|
-
if command_topic:
|
|
67
|
+
if command_topic and (discovery.can_update or force_command_topic):
|
|
62
68
|
config["command_topic"] = command_topic
|
|
63
|
-
|
|
69
|
+
if discovery.can_update:
|
|
70
|
+
config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
|
|
64
71
|
if discovery.custom.get("git_repo_path"):
|
|
65
72
|
config["git_repo_path"] = discovery.custom["git_repo_path"]
|
|
66
73
|
config.update(discovery.provider.hass_config_format(discovery))
|
|
@@ -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,17 +87,27 @@ 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
|
-
logger.info(f"Executing compose {command} {args}")
|
|
104
|
+
logger.info(f"Executing compose {command} {args} {service}")
|
|
97
105
|
cmd: str = "docker-compose" if self.cfg.compose_version == "v1" else "docker compose"
|
|
98
106
|
cmd = cmd + " " + command.value
|
|
99
107
|
if args:
|
|
100
108
|
cmd = cmd + " " + args
|
|
109
|
+
if service:
|
|
110
|
+
cmd = cmd + " " + service
|
|
101
111
|
|
|
102
112
|
proc = subprocess.run(cmd, check=False, shell=True, cwd=cwd)
|
|
103
113
|
if proc.returncode == 0:
|
|
@@ -112,7 +122,10 @@ class DockerProvider(ReleaseProvider):
|
|
|
112
122
|
def restart(self, discovery: Discovery) -> bool:
|
|
113
123
|
logger = self.log.bind(container=discovery.name, action="restart")
|
|
114
124
|
compose_path = discovery.custom.get("compose_path")
|
|
115
|
-
|
|
125
|
+
compose_service: str | None = discovery.custom.get("compose_service")
|
|
126
|
+
return self.execute_compose(
|
|
127
|
+
command=DockerComposeCommand.UP, args="--detach --yes", service=compose_service, cwd=compose_path, logger=logger
|
|
128
|
+
)
|
|
116
129
|
|
|
117
130
|
def rescan(self, discovery: Discovery) -> Discovery | None:
|
|
118
131
|
logger = self.log.bind(container=discovery.name, action="rescan")
|
|
@@ -172,7 +185,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
172
185
|
logger.warn("RepoDigests=%s", image.attrs.get("RepoDigests"))
|
|
173
186
|
|
|
174
187
|
platform: str = "Unknown"
|
|
175
|
-
pkg_info: PackageUpdateInfo = self.default_metadata(image_name)
|
|
188
|
+
pkg_info: PackageUpdateInfo = self.default_metadata(image_name, image_ref=image_ref)
|
|
176
189
|
|
|
177
190
|
try:
|
|
178
191
|
picture_url = env_override("UPD2MQTT_PICTURE", pkg_info.logo_url)
|
|
@@ -197,6 +210,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
197
210
|
retries_left = 3
|
|
198
211
|
while reg_data is None and retries_left > 0 and not self.stopped.is_set():
|
|
199
212
|
try:
|
|
213
|
+
logger.debug("Fetching registry data", image_ref=image_ref)
|
|
200
214
|
reg_data = self.client.images.get_registry_data(image_ref)
|
|
201
215
|
latest_version = reg_data.short_id[7:] if reg_data else None
|
|
202
216
|
except docker.errors.APIError as e:
|
|
@@ -221,6 +235,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
221
235
|
custom["image_ref"] = image_ref
|
|
222
236
|
save_if_set("compose_path", c.labels.get("com.docker.compose.project.working_dir"))
|
|
223
237
|
save_if_set("compose_version", c.labels.get("com.docker.compose.version"))
|
|
238
|
+
save_if_set("compose_service", c.labels.get("com.docker.compose.service"))
|
|
224
239
|
save_if_set("git_repo_path", c_env.get("UPD2MQTT_GIT_REPO_PATH"))
|
|
225
240
|
save_if_set("apt_pkgs", c_env.get("UPD2MQTT_APT_PKGS"))
|
|
226
241
|
|
|
@@ -262,6 +277,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
262
277
|
features.append("RELEASE_NOTES")
|
|
263
278
|
custom["can_pull"] = can_pull
|
|
264
279
|
|
|
280
|
+
logger.debug("Analyze generated discovery", discovery_name=c.name, current_version=local_version)
|
|
265
281
|
return Discovery(
|
|
266
282
|
self,
|
|
267
283
|
c.name,
|
|
@@ -283,21 +299,27 @@ class DockerProvider(ReleaseProvider):
|
|
|
283
299
|
)
|
|
284
300
|
except Exception:
|
|
285
301
|
logger.exception("Docker Discovery Failure", container_attrs=c.attrs)
|
|
302
|
+
logger.debug("Analyze returned empty discovery")
|
|
286
303
|
return None
|
|
287
304
|
|
|
288
305
|
async def scan(self, session: str) -> AsyncGenerator[Discovery]:
|
|
289
|
-
logger = self.log.bind(session=session, action="scan")
|
|
306
|
+
logger = self.log.bind(session=session, action="scan", source=self.source_type)
|
|
290
307
|
containers = results = 0
|
|
308
|
+
logger.debug("Starting container scan loop")
|
|
291
309
|
for c in self.client.containers.list():
|
|
310
|
+
logger.debug("Analyzing container", container=c.name)
|
|
292
311
|
if self.stopped.is_set():
|
|
293
312
|
logger.info(f"Shutdown detected, aborting scan at {c}")
|
|
294
313
|
break
|
|
295
314
|
containers = containers + 1
|
|
296
|
-
result = self.analyze(c, session)
|
|
315
|
+
result: Discovery | None = self.analyze(c, session)
|
|
297
316
|
if result:
|
|
317
|
+
logger.debug("Analyzed container", result_name=result.name, custom=result.custom)
|
|
298
318
|
self.discoveries[result.name] = result
|
|
299
319
|
results = results + 1
|
|
300
320
|
yield result
|
|
321
|
+
else:
|
|
322
|
+
logger.debug("No result from analysis", container=c.name)
|
|
301
323
|
logger.info("Completed", container_count=containers, result_count=results)
|
|
302
324
|
|
|
303
325
|
def command(self, discovery_name: str, command: str, on_update_start: Callable, on_update_end: Callable) -> bool:
|
|
@@ -348,27 +370,40 @@ class DockerProvider(ReleaseProvider):
|
|
|
348
370
|
# "platform": discovery.custom.get("platform"),
|
|
349
371
|
}
|
|
350
372
|
|
|
351
|
-
def default_metadata(self, image_name: str | None) -> PackageUpdateInfo:
|
|
352
|
-
|
|
353
|
-
|
|
373
|
+
def default_metadata(self, image_name: str | None, image_ref: str | None) -> PackageUpdateInfo:
|
|
374
|
+
def match(pkg: PackageUpdateInfo) -> bool:
|
|
375
|
+
if pkg is not None and pkg.docker is not None and pkg.docker.image_name is not None:
|
|
376
|
+
if image_name is not None and image_name == pkg.docker.image_name:
|
|
377
|
+
return True
|
|
378
|
+
if image_ref is not None and image_ref == pkg.docker.image_name:
|
|
379
|
+
return True
|
|
380
|
+
return False
|
|
354
381
|
|
|
355
|
-
if image_name is not None:
|
|
382
|
+
if image_name is not None and image_ref is not None:
|
|
356
383
|
for pkg in self.common_pkgs.values():
|
|
357
|
-
if pkg
|
|
384
|
+
if match(pkg):
|
|
358
385
|
self.log.debug(
|
|
359
|
-
"Found common package",
|
|
386
|
+
"Found common package",
|
|
387
|
+
image_name=pkg.docker.image_name, # type: ignore [union-attr]
|
|
388
|
+
logo_url=pkg.logo_url,
|
|
389
|
+
relnotes_url=pkg.release_notes_url,
|
|
360
390
|
)
|
|
361
391
|
return pkg
|
|
362
392
|
for pkg in self.discovered_pkgs.values():
|
|
363
|
-
if pkg
|
|
393
|
+
if match(pkg):
|
|
364
394
|
self.log.debug(
|
|
365
|
-
"Found discovered package",
|
|
395
|
+
"Found discovered package",
|
|
396
|
+
pkg=pkg.docker.image_name, # type: ignore [union-attr]
|
|
397
|
+
logo_url=pkg.logo_url,
|
|
398
|
+
relnotes_url=pkg.release_notes_url,
|
|
366
399
|
)
|
|
367
400
|
return pkg
|
|
368
401
|
|
|
369
402
|
self.log.debug("No common or discovered package found", image_name=image_name)
|
|
370
403
|
return PackageUpdateInfo(
|
|
371
|
-
DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE),
|
|
404
|
+
DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE),
|
|
405
|
+
logo_url=self.cfg.default_entity_picture_url,
|
|
406
|
+
release_notes_url=None,
|
|
372
407
|
)
|
|
373
408
|
|
|
374
409
|
def discover_metadata(self) -> dict[str, PackageUpdateInfo]:
|
|
@@ -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")
|
|
@@ -311,7 +317,6 @@ class MqttPublisher:
|
|
|
311
317
|
|
|
312
318
|
def publish_hass_config(self, discovery: Discovery) -> None:
|
|
313
319
|
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
320
|
self.publish(
|
|
316
321
|
self.config_topic(discovery),
|
|
317
322
|
hass_format_config(
|
|
@@ -320,7 +325,8 @@ class MqttPublisher:
|
|
|
320
325
|
node_name=self.node_cfg.name,
|
|
321
326
|
area=self.hass_cfg.area,
|
|
322
327
|
state_topic=self.state_topic(discovery),
|
|
323
|
-
command_topic=command_topic,
|
|
328
|
+
command_topic=self.command_topic(discovery.provider),
|
|
329
|
+
force_command_topic=self.hass_cfg.force_command_topic,
|
|
324
330
|
device_creation=self.hass_cfg.device_creation,
|
|
325
331
|
session=discovery.session,
|
|
326
332
|
),
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
import pytest
|
|
6
6
|
from omegaconf import OmegaConf
|
|
7
7
|
|
|
8
|
-
from updates2mqtt.config import MqttConfig, load_app_config, load_package_info
|
|
8
|
+
from updates2mqtt.config import MqttConfig, PackageUpdateInfo, load_app_config, load_package_info
|
|
9
9
|
|
|
10
10
|
EXAMPLES_ROOT = "examples"
|
|
11
11
|
examples = [str(p.name) for p in Path(EXAMPLES_ROOT).iterdir() if p.name.startswith("config")]
|
|
@@ -58,6 +58,12 @@ def test_round_trip_config() -> None:
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def test_package_config() -> None:
|
|
61
|
-
validated_pkg_info = load_package_info(Path("common_packages.yaml"))
|
|
61
|
+
validated_pkg_info: dict[str, PackageUpdateInfo] = load_package_info(Path("common_packages.yaml"))
|
|
62
62
|
assert validated_pkg_info is not None
|
|
63
|
-
assert len(validated_pkg_info
|
|
63
|
+
assert len(validated_pkg_info) > 0
|
|
64
|
+
for pkg_name, pkg in validated_pkg_info.items():
|
|
65
|
+
assert pkg_name
|
|
66
|
+
assert pkg.docker is not None
|
|
67
|
+
assert pkg.docker.image_name
|
|
68
|
+
assert pkg.logo_url or pkg.logo_url is None
|
|
69
|
+
assert pkg.release_notes_url or pkg.release_notes_url is None
|
|
@@ -13,7 +13,7 @@ from updates2mqtt.model import Discovery
|
|
|
13
13
|
|
|
14
14
|
async def test_scanner(mock_docker_client: DockerClient) -> None:
|
|
15
15
|
with patch("docker.from_env", return_value=mock_docker_client):
|
|
16
|
-
uut = mut.DockerProvider(mut.DockerConfig(discover_metadata={}),
|
|
16
|
+
uut = mut.DockerProvider(mut.DockerConfig(discover_metadata={}), {}, mut.NodeConfig())
|
|
17
17
|
session = "unit_123"
|
|
18
18
|
results: list[Discovery] = [d async for d in uut.scan(session)]
|
|
19
19
|
|
|
@@ -28,7 +28,7 @@ async def test_scanner(mock_docker_client: DockerClient) -> None:
|
|
|
28
28
|
|
|
29
29
|
async def test_common_packages(mock_docker_client: DockerClient) -> None:
|
|
30
30
|
with patch("docker.from_env", return_value=mock_docker_client):
|
|
31
|
-
uut = mut.DockerProvider(mut.DockerConfig(discover_metadata={}),
|
|
31
|
+
uut = mut.DockerProvider(mut.DockerConfig(discover_metadata={}), {}, mut.NodeConfig())
|
|
32
32
|
uut.common_pkgs = {
|
|
33
33
|
"common_pkg": mut.PackageUpdateInfo(
|
|
34
34
|
docker=DockerPackageUpdateInfo(image_name="common/pkg"),
|
|
@@ -65,7 +65,7 @@ def test_discover_metadata(httpx_mock: HTTPXMock, mock_docker_client: DockerClie
|
|
|
65
65
|
with patch("docker.from_env", return_value=mock_docker_client):
|
|
66
66
|
uut = mut.DockerProvider(
|
|
67
67
|
mut.DockerConfig(discover_metadata={"linuxserver.io": MetadataSourceConfig(enabled=True, cache_ttl=0)}),
|
|
68
|
-
|
|
68
|
+
{},
|
|
69
69
|
mut.NodeConfig(),
|
|
70
70
|
)
|
|
71
71
|
uut.discover_metadata()
|
|
@@ -79,7 +79,7 @@ def test_discover_metadata(httpx_mock: HTTPXMock, mock_docker_client: DockerClie
|
|
|
79
79
|
|
|
80
80
|
def test_build(mock_docker_client: DockerClient, fake_process: FakeProcess, tmpdir: Path) -> None:
|
|
81
81
|
with patch("docker.from_env", return_value=mock_docker_client):
|
|
82
|
-
uut = mut.DockerProvider(mut.DockerConfig(discover_metadata={}),
|
|
82
|
+
uut = mut.DockerProvider(mut.DockerConfig(discover_metadata={}), {}, mut.NodeConfig())
|
|
83
83
|
d = Discovery(uut, "build-test-dummy", session="test-123")
|
|
84
84
|
fake_process.register("docker compose build", returncode=0)
|
|
85
85
|
assert uut.build(d, str(tmpdir))
|
|
@@ -5,33 +5,38 @@ from updates2mqtt.hass_formatter import hass_format_config
|
|
|
5
5
|
|
|
6
6
|
def test_formatter_includes_device(mock_discoveries: list[Discovery], monkeypatch) -> None: # noqa: ANN001
|
|
7
7
|
monkeypatch.setattr(updates2mqtt, "version", "3.0.0")
|
|
8
|
-
msg = hass_format_config(
|
|
8
|
+
msg = hass_format_config(
|
|
9
|
+
mock_discoveries[0],
|
|
10
|
+
"obj001",
|
|
11
|
+
"testbed01",
|
|
12
|
+
"state_topic_1",
|
|
13
|
+
"command_topic_1",
|
|
14
|
+
force_command_topic=False,
|
|
15
|
+
device_creation=True,
|
|
16
|
+
area="Basement",
|
|
17
|
+
)
|
|
9
18
|
assert msg == {
|
|
10
|
-
"name": "thing-1 unit_test
|
|
19
|
+
"name": "thing-1 unit_test",
|
|
11
20
|
"unique_id": "obj001",
|
|
12
21
|
"update_policy": None,
|
|
13
22
|
"can_build": False,
|
|
14
23
|
"can_restart": False,
|
|
15
|
-
"can_update":
|
|
24
|
+
"can_update": True,
|
|
16
25
|
"command_topic": "command_topic_1",
|
|
17
26
|
"state_topic": "state_topic_1",
|
|
18
27
|
"device_class": None,
|
|
19
|
-
"entity_picture": None,
|
|
20
|
-
"icon": None,
|
|
21
|
-
"latest_version_template": "{{value_json.latest_version}}",
|
|
22
|
-
"latest_version_topic": "state_topic_1",
|
|
23
28
|
"payload_install": "unit_test|thing-1|install",
|
|
24
29
|
"source_session": None,
|
|
25
30
|
"supported_features": [],
|
|
26
31
|
"origin": {
|
|
27
|
-
"name": "testbed01 updates2mqtt
|
|
32
|
+
"name": "testbed01 updates2mqtt",
|
|
28
33
|
"sw_version": "3.0.0",
|
|
29
34
|
"support_url": "https://github.com/rhizomatics/updates2mqtt/issues",
|
|
30
35
|
},
|
|
31
36
|
"device": {
|
|
32
37
|
"identifiers": ["testbed01.updates2mqtt"],
|
|
33
38
|
"manufacturer": "rhizomatics",
|
|
34
|
-
"name": "testbed01 updates2mqtt
|
|
39
|
+
"name": "testbed01 updates2mqtt",
|
|
35
40
|
"suggested_area": "Basement",
|
|
36
41
|
"sw_version": "3.0.0",
|
|
37
42
|
},
|
|
@@ -41,7 +46,7 @@ def test_formatter_includes_device(mock_discoveries: list[Discovery], monkeypatc
|
|
|
41
46
|
def test_formatter_excludes_device(mock_discoveries: list[Discovery], monkeypatch) -> None: # noqa: ANN001
|
|
42
47
|
monkeypatch.setattr(updates2mqtt, "version", "3.0.0")
|
|
43
48
|
msg = hass_format_config(
|
|
44
|
-
mock_discoveries[0], "obj001", "testbed01", "state_topic_1", "command_topic_1", device_creation=False
|
|
49
|
+
mock_discoveries[0], "obj001", "testbed01", "state_topic_1", "command_topic_1", True, device_creation=False
|
|
45
50
|
)
|
|
46
51
|
assert msg == {
|
|
47
52
|
"name": "thing-1 unit_test on testbed01",
|
|
@@ -49,20 +54,32 @@ def test_formatter_excludes_device(mock_discoveries: list[Discovery], monkeypatc
|
|
|
49
54
|
"update_policy": None,
|
|
50
55
|
"can_build": False,
|
|
51
56
|
"can_restart": False,
|
|
52
|
-
"can_update":
|
|
57
|
+
"can_update": True,
|
|
53
58
|
"command_topic": "command_topic_1",
|
|
54
59
|
"state_topic": "state_topic_1",
|
|
55
60
|
"device_class": None,
|
|
56
|
-
"entity_picture": None,
|
|
57
|
-
"icon": None,
|
|
58
|
-
"latest_version_template": "{{value_json.latest_version}}",
|
|
59
|
-
"latest_version_topic": "state_topic_1",
|
|
60
61
|
"payload_install": "unit_test|thing-1|install",
|
|
61
62
|
"source_session": None,
|
|
62
63
|
"supported_features": [],
|
|
63
64
|
"origin": {
|
|
64
|
-
"name": "testbed01 updates2mqtt
|
|
65
|
+
"name": "testbed01 updates2mqtt",
|
|
65
66
|
"sw_version": "3.0.0",
|
|
66
67
|
"support_url": "https://github.com/rhizomatics/updates2mqtt/issues",
|
|
67
68
|
},
|
|
68
69
|
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_formatter_forces_command_topic(mock_discoveries: list[Discovery]) -> None:
|
|
73
|
+
discovery = mock_discoveries[0]
|
|
74
|
+
discovery.can_update = False
|
|
75
|
+
msg = hass_format_config(discovery, "obj001", "testbed01", "state_topic_1", "command_topic_1", True)
|
|
76
|
+
assert msg["command_topic"] == "command_topic_1"
|
|
77
|
+
assert "payload_install" not in msg
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_formatter_no_update_suppresses_command_topic(mock_discoveries: list[Discovery]) -> None:
|
|
81
|
+
discovery = mock_discoveries[0]
|
|
82
|
+
discovery.can_update = False
|
|
83
|
+
msg = hass_format_config(discovery, "obj001", "testbed01", "state_topic_1", "command_topic_1", False)
|
|
84
|
+
assert "command_topic" not in msg
|
|
85
|
+
assert "payload_install" not in msg
|
|
@@ -64,14 +64,14 @@ async def test_execute_command_remote(mock_mqtt_client: Mock, mock_provider: Rel
|
|
|
64
64
|
|
|
65
65
|
with patch.object(paho.mqtt.client.Client, "__new__", lambda *_args, **_kwargs: mock_mqtt_client):
|
|
66
66
|
uut = MqttPublisher(config, node_config, hass_config)
|
|
67
|
+
uut.providers_by_topic = {}
|
|
67
68
|
uut.start(event_loop=asyncio.get_running_loop())
|
|
68
69
|
|
|
69
70
|
uut.subscribe_hass_command(mock_provider)
|
|
70
|
-
dummy_callable = lambda: None # noqa: E731
|
|
71
71
|
|
|
72
72
|
mqtt_bytes_msg = MQTTMessage(topic=b"updates2mqtt/TESTBED/unit_test")
|
|
73
73
|
mqtt_bytes_msg.payload = b"unit_test|fooey|install"
|
|
74
|
-
await uut.execute_command(mqtt_bytes_msg,
|
|
74
|
+
await uut.execute_command(mqtt_bytes_msg, Mock(), Mock())
|
|
75
75
|
|
|
76
76
|
mock_mqtt_client.publish.assert_called_with(
|
|
77
77
|
"updates2mqtt/TESTBED/unit_test/fooey",
|
|
@@ -80,7 +80,7 @@ async def test_execute_command_remote(mock_mqtt_client: Mock, mock_provider: Rel
|
|
|
80
80
|
"installed_version": "v2",
|
|
81
81
|
"latest_version": "v2",
|
|
82
82
|
"title": "Update for fooey on TESTBED",
|
|
83
|
-
"in_progress":
|
|
83
|
+
"in_progress": False,
|
|
84
84
|
}
|
|
85
85
|
),
|
|
86
86
|
qos=0,
|
|
@@ -112,7 +112,7 @@ async def test_execute_command_local(mock_mqtt_client: Mock, mock_provider: Rele
|
|
|
112
112
|
"installed_version": "v2",
|
|
113
113
|
"latest_version": "v2",
|
|
114
114
|
"title": "Update for fooey on TESTBED",
|
|
115
|
-
"in_progress":
|
|
115
|
+
"in_progress": False,
|
|
116
116
|
}
|
|
117
117
|
),
|
|
118
118
|
qos=0,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|