Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 8 additions & 133 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,150 +1,25 @@
# hacs-hubitat

This file provides guidance to AI agents when working with code in this repository.

## Project Overview

This is a Home Assistant integration for Hubitat hubs that allows Hubitat devices to be controlled through Home Assistant. The integration uses Hubitat's Maker API to communicate with the hub and includes a local event server to receive real-time device updates.

## Development Commands

### Requirements

- Python >=3.13.2
- Home Assistant >=2025.4.0

### Development Setup

- Initialize development environment: `uv sync`

### Testing

- Run tests: `uv run pytest`
- Test with specific pattern: `uv run pytest -k "test_pattern"`

### Code Quality

- Type checking: `uv run ty check`
- Linting: `uv run ruff check`
- Format code: `uv run ruff format`

### Package Management

- Install dependencies: `uv sync`
- Add development dependency: `uv add --dev <package>`

### Local Development

- The app uses `uv`. Use `uv` to run all tools
- Run integration with local Home Assistant: `./home_assistant start`
- Pre-commit hooks run automatically on commit (ruff + type checking)
- Install pre-commit manually: `uv run pre-commit install`

### Publishing

- Create new release: `python scripts/publish.py` (prompts for version, creates tag, pushes)

## Architecture

### Core Components

**Integration Entry Point** (`custom_components/hubitat/__init__.py`):

- Manages the lifecycle of the integration
- Handles config entry setup/unload
- Registers services and event listeners

**Hub Management** (`custom_components/hubitat/hub.py`):

- Central hub class that manages connection to Hubitat
- Handles device discovery and registration
- Manages the event server for real-time updates
- Coordinates between Home Assistant and Hubitat devices

**Hubitat Maker Library** (`custom_components/hubitat/hubitatmaker/`):

- Low-level communication with Hubitat's Maker API
- Device modeling and event handling
- HTTP server for receiving device events from Hubitat

### Device Platforms

The integration supports multiple Home Assistant platforms, each in its own file:

- `alarm_control_panel.py` - Home security systems
- `binary_sensor.py` - Motion, contact, smoke, etc.
- `climate.py` - Thermostats and HVAC controls
- `cover.py` - Garage doors, window shades
- `event.py` - Event entities
- `fan.py` - Fan controls
- `light.py` - Lights with various capabilities (dimming, color, etc.)
- `lock.py` - Door locks and keypads
- `sensor.py` - Temperature, humidity, battery, etc.
- `switch.py` - Basic on/off switches
- `valve.py` - Water valves

### Configuration Flow

**Config Flow** (`custom_components/hubitat/config_flow.py`):

- Handles initial setup wizard
- Device discovery and selection
- SSL configuration for event server
- Device removal workflow

### Event System

The integration uses a bi-directional communication model:

1. **Control**: Home Assistant → Hubitat via Maker API HTTP requests
2. **Updates**: Hubitat → Home Assistant via HTTP POST to local event server

The event server is automatically configured in the Maker API instance to push device updates in real-time.

## Key Configuration

### Manifest (`custom_components/hubitat/manifest.json`)

- Integration metadata and Home Assistant compatibility
- No external dependencies (uses built-in HTTP libraries)

### Constants (`custom_components/hubitat/const.py`)

- Platform definitions, configuration keys
- Device capability mappings
- Event types and trigger definitions

## Development Notes

### Git Workflow

- Main branch: `master` (not `main`)
- PRs should target `master`

### Device Capability Mapping
## Device Capability Mapping

- Devices are mapped to HA platforms based on Hubitat capabilities
- Some devices may appear as multiple entities (e.g., a lock with battery sensor)
- Device type detection uses heuristics for ambiguous devices (e.g., switches vs lights)

### Event Server Architecture
## Event Server

- Python HTTP server runs alongside Home Assistant
- Automatically configured in Hubitat's Maker API
- Supports SSL for secure communication
- Port selection is automatic but can be manually configured

### Testing Strategy

