# Hassette

[![PyPI version](https://badge.fury.io/py/hassette.svg)](https://badge.fury.io/py/hassette)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

A modern, async-first Python framework for building Home Assistant automations with type safety and developer experience in mind.

Hassette brings the developer experience of modern Python projects to Home Assistant automation development. Think of it as an alternative to AppDaemon, built with today's Python tooling and best practices.

## ✨ Key Features

- **🌟 Async-first**: Built on `asyncio` with proper async/await patterns
- **🔧 Type-safe**: Full typing support for entities, events, and configurations
- **⚡ Event-driven**: Powerful event bus with flexible filtering and routing
- **⏰ Flexible scheduling**: Custom scheduler with cron and interval support
- **🎯 Simple configuration**: TOML-based app configuration with Pydantic validation
- **🧪 Tested**: Core and utilities are tested with unit and integration tests, user testing framework is coming soon!

## 🚀 Quick Start

### Installation

#### Local Development
```bash
uv pip install hassette && run-hassette
```

#### Docker
There is an official Docker image available on GitHub Container Registry. You can run it with Docker Compose:

```yaml
services:
  hassette:
    image: ghcr.io/nodejsmith/hassette:latest
    container_name: hassette
    pull_policy: always
    restart: unless-stopped
    volumes:
      - ./config:/config # where your hassette.toml (and optional .env file) will be located
      - ./src:/apps # or wherever your app files are located
      - data:/data # persistent data (e.g. sqlite db)
      - uv_cache:/uv_cache # uv cache for faster startup

volumes:
  uv_cache:
  data:
```

```bash
docker compose up -d
```

### Configuration

Create `hassette.toml`:

```toml
[hassette]
base_url = "localhost:8123"
app_dir = "src/apps"  # Directory where your app files are located

# App Configuration will be shown below
```

## 📚 Core Concepts

### Apps

Apps are the building blocks of your automations. They can:
- 👂 Listen to Home Assistant events and state changes
- ⏰ Schedule recurring tasks
- 📞 Call Home Assistant services
- 💾 Maintain their own state and configuration

### Configuration

Apps are configured via a `hassette.toml` file using Pydantic models for validation. Custom configuration is optional but recommended for most use cases.

**App with typed configuration:**
```python
from hassette import App, AppConfig
from pydantic import Field

class MyAppConfig(AppConfig):
    # you can add a model config and set an env_prefix to simplify your environment variables
    model_config = SettingsConfigDict(env_prefix="myapp_")

    # this way you can set environment variables like:
    # e.g. MYAPP_ENTITY_ID=10
    # note this this will default to a single underscore and being case-insensitive

    # otherwise you can set this with HASSETTE__APPS__MYAPP__CONFIG__ENTITY_ID=10
    # which is not the most user friendly

    # attributes
    entity_id: str = Field(..., description="Entity ID of the light")
    brightness_when_home: int = Field(200, ge=0, le=255)
    brightness_when_away: int = Field(50, ge=0, le=255)

# `App` is generic, use your config class as type parameter when inheriting
class MyApp(App[MyAppConfig]):
    async def initialize(self):
        # self.app_config is now fully typed
        assert type(self.app_config) is MyAppConfig

        # Fully typed access to configuration
        light_id = self.app_config.entity_id
        brightness = self.app_config.brightness_when_home
```

**Configuration file (`hassette.toml`):**
```toml
[apps.my_app]  # Validated by AppManifest during Hassette startup
enabled = true # defaults to true, so only technically required if you want to disable the app
filename = "my_app.py" # the file name within `app_dir`
class_name = "MyApp" # the class name within that file (e.g. `class MyApp(App): ...`)
# inline config - valid if only one instance of the app is needed
config = {entity_id = "light.living_room", brightness_when_home = 200, brightness_when_away = 50}

# config as a separate table - required if multiple instances of the same app are needed
# [apps.my_app.config] or [[apps.my_app.config]] for multiple instances
# entity_id = "light.living_room"
# brightness_when_home = 200
# brightness_when_away = 50
```

### Event Handling

Subscribe to state changes with powerful filtering options:

```python
# Listen to all light state changes
self.bus.on_entity("light.*", handler=self.light_changed)

# Listen to specific state transitions
self.bus.on_entity("binary_sensor.motion", handler=self.motion_detected, changed_to="on")

# Listen to specific attribute changes
self.bus.on_attribute("mobile_device.my_phone", "battery_level", self.battery_level_changed)

# Listen to Hassette events (including your own apps' events)
self.bus.on_hassette_service_started(handler=self.my_app_started)
```

**Advanced filtering with complex conditions:**
```python
from hassette import predicates

# Create specific attribute change filters
light_color_changed = predicates.AttrChanged("color", to="alice_blue")
brightness_changed = predicates.AttrChanged("brightness", from_=0)  # leaving 'to' undefined means "any value"

# Combine multiple conditions
self.bus.on_entity("light.*", handler=self.light_changed,
                   where=predicates.AllOf(brightness_changed, light_color_changed))
```

### Scheduling

Schedule tasks with cron expressions (courtesy of [`croniter`](https://github.com/pallets-eco/croniter)) or intervals/specific times (via [`whenever`](https://github.com/ariebovenberg/whenever)):

```python
# Every day at 6 AM
self.scheduler.run_cron(self.morning_routine, hour=6)

# Every 30 seconds
from whenever import TimeDelta
self.scheduler.run_every(self.check_sensors, TimeDelta(seconds=30))
# or simply:
self.scheduler.run_every(self.check_sensors, interval=30)

# One-time delayed execution
self.scheduler.run_in(self.delayed_task, delay=60*5)  # In 5 minutes
```

### Type Safety

Hassette provides comprehensive typing for Home Assistant entities and events:

```python
async def handle_light_change(self, event: StateChangeEvent[LightState]):
    # Event structure: event → payload → data
    # - event: Contains payload and topic (used for bus filtering)
    # - payload: Either HassPayload or HassettePayload with event_type and data
    # - data: The actual state change information

    light = event.payload.data
    if light.new_state_value == "on":
        brightness = light.new_state.attributes.brightness
        self.logger.info(f"Light turned on with brightness {brightness}")

    # Type information available at every level:
    reveal_type(light)                           # StateChangePayload[LightState]
    reveal_type(light.new_state)                 # LightState
    reveal_type(light.new_state.value)           # str (the "state" value)
    reveal_type(light.new_state.attributes.color_temp_kelvin)  # float
```

## 📁 Project Structure

```
your_project/
├── hassette.toml          # Configuration
├── apps/
│   ├── battery_monitor.py
│   ├── presence.py
│   └── climate_control.py
└── .env                   # Environment variables (optional)
```

## 🔧 Advanced Features

### Multiple App Instances

Configure multiple instances of the same app with different configurations:

```toml
[apps.presence]
filename = "presence.py"
class_name = "PresenceApp"

# Multiple instances using [[apps.presence.config]]
[[apps.presence.config]]
name = "upstairs"
motion_sensor = "binary_sensor.upstairs_motion"
lights = ["light.bedroom", "light.hallway"]

[[apps.presence.config]]
name = "downstairs"
motion_sensor = "binary_sensor.downstairs_motion"
lights = ["light.living_room", "light.kitchen"]
```

### Synchronous Apps

For simpler synchronous use cases, use `AppSync`. Only a few changes are required:

1. Inherit from `AppSync` instead of `App`.
2. Implement `initialize_sync` instead of `initialize`.
3. Use the `.sync` API for Home Assistant calls.

```python
from hassette import AppSync

class SimpleApp(AppSync[AppConfig]):
    def initialize_sync(self):
        # scheduler and bus are available in sync apps too with no changes required
        self.scheduler.run_in(self.check_batteries, 10)
        self.bus.on_entity("*", handler=self.handle_sensor_event)

    def my_task(self):
        # Use .sync API for synchronous Home Assistant calls
        states = self.api.sync.get_states()
        # All async API methods have sync equivalents
```


## 📖 Examples

Check out the [`examples/`](examples/) directory for more complete examples:
- [Battery monitoring](examples/battery.py)
- [Presence detection](examples/presence.py)
- [Sensor notifications](examples/sensor_notification.py)

## 🛣️ Status & Roadmap

Hassette is brand new and under active development. We follow semantic versioning and recommend pinning a minor version while the API stabilizes.

### Current Focus Areas

- 📚 **Comprehensive documentation**
- 🔐 **Enhanced type safety**: Service calls/responses, additional state types
- 🏗️ **Entity classes**: Include state data and service functionality (e.g. `LightEntity.turn_on()`)
- 🔄 **Enhanced error handling**: Better retry logic and error recovery
- 🧪 **Testing improvements**:
  - 📊 More tests for core and utilities
  - 🛠️ Test fixtures and framework for user apps
  - 🚫 No more manual state changes in HA Developer Tools for testing!
- 🐳 **Docker deployment support**

See the full [roadmap](roadmap.md) for details - open an issue or PR if you'd like to contribute or provide feedback!

## 🤝 Contributing

Hassette is in active development and contributions are welcome! Whether you're:

- 🐛 Reporting bugs
- 💡 Suggesting features
- 📝 Improving documentation
- 🔧 Contributing code

Early feedback and contributions help shape the project's direction.

## 📄 License

[MIT](LICENSE)

---

**Note**: Hassette requires Python 3.11+ and a running Home Assistant instance with WebSocket API access.
