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
203 changed files with 6116 additions and 6468 deletions

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

@@ -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

@@ -40,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,

View File

@@ -847,8 +847,8 @@ rules:
## Development Commands
### Code Quality & Linting
- **Run all linters on all files**: `prek run --all-files`
- **Run linters on staged files only**: `prek run`
- **Run all linters on all files**: `pre-commit run --all-files`
- **Run linters on staged files only**: `pre-commit run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`

View File

@@ -59,6 +59,7 @@ env:
# 15 is the latest version
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
PRE_COMMIT_CACHE: ~/.cache/pre-commit
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
@@ -82,6 +83,7 @@ jobs:
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
pre-commit_cache_key: ${{ steps.generate_pre-commit_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }}
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
@@ -109,6 +111,11 @@ jobs:
hashFiles('requirements_all.txt') }}-${{
hashFiles('homeassistant/package_constraints.txt') }}-${{
hashFiles('script/gen_requirements_all.py') }}" >> $GITHUB_OUTPUT
- name: Generate partial pre-commit restore key
id: generate_pre-commit_cache_key
run: >-
echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{
hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
@@ -237,8 +244,8 @@ jobs:
echo "skip_coverage: ${skip_coverage}"
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
prek:
name: Run prek checks
pre-commit:
name: Prepare pre-commit base
runs-on: *runs-on-ubuntu
needs: [info]
if: |
@@ -247,23 +254,147 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- *checkout
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: &key-pre-commit-venv >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: *actions-cache
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
key: &key-pre-commit-env >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Install pre-commit dependencies
if: steps.cache-precommit.outputs.cache-hit != 'true'
run: |
. venv/bin/activate
pre-commit install-hooks
lint-ruff-format:
name: Check ruff-format
runs-on: *runs-on-ubuntu
needs: &needs-pre-commit
- info
- pre-commit
steps:
- *checkout
- *setup-python-default
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
fail-on-cache-miss: true
key: *key-pre-commit-venv
- &cache-restore-pre-commit-env
name: Restore pre-commit environment from cache
id: cache-precommit
uses: *actions-cache-restore
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
key: *key-pre-commit-env
- name: Run ruff-format
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-ruff:
name: Check ruff
runs-on: *runs-on-ubuntu
needs: *needs-pre-commit
steps:
- *checkout
- *setup-python-default
- *cache-restore-pre-commit-venv
- *cache-restore-pre-commit-env
- name: Run ruff
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-other:
name: Check other linters
runs-on: *runs-on-ubuntu
needs: *needs-pre-commit
steps:
- *checkout
- *setup-python-default
- *cache-restore-pre-commit-venv
- *cache-restore-pre-commit-env
- name: Register yamllint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
- name: Run yamllint
run: |
. venv/bin/activate
pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure
- name: Register check-json problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-json.json"
- name: Run check-json
run: |
. venv/bin/activate
pre-commit run --hook-stage manual check-json --all-files --show-diff-on-failure
- name: Run prettier (fully)
if: needs.info.outputs.test_full_suite == 'true'
run: |
. venv/bin/activate
pre-commit run --hook-stage manual prettier --all-files --show-diff-on-failure
- name: Run prettier (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
run: |
. venv/bin/activate
shopt -s globstar
pre-commit run --hook-stage manual prettier --show-diff-on-failure --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*}
- name: Register check executables problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
- name: Run executables check
run: |
. venv/bin/activate
pre-commit run --hook-stage manual check-executables-have-shebangs --all-files --show-diff-on-failure
- name: Register codespell problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@91fd7d7cf70ae1dee9f4f44e7dfa5d1073fe6623 # v1.0.11
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
RUFF_OUTPUT_FORMAT: github
- name: Run codespell
run: |
. venv/bin/activate
pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files
lint-hadolint:
name: Check ${{ matrix.file }}
@@ -303,7 +434,7 @@ jobs:
- &setup-python-matrix
name: Set up Python ${{ matrix.python-version }}
id: python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: *actions-setup-python
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -316,7 +447,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: *actions-cache
with:
path: venv
key: &key-python-venv >-
@@ -431,7 +562,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: *actions-cache-restore
with:
path: *path-apt-cache
fail-on-cache-miss: true
@@ -448,13 +579,7 @@ jobs:
-o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
libturbojpeg
- *checkout
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: *actions-setup-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- *setup-python-default
- &cache-restore-python-default
name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
@@ -657,7 +782,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
steps:
- *cache-restore-apt
@@ -696,7 +823,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
- prepare-pytest-full
if: |
@@ -820,7 +949,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -935,7 +1066,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -1069,7 +1202,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
if: |
needs.info.outputs.lint_only != 'true'

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: "/language:python"

View File

@@ -46,7 +46,7 @@ repos:
# 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

@@ -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",
},

6
.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

2
CODEOWNERS generated
View File

@@ -1068,8 +1068,6 @@ 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
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio

View File

@@ -85,22 +85,6 @@ class AirzoneSystemEntity(AirzoneEntity):
value = system[key]
return value
async def _async_update_sys_params(self, params: dict[str, Any]) -> None:
"""Send system parameters to API."""
_params = {
API_SYSTEM_ID: self.system_id,
**params,
}
_LOGGER.debug("update_sys_params=%s", _params)
try:
await self.coordinator.airzone.set_sys_parameters(_params)
except AirzoneError as error:
raise HomeAssistantError(
f"Failed to set system {self.entity_id}: {error}"
) from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
class AirzoneHotWaterEntity(AirzoneEntity):
"""Define an Airzone Hot Water entity."""

View File

