Skip to content

Commit 3e29b77

Browse files
NodeJSmithCopilot
andauthored
Feature/state proxy; require python 3.13 (#192)
* rename services to core, move core.py back under here. didn't make sense as services since we have resources there too * add priority to handlers finally * reduce supported versions of python so we can use latest and greatest typing functionality * add default to TypeVar, remove all StateUnion annotations * clean up some types * start working on state proxy * use type * Update src/hassette/models/states/base.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * simplify state proxy resource * add comment about disabling UP040 * remove commented out code * update lockfile * Update src/hassette/core/state_proxy.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * rename config/core.py to config.py * improve logging, add comment about not listening to registry updated event * rename * start adding instance level States class * increase traceback level * need to wait for new staty proxy to be ready * add states * start using self.states * add state proxy to harness, use when app handler is used * ensure we don't add state of None to dict * start of tests for states/state_proxy by codex * add method to get listeners by owner * add classmethod to get domain from domain field * fix mess of tests * remove unused topic * add back another test * few more minor improvements * remove old homeassistant docker compose and files * remove entity updated registry event * separate out DI annotations that can return None/MISSING_VALUE and those that cannot. add new exceptions. raise if type doesn't match, allow override in config * add value as alias choice for value, in case we are getting it by converting a BaseState to a more specific one * use Maybe DI annotations * correct annotation type * remove KnownType types, weren't being used any longer * add more tests * specific is None check * get rid of AppMeta and move validate_app to load_app_class * fix: fix bug where changed app was not re-imported * bit more cleanup of method and comments * ignore out of order events * add more properties, make state_proxy private * add test that we have full coverage of domains, fix missing domains * fix failign tests * update changelog * update changelog * add missing domains to states * update documentation + add documentation for states * bump minor version and update changelog * Update src/hassette/bus/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/hassette/bus/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 2fae50e commit 3e29b77

File tree

100 files changed

+3566
-1724
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

100 files changed

+3566
-1724
lines changed

.github/workflows/test_and_lint.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
strategy:
1515
fail-fast: false
1616
matrix:
17-
python-version: ["3.11", "3.12", "3.13"]
17+
python-version: ["3.13"]
1818

1919
steps:
2020
- name: Checkout code
@@ -49,7 +49,7 @@ jobs:
4949
- name: Install uv and set the Python version
5050
uses: astral-sh/setup-uv@v6
5151
with:
52-
python-version: 3.12
52+
python-version: 3.13
5353

5454
- name: Install coverage
5555
run: |

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
## [0.17.0] - 2025-11-22
11+
12+
### Changed
13+
- **Breaking:** - Requires Python 3.13 going forward, Python 3.12 and 3.11 are no longer supported.
14+
- This allows use of `type`, defaults for TypeVars, and other new typing features.
15+
- Renamed `core_config.py` to `core.py`
16+
- Renamed `services` to `core` and move `core.py` under `core` directory
17+
- Didn't make sense to keep named as `services` since we have resources in here as well
18+
1019
### Added
1120
- Add `diskcache` dependency and `cache` attribute to all resources
1221
- Each resource class has its own cache directory under the Hassette data directory
22+
- Add `states` attribute to `App` - provides access to current states in Home Assistant
23+
- `states` is an instance of the new `States` class
24+
- `States` provides domain-based access to entity states, e.g. `app.states.light.get("light.my_light")`
25+
- `States` listens to state change events and keeps an up-to-date cache of states
26+
- New states documentation page under core-concepts
27+
- Add `Maybe*` DI annotations for optional dependencies in event handlers
28+
- `MaybeStateNew`, `MaybeStateOld`, `MaybeEntityId`, etc.
29+
- These will allow `None` or `MISSING_VALUE` to be returned if the value is not available
30+
- The original dependency annotations will raise an error if the value is not available
31+
- Add `raise_on_incorrect_dependency_type` to `HassetteConfig` to control whether to raise an error if a dependency cannot be provided due to type mismatch
32+
- Default is `true` in production mode, `false` in dev mode
33+
- When `false` a warning will be logged but the handler will still be called with whatever value was returned
34+
35+
### Fixed
36+
- Fixed bug that caused apps to not be re-imported when code changed due to skipping cache check in app handler
37+
- Fixed missing domains in `DomainLiteral` in `hassette.models.states.base`
38+
- Add tests to catch this in the future
1339

1440
## [0.16.0] - 2025-11-16
1541

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# syntax=docker/dockerfile:1
22

33
# ---- Builder stage ----
4-
FROM python:3.12-alpine AS builder
4+
FROM python:3.13-alpine AS builder
55
COPY --from=ghcr.io/astral-sh/uv:0.9.8 /uv /bin/
66

77
# uncomment this if/when we need to build packages with native extensions
@@ -23,7 +23,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
2323
uv sync --locked --no-editable --active
2424

2525
# ---- Final stage ----
26-
FROM python:3.12-alpine
26+
FROM python:3.13-alpine
2727

2828
# System packages you want available at runtime
2929
RUN apk add --no-cache curl tini tzdata
@@ -44,7 +44,7 @@ RUN addgroup -S hassette \
4444
&& chown -R hassette:hassette /config /data /apps /app
4545

4646
# Copy uv binary
47-
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /bin/
47+
COPY --from=ghcr.io/astral-sh/uv:0.9.8 /uv /bin/
4848

4949
# Copy app, venv, scripts
5050
COPY --from=builder --chown=hassette:hassette /app /app

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Check out the [`examples/`](https://github.com/NodeJSmith/hassette/tree/main/exa
9898

9999
**Configuration examples**:
100100
- [Docker Compose Guide](https://hassette.readthedocs.io/en/latest/pages/getting-started/docker/) - Docker deployment setup
101-
- [HassetteConfig](https://hassette.readthedocs.io/en/latest/reference/hassette/config/core/#hassette.config.core.HassetteConfig) - Complete configuration reference
101+
- [HassetteConfig](https://hassette.readthedocs.io/en/latest/reference/hassette/config/core/#hassette.config.config.HassetteConfig) - Complete configuration reference
102102

103103
## 🛣️ Status & Roadmap
104104

@@ -110,7 +110,6 @@ Development is tracked in our [GitHub project](https://github.com/users/NodeJSmi
110110

111111
- 🔐 **Enhanced type safety** - Fully typed service calls and additional state models
112112
- 🏗️ **Entity classes** - Rich entity objects with built-in methods (e.g., `await light.turn_on()`)
113-
- 💾 **State cache** - Local state caching for faster reads (similar to AppDaemon)
114113
- 🔄 **Enhanced error handling** - Better retry logic and error recovery
115114
- 🧪 **Testing improvements** - More comprehensive test coverage and user app testing framework
116115

docker-compose.ci.yml

Lines changed: 0 additions & 29 deletions
This file was deleted.

docker-compose.yml

Lines changed: 0 additions & 42 deletions
This file was deleted.

docs/pages/appdaemon-comparison.md

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ autocompletion and earlier error detection.
5656
| Monitor service calls | `self.listen_event(self.on_service, "call_service", domain="light")` | `self.bus.on_call_service(domain="light", handler=self.on_service)` |
5757
| Schedule something in 60 seconds | `self.run_in(self.turn_off, 60)` | `self.scheduler.run_in(self.turn_off, delay=60)` |
5858
| Run every morning at 07:30 | `self.run_daily(self.morning, time(7, 30, 0))` | `self.scheduler.run_daily(self.morning, start=time(7, 30))` |
59+
| Get entity state (cached) | `self.get_state("light.kitchen")` | `self.states.light.get("light.kitchen")` |
5960
| Call a Home Assistant service | `self.call_service("light/turn_on", entity_id="light.kitchen", brightness=200)` | `await self.api.call_service("light", "turn_on", target={"entity_id": "light.kitchen"}, brightness=200)` |
6061
| Access app configuration | `self.args["entity"]` | `self.app_config.entity` |
6162
| Stop a listener | `self.cancel_listen_state(handle)` | `subscription.cancel()` |
@@ -639,28 +640,45 @@ class StateGetter(Hass):
639640

640641
#### Hassette
641642

642-
Hassette aims to provide a fully typed API client that uses Pydantic
643-
models for requests and responses. The client methods are async and
644-
return rich objects with attributes. Attempting to access a non-existent
645-
entity will raise a `EntityNotFoundError` exception.
643+
Hassette provides two ways to access entity states:
646644

647-
The API client is accessed via the `self.api` attribute. This client
648-
makes direct calls to Home Assistant over REST API, which does require
649-
using `await`. When you call `set_state()`, it uses the Home Assistant
650-
REST API to update the state of the entity.
645+
1. **`self.states`** - Local state cache (similar to AppDaemon) that's automatically kept up-to-date via state change events. This is the preferred method for most reads.
646+
2. **`self.api`** - Direct API calls to Home Assistant for when you need to force a fresh read or perform writes.
651647

652-
!!! info "State Cache Coming Soon"
653-
A state cache similar to AppDaemon's is planned for a future release,
654-
which will reduce API calls for frequently accessed states.
648+
**Using the State Cache (`self.states`)**
649+
650+
The `self.states` attribute provides immediate access to all entity states without making API calls. It listens to state change events and maintains an up-to-date local cache, similar to AppDaemon's behavior.
655651

656652
```python
653+
from hassette import App
657654
from hassette.models import states
658655
659-
from hassette import App
660656
657+
class StateGetter(App):
658+
async def on_initialize(self):
659+
# Access via domain-specific property (no await needed)
660+
office_light = self.states.light.get("light.office_light_1")
661+
662+
if office_light:
663+
self.logger.info(f"Light state: {office_light.value}")
664+
self.logger.info(f"Brightness: {office_light.attributes.brightness}")
665+
666+
# Iterate over all lights
667+
for entity_id, light in self.states.light:
668+
self.logger.info(f"{entity_id}: {light.value}")
669+
670+
# Typed access for any domain
671+
my_light = self.states.get[states.LightState]("light.office_light_1")
672+
```
673+
674+
**Using the API Client (`self.api`)**
661675

676+
For cases where you need to force a fresh read from Home Assistant or perform writes:
677+
678+
```python
662679
class StateGetter(App):
663680
async def on_initialize(self):
681+
# Force fresh read from HA (requires await)
664682
office_light_state = await self.api.get_state("light.office_light_1", model=states.LightState)
665683
self.logger.info(f"{office_light_state=}")
666684
```
@@ -916,18 +934,40 @@ def initialize(self):
916934
```
917935

918936
**Hassette**:
937+
938+
Hassette provides two approaches - the **local cache** (preferred, similar to AppDaemon) and **direct API calls**:
939+
919940
```python
920941
from hassette.models import states
921942
922943
async def on_initialize(self):
923-
# Typed state object
944+
# PREFERRED: Use local state cache (no await, no API call)
945+
# This is most similar to AppDaemon's behavior
946+
light = self.states.light.get("light.kitchen")
947+
if light:
948+
brightness = light.attributes.brightness # Type-safe access
949+
value = light.value # State value as string
950+
951+
# Iterate over all lights in cache
952+
for entity_id, light in self.states.light:
953+
self.logger.info(f"{entity_id}: {light.value}")
954+
955+
# Typed access for any domain
956+
my_light = self.states.get[states.LightState]("light.kitchen")
957+
958+
# ALTERNATIVE: Force fresh read from Home Assistant API
924959
light = await self.api.get_state("light.kitchen", states.LightState)
925-
brightness = light.attributes.brightness # Type-safe access
960+
brightness = light.attributes.brightness
926961
927962
# Or get just the value
928963
value = await self.api.get_state_value("light.kitchen") # Returns string
929964
```
930965

966+
**When to use each approach:**
967+
968+
- **`self.states`** (recommended): For reading current state in event handlers, scheduled tasks, or any time you need quick access to entity state. The cache is automatically kept up-to-date via state change events.
969+
- **`self.api.get_state()`**: Only when you specifically need to force a fresh read from Home Assistant (rare) or if you're outside the normal app lifecycle.
970+
931971
#### Calling Services
932972

933973
**AppDaemon**:
@@ -1052,7 +1092,8 @@ class MyApp(App):
10521092
- In Hassette: `self.app_config.key`
10531093
- Define all config keys in your `AppConfig` model for validation
10541094

1055-
!!! info "State Cache"
1056-
- AppDaemon caches all states automatically
1057-
- Hassette currently makes direct API calls (cache coming in a future release)
1058-
- Use `get_states()` once and filter locally for better performance
1095+
!!! tip "State Access"
1096+
- AppDaemon: `self.get_state()` returns cached state
1097+
- Hassette: `self.states.light.get()` returns cached state (no `await` needed)
1098+
- Both maintain automatic local caches updated via state change events
1099+
- Use `self.api.get_state()` only when you need to force a fresh read from HA

docs/pages/core-concepts/api/index.md

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -281,15 +281,15 @@ async def check_climate(self):
281281

282282
### Common Attributes to Check
283283

284-
| Attribute | Found On | Type | Use Case |
285-
| --------------------- | ---------------------- | --------------- | --------------------------------- |
286-
| `battery_level` | Many devices | `int | None` | Monitor device health |
287-
| `brightness` | Lights | `int | None` | Check/set light intensity (0-255) |
288-
| `temperature` | Climate | `float | None` | Target temperature |
289-
| `current_temperature` | Climate | `float | None` | Actual temperature |
290-
| `media_title` | Media players | `str | None` | What's playing |
291-
| `friendly_name` | All entities | `str` | Display name |
292-
| `device_class` | Sensors/binary sensors | `str | None` | Type classification |
284+
| Attribute | Found On | Type | Use Case |
285+
| --------------------- | ---------------------- | ------ | ------------ |
286+
| `battery_level` | Many devices | `int | None` | Monitor device health |
287+
| `brightness` | Lights | `int | None` | Check/set light intensity (0-255) |
288+
| `temperature` | Climate | `float | None` | Target temperature |
289+
| `current_temperature` | Climate | `float | None` | Actual temperature |
290+
| `media_title` | Media players | `str | None` | What's playing |
291+
| `friendly_name` | All entities | `str` | Display name |
292+
| `device_class` | Sensors/binary sensors | `str | None` | Type classification |
293293

294294
## Error Handling
295295

@@ -353,11 +353,32 @@ async def refresh_cache(self):
353353
self.config_entities = await self.api.get_states()
354354
```
355355

356-
### Use State Cache (Coming Soon)
356+
### Use `self.states` for Local State Access
357357

358-
!!! info "Roadmap"
359-
A built-in state cache similar to AppDaemon's is planned. This will automatically maintain
360-
an up-to-date local copy of all entity states, eliminating most API calls for reads.
358+
Hassette maintains a local cache of all entity states via the `self.states` attribute. This cache is automatically updated by listening to state change events, providing instant access without API calls.
359+
360+
```python
361+
async def on_initialize(self):
362+
# Access cached state without API call
363+
light = self.states.light.get("light.bedroom")
364+
if light and light.attributes.brightness:
365+
self.logger.info(f"Brightness: {light.attributes.brightness}")
366+
367+
# Iterate over all sensors
368+
for entity_id, sensor in self.states.sensor:
369+
if hasattr(sensor.attributes, "battery_level"):
370+
if sensor.attributes.battery_level < 20:
371+
self.logger.warning(f"{entity_id} battery low: {sensor.attributes.battery_level}%")
372+
373+
# Count entities by domain
374+
light_count = len(self.states.light)
375+
sensor_count = len(self.states.sensor)
376+
```
377+
378+
**When to use `self.states` vs `self.api`:**
379+
380+
- **Use `self.states`** (recommended): For reading current state in event handlers, scheduled tasks, or any synchronous state access. The cache is kept up-to-date automatically.
381+
- **Use `self.api.get_state()`**: Only when you need to force a fresh read from Home Assistant (rare), or during initial setup before the cache is populated.
361382

362383
## History and Time-Based Queries
363384

@@ -456,7 +477,7 @@ except HomeAssistantError as e:
456477
| Get all states | `get_states()` | `list[BaseState]` |
457478
| Get single state | `get_state(entity_id, model)` | `StateT` |
458479
| Get state value only | `get_state_value(entity_id)` | `str` |
459-
| Check if entity exists | `get_state_or_none(entity_id, model)` | `StateT | None` |
480+
| Check if entity exists | `get_state_or_none(entity_id, model)` | `StateT | None` |
460481
| Call any service | `call_service(domain, service, **data)` | `ServiceResponse` |
461482
| Turn on entity | `turn_on(entity_id, **data)` | `ServiceResponse` |
462483
| Turn off entity | `turn_off(entity_id, **data)` | `ServiceResponse` |

docs/pages/core-concepts/api/states_example.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,21 @@
33

44
class StatesExample(App):
55
async def states_example(self):
6-
# Bulk fetch every entity as a typed state union
7-
all_states = await self.api.get_states()
6+
# PREFERRED: Use state cache for instant access (no API call)
7+
# Access all entities
8+
all_states = self.states.all
89

9-
# Target a specific device with a concrete model
10-
climate = await self.api.get_state("climate.living_room", states.ClimateState)
11-
if climate.attributes.hvac_action == "heating":
12-
self.logger.debug("Living room is warming up (%.1f°C)", climate.attributes.current_temperature)
10+
# Target a specific device with typed model (no await needed)
11+
climate = self.states.climate.get("climate.living_room")
12+
if climate and climate.attributes.hvac_action == "heating":
13+
self.logger.debug("Living room is warming up (%.1f)", climate.attributes.current_temperature)
14+
15+
# Access state value directly
16+
outdoor_sensor = self.states.sensor.get("sensor.outdoor_temp")
17+
temperature = outdoor_sensor.value if outdoor_sensor else None
18+
19+
# ALTERNATIVE: Force fresh read from API (rare, requires await)
20+
fresh_climate = await self.api.get_state("climate.living_room", states.ClimateState)
21+
self.logger.debug("Fresh HVAC action: %s", fresh_climate.attributes.hvac_action)
1322

14-
# Raw access
15-
temperature = await self.api.get_state_value("sensor.outdoor_temp")
1623
return all_states, climate, temperature

0 commit comments

Comments
 (0)