Metadata-Version: 2.4
Name: fw-nodes-homeassistant
Version: 0.0.1a1
Summary: Home Assistant nodes for Flowire workflow automation
License-Expression: MIT
Requires-Python: >=3.13
Requires-Dist: flowire-sdk>=0.0.1a1
Requires-Dist: httpx>=0.26.0
Requires-Dist: websockets>=12.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Description-Content-Type: text/markdown

# fw-nodes-homeassistant

Home Assistant nodes for Flowire workflow automation. Replaces Node-RED for HA automations with a visual workflow editor.

## Installation

```bash
pip install fw-nodes-homeassistant
```

Enable in your backend `.env`:

```
INSTALLED_NODE_PACKAGES=fw-nodes-core,fw-nodes-homeassistant
```

> **Backend requirement:** the **HA State Changed** filter fields (`entity_ids`,
> `to_state`, `from_state`, `for_seconds`) are declared on the node via its
> `stream_filter_fields` metadata, and the Flowire backend's stream-subscription
> sync forwards them to the live WebSocket connection automatically. This
> requires a Flowire backend that honors a node's `stream_filter_fields` (current
> versions do). On an older backend that ignores it, the trigger still fires but
> those filters have no effect (it watches all entities).

## Credential Setup

All nodes use a shared **Home Assistant** credential with two fields:

| Field | Example | Description |
|-------|---------|-------------|
| **Server URL** | `http://homeassistant.local:8123` | Your HA instance URL |
| **Access Token** | `eyJhbGciOi...` | Long-lived access token |

