Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/triggers/providers/mqtt/Hass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
9 changes: 8 additions & 1 deletion app/triggers/providers/mqtt/Hass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
88 changes: 87 additions & 1 deletion app/triggers/providers/mqtt/Mqtt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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',
Expand Down
Loading