@@ -20,7 +20,6 @@ from aioairzone.const import (
AZD_MODES,
AZD_Q_ADAPT,
AZD_SLEEP,
AZD_SYSTEMS,
AZD_ZONES,
)
@@ -31,7 +30,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity
from .entity import AirzoneEntity, AirzoneZoneEntity
@dataclass(frozen=True, kw_only=True)
@@ -86,18 +85,6 @@ def main_zone_options(
return [k for k, v in options.items() if v in modes]
SYSTEM_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_Q_ADAPT,
entity_category=EntityCategory.CONFIG,
key=AZD_Q_ADAPT,
options=list(Q_ADAPT_DICT),
options_dict=Q_ADAPT_DICT,
translation_key="q_adapt",
),
)
MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_MODE,
@@ -106,6 +93,14 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
options_fn=main_zone_options,
translation_key="modes",
),
AirzoneSelectDescription(
api_param=API_Q_ADAPT,
entity_category=EntityCategory.CONFIG,
key=AZD_Q_ADAPT,
options=list(Q_ADAPT_DICT),
options_dict=Q_ADAPT_DICT,
translation_key="q_adapt",
),
)
@@ -145,37 +140,16 @@ async def async_setup_entry(
"""Add Airzone select from a config_entry."""
coordinator = entry.runtime_data
added_systems: set[str] = set()
added_zones: set[str] = set()
def _async_entity_listener() -> None:
"""Handle additions of select."""
entities: list[AirzoneBaseSelect] = []
systems_data = coordinator.data.get(AZD_SYSTEMS, {})
received_systems = set(systems_data)
new_systems = received_systems - added_systems
if new_systems:
entities.extend(
AirzoneSystemSelect(
coordinator,
description,
entry,
system_id,
systems_data.get(system_id),
)
for system_id in new_systems
for description in SYSTEM_SELECT_TYPES
if description.key in systems_data.get(system_id)
)
added_systems.update(new_systems)
zones_data = coordinator.data.get(AZD_ZONES, {})
received_zones = set(zones_data)
new_zones = received_zones - added_zones
if new_zones:
entities.extend(
entities: list[AirzoneZoneSelect] = [
AirzoneZoneSelect(
coordinator,
description,
@@ -187,8 +161,8 @@ async def async_setup_entry(
for description in MAIN_ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
and zones_data.get(system_zone_id).get(AZD_MASTER) is True
)
entities.extend(
]
entities += [
AirzoneZoneSelect(
coordinator,
description,
@@ -199,11 +173,10 @@ async def async_setup_entry(
for system_zone_id in new_zones
for description in ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
)
]
async_add_entities(entities)
added_zones.update(new_zones)
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
@@ -230,38 +203,6 @@ class AirzoneBaseSelect(AirzoneEntity, SelectEntity):
self._attr_current_option = self._get_current_option()
class AirzoneSystemSelect(AirzoneSystemEntity, AirzoneBaseSelect):
"""Define an Airzone System select."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: AirzoneSelectDescription,
entry: ConfigEntry,
system_id: str,
system_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, system_data)
self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}"
self.entity_description = description
self._attr_options = self.entity_description.options_fn(
system_data, description.options_dict
)
self.values_dict = {v: k for k, v in description.options_dict.items()}
self._async_update_attrs()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
param = self.entity_description.api_param
value = self.entity_description.options_dict[option]
await self._async_update_sys_params({param: value})
class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect):
"""Define an Airzone Zone select."""

View File

@@ -4,8 +4,6 @@ from __future__ import annotations
import logging
import dateutil
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
@@ -181,7 +179,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
LAST_S_TEST: SensorEntityDescription(
key=LAST_S_TEST,
translation_key="last_self_test",
device_class=SensorDeviceClass.TIMESTAMP,
),
"lastxfer": SensorEntityDescription(
key="lastxfer",
@@ -235,7 +232,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
"masterupd": SensorEntityDescription(
key="masterupd",
translation_key="master_update",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"maxlinev": SensorEntityDescription(
@@ -369,7 +365,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
"starttime": SensorEntityDescription(
key="starttime",
translation_key="startup_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"statflag": SensorEntityDescription(
@@ -421,19 +416,16 @@ SENSORS: dict[str, SensorEntityDescription] = {
"xoffbat": SensorEntityDescription(
key="xoffbat",
translation_key="transfer_from_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xoffbatt": SensorEntityDescription(
key="xoffbatt",
translation_key="transfer_from_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xonbatt": SensorEntityDescription(
key="xonbatt",
translation_key="transfer_to_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
}
@@ -537,13 +529,7 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
self._attr_native_value = None
return
data = self.coordinator.data[key]
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
self._attr_native_value = dateutil.parser.parse(data)
return
self._attr_native_value, inferred_unit = infer_unit(data)
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit

View File

@@ -3,8 +3,9 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
import logging
import math
from pymicro_vad import MicroVad
from pysilero_vad import SileroVoiceActivityDetector
from pyspeex_noise import AudioProcessor
from .const import BYTES_PER_CHUNK
@@ -42,8 +43,8 @@ class AudioEnhancer(ABC):
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
class MicroVadSpeexEnhancer(AudioEnhancer):
"""Audio enhancer that runs microVAD and speex."""
class SileroVadSpeexEnhancer(AudioEnhancer):
"""Audio enhancer that runs Silero VAD and speex."""
def __init__(
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
@@ -69,21 +70,49 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
self.noise_suppression,
)
self.vad: MicroVad | None = None
self.vad: SileroVoiceActivityDetector | None = None
# We get 10ms chunks but Silero works on 32ms chunks, so we have to
# buffer audio. The previous speech probability is used until enough
# audio has been buffered.
self._vad_buffer: bytearray | None = None
self._vad_buffer_chunks = 0
self._vad_buffer_chunk_idx = 0
self._last_speech_probability: float | None = None
if self.is_vad_enabled:
self.vad = MicroVad()
_LOGGER.debug("Initialized microVAD")
self.vad = SileroVoiceActivityDetector()
# VAD buffer is a multiple of 10ms, but Silero VAD needs 32ms.
self._vad_buffer_chunks = int(
math.ceil(self.vad.chunk_bytes() / BYTES_PER_CHUNK)
)
self._vad_leftover_bytes = self.vad.chunk_bytes() - BYTES_PER_CHUNK
self._vad_buffer = bytearray(self.vad.chunk_bytes())
_LOGGER.debug("Initialized Silero VAD")
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
speech_probability: float | None = None
assert len(audio) == BYTES_PER_CHUNK
if self.vad is not None:
# Run VAD
speech_probability = self.vad.Process10ms(audio)
assert self._vad_buffer is not None
start_idx = self._vad_buffer_chunk_idx * BYTES_PER_CHUNK
self._vad_buffer[start_idx : start_idx + BYTES_PER_CHUNK] = audio
self._vad_buffer_chunk_idx += 1
if self._vad_buffer_chunk_idx >= self._vad_buffer_chunks:
# We have enough data to run Silero VAD (32 ms)
self._last_speech_probability = self.vad.process_chunk(
self._vad_buffer[: self.vad.chunk_bytes()]
)
# Copy leftover audio that wasn't processed to start
self._vad_buffer[: self._vad_leftover_bytes] = self._vad_buffer[
-self._vad_leftover_bytes :
]
self._vad_buffer_chunk_idx = 0
if self.audio_processor is not None:
# Run noise suppression and auto gain
@@ -92,5 +121,5 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
return EnhancedAudioChunk(
audio=audio,
timestamp_ms=timestamp_ms,
speech_probability=speech_probability,
speech_probability=self._last_speech_probability,
)

View File

@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["pymicro-vad==1.0.1", "pyspeex-noise==1.0.2"]
"requirements": ["pysilero-vad==3.2.0", "pyspeex-noise==1.0.2"]
}

View File

@@ -55,7 +55,7 @@ from homeassistant.util import (
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, SileroVadSpeexEnhancer
from .const import (
ACKNOWLEDGE_PATH,
BYTES_PER_CHUNK,
@@ -633,7 +633,7 @@ class PipelineRun:
# Initialize with audio settings
if self.audio_settings.needs_processor and (self.audio_enhancer is None):
# Default audio enhancer
self.audio_enhancer = MicroVadSpeexEnhancer(
self.audio_enhancer = SileroVadSpeexEnhancer(
self.audio_settings.auto_gain_dbfs,
self.audio_settings.noise_suppression_level,
self.audio_settings.is_vad_enabled,

View File

@@ -1,6 +1,5 @@
"""The BSB-Lan integration."""
import asyncio
import dataclasses
from bsblan import (
@@ -78,16 +77,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
bsblan = BSBLAN(config, session)
try:
# Initialize the client first - this sets up internal caches and validates
# the connection by fetching firmware version
# Initialize the client first - this sets up internal caches and validates the connection
await bsblan.initialize()
# Fetch device metadata in parallel for faster startup
device, info, static = await asyncio.gather(
bsblan.device(),
bsblan.info(),
bsblan.static_values(),
)
# Fetch all required device metadata
device = await bsblan.device()
info = await bsblan.info()
static = await bsblan.static_values()
except BSBLANConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
@@ -115,10 +110,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
# Perform first refresh of fast coordinator (required for entities)
# Perform first refresh of both coordinators
await fast_coordinator.async_config_entry_first_refresh()
# Refresh slow coordinator - don't fail if DHW is not available
# Try to refresh slow coordinator, but don't fail if DHW is not available
# This allows the integration to work even if the device doesn't support DHW
await slow_coordinator.async_refresh()

View File

@@ -111,17 +111,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
return None
return self.coordinator.data.state.target_temperature.value
@property
def _hvac_mode_value(self) -> int | str | None:
"""Return the raw hvac_mode value from the coordinator."""
if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
return None
return hvac_mode.value
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation ie. heat, cool mode."""
if (hvac_mode_value := self._hvac_mode_value) is None:
hvac_mode_value = self.coordinator.data.state.hvac_mode.value
if hvac_mode_value is None:
return None
# BSB-Lan returns integer values: 0=off, 1=auto, 2=eco, 3=heat
if isinstance(hvac_mode_value, int):
@@ -131,8 +125,9 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
hvac_mode_value = self.coordinator.data.state.hvac_mode.value
# BSB-Lan mode 2 is eco/reduced mode
if self._hvac_mode_value == 2:
if hvac_mode_value == 2:
return PRESET_ECO
return PRESET_NONE

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
from datetime import timedelta
from random import randint
from bsblan import (
BSBLAN,
@@ -22,17 +23,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER, SCAN_INTERVAL_FAST, SCAN_INTERVAL_SLOW
# Filter lists for optimized API calls - only fetch parameters we actually use
# This significantly reduces response time (~0.2s per parameter saved)
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
SENSOR_INCLUDE = ["current_temperature", "outside_temperature"]
DHW_STATE_INCLUDE = [
"operating_mode",
"nominal_setpoint",
"dhw_actual_value_top_temperature",
]
DHW_CONFIG_INCLUDE = ["reduced_setpoint", "nominal_setpoint_max"]
@dataclass
class BSBLanFastData:
@@ -90,18 +80,26 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
config_entry,
client,
name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}",
update_interval=SCAN_INTERVAL_FAST,
update_interval=self._get_update_interval(),
)
def _get_update_interval(self) -> timedelta:
"""Get the update interval with a random offset.
Add a random number of seconds to avoid timeouts when
the BSB-Lan device is already/still busy retrieving data,
e.g. for MQTT or internal logging.
"""
return SCAN_INTERVAL_FAST + timedelta(seconds=randint(1, 8))
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-Lan device."""
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
# This reduces response time significantly (~0.2s per parameter)
state = await self.client.state(include=STATE_INCLUDE)
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
# Fetch fast-changing data (state, sensor, DHW state)
state = await self.client.state()
sensor = await self.client.sensor()
dhw = await self.client.hot_water_state()
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
@@ -113,6 +111,9 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
f"Error while establishing connection with BSB-Lan device at {host}"
) from err
# Update the interval with random jitter for next update
self.update_interval = self._get_update_interval()
return BSBLanFastData(
state=state,
sensor=sensor,
@@ -142,8 +143,8 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
"""Fetch slow-changing data from the BSB-Lan device."""
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
# Fetch slow-changing configuration data
dhw_config = await self.client.hot_water_config()
dhw_schedule = await self.client.hot_water_schedule()
except AttributeError:

View File

@@ -29,11 +29,7 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
name=data.device.name,
manufacturer="BSBLAN Inc.",
model=(
data.info.device_identification.value
if data.info.device_identification
else None
),
model=data.info.device_identification.value,
sw_version=data.device.version,
configuration_url=f"http://{host}",
)

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
"requirements": ["python-bsblan==4.1.0"],
"requirements": ["python-bsblan==3.1.6"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -50,6 +50,7 @@ from . import (
from .client import CloudClient
from .const import (
CONF_ACCOUNT_LINK_SERVER,
CONF_ACCOUNTS_SERVER,
CONF_ACME_SERVER,
CONF_ALEXA,
CONF_ALIASES,
@@ -137,6 +138,7 @@ _BASE_CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
vol.Optional(CONF_ACCOUNTS_SERVER): str,
vol.Optional(CONF_ACME_SERVER): str,
vol.Optional(CONF_API_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str,

View File

@@ -76,6 +76,7 @@ CONF_GOOGLE_ACTIONS = "google_actions"
CONF_USER_POOL_ID = "user_pool_id"
CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server"
CONF_API_SERVER = "api_server"
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.9.0"],
"requirements": ["hass-nabucasa==1.7.0"],
"single_config_entry": true
}

View File

@@ -461,7 +461,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
key="sleep/timeInBed",
translation_key="sleep_time_in_bed",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:bed",
icon="mdi:hotel",
device_class=SensorDeviceClass.DURATION,
scope=FitbitScope.SLEEP,
state_class=SensorStateClass.TOTAL_INCREASING,

View File

@@ -31,7 +31,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
)
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_SMS_CODE): str,
vol.Required(CONF_SMS_CODE): int,
}
)
@@ -75,7 +75,7 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
return errors, False
async def _async_verify_sms_code(
self, sms_code: str
self, sms_code: int
) -> tuple[dict[str, str], str | None]:
"""Verify SMS code and return errors and access_token."""
errors: dict[str, str] = {}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["fressnapftracker==0.2.1"]
"requirements": ["fressnapftracker==0.2.0"]
}

View File

@@ -164,12 +164,13 @@ def _async_wol_buttons_list(
class FritzBoxWOLButton(FritzDeviceBase, ButtonEntity):
"""Defines a FRITZ!Box Tools Wake On LAN button."""
_attr_icon = "mdi:lan-pending"
_attr_entity_registry_enabled_default = False
_attr_translation_key = "wake_on_lan"
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize Fritz!Box WOL button."""
super().__init__(avm_wrapper, device)
self._name = f"{self.hostname} Wake on LAN"
self._attr_unique_id = f"{self._mac}_wake_on_lan"
self._is_available = True

View File

@@ -10,7 +10,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEFAULT_DEVICE_NAME
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .entity import FritzDeviceBase
from .helpers import device_filter_out_from_trackers
@@ -72,7 +71,6 @@ class FritzBoxTracker(FritzDeviceBase, ScannerEntity):
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize a FRITZ!Box device."""
super().__init__(avm_wrapper, device)
self._attr_name: str = device.hostname or DEFAULT_DEVICE_NAME
self._last_activity: datetime.datetime | None = device.last_activity
@property

View File

@@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .const import DEFAULT_DEVICE_NAME, DOMAIN
from .coordinator import AvmWrapper
from .models import FritzDevice
@@ -21,17 +21,21 @@ from .models import FritzDevice
class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
"""Entity base class for a device connected to a FRITZ!Box device."""
_attr_has_entity_name = True
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize a FRITZ!Box device."""
super().__init__(avm_wrapper)
self._avm_wrapper = avm_wrapper
self._mac: str = device.mac_address
self._name: str = device.hostname or DEFAULT_DEVICE_NAME
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}
)
@property
def name(self) -> str:
"""Return device name."""
return self._name
@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""

View File

@@ -3,9 +3,6 @@
"button": {
"cleanup": {
"default": "mdi:broom"
},
"wake_on_lan": {
"default": "mdi:lan-pending"
}
},
"sensor": {
@@ -51,11 +48,6 @@
"max_kb_s_sent": {
"default": "mdi:upload"
}
},
"switch": {
"internet_access": {
"default": "mdi:router-wireless-settings"
}
}
},
"services": {

View File

@@ -8,7 +8,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
"quality_scale": "bronze",
"requirements": ["fritzconnection[qr]==1.15.0", "xmltodict==1.0.2"],
"ssdp": [
{

View File

@@ -13,7 +13,9 @@ rules:
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
has-entity-name:
status: todo
comment: partially done
runtime-data: done
test-before-configure: done
test-before-setup: done

View File

@@ -108,9 +108,6 @@
},
"reconnect": {
"name": "Reconnect"
},
"wake_on_lan": {
"name": "Wake on LAN"
}
},
"sensor": {
@@ -165,11 +162,6 @@
"max_kb_s_sent": {
"name": "Max connection upload throughput"
}
},
"switch": {
"internet_access": {
"name": "Internet access"
}
}
},
"exceptions": {

View File

@@ -499,12 +499,13 @@ class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
"""Defines a FRITZ!Box Tools DeviceProfile switch."""
_attr_translation_key = "internet_access"
_attr_icon = "mdi:router-wireless-settings"
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Init Fritz profile."""
super().__init__(avm_wrapper, device)
self._attr_is_on: bool = False
self._name = f"{device.hostname} Internet Access"
self._attr_unique_id = f"{self._mac}_internet_access"
self._attr_entity_category = EntityCategory.CONFIG

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260107.1"]
"requirements": ["home-assistant-frontend==20260107.0"]
}

View File

@@ -1,21 +1,9 @@
{
"entity": {
"sensor": {
"ammonia": {
"default": "mdi:molecule"
},
"benzene": {
"default": "mdi:molecule"
},
"nitrogen_dioxide": {
"default": "mdi:molecule"
},
"nitrogen_monoxide": {
"default": "mdi:molecule"
},
"non_methane_hydrocarbons": {
"default": "mdi:molecule"
},
"ozone": {
"default": "mdi:molecule"
},

View File

@@ -99,14 +99,6 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
"local_aqi": data.indexes[1].display_name
},
),
AirQualitySensorEntityDescription(
key="c6h6",
translation_key="benzene",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.c6h6.concentration.units,
value_fn=lambda x: x.pollutants.c6h6.concentration.value,
exists_fn=lambda x: "c6h6" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="co",
state_class=SensorStateClass.MEASUREMENT,
@@ -114,30 +106,6 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
native_unit_of_measurement_fn=lambda x: x.pollutants.co.concentration.units,
value_fn=lambda x: x.pollutants.co.concentration.value,
),
AirQualitySensorEntityDescription(
key="nh3",
translation_key="ammonia",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.nh3.concentration.units,
value_fn=lambda x: x.pollutants.nh3.concentration.value,
exists_fn=lambda x: "nh3" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="nmhc",
translation_key="non_methane_hydrocarbons",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.nmhc.concentration.units,
value_fn=lambda x: x.pollutants.nmhc.concentration.value,
exists_fn=lambda x: "nmhc" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="no",
translation_key="nitrogen_monoxide",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.no.concentration.units,
value_fn=lambda x: x.pollutants.no.concentration.value,
exists_fn=lambda x: "no" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="no2",
translation_key="nitrogen_dioxide",

View File

@@ -76,12 +76,6 @@
},
"entity": {
"sensor": {
"ammonia": {
"name": "Ammonia"
},
"benzene": {
"name": "Benzene"
},
"local_aqi": {
"name": "{local_aqi} AQI"
},
@@ -195,9 +189,6 @@
"name": "{local_aqi} dominant pollutant",
"state": {
"co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"nh3": "[%key:component::google_air_quality::entity::sensor::ammonia::name%]",
"nmhc": "[%key:component::google_air_quality::entity::sensor::non_methane_hydrocarbons::name%]",
"no": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"o3": "[%key:component::sensor::entity_component::ozone::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
@@ -208,12 +199,6 @@
"nitrogen_dioxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
},
"nitrogen_monoxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]"
},
"non_methane_hydrocarbons": {
"name": "Non-methane hydrocarbons"
},
"ozone": {
"name": "[%key:component::sensor::entity_component::ozone::name%]"
},

View File

@@ -1,21 +0,0 @@
"""Diagnostics for HDFury Integration."""
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .coordinator import HDFuryCoordinator
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: HDFuryCoordinator = entry.runtime_data
return {
"board": coordinator.data.board,
"info": coordinator.data.info,
"config": coordinator.data.config,
}

View File

@@ -43,7 +43,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from pyhik.constants import SENSOR_MAP
from pyhik.hikvision import HikCamera
import requests
@@ -20,13 +19,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA]
PLATFORMS = [Platform.BINARY_SENSOR]
@dataclass
@@ -74,49 +70,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
device_type=device_type,
)
_LOGGER.debug(
"Device %s (type=%s) initial event_states: %s",
device_name,
device_type,
camera.current_event_states,
)
# For NVRs or devices with no detected events, try to fetch events from ISAPI
# Use broader notification methods for NVRs since they often use 'record' etc.
if device_type == "NVR" or not camera.current_event_states:
nvr_notification_methods = {"center", "HTTP", "record", "email", "beep"}
def fetch_and_inject_nvr_events() -> None:
"""Fetch and inject NVR events in a single executor job."""
nvr_events = camera.get_event_triggers(nvr_notification_methods)
_LOGGER.debug("NVR events fetched with extended methods: %s", nvr_events)
if nvr_events:
# Map raw event type names to friendly names using SENSOR_MAP
mapped_events: dict[str, list[int]] = {}
for event_type, channels in nvr_events.items():
friendly_name = SENSOR_MAP.get(event_type.lower(), event_type)
if friendly_name in mapped_events:
mapped_events[friendly_name].extend(channels)
else:
mapped_events[friendly_name] = list(channels)
_LOGGER.debug("Mapped NVR events: %s", mapped_events)
camera.inject_events(mapped_events)
if nvr_events := camera.get_event_triggers():
camera.inject_events(nvr_events)
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
# Start the event stream
await hass.async_add_executor_job(camera.start_stream)
# Register the main device before platforms that use via_device
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, device_id)},
name=device_name,
manufacturer="Hikvision",
model=device_type,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -185,27 +185,20 @@ class HikvisionBinarySensor(BinarySensorEntity):
# Build unique ID
self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}"
# Device info for device registry
# Build entity name based on device type
if self._data.device_type == "NVR":
# NVR channels get their own device linked to the NVR via via_device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
via_device=(DOMAIN, self._data.device_id),
name=f"{self._data.device_name} Channel {channel}",
manufacturer="Hikvision",
model="NVR Channel",
)
self._attr_name = sensor_type
self._attr_name = f"{sensor_type} {channel}"
else:
# Single camera device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._data.device_id)},
name=self._data.device_name,
manufacturer="Hikvision",
model=self._data.device_type,
)
self._attr_name = sensor_type
# Device info for device registry
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._data.device_id)},
name=self._data.device_name,
manufacturer="Hikvision",
model=self._data.device_type,
)
# Set device class
self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type)

View File

@@ -1,93 +0,0 @@
"""Support for Hikvision cameras."""
from __future__ import annotations
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HikvisionConfigEntry
from .const import DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: HikvisionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hikvision cameras from a config entry."""
data = entry.runtime_data
camera = data.camera
# Get available channels from the library
channels = await hass.async_add_executor_job(camera.get_channels)
if channels:
entities = [HikvisionCamera(entry, channel) for channel in channels]
else:
# Fallback to single camera if no channels detected
entities = [HikvisionCamera(entry, 1)]
async_add_entities(entities)
class HikvisionCamera(Camera):
"""Representation of a Hikvision camera."""
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(
self,
entry: HikvisionConfigEntry,
channel: int,
) -> None:
"""Initialize the camera."""
super().__init__()
self._data = entry.runtime_data
self._channel = channel
self._camera = self._data.camera
# Build unique ID (unique per platform per integration)
self._attr_unique_id = f"{self._data.device_id}_{channel}"
# Device info for device registry
if self._data.device_type == "NVR":
# NVR channels get their own device linked to the NVR via via_device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
via_device=(DOMAIN, self._data.device_id),
name=f"{self._data.device_name} Channel {channel}",
manufacturer="Hikvision",
model="NVR Channel",
)
else:
# Single camera device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._data.device_id)},
name=self._data.device_name,
manufacturer="Hikvision",
model=self._data.device_type,
)
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image from the camera."""
try:
return await self.hass.async_add_executor_job(
self._camera.get_snapshot, self._channel
)
except Exception as err:
raise HomeAssistantError(
f"Error getting image from {self._data.device_name} channel {self._channel}: {err}"
) from err
async def stream_source(self) -> str | None:
"""Return the stream source URL."""
return self._camera.get_stream_url(self._channel)

View File

@@ -13,6 +13,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==10.0.1"],
"requirements": ["python-homewizard-energy==10.0.0"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}

View File

@@ -27,7 +27,7 @@ from .const import (
SUPPORTED_PLATFORMS_UI,
SUPPORTED_PLATFORMS_YAML,
)
from .expose import create_combined_knx_exposure
from .expose import create_knx_exposure
from .knx_module import KNXModule
from .project import STORAGE_KEY as PROJECT_STORAGE_KEY
from .schema import (
@@ -121,10 +121,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[KNX_MODULE_KEY] = knx_module
if CONF_KNX_EXPOSE in config:
knx_module.yaml_exposures.extend(
create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE])
)
for expose_config in config[CONF_KNX_EXPOSE]:
knx_module.exposures.append(
create_knx_exposure(hass, knx_module.xknx, expose_config)
)
configured_platforms_yaml = {
platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config
}
@@ -149,9 +149,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# if not loaded directly return
return True
for exposure in knx_module.yaml_exposures:
exposure.async_remove()
for exposure in knx_module.service_exposures.values():
for exposure in knx_module.exposures:
exposure.async_remove()
configured_platforms_yaml = {

View File

@@ -2,22 +2,14 @@
from __future__ import annotations
from asyncio import TaskGroup
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from collections.abc import Callable
import logging
from typing import Any
from xknx import XKNX
from xknx.devices import DateDevice, DateTimeDevice, ExposeSensor, TimeDevice
from xknx.dpt import DPTBase, DPTNumeric, DPTString
from xknx.dpt.dpt_1 import DPT1BitEnum, DPTSwitch
from xknx.dpt import DPTNumeric, DPTString
from xknx.exceptions import ConversionError
from xknx.telegram.address import (
GroupAddress,
InternalGroupAddress,
parse_device_group_address,
)
from xknx.remote_value import RemoteValueSensor
from homeassistant.const import (
CONF_ENTITY_ID,
@@ -49,159 +41,79 @@ _LOGGER = logging.getLogger(__name__)
@callback
def create_knx_exposure(
hass: HomeAssistant, xknx: XKNX, config: ConfigType
) -> KnxExposeEntity | KnxExposeTime:
"""Create single exposure."""
) -> KNXExposeSensor | KNXExposeTime:
"""Create exposures from config."""
expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
exposure: KnxExposeEntity | KnxExposeTime
exposure: KNXExposeSensor | KNXExposeTime
if (
isinstance(expose_type, str)
and expose_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES
):
exposure = KnxExposeTime(
exposure = KNXExposeTime(
xknx=xknx,
config=config,
)
else:
exposure = KnxExposeEntity(
hass=hass,
exposure = KNXExposeSensor(
hass,
xknx=xknx,
entity_id=config[CONF_ENTITY_ID],
options=(_yaml_config_to_expose_options(config),),
config=config,
)
exposure.async_register()
return exposure
@callback
def create_combined_knx_exposure(
hass: HomeAssistant, xknx: XKNX, configs: list[ConfigType]
) -> list[KnxExposeEntity | KnxExposeTime]:
"""Create exposures from YAML config combined by entity_id."""
exposures: list[KnxExposeEntity | KnxExposeTime] = []
entity_exposure_map: dict[str, list[KnxExposeOptions]] = {}
for config in configs:
value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
if value_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES:
time_exposure = KnxExposeTime(
xknx=xknx,
config=config,
)
time_exposure.async_register()
exposures.append(time_exposure)
continue
entity_id = config[CONF_ENTITY_ID]
option = _yaml_config_to_expose_options(config)
entity_exposure_map.setdefault(entity_id, []).append(option)
for entity_id, options in entity_exposure_map.items():
entity_exposure = KnxExposeEntity(
hass=hass,
xknx=xknx,
entity_id=entity_id,
options=options,
)
entity_exposure.async_register()
exposures.append(entity_exposure)
return exposures
@dataclass(slots=True)
class KnxExposeOptions:
"""Options for KNX Expose."""
attribute: str | None
group_address: GroupAddress | InternalGroupAddress
dpt: type[DPTBase]
respond_to_read: bool
cooldown: float
default: Any | None
value_template: Template | None
def _yaml_config_to_expose_options(config: ConfigType) -> KnxExposeOptions:
"""Convert single yaml expose config to KnxExposeOptions."""
value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
dpt: type[DPTBase]
if value_type == "binary":
# HA yaml expose flag for DPT-1 (no explicit DPT 1 definitions in xknx back then)
dpt = DPTSwitch
else:
dpt = DPTBase.parse_transcoder(config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]) # type: ignore[assignment] # checked by schema validation
ga = parse_device_group_address(config[KNX_ADDRESS])
return KnxExposeOptions(
attribute=config.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE),
group_address=ga,
dpt=dpt,
respond_to_read=config[CONF_RESPOND_TO_READ],
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
default=config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT),
value_template=config.get(CONF_VALUE_TEMPLATE),
)
class KnxExposeEntity:
"""Expose Home Assistant entity values to KNX bus."""
class KNXExposeSensor:
"""Object to Expose Home Assistant entity to KNX bus."""
def __init__(
self,
hass: HomeAssistant,
xknx: XKNX,
entity_id: str,
options: Iterable[KnxExposeOptions],
config: ConfigType,
) -> None:
"""Initialize KnxExposeEntity class."""
"""Initialize of Expose class."""
self.hass = hass
self.xknx = xknx
self.entity_id = entity_id
self.entity_id: str = config[CONF_ENTITY_ID]
self.expose_attribute: str | None = config.get(
ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE
)
self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT)
self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
self.value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
self._remove_listener: Callable[[], None] | None = None
self._exposures = tuple(
(
option,
ExposeSensor(
xknx=self.xknx,
name=f"{self.entity_id} {option.attribute or 'state'}",
group_address=option.group_address,
respond_to_read=option.respond_to_read,
value_type=option.dpt,
cooldown=option.cooldown,
),
)
for option in options
self.device: ExposeSensor = ExposeSensor(
xknx=self.xknx,
name=f"{self.entity_id}__{self.expose_attribute or 'state'}",
group_address=config[KNX_ADDRESS],
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=self.expose_type,
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
)
@property
def name(self) -> str:
"""Return name of the expose entity."""
expose_names = [opt.attribute or "state" for opt, _ in self._exposures]
return f"{self.entity_id}__{'__'.join(expose_names)}"
@callback
def async_register(self) -> None:
"""Register listener and XKNX devices."""
"""Register listener."""
self._remove_listener = async_track_state_change_event(
self.hass, [self.entity_id], self._async_entity_changed
)
for _option, xknx_expose in self._exposures:
self.xknx.devices.async_add(xknx_expose)
self.xknx.devices.async_add(self.device)
self._init_expose_state()
@callback
def _init_expose_state(self) -> None:
"""Initialize state of all exposures."""
"""Initialize state of the exposure."""
init_state = self.hass.states.get(self.entity_id)
for option, xknx_expose in self._exposures:
state_value = self._get_expose_value(init_state, option)
try:
xknx_expose.sensor_value.value = state_value
except ConversionError:
_LOGGER.exception(
"Error setting value %s for expose sensor %s",
state_value,
xknx_expose.name,
)
state_value = self._get_expose_value(init_state)
try:
self.device.sensor_value.value = state_value
except ConversionError:
_LOGGER.exception("Error during sending of expose sensor value")
@callback
def async_remove(self) -> None:
@@ -209,57 +121,53 @@ class KnxExposeEntity:
if self._remove_listener is not None:
self._remove_listener()
self._remove_listener = None
for _option, xknx_expose in self._exposures:
self.xknx.devices.async_remove(xknx_expose)
self.xknx.devices.async_remove(self.device)
def _get_expose_value(
self, state: State | None, option: KnxExposeOptions
) -> bool | int | float | str | None:
"""Extract value from state for a specific option."""
def _get_expose_value(self, state: State | None) -> bool | int | float | str | None:
"""Extract value from state."""
if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
if option.default is None:
if self.expose_default is None:
return None
value = option.default
elif option.attribute is not None:
_attr = state.attributes.get(option.attribute)
value = _attr if _attr is not None else option.default
value = self.expose_default
elif self.expose_attribute is not None:
_attr = state.attributes.get(self.expose_attribute)
value = _attr if _attr is not None else self.expose_default
else:
value = state.state
if option.value_template is not None:
if self.value_template is not None:
try:
value = option.value_template.async_render_with_possible_json_value(
value = self.value_template.async_render_with_possible_json_value(
value, error_value=None
)
except (TemplateError, TypeError, ValueError) as err:
_LOGGER.warning(
"Error rendering value template for KNX expose %s %s %s: %s",
self.entity_id,
option.attribute or "state",
option.value_template.template,
"Error rendering value template for KNX expose %s %s: %s",
self.device.name,
self.value_template.template,
err,
)
return None
if issubclass(option.dpt, DPT1BitEnum):
if self.expose_type == "binary":
if value in (1, STATE_ON, "True"):
return True
if value in (0, STATE_OFF, "False"):
return False
# Handle numeric and string DPT conversions
if value is not None:
if value is not None and (
isinstance(self.device.sensor_value, RemoteValueSensor)
):
try:
if issubclass(option.dpt, DPTNumeric):
if issubclass(self.device.sensor_value.dpt_class, DPTNumeric):
return float(value)
if issubclass(option.dpt, DPTString):
if issubclass(self.device.sensor_value.dpt_class, DPTString):
# DPT 16.000 only allows up to 14 Bytes
return str(value)[:14]
except (ValueError, TypeError) as err:
_LOGGER.warning(
'Could not expose %s %s value "%s" to KNX: Conversion failed: %s',
self.entity_id,
option.attribute or "state",
self.expose_attribute or "state",
value,
err,
)
@@ -267,31 +175,32 @@ class KnxExposeEntity:
return value # type: ignore[no-any-return]
async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> None:
"""Handle entity change for all options."""
"""Handle entity change."""
new_state = event.data["new_state"]
async with TaskGroup() as tg:
for option, xknx_expose in self._exposures:
expose_value = self._get_expose_value(new_state, option)
if expose_value is None:
continue
tg.create_task(self._async_set_knx_value(xknx_expose, expose_value))
if (new_value := self._get_expose_value(new_state)) is None:
return
old_state = event.data["old_state"]
# don't use default value for comparison on first state change (old_state is None)
old_value = self._get_expose_value(old_state) if old_state is not None else None
# don't send same value sequentially
if new_value != old_value:
await self._async_set_knx_value(new_value)
async def _async_set_knx_value(
self, xknx_expose: ExposeSensor, value: StateType
) -> None:
async def _async_set_knx_value(self, value: StateType) -> None:
"""Set new value on xknx ExposeSensor."""
try:
await xknx_expose.set(value, skip_unchanged=True)
await self.device.set(value)
except ConversionError as err:
_LOGGER.warning(
'Could not expose %s value "%s" to KNX: %s',
xknx_expose.name,
'Could not expose %s %s value "%s" to KNX: %s',
self.entity_id,
self.expose_attribute or "state",
value,
err,
)
class KnxExposeTime:
class KNXExposeTime:
"""Object to Expose Time/Date object to KNX bus."""
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
@@ -313,11 +222,6 @@ class KnxExposeTime:
group_address=config[KNX_ADDRESS],
)
@property
def name(self) -> str:
"""Return name of the time expose object."""
return f"expose_{self.device.name}"
@callback
def async_register(self) -> None:
"""Register listener."""

View File

@@ -54,7 +54,7 @@ from .const import (
TELEGRAM_LOG_DEFAULT,
)
from .device import KNXInterfaceDevice
from .expose import KnxExposeEntity, KnxExposeTime
from .expose import KNXExposeSensor, KNXExposeTime
from .project import KNXProject
from .repairs import data_secure_group_key_issue_dispatcher
from .storage.config_store import KNXConfigStore
@@ -73,8 +73,8 @@ class KNXModule:
self.hass = hass
self.config_yaml = config
self.connected = False
self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = []
self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {}
self.exposures: list[KNXExposeSensor | KNXExposeTime] = []
self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {}
self.entry = entry
self.project = KNXProject(hass=hass, entry=entry)

View File

@@ -11,7 +11,7 @@
"loggers": ["xknx", "xknxproject"],
"quality_scale": "platinum",
"requirements": [
"xknx==3.14.0",
"xknx==3.13.0",
"xknxproject==3.8.2",
"knx-frontend==2025.12.30.151231"
],

View File

@@ -193,7 +193,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
" for '%s' - %s"
),
group_address,
replaced_exposure.name,
replaced_exposure.device.name,
)
replaced_exposure.async_remove()
exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data)
@@ -201,7 +201,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
_LOGGER.debug(
"Service exposure_register registered exposure for '%s' - %s",
group_address,
exposure.name,
exposure.device.name,
)

View File

@@ -256,8 +256,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
and WidgetType.CM_BREW_BY_WEIGHT_DOSES
in coordinator.device.dashboard.config
),
),
LaMarzoccoNumberEntityDescription(
@@ -291,8 +289,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
and WidgetType.CM_BREW_BY_WEIGHT_DOSES
in coordinator.device.dashboard.config
),
),
)

View File

@@ -149,8 +149,6 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
and WidgetType.CM_BREW_BY_WEIGHT_DOSES
in coordinator.device.dashboard.config
),
),
)

View File

@@ -42,7 +42,7 @@
},
"conditions": {
"is_off": {
"description": "Tests if one or more lights are off.",
"description": "Test if a light is off.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
@@ -52,7 +52,7 @@
"name": "If a light is off"
},
"is_on": {
"description": "Tests if one or more lights are on.",
"description": "Test if a light is on.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",

View File

@@ -66,9 +66,8 @@ class MatterRangeNumberEntityDescription(
format_max_value: Callable[[float], float] = lambda x: x
# command: a custom callback to create the command to send to the device
# the callback's argument will be the converted device value from ha_to_device
# if omitted the command will just be a write_attribute command to the primary attribute
command: Callable[[int], ClusterCommand] | None = None
# the callback's argument will be the index of the selected list value
command: Callable[[int], ClusterCommand]
class MatterNumber(MatterEntity, NumberEntity):
@@ -100,15 +99,9 @@ class MatterRangeNumber(MatterEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
send_value = self.entity_description.ha_to_device(value)
if self.entity_description.command:
# custom command defined to set the new value
await self.send_device_command(
self.entity_description.command(send_value),
)
return
# regular write attribute to set the new value
await self.write_attribute(
value=send_value,
# custom command defined to set the new value
await self.send_device_command(
self.entity_description.command(send_value),
)
@callback
@@ -260,30 +253,6 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterNumber,
required_attributes=(custom_clusters.EveCluster.Attributes.Altitude,),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterRangeNumberEntityDescription(
key="ThermostatOccupiedSetback",
entity_category=EntityCategory.CONFIG,
translation_key="occupied_setback",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_to_ha=lambda x: None if x is None else x / 10,
ha_to_device=lambda x: round(x * 10),
format_min_value=lambda x: x / 10,
format_max_value=lambda x: x / 10,
min_attribute=clusters.Thermostat.Attributes.OccupiedSetbackMin,
max_attribute=clusters.Thermostat.Attributes.OccupiedSetbackMax,
native_step=0.5,
mode=NumberMode.BOX,
),
entity_class=MatterRangeNumber,
required_attributes=(
clusters.Thermostat.Attributes.OccupiedSetback,
clusters.Thermostat.Attributes.OccupiedSetbackMin,
clusters.Thermostat.Attributes.OccupiedSetbackMax,
),
featuremap_contains=(clusters.Thermostat.Bitmaps.Feature.kSetback),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(

View File

@@ -217,9 +217,6 @@
"led_indicator_intensity_on": {
"name": "LED on intensity"
},
"occupied_setback": {
"name": "Occupied setback"
},
"off_transition_time": {
"name": "Off transition time"
},

View File

@@ -4,64 +4,45 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
from aiohttp import ClientConnectionError, ClientResponseError
from pymelcloud import get_devices
from pymelcloud import Device, get_devices
from pymelcloud.atw_device import Zone
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import UpdateFailed
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.util import Throttle
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDevice]]]
async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool:
"""Establish connection with MELCloud."""
token = entry.data[CONF_TOKEN]
session = async_get_clientsession(hass)
conf = entry.data
try:
async with asyncio.timeout(10):
all_devices = await get_devices(
token,
session,
conf_update_interval=timedelta(minutes=30),
device_set_debounce=timedelta(seconds=2),
)
mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN])
except ClientResponseError as ex:
if ex.status in (401, 403):
if isinstance(ex, ClientResponseError) and ex.code == 401:
raise ConfigEntryAuthFailed from ex
if ex.status == 429:
raise UpdateFailed(
"MELCloud rate limit exceeded. Your account may be temporarily blocked"
) from ex
raise UpdateFailed(f"Error communicating with MELCloud: {ex}") from ex
raise ConfigEntryNotReady from ex
except (TimeoutError, ClientConnectionError) as ex:
raise UpdateFailed(f"Error communicating with MELCloud: {ex}") from ex
raise ConfigEntryNotReady from ex
# Create per-device coordinators
coordinators: dict[str, list[MelCloudDeviceUpdateCoordinator]] = {}
device_registry = dr.async_get(hass)
for device_type, devices in all_devices.items():
coordinators[device_type] = []
for device in devices:
coordinator = MelCloudDeviceUpdateCoordinator(hass, device, entry)
# Perform initial refresh for this device
await coordinator.async_config_entry_first_refresh()
coordinators[device_type].append(coordinator)
# Register parent device now so zone entities can reference it via via_device
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
**coordinator.device_info,
)
entry.runtime_data = coordinators
entry.runtime_data = mel_devices
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -69,3 +50,90 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
class MelCloudDevice:
"""MELCloud Device instance."""
def __init__(self, device: Device) -> None:
"""Construct a device wrapper."""
self.device = device
self.name = device.name
self._available = True
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self, **kwargs):
"""Pull the latest data from MELCloud."""
try:
await self.device.update()
self._available = True
except ClientConnectionError:
_LOGGER.warning("Connection failed for %s", self.name)
self._available = False
async def async_set(self, properties: dict[str, Any]):
"""Write state changes to the MELCloud API."""
try:
await self.device.set(properties)
self._available = True
except ClientConnectionError:
_LOGGER.warning("Connection failed for %s", self.name)
self._available = False
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
@property
def device_id(self):
"""Return device ID."""
return self.device.device_id
@property
def building_id(self):
"""Return building ID of the device."""
return self.device.building_id
@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
model = None
if (unit_infos := self.device.units) is not None:
model = ", ".join([x["model"] for x in unit_infos if x["model"]])
return DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, self.device.mac)},
identifiers={(DOMAIN, f"{self.device.mac}-{self.device.serial}")},
manufacturer="Mitsubishi Electric",
model=model,
name=self.name,
)
def zone_device_info(self, zone: Zone) -> DeviceInfo:
"""Return a zone device description for device registry."""
dev = self.device
return DeviceInfo(
identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")},
manufacturer="Mitsubishi Electric",
model="ATW zone device",
name=f"{self.name} {zone.name}",
via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"),
)
async def mel_devices_setup(
hass: HomeAssistant, token: str
) -> dict[str, list[MelCloudDevice]]:
"""Query connected devices from MELCloud."""
session = async_get_clientsession(hass)
async with asyncio.timeout(10):
all_devices = await get_devices(
token,
session,
conf_update_interval=timedelta(minutes=30),
device_set_debounce=timedelta(seconds=2),
)
wrapped_devices: dict[str, list[MelCloudDevice]] = {}
for device_type, devices in all_devices.items():
wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices]
return wrapped_devices

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any, cast
from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice
@@ -28,6 +29,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudConfigEntry, MelCloudDevice
from .const import (
ATTR_STATUS,
ATTR_VANE_HORIZONTAL,
@@ -38,8 +40,9 @@ from .const import (
SERVICE_SET_VANE_HORIZONTAL,
SERVICE_SET_VANE_VERTICAL,
)
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
from .entity import MelCloudEntity
SCAN_INTERVAL = timedelta(seconds=60)
ATA_HVAC_MODE_LOOKUP = {
ata.OPERATION_MODE_HEAT: HVACMode.HEAT,
@@ -71,24 +74,27 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = {
async def async_setup_entry(
_hass: HomeAssistant,
hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
coordinators = entry.runtime_data
mel_devices = entry.runtime_data
entities: list[AtaDeviceClimate | AtwDeviceZoneClimate] = [
AtaDeviceClimate(coordinator, coordinator.device)
for coordinator in coordinators.get(DEVICE_TYPE_ATA, [])
AtaDeviceClimate(mel_device, mel_device.device)
for mel_device in mel_devices[DEVICE_TYPE_ATA]
]
entities.extend(
[
AtwDeviceZoneClimate(coordinator, coordinator.device, zone)
for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
for zone in coordinator.device.zones
AtwDeviceZoneClimate(mel_device, mel_device.device, zone)
for mel_device in mel_devices[DEVICE_TYPE_ATW]
for zone in mel_device.device.zones
]
)
async_add_entities(entities)
async_add_entities(
entities,
True,
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@@ -103,19 +109,21 @@ async def async_setup_entry(
)
class MelCloudClimate(MelCloudEntity, ClimateEntity):
class MelCloudClimate(ClimateEntity):
"""Base climate device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
) -> None:
def __init__(self, device: MelCloudDevice) -> None:
"""Initialize the climate."""
super().__init__(coordinator)
self._base_device = self.coordinator.device
self.api = device
self._base_device = self.api.device
async def async_update(self) -> None:
"""Update state from MELCloud."""
await self.api.async_update()
@property
def target_temperature_step(self) -> float | None:
@@ -134,29 +142,26 @@ class AtaDeviceClimate(MelCloudClimate):
| ClimateEntityFeature.TURN_ON
)
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
ata_device: AtaDevice,
) -> None:
def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None:
"""Initialize the climate."""
super().__init__(coordinator)
super().__init__(device)
self._device = ata_device
self._attr_unique_id = (
f"{self.coordinator.device.serial}-{self.coordinator.device.mac}"
)
self._attr_device_info = self.coordinator.device_info
self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}"
self._attr_device_info = self.api.device_info
# Add horizontal swing if device supports it
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
# We can only check for vane_horizontal once we fetch the device data from the cloud
if self._device.vane_horizontal:
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the optional state attributes with device specific additions."""
attr: dict[str, Any] = {}
attr.update(self.coordinator.extra_attributes)
attr = {}
if vane_horizontal := self._device.vane_horizontal:
attr.update(
@@ -203,7 +208,7 @@ class AtaDeviceClimate(MelCloudClimate):
"""Set new target hvac mode."""
set_dict: dict[str, Any] = {}
self._apply_set_hvac_mode(hvac_mode, set_dict)
await self.coordinator.async_set(set_dict)
await self._device.set(set_dict)
@property
def hvac_modes(self) -> list[HVACMode]:
@@ -236,7 +241,7 @@ class AtaDeviceClimate(MelCloudClimate):
set_dict["target_temperature"] = kwargs.get(ATTR_TEMPERATURE)
if set_dict:
await self.coordinator.async_set(set_dict)
await self._device.set(set_dict)
@property
def fan_mode(self) -> str | None:
@@ -245,7 +250,7 @@ class AtaDeviceClimate(MelCloudClimate):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
await self.coordinator.async_set({"fan_speed": fan_mode})
await self._device.set({"fan_speed": fan_mode})
@property
def fan_modes(self) -> list[str] | None:
@@ -259,7 +264,7 @@ class AtaDeviceClimate(MelCloudClimate):
f"Invalid horizontal vane position {position}. Valid positions:"
f" [{self._device.vane_horizontal_positions}]."
)
await self.coordinator.async_set({ata.PROPERTY_VANE_HORIZONTAL: position})
await self._device.set({ata.PROPERTY_VANE_HORIZONTAL: position})
async def async_set_vane_vertical(self, position: str) -> None:
"""Set vertical vane position."""
@@ -268,7 +273,7 @@ class AtaDeviceClimate(MelCloudClimate):
f"Invalid vertical vane position {position}. Valid positions:"
f" [{self._device.vane_vertical_positions}]."
)
await self.coordinator.async_set({ata.PROPERTY_VANE_VERTICAL: position})
await self._device.set({ata.PROPERTY_VANE_VERTICAL: position})
@property
def swing_mode(self) -> str | None:
@@ -300,11 +305,11 @@ class AtaDeviceClimate(MelCloudClimate):
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self.coordinator.async_set({"power": True})
await self._device.set({"power": True})
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self.coordinator.async_set({"power": False})
await self._device.set({"power": False})
@property
def min_temp(self) -> float:
@@ -333,18 +338,15 @@ class AtwDeviceZoneClimate(MelCloudClimate):
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
atw_device: AtwDevice,
atw_zone: Zone,
self, device: MelCloudDevice, atw_device: AtwDevice, atw_zone: Zone
) -> None:
"""Initialize the climate."""
super().__init__(coordinator)
super().__init__(device)
self._device = atw_device
self._zone = atw_zone
self._attr_unique_id = f"{self.coordinator.device.serial}-{atw_zone.zone_index}"
self._attr_device_info = self.coordinator.zone_device_info(atw_zone)
self._attr_unique_id = f"{self.api.device.serial}-{atw_zone.zone_index}"
self._attr_device_info = self.api.zone_device_info(atw_zone)
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -358,16 +360,15 @@ class AtwDeviceZoneClimate(MelCloudClimate):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
# Use zone status (heat/cool/idle) not operation_mode (heat-thermostat/etc.)
status = self._zone.status
if not self._device.power or status is None:
mode = self._zone.operation_mode
if not self._device.power or mode is None:
return HVACMode.OFF
return ATW_ZONE_HVAC_MODE_LOOKUP.get(status, HVACMode.OFF)
return ATW_ZONE_HVAC_MODE_LOOKUP.get(mode, HVACMode.OFF)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.OFF:
await self.coordinator.async_set({"power": False})
await self._device.set({"power": False})
return
operation_mode = ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode)
@@ -380,7 +381,7 @@ class AtwDeviceZoneClimate(MelCloudClimate):
props = {PROPERTY_ZONE_2_OPERATION_MODE: operation_mode}
if self.hvac_mode == HVACMode.OFF:
props["power"] = True
await self.coordinator.async_set(props)
await self._device.set(props)
@property
def hvac_modes(self) -> list[HVACMode]:
@@ -409,4 +410,3 @@ class AtwDeviceZoneClimate(MelCloudClimate):
await self._zone.set_target_temperature(
kwargs.get(ATTR_TEMPERATURE, self.target_temperature)
)
await self.coordinator.async_request_refresh()

View File

@@ -60,10 +60,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="cannot_connect")
except (TimeoutError, ClientError):
return self.async_abort(reason="cannot_connect")
except AttributeError:
# python-melcloud library bug: login() raises AttributeError on invalid
# credentials when API response doesn't contain expected "LoginData" key
return self.async_abort(reason="invalid_auth")
return await self._create_entry(username, acquired_token)

View File

@@ -1,193 +0,0 @@
"""DataUpdateCoordinator for the MELCloud integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from aiohttp import ClientConnectionError, ClientResponseError
from pymelcloud import Device
from pymelcloud.atw_device import Zone
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Delay before refreshing after a state change to allow device to process
# and avoid race conditions with rapid sequential changes
REQUEST_REFRESH_DELAY = 1.5
# Default update interval in minutes (matches upstream Throttle value)
DEFAULT_UPDATE_INTERVAL = 15
# Retry interval in seconds for transient failures
RETRY_INTERVAL_SECONDS = 30
# Number of consecutive failures before marking device unavailable
MAX_CONSECUTIVE_FAILURES = 3
class MelCloudDeviceUpdateCoordinator(DataUpdateCoordinator[None]):
"""Per-device coordinator for MELCloud data updates."""
def __init__(
self,
hass: HomeAssistant,
device: Device,
config_entry: ConfigEntry,
) -> None:
"""Initialize the per-device coordinator."""
self.device = device
self.device_available = True
self._consecutive_failures = 0
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_{device.name}",
update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL),
always_update=True,
request_refresh_debouncer=Debouncer(
hass,
_LOGGER,
cooldown=REQUEST_REFRESH_DELAY,
immediate=False,
),
)
@property
def extra_attributes(self) -> dict[str, Any]:
"""Return extra device attributes."""
data: dict[str, Any] = {
"device_id": self.device.device_id,
"serial": self.device.serial,
"mac": self.device.mac,
}
if (unit_infos := self.device.units) is not None:
for i, unit in enumerate(unit_infos[:2]):
data[f"unit_{i}_model"] = unit.get("model")
data[f"unit_{i}_serial"] = unit.get("serial")
return data
@property
def device_id(self) -> str:
"""Return device ID."""
return self.device.device_id
@property
def building_id(self) -> str:
"""Return building ID of the device."""
return self.device.building_id
@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
model = None
if (unit_infos := self.device.units) is not None:
model = ", ".join([x["model"] for x in unit_infos if x["model"]])
return DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, self.device.mac)},
identifiers={(DOMAIN, f"{self.device.mac}-{self.device.serial}")},
manufacturer="Mitsubishi Electric",
model=model,
name=self.device.name,
)
def zone_device_info(self, zone: Zone) -> DeviceInfo:
"""Return a zone device description for device registry."""
dev = self.device
return DeviceInfo(
identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")},
manufacturer="Mitsubishi Electric",
model="ATW zone device",
name=f"{self.device.name} {zone.name}",
via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"),
)
async def _async_update_data(self) -> None:
"""Fetch data for this specific device from MELCloud."""
try:
await self.device.update()
# Success - reset failure counter and restore normal interval
if self._consecutive_failures > 0:
_LOGGER.info(
"Connection restored for %s after %d failed attempt(s)",
self.device.name,
self._consecutive_failures,
)
self._consecutive_failures = 0
self.update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
self.device_available = True
except ClientResponseError as ex:
if ex.status in (401, 403):
raise ConfigEntryAuthFailed from ex
if ex.status == 429:
_LOGGER.error(
"MELCloud rate limit exceeded for %s. Your account may be "
"temporarily blocked",
self.device.name,
)
# Rate limit - mark unavailable immediately
self.device_available = False
raise UpdateFailed(
f"Rate limit exceeded for {self.device.name}"
) from ex
# Other HTTP errors - use retry logic
self._handle_failure(f"Error updating {self.device.name}: {ex}", ex)
except ClientConnectionError as ex:
self._handle_failure(f"Connection failed for {self.device.name}: {ex}", ex)
def _handle_failure(self, message: str, exception: Exception | None = None) -> None:
"""Handle a connection failure with retry logic.
For transient failures, entities remain available with their last known
values for up to MAX_CONSECUTIVE_FAILURES attempts. During retries, the
update interval is shortened to RETRY_INTERVAL_SECONDS for faster recovery.
After the threshold is reached, entities are marked unavailable.
"""
self._consecutive_failures += 1
if self._consecutive_failures < MAX_CONSECUTIVE_FAILURES:
# Keep entities available with cached data, use shorter retry interval
_LOGGER.warning(
"%s (attempt %d/%d, retrying in %ds)",
message,
self._consecutive_failures,
MAX_CONSECUTIVE_FAILURES,
RETRY_INTERVAL_SECONDS,
)
self.update_interval = timedelta(seconds=RETRY_INTERVAL_SECONDS)
else:
# Threshold reached - mark unavailable and restore normal interval
_LOGGER.warning(
"%s (attempt %d/%d, marking unavailable)",
message,
self._consecutive_failures,
MAX_CONSECUTIVE_FAILURES,
)
self.device_available = False
self.update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
raise UpdateFailed(message) from exception
async def async_set(self, properties: dict[str, Any]) -> None:
"""Write state changes to the MELCloud API."""
try:
await self.device.set(properties)
self.device_available = True
except ClientConnectionError:
_LOGGER.warning("Connection failed for %s", self.device.name)
self.device_available = False
await self.async_request_refresh()
type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDeviceUpdateCoordinator]]]

View File

@@ -9,7 +9,7 @@ from homeassistant.const import CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .coordinator import MelCloudConfigEntry
from . import MelCloudConfigEntry
TO_REDACT = {
CONF_USERNAME,

View File

@@ -1,18 +0,0 @@
"""Base entity for MELCloud integration."""
from __future__ import annotations
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import MelCloudDeviceUpdateCoordinator
class MelCloudEntity(CoordinatorEntity[MelCloudDeviceUpdateCoordinator]):
"""Base class for MELCloud entities."""
_attr_has_entity_name = True
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.device_available

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/melcloud",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["melcloud"],
"loggers": ["pymelcloud"],
"requirements": ["python-melcloud==0.1.2"]
}

View File

@@ -19,8 +19,7 @@ from homeassistant.const import UnitOfEnergy, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
from .entity import MelCloudEntity
from . import MelCloudConfigEntry, MelCloudDevice
@dataclasses.dataclass(frozen=True, kw_only=True)
@@ -112,67 +111,70 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
async def async_setup_entry(
_hass: HomeAssistant,
hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud device sensors based on config_entry."""
coordinators = entry.runtime_data
mel_devices = entry.runtime_data
entities: list[MelDeviceSensor] = [
MelDeviceSensor(coordinator, description)
MelDeviceSensor(mel_device, description)
for description in ATA_SENSORS
for coordinator in coordinators.get(DEVICE_TYPE_ATA, [])
if description.enabled(coordinator)
for mel_device in mel_devices[DEVICE_TYPE_ATA]
if description.enabled(mel_device)
] + [
MelDeviceSensor(coordinator, description)
MelDeviceSensor(mel_device, description)
for description in ATW_SENSORS
for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
if description.enabled(coordinator)
for mel_device in mel_devices[DEVICE_TYPE_ATW]
if description.enabled(mel_device)
]
entities.extend(
[
AtwZoneSensor(coordinator, zone, description)
for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
for zone in coordinator.device.zones
AtwZoneSensor(mel_device, zone, description)
for mel_device in mel_devices[DEVICE_TYPE_ATW]
for zone in mel_device.device.zones
for description in ATW_ZONE_SENSORS
if description.enabled(zone)
]
)
async_add_entities(entities)
async_add_entities(entities, True)
class MelDeviceSensor(MelCloudEntity, SensorEntity):
class MelDeviceSensor(SensorEntity):
"""Representation of a Sensor."""
entity_description: MelcloudSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
api: MelCloudDevice,
description: MelcloudSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._api = api
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.device.serial}-{coordinator.device.mac}-{description.key}"
)
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{description.key}"
self._attr_device_info = api.device_info
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator)
return self.entity_description.value_fn(self._api)
async def async_update(self) -> None:
"""Retrieve latest state."""
await self._api.async_update()
class AtwZoneSensor(MelDeviceSensor):
"""Air-to-Water zone sensor."""
"""Air-to-Air device sensor."""
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
api: MelCloudDevice,
zone: Zone,
description: MelcloudSensorEntityDescription,
) -> None:
@@ -182,9 +184,9 @@ class AtwZoneSensor(MelDeviceSensor):
description,
key=f"{description.key}-zone-{zone.zone_index}",
)
super().__init__(coordinator, description)
super().__init__(api, description)
self._attr_device_info = coordinator.zone_device_info(zone)
self._attr_device_info = api.zone_device_info(zone)
self._zone = zone
@property

View File

@@ -43,9 +43,6 @@
},
"entity": {
"sensor": {
"energy_consumed": {
"name": "Energy consumed"
},
"flow_temperature": {
"name": "Flow temperature"
},

View File

@@ -21,27 +21,27 @@ from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudConfigEntry, MelCloudDevice
from .const import ATTR_STATUS
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
from .entity import MelCloudEntity
async def async_setup_entry(
_hass: HomeAssistant,
hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
coordinators = entry.runtime_data
mel_devices = entry.runtime_data
async_add_entities(
[
AtwWaterHeater(coordinator, coordinator.device)
for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
]
AtwWaterHeater(mel_device, mel_device.device)
for mel_device in mel_devices[DEVICE_TYPE_ATW]
],
True,
)
class AtwWaterHeater(MelCloudEntity, WaterHeaterEntity):
class AtwWaterHeater(WaterHeaterEntity):
"""Air-to-Water water heater."""
_attr_supported_features = (
@@ -49,26 +49,27 @@ class AtwWaterHeater(MelCloudEntity, WaterHeaterEntity):
| WaterHeaterEntityFeature.ON_OFF
| WaterHeaterEntityFeature.OPERATION_MODE
)
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
device: AtwDevice,
) -> None:
def __init__(self, api: MelCloudDevice, device: AtwDevice) -> None:
"""Initialize water heater device."""
super().__init__(coordinator)
self._api = api
self._device = device
self._attr_unique_id = coordinator.device.serial
self._attr_device_info = coordinator.device_info
self._attr_unique_id = api.device.serial
self._attr_device_info = api.device_info
async def async_turn_on(self, **_kwargs: Any) -> None:
async def async_update(self) -> None:
"""Update state from MELCloud."""
await self._api.async_update()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.async_set({PROPERTY_POWER: True})
await self._device.set({PROPERTY_POWER: True})
async def async_turn_off(self, **_kwargs: Any) -> None:
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.async_set({PROPERTY_POWER: False})
await self._device.set({PROPERTY_POWER: False})
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
@@ -102,7 +103,7 @@ class AtwWaterHeater(MelCloudEntity, WaterHeaterEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await self.coordinator.async_set(
await self._device.set(
{
PROPERTY_TARGET_TANK_TEMPERATURE: kwargs.get(
"temperature", self.target_temperature
@@ -112,7 +113,7 @@ class AtwWaterHeater(MelCloudEntity, WaterHeaterEntity):
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new target operation mode."""
await self.coordinator.async_set({PROPERTY_OPERATION_MODE: operation_mode})
await self._device.set({PROPERTY_OPERATION_MODE: operation_mode})
@property
def min_temp(self) -> float:

View File

@@ -7,7 +7,6 @@ from mill_local import OperationMode
import voluptuous as vol
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -112,16 +111,13 @@ class MillHeater(MillBaseEntity, ClimateEntity):
super().__init__(coordinator, device)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature and optionally HVAC mode."""
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_heater_temp(
self._id, float(temperature)
)
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_handle_set_hvac_mode_service(hvac_mode)
else:
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -129,11 +125,12 @@ class MillHeater(MillBaseEntity, ClimateEntity):
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=True
)
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=False
)
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
@callback
def _update_attr(self, device: mill.Heater) -> None:
@@ -192,26 +189,25 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit
self._update_attr()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature and optionally HVAC mode."""
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_target_temperature(
float(temperature)
)
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_handle_set_hvac_mode_service(hvac_mode)
else:
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.HEAT:
await self.coordinator.mill_data_connection.set_operation_mode_control_individually()
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.set_operation_mode_off()
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.AUTO:
await self.coordinator.mill_data_connection.set_operation_mode_weekly_program()
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
from typing import Any
from mycroftapi import MycroftAPI
@@ -11,8 +10,6 @@ from homeassistant.components.notify import BaseNotificationService
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -22,17 +19,17 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> MycroftNotificationService:
"""Get the Mycroft notification service."""
return MycroftNotificationService(hass.data[DOMAIN])
return MycroftNotificationService(hass.data["mycroft"])
class MycroftNotificationService(BaseNotificationService):
"""The Mycroft Notification Service."""
def __init__(self, mycroft_ip: str) -> None:
def __init__(self, mycroft_ip):
"""Initialize the service."""
self.mycroft_ip = mycroft_ip
def send_message(self, message: str = "", **kwargs: Any) -> None:
def send_message(self, message="", **kwargs):
"""Send a message mycroft to speak on instance."""
text = message
@@ -40,4 +37,4 @@ class MycroftNotificationService(BaseNotificationService):
if mycroft is not None:
mycroft.speak_text(text)
else:
_LOGGER.warning("Could not reach this instance of mycroft")
_LOGGER.log("Could not reach this instance of mycroft")

View File

@@ -3,25 +3,23 @@
from datetime import timedelta
import logging
from aiohttp import ClientError, ClientSession
import defusedxml.ElementTree as ET
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
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.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, UPDATE_URL
_LOGGER = logging.getLogger(__name__)
DOMAIN = "namecheapdns"
INTERVAL = timedelta(minutes=5)
UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"
CONFIG_SCHEMA = vol.Schema(
{
@@ -36,74 +34,39 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
type NamecheapConfigEntry = ConfigEntry[None]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the namecheap DNS component."""
if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
"""Set up Namecheap DynamicDNS from a config entry."""
host = entry.data[CONF_HOST]
domain = entry.data[CONF_DOMAIN]
password = entry.data[CONF_PASSWORD]
host = config[DOMAIN][CONF_HOST]
domain = config[DOMAIN][CONF_DOMAIN]
password = config[DOMAIN][CONF_PASSWORD]
session = async_get_clientsession(hass)
try:
if not await update_namecheapdns(session, host, domain, password):
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
},
)
except ClientError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
},
) from e
result = await _update_namecheapdns(session, host, domain, password)
if not result:
return False
async def update_domain_interval(now):
"""Update the namecheap DNS entry."""
await update_namecheapdns(session, host, domain, password)
await _update_namecheapdns(session, host, domain, password)
entry.async_on_unload(
async_track_time_interval(hass, update_domain_interval, INTERVAL)
)
async_track_time_interval(hass, update_domain_interval, INTERVAL)
return True
return result
async def async_unload_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def update_namecheapdns(
session: ClientSession, host: str, domain: str, password: str
):
async def _update_namecheapdns(session, host, domain, password):
"""Update namecheap DNS entry."""
params = {"host": host, "domain": domain, "password": password}
resp = await session.get(UPDATE_URL, params=params)
xml_string = await resp.text()
root = ET.fromstring(xml_string)
err_count = root.find("ErrCount").text
if "<ErrCount>0</ErrCount>" not in xml_string:
if int(err_count) != 0:
_LOGGER.warning("Updating namecheap domain failed: %s", domain)
return False

View File

@@ -1,91 +0,0 @@
"""Config flow for the Namecheap DynamicDNS integration."""
from __future__ import annotations
import logging
from typing import Any
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from . import update_namecheapdns
from .const import DOMAIN
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default="@"): cv.string,
vol.Required(CONF_DOMAIN): cv.string,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD, autocomplete="current-password"
)
),
}
)
class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Namecheap DynamicDNS."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST], CONF_DOMAIN: user_input[CONF_DOMAIN]}
)
session = async_get_clientsession(self.hass)
try:
if not await update_namecheapdns(session, **user_input):
errors["base"] = "update_failed"
except ClientError:
_LOGGER.debug("Cannot connect", exc_info=True)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title=f"{user_input[CONF_HOST]}.{user_input[CONF_DOMAIN]}",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders={"account_panel": "https://ap.www.namecheap.com/"},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
self._async_abort_entries_match(
{CONF_HOST: import_info[CONF_HOST], CONF_DOMAIN: import_info[CONF_DOMAIN]}
)
result = await self.async_step_user(import_info)
if errors := result.get("errors"):
deprecate_yaml_issue(self.hass, import_success=False)
return self.async_abort(reason=errors["base"])
deprecate_yaml_issue(self.hass, import_success=True)
return result

View File

@@ -1,6 +0,0 @@
"""Constants for the Namecheap DynamicDNS integration."""
DOMAIN = "namecheapdns"
UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"

View File

@@ -1,40 +0,0 @@
"""Issues for Namecheap DynamicDNS integration."""
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
@callback
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"""Deprecate yaml issue."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.8.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Namecheap DynamicDNS",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_error",
breaks_in_ha_version="2026.8.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_error",
translation_placeholders={
"url": f"/config/integrations/dashboard/add?domain={DOMAIN}"
},
)

View File

@@ -1,10 +1,9 @@
{
"domain": "namecheapdns",
"name": "Namecheap DynamicDNS",
"codeowners": ["@tr4nt0r"],
"config_flow": true,
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/namecheapdns",
"integration_type": "service",
"iot_class": "cloud_push",
"requirements": []
"quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1"]
}

View File

@@ -1,41 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"update_failed": "Updating DNS failed"
},
"step": {
"user": {
"data": {
"domain": "[%key:common::config_flow::data::username%]",
"host": "[%key:common::config_flow::data::host%]",
"password": "Dynamic DNS password"
},
"data_description": {
"domain": "The domain to update ('example.com')",
"host": "The host to update ('home' for home.example.com). Use '@' to update the root domain",
"password": "Dynamic DNS password for the domain"
},
"description": "Enter your Namecheap DynamicDNS domain and password below to configure dynamic DNS updates. You can find the Dynamic DNS password in your [Namecheap account]({account_panel}) under Domain List > Manage > Advanced DNS > Dynamic DNS."
}
}
},
"exceptions": {
"connection_error": {
"message": "Updating Namecheap DynamicDNS domain {domain} failed due to a connection error"
},
"update_failed": {
"message": "Updating Namecheap DynamicDNS domain {domain} failed"
}
},
"issues": {
"deprecated_yaml_import_issue_error": {
"description": "Configuring Namecheap DynamicDNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Namecheap DynamicDNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Namecheap DynamicDNS YAML configuration import failed"
}
}
}

