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.
Files changed (68) hide show
  1. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/CHANGELOG.md +11 -1
  2. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/PKG-INFO +6 -4
  3. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/README.md +5 -3
  4. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/common_packages.yaml +13 -2
  5. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/conftest.py +1 -1
  6. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/examples/config_maximal.md +1 -1
  7. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/examples/config_minimal.md +1 -1
  8. updates2mqtt-1.5.0/docs/examples/docker_compose.md +9 -0
  9. updates2mqtt-1.5.0/docs/examples/env.md +7 -0
  10. updates2mqtt-1.5.0/docs/examples/index.md +5 -0
  11. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/home_assistant.md +13 -3
  12. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/examples/config.yaml.maximal +1 -0
  13. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/pyproject.toml +1 -1
  14. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/scripts/healthcheck.sh +5 -0
  15. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/app.py +16 -9
  16. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/config.py +24 -6
  17. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/hass_formatter.py +16 -9
  18. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/integrations/docker.py +54 -19
  19. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/mqtt.py +19 -13
  20. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_config.py +9 -3
  21. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_docker.py +4 -4
  22. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_hass_formatter.py +33 -16
  23. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_mqtt.py +4 -4
  24. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/uv.lock +1 -1
  25. updates2mqtt-1.4.2/docs/examples/docker_compose.md +0 -7
  26. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.dockerignore +0 -0
  27. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/dependabot.yml +0 -0
  28. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/auto_assign_issue.yml +0 -0
  29. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/auto_assign_pr.yml +0 -0
  30. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/docker-publish.yml +0 -0
  31. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/main.yml +0 -0
  32. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/pypi-publish.yml +0 -0
  33. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.github/workflows/python-package.yml +0 -0
  34. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.gitignore +0 -0
  35. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.hintrc +0 -0
  36. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.pre-commit-config.yaml +0 -0
  37. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.python-version +0 -0
  38. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/.safety-project.ini +0 -0
  39. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/Dockerfile +0 -0
  40. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/LICENSE +0 -0
  41. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/SECURITY.md +0 -0
  42. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/configuration.md +0 -0
  43. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_entities.png +0 -0
  44. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_mqtt_discovery.png +0 -0
  45. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_update_detail.png +0 -0
  46. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_update_dialog.png +0 -0
  47. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/ha_update_page.png +0 -0
  48. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/logo-blank-256x256.png +0 -0
  49. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/images/updates2mqtt-dark-256x256.png +0 -0
  50. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/index.md +0 -0
  51. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/installation.md +0 -0
  52. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/local_builds.md +0 -0
  53. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/robots.txt +0 -0
  54. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/docs/troubleshooting.md +0 -0
  55. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/examples/config.yaml.minimal +0 -0
  56. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/examples/docker-compose.yaml +0 -0
  57. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/mkdocs.yml +0 -0
  58. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/no_config.yaml +0 -0
  59. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/refresh-deps.sh +0 -0
  60. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/__init__.py +0 -0
  61. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/__main__.py +0 -0
  62. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/integrations/__init__.py +0 -0
  63. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/integrations/git_utils.py +0 -0
  64. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/model.py +0 -0
  65. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/src/updates2mqtt/py.typed +0 -0
  66. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/__init__.py +0 -0
  67. {updates2mqtt-1.4.2 → updates2mqtt-1.5.0}/tests/test_app.py +0 -0
  68. {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.4.2
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
- ![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", can_update=True)]
39
39
 
40
40
 
41
41
  @pytest.fixture
@@ -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
  ```
@@ -3,5 +3,5 @@
3
3
  Smallest working configuration
4
4
 
5
5
  ``` yaml
6
- --8<-- "examples/config.yaml.maximal"
6
+ --8<-- "examples/config.yaml.minimal"
7
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,7 @@
1
+ # Environment File
2
+
3
+ Example `.env` file for Docker Compose configuration to separately store the environment variables.
4
+
5
+ ``` bash
6
+ --8<-- "examples/.env"
7
+ ```
@@ -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/)
@@ -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.0"
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}"
@@ -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 = 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[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,15 +114,22 @@ 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
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": f"{discovery.name} {discovery.source_type} on {node_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 Agent",
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 Agent",
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
- config["payload_install"] = f"{discovery.source_type}|{discovery.name}|install"
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, UpdateInfoConfig
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: UpdateInfoConfig, node_cfg: NodeConfig) -> None:
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.common_packages if common_pkg_cfg else {}
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(DockerComposeCommand.BUILD, "", compose_path, logger)
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(self, command: DockerComposeCommand, args: str, cwd: str | None, logger: structlog.BoundLogger) -> bool:
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
- return self.execute_compose(DockerComposeCommand.UP, "--detach --yes", compose_path, logger)
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
- relnotes_url: str | None = None
353
- picture_url: str | None = self.cfg.default_entity_picture_url
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.docker is not None and pkg.docker.image_name is not None and pkg.docker.image_name == image_name:
384
+ if match(pkg):
358
385
  self.log.debug(
359
- "Found common package", pkg=pkg.docker.image_name, logo_url=picture_url, relnotes_url=relnotes_url
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.docker is not None and pkg.docker.image_name is not None and pkg.docker.image_name == image_name:
393
+ if match(pkg):
364
394
  self.log.debug(
365
- "Found discovered package", pkg=pkg.docker.image_name, logo_url=picture_url, relnotes_url=relnotes_url
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), logo_url=picture_url, release_notes_url=relnotes_url
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
- self.log.info("No valid MQTT protocol version found (%s), setting to default v3.11", self.cfg.protocol)
53
+ logger.info("No valid MQTT protocol version found (%s), setting to default v3.11", self.cfg.protocol)
54
54
  protocol = MQTTProtocolVersion.MQTTv311
55
- self.log.debug("MQTT protocol set to %r", protocol)
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
- self.log.info("Client connection requested", result_code=rc)
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.info("Connected to broker", host=self.cfg.host, port=self.cfg.port)
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
- self.log.info("Connected to broker", result_code=rc)
106
- for topic, provider in self.providers_by_topic.items():
107
- self.log.info("(Re)subscribing", topic=topic, provider=provider.source_type)
108
- self.client.subscribe(topic)
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
- self.log.info("Disconnected from broker", result_code=rc)
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, updated)
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.common_packages) > 0
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={}), mut.UpdateInfoConfig(), mut.NodeConfig())
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={}), mut.UpdateInfoConfig(), mut.NodeConfig())
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
- mut.UpdateInfoConfig(),
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={}), mut.UpdateInfoConfig(), mut.NodeConfig())
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(mock_discoveries[0], "obj001", "testbed01", "state_topic_1", "command_topic_1", area="Basement")
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 on testbed01",
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": False,
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 Agent",
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 Agent",
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": False,
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 Agent",
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, dummy_callable, dummy_callable)
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": True,
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": True,
115
+ "in_progress": False,
116
116
  }
117
117
  ),
118
118
  qos=0,
@@ -1314,7 +1314,7 @@ wheels = [
1314
1314
 
1315
1315
  [[package]]
1316
1316
  name = "updates2mqtt"
1317
- version = "1.4.2"
1317
+ version = "1.5.0"
1318
1318
  source = { editable = "." }
1319
1319
  dependencies = [
1320
1320
  { name = "docker" },
@@ -1,7 +0,0 @@
1
- # Docker Compose
2
-
3
- Example Docker Compose configuration to run *updates2mqtt* as a container.
4
-
5
- ``` yaml
6
- --8<-- "examples/docker-compose.yaml"
7
- ```
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes