Compare commits

..

2 Commits

Author SHA1 Message Date
Claude
6bcbcbc6a1 Convert agent files to proper Claude Skills
Restructured from agent markdown files to proper Claude Skills format:

Changed:
- Moved from .claude/agents/*.md to .claude/skills/*/SKILL.md
- Updated YAML frontmatter to Skills format (name + description only)
- Removed agent-specific fields (model, color, tools)
- Condensed descriptions to focus on triggering conditions
- Updated README to reflect proper Skills structure

New Skills:
- testing: Write, run, and fix tests for HA integrations
- code-review: Review code for quality and standards
- quality-scale-architect: Architecture and quality tier guidance

Skills use progressive disclosure for efficient context management:
- Level 1: Metadata (always loaded, ~100 tokens)
- Level 2: Instructions (loaded when triggered, <5k tokens)
- Level 3: Resources (loaded as needed, unlimited)

Reference files in .claude/references/ remain unchanged and are
loaded on-demand by Skills.

See: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/
2026-01-12 19:08:45 +00:00
Claude
5c8f494ac6 Add Claude Code skills and reference documentation
Created structured skill system for Home Assistant development:

Agents (specialized AI assistants):
- testing.md: Test writing, running, and fixing specialist
- code-review.md: Code quality and standards reviewer
- quality-scale-architect.md: Architecture and tier guidance

Reference documentation:
- diagnostics.md: Integration diagnostics implementation
- sensor.md: Sensor platform implementation guide
- binary_sensor.md: Binary sensor platform guide
- switch.md: Switch platform guide
- button.md: Button platform guide
- number.md: Number platform guide
- select.md: Select platform guide

Each file provides comprehensive implementation guidance, patterns,
best practices, and quality scale considerations extracted from
CLAUDE.md and organized for focused, specialized assistance.
2026-01-11 20:33:36 +00:00
6246 changed files with 111773 additions and 359476 deletions

View File

@@ -1 +0,0 @@
../.claude/skills/

168
.claude/README.md Normal file
View File

@@ -0,0 +1,168 @@
# Claude Code Skills and Reference Files
This directory contains Claude Skills and reference documentation for working with Home Assistant integrations.
## Directory Structure
```
.claude/
├── skills/ # Claude Skills (auto-loaded)
│ ├── testing/
│ │ └── SKILL.md # Testing specialist skill
│ ├── code-review/
│ │ └── SKILL.md # Code review specialist skill
│ └── quality-scale-architect/
│ └── SKILL.md # Architecture guidance skill
├── agents/ # Legacy agent definitions
│ └── quality-scale-rule-verifier.md
└── references/ # Deep-dive reference docs
├── diagnostics.md # Diagnostics implementation
├── sensor.md # Sensor platform
├── binary_sensor.md # Binary sensor platform
├── switch.md # Switch platform
├── button.md # Button platform
├── number.md # Number platform
└── select.md # Select platform
```
## Claude Skills
Claude Skills are modular capabilities that extend Claude's functionality. Each Skill packages instructions and metadata that Claude uses automatically when relevant.
### How Skills Work
Skills use **progressive disclosure** - they load content in stages:
1. **Level 1 - Metadata (always loaded)**: Skill name and description
2. **Level 2 - Instructions (loaded when triggered)**: Main SKILL.md content
3. **Level 3+ - Resources (loaded as needed)**: Reference files and additional docs
This means you can have many Skills installed with minimal context penalty. Claude only knows each Skill exists and when to use it until triggered.
### Available Skills
#### Testing (`testing`)
**Use when**: Writing, running, or fixing tests for Home Assistant integrations
Specializes in:
- Writing comprehensive test coverage (>95%)
- Running pytest with appropriate flags
- Fixing failing tests and updating snapshots
- Following Home Assistant testing patterns
- Modern fixture patterns and snapshot testing
**Triggers on**: Requests about writing tests, running tests, fixing test failures, test coverage, pytest, snapshots
#### Code Review (`code-review`)
**Use when**: Reviewing code for quality, best practices, and standards compliance
Specializes in:
- Reviewing pull requests and code changes
- Identifying anti-patterns and security vulnerabilities
- Verifying async patterns and error handling
- Ensuring quality scale compliance
- Performance optimization
**Triggers on**: Requests to review code, check for issues, analyze code quality, security review
#### Quality Scale Architect (`quality-scale-architect`)
**Use when**: Needing architectural guidance and quality scale planning
Specializes in:
- High-level architecture guidance
- Quality scale tier selection (Bronze/Silver/Gold/Platinum)
- Integration structure planning
- Pattern recommendations (coordinator, push, hub)
- Progression strategies between quality tiers
**Triggers on**: Requests about architecture, integration design, quality tiers, structural planning, choosing patterns
## Reference Files
Reference files provide deep-dive documentation for specific implementation areas. Skills can reference these for detailed guidance, and they're loaded on-demand to avoid consuming context.
### Available References
- **diagnostics.md**: Complete guide to implementing integration and device diagnostics, data redaction, testing
- **sensor.md**: Sensor platform implementation, device classes, state classes, entity descriptions
- **binary_sensor.md**: Binary sensor implementation, device classes, push-updated patterns
- **switch.md**: Switch control implementation, state updates, configuration switches
- **button.md**: Button action implementation, device classes, one-time actions
- **number.md**: Numeric value control, ranges, display modes, units
- **select.md**: Option selection implementation, enums, translations, dynamic options
## How to Use
### As a Developer
Skills work automatically - just ask Claude to help with tasks:
- **Testing**: "Write tests for my sensor platform" or "Fix the failing config flow tests"
- **Review**: "Review this integration for security issues" or "Check my async patterns"
- **Architecture**: "Help me design a hub integration" or "What quality tier should I target?"
### As Claude
Skills are triggered automatically when requests match the skill descriptions. Skills can reference the documentation files in `.claude/references/` for detailed implementation guidance.
Example:
```python
# When a testing request comes in, Claude triggers the testing skill
# The skill can then reference .claude/references/sensor.md for sensor-specific patterns
```
## Quality Scale Overview
Home Assistant uses a Quality Scale system:
- **Bronze**: Basic requirements (mandatory baseline) - Config flow, unique IDs, auth flows
- **Silver**: Enhanced functionality - Unavailability tracking, runtime data, parallel updates
- **Gold**: Advanced features - Diagnostics, translations, device registry
- **Platinum**: Highest quality - Strict typing, async-only dependencies, WebSession injection
All Bronze rules are mandatory. Higher tiers are additive.
## Skill Structure
Each Skill is a directory containing a `SKILL.md` file with YAML frontmatter:
```yaml
---
name: skill-name
description: Brief description of what this Skill does and when to use it (max 1024 chars)
---
# Skill Content in Markdown
Instructions, examples, and guidance...
```
**Progressive Loading**: Only the name/description are loaded initially. The full content loads when the Skill is triggered.
## Creating Custom Skills
To add a new Skill:
1. Create a directory: `.claude/skills/my-skill/`
2. Add a `SKILL.md` file with proper frontmatter
3. Include clear instructions and examples
4. Reference existing documentation when appropriate
See [Claude Skills Documentation](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) for complete guidance.
## Additional Resources
- Main instructions: `/home/user/core/CLAUDE.md`
- Home Assistant Docs: https://developers.home-assistant.io
- Integration Quality Scale: https://developers.home-assistant.io/docs/core/integration-quality-scale/
- Claude Skills Cookbook: https://platform.claude.com/cookbook/skills-notebooks-01-skills-introduction
## Contributing
When adding new Skills or references:
1. Follow the proper Skill structure (SKILL.md with frontmatter)
2. Keep descriptions concise and trigger-focused (max 1024 chars)
3. Include practical examples in Skill content
4. Link to reference documentation for deep dives
5. Consider quality scale implications
6. Test that Skills trigger appropriately

View File