- Unit tests focus on device mapping and capability detection
- Mock Hubitat API responses for predictable testing
- Integration tests validate the full setup flow

### Testing Gotchas

- **Device classes**: Device class assignment is based on capabilities. When adding new device types, verify `device_class` is set correctly (see tests/test_*.py for patterns).
- **Mock responses**: Mock data in `tests/hubitatmaker/` should match real Hubitat API responses for accuracy.

### Dependencies
## Live Testing

- Uses `uv` for dependency management
- Built-in Home Assistant libraries for core functionality
- Start a local instance with `./home_assistant start <version>`, like
`./home_assistant start 2026.3.1`.
- Start a remote tunnel with ssh to forward event server messages:
`ssh -R 0.0.0.0:12345:localhost:12345 hass`
4 changes: 2 additions & 2 deletions custom_components/hubitat/alarm_control_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

try:
from homeassistant.components.alarm_control_panel.const import (
AlarmControlPanelState, # pyright: ignore[reportAssignmentType]
AlarmControlPanelState,
)
except Exception:
# TODO: Remove this code by 2025.11
Expand Down Expand Up @@ -98,7 +98,7 @@ def __init__(self, **kwargs: Unpack[HubitatEntityArgs]):
def load_state(self):
self._attr_changed_by: str | None = self._get_changed_by()
self._attr_code_format: CodeFormat | None = self._get_code_format()
self._attr_alarm_state: AlarmControlPanelState | None = self._get_alarm_state() # pyright: ignore[reportIncompatibleVariableOverride]
self._attr_alarm_state: AlarmControlPanelState | None = self._get_alarm_state()

# TODO: remove this code by 2025.11; state will be handled by
# _attr_alarm_state
Expand Down
4 changes: 2 additions & 2 deletions custom_components/hubitat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None:
await self.send_command(DeviceCommand.ECO)

