Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
48 changes: 48 additions & 0 deletions docs/plugin-dev/concepts/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Action>` or `<MenuItem>`, 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
<!-- Correct -->
<Action id="setHeatSetpoint" uiPath="DeviceActions">
<Action id="readMeter" uiPath="EnergyActions">

<!-- Wrong — crashes the Indigo client -->
<Action id="setHeatSetpoint" uiPath="Device Actions">
<Action id="readMeter" uiPath="Energy Actions">

<!-- Special — Indigo's reserved literal for "no menu shown" -->
<Action id="internalAction" uiPath="hidden">
```

`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 `<Action>` element, not
the user-facing menu name.
2. **The prop names** — the `id` of every `<Field>` inside the action's
`<ConfigUI>`. 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
Expand Down
143 changes: 143 additions & 0 deletions docs/plugin-dev/concepts/devices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions docs/plugin-dev/concepts/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion docs/plugin-dev/concepts/plugin-preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/plugin-dev/troubleshooting/common-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
Loading