@@ -0,0 +1,470 @@
# Binary Sensor Platform Reference
## Overview
Binary sensors are read-only entities that represent an on/off, true/false, or open/closed state. They are simpler than regular sensors and don't have units or numeric values.
## Basic Binary Sensor Implementation
```python
"""Binary sensor platform for my_integration."""
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyConfigEntry
from .coordinator import MyCoordinator
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensors."""
coordinator = entry.runtime_data
async_add_entities(
MyBinarySensor(coordinator, device_id)
for device_id in coordinator.data.devices
)
class MyBinarySensor(MyEntity, BinarySensorEntity):
"""Representation of a binary sensor."""
_attr_has_entity_name = True
_attr_translation_key = "motion"
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(self, coordinator: MyCoordinator, device_id: str) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}_motion"
@property
def is_on(self) -> bool | None:
"""Return true if motion is detected."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.motion_detected
return None
```
## Binary Sensor State
The core property for binary sensors is `is_on`:
```python
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.device.is_active
# Alternatively, use attribute
_attr_is_on = True # or False, or None
```
**State Meaning**:
- `True` / `"on"` - Active/detected/open
- `False` / `"off"` - Inactive/not detected/closed
- `None` - Unknown (displays as "unavailable")
## Device Classes
Binary sensors should use device classes for proper representation:
```python
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
# Common device classes
_attr_device_class = BinarySensorDeviceClass.MOTION
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
_attr_device_class = BinarySensorDeviceClass.DOOR
_attr_device_class = BinarySensorDeviceClass.WINDOW
_attr_device_class = BinarySensorDeviceClass.OPENING
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
_attr_device_class = BinarySensorDeviceClass.BATTERY
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_device_class = BinarySensorDeviceClass.RUNNING
_attr_device_class = BinarySensorDeviceClass.SMOKE
_attr_device_class = BinarySensorDeviceClass.MOISTURE
_attr_device_class = BinarySensorDeviceClass.LOCK
_attr_device_class = BinarySensorDeviceClass.TAMPER
_attr_device_class = BinarySensorDeviceClass.PLUG
_attr_device_class = BinarySensorDeviceClass.POWER
```
### Device Class Selection Guide
**Detection Sensors**:
- Motion detector → `MOTION`
- Presence detector → `OCCUPANCY`
- Smoke detector → `SMOKE`
- Water leak detector → `MOISTURE`
**Contact Sensors**:
- Door sensor → `DOOR`
- Window sensor → `WINDOW`
- Generic contact → `OPENING`
**Status Sensors**:
- Network connection → `CONNECTIVITY`
- Device running → `RUNNING`
- Low battery → `BATTERY`
- Charging state → `BATTERY_CHARGING`
- Problem/fault → `PROBLEM`
- Tamper detection → `TAMPER`
**Power Sensors**:
- Outlet state → `PLUG`
- Power state → `POWER`
- Lock state → `LOCK`
## Entity Descriptions Pattern
For multiple similar binary sensors:
```python
from dataclasses import dataclass
from collections.abc import Callable
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntityDescription,
)
@dataclass(frozen=True, kw_only=True)
class MyBinarySensorDescription(BinarySensorEntityDescription):
"""Describes a binary sensor."""
is_on_fn: Callable[[MyData], bool | None]
BINARY_SENSORS: tuple[MyBinarySensorDescription, ...] = (
MyBinarySensorDescription(
key="motion",
translation_key="motion",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda data: data.motion_detected,
),
MyBinarySensorDescription(
key="door",
translation_key="door",
device_class=BinarySensorDeviceClass.DOOR,
is_on_fn=lambda data: data.door_open,
),
MyBinarySensorDescription(
key="battery_low",
translation_key="battery_low",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda data: data.battery_level < 20,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensors."""
coordinator = entry.runtime_data
async_add_entities(
MyBinarySensor(coordinator, device_id, description)
for device_id in coordinator.data.devices
for description in BINARY_SENSORS
)
class MyBinarySensor(MyEntity, BinarySensorEntity):
"""Binary sensor using entity description."""
entity_description: MyBinarySensorDescription
def __init__(
self,
coordinator: MyCoordinator,
device_id: str,
description: MyBinarySensorDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
if device := self.coordinator.data.devices.get(self.device_id):
return self.entity_description.is_on_fn(device)
return None
```
## Entity Category
Mark diagnostic or configuration binary sensors:
```python
from homeassistant.helpers.entity import EntityCategory
# Diagnostic sensors
_attr_entity_category = EntityCategory.DIAGNOSTIC
# Examples: connectivity, update available, battery low
# Config sensors
_attr_entity_category = EntityCategory.CONFIG
# Examples: configuration status
```
## State Inversion
For some sensors, you may need to invert the logic:
```python
class MyBinarySensor(BinarySensorEntity):
"""Binary sensor with inverted state."""
@property
def is_on(self) -> bool | None:
"""Return true if sensor is on."""
if self.device.is_closed:
return False # Closed = off for door sensor
if self.device.is_open:
return True # Open = on for door sensor
return None
```
## Push-Updated Binary Sensor
For event-driven sensors:
```python
class MyPushBinarySensor(BinarySensorEntity):
"""Push-updated binary sensor."""
_attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""Subscribe to updates when added."""
self.async_on_remove(
self.device.subscribe_state(self._handle_state_update)
)
@callback
def _handle_state_update(self, state: bool) -> None:
"""Handle state update from device."""
self._attr_is_on = state
self.async_write_ha_state()
```
## Testing Binary Sensors
### Snapshot Testing
```python
"""Test binary sensors."""
import pytest
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
init_integration,
) -> None:
"""Test binary sensor entities."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
```
### State Testing
```python
async def test_binary_sensor_states(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test binary sensor states."""
await init_integration(hass, mock_config_entry)
# Test on state
state = hass.states.get("binary_sensor.my_device_motion")
assert state
assert state.state == "on"
assert state.attributes["device_class"] == "motion"
# Test off state
state = hass.states.get("binary_sensor.my_device_door")
assert state
assert state.state == "off"
assert state.attributes["device_class"] == "door"
```
## Common Patterns
### Pattern 1: Coordinator-Based
```python
class MyBinarySensor(CoordinatorEntity[MyCoordinator], BinarySensorEntity):
"""Coordinator-based binary sensor."""
_attr_should_poll = False
@property
def is_on(self) -> bool | None:
"""Get state from coordinator data."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.is_active
return None
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.device_id in self.coordinator.data
```
### Pattern 2: Event-Driven
```python
class MyEventBinarySensor(BinarySensorEntity):
"""Event-driven binary sensor."""
_attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""Subscribe to events."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_event",
self._handle_event,
)
)
@callback
def _handle_event(self, event_type: str, active: bool) -> None:
"""Handle incoming event."""
if event_type == self.event_type:
self._attr_is_on = active
self.async_write_ha_state()
```
### Pattern 3: Calculated/Derived
```python
class MyCalculatedBinarySensor(BinarySensorEntity):
"""Binary sensor calculated from other sensors."""
_attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""Subscribe to source sensors."""
self.async_on_remove(
async_track_state_change_event(
self.hass,
["sensor.temperature", "sensor.humidity"],
self._handle_source_update,
)
)
@callback
def _handle_source_update(self, event: Event) -> None:
"""Recalculate when sources change."""
temp = self.hass.states.get("sensor.temperature")
humidity = self.hass.states.get("sensor.humidity")
if temp and humidity:
# Example: high comfort if temp 20-25 and humidity 30-60
temp_ok = 20 <= float(temp.state) <= 25
humidity_ok = 30 <= float(humidity.state) <= 60
self._attr_is_on = temp_ok and humidity_ok
self.async_write_ha_state()
```
## Best Practices
### ✅ DO
- Use appropriate device classes
- Return `None` for unknown state
- Use `is_on` property (not state)
- Implement unique IDs
- Use entity descriptions for similar sensors
- Mark diagnostic sensors with entity_category
- Use translation keys for entity names
- Handle availability properly
### ❌ DON'T
- Return strings like "on"/"off" from is_on
- Use regular Sensor for binary states
- Hardcode entity names
- Create binary sensors without device classes (when available)
- Use unavailable/unknown as state values
- Block the event loop
- Poll unnecessarily (use coordinator or events)
## Disabled by Default
For less important binary sensors:
```python
class MyConnectivitySensor(BinarySensorEntity):
"""Connectivity sensor - diagnostic."""
_attr_entity_registry_enabled_default = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
```
## Troubleshooting
### Binary Sensor Not Appearing
Check:
- [ ] Unique ID is set
- [ ] Platform is in PLATFORMS list
- [ ] Entity is added with async_add_entities
- [ ] is_on returns bool or None (not string)
### State Not Updating
Check:
- [ ] Coordinator is updating (if used)
- [ ] Event subscriptions are working
- [ ] is_on returns correct value
- [ ] async_write_ha_state() is called (push updates)
### Wrong Icon
Check:
- [ ] Device class is set correctly
- [ ] Device class matches sensor purpose
- [ ] Icon translations if using Gold tier
## Quality Scale Considerations
- **Bronze**: Unique ID required
- **Gold**: Entity translations, device class, entity category
- **Platinum**: Full type hints
## References
- [Binary Sensor Documentation](https://developers.home-assistant.io/docs/core/entity/binary-sensor)
- [Device Classes](https://www.home-assistant.io/integrations/binary_sensor/#device-class)

View File

@@ -0,0 +1,459 @@
# Button Platform Reference
## Overview
Buttons are entities that trigger an action when pressed. They don't have a state (on/off) and are used for one-time actions like rebooting a device, triggering an update, or running a routine.
## Basic Button Implementation
```python
"""Button platform for my_integration."""
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyConfigEntry
from .coordinator import MyCoordinator
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up buttons."""
coordinator = entry.runtime_data
async_add_entities(
MyButton(coordinator, device_id)
for device_id in coordinator.data.devices
)
class MyButton(MyEntity, ButtonEntity):
"""Representation of a button."""
_attr_has_entity_name = True
_attr_translation_key = "reboot"
def __init__(self, coordinator: MyCoordinator, device_id: str) -> None:
"""Initialize the button."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}_reboot"
async def async_press(self) -> None:
"""Handle the button press."""
await self.coordinator.client.reboot(self.device_id)
```
## Button Method
The only required method for buttons:
```python
async def async_press(self) -> None:
"""Handle the button press."""
await self.device.trigger_action()
```
**Note**: Buttons don't have state. They only perform an action when pressed.
## Device Class
Buttons can have device classes to indicate their purpose:
```python
from homeassistant.components.button import ButtonDeviceClass
_attr_device_class = ButtonDeviceClass.RESTART
_attr_device_class = ButtonDeviceClass.UPDATE
_attr_device_class = ButtonDeviceClass.IDENTIFY
```
Device classes:
- `RESTART` - Reboot/restart device
- `UPDATE` - Trigger update check or installation
- `IDENTIFY` - Make device identify itself (blink LED, beep, etc.)
## Entity Category
Most buttons are configuration actions:
```python
from homeassistant.helpers.entity import EntityCategory
# Config buttons (device settings/actions)
_attr_entity_category = EntityCategory.CONFIG
# Examples: reboot, reset, identify
# Diagnostic buttons (troubleshooting)
_attr_entity_category = EntityCategory.DIAGNOSTIC
# Examples: test connection, refresh diagnostics
```
## Entity Descriptions Pattern
For multiple buttons:
```python
from dataclasses import dataclass
from collections.abc import Awaitable, Callable
from homeassistant.components.button import ButtonEntityDescription, ButtonDeviceClass
@dataclass(frozen=True, kw_only=True)
class MyButtonDescription(ButtonEntityDescription):
"""Describes a button."""
press_fn: Callable[[MyClient, str], Awaitable[None]]
BUTTONS: tuple[MyButtonDescription, ...] = (
MyButtonDescription(
key="reboot",
translation_key="reboot",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda client, device_id: client.reboot(device_id),
),
MyButtonDescription(
key="identify",
translation_key="identify",
device_class=ButtonDeviceClass.IDENTIFY,
entity_category=EntityCategory.CONFIG,
press_fn=lambda client, device_id: client.identify(device_id),
),
MyButtonDescription(
key="check_update",
translation_key="check_update",
device_class=ButtonDeviceClass.UPDATE,
entity_category=EntityCategory.CONFIG,
press_fn=lambda client, device_id: client.check_updates(device_id),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up buttons."""
coordinator = entry.runtime_data
async_add_entities(
MyButton(coordinator, device_id, description)
for device_id in coordinator.data.devices
for description in BUTTONS
)
class MyButton(MyEntity, ButtonEntity):
"""Button using entity description."""
entity_description: MyButtonDescription
def __init__(
self,
coordinator: MyCoordinator,
device_id: str,
description: MyButtonDescription,
) -> None:
"""Initialize the button."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(
self.coordinator.client,
self.device_id,
)
```
## Common Button Types
### Restart Button
```python
class RestartButton(ButtonEntity):
"""Restart device button."""
_attr_device_class = ButtonDeviceClass.RESTART
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "restart"
async def async_press(self) -> None:
"""Restart the device."""
await self.device.restart()
```
### Update Button
```python
class UpdateButton(ButtonEntity):
"""Trigger update check button."""
_attr_device_class = ButtonDeviceClass.UPDATE
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "check_update"
async def async_press(self) -> None:
"""Check for updates."""
await self.device.check_for_updates()
```
### Identify Button
```python
class IdentifyButton(ButtonEntity):
"""Make device identify itself."""
_attr_device_class = ButtonDeviceClass.IDENTIFY
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "identify"
async def async_press(self) -> None:
"""Trigger device identification."""
await self.device.identify()
```
### Custom Action Button
```python
class CustomButton(ButtonEntity):
"""Custom action button."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "run_cycle"
async def async_press(self) -> None:
"""Run cleaning cycle."""
await self.device.start_cleaning_cycle()
```
## State Updates After Press
Buttons trigger coordinator refresh if needed:
```python
async def async_press(self) -> None:
"""Handle press with refresh."""
await self.coordinator.client.reboot(self.device_id)
# Refresh coordinator to update related entities
await self.coordinator.async_request_refresh()
```
## Error Handling
Handle errors appropriately:
```python
from homeassistant.exceptions import HomeAssistantError
async def async_press(self) -> None:
"""Handle press with error handling."""
try:
await self.device.reboot()
except DeviceOfflineError as err:
raise HomeAssistantError(f"Device is offline: {err}") from err
except DeviceError as err:
raise HomeAssistantError(f"Failed to reboot: {err}") from err
```
## Testing Buttons
### Snapshot Testing
```python
"""Test buttons."""
import pytest
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_buttons(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
init_integration,
) -> None:
"""Test button entities."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
```
### Press Testing
```python
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
async def test_button_press(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device,
) -> None:
"""Test button press."""
await init_integration(hass, mock_config_entry)
# Press button
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.my_device_reboot"},
blocking=True,
)
# Verify action was called
mock_device.reboot.assert_called_once()
```
### Error Testing
```python
async def test_button_press_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device,
) -> None:
"""Test button press with error."""
await init_integration(hass, mock_config_entry)
mock_device.reboot.side_effect = DeviceError("Connection failed")
# Press button should raise error
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.my_device_reboot"},
blocking=True,
)
```
## Common Patterns
### Pattern 1: Simple Action Button
```python
class SimpleButton(ButtonEntity):
"""Simple button that triggers action."""
async def async_press(self) -> None:
"""Trigger action."""
await self.device.do_something()
```
### Pattern 2: Button with Coordinator Refresh
```python
class RefreshingButton(CoordinatorEntity[MyCoordinator], ButtonEntity):
"""Button that refreshes coordinator."""
async def async_press(self) -> None:
"""Trigger action and refresh."""
await self.coordinator.client.action(self.device_id)
await self.coordinator.async_request_refresh()
```
### Pattern 3: Button with Validation
```python
class ValidatingButton(ButtonEntity):
"""Button with pre-action validation."""
async def async_press(self) -> None:
"""Validate then trigger action."""
if not self.device.is_ready:
raise HomeAssistantError("Device not ready")
await self.device.trigger_action()
```
## Best Practices
### ✅ DO
- Use appropriate device class
- Set entity category (usually CONFIG)
- Handle errors with HomeAssistantError
- Implement unique IDs
- Use translation keys
- Refresh coordinator if state changes
- Provide clear button names/translations
### ❌ DON'T
- Create buttons that track state (use switch instead)
- Poll buttons (they have no state)
- Block the event loop
- Ignore errors silently
- Create buttons without entity category
- Hardcode entity names
- Use buttons for binary controls (use switch)
## Button vs. Switch vs. Service
**Use Button when**:
- One-time action with no state
- Trigger command (reboot, identify)
- User initiates action
**Use Switch when**:
- Binary control (on/off)
- State matters
- Can be turned on and off
**Use Service when**:
- Complex parameters needed
- Multiple related actions
- Integration-wide operations
## Troubleshooting
### Button Not Appearing
Check:
- [ ] Unique ID is set
- [ ] Platform is in PLATFORMS list
- [ ] Entity is added with async_add_entities
- [ ] async_press is implemented
### Button Press Not Working
Check:
- [ ] async_press is async def
- [ ] Not blocking the event loop
- [ ] API client is working
- [ ] Errors are being raised properly
### Button Not in Expected Category
Check:
- [ ] entity_category is set
- [ ] Using correct EntityCategory value
- [ ] Device class is appropriate
## Quality Scale Considerations
- **Bronze**: Unique ID required
- **Gold**: Entity translations, device class, entity category
- **Platinum**: Full type hints
## References
- [Button Documentation](https://developers.home-assistant.io/docs/core/entity/button)
- [Button Integration](https://www.home-assistant.io/integrations/button/)

View File

@@ -0,0 +1,420 @@
# Diagnostics Reference
## Overview
Diagnostics provide a way to collect and export integration data for troubleshooting purposes. This is a **Gold tier** quality scale requirement that helps users and developers debug issues.
## When to Implement Diagnostics
Diagnostics are required for:
- ✅ Gold tier and above integrations
- ✅ Any integration where users might need support
- ✅ Integrations with complex configuration or state
## Diagnostics Types
Home Assistant supports two types of diagnostics:
### 1. Config Entry Diagnostics
Provides data about a specific configuration entry.
**File**: `diagnostics.py` in your integration folder
```python
"""Diagnostics support for My Integration."""
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
TO_REDACT = {
"api_key",
"access_token",
"refresh_token",
"password",
"username",
"email",
"latitude",
"longitude",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"entry": {
"title": entry.title,
"data": async_redact_data(entry.data, TO_REDACT),
"options": async_redact_data(entry.options, TO_REDACT),
},
"coordinator_data": coordinator.data.to_dict(),
"last_update_success": coordinator.last_update_success,
"last_update": coordinator.last_update_success_time.isoformat()
if coordinator.last_update_success_time
else None,
}
```
### 2. Device Diagnostics
Provides data about a specific device.
```python
async def async_get_device_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
device: dr.DeviceEntry,
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = entry.runtime_data
# Find device identifier
device_id = None
for identifier in device.identifiers:
if identifier[0] == DOMAIN:
device_id = identifier[1]
break
if device_id is None:
return {}
device_data = coordinator.data.devices.get(device_id)
if device_data is None:
return {}
return {
"device_info": {
"id": device_id,
"name": device_data.name,
"model": device_data.model,
"firmware": device_data.firmware_version,
},
"device_data": device_data.to_dict(),
"entities": [
{
"entity_id": entity.entity_id,
"name": entity.name,
"state": hass.states.get(entity.entity_id).state
if (state := hass.states.get(entity.entity_id))
else None,
}
for entity in er.async_entries_for_device(
er.async_get(hass), device.id, include_disabled_entities=True
)
],
}
```
## Data Redaction
**CRITICAL**: Always redact sensitive information!
### What to Redact
Always redact:
- API keys, tokens, secrets
- Passwords, credentials
- Email addresses, usernames
- Precise GPS coordinates (latitude, longitude)
- MAC addresses (sometimes)
- Serial numbers (if sensitive)
- Personal information
### Using async_redact_data
```python
from homeassistant.helpers import async_redact_data
# Basic redaction
data = async_redact_data(entry.data, TO_REDACT)
# With nested redaction
TO_REDACT = {
"api_key",
"auth.password", # Nested key
"user.email", # Nested key
}
# Redacting from multiple sources
diagnostics = {
"config": async_redact_data(entry.data, TO_REDACT),
"options": async_redact_data(entry.options, TO_REDACT),
"coordinator": async_redact_data(coordinator.data, TO_REDACT),
}
```
### Custom Redaction
For complex data structures:
```python
def redact_device_data(data: dict[str, Any]) -> dict[str, Any]:
"""Redact sensitive device data."""
redacted = data.copy()
# Redact specific fields
if "serial_number" in redacted:
redacted["serial_number"] = "**REDACTED**"
# Redact nested structures
if "location" in redacted:
redacted["location"] = {
"city": redacted["location"].get("city"),
# Don't include exact coordinates
}
return redacted
```
## What to Include
### Good Diagnostic Data
Include information helpful for troubleshooting:
- ✅ Integration version/state
- ✅ Configuration (redacted)
- ✅ Coordinator/connection status
- ✅ Device information (model, firmware)
- ✅ API response examples (redacted)
- ✅ Error states
- ✅ Entity states
- ✅ Feature flags/capabilities
### Example Comprehensive Diagnostics
```python
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
# Integration state
"integration": {
"version": coordinator.version,
"entry_id": entry.entry_id,
"title": entry.title,
"state": entry.state,
},
# Configuration (redacted)
"configuration": {
"data": async_redact_data(entry.data, TO_REDACT),
"options": async_redact_data(entry.options, TO_REDACT),
},
# Connection/Coordinator status
"coordinator": {
"last_update_success": coordinator.last_update_success,
"last_update": coordinator.last_update_success_time.isoformat()
if coordinator.last_update_success_time
else None,
"update_interval": coordinator.update_interval.total_seconds(),
"last_exception": str(coordinator.last_exception)
if coordinator.last_exception
else None,
},
# Device/System information
"devices": {
device_id: {
"name": device.name,
"model": device.model,
"firmware": device.firmware,
"features": device.supported_features,
"state": device.state,
}
for device_id, device in coordinator.data.devices.items()
},
# API information (redacted)
"api": {
"endpoint": coordinator.client.endpoint,
"authenticated": coordinator.client.is_authenticated,
"rate_limit_remaining": coordinator.client.rate_limit_remaining,
},
}
```
## Testing Diagnostics
### Test File Structure
```python
"""Test diagnostics."""
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from tests.components.my_integration import setup_integration
async def test_entry_diagnostics(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api,
) -> None:
"""Test config entry diagnostics."""
await setup_integration(hass, mock_config_entry)
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
# Verify structure
assert "entry" in diagnostics
assert "coordinator_data" in diagnostics
# Verify redaction
assert "api_key" not in str(diagnostics)
assert "password" not in str(diagnostics)
# Verify useful data is present
assert diagnostics["entry"]["title"] == "My Device"
assert diagnostics["coordinator_data"]["devices"]
async def test_device_diagnostics(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device diagnostics."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device(
identifiers={(DOMAIN, "device_id")}
)
assert device
diagnostics = await get_diagnostics_for_device(
hass, hass_client, mock_config_entry, device
)
# Verify device-specific data
assert diagnostics["device_info"]["id"] == "device_id"
assert "entities" in diagnostics
```
## Common Patterns
### Pattern 1: Coordinator-Based Integration
```python
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"coordinator": {
"last_update_success": coordinator.last_update_success,
"data": coordinator.data.to_dict(),
}
}
```
### Pattern 2: Multiple Coordinators
```python
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data = entry.runtime_data
return {
"device_coordinator": data.device_coordinator.data.to_dict(),
"status_coordinator": data.status_coordinator.data.to_dict(),
}
```
### Pattern 3: Hub with Multiple Devices
```python
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
hub = entry.runtime_data
return {
"hub": {
"connected": hub.connected,
"version": hub.version,
},
"devices": {
device_id: device.to_dict()
for device_id, device in hub.devices.items()
},
}
```
## Best Practices
### ✅ DO
- Redact all sensitive information
- Include coordinator state and update times
- Provide device/system information
- Include error messages (if present)
- Make data easily readable
- Test that redaction works
- Include API/connection status
### ❌ DON'T
- Include raw passwords, tokens, or API keys
- Include precise GPS coordinates
- Include personal information (emails, names)
- Make diagnostics too large (>1MB)
- Include binary data
- Assume all fields are present (use .get())
- Include sensitive serial numbers
## Troubleshooting
### Diagnostics Not Appearing
Check:
1. File named `diagnostics.py` in integration folder
2. Function named exactly `async_get_config_entry_diagnostics`
3. Proper import of `ConfigEntry` and `HomeAssistant`
4. Integration is loaded successfully
### Redaction Not Working
Check:
1. Using `async_redact_data` from `homeassistant.helpers`
2. Field names match exactly (case-sensitive)
3. Nested fields use dot notation: `"auth.password"`
4. TO_REDACT is a set, not a list
### Device Diagnostics Not Working
Check:
1. Device has proper identifiers
2. Function named exactly `async_get_device_diagnostics`
3. Device parameter is `dr.DeviceEntry`
4. Proper device lookup logic
## Quality Scale Considerations
Diagnostics are required for **Gold tier** integrations:
- Must implement config entry diagnostics
- Should implement device diagnostics (if applicable)
- Must redact all sensitive information
- Should provide comprehensive troubleshooting data
## References
- Quality Scale Rule: `diagnostics`
- Home Assistant Docs: [Integration Diagnostics](https://developers.home-assistant.io/docs/integration_fetching_data)
- Helper Functions: `homeassistant.helpers.redact`

View File

@@ -0,0 +1,508 @@
# Number Platform Reference
## Overview
Number entities allow users to control numeric values within a defined range. They're used for settings like volume, brightness, temperature setpoints, or any numeric configuration parameter.
## Basic Number Implementation
```python
"""Number platform for my_integration."""
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyConfigEntry
from .coordinator import MyCoordinator
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up numbers."""
coordinator = entry.runtime_data
async_add_entities(
MyNumber(coordinator, device_id)
for device_id in coordinator.data.devices
)
class MyNumber(MyEntity, NumberEntity):
"""Representation of a number."""
_attr_has_entity_name = True
_attr_translation_key = "volume"
_attr_native_min_value = 0
_attr_native_max_value = 100
_attr_native_step = 1
def __init__(self, coordinator: MyCoordinator, device_id: str) -> None:
"""Initialize the number."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}_volume"
@property
def native_value(self) -> float | None:
"""Return the current value."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.volume
return None
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self.coordinator.client.set_volume(self.device_id, int(value))
await self.coordinator.async_request_refresh()
```
## Number Properties
### Core Properties
```python
class MyNumber(NumberEntity):
"""Number with all common properties."""
# Basic identification
_attr_has_entity_name = True
_attr_translation_key = "brightness"
_attr_unique_id = "device_123_brightness"
# Value range and step
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1 # or 0.1 for decimals
# Unit of measurement
_attr_native_unit_of_measurement = PERCENTAGE # or other units
# Display mode
_attr_mode = NumberMode.SLIDER # or NumberMode.BOX, NumberMode.AUTO
# Entity category
_attr_entity_category = EntityCategory.CONFIG
@property
def native_value(self) -> float | None:
"""Return current value."""
return self.device.brightness
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self.device.set_brightness(int(value))
```
### Required Properties
```python
# Minimum value
_attr_native_min_value = 0
# Maximum value
_attr_native_max_value = 100
# Step size (precision)
_attr_native_step = 1 # Integers
_attr_native_step = 0.1 # One decimal place
_attr_native_step = 0.01 # Two decimal places
```
### Current Value
```python
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.device.current_value
# Or use attribute
_attr_native_value = 50.0
```
### Set Value Method
```python
async def async_set_native_value(self, value: float) -> None:
"""Update to new value."""
await self.device.set_value(value)
# Update state
self._attr_native_value = value
self.async_write_ha_state()
```
## Display Mode
Control how the number is displayed in the UI:
```python
from homeassistant.components.number import NumberMode
# Slider (default for ranges)
_attr_mode = NumberMode.SLIDER
# Input box (better for precise values or large ranges)
_attr_mode = NumberMode.BOX
# Auto (let HA decide based on range)
_attr_mode = NumberMode.AUTO
```
**When to use each**:
- `SLIDER`: Small ranges (0-100), settings like volume/brightness
- `BOX`: Large ranges, precise values, IDs or codes
- `AUTO`: Let Home Assistant decide (default)
## Device Class
Use device classes for proper representation:
```python
from homeassistant.components.number import NumberDeviceClass
# Common device classes
_attr_device_class = NumberDeviceClass.TEMPERATURE
_attr_device_class = NumberDeviceClass.HUMIDITY
_attr_device_class = NumberDeviceClass.VOLTAGE
_attr_device_class = NumberDeviceClass.CURRENT
_attr_device_class = NumberDeviceClass.POWER
_attr_device_class = NumberDeviceClass.BATTERY
_attr_device_class = NumberDeviceClass.DISTANCE
_attr_device_class = NumberDeviceClass.DURATION
```
## Units of Measurement
```python
from homeassistant.const import (
PERCENTAGE,
UnitOfTemperature,
UnitOfTime,
)
# Percentage (0-100)
_attr_native_unit_of_measurement = PERCENTAGE
# Temperature
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
# Time
_attr_native_unit_of_measurement = UnitOfTime.SECONDS
# Custom units
_attr_native_unit_of_measurement = "dB" # Decibels
```
## Entity Descriptions Pattern
For multiple number entities:
```python
from dataclasses import dataclass
from collections.abc import Awaitable, Callable
from homeassistant.components.number import NumberEntityDescription, NumberMode
@dataclass(frozen=True, kw_only=True)
class MyNumberDescription(NumberEntityDescription):
"""Describes a number."""
value_fn: Callable[[MyData], float | None]
set_fn: Callable[[MyClient, str, float], Awaitable[None]]
NUMBERS: tuple[MyNumberDescription, ...] = (
MyNumberDescription(
key="volume",
translation_key="volume",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.SLIDER,
entity_category=EntityCategory.CONFIG,
value_fn=lambda data: data.volume,
set_fn=lambda client, device_id, value: client.set_volume(device_id, int(value)),
),
MyNumberDescription(
key="temperature_setpoint",
translation_key="temperature_setpoint",
device_class=NumberDeviceClass.TEMPERATURE,
native_min_value=16,
native_max_value=30,
native_step=0.5,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
mode=NumberMode.SLIDER,
value_fn=lambda data: data.target_temperature,
set_fn=lambda client, device_id, value: client.set_temperature(device_id, value),
),
)
class MyNumber(MyEntity, NumberEntity):
"""Number using entity description."""
entity_description: MyNumberDescription
def __init__(
self,
coordinator: MyCoordinator,
device_id: str,
description: MyNumberDescription,
) -> None:
"""Initialize the number."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def native_value(self) -> float | None:
"""Return current value."""
if device := self.coordinator.data.devices.get(self.device_id):
return self.entity_description.value_fn(device)
return None
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self.entity_description.set_fn(
self.coordinator.client,
self.device_id,
value,
)
await self.coordinator.async_request_refresh()
```
## Value Validation
Home Assistant validates against min/max/step, but you can add custom validation:
```python
async def async_set_native_value(self, value: float) -> None:
"""Set value with custom validation."""
# Custom validation
if value % 5 != 0:
raise ValueError("Value must be multiple of 5")
await self.device.set_value(value)
await self.coordinator.async_request_refresh()
```
## State Update Patterns
### Pattern 1: Optimistic Update
```python
async def async_set_native_value(self, value: float) -> None:
"""Set value with optimistic update."""
# Update immediately
self._attr_native_value = value
self.async_write_ha_state()
try:
await self.device.set_value(value)
except DeviceError:
# Revert on error
await self.coordinator.async_request_refresh()
raise
```
### Pattern 2: Coordinator Refresh
```python
async def async_set_native_value(self, value: float) -> None:
"""Set value and refresh."""
await self.device.set_value(value)
# Get actual value from device
await self.coordinator.async_request_refresh()
```
### Pattern 3: Direct State Update
```python
async def async_set_native_value(self, value: float) -> None:
"""Set value with direct state update."""
new_value = await self.device.set_value(value)
# Device returns actual value
self._attr_native_value = new_value
self.async_write_ha_state()
```
## Testing Numbers
### Snapshot Testing
```python
"""Test numbers."""
import pytest
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_numbers(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
init_integration,
) -> None:
"""Test number entities."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
```
### Value Testing
```python
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
async def test_set_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device,
) -> None:
"""Test setting number value."""
await init_integration(hass, mock_config_entry)
# Check initial value
state = hass.states.get("number.my_device_volume")
assert state
assert state.state == "50"
assert state.attributes["min"] == 0
assert state.attributes["max"] == 100
assert state.attributes["step"] == 1
# Set new value
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: "number.my_device_volume",
ATTR_VALUE: 75,
},
blocking=True,
)
mock_device.set_volume.assert_called_once_with(75)
# Verify state updated
state = hass.states.get("number.my_device_volume")
assert state.state == "75"
```
## Common Number Types
### Volume Control
```python
class VolumeNumber(NumberEntity):
"""Volume control."""
_attr_native_min_value = 0
_attr_native_max_value = 100
_attr_native_step = 1
_attr_native_unit_of_measurement = PERCENTAGE
_attr_mode = NumberMode.SLIDER
```
### Temperature Setpoint
```python
class TemperatureNumber(NumberEntity):
"""Temperature setpoint."""
_attr_device_class = NumberDeviceClass.TEMPERATURE
_attr_native_min_value = 16.0
_attr_native_max_value = 30.0
_attr_native_step = 0.5
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_mode = NumberMode.SLIDER
```
### Duration Setting
```python
class DurationNumber(NumberEntity):
"""Duration setting."""
_attr_device_class = NumberDeviceClass.DURATION
_attr_native_min_value = 0
_attr_native_max_value = 3600
_attr_native_step = 60 # 1 minute steps
_attr_native_unit_of_measurement = UnitOfTime.SECONDS
_attr_mode = NumberMode.BOX
```
## Best Practices
### ✅ DO
- Set appropriate min/max/step values
- Use device class when available
- Use standard units
- Set display mode appropriately
- Implement unique IDs
- Use translation keys
- Mark config numbers with entity_category
- Handle value updates properly
### ❌ DON'T
- Allow invalid ranges (min > max)
- Use zero or negative step
- Block the event loop
- Ignore validation errors
- Create numbers without min/max/step
- Hardcode entity names
- Use for binary values (use switch)
- Use for selection from list (use select)
## Troubleshooting
### Number Not Appearing
Check:
- [ ] Unique ID is set
- [ ] Platform is in PLATFORMS list
- [ ] min/max/step are all set
- [ ] Entity is added with async_add_entities
### Value Not Updating
Check:
- [ ] async_set_native_value is called
- [ ] Coordinator refresh is working
- [ ] native_value returns correct value
- [ ] Value is within min/max range
### UI Shows Wrong Control Type
Check:
- [ ] mode is set correctly
- [ ] Range is appropriate for mode
- [ ] Step size is reasonable
## Quality Scale Considerations
- **Bronze**: Unique ID required
- **Gold**: Entity translations, device class, entity category
- **Platinum**: Full type hints
## References
- [Number Documentation](https://developers.home-assistant.io/docs/core/entity/number)
- [Number Integration](https://www.home-assistant.io/integrations/number/)

View File

@@ -0,0 +1,520 @@
# Select Platform Reference
## Overview
Select entities allow users to choose from a predefined list of options. They're used for settings like operation modes, presets, input sources, or any configuration with a fixed set of choices.
## Basic Select Implementation
```python
"""Select platform for my_integration."""
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyConfigEntry
from .coordinator import MyCoordinator
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up selects."""
coordinator = entry.runtime_data
async_add_entities(
MySelect(coordinator, device_id)
for device_id in coordinator.data.devices
)
class MySelect(MyEntity, SelectEntity):
"""Representation of a select."""
_attr_has_entity_name = True
_attr_translation_key = "operation_mode"
_attr_options = ["auto", "cool", "heat", "fan"]
def __init__(self, coordinator: MyCoordinator, device_id: str) -> None:
"""Initialize the select."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}_mode"
@property
def current_option(self) -> str | None:
"""Return the current selected option."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.mode
return None
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.coordinator.client.set_mode(self.device_id, option)
await self.coordinator.async_request_refresh()
```
## Select Properties
### Core Properties
```python
class MySelect(SelectEntity):
"""Select with all common properties."""
# Basic identification
_attr_has_entity_name = True
_attr_translation_key = "preset"
_attr_unique_id = "device_123_preset"
# Available options (required)
_attr_options = ["comfort", "eco", "away", "sleep"]
# Entity category
_attr_entity_category = EntityCategory.CONFIG
@property
def current_option(self) -> str | None:
"""Return current selected option."""
return self.device.preset
async def async_select_option(self, option: str) -> None:
"""Set the selected option."""
await self.device.set_preset(option)
```
### Required Properties and Methods
```python
# List of available options
_attr_options = ["option1", "option2", "option3"]
# Current selected option
@property
def current_option(self) -> str | None:
"""Return the selected option."""
return self.device.current_mode
# Or use attribute
_attr_current_option = "option1"
# Method to change option
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.device.set_option(option)
```
## Using Enums for Options
Recommended pattern for type safety:
```python
from enum import StrEnum
class OperationMode(StrEnum):
"""Operation modes."""
AUTO = "auto"
COOL = "cool"
HEAT = "heat"
FAN = "fan"
class MySelect(SelectEntity):
"""Select using enum."""
_attr_options = [mode.value for mode in OperationMode]
@property
def current_option(self) -> str | None:
"""Return current mode."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.mode
return None
async def async_select_option(self, option: str) -> None:
"""Set mode."""
# Validate option is in enum
mode = OperationMode(option)
await self.coordinator.client.set_mode(self.device_id, mode)
await self.coordinator.async_request_refresh()
```
## Entity Descriptions Pattern
For multiple select entities:
```python
from dataclasses import dataclass
from collections.abc import Awaitable, Callable
from homeassistant.components.select import SelectEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySelectDescription(SelectEntityDescription):
"""Describes a select."""
current_fn: Callable[[MyData], str | None]
select_fn: Callable[[MyClient, str, str], Awaitable[None]]
SELECTS: tuple[MySelectDescription, ...] = (
MySelectDescription(
key="mode",
translation_key="operation_mode",
options=["auto", "cool", "heat", "fan"],
entity_category=EntityCategory.CONFIG,
current_fn=lambda data: data.mode,
select_fn=lambda client, device_id, option: client.set_mode(device_id, option),
),
MySelectDescription(
key="preset",
translation_key="preset",
options=["comfort", "eco", "away", "sleep"],
entity_category=EntityCategory.CONFIG,
current_fn=lambda data: data.preset,
select_fn=lambda client, device_id, option: client.set_preset(device_id, option),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up selects."""
coordinator = entry.runtime_data
async_add_entities(
MySelect(coordinator, device_id, description)
for device_id in coordinator.data.devices
for description in SELECTS
)
class MySelect(MyEntity, SelectEntity):
"""Select using entity description."""
entity_description: MySelectDescription
def __init__(
self,
coordinator: MyCoordinator,
device_id: str,
description: MySelectDescription,
) -> None:
"""Initialize the select."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def current_option(self) -> str | None:
"""Return current option."""
if device := self.coordinator.data.devices.get(self.device_id):
return self.entity_description.current_fn(device)
return None
async def async_select_option(self, option: str) -> None:
"""Select option."""
await self.entity_description.select_fn(
self.coordinator.client,
self.device_id,
option,
)
await self.coordinator.async_request_refresh()
```
## Dynamic Options
If options change based on device state:
```python
class MyDynamicSelect(SelectEntity):
"""Select with dynamic options."""
@property
def options(self) -> list[str]:
"""Return available options based on device state."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.available_modes
return []
@property
def current_option(self) -> str | None:
"""Return current option."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.current_mode
return None
async def async_select_option(self, option: str) -> None:
"""Select option."""
await self.device.set_mode(option)
await self.coordinator.async_request_refresh()
```
## Option Translation
Use translation keys for user-friendly option labels:
```json
// strings.json
{
"entity": {
"select": {
"operation_mode": {
"name": "Operation mode",
"state": {
"auto": "Automatic",
"cool": "Cooling",
"heat": "Heating",
"fan": "Fan only"
}
}
}
}
}
```
```python
class MySelect(SelectEntity):
"""Select with translated options."""
_attr_translation_key = "operation_mode"
_attr_options = ["auto", "cool", "heat", "fan"]
```
## State Update Patterns
### Pattern 1: Optimistic Update
```python
async def async_select_option(self, option: str) -> None:
"""Select option with optimistic update."""
# Update immediately
self._attr_current_option = option
self.async_write_ha_state()
try:
await self.device.set_option(option)
except DeviceError:
# Revert on error
await self.coordinator.async_request_refresh()
raise
```
### Pattern 2: Coordinator Refresh
```python
async def async_select_option(self, option: str) -> None:
"""Select option and refresh."""
await self.device.set_option(option)
# Get actual option from device
await self.coordinator.async_request_refresh()
```
### Pattern 3: Direct State Update
```python
async def async_select_option(self, option: str) -> None:
"""Select option with direct state update."""
actual_option = await self.device.set_option(option)
# Device returns actual option
self._attr_current_option = actual_option
self.async_write_ha_state()
```
## Testing Selects
### Snapshot Testing
```python
"""Test selects."""
import pytest
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_selects(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
init_integration,
) -> None:
"""Test select entities."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
```
### Option Selection Testing
```python
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION
async def test_select_option(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device,
) -> None:
"""Test selecting an option."""
await init_integration(hass, mock_config_entry)
# Check initial state
state = hass.states.get("select.my_device_mode")
assert state
assert state.state == "auto"
assert state.attributes["options"] == ["auto", "cool", "heat", "fan"]
# Select new option
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.my_device_mode",
ATTR_OPTION: "cool",
},
blocking=True,
)
mock_device.set_mode.assert_called_once_with("cool")
# Verify state updated
state = hass.states.get("select.my_device_mode")
assert state.state == "cool"
```
## Common Select Types
### Operation Mode
```python
class ModeSelect(SelectEntity):
"""Operation mode select."""
_attr_translation_key = "operation_mode"
_attr_options = ["auto", "cool", "heat", "fan", "dry"]
_attr_entity_category = EntityCategory.CONFIG
```
### Preset
```python
class PresetSelect(SelectEntity):
"""Preset select."""
_attr_translation_key = "preset"
_attr_options = ["comfort", "eco", "away", "sleep", "boost"]
_attr_entity_category = EntityCategory.CONFIG
```
### Input Source
```python
class InputSourceSelect(SelectEntity):
"""Input source select."""
_attr_translation_key = "source"
_attr_options = ["hdmi1", "hdmi2", "usb", "bluetooth", "optical"]
```
### Effect/Scene
```python
class EffectSelect(SelectEntity):
"""Light effect select."""
_attr_translation_key = "effect"
_attr_options = ["none", "rainbow", "pulse", "strobe", "breathe"]
```
## Best Practices
### ✅ DO
- Use enums for type safety
- Provide translation keys for options
- Validate selected options
- Implement unique IDs
- Use entity_category for config selects
- Keep option lists reasonable (<20 items)
- Use consistent option naming (lowercase, underscores)
- Provide clear option translations
### ❌ DON'T
- Accept options not in the list
- Have too many options (use input_select helper instead)
- Block the event loop
- Hardcode entity names
- Change options list arbitrarily
- Use for numeric values (use number entity)
- Use for binary choices (use switch)
- Have empty options list
## Select vs. Other Entities
**Use Select when**:
- Fixed list of text options
- Modes, presets, or settings
- 2-20 options
**Use Switch when**:
- Binary on/off control
- Only 2 states
**Use Number when**:
- Numeric range
- Continuous values
**Use Input Select when**:
- User-defined options
- Need dynamic option list
- Helper/template integration
## Troubleshooting
### Select Not Appearing
Check:
- [ ] Unique ID is set
- [ ] Platform is in PLATFORMS list
- [ ] options list is not empty
- [ ] Entity is added with async_add_entities
### Option Not Accepted
Check:
- [ ] Option is in options list (case-sensitive)
- [ ] Options list is properly formatted
- [ ] async_select_option handles the option
### Options Not Translating
Check:
- [ ] translation_key is set
- [ ] strings.json has state translations
- [ ] Option keys match exactly
## Quality Scale Considerations
- **Bronze**: Unique ID required
- **Gold**: Entity translations, entity category
- **Platinum**: Full type hints, use StrEnum for options
## References
- [Select Documentation](https://developers.home-assistant.io/docs/core/entity/select)
- [Select Integration](https://www.home-assistant.io/integrations/select/)

View File

@@ -0,0 +1,560 @@
# Sensor Platform Reference
## Overview
Sensors are read-only entities that represent measurements, states, or information from devices and services. They display numeric values, strings, timestamps, or other data types.
## Basic Sensor Implementation
### Minimal Sensor
```python
"""Sensor platform for my_integration."""
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyConfigEntry
from .const import DOMAIN
from .coordinator import MyCoordinator
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinator = entry.runtime_data
async_add_entities(
MySensor(coordinator, device_id)
for device_id in coordinator.data.devices
)
class MySensor(MyEntity, SensorEntity):
"""Representation of a sensor."""
def __init__(self, coordinator: MyCoordinator, device_id: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}_temperature"
self._attr_translation_key = "temperature"
@property
def native_value(self) -> float | None:
"""Return the sensor value."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.temperature
return None
```
## Sensor Properties
### Core Properties
```python
class MySensor(SensorEntity):
"""Sensor with all common properties."""
# Basic identification
_attr_has_entity_name = True # Required
_attr_translation_key = "temperature" # For translations
_attr_unique_id = "device_123_temp" # Required
# Device class and units
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_suggested_display_precision = 1 # Decimal places
# State class for statistics
_attr_state_class = SensorStateClass.MEASUREMENT
# Entity category
_attr_entity_category = EntityCategory.DIAGNOSTIC # If diagnostic
# Availability
_attr_entity_registry_enabled_default = False # If noisy/less important
@property
def native_value(self) -> float | None:
"""Return sensor value."""
return self.device.temperature
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.device_id in self.coordinator.data
```
## Device Classes
Use device classes for proper representation:
```python
from homeassistant.components.sensor import SensorDeviceClass
# Common device classes
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_device_class = SensorDeviceClass.HUMIDITY
_attr_device_class = SensorDeviceClass.PRESSURE
_attr_device_class = SensorDeviceClass.BATTERY
_attr_device_class = SensorDeviceClass.ENERGY
_attr_device_class = SensorDeviceClass.POWER
_attr_device_class = SensorDeviceClass.VOLTAGE
_attr_device_class = SensorDeviceClass.CURRENT
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_device_class = SensorDeviceClass.MONETARY
```
Benefits:
- Automatic unit conversion
- Proper UI representation
- Voice assistant integration
- Historical statistics
## State Classes
For long-term statistics support:
```python
from homeassistant.components.sensor import SensorStateClass
# Measurement - value at a point in time
_attr_state_class = SensorStateClass.MEASUREMENT
# Examples: temperature, humidity, power
# Total - cumulative value that can increase/decrease
_attr_state_class = SensorStateClass.TOTAL
# Examples: energy consumed, data transferred
# Use with last_reset for resettable totals
# Total increasing - cumulative value that only increases
_attr_state_class = SensorStateClass.TOTAL_INCREASING
# Examples: lifetime energy, odometer
```
### When to Use State Classes
**Use MEASUREMENT for**:
- Temperature, humidity, pressure
- Current power usage
- Instantaneous values
**Use TOTAL for**:
- Daily/monthly energy consumption (resets)
- Periodic counters
**Use TOTAL_INCREASING for**:
- Lifetime energy consumption
- Monotonically increasing counters
**Don't use state class for**:
- Text/string sensors
- Status sensors (enum values)
- Non-numeric sensors
## Unit of Measurement
### Using Standard Units
```python
from homeassistant.const import (
UnitOfTemperature,
UnitOfPower,
UnitOfEnergy,
PERCENTAGE,
)
# Temperature
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
# Auto-converts to user's preference (°F/°C/K)
# Power
_attr_native_unit_of_measurement = UnitOfPower.WATT
# Energy
_attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
# Percentage
_attr_native_unit_of_measurement = PERCENTAGE
```
### Custom Units
```python
# For non-standard units
_attr_native_unit_of_measurement = "AQI" # Air Quality Index
_attr_native_unit_of_measurement = "ppm" # Parts per million
```
## Entity Descriptions Pattern
For multiple similar sensors, use SensorEntityDescription:
```python
from dataclasses import dataclass
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.helpers.typing import StateType
@dataclass(frozen=True, kw_only=True)
class MySensorDescription(SensorEntityDescription):
"""Describes a sensor."""
value_fn: Callable[[MyData], StateType]
SENSORS: tuple[MySensorDescription, ...] = (
MySensorDescription(
key="temperature",
translation_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.temperature,
),
MySensorDescription(
key="humidity",
translation_key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.humidity,
),
)
class MySensor(MyEntity, SensorEntity):
"""Sensor using entity description."""
entity_description: MySensorDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySensorDescription,
device_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
if device := self.coordinator.data.devices.get(self.device_id):
return self.entity_description.value_fn(device)
return None
```
### Lambda Functions in EntityDescription
When lambdas get long, use proper formatting:
```python
# ❌ Bad - too long
SensorEntityDescription(
key="temperature",
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None,
)
# ✅ Good - wrapped properly
SensorEntityDescription(
key="temperature",
value_fn=lambda data: (
round(data["temp_value"] * 1.8 + 32, 1)
if data.get("temp_value") is not None
else None
),
)
```
## Timestamp Sensors
For datetime values:
```python
from datetime import datetime
from homeassistant.components.sensor import SensorDeviceClass
class MyTimestampSensor(SensorEntity):
"""Timestamp sensor."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
@property
def native_value(self) -> datetime | None:
"""Return timestamp."""
return self.device.last_update
```
## Enum Sensors
For sensors with fixed set of possible values:
```python
from enum import StrEnum
from homeassistant.components.sensor import SensorEntity
class OperationMode(StrEnum):
"""Operation modes."""
AUTO = "auto"
MANUAL = "manual"
ECO = "eco"
class MyModeSensor(SensorEntity):
"""Mode sensor."""
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = [mode.value for mode in OperationMode]
@property
def native_value(self) -> str | None:
"""Return current mode."""
return self.device.mode
```
## Entity Category
Mark diagnostic or configuration sensors:
```python
from homeassistant.helpers.entity import EntityCategory
# Diagnostic sensors (technical info)
_attr_entity_category = EntityCategory.DIAGNOSTIC
# Examples: signal strength, uptime, IP address
# Config sensors (device settings)
_attr_entity_category = EntityCategory.CONFIG
# Examples: current mode setting, configuration values
```
## Disabled by Default
For noisy or less important sensors:
```python
class MySignalStrengthSensor(SensorEntity):
"""Signal strength sensor - noisy."""
_attr_entity_registry_enabled_default = False
```
## Dynamic Sensor Addition
For devices that appear after setup:
```python
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors with dynamic addition."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
@callback
def _add_new_devices() -> None:
"""Add newly discovered devices."""
current_devices = set(coordinator.data.devices.keys())
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
MySensor(coordinator, device_id)
for device_id in new_devices
)
# Initial setup
_add_new_devices()
# Listen for new devices
entry.async_on_unload(coordinator.async_add_listener(_add_new_devices))
```
## Testing Sensors
### Test with Snapshots
```python
"""Test sensors."""
import pytest
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
init_integration,
) -> None:
"""Test sensor entities."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
```
### Test Sensor Values
```python
async def test_sensor_values(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test sensor values are correct."""
await init_integration(hass, mock_config_entry)
state = hass.states.get("sensor.my_device_temperature")
assert state
assert state.state == "22.5"
assert state.attributes["unit_of_measurement"] == "°C"
assert state.attributes["device_class"] == "temperature"
```
## Best Practices
### ✅ DO
- Use device classes when available
- Set state classes for statistics
- Use standard units of measurement
- Implement unique IDs
- Use entity descriptions for similar sensors
- Mark diagnostic sensors with entity_category
- Disable noisy sensors by default
- Return None for unknown values
- Use translation keys for entity names
### ❌ DON'T
- Hardcode entity names
- Use string "unavailable" or "unknown" as state
- Mix units (always use native_unit_of_measurement)
- Create sensors without unique IDs
- Poll in sensor update if using coordinator
- Block the event loop
- Use state class for non-numeric sensors
## Common Patterns
### Pattern 1: Coordinator-Based Sensor
```python
class MySensor(CoordinatorEntity[MyCoordinator], SensorEntity):
"""Coordinator-based sensor."""
_attr_should_poll = False # Coordinator handles updates
@property
def native_value(self) -> StateType:
"""Get value from coordinator data."""
return self.coordinator.data.get(self.key)
```
### Pattern 2: Push-Updated Sensor
```python
class MyPushSensor(SensorEntity):
"""Push-updated sensor."""
_attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
self.async_on_remove(
self.device.subscribe(self._handle_update)
)
@callback
def _handle_update(self, value: float) -> None:
"""Handle push update."""
self._attr_native_value = value
self.async_write_ha_state()
```
### Pattern 3: Calculated Sensor
```python
class MyCalculatedSensor(SensorEntity):
"""Calculated from other sensors."""
_attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""Subscribe to source sensors."""
self.async_on_remove(
async_track_state_change_event(
self.hass,
["sensor.source1", "sensor.source2"],
self._handle_update,
)
)
@callback
def _handle_update(self, event: Event) -> None:
"""Recalculate when sources change."""
# Calculate new value
self._attr_native_value = self._calculate()
self.async_write_ha_state()
```
## Troubleshooting
### Sensor Not Appearing
Check:
- [ ] Unique ID is set
- [ ] Platform is in PLATFORMS list
- [ ] async_setup_entry is called
- [ ] Entity is added with async_add_entities
### Values Not Updating
Check:
- [ ] Coordinator is updating
- [ ] Entity is available
- [ ] native_value returns correct data
- [ ] should_poll is False for coordinator
### Units Not Converting
Check:
- [ ] Using standard unit constants
- [ ] Device class is set correctly
- [ ] Unit matches device class
### Statistics Not Working
Check:
- [ ] State class is set
- [ ] Values are numeric
- [ ] Device class is appropriate
- [ ] Units are consistent
## Quality Scale Considerations
- **Bronze**: Unique ID required
- **Gold**: Entity translations, device class, entity category
- **Platinum**: Full type hints
## References
- [Sensor Documentation](https://developers.home-assistant.io/docs/core/entity/sensor)
- [Device Classes](https://www.home-assistant.io/integrations/sensor/#device-class)
- [State Classes](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)

View File

@@ -0,0 +1,505 @@
# Switch Platform Reference
## Overview
Switches are entities that can be turned on or off. They represent controllable devices like smart plugs, relays, or any binary control. Unlike binary sensors, switches can be controlled by the user.
## Basic Switch Implementation
```python
"""Switch platform for my_integration."""
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyConfigEntry
from .coordinator import MyCoordinator
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches."""
coordinator = entry.runtime_data
async_add_entities(
MySwitch(coordinator, device_id)
for device_id in coordinator.data.devices
)
class MySwitch(MyEntity, SwitchEntity):
"""Representation of a switch."""
_attr_has_entity_name = True
_attr_translation_key = "outlet"
def __init__(self, coordinator: MyCoordinator, device_id: str) -> None:
"""Initialize the switch."""
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}_switch"
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.is_on
return None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.coordinator.client.turn_on(self.device_id)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.coordinator.client.turn_off(self.device_id)
await self.coordinator.async_request_refresh()
```
## Switch Properties and Methods
### Core Properties
```python
@property
def is_on(self) -> bool | None:
"""Return true if entity is on."""
return self.device.state
# Or use attribute
_attr_is_on = True # or False, or None
```
### Required Methods
```python
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.device.turn_on()
# Update state
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.device.turn_off()
# Update state
self._attr_is_on = False
self.async_write_ha_state()
```
### Optional Toggle Method
```python
async def async_toggle(self, **kwargs: Any) -> None:
"""Toggle the entity."""
# Only implement if device has native toggle
await self.device.toggle()
await self.coordinator.async_request_refresh()
```
**Note**: If `async_toggle` is not implemented, Home Assistant will use `async_turn_on`/`async_turn_off` based on current state.
## Device Class
Switches can have device classes to indicate their type:
```python
from homeassistant.components.switch import SwitchDeviceClass
_attr_device_class = SwitchDeviceClass.OUTLET
_attr_device_class = SwitchDeviceClass.SWITCH
```
Device classes:
- `OUTLET` - Smart plug/outlet
- `SWITCH` - Generic switch (default)
## State Update Patterns
### Pattern 1: Optimistic Update
For fast UI response:
```python
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
# Update state immediately (optimistic)
self._attr_is_on = True
self.async_write_ha_state()
try:
await self.coordinator.client.turn_on(self.device_id)
except DeviceError:
# Revert on error
self._attr_is_on = False
self.async_write_ha_state()
raise
```
### Pattern 2: Coordinator Refresh
Wait for actual state:
```python
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
await self.coordinator.client.turn_on(self.device_id)
# Refresh coordinator to get actual state
await self.coordinator.async_request_refresh()
```
### Pattern 3: Push Update
For push-based systems:
```python
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
# Command device
await self.device.turn_on()
# State will be updated via push event
# No need to call async_write_ha_state()
```
## Entity Descriptions Pattern
For multiple similar switches:
```python
from dataclasses import dataclass
from collections.abc import Awaitable, Callable
from homeassistant.components.switch import SwitchEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySwitchDescription(SwitchEntityDescription):
"""Describes a switch."""
is_on_fn: Callable[[MyData], bool | None]
turn_on_fn: Callable[[MyClient, str], Awaitable[None]]
turn_off_fn: Callable[[MyClient, str], Awaitable[None]]
SWITCHES: tuple[MySwitchDescription, ...] = (
MySwitchDescription(
key="outlet",
translation_key="outlet",
device_class=SwitchDeviceClass.OUTLET,
is_on_fn=lambda data: data.outlet_state,
turn_on_fn=lambda client, device_id: client.turn_on_outlet(device_id),
turn_off_fn=lambda client, device_id: client.turn_off_outlet(device_id),
),
MySwitchDescription(
key="led",
translation_key="led",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda data: data.led_enabled,
turn_on_fn=lambda client, device_id: client.enable_led(device_id),
turn_off_fn=lambda client, device_id: client.disable_led(device_id),
),
)
class MySwitch(MyEntity, SwitchEntity):
"""Switch using entity description."""
entity_description: MySwitchDescription
def __init__(
self,
coordinator: MyCoordinator,
device_id: str,
description: MySwitchDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return if switch is on."""
if device := self.coordinator.data.devices.get(self.device_id):
return self.entity_description.is_on_fn(device)
return None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
await self.entity_description.turn_on_fn(
self.coordinator.client,
self.device_id,
)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
await self.entity_description.turn_off_fn(
self.coordinator.client,
self.device_id,
)
await self.coordinator.async_request_refresh()
```
## Configuration Switches
Switches that control device settings (not physical devices):
```python
from homeassistant.helpers.entity import EntityCategory
class MyConfigSwitch(SwitchEntity):
"""Configuration switch."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "led_indicator"
@property
def is_on(self) -> bool:
"""Return if LED is enabled."""
return self.device.led_enabled
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable LED indicator."""
await self.device.set_led(True)
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable LED indicator."""
await self.device.set_led(False)
self._attr_is_on = False
self.async_write_ha_state()
```
## Error Handling
Handle errors gracefully:
```python
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on with error handling."""
try:
await self.device.turn_on()
except DeviceOfflineError as err:
# Let entity become unavailable
raise HomeAssistantError(f"Device is offline: {err}") from err
except DeviceError as err:
# Specific error
raise HomeAssistantError(f"Failed to turn on: {err}") from err
else:
self._attr_is_on = True
self.async_write_ha_state()
```
## Testing Switches
### Snapshot Testing
```python
"""Test switches."""
import pytest
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_switches(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
init_integration,
) -> None:
"""Test switch entities."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
```
### Control Testing
```python
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
async def test_switch_on_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device,
) -> None:
"""Test turning switch on and off."""
await init_integration(hass, mock_config_entry)
# Test initial state
state = hass.states.get("switch.my_device_outlet")
assert state
assert state.state == "off"
# Turn on
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.my_device_outlet"},
blocking=True,
)
mock_device.turn_on.assert_called_once()
# Check state updated
state = hass.states.get("switch.my_device_outlet")
assert state.state == "on"
# Turn off
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "switch.my_device_outlet"},
blocking=True,
)
mock_device.turn_off.assert_called_once()
state = hass.states.get("switch.my_device_outlet")
assert state.state == "off"
```
## Common Patterns
### Pattern 1: Coordinator-Based Switch
```python
class MySwitch(CoordinatorEntity[MyCoordinator], SwitchEntity):
"""Coordinator-based switch."""
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
await self.coordinator.client.turn_on(self.device_id)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
await self.coordinator.client.turn_off(self.device_id)
await self.coordinator.async_request_refresh()
@property
def is_on(self) -> bool | None:
"""Return if switch is on."""
if device := self.coordinator.data.devices.get(self.device_id):
return device.is_on
return None
```
### Pattern 2: Local State Management
```python
class MyLocalSwitch(SwitchEntity):
"""Switch with local state."""
_attr_should_poll = False
_attr_is_on = False
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
await self.device.turn_on()
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
await self.device.turn_off()
self._attr_is_on = False
self.async_write_ha_state()
```
### Pattern 3: With Additional Control
```python
class MyAdvancedSwitch(SwitchEntity):
"""Switch with timer support."""
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on with optional duration."""
duration = kwargs.get("duration") # Custom kwarg
if duration:
await self.device.turn_on_for(duration)
else:
await self.device.turn_on()
await self.coordinator.async_request_refresh()
```
## Best Practices
### ✅ DO
- Implement both turn_on and turn_off
- Update state after commands
- Handle errors properly
- Use coordinator for state management
- Implement unique IDs
- Use translation keys
- Mark config switches with entity_category
- Refresh coordinator after commands
### ❌ DON'T
- Block the event loop
- Ignore errors silently
- Create switches without unique IDs
- Mix control and sensing (use separate entities)
- Poll unnecessarily
- Hardcode entity names
- Forget to update state after commands
## Troubleshooting
### Switch Not Responding
Check:
- [ ] turn_on/turn_off methods are async
- [ ] Not blocking the event loop
- [ ] API client is working
- [ ] Errors are being raised properly
### State Not Updating
Check:
- [ ] async_write_ha_state() is called
- [ ] Coordinator refresh is working
- [ ] is_on returns correct value
- [ ] Push updates are subscribed
### Switch Appearing as Unavailable
Check:
- [ ] Device connection is working
- [ ] Coordinator update is successful
- [ ] available property returns True
- [ ] Entity is in coordinator.data
## Quality Scale Considerations
- **Bronze**: Unique ID required
- **Gold**: Entity translations, device class (if applicable)
- **Platinum**: Full type hints
## References
- [Switch Documentation](https://developers.home-assistant.io/docs/core/entity/switch)
- [Switch Integration](https://www.home-assistant.io/integrations/switch/)

View File

@@ -0,0 +1,285 @@
---
name: code-review
description: Review Home Assistant integration code for quality, best practices, and standards compliance. Use when reviewing pull requests, identifying anti-patterns, checking security vulnerabilities (OWASP), verifying async patterns, ensuring quality scale compliance, or providing comprehensive code feedback.
---
# Code Review Skill for Home Assistant Integrations
You are an expert Home Assistant code reviewer with deep knowledge of Python, async programming, Home Assistant architecture, and integration best practices.
## Review Guidelines
### What to Review
**DO review and comment on:**
- Architecture and design patterns
- Async programming correctness
- Error handling and edge cases
- Security vulnerabilities (XSS, SQL injection, command injection, etc.)
- Performance issues (blocking operations, inefficient loops)
- Code organization and clarity
- Compliance with Home Assistant patterns
- Quality scale requirements
- Missing functionality or incomplete implementations
**DO NOT comment on:**
- Missing imports (static analysis catches this)
- Code formatting (Ruff handles this)
- Minor style issues that linters catch
### Git Practices During Review
⚠️ **CRITICAL**: After review has started:
- **DO NOT amend commits**
- **DO NOT squash commits**
- **DO NOT rebase commits**
- Reviewers need to see what changed since their last review
## Key Review Areas
### 1. Async Programming Patterns
#### ✅ Good Async Patterns
```python
# Proper async I/O
data = await client.get_data()
# Using asyncio.sleep instead of time.sleep
await asyncio.sleep(5)
# Executor for blocking operations
result = await hass.async_add_executor_job(blocking_function, args)
# Gathering async operations
results = await asyncio.gather(
client.get_temp(),
client.get_humidity(),
)
# @callback for event loop safe functions
@callback
def async_update_callback(self, event):
self.async_write_ha_state()
```
#### ❌ Bad Async Patterns
```python
# Blocking operations in event loop
data = requests.get(url) # ❌ Blocks event loop
time.sleep(5) # ❌ Blocks event loop
# Awaiting in loops (use gather instead)
for device in devices:
data = await device.get_data() # ❌ Sequential, slow
# Reusing BleakClient instances
await self.client.connect() # ❌ Don't reuse BleakClient
```
### 2. Error Handling
#### ✅ Good Error Handling
```python
# Minimal try blocks, process outside
try:
data = await device.get_data()
except DeviceError as err:
_LOGGER.error("Failed to get data: %s", err)
return
# Process data outside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
# Proper exception types
try:
await client.connect()
except asyncio.TimeoutError as ex:
raise ConfigEntryNotReady(f"Timeout connecting to {host}") from ex
except AuthError as ex:
raise ConfigEntryAuthFailed("Invalid credentials") from ex
```
#### ❌ Bad Error Handling
```python
# Too much code in try block
try:
data = await device.get_data()
processed = data.get("value", 0) * 100 # ❌ Should be outside
self._attr_native_value = processed
except DeviceError:
_LOGGER.error("Failed")
# Bare exceptions in regular code
try:
data = await device.get_data()
except Exception: # ❌ Too broad (unless in config flow/background task)
_LOGGER.error("Failed")
# Wrong exception type
if end_date < start_date:
raise ValueError("Invalid dates") # ❌ Should be ServiceValidationError
```
### 3. Security Vulnerabilities
Check for OWASP Top 10 vulnerabilities:
```python
# ❌ Command Injection
os.system(f"ping {user_input}") # DANGEROUS
# ✅ Safe alternative
await hass.async_add_executor_job(
subprocess.run,
["ping", user_input],
check=True
)
# ❌ Exposing secrets in diagnostics
return {"api_key": entry.data[CONF_API_KEY]} # DANGEROUS
# ✅ Safe alternative
return async_redact_data(entry.data, {CONF_API_KEY, CONF_PASSWORD})
```
### 4. Configuration Flow Patterns
#### ✅ Good Config Flow
```python
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(self, user_input=None):
errors = {}
if user_input is not None:
try:
await self._test_connection(user_input)
except ConnectionError:
errors["base"] = "cannot_connect"
except AuthError:
errors["base"] = "invalid_auth"
except Exception: # ✅ Allowed in config flow
errors["base"] = "unknown"
else:
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=device_name,
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required(CONF_HOST): str,
vol.Required(CONF_API_KEY): str,
}),
errors=errors,
)
```
### 5. Entity Patterns
#### ✅ Good Entity Patterns
```python
class MySensor(CoordinatorEntity[MyCoordinator], SensorEntity):
_attr_has_entity_name = True
_attr_translation_key = "temperature"
def __init__(self, coordinator: MyCoordinator, device_id: str) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{device_id}_temperature"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
name=coordinator.data[device_id].name,
)
@property
def native_value(self) -> float | None:
if device_data := self.coordinator.data.get(self.device_id):
return device_data.temperature
return None
@property
def available(self) -> bool:
return super().available and self.device_id in self.coordinator.data
```
### 6. Quality Scale Compliance
Review manifest.json and quality_scale.yaml:
```json
{
"domain": "my_integration",
"name": "My Integration",
"codeowners": ["@me"],
"config_flow": true,
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "silver"
}
```
Check:
- [ ] All required Bronze rules implemented or exempted
- [ ] Rules match declared quality scale tier
- [ ] Valid exemption reasons provided
- [ ] manifest.json has all required fields
## Performance Patterns
### ✅ Good Performance
```python
# Parallel API calls
temp, humidity = await asyncio.gather(
api.get_temperature(),
api.get_humidity(),
)
# Efficient coordinator usage
PARALLEL_UPDATES = 0 # Unlimited for coordinator-based
```
### ❌ Bad Performance
```python
# Sequential API calls
temp = await api.get_temperature()
humidity = await api.get_humidity() # ❌ Should use gather
# User-configurable scan intervals
vol.Optional("scan_interval"): cv.positive_int # ❌ Not allowed
```
## Review Process
When reviewing code:
1. **Architecture Review**: Does it follow HA patterns?
2. **Code Quality**: Are async patterns correct? Is error handling comprehensive?
3. **Standards Compliance**: Quality scale requirements met?
4. **Performance & Efficiency**: No blocking operations? Efficient API usage?
5. **User Experience**: Clear error messages? Proper translations?
## Providing Feedback
Structure feedback as:
1. **Summary**: Overall assessment
2. **Critical Issues**: Must fix before merge
3. **Suggestions**: Nice-to-have improvements
4. **Positive Notes**: What's done well
Be specific with file:line references and provide code examples.
## Reference Files
For detailed patterns and best practices, see:
- `.claude/references/diagnostics.md` - Diagnostics implementation
- `.claude/references/sensor.md` - Sensor platform
- `.claude/references/binary_sensor.md` - Binary sensor platform
- `.claude/references/switch.md` - Switch platform
- `.claude/references/button.md` - Button platform
- `.claude/references/number.md` - Number platform
- `.claude/references/select.md` - Select platform

View File

@@ -1,46 +0,0 @@
---
name: github-pr-reviewer
description: Review a GitHub pull request and provide feedback comments. Use when the user says "review the current PR" or asks to review a specific PR.
---
# Review GitHub Pull Request
## Preparation:
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
- Do NOT attempt any workarounds.
- Do NOT proceed with the review.
- ALERT about the failure and WAIT for instructions.
- This is a hard requirement - no exceptions.
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.
3. Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
- Performance implications
- Security concerns
- Test coverage
- Documentation updates if needed
4. Ensure any existing review comments have been addressed.
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
## IMPORTANT:
- Just review. DO NOT make any changes
- Be constructive and specific in your comments
- Suggest improvements where appropriate
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
- No need to run tests or linters, just review the code changes.
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] Memory leak in homeassistant/components/sensor/my_sensor.py:143
- [PROBLEM] Inefficient algorithm in homeassistant/helpers/data_processing.py:87
- [SUGGESTION] Improve variable naming in homeassistant/helpers/config_validation.py:45
```

View File

@@ -1,784 +0,0 @@
---
name: Home Assistant Integration knowledge
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
---
### File Locations
- **Integration code**: `./homeassistant/components/<integration_domain>/`
- **Integration tests**: `./tests/components/<integration_domain>/`
## Integration Templates
### Standard Integration Structure
```
homeassistant/components/my_integration/
├── __init__.py # Entry point with async_setup_entry
├── manifest.json # Integration metadata and dependencies
├── const.py # Domain and constants
├── config_flow.py # UI configuration flow
├── coordinator.py # Data update coordinator (if needed)
├── entity.py # Base entity class (if shared patterns)
├── sensor.py # Sensor platform
├── strings.json # User-facing text and translations
├── services.yaml # Service definitions (if applicable)
└── quality_scale.yaml # Quality scale rule status
```
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
### Minimal Integration Checklist
- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.)
- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry`
- [ ] `config_flow.py` with UI configuration support
- [ ] `const.py` with `DOMAIN` constant
- [ ] `strings.json` with at least config flow text
- [ ] Platform files (`sensor.py`, etc.) as needed
- [ ] `quality_scale.yaml` with rule status tracking
## Integration Quality Scale
Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply:
### Quality Scale Levels
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
- **Silver**: Enhanced functionality
- **Gold**: Advanced features
- **Platinum**: Highest quality standards
### Quality Scale Progression
- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows
- **Silver → Gold**: Add device management, diagnostics, translations
- **Gold → Platinum**: Add strict typing, async dependencies, websession injection
### How Rules Apply
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
2. **Bronze Rules**: Always required for any integration with quality scale
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
- `done`: Rule implemented
- `exempt`: Rule doesn't apply (with reason in comment)
- `todo`: Rule needs implementation
### Example `quality_scale.yaml` Structure
```yaml
rules:
# Bronze (mandatory)
config-flow: done
entity-unique-id: done
action-setup:
status: exempt
comment: Integration does not register custom actions.
# Silver (if targeting Silver+)
entity-unavailable: done
parallel-updates: done
# Gold (if targeting Gold+)
devices: done
diagnostics: done
# Platinum (if targeting Platinum)
strict-typing: done
```
**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules.
## Code Organization
### Core Locations
- Shared constants: `homeassistant/const.py` (use these instead of hardcoding)
- Integration structure:
- `homeassistant/components/{domain}/const.py` - Constants
- `homeassistant/components/{domain}/models.py` - Data models
- `homeassistant/components/{domain}/coordinator.py` - Update coordinator
- `homeassistant/components/{domain}/config_flow.py` - Configuration flow
- `homeassistant/components/{domain}/{platform}.py` - Platform implementations
### Common Modules
- **coordinator.py**: Centralize data fetching logic
```python
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```
- **entity.py**: Base entity definitions to reduce duplication
```python
class MyEntity(CoordinatorEntity[MyCoordinator]):
_attr_has_entity_name = True
```
### Runtime Data Storage
- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data
```python
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
client = MyClient(entry.data[CONF_HOST])
entry.runtime_data = client
```
### Manifest Requirements
- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements`
- **Integration Types**: `device`, `hub`, `service`, `system`, `helper`
- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`)
- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb`
- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`)
### Config Flow Patterns
- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1`
- **Unique ID Management**:
```python
await self.async_set_unique_id(device_unique_id)
self._abort_if_unique_id_configured()
```
- **Error Handling**: Define errors in `strings.json` under `config.error`
- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.)
### Integration Ownership
- **manifest.json**: Add GitHub usernames to `codeowners`:
```json
{
"domain": "my_integration",
"name": "My Integration",
"codeowners": ["@me"]
}
```
### Async Dependencies (Platinum)
- **Requirement**: All dependencies must use asyncio
- Ensures efficient task handling without thread context switching
### WebSession Injection (Platinum)
- **Pass WebSession**: Support passing web sessions to dependencies
```python
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
"""Set up integration from config entry."""
client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass))
```
- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx)
### Data Update Coordinator
- **Standard Pattern**: Use for efficient data management
```python
class MyCoordinator(DataUpdateCoordinator):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
self.client = client
async def _async_update_data(self):
try:
return await self.client.fetch_data()
except ApiError as err:
raise UpdateFailed(f"API communication error: {err}")
```
- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues
- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended
## Integration Guidelines
### Configuration Flow
- **UI Setup Required**: All integrations must support configuration via UI
- **Manifest**: Set `"config_flow": true` in `manifest.json`
- **Data Storage**:
- Connection-critical config: Store in `ConfigEntry.data`
- Non-critical settings: Store in `ConfigEntry.options`
- **Validation**: Always validate user input before creating entries
- **Config Entry Naming**:
- ❌ Do NOT allow users to set config entry names in config flows
- Names are automatically generated or can be customized later in UI
- ✅ Exception: Helper integrations MAY allow custom names in config flow
- **Connection Testing**: Test device/service connection during config flow:
```python
try:
await client.get_data()
except MyException:
errors["base"] = "cannot_connect"
```
- **Duplicate Prevention**: Prevent duplicate configurations:
```python
# Using unique ID
await self.async_set_unique_id(identifier)
self._abort_if_unique_id_configured()
# Using unique data
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
```
### Reauthentication Support
- **Required Method**: Implement `async_step_reauth` in config flow
- **Credential Updates**: Allow users to update credentials without re-adding
- **Validation**: Verify account matches existing unique ID:
```python
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}
)
```
### Reconfiguration Flow
- **Purpose**: Allow configuration updates without removing device
- **Implementation**: Add `async_step_reconfigure` method
- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch`
### Device Discovery
- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.)
```json
{
"zeroconf": ["_mydevice._tcp.local."]
}
```
- **Discovery Handler**: Implement appropriate `async_step_*` method:
```python
async def async_step_zeroconf(self, discovery_info):
"""Handle zeroconf discovery."""
await self.async_set_unique_id(discovery_info.properties["serialno"])
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
```
- **Network Updates**: Use discovery to update dynamic IP addresses
### Network Discovery Implementation
- **Zeroconf/mDNS**: Use async instances
```python
aiozc = await zeroconf.async_get_async_instance(hass)
```
- **SSDP Discovery**: Register callbacks with cleanup
```python
entry.async_on_unload(
ssdp.async_register_callback(
hass, _async_discovered_device,
{"st": "urn:schemas-upnp-org:device:ZonePlayer:1"}
)
)
```
### Bluetooth Integration
- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies
- **Connectable**: Set `"connectable": true` for connection-required devices
- **Scanner Usage**: Always use shared scanner instance
```python
scanner = bluetooth.async_get_scanner()
entry.async_on_unload(
bluetooth.async_register_callback(
hass, _async_discovered_device,
{"service_uuid": "example_uuid"},
bluetooth.BluetoothScanningMode.ACTIVE
)
)
```
- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts
### Setup Validation
- **Test Before Setup**: Verify integration can be set up in `async_setup_entry`
- **Exception Handling**:
- `ConfigEntryNotReady`: Device offline or temporary failure
- `ConfigEntryAuthFailed`: Authentication issues
- `ConfigEntryError`: Unresolvable setup problems
### Config Entry Unloading
- **Required**: Implement `async_unload_entry` for runtime removal/reload
- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms`
- **Cleanup**: Register callbacks with `entry.async_on_unload`:
```python
async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry.runtime_data.listener() # Clean up resources
return unload_ok
```
### Service Actions
- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry`
- **Validation**: Check config entry existence and loaded state:
```python
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def service_action(call: ServiceCall) -> ServiceResponse:
if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])):
raise ServiceValidationError("Entry not found")
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError("Entry not loaded")
```
- **Exception Handling**: Raise appropriate exceptions:
```python
# For invalid input
if end_date < start_date:
raise ServiceValidationError("End date must be after start date")
# For service errors
try:
await client.set_schedule(start_date, end_date)
except MyConnectionError as err:
raise HomeAssistantError("Could not connect to the schedule") from err
```
### Service Registration Patterns
- **Entity Services**: Register on platform setup
```python
platform.async_register_entity_service(
"my_entity_service",
{vol.Required("parameter"): cv.string},
"handle_service_method"
)
```
- **Service Schema**: Always validate input
```python
SERVICE_SCHEMA = vol.Schema({
vol.Required("entity_id"): cv.entity_ids,
vol.Required("parameter"): cv.string,
vol.Optional("timeout", default=30): cv.positive_int,
})
```
- **Services File**: Create `services.yaml` with descriptions and field definitions
### Polling
- Use update coordinator pattern when possible
- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries
- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input
- **Minimum Intervals**:
- Local network: 5 seconds
- Cloud services: 60 seconds
- **Parallel Updates**: Specify number of concurrent updates:
```python
PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device
# OR
PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only)
```
## Entity Development
### Unique IDs
- **Required**: Every entity must have a unique ID for registry tracking
- Must be unique per platform (not per integration)
- Don't include integration domain or platform in ID
- **Implementation**:
```python
class MySensor(SensorEntity):
def __init__(self, device_id: str) -> None:
self._attr_unique_id = f"{device_id}_temperature"
```
**Acceptable ID Sources**:
- Device serial numbers
- MAC addresses (formatted using `format_mac` from device registry)
- Physical identifiers (printed/EEPROM)
- Config entry ID as last resort: `f"{entry.entry_id}-battery"`
**Never Use**:
- IP addresses, hostnames, URLs
- Device names
- Email addresses, usernames
### Entity Descriptions
- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation
- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability
- **Bad pattern**:
```python
SensorEntityDescription(
key="temperature",
name="Temperature",
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long
)
```
- **Good pattern**:
```python
SensorEntityDescription(
key="temperature",
name="Temperature",
value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda
round(data["temp_value"] * 1.8 + 32, 1)
if data.get("temp_value") is not None
else None
),
)
```
### Entity Naming
- **Use has_entity_name**: Set `_attr_has_entity_name = True`
- **For specific fields**:
```python
class MySensor(SensorEntity):
_attr_has_entity_name = True
def __init__(self, device: Device, field: str) -> None:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
)
self._attr_name = field # e.g., "temperature", "humidity"
```
- **For device itself**: Set `_attr_name = None`
### Event Lifecycle Management
- **Subscribe in `async_added_to_hass`**:
```python
async def async_added_to_hass(self) -> None:
"""Subscribe to events."""
self.async_on_remove(
self.client.events.subscribe("my_event", self._handle_event)
)
```
- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove`
- Never subscribe in `__init__` or other methods
### State Handling
- Unknown values: Use `None` (not "unknown" or "unavailable")
- Availability: Implement `available()` property instead of using "unavailable" state
### Entity Availability
- **Mark Unavailable**: When data cannot be fetched from device/service
- **Coordinator Pattern**:
```python
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.identifier in self.coordinator.data
```
- **Direct Update Pattern**:
```python
async def async_update(self) -> None:
"""Update entity."""
try:
data = await self.client.get_data()
except MyException:
self._attr_available = False
else:
self._attr_available = True
self._attr_native_value = data.value
```
### Extra State Attributes
- All attribute keys must always be present
- Unknown values: Use `None`
- Provide descriptive attributes
## Device Management
### Device Registry
- **Create Devices**: Group related entities under devices
- **Device Info**: Provide comprehensive metadata:
```python
_attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, device.mac)},
identifiers={(DOMAIN, device.id)},
name=device.name,
manufacturer="My Company",
model="My Sensor",
sw_version=device.version,
)
```
- For services: Add `entry_type=DeviceEntryType.SERVICE`
### Dynamic Device Addition
- **Auto-detect New Devices**: After initial setup
- **Implementation Pattern**:
```python
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices])
entry.async_on_unload(coordinator.async_add_listener(_check_device))
```
### Stale Device Removal
- **Auto-remove**: When devices disappear from hub/account
- **Device Registry Update**:
```python
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
```
- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed
### Entity Categories
- **Required**: Assign appropriate category to entities
- **Implementation**: Set `_attr_entity_category`
```python
class MySensor(SensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
```
- Categories include: `DIAGNOSTIC` for system/technical information
### Device Classes
- **Use When Available**: Set appropriate device class for entity type
```python
class MyTemperatureSensor(SensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
```
- Provides context for: unit conversion, voice control, UI representation
### Disabled by Default
- **Disable Noisy/Less Popular Entities**: Reduce resource usage
```python
class MySignalStrengthSensor(SensorEntity):
_attr_entity_registry_enabled_default = False
```
- Target: frequently changing states, technical diagnostics
### Entity Translations
- **Required with has_entity_name**: Support international users
- **Implementation**:
```python
class MySensor(SensorEntity):
_attr_has_entity_name = True
_attr_translation_key = "phase_voltage"
```
- Create `strings.json` with translations:
```json
{
"entity": {
"sensor": {
"phase_voltage": {
"name": "Phase voltage"
}
}
}
}
```
### Exception Translations (Gold)
- **Translatable Errors**: Use translation keys for user-facing exceptions
- **Implementation**:
```python
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_date_before_start_date",
)
```
- Add to `strings.json`:
```json
{
"exceptions": {
"end_date_before_start_date": {
"message": "The end date cannot be before the start date."
}
}
}
```
### Icon Translations (Gold)
- **Dynamic Icons**: Support state and range-based icon selection
- **State-based Icons**:
```json
{
"entity": {
"sensor": {
"tree_pollen": {
"default": "mdi:tree",
"state": {
"high": "mdi:tree-outline"
}
}
}
}
}
```
- **Range-based Icons** (for numeric values):
```json
{
"entity": {
"sensor": {
"battery_level": {
"default": "mdi:battery-unknown",
"range": {
"0": "mdi:battery-outline",
"90": "mdi:battery-90",
"100": "mdi:battery"
}
}
}
}
}
```
## Testing Requirements
- **Location**: `tests/components/{domain}/`
- **Coverage Requirement**: Above 95% test coverage for all modules
- **Best Practices**:
- Use pytest fixtures from `tests.common`
- Mock all external dependencies
- Use snapshots for complex data structures
- Follow existing test patterns
### Config Flow Testing
- **100% Coverage Required**: All config flow paths must be tested
- **Test Scenarios**:
- All flow initiation methods (user, discovery, import)
- Successful configuration paths
- Error recovery scenarios
- Prevention of duplicate entries
- Flow completion after errors
### Testing
- **Integration-specific tests** (recommended):
```bash
pytest ./tests/components/<integration_domain> \
--cov=homeassistant.components.<integration_domain> \
--cov-report term-missing \
--durations-min=1 \
--durations=0 \
--numprocesses=auto
```
### Testing Best Practices
- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead
- **Use snapshot testing** - For verifying entity states and attributes
- **Test through integration setup** - Don't test entities in isolation
- **Mock external APIs** - Use fixtures with realistic JSON data
- **Verify registries** - Ensure entities are properly registered with devices
### Config Flow Testing Template
```python
async def test_user_flow_success(hass, mock_api):
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
# Test form submission
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "My Device"
assert result["data"] == TEST_USER_INPUT
async def test_flow_connection_error(hass, mock_api_error):
"""Test connection error handling."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
```
### Entity Testing Patterns
```python
@pytest.fixture
def platforms() -> list[Platform]:
"""Overridden fixture to specify platforms to test."""
return [Platform.SENSOR] # Or another specific platform as needed.
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# Ensure entities are correctly assigned to device
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "device_unique_id")}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
```
### Mock Patterns
```python
# Modern integration fixture setup
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="My Integration",
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
unique_id="device_unique_id",
)
@pytest.fixture
def mock_device_api() -> Generator[MagicMock]:
"""Return a mocked device API."""
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
api = api_mock.return_value
api.get_data.return_value = MyDeviceData.from_json(
load_fixture("device_data.json", DOMAIN)
)
yield api
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return PLATFORMS
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device_api: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
```
## Debugging & Troubleshooting
### Common Issues & Solutions
- **Integration won't load**: Check `manifest.json` syntax and required fields
- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation
- **Config flow errors**: Check `strings.json` entries and error handling
- **Discovery not working**: Verify manifest discovery configuration and callbacks
- **Tests failing**: Check mock setup and async context
### Debug Logging Setup
```python
# Enable debug logging in tests
caplog.set_level(logging.DEBUG, logger="my_integration")
# In integration code - use proper logging
_LOGGER = logging.getLogger(__name__)
_LOGGER.debug("Processing data: %s", data) # Use lazy logging
```
### Validation Commands
```bash
# Check specific integration
python -m script.hassfest --integration-path homeassistant/components/my_integration
# Validate quality scale
# Check quality_scale.yaml against current rules
# Run integration tests with coverage
pytest ./tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing
```

View File

@@ -1,19 +0,0 @@
# Integration Diagnostics
Platform exists as `homeassistant/components/<domain>/diagnostics.py`.
- **Required**: Implement diagnostic data collection
- **Implementation**:
```python
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": entry.runtime_data.data,
}
```
- **Security**: Never expose passwords, tokens, or sensitive coordinates

View File

@@ -1,55 +0,0 @@
# Repairs platform
Platform exists as `homeassistant/components/<domain>/repairs.py`.
- **Actionable Issues Required**: All repair issues must be actionable for end users
- **Issue Content Requirements**:
- Clearly explain what is happening
- Provide specific steps users need to take to resolve the issue
- Use friendly, helpful language
- Include relevant context (device names, error details, etc.)
- **Implementation**:
```python
ir.async_create_issue(
hass,
DOMAIN,
"outdated_version",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.ERROR,
translation_key="outdated_version",
)
```
- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`:
```json
{
"issues": {
"outdated_version": {
"title": "Device firmware is outdated",
"description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant."
}
}
}
```
- **String Content Must Include**:
- What the problem is
- Why it matters
- Exact steps to resolve (numbered list when multiple steps)
- What to expect after following the steps
- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps
- **Severity Guidelines**:
- `CRITICAL`: Reserved for extreme scenarios only
- `ERROR`: Requires immediate user attention
- `WARNING`: Indicates future potential breakage
- **Additional Attributes**:
```python
ir.async_create_issue(
hass, DOMAIN, "issue_id",
breaks_in_ha_version="2024.1.0",
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key="issue_description",
)
```
- Only create issues for problems users can potentially resolve

View File

@@ -0,0 +1,297 @@
---
name: quality-scale-architect
description: Provide architectural guidance and quality scale oversight for Home Assistant integrations. Use when designing integration structure, selecting quality tiers (Bronze/Silver/Gold/Platinum), recommending architectural patterns (coordinator/push/hub), planning quality progression, or advising on integration organization.
---
# Quality Scale Architect for Home Assistant Integrations
You are an expert Home Assistant integration architect specializing in quality scale systems, best practices, and architectural patterns.
## Quality Scale System
### Quality Scale Tiers
**Bronze** - Basic Requirements (Mandatory for all integrations with quality scale)
- ✅ Config flow (UI configuration)
- ✅ Entity unique IDs
- ✅ Action setup (or exempt)
- ✅ Appropriate setup retries
- ✅ Reauthentication flow
- ✅ Reconfigure flow
- ✅ Test coverage
**Silver** - Enhanced Functionality
- All Bronze requirements +
- ✅ Entity unavailable tracking
- ✅ Parallel updates configuration
- ✅ Runtime data storage
- ✅ Unique config entry titles
**Gold** - Advanced Features
- All Silver requirements +
- ✅ Device registry usage
- ✅ Integration diagnostics
- ✅ Device diagnostics
- ✅ Entity category
- ✅ Device class
- ✅ Disabled by default (for noisy entities)
- ✅ Entity translations
- ✅ Exception translations
- ✅ Icon translations
**Platinum** - Highest Quality Standards
- All Gold requirements +
- ✅ Strict typing (full type hints)
- ✅ Async dependencies (no sync-blocking libs)
- ✅ WebSession injection
- ✅ config_entry parameter in coordinator
## Architectural Patterns
### Pattern 1: Coordinator-Based Architecture
**Use when**: Polling multiple entities from the same API
```python
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(
self,
hass: HomeAssistant,
client: MyClient,
config_entry: ConfigEntry,
) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
config_entry=config_entry, # ✅ Pass for Platinum
)
self.client = client
async def _async_update_data(self) -> MyData:
try:
return await self.client.fetch_data()
except ApiError as err:
raise UpdateFailed(f"Error: {err}") from err
```
### Pattern 2: Push-Based Architecture
**Use when**: Device pushes updates (webhooks, MQTT, WebSocket)
```python
class MyEntity(SensorEntity):
async def async_added_to_hass(self) -> None:
self.async_on_remove(
self.hub.subscribe_updates(self._handle_update)
)
@callback
def _handle_update(self, data: dict) -> None:
self._attr_native_value = data["value"]
self.async_write_ha_state()
```
### Pattern 3: Hub with Discovery
**Use when**: Hub device with multiple discoverable endpoints
```python
@callback
def _check_new_devices() -> None:
"""Check for new devices."""
current = set(coordinator.data.devices.keys())
new = current - known_devices
if new:
known_devices.update(new)
async_dispatcher_send(hass, f"{DOMAIN}_new_device", new)
entry.async_on_unload(coordinator.async_add_listener(_check_new_devices))
```
## Architectural Decision Guide
### Choosing Integration Type
**Device Integration** (`"integration_type": "device"`)
- Physical or virtual devices
- Example: Smart plugs, thermostats, cameras
**Hub Integration** (`"integration_type": "hub"`)
- Central hub controlling multiple devices
- Example: Philips Hue bridge, Z-Wave controller
**Service Integration** (`"integration_type": "service"`)
- Cloud services, APIs
- Example: Weather services, notification platforms
**Helper Integration** (`"integration_type": "helper"`)
- Utility integrations
- Example: Template, group, automation helpers
### Choosing IoT Class
```json
{
"iot_class": "cloud_polling", // API polling
"iot_class": "cloud_push", // Cloud webhooks/MQTT
"iot_class": "local_polling", // Local device polling
"iot_class": "local_push", // Local device push
"iot_class": "calculated" // No external data
}
```
## Quality Scale Progression Strategy
### Starting Bronze (Minimum Viable Integration)
**Essential Components**:
```
homeassistant/components/my_integration/
├── __init__.py # async_setup_entry, async_unload_entry
├── manifest.json # Required fields, quality_scale: "bronze"
├── const.py # DOMAIN constant
├── config_flow.py # UI configuration with reauth/reconfigure
├── sensor.py # Platform with unique IDs
├── strings.json # Translations
└── quality_scale.yaml # Rule tracking
tests/components/my_integration/
├── conftest.py # Test fixtures
├── test_config_flow.py # 100% coverage
└── test_sensor.py # Entity tests
```
**Bronze Checklist**:
- [ ] Config flow with UI setup
- [ ] Reauthentication flow
- [ ] Reconfigure flow
- [ ] All entities have unique IDs
- [ ] Proper setup error handling
- [ ] >95% test coverage
- [ ] 100% config flow coverage
### Progressing to Silver
**Add**:
- Entity unavailability tracking
- Runtime data storage (not hass.data)
- Parallel updates configuration
- Unique entry titles
```python
# Store in runtime_data (Silver requirement)
entry.runtime_data = coordinator
# Entity availability (Silver requirement)
@property
def available(self) -> bool:
return super().available and self.device_id in self.coordinator.data
# Parallel updates (Silver requirement)
PARALLEL_UPDATES = 0 # For coordinator-based
```
### Progressing to Gold
**Add**:
- Device registry entries
- Integration & device diagnostics
- Entity categories, device classes
- Entity translations
- Exception translations
- Icon translations
```python
# Device info (Gold requirement)
_attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
manufacturer="Manufacturer",
model="Model",
)
# Diagnostics (Gold requirement)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: MyConfigEntry,
) -> dict[str, Any]:
return {
"data": async_redact_data(entry.data, TO_REDACT),
"runtime": entry.runtime_data.to_dict(),
}
```
### Progressing to Platinum
**Add**:
- Comprehensive type hints (py.typed)
- Async-only dependencies
- WebSession injection support
```python
# Type hints (Platinum requirement)
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
# WebSession injection (Platinum requirement)
client = MyClient(
host=entry.data[CONF_HOST],
session=async_get_clientsession(hass),
)
# Pass config_entry to coordinator (Platinum requirement)
coordinator = MyCoordinator(hass, client, entry)
```
## Common Architectural Questions
### Q: Should I use a coordinator?
**Use coordinator when**:
- Polling API for multiple entities
- Want efficient data sharing
- Need coordinated updates
**Don't use coordinator when**:
- Push-based updates (use callbacks)
- Single entity integration
- Each entity has independent data source
### Q: Where should I store runtime data?
```python
# ✅ GOOD - Use runtime_data (Silver+)
entry.runtime_data = coordinator
# ❌ BAD - Don't use hass.data
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
```
### Q: When should I create devices vs. just entities?
**Create devices when**:
- Representing physical/virtual devices
- Multiple entities belong to same device
- Want grouped device management
**Just entities when**:
- Service integration (no physical device)
- Single entity integration
- Calculated/helper entities
## Reference Files
For detailed implementation guidance, see:
- `.claude/references/diagnostics.md` - Diagnostics implementation
- `.claude/references/sensor.md` - Sensor platform
- `.claude/references/binary_sensor.md` - Binary sensor platform
- `.claude/references/switch.md` - Switch platform
- `.claude/references/button.md` - Button platform
- `.claude/references/number.md` - Number platform
- `.claude/references/select.md` - Select platform
## Your Task
When providing architectural guidance:
1. **Understand Requirements**: What is the integration type? What data needs exposure? Polling or push? What quality tier?
2. **Recommend Architecture**: Suggest appropriate patterns, identify required components, explain decisions
3. **Quality Scale Guidance**: Recommend starting tier, identify applicable rules, suggest progression path
4. **Implementation Plan**: Outline file structure, identify key components, suggest implementation order
5. **Best Practices**: Performance considerations, maintainability tips, common pitfalls to avoid

View File

@@ -0,0 +1,205 @@
---
name: testing
description: Write, run, and fix tests for Home Assistant integrations. Use when writing comprehensive test coverage (>95%), running pytest, fixing failing tests, updating snapshots, or following HA testing patterns. Specializes in modern fixture patterns, config flow testing (100% coverage), entity snapshot testing, and mocking external APIs.
---
# Testing Skill for Home Assistant Integrations
You are an expert Home Assistant integration test engineer specializing in writing comprehensive, maintainable tests that follow Home Assistant conventions and best practices.
## Testing Standards
### Coverage Requirements
- **Minimum Coverage**: 95% for all modules
- **Config Flow**: 100% coverage required for all paths
- **Location**: Tests go in `tests/components/{domain}/`
### Test File Organization
```
tests/components/my_integration/
├── __init__.py
├── conftest.py # Fixtures and test setup
├── test_config_flow.py # Config flow tests (100% coverage)
├── test_sensor.py # Sensor platform tests
├── test_init.py # Integration setup tests
└── snapshots/ # Generated snapshot files
```
## Modern Fixture Setup Pattern
Always use this pattern for integration tests:
```python
from homeassistant.core import HomeAssistant
from homeassistant.const import Platform
from pytest_homeassistant_custom_component.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="My Integration",
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
unique_id="device_unique_id",
)
@pytest.fixture
def mock_device_api() -> Generator[MagicMock]:
"""Return a mocked device API."""
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
api = api_mock.return_value
api.get_data.return_value = MyDeviceData.from_json(
load_fixture("device_data.json", DOMAIN)
)
yield api
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.SENSOR]
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device_api: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
```
## Entity Testing with Snapshots
Use snapshot testing for entity verification:
```python
from syrupy import SnapshotAssertion
from homeassistant.helpers import entity_registry as er, device_registry as dr
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# Verify entities are assigned to device
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "device_unique_id")}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
```
## Config Flow Testing (100% Coverage Required)
Test ALL paths in config flow:
```python
async def test_user_flow_success(hass, mock_api):
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "My Device"
assert result["data"] == TEST_USER_INPUT
async def test_flow_connection_error(hass, mock_api_error):
"""Test connection error handling."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_flow_duplicate_entry(hass, mock_config_entry, mock_api):
"""Test duplicate entry prevention."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
```
## Running Tests
### Integration-Specific Tests (Recommended)
```bash
pytest ./tests/components/<integration_domain> \
--cov=homeassistant.components.<integration_domain> \
--cov-report term-missing \
--durations-min=1 \
--durations=0 \
--numprocesses=auto
```
### Quick Test of Changed Files
```bash
pytest --timeout=10 --picked
```
### Update Test Snapshots
```bash
pytest ./tests/components/<integration_domain> --snapshot-update
```
**⚠️ IMPORTANT**: After using `--snapshot-update`:
1. Run tests again WITHOUT the flag to verify snapshots
2. Review the snapshot changes carefully
3. Don't commit snapshot updates without verification
## Critical Testing Rules
### NEVER Do These Things
- ❌ Don't access `hass.data` directly in tests
- ❌ Don't test entities in isolation without integration setup
- ❌ Don't forget to mock external dependencies
### ALWAYS Do These Things
- ✅ Use proper integration setup through fixtures
- ✅ Mock all external APIs
- ✅ Test through the integration's public interface
- ✅ Use snapshot testing for entities
- ✅ Achieve 100% config flow coverage
- ✅ Achieve >95% overall coverage
## Reference Files
For detailed implementation guidance, see:
- `.claude/references/sensor.md` - Sensor platform patterns
- `.claude/references/binary_sensor.md` - Binary sensor patterns
- `.claude/references/switch.md` - Switch platform patterns
- `.claude/references/button.md` - Button platform patterns
- `.claude/references/number.md` - Number platform patterns
- `.claude/references/select.md` - Select platform patterns

View File

@@ -22,7 +22,6 @@ base_platforms: &base_platforms
- homeassistant/components/calendar/**
- homeassistant/components/camera/**
- homeassistant/components/climate/**
- homeassistant/components/conversation/**
- homeassistant/components/cover/**
- homeassistant/components/date/**
- homeassistant/components/datetime/**
@@ -34,7 +33,6 @@ base_platforms: &base_platforms
- homeassistant/components/humidifier/**
- homeassistant/components/image/**
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
@@ -55,7 +53,6 @@ base_platforms: &base_platforms
- homeassistant/components/update/**
- homeassistant/components/vacuum/**
- homeassistant/components/valve/**
- homeassistant/components/wake_word/**
- homeassistant/components/water_heater/**
- homeassistant/components/weather/**
@@ -73,6 +70,7 @@ components: &components
- homeassistant/components/cloud/**
- homeassistant/components/config/**
- homeassistant/components/configurator/**
- homeassistant/components/conversation/**
- homeassistant/components/demo/**
- homeassistant/components/device_automation/**
- homeassistant/components/dhcp/**
@@ -93,7 +91,6 @@ components: &components
- homeassistant/components/input_number/**
- homeassistant/components/input_select/**
- homeassistant/components/input_text/**
- homeassistant/components/labs/**
- homeassistant/components/logbook/**
- homeassistant/components/logger/**
- homeassistant/components/lovelace/**

View File

@@ -8,6 +8,9 @@
"PYTHONASYNCIODEBUG": "1"
},
"features": {
// Node feature required for Claude Code until fixed https://github.com/anthropics/devcontainer-features/issues/28
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// Port 5683 udp is used by Shelly integration
@@ -37,8 +40,7 @@
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
"python.analysis.typeCheckingMode": "basic",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
@@ -60,13 +62,7 @@
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"json.schemas": [

View File

@@ -1 +0,0 @@
../.claude/skills

View File

@@ -80,7 +80,7 @@ If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running: `python3 -m script.hassfest`.
- [ ] New or updated dependencies have been added to `requirements_all.txt`.
Updated by running `python3 -m script.gen_requirements_all`.
- [ ] For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.
- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.
<!--
This project is very active and we have a high turnover of pull requests.

File diff suppressed because it is too large Load Diff

View File

@@ -9,5 +9,3 @@ updates:
labels:
- dependency
- github_actions
cooldown:
default-days: 7

View File

@@ -10,26 +10,19 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.01.0"
BASE_IMAGE_VERSION: "2025.12.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
init:
name: Initialize build
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
outputs:
version: ${{ steps.version.outputs.version }}
channel: ${{ steps.version.outputs.channel }}
@@ -37,27 +30,25 @@ jobs:
architectures: ${{ env.ARCHITECTURES }}
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version-file: ".python-version"
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]
uses: home-assistant/actions/helpers/info@master
- name: Get version
id: version
uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses]
uses: home-assistant/actions/helpers/version@master
with:
type: ${{ env.BUILD_TYPE }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
uses: home-assistant/actions/helpers/verify-version@master
with:
ignore-dev: true
@@ -79,7 +70,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: translations
path: translations.tar.gz
@@ -91,9 +82,9 @@ jobs:
needs: init
runs-on: ${{ matrix.os }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
@@ -105,13 +96,11 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -122,7 +111,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -131,23 +120,22 @@ jobs:
workflow_conclusion: success
name: package
- name: Set up Python
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version-file: ".python-version"
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Adjust nightly version
if: needs.init.outputs.channel == 'dev'
shell: bash
env:
UV_PRERELEASE: allow
VERSION: ${{ needs.init.outputs.version }}
run: |
python3 -m pip install "$(grep '^uv' < requirements.txt)"
uv pip install packaging tomli
uv pip install .
python3 script/version_bump.py nightly --set-nightly-version "${VERSION}"
python3 script/version_bump.py nightly --set-nightly-version "${{ needs.init.outputs.version }}"
if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then
echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}"
@@ -177,11 +165,11 @@ jobs:
sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
homeassistant/package_constraints.txt
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
sed -i "s|home-assistant-intents==.*||" requirements_all.txt
fi
- name: Download translations
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: translations
@@ -193,16 +181,17 @@ jobs:
- name: Write meta info file
shell: bash
run: |
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Cosign
- &install_cosign
name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
@@ -213,36 +202,30 @@ jobs:
- name: Build variables
id: vars
shell: bash
env:
ARCH: ${{ matrix.arch }}
run: |
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
echo "base_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ env.BASE_IMAGE_VERSION }}" >> "$GITHUB_OUTPUT"
echo "cache_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:latest" >> "$GITHUB_OUTPUT"
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
- name: Verify base image signature
env:
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
"${BASE_IMAGE}"
"${{ steps.vars.outputs.base_image }}"
- name: Verify cache image signature
id: cache
continue-on-error: true
env:
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
"${CACHE_IMAGE}"
"${{ steps.vars.outputs.cache_image }}"
- name: Build base image
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
@@ -252,7 +235,6 @@ jobs:
build-args: |
BUILD_FROM=${{ steps.vars.outputs.base_image }}
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
labels: |
io.hass.arch=${{ matrix.arch }}
io.hass.version=${{ needs.init.outputs.version }}
@@ -260,22 +242,18 @@ jobs:
org.opencontainers.image.version=${{ needs.init.outputs.version }}
- name: Sign image
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.init.outputs.version }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
cosign sign --yes "ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}@${{ steps.build.outputs.digest }}"
build_machine:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ${{ matrix.runs-on }}
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
contents: read
packages: write
id-token: write
strategy:
matrix:
machine:
@@ -293,51 +271,32 @@ jobs:
- raspberrypi5-64
- yellow
- green
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
runs-on: ubuntu-24.04-arm
# Overrides for amd64 machines
- machine: generic-x86-64
arch: amd64
runs-on: ubuntu-24.04
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set build additional args
env:
VERSION: ${{ needs.init.outputs.version }}
run: |
# Create general tags
if [[ "${VERSION}" =~ d ]]; then
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
elif [[ "${VERSION}" =~ b ]]; then
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
else
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# home-assistant/builder doesn't support sha pinning
- name: Build base image
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
uses: home-assistant/builder@2025.11.0
with:
image: ${{ matrix.arch }}
args: |
$BUILD_ARGS \
--target /data/machine \
@@ -350,23 +309,19 @@ jobs:
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
uses: home-assistant/actions/helpers/git-init@master
with:
name: ${{ secrets.GIT_NAME }}
email: ${{ secrets.GIT_EMAIL }}
token: ${{ secrets.GIT_TOKEN }}
- name: Update version file
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
uses: home-assistant/actions/helpers/version-push@master
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
@@ -376,7 +331,7 @@ jobs:
- name: Update version file (stable -> beta)
if: needs.init.outputs.channel == 'stable'
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
uses: home-assistant/actions/helpers/version-push@master
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
@@ -391,28 +346,25 @@ jobs:
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
- *install_cosign
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -420,17 +372,14 @@ jobs:
- name: Verify architecture image signatures
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
for arch in $ARCHS; do
echo "Verifying ${arch} image signature..."
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
done
echo "✓ All images verified successfully"
@@ -461,19 +410,16 @@ jobs:
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
# Use imagetools to copy image blobs directly between registries
# This preserves provenance/attestations and seems to be much faster than pull/push
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
for arch in $ARCHS; do
echo "Copying ${arch} image to DockerHub..."
for attempt in 1 2 3; do
if docker buildx imagetools create \
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"; then
break
fi
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
@@ -483,28 +429,23 @@ jobs:
exit 1
fi
done
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
done
- name: Create and push multi-arch manifests
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
REGISTRY: ${{ matrix.registry }}
VERSION: ${{ needs.init.outputs.version }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
# Build list of architecture images dynamically
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
ARCH_IMAGES=()
for arch in $ARCHS; do
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
ARCH_IMAGES+=("${{ matrix.registry }}/${arch}-homeassistant:${{ needs.init.outputs.version }}")
done
# Build list of all tags for single manifest creation
# Note: Using sep-tags=',' in metadata-action for easier parsing
TAG_ARGS=()
IFS=',' read -ra TAGS <<< "${META_TAGS}"
IFS=',' read -ra TAGS <<< "${{ steps.meta.outputs.tags }}"
for tag in "${TAGS[@]}"; do
TAG_ARGS+=("--tag" "${tag}")
done
@@ -528,22 +469,20 @@ jobs:
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
id-token: write # For PyPI trusted publishing
contents: read
id-token: write
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version-file: ".python-version"
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: translations
@@ -569,10 +508,10 @@ jobs:
name: Build and test hassfest image
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
attestations: write # For build provenance attestation
id-token: write # For build provenance attestation
contents: read
packages: write
attestations: write
id-token: write
needs: ["init"]
if: github.repository_owner == 'home-assistant'
env:
@@ -580,19 +519,17 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -600,12 +537,12 @@ jobs:
tags: ${{ env.HASSFEST_IMAGE_TAG }}
- name: Run hassfest against core
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
run: docker run --rm -v ${{ github.workspace }}:/github/workspace ${{ env.HASSFEST_IMAGE_TAG }} --core-path=/github/workspace
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -614,7 +551,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,6 @@ on:
schedule:
- cron: "30 18 * * 4"
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -17,22 +15,20 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 360
permissions:
actions: read # To read workflow information for CodeQL
contents: read # To check out the repository
security-events: write # To upload CodeQL results
actions: read
contents: read
security-events: write
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: "/language:python"

View File

@@ -5,18 +5,13 @@ on:
issues:
types: [labeled]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
permissions:
issues: write
models: read
jobs:
detect-duplicates:
name: Detect duplicate issues
runs-on: ubuntu-latest
permissions:
issues: write # To comment on and label issues
models: read # For AI-based duplicate detection
steps:
- name: Check if integration label was added and extract details
@@ -236,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
with:
model: openai/gpt-4o
system-prompt: |

View File

@@ -5,18 +5,13 @@ on:
issues:
types: [opened]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
permissions:
issues: write
models: read
jobs:
detect-language:
name: Detect non-English issues
runs-on: ubuntu-latest
permissions:
issues: write # To comment on, label, and close issues
models: read # For AI-based language detection
steps:
- name: Check issue language
@@ -62,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
with:
model: openai/gpt-4o-mini
system-prompt: |

View File

@@ -5,20 +5,10 @@ on:
schedule:
- cron: "0 * * * *"
permissions: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
lock:
name: Lock inactive threads
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
permissions:
issues: write # To lock issues
pull-requests: write # To lock pull requests
steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:

View File

@@ -4,7 +4,7 @@
"owner": "check-executables-have-shebangs",
"pattern": [
{
"regexp": "^(.+):\\s(marked executable but has no \\(or invalid\\) shebang!.*)$",
"regexp": "^(.+):\\s(.+)$",
"file": 1,
"message": 2
}

View File

@@ -5,39 +5,9 @@ on:
issues:
types: [opened]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
jobs:
add-no-stale:
name: Add no-stale label
runs-on: ubuntu-latest
permissions:
issues: write # To add labels to issues
if: >-
github.event.issue.type.name == 'Task'
|| github.event.issue.type.name == 'Epic'
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Add no-stale label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['no-stale']
});
check-authorization:
name: Check authorization
runs-on: ubuntu-latest
permissions:
contents: read # To read CODEOWNERS file
issues: write # To comment on, label, and close issues
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task'
steps:

View File

@@ -6,20 +6,10 @@ on:
- cron: "0 * * * *"
workflow_dispatch:
permissions: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
stale:
name: Mark stale issues and PRs
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
permissions:
issues: write # To label and close stale issues
pull-requests: write # To label and close stale PRs
steps:
# The 60 day stale policy for PRs
# Used for:
@@ -27,7 +17,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
@@ -67,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
@@ -97,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"

View File

@@ -9,11 +9,8 @@ on:
paths:
- "**strings.json"
permissions: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
env:
DEFAULT_PYTHON: "3.13"
jobs:
upload:
@@ -22,17 +19,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version-file: ".python-version"
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Upload Translations
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
run: |
export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}"
python3 -m script.translations upload

View File

@@ -16,7 +16,8 @@ on:
- "requirements.txt"
- "script/gen_requirements_all.py"
permissions: {}
env:
DEFAULT_PYTHON: "3.13"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}
@@ -28,16 +29,15 @@ jobs:
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- &checkout
name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version-file: ".python-version"
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Create Python virtual environment
@@ -50,7 +50,7 @@ jobs:
- name: Create requirements_diff file
run: |
if [[ "${GITHUB_EVENT_NAME}" =~ (schedule|workflow_dispatch) ]]; then
if [[ ${{ github.event_name }} =~ (schedule|workflow_dispatch) ]]; then
touch requirements_diff.txt
else
curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements.txt
@@ -74,7 +74,7 @@ jobs:
) > .env_file
- name: Upload env_file
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: env_file
path: ./.env_file
@@ -82,7 +82,7 @@ jobs:
overwrite: true
- name: Upload requirements_diff
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: *actions-upload-artifact
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -94,7 +94,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: *actions-upload-artifact
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
@@ -106,8 +106,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
abi: ["cp314"]
matrix: &matrix-build
abi: ["cp313", "cp314"]
arch: ["amd64", "aarch64"]
include:
- arch: amd64
@@ -115,18 +115,17 @@ jobs:
- arch: aarch64
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- *checkout
- name: Download env_file
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
- &download-env-file
name: Download env_file
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
- &download-requirements-diff
name: Download requirements_diff
uses: *actions-download-artifact
with:
name: requirements_diff
@@ -137,7 +136,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: &home-assistant-wheels home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -157,32 +156,16 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
abi: ["cp314"]
arch: ["amd64", "aarch64"]
include:
- arch: amd64
os: ubuntu-latest
- arch: aarch64
os: ubuntu-24.04-arm
matrix: *matrix-build
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- *checkout
- name: Download env_file
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: env_file
- *download-env-file
- name: Download requirements_diff
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: requirements_diff
- *download-requirements-diff
- name: Download requirements_all_wheels
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: *actions-download-artifact
with:
name: requirements_all_wheels
@@ -195,7 +178,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: *home-assistant-wheels
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -206,4 +189,4 @@ jobs:
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all_wheels_${{ matrix.arch }}.txt"
requirements: "requirements_all.txt"

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.1
rev: v0.13.0
hooks:
- id: ruff-check
args:
@@ -17,12 +17,6 @@ repos:
- --quiet-level=2
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
hooks:
- id: zizmor
args:
- --pedantic
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
@@ -45,14 +39,14 @@ repos:
- id: prettier
additional_dependencies:
- prettier@3.6.2
- prettier-plugin-sort-json@4.2.0
- prettier-plugin-sort-json@4.1.1
- repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0
hooks:
# Run `python-typing-update` hook manually from time to time
# to update python typing syntax.
# Will require manual work, before submitting changes!
# prek run --hook-stage manual python-typing-update --all-files
# pre-commit run --hook-stage manual python-typing-update --all-files
- id: python-typing-update
stages: [manual]
args:

View File

@@ -1 +1 @@
3.14.2
3.13

View File

@@ -49,12 +49,10 @@ homeassistant.components.actiontec.*
homeassistant.components.adax.*
homeassistant.components.adguard.*
homeassistant.components.aftership.*
homeassistant.components.ai_task.*
homeassistant.components.air_quality.*
homeassistant.components.airgradient.*
homeassistant.components.airly.*
homeassistant.components.airnow.*
homeassistant.components.airobot.*
homeassistant.components.airos.*
homeassistant.components.airq.*
homeassistant.components.airthings.*
@@ -85,7 +83,6 @@ homeassistant.components.androidtv_remote.*
homeassistant.components.anel_pwrctrl.*
homeassistant.components.anova.*
homeassistant.components.anthemav.*
homeassistant.components.anthropic.*
homeassistant.components.apache_kafka.*
homeassistant.components.apcupsd.*
homeassistant.components.api.*
@@ -123,6 +120,7 @@ homeassistant.components.blueprint.*
homeassistant.components.bluesound.*
homeassistant.components.bluetooth.*
homeassistant.components.bluetooth_adapters.*
homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.*
homeassistant.components.bosch_alarm.*
homeassistant.components.braviatv.*
@@ -130,7 +128,6 @@ homeassistant.components.bring.*
homeassistant.components.brother.*
homeassistant.components.browser.*
homeassistant.components.bryant_evolution.*
homeassistant.components.bsblan.*
homeassistant.components.bthome.*
homeassistant.components.button.*
homeassistant.components.calendar.*
@@ -210,9 +207,7 @@ homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.*
homeassistant.components.folder_watcher.*
homeassistant.components.forecast_solar.*
homeassistant.components.freshr.*
homeassistant.components.fritz.*
homeassistant.components.fritzbox.*
homeassistant.components.fritzbox_callmonitor.*
@@ -225,7 +220,6 @@ homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.*
homeassistant.components.geocaching.*
homeassistant.components.ghost.*
homeassistant.components.gios.*
homeassistant.components.github.*
homeassistant.components.glances.*
@@ -246,7 +240,6 @@ homeassistant.components.guardian.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
homeassistant.components.hdfury.*
homeassistant.components.heos.*
homeassistant.components.here_travel_time.*
homeassistant.components.history.*
@@ -278,7 +271,6 @@ homeassistant.components.humidifier.*
homeassistant.components.husqvarna_automower.*
homeassistant.components.hydrawise.*
homeassistant.components.hyperion.*
homeassistant.components.hypontech.*
homeassistant.components.ibeacon.*
homeassistant.components.idasen_desk.*
homeassistant.components.image.*
@@ -289,12 +281,10 @@ homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
homeassistant.components.integration.*
homeassistant.components.intelliclima.*
homeassistant.components.intent.*
homeassistant.components.intent_script.*
homeassistant.components.ios.*
@@ -302,7 +292,6 @@ homeassistant.components.iotty.*
homeassistant.components.ipp.*
homeassistant.components.iqvia.*
homeassistant.components.iron_os.*
homeassistant.components.isal.*
homeassistant.components.islamic_prayer_times.*
homeassistant.components.isy994.*
homeassistant.components.jellyfin.*
@@ -313,7 +302,6 @@ homeassistant.components.knocki.*
homeassistant.components.knx.*
homeassistant.components.kraken.*
homeassistant.components.kulersky.*
homeassistant.components.labs.*
homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.*
@@ -342,7 +330,6 @@ homeassistant.components.lookin.*
homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.lutron.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.mastodon.*
@@ -374,7 +361,7 @@ homeassistant.components.my.*
homeassistant.components.mysensors.*
homeassistant.components.myuplink.*
homeassistant.components.nam.*
homeassistant.components.namecheapdns.*
homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.*
homeassistant.components.neato.*
homeassistant.components.nest.*
@@ -388,7 +375,6 @@ homeassistant.components.no_ip.*
homeassistant.components.nordpool.*
homeassistant.components.notify.*
homeassistant.components.notion.*
homeassistant.components.nrgkick.*
homeassistant.components.ntfy.*
homeassistant.components.number.*
homeassistant.components.nut.*
@@ -396,13 +382,11 @@ homeassistant.components.ohme.*
homeassistant.components.onboarding.*
homeassistant.components.oncue.*
homeassistant.components.onedrive.*
homeassistant.components.onedrive_for_business.*
homeassistant.components.onewire.*
homeassistant.components.onkyo.*
homeassistant.components.open_meteo.*
homeassistant.components.open_router.*
homeassistant.components.openai_conversation.*
homeassistant.components.openevse.*
homeassistant.components.openexchangerates.*
homeassistant.components.opensky.*
homeassistant.components.openuv.*
@@ -410,7 +394,6 @@ homeassistant.components.opnsense.*
homeassistant.components.opower.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
homeassistant.components.otp.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
@@ -424,10 +407,8 @@ homeassistant.components.person.*
homeassistant.components.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
homeassistant.components.pooldose.*
homeassistant.components.portainer.*
homeassistant.components.powerfox.*
homeassistant.components.powerfox_local.*
homeassistant.components.powerwall.*
homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.*
@@ -446,13 +427,10 @@ homeassistant.components.radarr.*
homeassistant.components.radio_browser.*
homeassistant.components.rainforest_raven.*
homeassistant.components.rainmachine.*
homeassistant.components.random.*
homeassistant.components.raspberry_pi.*
homeassistant.components.rdw.*
homeassistant.components.recollect_waste.*
homeassistant.components.recorder.*
homeassistant.components.recovery_mode.*
homeassistant.components.redgtech.*
homeassistant.components.remember_the_milk.*
homeassistant.components.remote.*
homeassistant.components.remote_calendar.*
@@ -476,14 +454,12 @@ homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsungtv.*
homeassistant.components.saunum.*
homeassistant.components.scene.*
homeassistant.components.schedule.*
homeassistant.components.schlage.*
homeassistant.components.scrape.*
homeassistant.components.script.*
homeassistant.components.search.*
homeassistant.components.season.*
homeassistant.components.select.*
homeassistant.components.sensibo.*
homeassistant.components.sensirion_ble.*
@@ -510,7 +486,6 @@ homeassistant.components.smtp.*
homeassistant.components.snooz.*
homeassistant.components.solarlog.*
homeassistant.components.sonarr.*
homeassistant.components.spaceapi.*
homeassistant.components.speedtestdotnet.*
homeassistant.components.spotify.*
homeassistant.components.sql.*
@@ -535,7 +510,6 @@ homeassistant.components.synology_dsm.*
homeassistant.components.system_health.*
homeassistant.components.system_log.*
homeassistant.components.systemmonitor.*
homeassistant.components.systemnexa2.*
homeassistant.components.tag.*
homeassistant.components.tailscale.*
homeassistant.components.tailwind.*
@@ -546,7 +520,6 @@ homeassistant.components.tcp.*
homeassistant.components.technove.*
homeassistant.components.tedee.*
homeassistant.components.telegram_bot.*
homeassistant.components.teslemetry.*
homeassistant.components.text.*
homeassistant.components.thethingsnetwork.*
homeassistant.components.threshold.*
@@ -579,14 +552,12 @@ homeassistant.components.update.*
homeassistant.components.uptime.*
homeassistant.components.uptime_kuma.*
homeassistant.components.uptimerobot.*
homeassistant.components.usage_prediction.*
homeassistant.components.usb.*
homeassistant.components.uvc.*
homeassistant.components.vacuum.*
homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
@@ -599,7 +570,6 @@ homeassistant.components.water_heater.*
homeassistant.components.watts.*
homeassistant.components.watttime.*
homeassistant.components.weather.*
homeassistant.components.web_rtc.*
homeassistant.components.webhook.*
homeassistant.components.webostv.*
homeassistant.components.websocket_api.*
@@ -616,7 +586,6 @@ homeassistant.components.yale_smart_alarm.*
homeassistant.components.yalexs_ble.*
homeassistant.components.youtube.*
homeassistant.components.zeroconf.*
homeassistant.components.zinvolt.*
homeassistant.components.zodiac.*
homeassistant.components.zone.*
homeassistant.components.zwave_js.*

View File

@@ -7,8 +7,8 @@
"python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
},

8
.vscode/tasks.json vendored
View File

@@ -45,7 +45,7 @@
{
"label": "Ruff",
"type": "shell",
"command": "prek run ruff-check --all-files",
"command": "pre-commit run ruff-check --all-files",
"group": {
"kind": "test",
"isDefault": true
@@ -57,9 +57,9 @@
"problemMatcher": []
},
{
"label": "Prek",
"label": "Pre-commit",
"type": "shell",
"command": "prek run --show-diff-on-failure",
"command": "pre-commit run --show-diff-on-failure",
"group": {
"kind": "test",
"isDefault": true
@@ -120,7 +120,7 @@
{
"label": "Generate Requirements",
"type": "shell",
"command": "${command:python.interpreterPath} -m script.gen_requirements_all",
"command": "./script/gen_requirements_all.py",
"group": {
"kind": "build",
"isDefault": true

View File

@@ -1,25 +0,0 @@
# GitHub Copilot & Claude Code Instructions
This repository contains the core of Home Assistant, a Python 3 based home automation application.
## Code Review Guidelines
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Development Commands
.vscode/tasks.json contains useful commands used for development.
## Python Syntax Notes
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
## Testing
When writing or modifying tests, ensure all test function parameters have type annotations.
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
## Good practices
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
.github/copilot-instructions.md

View File

@@ -1 +1 @@
AGENTS.md
.github/copilot-instructions.md

115
CODEOWNERS generated
View File

@@ -15,7 +15,7 @@
.yamllint @home-assistant/core
pyproject.toml @home-assistant/core
requirements_test.txt @home-assistant/core
/.devcontainer/ @home-assistant/core @edenhaus
/.devcontainer/ @home-assistant/core
/.github/ @home-assistant/core
/.vscode/ @home-assistant/core
/homeassistant/*.py @home-assistant/core
@@ -234,14 +234,14 @@ build.json @home-assistant/supervisor
/tests/components/bluetooth/ @bdraco
/homeassistant/components/bluetooth_adapters/ @bdraco
/tests/components/bluetooth_adapters/ @bdraco
/homeassistant/components/bmw_connected_drive/ @gerard33 @rikroe
/tests/components/bmw_connected_drive/ @gerard33 @rikroe
/homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900
/tests/components/bosch_alarm/ @mag1024 @sanjay900
/homeassistant/components/bosch_shc/ @tschamm
/tests/components/bosch_shc/ @tschamm
/homeassistant/components/brands/ @home-assistant/core
/tests/components/brands/ @home-assistant/core
/homeassistant/components/braviatv/ @bieniu @Drafteed
/tests/components/braviatv/ @bieniu @Drafteed
/homeassistant/components/bring/ @miaucl @tr4nt0r
@@ -279,8 +279,6 @@ build.json @home-assistant/supervisor
/tests/components/cert_expiry/ @jjlawren
/homeassistant/components/chacon_dio/ @cnico
/tests/components/chacon_dio/ @cnico
/homeassistant/components/chess_com/ @joostlek
/tests/components/chess_com/ @joostlek
/homeassistant/components/cisco_ios/ @fbradyirl
/homeassistant/components/cisco_mobility_express/ @fbradyirl
/homeassistant/components/cisco_webex_teams/ @fbradyirl
@@ -290,8 +288,6 @@ build.json @home-assistant/supervisor
/tests/components/cloud/ @home-assistant/cloud
/homeassistant/components/cloudflare/ @ludeeus @ctalkington
/tests/components/cloudflare/ @ludeeus @ctalkington
/homeassistant/components/cloudflare_r2/ @corrreia
/tests/components/cloudflare_r2/ @corrreia
/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99
/tests/components/co2signal/ @jpbede @VIKTORVAV99
/homeassistant/components/coinbase/ @tombrien
@@ -383,8 +379,6 @@ build.json @home-assistant/supervisor
/tests/components/dlna_dms/ @chishm
/homeassistant/components/dnsip/ @gjohansson-ST
/tests/components/dnsip/ @gjohansson-ST
/homeassistant/components/door/ @home-assistant/core
/tests/components/door/ @home-assistant/core
/homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket
/tests/components/doorbird/ @oblogic7 @bdraco @flacjacket
/homeassistant/components/dormakaba_dkey/ @emontnemery
@@ -403,10 +397,12 @@ build.json @home-assistant/supervisor
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duke_energy/ @hunterjm
/tests/components/duke_energy/ @hunterjm
/homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234
/homeassistant/components/eafm/ @Jc2k
@@ -551,14 +547,14 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/freshr/ @SierraNL
/tests/components/freshr/ @SierraNL
/homeassistant/components/fressnapf_tracker/ @eifinger
/tests/components/fressnapf_tracker/ @eifinger
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
/tests/components/fritzbox/ @mib1185 @flabbamann
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
/tests/components/fritzbox_callmonitor/ @cdce8p
/homeassistant/components/fronius/ @farmio
/tests/components/fronius/ @farmio
/homeassistant/components/frontend/ @home-assistant/frontend
@@ -571,14 +567,10 @@ build.json @home-assistant/supervisor
/tests/components/fully_kiosk/ @cgarwood
/homeassistant/components/fyta/ @dontinelli
/tests/components/fyta/ @dontinelli
/homeassistant/components/garage_door/ @home-assistant/core
/tests/components/garage_door/ @home-assistant/core
/homeassistant/components/garages_amsterdam/ @klaasnicolaas
/tests/components/garages_amsterdam/ @klaasnicolaas
/homeassistant/components/gardena_bluetooth/ @elupus
/tests/components/gardena_bluetooth/ @elupus
/homeassistant/components/gate/ @home-assistant/core
/tests/components/gate/ @home-assistant/core
/homeassistant/components/gdacs/ @exxamalte
/tests/components/gdacs/ @exxamalte
/homeassistant/components/generic/ @davet2001
@@ -601,8 +593,6 @@ build.json @home-assistant/supervisor
/tests/components/geonetnz_quakes/ @exxamalte
/homeassistant/components/geonetnz_volcano/ @exxamalte
/tests/components/geonetnz_volcano/ @exxamalte
/homeassistant/components/ghost/ @johnonolan
/tests/components/ghost/ @johnonolan
/homeassistant/components/gios/ @bieniu
/tests/components/gios/ @bieniu
/homeassistant/components/github/ @timmo001 @ludeeus
@@ -651,8 +641,6 @@ build.json @home-assistant/supervisor
/tests/components/gpsd/ @fabaff @jrieger
/homeassistant/components/gree/ @cmroche
/tests/components/gree/ @cmroche
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greeneye_monitor/ @jkeljo
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
@@ -678,8 +666,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/hdmi_cec/ @inytar
/tests/components/hdmi_cec/ @inytar
/homeassistant/components/heatmiser/ @andylockran
/homeassistant/components/hegel/ @boazca
/tests/components/hegel/ @boazca
/homeassistant/components/heos/ @andrewsayre
/tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger
@@ -723,10 +709,8 @@ build.json @home-assistant/supervisor
/tests/components/homekit_controller/ @Jc2k @bdraco
/homeassistant/components/homematic/ @pvizeli
/tests/components/homematic/ @pvizeli
/homeassistant/components/homematicip_cloud/ @hahn-th @lackas
/tests/components/homematicip_cloud/ @hahn-th @lackas
/homeassistant/components/homevolt/ @danielhiversen @liudger
/tests/components/homevolt/ @danielhiversen @liudger
/homeassistant/components/homematicip_cloud/ @hahn-th
/tests/components/homematicip_cloud/ @hahn-th
/homeassistant/components/homewizard/ @DCSBL
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
@@ -745,8 +729,6 @@ build.json @home-assistant/supervisor
/tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
/tests/components/humidifier/ @home-assistant/core @Shulyaka
/homeassistant/components/humidity/ @home-assistant/core
/tests/components/humidity/ @home-assistant/core
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/husqvarna_automower/ @Thomas55555
@@ -761,8 +743,6 @@ build.json @home-assistant/supervisor
/tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan
/homeassistant/components/hyperion/ @dermotduffy
/tests/components/hyperion/ @dermotduffy
/homeassistant/components/hypontech/ @jcisio
/tests/components/hypontech/ @jcisio
/homeassistant/components/ialarm/ @RyuzakiKK
/tests/components/ialarm/ @RyuzakiKK
/homeassistant/components/iammeter/ @lewei50
@@ -772,8 +752,6 @@ build.json @home-assistant/supervisor
/tests/components/icloud/ @Quentame @nzapponi
/homeassistant/components/idasen_desk/ @abmantis
/tests/components/idasen_desk/ @abmantis
/homeassistant/components/idrive_e2/ @patrickvorgers
/tests/components/idrive_e2/ @patrickvorgers
/homeassistant/components/igloohome/ @keithle888
/tests/components/igloohome/ @keithle888
/homeassistant/components/ign_sismologia/ @exxamalte
@@ -796,14 +774,10 @@ build.json @home-assistant/supervisor
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/indevolt/ @xirt
/tests/components/indevolt/ @xirt
/homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
/tests/components/influxdb/ @mdegat01 @Robbie1221
/homeassistant/components/infrared/ @home-assistant/core
/tests/components/infrared/ @home-assistant/core
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/inkbird/ @bdraco
/tests/components/inkbird/ @bdraco
/homeassistant/components/input_boolean/ @home-assistant/core
@@ -822,8 +796,6 @@ build.json @home-assistant/supervisor
/tests/components/insteon/ @teharris1
/homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
/tests/components/intelliclima/ @dvdinth
/homeassistant/components/intellifire/ @jeeftor
/tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
@@ -945,8 +917,6 @@ build.json @home-assistant/supervisor
/tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob
/homeassistant/components/liebherr/ @mettolen
/tests/components/liebherr/ @mettolen
/homeassistant/components/lifx/ @Djelibeybi
/tests/components/lifx/ @Djelibeybi
/homeassistant/components/light/ @home-assistant/core
@@ -1047,8 +1017,8 @@ build.json @home-assistant/supervisor
/tests/components/mill/ @danielhiversen
/homeassistant/components/min_max/ @gjohansson-ST
/tests/components/min_max/ @gjohansson-ST
/homeassistant/components/minecraft_server/ @elmurato @zachdeibert
/tests/components/minecraft_server/ @elmurato @zachdeibert
/homeassistant/components/minecraft_server/ @elmurato
/tests/components/minecraft_server/ @elmurato
/homeassistant/components/minio/ @tkislan
/tests/components/minio/ @tkislan
/homeassistant/components/moat/ @bdraco
@@ -1082,8 +1052,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mta/ @OnFreund
/tests/components/mta/ @OnFreund
/homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
@@ -1092,8 +1060,6 @@ build.json @home-assistant/supervisor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
/tests/components/my/ @home-assistant/core
/homeassistant/components/myneomitis/ @l-pr
/tests/components/myneomitis/ @l-pr
/homeassistant/components/mysensors/ @MartinHjelmare @functionpointer
/tests/components/mysensors/ @MartinHjelmare @functionpointer
/homeassistant/components/mystrom/ @fabaff
@@ -1102,16 +1068,14 @@ build.json @home-assistant/supervisor
/tests/components/myuplink/ @pajzo @astrandb
/homeassistant/components/nam/ @bieniu
/tests/components/nam/ @bieniu
/homeassistant/components/namecheapdns/ @tr4nt0r
/tests/components/namecheapdns/ @tr4nt0r
/homeassistant/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
/tests/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/homeassistant/components/ness_alarm/ @nickw444 @poshy163
/tests/components/ness_alarm/ @nickw444 @poshy163
/homeassistant/components/ness_alarm/ @nickw444
/tests/components/ness_alarm/ @nickw444
/homeassistant/components/nest/ @allenporter
/tests/components/nest/ @allenporter
/homeassistant/components/netatmo/ @cgtobi
@@ -1158,8 +1122,6 @@ build.json @home-assistant/supervisor
/tests/components/notify_events/ @matrozov @papajojo
/homeassistant/components/notion/ @bachya
/tests/components/notion/ @bachya
/homeassistant/components/nrgkick/ @andijakl
/tests/components/nrgkick/ @andijakl
/homeassistant/components/nsw_fuel_station/ @nickw444
/tests/components/nsw_fuel_station/ @nickw444
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
@@ -1198,8 +1160,6 @@ build.json @home-assistant/supervisor
/tests/components/ondilo_ico/ @JeromeHXP
/homeassistant/components/onedrive/ @zweckj
/tests/components/onedrive/ @zweckj
/homeassistant/components/onedrive_for_business/ @zweckj
/tests/components/onedrive_for_business/ @zweckj
/homeassistant/components/onewire/ @garbled1 @epenet
/tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
@@ -1210,8 +1170,6 @@ build.json @home-assistant/supervisor
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq
/homeassistant/components/openevse/ @c00w @firstof9
@@ -1297,12 +1255,8 @@ build.json @home-assistant/supervisor
/tests/components/portainer/ @erwindouna
/homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerfox_local/ @klaasnicolaas
/tests/components/powerfox_local/ @klaasnicolaas
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
/homeassistant/components/prana/ @prana-dev-official
/tests/components/prana/ @prana-dev-official
/homeassistant/components/private_ble_device/ @Jc2k
/tests/components/private_ble_device/ @Jc2k
/homeassistant/components/probe_plus/ @pantherale0
@@ -1317,8 +1271,7 @@ build.json @home-assistant/supervisor
/tests/components/prosegur/ @dgomes
/homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185
/homeassistant/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato
@@ -1389,8 +1342,6 @@ build.json @home-assistant/supervisor
/tests/components/recorder/ @home-assistant/core
/homeassistant/components/recovery_mode/ @home-assistant/core
/tests/components/recovery_mode/ @home-assistant/core
/homeassistant/components/redgtech/ @jonhsady @luan-nvg
/tests/components/redgtech/ @jonhsady @luan-nvg
/homeassistant/components/refoss/ @ashionky
/tests/components/refoss/ @ashionky
/homeassistant/components/rehlko/ @bdraco @peterager
@@ -1597,7 +1548,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/speedtestdotnet/ @rohankapoorcom @engrbm87
/tests/components/speedtestdotnet/ @rohankapoorcom @engrbm87
/homeassistant/components/splunk/ @Bre77
/tests/components/splunk/ @Bre77
/homeassistant/components/spotify/ @frenck @joostlek
/tests/components/spotify/ @frenck @joostlek
/homeassistant/components/sql/ @gjohansson-ST @dougiteixeira
@@ -1662,8 +1612,6 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/systemnexa2/ @konsulten
/tests/components/systemnexa2/ @konsulten
/homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna
/homeassistant/components/tag/ @home-assistant/core
@@ -1689,8 +1637,6 @@ build.json @home-assistant/supervisor
/tests/components/telegram_bot/ @hanwg
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken
/tests/components/teltonika/ @karlbeecken
/homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
@@ -1703,6 +1649,7 @@ build.json @home-assistant/supervisor
/tests/components/tessie/ @Bre77
/homeassistant/components/text/ @home-assistant/core
/tests/components/text/ @home-assistant/core
/homeassistant/components/tfiac/ @fredrike @mellado
/homeassistant/components/thermobeacon/ @bdraco
/tests/components/thermobeacon/ @bdraco
/homeassistant/components/thermopro/ @bdraco @h3ss
@@ -1756,8 +1703,6 @@ build.json @home-assistant/supervisor
/tests/components/trafikverket_train/ @gjohansson-ST
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
/homeassistant/components/trane/ @bdraco
/tests/components/trane/ @bdraco
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/homeassistant/components/trend/ @jpbede
@@ -1774,8 +1719,6 @@ build.json @home-assistant/supervisor
/tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen
/homeassistant/components/twitch/ @joostlek
/tests/components/twitch/ @joostlek
/homeassistant/components/uhoo/ @getuhoo @joshsmonta
/tests/components/uhoo/ @getuhoo @joshsmonta
/homeassistant/components/ukraine_alarm/ @PaulAnnekov
/tests/components/ukraine_alarm/ @PaulAnnekov
/homeassistant/components/unifi/ @Kane610
@@ -1863,7 +1806,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
/tests/components/waterfurnace/ @sdague @masterkoppa
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai
@@ -1893,8 +1835,8 @@ build.json @home-assistant/supervisor
/tests/components/webostv/ @thecode
/homeassistant/components/websocket_api/ @home-assistant/core
/tests/components/websocket_api/ @home-assistant/core
/homeassistant/components/weheat/ @barryvdh
/tests/components/weheat/ @barryvdh
/homeassistant/components/weheat/ @jesperraemaekers
/tests/components/weheat/ @jesperraemaekers
/homeassistant/components/wemo/ @esev
/tests/components/wemo/ @esev
/homeassistant/components/whirlpool/ @abmantis @mkmer
@@ -1905,15 +1847,13 @@ build.json @home-assistant/supervisor
/tests/components/wiffi/ @mampfes
/homeassistant/components/wilight/ @leofig-rj
/tests/components/wilight/ @leofig-rj
/homeassistant/components/window/ @home-assistant/core
/tests/components/window/ @home-assistant/core
/homeassistant/components/wirelesstag/ @sergeymaysak
/homeassistant/components/withings/ @joostlek
/tests/components/withings/ @joostlek
/homeassistant/components/wiz/ @sbidy @arturpragacz
/tests/components/wiz/ @sbidy @arturpragacz
/homeassistant/components/wled/ @frenck @mik-laj
/tests/components/wled/ @frenck @mik-laj
/homeassistant/components/wled/ @frenck
/tests/components/wled/ @frenck
/homeassistant/components/wmspro/ @mback2k
/tests/components/wmspro/ @mback2k
/homeassistant/components/wolflink/ @adamkrol93 @mtielen
@@ -1924,8 +1864,6 @@ build.json @home-assistant/supervisor
/tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger
/homeassistant/components/wsdot/ @ucodery
/tests/components/wsdot/ @ucodery
/homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @hunterjm @tr4nt0r
@@ -1974,14 +1912,11 @@ build.json @home-assistant/supervisor
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon
/tests/components/zimi/ @markhannon
/homeassistant/components/zinvolt/ @joostlek
/tests/components/zinvolt/ @joostlek
/homeassistant/components/zodiac/ @JulienTant
/tests/components/zodiac/ @JulienTant
/homeassistant/components/zone/ @home-assistant/core
/tests/components/zone/ @home-assistant/core
/homeassistant/components/zoneminder/ @rohankapoorcom @nabbi
/tests/components/zoneminder/ @rohankapoorcom @nabbi
/homeassistant/components/zwave_js/ @home-assistant/z-wave
/tests/components/zwave_js/ @home-assistant/z-wave
/homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS

4
Dockerfile generated
View File

@@ -24,13 +24,13 @@ ENV \
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.10.6
&& pip3 install uv==0.9.17
WORKDIR /usr/src

View File

@@ -52,9 +52,6 @@ RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
uv pip install -r requirements.txt -r requirements_test.txt
# Claude Code native install
RUN curl -fsSL https://claude.ai/install.sh | bash
WORKDIR /workspaces
# Set the default shell to bash instead of sh

View File

@@ -10,7 +10,6 @@ coverage:
target: auto
threshold: 1
paths:
- homeassistant/components/*/backup.py
- homeassistant/components/*/config_flow.py
- homeassistant/components/*/device_action.py
- homeassistant/components/*/device_condition.py
@@ -29,7 +28,6 @@ coverage:
target: 100
threshold: 0
paths:
- homeassistant/components/*/backup.py
- homeassistant/components/*/config_flow.py
- homeassistant/components/*/device_action.py
- homeassistant/components/*/device_condition.py

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import hashlib
import json
import logging
from pathlib import Path
@@ -39,6 +40,17 @@ class RestoreBackupFileContent:
restore_homeassistant: bool
def password_to_key(password: str) -> bytes:
"""Generate a AES Key from password.
Matches the implementation in supervisor.backups.utils.password_to_key.
"""
key: bytes = password.encode()
for _ in range(100):
key = hashlib.sha256(key).digest()
return key[:16]
def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
"""Return the contents of the restore backup file."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
@@ -84,14 +96,15 @@ def _extract_backup(
"""Extract the backup file to the config directory."""
with (
TemporaryDirectory() as tempdir,
securetar.SecureTarArchive(
securetar.SecureTarFile(
restore_content.backup_file_path,
gzip=False,
mode="r",
) as ostf,
):
ostf.tar.extractall(
ostf.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar),
members=securetar.secure_path(ostf),
filter="fully_trusted",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
@@ -113,7 +126,10 @@ def _extract_backup(
f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}",
),
gzip=backup_meta["compressed"],
password=restore_content.password,
key=password_to_key(restore_content.password)
if restore_content.password is not None
else None,
mode="r",
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),

View File

@@ -67,10 +67,12 @@ from .const import (
BASE_PLATFORMS,
FORMAT_DATETIME,
KEY_DATA_LOGGING as DATA_LOGGING,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
REQUIRED_NEXT_PYTHON_VER,
SIGNAL_BOOTSTRAP_INTEGRATIONS,
)
from .core_config import async_process_ha_core_config
from .exceptions import HomeAssistantError, UnsupportedStorageVersionError
from .exceptions import HomeAssistantError
from .helpers import (
area_registry,
category_registry,
@@ -210,7 +212,6 @@ DEFAULT_INTEGRATIONS = {
"analytics", # Needed for onboarding
"application_credentials",
"backup",
"brands",
"frontend",
"hardware",
"labs",
@@ -236,21 +237,9 @@ DEFAULT_INTEGRATIONS = {
"input_text",
"schedule",
"timer",
#
# Base platforms:
*BASE_PLATFORMS,
#
# Integrations providing triggers and conditions for base platforms:
"door",
"garage_door",
"gate",
"humidity",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.
"backup",
"cloud",
"frontend",
}
DEFAULT_INTEGRATIONS_SUPERVISOR = {
@@ -445,56 +434,32 @@ def _init_blocking_io_modules_in_executor() -> None:
is_docker_env()
async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
"""Load the registries and modules that will do blocking I/O.
Return whether loading succeeded.
"""
async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
"""Load the registries and modules that will do blocking I/O."""
if DATA_REGISTRIES_LOADED in hass.data:
return True
return
hass.data[DATA_REGISTRIES_LOADED] = None
entity.async_setup(hass)
frame.async_setup(hass)
template.async_setup(hass)
translation.async_setup(hass)
recovery = hass.config.recovery_mode
try:
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass, load_empty=recovery)),
create_eager_task(category_registry.async_load(hass, load_empty=recovery)),
create_eager_task(device_registry.async_load(hass, load_empty=recovery)),
create_eager_task(entity_registry.async_load(hass, load_empty=recovery)),
create_eager_task(floor_registry.async_load(hass, load_empty=recovery)),
create_eager_task(issue_registry.async_load(hass, load_empty=recovery)),
create_eager_task(label_registry.async_load(hass, load_empty=recovery)),
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
create_eager_task(template.async_load_custom_templates(hass)),
create_eager_task(restore_state.async_load(hass, load_empty=recovery)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)),
)
except UnsupportedStorageVersionError as err:
# If we're already in recovery mode, we don't want to handle the exception
# and activate recovery mode again, as that would lead to an infinite loop.
if recovery:
raise
_LOGGER.error(
"Storage file %s was created by a newer version of Home Assistant"
" (storage version %s > %s); activating recovery mode; on-disk data"
" is preserved; upgrade Home Assistant or restore from a backup",
err.storage_key,
err.found_version,
err.max_supported_version,
)
return False
return True
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass)),
create_eager_task(category_registry.async_load(hass)),
create_eager_task(device_registry.async_load(hass)),
create_eager_task(entity_registry.async_load(hass)),
create_eager_task(floor_registry.async_load(hass)),
create_eager_task(issue_registry.async_load(hass)),
create_eager_task(label_registry.async_load(hass)),
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
create_eager_task(template.async_load_custom_templates(hass)),
create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)),
)
async def async_from_config_dict(
@@ -511,9 +476,7 @@ async def async_from_config_dict(
# Prime custom component cache early so we know if registry entries are tied
# to a custom integration
await loader.async_get_custom_components(hass)
if not await async_load_base_functionality(hass):
return None
await async_load_base_functionality(hass)
# Set up core.
_LOGGER.debug("Setting up %s", CORE_INTEGRATIONS)
@@ -553,6 +516,38 @@ async def async_from_config_dict(
stop = monotonic()
_LOGGER.info("Home Assistant initialized in %.2fs", stop - start)
if (
REQUIRED_NEXT_PYTHON_HA_RELEASE
and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER
):
current_python_version = ".".join(str(x) for x in sys.version_info[:3])
required_python_version = ".".join(str(x) for x in REQUIRED_NEXT_PYTHON_VER[:2])
_LOGGER.warning(
(
"Support for the running Python version %s is deprecated and "
"will be removed in Home Assistant %s; "
"Please upgrade Python to %s"
),
current_python_version,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
required_python_version,
)
issue_registry.async_create_issue(
hass,
core.DOMAIN,
f"python_version_{required_python_version}",
is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,
translation_key="python_version",
translation_placeholders={
"current_python_version": current_python_version,
"required_python_version": required_python_version,
"breaks_in_ha_version": REQUIRED_NEXT_PYTHON_HA_RELEASE,
},
)
return hass

View File

@@ -1,5 +0,0 @@
{
"domain": "american_standard",
"name": "American Standard",
"integrations": ["nexia", "trane"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "cloudflare",
"name": "Cloudflare",
"integrations": ["cloudflare", "cloudflare_r2"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "heatit",
"name": "Heatit",
"iot_standards": ["zwave"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "heiman",
"name": "Heiman",
"iot_standards": ["matter", "zigbee"]
}

View File

@@ -1,6 +1,5 @@
{
"domain": "leviton",
"name": "Leviton",
"integrations": ["decora_wifi"],
"iot_standards": ["zwave"]
}

View File

@@ -13,7 +13,6 @@
"microsoft",
"msteams",
"onedrive",
"onedrive_for_business",
"xbox"
]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "powerfox",
"name": "Powerfox",
"integrations": ["powerfox", "powerfox_local"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "trane",
"name": "Trane",
"integrations": ["nexia", "trane"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "ubisys",
"name": "Ubisys",
"iot_standards": ["zigbee"]
}

View File

@@ -30,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER
from .const import CONF_POLLING, DOMAIN, LOGGER
from .services import async_setup_services
ATTR_DEVICE_NAME = "device_name"
@@ -99,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except (AbodeException, ConnectTimeout, HTTPError) as ex:
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling)
hass.data[DOMAIN] = AbodeSystem(abode, polling)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -113,12 +113,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout)
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop)
await hass.async_add_executor_job(hass.data[DOMAIN].abode.logout)
if logout_listener := hass.data[DOMAIN_DATA].logout_listener:
logout_listener()
hass.data.pop(DOMAIN_DATA)
hass.data[DOMAIN].logout_listener()
hass.data.pop(DOMAIN)
return unload_ok
@@ -128,16 +127,16 @@ async def setup_hass_events(hass: HomeAssistant) -> None:
def logout(event: Event) -> None:
"""Logout of Abode."""
if not hass.data[DOMAIN_DATA].polling:
hass.data[DOMAIN_DATA].abode.events.stop()
if not hass.data[DOMAIN].polling:
hass.data[DOMAIN].abode.events.stop()
hass.data[DOMAIN_DATA].abode.logout()
hass.data[DOMAIN].abode.logout()
LOGGER.info("Logged out of Abode")
if not hass.data[DOMAIN_DATA].polling:
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start)
if not hass.data[DOMAIN].polling:
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start)
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once(
hass.data[DOMAIN].logout_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, logout
)
@@ -179,6 +178,6 @@ def setup_abode_events(hass: HomeAssistant) -> None:
]
for event in events:
hass.data[DOMAIN_DATA].abode.events.add_event_callback(
hass.data[DOMAIN].abode.events.add_event_callback(
event, partial(event_callback, event)
)

View File

@@ -13,7 +13,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -23,7 +24,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode alarm control panel device."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
)