@override
async def async_set_temperature(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if self.hvac_mode == HVACMode.HEAT_COOL or self.hvac_mode == HVACMode.AUTO:
temp_low = cast(float | None, kwargs.get(ATTR_TARGET_TEMP_LOW))
Expand All @@ -289,7 +289,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: # pyright: ignore
await self.send_command(DeviceCommand.SET_HEATING_SETPOINT, temp)

@override
async def async_turn_off(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the thermostat."""
await self.send_command("off")

Expand Down
6 changes: 3 additions & 3 deletions custom_components/hubitat/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,19 @@ def device_attrs(self) -> tuple[DeviceAttribute, ...] | None:
return self._device_attrs

@override
async def async_close_cover(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
_LOGGER.debug("Closing %s", self.name)
await self.send_command(DeviceCommand.CLOSE)

@override
async def async_open_cover(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
_LOGGER.debug("Opening %s", self.name)
await self.send_command(DeviceCommand.OPEN)

@override
async def async_set_cover_position(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
pos = cast(str, kwargs[HA_ATTR_POSITION])
_LOGGER.debug("Setting cover position to %s", pos)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/hubitat/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
)
)

TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( # pyright: ignore[reportUnknownMemberType]
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
vol.Required(H_CONF_SUBTYPE): str,
Expand Down
6 changes: 3 additions & 3 deletions custom_components/hubitat/hubitatmaker/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ async def _load_modes(self) -> None:
_LOGGER.debug("Loaded modes")
self._modes = [Mode(m) for m in modes]

async def _api_request( # pyright: ignore[reportAny]
async def _api_request(
self, path: str, method: Literal["GET", "POST"] = "GET"
) -> Any:
"""Make a Maker API request."""
Expand Down Expand Up @@ -480,10 +480,10 @@ async def _api_request( # pyright: ignore[reportAny]
# sometimes mis-reports the content type as text/html
# even though the data is JSON
text = await resp.text()
data = json.loads(text) # pyright: ignore[reportAny]
data = json.loads(text)
if "error" in data and data["error"]:
raise RequestError(resp)
return data # pyright: ignore[reportAny]
return data
except ContentTypeError as e:
text = await resp.text()
_LOGGER.warning("Unable to parse as JSON: %s", text)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/hubitat/hubitatmaker/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def _run(self) -> None:
# Access the protected _server attribute to get socket info
site_server = cast(
AsyncioServer,
site._server, # pyright: ignore[reportPrivateUsage]
site._server,
)
sockets = list(site_server.sockets or [])
socket = sockets[0]
Expand Down
8 changes: 4 additions & 4 deletions custom_components/hubitat/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
DEFAULT_MIN_KELVIN,
)
except Exception:
DEFAULT_MIN_KELVIN = 2000 # pyright: ignore[reportConstantRedefinition]
DEFAULT_MAX_KELVIN = 6535 # pyright: ignore[reportConstantRedefinition]
DEFAULT_MIN_KELVIN = 2000
DEFAULT_MAX_KELVIN = 6535

from homeassistant.components.light.const import ColorMode, LightEntityFeature
from homeassistant.config_entries import ConfigEntry
Expand Down Expand Up @@ -184,7 +184,7 @@ def color_name(self) -> str | None:
return self.get_str_attr(DeviceAttribute.COLOR_NAME)

@override
async def async_turn_on(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
_LOGGER.debug(f"Turning on {self.name} with {kwargs}")

Expand Down Expand Up @@ -272,7 +272,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: # pyright: ignore[reportA
await self.send_command(DeviceCommand.FLASH)

@override
async def async_turn_off(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
_LOGGER.debug(f"Turning off {self.name}")
if ATTR_TRANSITION in kwargs:
Expand Down
4 changes: 2 additions & 2 deletions custom_components/hubitat/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ def device_attrs(self) -> tuple[DeviceAttribute, ...] | None:
return _device_attrs

@override
async def async_lock(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
await self.send_command(DeviceCommand.LOCK)

@override
async def async_unlock(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
await self.send_command(DeviceCommand.UNLOCK)

Expand Down
2 changes: 1 addition & 1 deletion custom_components/hubitat/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async def get_codes(service: ServiceCall) -> ServiceResponse:
code_list = cast(
JsonValueType,
sorted(
[{ATTR_POSITION: key, **value} for key, value in codes.items()], # pyright: ignore[reportAny]
[{ATTR_POSITION: key, **value} for key, value in codes.items()],
key=lambda x: int(x[ATTR_POSITION]),
),
)
Expand Down
8 changes: 4 additions & 4 deletions custom_components/hubitat/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def __init__(
"""Initialize a Hubitat switch."""
HubitatEntity.__init__(self, **kwargs)
SwitchEntity.__init__(self)
self._attr_device_class: SwitchDeviceClass = ( # pyright: ignore[reportIncompatibleVariableOverride]
self._attr_device_class: SwitchDeviceClass = (
SwitchDeviceClass.SWITCH
if _NAME_TEST.search(self._device.label)
else SwitchDeviceClass.OUTLET
Expand All @@ -72,13 +72,13 @@ def device_attrs(self) -> tuple[DeviceAttribute, ...] | None:
return (DeviceAttribute.SWITCH, DeviceAttribute.POWER)

@override
async def async_turn_on(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
_LOGGER.debug(f"Turning on {self.name} with {kwargs}")
await self.send_command(DeviceCommand.ON)

@override
async def async_turn_off(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
_LOGGER.debug(f"Turning off {self.name}")
await self.send_command("off")
Expand All @@ -103,7 +103,7 @@ def __init__(self, **kwargs: Unpack[HubitatEntityArgs]):
self._attr_icon: str | None = ICON_ALARM

@override
async def async_turn_on(self, **kwargs: Any) -> None: # pyright: ignore[reportAny]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the alarm."""
_LOGGER.debug("Activating alarm %s", self.name)
await self.send_command(DeviceCommand.BOTH)
Expand Down
2 changes: 0 additions & 2 deletions home_assistant
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
#!/usr/bin/env python3

# pyright: reportAny=false

import argparse
import os
import shutil
Expand Down
Loading
Loading