Skip to content

Commit 98dd455

Browse files
jpettittmill1000claude
committed
Use HA's official entity formatting functions when available
HA 2023.9+ exposes hass.formatEntityState/formatEntityAttributeValue, which apply the user's own locale/number-format/precision preferences the same way HA's own UI does. Delegate to them when present instead of this project's own formatNumber/computeStateDisplay reimplementation, falling back to the existing logic otherwise. A custom `format:` config still takes precedence over both. Based on the approach in benct#336 (originally by @mill1000), which should close benct#333, benct#308, benct#286, and benct#220 - a cluster of long-standing decimal/precision formatting complaints. That PR alone would have introduced a regression, though: HA installs stub formatEntityState/formatEntityAttributeValue functions on initial connection that return completely raw, unformatted values (confirmed by extracting the actual implementations from a running instance's frontend bundle - the stub is literally `(e,t) => (t ?? e.state) ?? ""`), and swaps in the real implementations asynchronously. This is the same race benct#373/benct#379 fixed for formatEntityName, but benct#336 didn't extend that re-render check to the new functions it depends on - so cards would have been stuck showing raw values whenever the swap happened after initial render, same bug class as benct#370/benct#371 but for values instead of names. Generalized hasConfigOrEntitiesChanged's formatEntityName check into a list covering all three formatter keys, and added tests simulating the swap for each. Also added tests covering the new delegation paths in entityStateDisplay, which the original PR's diff had zero coverage for. Fixes benct#333, benct#308, benct#286, benct#220 Co-Authored-By: Tucker Kern <mill1000@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a40ae91 commit 98dd455

4 files changed

Lines changed: 77 additions & 25 deletions

File tree

src/entity.js

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,16 @@ export const entityName = (stateObj, config) => {
2525
};
2626

2727
export const entityStateDisplay = (hass, stateObj, config) => {
28+
// HA 2023.9+ exposes hass.formatEntityState/formatEntityAttributeValue, which apply the
29+
// user's own locale/number-format/precision preferences the same way HA's own UI does.
30+
// Prefer them over our own formatNumber/computeStateDisplay reimplementation when present.
31+
const hasOfficialStateFormatter = typeof hass.formatEntityState === 'function';
32+
const hasOfficialAttributeFormatter = typeof hass.formatEntityAttributeValue === 'function';
33+
2834
if (isUnavailable(stateObj)) {
29-
return hass.localize(`state.default.${stateObj.state}`);
35+
return hasOfficialStateFormatter
36+
? hass.formatEntityState(stateObj)
37+
: hass.localize(`state.default.${stateObj.state}`);
3038
}
3139

3240
let value = config.attribute ? stateObj.attributes[config.attribute] : stateObj.state;
@@ -74,13 +82,17 @@ export const entityStateDisplay = (hass, stateObj, config) => {
7482
return `${value}${unit ? ` ${unit}` : ''}`;
7583
}
7684

85+
const modifiedStateObj = { ...stateObj, attributes: { ...stateObj.attributes, unit_of_measurement: unit } };
86+
7787
if (config.attribute) {
78-
return `${isNaN(value) ? value : formatNumber(value, hass.locale)}${unit ? ` ${unit}` : ''}`;
88+
return hasOfficialAttributeFormatter
89+
? hass.formatEntityAttributeValue(modifiedStateObj, config.attribute)
90+
: `${isNaN(value) ? value : formatNumber(value, hass.locale)}${unit ? ` ${unit}` : ''}`;
7991
}
8092

81-
const modifiedStateObj = { ...stateObj, attributes: { ...stateObj.attributes, unit_of_measurement: unit } };
82-
83-
return computeStateDisplay(hass.localize, modifiedStateObj, hass.locale, hass.entities);
93+
return hasOfficialStateFormatter
94+
? hass.formatEntityState(modifiedStateObj)
95+
: computeStateDisplay(hass.localize, modifiedStateObj, hass.locale, hass.entities);
8496
};
8597

8698
export const entityStyles = (config) =>

src/entity.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ describe('entityStateDisplay', () => {
117117
const stateObj = { entity_id: 'sensor.temp', state: '21', attributes: { unit_of_measurement: '°C' } };
118118
expect(entityStateDisplay(hass, stateObj, {})).toBe('21 °C');
119119
});
120+
121+
// See https://developers.home-assistant.io/docs/frontend/data#entity-state-formatting -
122+
// HA 2023.9+ exposes hass.formatEntityState/formatEntityAttributeValue, which apply the user's
123+
// own locale/precision preferences. Prefer these over our own reimplementation when present.
124+
describe('with HA official formatting functions available', () => {
125+
const officialHass = {
126+
...hass,
127+
formatEntityState: vi.fn((stateObj) => `official-state:${stateObj.state}`),
128+
formatEntityAttributeValue: vi.fn((stateObj, attribute) => `official-attr:${attribute}`),
129+
};
130+
131+
it('delegates unavailable-state localization to formatEntityState', () => {
132+
const stateObj = { state: 'unavailable', attributes: {} };
133+
expect(entityStateDisplay(officialHass, stateObj, {})).toBe('official-state:unavailable');
134+
});
135+
136+
it('delegates attribute display to formatEntityAttributeValue', () => {
137+
const stateObj = { state: 'on', attributes: { battery_level: 42 } };
138+
expect(entityStateDisplay(officialHass, stateObj, { attribute: 'battery_level' })).toBe(
139+
'official-attr:battery_level'
140+
);
141+
});
142+
143+
it('delegates main state display to formatEntityState', () => {
144+
const stateObj = { entity_id: 'sensor.temp', state: '21', attributes: {} };
145+
expect(entityStateDisplay(officialHass, stateObj, {})).toBe('official-state:21');
146+
});
147+
148+
it('still applies a custom format instead of the official formatters', () => {
149+
const stateObj = { state: '90', attributes: {} };
150+
expect(entityStateDisplay(officialHass, stateObj, { format: 'duration' })).toBe('1:30');
151+
});
152+
});
120153
});
121154

