diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ae8f407..c58b21f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "indigo", "source": "./", "description": "Indigo home automation development toolkit \u2014 plugin development, API integration, and control page building", - "version": "1.9.4", + "version": "1.9.5", "repository": "https://github.com/simons-plugins/indigo-claude-plugin", "license": "MIT", "keywords": [ diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 260caca..3ad870f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "indigo", - "version": "1.9.4", + "version": "1.9.5", "description": "Indigo home automation development toolkit \u2014 plugin development, API integration, and control page building", "repository": "https://github.com/simons-plugins/indigo-claude-plugin" } diff --git a/docs/plugin-dev/concepts/actions.md b/docs/plugin-dev/concepts/actions.md index b2056aa..086e92c 100644 --- a/docs/plugin-dev/concepts/actions.md +++ b/docs/plugin-dev/concepts/actions.md @@ -332,6 +332,54 @@ def actionControlUniversal(self, action, dev): self.logger.info(f"sent \"{dev.name}\" status request") ``` +## `uiPath` attribute — PascalCase, no spaces + +When you group Plugin actions under a sub-menu in Indigo's UI via +`uiPath="..."` on `` or ``, the value MUST be PascalCase +with no spaces and no punctuation. Spaces cause an +`NSInternalInconsistencyException` crash in the Indigo client (confirmed +2026-04-30 during SigenEnergyManager development). + +```xml + + + + + + + + + + +``` + +`uiPath="hidden"` is the documented reserved value that hides the action +from the user-visible Action picker (e.g. for actions only invoked from +Plugin code via `executeAction`). + +## Calling another Plugin's actions + +`indigo.server.getPlugin(plugin_id).executeAction(action_id, props=...)` +lets you invoke actions exposed by other installed Plugins. Two things +must match exactly what the target Plugin declares in its `Actions.xml`: + +1. **The action ID** — the `id` attribute on the `` element, not + the user-facing menu name. +2. **The prop names** — the `id` of every `` inside the action's + ``. A typo or guessed name is silently dropped during + cross-Plugin serialization; the action runs with missing data and no + error is raised. + +Always read the target Plugin's `Actions.xml` to confirm both. Don't +infer the action ID from the menu label, and don't guess prop names +from what feels natural ("title", "message" etc.) — they are whatever +that Plugin's author chose. + +If Indigo exposes a direct server API for the same operation (e.g. +`indigo.server.sendEmailTo(...)`), prefer it over routing through +`getPlugin(...).executeAction(...)` — fewer moving parts and no prop- +serialization layer to misbehave. + ## Action Validation ```python diff --git a/docs/plugin-dev/concepts/devices.md b/docs/plugin-dev/concepts/devices.md index 522bef7..17dfd67 100644 --- a/docs/plugin-dev/concepts/devices.md +++ b/docs/plugin-dev/concepts/devices.md @@ -295,17 +295,160 @@ dev.deviceTypeId # Type ID from Devices.xml dev.enabled # Is device enabled? ``` +## State ID naming rules (undocumented but strict) + +Indigo's Plugin host validates custom state IDs more strictly than XML or +Python identifiers permit. Violating any of these rules raises +`LowLevelBadParameterError -- illegal XML tag name character` from +`stateListOrDisplayStateIdChanged()` or `replacePluginPropsOnServer()`, +and the error message does **not** identify which key is bad. + +| Rule | OK | Not OK | +|---|---|---| +| Must start with an ASCII letter | `colorTemp`, `linkQuality` | `_internal`, `2state` | +| Body is ASCII letters and digits ONLY | `colorTempStartup`, `motionSensitivity` | `color_temp_startup`, `temp-c`, `state.foo` | +| Underscores are **forbidden** despite XML allowing them | `lastSeen` | `last_seen` | +| Non-ASCII letters are forbidden despite `str.isalnum()` accepting them | `notifie` | `notifié` | + +Convert MQTT/JSON-style snake_case to camelCase before declaring states: + +```python +def _sanitise_state_key(key): + """color_temp_startup -> colorTempStartup""" + parts = [] + cur = [] + for c in key: + if c.isascii() and c.isalnum(): + cur.append(c) + else: + if cur: parts.append("".join(cur)); cur = [] + if cur: parts.append("".join(cur)) + if not parts: return "" + sk = parts[0][0].lower() + parts[0][1:] + "".join(p[:1].upper() + p[1:] for p in parts[1:]) + if not sk[0].isalpha(): + sk = "z" + sk[:1].upper() + sk[1:] # force ASCII-letter start + return sk +``` + +Strict validator (use before every `updateStateOnServer`): + +```python +def _is_valid_state_id(key): + if not key or not key[0].isascii() or not key[0].isalpha(): + return False + return all(c.isascii() and c.isalnum() for c in key) +``` + +## Reserved state names — don't shadow native device properties + +Indigo has reserved property names on device objects (e.g. `device.batteryLevel`). +If a Plugin declares a custom state with the same name, Indigo silently routes +`updateStateOnServer()` writes to the **native property** instead of Custom States. +The state never appears in the Custom States panel and no error is raised. + +Known reserved names to avoid as custom state IDs: + +- `batteryLevel` — use `battery` (with `Integer` type) instead + +The reservation hides bugs that look like "my Plugin isn't writing the state" +when actually the write succeeded into the wrong slot. Use `Integer` rather +than `Number` for whole-number percentages so the Custom States panel renders +the value correctly. + +## Dynamic state declaration — three subtle rules + +When overriding `getDeviceStateList(dev)` to advertise states beyond what's in +Devices.xml (typical pattern: capture-all sensor Plugins, MQTT/HA bridges): + +### 1. The parent's list is a LIVE reference, not a copy + +`indigo.PluginBase.getDeviceStateList(self, dev)` returns the parser's +**internal cache** for that device type — not a fresh list. Appending to it +permanently corrupts subsequent reads: every call accumulates more duplicates, +and eventually Indigo's XML serialiser blows up. The error looks like a +random "illegal XML tag name character" failure that gets worse over time. + +Always work on a shallow copy: + +```python +def getDeviceStateList(self, dev): + state_list = list(indigo.PluginBase.getDeviceStateList(self, dev) or []) + # ...append dynamic state dicts to state_list, not to the parent return value + return state_list +``` + +### 2. `dev.pluginProps` keys cannot start with underscore + +Indigo's XML serialiser rejects `dev.replacePluginPropsOnServer({"_seenKeys": ...})` +with `LowLevelBadParameterError`. This is **distinct** from `self.pluginPrefs` +(Plugin-level prefs written via direct dict assignment) — those accept +underscore-prefixed keys fine. Only **device-level** pluginProps written via +`replacePluginPropsOnServer` are strict. + +```python +# Bad — replacePluginPropsOnServer fails +new_props["_dynamicKeys"] = ",".join(seen) +dev.replacePluginPropsOnServer(new_props) + +# Good +new_props["dynamicKeys"] = ",".join(seen) +dev.replacePluginPropsOnServer(new_props) +``` + +### 3. Roll back pluginProps on stateListOrDisplay failure + +When you persist a new state name in pluginProps and then call +`stateListOrDisplayStateIdChanged()`, the latter can fail (e.g. the new name +hits an undocumented validation rule). The pluginProps write has already +committed though — so on failure you should restore the prior value, otherwise +every subsequent message fails the same way: + +```python +seen_csv_before = dev.pluginProps.get("dynamicKeys", "") +try: + new_props = dict(dev.pluginProps) + new_props["dynamicKeys"] = ",".join(sorted(seen_after)) + dev.replacePluginPropsOnServer(new_props) + indigo.devices[dev.id].stateListOrDisplayStateIdChanged() +except Exception: + rollback = dict(dev.pluginProps) + rollback["dynamicKeys"] = seen_csv_before + dev.replacePluginPropsOnServer(rollback) + raise +``` + +## `deviceUpdated` self-loop guard + +If your Plugin calls `indigo.devices.subscribeToChanges()` AND also writes +states on its own devices, every state write fires `deviceUpdated()` again — +infinite loop unless guarded. + +The guard MUST be at the very top of `deviceUpdated()` and check `pluginId`, +not `id`. A per-device id check is not sufficient if the Plugin manages more +than one device — it doesn't prevent A→B→A→B cross-device loops. + +```python +def deviceUpdated(self, origDev, newDev): + super().deviceUpdated(origDev, newDev) + if newDev.pluginId == self.pluginId: + return # ignore our own device updates + # ...rest of the handler +``` + ## Best Practices ### State Design - Use descriptive state IDs: `temperatureSensor1` not `temp1` - Choose appropriate value types for your data - Set `UiDisplayStateId` to most important state +- camelCase ASCII only — no underscores, no non-ASCII letters +- Don't reuse reserved names like `batteryLevel` ### Device Communication - Initialize connections in `deviceStartComm()` - Clean up in `deviceStopComm()` - Handle device offline gracefully +- If you `subscribeToChanges()`, add the `pluginId` self-loop guard at the top of `deviceUpdated()` ### Configuration - Provide sensible defaults diff --git a/docs/plugin-dev/concepts/events.md b/docs/plugin-dev/concepts/events.md index e43f944..b346b8a 100644 --- a/docs/plugin-dev/concepts/events.md +++ b/docs/plugin-dev/concepts/events.md @@ -78,6 +78,46 @@ def _check_battery(self, dev, level): indigo.trigger.execute(trigger) ``` +## Common mistake — methods that don't exist + +Two patterns look like they should fire custom events but **do not** — +both raise `AttributeError`: + +```python +# ❌ WRONG — does not exist on indigo.server / ServerInfo +indigo.server.fireEvent("myEvent") + +# ❌ WRONG — NOT a built-in on PluginBase +# (ZwaveLockManager defines its own custom method with this name, +# which misleads anyone who copy-pastes from there) +self.triggerEvent("myEvent") +``` + +The correct pattern is the `triggerStartProcessing` / `triggerStopProcessing` +lifecycle plus `indigo.trigger.execute(trigger_object)`: + +```python +def __init__(self, ...): + self.event_triggers = {} # trigger.id -> trigger object + +def triggerStartProcessing(self, trigger): + self.event_triggers[trigger.id] = trigger + +def triggerStopProcessing(self, trigger): + self.event_triggers.pop(trigger.id, None) + +def fire_event(self, event_id): + """Iterate registered triggers and execute the matching ones.""" + for trigger in self.event_triggers.values(): + if trigger.pluginTypeId == event_id: + indigo.trigger.execute(trigger) +``` + +The `AttributeError` is easy to miss because it's typically caught by a broad +`except Exception` in the calling code and swallowed at debug level. A custom +event silently never fires until someone notices the trigger was never +configured. Always log trigger-execute failures at ERROR. + ## Event Callback Methods ### triggerStartProcessing diff --git a/docs/plugin-dev/concepts/plugin-preferences.md b/docs/plugin-dev/concepts/plugin-preferences.md index 672f049..f6672d3 100644 --- a/docs/plugin-dev/concepts/plugin-preferences.md +++ b/docs/plugin-dev/concepts/plugin-preferences.md @@ -77,6 +77,28 @@ def _after_sync(self): self.pluginPrefs["_syncCount"] = self.pluginPrefs.get("_syncCount", 0) + 1 ``` +> **⚠️ The `_` prefix convention applies to `self.pluginPrefs` only — NOT to +> device-level `dev.pluginProps`.** +> +> Plugin-level prefs (`self.pluginPrefs[...] = ...`) accept underscore-prefixed +> keys because they're written via direct dict mutation. Device-level +> `dev.pluginProps` written via `replacePluginPropsOnServer()` go through +> Indigo's XML serialiser which rejects keys starting with `_`: +> +> ```python +> # Fine — direct dict, no XML validation +> self.pluginPrefs["_lastSync"] = "..." +> +> # FAILS with LowLevelBadParameterError -- illegal XML tag name character +> new_props = dict(dev.pluginProps) +> new_props["_dynamicKeys"] = "..." +> dev.replacePluginPropsOnServer(new_props) +> +> # Right — same intent, valid name +> new_props["dynamicKeys"] = "..." +> dev.replacePluginPropsOnServer(new_props) +> ``` + ## Validating Preferences ```python @@ -179,7 +201,7 @@ def startup(self): ## Best Practices - Use `get()` with defaults for safe access -- Prefix hidden preferences with underscore (`_cacheTime`) +- Prefix hidden Plugin-level preferences with underscore (`_cacheTime`) — but **never** prefix device-level `dev.pluginProps` keys with `_`; those go through Indigo's XML serialiser and a leading `_` raises `LowLevelBadParameterError`. See the warning above. - Validate all user input in `validatePrefsConfigUi()` - React to changes in `closedPrefsConfigUi()` - Don't store sensitive data like passwords in plain text diff --git a/docs/plugin-dev/troubleshooting/common-issues.md b/docs/plugin-dev/troubleshooting/common-issues.md index 06a49d1..c1c77fa 100644 --- a/docs/plugin-dev/troubleshooting/common-issues.md +++ b/docs/plugin-dev/troubleshooting/common-issues.md @@ -135,6 +135,20 @@ ## Device Issues +### `LowLevelBadParameterError -- illegal XML tag name character` + +This is Indigo's catch-all rejection from the XML serialiser when something +in your `getDeviceStateList()` output, `replacePluginPropsOnServer()` payload, +or `stateListOrDisplayStateIdChanged()` refresh contains a character it +considers invalid for an XML element name. The error message does **not** +identify which key is bad. Three undocumented rules cause it; see +[concepts/devices.md → State ID naming rules](../concepts/devices.md#state-id-naming-rules-undocumented-but-strict) +for the full diagnosis. Quick checklist: + +- State IDs must be camelCase ASCII, no underscores (`colorTempStartup`, NOT `color_temp_startup`) +- `dev.pluginProps` keys via `replacePluginPropsOnServer` cannot start with `_` +- Don't append to the LIVE list returned by `indigo.PluginBase.getDeviceStateList()` — make a `list(...)` copy first + ### Device Won't Create **Check**: