updates2mqtt 1.4.2__tar.gz → 1.5.1__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.1}/CHANGELOG.md +17 -1
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/PKG-INFO +6 -4
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/README.md +5 -3
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/common_packages.yaml +13 -2
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/conftest.py +2 -2
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/configuration.md +22 -2
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/examples/config_maximal.md +1 -1
- updates2mqtt-1.5.1/docs/examples/config_minimal.md +7 -0
- updates2mqtt-1.5.1/docs/examples/docker_compose.md +9 -0
- updates2mqtt-1.5.1/docs/examples/env.md +15 -0
- updates2mqtt-1.5.1/docs/examples/index.md +5 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/home_assistant.md +13 -3
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/installation.md +1 -1
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/examples/config.yaml.maximal +1 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/pyproject.toml +1 -1
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/scripts/healthcheck.sh +5 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/app.py +16 -9
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/config.py +36 -11
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/hass_formatter.py +14 -16
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/integrations/docker.py +67 -21
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/model.py +11 -1
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/mqtt.py +19 -15
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_config.py +39 -3
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_docker.py +5 -5
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_hass_formatter.py +33 -19
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_mqtt.py +24 -20
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/uv.lock +64 -64
- updates2mqtt-1.4.2/docs/examples/config_minimal.md +0 -7
- updates2mqtt-1.4.2/docs/examples/docker_compose.md +0 -7
- updates2mqtt-1.4.2/no_config.yaml +0 -33
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.dockerignore +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/dependabot.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/auto_assign_issue.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/auto_assign_pr.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/docker-publish.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/main.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/pypi-publish.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/python-package.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.gitignore +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.hintrc +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.pre-commit-config.yaml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.python-version +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.safety-project.ini +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/Dockerfile +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/LICENSE +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/SECURITY.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_entities.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_mqtt_discovery.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_update_detail.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_update_dialog.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_update_page.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/logo-blank-256x256.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/updates2mqtt-dark-256x256.png +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/index.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/local_builds.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/robots.txt +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/troubleshooting.md +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/examples/config.yaml.minimal +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/examples/docker-compose.yaml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/mkdocs.yml +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/refresh-deps.sh +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/__init__.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/__main__.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/integrations/__init__.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/integrations/git_utils.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/py.typed +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/__init__.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_app.py +0 -0
- {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_git_utils.py +0 -0
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 1.5.1
|
|
4
|
+
- `MQTT_VERSION` environment variable added, defaults to `3.11`
|
|
5
|
+
- `U2M_AUTOGEN_CONFIG` environment variable added to control auto-generation of config files and directories
|
|
6
|
+
- `U2M_LOG_LEVEL` environment variable added to set log level without config file
|
|
7
|
+
- Title generation for Docker images reverts to same whether HA device set or not
|
|
8
|
+
- Test added to ensure component always functions without a config file, if no env var present
|
|
9
|
+
## 1.5.0
|
|
10
|
+
- Target specific service on docker compose commands, where available from `com.docker.compose.service` label
|
|
11
|
+
- Log level in config is now an enum, and forced to be upper case
|
|
12
|
+
- Removed unnecessary latest_version fields from config message, which also saves a redundant MQTT subscription
|
|
13
|
+
- Publication of `command_topic` for each discovery can now be forced with `force_command_topic` option
|
|
14
|
+
- More common packages: docker:cli
|
|
15
|
+
- Common packages can now match on the image ref rather than base name, for example `docker:cli`
|
|
16
|
+
- Reduced log noise in INFO and increased logging detail for DEBUG
|
|
17
|
+
- Common Packages now allow entries without all the values, initially `rtl_433` which lacks a logo
|
|
3
18
|
## 1.4.2
|
|
4
19
|
- Replace `origin` in config MQTT message with `device` for better HomeAssistant compatibility
|
|
5
20
|
- 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
|
|
21
|
+
- Icon and release note info added for Owntone, Nextcloud, n8n, and Homarr
|
|
22
|
+
- More testcases
|
|
7
23
|
## 1.4.1
|
|
8
24
|
- More logging for Docker discovery on why Home Assistant doesn't show an update button
|
|
9
25
|
- More test cases
|
|
@@ -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
|
|
|
@@ -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", "testbed01", can_update=True, update_type="TestRun")]
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
@pytest.fixture
|
|
@@ -71,7 +71,7 @@ def mock_provider() -> ReleaseProvider:
|
|
|
71
71
|
provider.source_type = "unit_test"
|
|
72
72
|
provider.command.return_value = True # type: ignore[attr-defined]
|
|
73
73
|
provider.resolve.return_value = Discovery( # type: ignore[attr-defined]
|
|
74
|
-
provider, "fooey", session="test-mqtt-123", current_version="v2", latest_version="v2"
|
|
74
|
+
provider, "fooey", session="test-mqtt-123", node="node002", current_version="v2", latest_version="v2"
|
|
75
75
|
)
|
|
76
76
|
provider.hass_state_format.return_value = {"fixture": "test_exec"} # type: ignore[attr-defined]
|
|
77
77
|
return provider
|
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
# Configuration
|
|
1
|
+
# Minimal Configuration
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The core configuration can be supplied by environment variables, everything else will default, either to fixed values built into Updates2MQTT, or in the case of the node name, taken from the operating system.
|
|
4
|
+
|
|
5
|
+
| Env Var | Default |
|
|
6
|
+
|---------------|--------------|
|
|
7
|
+
| MQTT_HOST | localhost |
|
|
8
|
+
| MQTT_PORT | 1883 |
|
|
9
|
+
| MQTT_USER | *NO DEFAULT* |
|
|
10
|
+
| MQTT_PASSWORD | *NO DEFAULT* |
|
|
11
|
+
| MQTT_VERSION | 3.11. |
|
|
12
|
+
| U2M_LOG_LEVEL | INFO |
|
|
13
|
+
|
|
14
|
+
Startup will fail if `MQTT_USER` and `MQTT_PASSWORD` are not defined some how.
|
|
15
|
+
|
|
16
|
+
The example [docker-compose.yaml](docker_compose.md) and [.env](env.md) demonstrate one way of doing this, or skip
|
|
17
|
+
the `.env` file and use an `environment` section in the Compose file.
|
|
18
|
+
|
|
19
|
+
# Configuration File
|
|
20
|
+
|
|
21
|
+
The configuration file is optional, and only needed if you have to override the defaults.
|
|
22
|
+
|
|
23
|
+
Create file `config.yaml` in `conf` directory. If the file is not present, a default file will be generated, and the parent director if necessary. If you don't want that to happen, then set `U2M_AUTOGEN_CONFIG=0`.
|
|
4
24
|
|
|
5
25
|
### Example configuration file
|
|
6
26
|
|
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Environment File
|
|
2
|
+
|
|
3
|
+
Example `.env` file for Docker Compose configuration to separately store the environment variables.
|
|
4
|
+
|
|
5
|
+
``` bash
|
|
6
|
+
# Example env file for docker-compose.yaml
|
|
7
|
+
# Used by both the app itself and the healthcheck script
|
|
8
|
+
|
|
9
|
+
# To use these, add to config.yaml like ${oc.env:MQTT_HOST}
|
|
10
|
+
|
|
11
|
+
MQTT_HOST=192.168.0.1
|
|
12
|
+
MQTT_PORT=1883
|
|
13
|
+
MQTT_USER=my_mqtt_user
|
|
14
|
+
MQTT_PASS=my_mqtt_secret
|
|
15
|
+
```
|
|
@@ -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/)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
updates2mqtt prefers to be run inside a Docker container, though can run standalone, for example scripted via cron or systemd.
|
|
6
6
|
|
|
7
|
-
The only mandatory configuration is the MQTT broker host, user name and password, which can be set by environment
|
|
7
|
+
The only mandatory configuration is the MQTT broker host, user name and password, which can be set by environment variables, or the config file. The node name will be taken from the operating system if there's no config file. See [Configuration](configuration.md) for details.
|
|
8
8
|
|
|
9
9
|
### Docker
|
|
10
10
|
|
|
@@ -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}"
|
|
@@ -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:
|
|
@@ -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:
|