To create a long-lived access token:
1. Open Home Assistant → click your profile (bottom-left)
2. Open the **Security** tab → scroll to **Long-Lived Access Tokens** → Create Token
3. Copy the token (it's only shown once)

## Nodes

### Triggers (Entry Points)

#### HA State Changed

Triggers when any entity's state changes. This is the most common trigger for home automation.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `credential_id` | string | required | HA credential |
| `entity_ids` | string[] | null (all) | Entity IDs to watch. Dynamic dropdown. |
| `to_state` | string | null (any) | Only fire when the entity changes **to** this state (e.g., `on`, `home`). |
| `from_state` | string | null (any) | Only fire when the entity changes **from** this state (e.g., `off`). |
| `for_seconds` | number | null | Only fire once the entity has held `to_state` continuously for this many seconds. Requires `to_state`. |

The `to_state` / `from_state` / `for_seconds` filters let you express common
automations directly in the trigger — no separate Condition or Delay node
needed.

- These filters fire on a real **state transition** (the state value actually
  changes), not on attribute-only updates. So `to_state: on` fires when a light
  turns on, not every time its brightness changes while already on.
- With `for_seconds`, the trigger fires once after the entity has held `to_state`
  for the duration. If it leaves `to_state` first, the countdown is cancelled
  (matching Home Assistant's own `for:` semantics). `for_seconds` requires
  `to_state`; without it the duration is ignored.
- **Reconnect caveat:** a WebSocket reconnect (network blip, HA restart) clears
  in-flight `for_seconds` countdowns, so a long hold spanning a reconnect restarts
  from the next state change. Keep this in mind for very long durations.

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `entity_id` | string | Entity that changed (e.g., `light.kitchen`) |
| `new_state` | string | New state value (`on`, `off`, `22.5`, etc.) |
| `old_state` | string | Previous state value |
| `new_attributes` | dict | New entity attributes (brightness, temperature, etc.) |
| `old_attributes` | dict | Previous entity attributes |
| `last_changed` | string | ISO timestamp of state change |
| `last_updated` | string | ISO timestamp of last update |
| `context` | dict | Event context (user_id, parent_id) |
| `raw_event` | dict | Full raw event payload |

**Example workflow: "Notify when front door opens"**
1. HA State Changed → entity_ids: `binary_sensor.front_door`, to_state: `on`
2. HA Call Service → `notify.mobile_app_phone` with `{"message": "Front door opened!"}`

**Example: "Garage left open for 10 minutes"** (no Delay node needed)
1. HA State Changed → entity_ids: `cover.garage_door`, to_state: `open`, for_seconds: `600`
2. HA Call Service → `notify.mobile_app_phone` with `{"message": "Garage has been open 10 minutes!"}`

---

#### HA Event Trigger

Triggers on any Home Assistant event type. More flexible than State Changed.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `credential_id` | string | required | HA credential |
| `events` | string[] | `["state_changed"]` | Event types to listen for |

**Common event types:**
- `state_changed` — Entity state updates
- `call_service` — When any service is called
- `automation_triggered` — When HA automations fire
- `script_started` — When scripts start
- `timer_finished` / `timer_started` — Timer events
- `tag_scanned` — NFC tag scans
- `homeassistant_start` / `homeassistant_stop` — System lifecycle
- `device_registry_updated` — Device changes

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `event_type` | string | Event type name |
| `data` | dict | Event payload data |
| `origin` | string | `LOCAL` or `REMOTE` |
| `time_fired` | string | ISO timestamp |
| `context` | dict | Event context |
| `raw_event` | dict | Full raw event payload |

---

#### HA Webhook

Receives HTTP webhook callbacks from Home Assistant automations.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `webhook_id` | string | null | Optional webhook ID to validate |

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `webhook_id` | string | Webhook ID from the request |
| `data` | dict | Webhook payload (`{}` when the request has no/empty body) |
| `headers` | dict | HTTP headers |
| `query` | dict | Query string parameters |
| `method` | string | HTTP method (POST, GET, etc.) |

An empty or bodyless request is accepted (useful for simple pings) and yields
`data: {}`. If `webhook_id` is set, it's validated against `webhook_id` in the
body when present.

---

### Actions

#### HA Call Service

Call any Home Assistant service. This is the primary node for controlling devices.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `credential_id` | string | required | HA credential |
| `service` | string | required | Service in `domain.action` format. Dynamic dropdown. |
| `entity_id` | string | null | Target entity. Dynamic dropdown. |
| `area_id` | string | null | Target a whole **area** by ID (e.g., `kitchen`). |
| `device_id` | string | null | Target a **device** by ID (affects all its entities). |
| `label_id` | string | null | Target every entity carrying a **label** by ID. |
| `service_data` | dict | null | Additional parameters for the service |
| `return_response` | bool | false | Request response data from the service (HA 2023.7+). Required for services that *return* data. |

You can target a single `entity_id` **or** a whole group via `area_id` /
`device_id` / `label_id` (e.g. `light.turn_off` with `area_id: kitchen` turns
off every light in the kitchen). Combine multiple targets if needed.

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `success` | bool | Whether the call succeeded |
| `service` | string | Service that was called |
| `changed_states` | list | Entity states that changed as a result |
| `response` | dict | Service response data (only when `return_response` is enabled) |

##### Service response data (`return_response`)

Some Home Assistant services *return* data instead of (or in addition to)
changing state. Enable `return_response` to call them and read the result from
the `response` output.

**Get the weather forecast:**
```
service: weather.get_forecasts
entity_id: weather.home
service_data: {"type": "daily"}
return_response: true
```
→ `response` contains `{"weather.home": {"forecast": [...]}}`

**Get upcoming calendar events:**
```
service: calendar.get_events
entity_id: calendar.family
service_data: {"start_date_time": "2025-01-01 00:00:00", "end_date_time": "2025-01-02 00:00:00"}
return_response: true
```

**List to-do items:**
```
service: todo.get_items
entity_id: todo.shopping_list
return_response: true
```

> Services that return data **require** `return_response: true` and will error
> without it. Conversely, most action services reject it — leave it off for
> normal calls like `light.turn_on`.

##### Target an area / device / label

**Turn off all lights in an area:**
```
service: light.turn_off
area_id: kitchen
```

**Turn on everything tagged with a label:**
```
service: homeassistant.turn_on
label_id: holiday_lights
```

##### Lights

**Turn on a light:**
```
service: light.turn_on
entity_id: light.kitchen
```

**Turn on at 75% brightness:**
```
service: light.turn_on
entity_id: light.kitchen
service_data: {"brightness_pct": 75}
```

**Set RGB color (red) at full brightness:**
```
service: light.turn_on
entity_id: light.led_strip
service_data: {"rgb_color": [255, 0, 0], "brightness": 255}
```

**Set color by name:**
```
service: light.turn_on
entity_id: light.bedroom
service_data: {"color_name": "blue", "brightness_pct": 50}
```

**Set color temperature (warm white):**
```
service: light.turn_on
entity_id: light.living_room
service_data: {"color_temp_kelvin": 2700}
```

**Transition over 5 seconds:**
```
service: light.turn_on
entity_id: light.bedroom
service_data: {"brightness_pct": 100, "transition": 5}
```

**Turn off with transition:**
```
service: light.turn_off
entity_id: light.bedroom
service_data: {"transition": 3}
```

**Toggle a light:**
```
service: light.toggle
entity_id: light.kitchen
```

**Flash a light (short or long):**
```
service: light.turn_on
entity_id: light.hallway
service_data: {"flash": "short"}
```

**Light service_data reference:**

| Parameter | Type | Description |
|-----------|------|-------------|
| `brightness` | int (0-255) | Absolute brightness |
| `brightness_pct` | int (0-100) | Brightness as percentage |
| `brightness_step` | int (-255 to 255) | Adjust relative to current |
| `brightness_step_pct` | int (-100 to 100) | Adjust relative (%) |
| `rgb_color` | [R, G, B] | RGB color (0-255 each) |
| `rgbw_color` | [R, G, B, W] | RGBW color |
| `rgbww_color` | [R, G, B, CW, WW] | RGBWW color |
| `hs_color` | [hue, sat] | Hue (0-360), Saturation (0-100) |
| `xy_color` | [x, y] | CIE xy color |
| `color_temp_kelvin` | int | Color temperature in Kelvin |
| `color_name` | string | CSS3 color name (`red`, `blue`, etc.) |
| `transition` | number | Transition time in seconds |
| `flash` | string | `"short"` or `"long"` |
| `effect` | string | Effect name (e.g., `"colorloop"`) |
| `profile` | string | `"relax"`, `"energize"`, `"concentrate"`, `"reading"` |

> Only one color parameter is allowed per call. Don't combine `rgb_color` with `color_temp_kelvin`, etc.

##### Switches

**Turn on/off:**
```
service: switch.turn_on
entity_id: switch.coffee_maker
```

**Toggle:**
```
service: switch.toggle
entity_id: switch.garage_door
```

##### Climate / Thermostats

**Set temperature:**
```
service: climate.set_temperature
entity_id: climate.thermostat
service_data: {"temperature": 22}
```

**Set temperature with HVAC mode:**
```
service: climate.set_temperature
entity_id: climate.thermostat
service_data: {"temperature": 22, "hvac_mode": "heat"}
```

**Set heat/cool range:**
```
service: climate.set_temperature
entity_id: climate.thermostat
service_data: {"target_temp_high": 24, "target_temp_low": 20}
```

**Change HVAC mode:**
```
service: climate.set_hvac_mode
entity_id: climate.thermostat
service_data: {"hvac_mode": "cool"}
```
HVAC modes: `off`, `heat`, `cool`, `heat_cool`, `auto`, `dry`, `fan_only`

**Set fan mode:**
```
service: climate.set_fan_mode
entity_id: climate.thermostat
service_data: {"fan_mode": "auto"}
```

##### Covers (Blinds, Garage Doors)

**Open / Close / Stop:**
```
service: cover.open_cover
entity_id: cover.garage_door
```
```
service: cover.close_cover
entity_id: cover.living_room_blinds
```
```
service: cover.stop_cover
entity_id: cover.living_room_blinds
```

**Set position (0=closed, 100=open):**
```
service: cover.set_cover_position
entity_id: cover.living_room_blinds
service_data: {"position": 50}
```

##### Media Players

**Play/Pause/Stop:**
```
service: media_player.media_play
entity_id: media_player.living_room_speaker
```
```
service: media_player.media_pause
entity_id: media_player.living_room_speaker
```

**Set volume (0.0 to 1.0):**
```
service: media_player.volume_set
entity_id: media_player.living_room_speaker
service_data: {"volume_level": 0.5}
```

**Play media:**
```
service: media_player.play_media
entity_id: media_player.living_room_speaker
service_data: {
  "media_content_id": "https://example.com/song.mp3",
  "media_content_type": "music"
}
```

##### Fans

**Turn on with speed:**
```
service: fan.turn_on
entity_id: fan.bedroom
service_data: {"percentage": 50}
```

**Set preset mode:**
```
service: fan.set_preset_mode
entity_id: fan.bedroom
service_data: {"preset_mode": "sleep"}
```

##### Notifications

**Send mobile notification:**
```
service: notify.mobile_app_phone
service_data: {
  "message": "Motion detected in the backyard!",
  "title": "Security Alert"
}
```

**Notification with image:**
```
service: notify.mobile_app_phone
service_data: {
  "message": "Someone is at the door",
  "title": "Doorbell",
  "data": {
    "image": "/api/camera_proxy/camera.front_door"
  }
}
```

##### Scripts & Automations

**Run a script:**
```
service: script.turn_on
entity_id: script.good_morning
```

**Trigger an automation:**
```
service: automation.trigger
entity_id: automation.morning_routine
```

**Enable/disable an automation:**
```
service: automation.turn_off
entity_id: automation.motion_lights
```

##### Locks

**Lock/Unlock:**
```
service: lock.lock
entity_id: lock.front_door
```
```
service: lock.unlock
entity_id: lock.front_door
```

##### Scenes

**Activate a scene:**
```
service: scene.turn_on
entity_id: scene.movie_night
```

##### Input Helpers

**Set an input boolean:**
```
service: input_boolean.turn_on
entity_id: input_boolean.vacation_mode
```

**Set an input number:**
```
service: input_number.set_value
entity_id: input_number.target_temperature
service_data: {"value": 22}
```

**Set an input select:**
```
service: input_select.select_option
entity_id: input_select.house_mode
service_data: {"option": "away"}
```

##### System

**Restart Home Assistant:**
```
service: homeassistant.restart
(no entity_id needed)
```

**Reload automations:**
```
service: automation.reload
(no entity_id needed)
```

---

#### HA Get State

Get the current state and attributes of a specific entity.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `credential_id` | string | required | HA credential |
| `entity_id` | string | required | Entity to query. Dynamic dropdown. |

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `entity_id` | string | Entity ID |
| `state` | string | Current state (`on`, `off`, `22.5`, etc.) |
| `attributes` | dict | All entity attributes |
| `friendly_name` | string | Human-readable name |
| `last_changed` | string | When state last changed |
| `last_updated` | string | When state was last updated |

---

#### HA Get Entities

Get multiple entities with filtering. Useful for bulk queries.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `credential_id` | string | required | HA credential |
| `domain` | string | null | Filter by domain (`light`, `switch`, `sensor`, etc.) |
| `state` | string | null | Filter by state value (`on`, `off`, `unavailable`) |
| `area` | string | null | Name match: substring of the entity's friendly name or entity_id (`kitchen`, `bedroom`). **Not** a true HA Area — see note below. |
| `entity_ids` | string[] | null | Specific entity IDs (overrides other filters) |

> **Note:** the `area` filter is a convenience **name/ID substring match**, not a
> lookup against Home Assistant's Area registry. It matches entities whose
> friendly name or entity_id contains the text. To act on a real HA Area, use
> **HA Call Service** with `area_id`.

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `entities` | list | List of matching entity states |
| `count` | int | Number of matches |

**Example: "Get all lights that are on"**
```
domain: light
state: on
```

**Example: "Get all kitchen entities"**
```
area: kitchen
```

---

#### HA Get History

Fetch historical state data for an entity.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `credential_id` | string | required | HA credential |
| `entity_id` | string | required | Entity to get history for. Dynamic dropdown. |
| `hours` | number | 24 | Hours of history to retrieve |
| `minimal_response` | bool | true | Return minimal data for better performance |
| `no_attributes` | bool | false | Exclude attributes for faster queries |

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `entity_id` | string | Entity ID |
| `states` | list | Historical state entries |
| `count` | int | Number of entries |
| `start_time` | string | Start of period (ISO) |
| `end_time` | string | End of period (ISO) |

> With `minimal_response: true` (the default) HA returns reduced entries — only
> `state` and `last_changed` for most entries, **without** `attributes`. Set
> `minimal_response: false` if you need full state objects (e.g. to read
> `states[i].attributes`).

---

#### HA Fire Event

Fire a custom event on the Home Assistant event bus.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `credential_id` | string | required | HA credential |
| `event_type` | string | required | Event type to fire |
| `event_data` | dict | null | Data to include with the event |

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `success` | bool | Whether the event was fired |
| `event_type` | string | Event type that was fired |
| `message` | string | HA response message |

**Example: Trigger a HA automation from Flowire**
```
event_type: flowire_alert
event_data: {"level": "warning", "room": "garage", "message": "Smoke detected"}
```
Then in HA, create an automation that triggers on `flowire_alert` events.

---

#### HA Render Template

Render a Jinja2 template server-side on Home Assistant.

**Inputs:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `credential_id` | string | required | HA credential |
| `template` | string | required | Jinja2 template using HA template syntax. Sent to HA **verbatim**. |
| `variables` | dict | null | Variables made available to the template. Values support Flowire `{{...}}` expressions. |

> **Important — `{{ }}` is HA's, not Flowire's.** Because Home Assistant
> templates use the same `{{ }}` braces as Flowire expressions, the `template`
> field is sent to Home Assistant **exactly as written** and is *not* processed
> by Flowire's expression engine. To pull data from an upstream node into a
> template, put it in `variables` (which *is* Flowire-parsed) and reference it
> by name:
>
> ```
> template:  "Alert for {{ room }}: {{ states('sensor.' ~ room ~ '_temp') }}°C"
> variables: {"room": "{{SomeNode.room_name}}"}
> ```

**Outputs:**

| Field | Type | Description |
|-------|------|-------------|
| `result` | string | Rendered template output |

**Example templates:**

Get a sensor value:
```
{{ states("sensor.temperature") }}
```

Check if entity is in a state:
```
{{ is_state("light.kitchen", "on") }}
```

Count lights that are on:
```
{{ states.light | selectattr("state", "eq", "on") | list | count }}
```

Format a message with multiple values:
```
Temperature: {{ states("sensor.temperature") }}°C, Humidity: {{ states("sensor.humidity") }}%
```

Calculate average of multiple sensors:
```
{% set temps = [
  states("sensor.kitchen_temp") | float,
  states("sensor.bedroom_temp") | float,
  states("sensor.living_room_temp") | float
] %}
{{ (temps | sum / temps | count) | round(1) }}
```

---

## Example Workflows

### Motion-activated lights
1. **HA State Changed** → `binary_sensor.motion_living_room`, to_state: `on`
2. **HA Call Service** → `light.turn_on`, entity: `light.living_room`, data: `{"brightness_pct": 80, "color_temp_kelvin": 3000}`

### Thermostat schedule
1. **Cron Trigger** → `0 7 * * 1-5` (7 AM weekdays)
2. **HA Call Service** → `climate.set_temperature`, entity: `climate.thermostat`, data: `{"temperature": 22, "hvac_mode": "heat"}`

### Daily energy report
1. **Cron Trigger** → `0 20 * * *` (8 PM daily)
2. **HA Render Template** → `{{ states("sensor.daily_energy") }} kWh used today`
3. **HA Call Service** → `notify.mobile_app_phone`, data: `{"message": "{{render-template.result}}", "title": "Energy Report"}`
   > Reference the Render Template node by its **node id** (`{{render-template.result}}`), not its display name — pick it from the expression autocomplete in the editor.

### Garage door left open alert
1. **HA State Changed** → `cover.garage_door`, to_state: `open`, for_seconds: `600`
2. **HA Call Service** → `notify.mobile_app_phone`, data: `{"message": "Garage door has been open for 10 minutes!"}`

## Development

```bash
just install    # Install dependencies
just test       # Run tests
just lint       # Run linter
just format     # Format code
just check      # Lint + format check
```
