Mariusthvdb / custom-ui

Add templates and icon_color to Home Assistant UI
163 stars 30 forks source link

causing trouble in Kiosk-mode #145

Closed Mariusthvdb closed 1 month ago

Mariusthvdb commented 1 month ago
          well, this is unexpected, Ive found the issue:

I've added a custom resource of myself recently, that can inject templates on attributes, which I need for 1 specific reason.

Disabling that immediately returns the correct functionality to Kiosk-mode..... Which is great on the 1 hand, since this plugin is not flawed 🎉

Otoh... @elchininet could I ask you to please have a look at the new feature and the resource js file ?

it is a miniature of the former custom-ui that never caused anything like this, so maybe I made an error after all there.

Closing this issue here

Originally posted by @Mariusthvdb in https://github.com/NemesisRE/kiosk-mode/issues/271#issuecomment-2350916816

Mariusthvdb commented 1 month ago

wild attempt: threw the issue at ChatGPT:

TLDR: testing, and at first glance this seems to work, and Not kill Kiosk-mode functionality.

BUT: the ChatGPT answer/solution is No good.... if actually fixes the Kiosk mode, but introduces endless Promise rejections, strange enough all related to custom:button-card, which I use extensively.

Need a real dev to have a look.


Me: how could this script cause the Kiosk-mode plugin (https://github.com/NemesisRE/kiosk-mode) to have a delay of exactly 1 state change?

ChatGPT:

The script could introduce a delay of exactly one state change in the Kiosk-mode plugin due to how the custom UI is interacting with the state update process of Home Assistant. Here's how this delay might occur:

Hooking into the State Update Process

The script overrides the default _updateHass method in the home-assistant component, which is responsible for updating the state of entities in Home Assistant. The original state update is intercepted, and a custom logic (from window.customUI.maybeApplyTemplateAttributes) is applied to potentially modify an entity’s attributes before allowing the state to propagate.

This asynchronous operation (using await) inside the overridden _updateHass function could cause a delay in applying these updates. Here’s why:

  1. Custom Asynchronous Computation: Each entity's attributes that contain templates are recalculated by the maybeApplyTemplateAttributes method, which is asynchronous (async/await). This means the script waits for the result of the template computation before updating the entity state.

  2. Conditional State Modification: The state only gets updated if the computed new entity (with updated attributes) differs from the original entity (if (JSON.stringify(entity) !== JSON.stringify(newEntity))). So, if the attributes do not change (or are delayed in being processed), the original state might remain unchanged, causing the state change to happen on the next update cycle instead of immediately.

  3. Impact on Kiosk Mode: Kiosk Mode likely relies on immediate state updates to hide/show UI elements or adjust settings dynamically. If this custom UI logic delays the state change by one cycle due to waiting for templates to compute asynchronously, it would result in the Kiosk Mode reacting with a delay of one state change. Essentially, the first state change is processed by the custom UI logic, but its result might not be immediately visible until the next state update happens.

Example Scenario:

  • The Kiosk Mode plugin expects a UI element to hide when an entity's state changes.
  • The custom UI script intercepts this state update and asynchronously computes new attribute values.
  • If the entity's state or attributes are modified asynchronously, the final state is only applied after the template computation is done.
  • This causes the visual change (like hiding a UI element) to appear after the second state change instead of immediately.

Potential Solution:

To fix this, you could optimize the custom UI logic to avoid delaying the state update. For instance, the template computation could be handled in parallel or outside the critical path of _updateHass, allowing the state to be updated immediately, while the attributes are adjusted afterward.

ChatGPT suggestion for solution:

To address the delay in state changes caused by the asynchronous processing in your script, you can modify the state update logic so that the state is updated immediately, and the attribute templating is handled in the background without blocking the main state update process.

Here’s how to fix your script to ensure that the custom attributes are applied asynchronously while allowing the state to be updated promptly:

  1. Separate the State Update from the Attribute Update: We can update the state immediately and then apply the attribute template asynchronously without waiting for it to complete during the _updateHass call.

  2. Optimize the Asynchronous Handling: We can handle the asynchronous template computation in the background while still updating the state on time. This ensures that the Kiosk Mode plugin can react to state changes without delay.

Here’s a refactored version of your script:

Key Changes:

  1. Immediate State Update: The original state update logic (originalUpdate.call(this, obj)) is called immediately, ensuring the state change happens without delay.

  2. Asynchronous Attribute Application: The method applyTemplateAttributesAsync is introduced to handle attribute templating asynchronously. This ensures the state change doesn't wait for the attribute processing to finish, which was causing the delay in Kiosk Mode.

  3. Non-blocking Asynchronous Updates: The applyTemplateAttributesAsync method applies the templated attributes in the background and updates the state only if the attributes change, preventing unnecessary re-rendering and avoiding state change delays.

Result:

With this change, your state changes will propagate immediately, and any custom attribute templating will be handled asynchronously, avoiding the one-state-change delay that was affecting the Kiosk Mode plugin.

and in JS code:

// Define constants for the custom-ui component
const NAME = "Custom-attributes-templates";
const VERSION = "20240914";
const DESCRIPTION = "add attributes templates";
const URL = "https://github.com/Mariusthvdb/custom-ui";

// Log information about the custom-ui component
console.groupCollapsed(
  `%c ${NAME} ${VERSION} is installed \n%c ${DESCRIPTION}`,
  "color: red; font-weight: bold; background: black",
  "color: white; font-weight: bold; background: steelblue"
);
console.log("Readme:", URL);
console.groupEnd();

// Define the custom-ui object and its methods
window.customUI = {
  // Helper function to find an element either in shadowRoot or regular DOM
  lightOrShadow: (elem, selector) =>
    elem.shadowRoot ? elem.shadowRoot.querySelector(selector) : elem.querySelector(selector),

  // Apply template attributes to an entity's attributes
  async maybeApplyTemplateAttributes(hass, states, entity) {
    const newAttributes = {};
    const templateKeys = Object.keys(entity.attributes.templates);
    for (const key of templateKeys) {
      if (key === "state") {
        console.warn(
          `State templating is not supported anymore, please check your customization for ${entity.entity_id}`
        );
        continue;
      }
      const template = entity.attributes.templates[key];
      const value = await this.computeTemplate(
        template,
        hass,
        states,
        entity,
        entity.attributes,
        entity.untemplated_attributes?.[key] || entity.attributes[key],
        entity.untemplated_state || entity.state
      );
      if (value !== null) {
        newAttributes[key] = value;
      }
    }
    return {
      ...entity,
      attributes: {
        ...entity.attributes,
        ...newAttributes,
      },
      untemplated_attributes: entity.untemplated_attributes ?? entity.attributes,
    };
  },

  // Install a hook to update the states with template attributes
  installTemplateAttributesHook() {
    customElements.whenDefined("home-assistant").then(() => {
      const homeAssistant = customElements.get("home-assistant");
      if (!homeAssistant?.prototype?._updateHass) return;
      const originalUpdate = homeAssistant.prototype._updateHass;

      // Override _updateHass to handle state changes
      homeAssistant.prototype._updateHass = function update(obj) {
        if (obj.states) {
          for (const key of Object.keys(obj.states)) {
            const entity = obj.states[key];
            // Handle attribute templates asynchronously
            if (entity.attributes.templates) {
              window.customUI.applyTemplateAttributesAsync(this, obj.states, entity, key);
            }
          }
        }
        // Call the original state update immediately
        originalUpdate.call(this, obj);
      };
    });
  },

  // Apply template attributes asynchronously without blocking the state update
  async applyTemplateAttributesAsync(context, states, entity, key) {
    const newEntity = await window.customUI.maybeApplyTemplateAttributes(
      context.hass,
      states,
      entity
    );

    // If new attributes differ, update the state in the background
    if (JSON.stringify(entity) !== JSON.stringify(newEntity)) {
      context.hass.states[key] = newEntity;
      context._updateHass({ states: { [key]: newEntity } });
    }
  },

  // Evaluate a template expression
  computeTemplate(template, hass, entities, entity, attributes, attribute, state) {
    const functionBody = template.includes("return") ? template : `return \`${template}\`;`;
    try {
      return new Function(
        "hass",
        "entities",
        "entity",
        "attributes",
        "attribute",
        "state",
        functionBody
      )(hass, entities, entity, attributes, attribute, state);
    } catch (e) {
      console.warn(`${e.name}: ${e.message} in custom-ui template ${functionBody}`);
      return null;
    }
  },

  async init() {
    if (this.initDone) return;

    // Wait for the hass.states to be populated
    await new Promise((resolve) => {
      const intervalId = setInterval(() => {
        const main = this.lightOrShadow(document, "home-assistant");
        if (main?.hass?.states) {
          clearInterval(intervalId);
          resolve();
        }
      }, 100);
    });

    // Install the hooks and mark initialization as done
    this.installTemplateAttributesHook();
    this.initDone = true;

    // Push custom-ui information to a global list
    window.CUSTOM_UI_LIST = window.CUSTOM_UI_LIST || [];
    window.CUSTOM_UI_LIST.push({
      name: NAME,
      version: `${VERSION} ${DESCRIPTION}`,
      url: URL,
    });
  },
};

// Initialize the custom-ui component
window.customUI.init();
Mariusthvdb commented 1 month ago

ok so this now works correctly:

// Define constants for the custom-ui component
const NAME = "Custom-attributes-templates";
const VERSION = "20240814-c";
const DESCRIPTION = "add attributes templates";
const URL = "https://github.com/Mariusthvdb/custom-ui";

// Log information about the custom-ui component
console.groupCollapsed(
  `%c ${NAME} ${VERSION} is installed \n%c ${DESCRIPTION}`,
  "color: red; font-weight: bold; background: black",
  "color: white; font-weight: bold; background: steelblue"
);
console.log("Readme:", URL);
console.groupEnd();

// Define the custom-ui object and its methods
window.customUI = {
  // Helper function to find an element either in shadowRoot or regular DOM
  lightOrShadow: (elem, selector) =>
    elem.shadowRoot ? elem.shadowRoot.querySelector(selector) : elem.querySelector(selector),

  // Apply template attributes to an entity's attributes
  maybeApplyTemplateAttributes(hass, entity) {
    const newAttributes = {};
    const templateKeys = Object.keys(entity.attributes.templates || {});

    for (const key of templateKeys) {
      if (key === "state") {
        console.warn(
          `State templating is not supported anymore, please check your customization for ${entity.entity_id}`
        );
        continue;
      }

      const template = entity.attributes.templates[key];
      try {
        const value = this.computeTemplate(
          template,
          hass,
          hass.states,
          entity,
          entity.attributes,
          entity.untemplated_attributes?.[key] || entity.attributes[key],
          entity.untemplated_state || entity.state
        );

        if (value !== null) {
          newAttributes[key] = value;
        }
      } catch (error) {
        console.warn(`Error computing template for ${entity.entity_id}: ${error.message}`);
      }
    }

    // Only update attributes if there are changes
    if (Object.keys(newAttributes).length > 0) {
      return {
        ...entity,
        attributes: {
          ...entity.attributes,
          ...newAttributes,
        },
        untemplated_attributes: entity.untemplated_attributes ?? entity.attributes,
      };
    }

    return entity; // No changes, return the original entity
  },

  // Install a hook to update the states with template attributes
  installTemplateAttributesHook() {
    customElements.whenDefined("home-assistant").then(() => {
      const homeAssistant = customElements.get("home-assistant");
      if (!homeAssistant?.prototype?._updateHass) return;
      const originalUpdate = homeAssistant.prototype._updateHass;

      // Override _updateHass to handle state changes
      homeAssistant.prototype._updateHass = function update(obj) {
        if (obj.states) {
          for (const key of Object.keys(obj.states)) {
            const entity = obj.states[key];

            if (entity.attributes?.templates) {
              // Apply templates without async to avoid promise rejections
              const newEntity = window.customUI.maybeApplyTemplateAttributes(this.hass, entity);

              // Only update if the entity has changed
              if (JSON.stringify(entity) !== JSON.stringify(newEntity)) {
                obj.states[key] = newEntity;
              }
            }
          }
        }

        // Call the original state update immediately
        originalUpdate.call(this, obj);
      };
    });
  },

  // Evaluate a template expression
  computeTemplate(template, hass, entities, entity, attributes, attribute, state) {
    const functionBody = template.includes("return") ? template : `return \`${template}\`;`;
    try {
      return new Function(
        "hass",
        "entities",
        "entity",
        "attributes",
        "attribute",
        "state",
        functionBody
      )(hass, entities, entity, attributes, attribute, state);
    } catch (e) {
      console.warn(`${e.name}: ${e.message} in custom-ui template ${functionBody}`);
      return null;
    }
  },

  async init() {
    if (this.initDone) return;

    // Wait for the hass.states to be populated
    await new Promise((resolve) => {
      const intervalId = setInterval(() => {
        const main = this.lightOrShadow(document, "home-assistant");
        if (main?.hass?.states) {
          clearInterval(intervalId);
          resolve();
        }
      }, 100);
    });

    // Install the hooks and mark initialization as done
    this.installTemplateAttributesHook();
    this.initDone = true;

    // Push custom-ui information to a global list
    window.CUSTOM_UI_LIST = window.CUSTOM_UI_LIST || [];
    window.CUSTOM_UI_LIST.push({
      name: NAME,
      version: `${VERSION} ${DESCRIPTION}`,
      url: URL,
    });
  },
};

// Initialize the custom-ui component
window.customUI.init();

// ### Summary of Changes: Simplified Custom-UI Script
//
// The following changes were made to your original script to fix the Kiosk-mode issue and eliminate the endless Promise rejections:
//
// #### 1. **Removed Asynchronous Logic from State Updates**
//    - **Original**: The `maybeApplyTemplateAttributes` function was called asynchronously using `async/await` inside the `_updateHass` function.
//    - **Update**: The script now handles the state update synchronously. This eliminates the potential for race conditions or blocked state updates, making the updates immediate and more reliable for Kiosk-mode.
//
// #### 2. **Avoided Recursive or Unnecessary State Updates**
//    - **Original**: State updates were happening without checking if the entity actually changed, leading to recursive or redundant updates.
//    - **Update**: Added a check using `JSON.stringify(entity) !== JSON.stringify(newEntity)` to ensure that the state is only updated if there are actual changes in the entity's attributes. This prevents unnecessary re-triggering of state updates.
//
// #### 3. **Simplified State Handling**
//    - **Original**: The use of `async` logic within the `_updateHass` function could introduce Promise rejections or delays due to asynchronous state handling.
//    - **Update**: The function `maybeApplyTemplateAttributes` now runs synchronously and directly modifies the state, which avoids triggering rejections and ensures that Kiosk-mode works seamlessly without delays.
//
// #### 4. **Error Handling for Template Processing**
//    - **Original**: Error handling in `computeTemplate` was already present, but errors from promise rejections in state updates were not handled properly.
//    - **Update**: By keeping everything synchronous, potential errors related to promise rejections were eliminated. The existing error handling for template evaluation remains intact and now safely processes template logic.
//
// #### 5. **Simplified Logic for Applying Template Attributes**
//    - **Original**: Template attributes were being applied asynchronously for every entity within the state, leading to complexity and potential for issues.
//    - **Update**: The script now applies template attributes synchronously and only when `entity.attributes.templates` exist. The simplified structure makes the process more predictable and efficient.
//
// ### Result:
// These changes simplify the state update process by making it synchronous and ensuring state updates are only triggered when necessary. The modifications ensure that Kiosk-mode functions without delays or errors, and the issue with endless Promise rejections is fully resolved.

will update the resource accordingly

elchininet commented 1 month ago

@Mariusthvdb, Without analysing the code line by line, what ChatGPT said has a lot of sense and that is a reasonable explanation of the behaviour that you were suffering. What it is changind is to make your logic asynchronous without stopping the state update of the entities. I would say that if you test it well and it works without issues that you can update the code directly in the repo.

Mariusthvdb commented 1 month ago

thanks. merged the new changes and closing