View File

@@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -25,7 +26,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode binary sensor devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
device_types = [
"connectivity",

View File

@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from . import AbodeSystem
from .const import DOMAIN_DATA, LOGGER
from .const import DOMAIN, LOGGER
from .entity import AbodeDevice
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
@@ -31,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode camera devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeCamera(data, device, timeline.CAPTURE_IMAGE)

View File

@@ -64,7 +64,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
else:
errors = {"base": "cannot_connect"}
except ConnectTimeout, HTTPError:
except (ConnectTimeout, HTTPError):
errors = {"base": "cannot_connect"}
if errors:

View File

@@ -1,19 +1,10 @@
"""Constants for the Abode Security System component."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AbodeSystem
LOGGER = logging.getLogger(__package__)
DOMAIN = "abode"
DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN)
ATTRIBUTION = "Data provided by goabode.com"
CONF_POLLING = "polling"

View File

@@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -19,7 +20,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode cover devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeCover(data, device)

View File

@@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import AbodeSystem
from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA
from .const import ATTRIBUTION, DOMAIN
class AbodeEntity(Entity):
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
self._update_connection_status,
)
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id)
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from Abode connection status updates."""

View File

@@ -20,7 +20,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -30,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode light devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeLight(data, device)
@@ -99,7 +100,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return _hs
@property
def color_mode(self) -> ColorMode:
def color_mode(self) -> str | None:
"""Return the color mode of the light."""
if self._device.is_dimmable and self._device.is_color_capable:
if self.hs_color is not None:
@@ -110,7 +111,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return ColorMode.ONOFF
@property
def supported_color_modes(self) -> set[ColorMode]:
def supported_color_modes(self) -> set[str] | None:
"""Flag supported color modes."""
if self._device.is_dimmable and self._device.is_color_capable:
return {ColorMode.COLOR_TEMP, ColorMode.HS}

View File

@@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -19,7 +20,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode lock devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeLock(data, device)

View File

@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem
from .const import DOMAIN_DATA
from .const import DOMAIN
from .entity import AbodeDevice
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
@@ -66,7 +66,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode sensor devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeSensor(data, device, description)

View File

@@ -10,7 +10,11 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, DOMAIN_DATA, LOGGER
from .const import DOMAIN, LOGGER
SERVICE_SETTINGS = "change_setting"
SERVICE_CAPTURE_IMAGE = "capture_image"
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
ATTR_SETTING = "setting"
ATTR_VALUE = "value"
@@ -31,7 +35,7 @@ def _change_setting(call: ServiceCall) -> None:
value = call.data[ATTR_VALUE]
try:
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value)
call.hass.data[DOMAIN].abode.set_setting(setting, value)
except AbodeException as ex:
LOGGER.warning(ex)
@@ -42,7 +46,7 @@ def _capture_image(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
for entity_id in call.hass.data[DOMAIN].entity_ids
if entity_id in entity_ids
]
@@ -57,7 +61,7 @@ def _trigger_automation(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
for entity_id in call.hass.data[DOMAIN].entity_ids
if entity_id in entity_ids
]
@@ -71,13 +75,16 @@ def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
hass.services.async_register(
DOMAIN, "change_setting", _change_setting, schema=CHANGE_SETTING_SCHEMA
DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA
)
hass.services.async_register(
DOMAIN, "capture_image", _capture_image, schema=CAPTURE_IMAGE_SCHEMA
DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA
)
hass.services.async_register(
DOMAIN, "trigger_automation", _trigger_automation, schema=AUTOMATION_SCHEMA
DOMAIN,
SERVICE_TRIGGER_AUTOMATION,
_trigger_automation,
schema=AUTOMATION_SCHEMA,
)

View File

@@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeAutomation, AbodeDevice
DEVICE_TYPES = ["switch", "valve"]
@@ -24,7 +25,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode switch devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
entities: list[SwitchEntity] = [
AbodeSwitch(data, device)

View File

@@ -7,7 +7,7 @@ import logging
from accuweather import AccuWeather
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry)
ent_reg = er.async_get(hass)
for day in range(5):
unique_id = f"{location_key}-ozone-{day}"
if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id):
if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id):
_LOGGER.debug("Removing ozone sensor entity %s", entity_id)
ent_reg.async_remove(entity_id)

View File

@@ -43,7 +43,7 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
longitude=user_input[CONF_LONGITUDE],
)
await accuweather.async_get_location()
except ApiError, ClientConnectorError, TimeoutError, ClientError:
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
errors["base"] = "cannot_connect"
except InvalidApiKeyError:
errors[CONF_API_KEY] = "invalid_api_key"
@@ -104,7 +104,7 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
longitude=self._longitude,
)
await accuweather.async_get_location()
except ApiError, ClientConnectorError, TimeoutError, ClientError:
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
errors["base"] = "cannot_connect"
except InvalidApiKeyError:
errors["base"] = "invalid_api_key"

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"requirements": ["accuweather==5.1.0"]
"requirements": ["accuweather==5.0.0"]
}

View File

@@ -30,8 +30,6 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
)
return {
"can_reach_server": system_health.async_check_can_reach_url(
hass, str(ENDPOINT)
),
"can_reach_server": system_health.async_check_can_reach_url(hass, ENDPOINT),
"remaining_requests": remaining_requests,
}

View File

@@ -191,7 +191,7 @@ class AccuWeatherEntity(
{
ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(),
ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCoverDay"],
ATTR_FORECAST_HUMIDITY: item["RelativeHumidityDay"].get("Average"),
ATTR_FORECAST_HUMIDITY: item["RelativeHumidityDay"]["Average"],
ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"][ATTR_VALUE],
ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"][ATTR_VALUE],
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperatureMax"][

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors
from .entity import ActronAirAcEntity, ActronAirZoneEntity
PARALLEL_UPDATES = 0
@@ -136,19 +136,16 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._status.user_aircon_settings.temperature_setpoint_cool_c
@handle_actron_api_errors
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
@@ -212,13 +209,11 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._zone.temperature_setpoint_cool_c
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
is_enabled = hvac_mode != HVACMode.OFF
await self._zone.enable(is_enabled)
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))

View File

@@ -1,12 +1,7 @@
"""Base entity classes for Actron Air integration."""
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from actron_neo_api import ActronAirZone
from actron_neo_api import ActronAirAPIError, ActronAirZone
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -14,26 +9,6 @@ from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Actron Air API calls to handle ActronAirAPIError exceptions."""
@wraps(func)
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap API calls with exception handling."""
try:
await func(self, *args, **kwargs)
except ActronAirAPIError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
return wrapper
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
"""Base class for Actron Air entities."""

View File

@@ -26,7 +26,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: done
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt

View File

@@ -49,9 +49,6 @@
}
},
"exceptions": {
"api_error": {
"message": "Failed to communicate with Actron Air device: {error}"
},
"auth_error": {
"message": "Authentication failed, please reauthenticate"
},

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, handle_actron_api_errors
from .entity import ActronAirAcEntity
PARALLEL_UPDATES = 0
@@ -29,42 +29,30 @@ SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
key="away_mode",
translation_key="away_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
set_fn=lambda coordinator, enabled: (
coordinator.data.user_aircon_settings.set_away_mode(enabled)
),
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="continuous_fan",
translation_key="continuous_fan",
is_on_fn=lambda coordinator: (
coordinator.data.user_aircon_settings.continuous_fan_enabled
),
set_fn=lambda coordinator, enabled: (
coordinator.data.user_aircon_settings.set_continuous_mode(enabled)
),
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="quiet_mode",
translation_key="quiet_mode",
is_on_fn=lambda coordinator: (
coordinator.data.user_aircon_settings.quiet_mode_enabled
),
set_fn=lambda coordinator, enabled: (
coordinator.data.user_aircon_settings.set_quiet_mode(enabled)
),
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="turbo_mode",
translation_key="turbo_mode",
is_on_fn=lambda coordinator: (
coordinator.data.user_aircon_settings.turbo_enabled
),
set_fn=lambda coordinator, enabled: (
coordinator.data.user_aircon_settings.set_turbo_mode(enabled)
),
is_supported_fn=lambda coordinator: (
coordinator.data.user_aircon_settings.turbo_supported
),
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled),
is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported,
),
)
@@ -105,12 +93,10 @@ class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
@handle_actron_api_errors
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
@handle_actron_api_errors
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

