-
Notifications
You must be signed in to change notification settings - Fork 0
Add dynamic dropdown/selector options from entity attributes and templates (Ref #137) #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
3d7a69e
c3c0f7e
6180aa3
9486e5b
3a39c57
8cc2178
a4eca89
f343f58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,10 +10,24 @@ import { CSSResult, LitElement, PropertyValues, css, html } from 'lit'; | |
| import { customElement, property, state } from 'lit/decorators.js'; | ||
|
|
||
| import { load } from 'js-yaml'; | ||
| import { UPDATE_AFTER_ACTION_DELAY } from '../models/constants'; | ||
| import { ActionType, IAction, IActions, IEntry } from '../models/interfaces'; | ||
| import { AUTOFILL, UPDATE_AFTER_ACTION_DELAY } from '../models/constants'; | ||
| import { | ||
| ActionType, | ||
| IAction, | ||
| IActions, | ||
| IEntry, | ||
| IOption, | ||
| } from '../models/interfaces'; | ||
| import { MdRipple } from '../models/interfaces/MdRipple'; | ||
| import { deepGet, deepSet, getDeepKeys } from '../utils'; | ||
| import { | ||
| buildTemplatedOption, | ||
| deepGet, | ||
| deepSet, | ||
| defaultOptionAction, | ||
| getDeepKeys, | ||
| parseOptionsList, | ||
| resolveOptionsAttribute, | ||
| } from '../utils'; | ||
| import { handleConfirmation } from '../utils/cardHelpers'; | ||
|
|
||
| @customElement('base-custom-feature') | ||
|
|
@@ -364,7 +378,8 @@ export class BaseCustomFeature extends LitElement { | |
|
|
||
| this.valueAttribute = ( | ||
| this.renderTemplate( | ||
| (this.config.value_attribute as string) ?? 'state', | ||
| (this.config.value_attribute as string) ?? | ||
| this.defaultValueAttribute(), | ||
|
Comment on lines
+381
to
+382
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a dynamic dropdown/selector omits Useful? React with 👍 / 👎. |
||
| ) as string | ||
| ).toLowerCase(); | ||
| if (!this.hass.states[this.entityId]) { | ||
|
|
@@ -491,6 +506,230 @@ export class BaseCustomFeature extends LitElement { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Options resolved from `config.options`, used by dropdowns and selectors. | ||
| * When `config.options` is a list it is used directly; when it is a template | ||
| * string it is rendered, parsed into a list, and one option is generated per | ||
| * item using `config.option_template`. | ||
| */ | ||
| options: IOption[] = []; | ||
| private optionsSignature?: string; | ||
|
|
||
| /** | ||
| * Resolve `config.options` into the concrete `options` array. | ||
| * | ||
| * Returns whether the resolved options changed and an update is required. | ||
| * Dynamic sources (attribute or template) are cached on a signature of the | ||
| * source value so that the (potentially large) list is only rebuilt when the | ||
| * source actually changes, keeping frequent hass updates cheap. | ||
| */ | ||
| setOptions(): boolean { | ||
| const config = this.config.options; | ||
|
|
||
| // Explicit list of options (backwards compatible, unchanged behavior). The | ||
| // signature snapshot detects in-place edits to option objects, which a | ||
| // reference comparison would miss. | ||
| if (Array.isArray(config)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a user converts an existing manual dropdown/selector by adding Useful? React with 👍 / 👎. |
||
| const signature = `list:${JSON.stringify(config)}`; | ||
| if (signature == this.optionsSignature) { | ||
| return false; | ||
| } | ||
| this.optionsSignature = signature; | ||
| // A shallow copy avoids aliasing the config array. | ||
| this.options = [...(config as IOption[])]; | ||
| return true; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an explicit options array contains primitive entries such as Useful? React with 👍 / 👎. |
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Dynamic source: an entity attribute or a template that renders to a list. | ||
| const source = this.resolveDynamicOptions(); | ||
| if (!source) { | ||
| this.optionsSignature = undefined; | ||
| if (this.options.length) { | ||
| this.options = []; | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| if (source.signature == this.optionsSignature) { | ||
| return false; | ||
| } | ||
| this.optionsSignature = source.signature; | ||
| this.options = source.items | ||
| .filter(Boolean) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an attribute or JSON/YAML template source returns primitive options like Useful? React with 👍 / 👎. |
||
| .map((item) => this.buildOption(item, source.attribute)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the `options_attribute`/`options_entity` source into the entity and | ||
| * attribute to read. A blank `options_attribute:` in YAML is null (not | ||
| * undefined), so a strict check is used to treat its presence — even when | ||
| * empty — as opt-in. Returns undefined when no attribute source is configured. | ||
| */ | ||
| private resolveAttributeSource(): | ||
| | { entityId: string; attribute: string } | ||
| | undefined { | ||
| // No attribute source when no attribute fields are set, or when an explicit | ||
| // `options` template is present (it wins over leftover attribute-source | ||
| // fields, so a hand-written config containing both renders the template | ||
| // instead of silently reading the attribute). | ||
| if ( | ||
| (this.config.options_attribute === undefined && | ||
| this.config.options_entity === undefined) || | ||
| (typeof this.config.options == 'string' && this.config.options.trim()) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| ) { | ||
| return undefined; | ||
| } | ||
| // Resolve from the current config (not the cached entityId, which is stale | ||
| // after a config-only entity change) and fall back to the feature entity | ||
| // when the source entity is unset or blank (`||`, so '' falls through). | ||
| const entityId = String( | ||
| this.renderTemplate( | ||
| (this.config.options_entity || this.config.entity_id || '') as string, | ||
| ), | ||
| ); | ||
| const attribute = resolveOptionsAttribute( | ||
| String( | ||
| this.renderTemplate((this.config.options_attribute || '') as string), | ||
| ), | ||
| entityId, | ||
| ); | ||
| return { entityId, attribute }; | ||
| } | ||
|
|
||
| /** | ||
| * Default value attribute to track for the current options source, so the | ||
| * selected option is highlighted without configuring `value_attribute`. Falls | ||
| * back to 'state' when there is no recognized attribute source. | ||
| */ | ||
| private defaultValueAttribute(): string { | ||
| const attrSource = this.resolveAttributeSource(); | ||
| if (attrSource?.attribute) { | ||
| // Derive from the controlled (feature) entity's domain, not the source | ||
| // entity's, so a cross-domain source (e.g. an input_select reading a | ||
| // light's effect_list) tracks the feature entity's state instead of an | ||
| // attribute it does not have. | ||
| const featureEntity = String( | ||
| this.renderTemplate((this.config.entity_id ?? '') as string), | ||
| ); | ||
| const action = defaultOptionAction( | ||
| featureEntity.split('.')[0], | ||
| attrSource.attribute, | ||
|
kristofferR marked this conversation as resolved.
|
||
| ); | ||
| if (action) { | ||
| return action.value_attribute; | ||
| } | ||
| } | ||
| return 'state'; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a dynamic options source into a raw list of items plus a cache | ||
| * signature. Supports reading a list straight from an entity attribute | ||
| * (`options_attribute`/`options_entity`) and rendering an `options` template | ||
| * string. Returns undefined when no dynamic source is set. | ||
| */ | ||
| private resolveDynamicOptions(): | ||
| | { items: unknown[]; signature: string; attribute?: string } | ||
| | undefined { | ||
| const config = this.config.options; | ||
|
|
||
| // The generated options also depend on the option template and the default | ||
| // action inputs, so they must be part of the cache signature — otherwise an | ||
| // option_template edit that leaves the source list unchanged would not | ||
| // rebuild the resolved options. The feature entity is rendered (not the raw | ||
| // template or the cached entityId) so changing the controlled entity — even | ||
| // via a template whose value changes — invalidates the cache. | ||
| const optionSignature = JSON.stringify([ | ||
| this.config.option_template ?? null, | ||
| this.config.autofill_entity_id ?? null, | ||
|
Comment on lines
+664
to
+665
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| String(this.renderTemplate((this.config.entity_id ?? '') as string)), | ||
| ]); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Attribute source: `options_attribute`/`options_entity`. | ||
| const attrSource = this.resolveAttributeSource(); | ||
| if (attrSource) { | ||
| const { entityId, attribute } = attrSource; | ||
| if (!attribute) { | ||
| return undefined; | ||
| } | ||
| const value = | ||
| entityId && this.hass?.states?.[entityId] | ||
| ? deepGet(this.hass.states[entityId].attributes, attribute) | ||
| : undefined; | ||
| return { | ||
| items: parseOptionsList(value), | ||
| signature: `attr:${entityId}:${attribute}:${JSON.stringify(value ?? null)}:${optionSignature}`, | ||
| attribute, | ||
| }; | ||
| } | ||
|
|
||
| // Template source. | ||
| if (typeof config == 'string' && config.trim()) { | ||
| const rendered = String(this.renderTemplate(config)); | ||
| return { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an Useful? React with 👍 / 👎. |
||
| items: parseOptionsList(rendered), | ||
| signature: `tmpl:${rendered}:${optionSignature}`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass the raw template result into
Suggested fix // Template source.
if (typeof config == 'string' && config.trim()) {
- const rendered = String(this.renderTemplate(config));
+ const rendered = this.renderTemplate(config as string) as unknown;
return {
items: parseOptionsList(rendered),
- signature: `tmpl:${rendered}:${optionSignature}`,
+ signature: `tmpl:${JSON.stringify(rendered ?? null)}:${optionSignature}`,
};
}🤖 Prompt for AI Agents |
||
| }; | ||
| } | ||
|
|
||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Build a single generated option from a list item, applying the option | ||
| * template and a default action derived from the source attribute, so that | ||
| * generating options from common list attributes (effects, sources, modes, | ||
| * select options, …) works with no action configuration. | ||
| */ | ||
| private buildOption(item: unknown, sourceAttribute?: string): IOption { | ||
| const option = buildTemplatedOption(item, this.config.option_template); | ||
|
kristofferR marked this conversation as resolved.
kristofferR marked this conversation as resolved.
|
||
|
|
||
| // The feature entity the action controls. Resolved from the current config | ||
| // (not the cached entityId, which is stale after a config-only change). | ||
| const featureEntity = String( | ||
| this.renderTemplate((this.config.entity_id ?? '') as string), | ||
| ); | ||
|
|
||
| // Inherit the parent feature's entity, like the editor autofill does for | ||
| // manual options, so option templates can use `{{ config.entity }}`. | ||
| if (featureEntity && option.entity_id == undefined) { | ||
| option.entity_id = featureEntity; | ||
| } | ||
|
|
||
| // Provide a sensible default action so generating options is zero | ||
| // configuration (mirrors the editor autofill behavior for manual options). | ||
| // The option template can opt out per option with `autofill_entity_id: false`. | ||
| const autofill = this.renderTemplate( | ||
| (option.autofill_entity_id ?? | ||
| this.config.autofill_entity_id ?? | ||
| AUTOFILL) as unknown as string, | ||
| ); | ||
|
Comment on lines
+730
to
+734
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For dynamic Useful? React with 👍 / 👎. |
||
| if ( | ||
| autofill && | ||
| !option.tap_action && | ||
| !option.double_tap_action && | ||
| !option.hold_action && | ||
| !option.momentary_start_action && | ||
| !option.momentary_repeat_action && | ||
| !option.momentary_end_action | ||
| ) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| const domain = featureEntity.split('.')[0]; | ||
| const action = defaultOptionAction(domain, sourceAttribute ?? ''); | ||
|
kristofferR marked this conversation as resolved.
|
||
| if (action) { | ||
| option.tap_action = { | ||
| action: 'perform-action', | ||
| perform_action: action.perform_action, | ||
| data: { [action.data_key]: option.option }, | ||
| target: { entity_id: featureEntity }, | ||
| } as IAction; | ||
| } | ||
| } | ||
|
|
||
| return option; | ||
| } | ||
|
|
||
| renderTemplate( | ||
| str: string | number | boolean, | ||
| context?: object, | ||
|
|
@@ -514,6 +753,9 @@ export class BaseCustomFeature extends LitElement { | |
| currentY: this.currentY, | ||
| deltaX: this.deltaX, | ||
| deltaY: this.deltaY, | ||
| // Convenience alias for an option's own value (see issue #198), so that | ||
| // option templates can use `{{ option }}` as well as `{{ config.option }}`. | ||
| option: (this.config as IOption).option, | ||
| config: { | ||
| ...this.config, | ||
| entity: this.entityId, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Clarify the blank
options_attributesyntax in the example.Line 2537 shows
options_attribute:with no value after the colon. While this is valid YAML (parses asnull), it may confuse readers. Consider either:optionsattribute"options_attributekey at all (since the docs state select/input_select default tooptions)The second option would be clearer and more consistent with the "you can leave the value blank and omit
option_template" phrasing in the text above.📝 Clearer example
```yaml - type: dropdown entity_id: input_select.scene - options_attribute:Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
@README.mdaround lines 2532 - 2538, The example shows an emptyoptions_attribute:which is valid YAML but confusing; update the snippet forclarity by removing the
options_attributeline entirely (sinceselect/input_select default to the
optionsattribute) or replace it with aninline comment like
# uses default "options" attributeso readers understandthis signals the default behavior for
select/input_select(referenceoptions_attribute,option_template,options, and the example entityinput_select.scene).