View File

@@ -21,7 +21,6 @@ from .nasweb_data import NASwebData
PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
Platform.CLIMATE,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -1,168 +0,0 @@
"""Platform for NASweb thermostat."""
from __future__ import annotations
import time
from typing import Any
from webio_api import Thermostat as NASwebThermostat
from webio_api.const import KEY_THERMOSTAT
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
UnitOfTemperature,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
BaseDataUpdateCoordinatorProtocol,
)
from . import NASwebConfigEntry
from .const import DOMAIN, STATUS_UPDATE_MAX_TIME_INTERVAL
CLIMATE_TRANSLATION_KEY = "thermostat"
async def async_setup_entry(
hass: HomeAssistant,
config: NASwebConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up Climate platform."""
coordinator = config.runtime_data
nasweb_thermostat: NASwebThermostat = coordinator.data[KEY_THERMOSTAT]
climate = Thermostat(coordinator, nasweb_thermostat)
async_add_entities([climate])
class Thermostat(ClimateEntity, BaseCoordinatorEntity):
"""Entity representing NASweb thermostat."""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_has_entity_name = True
_attr_hvac_modes = [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
HVACMode.FAN_ONLY,
]
_attr_max_temp = 50
_attr_min_temp = -50
_attr_precision = 1.0
_attr_should_poll = False
_attr_supported_features = ClimateEntityFeature(
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
_attr_target_temperature_step = 1.0
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = CLIMATE_TRANSLATION_KEY
def __init__(
self,
coordinator: BaseDataUpdateCoordinatorProtocol,
nasweb_thermostat: NASwebThermostat,
) -> None:
"""Initialize Thermostat."""
super().__init__(coordinator)
self._thermostat = nasweb_thermostat
self._attr_available = False
self._attr_name = nasweb_thermostat.name
self._attr_unique_id = f"{DOMAIN}.{self._thermostat.webio_serial}.thermostat"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._thermostat.webio_serial)}
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._handle_coordinator_update()
def _set_attr_available(
self, entity_last_update: float, available: bool | None
) -> None:
if (
self.coordinator.last_update is None
or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL
):
self._attr_available = False
else:
self._attr_available = available if available is not None else False
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_current_temperature = self._thermostat.current_temp
self._attr_target_temperature_low = self._thermostat.temp_target_min
self._attr_target_temperature_high = self._thermostat.temp_target_max
self._attr_hvac_mode = self._get_current_hvac_mode()
self._attr_hvac_action = self._get_current_action()
self._attr_name = self._thermostat.name if self._thermostat.name else None
self._set_attr_available(
self._thermostat.last_update, self._thermostat.available
)
self.async_write_ha_state()
def _get_current_hvac_mode(self) -> HVACMode:
have_cooling = self._thermostat.enabled_above_output
have_heating = self._thermostat.enabled_below_output
if have_cooling and have_heating:
return HVACMode.HEAT_COOL
if have_cooling:
return HVACMode.COOL
if have_heating:
return HVACMode.HEAT
if self._thermostat.enabled_inrange_output:
return HVACMode.FAN_ONLY
return HVACMode.OFF
def _get_current_action(self) -> HVACAction:
if self._thermostat.current_temp is None:
return HVACAction.OFF
if (
self._thermostat.temp_target_min is not None
and self._thermostat.current_temp < self._thermostat.temp_target_min
and self._thermostat.enabled_below_output
):
return HVACAction.HEATING
if (
self._thermostat.temp_target_max is not None
and self._thermostat.current_temp > self._thermostat.temp_target_max
and self._thermostat.enabled_above_output
):
return HVACAction.COOLING
if (
self._thermostat.temp_target_min is not None
and self._thermostat.temp_target_max is not None
and self._thermostat.current_temp >= self._thermostat.temp_target_min
and self._thermostat.current_temp <= self._thermostat.temp_target_max
and self._thermostat.enabled_inrange_output
):
return HVACAction.FAN
return HVACAction.IDLE
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
Scheduling updates is not necessary, the coordinator takes care of updates via push notifications.
"""
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVACMode for Thermostat."""
await self._thermostat.set_hvac_mode(hvac_mode)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set temperature range for Thermostat."""
await self._thermostat.set_temperature(
kwargs["target_temp_low"], kwargs["target_temp_high"]
)

View File

@@ -23,7 +23,6 @@ _LOGGER = logging.getLogger(__name__)
KEY_INPUTS = "inputs"
KEY_OUTPUTS = "outputs"
KEY_THERMOSTAT = "thermostat"
KEY_ZONES = "zones"
@@ -105,7 +104,6 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
KEY_THERMOSTAT: self.webio_api.thermostat,
KEY_ZONES: self.webio_api.zones,
}
self.async_set_updated_data(data)
@@ -201,7 +199,6 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
KEY_THERMOSTAT: self.webio_api.thermostat,
KEY_ZONES: self.webio_api.zones,
}
self.async_set_updated_data(new_data)

