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.
Files changed (69) hide show
  1. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/CHANGELOG.md +17 -1
  2. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/PKG-INFO +6 -4
  3. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/README.md +5 -3
  4. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/common_packages.yaml +13 -2
  5. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/conftest.py +2 -2
  6. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/configuration.md +22 -2
  7. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/examples/config_maximal.md +1 -1
  8. updates2mqtt-1.5.1/docs/examples/config_minimal.md +7 -0
  9. updates2mqtt-1.5.1/docs/examples/docker_compose.md +9 -0
  10. updates2mqtt-1.5.1/docs/examples/env.md +15 -0
  11. updates2mqtt-1.5.1/docs/examples/index.md +5 -0
  12. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/home_assistant.md +13 -3
  13. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/installation.md +1 -1
  14. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/examples/config.yaml.maximal +1 -0
  15. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/pyproject.toml +1 -1
  16. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/scripts/healthcheck.sh +5 -0
  17. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/app.py +16 -9
  18. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/config.py +36 -11
  19. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/hass_formatter.py +14 -16
  20. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/integrations/docker.py +67 -21
  21. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/model.py +11 -1
  22. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/mqtt.py +19 -15
  23. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_config.py +39 -3
  24. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_docker.py +5 -5
  25. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_hass_formatter.py +33 -19
  26. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_mqtt.py +24 -20
  27. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/uv.lock +64 -64
  28. updates2mqtt-1.4.2/docs/examples/config_minimal.md +0 -7
  29. updates2mqtt-1.4.2/docs/examples/docker_compose.md +0 -7
  30. updates2mqtt-1.4.2/no_config.yaml +0 -33
  31. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.dockerignore +0 -0
  32. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/dependabot.yml +0 -0
  33. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/auto_assign_issue.yml +0 -0
  34. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/auto_assign_pr.yml +0 -0
  35. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/docker-publish.yml +0 -0
  36. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/main.yml +0 -0
  37. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/pypi-publish.yml +0 -0
  38. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.github/workflows/python-package.yml +0 -0
  39. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.gitignore +0 -0
  40. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.hintrc +0 -0
  41. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.pre-commit-config.yaml +0 -0
  42. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.python-version +0 -0
  43. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/.safety-project.ini +0 -0
  44. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/Dockerfile +0 -0
  45. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/LICENSE +0 -0
  46. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/SECURITY.md +0 -0
  47. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_entities.png +0 -0
  48. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_mqtt_discovery.png +0 -0
  49. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_update_detail.png +0 -0
  50. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_update_dialog.png +0 -0
  51. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/ha_update_page.png +0 -0
  52. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/logo-blank-256x256.png +0 -0
  53. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/images/updates2mqtt-dark-256x256.png +0 -0
  54. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/index.md +0 -0
  55. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/local_builds.md +0 -0
  56. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/robots.txt +0 -0
  57. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/docs/troubleshooting.md +0 -0
  58. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/examples/config.yaml.minimal +0 -0
  59. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/examples/docker-compose.yaml +0 -0
  60. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/mkdocs.yml +0 -0
  61. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/refresh-deps.sh +0 -0
  62. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/__init__.py +0 -0
  63. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/__main__.py +0 -0
  64. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/integrations/__init__.py +0 -0
  65. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/integrations/git_utils.py +0 -0
  66. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/src/updates2mqtt/py.typed +0 -0
  67. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/__init__.py +0 -0
  68. {updates2mqtt-1.4.2 → updates2mqtt-1.5.1}/tests/test_app.py +0 -0
  69. {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.4.2
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
- ![updates2mqtt](/images/updates2mqtt-dark-256x256.png){ align=left }
38
+ ![updates2mqtt](images/updates2mqtt-dark-256x256.png){ 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
- ## Healthcheck
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
- ![updates2mqtt](/images/updates2mqtt-dark-256x256.png){ align=left }
1
+ ![updates2mqtt](images/updates2mqtt-dark-256x256.png){ 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
- ## Healthcheck
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/pkgs/container/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
- Create file `config.yaml` in `conf` directory. If the file is not present, a default file will be generated.
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
 
@@ -3,5 +3,5 @@
3
3
  More extensive example configuration, using all the features
4
4
 
5
5
  ``` yaml
6
- --8<-- "examples/config.yaml.minimal"
6
+ --8<-- "examples/config.yaml.maximal"
7
7
  ```
@@ -0,0 +1,7 @@
1
+ # Minimal Configuration
2
+
3
+ Smallest working configuration ( although the actual smallest is no file at all and using only environment variables for MQTT )
4
+
5
+ ``` yaml
6
+ --8<-- "examples/config.yaml.minimal"
7
+ ```
@@ -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
+ ```
@@ -0,0 +1,5 @@
1
+ # Example Configurations
2
+
3
+ Configuration files and Docker Compose files.
4
+
5
+ {{ pagetree(siblings) }}
@@ -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. A topic is created per component, with a name like `homeassistant/update/dockernuc_docker_jellyfin/update/config`. This can be disabled in the config file, and the `homeassistant` topic prefix can also be configured.
31
- - *State* to report the current version and the latest version available, again one topic per component, like `updates2mqtt/dockernuc/docker/jellyfin`.
32
- - *Command* to support triggering an update. These will be created on the fly by HomeAssistant when an update is requested, and updates2mqtt subscribes to pick up the changes, so you won't typically see these if browsing MQTT topics. Only one is needed per updates2mqtt agent, with a name like `updates2mqtt/dockernuc/docker`
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 variable, or the config file. See [Configuration](configuration.md).
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
 
@@ -21,6 +21,7 @@ homeassistant:
21
21
  state_topic_suffix: state
22
22
  area: Server Room
23
23
  device_creation: true
24
+ force_command_topic: false
24
25
  docker:
25
26
  enabled: true
26
27
  allow_pull: true
@@ -7,7 +7,7 @@ authors = [
7
7
  ]
8
8
 
9
9
  requires-python = ">=3.13"
10
- version = "1.4.2"
10
+ version = "1.5.1"
11
11
  license="Apache-2.0"
12
12
  keywords=["mqtt", "docker", "updates", "automation","home-assistant","homeassistant","selfhosting"]
13
13
 
@@ -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.info("Cleaning topics before scan", source_type=scanner.source_type)
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
- log.info("Scanning", source=scanner.source_type, session=session)
77
+ slog.info("Scanning ...")
76
78
  async with asyncio.TaskGroup() as tg:
77
- async for discovery in scanner.scan(session): # xtype: ignore[attr-defined]
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
- log.info("Scan complete", source_type=scanner.source_type)
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": datetime.now(UTC).isoformat(),
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
- device_icon: str = "mdi:docker" # Icon to show when browsing entities in Home Assistant
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: str = "INFO"
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[reportCallIssue, reportArgumentType]
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) -> UpdateInfoConfig:
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
- OmegaConf.set_readonly(cfg, True)
114
- return typing.cast("UpdateInfoConfig", cfg)
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
- else:
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.exception("Unable to create config directory", path=conf_file_path.parent)
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.exception("Unable to write config file", path=conf_file_path)
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": f"{discovery.name} {discovery.source_type} on {node_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"{node_name} updates2mqtt Agent",
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"{node_name} updates2mqtt Agent",
54
+ "name": f"{discovery.node} updates2mqtt",
55
55
  "sw_version": updates2mqtt.version, # pyright: ignore[reportAttributeAccessIssue]
56
56
  "manufacturer": "rhizomatics",
57
- "identifiers": [f"{node_name}.updates2mqtt"],
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
- config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
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, node_name: str, session: str, in_progress: bool = False) -> dict[str, Any]: # noqa: ARG001
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: