diff --git a/modules/helpers/__tests__/hass.test.js b/modules/helpers/__tests__/hass.test.js index 7439b8b..605c981 100644 --- a/modules/helpers/__tests__/hass.test.js +++ b/modules/helpers/__tests__/hass.test.js @@ -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 + }); +}); diff --git a/modules/helpers/hass.js b/modules/helpers/hass.js index eab6aa3..92f412f 100644 --- a/modules/helpers/hass.js +++ b/modules/helpers/hass.js @@ -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, @@ -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; }; diff --git a/modules/icon_border_progress/__tests__/integration.test.js b/modules/icon_border_progress/__tests__/integration.test.js index 313b39f..651d649 100644 --- a/modules/icon_border_progress/__tests__/integration.test.js +++ b/modules/icon_border_progress/__tests__/integration.test.js @@ -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" }, }, }; @@ -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(); + }); + }); }); diff --git a/modules/icon_border_progress/code.js b/modules/icon_border_progress/code.js index 5909bc3..12172ed 100644 --- a/modules/icon_border_progress/code.js +++ b/modules/icon_border_progress/code.js @@ -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)); diff --git a/modules/icon_border_progress/editor.yaml b/modules/icon_border_progress/editor.yaml index 6b3f38e..bab7c64 100644 --- a/modules/icon_border_progress/editor.yaml +++ b/modules/icon_border_progress/editor.yaml @@ -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: @@ -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: diff --git a/modules/icon_border_progress/schema.yaml b/modules/icon_border_progress/schema.yaml index 407b659..569acdd 100644 --- a/modules/icon_border_progress/schema.yaml +++ b/modules/icon_border_progress/schema.yaml @@ -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" diff --git a/modules/separator_as_progress_bar/CONFIG_OPTIONS.md b/modules/separator_as_progress_bar/CONFIG_OPTIONS.md index 3cc7ee3..1fa8734 100644 --- a/modules/separator_as_progress_bar/CONFIG_OPTIONS.md +++ b/modules/separator_as_progress_bar/CONFIG_OPTIONS.md @@ -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` diff --git a/modules/separator_as_progress_bar/__tests__/integration.test.js b/modules/separator_as_progress_bar/__tests__/integration.test.js index 6f2ca90..87c7c72 100644 --- a/modules/separator_as_progress_bar/__tests__/integration.test.js +++ b/modules/separator_as_progress_bar/__tests__/integration.test.js @@ -28,6 +28,22 @@ describe("separator_as_progress_bar - Integration Tests", () => { "sensor.saros_10_cleaning_progress": { state: "80" }, "sensor.saros_10_current_room": { state: "Living Room" }, "sensor.saros_10_status": { state: "cleaning" }, + // New entities for UI attribute testing + "sensor.battery_device": { + state: "charging", + attributes: { + battery_level: 45, + temperature: 25.5, + voltage: 3.7, + }, + }, + "sensor.solar_system": { + state: "generating", + attributes: { + current_power: 85, + daily_energy: 12.5, + }, + }, // Edge case entities "sensor.invalid_progress": { state: "invalid" }, "sensor.negative_progress": { state: "-10" }, @@ -1865,4 +1881,134 @@ describe("separator_as_progress_bar - Integration Tests", () => { expect(wrapper.querySelector(".bubble-line-text.below-text")).toBeTruthy(); // Should exist again }); }); + + describe("UI Editor Attribute Support", () => { + it("should use separate entity and attribute fields from UI editor", () => { + const config = { + source: "sensor.battery_device", + source_attribute: "battery_level", + progress_style: { + color_stops: [ + { + color: "red", + percent: 0, + }, + { + color: "green", + percent: 100, + }, + ], + }, + }; + + mockThis.config = { separator_as_progress_bar: config }; + separator_as_progress_bar.call(mockThis, mockCard, mockHass); + + const element = mockCard.querySelector(".bubble-line"); + expect(element.classList.contains("bubble-line-progress")).toBe(true); + expect(element.style.getPropertyValue("--progress-width")).toBe("45cqw"); // battery_level attribute value + }); + + it("should prioritize DER syntax over separate attribute field", () => { + const config = { + source: "sensor.battery_device[voltage]", // DER syntax for voltage + source_attribute: "battery_level", // Separate attribute field + progress_style: { + color_stops: [ + { + color: "red", + percent: 0, + }, + { + color: "green", + percent: 100, + }, + ], + }, + }; + + mockThis.config = { separator_as_progress_bar: config }; + separator_as_progress_bar.call(mockThis, mockCard, mockHass); + + const element = mockCard.querySelector(".bubble-line"); + expect(element.classList.contains("bubble-line-progress")).toBe(true); + expect(element.style.getPropertyValue("--progress-width")).toBe("3.7cqw"); // voltage from DER, not battery_level + }); + + it("should handle missing attribute gracefully with UI fields", () => { + const config = { + source: "sensor.battery_device", + source_attribute: "missing_attribute", + progress_style: { + color_stops: [ + { + color: "red", + percent: 0, + }, + { + color: "green", + percent: 100, + }, + ], + }, + }; + + mockThis.config = { separator_as_progress_bar: config }; + separator_as_progress_bar.call(mockThis, mockCard, mockHass); + + const element = mockCard.querySelector(".bubble-line"); + expect(element.classList.contains("bubble-line-progress")).toBe(true); + expect(element.style.getPropertyValue("--progress-width")).toBe("0cqw"); // Should default to 0 for NaN + }); + + it("should work with just entity field (no attribute) from UI", () => { + const config = { + source: "sensor.task_progress", // Just entity, no attribute + progress_style: { + color_stops: [ + { + color: "red", + percent: 0, + }, + { + color: "green", + percent: 100, + }, + ], + }, + }; + + mockThis.config = { separator_as_progress_bar: config }; + separator_as_progress_bar.call(mockThis, mockCard, mockHass); + + const element = mockCard.querySelector(".bubble-line"); + expect(element.classList.contains("bubble-line-progress")).toBe(true); + expect(element.style.getPropertyValue("--progress-width")).toBe("75cqw"); // entity state value + }); + + it("should maintain backward compatibility with YAML DER syntax", () => { + const config = { + source: "sensor.solar_system[current_power]", // YAML DER syntax + progress_style: { + color_stops: [ + { + color: "red", + percent: 0, + }, + { + color: "green", + percent: 100, + }, + ], + }, + }; + + mockThis.config = { separator_as_progress_bar: config }; + separator_as_progress_bar.call(mockThis, mockCard, mockHass); + + const element = mockCard.querySelector(".bubble-line"); + expect(element.classList.contains("bubble-line-progress")).toBe(true); + expect(element.style.getPropertyValue("--progress-width")).toBe("85cqw"); // current_power attribute value + }); + }); }); diff --git a/modules/separator_as_progress_bar/code.js b/modules/separator_as_progress_bar/code.js index 1f9e41f..926404b 100644 --- a/modules/separator_as_progress_bar/code.js +++ b/modules/separator_as_progress_bar/code.js @@ -34,7 +34,14 @@ export function separator_as_progress_bar(card, hass) { } } - let progressValue = config.override ? config.override : parseFloat(getState(config.source)); + let progressValue = config.override + ? config.override + : parseFloat( + getState({ + entity: config.source, + attribute: config.source_attribute, + }), + ); if (isNaN(progressValue) || progressValue < 0 || progressValue > 100) { progressValue = 0; diff --git a/modules/separator_as_progress_bar/editor.yaml b/modules/separator_as_progress_bar/editor.yaml index 1387bc8..fdb7a2f 100644 --- a/modules/separator_as_progress_bar/editor.yaml +++ b/modules/separator_as_progress_bar/editor.yaml @@ -12,6 +12,10 @@ editor: label: "✨Source entity" selector: entity: {} + - name: source_attribute + label: "Source entity attribute (optional)" + selector: + attribute: {} - name: invert label: "Invert progress (bar decreases as progress completes)" default: false diff --git a/modules/separator_as_progress_bar/schema.yaml b/modules/separator_as_progress_bar/schema.yaml index e75e627..6c684ac 100644 --- a/modules/separator_as_progress_bar/schema.yaml +++ b/modules/separator_as_progress_bar/schema.yaml @@ -8,6 +8,9 @@ properties: type: string description: The entity or attribute source. format: entity[attribute] + source_attribute: + type: string + description: "Attribute name to use from the source entity (optional - can also use entity[attribute] syntax)" invert: type: boolean description: Invert the progress bar (bar decreases as progress completes) diff --git a/modules/separator_as_timeline/__tests__/code.test.js b/modules/separator_as_timeline/__tests__/code.test.js index 8e5ac5c..b3922bc 100644 --- a/modules/separator_as_timeline/__tests__/code.test.js +++ b/modules/separator_as_timeline/__tests__/code.test.js @@ -283,7 +283,13 @@ describe("separator_as_timeline - Unit Tests", () => { separator_as_timeline.call(mockThis, mockCard, mockHass); // Verify - expect(getState).toHaveBeenCalledWith("sensor.work_start", false); + expect(getState).toHaveBeenCalledWith( + { + entity: "sensor.work_start", + attribute: undefined, + }, + false, + ); expect(mockWrapper.appendChild).toHaveBeenCalled(); }); @@ -305,7 +311,13 @@ describe("separator_as_timeline - Unit Tests", () => { separator_as_timeline.call(mockThis, mockCard, mockHass); // Verify - Check that entity with attribute is queried correctly - expect(getState).toHaveBeenCalledWith("sensor.work_schedule[end_time]", false); + expect(getState).toHaveBeenCalledWith( + { + entity: "sensor.work_schedule", + attribute: "end_time", + }, + false, + ); expect(mockWrapper.appendChild).toHaveBeenCalled(); }); }); @@ -682,8 +694,20 @@ describe("separator_as_timeline - Unit Tests", () => { separator_as_timeline.call(mockThis, mockCard, mockHass); // Verify - Both entity queries should be made - expect(getState).toHaveBeenCalledWith("sensor.work_start", false); - expect(getState).toHaveBeenCalledWith("sensor.sleep_time[bedtime]", false); + expect(getState).toHaveBeenCalledWith( + { + entity: "sensor.work_start", + attribute: undefined, + }, + false, + ); + expect(getState).toHaveBeenCalledWith( + { + entity: "sensor.sleep_time", + attribute: "bedtime", + }, + false, + ); expect(mockWrapper.appendChild).toHaveBeenCalled(); }); diff --git a/modules/separator_as_timeline/code.js b/modules/separator_as_timeline/code.js index 9a4a613..3d6cf54 100644 --- a/modules/separator_as_timeline/code.js +++ b/modules/separator_as_timeline/code.js @@ -119,7 +119,13 @@ export function separator_as_timeline(card, hass) { //TODO - replace with a single start field. // this will be a breaking change, so we need to // implement auto-migration first - startTimeValue = getState(`${r.start_entity}${r.start_attribute ? `[${r.start_attribute}]` : ""}`, false); + startTimeValue = getState( + { + entity: r.start_entity, + attribute: r.start_attribute, + }, + false, + ); } else { startTimeValue = r.start; } @@ -127,7 +133,13 @@ export function separator_as_timeline(card, hass) { // Get end time (from entity if provided, otherwise from direct value) let endTimeValue; if (r.end_entity) { - endTimeValue = getState(`${r.end_entity}${r.end_attribute ? `[${r.end_attribute}]` : ""}`, false); + endTimeValue = getState( + { + entity: r.end_entity, + attribute: r.end_attribute, + }, + false, + ); } else { endTimeValue = r.end; } diff --git a/scripts/py/__pycache__/md_template.cpython-311.pyc b/scripts/py/__pycache__/md_template.cpython-311.pyc new file mode 100644 index 0000000..4b68405 Binary files /dev/null and b/scripts/py/__pycache__/md_template.cpython-311.pyc differ diff --git a/scripts/py/__pycache__/template_renderer.cpython-311.pyc b/scripts/py/__pycache__/template_renderer.cpython-311.pyc new file mode 100644 index 0000000..622fcc1 Binary files /dev/null and b/scripts/py/__pycache__/template_renderer.cpython-311.pyc differ