├── .dockerignore ├── .github ├── codecov.yml ├── copilot-instructions.md └── workflows │ ├── build_and_publish_image.yml │ ├── python_package.yml │ └── test_and_lint.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── agents.md ├── docker-compose.ci.yml ├── docker-compose.yml ├── docs ├── _autoapi_templates │ └── index.rst ├── _static │ └── style.css ├── api.rst ├── apps.rst ├── bus.rst ├── comparisons │ ├── appdaemon.rst │ └── index.rst ├── conf.py ├── configuration.rst ├── getting-started │ ├── config.toml │ ├── first_app.py │ └── index.rst ├── index.rst └── scheduler.rst ├── examples ├── apps │ ├── battery.py │ ├── laundry_room_light.py │ ├── office_button_app.py │ ├── presence.py │ ├── sensor_notification.py │ └── sound.py ├── config │ └── hassette.toml └── docker-compose.yml ├── mise.toml ├── noxfile.py ├── pyproject.toml ├── pyrightconfig.json ├── ruff.toml ├── scripts ├── compile_requirements.py ├── docker_start.sh └── internal │ ├── add_state_docstrings.py │ └── generate_pydantic_models.py ├── src └── hassette │ ├── __init__.py │ ├── __main__.py │ ├── api │ ├── __init__.py │ ├── api.py │ └── sync.py │ ├── app │ ├── __init__.py │ ├── app.py │ ├── app_config.py │ └── utils.py │ ├── bus │ ├── __init__.py │ ├── accessors.py │ ├── bus.py │ ├── conditions.py │ ├── listeners.py │ ├── predicates.py │ └── utils.py │ ├── config │ ├── __init__.py │ ├── app_manifest.py │ ├── core_config.py │ └── sources_helper.py │ ├── const │ ├── __init__.py │ ├── colors.py │ ├── misc.py │ └── sensor.py │ ├── context.py │ ├── core.py │ ├── events │ ├── __init__.py │ ├── base.py │ ├── hass │ │ ├── __init__.py │ │ ├── hass.py │ │ └── raw.py │ └── hassette.py │ ├── exceptions.py │ ├── logging_.py │ ├── models │ ├── __init__.py │ ├── entities │ │ ├── __init__.py │ │ ├── base.py │ │ └── light.py │ ├── history.py │ ├── services.py │ └── states │ │ ├── __init__.py │ │ ├── air_quality.py │ │ ├── alarm_control_panel.py │ │ ├── assist_satellite.py │ │ ├── automation.py │ │ ├── base.py │ │ ├── calendar.py │ │ ├── camera.py │ │ ├── climate.py │ │ ├── device_tracker.py │ │ ├── event.py │ │ ├── fan.py │ │ ├── humidifier.py │ │ ├── image_processing.py │ │ ├── input.py │ │ ├── light.py │ │ ├── media_player.py │ │ ├── number.py │ │ ├── person.py │ │ ├── remote.py │ │ ├── scene.py │ │ ├── script.py │ │ ├── select.py │ │ ├── sensor.py │ │ ├── simple.py │ │ ├── siren.py │ │ ├── sun.py │ │ ├── text.py │ │ ├── timer.py │ │ ├── update.py │ │ ├── vacuum.py │ │ ├── water_heater.py │ │ ├── weather.py │ │ └── zone.py │ ├── py.typed │ ├── resources │ ├── __init__.py │ ├── base.py │ └── mixins.py │ ├── scheduler │ ├── __init__.py │ ├── classes.py │ └── scheduler.py │ ├── services │ ├── __init__.py │ ├── api_resource.py │ ├── app_handler.py │ ├── bus_service.py │ ├── file_watcher.py │ ├── health_service.py │ ├── scheduler_service.py │ ├── service_watcher.py │ └── websocket_service.py │ ├── sphinx.py │ ├── task_bucket.py │ ├── test_utils │ ├── __init__.py │ ├── fixtures.py │ ├── harness.py │ └── test_server.py │ ├── types │ ├── __init__.py │ ├── enums.py │ ├── handler.py │ ├── topics.py │ └── types.py │ └── utils │ ├── __init__.py │ ├── app_utils.py │ ├── date_utils.py │ ├── exception_utils.py │ ├── func_utils.py │ ├── glob_utils.py │ ├── hass_utils.py │ ├── request_utils.py │ ├── service_utils.py │ └── url_utils.py ├── tests ├── conftest.py ├── data │ ├── .env │ ├── device_tracker_event.json │ ├── disabled_app.py │ ├── full_history.json │ ├── hassette.toml │ ├── hassette_apps.toml │ ├── minimal_history.json │ ├── my_app.py │ └── my_app_sync.py ├── predicates │ ├── test_base_predicates.py │ ├── test_conditions.py │ ├── test_predicates.py │ ├── test_service_data_where.py │ └── test_state_predicates.py ├── test_api.py ├── test_app_utils.py ├── test_apps.py ├── test_auto_detect_apps.py ├── test_bus.py ├── test_config.py ├── test_core.py ├── test_file_watcher.py ├── test_history.py ├── test_listeners.py ├── test_scheduler.py ├── test_service_watcher.py ├── test_task_bucket.py ├── test_triggers.py ├── test_url_utils.py └── test_websocket_service.py ├── uv.lock └── volumes └── config ├── .storage ├── assist_pipeline.pipelines ├── auth ├── auth_provider.homeassistant ├── core.analytics ├── core.area_registry ├── core.config ├── core.config_entries ├── core.device_registry ├── core.entity_registry ├── core.uuid ├── frontend.user_data_72317891e301476bb6e79b9fa3b9f2ea ├── frontend.user_data_caa14e06472b499cb00545bb65e56e5a ├── homeassistant.exposed_entities ├── http ├── http.auth ├── input_button ├── input_datetime ├── lovelace.map ├── lovelace_dashboards ├── onboarding ├── person └── repairs.issue_registry ├── configuration.yaml └── known_devices.yaml /.dockerignore: -------------------------------------------------------------------------------- 1 | # VCS 2 | .git 3 | .gitignore 4 | .gitattributes 5 | 6 | # Env 7 | .env 8 | .env.* 9 | *.env.* 10 | *.env 11 | 12 | # Python 13 | __pycache__/ 14 | *.py[cod] 15 | *.pyo 16 | *.pyd 17 | *.pkl 18 | *.egg-info/ 19 | *.egg 20 | *.whl 21 | .eggs/ 22 | .python-version 23 | .venv/ 24 | venv/ 25 | ENV/ 26 | env/ 27 | .Python 28 | 29 | # Build/test artifacts 30 | build/ 31 | dist/ 32 | .coverage 33 | .coverage.* 34 | .cache/ 35 | .pytest_cache/ 36 | .mypy_cache/ 37 | .dmypy.json 38 | .pyre/ 39 | .pytype/ 40 | .tox/ 41 | .nox/ 42 | 43 | # IDE/editor 44 | .vscode/ 45 | .idea/ 46 | *.swp 47 | *.swo 48 | 49 | # Logs/temp 50 | *.log 51 | *.out 52 | *.tmp 53 | *.bak 54 | *.old 55 | 56 | # Docker 57 | Dockerfile* 58 | docker-compose*.yml 59 | .dockerignore 60 | 61 | # Project-specific ignores (add as needed) 62 | # /config/ 63 | # /data/ 64 | # /apps/ 65 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | branch: main 3 | -------------------------------------------------------------------------------- /.github/workflows/build_and_publish_image.yml: -------------------------------------------------------------------------------- 1 | name: Build & Publish Image 2 | 3 | on: 4 | push: 5 | tags: ["v*"] # only versioned releases 6 | workflow_dispatch: # manual trigger 7 | 8 | permissions: 9 | contents: read 10 | packages: write 11 | id-token: write 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | env: 17 | IMAGE_NAME: hassette 18 | REGISTRY: ghcr.io 19 | OWNER: ${{ github.repository_owner }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v3 28 | 29 | - name: Login to GHCR 30 | uses: docker/login-action@v3 31 | with: 32 | registry: ${{ env.REGISTRY }} 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Docker metadata 37 | id: meta 38 | uses: docker/metadata-action@v5 39 | with: 40 | images: ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.IMAGE_NAME }} 41 | tags: | 42 | # Always tag by short commit SHA (7 chars) 43 | type=sha,prefix=sha-,format=short 44 | 45 | # Only tag releases (v*) with version number 46 | type=ref,event=tag 47 | 48 | # Only tag latest when building on a release tag 49 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 50 | 51 | - name: Build & Push 52 | uses: docker/build-push-action@v6 53 | with: 54 | context: . 55 | push: true 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | cache-from: type=gha 59 | cache-to: type=gha,mode=max 60 | platforms: linux/amd64,linux/arm64 61 | -------------------------------------------------------------------------------- /.github/workflows/python_package.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python package 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-pypi-dists: 11 | name: Build Python package 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v6 20 | with: 21 | version: "0.8.14" 22 | activate-environment: true 23 | enable-cache: true 24 | 25 | - name: Set up Python 26 | run: uv python install 27 | 28 | - name: Install python packages 29 | run: uv sync 30 | 31 | - name: Build a binary wheel and a source tarball 32 | run: uv build 33 | 34 | - name: Publish build artifacts 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: pypi-dists 38 | path: "./dist" 39 | 40 | publish-pypi-dists: 41 | name: Publish to PyPI 42 | environment: 43 | name: release 44 | url: https://pypi.org/p/hassette 45 | needs: [build-pypi-dists] 46 | runs-on: ubuntu-latest 47 | permissions: 48 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 49 | 50 | steps: 51 | - name: Download build artifacts 52 | uses: actions/download-artifact@v4 53 | with: 54 | name: pypi-dists 55 | path: "./dist" 56 | 57 | - name: Publish distribution to PyPI 58 | uses: pypa/gh-action-pypi-publish@release/v1 59 | -------------------------------------------------------------------------------- /.github/workflows/test_and_lint.yml: -------------------------------------------------------------------------------- 1 | name: Test and Lint 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | env: 12 | TZ: America/Chicago 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.11", "3.12", "3.13"] 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Install uv and set the Python version 24 | uses: astral-sh/setup-uv@v6 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Run lint checks 29 | run: uvx pre-commit run --all-files 30 | 31 | - run: uv sync 32 | 33 | - name: Run tests with coverage 34 | run: uv run nox -t coverage -p ${{ matrix.python-version }} 35 | 36 | - name: Upload coverage data 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: .coverage.${{ matrix.python-version }} 40 | path: .coverage.${{ matrix.python-version }} 41 | include-hidden-files: true 42 | 43 | combine-and-report: 44 | needs: [test] 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v4 48 | 49 | - name: Install uv and set the Python version 50 | uses: astral-sh/setup-uv@v6 51 | with: 52 | python-version: 3.12 53 | 54 | - name: Install coverage 55 | run: | 56 | uv venv 57 | uv pip install -U 'coverage[toml]' 58 | 59 | - uses: actions/download-artifact@v5 60 | with: 61 | path: coverage-parts 62 | pattern: .coverage.* 63 | merge-multiple: true 64 | 65 | - name: Combine coverage and report 66 | run: | 67 | uv run coverage combine coverage-parts 68 | uv run coverage xml -o coverage.xml 69 | uv run coverage report 70 | 71 | - name: Upload HTML report (artifact) 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: coverage-html 75 | path: htmlcov 76 | 77 | - name: Upload XML for integrations 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: coverage-xml 81 | path: coverage.xml 82 | 83 | - name: Upload coverage to Codecov 84 | uses: codecov/codecov-action@v5 85 | with: 86 | files: coverage.xml 87 | fail_ci_if_error: true 88 | verbose: true 89 | name: codecov-${{ github.run_id }} 90 | token: ${{ secrets.CODECOV_TOKEN }} 91 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: "v6.0.0" 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: mixed-line-ending 8 | - id: check-builtin-literals 9 | - id: check-case-conflict 10 | - id: check-shebang-scripts-are-executable 11 | - id: debug-statements 12 | 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | # Ruff version. 15 | rev: "v0.14.0" 16 | hooks: 17 | # Run the linter. 18 | - id: ruff-check 19 | args: [--fix] 20 | # Run the formatter. 21 | - id: ruff-format 22 | 23 | - repo: local 24 | hooks: 25 | - id: check-codecov-yml 26 | name: Check codecov.yml validity 27 | language: system 28 | entry: curl --connect-timeout 10 --max-time 60 --fail --data-binary @./.github/codecov.yml https://codecov.io/validate 29 | types: [yaml] 30 | files: ^\.github/codecov\.yml$ 31 | pass_filenames: false 32 | 33 | - repo: https://github.com/RobertCraigie/pyright-python 34 | rev: v1.1.406 35 | hooks: 36 | - id: pyright 37 | stages: [pre-push] 38 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.11" 12 | jobs: 13 | pre_create_environment: 14 | - asdf plugin add uv 15 | - asdf install uv latest 16 | - asdf global uv latest 17 | create_environment: 18 | - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" 19 | install: 20 | - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs 21 | 22 | # Build documentation in the "docs/" directory with Sphinx 23 | sphinx: 24 | configuration: docs/conf.py 25 | fail_on_warning: false 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # ---- Builder stage ---- 4 | FROM python:3.12-alpine AS builder 5 | COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /bin/ 6 | 7 | # uncomment this if/when we need to build packages with native extensions 8 | # RUN apk add --no-cache build-base 9 | 10 | WORKDIR /app 11 | 12 | # Copy lock + manifest for dependency resolution 13 | ADD . /app 14 | 15 | ENV UV_LINK_MODE=copy 16 | 17 | # Install deps (without project) 18 | RUN --mount=type=cache,target=/root/.cache/uv \ 19 | uv sync --locked --no-install-project --no-editable --active 20 | 21 | # Install project into venv (not editable, root owns at this point) 22 | RUN --mount=type=cache,target=/root/.cache/uv \ 23 | uv sync --locked --no-editable --active 24 | 25 | # ---- Final stage ---- 26 | FROM python:3.12-alpine 27 | 28 | # System packages you want available at runtime 29 | RUN apk add --no-cache curl tini tzdata 30 | 31 | WORKDIR /app 32 | 33 | ENV UV_CACHE_DIR=/uv_cache 34 | # Set timezone to UTC as a default, user can override at runtime 35 | ENV TZ=UTC 36 | 37 | # Create non-root user first 38 | RUN addgroup -S hassette \ 39 | && adduser -S -G hassette -h /home/hassette hassette \ 40 | && chown -R hassette:hassette /home/hassette \ 41 | && mkdir -p $UV_CACHE_DIR \ 42 | && chown -R hassette:hassette $UV_CACHE_DIR \ 43 | && mkdir -p /config /data /apps \ 44 | && chown -R hassette:hassette /config /data /apps /app 45 | 46 | # Copy uv binary 47 | COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /bin/ 48 | 49 | # Copy app, venv, scripts 50 | COPY --from=builder --chown=hassette:hassette /app /app 51 | 52 | USER hassette 53 | 54 | # add OSTYPE to fix issue in python3.12 (https://github.com/python/cpython/issues/112252) 55 | ENV HOME=/home/hassette \ 56 | HASSETTE__CONFIG_DIR=/config \ 57 | HASSETTE__DATA_DIR=/data \ 58 | HASSETTE__APP_DIR=/apps \ 59 | PYTHONUNBUFFERED=1 \ 60 | UV_LINK_MODE=copy \ 61 | UV_CACHE_DIR=/uv_cache \ 62 | OSTYPE=linux \ 63 | PATH="/app/.venv/bin:$PATH" 64 | 65 | VOLUME ["/config", "/data", "/apps", "/uv_cache"] 66 | 67 | ENTRYPOINT ["tini", "--", "/app/scripts/docker_start.sh"] 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jessica Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hassette 2 | 3 | [![PyPI version](https://badge.fury.io/py/hassette.svg)](https://badge.fury.io/py/hassette) 4 | [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![Documentation Status](https://readthedocs.org/projects/hassette/badge/?version=stable)](https://hassette.readthedocs.io/en/latest/?badge=stable) 7 | [![codecov](https://codecov.io/github/NodeJSmith/hassette/graph/badge.svg?token=I3E5S2E3X8)](https://codecov.io/github/NodeJSmith/hassette) 8 | 9 | A simple, modern, async-first Python framework for building Home Assistant automations. 10 | 11 | Documentation: https://hassette.readthedocs.io 12 | 13 | Why Hassette? 14 | ------------- 15 | - 🌟 **Modern developer experience** with typed APIs, Pydantic models, and IDE-friendly design 16 | - ⚡ **Async-first architecture** designed for modern Python from the ground up 17 | - 🔍 **Simple, transparent framework** with minimal magic and clear extension points 18 | - 🎯 **Focused mission**: does one thing well — run user-defined apps that interact with Home Assistant 19 | 20 | ## AppDaemon User? 21 | 22 | We have a dedicated comparison guide for AppDaemon users considering Hassette: 23 | 24 | - [AppDaemon Comparison](https://hassette.readthedocs.io/en/latest/comparisons/index.html) 25 | 26 | ## 📖 Examples 27 | 28 | Check out the [`examples/`](https://github.com/NodeJSmith/hassette/tree/main/examples) directory for more complete examples: 29 | - Based on AppDaemon's examples: 30 | - [Battery monitoring](https://github.com/NodeJSmith/hassette/tree/main/examples/apps/battery.py) 31 | - [Presence detection](https://github.com/NodeJSmith/hassette/tree/main/examples/apps/presence.py) 32 | - [Sensor notifications](https://github.com/NodeJSmith/hassette/tree/main/examples/apps/sensor_notification.py) 33 | - Cleaned up versions of my own apps: 34 | - [Office Button App](https://github.com/NodeJSmith/hassette/tree/main/examples/apps/office_button_app.py) 35 | - [Laundry Room Lights](https://github.com/NodeJSmith/hassette/tree/main/examples/apps/laundry_room_light.py) 36 | - `docker-compose.yml` example: [docker-compose.yml](https://github.com/NodeJSmith/hassette/blob/main/examples/docker-compose.yml) 37 | - `hassette.toml` example: [hassette.toml](https://github.com/NodeJSmith/hassette/blob/main/examples/config/hassette.toml) 38 | 39 | ## 🛣️ Status & Roadmap 40 | 41 | Hassette is brand new and under active development. We follow semantic versioning and recommend pinning a minor version while the API stabilizes. 42 | 43 | Hassette development is tracked in [this project](https://github.com/users/NodeJSmith/projects/1) (still a slight work-in-progress) - open an issue or PR if you'd like to contribute or provide feedback! 44 | 45 | ### Current Focus Areas 46 | 47 | - 📚 **Comprehensive documentation** 48 | - 🔐 **Enhanced type safety**: Service calls/responses, additional state types 49 | - 🏗️ **Entity classes**: Include state data and service functionality (e.g. `LightEntity.turn_on()`) 50 | - 🔄 **Enhanced error handling**: Better retry logic and error recovery 51 | - 🧪 **Testing improvements**: 52 | - 📊 More tests for core and utilities 53 | - 🛠️ Test fixtures and framework for user apps 54 | - 🚫 No more manual state changes in HA Developer Tools for testing! 55 | 56 | ## 🤝 Contributing 57 | 58 | Hassette is in active development and contributions are welcome! Whether you're: 59 | 60 | - 🐛 Reporting bugs 61 | - 💡 Suggesting features 62 | - 📝 Improving documentation 63 | - 🔧 Contributing code 64 | 65 | Early feedback and contributions help shape the project's direction. 66 | 67 | ## 📄 License 68 | 69 | [MIT](LICENSE) 70 | -------------------------------------------------------------------------------- /docker-compose.ci.yml: -------------------------------------------------------------------------------- 1 | services: 2 | test-homeassistant: 3 | user: "0:0" 4 | 5 | hassette: 6 | # Reuse same build/image from docker-compose.yml 7 | volumes: 8 | # Mount your local source tree into the container 9 | - ./src:/app/src:cached 10 | 11 | # Mount config/data 12 | - ./tests/data:/config 13 | - ./tests/data:/data 14 | 15 | environment: 16 | - HASSETTE__LOG_LEVEL=debug 17 | - HASSETTE__CONFIG_DIR=/config 18 | - HASSETTE__DATA_DIR=/data 19 | - HASSETTE__APP_DIR=/apps 20 | - HASSETTE__BASE_URL=http://test-homeassistant:8123 21 | # Make Python look at /app/src first 22 | - PYTHONPATH=/app/src 23 | 24 | depends_on: 25 | test-homeassistant: 26 | condition: service_healthy 27 | 28 | entrypoint: ["/app/.venv/bin/python", "-m", "hassette"] 29 | command: ["run"] 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | test-homeassistant: 3 | container_name: test-homeassistant 4 | image: "homeassistant/home-assistant:stable" 5 | ports: 6 | - "8123:8123" 7 | volumes: 8 | - ./volumes/config:/config 9 | user: "1000:1000" # Use a non-root user so we can still access the files 10 | networks: 11 | - default 12 | healthcheck: 13 | test: ["CMD", "curl", "-f", "http://127.0.0.1:8123"] 14 | interval: 10s 15 | timeout: 5s 16 | retries: 5 17 | start_period: 30s 18 | 19 | hassette: 20 | # image: ghcr.io/yourusername/hassette:latest 21 | build: 22 | context: . # where Dockerfile and project live 23 | dockerfile: Dockerfile # optional if it’s literally named Dockerfile 24 | container_name: hassette 25 | restart: unless-stopped 26 | volumes: 27 | - ./config:/config 28 | - ./data:/data 29 | environment: 30 | - TZ=America/New_York 31 | - LOG_LEVEL=info 32 | networks: 33 | - default 34 | healthcheck: 35 | test: ["CMD", "curl", "-f", "http://127.0.0.1:8126/healthz"] 36 | interval: 10s 37 | timeout: 5s 38 | retries: 3 39 | 40 | networks: 41 | default: 42 | name: homeassistantapi_default 43 | -------------------------------------------------------------------------------- /docs/_autoapi_templates/index.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | This page contains auto-generated API reference documentation [#f1]_. 5 | 6 | .. toctree:: 7 | :titlesonly: 8 | :maxdepth: 5 9 | 10 | {% for page in pages|selectattr("is_top_level_object") %} 11 | {{ page.include_path }} 12 | {% endfor %} 13 | 14 | .. [#f1] Created with `sphinx-autoapi `_ 15 | -------------------------------------------------------------------------------- /docs/_static/style.css: -------------------------------------------------------------------------------- 1 | .wy-nav-content { 2 | max-width: 1000px; 3 | } 4 | 5 | 6 | /* Match link-in-code color to normal code-literal red */ 7 | .rst-content a.reference code.literal { 8 | color: #e74c3c !important; /* tweak to taste */ 9 | text-decoration: underline; /* optional — helps signal it's clickable */ 10 | } 11 | 12 | /* Optional hover state: slightly darker red for clarity */ 13 | .rst-content a.reference:hover code.literal { 14 | color: #a60000 !important; /* tweak to taste */ 15 | } 16 | -------------------------------------------------------------------------------- /docs/comparisons/index.rst: -------------------------------------------------------------------------------- 1 | Comparisons 2 | =========== 3 | 4 | There are two main contenders in the Home Assistant Python automation ecosystem: ``appdaemon`` and ``pyscript``. 5 | 6 | AppDaemon is the most similar to Hassette and was, in fact, the inspiration for it. It runs as it's own process, either self-hosted or as a Home Assistant add-on. It has an absolute ton of features, including a web dashboard, and is very mature. 7 | It does show its age a bit these days, is synchronous first, and is not very strongly typed. It also has a lot of layers of indirection, which can make it hard to debug. 8 | 9 | Pyscript is a Home Assistant integration that runs your Python inside of Home Assistant itself. It is actually a pretty brilliant bit of software, as it "implements a Python interpreter using the AST parser output" 10 | and runs that interpreter inside the Home Assistant event loop. It has similar features to AppDaemon, but does suffer (in my opinion) from being stringly typed, limited IDE support, and too much magic. Pyscript has 11 | it's own `AppDaemon comparison page `__, which is worth a read. 12 | 13 | I have spent some time writing up a detailed comparison between AppDaemon and Hassette. At this time, however, I have not done a similar comparison for Pyscript. This is partially because I have not used it enough to feel 14 | comfortable doing so, and partially because Pyscript's design is fundamentally different from both AppDaemon and Hassette. 15 | 16 | .. toctree:: 17 | :maxdepth: 1 18 | 19 | AppDaemon Comparison 20 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | # Project root → ensure AutoAPI can find files 6 | ROOT = Path(__file__).parent.parent.absolute() 7 | SRC = os.path.join(ROOT, "src") 8 | sys.path.insert(0, SRC) 9 | 10 | project = "Hassette" 11 | extensions = [ 12 | "sphinx.ext.napoleon", # Google/NumPy docstrings -> nice HTML 13 | "sphinx.ext.intersphinx", 14 | "autoapi.extension", 15 | "hassette.sphinx", # custom Sphinx helpers 16 | ] 17 | 18 | html_theme = "sphinx_rtd_theme" 19 | templates_path = ["_templates"] 20 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "_autoapi_templates", "sphinx.py"] 21 | 22 | 23 | # --- AutoAPI: parse Python source (no runtime import) --- 24 | autoapi_type = "python" 25 | autoapi_dirs = [os.path.join(SRC, "hassette")] 26 | autoapi_add_toctree_entry = True 27 | autoapi_member_order = "bysource" 28 | autoapi_keep_files = True 29 | autoapi_root = "code-reference" # where in the ToC it lands 30 | autoapi_python_class_content = "both" # class docstring + __init__ docstring 31 | autoapi_options = [ 32 | "members", 33 | "undoc-members", 34 | "show-inheritance", 35 | "show-module-summary", 36 | ] 37 | autoapi_template_dir = "_autoapi_templates" 38 | # optional: hide private/dunder unless you need them 39 | autoapi_python_use_implicit_namespaces = True 40 | autoapi_own_page_level = "function" # one page per object (nice deep linking) 41 | 42 | # --- Google docstrings tuning --- 43 | napoleon_google_docstring = True 44 | napoleon_numpy_docstring = False 45 | napoleon_use_param = True 46 | napoleon_use_rtype = True 47 | napoleon_preprocess_types = True 48 | 49 | # --- Types & cross-refs --- 50 | # AutoAPI reads annotations from source; add intersphinx so externals link 51 | intersphinx_mapping = { 52 | "python": ("https://docs.python.org/3/", None), 53 | "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), 54 | "whenever": ("https://whenever.readthedocs.io/en/latest/", None), 55 | "pydantic": ("https://docs.pydantic.dev/latest/", None), 56 | } 57 | 58 | 59 | python_use_unqualified_type_names = True 60 | 61 | # --- Make nitpicky helpful, not hateful (optional) --- 62 | nitpicky = True 63 | nitpick_ignore_regex = [ 64 | # Don't nag about std typing internals you don't want to document 65 | (r"py:.*", r"^typing(_extensions)?\."), 66 | (r"py:.*", r"^builtins\."), 67 | ] 68 | 69 | html_css_files = ["style.css"] 70 | -------------------------------------------------------------------------------- /docs/getting-started/config.toml: -------------------------------------------------------------------------------- 1 | [hassette] 2 | base_url = "http://localhost:8123" # or your HA address 3 | app_dir = "src/apps" # path containing my_app.py 4 | 5 | [apps.my_app] 6 | filename = "my_app.py" 7 | class_name = "MyApp" 8 | enabled = true 9 | # inline config for a single instance (optional) 10 | config = {} 11 | -------------------------------------------------------------------------------- /docs/getting-started/first_app.py: -------------------------------------------------------------------------------- 1 | from hassette import App, AppConfig, StateChangeEvent, states 2 | 3 | 4 | class MyConfig(AppConfig): 5 | pass 6 | 7 | 8 | class MyApp(App[MyConfig]): 9 | async def on_initialize(self): 10 | # React when any light changes 11 | self.bus.on_state_change("sun.*", handler=self.changed) 12 | 13 | async def changed(self, event: StateChangeEvent[states.SunState]): 14 | self.logger.info("Sun changed: %s", event.payload.data) 15 | -------------------------------------------------------------------------------- /docs/getting-started/index.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | This guide walks you from zero to a running Hassette instance with a tiny automation app. 5 | 6 | Prerequisites 7 | ------------- 8 | - A running Home Assistant instance with WebSocket API access 9 | - A long-lived access token from your HA profile 10 | - Either Docker Compose or Python 3.11+ with ``uv`` (or pip) 11 | 12 | 1) Create your first app 13 | ------------------------ 14 | Create a Python file in your apps directory (e.g., ``src/apps/my_app.py``): 15 | 16 | .. include:: ./first_app.py 17 | :literal: 18 | 19 | Type-safe by default 20 | ~~~~~~~~~~~~~~~~~~~~ 21 | The event passed to your handler is fully typed. For lights, ``event`` is a 22 | ``StateChangeEvent[states.LightState]``, so your editor can offer completions and 23 | catch mistakes early. 24 | 25 | .. code-block:: python 26 | 27 | # Inside your handler 28 | data = event.payload.data # StateChangePayload[LightState] 29 | brightness = data.new_state.attributes.brightness # float | None 30 | if data.new_state_value == "on": # new_state_value handles missing `new_state` for you 31 | ... # do something when the light turns on 32 | 33 | 1) Add configuration 34 | -------------------- 35 | Create ``config/hassette.toml`` (or ``hassette.toml`` in your working directory): 36 | 37 | .. include:: ./config.toml 38 | :literal: 39 | 40 | 3) Provide your Home Assistant token 41 | ------------------------------------ 42 | Export one of these environment variables before starting Hassette: 43 | 44 | .. code-block:: bash 45 | 46 | export HASSETTE__TOKEN= 47 | # or 48 | export HOME_ASSISTANT_TOKEN= 49 | 50 | 4) Run Hassette 51 | --------------- 52 | 53 | Option A - Docker Compose 54 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 55 | 56 | .. code-block:: yaml 57 | 58 | services: 59 | hassette: 60 | image: ghcr.io/nodejsmith/hassette:latest 61 | container_name: hassette 62 | restart: unless-stopped 63 | environment: 64 | HASSETTE__TOKEN: ${HASSETTE__TOKEN} 65 | volumes: 66 | - ./config:/config 67 | - ./src:/apps 68 | 69 | .. code-block:: bash 70 | 71 | docker compose up -d 72 | 73 | Option B - Local (uv) 74 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 75 | 76 | .. code-block:: bash 77 | 78 | uvx hassette -c ./config/hassette.toml -e ./config/.env 79 | 80 | To pass the token on the command line instead of env vars: 81 | 82 | .. code-block:: bash 83 | 84 | uvx hassette --token 85 | 86 | 5) Verify it's working 87 | ---------------------- 88 | - You should see log lines indicating WebSocket authentication and service startup. 89 | - Set HASSETTE__LOG_LEVEL=DEBUG to see detailed logs. 90 | 91 | Next steps 92 | ---------- 93 | - Explore the :doc:`../bus` page for powerful filtering and predicates. 94 | - Learn the :doc:`../api` for service calls, state access, and history. 95 | - Schedule recurring jobs with the :doc:`../scheduler`. 96 | - Build richer automations with typed configs and lifecycle details in :doc:`../apps`. 97 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Hassette 2 | ======== 3 | 4 | A simple, modern, async-first Python framework for building Home Assistant automations. 5 | 6 | Why Hassette? 7 | ------------- 8 | - **Modern developer experience** with typed APIs, Pydantic models, and IDE-friendly design 9 | - **Async-first architecture** designed for modern Python from the ground up 10 | - **Simple, transparent framework** with minimal magic and clear extension points 11 | - **Focused on Home Assistant** with first-class support for its APIs and event bus 12 | 13 | Getting Started 14 | ================ 15 | 16 | You can get running with Hassette in a few lines of code. 17 | 18 | 1) Copy the below to a file. 19 | 20 | .. include:: ./getting-started/first_app.py 21 | :literal: 22 | 23 | 24 | 2) Run Hassette, giving it your Home Assistant token, url, and app directory (where you saved the above file). 25 | 26 | .. code-block:: bash 27 | 28 | uvx hassette -t $HOME_ASSISTANT_TOKEN --base-url 'http://192.168.1.179:8123' --app-dir . 29 | 30 | 31 | See the :doc:`getting started guide ` for more details. 32 | 33 | 34 | Learn More 35 | ~~~~~~~~~~~~~~~~~~~~~ 36 | 37 | .. toctree:: 38 | :maxdepth: 1 39 | :caption: Next steps 40 | 41 | configuration 42 | apps 43 | api 44 | bus 45 | scheduler 46 | comparisons/index 47 | code-reference/index 48 | -------------------------------------------------------------------------------- /examples/apps/laundry_room_light.py: -------------------------------------------------------------------------------- 1 | # another actual app that I use, not meant to be comparable to any AD example app 2 | 3 | from hassette import App, AppConfig, StateChangeEvent, entities, states 4 | 5 | 6 | class LaundryRoomLightAppConfig(AppConfig): 7 | toggle_entity: str = "input_boolean.ad_laundry_room_lights" 8 | light_entity: str = "light.laundry_room" 9 | motion_entity: str = "binary_sensor.motion_sensor_9b2a" 10 | brightness_cutoff: int = 50 11 | default_brightness: int = 26 12 | 13 | 14 | class LaundryRoomLightsApp(App[LaundryRoomLightAppConfig]): 15 | toggle_entity: str = "input_boolean.ad_laundry_room_lights" 16 | 17 | async def toggle_enabled(self, event: StateChangeEvent[states.InputBooleanState]) -> None: 18 | """Handle toggling the enabled state of the app.""" 19 | 20 | self.enabled = event.payload.data.new_state_value 21 | 22 | async def on_initialize(self) -> None: 23 | """Use the `on_initialize` lifecycle hook to set up the app.""" 24 | self.prev_state = await self.api.get_state(self.app_config.light_entity, states.LightState) 25 | self.light_entity = await self.api.get_entity(self.app_config.light_entity, entities.LightEntity) 26 | 27 | self.bus.on_state_change(self.app_config.motion_entity, handler=self.motion_detected, changed_to="on") 28 | self.bus.on_state_change(self.app_config.motion_entity, handler=self.motion_cleared, changed_to="off") 29 | 30 | await self.set_enabled() 31 | 32 | async def set_enabled(self): 33 | try: 34 | self.enabled = (await self.api.get_state_value(self.toggle_entity)) == "on" 35 | self.bus.on_state_change(self.toggle_entity, handler=self.toggle_enabled) 36 | except Exception: 37 | self.logger.exception("Error setting initial enabled state") 38 | self.enabled = True 39 | 40 | async def is_in_valid_state(self) -> bool: 41 | try: 42 | brightness = (await self.light_entity.refresh()).attributes.brightness or 0 43 | 44 | return brightness < self.app_config.brightness_cutoff 45 | except Exception: 46 | self.logger.exception("Error checking light brightness") 47 | return False 48 | 49 | async def motion_cleared(self, event: StateChangeEvent[states.BinarySensorState]) -> None: 50 | data = event.payload.data 51 | if not self.enabled: 52 | self.logger.info("%s is disabled", self.toggle_entity) 53 | return 54 | 55 | if not data.new_state or data.new_state_value is not False: 56 | self.logger.debug("Received motion detected event with no new state or state not 'off'") 57 | return 58 | 59 | if not await self.is_in_valid_state(): 60 | return 61 | 62 | try: 63 | if not self.prev_state: 64 | self.logger.info("No state to revert to") 65 | return 66 | brightness = self.prev_state.attributes.brightness 67 | 68 | await self.light_entity.turn_on(brightness=brightness or self.app_config.default_brightness) 69 | except Exception: 70 | self.logger.exception("Error in motion_cleared") 71 | 72 | async def motion_detected(self, event: StateChangeEvent[states.BinarySensorState]) -> None: 73 | if not self.enabled: 74 | self.logger.info("%s is disabled", self.toggle_entity) 75 | return 76 | 77 | if not await self.is_in_valid_state(): 78 | return 79 | 80 | # store the current state to revert to later 81 | self.prev_state = await self.api.get_state("light.laundry_room", states.LightState) 82 | 83 | try: 84 | brightness = (await self.light_entity.refresh()).attributes.brightness 85 | 86 | if brightness is None: 87 | self.logger.info("Brightness is None") 88 | return 89 | 90 | new_brightness = int(brightness * 1.5) 91 | 92 | await self.light_entity.turn_on(brightness=new_brightness) 93 | 94 | except Exception: 95 | self.logger.exception("Error in motion_detected") 96 | -------------------------------------------------------------------------------- /examples/apps/sensor_notification.py: -------------------------------------------------------------------------------- 1 | # compare to: https://github.com/AppDaemon/appdaemon/blob/dev/conf/example_apps/sensor_notification.py 2 | 3 | from hassette import App, AppConfig, StateChangeEvent, states 4 | 5 | 6 | class SensorNotificationAppConfig(AppConfig): 7 | sensor: str | list[str] | None = None 8 | idle_state: str = "Idle" 9 | turn_on: str = "scene.house_bright" 10 | input_select: str | list[str] | None = None 11 | 12 | @property 13 | def sensor_as_list(self): 14 | if not self.sensor: 15 | return [] 16 | if isinstance(self.sensor, list): 17 | return self.sensor 18 | return [self.sensor] 19 | 20 | @property 21 | def input_select_as_list(self): 22 | if not self.input_select: 23 | return [] 24 | if isinstance(self.input_select, list): 25 | return self.input_select 26 | return [self.input_select] 27 | 28 | 29 | class SensorNotification(App[SensorNotificationAppConfig]): 30 | async def on_initialize(self) -> None: 31 | """Use the `on_initialize` lifecycle hook to set up the app.""" 32 | if self.app_config.sensor is None: 33 | return 34 | 35 | sensors = self.app_config.sensor if isinstance(self.app_config.sensor, list) else [self.app_config.sensor] 36 | for sensor in sensors: 37 | self.bus.on_state_change(sensor, handler=self.state_change) 38 | 39 | async def state_change(self, event: StateChangeEvent[states.SensorState]): 40 | data = event.payload.data 41 | if not data.new_state: 42 | return 43 | 44 | friendly_name = data.new_state.attributes.friendly_name or data.entity_id 45 | new = data.new_state_value 46 | 47 | if new != "": 48 | if self.app_config.input_select_as_list: 49 | valid_modes = self.app_config.input_select_as_list 50 | select = valid_modes.pop(0) 51 | is_state = await self.api.get_state_value(select) 52 | else: 53 | is_state = None 54 | valid_modes = () 55 | 56 | self.logger.info("%s changed to %s", friendly_name, new) 57 | # self.notify(f"{friendly_name} changed to {new}", name=globals.notify) 58 | 59 | if new != self.app_config.idle_state and self.app_config.turn_on and is_state in valid_modes: 60 | await self.api.turn_on(self.app_config.turn_on) 61 | -------------------------------------------------------------------------------- /examples/apps/sound.py: -------------------------------------------------------------------------------- 1 | # method taken from: https://github.com/AppDaemon/appdaemon/blob/dev/conf/example_apps/sound.py 2 | # but overall app is not meant to match 3 | 4 | import asyncio 5 | 6 | from hassette import App, AppConfig 7 | 8 | 9 | class SoundAppConfig(AppConfig): 10 | player: str = "media_player.living_room_echo" 11 | 12 | 13 | class Sound(App[SoundAppConfig]): 14 | async def tts(self, text: str, volume: float, length: float): 15 | # Save current volume 16 | current_volume = await self.api.get_attribute(self.app_config.player, attribute="volume_level") 17 | 18 | # Set to the desired volume 19 | await self.api.call_service("volume_set", "media_player", entity_id=self.app_config.player, volume_level=volume) 20 | 21 | # Call TTS service 22 | await self.api.call_service("amazon_polly_say", "tts", entity_id=self.app_config.player, message=text) 23 | 24 | # Wait for the length of the message 25 | await asyncio.sleep(length) 26 | 27 | # Restore original volume 28 | await self.api.call_service( 29 | "volume_set", "media_player", entity_id=self.app_config.player, volume_level=current_volume 30 | ) 31 | -------------------------------------------------------------------------------- /examples/config/hassette.toml: -------------------------------------------------------------------------------- 1 | [hassette] 2 | base_url = "http://127.0.0.1:8123" 3 | app_dir = "/apps" # assuming we're running via docker compose, this should be absolute starting at /apps/ 4 | 5 | [apps.battery] 6 | enabled = true 7 | filename = "battery.py" 8 | class_name = "Battery" 9 | config = {always_send = false, force = false, threshold = 10} 10 | 11 | [apps.battery_sync] 12 | enabled = true 13 | filename = "battery.py" 14 | class_name = "BatterySync" 15 | config = {always_send = false, force = false, threshold = 10} 16 | -------------------------------------------------------------------------------- /examples/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | hassette: 3 | image: ghcr.io/nodejsmith/hassette:latest 4 | container_name: hassette 5 | restart: unless-stopped 6 | volumes: 7 | - ./config:/config 8 | - ./apps:/apps 9 | - data:/data 10 | - uv_cache:/uv_cache 11 | environment: 12 | - LOG_LEVEL=info 13 | healthcheck: 14 | test: ["CMD", "curl", "-f", "http://127.0.0.1:8126/healthz"] 15 | interval: 10s 16 | timeout: 5s 17 | retries: 3 18 | 19 | volumes: 20 | uv_cache: 21 | data: 22 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | pre-commit = "4.3.0" 3 | python = ["3.12", "3.13", "3.11"] 4 | uv = "0.8.14" 5 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import nox 4 | 5 | if typing.TYPE_CHECKING: 6 | from nox.sessions import Session 7 | 8 | nox.options.default_venv_backend = "uv|virtualenv" 9 | 10 | 11 | @nox.session(python=["3.11", "3.12", "3.13"]) 12 | def tests(session: "Session"): 13 | session.run( 14 | "uv", 15 | "run", 16 | "--active", 17 | "--reinstall-package", 18 | "hassette", 19 | "pytest", 20 | "-W", 21 | "error", 22 | external=True, 23 | ) 24 | 25 | 26 | @nox.session(python=["3.11", "3.12", "3.13"], tags=["coverage"]) 27 | def tests_with_coverage(session: "Session"): 28 | session.env["COVERAGE_FILE"] = f".coverage.{session.python}" 29 | session.run( 30 | "uv", 31 | "run", 32 | "--active", 33 | "--reinstall-package", 34 | "hassette", 35 | "pytest", 36 | "-W", 37 | "error", 38 | "--cov=hassette", 39 | "--cov-branch", 40 | "--cov-report=term-missing:skip-covered", 41 | "--cov-report=xml", 42 | "--cov-report=html", 43 | external=True, 44 | ) 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["uv_build>=0.8.13,<0.9.0"] 3 | build-backend = "uv_build" 4 | 5 | [project] 6 | name = "hassette" 7 | version = "0.15.2" 8 | description = "Hassette is a simple, modern, async-first Python framework for building Home Assistant automations." 9 | readme = "README.md" 10 | authors = [{ name = "Jessica", email = "12jessicasmith34@gmail.com" }] 11 | license = "MIT" 12 | requires-python = ">=3.11,<3.14" 13 | keywords = [ 14 | "home-assistant", 15 | "automation", 16 | "async", 17 | "typed", 18 | "framework", 19 | "smart-home", 20 | "iot", 21 | ] 22 | classifiers = [ 23 | "Development Status :: 4 - Beta", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: MIT License", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Topic :: Home Automation", 31 | "Topic :: Software Development :: Libraries :: Python Modules", 32 | "Framework :: AsyncIO", 33 | "Typing :: Typed", 34 | ] 35 | dependencies = [ 36 | "aiohttp>=3.11.18", 37 | "anyio>=4.10.0", 38 | "coloredlogs>=15.0.1", 39 | "croniter>=6.0.0", 40 | "deepdiff>=8.6.1", 41 | "fair-async-rlock>=1.0.7", 42 | "glom>=24.11.0", 43 | "humanize>=4.13.0", 44 | "orjson>=3.10.18", 45 | "packaging>=25.0", 46 | "platformdirs>=4.3.8", 47 | "pydantic-settings>=2.10.0", 48 | "python-dotenv>=1.1.0", 49 | "tenacity>=9.1.2", 50 | "typing-extensions==4.15.*", 51 | "watchfiles>=1.1.0", 52 | "whenever==0.9.*", 53 | ] 54 | 55 | [project.urls] 56 | Homepage = "https://github.com/nodejsmith/hassette" 57 | Repository = "https://github.com/nodejsmith/hassette" 58 | Documentation = "https://github.com/nodejsmith/hassette#readme" 59 | "Bug Reports" = "https://github.com/nodejsmith/hassette/issues" 60 | Changelog = "https://github.com/nodejsmith/hassette/blob/main/CHANGELOG.md" 61 | 62 | 63 | [dependency-groups] 64 | dev = [ 65 | "coverage[toml]>=7.10.7", 66 | "pytest>=8.4.0", 67 | "pytest-cov>=7.0.0", 68 | "tomli-w>=1.2.0", 69 | ] 70 | test = [ 71 | "docker>=7.1.0", 72 | "nox>=2025.5.1", 73 | "pytest>=8.4.0", 74 | "pytest-asyncio>=1.0.0", 75 | "pytest-randomly>=3.16.0", 76 | "pytest-xdist>=3.8.0", 77 | ] 78 | model_gen = ["datamodel-code-generator>=0.30.1"] 79 | docs = [ 80 | "autodoc-pydantic>=2.2.0", 81 | "sphinx>=8.2.3", 82 | "sphinx-autobuild==2024.10.3", 83 | "sphinx-autodoc-typehints==3.2.0", 84 | "sphinx-autodoc2==0.5.0", 85 | "sphinx-basic-ng==1.0.0b2", 86 | "sphinxcontrib-applehelp==2.0.0", 87 | "sphinxcontrib-devhelp==2.0.0", 88 | "sphinxcontrib-htmlhelp==2.1.0", 89 | "sphinxcontrib-jsmath==1.0.1", 90 | "sphinxcontrib-qthelp==2.0.0", 91 | "sphinxcontrib-serializinghtml==2.0.0", 92 | "sphinx-copybutton>=0.5.2", 93 | "sphinx-rtd-theme>=3.0.2", 94 | "sphinx-autoapi>=3.6.0", 95 | ] 96 | 97 | [tool.uv] 98 | default-groups = ["dev", "test", "docs"] 99 | 100 | [project.scripts] 101 | hassette = "hassette.__main__:entrypoint" 102 | 103 | [tool.pytest.ini_options] 104 | pythonpath = "src" 105 | asyncio_mode = "auto" 106 | asyncio_default_fixture_loop_scope = "session" 107 | asyncio_default_test_loop_scope = "session" 108 | 109 | [tool.coverage.run] 110 | branch = true # measure branch coverage, not just lines 111 | source = ["hassette"] # your packages/modules 112 | parallel = true # enable combining across workers/CI shards 113 | relative_files = true # stable paths in CI 114 | # Measure subprocesses (pytest-cov will set env automatically; this is belt+suspenders): 115 | concurrency = ["thread", "multiprocessing"] 116 | omit = ["*/__init__.py", "*/__main__.py"] 117 | 118 | [tool.coverage.report] 119 | show_missing = true 120 | skip_covered = true 121 | # fail_under = 85 # local default; CI can override 122 | exclude_lines = [ 123 | "pragma: no cover", 124 | "if TYPE_CHECKING:", 125 | "if __name__ == .__main__.:", 126 | "raise NotImplementedError", 127 | ] 128 | 129 | [tool.coverage.html] 130 | directory = "htmlcov" 131 | 132 | [tool.coverage.xml] 133 | output = "coverage.xml" 134 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Exclude a variety of commonly ignored directories. 2 | exclude = [ 3 | ".bzr", 4 | ".direnv", 5 | ".eggs", 6 | ".git", 7 | ".git-rewrite", 8 | ".hg", 9 | ".ipynb_checkpoints", 10 | ".mypy_cache", 11 | ".nox", 12 | ".pants.d", 13 | ".pyenv", 14 | ".pytest_cache", 15 | ".pytype", 16 | ".ruff_cache", 17 | ".svn", 18 | ".tox", 19 | ".venv", 20 | ".vscode", 21 | "__pypackages__", 22 | "_build", 23 | "buck-out", 24 | "build", 25 | "dist", 26 | "node_modules", 27 | "site-packages", 28 | "venv", 29 | ] 30 | 31 | # Same as Black. 32 | line-length = 120 33 | indent-width = 4 34 | 35 | target-version = "py311" 36 | 37 | [lint] 38 | select = [ 39 | # "ANN", 40 | "E", # pycodestyle (error) 41 | "W", # pycodestyle (warning) 42 | "F", # pyflakes 43 | "UP", # pyupgrade 44 | "C419", # pylint - convention - unnecessary-comprehension-in-call 45 | "RET", # flake8-return 46 | "SIM", # flake8-simplify 47 | "ARG", # flake8-unused-arguments 48 | "PTH", # flake8-use-pathlib 49 | "PD", # pandas-vet 50 | "RUF", # ruff 51 | "TCH", # flake8-type-checking 52 | "I", # isort 53 | "DTZ", # flake8-datetimez 54 | "PT", # flake8-pytest-style (PT) 55 | "B", # bugbear 56 | "LOG", # flake8-logging (LOG) 57 | "G", #flake8-logging-format (G) 58 | "N", #pep8-naming (N), 59 | "PERF", # Perflint (PERF) 60 | "TID252", # relative imports 61 | ] 62 | 63 | ignore = [ 64 | "RET504", # flake8-return - unnecessary-assign 65 | "SIM102", # flake8-simplify - collapsible-if 66 | "PTH123", # flake8-use-pathlib - builtin-open 67 | "RUF015", # ruff - unnecessary-iterable-allocation-for-first-element 68 | "PTH118", # flake8-use-pathlib - os-path-join 69 | "DTZ001", # call-datetime-without-tzinfo (DTZ001) 70 | "DTZ002", # call-datetime-today (DTZ002) 71 | "DTZ005", # call-datetime-now-without-tzinfo (DTZ005) 72 | "DTZ007", # call-datetime-strptime-without-zone (DTZ007) 73 | "D100", # undocumented-public-module 74 | "D101", # undocumented-public-class 75 | "D104", # undocumented-public-package 76 | # "D103", # undocumented-public-function 77 | "D107", # undocumented-public-init 78 | "ANN204", # missing-return-type-special-method 79 | "ANN003", # missing-type-kwargs 80 | "ANN002", # missing-type-args (ANN002) 81 | "ARG002", # unused-method-argument (ARG002) 82 | "UP046", # non-pep695-generic-class (UP046) 83 | "UP047", # non-pep695-generic-function (UP047) 84 | "N801", # invalid-class-name 85 | "SIM103", # needless-bool 86 | "N812", # lowercase-imported-as-non-lowercase 87 | ] 88 | 89 | # Allow fix for all enabled rules (when `--fix`) is provided. 90 | fixable = ["ALL"] 91 | unfixable = [] 92 | 93 | # Allow unused variables when underscore-prefixed. 94 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 95 | 96 | [format] 97 | # Like Black, use double quotes for strings. 98 | quote-style = "double" 99 | 100 | # Like Black, indent with spaces, rather than tabs. 101 | indent-style = "space" 102 | 103 | # Like Black, respect magic trailing commas. 104 | skip-magic-trailing-comma = false 105 | 106 | # Like Black, automatically detect the appropriate line ending. 107 | line-ending = "auto" 108 | 109 | [lint.pydocstyle] 110 | convention = "google" # Accepts: "google", "numpy", or "pep257". 111 | -------------------------------------------------------------------------------- /scripts/compile_requirements.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run --script 2 | 3 | from collections.abc import Iterable 4 | from pathlib import Path 5 | 6 | CONFIG_PATH = Path("/config") 7 | APPS_PATH = Path("/apps") 8 | 9 | 10 | ALLOWED_FILE_NAMES = ("requirements.txt", "hassette-requirements.txt") 11 | OUTPUT_PATH = Path("/tmp/merged_requirements.txt") 12 | 13 | 14 | def find_req_files(root: Path, names: tuple[str, ...]) -> list[Path]: 15 | hits: list[Path] = [] 16 | for name in names: 17 | hits.extend(root.rglob(name)) 18 | return [p for p in hits if p.is_file() and p.stat().st_size > 0] 19 | 20 | 21 | def read_lines_dedup(paths: Iterable[Path]) -> list[str]: 22 | seen: set[str] = set() 23 | out: list[str] = [] 24 | for p in paths: 25 | try: 26 | txt = p.read_text(encoding="utf-8", errors="replace") 27 | except Exception: 28 | continue 29 | for raw in txt.splitlines(): 30 | line = raw.strip().replace("\r", "") 31 | if not line or line.startswith("#"): 32 | continue 33 | if line not in seen: 34 | seen.add(line) 35 | out.append(line) 36 | return out 37 | 38 | 39 | def main() -> int: 40 | if not CONFIG_PATH.exists(): 41 | print("Config path /config does not exist, cannot continue") 42 | return 1 43 | 44 | if not APPS_PATH.exists(): 45 | print("Apps path /apps does not exist, cannot continue") 46 | return 1 47 | 48 | config_files = find_req_files(CONFIG_PATH, ALLOWED_FILE_NAMES) 49 | app_files = find_req_files(APPS_PATH, ALLOWED_FILE_NAMES) 50 | files = config_files + app_files 51 | 52 | lines = read_lines_dedup(files) 53 | 54 | if not lines: 55 | print(f"No requirements files found in {CONFIG_PATH} or {APPS_PATH}, skipping") 56 | return 0 57 | 58 | OUTPUT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") 59 | 60 | print(f"Merged {len(files)} file(s) into {OUTPUT_PATH} with {len(lines)} unique requirements") 61 | return 0 62 | 63 | 64 | if __name__ == "__main__": 65 | raise SystemExit(main()) 66 | -------------------------------------------------------------------------------- /scripts/docker_start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -eu # no pipefail in busybox ash 4 | 5 | ## We use a virtual environment because uv REALLY doesn't like to not use one 6 | ## plus it isolates the hassette dependencies from the host system 7 | ## so the first step is activate it 8 | 9 | ## then we look to see if there is a project to install in /apps 10 | ## if there is, we install it 11 | 12 | ## otherwise we look for hassette-requirements.txt/requirements.txt files in /config and /apps and install those 13 | 14 | # ---- Activate venv (guard bash-isms in activate) ------------------------- 15 | # shellcheck disable=SC1091 16 | . /app/.venv/bin/activate 17 | 18 | APPS=/apps 19 | 20 | # Check recursively under $CONF directory for additional python dependencies defined by the end-user via requirements.txt 21 | # find $CONF -name requirements.txt -type f -not -empty -exec uv pip install -r {} --directory $APPS \; 22 | # if pyproject.toml or uv.lock exists in $APPS, install that 23 | if [ -f $APPS/pyproject.toml ] || [ -f $APPS/uv.lock ]; then 24 | echo "Installing project in $APPS" 25 | uv sync --directory $APPS --no-default-groups --inexact --no-build-isolation --active # leave existing packages alone 26 | fi 27 | 28 | # find $CONF -name requirements.txt -type f -not -empty -exec uv pip install -r {} --directory $APPS \; 29 | if uv run scripts/compile_requirements.py && [ -f /tmp/merged_requirements.txt ]; then 30 | uv pip install -r /tmp/merged_requirements.txt --no-deps --no-build-isolation 31 | fi 32 | 33 | exec hassette 34 | -------------------------------------------------------------------------------- /scripts/internal/generate_pydantic_models.py: -------------------------------------------------------------------------------- 1 | """Generates pydantic models from the incoming JSON data. 2 | 3 | These will not be ready to use, but they are a decent starting point.""" 4 | 5 | import json 6 | from pathlib import Path 7 | from tempfile import TemporaryDirectory 8 | 9 | from datamodel_code_generator import DataModelType, InputFileType, generate 10 | from datamodel_code_generator.format import Formatter 11 | 12 | DEFAULT_FORMATTERS = [Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT] 13 | 14 | 15 | def generate_models( 16 | input_data: dict | list, 17 | output_file: str, 18 | aliases: dict[str, str] | None = None, 19 | class_name: str | None = None, 20 | special_field_name_prefix: str | None = None, 21 | ) -> None: 22 | with TemporaryDirectory() as temporary_directory_name: 23 | temporary_directory = Path(temporary_directory_name) 24 | output = Path(temporary_directory / "model.py") 25 | 26 | final_output = Path.cwd().joinpath(output_file) 27 | if not final_output.parent.exists(): 28 | final_output.parent.mkdir(parents=True, exist_ok=True) 29 | if final_output.exists(): 30 | final_output.unlink() 31 | output.touch() 32 | 33 | generate( 34 | json.dumps(input_data, indent=2, default=str), 35 | input_file_type=InputFileType.Json, 36 | output=output, 37 | output_model_type=DataModelType.PydanticV2BaseModel, 38 | snake_case_field=True, 39 | use_standard_collections=True, 40 | use_union_operator=True, 41 | use_schema_description=True, 42 | force_optional_for_required_fields=True, 43 | reuse_model=True, 44 | aliases=aliases, 45 | class_name=class_name, 46 | special_field_name_prefix=special_field_name_prefix, 47 | formatters=DEFAULT_FORMATTERS, 48 | ) 49 | model: str = output.read_text() 50 | model_lines = model.splitlines() 51 | model_lines = [line for line in model_lines if not line.startswith("#")] 52 | model = "\n".join(model_lines) 53 | with final_output.open("w") as f: 54 | f.write(model) 55 | -------------------------------------------------------------------------------- /src/hassette/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .api import Api 4 | from .app import App, AppConfig, AppSync, only_app 5 | from .bus import Bus, accessors, conditions, predicates 6 | from .config import HassetteConfig 7 | from .const import MISSING_VALUE, NOT_PROVIDED 8 | from .core import Hassette 9 | from .events import StateChangeEvent 10 | from .models import states 11 | from .models.services import ServiceResponse 12 | from .scheduler import Scheduler 13 | from .task_bucket import TaskBucket 14 | 15 | logging.getLogger("hassette").addHandler(logging.NullHandler()) 16 | 17 | __all__ = [ 18 | "MISSING_VALUE", 19 | "NOT_PROVIDED", 20 | "Api", 21 | "App", 22 | "AppConfig", 23 | "AppSync", 24 | "Bus", 25 | "Hassette", 26 | "HassetteConfig", 27 | "Scheduler", 28 | "ServiceResponse", 29 | "StateChangeEvent", 30 | "TaskBucket", 31 | "accessors", 32 | "conditions", 33 | "only_app", 34 | "predicates", 35 | "states", 36 | ] 37 | -------------------------------------------------------------------------------- /src/hassette/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from argparse import ArgumentParser 3 | from logging import getLogger 4 | 5 | from hassette import Hassette, HassetteConfig 6 | from hassette.exceptions import AppPrecheckFailedError, FatalError 7 | 8 | name = "hassette.hass_main" if __name__ == "__main__" else __name__ 9 | 10 | LOGGER = getLogger(name) 11 | 12 | 13 | def get_parser() -> ArgumentParser: 14 | """ 15 | Parse command line arguments for the Hassette application. 16 | """ 17 | parser = ArgumentParser(description="Hassette - A Home Assistant integration", add_help=False) 18 | parser.add_argument( 19 | "--config-file", 20 | "-c", 21 | type=str, 22 | default=None, 23 | help="Path to the settings file", 24 | dest="config_file", 25 | ) 26 | parser.add_argument( 27 | "--env-file", 28 | "--env", 29 | "-e", 30 | type=str, 31 | default=None, 32 | help="Path to the environment file (default: .env)", 33 | dest="env_file", 34 | ) 35 | return parser 36 | 37 | 38 | async def main() -> None: 39 | """Main function to run the Hassette application.""" 40 | 41 | args = get_parser().parse_known_args()[0] 42 | 43 | if args.env_file: 44 | HassetteConfig.model_config["env_file"] = args.env_file 45 | 46 | if args.config_file: 47 | HassetteConfig.model_config["toml_file"] = args.config_file 48 | 49 | config = HassetteConfig() 50 | 51 | core = Hassette(config=config) 52 | core.logger.info("Starting Hassette...") 53 | 54 | await core.run_forever() 55 | 56 | 57 | def entrypoint() -> None: 58 | """ 59 | This is the entry point for the Home Assistant integration. 60 | It initializes the HASS_CONTEXT and starts the event loop. 61 | """ 62 | 63 | try: 64 | asyncio.run(main()) 65 | except KeyboardInterrupt: 66 | LOGGER.info("Keyboard interrupt received, shutting down") 67 | except AppPrecheckFailedError as e: 68 | LOGGER.error("App precheck failed: %s", e) 69 | LOGGER.error("Hassette is shutting down due to app precheck failure") 70 | except FatalError as e: 71 | LOGGER.error("Fatal error occurred: %s", e) 72 | LOGGER.error("Hassette is shutting down due to a fatal error") 73 | except Exception as e: 74 | LOGGER.exception("Unexpected error in Hassette: %s", e) 75 | raise 76 | 77 | 78 | if __name__ == "__main__": 79 | entrypoint() 80 | -------------------------------------------------------------------------------- /src/hassette/api/__init__.py: -------------------------------------------------------------------------------- 1 | """API functionality for interacting with Home Assistant. 2 | 3 | This module provides clean access to the API classes for making HTTP requests, 4 | managing WebSocket connections, and handling entity states. 5 | """ 6 | 7 | from .api import Api 8 | from .sync import ApiSyncFacade 9 | 10 | __all__ = ["Api", "ApiSyncFacade"] 11 | -------------------------------------------------------------------------------- /src/hassette/app/__init__.py: -------------------------------------------------------------------------------- 1 | """App base classes and configuration for building Home Assistant automations. 2 | 3 | This module provides clean access to the app framework for creating both async and sync 4 | applications with typed configuration. 5 | """ 6 | 7 | from .app import App, AppSync, only_app 8 | from .app_config import AppConfig, AppConfigT 9 | 10 | __all__ = [ 11 | "App", 12 | "AppConfig", 13 | "AppConfigT", 14 | "AppSync", 15 | "only_app", 16 | ] 17 | -------------------------------------------------------------------------------- /src/hassette/app/app_config.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | from pydantic_settings import BaseSettings, SettingsConfigDict 4 | 5 | from hassette.const import LOG_LEVELS 6 | 7 | 8 | class AppConfig(BaseSettings): 9 | """Base configuration class for applications in the Hassette framework. 10 | 11 | This default class does not define any fields, allowing anyone who prefers to not use 12 | this functionality to ignore it. It also allows all extras, so arbitrary additional 13 | configuration data can be passed without needing to define a custom subclass. 14 | 15 | Fields can be set on subclasses and extra can be overriden by assigning a new value to `model_config`.""" 16 | 17 | model_config = SettingsConfigDict(extra="allow", arbitrary_types_allowed=True, env_file=["/config/.env", ".env"]) 18 | 19 | instance_name: str = "" 20 | """Name for the instance of the app.""" 21 | 22 | log_level: LOG_LEVELS = "INFO" 23 | """Log level for the app instance. Defaults to 'INFO'.""" 24 | 25 | 26 | AppConfigT = TypeVar("AppConfigT", bound=AppConfig) 27 | """Type variable for app configuration classes.""" 28 | -------------------------------------------------------------------------------- /src/hassette/bus/__init__.py: -------------------------------------------------------------------------------- 1 | from . import accessors, conditions, predicates 2 | from .bus import Bus 3 | from .listeners import Listener, Subscription 4 | 5 | __all__ = [ 6 | "Bus", 7 | "Listener", 8 | "Subscription", 9 | "accessors", 10 | "conditions", 11 | "predicates", 12 | ] 13 | -------------------------------------------------------------------------------- /src/hassette/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .app_manifest import AppManifest 2 | from .core_config import HassetteConfig 3 | 4 | __all__ = ["AppManifest", "HassetteConfig"] 5 | -------------------------------------------------------------------------------- /src/hassette/config/sources_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from pydantic_settings import BaseSettings 5 | from pydantic_settings.sources import InitSettingsSource, PathType, TomlConfigSettingsSource 6 | 7 | DEFAULT_PATH = Path() 8 | DEFAULT_HASSETTE_TOML_PATH = Path("hassette.toml") 9 | 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | class HassetteTomlConfigSettingsSource(TomlConfigSettingsSource): 15 | def __init__(self, settings_cls: type[BaseSettings], toml_file: PathType | None = DEFAULT_PATH): 16 | self.toml_file_path = toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get("toml_file") 17 | self.toml_data = self._read_files(self.toml_file_path) 18 | 19 | if "hassette" not in self.toml_data: 20 | # just let the standard class handle it 21 | super().__init__(settings_cls, self.toml_file_path) 22 | return 23 | 24 | LOGGER.info("Merging 'hassette' section from TOML config into top level") 25 | top_level_keys = set(self.toml_data.keys()) - {"hassette"} 26 | hassette_values = self.toml_data.pop("hassette") 27 | for key in top_level_keys.intersection(hassette_values.keys()): 28 | LOGGER.warning( 29 | "Key %r found in both top level and 'hassette' section of TOML config, " 30 | "the [hassette] value will be used", 31 | key, 32 | ) 33 | 34 | self.toml_data.update(hassette_values) 35 | 36 | # need to call InitSettingSource directly, as super() expects a file path 37 | # as the second argument 38 | InitSettingsSource.__init__(self, settings_cls, self.toml_data) 39 | -------------------------------------------------------------------------------- /src/hassette/const/__init__.py: -------------------------------------------------------------------------------- 1 | from .colors import COLORS, Color 2 | from .misc import LOG_LEVELS, MISSING_VALUE, NOT_PROVIDED 3 | from .sensor import DEVICE_CLASS, STATE_CLASS, UNIT_OF_MEASUREMENT 4 | 5 | __all__ = [ 6 | "COLORS", 7 | "DEVICE_CLASS", 8 | "LOG_LEVELS", 9 | "MISSING_VALUE", 10 | "NOT_PROVIDED", 11 | "STATE_CLASS", 12 | "UNIT_OF_MEASUREMENT", 13 | "Color", 14 | ] 15 | -------------------------------------------------------------------------------- /src/hassette/const/colors.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, get_args 2 | 3 | Color = Literal[ 4 | "aliceblue", 5 | "antiquewhite", 6 | "aqua", 7 | "aquamarine", 8 | "azure", 9 | "beige", 10 | "bisque", 11 | "black", 12 | "blanchedalmond", 13 | "blue", 14 | "blueviolet", 15 | "brown", 16 | "burlywood", 17 | "cadetblue", 18 | "chartreuse", 19 | "chocolate", 20 | "coral", 21 | "cornflowerblue", 22 | "cornsilk", 23 | "crimson", 24 | "cyan", 25 | "darkblue", 26 | "darkcyan", 27 | "darkgoldenrod", 28 | "darkgray", 29 | "darkgreen", 30 | "darkgrey", 31 | "darkkhaki", 32 | "darkmagenta", 33 | "darkolivegreen", 34 | "darkorange", 35 | "darkorchid", 36 | "darkred", 37 | "darksalmon", 38 | "darkseagreen", 39 | "darkslateblue", 40 | "darkslategray", 41 | "darkslategrey", 42 | "darkturquoise", 43 | "darkviolet", 44 | "deeppink", 45 | "deepskyblue", 46 | "dimgray", 47 | "dimgrey", 48 | "dodgerblue", 49 | "firebrick", 50 | "floralwhite", 51 | "forestgreen", 52 | "fuchsia", 53 | "gainsboro", 54 | "ghostwhite", 55 | "gold", 56 | "goldenrod", 57 | "gray", 58 | "green", 59 | "greenyellow", 60 | "grey", 61 | "honeydew", 62 | "hotpink", 63 | "indianred", 64 | "indigo", 65 | "ivory", 66 | "khaki", 67 | "lavender", 68 | "lavenderblush", 69 | "lawngreen", 70 | "lemonchiffon", 71 | "lightblue", 72 | "lightcoral", 73 | "lightcyan", 74 | "lightgoldenrodyellow", 75 | "lightgray", 76 | "lightgreen", 77 | "lightgrey", 78 | "lightpink", 79 | "lightsalmon", 80 | "lightseagreen", 81 | "lightskyblue", 82 | "lightslategray", 83 | "lightslategrey", 84 | "lightsteelblue", 85 | "lightyellow", 86 | "lime", 87 | "limegreen", 88 | "linen", 89 | "magenta", 90 | "maroon", 91 | "mediumaquamarine", 92 | "mediumblue", 93 | "mediumorchid", 94 | "mediumpurple", 95 | "mediumseagreen", 96 | "mediumslateblue", 97 | "mediumspringgreen", 98 | "mediumturquoise", 99 | "mediumvioletred", 100 | "midnightblue", 101 | "mintcream", 102 | "mistyrose", 103 | "moccasin", 104 | "navajowhite", 105 | "navy", 106 | "navyblue", 107 | "oldlace", 108 | "olive", 109 | "olivedrab", 110 | "orange", 111 | "orangered", 112 | "orchid", 113 | "palegoldenrod", 114 | "palegreen", 115 | "paleturquoise", 116 | "palevioletred", 117 | "papayawhip", 118 | "peachpuff", 119 | "peru", 120 | "pink", 121 | "plum", 122 | "powderblue", 123 | "purple", 124 | "red", 125 | "rosybrown", 126 | "royalblue", 127 | "saddlebrown", 128 | "salmon", 129 | "sandybrown", 130 | "seagreen", 131 | "seashell", 132 | "sienna", 133 | "silver", 134 | "skyblue", 135 | "slateblue", 136 | "slategray", 137 | "slategrey", 138 | "snow", 139 | "springgreen", 140 | "steelblue", 141 | "tan", 142 | "teal", 143 | "thistle", 144 | "tomato", 145 | "turquoise", 146 | "violet", 147 | "wheat", 148 | "white", 149 | "whitesmoke", 150 | "yellow", 151 | "yellowgreen", 152 | "homeassistant", 153 | ] 154 | """Literal type for supported color names.""" 155 | 156 | COLORS = list(sorted(set(get_args(Color)))) 157 | """List of supported color names.""" 158 | -------------------------------------------------------------------------------- /src/hassette/const/misc.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from typing_extensions import Sentinel 4 | 5 | MISSING_VALUE = Sentinel("MISSING_VALUE") 6 | """Sentinel value to indicate a missing value.""" 7 | 8 | NOT_PROVIDED = Sentinel("NOT_PROVIDED") 9 | """Sentinel value to indicate a value was not provided.""" 10 | 11 | LOG_LEVELS = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] 12 | """Log levels for configuring logging.""" 13 | -------------------------------------------------------------------------------- /src/hassette/context.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import typing 3 | from collections.abc import Generator 4 | from contextvars import ContextVar 5 | from typing import Any, TypeVar 6 | 7 | if typing.TYPE_CHECKING: 8 | from hassette import Hassette, HassetteConfig, TaskBucket 9 | 10 | T = TypeVar("T") 11 | 12 | CURRENT_BUCKET: ContextVar["TaskBucket | None"] = ContextVar("CURRENT_BUCKET", default=None) 13 | HASSETTE_INSTANCE: ContextVar["Hassette | None"] = ContextVar("HASSETTE_INSTANCE", default=None) 14 | HASSETTE_CONFIG: ContextVar["HassetteConfig | None"] = ContextVar("HASSETTE_CONFIG", default=None) 15 | 16 | 17 | @contextlib.contextmanager 18 | def use(var: ContextVar[T], value: T) -> Generator[None, Any, None]: 19 | """Temporarily set a ContextVar to `value` within a block.""" 20 | token = var.set(value) 21 | try: 22 | yield 23 | finally: 24 | var.reset(token) 25 | -------------------------------------------------------------------------------- /src/hassette/events/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Event, EventT, HassContext, HassettePayload, HassPayload 2 | from .hass.hass import ( 3 | AutomationTriggeredEvent, 4 | CallServiceEvent, 5 | ComponentLoadedEvent, 6 | HassEvent, 7 | LogbookEntryEvent, 8 | ScriptStartedEvent, 9 | ServiceRegisteredEvent, 10 | ServiceRemovedEvent, 11 | StateChangeEvent, 12 | UserAddedEvent, 13 | UserRemovedEvent, 14 | create_event_from_hass, 15 | ) 16 | from .hass.raw import HassContextDict, HassEventDict, HassEventEnvelopeDict, HassStateDict 17 | from .hassette import ( 18 | FileWatcherEventPayload, 19 | HassetteFileWatcherEvent, 20 | HassetteServiceEvent, 21 | HassetteSimpleEvent, 22 | ServiceStatusPayload, 23 | WebsocketConnectedEventPayload, 24 | WebsocketDisconnectedEventPayload, 25 | ) 26 | 27 | __all__ = [ 28 | "AutomationTriggeredEvent", 29 | "CallServiceEvent", 30 | "ComponentLoadedEvent", 31 | "Event", 32 | "EventT", 33 | "FileWatcherEventPayload", 34 | "HassContext", 35 | "HassContextDict", 36 | "HassEvent", 37 | "HassEventDict", 38 | "HassEventEnvelopeDict", 39 | "HassPayload", 40 | "HassStateDict", 41 | "HassetteFileWatcherEvent", 42 | "HassettePayload", 43 | "HassetteServiceEvent", 44 | "HassetteSimpleEvent", 45 | "LogbookEntryEvent", 46 | "ScriptStartedEvent", 47 | "ServiceRegisteredEvent", 48 | "ServiceRemovedEvent", 49 | "ServiceStatusPayload", 50 | "StateChangeEvent", 51 | "UserAddedEvent", 52 | "UserRemovedEvent", 53 | "WebsocketConnectedEventPayload", 54 | "WebsocketDisconnectedEventPayload", 55 | "create_event_from_hass", 56 | ] 57 | -------------------------------------------------------------------------------- /src/hassette/events/base.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from dataclasses import dataclass, field 3 | from typing import Generic, Literal, TypeVar 4 | 5 | from whenever import ZonedDateTime 6 | 7 | from hassette.utils.date_utils import convert_datetime_str_to_system_tz 8 | 9 | PayloadT = TypeVar("PayloadT", bound="EventPayload") 10 | """Represents the payload type of an event.""" 11 | 12 | DataT = TypeVar("DataT") 13 | """Represents the data type within an event payload.""" 14 | 15 | EventT = TypeVar("EventT", bound="Event", contravariant=True) 16 | """Represents an event type.""" 17 | 18 | HASSETTE_EVENT_ID_SEQ = itertools.count(1) 19 | 20 | 21 | @dataclass(frozen=True, slots=True) 22 | class Event(Generic[PayloadT]): 23 | """Base event with strongly typed payload.""" 24 | 25 | topic: str 26 | """Topic of the event.""" 27 | 28 | payload: PayloadT 29 | """The event payload.""" 30 | 31 | 32 | @dataclass(frozen=True, slots=True) 33 | class EventPayload(Generic[DataT]): 34 | """Base payload with typed data.""" 35 | 36 | event_type: str 37 | """Type of the event.""" 38 | 39 | data: DataT 40 | """The actual event data.""" 41 | 42 | 43 | @dataclass(frozen=True, slots=True) 44 | class HassContext: 45 | """Structure for the context of a Home Assistant event.""" 46 | 47 | id: str 48 | parent_id: str | None 49 | user_id: str | None 50 | 51 | 52 | @dataclass(frozen=True, slots=True) 53 | class HassPayload(EventPayload[DataT]): 54 | """Home Assistant event payload with additional metadata.""" 55 | 56 | event_type: str 57 | """Type of the event, e.g., 'state_changed', 'call_service', etc.""" 58 | 59 | data: DataT 60 | """The actual event data from Home Assistant.""" 61 | 62 | origin: Literal["LOCAL", "REMOTE"] 63 | """Origin of the event, either 'LOCAL' or 'REMOTE'.""" 64 | 65 | time_fired: ZonedDateTime 66 | """The time the event was fired.""" 67 | 68 | context: HassContext 69 | """The context of the event.""" 70 | 71 | def __post_init__(self): 72 | object.__setattr__(self, "time_fired", convert_datetime_str_to_system_tz(self.time_fired)) 73 | 74 | @property 75 | def entity_id(self) -> str | None: 76 | """Return the entity_id if present in the data.""" 77 | return getattr(self.data, "entity_id", None) 78 | 79 | @property 80 | def domain(self) -> str | None: 81 | """Return the domain if present in the data.""" 82 | if hasattr(self.data, "domain"): 83 | return getattr(self.data, "domain", None) 84 | 85 | entity_id = self.entity_id 86 | if entity_id: 87 | return entity_id.split(".")[0] 88 | return None 89 | 90 | @property 91 | def service(self) -> str | None: 92 | """Return the service if present in the data.""" 93 | return getattr(self.data, "service", None) 94 | 95 | @property 96 | def event_id(self) -> str: 97 | """The unique identifier for the event.""" 98 | return self.context.id 99 | 100 | 101 | @dataclass(frozen=True, slots=True) 102 | class HassettePayload(EventPayload[DataT]): 103 | """Hassette event payload with additional metadata.""" 104 | 105 | event_type: str 106 | """Type of the event, e.g., 'state_changed', 'call_service', etc.""" 107 | 108 | data: DataT 109 | """The actual event data from Home Assistant.""" 110 | 111 | event_id: int = field(default_factory=lambda: next(HASSETTE_EVENT_ID_SEQ)) 112 | """The unique identifier for the event.""" 113 | -------------------------------------------------------------------------------- /src/hassette/events/hass/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeJSmith/hassette/224104ed9a360c496621fd1040328506297aa377/src/hassette/events/hass/__init__.py -------------------------------------------------------------------------------- /src/hassette/events/hass/raw.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal, Required 2 | 3 | from typing_extensions import TypedDict 4 | 5 | # These represent the structure of the data as it comes from Home Assistant's websocket API, prior to any processing. 6 | 7 | 8 | class HassContextDict(TypedDict): 9 | """Structure for the context of a state change event.""" 10 | 11 | id: str 12 | parent_id: str | None 13 | user_id: str | None 14 | 15 | 16 | class HassStateDict(TypedDict, total=False): 17 | """Structure for the state of an entity. 18 | 19 | This structure is seen both in a state change event or by calling the HA API to get the state of an entity. 20 | """ 21 | 22 | domain: str 23 | entity_id: Required[str] 24 | last_changed: str | None 25 | last_reported: str | None 26 | last_updated: str | None 27 | context: Required[HassContextDict] 28 | 29 | state: Required[Any] 30 | attributes: Required[dict[str, Any]] 31 | 32 | 33 | class HassEventDict(TypedDict): 34 | """Structure for the state change event data.""" 35 | 36 | event_type: str 37 | data: dict[str, Any] | None 38 | origin: Literal["LOCAL", "REMOTE"] 39 | time_fired: str 40 | context: HassContextDict 41 | 42 | 43 | class HassEventEnvelopeDict(TypedDict): 44 | """The structure of what comes from Home Assistant's websocket API for state change events. 45 | 46 | When turned into an Event, the `event` attribute is popped and used to create the event, 47 | with `type` and `id` being discarded. 48 | """ 49 | 50 | event: HassEventDict 51 | type: Literal["event"] 52 | id: int # from the websocket message, not the event's ID 53 | -------------------------------------------------------------------------------- /src/hassette/exceptions.py: -------------------------------------------------------------------------------- 1 | from yarl import URL 2 | 3 | 4 | class FatalError(Exception): 5 | """Custom exception to indicate a fatal error in the application. 6 | 7 | Exceptions that indicate that the service should not be restarted should inherit from this class. 8 | """ 9 | 10 | 11 | class BaseUrlRequiredError(FatalError): 12 | """Custom exception to indicate that the base_url configuration is required.""" 13 | 14 | 15 | class IPV6NotSupportedError(FatalError): 16 | """Custom exception to indicate that IPv6 addresses are not supported in base_url.""" 17 | 18 | 19 | class SchemeRequiredInBaseUrlError(FatalError): 20 | """Custom exception to indicate that the base_url must include a scheme (http:// or https://).""" 21 | 22 | 23 | class ConnectionClosedError(Exception): 24 | """Custom exception to indicate that the WebSocket connection was closed unexpectedly.""" 25 | 26 | 27 | class CouldNotFindHomeAssistantError(FatalError): 28 | """Custom exception to indicate that the Home Assistant instance could not be found.""" 29 | 30 | def __init__(self, url: str): 31 | yurl = URL(url) 32 | msg = f"Could not find Home Assistant instance at {url}, ensure it is running and accessible" 33 | if not yurl.explicit_port: 34 | msg += " and that the port is specified if necessary" 35 | super().__init__(msg) 36 | 37 | 38 | class RetryableConnectionClosedError(ConnectionClosedError): 39 | """Custom exception to indicate that the WebSocket connection was closed but can be retried.""" 40 | 41 | 42 | class FailedMessageError(Exception): 43 | """Custom exception to indicate that a message sent to the WebSocket failed.""" 44 | 45 | @classmethod 46 | def from_error_response( 47 | cls, 48 | error: str | None = None, 49 | original_data: dict | None = None, 50 | ): 51 | msg = f"WebSocket message for failed with response '{error}' (data={original_data})" 52 | return cls(msg) 53 | 54 | 55 | class InvalidAuthError(FatalError): 56 | """Custom exception to indicate that the authentication token is invalid.""" 57 | 58 | 59 | class InvalidInheritanceError(TypeError): 60 | """Raised when a class inherits from App incorrectly.""" 61 | 62 | 63 | class UndefinedUserConfigError(TypeError): 64 | """Raised when a class does not define a user_config_class.""" 65 | 66 | 67 | class EntityNotFoundError(ValueError): 68 | """Custom error for handling 404 in the Api""" 69 | 70 | 71 | class ResourceNotReadyError(Exception): 72 | """Custom exception to indicate that a resource is not ready for use.""" 73 | 74 | 75 | class AppPrecheckFailedError(Exception): 76 | """Custom exception to indicate that one or more prechecks for an app failed.""" 77 | 78 | 79 | class CannotOverrideFinalError(TypeError): 80 | """Custom exception to indicate that a final method or class cannot be overridden.""" 81 | 82 | def __init__( 83 | self, 84 | method_name: str, 85 | origin_name: str, 86 | subclass_name: str, 87 | suggested_alt: str | None = None, 88 | location: str | None = None, 89 | ): 90 | msg = ( 91 | f"App '{subclass_name}' attempted to override the final lifecycle method " 92 | f"'{method_name}' defined in {origin_name!r}. " 93 | ) 94 | if suggested_alt: 95 | msg += f"Use '{suggested_alt}' instead." 96 | if location: 97 | msg += f" (at {location})" 98 | super().__init__(msg) 99 | -------------------------------------------------------------------------------- /src/hassette/logging_.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import threading 4 | from contextlib import suppress 5 | from typing import Literal 6 | 7 | import coloredlogs 8 | 9 | FORMAT_DATE = "%Y-%m-%d" 10 | FORMAT_TIME = "%H:%M:%S" 11 | FORMAT_DATETIME = f"{FORMAT_DATE} {FORMAT_TIME}" 12 | FMT = "%(asctime)s.%(msecs)03d %(levelname)s %(name)s.%(funcName)s:%(lineno)d ─ %(message)s" 13 | 14 | # TODO: remove coloredlogs and roll our own? or use colorlogs 15 | # coloredlogs is unmaintained and parts of it are broken on Python >3.13 16 | 17 | 18 | def enable_logging( 19 | log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 20 | ) -> None: 21 | """Set up the logging""" 22 | 23 | logger = logging.getLogger("hassette") 24 | 25 | # Set the base hassette logger 26 | logger.setLevel(log_level) 27 | 28 | # don't propagate to root - if someone wants to do a basicConfig on root we don't want 29 | # our logs going there too. 30 | logger.propagate = False 31 | 32 | # Clear any old handlers 33 | logger.handlers.clear() 34 | 35 | # NOTSET - don't clamp child logs 36 | # this is the kicker - if the handler is filtered then it doesn't matter what we set the 37 | # logger to, it won't log anything lower than the handler's level. 38 | # So we set the handler to NOTSET and clamp the logger itself. 39 | # don't know why it took me five years to learn this. 40 | coloredlogs.install(level=logging.NOTSET, logger=logger, fmt=FMT, datefmt=FORMAT_DATETIME) 41 | 42 | # reset hassette logger to desired level, as coloredlogs.install sets it to WARNING 43 | logger.setLevel(log_level) 44 | 45 | # coloredlogs does something funky to the root logger and i can't figure out what 46 | # so for now i'm just resorting to this 47 | with suppress(IndexError): 48 | logging.getLogger().handlers.pop(0) 49 | 50 | # here and below were pulled from Home Assistant 51 | 52 | # Capture warnings.warn(...) and friends messages in logs. 53 | # The standard destination for them is stderr, which may end up unnoticed. 54 | # This way they're where other messages are, and can be filtered as usual. 55 | logging.captureWarnings(True) 56 | 57 | # Suppress overly verbose logs from libraries that aren't helpful 58 | logging.getLogger("requests").setLevel(logging.WARNING) 59 | logging.getLogger("urllib3").setLevel(logging.WARNING) 60 | logging.getLogger("aiohttp.access").setLevel(logging.WARNING) 61 | logging.getLogger("httpx").setLevel(logging.WARNING) 62 | 63 | sys.excepthook = lambda *args: logging.getLogger().exception("Uncaught exception", exc_info=args) 64 | threading.excepthook = lambda args: logging.getLogger().exception( 65 | "Uncaught thread exception", 66 | exc_info=(args.exc_type, args.exc_value, args.exc_traceback), 67 | ) 68 | -------------------------------------------------------------------------------- /src/hassette/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeJSmith/hassette/224104ed9a360c496621fd1040328506297aa377/src/hassette/models/__init__.py -------------------------------------------------------------------------------- /src/hassette/models/entities/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from .base import BaseEntity 4 | from .light import LightEntity 5 | 6 | EntityT = typing.TypeVar("EntityT") 7 | """Represents a specific entity type, e.g., LightEntity, SensorEntity, etc.""" 8 | 9 | 10 | __all__ = ["BaseEntity", "EntityT", "LightEntity"] 11 | -------------------------------------------------------------------------------- /src/hassette/models/entities/base.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import Generic 3 | 4 | from pydantic import BaseModel, ConfigDict, PrivateAttr 5 | 6 | from hassette import context 7 | from hassette.models.states import StateT 8 | 9 | if typing.TYPE_CHECKING: 10 | from hassette import Api, Hassette 11 | 12 | 13 | class BaseEntity(BaseModel, Generic[StateT]): 14 | """Base class for all entities.""" 15 | 16 | model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) 17 | 18 | state: StateT 19 | _sync: "BaseEntitySyncFacade[StateT]" = PrivateAttr(default=None, init=False) # pyright: ignore[reportAssignmentType] 20 | 21 | async def refresh(self) -> StateT: 22 | self.state = await self.hassette.api.get_state(self.entity_id, type(self.state)) 23 | return self.state 24 | 25 | @property 26 | def value(self) -> str: 27 | return self.state.value 28 | 29 | @property 30 | def entity_id(self) -> str: 31 | return self.state.entity_id 32 | 33 | @property 34 | def domain(self) -> str: 35 | return self.state.domain 36 | 37 | @property 38 | def hassette(self) -> "Hassette": 39 | """Get the HassAPI instance for this state.""" 40 | 41 | inst = context.HASSETTE_INSTANCE.get(None) 42 | if inst is None: 43 | raise RuntimeError("Hassette instance not set in context") 44 | 45 | return inst 46 | 47 | @property 48 | def api(self) -> "Api": 49 | """Get the Hassette API instance for this state.""" 50 | return self.hassette.api 51 | 52 | @property 53 | def sync(self) -> "BaseEntitySyncFacade[StateT]": 54 | if self._sync is None: 55 | self._sync = BaseEntitySyncFacade(entity=self) 56 | return self._sync 57 | 58 | async def turn_off(self): 59 | """Turn off the entity.""" 60 | return await self.api.turn_off(self.entity_id, self.domain) 61 | 62 | async def turn_on(self, **data): 63 | """Turn on the entity.""" 64 | return await self.api.turn_on(self.entity_id, self.domain, **data) 65 | 66 | async def toggle(self): 67 | """Toggle the entity.""" 68 | return await self.api.toggle_service(self.entity_id, self.domain) 69 | 70 | 71 | class BaseEntitySyncFacade(Generic[StateT]): 72 | """Synchronous facade for BaseEntity to allow easier access to properties without async/await.""" 73 | 74 | entity: BaseEntity[StateT] 75 | 76 | def __init__(self, entity: BaseEntity[StateT]) -> None: 77 | self.entity = entity 78 | 79 | def turn_off(self): 80 | """Turn off the entity.""" 81 | return self.entity.api.sync.turn_off(self.entity.entity_id, self.entity.domain) 82 | 83 | def turn_on(self, **data): 84 | """Turn on the entity.""" 85 | return self.entity.api.sync.turn_on(self.entity.entity_id, self.entity.domain, **data) 86 | 87 | def toggle(self): 88 | """Toggle the entity.""" 89 | return self.entity.api.sync.toggle_service(self.entity.entity_id, self.entity.domain) 90 | -------------------------------------------------------------------------------- /src/hassette/models/entities/light.py: -------------------------------------------------------------------------------- 1 | from hassette.const.colors import Color 2 | from hassette.models.states import LightState 3 | 4 | from .base import BaseEntity 5 | 6 | 7 | class LightEntity(BaseEntity[LightState]): 8 | async def turn_on( 9 | self, 10 | color_name: Color | None = None, 11 | rgb_color: tuple[int, int, int] | None = None, 12 | rgbw_color: tuple[int, int, int, int] | None = None, 13 | rgbww_color: tuple[int, int, int, int, int] | None = None, 14 | xy_color: tuple[float, float] | None = None, 15 | hs_color: tuple[float, float] | None = None, 16 | color_temp_kelvin: int | None = None, 17 | min_color_temp_kelvin: int | None = None, 18 | max_color_temp_kelvin: int | None = None, 19 | white: int | None = None, 20 | **data, 21 | ): 22 | """Turn on the light with optional color.""" 23 | return await self.api.turn_on( 24 | self.entity_id, 25 | self.domain, 26 | color_name=color_name, 27 | rgb_color=rgb_color, 28 | rgbw_color=rgbw_color, 29 | rgbww_color=rgbww_color, 30 | xy_color=xy_color, 31 | hs_color=hs_color, 32 | color_temp_kelvin=color_temp_kelvin, 33 | min_color_temp_kelvin=min_color_temp_kelvin, 34 | max_color_temp_kelvin=max_color_temp_kelvin, 35 | white=white, 36 | **data, 37 | ) 38 | -------------------------------------------------------------------------------- /src/hassette/models/history.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from typing import Any 3 | 4 | from pydantic import BaseModel 5 | from whenever import Instant 6 | 7 | 8 | class HistoryEntry(BaseModel): 9 | model_config = {"arbitrary_types_allowed": True} 10 | 11 | entity_id: str 12 | state: Any 13 | attributes: dict[str, Any] | None 14 | last_changed: Instant 15 | last_updated: Instant 16 | 17 | 18 | def normalize_history(entries: Any) -> list[list[dict[str, Any]]]: 19 | if not isinstance(entries, Sequence) or not all(isinstance(e, list) for e in entries): 20 | entries = [entries] # Wrap in a list if not already a list of lists 21 | 22 | normalized_entries: list[list[dict[str, Any]]] = [] 23 | 24 | for entity_history in entries: 25 | if not entity_history: 26 | continue 27 | 28 | normalized_list = [] 29 | 30 | # Use the first entry as the base for later updates 31 | base_entry = entity_history[0] 32 | 33 | for delta_entry in entity_history: 34 | # if we have the same set of keys then we don't need to use the base entries 35 | # as we already have everything we need 36 | # this happens when minimal_response is not set 37 | if set(base_entry.keys()) == set(delta_entry.keys()): 38 | normalized_list.append(delta_entry) 39 | continue 40 | 41 | merged = base_entry.copy() | delta_entry 42 | normalized_list.append(merged) 43 | 44 | if normalized_list: 45 | normalized_entries.append(normalized_list) 46 | 47 | return normalized_entries 48 | -------------------------------------------------------------------------------- /src/hassette/models/services.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from hassette.models.states.base import Context 4 | 5 | 6 | class ServiceResponse(BaseModel): 7 | """Represents the response from a service call.""" 8 | 9 | context: Context 10 | response: dict = Field(default_factory=dict) 11 | -------------------------------------------------------------------------------- /src/hassette/models/states/air_quality.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from hassette.const.sensor import UNIT_OF_MEASUREMENT 6 | 7 | from .base import AttributesBase, StringBaseState 8 | 9 | 10 | class AirQualityState(StringBaseState): 11 | """Representation of a Home Assistant air_quality state. 12 | 13 | See: https://www.home-assistant.io/integrations/air_quality/ 14 | """ 15 | 16 | class Attributes(AttributesBase): 17 | nitrogen_oxide: float | None = Field(default=None) 18 | particulate_matter_10: float | None = Field(default=None) 19 | particulate_matter_2_5: float | None = Field(default=None) 20 | unit_of_measurement: UNIT_OF_MEASUREMENT | str | None = Field(default=None) 21 | attribution: str | None = Field(default=None) 22 | 23 | domain: Literal["air_quality"] 24 | 25 | attributes: Attributes 26 | -------------------------------------------------------------------------------- /src/hassette/models/states/alarm_control_panel.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class AlarmControlPanelState(StringBaseState): 9 | """Representation of a Home Assistant alarm_control_panel state. 10 | 11 | See: https://www.home-assistant.io/integrations/alarm_control_panel/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | code_format: str | None = Field(default=None) 16 | changed_by: Any | None = Field(default=None) 17 | code_arm_required: bool | None = Field(default=None) 18 | previous_state: Any | None = Field(default=None) 19 | next_state: Any | None = Field(default=None) 20 | 21 | domain: Literal["alarm_control_panel"] 22 | 23 | attributes: Attributes 24 | -------------------------------------------------------------------------------- /src/hassette/models/states/assist_satellite.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class AssistSatelliteState(StringBaseState): 9 | """Representation of a Home Assistant assist_satellite state. 10 | 11 | See: https://www.home-assistant.io/integrations/assist_satellite/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | restored: bool | None = Field(default=None) 16 | 17 | domain: Literal["assist_satellite"] 18 | 19 | attributes: Attributes 20 | -------------------------------------------------------------------------------- /src/hassette/models/states/automation.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field, field_validator 4 | from whenever import ZonedDateTime 5 | 6 | from hassette.utils.date_utils import convert_datetime_str_to_system_tz 7 | 8 | from .base import AttributesBase, StringBaseState 9 | 10 | 11 | class AutomationState(StringBaseState): 12 | """Representation of a Home Assistant automation state. 13 | 14 | See: https://www.home-assistant.io/integrations/automation/ 15 | """ 16 | 17 | class Attributes(AttributesBase): 18 | id: str | None = Field(default=None) 19 | last_triggered: ZonedDateTime | None = Field(default=None) 20 | mode: str | None = Field(default=None) 21 | current: int | float | None = Field(default=None) 22 | max: int | float | None = Field(default=None) 23 | 24 | @field_validator("last_triggered", mode="before") 25 | @classmethod 26 | def parse_last_triggered(cls, value: ZonedDateTime | str | None) -> ZonedDateTime | None: 27 | return convert_datetime_str_to_system_tz(value) 28 | 29 | domain: Literal["automation"] 30 | 31 | attributes: Attributes 32 | -------------------------------------------------------------------------------- /src/hassette/models/states/calendar.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | from whenever import Instant, PlainDateTime, ZonedDateTime 5 | 6 | from .base import AttributesBase, StringBaseState 7 | 8 | 9 | class CalendarState(StringBaseState): 10 | """Representation of a Home Assistant calendar state. 11 | 12 | See: https://www.home-assistant.io/integrations/calendar/ 13 | """ 14 | 15 | class Attributes(AttributesBase): 16 | message: str | None = Field(default=None) 17 | all_day: bool | None = Field(default=None) 18 | start_time: Instant | PlainDateTime | ZonedDateTime | None = Field(default=None) 19 | end_time: Instant | PlainDateTime | ZonedDateTime | None = Field(default=None) 20 | location: str | None = Field(default=None) 21 | description: str | None = Field(default=None) 22 | offset_reached: bool | None = Field(default=None) 23 | 24 | domain: Literal["calendar"] 25 | 26 | attributes: Attributes 27 | -------------------------------------------------------------------------------- /src/hassette/models/states/camera.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field, SecretStr 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class CameraState(StringBaseState): 9 | """Representation of a Home Assistant camera state. 10 | 11 | See: https://www.home-assistant.io/integrations/camera/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | access_token: SecretStr | None = Field(default=None) 16 | model_name: str | None = Field(default=None) 17 | brand: str | None = Field(default=None) 18 | entity_picture: str | None = Field(default=None) 19 | 20 | domain: Literal["camera"] 21 | 22 | attributes: Attributes 23 | -------------------------------------------------------------------------------- /src/hassette/models/states/climate.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class ClimateState(StringBaseState): 9 | """Representation of a Home Assistant climate state. 10 | 11 | See: https://www.home-assistant.io/integrations/climate/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | hvac_modes: list[str] | None = Field(default=None) 16 | min_temp: int | float | None = Field(default=None) 17 | max_temp: int | float | None = Field(default=None) 18 | fan_modes: list[str] | None = Field(default=None) 19 | preset_modes: list[str] | None = Field(default=None) 20 | current_temperature: int | float | None = Field(default=None) 21 | temperature: int | float | None = Field(default=None) 22 | target_temp_high: float | None = Field(default=None) 23 | target_temp_low: float | None = Field(default=None) 24 | current_humidity: float | None = Field(default=None) 25 | fan_mode: str | None = Field(default=None) 26 | hvac_action: str | None = Field(default=None) 27 | preset_mode: str | None = Field(default=None) 28 | swing_mode: str | None = Field(default=None) 29 | swing_modes: list[str] | None = Field(default=None) 30 | 31 | domain: Literal["climate"] 32 | 33 | attributes: Attributes 34 | -------------------------------------------------------------------------------- /src/hassette/models/states/device_tracker.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field, field_validator 4 | from whenever import ZonedDateTime 5 | 6 | from hassette.utils.date_utils import convert_datetime_str_to_system_tz 7 | 8 | from .base import AttributesBase, StringBaseState 9 | 10 | 11 | class DeviceTrackerState(StringBaseState): 12 | """Representation of a Home Assistant device_tracker state. 13 | 14 | See: https://www.home-assistant.io/integrations/device_tracker/ 15 | """ 16 | 17 | class Attributes(AttributesBase): 18 | source_type: str | None = Field(default=None) 19 | battery_level: int | float | None = Field(default=None) 20 | latitude: float | None = Field(default=None) 21 | longitude: float | None = Field(default=None) 22 | gps_accuracy: int | float | None = Field(default=None) 23 | altitude: float | None = Field(default=None) 24 | vertical_accuracy: int | float | None = Field(default=None) 25 | course: int | float | None = Field(default=None) 26 | speed: int | float | None = Field(default=None) 27 | scanner: str | None = Field(default=None) 28 | area: str | None = Field(default=None) 29 | mac: str | None = Field(default=None) 30 | last_time_reachable: ZonedDateTime | None = Field(default=None) 31 | reason: str | None = Field(default=None) 32 | ip: str | None = Field(default=None) 33 | host_name: str | None = Field(default=None) 34 | 35 | @field_validator("last_time_reachable", mode="before") 36 | @classmethod 37 | def parse_last_triggered(cls, value: ZonedDateTime | str | None) -> ZonedDateTime | None: 38 | return convert_datetime_str_to_system_tz(value) 39 | 40 | domain: Literal["device_tracker"] 41 | 42 | attributes: Attributes 43 | -------------------------------------------------------------------------------- /src/hassette/models/states/event.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, DateTimeBaseState 6 | 7 | 8 | class EventState(DateTimeBaseState): 9 | """Representation of a Home Assistant event state. 10 | 11 | See: https://www.home-assistant.io/integrations/event/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | event_types: list[str] | None = Field(default=None) 16 | event_type: str | None = Field(default=None) 17 | button: str | None = Field(default=None) 18 | 19 | domain: Literal["event"] 20 | 21 | attributes: Attributes 22 | -------------------------------------------------------------------------------- /src/hassette/models/states/fan.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class FanState(StringBaseState): 9 | """Representation of a Home Assistant fan state. 10 | 11 | See: https://www.home-assistant.io/integrations/fan/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | preset_modes: list[str] | None = Field(default=None) 16 | oscillating: bool | None = Field(default=None) 17 | percentage: int | float | None = Field(default=None) 18 | percentage_step: float | None = Field(default=None) 19 | preset_mode: str | None = Field(default=None) 20 | temperature: int | float | None = Field(default=None) 21 | model: str | None = Field(default=None) 22 | sn: str | None = Field(default=None) 23 | screen_status: bool | None = Field(default=None) 24 | child_lock: bool | None = Field(default=None) 25 | night_light: str | None = Field(default=None) 26 | mode: str | None = Field(default=None) 27 | 28 | domain: Literal["fan"] 29 | 30 | attributes: Attributes 31 | -------------------------------------------------------------------------------- /src/hassette/models/states/humidifier.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class HumidifierState(StringBaseState): 9 | """Representation of a Home Assistant humidifier state. 10 | 11 | See: https://www.home-assistant.io/integrations/humidifier/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | min_humidity: float | None = Field(default=None) 16 | max_humidity: float | None = Field(default=None) 17 | available_modes: list[str] | None = Field(default=None) 18 | current_humidity: float | None = Field(default=None) 19 | humidity: float | None = Field(default=None) 20 | mode: str | None = Field(default=None) 21 | action: str | None = Field(default=None) 22 | 23 | domain: Literal["humidifier"] 24 | 25 | attributes: Attributes 26 | -------------------------------------------------------------------------------- /src/hassette/models/states/image_processing.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class ImageProcessingState(StringBaseState): 9 | """Representation of a Home Assistant image_processing state. 10 | 11 | See: https://www.home-assistant.io/integrations/image_processing/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | faces: list | None = Field(default=None) 16 | total_faces: int | float | None = Field(default=None) 17 | 18 | domain: Literal["image_processing"] 19 | 20 | attributes: Attributes 21 | -------------------------------------------------------------------------------- /src/hassette/models/states/input.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import Field 4 | from whenever import Instant, ZonedDateTime 5 | 6 | from hassette.utils.date_utils import convert_utc_timestamp_to_system_tz 7 | 8 | from .base import AttributesBase, BoolBaseState, DateTimeBaseState, NumericBaseState, StringBaseState 9 | 10 | 11 | class InputAttributesBase(AttributesBase): 12 | """Base attributes class for all input states.""" 13 | 14 | editable: bool | None = Field(default=None) 15 | 16 | 17 | class InputBooleanState(BoolBaseState): 18 | """Representation of a Home Assistant input_boolean state. 19 | 20 | See: https://www.home-assistant.io/integrations/input_boolean/ 21 | """ 22 | 23 | domain: Literal["input_boolean"] 24 | 25 | attributes: InputAttributesBase 26 | 27 | 28 | class InputButtonState(DateTimeBaseState): 29 | """Representation of a Home Assistant input_button state. 30 | 31 | See: https://www.home-assistant.io/integrations/input_button/ 32 | """ 33 | 34 | domain: Literal["input_button"] 35 | 36 | attributes: InputAttributesBase 37 | 38 | 39 | class InputDatetimeState(DateTimeBaseState): 40 | """Representation of a Home Assistant input_datetime state. 41 | 42 | See: https://www.home-assistant.io/integrations/input_datetime/ 43 | """ 44 | 45 | class Attributes(InputAttributesBase): 46 | has_date: bool | None = Field(default=None) 47 | has_time: bool | None = Field(default=None) 48 | year: int | None = Field(default=None) 49 | month: int | None = Field(default=None) 50 | day: int | None = Field(default=None) 51 | hour: int | None = Field(default=None) 52 | minute: int | None = Field(default=None) 53 | second: int | None = Field(default=None) 54 | timestamp: float | None = Field(default=None) 55 | 56 | @property 57 | def timestamp_as_instant(self) -> Instant | None: 58 | if self.timestamp is None: 59 | return None 60 | return Instant.from_timestamp(self.timestamp) 61 | 62 | @property 63 | def timestamp_as_system_datetime(self) -> ZonedDateTime | None: 64 | if self.timestamp is None: 65 | return None 66 | return convert_utc_timestamp_to_system_tz(self.timestamp) 67 | 68 | domain: Literal["input_datetime"] 69 | 70 | attributes: Attributes 71 | 72 | 73 | class InputNumberState(NumericBaseState): 74 | """Representation of a Home Assistant input_number state. 75 | 76 | See: https://www.home-assistant.io/integrations/input_number/ 77 | """ 78 | 79 | class Attributes(InputAttributesBase): 80 | max: float | None = Field(default=None) 81 | initial: float | None = Field(default=None) 82 | step: int | float | None = Field(default=None) 83 | mode: str | None = Field(default=None) 84 | min: int | float | None = Field(default=None) 85 | 86 | domain: Literal["input_number"] 87 | 88 | attributes: Attributes 89 | 90 | 91 | class InputSelectState(StringBaseState): 92 | """Representation of a Home Assistant input_select state. 93 | 94 | See: https://www.home-assistant.io/integrations/input_select/ 95 | """ 96 | 97 | class Attributes(InputAttributesBase): 98 | options: list[str] = Field(default_factory=list) 99 | 100 | domain: Literal["input_select"] 101 | 102 | attributes: Attributes 103 | 104 | 105 | class InputTextState(StringBaseState): 106 | """Representation of a Home Assistant input_text state. 107 | 108 | See: https://www.home-assistant.io/integrations/input_text/ 109 | """ 110 | 111 | class Attributes(InputAttributesBase): 112 | min: int | float | None = Field(default=None) 113 | max: int | float | None = Field(default=None) 114 | pattern: Any | None = Field(default=None) 115 | mode: str | None = Field(default=None) 116 | 117 | domain: Literal["input_text"] 118 | 119 | attributes: Attributes 120 | -------------------------------------------------------------------------------- /src/hassette/models/states/light.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class LightState(StringBaseState): 9 | """Representation of a Home Assistant light state. 10 | 11 | See: https://www.home-assistant.io/integrations/light/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | min_color_temp_kelvin: int | float | None = Field(default=None) 16 | max_color_temp_kelvin: int | float | None = Field(default=None) 17 | min_mireds: int | float | None = Field(default=None) 18 | max_mireds: int | float | None = Field(default=None) 19 | effect_list: list[str] | None = Field(default=None) 20 | supported_color_modes: list[str] | None = Field(default=None) 21 | effect: Any | None = Field(default=None) 22 | color_mode: str | None = Field(default=None) 23 | brightness: int | float | None = Field(default=None) 24 | color_temp_kelvin: int | float | None = Field(default=None) 25 | color_temp: int | float | None = Field(default=None) 26 | hs_color: list[float] | None = Field(default=None) 27 | rgb_color: list[int] | None = Field(default=None) 28 | xy_color: list[float] | None = Field(default=None) 29 | 30 | domain: Literal["light"] 31 | 32 | attributes: Attributes 33 | -------------------------------------------------------------------------------- /src/hassette/models/states/media_player.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class MediaPlayerState(StringBaseState): 9 | """Representation of a Home Assistant media_player state. 10 | 11 | See: https://www.home-assistant.io/integrations/media_player/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | assumed_state: bool | None = Field(default=None) 16 | adb_response: Any | None = Field(default=None) 17 | hdmi_input: Any | None = Field(default=None) 18 | 19 | domain: Literal["media_player"] 20 | -------------------------------------------------------------------------------- /src/hassette/models/states/number.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from hassette.const.sensor import UNIT_OF_MEASUREMENT 6 | 7 | from .base import AttributesBase, NumericBaseState 8 | 9 | 10 | class NumberState(NumericBaseState): 11 | """Representation of a Home Assistant number state. 12 | 13 | See: https://www.home-assistant.io/integrations/number/ 14 | """ 15 | 16 | class Attributes(AttributesBase): 17 | min: int | float | None = Field(default=None) 18 | max: int | float | None = Field(default=None) 19 | step: int | float | None = Field(default=None) 20 | mode: str | None = Field(default=None) 21 | unit_of_measurement: UNIT_OF_MEASUREMENT | str | None = Field(default=None) 22 | 23 | domain: Literal["number"] 24 | 25 | attributes: Attributes 26 | -------------------------------------------------------------------------------- /src/hassette/models/states/person.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class PersonState(StringBaseState): 9 | """Representation of a Home Assistant person state. 10 | 11 | See: https://www.home-assistant.io/integrations/person/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | editable: bool | None = Field(default=None) 16 | id: str | None = Field(default=None) 17 | device_trackers: list[str] | None = Field(default=None) 18 | source: str | None = Field(default=None) 19 | user_id: str | None = Field(default=None) 20 | entity_picture: str | None = Field(default=None) 21 | 22 | domain: Literal["person"] 23 | 24 | attributes: Attributes 25 | -------------------------------------------------------------------------------- /src/hassette/models/states/remote.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class RemoteState(StringBaseState): 9 | """Representation of a Home Assistant remote state. 10 | 11 | See: https://www.home-assistant.io/integrations/remote/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | activity_list: list | None = Field(default=None) 16 | current_activity: str | None = Field(default=None) 17 | 18 | domain: Literal["remote"] 19 | 20 | attributes: Attributes 21 | -------------------------------------------------------------------------------- /src/hassette/models/states/scene.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, DateTimeBaseState 6 | 7 | 8 | class SceneState(DateTimeBaseState): 9 | """Representation of a Home Assistant scene state. 10 | 11 | See: https://www.home-assistant.io/integrations/scene/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | id: str | None = Field(default=None) 16 | 17 | domain: Literal["scene"] 18 | 19 | attributes: Attributes 20 | -------------------------------------------------------------------------------- /src/hassette/models/states/script.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field, field_validator 4 | from whenever import ZonedDateTime 5 | 6 | from hassette.utils.date_utils import convert_datetime_str_to_system_tz 7 | 8 | from .base import AttributesBase, StringBaseState 9 | 10 | 11 | class ScriptState(StringBaseState): 12 | """Representation of a Home Assistant script state. 13 | 14 | See: https://www.home-assistant.io/integrations/script/ 15 | """ 16 | 17 | class Attributes(AttributesBase): 18 | last_triggered: ZonedDateTime | None = Field(default=None) 19 | mode: str | None = Field(default=None) 20 | current: int | float | None = Field(default=None) 21 | 22 | @field_validator("last_triggered", mode="before") 23 | @classmethod 24 | def parse_last_triggered(cls, value: ZonedDateTime | str | None) -> ZonedDateTime | None: 25 | return convert_datetime_str_to_system_tz(value) 26 | 27 | domain: Literal["script"] 28 | 29 | attributes: Attributes 30 | -------------------------------------------------------------------------------- /src/hassette/models/states/select.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class SelectState(StringBaseState): 9 | """Representation of a Home Assistant select state. 10 | 11 | See: https://www.home-assistant.io/integrations/select/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | options: list[str] | None = Field(default=None) 16 | 17 | domain: Literal["select"] 18 | 19 | attributes: Attributes 20 | -------------------------------------------------------------------------------- /src/hassette/models/states/sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from hassette.const.sensor import DEVICE_CLASS, STATE_CLASS, UNIT_OF_MEASUREMENT 6 | 7 | from .base import AttributesBase, StringBaseState 8 | 9 | 10 | class SensorAttributes(AttributesBase): 11 | device_class: DEVICE_CLASS | str | None = Field(default=None) 12 | 13 | state_class: STATE_CLASS | None | str = Field(default=None) 14 | unit_of_measurement: UNIT_OF_MEASUREMENT | str | None = Field(default=None) 15 | 16 | options: list[str] | None = Field(default=None) 17 | 18 | 19 | class SensorState(StringBaseState): 20 | """Representation of a Home Assistant sensor state. 21 | 22 | See: https://www.home-assistant.io/integrations/sensor/""" 23 | 24 | domain: Literal["sensor"] 25 | attributes: SensorAttributes 26 | -------------------------------------------------------------------------------- /src/hassette/models/states/simple.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from .base import BoolBaseState, DateTimeBaseState, NumericBaseState, StringBaseState, TimeBaseState 4 | 5 | 6 | class AiTaskState(StringBaseState): 7 | """Representation of a Home Assistant ai_task state. 8 | 9 | See: https://www.home-assistant.io/integrations/ai_task/ 10 | """ 11 | 12 | domain: Literal["ai_task"] 13 | 14 | 15 | class ButtonState(StringBaseState): 16 | """Representation of a Home Assistant button state. 17 | 18 | See: https://www.home-assistant.io/integrations/button/ 19 | """ 20 | 21 | domain: Literal["button"] 22 | 23 | 24 | class ConversationState(StringBaseState): 25 | """Representation of a Home Assistant conversation state. 26 | 27 | See: https://www.home-assistant.io/integrations/conversation/ 28 | """ 29 | 30 | domain: Literal["conversation"] 31 | 32 | 33 | class CoverState(StringBaseState): 34 | """Representation of a Home Assistant cover state. 35 | 36 | See: https://www.home-assistant.io/integrations/cover/ 37 | """ 38 | 39 | domain: Literal["cover"] 40 | 41 | 42 | class DateState(DateTimeBaseState): 43 | """Representation of a Home Assistant date state. 44 | 45 | See: https://www.home-assistant.io/integrations/date/ 46 | """ 47 | 48 | domain: Literal["date"] 49 | 50 | 51 | class DateTimeState(DateTimeBaseState): 52 | """Representation of a Home Assistant datetime state. 53 | 54 | See: https://www.home-assistant.io/integrations/datetime/ 55 | """ 56 | 57 | domain: Literal["datetime"] 58 | 59 | 60 | class LockState(StringBaseState): 61 | """Representation of a Home Assistant lock state. 62 | 63 | See: https://www.home-assistant.io/integrations/lock/ 64 | """ 65 | 66 | domain: Literal["lock"] 67 | 68 | 69 | class NotifyState(StringBaseState): 70 | """Representation of a Home Assistant notify state. 71 | 72 | See: https://www.home-assistant.io/integrations/notify/ 73 | """ 74 | 75 | domain: Literal["notify"] 76 | 77 | 78 | class SttState(StringBaseState): 79 | """Representation of a Home Assistant stt state. 80 | 81 | See: https://www.home-assistant.io/integrations/stt/ 82 | """ 83 | 84 | domain: Literal["stt"] 85 | 86 | 87 | class SwitchState(StringBaseState): 88 | """Representation of a Home Assistant switch state. 89 | 90 | See: https://www.home-assistant.io/integrations/switch/ 91 | """ 92 | 93 | domain: Literal["switch"] 94 | 95 | 96 | class TimeState(TimeBaseState): 97 | """Representation of a Home Assistant time state. 98 | 99 | See: https://www.home-assistant.io/integrations/time/ 100 | """ 101 | 102 | domain: Literal["time"] 103 | 104 | 105 | class TodoState(NumericBaseState): 106 | """Representation of a Home Assistant todo state. 107 | 108 | See: https://www.home-assistant.io/integrations/todo/ 109 | """ 110 | 111 | domain: Literal["todo"] 112 | 113 | 114 | class TtsState(DateTimeBaseState): 115 | """Representation of a Home Assistant tts state. 116 | 117 | See: https://www.home-assistant.io/integrations/tts/ 118 | """ 119 | 120 | domain: Literal["tts"] 121 | 122 | 123 | class ValveState(StringBaseState): 124 | """Representation of a Home Assistant valve state. 125 | 126 | See: https://www.home-assistant.io/integrations/valve/ 127 | """ 128 | 129 | domain: Literal["valve"] 130 | 131 | 132 | class BinarySensorState(BoolBaseState): 133 | """Representation of a Home Assistant binary_sensor state. 134 | 135 | See: https://www.home-assistant.io/integrations/binary_sensor/ 136 | """ 137 | 138 | domain: Literal["binary_sensor"] 139 | -------------------------------------------------------------------------------- /src/hassette/models/states/siren.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class SirenState(StringBaseState): 9 | """Representation of a Home Assistant siren state. 10 | 11 | See: https://www.home-assistant.io/integrations/siren/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | available_tones: list[str] | None = Field(default=None) 16 | 17 | domain: Literal["siren"] 18 | 19 | attributes: Attributes 20 | -------------------------------------------------------------------------------- /src/hassette/models/states/sun.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field, field_validator 4 | from whenever import ZonedDateTime 5 | 6 | from hassette.utils.date_utils import convert_datetime_str_to_system_tz 7 | 8 | from .base import AttributesBase, StringBaseState 9 | 10 | 11 | class SunState(StringBaseState): 12 | """Representation of a Home Assistant sun state. 13 | 14 | See: https://www.home-assistant.io/integrations/sun/ 15 | """ 16 | 17 | class Attributes(AttributesBase): 18 | next_dawn: ZonedDateTime | None = Field(default=None) 19 | next_dusk: ZonedDateTime | None = Field(default=None) 20 | next_midnight: ZonedDateTime | None = Field(default=None) 21 | next_noon: ZonedDateTime | None = Field(default=None) 22 | next_rising: ZonedDateTime | None = Field(default=None) 23 | next_setting: ZonedDateTime | None = Field(default=None) 24 | elevation: float | None = Field(default=None) 25 | azimuth: float | None = Field(default=None) 26 | rising: bool | None = Field(default=None) 27 | 28 | @field_validator( 29 | "next_dawn", "next_dusk", "next_midnight", "next_noon", "next_rising", "next_setting", mode="before" 30 | ) 31 | @classmethod 32 | def parse_datetime_fields(cls, value: ZonedDateTime | str | None) -> ZonedDateTime | None: 33 | return convert_datetime_str_to_system_tz(value) 34 | 35 | domain: Literal["sun"] 36 | 37 | attributes: Attributes 38 | -------------------------------------------------------------------------------- /src/hassette/models/states/text.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class TextState(StringBaseState): 9 | """Representation of a Home Assistant text state. 10 | 11 | See: https://www.home-assistant.io/integrations/text/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | editable: bool | None = Field(default=None) 16 | min: int | float | None = Field(default=None) 17 | max: int | float | None = Field(default=None) 18 | pattern: Any | None = Field(default=None) 19 | mode: str | None = Field(default=None) 20 | 21 | domain: Literal["text"] 22 | 23 | attributes: Attributes 24 | -------------------------------------------------------------------------------- /src/hassette/models/states/timer.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class TimerState(StringBaseState): 9 | """Representation of a Home Assistant timer state. 10 | 11 | See: https://www.home-assistant.io/integrations/timer/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | duration: str | None = Field(default=None) 16 | editable: bool | None = Field(default=None) 17 | restore: bool | None = Field(default=None) 18 | 19 | domain: Literal["timer"] 20 | 21 | attributes: Attributes 22 | -------------------------------------------------------------------------------- /src/hassette/models/states/update.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class UpdateState(StringBaseState): 9 | """Representation of a Home Assistant update state. 10 | 11 | See: https://www.home-assistant.io/integrations/update/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | auto_update: bool | None = Field(default=None) 16 | display_precision: int | float | None = Field(default=None) 17 | installed_version: str | None = Field(default=None) 18 | in_progress: bool | None = Field(default=None) 19 | latest_version: str | None = Field(default=None) 20 | release_summary: Any | None = Field(default=None) 21 | release_url: str | None = Field(default=None) 22 | skipped_version: Any | None = Field(default=None) 23 | title: str | None = Field(default=None) 24 | update_percentage: Any | None = Field(default=None) 25 | entity_picture: str | None = Field(default=None) 26 | 27 | domain: Literal["update"] 28 | 29 | attributes: Attributes 30 | -------------------------------------------------------------------------------- /src/hassette/models/states/vacuum.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class VacuumState(StringBaseState): 9 | """Representation of a Home Assistant vacuum state. 10 | 11 | See: https://www.home-assistant.io/integrations/vacuum/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | fan_speed_list: list[str] | None = Field(default=None) 16 | battery_level: int | float | None = Field(default=None) 17 | battery_icon: str | None = Field(default=None) 18 | fan_speed: str | None = Field(default=None) 19 | cleaned_area: int | float | None = Field(default=None) 20 | 21 | domain: Literal["vacuum"] 22 | 23 | attributes: Attributes 24 | -------------------------------------------------------------------------------- /src/hassette/models/states/water_heater.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class WaterHeaterState(StringBaseState): 9 | """Representation of a Home Assistant water_heater state. 10 | 11 | See: https://www.home-assistant.io/integrations/water_heater/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | min_temp: float | None = Field(default=None) 16 | max_temp: float | None = Field(default=None) 17 | target_temp_step: float | None = Field(default=None) 18 | operation_list: list[str] | None = Field(default=None) 19 | current_temperature: float | None = Field(default=None) 20 | temperature: float | None = Field(default=None) 21 | target_temp_high: float | None = Field(default=None) 22 | target_temp_low: float | None = Field(default=None) 23 | operation_mode: str | None = Field(default=None) 24 | away_mode: str | None = Field(default=None) 25 | 26 | domain: Literal["water_heater"] 27 | 28 | attributes: Attributes 29 | -------------------------------------------------------------------------------- /src/hassette/models/states/weather.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, StringBaseState 6 | 7 | 8 | class WeatherState(StringBaseState): 9 | """Representation of a Home Assistant weather state. 10 | 11 | See: https://www.home-assistant.io/integrations/weather/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | temperature: float | None = Field(default=None) 16 | apparent_temperature: float | None = Field(default=None) 17 | dew_point: float | None = Field(default=None) 18 | temperature_unit: str | None = Field(default=None) 19 | humidity: float | None = Field(default=None) 20 | cloud_coverage: float | None = Field(default=None) 21 | pressure: float | None = Field(default=None) 22 | pressure_unit: str | None = Field(default=None) 23 | wind_bearing: float | None = Field(default=None) 24 | wind_speed: float | None = Field(default=None) 25 | wind_speed_unit: str | None = Field(default=None) 26 | visibility_unit: str | None = Field(default=None) 27 | precipitation_unit: str | None = Field(default=None) 28 | attribution: str | None = Field(default=None) 29 | 30 | domain: Literal["weather"] 31 | 32 | attributes: Attributes 33 | -------------------------------------------------------------------------------- /src/hassette/models/states/zone.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from .base import AttributesBase, IntBaseState 6 | 7 | 8 | class ZoneState(IntBaseState): 9 | """Representation of a Home Assistant zone state. 10 | 11 | See: https://www.home-assistant.io/integrations/zone/ 12 | """ 13 | 14 | class Attributes(AttributesBase): 15 | latitude: float | None = Field(default=None) 16 | longitude: float | None = Field(default=None) 17 | radius: float | None = Field(default=None) 18 | passive: bool | None = Field(default=None) 19 | persons: list[str] | None = Field(default=None) 20 | editable: bool | None = Field(default=None) 21 | 22 | domain: Literal["zone"] 23 | 24 | attributes: Attributes 25 | -------------------------------------------------------------------------------- /src/hassette/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeJSmith/hassette/224104ed9a360c496621fd1040328506297aa377/src/hassette/py.typed -------------------------------------------------------------------------------- /src/hassette/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeJSmith/hassette/224104ed9a360c496621fd1040328506297aa377/src/hassette/resources/__init__.py -------------------------------------------------------------------------------- /src/hassette/scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | """Task scheduling functionality for Home Assistant automations. 2 | 3 | This module provides clean access to the scheduler system for running jobs 4 | at specific times, intervals, or based on cron expressions. 5 | """ 6 | 7 | from .classes import CronTrigger, IntervalTrigger, ScheduledJob 8 | from .scheduler import Scheduler 9 | 10 | __all__ = [ 11 | "CronTrigger", 12 | "IntervalTrigger", 13 | "ScheduledJob", 14 | "Scheduler", 15 | ] 16 | -------------------------------------------------------------------------------- /src/hassette/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeJSmith/hassette/224104ed9a360c496621fd1040328506297aa377/src/hassette/services/__init__.py -------------------------------------------------------------------------------- /src/hassette/services/file_watcher.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from watchfiles import awatch 4 | 5 | from hassette.events.hassette import HassetteFileWatcherEvent 6 | from hassette.resources.base import Service 7 | 8 | 9 | class FileWatcherService(Service): 10 | """Background task to watch for file changes and reload apps.""" 11 | 12 | @property 13 | def config_log_level(self): 14 | """Return the log level from the config for this resource.""" 15 | return self.hassette.config.file_watcher_log_level 16 | 17 | async def before_initialize(self) -> None: 18 | self.logger.debug("Waiting for Hassette ready event") 19 | await self.hassette.ready_event.wait() 20 | 21 | async def serve(self) -> None: 22 | """Watch app directories for changes and trigger reloads.""" 23 | if not self.hassette.config.watch_files: 24 | self.logger.warning("File watching is disabled due to configuration") 25 | return 26 | 27 | paths = self.hassette.config.get_watchable_files() 28 | 29 | self.logger.debug("Watching app directories for changes: %s", ", ".join(str(p) for p in paths)) 30 | self.mark_ready(reason="File watcher started") 31 | 32 | async for changes in awatch( 33 | *paths, 34 | stop_event=self.shutdown_event, 35 | step=self.hassette.config.file_watcher_step_milliseconds, 36 | debounce=self.hassette.config.file_watcher_debounce_milliseconds, 37 | ): 38 | if self.shutdown_event.is_set(): 39 | break 40 | 41 | for _, changed_path in changes: 42 | changed_path = Path(changed_path).resolve() 43 | self.logger.debug("Detected change in %s", changed_path) 44 | event = HassetteFileWatcherEvent.create_event(changed_file_path=changed_path) 45 | await self.hassette.send_event(event.topic, event) 46 | 47 | # update paths in case new apps were added 48 | paths = self.hassette.config.get_watchable_files() 49 | -------------------------------------------------------------------------------- /src/hassette/services/health_service.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from aiohttp import web 4 | 5 | from hassette.resources.base import Service 6 | from hassette.types.enums import ResourceStatus 7 | 8 | if typing.TYPE_CHECKING: 9 | from hassette import Hassette 10 | 11 | _T = typing.TypeVar("_T") 12 | 13 | 14 | # subclass to prevent the weird UnboundLocalError we get from aiohttp 15 | # i think it's due to pytest but i'm tired of trying to figure it out 16 | # that's why you don't use frame inspections 17 | class MyAppKey(web.AppKey[_T]): 18 | def __init__(self, name: str, t: type[_T]): 19 | self._name = __name__ + "." + name 20 | self._t = t 21 | 22 | 23 | class HealthService(Service): 24 | """Tiny HTTP server exposing /healthz for container healthchecks.""" 25 | 26 | host: str 27 | """Host to bind the health server to.""" 28 | 29 | port: int 30 | """Port to bind the health server to.""" 31 | 32 | _runner: web.AppRunner | None 33 | """Aiohttp app runner for the health server.""" 34 | 35 | @classmethod 36 | def create(cls, hassette: "Hassette", host: str = "0.0.0.0", port: int | None = None): 37 | inst = cls(hassette, parent=hassette) 38 | inst.host = host 39 | inst.port = port or hassette.config.health_service_port or 8126 40 | inst._runner = None 41 | 42 | return inst 43 | 44 | @property 45 | def config_log_level(self): 46 | """Return the log level from the config for this resource.""" 47 | return self.hassette.config.health_service_log_level 48 | 49 | async def serve(self) -> None: 50 | if not self.hassette.config.run_health_service: 51 | return 52 | 53 | try: 54 | # Just idle until cancelled 55 | await self.shutdown_event.wait() 56 | except OSError as e: 57 | error_no = e.errno if hasattr(e, "errno") else type(e) 58 | self.logger.error("Health service failed to start: %s (errno=%s)", e, error_no) 59 | raise 60 | 61 | async def before_initialize(self) -> None: 62 | self.logger.debug("Waiting for Hassette ready event") 63 | await self.hassette.ready_event.wait() 64 | 65 | async def on_initialize(self): 66 | """Start the health HTTP server.""" 67 | 68 | if not self.hassette.config.run_health_service: 69 | self.logger.warning("Health service disabled by configuration") 70 | # we don't want to fail startup due to "not ready", as this is not unhealthy, just disabled 71 | self.mark_ready(reason="Health service disabled") 72 | return 73 | 74 | app = web.Application() 75 | hassette_key = MyAppKey[HealthService]("health_service", HealthService) 76 | app[hassette_key] = self 77 | app.router.add_get("/healthz", self._handle_health) 78 | 79 | self._runner = web.AppRunner(app) 80 | await self._runner.setup() 81 | site = web.TCPSite(self._runner, self.host, self.port) 82 | await site.start() 83 | 84 | self.logger.debug("Health service listening on %s:%s", self.host, self.port) 85 | 86 | self.mark_ready(reason="Health service started") 87 | 88 | async def on_shutdown(self) -> None: 89 | if self._runner: 90 | await self._runner.cleanup() 91 | self.logger.debug("Health service stopped") 92 | 93 | async def _handle_health(self, request: web.Request) -> web.Response: 94 | # You can check internals here (e.g., WS status) 95 | ws_running = self.hassette._websocket_service.status == ResourceStatus.RUNNING 96 | if ws_running: 97 | self.logger.debug("Health check OK") 98 | return web.json_response({"status": "ok", "ws": "connected"}) 99 | self.logger.warning("Health check FAILED: WebSocket disconnected") 100 | return web.json_response({"status": "degraded", "ws": "disconnected"}, status=503) 101 | -------------------------------------------------------------------------------- /src/hassette/sphinx.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sphinx extension to remap documented references to their canonical locations. 3 | 4 | This is to deal with AutoApi missing certain indirections and not handling types well. 5 | And sphinx.ext.autodoc not handling TYPE_CHECKING. 6 | And autodoc_type_hints not working well with complex type aliases. 7 | And autodoc2 not working well at all. 8 | 9 | Nothing works well - so this is my bandaid for now. If anyone else has a better suggestion 10 | I would *LOVE* to hear it. 11 | """ 12 | 13 | from docutils.nodes import Text 14 | from sphinx.addnodes import pending_xref 15 | from sphinx.util import logging 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | FOUND_PATH_TO_CANONICAL_MAP = { 21 | "hassette.events.HassStateDict": "hassette.events.hass.raw.HassStateDict", 22 | "hassette.models.states.BaseState": "hassette.models.states.base.BaseState", 23 | "hassette.TaskBucket": "hassette.task_bucket.TaskBucket", 24 | "hassette.Hassette": "hassette.core.Hassette", 25 | "hassette.HassetteConfig": "hassette.config.core_config.HassetteConfig", 26 | "hassette.bus.Listener": "hassette.bus.listeners.Listener", 27 | "hassette.bus.Bus": "hassette.bus.bus.Bus", 28 | "hassette.types.JobCallable": "hassette.types.types.JobCallable", 29 | "hassette.types.ScheduleStartType": "hassette.types.types.ScheduleStartType", 30 | "hassette.models.states.StateT": "hassette.models.states.base.StateT", 31 | "hassette.models.states.StateValueT": "hassette.models.states.base.StateValueT", 32 | "hassette.models.entities.EntityT": "hassette.models.entities.base.EntityT", 33 | "hassette.types.KnownTypeScalar": "hassette.types.types.KnownTypeScalar", 34 | "hassette.types.HandlerType": "hassette.types.handlers.HandlerType", 35 | "hassette.types.AsyncHandlerType": "hassette.types.handlers.AsyncHandlerType", 36 | "hassette.types.ComparisonCondition": "hassette.types.types.ComparisonCondition", 37 | "hassette.types.Predicate": "hassette.types.types.Predicate", 38 | "hassette.types.ChangeType": "hassette.types.types.ChangeType", 39 | "hassette.models.states.StateUnion": "hassette.models.states.base.StateUnion", 40 | "EntityT": "hassette.models.entities.base.EntityT", 41 | "StateT": "hassette.models.states.base.StateT", 42 | "StateValueT": "hassette.models.states.base.StateValueT", 43 | "hassette.Api": "hassette.api.api.Api", 44 | "hassette.api.Api": "hassette.api.api.Api", 45 | "hassette.scheduler.Scheduler": "hassette.scheduler.scheduler.Scheduler", 46 | "hassette.app.App": "hassette.app.app.App", 47 | } 48 | 49 | CANONICAL_TYPE_MAP = { 50 | "hassette.types.types.JobCallable": "type", 51 | "hassette.types.types.ScheduleStartType": "type", 52 | "hassette.models.states.base.StateT": "type", 53 | "hassette.models.states.base.StateValueT": "type", 54 | "hassette.models.entities.base.EntityT": "type", 55 | "hassette.types.handlers.HandlerType": "type", 56 | "hassette.types.handlers.AsyncHandlerType": "type", 57 | "hassette.types.types.KnownTypeScalar": "type", 58 | "hassette.types.types.ComparisonCondition": "type", 59 | "hassette.types.types.Predicate": "type", 60 | "hassette.types.types.ChangeType": "type", 61 | } 62 | 63 | 64 | def resolve_aliases(app, doctree): # noqa 65 | """Remap documented references to their canonical locations and types.""" 66 | pending_xrefs = doctree.traverse(condition=pending_xref) 67 | for node in pending_xrefs: 68 | alias = node.get("reftarget", None) 69 | 70 | # if we've defined this in our remap table, swap it out 71 | if alias is not None and alias in FOUND_PATH_TO_CANONICAL_MAP: 72 | real_ref = FOUND_PATH_TO_CANONICAL_MAP[alias] 73 | node["reftarget"] = real_ref 74 | 75 | # if real ref is a different reftype, swap that too 76 | if real_ref in CANONICAL_TYPE_MAP: 77 | node["reftype"] = CANONICAL_TYPE_MAP[real_ref] 78 | 79 | text_node = next(iter(node.traverse(lambda n: n.tagname == "#text"))) 80 | text_node.parent.replace(text_node, Text(real_ref)) 81 | 82 | 83 | def setup(app): 84 | app.connect("doctree-read", resolve_aliases) 85 | return { 86 | "version": "0.1", 87 | "parallel_read_safe": True, 88 | "parallel_write_safe": True, 89 | } 90 | -------------------------------------------------------------------------------- /src/hassette/test_utils/__init__.py: -------------------------------------------------------------------------------- 1 | """These are quick and dirty fixtures for testing during internal development. 2 | 3 | They currently are not meant to be used by external users and will likely not be supported (e.g. bug requests). 4 | However, if you find them useful, knock yourself out. 5 | """ 6 | 7 | from .fixtures import ( 8 | hassette_harness, 9 | hassette_with_app_handler, 10 | hassette_with_bus, 11 | hassette_with_file_watcher, 12 | hassette_with_mock_api, 13 | hassette_with_scheduler, 14 | ) 15 | from .harness import HassetteHarness 16 | from .test_server import SimpleTestServer 17 | 18 | __all__ = [ 19 | "HassetteHarness", 20 | "SimpleTestServer", 21 | "hassette_harness", 22 | "hassette_with_app_handler", 23 | "hassette_with_bus", 24 | "hassette_with_file_watcher", 25 | "hassette_with_mock_api", 26 | "hassette_with_scheduler", 27 | ] 28 | 29 | # TODO: clean these up and make them user facing 30 | -------------------------------------------------------------------------------- /src/hassette/test_utils/test_server.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, deque 2 | from collections.abc import Iterable 3 | from dataclasses import astuple, dataclass 4 | from typing import Any 5 | 6 | from aiohttp import web 7 | from whenever import PlainDateTime 8 | 9 | # Key = tuple[str, str, str] # (METHOD, PATH, QUERYSTRING) 10 | 11 | 12 | @dataclass(eq=True, frozen=True) 13 | class Key: 14 | method: str 15 | path: str 16 | query: str 17 | 18 | 19 | @dataclass 20 | class Expected: 21 | status: int 22 | json: Any 23 | 24 | 25 | class SimpleTestServer: 26 | """ 27 | Minimal HTTP double for Home Assistant. 28 | 29 | Usage: 30 | mock.expect("GET", "/api/states/light.kitchen", "", json={...}) 31 | app.router.add_route("*", "/{tail:.*}", mock.handle_request) 32 | """ 33 | 34 | def __init__(self) -> None: 35 | self._expectations: dict[Key, deque[Expected]] = defaultdict(deque) 36 | self._unexpected: list[Key] = [] 37 | 38 | # ----- registering expectations ----- 39 | 40 | def expect( 41 | self, 42 | method: str, 43 | path: str, 44 | query: str = "", 45 | *, 46 | json: Any = None, 47 | status: int = 200, 48 | repeat: int = 1, 49 | ) -> None: 50 | key = Key(method.upper(), path, query or "") 51 | for _ in range(repeat): 52 | self._expectations[key].append(Expected(status=status, json=json)) 53 | 54 | # Nice helper for history endpoints (keeps query ordering stable) 55 | @staticmethod 56 | def make_history_path( 57 | entity_ids: Iterable[str], 58 | start: PlainDateTime, 59 | end: PlainDateTime, 60 | *, 61 | minimal: bool = False, 62 | ): 63 | ids = ",".join(entity_ids) 64 | path = f"/api/history/period/{start.format_iso()}" 65 | qs = f"filter_entity_id={ids}&end_time={end.format_iso()}" 66 | if minimal: 67 | qs += "&minimal_response=true" 68 | # Caller still needs to provide METHOD, so this returns (PATH, QUERY) 69 | return path, qs # (path, query) 70 | 71 | # ----- request handler ----- 72 | 73 | async def handle_request(self, request: web.Request) -> web.StreamResponse: 74 | key = Key(request.method, request.path, request.query_string or "") 75 | bucket = self._expectations.get(key) 76 | 77 | if not bucket: 78 | # record so teardown can fail loudly with details 79 | self._unexpected.append(key) 80 | return web.Response(status=599, text=f"Unexpected request: {key}") 81 | 82 | exp = bucket.popleft() 83 | if exp.json is None: 84 | return web.Response(status=exp.status) 85 | return web.json_response(exp.json, status=exp.status) 86 | 87 | # ----- teardown assertions ----- 88 | 89 | def _leftovers(self) -> list[tuple[Key, int]]: 90 | return [(k, len(v)) for k, v in self._expectations.items() if v] 91 | 92 | def assert_clean(self) -> None: 93 | leftovers = self._leftovers() 94 | 95 | errors = [] 96 | if self._unexpected: 97 | errors.append(f"Unexpected requests: {self._unexpected}") 98 | 99 | if leftovers: 100 | errors.append(f"Expected requests not seen: {leftovers}") 101 | 102 | assert not errors, f"MockHaApi assertions failed: {errors}" 103 | 104 | def dump_all(self): 105 | expectations = {astuple(k): [astuple(e) for e in v] for k, v in self._expectations.items()} 106 | expectations = {str(k): v for k, v in expectations.items() if v} 107 | 108 | # expectations = {str(k): v for k, v in self._expectations.items() if v} 109 | unexpected = [str(k) for k in self._unexpected] 110 | 111 | return {"expectations": expectations, "unexpected": unexpected} 112 | -------------------------------------------------------------------------------- /src/hassette/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .handler import ( 2 | AsyncHandlerType, 3 | AsyncHandlerTypeEvent, 4 | AsyncHandlerTypeNoEvent, 5 | HandlerType, 6 | HandlerTypeNoEvent, 7 | ) 8 | from .types import ( 9 | ChangeType, 10 | ComparisonCondition, 11 | JobCallable, 12 | KnownType, 13 | KnownTypeScalar, 14 | Predicate, 15 | ScheduleStartType, 16 | TriggerProtocol, 17 | ) 18 | 19 | __all__ = [ 20 | "AsyncHandlerType", 21 | "AsyncHandlerTypeEvent", 22 | "AsyncHandlerTypeNoEvent", 23 | "ChangeType", 24 | "ComparisonCondition", 25 | "HandlerType", 26 | "HandlerTypeNoEvent", 27 | "JobCallable", 28 | "KnownType", 29 | "KnownTypeScalar", 30 | "Predicate", 31 | "ScheduleStartType", 32 | "TriggerProtocol", 33 | ] 34 | -------------------------------------------------------------------------------- /src/hassette/types/enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class ResourceStatus(StrEnum): 5 | """Enumeration for resource status.""" 6 | 7 | NOT_STARTED = auto() 8 | """The resource has not been started yet.""" 9 | 10 | STARTING = auto() 11 | """The resource is in the process of starting.""" 12 | 13 | RUNNING = auto() 14 | """The resource is currently running.""" 15 | 16 | STOPPED = auto() 17 | """The resource has been stopped without errors.""" 18 | 19 | FAILED = auto() 20 | """The resource has failed with a recoverable error.""" 21 | 22 | CRASHED = auto() 23 | """The resource has crashed unexpectedly and cannot recover.""" 24 | 25 | 26 | class ResourceRole(StrEnum): 27 | """Enumeration for resource roles.""" 28 | 29 | CORE = "Core" 30 | """Only used by Hassette directly, as it does not inherit from Resource.""" 31 | 32 | BASE = "Base" 33 | """The base role for all resources.""" 34 | 35 | SERVICE = "Service" 36 | """A service resource.""" 37 | 38 | RESOURCE = "Resource" 39 | """A generic resource.""" 40 | 41 | APP = "App" 42 | """An application resource.""" 43 | 44 | UNKNOWN = "Unknown" 45 | """An unknown or unclassified resource.""" 46 | -------------------------------------------------------------------------------- /src/hassette/types/handler.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable 2 | from typing import Any, Protocol 3 | 4 | from typing_extensions import TypeAliasType 5 | 6 | from hassette.events.base import EventT 7 | 8 | 9 | class SyncHandlerTypeNoEvent(Protocol): 10 | """Protocol for sync handlers that do not take an event argument.""" 11 | 12 | def __call__(self, *args: Any, **kwargs: Any) -> Any: ... 13 | 14 | 15 | class SyncHandler(Protocol[EventT]): 16 | """Protocol for sync handlers that take an event as first parameter.""" 17 | 18 | def __call__(self, event: EventT, *args: Any, **kwargs: Any) -> Any: ... 19 | 20 | 21 | class AsyncHandlerTypeNoEvent(Protocol): 22 | """Protocol for async handlers that do not take an event argument.""" 23 | 24 | def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[None]: ... 25 | 26 | 27 | class AsyncHandler(Protocol[EventT]): 28 | """Protocol for async handlers that take an event as first parameter.""" 29 | 30 | def __call__(self, event: EventT, *args: Any, **kwargs: Any) -> Awaitable[None]: ... 31 | 32 | 33 | ## Type Aliases ## 34 | 35 | SyncHandlerTypeEvent = TypeAliasType("SyncHandlerTypeEvent", SyncHandler[EventT], type_params=(EventT,)) 36 | """Alias for sync handler types that take an event argument.""" 37 | 38 | 39 | AsyncHandlerTypeEvent = TypeAliasType("AsyncHandlerTypeEvent", AsyncHandler[EventT], type_params=(EventT,)) 40 | """Alias for async handler types that take an event argument.""" 41 | 42 | AsyncHandlerType = TypeAliasType( 43 | "AsyncHandlerType", AsyncHandlerTypeEvent[EventT] | AsyncHandlerTypeNoEvent, type_params=(EventT,) 44 | ) 45 | """Alias for all valid async handler types.""" 46 | 47 | HandlerTypeNoEvent = SyncHandlerTypeNoEvent | AsyncHandlerTypeNoEvent 48 | """Alias for all valid handler types that do not take an event argument.""" 49 | 50 | HandlerTypeEvent = TypeAliasType( 51 | "HandlerTypeEvent", SyncHandlerTypeEvent[EventT] | AsyncHandlerTypeEvent[EventT], type_params=(EventT,) 52 | ) 53 | """Alias for all valid handler types that take an event argument.""" 54 | 55 | HandlerType = TypeAliasType("HandlerType", HandlerTypeEvent[EventT] | HandlerTypeNoEvent, type_params=(EventT,)) 56 | """Alias for all valid handler types.""" 57 | -------------------------------------------------------------------------------- /src/hassette/types/topics.py: -------------------------------------------------------------------------------- 1 | # TODO: convert these to Enums 2 | 3 | # service events 4 | 5 | HASSETTE_EVENT_SERVICE_STATUS = "hassette.event.service_status" 6 | """Service status updates""" 7 | 8 | HASSETTE_EVENT_WEBSOCKET_STATUS = "hassette.event.websocket" 9 | """WebSocket connection status updates""" 10 | 11 | HASSETTE_EVENT_FILE_WATCHER = "hassette.event.file_watcher" 12 | """File watcher events""" 13 | 14 | HASSETTE_EVENT_APP_LOAD_COMPLETED = "hassette.event.app_load_completed" 15 | """Application load completion events""" 16 | 17 | # Home Assistant events 18 | 19 | HASS_EVENT_STATE_CHANGED = "hass.event.state_changed" 20 | """State change events""" 21 | 22 | HASS_EVENT_CALL_SERVICE = "hass.event.call_service" 23 | """Service call events""" 24 | 25 | HASS_EVENT_COMPONENT_LOADED = "hass.event.component_loaded" 26 | """Component loaded events""" 27 | 28 | HASS_EVENT_SERVICE_REGISTERED = "hass.event.service_registered" 29 | """Service registered events""" 30 | 31 | HASS_EVENT_SERVICE_REMOVED = "hass.event.service_removed" 32 | """Service removed events""" 33 | 34 | HASS_EVENT_LOGBOOK_ENTRY = "hass.event.logbook_entry" 35 | """Logbook entry events""" 36 | 37 | HASS_EVENT_USER_ADDED = "hass.event.user_added" 38 | """User added events""" 39 | 40 | HASS_EVENT_USER_REMOVED = "hass.event.user_removed" 41 | """User removed events""" 42 | 43 | HASS_EVENT_AUTOMATION_TRIGGERED = "hass.event.automation_triggered" 44 | """Automation triggered events""" 45 | 46 | HASS_EVENT_SCRIPT_STARTED = "hass.event.script_started" 47 | """Script started events""" 48 | -------------------------------------------------------------------------------- /src/hassette/types/types.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable, Mapping, Sequence 2 | from datetime import time 3 | from pathlib import Path 4 | from typing import Any, Protocol, Required, TypeAlias, TypeVar 5 | 6 | from typing_extensions import Sentinel, TypeAliasType, TypedDict 7 | from whenever import Date, PlainDateTime, Time, TimeDelta, ZonedDateTime 8 | 9 | from hassette.events.base import EventT 10 | 11 | V = TypeVar("V") # value type from the accessor 12 | V_contra = TypeVar("V_contra", contravariant=True) 13 | 14 | 15 | class Predicate(Protocol[EventT]): 16 | """Protocol for defining predicates that evaluate events.""" 17 | 18 | def __call__(self, event: EventT) -> bool: ... 19 | 20 | 21 | class Condition(Protocol[V_contra]): 22 | """Alias for a condition callable that takes a value or Sentinel and returns a bool.""" 23 | 24 | def __call__(self, value: V_contra, /) -> bool: ... 25 | 26 | 27 | class ComparisonCondition(Protocol[V_contra]): 28 | """Protocol for a comparison condition callable that takes two values and returns a bool.""" 29 | 30 | def __call__(self, old_value: V_contra, new_value: V_contra, /) -> bool: ... 31 | 32 | 33 | class TriggerProtocol(Protocol): 34 | """Protocol for defining triggers.""" 35 | 36 | def next_run_time(self) -> ZonedDateTime: 37 | """Return the next run time of the trigger.""" 38 | ... 39 | 40 | 41 | KnownTypeScalar: TypeAlias = ZonedDateTime | PlainDateTime | Time | Date | None | float | int | bool | str 42 | """Alias for all known valid scalar state types.""" 43 | 44 | KnownType: TypeAlias = KnownTypeScalar | Sequence[KnownTypeScalar] | Mapping[str, KnownTypeScalar] 45 | """Alias for all known valid state types.""" 46 | 47 | ChangeType = TypeAliasType( 48 | "ChangeType", None | Sentinel | V | Condition[V | Sentinel] | ComparisonCondition[V | Sentinel], type_params=(V,) 49 | ) 50 | """Alias for types that can be used to specify changes in predicates.""" 51 | 52 | JobCallable: TypeAlias = Callable[..., Awaitable[None]] | Callable[..., Any] 53 | """Alias for a callable that can be scheduled as a job.""" 54 | 55 | ScheduleStartType: TypeAlias = ZonedDateTime | Time | time | tuple[int, int] | TimeDelta | int | float | None 56 | """Type for specifying start times.""" 57 | 58 | 59 | class RawAppDict(TypedDict, total=False): 60 | """Structure for raw app configuration before processing. 61 | 62 | Not all fields are required at this stage, as we will enrich and validate them later. 63 | """ 64 | 65 | filename: Required[str] 66 | class_name: Required[str] 67 | app_dir: Path | str 68 | enabled: bool 69 | config: dict[str, Any] | list[dict[str, Any]] 70 | auto_loaded: bool 71 | 72 | 73 | class AppDict(TypedDict, total=False): 74 | """Structure for processed app configuration.""" 75 | 76 | app_key: Required[str] 77 | filename: Required[str] 78 | class_name: Required[str] 79 | app_dir: Required[Path] 80 | enabled: bool 81 | config: list[dict[str, Any]] 82 | auto_loaded: bool 83 | full_path: Required[Path] 84 | -------------------------------------------------------------------------------- /src/hassette/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .date_utils import convert_datetime_str_to_system_tz, convert_utc_timestamp_to_system_tz 2 | from .exception_utils import get_traceback_string 3 | from .service_utils import wait_for_ready 4 | 5 | __all__ = [ 6 | "convert_datetime_str_to_system_tz", 7 | "convert_utc_timestamp_to_system_tz", 8 | "get_traceback_string", 9 | "wait_for_ready", 10 | ] 11 | -------------------------------------------------------------------------------- /src/hassette/utils/date_utils.py: -------------------------------------------------------------------------------- 1 | from whenever import OffsetDateTime, ZonedDateTime 2 | 3 | 4 | def convert_utc_timestamp_to_system_tz(timestamp: int | float) -> ZonedDateTime: 5 | """Convert a UTC timestamp to ZonedDateTime in system timezone. 6 | 7 | Args: 8 | timestamp: The UTC timestamp. 9 | 10 | Returns: 11 | The converted ZonedDateTime. 12 | """ 13 | return ZonedDateTime.from_timestamp(timestamp, tz="UTC").to_system_tz() 14 | 15 | 16 | def convert_datetime_str_to_system_tz(value: str | ZonedDateTime | None) -> ZonedDateTime | None: 17 | """Convert an ISO 8601 datetime string to ZonedDateTime in system timezone. 18 | 19 | Args: 20 | value: The ISO 8601 datetime string. 21 | 22 | Returns: 23 | ZonedDateTime | None: The converted ZonedDateTime or None if input is None. 24 | """ 25 | if value is None or isinstance(value, ZonedDateTime): 26 | return value 27 | return OffsetDateTime.parse_iso(value).to_system_tz() 28 | 29 | 30 | def now() -> ZonedDateTime: 31 | """Get the current time. 32 | 33 | This exists to avoid direct calls to ZonedDateTime.now_in_system_tz() in the codebase, in case we need to change 34 | the implementation later. 35 | """ 36 | return ZonedDateTime.now_in_system_tz() 37 | -------------------------------------------------------------------------------- /src/hassette/utils/exception_utils.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | 4 | def get_traceback_string(exception: Exception | BaseException) -> str: 5 | """Get a formatted traceback string from an exception.""" 6 | 7 | return "".join(traceback.format_exception(type(exception), exception, exception.__traceback__)) 8 | -------------------------------------------------------------------------------- /src/hassette/utils/func_utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import inspect 3 | from collections.abc import Callable 4 | from functools import lru_cache, partial 5 | from types import MethodType 6 | from typing import Any 7 | 8 | 9 | def is_async_callable(fn: Callable[..., object] | Any) -> bool: 10 | """Check if a callable is asynchronous. 11 | 12 | Args: 13 | fn: The callable to check. 14 | 15 | Returns: 16 | True if the callable is asynchronous, False otherwise. 17 | 18 | This function checks for various types of callables, including: 19 | - Plain async functions 20 | - functools.partial objects wrapping async functions 21 | - Callable instances with an async __call__ method 22 | - Functions decorated with @wraps that preserve the async nature 23 | """ 24 | 25 | # plain async def foo(...) 26 | if inspect.iscoroutinefunction(fn): 27 | return True 28 | 29 | # functools.partial of something async 30 | if isinstance(fn, partial): 31 | return is_async_callable(fn.func) 32 | 33 | # callable instance with async __call__ 34 | call = getattr(fn, "__call__", None) # noqa: B004 35 | if call and inspect.iscoroutinefunction(call): 36 | return True 37 | 38 | # unwrapped functions (decorated with @wraps) 39 | if hasattr(fn, "__wrapped__"): 40 | try: 41 | unwrapped = inspect.unwrap(fn) # follows __wrapped__ chain 42 | except Exception: 43 | return False 44 | return inspect.iscoroutinefunction(unwrapped) 45 | 46 | return False 47 | 48 | 49 | @lru_cache(maxsize=1024) 50 | def callable_name(fn: Any) -> str: 51 | """Get a human-readable name for a callable object. 52 | 53 | This function attempts to return a string representation of the callable that includes 54 | its module, class (if applicable), and function name. It handles various types of callables 55 | including functions, methods, and partials. 56 | 57 | Args: 58 | fn: The callable object to inspect. 59 | 60 | Returns: 61 | A string representation of the callable. 62 | """ 63 | # unwrap decorator chains 64 | with contextlib.suppress(Exception): 65 | fn = inspect.unwrap(fn) 66 | 67 | # functools.partial 68 | if isinstance(fn, partial): 69 | return f"partial({callable_name(fn.func)})" 70 | 71 | # bound method 72 | if isinstance(fn, MethodType): 73 | self_obj = fn.__self__ 74 | cls = type(self_obj).__name__ 75 | return f"{self_obj.__module__}.{cls}.{fn.__name__}" 76 | 77 | # plain function 78 | if hasattr(fn, "__qualname__"): 79 | mod = getattr(fn, "__module__", None) or "" 80 | return f"{mod}.{fn.__qualname__}" 81 | 82 | # callable object 83 | if callable(fn): 84 | cls = type(fn).__name__ 85 | mod = type(fn).__module__ 86 | return f"{mod}.{cls}.__call__" 87 | 88 | return repr(fn) 89 | 90 | 91 | def callable_short_name(fn: Any) -> str: 92 | """Get a short name for a callable object. 93 | 94 | This function returns the last part of the callable's full name, which is typically the 95 | function or method name. 96 | 97 | Args: 98 | fn: The callable object to inspect. 99 | 100 | Returns: 101 | The short name of the callable. 102 | """ 103 | 104 | full_name = callable_name(fn) 105 | return full_name.split(".")[-1] 106 | -------------------------------------------------------------------------------- /src/hassette/utils/glob_utils.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from fnmatch import fnmatch 3 | 4 | GLOB_CHARS = "*?[" 5 | """Characters that indicate a glob pattern.""" 6 | 7 | 8 | def is_glob(value: str) -> bool: 9 | """Returns True if the value is a glob pattern. 10 | 11 | Args: 12 | value: The value to check. 13 | 14 | Returns: 15 | True if the value is a glob pattern, False otherwise. 16 | """ 17 | 18 | return any(ch in value for ch in GLOB_CHARS) 19 | 20 | 21 | def split_exact_and_glob(values: typing.Iterable[str]) -> tuple[set[str], tuple[str, ...]]: 22 | """Splits a list of strings into exact matches and glob patterns. 23 | 24 | Args: 25 | values: The list of strings to split. 26 | 27 | Returns: 28 | A tuple containing a set of exact matches and a tuple of glob patterns. 29 | """ 30 | 31 | exact: set[str] = set() 32 | globs: list[str] = [] 33 | for value in values: 34 | if any(ch in value for ch in GLOB_CHARS): 35 | globs.append(value) 36 | else: 37 | exact.add(value) 38 | return exact, tuple(globs) 39 | 40 | 41 | def matches_globs(value: str, patterns: tuple[str, ...]) -> bool: 42 | """Returns True if the value matches any of the glob patterns. 43 | 44 | Args: 45 | value: The value to check. 46 | patterns: The glob patterns to match against. 47 | 48 | Returns: 49 | True if the value matches any of the patterns, False otherwise. 50 | """ 51 | 52 | return any(fnmatch(value, pattern) for pattern in patterns) 53 | -------------------------------------------------------------------------------- /src/hassette/utils/hass_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for Home Assistant. 2 | 3 | These have been copied from HA to ensure we use the same logic.""" 4 | 5 | import functools 6 | import re 7 | 8 | MAX_EXPECTED_ENTITY_IDS = 16384 9 | 10 | 11 | @functools.lru_cache(MAX_EXPECTED_ENTITY_IDS) 12 | def split_entity_id(entity_id: str) -> tuple[str, str]: 13 | """Split a state entity ID into domain and object ID.""" 14 | domain, _, object_id = entity_id.partition(".") 15 | if not domain or not object_id: 16 | raise ValueError(f"Invalid entity ID {entity_id}") 17 | return domain, object_id 18 | 19 | 20 | _OBJECT_ID = r"(?!_)[\da-z_]+(? bool: 28 | """Test if a domain a valid format.""" 29 | return VALID_DOMAIN.match(domain) is not None 30 | 31 | 32 | @functools.lru_cache(512) 33 | def valid_entity_id(entity_id: str) -> bool: 34 | """Test if an entity ID is a valid format. 35 | 36 | Format: . where both are slugs. 37 | """ 38 | return VALID_ENTITY_ID.match(entity_id) is not None 39 | -------------------------------------------------------------------------------- /src/hassette/utils/request_utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Mapping 2 | from datetime import datetime 3 | from typing import Any 4 | 5 | import orjson 6 | from whenever import Date, Instant, PlainDateTime, ZonedDateTime 7 | 8 | 9 | def orjson_dump(data: Any) -> str: 10 | return orjson.dumps(data, default=str).decode("utf-8") 11 | 12 | 13 | def clean_kwargs(**kwargs: Any) -> dict[str, Any]: 14 | """Converts values to strings where needed and removes keys with None values.""" 15 | 16 | def clean_value(val: Any) -> Any: 17 | if val is None: 18 | return None 19 | 20 | if isinstance(val, bool): 21 | return str(val).lower() 22 | 23 | if isinstance(val, (PlainDateTime | ZonedDateTime | Instant | Date)): 24 | return val.format_iso() 25 | 26 | if isinstance(val, (int | float | str)): 27 | if isinstance(val, str) and not val.strip(): 28 | return None 29 | return val 30 | 31 | if isinstance(val, datetime): 32 | return val.isoformat() 33 | 34 | if isinstance(val, Mapping): 35 | return {k: clean_value(v) for k, v in val.items() if v is not None} 36 | 37 | if isinstance(val, Iterable) and not isinstance(val, str | bytes): 38 | return [clean_value(v) for v in val if v is not None] 39 | 40 | return str(val) 41 | 42 | return {k: cleaned for k, v in kwargs.items() if (cleaned := clean_value(v)) is not None} 43 | -------------------------------------------------------------------------------- /src/hassette/utils/service_utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing 3 | 4 | if typing.TYPE_CHECKING: 5 | from hassette.resources.base import Resource 6 | 7 | 8 | async def wait_for_ready( 9 | resources: "list[Resource] | Resource", 10 | poll_interval: float = 0.1, 11 | timeout: int = 20, 12 | shutdown_event: asyncio.Event | None = None, 13 | ) -> bool: 14 | """Block until all dependent resources are ready or shutdown is requested. 15 | 16 | Args: 17 | resources: The resources to wait for. 18 | poll_interval: The interval to poll for resource status. 19 | timeout: The timeout for the wait operation. 20 | 21 | Returns: 22 | True if all resources are ready, False if timeout or shutdown. 23 | 24 | Raises: 25 | CancelledError: If the wait operation is cancelled. 26 | TimeoutError: If the wait operation times out. 27 | """ 28 | 29 | resources = resources if isinstance(resources, list) else [resources] 30 | deadline = asyncio.get_event_loop().time() + timeout 31 | while True: 32 | if shutdown_event and shutdown_event.is_set(): 33 | return False 34 | if all(r.is_ready() for r in resources): 35 | return True 36 | if asyncio.get_event_loop().time() >= deadline: 37 | return False 38 | await asyncio.sleep(poll_interval) 39 | -------------------------------------------------------------------------------- /src/hassette/utils/url_utils.py: -------------------------------------------------------------------------------- 1 | """URL utilities for constructing Home Assistant API endpoints.""" 2 | 3 | import typing 4 | 5 | from yarl import URL 6 | 7 | from hassette.exceptions import BaseUrlRequiredError, IPV6NotSupportedError, SchemeRequiredInBaseUrlError 8 | 9 | if typing.TYPE_CHECKING: 10 | from hassette.config.core_config import HassetteConfig 11 | 12 | 13 | def _parse_and_normalize_url(config: "HassetteConfig") -> tuple[str, str, int | None]: 14 | """Parse base_url and extract normalized components. 15 | 16 | Args: 17 | config: Hassette configuration containing base_url and api_port 18 | 19 | Returns: 20 | schema, hostname, and port 21 | 22 | Raises: 23 | BaseUrlRequiredError: If base_url is not set in the configuration. 24 | IPV6NotSupportedError: If base_url contains an IPv6 address. 25 | SchemeRequiredInBaseUrlError: If base_url does not include a scheme. 26 | """ 27 | 28 | if not config.base_url: 29 | raise BaseUrlRequiredError(f"base_url must be set in the configuration, got: {config.base_url}") 30 | 31 | if "::" in config.base_url: 32 | raise IPV6NotSupportedError(f"IPv6 addresses are not supported in base_url, got: {config.base_url}") 33 | 34 | yurl = URL(config.base_url.strip()) 35 | 36 | if not yurl.scheme: 37 | raise SchemeRequiredInBaseUrlError( 38 | f"base_url must include a scheme (http:// or https://), got: {config.base_url}" 39 | ) 40 | 41 | if yurl.host is None: 42 | raise BaseUrlRequiredError(f"base_url must include a valid hostname, got: {config.base_url}") 43 | 44 | return yurl.scheme, yurl.host, yurl.explicit_port 45 | 46 | 47 | def build_ws_url(config: "HassetteConfig") -> str: 48 | """Construct the WebSocket URL for Home Assistant. 49 | 50 | Args: 51 | config: Hassette configuration containing connection details 52 | 53 | Returns: 54 | Complete WebSocket URL for Home Assistant API 55 | """ 56 | scheme, hostname, port = _parse_and_normalize_url(config) 57 | 58 | # Convert HTTP scheme to WebSocket scheme 59 | ws_scheme = "wss" if scheme == "https" else "ws" 60 | 61 | yurl = URL.build(scheme=ws_scheme, host=hostname, port=port, path="/api/websocket") 62 | return str(yurl) 63 | 64 | 65 | def build_rest_url(config: "HassetteConfig") -> str: 66 | """Construct the REST API URL for Home Assistant. 67 | 68 | Args: 69 | config: Hassette configuration containing connection details 70 | 71 | Returns: 72 | Complete REST API URL for Home Assistant API 73 | """ 74 | scheme, hostname, port = _parse_and_normalize_url(config) 75 | 76 | yurl = URL.build(scheme=scheme, host=hostname, port=port, path="/api/") 77 | 78 | return str(yurl) 79 | -------------------------------------------------------------------------------- /tests/data/.env: -------------------------------------------------------------------------------- 1 | hassette__token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmNjlhYTIxMDlhNjU0MGU3YWZiYzdjZDJlN2U0ODIwOSIsImlhdCI6MTc1MzIzNzc2MiwiZXhwIjoyMDY4NTk3NzYyfQ.Q-V4TOPp9dVb_S9kTi1OAWQoe0DG0AIWQIsNwEAJ3Fg 2 | hassette__base_url=http://127.0.0.1:8123 3 | hassette__apps__myfakeapp__config__value=42 4 | -------------------------------------------------------------------------------- /tests/data/device_tracker_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "event", 3 | "event": { 4 | "event_type": "state_changed", 5 | "data": { 6 | "entity_id": "device_tracker.tp_link_technologies_45_85_86", 7 | "old_state": { 8 | "entity_id": "device_tracker.tp_link_technologies_45_85_86", 9 | "state": "not home", 10 | "attributes": { 11 | "source_type": "router", 12 | "ip": "192.168.1.245", 13 | "mac": "00:11:22:33:44:55", 14 | "last_time_reachable": "2025-06-30T15:14:43-05:00", 15 | "reason": "arp-response", 16 | "friendly_name": "TP-Link Technologies 33:44:55" 17 | }, 18 | "last_changed": "2025-06-30T16:42:44.000135+00:00", 19 | "last_reported": "2025-06-30T20:14:43.591194+00:00", 20 | "last_updated": "2025-06-30T20:14:43.591194+00:00", 21 | "context": { 22 | "id": "01JZ17MJC7F8HGT7A4CRFXZ0DP", 23 | "parent_id": null, 24 | "user_id": null 25 | } 26 | }, 27 | "new_state": { 28 | "entity_id": "device_tracker.tp_link_technologies_45_85_86", 29 | "state": "home", 30 | "attributes": { 31 | "source_type": "router", 32 | "ip": "192.168.1.245", 33 | "mac": "00:11:22:33:44:55", 34 | "last_time_reachable": "2025-06-30T15:16:43-05:00", 35 | "reason": "arp-response", 36 | "friendly_name": "TP-Link Technologies 33:44:55" 37 | }, 38 | "last_changed": "2025-06-30T16:42:44.000135+00:00", 39 | "last_reported": "2025-06-30T20:16:43.677404+00:00", 40 | "last_updated": "2025-06-30T20:16:43.677404+00:00", 41 | "context": { 42 | "id": "01JZ17R7MX7E2Z7DCR1WYGNGEM", 43 | "parent_id": null, 44 | "user_id": null 45 | } 46 | } 47 | }, 48 | "origin": "LOCAL", 49 | "time_fired": "2025-06-30T20:16:43.677404+00:00", 50 | "context": { 51 | "id": "01JZ17R7MX7E2Z7DCR1WYGNGEM", 52 | "parent_id": null, 53 | "user_id": null 54 | } 55 | }, 56 | "id": 1 57 | } 58 | -------------------------------------------------------------------------------- /tests/data/disabled_app.py: -------------------------------------------------------------------------------- 1 | from hassette import App, AppConfig 2 | 3 | 4 | class MyAppUserConfig(AppConfig): 5 | test_entity: str = "input_button.test" 6 | 7 | 8 | class DisabledApp(App[MyAppUserConfig]): ... 9 | -------------------------------------------------------------------------------- /tests/data/hassette.toml: -------------------------------------------------------------------------------- 1 | [hassette] 2 | base_url = "http://127.0.0.1:8123" 3 | app_dir = "tests/data" 4 | 5 | secrets = ["my_secret"] 6 | -------------------------------------------------------------------------------- /tests/data/hassette_apps.toml: -------------------------------------------------------------------------------- 1 | [hassette] 2 | base_url = "http://127.0.0.1:8123" 3 | app_dir = "tests/data" 4 | 5 | secrets = ["my_secret"] 6 | 7 | [apps.my_app] 8 | enabled = true 9 | filename = "my_app.py" 10 | class_name = "MyApp" 11 | display_name = "My Test App" 12 | app_dir = "tests/data" 13 | config = { test_entity = "input_button.test", instance_name = "unique_instance_name" } 14 | 15 | [apps.my_app_sync] 16 | enabled = true 17 | filename = "my_app_sync.py" 18 | class_name = "MyAppSync" 19 | display_name = "My Test App Sync" 20 | app_dir = "tests/data" 21 | # instance_name left off for testing default behavior 22 | config = { test_entity = "input_button.test" } 23 | 24 | [apps.disabled_app] 25 | enabled = false 26 | filename = "disabled_app.py" 27 | class_name = "DisabledApp" 28 | display_name = "Disabled App" 29 | app_dir = "tests/data" 30 | config = { test_entity = "input_button.test", instance_name = "disabled_instance" } 31 | 32 | 33 | [apps.non_existent_app] 34 | enabled = false 35 | filename = "non_existent_file.py" 36 | class_name = "MissingClass" 37 | display_name = "Missing App" 38 | app_dir = "tests/data" 39 | config = { test_entity = "input_button.test", instance_name = "missing" } 40 | -------------------------------------------------------------------------------- /tests/data/minimal_history.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | { 4 | "entity_id": "light.entryway", 5 | "state": "on", 6 | "attributes": { 7 | "icon": "mdi:lightbulb-group", 8 | "friendly_name": "Entryway" 9 | }, 10 | "last_changed": "2025-06-30T05:00:00+00:00", 11 | "last_updated": "2025-06-30T05:00:00+00:00" 12 | }, 13 | { 14 | "state": "unavailable", 15 | "last_changed": "2025-06-30T16:39:15.821182+00:00" 16 | }, 17 | { 18 | "state": "unknown", 19 | "last_changed": "2025-06-30T16:39:42.249438+00:00" 20 | }, 21 | { 22 | "state": "on", 23 | "last_changed": "2025-06-30T16:40:27.543323+00:00" 24 | }, 25 | { 26 | "state": "unavailable", 27 | "last_changed": "2025-06-30T16:42:01.964269+00:00" 28 | }, 29 | { 30 | "state": "unknown", 31 | "last_changed": "2025-06-30T16:42:22.347849+00:00" 32 | }, 33 | { 34 | "state": "on", 35 | "last_changed": "2025-06-30T16:43:08.425249+00:00" 36 | } 37 | ], 38 | [ 39 | { 40 | "entity_id": "light.office", 41 | "state": "off", 42 | "attributes": { 43 | "icon": "mdi:lightbulb-group", 44 | "friendly_name": "Office" 45 | }, 46 | "last_changed": "2025-06-30T05:00:00+00:00", 47 | "last_updated": "2025-06-30T05:00:00+00:00" 48 | }, 49 | { 50 | "state": "on", 51 | "last_changed": "2025-06-30T11:00:00.638833+00:00" 52 | }, 53 | { 54 | "state": "unavailable", 55 | "last_changed": "2025-06-30T16:39:15.829305+00:00" 56 | }, 57 | { 58 | "state": "unknown", 59 | "last_changed": "2025-06-30T16:39:42.255047+00:00" 60 | }, 61 | { 62 | "state": "on", 63 | "last_changed": "2025-06-30T16:40:27.527050+00:00" 64 | }, 65 | { 66 | "state": "unavailable", 67 | "last_changed": "2025-06-30T16:42:01.970230+00:00" 68 | }, 69 | { 70 | "state": "unknown", 71 | "last_changed": "2025-06-30T16:42:22.351884+00:00" 72 | }, 73 | { 74 | "state": "on", 75 | "last_changed": "2025-06-30T16:43:08.407463+00:00" 76 | }, 77 | { 78 | "state": "off", 79 | "last_changed": "2025-07-01T04:08:51.472584+00:00" 80 | } 81 | ] 82 | ] 83 | -------------------------------------------------------------------------------- /tests/data/my_app.py: -------------------------------------------------------------------------------- 1 | from hassette import App, AppConfig 2 | from hassette.events import StateChangeEvent 3 | from hassette.models.entities import LightEntity 4 | from hassette.models.states import InputButtonState, LightState 5 | 6 | 7 | class MyAppUserConfig(AppConfig): 8 | test_entity: str = "input_button.test" 9 | 10 | 11 | class MyApp(App[MyAppUserConfig]): 12 | async def on_initialize(self) -> None: 13 | self.logger.info("MyApp is initializing") 14 | self.bus.on_state_change( 15 | "input_button.test", 16 | handler=self.handle_event_sync, 17 | args=("arg1",), 18 | kwargs={"kwarg1": "value1"}, 19 | ) 20 | self.scheduler.run_in(self.api.get_states, 1) 21 | self.scheduler.run_every( 22 | self.scheduled_job_example, 10, args=("value1", "value2"), kwargs={"kwarg1": "kwarg_value"} 23 | ) 24 | 25 | self.office_light_exists = await self.api.entity_exists("light.office") 26 | self.test_button_exists = await self.api.entity_exists("input_button.test") 27 | 28 | async def test_reload_app(self): 29 | await self.hassette._app_handler.reload_app(self.app_manifest.app_key) 30 | 31 | async def test_stuff(self) -> None: 32 | if self.office_light_exists: 33 | self.light_state = await self.api.get_state("light.office", model=LightState) 34 | self.light_entity = await self.api.get_entity("light.office", model=LightEntity) 35 | elif self.test_button_exists: 36 | self.button_state = await self.api.get_state("input_button.test", model=InputButtonState) 37 | self.logger.info("Button state: %s", self.button_state) 38 | 39 | def handle_event_sync(self, event: StateChangeEvent[InputButtonState], *args, **kwargs) -> None: 40 | self.logger.info("event: %s, args: %s, kwargs: %s", event, args, kwargs) 41 | test = self.api.sync.get_state_value("input_button.test") 42 | self.logger.info("state: %s", test) 43 | 44 | async def handle_event(self, event: StateChangeEvent, *args, **kwargs) -> None: 45 | self.logger.info("Async event: %s, args: %s, kwargs: %s", event, args, kwargs) 46 | test = await self.api.get_state_value("input_button.test") 47 | self.logger.info("Async state: %s", test) 48 | 49 | async def scheduled_job_example(self, test_value: str, test_value2: str, *, kwarg1: str | None = None): 50 | self.logger.info( 51 | "Scheduled job executed with test_value=%s, test_value2=%s, kwarg1=%s", test_value, test_value2, kwarg1 52 | ) 53 | -------------------------------------------------------------------------------- /tests/data/my_app_sync.py: -------------------------------------------------------------------------------- 1 | from hassette import AppConfig, AppSync 2 | from hassette.events import StateChangeEvent 3 | from hassette.models.entities import LightEntity 4 | from hassette.models.states import InputButtonState, LightState 5 | 6 | try: 7 | from .my_app import MyApp 8 | except ImportError: 9 | from my_app import MyApp # type: ignore 10 | 11 | 12 | class MyAppUserConfig(AppConfig): 13 | test_entity: str = "input_button.test" 14 | 15 | 16 | class MyAppSync(AppSync): 17 | def on_initialize_sync(self) -> None: 18 | self.bus.on_state_change("input_button.*", handler=self.handle_event) 19 | self.scheduler.run_in(self.test_stuff, 1) 20 | 21 | self.office_light_exists = self.api.sync.entity_exists("light.office") 22 | self.test_button_exists = self.api.sync.entity_exists("input_button.test") 23 | 24 | def test_stuff(self) -> None: 25 | my_app = self.hassette.get_app("my_app", 0) 26 | assert isinstance(my_app, MyApp), f"Expected MyApp, got {type(my_app)}" 27 | 28 | if self.office_light_exists: 29 | self.light_state = self.api.sync.get_state("light.office", model=LightState) 30 | self.light_entity = self.api.sync.get_entity("light.office", model=LightEntity) 31 | elif self.test_button_exists: 32 | self.button_state = self.api.sync.get_state("input_button.test", model=InputButtonState) 33 | self.logger.info("Button state: %s", self.button_state) 34 | 35 | def handle_event(self, event: StateChangeEvent) -> None: 36 | self.logger.info("event: %s", event) 37 | test = self.api.sync.get_state_value("input_button.test") 38 | self.logger.info("state: %s", test) 39 | -------------------------------------------------------------------------------- /tests/predicates/test_base_predicates.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | 3 | from hassette.bus.predicates import AllOf, AnyOf, Guard, Not 4 | from hassette.bus.utils import ensure_tuple, normalize_where 5 | 6 | 7 | def test_allof_requires_all_predicates_true() -> None: 8 | """Test that AllOf predicate returns True only when all contained predicates return True.""" 9 | predicate = AllOf((lambda _: True, lambda _: True)) # pyright: ignore[reportArgumentType] 10 | assert predicate(SimpleNamespace()) is True # pyright: ignore[reportArgumentType] 11 | 12 | 13 | def test_allof_returns_false_when_any_predicate_fails() -> None: 14 | """Test that AllOf predicate returns False when any contained predicate returns False.""" 15 | predicate = AllOf((lambda _: True, lambda _: False)) # pyright: ignore[reportArgumentType] 16 | assert predicate(SimpleNamespace()) is False # pyright: ignore[reportArgumentType] 17 | 18 | 19 | def test_anyof_succeeds_when_any_predicate_matches() -> None: 20 | """Test that AnyOf predicate returns True when any contained predicate returns True.""" 21 | predicate = AnyOf((lambda _: False, lambda _: True)) # pyright: ignore[reportArgumentType] 22 | assert predicate(SimpleNamespace()) is True # pyright: ignore[reportArgumentType] 23 | 24 | 25 | def test_anyof_returns_false_when_all_predicates_fail() -> None: 26 | """Test that AnyOf predicate returns False only when all contained predicates return False.""" 27 | predicate = AnyOf((lambda _: False, lambda _: False)) # pyright: ignore[reportArgumentType] 28 | assert predicate(SimpleNamespace()) is False # pyright: ignore[reportArgumentType] 29 | 30 | 31 | def test_not_inverts_predicate_result() -> None: 32 | """Test that Not predicate inverts the result of the wrapped predicate.""" 33 | predicate = Not(lambda _: True) # pyright: ignore[reportArgumentType] 34 | assert predicate(SimpleNamespace()) is False # pyright: ignore[reportArgumentType] 35 | 36 | 37 | def test_guard_wraps_callable_and_executes_it() -> None: 38 | """Test that Guard wraps a callable predicate and executes it correctly.""" 39 | sentinel = object() 40 | guard = Guard(lambda event: event is sentinel) 41 | assert guard(sentinel) is True 42 | assert guard(object()) is False 43 | 44 | 45 | def test_normalize_where_returns_allof_for_sequences() -> None: 46 | """Test that normalize_where wraps sequences of predicates in AllOf.""" 47 | predicate = normalize_where([lambda _: True, lambda _: True]) # pyright: ignore[reportArgumentType] 48 | assert isinstance(predicate, AllOf) 49 | 50 | 51 | def test_normalize_where_returns_single_predicate() -> None: 52 | """Test that normalize_where returns single predicates unchanged.""" 53 | 54 | def single(event) -> bool: # pyright: ignore[reportUnusedParameter] # noqa: ARG001 55 | return True 56 | 57 | predicate = normalize_where(single) 58 | assert predicate is single 59 | 60 | 61 | def test_normalize_where_returns_none_for_none() -> None: 62 | """Test that normalize_where returns None when passed None.""" 63 | predicate = normalize_where(None) 64 | assert predicate is None 65 | 66 | 67 | def test_ensure_tuple_flattens_nested_sequences() -> None: 68 | """Test that ensure_tuple flattens nested sequences of predicates into a flat tuple.""" 69 | predicates = ensure_tuple([lambda _: True, (lambda _: False, lambda _: True)]) # pyright: ignore[reportArgumentType] 70 | assert len(predicates) == 3 71 | 72 | 73 | def test_ensure_tuple_handles_single_predicate() -> None: 74 | """Test that ensure_tuple wraps single predicates in a tuple.""" 75 | 76 | def predicate(_event) -> bool: # pyright: ignore[reportUnusedParameter] 77 | return True 78 | 79 | result = ensure_tuple(predicate) # pyright: ignore[reportArgumentType] 80 | assert result == (predicate,) 81 | assert len(result) == 1 82 | -------------------------------------------------------------------------------- /tests/predicates/test_service_data_where.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from types import SimpleNamespace 3 | 4 | from hassette.bus.predicates import ServiceDataWhere 5 | from hassette.const import NOT_PROVIDED 6 | from hassette.events import Event 7 | 8 | 9 | def _make_event(service_data: dict[str, typing.Any]) -> Event: 10 | """Create a mock CallServiceEvent for testing.""" 11 | payload = SimpleNamespace(data=SimpleNamespace(service_data=service_data)) 12 | return typing.cast("Event", SimpleNamespace(payload=payload)) 13 | 14 | 15 | def test_service_data_where_not_provided_requires_presence() -> None: 16 | """Test that ServiceDataWhere with NOT_PROVIDED requires key presence.""" 17 | predicate = ServiceDataWhere({"required": NOT_PROVIDED}) 18 | 19 | assert predicate(_make_event({"required": 0})) is True 20 | assert predicate(_make_event({})) is False 21 | 22 | 23 | def test_service_data_where_typing_any_requires_presence() -> None: 24 | """Test that ServiceDataWhere with NOT_PROVIDED works with any value type.""" 25 | predicate = ServiceDataWhere({"required": NOT_PROVIDED}) 26 | 27 | assert predicate(_make_event({"required": "value"})) is True 28 | assert predicate(_make_event({})) is False 29 | 30 | 31 | def test_service_data_where_exact_value_matching() -> None: 32 | """Test that ServiceDataWhere matches exact values in service data.""" 33 | predicate = ServiceDataWhere({"brightness": 255, "entity_id": "light.living"}) 34 | 35 | matching_event = _make_event({"brightness": 255, "entity_id": "light.living"}) 36 | non_matching_brightness = _make_event({"brightness": 200, "entity_id": "light.living"}) 37 | non_matching_entity = _make_event({"brightness": 255, "entity_id": "light.kitchen"}) 38 | 39 | assert predicate(matching_event) is True 40 | assert predicate(non_matching_brightness) is False 41 | assert predicate(non_matching_entity) is False 42 | 43 | 44 | def test_service_data_where_with_callable_conditions() -> None: 45 | """Test that ServiceDataWhere works with callable condition functions.""" 46 | 47 | def brightness_gt_200(value: int) -> bool: 48 | return value > 200 49 | 50 | predicate = ServiceDataWhere({"brightness": brightness_gt_200}) 51 | 52 | high_brightness = _make_event({"brightness": 255}) 53 | low_brightness = _make_event({"brightness": 100}) 54 | 55 | assert predicate(high_brightness) is True 56 | assert predicate(low_brightness) is False 57 | 58 | 59 | def test_service_data_where_with_glob_patterns() -> None: 60 | """Test that ServiceDataWhere automatically handles glob patterns.""" 61 | predicate = ServiceDataWhere({"entity_id": "light.*"}) 62 | 63 | kitchen_light = _make_event({"entity_id": "light.kitchen"}) 64 | living_light = _make_event({"entity_id": "light.living"}) 65 | sensor_temp = _make_event({"entity_id": "sensor.temperature"}) 66 | 67 | assert predicate(kitchen_light) is True 68 | assert predicate(living_light) is True 69 | assert predicate(sensor_temp) is False 70 | 71 | 72 | def test_service_data_where_multiple_conditions() -> None: 73 | """Test that ServiceDataWhere evaluates all conditions (AND logic).""" 74 | predicate = ServiceDataWhere( 75 | { 76 | "entity_id": "light.*", 77 | "brightness": NOT_PROVIDED, # Must be present 78 | "transition": 2, 79 | } 80 | ) 81 | 82 | matching_event = _make_event({"entity_id": "light.kitchen", "brightness": 255, "transition": 2}) 83 | 84 | missing_brightness = _make_event({"entity_id": "light.kitchen", "transition": 2}) 85 | 86 | wrong_transition = _make_event({"entity_id": "light.kitchen", "brightness": 255, "transition": 1}) 87 | 88 | assert predicate(matching_event) is True 89 | assert predicate(missing_brightness) is False 90 | assert predicate(wrong_transition) is False 91 | 92 | 93 | def test_service_data_where_from_kwargs() -> None: 94 | """Test the ServiceDataWhere.from_kwargs convenience constructor.""" 95 | predicate = ServiceDataWhere.from_kwargs(entity_id="light.*", brightness=255) 96 | 97 | matching_event = _make_event({"entity_id": "light.kitchen", "brightness": 255}) 98 | non_matching_event = _make_event({"entity_id": "sensor.temp", "brightness": 255}) 99 | 100 | assert predicate(matching_event) is True 101 | assert predicate(non_matching_event) is False 102 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from hassette.api import Api, ApiSyncFacade 4 | from hassette.test_utils import SimpleTestServer 5 | from hassette.utils.request_utils import clean_kwargs 6 | 7 | 8 | async def test_api_rest_request_sets_body_and_headers(hassette_with_mock_api: tuple[Api, SimpleTestServer]): 9 | """POST requests include JSON body and expected headers.""" 10 | api_client, mock_server = hassette_with_mock_api 11 | 12 | mock_server.expect("POST", "/api/thing", "", json={"a": 1}, status=200) 13 | 14 | response = await api_client.rest_request("POST", "/api/thing", data={"a": 1}) 15 | payload = await response.json() 16 | 17 | assert "application/json" in response.headers.get("Content-Type", ""), "Expected JSON response" 18 | assert payload == {"a": 1}, f"Expected echoed JSON, got {payload}" 19 | 20 | 21 | async def test_api_rest_request_cleans_params(hassette_with_mock_api: tuple[Api, SimpleTestServer]): 22 | """Query parameters are cleaned before sending a GET request.""" 23 | api_client, mock_server = hassette_with_mock_api 24 | 25 | request_params = {"keep": "x", "none": None, "empty": " ", "flag": False} 26 | 27 | mock_server.expect("GET", "/api/thing", "keep=x&flag=false", status=200) 28 | 29 | response = await api_client.rest_request("GET", "/api/thing", params=request_params) 30 | 31 | assert response.status == 200, f"Expected 200 OK, got {response.status}" 32 | 33 | assert dict(response.request_info.url.query) == {"keep": "x", "flag": "false"}, ( 34 | f"Unexpected query params: {response.request_info.url.query}" 35 | ) 36 | 37 | 38 | def test_clean_kwargs_basic(): 39 | """clean_kwargs drops empty values and normalises booleans.""" 40 | cleaned_kwargs = clean_kwargs(a=None, b=False, c=" ", d="x", e=5) 41 | assert cleaned_kwargs == {"b": "false", "d": "x", "e": 5}, f"Unexpected cleaned kwargs: {cleaned_kwargs}" 42 | 43 | 44 | def test_sync_parity(): 45 | """Sync facade exposes the same public methods as the async API.""" 46 | api_methods = inspect.getmembers(Api, predicate=inspect.isfunction) 47 | api_sync_methods = inspect.getmembers(ApiSyncFacade, predicate=inspect.isfunction) 48 | 49 | api_method_names = {name for name, _ in api_methods if not name.startswith("_")} 50 | api_sync_method_names = {name for name, _ in api_sync_methods if not name.startswith("_")} 51 | 52 | assert api_method_names == api_sync_method_names, f"Mismatch: {api_method_names ^ api_sync_method_names}" 53 | -------------------------------------------------------------------------------- /tests/test_app_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from textwrap import dedent 3 | 4 | import pytest 5 | 6 | from hassette.utils.app_utils import _module_name_for 7 | 8 | 9 | def test_module_name_for_with_invalid_path(tmp_path: Path): 10 | """Test _module_name_for with an invalid path.""" 11 | 12 | app_file = tmp_path / "non_existent.py" 13 | 14 | with pytest.raises(FileNotFoundError): 15 | _module_name_for(tmp_path, app_file, "") 16 | 17 | 18 | def test_module_name_for_with_directory_path(tmp_path: Path): 19 | """Test _module_name_for with a directory path.""" 20 | 21 | app_dir = tmp_path / "app_dir" 22 | app_dir.mkdir() 23 | 24 | with pytest.raises(IsADirectoryError): 25 | _module_name_for(tmp_path, app_dir, "") 26 | 27 | 28 | def test_module_name_for_with_no_parent(tmp_path: Path): 29 | """Test _module_name_for when there is no parent package.""" 30 | 31 | app_file = tmp_path / "test.py" 32 | app_file.write_text( 33 | dedent(""" 34 | from hassette import App, AppConfig 35 | 36 | class CurrentDirApp(App[AppConfig]): ... 37 | """) 38 | ) 39 | 40 | module_name = _module_name_for(tmp_path, app_file, "") 41 | assert module_name == "test", f"Expected 'test', got '{module_name}'" 42 | 43 | 44 | def test_module_name_for_with_parent(tmp_path: Path): 45 | """Test _module_name_for when there is a parent package.""" 46 | 47 | app_path = tmp_path / "apps" 48 | app_path.mkdir() 49 | app_file = app_path / "test.py" 50 | app_file.write_text( 51 | dedent(""" 52 | from hassette import App, AppConfig 53 | 54 | class CurrentDirApp(App[AppConfig]): ... 55 | """) 56 | ) 57 | 58 | module_name = _module_name_for(tmp_path, app_file, "") 59 | assert module_name == "apps.test", f"Expected 'apps.test', got '{module_name}'" 60 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import dotenv 4 | 5 | from hassette import Hassette, HassetteConfig 6 | 7 | 8 | def test_overrides_are_used(env_file_path: Path, test_config: HassetteConfig) -> None: 9 | """Configuration values honour overrides from the test TOML and .env.""" 10 | 11 | test_config.reload() 12 | 13 | expected_token = dotenv.get_key(env_file_path, "hassette__token") 14 | 15 | # Create a Hassette instance to test URL functionality 16 | hassette = Hassette(test_config) 17 | 18 | assert hassette.ws_url == "ws://127.0.0.1:8123/api/websocket", ( 19 | f"Expected ws://127.0.0.1:8123/api/websocket, got {hassette.ws_url}" 20 | ) 21 | assert test_config.token == expected_token, f"Expected token to be {expected_token}, got {test_config.token}" 22 | 23 | 24 | def test_env_overrides_are_used(test_config_class, monkeypatch, tmp_path): 25 | """Environment overrides win when constructing a HassetteConfig.""" 26 | app_dir = tmp_path / "custom/apps" 27 | app_dir.mkdir(parents=True, exist_ok=True) 28 | monkeypatch.setenv("hassette__app_dir", str(app_dir)) 29 | config_with_env_override = test_config_class() 30 | assert config_with_env_override.app_dir == app_dir, f"Expected {app_dir}, got {config_with_env_override.app_dir}" 31 | -------------------------------------------------------------------------------- /tests/test_file_watcher.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import typing 4 | from typing import Any 5 | 6 | import anyio 7 | 8 | from hassette.events.hassette import Event 9 | from hassette.types.topics import HASSETTE_EVENT_FILE_WATCHER 10 | 11 | if typing.TYPE_CHECKING: 12 | from hassette.core.core import Hassette 13 | 14 | 15 | async def test_event_emitted_on_file_change(hassette_with_file_watcher: "Hassette"): 16 | """File watcher emits an event when a tracked file changes.""" 17 | hassette_instance = hassette_with_file_watcher 18 | file_watcher_service = hassette_instance._file_watcher 19 | 20 | file_event_received = asyncio.Event() 21 | 22 | async def handler(event: Event[Any]) -> None: 23 | hassette_with_file_watcher.task_bucket.post_to_loop(file_event_received.set) 24 | assert event.topic == HASSETTE_EVENT_FILE_WATCHER, f"Unexpected topic: {event.topic}" 25 | 26 | hassette_instance._bus.on(topic=HASSETTE_EVENT_FILE_WATCHER, handler=handler) 27 | 28 | # Allow watcher to settle before touching files. 29 | await asyncio.sleep(0.2) 30 | 31 | updated_files: list[Any] = [] 32 | for candidate_path in file_watcher_service.hassette.config.get_watchable_files(): 33 | if candidate_path.is_file(): 34 | candidate_path.write_text(candidate_path.read_text()) 35 | updated_files.append(candidate_path) 36 | break 37 | 38 | assert updated_files, "No watchable files found to touch in test_event_emitted_on_file_change" 39 | await asyncio.sleep(0.2) 40 | 41 | # Event emission can be racy, so retry briefly. 42 | for _ in range(2): 43 | with contextlib.suppress(asyncio.TimeoutError): 44 | with anyio.fail_after(1): 45 | await file_event_received.wait() 46 | assert file_event_received.is_set(), ( 47 | f"Expected file_event_received to be set, got {file_event_received.is_set()}" 48 | ) 49 | return 50 | -------------------------------------------------------------------------------- /tests/test_scheduler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from zoneinfo import ZoneInfo 3 | 4 | from whenever import ZonedDateTime 5 | 6 | from hassette import Hassette 7 | from hassette.scheduler import ScheduledJob 8 | from hassette.utils.date_utils import now 9 | 10 | TZ = ZoneInfo("America/Chicago") 11 | 12 | 13 | async def test_run_in_passes_args_kwargs_async(hassette_with_scheduler: Hassette) -> None: 14 | """run_in forwards args/kwargs to async callables.""" 15 | job_executed = asyncio.Event() 16 | captured_arguments: list[tuple[int, int, bool]] = [] 17 | 18 | async def target(a: int, b: int, *, flag: bool) -> None: 19 | captured_arguments.append((a, b, flag)) 20 | hassette_with_scheduler.task_bucket.post_to_loop(job_executed.set) 21 | 22 | scheduled_job = hassette_with_scheduler._scheduler.run_in(target, delay=0.01, args=(1, 2), kwargs={"flag": True}) 23 | 24 | await asyncio.wait_for(job_executed.wait(), timeout=1) 25 | scheduled_job.cancel() 26 | 27 | assert captured_arguments == [(1, 2, True)], f"Expected [(1, 2, True)], got {captured_arguments}" 28 | 29 | 30 | async def test_run_in_passes_args_kwargs_sync(hassette_with_scheduler: Hassette) -> None: 31 | """run_in forwards args/kwargs to sync callables.""" 32 | event_loop = asyncio.get_running_loop() 33 | job_executed = asyncio.Event() 34 | captured_arguments: list[tuple[str, int]] = [] 35 | 36 | def target(name: str, *, count: int) -> None: 37 | captured_arguments.append((name, count)) 38 | event_loop.call_soon_threadsafe(job_executed.set) 39 | 40 | scheduled_job = hassette_with_scheduler._scheduler.run_in(target, delay=0.01, args=("sensor",), kwargs={"count": 3}) 41 | 42 | await asyncio.wait_for(job_executed.wait(), timeout=1) 43 | scheduled_job.cancel() 44 | 45 | assert captured_arguments == [("sensor", 3)], f"Expected [('sensor', 3)], got {captured_arguments}" 46 | 47 | 48 | def test_scheduled_job_copies_args_kwargs() -> None: 49 | """ScheduledJob stores copies so external mutations do not leak in.""" 50 | args = [1, 2] 51 | kwargs = {"alpha": 99} 52 | 53 | job = ScheduledJob( 54 | owner="owner", 55 | next_run=ZonedDateTime.from_system_tz(2030, 1, 1, 0, 0, 0), 56 | job=lambda *a, **kw: None, # noqa 57 | args=args, # type: ignore 58 | kwargs=kwargs, 59 | ) 60 | 61 | args.append(3) 62 | kwargs["alpha"] = 0 63 | 64 | assert job.args == (1, 2), f"Expected (1, 2), got {job.args}" 65 | assert job.kwargs == {"alpha": 99}, f"Expected {{'alpha': 99}}, got {job.kwargs}" 66 | 67 | 68 | async def test_jobs_execute_in_run_order(hassette_with_scheduler: Hassette) -> None: 69 | """run_once executes jobs according to their scheduled time.""" 70 | execution_order: list[str] = [] 71 | early_job_complete = asyncio.Event() 72 | late_job_complete = asyncio.Event() 73 | 74 | def make_job(label: str, signal: asyncio.Event): 75 | def _job() -> None: 76 | execution_order.append(label) 77 | hassette_with_scheduler.task_bucket.post_to_loop(signal.set) 78 | 79 | return _job 80 | 81 | reference = now() 82 | hassette_with_scheduler._scheduler.run_once(make_job("late", late_job_complete), start=reference.add(seconds=0.4)) 83 | hassette_with_scheduler._scheduler.run_once(make_job("early", early_job_complete), start=reference.add(seconds=0.1)) 84 | 85 | await asyncio.wait_for(early_job_complete.wait(), timeout=2) 86 | await asyncio.wait_for(late_job_complete.wait(), timeout=2) 87 | 88 | actual = set(execution_order[:2]) 89 | expected = {"early", "late"} 90 | assert actual == expected, f"Expected {expected}, got {actual}" 91 | -------------------------------------------------------------------------------- /tests/test_service_watcher.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | from hassette.events.hassette import HassetteServiceEvent 6 | from hassette.resources.base import Service 7 | from hassette.services.service_watcher import ServiceWatcher 8 | from hassette.types.enums import ResourceStatus 9 | 10 | 11 | @pytest.fixture 12 | def get_service_watcher_mock(hassette_with_bus): 13 | """Return a fresh service watcher for each test.""" 14 | return ServiceWatcher(hassette_with_bus) 15 | 16 | 17 | def get_dummy_service(called: dict[str, int], hassette) -> Service: 18 | class _Dummy(Service): 19 | """Does nothing, just tracks calls.""" 20 | 21 | async def serve(self): 22 | pass 23 | 24 | async def on_shutdown(self): 25 | called["cancel"] += 1 26 | 27 | async def on_initialize(self): 28 | called["start"] += 1 29 | 30 | return _Dummy(hassette) 31 | 32 | 33 | async def test_restart_service_cancels_then_starts(get_service_watcher_mock: ServiceWatcher): 34 | """Restarting a failed service cancels and reinitializes it.""" 35 | call_counts = {"cancel": 0, "start": 0} 36 | 37 | dummy_service = get_dummy_service(call_counts, get_service_watcher_mock.hassette) 38 | get_service_watcher_mock.hassette.children.append(dummy_service) 39 | 40 | event = HassetteServiceEvent.from_data( 41 | resource_name=dummy_service.class_name, 42 | role=dummy_service.role, 43 | status=ResourceStatus.FAILED, 44 | exception=Exception("test"), 45 | ) 46 | 47 | await get_service_watcher_mock.restart_service(event) 48 | await asyncio.sleep(0.1) # allow restart to run 49 | 50 | assert call_counts == {"cancel": 1, "start": 1}, ( 51 | f"Expected cancel and start to be called once each, got {call_counts}" 52 | ) 53 | -------------------------------------------------------------------------------- /tests/test_triggers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime 3 | from unittest.mock import patch 4 | from zoneinfo import ZoneInfo 5 | 6 | import pytest 7 | from whenever import TimeDelta, ZonedDateTime 8 | 9 | from hassette import Hassette 10 | from hassette.scheduler import CronTrigger, IntervalTrigger 11 | 12 | TZ = ZoneInfo("America/Chicago") 13 | 14 | 15 | async def test_interval_trigger_catchup() -> None: 16 | # start 30s in the past, interval 10s, now=00:01:30 → next should be 00:01:40 17 | """Interval trigger advances through missed runs before scheduling the next.""" 18 | fake_now = ZonedDateTime.from_py_datetime(datetime(2025, 8, 18, 0, 1, 30, tzinfo=TZ)) # "2025-08-18T00:01:30") 19 | with patch("hassette.scheduler.classes.now", lambda: fake_now): 20 | interval_trigger = IntervalTrigger( 21 | TimeDelta(seconds=10), start=ZonedDateTime.from_system_tz(2025, 8, 18, 0, 1, 0) 22 | ) 23 | next_run_time = interval_trigger.next_run_time() 24 | assert next_run_time.format_iso() == "2025-08-18T00:01:40-05:00[America/Chicago]", ( 25 | f"Got {next_run_time.format_iso()}" 26 | ) 27 | 28 | 29 | async def test_cron_trigger_catchup() -> None: 30 | """Cron trigger catches up to the next valid schedule after a delay.""" 31 | fake_now = ZonedDateTime.from_py_datetime(datetime(2025, 8, 18, 0, 1, 30, tzinfo=TZ)) # "2025-08-18T00:01:30") 32 | with patch("hassette.scheduler.classes.now", lambda: fake_now): 33 | cron_trigger = CronTrigger.from_arguments( 34 | second="*/10", minute="*", hour="*", start=ZonedDateTime.from_system_tz(2025, 8, 18, 0, 1, 0) 35 | ) 36 | next_run_time = cron_trigger.next_run_time() 37 | assert next_run_time.format_iso() == "2025-08-18T00:01:40-05:00[America/Chicago]", ( 38 | f"Got {next_run_time.format_iso()}" 39 | ) 40 | 41 | 42 | async def test_run_cron_rejects_invalid(hassette_with_scheduler: Hassette) -> None: 43 | """run_cron raises ValueError when the cron expression is invalid.""" 44 | with pytest.raises(ValueError, match="Invalid cron expression"): 45 | hassette_with_scheduler._scheduler.run_cron(lambda: None, second="nope") 46 | 47 | 48 | async def test_run_cron_accepts_valid(hassette_with_scheduler: Hassette) -> None: 49 | # “every 5 seconds” (fields: sec min hour dom mon dow year) 50 | """Valid cron expressions schedule jobs successfully.""" 51 | scheduled_job = hassette_with_scheduler._scheduler.run_cron( 52 | lambda: None, second="1", start=ZonedDateTime.from_system_tz(2025, 8, 18, 0, 0, 0) 53 | ) 54 | await asyncio.sleep(0) # allow scheduling to complete 55 | scheduled_job.cancel() 56 | 57 | 58 | async def test_cron_trigger_seconds(): 59 | """Cron expressions constrained to seconds advance by one second.""" 60 | start_time = ZonedDateTime.from_system_tz(2025, 8, 18, 0, 0, 0) 61 | 62 | cron_trigger = CronTrigger.from_arguments(second="*/1", start=start_time) 63 | assert cron_trigger.cron_expression == "0 0 * * * */1", f"Got {cron_trigger.cron_expression}" 64 | 65 | next_run = cron_trigger.cron_iter.get_next() 66 | 67 | delta = ZonedDateTime.from_py_datetime(next_run) - start_time 68 | 69 | assert delta.in_seconds() == 1.0, f"Delta was {delta.in_seconds()} seconds" 70 | 71 | 72 | async def test_cron_trigger_minutes(): 73 | """Cron expressions constrained to minutes advance by sixty seconds.""" 74 | start_time = ZonedDateTime.from_system_tz(2025, 8, 18, 0, 0, 0) 75 | 76 | cron_trigger = CronTrigger.from_arguments(second="0", minute="*/1", start=start_time) 77 | assert cron_trigger.cron_expression == "*/1 0 * * * 0", f"Got {cron_trigger.cron_expression}" 78 | 79 | next_run = cron_trigger.cron_iter.get_next() 80 | 81 | delta = ZonedDateTime.from_py_datetime(next_run) - start_time 82 | 83 | assert delta.in_seconds() == 60, f"Delta was {delta.in_seconds()} seconds" 84 | -------------------------------------------------------------------------------- /tests/test_url_utils.py: -------------------------------------------------------------------------------- 1 | """Comprehensive tests for URL utility functions.""" 2 | 3 | import pytest 4 | 5 | from hassette.config.core_config import HassetteConfig 6 | from hassette.exceptions import BaseUrlRequiredError, IPV6NotSupportedError, SchemeRequiredInBaseUrlError 7 | from hassette.utils.url_utils import _parse_and_normalize_url, build_rest_url, build_ws_url 8 | 9 | 10 | def _make_config(base_url: str, api_port: int = 8123) -> HassetteConfig: 11 | """Create a test configuration with the given base_url and api_port.""" 12 | config = HassetteConfig.model_construct(_fields_set=set()) 13 | config.token = "test-token" 14 | config.base_url = base_url 15 | config.api_port = api_port 16 | return config 17 | 18 | 19 | def test_basic_http_with_explicit_port(): 20 | """Test URL construction with explicit HTTP scheme and port.""" 21 | config = _make_config("http://localhost:8123") 22 | 23 | assert build_ws_url(config) == "ws://localhost:8123/api/websocket" 24 | assert build_rest_url(config) == "http://localhost:8123/api/" 25 | 26 | 27 | def test_https_scheme_conversion(): 28 | """Test that HTTPS scheme correctly converts to WSS for WebSocket URLs.""" 29 | config = _make_config("https://example.com") 30 | 31 | assert build_ws_url(config) == "wss://example.com/api/websocket" 32 | assert build_rest_url(config) == "https://example.com/api/" 33 | 34 | 35 | def test_custom_port_in_url_overrides_api_port(): 36 | """Test that port specified in URL takes precedence over api_port.""" 37 | config = _make_config("http://example.com:9000", api_port=8123) 38 | 39 | assert build_ws_url(config) == "ws://example.com:9000/api/websocket" 40 | assert build_rest_url(config) == "http://example.com:9000/api/" 41 | 42 | 43 | def test_https_with_custom_port(): 44 | """Test HTTPS URL with custom port.""" 45 | config = _make_config("https://hass.example.com:8443") 46 | 47 | assert build_ws_url(config) == "wss://hass.example.com:8443/api/websocket" 48 | assert build_rest_url(config) == "https://hass.example.com:8443/api/" 49 | 50 | 51 | @pytest.mark.parametrize( 52 | ("base_url", "expected_port"), 53 | [ 54 | ("http://test.local", None), 55 | ("https://test.local", None), 56 | ("http://192.168.1.1", None), 57 | ("http://localhost:8000", 8000), 58 | ("http://127.0.0.1:8000", 8000), 59 | ], 60 | ) 61 | def test_no_port_added_if_not_provided(base_url: str, expected_port: int | None): 62 | """Test that no port is added if not provided in the URL.""" 63 | config = _make_config(base_url) 64 | 65 | _, _, port = _parse_and_normalize_url(config) 66 | 67 | assert port == expected_port 68 | 69 | 70 | @pytest.mark.parametrize( 71 | ("base_url", "expected_ws_scheme", "expected_rest_scheme"), 72 | [ 73 | ("http://test.local", "ws", "http"), 74 | ("https://test.local", "wss", "https"), 75 | ("ftp://test.local", "ws", "ftp"), # Non-standard scheme 76 | ], 77 | ) 78 | def test_scheme_conversion_parametrized(base_url: str, expected_ws_scheme: str, expected_rest_scheme: str): 79 | """Test scheme conversion with various input schemes.""" 80 | config = _make_config(base_url) 81 | 82 | ws_url = build_ws_url(config) 83 | rest_url = build_rest_url(config) 84 | 85 | assert ws_url.startswith(f"{expected_ws_scheme}://") 86 | assert rest_url.startswith(f"{expected_rest_scheme}://") 87 | 88 | 89 | @pytest.mark.parametrize(("func"), [build_ws_url, build_rest_url]) 90 | def test_config_with_empty_base_url_raises(func): 91 | """Test that an exception is raised for URLs without schemes.""" 92 | config = _make_config("") 93 | 94 | with pytest.raises(BaseUrlRequiredError): 95 | func(config) 96 | 97 | 98 | @pytest.mark.parametrize(("func"), [build_ws_url, build_rest_url]) 99 | def test_ipv6_address(func): 100 | """Test IPv6 address handling.""" 101 | config = _make_config("http://[::1]:8123") 102 | 103 | with pytest.raises(IPV6NotSupportedError): 104 | func(config) 105 | 106 | 107 | @pytest.mark.parametrize(("func"), [build_ws_url, build_rest_url]) 108 | def test_no_scheme_raises_exception(func): 109 | """Test that an exception is raised for URLs without schemes.""" 110 | config = _make_config("example.com", api_port=9123) 111 | 112 | with pytest.raises(SchemeRequiredInBaseUrlError): 113 | func(config) 114 | -------------------------------------------------------------------------------- /volumes/config/.storage/assist_pipeline.pipelines: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 2, 4 | "key": "assist_pipeline.pipelines", 5 | "data": { 6 | "items": [ 7 | { 8 | "conversation_engine": "conversation.home_assistant", 9 | "conversation_language": "en", 10 | "id": "01jzbbxgtnst4g2y0mt8j00rjc", 11 | "language": "en", 12 | "name": "Home Assistant", 13 | "stt_engine": null, 14 | "stt_language": null, 15 | "tts_engine": null, 16 | "tts_language": null, 17 | "tts_voice": null, 18 | "wake_word_entity": null, 19 | "wake_word_id": null, 20 | "prefer_local_intents": false 21 | } 22 | ], 23 | "preferred_item": "01jzbbxgtnst4g2y0mt8j00rjc" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /volumes/config/.storage/auth_provider.homeassistant: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "auth_provider.homeassistant", 5 | "data": { 6 | "users": [ 7 | { 8 | "username": "testuser", 9 | "password": "JDJiJDEyJEF2WndiTkExRWNPQ09PWXVGYjZTOWUzZ2dZMVR0bm9BcFhudWF5dUUwNndyVVM0dkszc0Ey" 10 | } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /volumes/config/.storage/core.analytics: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "core.analytics", 5 | "data": { 6 | "onboarded": true, 7 | "preferences": {}, 8 | "uuid": null 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /volumes/config/.storage/core.area_registry: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 8, 4 | "key": "core.area_registry", 5 | "data": { 6 | "areas": [ 7 | { 8 | "aliases": [], 9 | "floor_id": null, 10 | "humidity_entity_id": null, 11 | "icon": null, 12 | "id": "living_room", 13 | "labels": [], 14 | "name": "Living Room", 15 | "picture": null, 16 | "temperature_entity_id": null, 17 | "created_at": "2025-07-04T18:42:24.356700+00:00", 18 | "modified_at": "2025-07-04T18:42:24.356701+00:00" 19 | }, 20 | { 21 | "aliases": [], 22 | "floor_id": null, 23 | "humidity_entity_id": null, 24 | "icon": null, 25 | "id": "kitchen", 26 | "labels": [], 27 | "name": "Kitchen", 28 | "picture": null, 29 | "temperature_entity_id": null, 30 | "created_at": "2025-07-04T18:42:24.356748+00:00", 31 | "modified_at": "2025-07-04T18:42:24.356748+00:00" 32 | }, 33 | { 34 | "aliases": [], 35 | "floor_id": null, 36 | "humidity_entity_id": null, 37 | "icon": null, 38 | "id": "bedroom", 39 | "labels": [], 40 | "name": "Bedroom", 41 | "picture": null, 42 | "temperature_entity_id": null, 43 | "created_at": "2025-07-04T18:42:24.356764+00:00", 44 | "modified_at": "2025-07-04T18:42:24.356765+00:00" 45 | } 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /volumes/config/.storage/core.config: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 4, 4 | "key": "core.config", 5 | "data": { 6 | "latitude": 52.3731339, 7 | "longitude": 4.8903147, 8 | "elevation": 0, 9 | "unit_system_v2": "us_customary", 10 | "location_name": "Home", 11 | "time_zone": "America/Chicago", 12 | "external_url": null, 13 | "internal_url": null, 14 | "currency": "USD", 15 | "country": "US", 16 | "language": "en", 17 | "radius": 100 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /volumes/config/.storage/core.config_entries: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 5, 4 | "key": "core.config_entries", 5 | "data": { 6 | "entries": [ 7 | {"created_at":"2025-07-04T18:41:56.021842+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"sun","entry_id":"01JZBBXH9NV761EQQSWEHE5VAV","minor_version":1,"modified_at":"2025-07-04T18:41:56.021845+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"import","subentries":[],"title":"Sun","unique_id":null,"version":1}, 8 | {"created_at":"2025-07-04T18:41:56.195599+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"go2rtc","entry_id":"01JZBBXHF3NWF7EYH6X9N8K1G8","minor_version":1,"modified_at":"2025-07-04T18:41:56.195600+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"system","subentries":[],"title":"go2rtc","unique_id":null,"version":1}, 9 | {"created_at":"2025-07-04T18:41:56.205396+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"backup","entry_id":"01JZBBXHFDJ7AC5FKM2WZB8QVM","minor_version":1,"modified_at":"2025-07-04T18:41:56.205397+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"system","subentries":[],"title":"Backup","unique_id":null,"version":1}, 10 | {"created_at":"2025-07-04T18:42:28.848688+00:00","data":{"language":"en","tld":"com"},"disabled_by":null,"discovery_keys":{},"domain":"google_translate","entry_id":"01JZBBYHBG6NFA4M1RZHQZ6X8J","minor_version":1,"modified_at":"2025-07-04T18:42:28.848691+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"onboarding","subentries":[],"title":"Google Translate text-to-speech","unique_id":null,"version":1}, 11 | {"created_at":"2025-07-04T18:42:28.851199+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"shopping_list","entry_id":"01JZBBYHBKD9D4P325K50Q0SJF","minor_version":1,"modified_at":"2025-07-04T18:42:28.851202+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"onboarding","subentries":[],"title":"Shopping list","unique_id":"shopping_list","version":1}, 12 | {"created_at":"2025-07-04T18:42:28.914279+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"radio_browser","entry_id":"01JZBBYHDJ8P0GTE9CWXQHEBV0","minor_version":1,"modified_at":"2025-07-04T18:42:28.914283+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"onboarding","subentries":[],"title":"Radio Browser","unique_id":null,"version":1}, 13 | {"created_at":"2025-09-02T19:00:04.125651+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"demo","entry_id":"01K45WSVWXJRXFGBB7EXAVQJC3","minor_version":1,"modified_at":"2025-09-02T19:00:04.125655+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"import","subentries":[],"title":"Demo","unique_id":null,"version":1} 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /volumes/config/.storage/core.uuid: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "core.uuid", 5 | "data": { 6 | "uuid": "aabab781bbd4495b9d9241b44b892db6" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /volumes/config/.storage/frontend.user_data_72317891e301476bb6e79b9fa3b9f2ea: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "frontend.user_data_72317891e301476bb6e79b9fa3b9f2ea", 5 | "data": { 6 | "language": { 7 | "language": "en", 8 | "number_format": "language", 9 | "time_format": "language", 10 | "date_format": "language", 11 | "time_zone": "local", 12 | "first_weekday": "language" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /volumes/config/.storage/frontend.user_data_caa14e06472b499cb00545bb65e56e5a: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "frontend.user_data_caa14e06472b499cb00545bb65e56e5a", 5 | "data": { 6 | "language": { 7 | "language": "en", 8 | "number_format": "language", 9 | "time_format": "language", 10 | "date_format": "language", 11 | "time_zone": "local", 12 | "first_weekday": "language" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /volumes/config/.storage/http: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "http", 5 | "data": { 6 | "ip_ban_enabled": true, 7 | "server_port": 8123, 8 | "cors_allowed_origins": [ 9 | "https://cast.home-assistant.io" 10 | ], 11 | "ssl_profile": "modern", 12 | "server_host": [ 13 | "0.0.0.0", 14 | "::" 15 | ], 16 | "login_attempts_threshold": -1, 17 | "use_x_frame_options": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /volumes/config/.storage/http.auth: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "http.auth", 5 | "data": { 6 | "content_user": "934e2b74dc814e41885df97aebd08bbe" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /volumes/config/.storage/input_button: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "input_button", 5 | "data": { 6 | "items": [ 7 | { 8 | "id": "test", 9 | "name": "Test" 10 | }, 11 | { 12 | "id": "test_button", 13 | "name": "test_button" 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /volumes/config/.storage/input_datetime: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "input_datetime", 5 | "data": { 6 | "items": [ 7 | { 8 | "id": "datetimehelper", 9 | "has_time": true, 10 | "has_date": true, 11 | "name": "DatetimeHelper" 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /volumes/config/.storage/lovelace.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "lovelace.map", 5 | "data": { 6 | "config": { 7 | "strategy": { 8 | "type": "map" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /volumes/config/.storage/lovelace_dashboards: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 1, 4 | "key": "lovelace_dashboards", 5 | "data": { 6 | "items": [ 7 | { 8 | "id": "map", 9 | "icon": "mdi:map", 10 | "title": "Map", 11 | "url_path": "map", 12 | "mode": "storage", 13 | "require_admin": false, 14 | "show_in_sidebar": true 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /volumes/config/.storage/onboarding: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "minor_version": 1, 4 | "key": "onboarding", 5 | "data": { 6 | "done": [ 7 | "user", 8 | "core_config", 9 | "analytics", 10 | "integration" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /volumes/config/.storage/person: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "minor_version": 1, 4 | "key": "person", 5 | "data": { 6 | "items": [ 7 | { 8 | "id": "testuser", 9 | "name": "testuser", 10 | "user_id": "72317891e301476bb6e79b9fa3b9f2ea", 11 | "device_trackers": [] 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /volumes/config/.storage/repairs.issue_registry: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 2, 4 | "key": "repairs.issue_registry", 5 | "data": { 6 | "issues": [] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /volumes/config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # Loads default set of integrations. Do not remove. 2 | default_config: 3 | 4 | # Load frontend themes from the themes folder 5 | frontend: 6 | 7 | # demo 8 | demo: 9 | -------------------------------------------------------------------------------- /volumes/config/known_devices.yaml: -------------------------------------------------------------------------------- 1 | 2 | demo_paulus: 3 | name: Paulus 4 | mac: 5 | icon: 6 | picture: 7 | track: true 8 | 9 | demo_anne_therese: 10 | name: Anne Therese 11 | mac: 12 | icon: 13 | picture: 14 | track: true 15 | 16 | demo_home_boy: 17 | name: Home Boy 18 | mac: 19 | icon: 20 | picture: 21 | track: true 22 | --------------------------------------------------------------------------------