122155
describe('entityStyles', () => {

src/util.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,21 @@ export const getEntityIds = (config) =>
4545
.concat(config.entities?.map((entity) => (typeof entity === 'string' ? entity : entity.entity)))
4646
.filter((entity) => entity);
4747

48+
// HA installs stub formatEntityName/formatEntityState/formatEntityAttributeValue functions on
49+
// initial connection (returning raw, unformatted values) and replaces them asynchronously with
50+
// the real implementations once translations load. None of that swap is otherwise observable, so
51+
// without this check a row can get stuck showing stale/raw output until some unrelated entity
52+
// state change happens to force a re-render.
53+
const HASS_FORMATTER_KEYS = ['formatEntityName', 'formatEntityState', 'formatEntityAttributeValue'];
54+
4855
export const hasConfigOrEntitiesChanged = (node, changedProps) => {
4956
if (changedProps.has('config')) {
5057
return true;
5158
}
5259

5360
const oldHass = changedProps.get('_hass');
5461
if (oldHass) {
55-
// HA installs a stub `formatEntityName` that ignores the name override on initial connection
56-
// and replaces it asynchronously once translations load. Re-render when that swap happens so
57-
// the `name` override actually takes effect on the main row.
58-
if (oldHass.formatEntityName !== node._hass.formatEntityName) {
62+
if (HASS_FORMATTER_KEYS.some((key) => oldHass[key] !== node._hass[key])) {
5963
return true;
6064
}
6165
return node.entityIds.some((entity) => oldHass.states[entity] !== node._hass.states[entity]);

src/util.test.js

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -142,20 +142,23 @@ describe('hasConfigOrEntitiesChanged', () => {
142142
});
143143

144144
// See https://github.com/benct/lovelace-multiple-entity-row/issues/370 and
145-
// https://github.com/benct/lovelace-multiple-entity-row/issues/371 - HA 2026.2+ installs a stub
146-
// `formatEntityName` on initial connection and swaps in the real implementation asynchronously
147-
// once translations load. A `name` override wouldn't take effect until some unrelated entity
148-
// state changed forced a re-render, because this swap alone wasn't treated as a change.
149-
it('is true when hass swaps in a new formatEntityName implementation', () => {
150-
const sharedState = { state: 'on' };
151-
const stubFormatEntityName = () => 'stub';
152-
const realFormatEntityName = () => 'real';
153-
const oldHass = { states: { 'sensor.a': sharedState }, formatEntityName: stubFormatEntityName };
154-
const node = {
155-
entityIds: ['sensor.a'],
156-
_hass: { states: { 'sensor.a': sharedState }, formatEntityName: realFormatEntityName },
157-
};
158-
const changedProps = new Map([['_hass', oldHass]]);
159-
expect(hasConfigOrEntitiesChanged(node, changedProps)).toBe(true);
160-
});
145+
// https://github.com/benct/lovelace-multiple-entity-row/issues/371 - HA 2026.2+ installs stub
146+
// formatEntityName/formatEntityState/formatEntityAttributeValue functions on initial connection
147+
// (returning raw, unformatted values) and swaps in the real implementations asynchronously once
148+
// translations load. Without detecting that swap, a row can get stuck showing stale/raw output
149+
// (e.g. a `name` override reverting, or a numeric state showing unformatted) until some
150+
// unrelated entity state change happens to force a re-render.
151+
it.each(['formatEntityName', 'formatEntityState', 'formatEntityAttributeValue'])(
152+
'is true when hass swaps in a new %s implementation',
153+
(key) => {
154+
const sharedState = { state: 'on' };
155+
const oldHass = { states: { 'sensor.a': sharedState }, [key]: () => 'stub' };
156+
const node = {
157+
entityIds: ['sensor.a'],
158+
_hass: { states: { 'sensor.a': sharedState }, [key]: () => 'real' },
159+
};
160+
const changedProps = new Map([['_hass', oldHass]]);
161+
expect(hasConfigOrEntitiesChanged(node, changedProps)).toBe(true);
162+
}
163+
);
161164
});

0 commit comments

Comments
 (0)