40
homeassistant/components/adax/climate.py Executable file → Normal file
View File

@@ -168,57 +168,29 @@ class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity):
if hvac_mode == HVACMode.HEAT:
temperature = self._attr_target_temperature or self._attr_min_temp
await self._adax_data_handler.set_target_temperature(temperature)
self._attr_target_temperature = temperature
self._attr_icon = "mdi:radiator"
elif hvac_mode == HVACMode.OFF:
await self._adax_data_handler.set_target_temperature(0)
self._attr_icon = "mdi:radiator-off"
else:
# Ignore unsupported HVAC modes to avoid desynchronizing entity state
# from the physical device.
return
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
if self._attr_hvac_mode == HVACMode.HEAT:
await self._adax_data_handler.set_target_temperature(temperature)
await self._adax_data_handler.set_target_temperature(temperature)
self._attr_target_temperature = temperature
self.async_write_ha_state()
def _update_hvac_attributes(self) -> None:
"""Update hvac mode and temperatures from coordinator data.
The coordinator reports a target temperature of 0 when the heater is
turned off. In that case, only the hvac mode and icon are updated and
the previous non-zero target temperature is preserved. When the
reported target temperature is non-zero, the stored target temperature
is updated to match the coordinator value.
"""
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if data := self.coordinator.data:
self._attr_current_temperature = data["current_temperature"]
self._attr_available = self._attr_current_temperature is not None
if (target_temp := data["target_temperature"]) == 0:
self._attr_hvac_mode = HVACMode.OFF
self._attr_icon = "mdi:radiator-off"
if self._attr_target_temperature is None:
if target_temp == 0:
self._attr_target_temperature = self._attr_min_temp
else:
self._attr_hvac_mode = HVACMode.HEAT
self._attr_icon = "mdi:radiator"
self._attr_target_temperature = target_temp
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_hvac_attributes()
super()._handle_coordinator_update()
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._update_hvac_attributes()

View File

@@ -87,7 +87,7 @@ class AdaxConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=data_schema,
)
wifi_ssid = user_input[WIFI_SSID]
wifi_ssid = user_input[WIFI_SSID].replace(" ", "")
wifi_pswd = user_input[WIFI_PSWD].replace(" ", "")
configurator = adax_local.AdaxConfig(wifi_ssid, wifi_pswd)

View File