View File

@@ -29,11 +29,6 @@
"name": "Zone {index}"
}
},
"climate": {
"thermostat": {
"name": "[%key:component::climate::entity_component::_::name%]"
}
},
"sensor": {
"sensor_input": {
"name": "Input {index}",

View File

@@ -47,6 +47,7 @@ rules:
test-coverage:
status: todo
comment: |
Use load_json_object_fixture in tests
Patch the library instead of the HTTP requests
Create a shared fixture for the mock config entry
Use init_integration in tests

View File

@@ -28,7 +28,7 @@ DEVICE_SUPPORT = {
"3A": (),
"3B": (),
"42": (),
"7E": ("EDS0065", "EDS0066", "EDS0068"),
"7E": ("EDS0066", "EDS0068"),
"A6": (),
"EF": ("HB_HUB", "HB_MOISTURE_METER", "HobbyBoards_EF"),
}

View File

@@ -297,20 +297,6 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
# 7E sensors are special sensors by Embedded Data Systems
EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
"EDS0065": (
OneWireSensorEntityDescription(
key="EDS0065/temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
OneWireSensorEntityDescription(
key="EDS0065/humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
),
"EDS0066": (
OneWireSensorEntityDescription(
key="EDS0066/temperature",

View File

@@ -4,35 +4,27 @@ from __future__ import annotations
from openevsehttp.__main__ import OpenEVSE
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSE]
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
"""Set up OpenEVSE from a config entry."""
charger = OpenEVSE(
entry.data[CONF_HOST],
entry.data.get(CONF_USERNAME),
entry.data.get(CONF_PASSWORD),
)
"""Set up openevse from a config entry."""
entry.runtime_data = OpenEVSE(entry.data[CONF_HOST])
try:
await charger.test_and_get()
await entry.runtime_data.test_and_get()
except TimeoutError as ex:
raise ConfigEntryNotReady("Unable to connect to charger") from ex
coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
raise ConfigEntryError("Unable to connect to charger") from ex
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
return True
async def async_unload_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR])

View File

@@ -3,22 +3,14 @@
from typing import Any
from openevsehttp.__main__ import OpenEVSE
from openevsehttp.exceptions import AuthenticationError, MissingSerial
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import config_validation as cv
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.service_info import zeroconf
from .const import CONF_ID, CONF_SERIAL, DOMAIN
USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string})
AUTH_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string}
)
class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
"""OpenEVSE config flow."""
@@ -29,49 +21,39 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Set up the instance."""
self.discovery_info: dict[str, Any] = {}
self._host: str | None = None
async def check_status(
self, host: str, user: str | None = None, password: str | None = None
) -> tuple[dict[str, str], str | None]:
async def check_status(self, host: str) -> tuple[bool, str | None]:
"""Check if we can connect to the OpenEVSE charger."""
charger = OpenEVSE(host, user, password)
charger = OpenEVSE(host)
try:
result = await charger.test_and_get()
except TimeoutError:
return {"base": "cannot_connect"}, None
except AuthenticationError:
return {"base": "invalid_auth"}, None
except MissingSerial:
return {}, None
return {}, result.get(CONF_SERIAL)
return False, None
return True, result.get(CONF_SERIAL)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
errors, serial = await self.check_status(user_input[CONF_HOST])
if not errors:
if serial is not None:
if (result := await self.check_status(user_input[CONF_HOST]))[0]:
if (serial := result[1]) is not None:
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"OpenEVSE {user_input[CONF_HOST]}",
data=user_input,
)
if errors["base"] == "invalid_auth":
self._host = user_input[CONF_HOST]
return await self.async_step_auth()
errors = {CONF_HOST: "cannot_connect"}
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input),
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
@@ -79,10 +61,9 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
self._async_abort_entries_match({CONF_HOST: data[CONF_HOST]})
errors, serial = await self.check_status(data[CONF_HOST])
if not errors:
if serial is not None:
if (result := await self.check_status(data[CONF_HOST]))[0]:
if (serial := result[1]) is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
else:
@@ -111,20 +92,17 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
self.context.update({"title_placeholders": {"name": name}})
if not (await self.check_status(host))[0]:
return self.async_abort(reason="cannot_connect")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
errors, _ = await self.check_status(self.discovery_info[CONF_HOST])
if errors:
if errors["base"] == "invalid_auth":
return await self.async_step_auth()
return self.async_abort(reason="unavailable_host")
if user_input is None:
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self.discovery_info[CONF_NAME]},
@@ -134,36 +112,3 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
title=self.discovery_info[CONF_NAME],
data={CONF_HOST: self.discovery_info[CONF_HOST]},
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the authentication step."""
errors: dict[str, str] = {}
if user_input is not None:
host = self._host or self.discovery_info[CONF_HOST]
errors, serial = await self.check_status(
host,
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
if not errors:
if self.unique_id is None and serial is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"OpenEVSE {host}",
data={
CONF_HOST: host,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="auth",
data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input),
errors=errors,
)

View File

@@ -1,51 +0,0 @@
"""Data update coordinator for OpenEVSE."""
from __future__ import annotations
from datetime import timedelta
import logging
from openevsehttp.__main__ import OpenEVSE
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSEDataUpdateCoordinator]
class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching OpenEVSE data."""
config_entry: OpenEVSEConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: OpenEVSEConfigEntry,
charger: OpenEVSE,
) -> None:
"""Initialize coordinator."""
self.charger = charger
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> None:
"""Fetch data from OpenEVSE charger."""
try:
await self.charger.update()
except TimeoutError as error:
raise UpdateFailed(
f"Timeout communicating with charger: {error}"
) from error

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from openevsehttp.__main__ import OpenEVSE
@@ -35,82 +33,61 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import ConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OpenEVSESensorDescription(SensorEntityDescription):
"""Describes an OpenEVSE sensor entity."""
value_fn: Callable[[OpenEVSE], str | float | None]
SENSOR_TYPES: tuple[OpenEVSESensorDescription, ...] = (
OpenEVSESensorDescription(
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="status",
translation_key="status",
value_fn=lambda ev: ev.status,
),
OpenEVSESensorDescription(
SensorEntityDescription(
key="charge_time",
translation_key="charge_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ev: ev.charge_time_elapsed,
),
OpenEVSESensorDescription(
SensorEntityDescription(
key="ambient_temp",
translation_key="ambient_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ev: ev.ambient_temperature,
),
OpenEVSESensorDescription(
SensorEntityDescription(
key="ir_temp",
translation_key="ir_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ev: ev.ir_temperature,
entity_registry_enabled_default=False,
),
OpenEVSESensorDescription(
SensorEntityDescription(
key="rtc_temp",
translation_key="rtc_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ev: ev.rtc_temperature,
entity_registry_enabled_default=False,
),
OpenEVSESensorDescription(
SensorEntityDescription(
key="usage_session",
translation_key="usage_session",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda ev: ev.usage_session,
),
OpenEVSESensorDescription(
SensorEntityDescription(
key="usage_total",
translation_key="usage_total",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda ev: ev.usage_total,
),
)
@@ -177,34 +154,41 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: OpenEVSEConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OpenEVSE sensors based on config entry."""
coordinator = entry.runtime_data
identifier = entry.unique_id or entry.entry_id
"""Add sensors for passed config_entry in HA."""
async_add_entities(
OpenEVSESensor(coordinator, description, identifier, entry.unique_id)
for description in SENSOR_TYPES
(
OpenEVSESensor(
config_entry.runtime_data,
description,
config_entry.entry_id,
config_entry.unique_id,
)
for description in SENSOR_TYPES
),
True,
)
class OpenEVSESensor(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], SensorEntity):
class OpenEVSESensor(SensorEntity):
"""Implementation of an OpenEVSE sensor."""
_attr_has_entity_name = True
entity_description: OpenEVSESensorDescription
def __init__(
self,
coordinator: OpenEVSEDataUpdateCoordinator,
description: OpenEVSESensorDescription,
identifier: str,
charger: OpenEVSE,
description: SensorEntityDescription,
entry_id: str,
unique_id: str | None,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self.charger = charger
identifier = unique_id or entry_id
self._attr_unique_id = f"{identifier}-{description.key}"
self._attr_device_info = DeviceInfo(
@@ -217,7 +201,28 @@ class OpenEVSESensor(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], SensorEnt
}
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.charger)
async def async_update(self) -> None:
"""Get the monitored data from the charger."""
try:
await self.charger.update()
except TimeoutError:
_LOGGER.warning("Could not update status for %s", self.name)
return
sensor_type = self.entity_description.key
if sensor_type == "status":
self._attr_native_value = self.charger.status
elif sensor_type == "charge_time":
self._attr_native_value = self.charger.charge_time_elapsed / 60
elif sensor_type == "ambient_temp":
self._attr_native_value = self.charger.ambient_temperature
elif sensor_type == "ir_temp":
self._attr_native_value = self.charger.ir_temperature
elif sensor_type == "rtc_temp":
self._attr_native_value = self.charger.rtc_temperature
elif sensor_type == "usage_session":
self._attr_native_value = float(self.charger.usage_session) / 1000
elif sensor_type == "usage_total":
self._attr_native_value = float(self.charger.usage_total) / 1000
else:
self._attr_native_value = "Unknown"

