Skip to content
Draft
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
41 changes: 41 additions & 0 deletions modules/helpers/__tests__/hass.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,44 @@ describe("getState() with object input", () => {
expect(getState({ foo: "bar" }, false)).toBeUndefined();
});
});

describe("getState() with DER precedence logic", () => {
it("should use DER syntax when present in object entity field, overriding separate attribute", () => {
// DER syntax in entity field should take precedence over separate attribute field
const input = { entity: "sensor.temperature[unit_of_measurement]", attribute: "calibrated" };
expect(getState(input)).toBe("°C"); // Should use unit_of_measurement, not calibrated
});

it("should use separate attribute when no DER syntax in entity field", () => {
// When no DER syntax, should use separate attribute field
const input = { entity: "sensor.temperature", attribute: "calibrated" };
expect(getState(input)).toBe(true); // Should use calibrated attribute
});

it("should handle DER syntax in entity_id field", () => {
// Test with entity_id instead of entity
const input = { entity_id: "light.living_room[brightness]", attribute: "some_other_attr" };
expect(getState(input)).toBe(200); // Should use brightness from DER, not some_other_attr
});

it("should handle DER syntax with non-existent attribute gracefully", () => {
const input = { entity: "sensor.temperature[missing_attr]", attribute: "calibrated" };
expect(getState(input, false)).toBeUndefined();
});

it("should handle DER syntax with non-existent entity gracefully", () => {
const input = { entity: "sensor.unknown[some_attr]", attribute: "fallback_attr" };
expect(getState(input)).toEqual(input); // Should return raw input when fallbackToRaw is true
expect(getState(input, false)).toBeUndefined();
});

it("should still work with entity only (no attribute fields)", () => {
const input = { entity: "sensor.temperature" };
expect(getState(input)).toBe("22.5"); // Should return entity state
});

it("should handle edge case where entity field contains DER but no separate attribute", () => {
const input = { entity: "sensor.temperature[unit_of_measurement]" };
expect(getState(input)).toBe("°C"); // Should extract attribute from DER
});
});
34 changes: 22 additions & 12 deletions modules/helpers/hass.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@
* If the input is an object, the entityId is extracted from either the `entity_id` or `entity` field,
* and the attribute is extracted from either the `attribute` or the `att` field.
*
* **DER Precedence**: If the entity field contains DER syntax (`entity[attribute]`), this ALWAYS
* takes precedence over any separate attribute field. This ensures consistent behavior regardless
* of input format and allows UI editors to provide separate entity/attribute fields while maintaining
* full backward compatibility with DER syntax.
*
* For any other inputs, the function directly returns the input value or undefined, depending on the value of `fallbackToRaw`.
*
* @param {string} input - The input to retrieve the state or attribute for. Can be an entity ID,
* an entity ID with an attribute (e.g., `entity_id[attribute]`), or a raw value.
* @param {string|object} input - The input to retrieve the state or attribute for. Can be an entity ID,
* an entity ID with an attribute (e.g., `entity_id[attribute]`), an object with
* entity and optional attribute fields, or a raw value.
* @param {boolean} [fallbackToRaw=true] - Determines whether to return the raw input value if the
* state or entity is not found. Defaults to true.
* @returns {string|undefined} - The state of the entity, the specified attribute value, the raw input,
Expand All @@ -24,23 +30,27 @@ export const getState = (input, fallbackToRaw = true) => {

let entityId, attribute;

// Step 1: Extract initial values based on input type
if (typeof input === "object") {
entityId = input.entity_id || input.entity;
attribute = input.attribute || input.att;
} else {
// Pattern: entity_id[attribute]
const match = input.match(/^([A-z0-9_.]+)\[([A-z0-9_]+)]$/);
} else if (typeof input === "string") {
entityId = input;
}

// Step 2: ALWAYS check for DER pattern and override if found
if (typeof entityId === "string") {
const match = entityId.match(/^([A-z0-9_.]+)\[([A-z0-9_]+)]$/);
if (match) {
[, entityId, attribute] = match;
} else if (hass.states[input]) {
entityId = input;
} else {
return fallbackToRaw ? input : undefined;
[, entityId, attribute] = match; // DER takes precedence
}
}

const stateObj = hass.states[entityId];
if (!stateObj) return fallbackToRaw ? input : undefined;
// Step 3: Validate and return
if (!entityId || !hass.states[entityId]) {
return fallbackToRaw ? input : undefined;
}

const stateObj = hass.states[entityId];
return attribute ? stateObj.attributes[attribute] : stateObj.state;
};
199 changes: 199 additions & 0 deletions modules/icon_border_progress/__tests__/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ describe("icon_border_progress - Integration Tests", () => {
"sensor.filament_abs_level": { state: "65" },
"sensor.filament_petg_level": { state: "35" },
"sensor.filament_cf_level": { state: "90" },
// New entities for UI attribute testing
"sensor.device_status": {
state: "online",
attributes: {
battery_level: 85,
signal_strength: 95,
temperature: 23.5,
},
},
"sensor.system_monitor": {
state: "running",
attributes: {
cpu_usage: 45,
memory_usage: 60,
disk_usage: 75,
},
},
// Default start/end values for progress calculation
"sensor.start_value": { state: "0" },
"sensor.end_value": { state: "100" },
},
};

Expand Down Expand Up @@ -1844,4 +1864,183 @@ describe("icon_border_progress - Integration Tests", () => {
expect(mainIcon.style.background).toBe("rgb(128, 128, 128)");
});
});

describe("UI Editor Attribute Support", () => {
it("should use separate entity and attribute fields from UI editor", () => {
const config = [
{
button: "sub-button-1",
source: "sensor.device_status",
source_attribute: "battery_level",
start: "sensor.start_value",
end: "sensor.end_value",
color_stops: [
{
color: "red",
percent: 0,
},
{
color: "green",
percent: 100,
},
],
remaining_color: "#333",
background_color: "#111",
},
];

mockThis.config = { icon_border_progress: config };
icon_border_progress.call(mockThis, mockCard, mockHass);

const subButton1 = mockCard.querySelector(".bubble-sub-button-1");
expect(subButton1).toBeTruthy();

// Should create SVG progress border based on battery_level attribute (85)
const svg = subButton1.querySelector(".stroke-dash-aligned-svg");
expect(svg).toBeTruthy();

const progressPath = svg.querySelector(".progress-path");
expect(progressPath).toBeTruthy();
});

it("should prioritize DER syntax over separate attribute field", () => {
const config = [
{
button: "sub-button-2",
source: "sensor.device_status[signal_strength]", // DER syntax for signal_strength
source_attribute: "battery_level", // Separate attribute field
start: "sensor.start_value",
end: "sensor.end_value",
color_stops: [
{
color: "red",
percent: 0,
},
{
color: "green",
percent: 100,
},
],
remaining_color: "#333",
background_color: "#111",
},
];

mockThis.config = { icon_border_progress: config };
icon_border_progress.call(mockThis, mockCard, mockHass);

const subButton2 = mockCard.querySelector(".bubble-sub-button-2");
expect(subButton2).toBeTruthy();

// Should create SVG progress border based on signal_strength (95) from DER, not battery_level (85)
const svg = subButton2.querySelector(".stroke-dash-aligned-svg");
expect(svg).toBeTruthy();

const progressPath = svg.querySelector(".progress-path");
expect(progressPath).toBeTruthy();
});

it("should handle missing attribute gracefully with UI fields", () => {
const config = [
{
button: "main",
source: "sensor.device_status",
source_attribute: "missing_attribute",
start: "sensor.start_value",
end: "sensor.end_value",
color_stops: [
{
color: "red",
percent: 0,
},
{
color: "green",
percent: 100,
},
],
remaining_color: "#333",
background_color: "#111",
},
];

mockThis.config = { icon_border_progress: config };
icon_border_progress.call(mockThis, mockCard, mockHass);

const mainIcon = mockCard.querySelector(".bubble-icon-container");
// Should set background color due to missing attribute (NaN progress → 0% → background color treatment)
expect(mainIcon.style.background).toBe("rgb(17, 17, 17)");
});

it("should work with just entity field (no attribute) from UI", () => {
const config = [
{
button: "sub-button-3",
source: "sensor.saros_10_battery", // Just entity, no attribute
start: "sensor.start_value",
end: "sensor.end_value",
color_stops: [
{
color: "red",
percent: 0,
},
{
color: "green",
percent: 100,
},
],
remaining_color: "#333",
background_color: "#111",
},
];

mockThis.config = { icon_border_progress: config };
icon_border_progress.call(mockThis, mockCard, mockHass);

const subButton3 = mockCard.querySelector(".bubble-sub-button-3");
expect(subButton3).toBeTruthy();

// Should create SVG progress border based on entity state value (75)
const svg = subButton3.querySelector(".stroke-dash-aligned-svg");
expect(svg).toBeTruthy();

const progressPath = svg.querySelector(".progress-path");
expect(progressPath).toBeTruthy();
});

it("should maintain backward compatibility with YAML DER syntax", () => {
const config = [
{
button: "sub-button-4",
source: "sensor.system_monitor[cpu_usage]", // YAML DER syntax
start: "sensor.start_value",
end: "sensor.end_value",
color_stops: [
{
color: "red",
percent: 0,
},
{
color: "green",
percent: 100,
},
],
remaining_color: "#333",
background_color: "#111",
},
];

mockThis.config = { icon_border_progress: config };
icon_border_progress.call(mockThis, mockCard, mockHass);

const subButton4 = mockCard.querySelector(".bubble-sub-button-4");
expect(subButton4).toBeTruthy();

// Should create SVG progress border based on cpu_usage attribute value (45)
const svg = subButton4.querySelector(".stroke-dash-aligned-svg");
expect(svg).toBeTruthy();

const progressPath = svg.querySelector(".progress-path");
expect(progressPath).toBeTruthy();
});
});
});
17 changes: 16 additions & 1 deletion modules/icon_border_progress/code.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,22 @@ export function icon_border_progress(card, hass) {
}