@@ -20,10 +20,9 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_FORCE,
@@ -46,7 +45,6 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
type AdGuardConfigEntry = ConfigEntry[AdGuardData]
@@ -59,69 +57,6 @@ class AdGuardData:
version: str
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
def _get_adguard_instances(hass: HomeAssistant) -> list[AdGuardHome]:
"""Get the AdGuardHome instances."""
entries: list[AdGuardConfigEntry] = hass.config_entries.async_loaded_entries(
DOMAIN
)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
)
return [entry.runtime_data.client for entry in entries]
async def add_url(call: ServiceCall) -> None:
"""Service call to add a new filter subscription to AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.add_url(
allowlist=False, name=call.data[CONF_NAME], url=call.data[CONF_URL]
)
async def remove_url(call: ServiceCall) -> None:
"""Service call to remove a filter subscription from AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.remove_url(allowlist=False, url=call.data[CONF_URL])
async def enable_url(call: ServiceCall) -> None:
"""Service call to enable a filter subscription in AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.enable_url(allowlist=False, url=call.data[CONF_URL])
async def disable_url(call: ServiceCall) -> None:
"""Service call to disable a filter subscription in AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.disable_url(
allowlist=False, url=call.data[CONF_URL]
)
async def refresh(call: ServiceCall) -> None:
"""Service call to refresh the filter subscriptions in AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.refresh(
allowlist=False, force=call.data[CONF_FORCE]
)
hass.services.async_register(
DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
"""Set up AdGuard Home from a config entry."""
session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
@@ -144,9 +79,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def add_url(call: ServiceCall) -> None:
"""Service call to add a new filter subscription to AdGuard Home."""
await adguard.filtering.add_url(
allowlist=False, name=call.data[CONF_NAME], url=call.data[CONF_URL]
)
async def remove_url(call: ServiceCall) -> None:
"""Service call to remove a filter subscription from AdGuard Home."""
await adguard.filtering.remove_url(allowlist=False, url=call.data[CONF_URL])
async def enable_url(call: ServiceCall) -> None:
"""Service call to enable a filter subscription in AdGuard Home."""
await adguard.filtering.enable_url(allowlist=False, url=call.data[CONF_URL])
async def disable_url(call: ServiceCall) -> None:
"""Service call to disable a filter subscription in AdGuard Home."""
await adguard.filtering.disable_url(allowlist=False, url=call.data[CONF_URL])
async def refresh(call: ServiceCall) -> None:
"""Service call to refresh the filter subscriptions in AdGuard Home."""
await adguard.filtering.refresh(allowlist=False, force=call.data[CONF_FORCE])
hass.services.async_register(
DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
"""Unload AdGuard Home config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not hass.config_entries.async_loaded_entries(DOMAIN):
# This is the last loaded instance of AdGuard, deregister any services
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL)
hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL)
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
return unload_ok

View File

@@ -107,7 +107,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_hassio(
self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a Hass.io AdGuard Home app.
"""Prepare configuration for a Hass.io AdGuard Home add-on.
This flow is triggered by the discovery component.
"""

View File

@@ -52,7 +52,7 @@ class AdGuardHomeEntity(Entity):
def device_info(self) -> DeviceInfo:
"""Return device information about this AdGuard Home instance."""
if self._entry.source == SOURCE_HASSIO:
config_url = "homeassistant://app/a0d7b954_adguard"
config_url = "homeassistant://hassio/ingress/a0d7b954_adguard"
elif self.adguard.tls:
config_url = f"https://{self.adguard.host}:{self.adguard.port}"
else:

View File

@@ -9,8 +9,8 @@
},
"step": {
"hassio_confirm": {
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the app: {addon}?",
"title": "AdGuard Home via Home Assistant app"
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the add-on: {addon}?",
"title": "AdGuard Home via Home Assistant add-on"
},
"user": {
"data": {
@@ -76,11 +76,6 @@
}
}
},
"exceptions": {
"config_entry_not_loaded": {
"message": "Config entry not loaded."
}
},
"services": {
"add_url": {
"description": "Adds a new filter subscription to AdGuard Home.",

View File

@@ -9,13 +9,9 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
LightEntity,
filter_supported_color_modes,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
@@ -28,20 +24,13 @@ from .entity import AdsEntity
from .hub import AdsHub
CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness"
CONF_ADS_VAR_COLOR_TEMP_KELVIN = "adsvar_color_temp_kelvin"
CONF_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin"
CONF_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin"
STATE_KEY_BRIGHTNESS = "brightness"
STATE_KEY_COLOR_TEMP_KELVIN = "color_temp_kelvin"
DEFAULT_NAME = "ADS Light"
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string,
vol.Optional(CONF_ADS_VAR_COLOR_TEMP_KELVIN): cv.string,
vol.Optional(CONF_MIN_COLOR_TEMP_KELVIN): cv.positive_int,
vol.Optional(CONF_MAX_COLOR_TEMP_KELVIN): cv.positive_int,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
}
)
@@ -58,24 +47,9 @@ def setup_platform(
ads_var_enable: str = config[CONF_ADS_VAR]
ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS)
ads_var_color_temp_kelvin: str | None = config.get(CONF_ADS_VAR_COLOR_TEMP_KELVIN)
min_color_temp_kelvin: int | None = config.get(CONF_MIN_COLOR_TEMP_KELVIN)
max_color_temp_kelvin: int | None = config.get(CONF_MAX_COLOR_TEMP_KELVIN)
name: str = config[CONF_NAME]
add_entities(
[
AdsLight(
ads_hub,
ads_var_enable,
ads_var_brightness,
ads_var_color_temp_kelvin,
min_color_temp_kelvin,
max_color_temp_kelvin,
name,
)
]
)
add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)])
class AdsLight(AdsEntity, LightEntity):
@@ -86,40 +60,18 @@ class AdsLight(AdsEntity, LightEntity):
ads_hub: AdsHub,
ads_var_enable: str,
ads_var_brightness: str | None,
ads_var_color_temp_kelvin: str | None,
min_color_temp_kelvin: int | None,
max_color_temp_kelvin: int | None,
name: str,
) -> None:
"""Initialize AdsLight entity."""
super().__init__(ads_hub, name, ads_var_enable)
self._state_dict[STATE_KEY_BRIGHTNESS] = None
self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] = None
self._ads_var_brightness = ads_var_brightness
self._ads_var_color_temp_kelvin = ads_var_color_temp_kelvin
# Determine supported color modes
color_modes = {ColorMode.ONOFF}
if ads_var_brightness is not None:
color_modes.add(ColorMode.BRIGHTNESS)
if ads_var_color_temp_kelvin is not None:
color_modes.add(ColorMode.COLOR_TEMP)
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
# Set color temperature range (static config values take precedence over defaults)
if ads_var_color_temp_kelvin is not None:
self._attr_min_color_temp_kelvin = (
min_color_temp_kelvin
if min_color_temp_kelvin is not None
else DEFAULT_MIN_KELVIN
)
self._attr_max_color_temp_kelvin = (
max_color_temp_kelvin
if max_color_temp_kelvin is not None
else DEFAULT_MAX_KELVIN
)
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
else:
self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = {ColorMode.ONOFF}
async def async_added_to_hass(self) -> None:
"""Register device notification."""
@@ -132,23 +84,11 @@ class AdsLight(AdsEntity, LightEntity):
STATE_KEY_BRIGHTNESS,
)
if self._ads_var_color_temp_kelvin is not None:
await self.async_initialize_device(
self._ads_var_color_temp_kelvin,
pyads.PLCTYPE_UINT,
STATE_KEY_COLOR_TEMP_KELVIN,
)
@property
def brightness(self) -> int | None:
"""Return the brightness of the light (0..255)."""
return self._state_dict[STATE_KEY_BRIGHTNESS]
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature in Kelvin."""
return self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN]
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
@@ -157,8 +97,6 @@ class AdsLight(AdsEntity, LightEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on or set a specific dimmer value."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL)
if self._ads_var_brightness is not None and brightness is not None:
@@ -166,11 +104,6 @@ class AdsLight(AdsEntity, LightEntity):
self._ads_var_brightness, brightness, pyads.PLCTYPE_UINT
)
if self._ads_var_color_temp_kelvin is not None and color_temp is not None:
self._ads_hub.write_by_name(
self._ads_var_color_temp_kelvin, color_temp, pyads.PLCTYPE_UINT
)
def turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL)

View File

@@ -1,17 +1,23 @@
"""Advantage Air climate integration."""
from advantage_air import advantage_air
from datetime import timedelta
import logging
from advantage_air import ApiError, advantage_air
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ADVANTAGE_AIR_RETRY, DOMAIN
from .coordinator import AdvantageAirCoordinator, AdvantageAirDataConfigEntry
from .services import async_setup_services
from .const import ADVANTAGE_AIR_RETRY
from .models import AdvantageAirData
type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirData]
ADVANTAGE_AIR_SYNC_INTERVAL = 15
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
@@ -23,13 +29,8 @@ PLATFORMS = [
Platform.UPDATE,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
_LOGGER = logging.getLogger(__name__)
REQUEST_REFRESH_DELAY = 0.5
async def async_setup_entry(
@@ -45,10 +46,27 @@ async def async_setup_entry(
retry=ADVANTAGE_AIR_RETRY,
)
coordinator = AdvantageAirCoordinator(hass, entry, api)
async def async_get():
try:
return await api.async_get()
except ApiError as err:
raise UpdateFailed(err) from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name="Advantage Air",
update_method=async_get,
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.runtime_data = AdvantageAirData(coordinator, api)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
PARALLEL_UPDATES = 0
@@ -24,23 +24,19 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir Binary Sensor platform."""
coordinator = config_entry.runtime_data
instance = config_entry.runtime_data
entities: list[BinarySensorEntity] = []
if aircons := coordinator.data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirFilter(coordinator, ac_key))
entities.append(AdvantageAirFilter(instance, ac_key))
for zone_key, zone in ac_device["zones"].items():
# Only add motion sensor when motion is enabled
if zone["motionConfig"] >= 2:
entities.append(
AdvantageAirZoneMotion(coordinator, ac_key, zone_key)
)
entities.append(AdvantageAirZoneMotion(instance, ac_key, zone_key))
# Only add MyZone if it is available
if zone["type"] != 0:
entities.append(
AdvantageAirZoneMyZone(coordinator, ac_key, zone_key)
)
entities.append(AdvantageAirZoneMyZone(instance, ac_key, zone_key))
async_add_entities(entities)
@@ -51,9 +47,9 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_name = "Filter"
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air Filter sensor."""
super().__init__(coordinator, ac_key)
super().__init__(instance, ac_key)
self._attr_unique_id += "-filter"
@property
@@ -67,11 +63,9 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Motion sensor."""
super().__init__(coordinator, ac_key, zone_key)
super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} motion"
self._attr_unique_id += "-motion"
@@ -87,11 +81,9 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity):
_attr_entity_registry_enabled_default = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone MyZone sensor."""
super().__init__(coordinator, ac_key, zone_key)
super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} myZone"
self._attr_unique_id += "-myzone"

View File

@@ -31,8 +31,8 @@ from .const import (
ADVANTAGE_AIR_STATE_ON,
ADVANTAGE_AIR_STATE_OPEN,
)
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_HVAC_MODES = {
"heat": HVACMode.HEAT,
@@ -90,16 +90,16 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir climate platform."""
coordinator = config_entry.runtime_data
instance = config_entry.runtime_data
entities: list[ClimateEntity] = []
if aircons := coordinator.data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirAC(coordinator, ac_key))
entities.append(AdvantageAirAC(instance, ac_key))
for zone_key, zone in ac_device["zones"].items():
# Only add zone climate control when zone is in temperature control
if zone["type"] > 0:
entities.append(AdvantageAirZone(coordinator, ac_key, zone_key))
entities.append(AdvantageAirZone(instance, ac_key, zone_key))
async_add_entities(entities)
@@ -114,9 +114,9 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
_attr_name = None
_support_preset = ClimateEntityFeature(0)
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an AdvantageAir AC unit."""
super().__init__(coordinator, ac_key)
super().__init__(instance, ac_key)
self._attr_preset_modes = [ADVANTAGE_AIR_MYZONE]
@@ -282,11 +282,9 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
_attr_max_temp = 32
_attr_min_temp = 16
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an AdvantageAir Zone control."""
super().__init__(coordinator, ac_key, zone_key)
super().__init__(instance, ac_key, zone_key)
self._attr_name = self._zone["name"]
@property

View File

@@ -1,59 +0,0 @@
"""Coordinator for the Advantage Air integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from advantage_air import ApiError, advantage_air
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
ADVANTAGE_AIR_SYNC_INTERVAL = 15
REQUEST_REFRESH_DELAY = 0.5
_LOGGER = logging.getLogger(__name__)
type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirCoordinator]
class AdvantageAirCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Advantage Air coordinator."""
config_entry: AdvantageAirDataConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
api: advantage_air,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="Advantage Air",
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
self.api = api
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the API."""
try:
return await self.api.async_get()
except ApiError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err

View File

@@ -13,8 +13,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
PARALLEL_UPDATES = 0
@@ -26,24 +26,24 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir cover platform."""
coordinator = config_entry.runtime_data
instance = config_entry.runtime_data
entities: list[CoverEntity] = []
if aircons := coordinator.data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
for zone_key, zone in ac_device["zones"].items():
# Only add zone vent controls when zone in vent control mode.
if zone["type"] == 0:
entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key))
if things := coordinator.data.get("myThings"):
entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key))
if things := instance.coordinator.data.get("myThings"):
for thing in things["things"].values():
if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2"
entities.append(
AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.BLIND)
AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND)
)
elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door"
entities.append(
AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.GARAGE)
AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE)
)
async_add_entities(entities)
@@ -58,11 +58,9 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity):
| CoverEntityFeature.SET_POSITION
)
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Vent."""
super().__init__(coordinator, ac_key, zone_key)
super().__init__(instance, ac_key, zone_key)
self._attr_name = self._zone["name"]
@property
@@ -108,12 +106,12 @@ class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity):
def __init__(
self,
coordinator: AdvantageAirCoordinator,
instance: AdvantageAirData,
thing: dict[str, Any],
device_class: CoverDeviceClass,
) -> None:
"""Initialize an Advantage Air Things Cover."""
super().__init__(coordinator, thing)
super().__init__(instance, thing)
self._attr_device_class = device_class
@property

View File

@@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data = config_entry.runtime_data.data
data = config_entry.runtime_data.coordinator.data
# Return only the relevant children
return {

View File

@@ -9,17 +9,17 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AdvantageAirCoordinator
from .models import AdvantageAirData
class AdvantageAirEntity(CoordinatorEntity[AdvantageAirCoordinator]):
class AdvantageAirEntity(CoordinatorEntity):
"""Parent class for Advantage Air Entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: AdvantageAirCoordinator) -> None:
def __init__(self, instance: AdvantageAirData) -> None:
"""Initialize common aspects of an Advantage Air entity."""
super().__init__(coordinator)
super().__init__(instance.coordinator)
self._attr_unique_id: str = self.coordinator.data["system"]["rid"]
def update_handle_factory(self, func, *keys):
@@ -41,9 +41,9 @@ class AdvantageAirEntity(CoordinatorEntity[AdvantageAirCoordinator]):
class AdvantageAirAcEntity(AdvantageAirEntity):
"""Parent class for Advantage Air AC Entities."""
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize common aspects of an Advantage Air ac entity."""
super().__init__(coordinator)
super().__init__(instance)
self.ac_key: str = ac_key
self._attr_unique_id += f"-{ac_key}"
@@ -56,7 +56,7 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"],
)
self.async_update_ac = self.update_handle_factory(
coordinator.api.aircon.async_update_ac, self.ac_key
instance.api.aircon.async_update_ac, self.ac_key
)
@property
@@ -73,16 +73,14 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
class AdvantageAirZoneEntity(AdvantageAirAcEntity):
"""Parent class for Advantage Air Zone Entities."""
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize common aspects of an Advantage Air zone entity."""
super().__init__(coordinator, ac_key)
super().__init__(instance, ac_key)
self.zone_key: str = zone_key
self._attr_unique_id += f"-{zone_key}"
self.async_update_zone = self.update_handle_factory(
coordinator.api.aircon.async_update_zone, self.ac_key, self.zone_key
instance.api.aircon.async_update_zone, self.ac_key, self.zone_key
)
@property
@@ -95,11 +93,9 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
_attr_name = None
def __init__(
self, coordinator: AdvantageAirCoordinator, thing: dict[str, Any]
) -> None:
def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None:
"""Initialize common aspects of an Advantage Air Things entity."""
super().__init__(coordinator)
super().__init__(instance)
self._id = thing["id"]
self._attr_unique_id += f"-{self._id}"
@@ -112,7 +108,7 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
name=thing["name"],
)
self.async_update_value = self.update_handle_factory(
coordinator.api.things.async_update_value, self._id
instance.api.things.async_update_value, self._id
)
@property
@@ -121,7 +117,7 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
return self.coordinator.data["myThings"]["things"][self._id]
@property
def is_on(self) -> bool:
def is_on(self):
"""Return if the thing is considered on."""
return self._data["value"] > 0

View File

@@ -9,8 +9,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirEntity, AdvantageAirThingEntity
from .models import AdvantageAirData
async def async_setup_entry(
@@ -20,21 +20,21 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir light platform."""
coordinator = config_entry.runtime_data
instance = config_entry.runtime_data
entities: list[LightEntity] = []
if my_lights := coordinator.data.get("myLights"):
if my_lights := instance.coordinator.data.get("myLights"):
for light in my_lights["lights"].values():
if light.get("relay"):
entities.append(AdvantageAirLight(coordinator, light))
entities.append(AdvantageAirLight(instance, light))
else:
entities.append(AdvantageAirLightDimmable(coordinator, light))
if things := coordinator.data.get("myThings"):
entities.append(AdvantageAirLightDimmable(instance, light))
if things := instance.coordinator.data.get("myThings"):
for thing in things["things"].values():
if thing["channelDipState"] == 4: # 4 = "Light (on/off)""
entities.append(AdvantageAirThingLight(coordinator, thing))
entities.append(AdvantageAirThingLight(instance, thing))
elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)""
entities.append(AdvantageAirThingLightDimmable(coordinator, thing))
entities.append(AdvantageAirThingLightDimmable(instance, thing))
async_add_entities(entities)
@@ -45,11 +45,9 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_name = None
def __init__(
self, coordinator: AdvantageAirCoordinator, light: dict[str, Any]
) -> None:
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
"""Initialize an Advantage Air Light."""
super().__init__(coordinator)
super().__init__(instance)
self._id: str = light["id"]
self._attr_unique_id += f"-{self._id}"
@@ -61,7 +59,7 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
name=light["name"],
)
self.async_update_state = self.update_handle_factory(
coordinator.api.lights.async_update_state, self._id
instance.api.lights.async_update_state, self._id
)
@property
@@ -89,13 +87,11 @@ class AdvantageAirLightDimmable(AdvantageAirLight):
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(
self, coordinator: AdvantageAirCoordinator, light: dict[str, Any]
) -> None:
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
"""Initialize an Advantage Air Dimmable Light."""
super().__init__(coordinator, light)
super().__init__(instance, light)
self.async_update_value = self.update_handle_factory(
coordinator.api.lights.async_update_value, self._id
instance.api.lights.async_update_value, self._id
)
@property

View File

@@ -0,0 +1,17 @@
"""The Advantage Air integration models."""
from __future__ import annotations
from dataclasses import dataclass
from advantage_air import advantage_air
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@dataclass
class AdvantageAirData:
"""Data for the Advantage Air integration."""
coordinator: DataUpdateCoordinator
api: advantage_air

View File

@@ -1,99 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: |
Add mock_setup_entry common fixture.
Test unique_id of the entry in happy flow.
Split duplicate entry test from happy flow, use mock_config_entry.
Error flow should end in CREATE_ENTRY to test recovery.
Add data_description for ip_address (and port) to strings.json - tests fail with:
"Translation not found for advantage_air: config.step.user.data_description.ip_address"
config-flow:
status: todo
comment: Data descriptions missing
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: todo
docs-removal-instructions: todo
entity-event-setup:
status: exempt
comment: Entities do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to be set.
docs-installation-parameters: done
entity-unavailable:
status: todo
comment: MyZone temp entity should be unavailable when MyZone is disabled rather than returning None.
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: Integration connects to local device without authentication.
test-coverage:
status: todo
comment: |
Patch the library instead of mocking at integration level.
Split binary sensor tests into multiple tests (enable entities etc).
Split tests into Creation (right entities with right values), Actions (right library calls), and Other behaviors.
# Gold
devices:
status: todo
comment: Consider making every zone its own device for better naming and room assignment. Breaking change to split cover entities to separate devices.
diagnostics: done
discovery-update-info:
status: exempt
comment: Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices, not discoverable.
discovery:
status: exempt
comment: Check mDNS, DHCP, SSDP confirmed not feasible. Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: AC zones are static per unit and configured on the device itself.
entity-category: done
entity-device-class:
status: todo
comment: Consider using UPDATE device class for app update binary sensor instead of custom.
entity-disabled-by-default: done
entity-translations: todo
exception-translations:
status: todo
comment: HomeAssistantError in entity.py and ServiceValidationError in climate.py
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: Integration does not raise repair issues.
stale-devices:
status: exempt
comment: Zones are part of the AC unit, not separate removable devices.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -5,8 +5,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_INACTIVE = "Inactive"
@@ -18,12 +18,10 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir select platform."""
coordinator = config_entry.runtime_data
instance = config_entry.runtime_data
if aircons := coordinator.data.get("aircons"):
async_add_entities(
AdvantageAirMyZone(coordinator, ac_key) for ac_key in aircons
)
if aircons := instance.coordinator.data.get("aircons"):
async_add_entities(AdvantageAirMyZone(instance, ac_key) for ac_key in aircons)
class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
@@ -32,16 +30,16 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
_attr_icon = "mdi:home-thermometer"
_attr_name = "MyZone"
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air MyZone control."""
super().__init__(coordinator, ac_key)
super().__init__(instance, ac_key)
self._attr_unique_id += "-myzone"
self._attr_options = [ADVANTAGE_AIR_INACTIVE]
self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE}
self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0}
if "aircons" in coordinator.data:
for zone in coordinator.data["aircons"][ac_key]["zones"].values():
if "aircons" in instance.coordinator.data:
for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values():
if zone["type"] > 0:
self._name_to_number[zone["name"]] = zone["number"]
self._number_to_name[zone["number"]] = zone["name"]

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from decimal import Decimal
from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -12,15 +14,17 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_OPEN
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes"
ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min"
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
PARALLEL_UPDATES = 0
@@ -32,25 +36,30 @@ async def async_setup_entry(
) -> None:
"""Set up AdvantageAir sensor platform."""
coordinator = config_entry.runtime_data
instance = config_entry.runtime_data
entities: list[SensorEntity] = []
if aircons := coordinator.data.get("aircons"):
if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirTimeTo(coordinator, ac_key, "On"))
entities.append(AdvantageAirTimeTo(coordinator, ac_key, "Off"))
entities.append(AdvantageAirTimeTo(instance, ac_key, "On"))
entities.append(AdvantageAirTimeTo(instance, ac_key, "Off"))
for zone_key, zone in ac_device["zones"].items():
# Only show damper and temp sensors when zone is in temperature control
if zone["type"] != 0:
entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key))
entities.append(AdvantageAirZoneTemp(coordinator, ac_key, zone_key))
entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key))
entities.append(AdvantageAirZoneTemp(instance, ac_key, zone_key))
# Only show wireless signal strength sensors when using wireless sensors
if zone["rssi"] > 0:
entities.append(
AdvantageAirZoneSignal(coordinator, ac_key, zone_key)
)
entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key))
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
{vol.Required("minutes"): cv.positive_int},
"set_time_to",
)
class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
"""Representation of Advantage Air timer control."""
@@ -58,11 +67,9 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
_attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, action: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None:
"""Initialize the Advantage Air timer control."""
super().__init__(coordinator, ac_key)
super().__init__(instance, ac_key)
self.action = action
self._time_key = f"countDownTo{action}"
self._attr_name = f"Time to {action}"
@@ -93,11 +100,9 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Vent Sensor."""
super().__init__(coordinator, ac_key, zone_key=zone_key)
super().__init__(instance, ac_key, zone_key=zone_key)
self._attr_name = f"{self._zone['name']} vent"
self._attr_unique_id += "-vent"
@@ -123,11 +128,9 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone wireless signal sensor."""
super().__init__(coordinator, ac_key, zone_key)
super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} signal"
self._attr_unique_id += "-signal"
@@ -159,11 +162,9 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity):
_attr_entity_registry_enabled_default = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Temp Sensor."""
super().__init__(coordinator, ac_key, zone_key)
super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} temperature"
self._attr_unique_id += "-temp"

View File

@@ -1,25 +0,0 @@
"""Services for Advantage Air integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_time_to",
entity_domain=SENSOR_DOMAIN,
schema={vol.Required("minutes"): cv.positive_int},
func="set_time_to",
)

View File

@@ -17,11 +17,6 @@
}
}
},
"exceptions": {
"update_failed": {
"message": "An error occurred while updating from the Advantage Air API: {error}"
}
},
"services": {
"set_time_to": {
"description": "Controls timers to turn the system on or off after a set number of minutes.",

Some files were not shown because too many files have changed in this diff Show More