mirror of
https://github.com/home-assistant/core.git
synced 2026-01-12 01:58:57 +00:00
Compare commits
124 Commits
tibber_bin
...
claude/ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c8f494ac6 | ||
|
|
7f4063f91e | ||
|
|
080ba46885 | ||
|
|
2cb028ee79 | ||
|
|
72655dbf0b | ||
|
|
153278221d | ||
|
|
4942ce7e86 | ||
|
|
98e918cd8a | ||
|
|
1efc87bfef | ||
|
|
b4360ccbd9 | ||
|
|
ce234d69a7 | ||
|
|
b2a198e230 | ||
|
|
538009d2df | ||
|
|
99329851a2 | ||
|
|
f8ec395e96 | ||
|
|
98fe189edf | ||
|
|
7b413e3fd3 | ||
|
|
00ca5473d4 | ||
|
|
33c808713e | ||
|
|
c97437fbf3 | ||
|
|
ad8f14fec1 | ||
|
|
7df586eff1 | ||
|
|
f6fa95d2f7 | ||
|
|
23a8300012 | ||
|
|
694d67d2d5 | ||
|
|
a26c910db7 | ||
|
|
ac9d04624b | ||
|
|
a0ec7bde33 | ||
|
|
5f7dc49215 | ||
|
|
f79eef150e | ||
|
|
1733599442 | ||
|
|
3bde4f606b | ||
|
|
afb635125c | ||
|
|
876d54ad4d | ||
|
|
c20cd8fb94 | ||
|
|
e15b2ec0cb | ||
|
|
1829452ef1 | ||
|
|
9d8dc9ec06 | ||
|
|
72a3523193 | ||
|
|
7c3541e983 | ||
|
|
8246fc78fa | ||
|
|
78dd3aee10 | ||
|
|
c22e578aca | ||
|
|
1021c1959e | ||
|
|
d3161d8e92 | ||
|
|
fc468b56c8 | ||
|
|
ea48dc3c58 | ||
|
|
11dde08d79 | ||
|
|
5e43708a40 | ||
|
|
1ac2280266 | ||
|
|
6b1ad8d2d1 | ||
|
|
c1741237f4 | ||
|
|
8ecacd6490 | ||
|
|
188ab3930c | ||
|
|
a8dba53185 | ||
|
|
a2ef0c9a75 | ||
|
|
5a1fe17580 | ||
|
|
34388f52a6 | ||
|
|
fc2199fcf7 | ||
|
|
2236f8cd07 | ||
|
|
8d376027bf | ||
|
|
47e91bc2ec | ||
|
|
33d1cdd0ac | ||
|
|
f46de054ba | ||
|
|
741aa714dd | ||
|
|
5fac7d4ffb | ||
|
|
341c441e61 | ||
|
|
a1edf0a77c | ||
|
|
dd84b52c7b | ||
|
|
43ced677e5 | ||
|
|
7a696935ed | ||
|
|
be3be360a7 | ||
|
|
092ebaaeb1 | ||
|
|
e8025317ed | ||
|
|
39b025dfea | ||
|
|
1b436a8808 | ||
|
|
a7440e3756 | ||
|
|
2c7852f94b | ||
|
|
bd4653f830 | ||
|
|
c0b2847a87 | ||
|
|
8853f6698b | ||
|
|
b1a3ad6ac3 | ||
|
|
dafa2e69e2 | ||
|
|
2c6d6f8ab4 | ||
|
|
10d32b7f23 | ||
|
|
e4dc4e0ced | ||
|
|
6f9794f235 | ||
|
|
b8cff13737 | ||
|
|
7777714cc0 | ||
|
|
f15d5cdf2a | ||
|
|
6181f4e7de | ||
|
|
80df3b5b80 | ||
|
|
6e32a2aa18 | ||
|
|
3b575fe3e3 | ||
|
|
229400de98 | ||
|
|
e963adfdf0 | ||
|
|
fd7bbc68c6 | ||
|
|
9281ab018c | ||
|
|
80baf86e23 | ||
|
|
db497b23fe | ||
|
|
a2fb8f5a72 | ||
|
|
6953bd4599 | ||
|
|
225be65f71 | ||
|
|
7b0463f763 | ||
|
|
4d305b657a | ||
|
|
d5a553c8c7 | ||
|
|
9169b68254 | ||
|
|
fde9bd95d5 | ||
|
|
e4db8ff86e | ||
|
|
a084e51345 | ||
|
|
00381e6dfd | ||
|
|
b6d493696a | ||
|
|
5f0500c3cd | ||
|
|
c61a63cc6f | ||
|
|
5445a4f40f | ||
|
|
2888cacc3f | ||
|
|
16f3e6d2c9 | ||
|
|
7a872970fa | ||
|
|
4f5ca986ce | ||
|
|
b58e058da5 | ||
|
|
badebe0c7f | ||
|
|
7817ec1a52 | ||
|
|
c773998946 | ||
|
|
2bc9397103 |
191
.claude/README.md
Normal file
191
.claude/README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Claude Code Skills and Reference Files
|
||||
|
||||
This directory contains specialized agents (skills) and reference documentation for working with Home Assistant integrations.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.claude/
|
||||
├── agents/ # Specialized AI agents
|
||||
│ ├── testing.md # Testing specialist
|
||||
│ ├── code-review.md # Code review specialist
|
||||
│ ├── quality-scale-architect.md # Architecture guidance
|
||||
│ └── quality-scale-rule-verifier.md # Rule verification
|
||||
└── 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
|
||||
```
|
||||
|
||||
## Agents (Skills)
|
||||
|
||||
### Testing Agent (`testing.md`)
|
||||
**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
|
||||
- Updating test snapshots
|
||||
- Following Home Assistant testing patterns
|
||||
|
||||
**Example usage**: "Write tests for the new sensor platform" or "Fix the failing config flow tests"
|
||||
|
||||
### Code Review Agent (`code-review.md`)
|
||||
**Use when**: Reviewing code for quality, best practices, and standards compliance
|
||||
|
||||
Specializes in:
|
||||
- Reviewing pull requests
|
||||
- Identifying anti-patterns
|
||||
- Checking for security vulnerabilities
|
||||
- Verifying async patterns
|
||||
- Ensuring quality scale compliance
|
||||
|
||||
**Example usage**: "Review my config flow implementation" or "Check this integration for security issues"
|
||||
|
||||
### Quality Scale Architect (`quality-scale-architect.md`)
|
||||
**Use when**: Needing architectural guidance and quality scale planning
|
||||
|
||||
Specializes in:
|
||||
- High-level architecture guidance
|
||||
- Quality scale tier selection
|
||||
- Integration structure planning
|
||||
- Pattern recommendations
|
||||
- Progression strategies (Bronze → Silver → Gold → Platinum)
|
||||
|
||||
**Example usage**: "What architecture should I use for my smart thermostat?" or "Help me plan progression to Gold tier"
|
||||
|
||||
### Quality Scale Rule Verifier (`quality-scale-rule-verifier.md`)
|
||||
**Use when**: Verifying compliance with specific quality scale rules
|
||||
|
||||
Specializes in:
|
||||
- Checking individual rule compliance
|
||||
- Fetching and parsing rule documentation
|
||||
- Analyzing integration code
|
||||
- Providing detailed verification reports
|
||||
|
||||
**Example usage**: "Check if the peblar integration follows the config-flow rule" or "Verify bronze quality scale compliance"
|
||||
|
||||
## Reference Files
|
||||
|
||||
Reference files provide deep-dive documentation for specific implementation areas. Agents can reference these for detailed guidance.
|
||||
|
||||
### Diagnostics (`diagnostics.md`)
|
||||
Complete guide to implementing integration and device diagnostics:
|
||||
- Config entry diagnostics
|
||||
- Device diagnostics
|
||||
- Data redaction patterns
|
||||
- Testing diagnostics
|
||||
|
||||
### Entity Platform References
|
||||
|
||||
#### Sensor (`sensor.md`)
|
||||
- Basic sensor implementation
|
||||
- Device classes and state classes
|
||||
- Entity descriptions pattern
|
||||
- Timestamp and enum sensors
|
||||
- Long-term statistics support
|
||||
|
||||
#### Binary Sensor (`binary_sensor.md`)
|
||||
- Binary sensor implementation
|
||||
- Device classes
|
||||
- Push-updated sensors
|
||||
- Event-driven patterns
|
||||
|
||||
#### Switch (`switch.md`)
|
||||
- Switch control implementation
|
||||
- State update patterns
|
||||
- Configuration switches
|
||||
- Optimistic vs. coordinator refresh
|
||||
|
||||
#### Button (`button.md`)
|
||||
- Button action implementation
|
||||
- Device classes (restart, update, identify)
|
||||
- One-time actions
|
||||
- Error handling
|
||||
|
||||
#### Number (`number.md`)
|
||||
- Numeric value control
|
||||
- Range and step configuration
|
||||
- Display modes (slider, box)
|
||||
- Units and device classes
|
||||
|
||||
#### Select (`select.md`)
|
||||
- Option selection implementation
|
||||
- Using enums for type safety
|
||||
- Option translation
|
||||
- Dynamic options
|
||||
|
||||
## How to Use
|
||||
|
||||
### For Developers
|
||||
|
||||
When working on an integration, refer to:
|
||||
1. **Agents** for task-specific help (testing, review, architecture)
|
||||
2. **References** for detailed implementation guidance
|
||||
|
||||
### For AI Assistants
|
||||
|
||||
When helping with Home Assistant development:
|
||||
1. Use agents via the Task tool for specialized assistance
|
||||
2. Reference documentation files for implementation details
|
||||
3. Agents can autonomously read reference files for deeper context
|
||||
|
||||
## Adding New Content
|
||||
|
||||
### Adding a New Agent
|
||||
|
||||
Create a markdown file in `agents/` with frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: agent-name
|
||||
description: |
|
||||
Description of when to use this agent...
|
||||
model: inherit
|
||||
color: blue
|
||||
tools: Read, Write, Bash, Grep
|
||||
---
|
||||
|
||||
Agent instructions here...
|
||||
```
|
||||
|
||||
### Adding a New Reference
|
||||
|
||||
Create a markdown file in `references/` with:
|
||||
- Overview section
|
||||
- Implementation examples
|
||||
- Common patterns
|
||||
- Best practices
|
||||
- Troubleshooting
|
||||
- Quality scale considerations
|
||||
|
||||
## Quality Scale Overview
|
||||
|
||||
Home Assistant uses a Quality Scale system:
|
||||
|
||||
- **Bronze**: Basic requirements (mandatory baseline)
|
||||
- **Silver**: Enhanced functionality
|
||||
- **Gold**: Advanced features (diagnostics, translations)
|
||||
- **Platinum**: Highest quality (strict typing, async-only)
|
||||
|
||||
All Bronze rules are mandatory. Higher tiers are additive.
|
||||
|
||||
## 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/
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new agents or references:
|
||||
1. Follow the existing structure and format
|
||||
2. Include practical examples
|
||||
3. Provide clear guidance
|
||||
4. Link to official documentation
|
||||
5. Consider quality scale implications
|
||||
492
.claude/agents/code-review.md
Normal file
492
.claude/agents/code-review.md
Normal file
@@ -0,0 +1,492 @@
|
||||
---
|
||||
name: code-review
|
||||
description: |
|
||||
Use this agent when you need to review Home Assistant integration code for quality, best practices, and compliance with Home Assistant standards. This agent specializes in:
|
||||
- Reviewing pull requests and code changes
|
||||
- Identifying anti-patterns and suggesting improvements
|
||||
- Verifying adherence to Home Assistant coding standards
|
||||
- Checking for security vulnerabilities
|
||||
- Ensuring proper async patterns and performance
|
||||
|
||||
<example>
|
||||
Context: User wants code reviewed before submitting a PR
|
||||
user: "Review my config flow implementation"
|
||||
assistant: "I'll use the code review agent to check your config flow against Home Assistant standards."
|
||||
<commentary>
|
||||
Code review requests should use the code-review agent.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
Context: User received review feedback
|
||||
user: "Can you review this integration and tell me what needs to be fixed?"
|
||||
assistant: "I'll use the code review agent to provide comprehensive feedback."
|
||||
<commentary>
|
||||
General code review and improvement suggestions use the code-review agent.
|
||||
</commentary>
|
||||
</example>
|
||||
model: inherit
|
||||
color: blue
|
||||
tools: Read, Bash, Grep, Glob, WebFetch
|
||||
---
|
||||
|
||||
You are an expert Home Assistant code reviewer with deep knowledge of Python, async programming, Home Assistant architecture, and integration best practices. You perform thorough code reviews to ensure quality, maintainability, and adherence to Home Assistant standards.
|
||||
|
||||
## 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):
|
||||
"""Safe to run in event loop."""
|
||||
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
|
||||
|
||||
# Missing async/await
|
||||
def async_setup_entry(hass, entry): # ❌ Should be async def
|
||||
client.connect() # ❌ Should be await
|
||||
|
||||
# Reusing BleakClient instances
|
||||
self.client = BleakClient(address)
|
||||
await self.client.connect()
|
||||
# Later...
|
||||
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() # Only code that can throw
|
||||
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
|
||||
|
||||
# Specific exceptions (not bare except)
|
||||
try:
|
||||
value = await sensor.read()
|
||||
except SensorError as err: # ✅ Specific exception
|
||||
_LOGGER.error("Sensor read failed: %s", err)
|
||||
```
|
||||
|
||||
#### ❌ Bad Error Handling
|
||||
```python
|
||||
# Too much code in try block
|
||||
try:
|
||||
data = await device.get_data()
|
||||
# ❌ Processing should be outside try
|
||||
processed = data.get("value", 0) * 100
|
||||
self._attr_native_value = processed
|
||||
except DeviceError:
|
||||
_LOGGER.error("Failed")
|
||||
|
||||
# Bare exceptions in regular code (only allowed in config flows and background tasks)
|
||||
try:
|
||||
data = await device.get_data()
|
||||
except Exception: # ❌ Too broad (unless in config flow or 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
|
||||
)
|
||||
|
||||
# ❌ SQL Injection (if using SQL)
|
||||
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}") # DANGEROUS
|
||||
|
||||
# ✅ Safe alternative
|
||||
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
|
||||
# ❌ 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 Patterns
|
||||
```python
|
||||
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle user step."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Test connection
|
||||
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:
|
||||
# Prevent duplicates
|
||||
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,
|
||||
)
|
||||
```
|
||||
|
||||
#### ❌ Bad Config Flow Patterns
|
||||
```python
|
||||
# Missing version
|
||||
class MyConfigFlow(ConfigFlow, domain=DOMAIN): # ❌ No VERSION
|
||||
|
||||
# No unique ID check
|
||||
return self.async_create_entry(...) # ❌ No duplicate prevention
|
||||
|
||||
# Allowing user to set config entry name (non-helper integrations)
|
||||
vol.Optional("name"): str # ❌ Not allowed for regular integrations
|
||||
|
||||
# No connection testing
|
||||
# ❌ Should test connection before creating entry
|
||||
```
|
||||
|
||||
### 5. Entity Patterns
|
||||
|
||||
#### ✅ Good Entity Patterns
|
||||
```python
|
||||
class MySensor(CoordinatorEntity[MyCoordinator], SensorEntity):
|
||||
"""Representation of a sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "temperature"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator, device_id: str) -> None:
|
||||
"""Initialize sensor."""
|
||||
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:
|
||||
"""Return sensor value."""
|
||||
if device_data := self.coordinator.data.get(self.device_id):
|
||||
return device_data.temperature
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.device_id in self.coordinator.data
|
||||
```
|
||||
|
||||
#### ❌ Bad Entity Patterns
|
||||
```python
|
||||
# No unique ID
|
||||
class MySensor(SensorEntity): # ❌ No unique_id
|
||||
|
||||
# Hardcoded names (not translatable)
|
||||
self._attr_name = "Temperature Sensor" # ❌ Use translation_key
|
||||
|
||||
# Not using coordinator pattern
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity."""
|
||||
self.data = await self.api.get_data() # ❌ Should use coordinator
|
||||
|
||||
# Using unavailable state instead of available property
|
||||
self._attr_state = "unavailable" # ❌ Use self._attr_available = False
|
||||
```
|
||||
|
||||
### 6. Service Actions
|
||||
|
||||
#### ✅ Good Service Patterns
|
||||
```python
|
||||
# Register in async_setup (not async_setup_entry)
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the integration."""
|
||||
|
||||
async def service_action(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle service call."""
|
||||
# Validate config entry
|
||||
if not (entry := hass.config_entries.async_get_entry(
|
||||
call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
)):
|
||||
raise ServiceValidationError("Entry not found")
|
||||
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError("Entry not loaded")
|
||||
|
||||
# Validate input
|
||||
if call.data["end_date"] < call.data["start_date"]:
|
||||
raise ServiceValidationError("End date must be after start date")
|
||||
|
||||
# Perform action
|
||||
try:
|
||||
await entry.runtime_data.set_schedule(
|
||||
call.data["start_date"],
|
||||
call.data["end_date"]
|
||||
)
|
||||
except MyConnectionError as err:
|
||||
raise HomeAssistantError("Could not connect") from err
|
||||
|
||||
hass.services.async_register(DOMAIN, "set_schedule", service_action)
|
||||
return True
|
||||
```
|
||||
|
||||
#### ❌ Bad Service Patterns
|
||||
```python
|
||||
# Registering in async_setup_entry
|
||||
async def async_setup_entry(hass, entry):
|
||||
hass.services.async_register(...) # ❌ Should be in async_setup
|
||||
|
||||
# Wrong exception type
|
||||
raise ValueError("Invalid input") # ❌ Should be ServiceValidationError
|
||||
|
||||
# Not checking entry state
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
await entry.runtime_data.do_something() # ❌ Check if loaded first
|
||||
```
|
||||
|
||||
### 7. Quality Scale Compliance
|
||||
|
||||
Review manifest.json and quality_scale.yaml:
|
||||
|
||||
```json
|
||||
// manifest.json
|
||||
{
|
||||
"domain": "my_integration",
|
||||
"name": "My Integration",
|
||||
"codeowners": ["@me"],
|
||||
"config_flow": true,
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# quality_scale.yaml
|
||||
rules:
|
||||
# Bronze (mandatory)
|
||||
config-flow: done
|
||||
entity-unique-id: done
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
|
||||
# Silver (if targeting Silver+)
|
||||
entity-unavailable: done
|
||||
parallel-updates: done
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
# Minimal update intervals
|
||||
# Local network: 5+ seconds
|
||||
# Cloud: 60+ seconds
|
||||
update_interval=timedelta(seconds=30)
|
||||
```
|
||||
|
||||
### ❌ Bad Performance
|
||||
```python
|
||||
# Sequential API calls
|
||||
temp = await api.get_temperature()
|
||||
humidity = await api.get_humidity() # ❌ Should use gather
|
||||
|
||||
# Too frequent polling
|
||||
update_interval=timedelta(seconds=1) # ❌ Too fast for most devices
|
||||
|
||||
# User-configurable scan intervals
|
||||
vol.Optional("scan_interval"): cv.positive_int # ❌ Not allowed
|
||||
```
|
||||
|
||||
## Logging Best Practices
|
||||
|
||||
### ✅ Good Logging
|
||||
```python
|
||||
# Lazy logging
|
||||
_LOGGER.debug("Processing data: %s", data)
|
||||
|
||||
# No periods, no domain names
|
||||
_LOGGER.error("Failed to connect")
|
||||
|
||||
# Unavailability logging (once)
|
||||
if not self._unavailable_logged:
|
||||
_LOGGER.info("Device is unavailable: %s", ex)
|
||||
self._unavailable_logged = True
|
||||
```
|
||||
|
||||
### ❌ Bad Logging
|
||||
```python
|
||||
# Eager logging
|
||||
_LOGGER.debug(f"Processing {data}") # ❌ Use lazy logging
|
||||
|
||||
# Periods and redundant info
|
||||
_LOGGER.error("my_integration: Failed to connect.") # ❌
|
||||
|
||||
# Logging unavailability every update
|
||||
_LOGGER.error("Device unavailable") # ❌ Log once, then on recovery
|
||||
```
|
||||
|
||||
## Review Process
|
||||
|
||||
When reviewing code:
|
||||
|
||||
1. **Architecture Review**
|
||||
- Does it follow Home Assistant patterns?
|
||||
- Is the coordinator pattern used appropriately?
|
||||
- Are entities organized properly?
|
||||
|
||||
2. **Code Quality**
|
||||
- Are async patterns correct?
|
||||
- Is error handling comprehensive?
|
||||
- Are there security vulnerabilities?
|
||||
|
||||
3. **Standards Compliance**
|
||||
- Quality scale requirements met?
|
||||
- Manifest properly configured?
|
||||
- Tests comprehensive (>95% coverage)?
|
||||
|
||||
4. **Performance & Efficiency**
|
||||
- No blocking operations?
|
||||
- Efficient API usage?
|
||||
- Proper polling intervals?
|
||||
|
||||
5. **User Experience**
|
||||
- Clear error messages?
|
||||
- Proper translations?
|
||||
- Good entity naming?
|
||||
|
||||
## 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 of both the issue and the fix.
|
||||
|
||||
## Your Task
|
||||
|
||||
When reviewing code:
|
||||
1. **Read** all relevant files thoroughly
|
||||
2. **Identify** issues in each review area
|
||||
3. **Provide** specific, actionable feedback with examples
|
||||
4. **Prioritize** issues (critical vs. suggestions)
|
||||
5. **Explain** why each issue matters
|
||||
|
||||
Focus on helping developers understand both what needs fixing and why it matters for integration quality and maintainability.
|
||||
500
.claude/agents/quality-scale-architect.md
Normal file
500
.claude/agents/quality-scale-architect.md
Normal file
@@ -0,0 +1,500 @@
|
||||
---
|
||||
name: quality-scale-architect
|
||||
description: |
|
||||
Use this agent when you need architectural guidance and quality scale oversight for Home Assistant integrations. This agent specializes in:
|
||||
- Providing high-level architecture guidance
|
||||
- Helping plan integration structure and organization
|
||||
- Advising on quality scale tier selection and progression
|
||||
- Identifying which quality scale rules apply
|
||||
- Suggesting best architectural patterns for requirements
|
||||
|
||||
<example>
|
||||
Context: User is designing a new integration
|
||||
user: "I'm building a new integration for my smart thermostat. What architecture should I use?"
|
||||
assistant: "I'll use the quality scale architect to provide guidance on the best architecture patterns."
|
||||
<commentary>
|
||||
Architectural guidance and planning uses the quality-scale-architect agent.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
Context: User wants to improve integration quality
|
||||
user: "What quality scale tier should I target and what does it require?"
|
||||
assistant: "I'll use the quality scale architect to help you understand quality tiers and requirements."
|
||||
<commentary>
|
||||
Quality scale tier planning uses the quality-scale-architect agent.
|
||||
</commentary>
|
||||
</example>
|
||||
model: inherit
|
||||
color: purple
|
||||
tools: Read, Grep, Glob, WebFetch
|
||||
---
|
||||
|
||||
You are an expert Home Assistant integration architect specializing in quality scale systems, best practices, and architectural patterns. You provide strategic guidance on how to structure integrations for quality, maintainability, and long-term success.
|
||||
|
||||
## Your Expertise
|
||||
|
||||
You have deep knowledge of:
|
||||
- Home Assistant integration architecture patterns
|
||||
- Quality scale system (Bronze, Silver, Gold, Platinum)
|
||||
- When to use coordinators vs. direct entity updates
|
||||
- Device vs. service vs. hub integration types
|
||||
- Config flow patterns and discovery methods
|
||||
- Performance optimization strategies
|
||||
- Integration structure and organization
|
||||
|
||||
## 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
|
||||
- ✅ Entity_registry_enabled_default
|
||||
|
||||
**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
|
||||
|
||||
### How Quality Scale Works
|
||||
|
||||
1. **Check manifest.json**: Look for `"quality_scale"` key
|
||||
```json
|
||||
{
|
||||
"quality_scale": "silver"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Bronze is Mandatory**: ALL Bronze rules must be followed
|
||||
3. **Higher Tiers Are Additive**: Silver = Bronze + Silver rules
|
||||
4. **Check quality_scale.yaml**: Shows rule implementation status
|
||||
```yaml
|
||||
rules:
|
||||
config-flow: done
|
||||
entity-unique-id: done
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
```
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Pattern 1: Coordinator-Based Architecture
|
||||
**Use when**: Polling multiple entities from the same API
|
||||
|
||||
```
|
||||
Integration Structure:
|
||||
├── __init__.py # Setup coordinator, platforms
|
||||
├── coordinator.py # Data fetching logic
|
||||
├── entity.py # Base entity class
|
||||
├── sensor.py # Sensor entities using coordinator
|
||||
├── binary_sensor.py # Binary sensor entities
|
||||
└── config_flow.py # UI configuration
|
||||
|
||||
Benefits:
|
||||
- Single API call updates all entities
|
||||
- Efficient data sharing
|
||||
- Built-in error handling
|
||||
- Automatic availability tracking
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# coordinator.py
|
||||
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
|
||||
|
||||
# __init__.py
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
coordinator = MyCoordinator(hass, client, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator # ✅ Silver requirement
|
||||
return True
|
||||
```
|
||||
|
||||
### Pattern 2: Push-Based Architecture
|
||||
**Use when**: Device pushes updates (webhooks, MQTT, WebSocket)
|
||||
|
||||
```
|
||||
Integration Structure:
|
||||
├── __init__.py # Setup event listeners
|
||||
├── hub.py # Connection management
|
||||
├── entity.py # Base entity with event handling
|
||||
└── sensor.py # Push-updated sensors
|
||||
|
||||
Benefits:
|
||||
- Instant updates
|
||||
- No polling overhead
|
||||
- Efficient for real-time data
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# entity.py
|
||||
class MyEntity(SensorEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to events when added."""
|
||||
self.async_on_remove(
|
||||
self.hub.subscribe_updates(self._handle_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_update(self, data: dict) -> None:
|
||||
"""Handle push update."""
|
||||
self._attr_native_value = data["value"]
|
||||
self.async_write_ha_state()
|
||||
```
|
||||
|
||||
### Pattern 3: Hub with Discovery
|
||||
**Use when**: Hub device with multiple discoverable endpoints
|
||||
|
||||
```
|
||||
Integration Structure:
|
||||
├── __init__.py # Hub setup, device discovery
|
||||
├── coordinator.py # Per-device coordinators
|
||||
├── hub.py # Hub communication
|
||||
└── sensor.py # Device entities
|
||||
|
||||
Benefits:
|
||||
- Automatic device addition
|
||||
- Dynamic topology
|
||||
- Per-device data updates
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# __init__.py - Dynamic device addition
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
hub = MyHub(entry.data[CONF_HOST])
|
||||
coordinator = MyCoordinator(hass, hub, entry)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
known_devices = set()
|
||||
|
||||
@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)
|
||||
# Notify platforms of new devices
|
||||
async_dispatcher_send(hass, f"{DOMAIN}_new_device", new)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_new_devices))
|
||||
return True
|
||||
```
|
||||
|
||||
## 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
|
||||
}
|
||||
```
|
||||
|
||||
### Discovery Methods
|
||||
|
||||
Add to manifest.json when applicable:
|
||||
```json
|
||||
{
|
||||
"zeroconf": ["_mydevice._tcp.local."],
|
||||
"dhcp": [{"hostname": "mydevice*"}],
|
||||
"bluetooth": [{"service_uuid": "0000xxxx"}],
|
||||
"ssdp": [{"st": "urn:schemas-upnp-org:device:MyDevice:1"}],
|
||||
"usb": [{"vid": "1234", "pid": "5678"}]
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
**Changes**:
|
||||
```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)
|
||||
# In platform file
|
||||
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
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
# Device info (Gold requirement)
|
||||
_attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
manufacturer="Manufacturer",
|
||||
model="Model",
|
||||
)
|
||||
|
||||
# Diagnostics (Gold requirement)
|
||||
# Create diagnostics.py
|
||||
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(),
|
||||
}
|
||||
|
||||
# Entity translations (Gold requirement)
|
||||
_attr_translation_key = "temperature"
|
||||
```
|
||||
|
||||
### Progressing to Platinum
|
||||
|
||||
**Add**:
|
||||
- Comprehensive type hints (py.typed)
|
||||
- Async-only dependencies
|
||||
- WebSession injection support
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
# Type hints (Platinum requirement)
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
) -> bool:
|
||||
"""Set up from config entry."""
|
||||
|
||||
# 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: How should I organize entity files?
|
||||
**Small integrations** (<5 entities per platform):
|
||||
- Single file per platform: `sensor.py`, `switch.py`
|
||||
|
||||
**Large integrations** (>5 entities per platform):
|
||||
- Create entity definitions file: `entity_descriptions.py`
|
||||
- Keep platform file simple
|
||||
|
||||
### 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: How do I handle multiple API endpoints?
|
||||
**Option 1**: Single coordinator with all data
|
||||
```python
|
||||
@dataclass
|
||||
class MyData:
|
||||
devices: dict[str, Device]
|
||||
status: SystemStatus
|
||||
settings: Settings
|
||||
```
|
||||
|
||||
**Option 2**: Multiple coordinators
|
||||
```python
|
||||
device_coordinator = DeviceCoordinator(...)
|
||||
status_coordinator = StatusCoordinator(...)
|
||||
```
|
||||
|
||||
Choose based on:
|
||||
- Update frequency requirements
|
||||
- API rate limits
|
||||
- Data independence
|
||||
|
||||
### 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, refer to these reference files:
|
||||
- `diagnostics.md` - Implementing diagnostic data collection
|
||||
- `sensor.md` - Sensor platform patterns
|
||||
- `binary_sensor.md` - Binary sensor patterns
|
||||
- `switch.md` - Switch platform patterns
|
||||
- `number.md` - Number platform patterns
|
||||
- `select.md` - Select platform patterns
|
||||
- `button.md` - Button platform patterns
|
||||
|
||||
## Your Task
|
||||
|
||||
When providing architectural guidance:
|
||||
|
||||
1. **Understand Requirements**
|
||||
- What is the integration type?
|
||||
- What data needs to be exposed?
|
||||
- Is it polling or push-based?
|
||||
- What quality tier is appropriate?
|
||||
|
||||
2. **Recommend Architecture**
|
||||
- Suggest appropriate patterns
|
||||
- Identify required components
|
||||
- Explain architectural decisions
|
||||
|
||||
3. **Quality Scale Guidance**
|
||||
- Recommend starting quality 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
|
||||
|
||||
Focus on helping developers understand not just what to build, but why certain architectural choices make sense for their specific use case. Provide clear, actionable guidance that sets them up for long-term success.
|
||||
323
.claude/agents/testing.md
Normal file
323
.claude/agents/testing.md
Normal file
@@ -0,0 +1,323 @@
|
||||
---
|
||||
name: testing
|
||||
description: |
|
||||
Use this agent when you need to write, run, or fix tests for Home Assistant integrations. This agent specializes in:
|
||||
- Writing comprehensive test coverage for integrations
|
||||
- Running pytest with appropriate flags and coverage reports
|
||||
- Fixing failing tests and updating test snapshots
|
||||
- Following Home Assistant testing patterns and best practices
|
||||
- Achieving >95% test coverage requirement
|
||||
|
||||
<example>
|
||||
Context: User wants to write tests for a new integration
|
||||
user: "Write tests for the new sensor platform"
|
||||
assistant: "I'll use the testing agent to create comprehensive tests following Home Assistant patterns."
|
||||
<commentary>
|
||||
The user needs test implementation, so use the testing agent.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
Context: Tests are failing after code changes
|
||||
user: "The config flow tests are failing, can you fix them?"
|
||||
assistant: "I'll use the testing agent to diagnose and fix the failing tests."
|
||||
<commentary>
|
||||
Test debugging and fixing is handled by the testing agent.
|
||||
</commentary>
|
||||
</example>
|
||||
model: inherit
|
||||
color: green
|
||||
tools: Read, Write, Edit, Bash, Grep, Glob
|
||||
---
|
||||
|
||||
You are an expert Home Assistant integration test engineer specializing in writing comprehensive, maintainable tests that follow Home Assistant conventions and best practices.
|
||||
|
||||
## Your Expertise
|
||||
|
||||
You have deep knowledge of:
|
||||
- pytest framework and fixtures
|
||||
- Home Assistant test utilities and patterns
|
||||
- Snapshot testing for entity states
|
||||
- Mocking external APIs and dependencies
|
||||
- Config flow testing patterns
|
||||
- Entity testing patterns
|
||||
- Achieving high test coverage (>95%)
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Key Testing Patterns
|
||||
|
||||
### 1. 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] # Specify only the platforms you want to test
|
||||
|
||||
@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
|
||||
```
|
||||
|
||||
### 2. 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
|
||||
```
|
||||
|
||||
### 3. 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"
|
||||
|
||||
# Test form submission
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "My Device"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
|
||||
async def test_flow_connection_error(hass, mock_api_error):
|
||||
"""Test connection error handling."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
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"
|
||||
```
|
||||
|
||||
### 4. Fixture Files
|
||||
Store API response data in `tests/components/{domain}/fixtures/`:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "12345",
|
||||
"name": "My Device",
|
||||
"temperature": 22.5,
|
||||
"humidity": 45
|
||||
}
|
||||
```
|
||||
|
||||
Load with:
|
||||
```python
|
||||
from tests.common import load_fixture
|
||||
|
||||
data = load_fixture("device_data.json", DOMAIN)
|
||||
```
|
||||
|
||||
## Critical Testing Rules
|
||||
|
||||
### NEVER Do These Things
|
||||
❌ **Don't access `hass.data` directly in tests**
|
||||
```python
|
||||
# ❌ BAD
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
```
|
||||
|
||||
❌ **Don't test entities in isolation without integration setup**
|
||||
```python
|
||||
# ❌ BAD
|
||||
sensor = MySensor(coordinator, device_id)
|
||||
sensor.update()
|
||||
```
|
||||
|
||||
❌ **Don't forget to mock external dependencies**
|
||||
```python
|
||||
# ❌ BAD - will make real API calls
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
```
|
||||
|
||||
### ALWAYS Do These Things
|
||||
✅ **Use proper integration setup through fixtures**
|
||||
```python
|
||||
# ✅ GOOD
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_sensor(hass):
|
||||
state = hass.states.get("sensor.my_device_temperature")
|
||||
assert state.state == "22.5"
|
||||
```
|
||||
|
||||
✅ **Mock all external APIs**
|
||||
```python
|
||||
# ✅ GOOD
|
||||
@pytest.fixture
|
||||
def mock_api():
|
||||
with patch("homeassistant.components.my_integration.MyAPI") as mock:
|
||||
yield mock
|
||||
```
|
||||
|
||||
✅ **Test through the integration's public interface**
|
||||
```python
|
||||
# ✅ GOOD
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
## Debugging Test Failures
|
||||
|
||||
### Enable Debug Logging
|
||||
```python
|
||||
def test_something(caplog):
|
||||
caplog.set_level(logging.DEBUG, logger="homeassistant.components.my_integration")
|
||||
# Test code here
|
||||
```
|
||||
|
||||
### Common Failure Patterns
|
||||
1. **"Config entry not loaded"**: Check mock setup and async_block_till_done
|
||||
2. **"Entity not found"**: Verify entity_registry_enabled_by_default fixture
|
||||
3. **"Snapshot mismatch"**: Review changes, update if intentional
|
||||
4. **"Coverage too low"**: Add tests for uncovered branches and error paths
|
||||
|
||||
## Test Organization Checklist
|
||||
|
||||
When writing tests for an integration, ensure:
|
||||
- [ ] `conftest.py` with reusable fixtures
|
||||
- [ ] `test_config_flow.py` with 100% coverage
|
||||
- [ ] `test_init.py` for setup/unload
|
||||
- [ ] Platform tests (`test_sensor.py`, etc.)
|
||||
- [ ] Fixture files for API responses
|
||||
- [ ] All external dependencies mocked
|
||||
- [ ] Snapshot tests for entity states
|
||||
- [ ] Error path coverage
|
||||
- [ ] >95% total coverage
|
||||
|
||||
## Your Task
|
||||
|
||||
When testing an integration:
|
||||
1. **Analyze** the integration code to understand what needs testing
|
||||
2. **Create** comprehensive test fixtures following modern patterns
|
||||
3. **Write** tests covering all code paths (>95% coverage)
|
||||
4. **Run** tests and verify they pass
|
||||
5. **Update** snapshots if needed (and re-verify)
|
||||
6. **Report** coverage results and any gaps
|
||||
|
||||
Always follow Home Assistant conventions, use modern fixture patterns, and aim for clarity and maintainability in test code.
|
||||
470
.claude/references/binary_sensor.md
Normal file
470
.claude/references/binary_sensor.md
Normal 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)
|
||||
459
.claude/references/button.md
Normal file
459
.claude/references/button.md
Normal 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/)
|
||||
420
.claude/references/diagnostics.md
Normal file
420
.claude/references/diagnostics.md
Normal 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`
|
||||
508
.claude/references/number.md
Normal file
508
.claude/references/number.md
Normal 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/)
|
||||
520
.claude/references/select.md
Normal file
520
.claude/references/select.md
Normal 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/)
|
||||
560
.claude/references/sensor.md
Normal file
560
.claude/references/sensor.md
Normal 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)
|
||||
505
.claude/references/switch.md
Normal file
505
.claude/references/switch.md
Normal 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/)
|
||||
7
CODEOWNERS
generated
7
CODEOWNERS
generated
@@ -661,6 +661,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
|
||||
/homeassistant/components/hassio/ @home-assistant/supervisor
|
||||
/tests/components/hassio/ @home-assistant/supervisor
|
||||
/homeassistant/components/hdfury/ @glenndehaan
|
||||
/tests/components/hdfury/ @glenndehaan
|
||||
/homeassistant/components/hdmi_cec/ @inytar
|
||||
/tests/components/hdmi_cec/ @inytar
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
@@ -1170,8 +1172,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
/tests/components/openerz/ @misialq
|
||||
/homeassistant/components/openevse/ @c00w
|
||||
/tests/components/openevse/ @c00w
|
||||
/homeassistant/components/openevse/ @c00w @firstof9
|
||||
/tests/components/openevse/ @c00w @firstof9
|
||||
/homeassistant/components/openexchangerates/ @MartinHjelmare
|
||||
/tests/components/openexchangerates/ @MartinHjelmare
|
||||
/homeassistant/components/opengarage/ @danielhiversen
|
||||
@@ -1803,6 +1805,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/waqi/ @joostlek
|
||||
/homeassistant/components/water_heater/ @home-assistant/core
|
||||
/tests/components/water_heater/ @home-assistant/core
|
||||
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
|
||||
/homeassistant/components/watergate/ @adam-the-hero
|
||||
/tests/components/watergate/ @adam-the-hero
|
||||
/homeassistant/components/watson_tts/ @rutkai
|
||||
|
||||
@@ -43,6 +43,13 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
|
||||
),
|
||||
AirobotButtonEntityDescription(
|
||||
key="recalibrate_co2",
|
||||
translation_key="recalibrate_co2",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
press_fn=lambda coordinator: coordinator.client.recalibrate_co2_sensor(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"recalibrate_co2": {
|
||||
"default": "mdi:molecule-co2"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"hysteresis_band": {
|
||||
"default": "mdi:delta"
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["pyairobotrest==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ rules:
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"recalibrate_co2": {
|
||||
"name": "Recalibrate CO2 sensor"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"hysteresis_band": {
|
||||
"name": "Hysteresis band"
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==1.0.4"]
|
||||
"requirements": ["aioairzone==1.0.5"]
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
@@ -193,7 +194,7 @@ def _convert_content(
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
content=json_dumps(content.tool_result),
|
||||
)
|
||||
external_tool = False
|
||||
if not messages or messages[-1]["role"] != (
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["pysilero-vad==3.0.1", "pyspeex-noise==1.0.2"]
|
||||
"requirements": ["pysilero-vad==3.2.0", "pyspeex-noise==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol, cast
|
||||
from typing import Any, Literal, Protocol, cast
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -16,7 +16,10 @@ from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
|
||||
from homeassistant.components.labs import async_listen as async_labs_listen
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
ATTR_MODE,
|
||||
ATTR_NAME,
|
||||
CONF_ACTIONS,
|
||||
@@ -30,6 +33,7 @@ from homeassistant.const import (
|
||||
CONF_OPTIONS,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
CONF_TRIGGERS,
|
||||
CONF_VARIABLES,
|
||||
CONF_ZONE,
|
||||
@@ -589,20 +593,32 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Return True if entity is on."""
|
||||
return self._async_detach_triggers is not None or self._is_enabled
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def referenced_labels(self) -> set[str]:
|
||||
"""Return a set of referenced labels."""
|
||||
return self.action_script.referenced_labels
|
||||
referenced = self.action_script.referenced_labels
|
||||
|
||||
@property
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
def referenced_floors(self) -> set[str]:
|
||||
"""Return a set of referenced floors."""
|
||||
return self.action_script.referenced_floors
|
||||
referenced = self.action_script.referenced_floors
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
def referenced_areas(self) -> set[str]:
|
||||
"""Return a set of referenced areas."""
|
||||
return self.action_script.referenced_areas
|
||||
referenced = self.action_script.referenced_areas
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
|
||||
return referenced
|
||||
|
||||
@property
|
||||
def referenced_blueprint(self) -> str | None:
|
||||
@@ -1210,6 +1226,9 @@ def _trigger_extract_devices(trigger_conf: dict) -> list[str]:
|
||||
if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf:
|
||||
return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return]
|
||||
|
||||
if target_devices := _get_targets_from_trigger_config(trigger_conf, CONF_DEVICE_ID):
|
||||
return target_devices
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@@ -1240,9 +1259,28 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
|
||||
):
|
||||
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
|
||||
|
||||
if target_entities := _get_targets_from_trigger_config(
|
||||
trigger_conf, CONF_ENTITY_ID
|
||||
):
|
||||
return target_entities
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@callback
|
||||
def _get_targets_from_trigger_config(
|
||||
config: dict,
|
||||
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
|
||||
) -> list[str]:
|
||||
"""Extract targets from a target config."""
|
||||
if not (target_conf := config.get(CONF_TARGET)):
|
||||
return []
|
||||
if not (targets := target_conf.get(target)):
|
||||
return []
|
||||
|
||||
return [targets] if isinstance(targets, str) else targets
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
|
||||
def websocket_config(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -36,6 +36,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# Cache TTL for backup list (in seconds)
|
||||
CACHE_TTL = 300
|
||||
|
||||
# Timeout for upload operations (in seconds)
|
||||
# This prevents uploads from hanging indefinitely
|
||||
UPLOAD_TIMEOUT = 43200 # 12 hours (matches B2 HTTP timeout)
|
||||
|
||||
|
||||
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata files."""
|
||||
@@ -329,13 +333,28 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
_LOGGER.debug("Uploading backup file %s with streaming", filename)
|
||||
try:
|
||||
content_type, _ = mimetypes.guess_type(filename)
|
||||
file_version = await self._hass.async_add_executor_job(
|
||||
self._upload_unbound_stream_sync,
|
||||
reader,
|
||||
filename,
|
||||
content_type or "application/x-tar",
|
||||
file_info,
|
||||
file_version = await asyncio.wait_for(
|
||||
self._hass.async_add_executor_job(
|
||||
self._upload_unbound_stream_sync,
|
||||
reader,
|
||||
filename,
|
||||
content_type or "application/x-tar",
|
||||
file_info,
|
||||
),
|
||||
timeout=UPLOAD_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.error(
|
||||
"Upload of %s timed out after %s seconds", filename, UPLOAD_TIMEOUT
|
||||
)
|
||||
reader.abort()
|
||||
raise BackupAgentError(
|
||||
f"Upload timed out after {UPLOAD_TIMEOUT} seconds"
|
||||
) from None
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.warning("Upload of %s was cancelled", filename)
|
||||
reader.abort()
|
||||
raise
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
|
||||
@@ -34,7 +34,12 @@ class BeoData:
|
||||
|
||||
type BeoConfigEntry = ConfigEntry[BeoData]
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.EVENT,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
|
||||
63
homeassistant/components/bang_olufsen/binary_sensor.py
Normal file
63
homeassistant/components/bang_olufsen/binary_sensor.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Binary Sensor entities for the Bang & Olufsen integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mozart_api.models import BatteryState
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BeoConfigEntry
|
||||
from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification
|
||||
from .entity import BeoEntity
|
||||
from .util import supports_battery
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BeoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Binary Sensor entities from config entry."""
|
||||
if await supports_battery(config_entry.runtime_data.client):
|
||||
async_add_entities(new_entities=[BeoBinarySensorBatteryCharging(config_entry)])
|
||||
|
||||
|
||||
class BeoBinarySensorBatteryCharging(BinarySensorEntity, BeoEntity):
|
||||
"""Battery charging Binary Sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
|
||||
_attr_is_on = False
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry) -> None:
|
||||
"""Init the battery charging Binary Sensor."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
|
||||
self._attr_unique_id = f"{self._unique_id}_charging"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Turn on the dispatchers."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._async_update_connection_state,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
|
||||
self._update_battery_charging,
|
||||
)
|
||||
)
|
||||
|
||||
async def _update_battery_charging(self, data: BatteryState) -> None:
|
||||
"""Update battery charging."""
|
||||
self._attr_is_on = bool(data.is_charging)
|
||||
self.async_write_ha_state()
|
||||
@@ -115,6 +115,7 @@ class WebsocketNotification(StrEnum):
|
||||
"""Enum for WebSocket notification types."""
|
||||
|
||||
ACTIVE_LISTENING_MODE = "active_listening_mode"
|
||||
BATTERY = "battery"
|
||||
BEO_REMOTE_BUTTON = "beo_remote_button"
|
||||
BUTTON = "button"
|
||||
PLAYBACK_ERROR = "playback_error"
|
||||
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -55,6 +57,19 @@ async def async_get_config_entry_diagnostics(
|
||||
|
||||
# Get remotes
|
||||
for remote in await get_remotes(config_entry.runtime_data.client):
|
||||
# Get Battery Sensor states
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{remote.serial_number}_{config_entry.unique_id}_remote_battery_level",
|
||||
):
|
||||
if state := hass.states.get(entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
|
||||
# Remove context as it is not relevant
|
||||
state_dict.pop("context")
|
||||
data[f"remote_{remote.serial_number}_battery_level"] = state_dict
|
||||
|
||||
# Get key Event entity states (if enabled)
|
||||
for key_type in get_remote_keys():
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
@@ -72,4 +87,26 @@ async def async_get_config_entry_diagnostics(
|
||||
# Add remote Mozart model
|
||||
data[f"remote_{remote.serial_number}"] = dict(remote)
|
||||
|
||||
# Get Mozart battery entity
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_battery_level"
|
||||
):
|
||||
if state := hass.states.get(entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
|
||||
# Remove context as it is not relevant
|
||||
state_dict.pop("context")
|
||||
data["battery_level"] = state_dict
|
||||
|
||||
# Get Mozart battery charging entity
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
BINARY_SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_charging"
|
||||
):
|
||||
if state := hass.states.get(entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
|
||||
# Remove context as it is not relevant
|
||||
state_dict.pop("context")
|
||||
data["charging"] = state_dict
|
||||
|
||||
return data
|
||||
|
||||
139
homeassistant/components/bang_olufsen/sensor.py
Normal file
139
homeassistant/components/bang_olufsen/sensor.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Sensor entities for the Bang & Olufsen integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from mozart_api.exceptions import ApiException
|
||||
from mozart_api.models import BatteryState, PairedRemote
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BeoConfigEntry
|
||||
from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification
|
||||
from .entity import BeoEntity
|
||||
from .util import get_remotes, supports_battery
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=15)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BeoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Sensor entities from config entry."""
|
||||
entities: list[BeoSensor] = []
|
||||
|
||||
# Check for Mozart device with battery
|
||||
if await supports_battery(config_entry.runtime_data.client):
|
||||
entities.append(BeoSensorBatteryLevel(config_entry))
|
||||
|
||||
# Add any Beoremote One remotes
|
||||
entities.extend(
|
||||
[
|
||||
BeoSensorRemoteBatteryLevel(config_entry, remote)
|
||||
for remote in (await get_remotes(config_entry.runtime_data.client))
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
class BeoSensor(SensorEntity, BeoEntity):
|
||||
"""Base Bang & Olufsen Sensor."""
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry) -> None:
|
||||
"""Initialize Sensor."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
|
||||
|
||||
class BeoSensorBatteryLevel(BeoSensor):
|
||||
"""Battery level Sensor for Mozart devices."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry) -> None:
|
||||
"""Init the battery level Sensor."""
|
||||
super().__init__(config_entry)
|
||||
|
||||
self._attr_unique_id = f"{self._unique_id}_battery_level"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Turn on the dispatchers."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._async_update_connection_state,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
|
||||
self._update_battery,
|
||||
)
|
||||
)
|
||||
|
||||
async def _update_battery(self, data: BatteryState) -> None:
|
||||
"""Update sensor value."""
|
||||
self._attr_native_value = data.battery_level
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class BeoSensorRemoteBatteryLevel(BeoSensor):
|
||||
"""Battery level Sensor for the Beoremote One."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_should_poll = True
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry, remote: PairedRemote) -> None:
|
||||
"""Init the battery level Sensor."""
|
||||
super().__init__(config_entry)
|
||||
# Serial number is not None, as the remote object is provided by get_remotes
|
||||
assert remote.serial_number
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
|
||||
)
|
||||
self._attr_native_value = remote.battery_level
|
||||
self._remote = remote
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Turn on the dispatchers."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._async_update_connection_state,
|
||||
)
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Poll battery status."""
|
||||
with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
|
||||
for remote in await get_remotes(self._client):
|
||||
if remote.serial_number == self._remote.serial_number:
|
||||
self._attr_native_value = remote.battery_level
|
||||
break
|
||||
@@ -84,3 +84,10 @@ def get_remote_keys() -> list[str]:
|
||||
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
async def supports_battery(client: MozartClient) -> bool:
|
||||
"""Get if a Mozart device has a battery."""
|
||||
battery_state = await client.get_battery_state()
|
||||
|
||||
return battery_state.state != "BatteryNotPresent"
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mozart_api.models import (
|
||||
BatteryState,
|
||||
BeoRemoteButton,
|
||||
ButtonEvent,
|
||||
ListeningModeProps,
|
||||
@@ -60,6 +61,7 @@ class BeoWebsocket(BeoBase):
|
||||
self._client.get_active_listening_mode_notifications(
|
||||
self.on_active_listening_mode
|
||||
)
|
||||
self._client.get_battery_notifications(self.on_battery_notification)
|
||||
self._client.get_beo_remote_button_notifications(
|
||||
self.on_beo_remote_button_notification
|
||||
)
|
||||
@@ -115,6 +117,14 @@ class BeoWebsocket(BeoBase):
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_battery_notification(self, notification: BatteryState) -> None:
|
||||
"""Send battery dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None:
|
||||
"""Send beo_remote_button dispatch."""
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_USE_SSL
|
||||
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
|
||||
|
||||
PLATFORMS: Final[list[Platform]] = [
|
||||
@@ -26,11 +27,12 @@ async def async_setup_entry(
|
||||
"""Set up a config entry."""
|
||||
host = config_entry.data[CONF_HOST]
|
||||
mac = config_entry.data[CONF_MAC]
|
||||
ssl = config_entry.data.get(CONF_USE_SSL, False)
|
||||
|
||||
session = async_create_clientsession(
|
||||
hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False)
|
||||
)
|
||||
client = BraviaClient(host, mac, session=session)
|
||||
client = BraviaClient(host, mac, session=session, ssl=ssl)
|
||||
coordinator = BraviaTVCoordinator(
|
||||
hass=hass,
|
||||
config_entry=config_entry,
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
ATTR_MODEL,
|
||||
CONF_NICKNAME,
|
||||
CONF_USE_PSK,
|
||||
CONF_USE_SSL,
|
||||
DOMAIN,
|
||||
NICKNAME_PREFIX,
|
||||
)
|
||||
@@ -46,11 +47,12 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def create_client(self) -> None:
|
||||
"""Create Bravia TV client from config."""
|
||||
host = self.device_config[CONF_HOST]
|
||||
ssl = self.device_config[CONF_USE_SSL]
|
||||
session = async_create_clientsession(
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(unsafe=True, quote_cookie=False),
|
||||
)
|
||||
self.client = BraviaClient(host=host, session=session)
|
||||
self.client = BraviaClient(host=host, session=session, ssl=ssl)
|
||||
|
||||
async def gen_instance_ids(self) -> tuple[str, str]:
|
||||
"""Generate client_id and nickname."""
|
||||
@@ -123,10 +125,10 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle authorize step."""
|
||||
self.create_client()
|
||||
|
||||
if user_input is not None:
|
||||
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
|
||||
self.device_config[CONF_USE_SSL] = user_input[CONF_USE_SSL]
|
||||
self.create_client()
|
||||
if user_input[CONF_USE_PSK]:
|
||||
return await self.async_step_psk()
|
||||
return await self.async_step_pin()
|
||||
@@ -136,6 +138,7 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USE_PSK, default=False): bool,
|
||||
vol.Required(CONF_USE_SSL, default=False): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ ATTR_MODEL: Final = "model"
|
||||
|
||||
CONF_NICKNAME: Final = "nickname"
|
||||
CONF_USE_PSK: Final = "use_psk"
|
||||
CONF_USE_SSL: Final = "use_ssl"
|
||||
|
||||
DOMAIN: Final = "braviatv"
|
||||
LEGACY_CLIENT_ID: Final = "HomeAssistant"
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import MediaType
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_PIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -56,8 +56,31 @@ def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P](
|
||||
"""Catch Bravia errors and log message."""
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except BraviaNotFound as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error_not_found",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error_offline",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except BraviaError as err:
|
||||
_LOGGER.error("Command error: %s", err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
"error": repr(err),
|
||||
},
|
||||
) from err
|
||||
await self.async_request_refresh()
|
||||
|
||||
return wrapper
|
||||
@@ -165,17 +188,35 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
|
||||
if self.skipped_updates < 10:
|
||||
self.connected = False
|
||||
self.skipped_updates += 1
|
||||
_LOGGER.debug("Update skipped, Bravia API service is reloading")
|
||||
_LOGGER.debug(
|
||||
"Update for %s skipped: the Bravia API service is reloading",
|
||||
self.config_entry.title,
|
||||
)
|
||||
return
|
||||
raise UpdateFailed("Error communicating with device") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error_not_found",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff):
|
||||
self.is_on = False
|
||||
self.connected = False
|
||||
_LOGGER.debug("Update skipped, Bravia TV is off")
|
||||
_LOGGER.debug(
|
||||
"Update for %s skipped: the TV is turned off", self.config_entry.title
|
||||
)
|
||||
except BraviaError as err:
|
||||
self.is_on = False
|
||||
self.connected = False
|
||||
raise UpdateFailed("Error communicating with device") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
"error": repr(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_update_volume(self) -> None:
|
||||
"""Update volume information."""
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pybravia"],
|
||||
"requirements": ["pybravia==0.3.4"],
|
||||
"requirements": ["pybravia==0.4.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Sony Corporation",
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
"step": {
|
||||
"authorize": {
|
||||
"data": {
|
||||
"use_psk": "Use PSK authentication"
|
||||
"use_psk": "Use PSK authentication",
|
||||
"use_ssl": "Use SSL connection"
|
||||
},
|
||||
"description": "Make sure that «Control remotely» is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.",
|
||||
"description": "Make sure that «Control remotely» is enabled on your TV. Go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended, as it is more stable. \n\nUse an SSL connection only if your TV supports this connection type.",
|
||||
"title": "Authorize Sony Bravia TV"
|
||||
},
|
||||
"confirm": {
|
||||
@@ -54,5 +55,22 @@
|
||||
"name": "Terminate apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"command_error": {
|
||||
"message": "Error sending command to {device}: {error}"
|
||||
},
|
||||
"command_error_not_found": {
|
||||
"message": "Error sending command to {device}: the Bravia API service is reloading"
|
||||
},
|
||||
"command_error_offline": {
|
||||
"message": "Error sending command to {device}: the TV is turned off"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "Error updating data for {device}: {error}"
|
||||
},
|
||||
"update_error_not_found": {
|
||||
"message": "Error updating data for {device}: the Bravia API service is stuck"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.17.0"]
|
||||
"requirements": ["bthome-ble==3.16.0"]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_HVAC_MODE): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [HVACMode]
|
||||
cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)]
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
selector:
|
||||
choose:
|
||||
choices:
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
@@ -27,14 +31,11 @@
|
||||
- input_number
|
||||
- number
|
||||
- sensor
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
translation_key: number_or_entity
|
||||
|
||||
.trigger_threshold_type: &trigger_threshold_type
|
||||
required: true
|
||||
default: above
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.6"]
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ FRIENDS_OF_HUE_SWITCH = {
|
||||
}
|
||||
|
||||
RODRET_REMOTE_MODEL = "RODRET Dimmer"
|
||||
RODRET_REMOTE_MODEL_2 = "RODRET wireless dimmer"
|
||||
RODRET_REMOTE = {
|
||||
(CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002},
|
||||
(CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001},
|
||||
@@ -624,6 +625,7 @@ REMOTES = {
|
||||
HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE,
|
||||
FRIENDS_OF_HUE_SWITCH_MODEL: FRIENDS_OF_HUE_SWITCH,
|
||||
RODRET_REMOTE_MODEL: RODRET_REMOTE,
|
||||
RODRET_REMOTE_MODEL_2: RODRET_REMOTE,
|
||||
SOMRIG_REMOTE_MODEL: SOMRIG_REMOTE,
|
||||
STYRBAR_REMOTE_MODEL: STYRBAR_REMOTE,
|
||||
SYMFONISK_SOUND_CONTROLLER_MODEL: SYMFONISK_SOUND_CONTROLLER,
|
||||
|
||||
@@ -28,10 +28,11 @@ async def async_setup_entry(
|
||||
DemoHumidifier(
|
||||
name="Humidifier",
|
||||
mode=None,
|
||||
target_humidity=68,
|
||||
target_humidity=65,
|
||||
current_humidity=45,
|
||||
action=HumidifierAction.HUMIDIFYING,
|
||||
device_class=HumidifierDeviceClass.HUMIDIFIER,
|
||||
target_humidity_step=5,
|
||||
),
|
||||
DemoHumidifier(
|
||||
name="Dehumidifier",
|
||||
@@ -66,6 +67,7 @@ class DemoHumidifier(HumidifierEntity):
|
||||
is_on: bool = True,
|
||||
action: HumidifierAction | None = None,
|
||||
device_class: HumidifierDeviceClass | None = None,
|
||||
target_humidity_step: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize the humidifier device."""
|
||||
self._attr_name = name
|
||||
@@ -79,6 +81,7 @@ class DemoHumidifier(HumidifierEntity):
|
||||
self._attr_mode = mode
|
||||
self._attr_available_modes = available_modes
|
||||
self._attr_device_class = device_class
|
||||
self._attr_target_humidity_step = target_humidity_step
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dnsip",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["aiodns==3.6.1"]
|
||||
"requirements": ["aiodns==4.0.0"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["easyenergy==2.1.2"],
|
||||
"requirements": ["easyenergy==2.2.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.1"]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
|
||||
name=device.name,
|
||||
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
|
||||
manufacturer="EHEIM",
|
||||
model=device.device_type.model_name,
|
||||
model=device.model_name,
|
||||
identifiers={(DOMAIN, device.mac_address)},
|
||||
suggested_area=device.aquarium_name,
|
||||
sw_version=device.sw_version,
|
||||
@@ -59,9 +59,9 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
|
||||
def exception_handler[_EntityT: EheimDigitalEntity[EheimDigitalDevice], **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate AirGradient calls to handle exceptions.
|
||||
"""Decorate eheimdigital calls to handle exceptions.
|
||||
|
||||
A decorator that wraps the passed in function, catches AirGradient errors.
|
||||
A decorator that wraps the passed in function, catches eheimdigital errors.
|
||||
"""
|
||||
|
||||
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.heater import EheimDigitalHeater
|
||||
from eheimdigital.types import HeaterUnit
|
||||
|
||||
@@ -21,6 +22,7 @@ from homeassistant.const import (
|
||||
PRECISION_WHOLE,
|
||||
EntityCategory,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -42,6 +44,34 @@ class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
|
||||
uom_fn: Callable[[_DeviceT], str] | None = None
|
||||
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalNumberDescription[EheimDigitalFilter](
|
||||
key="high_pulse_time",
|
||||
translation_key="high_pulse_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_min_value=5,
|
||||
native_max_value=200000,
|
||||
value_fn=lambda device: device.high_pulse_time,
|
||||
set_value_fn=lambda device, value: device.set_high_pulse_time(int(value)),
|
||||
),
|
||||
EheimDigitalNumberDescription[EheimDigitalFilter](
|
||||
key="low_pulse_time",
|
||||
translation_key="low_pulse_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_min_value=5,
|
||||
native_max_value=200000,
|
||||
value_fn=lambda device: device.low_pulse_time,
|
||||
set_value_fn=lambda device, value: device.set_low_pulse_time(int(value)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
EheimDigitalNumberDescription[EheimDigitalClassicVario], ...
|
||||
] = (
|
||||
@@ -145,6 +175,13 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in CLASSICVARIO_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalFilter):
|
||||
entities.extend(
|
||||
EheimDigitalNumber[EheimDigitalFilter](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in FILTER_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalHeater):
|
||||
entities.extend(
|
||||
EheimDigitalNumber[EheimDigitalHeater](
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
from typing import Any, Literal, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.types import FilterMode
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.types import (
|
||||
FilterMode,
|
||||
FilterModeProf,
|
||||
UnitOfMeasurement as EheimDigitalUnitOfMeasurement,
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory, UnitOfFrequency, UnitOfVolumeFlowRate
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -24,8 +30,109 @@ class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
|
||||
):
|
||||
"""Class describing EHEIM Digital select entities."""
|
||||
|
||||
options_fn: Callable[[_DeviceT], list[str]] | None = None
|
||||
use_api_unit: Literal[True] | None = None
|
||||
value_fn: Callable[[_DeviceT], str | None]
|
||||
set_value_fn: Callable[[_DeviceT, str], Awaitable[None]]
|
||||
set_value_fn: Callable[[_DeviceT, str], Awaitable[None] | None]
|
||||
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalSelectDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="filter_mode",
|
||||
translation_key="filter_mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
options=[item.lower() for item in FilterModeProf._member_names_],
|
||||
value_fn=lambda device: device.filter_mode.name.lower(),
|
||||
set_value_fn=lambda device, value: device.set_filter_mode(
|
||||
FilterModeProf[value.upper()]
|
||||
),
|
||||
),
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="manual_speed",
|
||||
translation_key="manual_speed",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
options_fn=lambda device: [str(i) for i in device.filter_manual_values],
|
||||
value_fn=lambda device: str(device.manual_speed),
|
||||
set_value_fn=lambda device, value: device.set_manual_speed(float(value)),
|
||||
),
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="const_flow_speed",
|
||||
translation_key="const_flow_speed",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
use_api_unit=True,
|
||||
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
|
||||
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
|
||||
value_fn=lambda device: str(device.filter_const_flow_values[device.const_flow]),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_const_flow(
|
||||
device.filter_const_flow_values.index(int(value))
|
||||
)
|
||||
),
|
||||
),
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="day_speed",
|
||||
translation_key="day_speed",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
use_api_unit=True,
|
||||
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
|
||||
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
|
||||
value_fn=lambda device: str(device.filter_const_flow_values[device.day_speed]),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_day_speed(
|
||||
device.filter_const_flow_values.index(int(value))
|
||||
)
|
||||
),
|
||||
),
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="night_speed",
|
||||
translation_key="night_speed",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
use_api_unit=True,
|
||||
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
|
||||
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
|
||||
value_fn=lambda device: str(
|
||||
device.filter_const_flow_values[device.night_speed]
|
||||
),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_night_speed(
|
||||
device.filter_const_flow_values.index(int(value))
|
||||
)
|
||||
),
|
||||
),
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="high_pulse_speed",
|
||||
translation_key="high_pulse_speed",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
use_api_unit=True,
|
||||
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
|
||||
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
|
||||
value_fn=lambda device: str(
|
||||
device.filter_const_flow_values[device.high_pulse_speed]
|
||||
),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_high_pulse_speed(
|
||||
device.filter_const_flow_values.index(int(value))
|
||||
)
|
||||
),
|
||||
),
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="low_pulse_speed",
|
||||
translation_key="low_pulse_speed",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
use_api_unit=True,
|
||||
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
|
||||
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
|
||||
value_fn=lambda device: str(
|
||||
device.filter_const_flow_values[device.low_pulse_speed]
|
||||
),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_low_pulse_speed(
|
||||
device.filter_const_flow_values.index(int(value))
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
@@ -34,11 +141,7 @@ CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSelectDescription[EheimDigitalClassicVario](
|
||||
key="filter_mode",
|
||||
translation_key="filter_mode",
|
||||
value_fn=(
|
||||
lambda device: device.filter_mode.name.lower()
|
||||
if device.filter_mode is not None
|
||||
else None
|
||||
),
|
||||
value_fn=lambda device: device.filter_mode.name.lower(),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_filter_mode(FilterMode[value.upper()])
|
||||
),
|
||||
@@ -68,6 +171,11 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in CLASSICVARIO_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalFilter):
|
||||
entities.extend(
|
||||
EheimDigitalFilterSelect(coordinator, device, description)
|
||||
for description in FILTER_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -82,6 +190,8 @@ class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
|
||||
|
||||
entity_description: EheimDigitalSelectDescription[_DeviceT]
|
||||
|
||||
_attr_options: list[str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
@@ -91,13 +201,49 @@ class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
|
||||
"""Initialize an EHEIM Digital select entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
if description.options_fn is not None:
|
||||
self._attr_options = description.options_fn(device)
|
||||
elif description.options is not None:
|
||||
self._attr_options = description.options
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
@override
|
||||
@exception_handler
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
return await self.entity_description.set_value_fn(self._device, option)
|
||||
if await_return := self.entity_description.set_value_fn(self._device, option):
|
||||
return await await_return
|
||||
return None
|
||||
|
||||
@override
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_current_option = self.entity_description.value_fn(self._device)
|
||||
|
||||
|
||||
class EheimDigitalFilterSelect(EheimDigitalSelect[EheimDigitalFilter]):
|
||||
"""Represent an EHEIM Digital Filter select entity."""
|
||||
|
||||
entity_description: EheimDigitalSelectDescription[EheimDigitalFilter]
|
||||
_attr_native_unit_of_measurement: str | None
|
||||
|
||||
@override
|
||||
def _async_update_attrs(self) -> None:
|
||||
if (
|
||||
self.entity_description.options is None
|
||||
and self.entity_description.options_fn is not None
|
||||
):
|
||||
self._attr_options = self.entity_description.options_fn(self._device)
|
||||
if self.entity_description.use_api_unit:
|
||||
if (
|
||||
self.entity_description.unit_of_measurement
|
||||
== UnitOfVolumeFlowRate.LITERS_PER_HOUR
|
||||
and self._device.usrdta["unit"]
|
||||
== int(EheimDigitalUnitOfMeasurement.US_CUSTOMARY)
|
||||
):
|
||||
self._attr_native_unit_of_measurement = (
|
||||
UnitOfVolumeFlowRate.GALLONS_PER_HOUR
|
||||
)
|
||||
else:
|
||||
self._attr_native_unit_of_measurement = (
|
||||
self.entity_description.unit_of_measurement
|
||||
)
|
||||
super()._async_update_attrs()
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.types import FilterErrorCode
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -13,7 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfFrequency, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -33,6 +34,27 @@ class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice](
|
||||
value_fn: Callable[[_DeviceT], float | str | None]
|
||||
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalSensorDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalSensorDescription[EheimDigitalFilter](
|
||||
key="current_speed",
|
||||
translation_key="current_speed",
|
||||
value_fn=lambda device: device.current_speed,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
),
|
||||
EheimDigitalSensorDescription[EheimDigitalFilter](
|
||||
key="service_hours",
|
||||
translation_key="service_hours",
|
||||
value_fn=lambda device: device.service_hours,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.HOURS,
|
||||
suggested_unit_of_measurement=UnitOfTime.DAYS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSensorDescription[EheimDigitalClassicVario], ...
|
||||
] = (
|
||||
@@ -54,11 +76,7 @@ CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSensorDescription[EheimDigitalClassicVario](
|
||||
key="error_code",
|
||||
translation_key="error_code",
|
||||
value_fn=(
|
||||
lambda device: device.error_code.name.lower()
|
||||
if device.error_code is not None
|
||||
else None
|
||||
),
|
||||
value_fn=lambda device: device.error_code.name.lower(),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[name.lower() for name in FilterErrorCode._member_names_],
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -80,6 +98,13 @@ async def async_setup_entry(
|
||||
"""Set up the light entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalSensor[Any]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalFilter):
|
||||
entities += [
|
||||
EheimDigitalSensor[EheimDigitalFilter](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in FILTER_DESCRIPTIONS
|
||||
]
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities += [
|
||||
EheimDigitalSensor[EheimDigitalClassicVario](
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
"day_speed": {
|
||||
"name": "Day speed"
|
||||
},
|
||||
"high_pulse_time": {
|
||||
"name": "High pulse duration"
|
||||
},
|
||||
"low_pulse_time": {
|
||||
"name": "Low pulse duration"
|
||||
},
|
||||
"manual_speed": {
|
||||
"name": "Manual speed"
|
||||
},
|
||||
@@ -78,13 +84,32 @@
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"const_flow_speed": {
|
||||
"name": "Constant flow speed"
|
||||
},
|
||||
"day_speed": {
|
||||
"name": "Day speed"
|
||||
},
|
||||
"filter_mode": {
|
||||
"name": "Filter mode",
|
||||
"state": {
|
||||
"bio": "Bio",
|
||||
"constant_flow": "Constant flow",
|
||||
"manual": "Manual",
|
||||
"pulse": "Pulse"
|
||||
}
|
||||
},
|
||||
"high_pulse_speed": {
|
||||
"name": "High pulse speed"
|
||||
},
|
||||
"low_pulse_speed": {
|
||||
"name": "Low pulse speed"
|
||||
},
|
||||
"manual_speed": {
|
||||
"name": "Manual speed"
|
||||
},
|
||||
"night_speed": {
|
||||
"name": "Night speed"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -99,8 +124,17 @@
|
||||
"rotor_stuck": "Rotor stuck"
|
||||
}
|
||||
},
|
||||
"operating_time": {
|
||||
"name": "Operating time"
|
||||
},
|
||||
"service_hours": {
|
||||
"name": "Remaining hours until service"
|
||||
},
|
||||
"turn_feeding_time": {
|
||||
"name": "Remaining off time after feeding"
|
||||
},
|
||||
"turn_off_time": {
|
||||
"name": "Remaining off time"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -30,8 +31,8 @@ async def async_setup_entry(
|
||||
"""Set up the switch entities for one or multiple devices."""
|
||||
entities: list[SwitchEntity] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities.append(EheimDigitalClassicVarioSwitch(coordinator, device)) # noqa: PERF401
|
||||
if isinstance(device, (EheimDigitalClassicVario, EheimDigitalFilter)):
|
||||
entities.append(EheimDigitalFilterSwitch(coordinator, device)) # noqa: PERF401
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -39,10 +40,10 @@ async def async_setup_entry(
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalClassicVarioSwitch(
|
||||
EheimDigitalEntity[EheimDigitalClassicVario], SwitchEntity
|
||||
class EheimDigitalFilterSwitch(
|
||||
EheimDigitalEntity[EheimDigitalClassicVario | EheimDigitalFilter], SwitchEntity
|
||||
):
|
||||
"""Represent an EHEIM Digital classicVARIO switch entity."""
|
||||
"""Represent an EHEIM Digital classicVARIO or filter switch entity."""
|
||||
|
||||
_attr_translation_key = "filter_active"
|
||||
_attr_name = None
|
||||
@@ -50,9 +51,9 @@ class EheimDigitalClassicVarioSwitch(
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: EheimDigitalClassicVario,
|
||||
device: EheimDigitalClassicVario | EheimDigitalFilter,
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital classicVARIO switch entity."""
|
||||
"""Initialize an EHEIM Digital classicVARIO or filter switch entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self._attr_unique_id = device.mac_address
|
||||
self._async_update_attrs()
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, final, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.heater import EheimDigitalHeater
|
||||
|
||||
from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
@@ -28,6 +29,23 @@ class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescri
|
||||
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
|
||||
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalTimeDescription[EheimDigitalFilter](
|
||||
key="day_start_time",
|
||||
translation_key="day_start_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda device: device.day_start_time,
|
||||
set_value_fn=lambda device, value: device.set_day_start_time(value),
|
||||
),
|
||||
EheimDigitalTimeDescription[EheimDigitalFilter](
|
||||
key="night_start_time",
|
||||
translation_key="night_start_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda device: device.night_start_time,
|
||||
set_value_fn=lambda device, value: device.set_night_start_time(value),
|
||||
),
|
||||
)
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
EheimDigitalTimeDescription[EheimDigitalClassicVario], ...
|
||||
] = (
|
||||
@@ -79,6 +97,13 @@ async def async_setup_entry(
|
||||
"""Set up the time entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalTime[Any]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalFilter):
|
||||
entities.extend(
|
||||
EheimDigitalTime[EheimDigitalFilter](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in FILTER_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities.extend(
|
||||
EheimDigitalTime[EheimDigitalClassicVario](
|
||||
|
||||
@@ -206,7 +206,7 @@ class EnvoyProductionSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Envoy production sensor entity."""
|
||||
|
||||
value_fn: Callable[[EnvoySystemProduction], int]
|
||||
on_phase: str | None
|
||||
on_phase: str | None = None
|
||||
|
||||
|
||||
PRODUCTION_SENSORS = (
|
||||
@@ -219,7 +219,6 @@ PRODUCTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watts_now"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyProductionSensorEntityDescription(
|
||||
key="daily_production",
|
||||
@@ -230,7 +229,6 @@ PRODUCTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
value_fn=attrgetter("watt_hours_today"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyProductionSensorEntityDescription(
|
||||
key="seven_days_production",
|
||||
@@ -240,7 +238,6 @@ PRODUCTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=1,
|
||||
value_fn=attrgetter("watt_hours_last_7_days"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyProductionSensorEntityDescription(
|
||||
key="lifetime_production",
|
||||
@@ -251,7 +248,6 @@ PRODUCTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watt_hours_lifetime"),
|
||||
on_phase=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -277,7 +273,7 @@ class EnvoyConsumptionSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Envoy consumption sensor entity."""
|
||||
|
||||
value_fn: Callable[[EnvoySystemConsumption], int]
|
||||
on_phase: str | None
|
||||
on_phase: str | None = None
|
||||
|
||||
|
||||
CONSUMPTION_SENSORS = (
|
||||
@@ -290,7 +286,6 @@ CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watts_now"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyConsumptionSensorEntityDescription(
|
||||
key="daily_consumption",
|
||||
@@ -301,7 +296,6 @@ CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
value_fn=attrgetter("watt_hours_today"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyConsumptionSensorEntityDescription(
|
||||
key="seven_days_consumption",
|
||||
@@ -311,7 +305,6 @@ CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=1,
|
||||
value_fn=attrgetter("watt_hours_last_7_days"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyConsumptionSensorEntityDescription(
|
||||
key="lifetime_consumption",
|
||||
@@ -322,7 +315,6 @@ CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watt_hours_lifetime"),
|
||||
on_phase=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -354,7 +346,6 @@ NET_CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watts_now"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyConsumptionSensorEntityDescription(
|
||||
key="lifetime_balanced_net_consumption",
|
||||
@@ -366,7 +357,6 @@ NET_CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watt_hours_lifetime"),
|
||||
on_phase=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -395,7 +385,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
|
||||
[EnvoyMeterData],
|
||||
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
|
||||
]
|
||||
on_phase: str | None
|
||||
on_phase: str | None = None
|
||||
cttype: str | None = None
|
||||
|
||||
|
||||
@@ -411,7 +401,6 @@ CT_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -430,7 +419,6 @@ CT_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -449,7 +437,6 @@ CT_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -468,7 +455,6 @@ CT_SENSORS = (
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -488,7 +474,6 @@ CT_SENSORS = (
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -508,7 +493,6 @@ CT_SENSORS = (
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -526,7 +510,6 @@ CT_SENSORS = (
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -544,7 +527,6 @@ CT_SENSORS = (
|
||||
options=list(CtMeterStatus),
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -565,7 +547,6 @@ CT_SENSORS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -783,7 +764,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
translation_key="available_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
value_fn=attrgetter("available_energy"),
|
||||
),
|
||||
EnvoyEnchargeAggregateSensorEntityDescription(
|
||||
@@ -791,14 +772,14 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
translation_key="reserve_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
value_fn=attrgetter("backup_reserve"),
|
||||
),
|
||||
EnvoyEnchargeAggregateSensorEntityDescription(
|
||||
key="max_capacity",
|
||||
translation_key="max_capacity",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
value_fn=attrgetter("max_available_capacity"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -77,9 +77,14 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
)
|
||||
LOGGER.debug("enable smarthome templates: %s", self.has_templates)
|
||||
|
||||
self.has_triggers = await self.hass.async_add_executor_job(
|
||||
self.fritz.has_triggers
|
||||
)
|
||||
try:
|
||||
self.has_triggers = await self.hass.async_add_executor_job(
|
||||
self.fritz.has_triggers
|
||||
)
|
||||
except HTTPError:
|
||||
# Fritz!OS < 7.39 just don't have this api endpoint
|
||||
# so we need to fetch the HTTPError here and assume no triggers
|
||||
self.has_triggers = False
|
||||
LOGGER.debug("enable smarthome triggers: %s", self.has_triggers)
|
||||
|
||||
self.configuration_url = self.fritz.get_prefixed_host()
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251229.0"]
|
||||
"requirements": ["home-assistant-frontend==20260107.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==2.0.2"]
|
||||
"requirements": ["google_air_quality_api==2.1.2"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/gree",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["greeclimate"],
|
||||
"requirements": ["greeclimate==2.1.0"]
|
||||
"requirements": ["greeclimate==2.1.1"]
|
||||
}
|
||||
|
||||
31
homeassistant/components/hdfury/__init__.py
Normal file
31
homeassistant/components/hdfury/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""The HDFury Integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.SELECT,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HDFuryConfigEntry) -> bool:
|
||||
"""Set up HDFury as config entry."""
|
||||
|
||||
coordinator = HDFuryCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HDFuryConfigEntry) -> bool:
|
||||
"""Unload a HDFury config entry."""
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
74
homeassistant/components/hdfury/button.py
Normal file
74
homeassistant/components/hdfury/button.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Button platform for HDFury Integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hdfury import HDFuryAPI, HDFuryError
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HDFuryConfigEntry
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HDFuryButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Description for HDFury button entities."""
|
||||
|
||||
press_fn: Callable[[HDFuryAPI], Awaitable[None]]
|
||||
|
||||
|
||||
BUTTONS: tuple[HDFuryButtonEntityDescription, ...] = (
|
||||
HDFuryButtonEntityDescription(
|
||||
key="reboot",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda client: client.issue_reboot(),
|
||||
),
|
||||
HDFuryButtonEntityDescription(
|
||||
key="issue_hotplug",
|
||||
translation_key="issue_hotplug",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda client: client.issue_hotplug(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HDFuryConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up buttons using the platform schema."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HDFuryButton(coordinator, description) for description in BUTTONS
|
||||
)
|
||||
|
||||
|
||||
class HDFuryButton(HDFuryEntity, ButtonEntity):
|
||||
"""HDFury Button Class."""
|
||||
|
||||
entity_description: HDFuryButtonEntityDescription
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle Button Press."""
|
||||
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator.client)
|
||||
except HDFuryError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
) from error
|
||||
54
homeassistant/components/hdfury/config_flow.py
Normal file
54
homeassistant/components/hdfury/config_flow.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Config flow for HDFury Integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hdfury import HDFuryAPI, HDFuryError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class HDFuryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle Config Flow for HDFury."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle Initial Setup."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
|
||||
serial = await self._validate_connection(host)
|
||||
if serial is not None:
|
||||
await self.async_set_unique_id(serial)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"HDFury ({host})", data=user_input
|
||||
)
|
||||
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _validate_connection(self, host: str) -> str | None:
|
||||
"""Try to fetch serial number to confirm it's a valid HDFury device."""
|
||||
|
||||
client = HDFuryAPI(host, async_get_clientsession(self.hass))
|
||||
|
||||
try:
|
||||
data = await client.get_board()
|
||||
except HDFuryError:
|
||||
return None
|
||||
|
||||
return data["serial"]
|
||||
3
homeassistant/components/hdfury/const.py
Normal file
3
homeassistant/components/hdfury/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for HDFury Integration."""
|
||||
|
||||
DOMAIN = "hdfury"
|
||||
67
homeassistant/components/hdfury/coordinator.py
Normal file
67
homeassistant/components/hdfury/coordinator.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""DataUpdateCoordinator for HDFury Integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from hdfury import HDFuryAPI, HDFuryError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=60)
|
||||
|
||||
type HDFuryConfigEntry = ConfigEntry[HDFuryCoordinator]
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HDFuryData:
|
||||
"""HDFury Data Class."""
|
||||
|
||||
board: dict[str, str]
|
||||
info: dict[str, str]
|
||||
config: dict[str, str]
|
||||
|
||||
|
||||
class HDFuryCoordinator(DataUpdateCoordinator[HDFuryData]):
|
||||
"""HDFury Device Coordinator Class."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: HDFuryConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="HDFury",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.host: str = entry.data[CONF_HOST]
|
||||
self.client = HDFuryAPI(self.host, async_get_clientsession(hass))
|
||||
|
||||
async def _async_update_data(self) -> HDFuryData:
|
||||
"""Fetch the latest device data."""
|
||||
|
||||
try:
|
||||
board = await self.client.get_board()
|
||||
info = await self.client.get_info()
|
||||
config = await self.client.get_config()
|
||||
except HDFuryError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
) from error
|
||||
|
||||
return HDFuryData(
|
||||
board=board,
|
||||
info=info,
|
||||
config=config,
|
||||
)
|
||||
39
homeassistant/components/hdfury/entity.py
Normal file
39
homeassistant/components/hdfury/entity.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Base class for HDFury entities."""
|
||||
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import HDFuryCoordinator
|
||||
|
||||
|
||||
class HDFuryEntity(CoordinatorEntity[HDFuryCoordinator]):
|
||||
"""Common elements for all entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: HDFuryCoordinator, entity_description: EntityDescription
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = entity_description
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.data.board['serial']}_{entity_description.key}"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=f"HDFury {coordinator.data.board['hostname']}",
|
||||
manufacturer="HDFury",
|
||||
model=coordinator.data.board["hostname"].split("-")[0],
|
||||
serial_number=coordinator.data.board["serial"],
|
||||
sw_version=coordinator.data.board["version"].removeprefix("FW: "),
|
||||
hw_version=coordinator.data.board.get("pcbv"),
|
||||
configuration_url=f"http://{coordinator.host}",
|
||||
connections={
|
||||
(dr.CONNECTION_NETWORK_MAC, coordinator.data.config["macaddr"])
|
||||
},
|
||||
)
|
||||
52
homeassistant/components/hdfury/icons.json
Normal file
52
homeassistant/components/hdfury/icons.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"issue_hotplug": {
|
||||
"default": "mdi:connection"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"opmode": {
|
||||
"default": "mdi:cogs"
|
||||
},
|
||||
"portseltx0": {
|
||||
"default": "mdi:hdmi-port"
|
||||
},
|
||||
"portseltx1": {
|
||||
"default": "mdi:hdmi-port"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"autosw": {
|
||||
"default": "mdi:import"
|
||||
},
|
||||
"htpcmode0": {
|
||||
"default": "mdi:desktop-classic"
|
||||
},
|
||||
"htpcmode1": {
|
||||
"default": "mdi:desktop-classic"
|
||||
},
|
||||
"htpcmode2": {
|
||||
"default": "mdi:desktop-classic"
|
||||
},
|
||||
"htpcmode3": {
|
||||
"default": "mdi:desktop-classic"
|
||||
},
|
||||
"iractive": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"mutetx0": {
|
||||
"default": "mdi:volume-mute"
|
||||
},
|
||||
"mutetx1": {
|
||||
"default": "mdi:volume-mute"
|
||||
},
|
||||
"oled": {
|
||||
"default": "mdi:cellphone-information"
|
||||
},
|
||||
"relay": {
|
||||
"default": "mdi:electric-switch"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
homeassistant/components/hdfury/manifest.json
Normal file
11
homeassistant/components/hdfury/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "hdfury",
|
||||
"name": "HDFury",
|
||||
"codeowners": ["@glenndehaan"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hdfury",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hdfury==1.3.1"]
|
||||
}
|
||||
74
homeassistant/components/hdfury/quality_scale.yaml
Normal file
74
homeassistant/components/hdfury/quality_scale.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities do not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration has no options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Integration has no authentication flow.
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Device type integration.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Device type integration.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
122
homeassistant/components/hdfury/select.py
Normal file
122
homeassistant/components/hdfury/select.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Select platform for HDFury Integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hdfury import (
|
||||
OPERATION_MODES,
|
||||
TX0_INPUT_PORTS,
|
||||
TX1_INPUT_PORTS,
|
||||
HDFuryAPI,
|
||||
HDFuryError,
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HDFurySelectEntityDescription(SelectEntityDescription):
|
||||
"""Description for HDFury select entities."""
|
||||
|
||||
set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]]
|
||||
|
||||
|
||||
SELECT_PORTS: tuple[HDFurySelectEntityDescription, ...] = (
|
||||
HDFurySelectEntityDescription(
|
||||
key="portseltx0",
|
||||
translation_key="portseltx0",
|
||||
options=list(TX0_INPUT_PORTS.keys()),
|
||||
set_value_fn=lambda coordinator, value: _set_ports(coordinator),
|
||||
),
|
||||
HDFurySelectEntityDescription(
|
||||
key="portseltx1",
|
||||
translation_key="portseltx1",
|
||||
options=list(TX1_INPUT_PORTS.keys()),
|
||||
set_value_fn=lambda coordinator, value: _set_ports(coordinator),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
SELECT_OPERATION_MODE: HDFurySelectEntityDescription = HDFurySelectEntityDescription(
|
||||
key="opmode",
|
||||
translation_key="opmode",
|
||||
options=list(OPERATION_MODES.keys()),
|
||||
set_value_fn=lambda coordinator, value: coordinator.client.set_operation_mode(
|
||||
value
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _set_ports(coordinator: HDFuryCoordinator) -> None:
|
||||
tx0 = coordinator.data.info.get("portseltx0")
|
||||
tx1 = coordinator.data.info.get("portseltx1")
|
||||
|
||||
if tx0 is None or tx1 is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="tx_state_error",
|
||||
translation_placeholders={"details": f"tx0={tx0}, tx1={tx1}"},
|
||||
)
|
||||
|
||||
await coordinator.client.set_port_selection(tx0, tx1)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HDFuryConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up selects using the platform schema."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[HDFuryEntity] = []
|
||||
|
||||
for description in SELECT_PORTS:
|
||||
if description.key not in coordinator.data.info:
|
||||
continue
|
||||
|
||||
entities.append(HDFurySelect(coordinator, description))
|
||||
|
||||
# Add OPMODE select if present
|
||||
if "opmode" in coordinator.data.info:
|
||||
entities.append(HDFurySelect(coordinator, SELECT_OPERATION_MODE))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HDFurySelect(HDFuryEntity, SelectEntity):
|
||||
"""HDFury Select Class."""
|
||||
|
||||
entity_description: HDFurySelectEntityDescription
|
||||
|
||||
@property
|
||||
def current_option(self) -> str:
|
||||
"""Return the current option."""
|
||||
|
||||
return self.coordinator.data.info[self.entity_description.key]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Update the current option."""
|
||||
|
||||
# Update local data first
|
||||
self.coordinator.data.info[self.entity_description.key] = option
|
||||
|
||||
# Send command to device
|
||||
try:
|
||||
await self.entity_description.set_value_fn(self.coordinator, option)
|
||||
except HDFuryError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
) from error
|
||||
|
||||
# Trigger HA coordinator refresh
|
||||
await self.coordinator.async_request_refresh()
|
||||
101
homeassistant/components/hdfury/strings.json
Normal file
101
homeassistant/components/hdfury/strings.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of your HDFury device."
|
||||
},
|
||||
"description": "Set up your HDFury to integrate with Home Assistant."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"issue_hotplug": {
|
||||
"name": "Issue hotplug"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"opmode": {
|
||||
"name": "Operation mode",
|
||||
"state": {
|
||||
"0": "Mode 0 - Splitter TX0/TX1 FRL5 VRR",
|
||||
"1": "Mode 1 - Splitter TX0/TX1 UPSCALE FRL5",
|
||||
"2": "Mode 2 - Matrix TMDS",
|
||||
"3": "Mode 3 - Matrix FRL->TMDS",
|
||||
"4": "Mode 4 - Matrix DOWNSCALE",
|
||||
"5": "Mode 5 - Matrix RX0:FRL5 + RX1-3:TMDS"
|
||||
}
|
||||
},
|
||||
"portseltx0": {
|
||||
"name": "Port select TX0",
|
||||
"state": {
|
||||
"0": "Input 0",
|
||||
"1": "Input 1",
|
||||
"2": "Input 2",
|
||||
"3": "Input 3",
|
||||
"4": "Copy TX1"
|
||||
}
|
||||
},
|
||||
"portseltx1": {
|
||||
"name": "Port select TX1",
|
||||
"state": {
|
||||
"0": "Input 0",
|
||||
"1": "Input 1",
|
||||
"2": "Input 2",
|
||||
"3": "Input 3",
|
||||
"4": "Copy TX0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"autosw": {
|
||||
"name": "Auto switch inputs"
|
||||
},
|
||||
"htpcmode0": {
|
||||
"name": "HTPC mode RX0"
|
||||
},
|
||||
"htpcmode1": {
|
||||
"name": "HTPC mode RX1"
|
||||
},
|
||||
"htpcmode2": {
|
||||
"name": "HTPC mode RX2"
|
||||
},
|
||||
"htpcmode3": {
|
||||
"name": "HTPC mode RX3"
|
||||
},
|
||||
"iractive": {
|
||||
"name": "Infrared"
|
||||
},
|
||||
"mutetx0": {
|
||||
"name": "Mute audio TX0"
|
||||
},
|
||||
"mutetx1": {
|
||||
"name": "Mute audio TX1"
|
||||
},
|
||||
"oled": {
|
||||
"name": "OLED display"
|
||||
},
|
||||
"relay": {
|
||||
"name": "Relay"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "An error occurred while communicating with HDFury device"
|
||||
},
|
||||
"tx_state_error": {
|
||||
"message": "An error occurred while validating TX states: {details}"
|
||||
}
|
||||
}
|
||||
}
|
||||
142
homeassistant/components/hdfury/switch.py
Normal file
142
homeassistant/components/hdfury/switch.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Switch platform for HDFury Integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hdfury import HDFuryAPI, HDFuryError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HDFuryConfigEntry
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HDFurySwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Description for HDFury switch entities."""
|
||||
|
||||
set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]]
|
||||
|
||||
|
||||
SWITCHES: tuple[HDFurySwitchEntityDescription, ...] = (
|
||||
HDFurySwitchEntityDescription(
|
||||
key="autosw",
|
||||
translation_key="autosw",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_auto_switch_inputs(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="htpcmode0",
|
||||
translation_key="htpcmode0",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_htpc_mode_rx0(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="htpcmode1",
|
||||
translation_key="htpcmode1",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_htpc_mode_rx1(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="htpcmode2",
|
||||
translation_key="htpcmode2",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_htpc_mode_rx2(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="htpcmode3",
|
||||
translation_key="htpcmode3",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_htpc_mode_rx3(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="mutetx0",
|
||||
translation_key="mutetx0",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_mute_tx0_audio(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="mutetx1",
|
||||
translation_key="mutetx1",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_mute_tx1_audio(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="oled",
|
||||
translation_key="oled",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_oled(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="iractive",
|
||||
translation_key="iractive",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_ir_active(value),
|
||||
),
|
||||
HDFurySwitchEntityDescription(
|
||||
key="relay",
|
||||
translation_key="relay",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_relay(value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HDFuryConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switches using the platform schema."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HDFurySwitch(coordinator, description)
|
||||
for description in SWITCHES
|
||||
if description.key in coordinator.data.config
|
||||
)
|
||||
|
||||
|
||||
class HDFurySwitch(HDFuryEntity, SwitchEntity):
|
||||
"""Base HDFury Switch Class."""
|
||||
|
||||
entity_description: HDFurySwitchEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Set Switch State."""
|
||||
|
||||
return self.coordinator.data.config.get(self.entity_description.key) == "1"
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Handle Switch On Event."""
|
||||
|
||||
try:
|
||||
await self.entity_description.set_value_fn(self.coordinator.client, "on")
|
||||
except HDFuryError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
) from error
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Handle Switch Off Event."""
|
||||
|
||||
try:
|
||||
await self.entity_description.set_value_fn(self.coordinator.client, "off")
|
||||
except HDFuryError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
) from error
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhik"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyHik==0.3.4"]
|
||||
"requirements": ["pyHik==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.26.0"],
|
||||
"requirements": ["aiohomeconnect==0.28.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ set_program_and_options:
|
||||
- active_program
|
||||
- selected_program
|
||||
program:
|
||||
example: dishcare_dishwasher_program_auto2
|
||||
example: dishcare_dishwasher_program_auto_2
|
||||
selector:
|
||||
select:
|
||||
mode: dropdown
|
||||
@@ -73,6 +73,7 @@ set_program_and_options:
|
||||
- dishcare_dishwasher_program_intensiv_45
|
||||
- dishcare_dishwasher_program_auto_half_load
|
||||
- dishcare_dishwasher_program_intensiv_power
|
||||
- dishcare_dishwasher_program_intensive_fixed_zone
|
||||
- dishcare_dishwasher_program_magic_daily
|
||||
- dishcare_dishwasher_program_super_60
|
||||
- dishcare_dishwasher_program_kurz_60
|
||||
@@ -121,6 +122,7 @@ set_program_and_options:
|
||||
- cooking_oven_program_heating_mode_pre_heating
|
||||
- cooking_oven_program_heating_mode_hot_air
|
||||
- cooking_oven_program_heating_mode_hot_air_eco
|
||||
- cooking_oven_program_heating_mode_hot_air_gentle
|
||||
- cooking_oven_program_heating_mode_hot_air_grilling
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating_eco
|
||||
@@ -147,6 +149,7 @@ set_program_and_options:
|
||||
- cooking_oven_program_microwave_900_watt
|
||||
- cooking_oven_program_microwave_1000_watt
|
||||
- cooking_oven_program_microwave_max
|
||||
- cooking_oven_program_steam_modes_steam
|
||||
- cooking_oven_program_heating_mode_warming_drawer
|
||||
- laundry_care_washer_program_auto_30
|
||||
- laundry_care_washer_program_auto_40
|
||||
@@ -174,7 +177,7 @@ set_program_and_options:
|
||||
- laundry_care_washer_program_rinse_rinse_spin_drain
|
||||
- laundry_care_washer_program_sensitive
|
||||
- laundry_care_washer_program_shirts_blouses
|
||||
- laundry_care_washer_program_spin_drain
|
||||
- laundry_care_washer_program_spin_spin_drain
|
||||
- laundry_care_washer_program_sport_fitness
|
||||
- laundry_care_washer_program_super_153045_super_15
|
||||
- laundry_care_washer_program_super_153045_super_1530
|
||||
|
||||
@@ -240,6 +240,7 @@
|
||||
"cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_gentle": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_gentle%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
|
||||
"cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
|
||||
"cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
|
||||
@@ -271,6 +272,7 @@
|
||||
"dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
|
||||
"dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
|
||||
"dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
|
||||
"dishcare_dishwasher_program_intensive_fixed_zone": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensive_fixed_zone%]",
|
||||
"dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
|
||||
"dishcare_dishwasher_program_learning_dishwasher": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_learning_dishwasher%]",
|
||||
"dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
|
||||
@@ -350,7 +352,7 @@
|
||||
"laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
|
||||
"laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
|
||||
"laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
|
||||
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_drain%]",
|
||||
"laundry_care_washer_program_spin_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
|
||||
"laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
|
||||
"laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
|
||||
"laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
|
||||
@@ -592,6 +594,7 @@
|
||||
"cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_gentle": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_gentle%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
|
||||
"cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
|
||||
"cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
|
||||
@@ -612,6 +615,7 @@
|
||||
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
|
||||
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
|
||||
"cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]",
|
||||
"cooking_oven_program_steam_modes_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_steam_modes_steam%]",
|
||||
"dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]",
|
||||
"dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]",
|
||||
"dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]",
|
||||
@@ -623,6 +627,7 @@
|
||||
"dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
|
||||
"dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
|
||||
"dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
|
||||
"dishcare_dishwasher_program_intensive_fixed_zone": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensive_fixed_zone%]",
|
||||
"dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
|
||||
"dishcare_dishwasher_program_learning_dishwasher": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_learning_dishwasher%]",
|
||||
"dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
|
||||
@@ -702,7 +707,7 @@
|
||||
"laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
|
||||
"laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
|
||||
"laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
|
||||
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_drain%]",
|
||||
"laundry_care_washer_program_spin_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
|
||||
"laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
|
||||
"laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
|
||||
"laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
|
||||
@@ -1583,6 +1588,7 @@
|
||||
"cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
|
||||
"cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
|
||||
"cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
|
||||
"cooking_oven_program_heating_mode_hot_air_gentle": "Hot air gentle",
|
||||
"cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
|
||||
"cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
|
||||
"cooking_oven_program_heating_mode_keep_warm": "Keep warm",
|
||||
@@ -1603,6 +1609,7 @@
|
||||
"cooking_oven_program_microwave_900_watt": "900 Watt",
|
||||
"cooking_oven_program_microwave_90_watt": "90 Watt",
|
||||
"cooking_oven_program_microwave_max": "Max",
|
||||
"cooking_oven_program_steam_modes_steam": "Steam mode",
|
||||
"dishcare_dishwasher_program_auto_1": "Auto 1",
|
||||
"dishcare_dishwasher_program_auto_2": "Auto 2",
|
||||
"dishcare_dishwasher_program_auto_3": "Auto 3",
|
||||
@@ -1614,6 +1621,7 @@
|
||||
"dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
|
||||
"dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
|
||||
"dishcare_dishwasher_program_intensiv_power": "Intensive power",
|
||||
"dishcare_dishwasher_program_intensive_fixed_zone": "Intensive fixed zone",
|
||||
"dishcare_dishwasher_program_kurz_60": "Speed 60ºC",
|
||||
"dishcare_dishwasher_program_learning_dishwasher": "Intelligent",
|
||||
"dishcare_dishwasher_program_machine_care": "Machine care",
|
||||
@@ -1693,7 +1701,7 @@
|
||||
"laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
|
||||
"laundry_care_washer_program_sensitive": "Sensitive",
|
||||
"laundry_care_washer_program_shirts_blouses": "Shirts/blouses",
|
||||
"laundry_care_washer_program_spin_drain": "Spin/drain",
|
||||
"laundry_care_washer_program_spin_spin_drain": "Spin/drain",
|
||||
"laundry_care_washer_program_sport_fitness": "Sport/fitness",
|
||||
"laundry_care_washer_program_super_153045_super_15": "Super 15 min",
|
||||
"laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": [
|
||||
"serialx==0.5.0",
|
||||
"serialx==0.6.2",
|
||||
"universal-silabs-flasher==0.1.2",
|
||||
"ha-silabs-firmware-client==0.3.0"
|
||||
]
|
||||
|
||||
@@ -34,6 +34,7 @@ from .const import ( # noqa: F401
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_MAX_HUMIDITY,
|
||||
ATTR_MIN_HUMIDITY,
|
||||
ATTR_TARGET_HUMIDITY_STEP,
|
||||
DEFAULT_MAX_HUMIDITY,
|
||||
DEFAULT_MIN_HUMIDITY,
|
||||
DOMAIN,
|
||||
@@ -141,6 +142,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
|
||||
"min_humidity",
|
||||
"max_humidity",
|
||||
"supported_features",
|
||||
"target_humidity_step",
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +150,12 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
|
||||
"""Base class for humidifier entities."""
|
||||
|
||||
_entity_component_unrecorded_attributes = frozenset(
|
||||
{ATTR_MIN_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_AVAILABLE_MODES}
|
||||
{
|
||||
ATTR_MIN_HUMIDITY,
|
||||
ATTR_MAX_HUMIDITY,
|
||||
ATTR_AVAILABLE_MODES,
|
||||
ATTR_TARGET_HUMIDITY_STEP,
|
||||
}
|
||||
)
|
||||
|
||||
entity_description: HumidifierEntityDescription
|
||||
@@ -161,6 +168,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
|
||||
_attr_mode: str | None
|
||||
_attr_supported_features: HumidifierEntityFeature = HumidifierEntityFeature(0)
|
||||
_attr_target_humidity: float | None = None
|
||||
_attr_target_humidity_step: float | None = None
|
||||
|
||||
@property
|
||||
def capability_attributes(self) -> dict[str, Any]:
|
||||
@@ -169,6 +177,8 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
|
||||
ATTR_MIN_HUMIDITY: self.min_humidity,
|
||||
ATTR_MAX_HUMIDITY: self.max_humidity,
|
||||
}
|
||||
if self.target_humidity_step is not None:
|
||||
data[ATTR_TARGET_HUMIDITY_STEP] = self.target_humidity_step
|
||||
|
||||
if HumidifierEntityFeature.MODES in self.supported_features:
|
||||
data[ATTR_AVAILABLE_MODES] = self.available_modes
|
||||
@@ -251,6 +261,11 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
|
||||
"""Set new mode."""
|
||||
await self.hass.async_add_executor_job(self.set_mode, mode)
|
||||
|
||||
@cached_property
|
||||
def target_humidity_step(self) -> float | None:
|
||||
"""Return the supported step of humidity."""
|
||||
return self._attr_target_humidity_step
|
||||
|
||||
@cached_property
|
||||
def min_humidity(self) -> float:
|
||||
"""Return the minimum humidity."""
|
||||
|
||||
@@ -28,6 +28,7 @@ ATTR_CURRENT_HUMIDITY = "current_humidity"
|
||||
ATTR_HUMIDITY = "humidity"
|
||||
ATTR_MAX_HUMIDITY = "max_humidity"
|
||||
ATTR_MIN_HUMIDITY = "min_humidity"
|
||||
ATTR_TARGET_HUMIDITY_STEP = "target_humidity_step"
|
||||
|
||||
DEFAULT_MIN_HUMIDITY = 0
|
||||
DEFAULT_MAX_HUMIDITY = 100
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
selector:
|
||||
choose:
|
||||
choices:
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
@@ -27,14 +31,11 @@
|
||||
- input_number
|
||||
- number
|
||||
- sensor
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
translation_key: number_or_entity
|
||||
|
||||
.trigger_threshold_type: &trigger_threshold_type
|
||||
required: true
|
||||
default: above
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["incomfortclient"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["incomfort-client==0.6.10"]
|
||||
"requirements": ["incomfort-client==0.6.11"]
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ class IsraelRailEntitySensor(
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
if self.entity_description.index >= len(self.coordinator.data):
|
||||
return None
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data[self.entity_description.index]
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==1.1.2"]
|
||||
"requirements": ["pyjvcprojector==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ COMMANDS = {
|
||||
"mode_1": const.REMOTE_MODE_1,
|
||||
"mode_2": const.REMOTE_MODE_2,
|
||||
"mode_3": const.REMOTE_MODE_3,
|
||||
"mode_4": const.REMOTE_MODE_4,
|
||||
"mode_5": const.REMOTE_MODE_5,
|
||||
"mode_6": const.REMOTE_MODE_6,
|
||||
"mode_7": const.REMOTE_MODE_7,
|
||||
"mode_8": const.REMOTE_MODE_8,
|
||||
"mode_9": const.REMOTE_MODE_9,
|
||||
"mode_10": const.REMOTE_MODE_10,
|
||||
"lens_ap": const.REMOTE_LENS_AP,
|
||||
"gamma": const.REMOTE_GAMMA,
|
||||
"color_temp": const.REMOTE_COLOR_TEMP,
|
||||
|
||||
@@ -42,6 +42,7 @@ PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.EVENT,
|
||||
Platform.FAN,
|
||||
Platform.HUMIDIFIER,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
|
||||
195
homeassistant/components/lg_thinq/humidifier.py
Normal file
195
homeassistant/components/lg_thinq/humidifier.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Support for humidifier entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from thinqconnect import DeviceType
|
||||
from thinqconnect.devices.const import Property as ThinQProperty
|
||||
from thinqconnect.integration import ActiveMode
|
||||
|
||||
from homeassistant.components.humidifier import (
|
||||
HumidifierAction,
|
||||
HumidifierDeviceClass,
|
||||
HumidifierEntity,
|
||||
HumidifierEntityDescription,
|
||||
HumidifierEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ThinqConfigEntry
|
||||
from .coordinator import DeviceDataUpdateCoordinator
|
||||
from .entity import ThinQEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ThinQHumidifierEntityDescription(HumidifierEntityDescription):
|
||||
"""Describes ThinQ humidifier entity."""
|
||||
|
||||
current_humidity_key: str
|
||||
operation_key: str
|
||||
mode_key: str = ThinQProperty.CURRENT_JOB_MODE
|
||||
|
||||
|
||||
DEVICE_TYPE_HUM_MAP: dict[DeviceType, ThinQHumidifierEntityDescription] = {
|
||||
DeviceType.DEHUMIDIFIER: ThinQHumidifierEntityDescription(
|
||||
key=ThinQProperty.TARGET_HUMIDITY,
|
||||
name=None,
|
||||
device_class=HumidifierDeviceClass.DEHUMIDIFIER,
|
||||
translation_key="dehumidifier",
|
||||
current_humidity_key=ThinQProperty.CURRENT_HUMIDITY,
|
||||
operation_key=ThinQProperty.DEHUMIDIFIER_OPERATION_MODE,
|
||||
),
|
||||
DeviceType.HUMIDIFIER: ThinQHumidifierEntityDescription(
|
||||
key=ThinQProperty.TARGET_HUMIDITY,
|
||||
name=None,
|
||||
device_class=HumidifierDeviceClass.HUMIDIFIER,
|
||||
translation_key="humidifier",
|
||||
current_humidity_key=ThinQProperty.HUMIDITY,
|
||||
operation_key=ThinQProperty.HUMIDIFIER_OPERATION_MODE,
|
||||
),
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ThinqConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up an entry for humidifier platform."""
|
||||
entities: list[ThinQHumidifierEntity] = []
|
||||
for coordinator in entry.runtime_data.coordinators.values():
|
||||
if (
|
||||
description := DEVICE_TYPE_HUM_MAP.get(coordinator.api.device.device_type)
|
||||
) is not None:
|
||||
entities.extend(
|
||||
ThinQHumidifierEntity(coordinator, description, property_id)
|
||||
for property_id in coordinator.api.get_active_idx(
|
||||
description.key, ActiveMode.READ_WRITE
|
||||
)
|
||||
)
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ThinQHumidifierEntity(ThinQEntity, HumidifierEntity):
|
||||
"""Represent a ThinQ humidifier entity."""
|
||||
|
||||
entity_description: ThinQHumidifierEntityDescription
|
||||
_attr_supported_features = HumidifierEntityFeature.MODES
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DeviceDataUpdateCoordinator,
|
||||
entity_description: ThinQHumidifierEntityDescription,
|
||||
property_id: str,
|
||||
) -> None:
|
||||
"""Initialize a humidifier entity."""
|
||||
super().__init__(coordinator, entity_description, property_id)
|
||||
self._attr_available_modes = self.coordinator.data[
|
||||
self.entity_description.mode_key
|
||||
].options
|
||||
|
||||
if self.data.max is not None:
|
||||
self._attr_max_humidity = self.data.max
|
||||
if self.data.min is not None:
|
||||
self._attr_min_humidity = self.data.min
|
||||
self._attr_target_humidity_step = (
|
||||
self.data.step if self.data.step is not None else 1
|
||||
)
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status itself."""
|
||||
super()._update_status()
|
||||
|
||||
self._attr_target_humidity = self.data.value
|
||||
self._attr_current_humidity = self.coordinator.data[
|
||||
self.entity_description.current_humidity_key
|
||||
].value
|
||||
self._attr_is_on = self.coordinator.data[
|
||||
self.entity_description.operation_key
|
||||
].is_on
|
||||
self._attr_mode = self.coordinator.data[self.entity_description.mode_key].value
|
||||
if self.is_on:
|
||||
self._attr_action = (
|
||||
HumidifierAction.DRYING
|
||||
if self.entity_description.device_class
|
||||
== HumidifierDeviceClass.DEHUMIDIFIER
|
||||
else HumidifierAction.HUMIDIFYING
|
||||
)
|
||||
else:
|
||||
self._attr_action = HumidifierAction.OFF
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] update status: c:%s, t:%s, mode:%s, action:%s, is_on:%s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
self.current_humidity,
|
||||
self.target_humidity,
|
||||
self.mode,
|
||||
self.action,
|
||||
self.is_on,
|
||||
)
|
||||
|
||||
async def async_set_mode(self, mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_mode: %s",
|
||||
self.coordinator.device_name,
|
||||
self.entity_description.mode_key,
|
||||
mode,
|
||||
)
|
||||
await self.async_call_api(
|
||||
self.coordinator.api.post(self.entity_description.mode_key, mode)
|
||||
)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set new target humidity."""
|
||||
_target_humidity = round(humidity / (self.target_humidity_step or 1)) * (
|
||||
self.target_humidity_step or 1
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_humidity: %s, target_humidity: %s, step: %s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
humidity,
|
||||
_target_humidity,
|
||||
self.target_humidity_step,
|
||||
)
|
||||
if _target_humidity == self.target_humidity:
|
||||
return
|
||||
await self.async_call_api(
|
||||
self.coordinator.api.post(self.property_id, _target_humidity)
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
if self.is_on:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_turn_on",
|
||||
self.coordinator.device_name,
|
||||
self.entity_description.operation_key,
|
||||
)
|
||||
await self.async_call_api(
|
||||
self.coordinator.api.async_turn_on(self.entity_description.operation_key)
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
if not self.is_on:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_turn_off",
|
||||
self.coordinator.device_name,
|
||||
self.entity_description.operation_key,
|
||||
)
|
||||
await self.async_call_api(
|
||||
self.coordinator.api.async_turn_off(self.entity_description.operation_key)
|
||||
)
|
||||
@@ -199,6 +199,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"humidifier": {
|
||||
"dehumidifier": {
|
||||
"state_attributes": {
|
||||
"mode": {
|
||||
"state": {
|
||||
"air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]",
|
||||
"clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]",
|
||||
"intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]",
|
||||
"quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]",
|
||||
"rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]",
|
||||
"smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"humidifier": {
|
||||
"state_attributes": {
|
||||
"mode": {
|
||||
"state": {
|
||||
"air_clean": "[%key:component::lg_thinq::entity::select::current_job_mode::state::air_clean%]",
|
||||
"humidify": "[%key:component::lg_thinq::entity::select::current_job_mode::state::humidify%]",
|
||||
"humidify_and_air_clean": "[%key:component::lg_thinq::entity::select::current_job_mode::state::humidify_and_air_clean%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"fan_speed": {
|
||||
"name": "Fan"
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
selector:
|
||||
choose:
|
||||
choices:
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
@@ -27,10 +31,6 @@
|
||||
- input_number
|
||||
- number
|
||||
- sensor
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
translation_key: number_or_entity
|
||||
|
||||
turned_on: *trigger_common
|
||||
@@ -48,6 +48,7 @@ brightness_crossed_threshold:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type:
|
||||
required: true
|
||||
default: above
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "namecheapdns",
|
||||
"name": "Namecheap FreeDNS",
|
||||
"name": "Namecheap DynamicDNS",
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/namecheapdns",
|
||||
"iot_class": "cloud_push",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Support for Netatmo binary sensors."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Final, cast
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
@@ -9,17 +13,33 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import NETATMO_CREATE_WEATHER_SENSOR
|
||||
from .const import NETATMO_CREATE_WEATHER_BINARY_SENSOR
|
||||
from .data_handler import NetatmoDevice
|
||||
from .entity import NetatmoWeatherModuleEntity
|
||||
|
||||
BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
|
||||
BinarySensorEntityDescription(
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class NetatmoBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Netatmo binary sensor entity."""
|
||||
|
||||
name: str | None = None # The default name of the sensor
|
||||
netatmo_name: str # The name used by Netatmo API for this sensor
|
||||
|
||||
|
||||
NETATMO_WEATHER_BINARY_SENSOR_DESCRIPTIONS: Final[
|
||||
list[NetatmoBinarySensorEntityDescription]
|
||||
] = [
|
||||
NetatmoBinarySensorEntityDescription(
|
||||
key="reachable",
|
||||
name="Connectivity",
|
||||
netatmo_name="reachable",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,36 +47,75 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Netatmo binary sensors based on a config entry."""
|
||||
"""Set up Netatmo weather binary sensors based on a config entry."""
|
||||
|
||||
@callback
|
||||
def _create_weather_binary_sensor_entity(netatmo_device: NetatmoDevice) -> None:
|
||||
async_add_entities(
|
||||
NetatmoWeatherBinarySensor(netatmo_device, description)
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
if description.key in netatmo_device.device.features
|
||||
)
|
||||
"""Create weather binary sensor entities for a Netatmo weather device."""
|
||||
|
||||
descriptions_to_add = NETATMO_WEATHER_BINARY_SENSOR_DESCRIPTIONS
|
||||
|
||||
entities: list[NetatmoWeatherBinarySensor] = []
|
||||
|
||||
# Create binary sensors for module
|
||||
for description in descriptions_to_add:
|
||||
# Actual check is simple for reachable
|
||||
feature_check = description.key
|
||||
if feature_check in netatmo_device.device.features:
|
||||
_LOGGER.debug(
|
||||
'Adding "%s" weather binary sensor for device %s',
|
||||
feature_check,
|
||||
netatmo_device.device.name,
|
||||
)
|
||||
entities.append(
|
||||
NetatmoWeatherBinarySensor(
|
||||
netatmo_device,
|
||||
description,
|
||||
)
|
||||
)
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_binary_sensor_entity
|
||||
hass,
|
||||
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
|
||||
_create_weather_binary_sensor_entity,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class NetatmoWeatherBinarySensor(NetatmoWeatherModuleEntity, BinarySensorEntity):
|
||||
"""Implementation of a Netatmo binary sensor."""
|
||||
"""Implementation of a Netatmo weather binary sensor."""
|
||||
|
||||
entity_description: NetatmoBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self, device: NetatmoDevice, description: BinarySensorEntityDescription
|
||||
self,
|
||||
netatmo_device: NetatmoDevice,
|
||||
description: NetatmoBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Netatmo binary sensor."""
|
||||
super().__init__(device)
|
||||
"""Initialize a Netatmo weather binary sensor."""
|
||||
|
||||
super().__init__(netatmo_device)
|
||||
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self.device.entity_id}-{description.key}"
|
||||
|
||||
@callback
|
||||
def async_update_callback(self) -> None:
|
||||
"""Update the entity's state."""
|
||||
self._attr_is_on = self.device.reachable
|
||||
|
||||
value: StateType | None = None
|
||||
|
||||
value = getattr(self.device, self.entity_description.netatmo_name, None)
|
||||
|
||||
if value is None:
|
||||
self._attr_available = False
|
||||
self._attr_is_on = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._attr_is_on = cast(bool, value)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -53,6 +53,7 @@ NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor"
|
||||
NETATMO_CREATE_SELECT = "netatmo_create_select"
|
||||
NETATMO_CREATE_SENSOR = "netatmo_create_sensor"
|
||||
NETATMO_CREATE_SWITCH = "netatmo_create_switch"
|
||||
NETATMO_CREATE_WEATHER_BINARY_SENSOR = "netatmo_create_weather_binary_sensor"
|
||||
NETATMO_CREATE_WEATHER_SENSOR = "netatmo_create_weather_sensor"
|
||||
|
||||
CONF_AREA_NAME = "area_name"
|
||||
|
||||
@@ -45,6 +45,7 @@ from .const import (
|
||||
NETATMO_CREATE_SELECT,
|
||||
NETATMO_CREATE_SENSOR,
|
||||
NETATMO_CREATE_SWITCH,
|
||||
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
|
||||
NETATMO_CREATE_WEATHER_SENSOR,
|
||||
PLATFORMS,
|
||||
WEBHOOK_ACTIVATION,
|
||||
@@ -332,16 +333,20 @@ class NetatmoDataHandler:
|
||||
"""Set up home coach/air care modules."""
|
||||
for module in self.account.modules.values():
|
||||
if module.device_category is NetatmoDeviceCategory.air_care:
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
for signal in (
|
||||
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
|
||||
NETATMO_CREATE_WEATHER_SENSOR,
|
||||
NetatmoDevice(
|
||||
self,
|
||||
module,
|
||||
AIR_CARE,
|
||||
AIR_CARE,
|
||||
),
|
||||
)
|
||||
):
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
signal,
|
||||
NetatmoDevice(
|
||||
self,
|
||||
module,
|
||||
AIR_CARE,
|
||||
AIR_CARE,
|
||||
),
|
||||
)
|
||||
|
||||
def setup_modules(self, home: pyatmo.Home, signal_home: str) -> None:
|
||||
"""Set up modules."""
|
||||
@@ -379,16 +384,20 @@ class NetatmoDataHandler:
|
||||
),
|
||||
)
|
||||
if module.device_category is NetatmoDeviceCategory.weather:
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
for signal in (
|
||||
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
|
||||
NETATMO_CREATE_WEATHER_SENSOR,
|
||||
NetatmoDevice(
|
||||
self,
|
||||
module,
|
||||
home.entity_id,
|
||||
WEATHER,
|
||||
),
|
||||
)
|
||||
):
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
signal,
|
||||
NetatmoDevice(
|
||||
self,
|
||||
module,
|
||||
home.entity_id,
|
||||
WEATHER,
|
||||
),
|
||||
)
|
||||
|
||||
def setup_rooms(self, home: pyatmo.Home, signal_home: str) -> None:
|
||||
"""Set up rooms."""
|
||||
|
||||
@@ -256,7 +256,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
if slot_id > user_input[CONF_MESSAGE_SLOTS]
|
||||
]
|
||||
|
||||
removed_entites_area = [
|
||||
removed_entities_area = [
|
||||
f"{cfg_region}-{slot_id}"
|
||||
for slot_id in range(1, self.data[CONF_MESSAGE_SLOTS] + 1)
|
||||
for cfg_region in self.data[CONF_REGIONS]
|
||||
@@ -265,7 +265,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
|
||||
for entry in entries:
|
||||
for entity_uid in list(
|
||||
set(removed_entities_slots + removed_entites_area)
|
||||
set(removed_entities_slots + removed_entities_area)
|
||||
):
|
||||
if entry.unique_id == entity_uid:
|
||||
entity_registry.async_remove(entry.entity_id)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynina"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynina==0.3.6"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
103
homeassistant/components/nina/quality_scale.yaml
Normal file
103
homeassistant/components/nina/quality_scale.yaml
Normal file
@@ -0,0 +1,103 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use services that need authentication.
|
||||
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
|
||||
Evaluate the need of test_config_entry_not_ready
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does use a cloud service.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does use a cloud service.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use devices.
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: done
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: |
|
||||
Extract attributes into own entities.
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not custom icons.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynintendoauth", "pynintendoparental"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.0"]
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2"]
|
||||
}
|
||||
|
||||
@@ -28,9 +28,15 @@
|
||||
"exchange_rate": {
|
||||
"default": "mdi:currency-usd"
|
||||
},
|
||||
"highest_price": {
|
||||
"default": "mdi:cash-plus"
|
||||
},
|
||||
"last_price": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
"lowest_price": {
|
||||
"default": "mdi:cash-minus"
|
||||
},
|
||||
"next_price": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import openevsewifi
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
|
||||
type OpenEVSEConfigEntry = ConfigEntry[openevsewifi.Charger]
|
||||
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSE]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
|
||||
"""Set up openevse from a config entry."""
|
||||
|
||||
entry.runtime_data = openevsewifi.Charger(entry.data[CONF_HOST])
|
||||
entry.runtime_data = OpenEVSE(entry.data[CONF_HOST])
|
||||
try:
|
||||
await hass.async_add_executor_job(entry.runtime_data.getStatus)
|
||||
except AttributeError as ex:
|
||||
await entry.runtime_data.test_and_get()
|
||||
except TimeoutError as ex:
|
||||
raise ConfigEntryError("Unable to connect to charger") from ex
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
import openevsewifi
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||
from homeassistant.helpers.service_info import zeroconf
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_ID, CONF_SERIAL, DOMAIN
|
||||
|
||||
|
||||
class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -17,27 +18,33 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def check_status(self, host: str) -> bool:
|
||||
def __init__(self) -> None:
|
||||
"""Set up the instance."""
|
||||
self.discovery_info: dict[str, Any] = {}
|
||||
|
||||
async def check_status(self, host: str) -> tuple[bool, str | None]:
|
||||
"""Check if we can connect to the OpenEVSE charger."""
|
||||
|
||||
charger = openevsewifi.Charger(host)
|
||||
charger = OpenEVSE(host)
|
||||
try:
|
||||
result = await self.hass.async_add_executor_job(charger.getStatus)
|
||||
except AttributeError:
|
||||
return False
|
||||
else:
|
||||
return result is not None
|
||||
result = await charger.test_and_get()
|
||||
except TimeoutError:
|
||||
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 = None
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
|
||||
if await self.check_status(user_input[CONF_HOST]):
|
||||
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,
|
||||
@@ -55,10 +62,53 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self._async_abort_entries_match({CONF_HOST: data[CONF_HOST]})
|
||||
|
||||
if not await self.check_status(data[CONF_HOST]):
|
||||
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:
|
||||
return self.async_abort(reason="unavailable_host")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"OpenEVSE {data[CONF_HOST]}",
|
||||
data=data,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
self._async_abort_entries_match({CONF_HOST: discovery_info.host})
|
||||
|
||||
await self.async_set_unique_id(discovery_info.properties[CONF_ID])
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
|
||||
|
||||
host = discovery_info.host
|
||||
name = f"OpenEVSE {discovery_info.name.split('.')[0]}"
|
||||
self.discovery_info.update(
|
||||
{
|
||||
CONF_HOST: host,
|
||||
CONF_NAME: name,
|
||||
}
|
||||
)
|
||||
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."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"name": self.discovery_info[CONF_NAME]},
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self.discovery_info[CONF_NAME],
|
||||
data={CONF_HOST: self.discovery_info[CONF_HOST]},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Constants for the OpenEVSE integration."""
|
||||
|
||||
CONF_ID = "id"
|
||||
CONF_SERIAL = "serial"
|
||||
DOMAIN = "openevse"
|
||||
INTEGRATION_TITLE = "OpenEVSE"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"domain": "openevse",
|
||||
"name": "OpenEVSE",
|
||||
"codeowners": ["@c00w"],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": ["@c00w", "@firstof9"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/openevse",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["openevsewifi"],
|
||||
"loggers": ["openevsehttp"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["openevsewifi==1.1.2"]
|
||||
"requirements": ["python-openevse-http==0.2.1"],
|
||||
"zeroconf": ["_openevse._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import openevsewifi
|
||||
from requests import RequestException
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -18,6 +17,8 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
ATTR_SERIAL_NUMBER,
|
||||
CONF_HOST,
|
||||
CONF_MONITORED_VARIABLES,
|
||||
UnitOfEnergy,
|
||||
@@ -27,6 +28,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -41,25 +43,25 @@ _LOGGER = logging.getLogger(__name__)
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="status",
|
||||
name="Charging Status",
|
||||
translation_key="status",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="charge_time",
|
||||
name="Charge Time Elapsed",
|
||||
translation_key="charge_time",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ambient_temp",
|
||||
name="Ambient Temperature",
|
||||
translation_key="ambient_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ir_temp",
|
||||
name="IR Temperature",
|
||||
translation_key="ir_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -67,7 +69,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="rtc_temp",
|
||||
name="RTC Temperature",
|
||||
translation_key="rtc_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -75,14 +77,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="usage_session",
|
||||
name="Usage this Session",
|
||||
translation_key="usage_session",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="usage_total",
|
||||
name="Total Usage",
|
||||
translation_key="usage_total",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -159,9 +161,10 @@ async def async_setup_entry(
|
||||
async_add_entities(
|
||||
(
|
||||
OpenEVSESensor(
|
||||
config_entry.data[CONF_HOST],
|
||||
config_entry.runtime_data,
|
||||
description,
|
||||
config_entry.entry_id,
|
||||
config_entry.unique_id,
|
||||
)
|
||||
for description in SENSOR_TYPES
|
||||
),
|
||||
@@ -172,36 +175,54 @@ async def async_setup_entry(
|
||||
class OpenEVSESensor(SensorEntity):
|
||||
"""Implementation of an OpenEVSE sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
charger: openevsewifi.Charger,
|
||||
charger: OpenEVSE,
|
||||
description: SensorEntityDescription,
|
||||
entry_id: str,
|
||||
unique_id: str | None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_description = description
|
||||
self.host = host
|
||||
self.charger = charger
|
||||
|
||||
def update(self) -> None:
|
||||
identifier = unique_id or entry_id
|
||||
self._attr_unique_id = f"{identifier}-{description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, identifier)},
|
||||
manufacturer="OpenEVSE",
|
||||
)
|
||||
if unique_id:
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_NETWORK_MAC, unique_id)
|
||||
}
|
||||
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Get the monitored data from the charger."""
|
||||
try:
|
||||
sensor_type = self.entity_description.key
|
||||
if sensor_type == "status":
|
||||
self._attr_native_value = self.charger.getStatus()
|
||||
elif sensor_type == "charge_time":
|
||||
self._attr_native_value = self.charger.getChargeTimeElapsed() / 60
|
||||
elif sensor_type == "ambient_temp":
|
||||
self._attr_native_value = self.charger.getAmbientTemperature()
|
||||
elif sensor_type == "ir_temp":
|
||||
self._attr_native_value = self.charger.getIRTemperature()
|
||||
elif sensor_type == "rtc_temp":
|
||||
self._attr_native_value = self.charger.getRTCTemperature()
|
||||
elif sensor_type == "usage_session":
|
||||
self._attr_native_value = float(self.charger.getUsageSession()) / 1000
|
||||
elif sensor_type == "usage_total":
|
||||
self._attr_native_value = float(self.charger.getUsageTotal()) / 1000
|
||||
else:
|
||||
self._attr_native_value = "Unknown"
|
||||
except (RequestException, ValueError, KeyError):
|
||||
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"
|
||||
|
||||
@@ -13,11 +13,36 @@
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Enter the IP Address of your openevse. Should match the address you used to set it up."
|
||||
"host": "Enter the IP address of your OpenEVSE. Should match the address you used to set it up."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ambient_temp": {
|
||||
"name": "Ambient temperature"
|
||||
},
|
||||
"charge_time": {
|
||||
"name": "Charge time elapsed"
|
||||
},
|
||||
"ir_temp": {
|
||||
"name": "IR temperature"
|
||||
},
|
||||
"rtc_temp": {
|
||||
"name": "RTC temperature"
|
||||
},
|
||||
"status": {
|
||||
"name": "Charging status"
|
||||
},
|
||||
"usage_session": {
|
||||
"name": "Usage this session"
|
||||
},
|
||||
"usage_total": {
|
||||
"name": "Total energy usage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"yaml_deprecated": {
|
||||
"description": "Configuring OpenEVSE using YAML is being removed. Your existing YAML configuration has been imported into the UI automatically. Remove the `openevse` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.16.0"]
|
||||
"requirements": ["opower==0.16.1"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user