View File

@@ -5,20 +5,9 @@
"unavailable_host": "Unable to connect to host"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
"cannot_connect": "Unable to connect"
},
"step": {
"auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password to access your OpenEVSE charger",
"username": "The username to access your OpenEVSE charger"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.16.2"]
"requirements": ["opower==0.16.1"]
}

View File

@@ -7,6 +7,7 @@ from aiohttp import ClientError, ClientResponseError, web
from pypoint import PointSession
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -20,12 +21,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from . import api
from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK
from .coordinator import PointConfigEntry, PointDataUpdateCoordinator
from .coordinator import PointDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool:
"""Set up Minut Point from a config entry."""
@@ -56,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo
point_session = PointSession(auth)
coordinator = PointDataUpdateCoordinator(hass, point_session, entry)
coordinator = PointDataUpdateCoordinator(hass, point_session)
await coordinator.async_config_entry_first_refresh()

View File

@@ -16,8 +16,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PointConfigEntry
from .const import DOMAIN, SIGNAL_WEBHOOK
from .coordinator import PointConfigEntry
_LOGGER = logging.getLogger(__name__)

View File

@@ -15,8 +15,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PointConfigEntry
from .const import SIGNAL_WEBHOOK
from .coordinator import PointConfigEntry, PointDataUpdateCoordinator
from .coordinator import PointDataUpdateCoordinator
from .entity import MinutPointEntity
_LOGGER = logging.getLogger(__name__)

View File

@@ -7,7 +7,6 @@ from typing import Any
from pypoint import PointSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.dt import parse_datetime
@@ -16,24 +15,17 @@ from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator]
class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Class to manage fetching Point data from the API."""
config_entry: PointConfigEntry
def __init__(
self, hass: HomeAssistant, point: PointSession, config_entry: PointConfigEntry
) -> None:
def __init__(self, hass: HomeAssistant, point: PointSession) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
self.point = point
self.device_updates: dict[str, datetime] = {}

View File

@@ -14,7 +14,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import PointConfigEntry, PointDataUpdateCoordinator
from . import PointConfigEntry
from .coordinator import PointDataUpdateCoordinator
from .entity import MinutPointEntity
_LOGGER = logging.getLogger(__name__)

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