diff --git a/CHANGELOG.md b/CHANGELOG.md index e18ed2518..a4300f5f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Home Assistant `update.*` entities showed "Unknown" for both Installed and Newest version on up-to-date containers.** The MQTT HASS discovery `latest_version` template rendered an empty string whenever a container had no pending update (no `result` in the payload). Home Assistant silently discards an empty render, so the "Newest version" attribute stayed unset and, because HA blanks the whole entity state when either version is missing, the entity read `Unknown` forever instead of resolving to "up to date". The template now falls back to the installed image tag when no update result is present, and guards the digest slice so a digest-kind report that carries no digest no longer throws in HA's template engine. (#491) + ## [1.5.1-rc.6] — 2026-07-05 ### Fixed diff --git a/app/triggers/providers/mqtt/Hass.test.ts b/app/triggers/providers/mqtt/Hass.test.ts index 7f56de0ab..35333f75d 100644 --- a/app/triggers/providers/mqtt/Hass.test.ts +++ b/app/triggers/providers/mqtt/Hass.test.ts @@ -228,7 +228,7 @@ test('addContainerSensor must publish sensor discovery message expected by HA', value_template: '{{ value_json.image_tag_value }}', latest_version_topic: 'topic/watcher-name/container-name', latest_version_template: - '{% if value_json.update_kind_kind == "digest" %}{{ value_json.result_digest[:15] }}{% else %}{{ value_json.result_tag }}{% endif %}', + '{% if value_json.update_kind_kind == "digest" %}{{ value_json.result_digest[:15] if value_json.result_digest else value_json.image_tag_value }}{% else %}{{ value_json.result_tag if value_json.result_tag else value_json.image_tag_value }}{% endif %}', json_attributes_topic: 'topic/watcher-name/container-name', }), { retain: true }, diff --git a/app/triggers/providers/mqtt/Hass.ts b/app/triggers/providers/mqtt/Hass.ts index d635ad6ce..5492babf7 100644 --- a/app/triggers/providers/mqtt/Hass.ts +++ b/app/triggers/providers/mqtt/Hass.ts @@ -16,8 +16,15 @@ const HASS_DEVICE_ID = 'drydock'; const HASS_DEVICE_NAME = 'drydock'; const HASS_MANUFACTURER = 'drydock'; const HASS_ENTITY_VALUE_TEMPLATE = '{{ value_json.image_tag_value }}'; +// Newest version. When no update is pending, container.result is absent, so +// result_tag/result_digest never appear in the flattened payload. Fall back to +// the installed tag (image_tag_value) so HA resolves latest == installed ("up to +// date") instead of rendering an empty string — which HA silently discards, +// leaving both "Newest version" and the entity state permanently Unknown (#491). +// The `if value_json.result_digest` guard also avoids the `Undefined[:15]` slice +// error HA's Jinja throws when a digest-kind report carries no result_digest. const HASS_LATEST_VERSION_TEMPLATE = - '{% if value_json.update_kind_kind == "digest" %}{{ value_json.result_digest[:15] }}{% else %}{{ value_json.result_tag }}{% endif %}'; + '{% if value_json.update_kind_kind == "digest" %}{{ value_json.result_digest[:15] if value_json.result_digest else value_json.image_tag_value }}{% else %}{{ value_json.result_tag if value_json.result_tag else value_json.image_tag_value }}{% endif %}'; const HASS_DEFAULT_ENTITY_PICTURE = 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png'; export const HASS_CONTAINER_STATE_TOPIC_TRACK_LIMIT = 10_000; diff --git a/app/triggers/providers/mqtt/Mqtt.test.ts b/app/triggers/providers/mqtt/Mqtt.test.ts index a19d04063..ad8ecd8f8 100644 --- a/app/triggers/providers/mqtt/Mqtt.test.ts +++ b/app/triggers/providers/mqtt/Mqtt.test.ts @@ -6,7 +6,7 @@ import { emitContainerUpdated, } from '../../../event/index.js'; import log from '../../../log/index.js'; -import { flatten } from '../../../model/container.js'; +import { flatten, validate } from '../../../model/container.js'; vi.mock('mqtt'); vi.mock('node:fs/promises', () => ({ @@ -235,6 +235,92 @@ test.each(containerData)('trigger should format json message payload as expected }); }); +// Regression guard for #491: the HA latest_version_template reads result_tag / +// result_digest / image_tag_value from the flattened MQTT state payload. Lock the +// shape the template depends on — an up-to-date container carries image_tag_value +// but NO result_* keys (the case that used to render an empty "Newest version"), +// while tag/digest updates carry the matching result_* field. +test('trigger should publish latest-version source fields for current, tag, and digest states', async () => { + mqtt.configuration = { + topic: 'dd/container', + exclude: '', + hass: { + attributes: 'full', + filter: { + include: '', + exclude: '', + }, + }, + }; + + const installedDigest = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const remoteDigest = 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + const buildContainer = ({ id, name, digestWatch = false, result }) => + validate({ + id, + name, + watcher: 'local', + image: { + id: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + registry: { + name: 'docker.io', + url: 'docker.io', + }, + name: 'library/test', + tag: { + value: '1.25.0', + semver: true, + }, + digest: { + watch: digestWatch, + value: installedDigest, + }, + architecture: 'amd64', + os: 'linux', + }, + ...(result ? { result } : {}), + }); + + const publishContainer = async (container) => { + mqtt.client.publish.mockClear(); + await mqtt.trigger(container); + expect(mqtt.client.publish).toHaveBeenCalledTimes(1); + return JSON.parse(mqtt.client.publish.mock.calls[0][1]); + }; + + const upToDatePayload = await publishContainer( + buildContainer({ id: 'container-current', name: 'current' }), + ); + expect(upToDatePayload).toHaveProperty('update_available', false); + expect(upToDatePayload).toHaveProperty('image_tag_value', '1.25.0'); + expect(upToDatePayload).not.toHaveProperty('result_tag'); + expect(upToDatePayload).not.toHaveProperty('result_digest'); + + const tagUpdatePayload = await publishContainer( + buildContainer({ + id: 'container-tag-update', + name: 'tag-update', + result: { tag: '1.26.0' }, + }), + ); + expect(tagUpdatePayload).toHaveProperty('update_available', true); + expect(tagUpdatePayload).toHaveProperty('update_kind_kind', 'tag'); + expect(tagUpdatePayload).toHaveProperty('result_tag', '1.26.0'); + + const digestUpdatePayload = await publishContainer( + buildContainer({ + id: 'container-digest-update', + name: 'digest-update', + digestWatch: true, + result: { digest: remoteDigest }, + }), + ); + expect(digestUpdatePayload).toHaveProperty('update_available', true); + expect(digestUpdatePayload).toHaveProperty('update_kind_kind', 'digest'); + expect(digestUpdatePayload).toHaveProperty('result_digest', remoteDigest); +}); + test('trigger should normalize recreated alias-prefixed container names to their base topic', async () => { mqtt.configuration = { topic: 'dd/container',