function calculateProgressValue(progressSource, buttonConfig) {
let progressValue = parseFloat(getState(progressSource));
let progressValue;

// Check if we have a separate source_attribute field
if (buttonConfig && buttonConfig.source_attribute) {
// Use object format for new UI attribute support
progressValue = parseFloat(
getState({
entity: progressSource,
attribute: buttonConfig.source_attribute,
}),
);
} else {
// Fall back to original behavior for backward compatibility
progressValue = parseFloat(getState(progressSource));
}

let startValue = parseInt(getState(buttonConfig.start));
let endValue = parseInt(getState(buttonConfig.end));

Expand Down
8 changes: 8 additions & 0 deletions modules/icon_border_progress/editor.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ editor:
label: "✨Source entity"
selector:
entity: {}
- name: source_attribute
label: "Source entity attribute (optional)"
selector:
attribute: {}
- name: condition
label: "Condition to show progress (see docs for additional condition configuration)"
selector:
Expand Down Expand Up @@ -106,6 +110,10 @@ editor:
label: "✨Source entity"
selector:
entity: {}
- name: source_attribute
label: "Source entity attribute (optional)"
selector:
attribute: {}
- name: condition
label: "Condition to show progress (see docs for additional condition configuration)"
selector:
Expand Down
3 changes: 3 additions & 0 deletions modules/icon_border_progress/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ definitions:
type: string
description: "Source entity for progress value"
format: "entity[attribute]"
source_attribute:
type: string
description: "Attribute name to use from the source entity (optional - can also use entity[attribute] syntax)"
entity:
type: string
description: "[DEPRECATED] Use 'source' instead. Source entity for progress value"
Expand Down
8 changes: 0 additions & 8 deletions modules/separator_as_progress_bar/CONFIG_OPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1406,21 +1406,13 @@ Specific value: `"not"`
Must be one of:

- "none"

- "dotted"

- "dashed"

- "solid"

- "double"

- "groove"

- "ridge"

- "inset"

- "outset"

### 5.5. Property `color_stops`
Expand Down
Loading