Compare commits

..

56 Commits

Author SHA1 Message Date
epenet
91344efd5c Improve 2026-03-31 08:59:57 +00:00
epenet
d243a26e71 Migrate nuheat to use runtime_data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:53:01 +00:00
epenet
7ce32f0668 Remove unused hass.data[DOMAIN] in nfandroidtv (#166931)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:27:49 +02:00
Artur Pragacz
dc5547d7b6 Unprefix entity name for template function (#166899) 2026-03-31 09:08:21 +02:00
Artur Pragacz
de98bc7dcf Unprefix entity name for entity ID generation (#166900) 2026-03-31 09:05:39 +02:00
dependabot[bot]
a71d48085a Bump j178/prek-action from 2.0.0 to 2.0.1 (#166924)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 08:45:54 +02:00
Brett Adams
9e20a13936 Fix Tesla Fleet startup scopes after OAuth refresh (#166922) 2026-03-31 07:53:15 +02:00
Mike Degatano
e164e65217 Use aiohasupervisor for all Supervisor service calls (#166558) 2026-03-31 07:35:41 +02:00
Manu
07998de35e Bump aiontfy to 0.8.4 (#166917) 2026-03-31 01:05:37 +02:00
smarthome-10
5253dc11dc Rename component to integration in Linksys Smart Wi-Fi (#166885) 2026-03-30 22:42:00 +01:00
smarthome-10
3f9022cd53 Rename component to integration in Arris TG2492LG (#166883) 2026-03-30 23:23:27 +02:00
Leon Grave
073f498c75 Add freshr diagnostics (#166912) 2026-03-30 23:16:00 +02:00
reneboer
c5b24e9470 Update datetime selector in Renault ac_start action (#166860) 2026-03-30 22:34:51 +02:00
smarthome-10
c12b7bfd18 Rename component to integration in Bitcoin (#166882) 2026-03-30 20:41:26 +01:00
smarthome-10
1c2f583587 Rename component to integration in FortiOS (#166887)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 20:33:06 +01:00
Raj Laud
58a376e68b Bump victron-ble-ha-parser (#166906) 2026-03-30 20:23:22 +01:00
Jan Bouwhuis
78b251e7cb Add clean segment support to MQTT vacuum entities (#166794) 2026-03-30 21:20:17 +02:00
Abílio Costa
a2c65b9126 Remove checkout requirement from PR review skill (#166902) 2026-03-30 19:12:59 +01:00
Denis Shulyaka
5e443681c3 Add troubleshooting documentation for Anthropic integration (#166766) 2026-03-30 20:10:49 +02:00
smarthome-10
13756863f1 Rename component to integration in Fail2Ban (#166901) 2026-03-30 20:08:56 +02:00
Raphael Hehl
fd54e45aeb Add dynamic device support for UniFi Access door platforms (#166793) 2026-03-30 19:51:05 +02:00
Manu
52af74c3b6 Add entity action html5.send_message to HTML5 integration (#166349)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-30 19:49:59 +02:00
Denis Shulyaka
dc111a475e Add support for web search dynamic filtering for Anthropic (#164116) 2026-03-30 19:40:56 +02:00
Chase
14cb42349a OpenRouter: Add WebSearch Support (#164293)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-30 19:40:02 +02:00
Raphael Hehl
c42b50418e Add stale device removal support to UniFi Access (#166792)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-30 19:19:20 +02:00
AlCalzone
501b4e6efb Convert Z-Wave Opening state to separate Open/Closed and Tilted sensors (#166635)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 19:17:05 +02:00
smarthome-10
ca2099b165 Rename component to integration in Panasonic Blu-Ray (#166890)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 18:13:17 +02:00
smarthome-10
69b55c295d Rename component to integration in OhmConnect (#166881) 2026-03-30 17:47:38 +02:00
smarthome-10
13709b1c90 Rename component to integration in Sky Hub (#166888) 2026-03-30 17:45:18 +02:00
smarthome-10
2c013777db Rename component to integration in Opple (#166891) 2026-03-30 17:43:56 +02:00
Raphael Hehl
91099ea489 Update UniFi Access quality scale: mark fulfilled Gold rules (#166789)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-30 17:19:07 +02:00
Michal Čihař
70cea66e5b Skip unavailable sensors in LaCrosse View (#166859) 2026-03-30 17:03:21 +02:00
Taylor Wilsdon
e78bb97e84 Support vacation mode in Econet (#166659) 2026-03-30 16:58:11 +02:00
Robert Svensson
732b170190 Introduce per-source DataUpdateCoordinator for UniFi polling data sources (#166806) 2026-03-30 16:48:18 +02:00
Raphael Hehl
0a05993a4e Unifi Access add reconfiguration flow and refactor validation logic (#166812)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-30 16:44:12 +02:00
Abílio Costa
42c3610685 Add counter purpose-specific condition (#166879) 2026-03-30 16:41:08 +02:00
Raphael Hehl
4ad73da7ec Add strict typing to UniFi Access integration (#166787) 2026-03-30 16:36:07 +02:00
hanwg
0d14bdab24 Fix webhook leak for Telegram bot (#166776) 2026-03-30 16:29:28 +02:00
Denis Shulyaka
157362f225 Fix OpenAI image generation with reasoning (#166827) 2026-03-30 16:27:39 +02:00
Manu
1aa380fdfa Add tr4nt0r as codeowner to html5 integration (#166771) 2026-03-30 10:25:10 -04:00
Jan Bouwhuis
9348948afa Add attribute group_entities to the list of blocked MQTT entity attributes (#165360) 2026-03-30 16:21:02 +02:00
Jan Bouwhuis
14b9915914 Add repair flow when MQTT YAML config is present but the broker is not set up correctly (#165090)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-30 16:16:31 +02:00
smarthome-10
607462028b Rename component to integration in Thomson (#166880) 2026-03-30 16:08:03 +02:00
epenet
8c07348a3d Migrate neato to use runtime_data (#166854)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:03:43 +02:00
epenet
cda52af178 Migrate motioneye to use runtime_data (#166848)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:56:08 +02:00
Tom Matheussen
d1ccda18f7 Skip unchanged connection check on reconfigure flow for Satel Integra (#166695)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-30 15:52:11 +02:00
Franck Nijhof
9fb0b69f0a Improve text action naming consistency (#166523)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-30 15:42:31 +02:00
Paul Bottein
f0848edea9 Use translation key and icons.json for Synology DSM button entities (#166862) 2026-03-30 15:23:49 +02:00
Mike O'Driscoll
5be12a213d Bump pycasperglow to 1.2.0 (#166791) 2026-03-30 15:03:40 +02:00
mettolen
20b284d0e9 Fix Huum exception translations (#166778)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 14:55:45 +02:00
Lorenzo Gasparini
49c3376c95 Bump fing_agent_api to 1.1.0 (#166855) 2026-03-30 14:33:00 +02:00
Joost Lekkerkerker
174b5f5593 Get list of analytics insights integrations from next environment (#166867) 2026-03-30 14:29:25 +02:00
epenet
b38e41a34a Refactor Tuya device diagnostics (#166846) 2026-03-30 14:01:18 +02:00
epenet
b6350478a5 Migrate meteo_france to use runtime_data (#166852)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:48:01 +02:00
Erik Montnemery
b75af6d84a Mark Entity.async_write_ha_state as final (#166627) 2026-03-30 13:21:45 +02:00
Ariel Ebersberger
194485d863 Fix shelly tests - mock async_unload_entry (#166851) 2026-03-30 13:19:52 +02:00
154 changed files with 5894 additions and 1142 deletions

View File

@@ -5,14 +5,6 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
# Review GitHub Pull Request
## Preparation:
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
- Do NOT attempt any workarounds.
- Do NOT proceed with the review.
- ALERT about the failure and WAIT for instructions.
- This is a hard requirement - no exceptions.
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.

View File

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

View File

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

View File

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

View File

@@ -280,7 +280,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -301,7 +301,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
with:
extra-args: --all-files zizmor

View File

@@ -579,6 +579,7 @@ homeassistant.components.trmnl.*
homeassistant.components.tts.*
homeassistant.components.twentemilieu.*
homeassistant.components.unifi.*
homeassistant.components.unifi_access.*
homeassistant.components.unifiprotect.*
homeassistant.components.upcloud.*
homeassistant.components.update.*

8
CODEOWNERS generated
View File

@@ -741,8 +741,8 @@ build.json @home-assistant/supervisor
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015
/tests/components/html5/ @alexyao2015
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
/tests/components/html5/ @alexyao2015 @tr4nt0r
/homeassistant/components/http/ @home-assistant/core
/tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle
@@ -1232,8 +1232,8 @@ build.json @home-assistant/supervisor
/tests/components/onvif/ @jterrace
/homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
/homeassistant/components/open_router/ @joostlek @ab3lson
/tests/components/open_router/ @joostlek @ab3lson
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from python_homeassistant_analytics import (
Environment,
HomeassistantAnalyticsClient,
HomeassistantAnalyticsConnectionError,
)
@@ -38,7 +39,7 @@ async def async_setup_entry(
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
try:
integrations = await client.get_integrations()
integrations = await client.get_integrations(Environment.NEXT)
except HomeassistantAnalyticsConnectionError as ex:
raise ConfigEntryNotReady("Could not fetch integration list") from ex

View File

@@ -71,6 +71,16 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -19,6 +19,8 @@ from anthropic.types import (
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockParamContentParam,
Container,
ContentBlockParam,
DocumentBlockParam,
@@ -61,15 +63,16 @@ from anthropic.types import (
ToolUseBlockParam,
Usage,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
WebSearchToolResultBlockParamContentParam,
)
from anthropic.types.bash_code_execution_tool_result_block_param import (
Content as BashCodeExecutionToolResultContentParam,
Content as BashCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultContentParam,
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -105,6 +108,7 @@ from .const import (
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
@@ -224,12 +228,22 @@ def _convert_content(
},
),
}
elif content.tool_name == "code_execution":
tool_result_block = {
"type": "code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
CodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "bash_code_execution":
tool_result_block = {
"type": "bash_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
BashCodeExecutionToolResultContentParam, content.tool_result
BashCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "text_editor_code_execution":
@@ -237,7 +251,7 @@ def _convert_content(
"type": "text_editor_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
TextEditorCodeExecutionToolResultContentParam,
TextEditorCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
@@ -368,6 +382,7 @@ def _convert_content(
name=cast(
Literal[
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
],
@@ -379,6 +394,7 @@ def _convert_content(
and tool_call.tool_name
in [
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
]
@@ -470,7 +486,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input={},
input=response.content_block.input or {},
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -532,13 +548,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input={},
input=response.content_block.input or {},
)
current_tool_args = ""
elif isinstance(
response.content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
),
@@ -594,13 +611,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] = tool_args
current_tool_block["input"] |= tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=tool_args,
tool_args=current_tool_block["input"],
external=current_tool_block["type"] == "server_tool_use",
)
]
@@ -735,19 +752,34 @@ class AnthropicBaseLLMEntity(Entity):
]
if options.get(CONF_CODE_EXECUTION):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_WEB_SEARCH):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
if options.get(CONF_WEB_SEARCH):
web_search = WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_CODE_EXECUTION):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"type": "approximate",

View File

@@ -66,7 +66,7 @@ rules:
comment: |
To write something about what models we support.
docs-supported-functions: done
docs-troubleshooting: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt

View File

@@ -1 +1 @@
"""The Arris TG2492LG component."""
"""The Arris TG2492LG integration."""

View File

@@ -124,6 +124,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"battery",
"calendar",
"climate",
"counter",
"cover",
"device_tracker",
"door",

View File

@@ -1 +1 @@
"""The bitcoin component."""
"""The Bitcoin integration."""

View File

@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "silver",
"requirements": ["pycasperglow==1.1.0"]
"requirements": ["pycasperglow==1.2.0"]
}

View File

@@ -0,0 +1,15 @@
"""Provides conditions for counters."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
DOMAIN = "counter"
CONDITIONS: dict[str, type[Condition]] = {
"is_value": make_entity_numerical_condition(DOMAIN),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for counters."""
return CONDITIONS

View File

@@ -0,0 +1,25 @@
is_value:
target:
entity:
- domain: counter
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
threshold:
required: true
selector:
numeric_threshold:
entity:
- domain: counter
- domain: input_number
- domain: number
mode: is
number:
mode: box

View File

@@ -1,4 +1,9 @@
{
"conditions": {
"is_value": {
"condition": "mdi:counter"
}
},
"services": {
"decrement": {
"service": "mdi:numeric-negative-1"

View File

@@ -3,6 +3,22 @@
"trigger_behavior_description": "The behavior of the targeted counters to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_value": {
"description": "Tests the value of one or more counters.",
"fields": {
"behavior": {
"description": "How the state should match on the targeted counters.",
"name": "Behavior"
},
"threshold": {
"description": "What to test for and threshold values.",
"name": "Threshold"
}
},
"name": "Counter value"
}
},
"entity_component": {
"_": {
"name": "[%key:component::counter::title%]",
@@ -30,6 +46,12 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -45,6 +45,13 @@ SUPPORT_FLAGS_HEATER = (
)
def _operation_mode_to_ha(mode: WaterHeaterOperationMode | None) -> str:
"""Translate an EcoNet operation mode to a Home Assistant state."""
if mode in (None, WaterHeaterOperationMode.VACATION):
return STATE_OFF
return ECONET_STATE_TO_HA[mode]
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
@@ -80,26 +87,22 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
@property
def current_operation(self) -> str:
"""Return current operation."""
econet_mode = self.water_heater.mode
_current_op = STATE_OFF
if econet_mode is not None:
_current_op = ECONET_STATE_TO_HA[econet_mode]
return _current_op
return _operation_mode_to_ha(self.water_heater.mode)
@property
def operation_list(self) -> list[str]:
"""List of available operation modes."""
econet_modes = self.water_heater.modes
operation_modes = set()
for mode in econet_modes:
if (
mode is not WaterHeaterOperationMode.UNKNOWN
and mode is not WaterHeaterOperationMode.VACATION
):
ha_mode = ECONET_STATE_TO_HA[mode]
operation_modes.add(ha_mode)
return list(operation_modes)
return list(
dict.fromkeys(
ECONET_STATE_TO_HA[mode]
for mode in self.water_heater.modes
if mode
not in (
WaterHeaterOperationMode.UNKNOWN,
WaterHeaterOperationMode.VACATION,
)
)
)
@property
def supported_features(self) -> WaterHeaterEntityFeature:

View File

@@ -1 +1 @@
"""The fail2ban component."""
"""The Fail2Ban integration."""

View File

@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.helpers.httpx_client import get_async_client
from .const import DOMAIN, UPNP_AVAILABLE
@@ -40,6 +41,7 @@ class FingConfigFlow(ConfigFlow, domain=DOMAIN):
ip=user_input[CONF_IP_ADDRESS],
port=int(user_input[CONF_PORT]),
key=user_input[CONF_API_KEY],
client=get_async_client(self.hass),
)
try:

View File

@@ -11,6 +11,7 @@ import httpx
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPNP_AVAILABLE
@@ -38,6 +39,7 @@ class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
ip=config_entry.data[CONF_IP_ADDRESS],
port=int(config_entry.data[CONF_PORT]),
key=config_entry.data[CONF_API_KEY],
client=get_async_client(hass),
)
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
update_interval = timedelta(seconds=30)

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.0.3"]
"requirements": ["fing_agent_api==1.1.0"]
}

View File

@@ -68,5 +68,5 @@ rules:
# Platinum
async-dependency: todo
inject-websession: todo
inject-websession: done
strict-typing: todo

View File

@@ -1 +1 @@
"""Fortinet FortiOS components."""
"""Fortinet FortiOS integration."""

View File

@@ -1,6 +1,6 @@
"""Support to use FortiOS device like FortiGate as device tracker.
This component is part of the device_tracker platform.
This FortiOS integration provides a device_tracker platform.
"""
from __future__ import annotations

View File

@@ -0,0 +1,34 @@
"""Diagnostics support for Fresh-r."""
from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import FreshrConfigEntry
TO_REDACT = {CONF_PASSWORD}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: FreshrConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
runtime_data = entry.runtime_data
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"devices": [
dataclasses.asdict(device) for device in runtime_data.devices.data.values()
],
"readings": {
device_id: dataclasses.asdict(coordinator.data)
if coordinator.data is not None
else None
for device_id, coordinator in runtime_data.readings.items()
},
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Integration connects to a cloud service; no local network discovery is possible.

View File

@@ -3,14 +3,12 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from dataclasses import replace
from datetime import datetime
import logging
import os
import re
import struct
from typing import Any, NamedTuple, cast
from typing import Any, cast
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
@@ -41,35 +39,23 @@ from homeassistant.components.http import (
)
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_NAME,
EVENT_CORE_CONFIG_UPDATE,
HASSIO_USER_NAME,
SERVER_PORT,
Platform,
)
from homeassistant.core import (
Event,
HassJob,
HomeAssistant,
ServiceCall,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery_flow,
issue_registry as ir,
selector,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.dt import now
# config_flow, diagnostics, system_health, and entity platforms are imported to
# ensure other dependencies that wait for hassio are not waiting
@@ -92,19 +78,7 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_REPOSITORIES,
ATTR_SLUG,
DATA_ADDONS_LIST,
DATA_COMPONENT,
DATA_CONFIG_STORE,
@@ -118,7 +92,6 @@ from .const import (
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_UPDATE_INTERVAL,
SupervisorEntityModel,
)
from .coordinator import (
HassioDataUpdateCoordinator,
@@ -136,15 +109,11 @@ from .coordinator import (
get_supervisor_stats,
)
from .discovery import async_setup_discovery_view
from .handler import (
HassIO,
HassioAPIError,
async_update_diagnostics,
get_supervisor_client,
)
from .handler import HassIO, async_update_diagnostics, get_supervisor_client
from .http import HassIOView
from .ingress import async_setup_ingress_view
from .issues import SupervisorIssues
from .services import async_setup_services
from .websocket_api import async_load_websocket_api
# Expose the future safe name now so integrations can use it
@@ -190,23 +159,6 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_APP_START = "app_start"
SERVICE_APP_STOP = "app_stop"
SERVICE_APP_RESTART = "app_restart"
SERVICE_APP_STDIN = "app_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
DEPRECATION_URL = (
"https://www.home-assistant.io/blog/2025/05/22/"
@@ -214,148 +166,11 @@ DEPRECATION_URL = (
)
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
value = VALID_ADDON_SLUG(value)
hass = async_get_hass_or_none()
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
raise vol.Invalid("Not a valid app slug")
return value
SCHEMA_NO_DATA = vol.Schema({})
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
}
)
SCHEMA_RESTORE_FULL = vol.Schema(
{
vol.Required(ATTR_SLUG): cv.slug,
vol.Optional(ATTR_PASSWORD): cv.string,
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
def _is_32_bit() -> bool:
size = struct.calcsize("P")
return size * 8 == 32
class APIEndpointSettings(NamedTuple):
"""Settings for API endpoint."""
command: str
schema: vol.Schema
timeout: int | None = 60
pass_data: bool = False
MAP_SERVICE_API = {
# Legacy addon services
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
SERVICE_ADDON_STDIN: APIEndpointSettings(
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
),
# New app services
SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP),
SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP),
SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP),
SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN),
SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA),
SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA),
SERVICE_BACKUP_FULL: APIEndpointSettings(
"/backups/new/full",
SCHEMA_BACKUP_FULL,
None,
True,
),
SERVICE_BACKUP_PARTIAL: APIEndpointSettings(
"/backups/new/partial",
SCHEMA_BACKUP_PARTIAL,
None,
True,
),
SERVICE_RESTORE_FULL: APIEndpointSettings(
"/backups/{slug}/restore/full",
SCHEMA_RESTORE_FULL,
None,
True,
),
SERVICE_RESTORE_PARTIAL: APIEndpointSettings(
"/backups/{slug}/restore/partial",
SCHEMA_RESTORE_PARTIAL,
None,
True,
),
}
HARDWARE_INTEGRATIONS = {
"green": "homeassistant_green",
"odroid-c2": "hardkernel",
@@ -397,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)
hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host)
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
supervisor_client = get_supervisor_client(hass)
try:
@@ -510,74 +325,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
async def async_service_handler(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
api_endpoint = MAP_SERVICE_API[service.service]
data = service.data.copy()
addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None)
slug = data.pop(ATTR_SLUG, None)
if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None):
data[ATTR_ADDONS] = addons
payload = None
# Pass data to Hass.io API
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
payload = data[ATTR_INPUT]
elif api_endpoint.pass_data:
payload = data
# Call API
# The exceptions are logged properly in hassio.send_command
with suppress(HassioAPIError):
await hassio.send_command(
api_endpoint.command.format(addon=addon, slug=slug),
payload=payload,
timeout=api_endpoint.timeout,
)
for service, settings in MAP_SERVICE_API.items():
hass.services.async_register(
DOMAIN, service, async_service_handler, schema=settings.schema
)
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)
# Register services
async_setup_services(hass, supervisor_client)
async def update_info_data(_: datetime | None = None) -> None:
"""Update last available supervisor information."""

View File

@@ -26,7 +26,7 @@ from aiohasupervisor.models import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .handler import HassioAPIError, get_supervisor_client
from .handler import get_supervisor_client
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
type _ReturnFuncType[_T, **_P, _R] = Callable[
@@ -36,18 +36,15 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[
def api_error[_AddonManagerT: AddonManager, **_P, _R](
error_message: str,
*,
expected_error_type: type[HassioAPIError | SupervisorError] | None = None,
) -> Callable[
[_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R]
]:
"""Handle HassioAPIError and raise a specific AddonError."""
error_type = expected_error_type or (HassioAPIError, SupervisorError)
"""Handle SupervisorError and raise a specific AddonError."""
def handle_hassio_api_error(
def handle_supervisor_error(
func: _FuncType[_AddonManagerT, _P, _R],
) -> _ReturnFuncType[_AddonManagerT, _P, _R]:
"""Handle a HassioAPIError."""
"""Handle a SupervisorError."""
@wraps(func)
async def wrapper(
@@ -56,7 +53,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
"""Wrap an add-on manager method."""
try:
return_value = await func(self, *args, **kwargs)
except error_type as err:
except SupervisorError as err:
raise AddonError(
f"{error_message.format(addon_name=self.addon_name)}: {err}"
) from err
@@ -65,7 +62,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
return wrapper
return handle_hassio_api_error
return handle_supervisor_error
@dataclass
@@ -128,10 +125,7 @@ class AddonManager:
)
)
@api_error(
"Failed to get the {addon_name} app discovery info",
expected_error_type=SupervisorError,
)
@api_error("Failed to get the {addon_name} app discovery info")
async def async_get_addon_discovery_info(self) -> dict:
"""Return add-on discovery info."""
discovery_info = next(
@@ -148,10 +142,7 @@ class AddonManager:
return discovery_info.config
@api_error(
"Failed to get the {addon_name} app info",
expected_error_type=SupervisorError,
)
@api_error("Failed to get the {addon_name} app info")
async def async_get_addon_info(self) -> AddonInfo:
"""Return and cache manager add-on info."""
addon_store_info = await self._supervisor_client.store.addon_info(
@@ -199,19 +190,14 @@ class AddonManager:
version=addon_info.version,
)
@api_error(
"Failed to set the {addon_name} app options",
expected_error_type=SupervisorError,
)
@api_error("Failed to set the {addon_name} app options")
async def async_set_addon_options(self, config: dict) -> None:
"""Set manager add-on options."""
await self._supervisor_client.addons.set_addon_options(
self.addon_slug, AddonsOptions(config=config)
)
@api_error(
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
)
@api_error("Failed to install the {addon_name} app")
async def async_install_addon(self) -> None:
"""Install the managed add-on."""
try:
@@ -221,10 +207,7 @@ class AddonManager:
f"{self.addon_name} app is not available: {err!s}"
) from None
@api_error(
"Failed to uninstall the {addon_name} app",
expected_error_type=SupervisorError,
)
@api_error("Failed to uninstall the {addon_name} app")
async def async_uninstall_addon(self) -> None:
"""Uninstall the managed add-on."""
await self._supervisor_client.addons.uninstall_addon(self.addon_slug)
@@ -259,31 +242,22 @@ class AddonManager:
self.addon_slug, StoreAddonUpdate(backup=False)
)
@api_error(
"Failed to start the {addon_name} app", expected_error_type=SupervisorError
)
@api_error("Failed to start the {addon_name} app")
async def async_start_addon(self) -> None:
"""Start the managed add-on."""
await self._supervisor_client.addons.start_addon(self.addon_slug)
@api_error(
"Failed to restart the {addon_name} app", expected_error_type=SupervisorError
)
@api_error("Failed to restart the {addon_name} app")
async def async_restart_addon(self) -> None:
"""Restart the managed add-on."""
await self._supervisor_client.addons.restart_addon(self.addon_slug)
@api_error(
"Failed to stop the {addon_name} app", expected_error_type=SupervisorError
)
@api_error("Failed to stop the {addon_name} app")
async def async_stop_addon(self) -> None:
"""Stop the managed add-on."""
await self._supervisor_client.addons.stop_addon(self.addon_slug)
@api_error(
"Failed to create a backup of the {addon_name} app",
expected_error_type=SupervisorError,
)
@api_error("Failed to create a backup of the {addon_name} app")
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
"""Create a partial backup of the managed add-on."""
if addon_info:

View File

@@ -0,0 +1,439 @@
"""Set up Supervisor services."""
from collections.abc import Awaitable, Callable
import json
import re
from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
PartialRestoreOptions,
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
selector,
)
from homeassistant.util.dt import now
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
SupervisorEntityModel,
)
from .coordinator import HassioDataUpdateCoordinator, get_addons_info
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_APP_START = "app_start"
SERVICE_APP_STOP = "app_stop"
SERVICE_APP_RESTART = "app_restart"
SERVICE_APP_STDIN = "app_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
value = VALID_ADDON_SLUG(value)
hass = async_get_hass_or_none()
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
raise vol.Invalid("Not a valid app slug")
return value
SCHEMA_NO_DATA = vol.Schema({})
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
}
)
SCHEMA_RESTORE_FULL = vol.Schema(
{
vol.Required(ATTR_SLUG): cv.slug,
vol.Optional(ATTR_PASSWORD): cv.string,
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
@callback
def async_setup_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register the Supervisor services."""
async_register_app_services(hass, supervisor_client)
async_register_host_services(hass, supervisor_client)
async_register_backup_restore_services(hass, supervisor_client)
async_register_network_storage_services(hass, supervisor_client)
@callback
def async_register_app_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register app services."""
simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
SERVICE_APP_START: ("start", supervisor_client.addons.start_addon),
SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon),
SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon),
}
async def async_simple_app_service_handler(service: ServiceCall) -> None:
"""Handles app services which only take a slug and have no response."""
action, api_method = simple_app_services[service.service]
app_slug = service.data[ATTR_APP]
try:
await api_method(app_slug)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to {action} app {app_slug}: {err}"
) from err
for service in simple_app_services:
hass.services.async_register(
DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
)
async def async_app_stdin_service_handler(service: ServiceCall) -> None:
"""Handles app stdin service."""
app_slug = service.data[ATTR_APP]
data: dict | str = service.data[ATTR_INPUT]
# For backwards compatibility the payload here must be valid json
# This is sensible when a dictionary is provided, it must be serialized
# If user provides a string though, we wrap it in quotes before encoding
# This is purely for legacy reasons, Supervisor has no json requirement
# Supervisor just hands the raw request as binary to the container
data = json.dumps(data)
payload = data.encode(encoding="utf-8")
try:
await supervisor_client.addons.write_addon_stdin(app_slug, payload)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to write stdin to app {app_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_APP_STDIN,
async_app_stdin_service_handler,
schema=SCHEMA_APP_STDIN,
)
# LEGACY - Register equivalent addon services for compatibility
simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon),
SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon),
SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon),
}
async def async_simple_addon_service_handler(service: ServiceCall) -> None:
"""Handles addon services which only take a slug and have no response."""
action, api_method = simple_addon_services[service.service]
addon_slug = service.data[ATTR_ADDON]
try:
await api_method(addon_slug)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to {action} app {addon_slug}: {err}"
) from err
for service in simple_addon_services:
hass.services.async_register(
DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON
)
async def async_addon_stdin_service_handler(service: ServiceCall) -> None:
"""Handles addon stdin service."""
addon_slug = service.data[ATTR_ADDON]
data: dict | str = service.data[ATTR_INPUT]
# See explanation for why we make strings into json in async_app_stdin_service_handler
data = json.dumps(data)
payload = data.encode(encoding="utf-8")
try:
await supervisor_client.addons.write_addon_stdin(addon_slug, payload)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to write stdin to app {addon_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_ADDON_STDIN,
async_addon_stdin_service_handler,
schema=SCHEMA_ADDON_STDIN,
)
@callback
def async_register_host_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register host services."""
simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = {
SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot),
SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown),
}
async def async_simple_host_service_handler(service: ServiceCall) -> None:
"""Handler for host services that take no input and return no response."""
action, api_method = simple_host_services[service.service]
try:
await api_method()
except SupervisorError as err:
raise HomeAssistantError(f"Failed to {action} the host: {err}") from err
for service in simple_host_services:
hass.services.async_register(
DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA
)
@callback
def async_register_backup_restore_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register backup and restore services."""
async def async_full_backup_service_handler(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create full backup service. Returns the new backup's ID."""
options = FullBackupOptions(**service.data)
try:
backup = await supervisor_client.backups.full_backup(options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to create full backup {options.name}: {err}"
) from err
return {"backup": backup.slug}
hass.services.async_register(
DOMAIN,
SERVICE_BACKUP_FULL,
async_full_backup_service_handler,
schema=SCHEMA_BACKUP_FULL,
supports_response=SupportsResponse.OPTIONAL,
)
async def async_partial_backup_service_handler(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialBackupOptions(**data)
try:
backup = await supervisor_client.backups.partial_backup(options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to create partial backup {options.name}: {err}"
) from err
return {"backup": backup.slug}
hass.services.async_register(
DOMAIN,
SERVICE_BACKUP_PARTIAL,
async_partial_backup_service_handler,
schema=SCHEMA_BACKUP_PARTIAL,
supports_response=SupportsResponse.OPTIONAL,
)
async def async_full_restore_service_handler(service: ServiceCall) -> None:
"""Handler for full restore service."""
backup_slug = service.data[ATTR_SLUG]
options: FullRestoreOptions | None = None
if ATTR_PASSWORD in service.data:
options = FullRestoreOptions(password=service.data[ATTR_PASSWORD])
try:
await supervisor_client.backups.full_restore(backup_slug, options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to full restore from backup {backup_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE_FULL,
async_full_restore_service_handler,
schema=SCHEMA_RESTORE_FULL,
)
async def async_partial_restore_service_handler(service: ServiceCall) -> None:
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialRestoreOptions(**data)
try:
await supervisor_client.backups.partial_restore(backup_slug, options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to partial restore from backup {backup_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE_PARTIAL,
async_partial_restore_service_handler,
schema=SCHEMA_RESTORE_PARTIAL,
)
@callback
def async_register_network_storage_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register network storage (or mount) services."""
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)

View File

@@ -18,7 +18,6 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from . import HassioAPIError
from .config import HassioUpdateParametersDict
from .const import (
ATTR_DATA,
@@ -40,6 +39,7 @@ from .const import (
WS_TYPE_SUBSCRIBE,
)
from .coordinator import get_addons_list
from .handler import HassioAPIError
from .update_helper import update_addon, update_core
SCHEMA_WEBSOCKET_EVENT = vol.Schema(

View File

@@ -4,14 +4,23 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HTML5 services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HTML5 from a config entry."""
hass.async_create_task(

View File

@@ -11,5 +11,18 @@ ATTR_VAPID_EMAIL = "vapid_email"
REGISTRATIONS_FILE = "html5_push_registrations.conf"
ATTR_ACTION = "action"
ATTR_ACTIONS = "actions"
ATTR_BADGE = "badge"
ATTR_DATA = "data"
ATTR_DIR = "dir"
ATTR_ICON = "icon"
ATTR_IMAGE = "image"
ATTR_LANG = "lang"
ATTR_RENOTIFY = "renotify"
ATTR_REQUIRE_INTERACTION = "require_interaction"
ATTR_SILENT = "silent"
ATTR_TAG = "tag"
ATTR_TIMESTAMP = "timestamp"
ATTR_TTL = "ttl"
ATTR_URGENCY = "urgency"
ATTR_VIBRATE = "vibrate"

View File

@@ -9,6 +9,9 @@
"services": {
"dismiss": {
"service": "mdi:bell-off"
},
"send_message": {
"service": "mdi:message-arrow-right"
}
}
}

View File

@@ -0,0 +1,31 @@
"""Issues for HTML5 integration."""
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util import slugify
from .const import DOMAIN
@callback
def deprecated_notify_action_call(
hass: HomeAssistant, target: list[str] | None
) -> None:
"""Deprecated action call."""
action = (
f"notify.html5_{slugify(target[0])}"
if target and len(target) == 1
else "notify.html5"
)
async_create_issue(
hass,
DOMAIN,
f"deprecated_notify_action_{action}",
breaks_in_ha_version="2026.11.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_notify_action",
translation_placeholders={"action": action},
)

View File

@@ -1,7 +1,7 @@
{
"domain": "html5",
"name": "HTML5 Push Notifications",
"codeowners": ["@alexyao2015"],
"codeowners": ["@alexyao2015", "@tr4nt0r"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/html5",

View File

@@ -47,7 +47,11 @@ from homeassistant.util.json import load_json_object
from .const import (
ATTR_ACTION,
ATTR_ACTIONS,
ATTR_REQUIRE_INTERACTION,
ATTR_TAG,
ATTR_TIMESTAMP,
ATTR_TTL,
ATTR_VAPID_EMAIL,
ATTR_VAPID_PRV_KEY,
ATTR_VAPID_PUB_KEY,
@@ -56,6 +60,7 @@ from .const import (
SERVICE_DISMISS,
)
from .entity import HTML5Entity, Registration
from .issue import deprecated_notify_action_call
_LOGGER = logging.getLogger(__name__)
@@ -69,13 +74,11 @@ ATTR_AUTH = "auth"
ATTR_P256DH = "p256dh"
ATTR_EXPIRATIONTIME = "expirationTime"
ATTR_ACTIONS = "actions"
ATTR_TYPE = "type"
ATTR_URL = "url"
ATTR_DISMISS = "dismiss"
ATTR_PRIORITY = "priority"
DEFAULT_PRIORITY = "normal"
ATTR_TTL = "ttl"
DEFAULT_TTL = 86400
DEFAULT_BADGE = "/static/images/notification-badge.png"
@@ -465,6 +468,9 @@ class HTML5NotificationService(BaseNotificationService):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET))
tag = str(uuid.uuid4())
payload: dict[str, Any] = {
"badge": DEFAULT_BADGE,
@@ -605,32 +611,53 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
_key = "device"
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to a device."""
timestamp = int(time.time())
tag = str(uuid.uuid4())
"""Send a message to a device via notify.send_message action."""
await self._webpush(
title=title or ATTR_TITLE_DEFAULT,
message=message,
badge=DEFAULT_BADGE,
icon=DEFAULT_ICON,
)
payload: dict[str, Any] = {
"badge": DEFAULT_BADGE,
"body": message,
"icon": DEFAULT_ICON,
ATTR_TAG: tag,
ATTR_TITLE: title or ATTR_TITLE_DEFAULT,
"timestamp": timestamp * 1000,
ATTR_DATA: {
ATTR_JWT: add_jwt(
timestamp,
self.target,
tag,
self.registration["subscription"]["keys"]["auth"],
)
},
}
async def send_push_notification(self, **kwargs: Any) -> None:
"""Send a message to a device via html5.send_message action."""
await self._webpush(**kwargs)
self._async_record_notification()
async def _webpush(
self,
message: str | None = None,
timestamp: datetime | None = None,
ttl: timedelta | None = None,
urgency: str | None = None,
**kwargs: Any,
) -> None:
"""Shared internal helper to push messages."""
payload: dict[str, Any] = kwargs
if message is not None:
payload["body"] = message
payload.setdefault(ATTR_TAG, str(uuid.uuid4()))
ts = int(timestamp.timestamp()) if timestamp else int(time.time())
payload[ATTR_TIMESTAMP] = ts * 1000
if ATTR_REQUIRE_INTERACTION in payload:
payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION)
payload.setdefault(ATTR_DATA, {})
payload[ATTR_DATA][ATTR_JWT] = add_jwt(
ts,
self.target,
payload[ATTR_TAG],
self.registration["subscription"]["keys"]["auth"],
)
endpoint = urlparse(self.registration["subscription"]["endpoint"])
vapid_claims = {
"sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}",
"aud": f"{endpoint.scheme}://{endpoint.netloc}",
"exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
"exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
}
try:
@@ -639,6 +666,8 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
json.dumps(payload),
self.config_entry.data[ATTR_VAPID_PRV_KEY],
vapid_claims,
ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL,
headers={"Urgency": urgency} if urgency else None,
aiohttp_session=self.session,
)
cast(ClientResponse, response).raise_for_status()

View File

@@ -0,0 +1,82 @@
"""Service registration for HTML5 integration."""
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
ATTR_MESSAGE,
ATTR_TITLE,
ATTR_TITLE_DEFAULT,
DOMAIN as NOTIFY_DOMAIN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import (
ATTR_ACTION,
ATTR_ACTIONS,
ATTR_BADGE,
ATTR_DIR,
ATTR_ICON,
ATTR_IMAGE,
ATTR_LANG,
ATTR_RENOTIFY,
ATTR_REQUIRE_INTERACTION,
ATTR_SILENT,
ATTR_TAG,
ATTR_TIMESTAMP,
ATTR_TTL,
ATTR_URGENCY,
ATTR_VIBRATE,
DOMAIN,
)
SERVICE_SEND_MESSAGE = "send_message"
SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema(
{
vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string,
vol.Optional(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}),
vol.Optional(ATTR_ICON): cv.string,
vol.Optional(ATTR_BADGE): cv.string,
vol.Optional(ATTR_IMAGE): cv.string,
vol.Optional(ATTR_TAG): cv.string,
vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All(
cv.ensure_list,
[vol.All(vol.Coerce(int), vol.Range(min=0))],
),
vol.Optional(ATTR_TIMESTAMP): cv.datetime,
vol.Optional(ATTR_LANG): cv.language,
vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean,
vol.Optional(ATTR_RENOTIFY): cv.boolean,
vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean,
vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}),
vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta),
vol.Optional(ATTR_ACTIONS): vol.All(
cv.ensure_list,
[
{
vol.Required(ATTR_ACTION): cv.string,
vol.Required(ATTR_TITLE): cv.string,
vol.Optional(ATTR_ICON): cv.string,
}
],
),
vol.Optional(ATTR_DATA): dict,
}
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for HTML5 integration."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SEND_MESSAGE,
entity_domain=NOTIFY_DOMAIN,
schema=SERVICE_SEND_MESSAGE_SCHEMA,
func="send_push_notification",
)

View File

@@ -8,3 +8,137 @@ dismiss:
example: '{ "tag": "tagname" }'
selector:
object:
send_message:
target:
entity:
domain: notify
integration: html5
fields:
title:
required: true
selector:
text:
example: Home Assistant
default: Home Assistant
message:
required: false
selector:
text:
multiline: true
example: Hello World
icon:
required: false
selector:
text:
type: url
example: /static/icons/favicon-192x192.png
badge:
required: false
selector:
text:
type: url
example: /static/images/notification-badge.png
image:
required: false
selector:
text:
type: url
example: /static/images/image.jpg
tag:
required: false
selector:
text:
example: message-group-1
actions:
selector:
object:
label_field: "action"
description_field: "title"
multiple: true
translation_key: actions
fields:
action:
required: true
selector:
text:
title:
required: true
selector:
text:
icon:
selector:
text:
type: url
example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]'
dir:
required: false
selector:
select:
options:
- auto
- ltr
- rtl
mode: dropdown
translation_key: dir
example: auto
renotify:
required: false
selector:
constant:
value: true
label: ""
example: true
silent:
required: false
selector:
constant:
value: true
label: ""
example: true
require_interaction:
required: false
selector:
constant:
value: true
label: ""
example: true
vibrate:
required: false
selector:
text:
multiple: true
type: number
suffix: ms
example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]"
lang:
required: false
selector:
language:
example: es-419
timestamp:
required: false
selector:
datetime:
example: "1970-01-01 00:00:00"
ttl:
required: false
selector:
duration:
enable_day: true
example: "{'days': 28}"
urgency:
required: false
selector:
select:
options:
- low
- normal
- high
mode: dropdown
translation_key: urgency
example: normal
data:
required: false
selector:
object:
example: "{'customKey': 'customValue'}"

View File

@@ -48,6 +48,44 @@
"message": "Sending notification to {target} failed due to a request error"
}
},
"issues": {
"deprecated_notify_action": {
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.",
"title": "Detected use of deprecated action {action}"
}
},
"selector": {
"actions": {
"fields": {
"action": {
"description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.",
"name": "Action identifier"
},
"icon": {
"description": "URL of an image displayed as the icon for this button.",
"name": "Icon"
},
"title": {
"description": "The label of the button displayed to the user.",
"name": "Title"
}
}
},
"dir": {
"options": {
"auto": "[%key:common::state::auto%]",
"ltr": "Left-to-right",
"rtl": "Right-to-left"
}
},
"urgency": {
"options": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"normal": "[%key:common::state::normal%]"
}
}
},
"services": {
"dismiss": {
"description": "Dismisses an HTML5 notification.",
@@ -62,6 +100,80 @@
}
},
"name": "Dismiss"
},
"send_message": {
"description": "Sends a message via HTML5 Push Notifications",
"fields": {
"actions": {
"description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.",
"name": "Action buttons"
},
"badge": {
"description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px",
"name": "Badge"
},
"data": {
"description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.",
"name": "Extra data"
},
"dir": {
"description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.",
"name": "Text direction"
},
"icon": {
"description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.",
"name": "Icon"
},
"image": {
"description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.",
"name": "Image"
},
"lang": {
"description": "The language of the notification's content.",
"name": "Language"
},
"message": {
"description": "The message body of the notification.",
"name": "Message"
},
"renotify": {
"description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.",
"name": "Renotify"
},
"require_interaction": {
"description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.",
"name": "Require interaction"
},
"silent": {
"description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.",
"name": "Silent"
},
"tag": {
"description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.",
"name": "Tag"
},
"timestamp": {
"description": "The timestamp of the notification. By default, it uses the time when the notification is sent.",
"name": "Timestamp"
},
"title": {
"description": "Title for your notification message.",
"name": "Title"
},
"ttl": {
"description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.",
"name": "Time to live"
},
"urgency": {
"description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.",
"name": "Urgency"
},
"vibrate": {
"description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.",
"name": "Vibration pattern"
}
},
"name": "Send message"
}
}
}

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from typing import Any
from huum.const import SaunaStatus
@@ -18,12 +17,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP, DOMAIN
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
from .entity import HuumBaseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
@@ -113,5 +110,7 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
try:
await self.coordinator.huum.turn_on(temperature)
except (ValueError, SafetyException) as err:
_LOGGER.error(str(err))
raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_turn_on",
) from err

View File

@@ -56,5 +56,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
return await self.huum.status()
except (Forbidden, NotAuthenticated) as err:
raise ConfigEntryAuthFailed(
"Could not log in to Huum with given credentials"
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err

View File

@@ -62,7 +62,7 @@ rules:
status: exempt
comment: All entities are core functionality.
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:

View File

@@ -45,5 +45,13 @@
"name": "[%key:component::sensor::entity_component::humidity::name%]"
}
}
},
"exceptions": {
"auth_failed": {
"message": "Could not log in to Huum with the given credentials."
},
"unable_to_turn_on": {
"message": "Unable to turn on the sauna."
}
}
}

View File

@@ -73,31 +73,45 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
except HTTPError as error:
raise UpdateFailed from error
try:
# Fetch last hour of data
for sensor in self.devices:
# Fetch last hour of data
for sensor in self.devices:
try:
data = await self.api.get_sensor_status(
sensor=sensor,
tz=self.hass.config.time_zone,
)
_LOGGER.debug("Got data: %s", data)
except HTTPError as error:
error_data = error.args[1] if len(error.args) > 1 else None
if (
isinstance(error_data, dict)
and error_data.get("error") == "no_readings"
):
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
_LOGGER.debug("Got data: %s", data)
sensor.data = data["data"]["current"]
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
except HTTPError as error:
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
current_data = data.get("data", {}).get("current")
if current_data is None:
sensor.data = None
_LOGGER.debug("No current data payload for %s", sensor.name)
continue
sensor.data = current_data
# Verify that we have permission to read the sensors
for sensor in self.devices:

View File

@@ -1 +1 @@
"""The linksys_smart component."""
"""The Linksys Smart Wi-Fi integration."""

View File

@@ -6,19 +6,14 @@ from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.helpers import is_valid_warning_department
from requests import RequestException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import (
COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN,
PLATFORMS,
)
from .const import DOMAIN, PLATFORMS
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceConfigEntry,
MeteoFranceData,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
@@ -26,7 +21,7 @@ from .coordinator import (
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MeteoFranceConfigEntry) -> bool:
"""Set up a Meteo-France account from a config entry."""
hass.data.setdefault(DOMAIN, {})
@@ -91,25 +86,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
hass.data[DOMAIN][entry.entry_id] = {
COORDINATOR_FORECAST: coordinator_forecast,
}
if coordinator_rain and coordinator_rain.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain
if coordinator_alert and coordinator_alert.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert
if coordinator_rain and not coordinator_rain.last_update_success:
coordinator_rain = None
if coordinator_alert and not coordinator_alert.last_update_success:
coordinator_alert = None
entry.runtime_data = MeteoFranceData(
forecast_coordinator=coordinator_forecast,
rain_coordinator=coordinator_rain,
alert_coordinator=coordinator_alert,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: MeteoFranceConfigEntry
) -> bool:
"""Unload a config entry."""
if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]:
department = hass.data[DOMAIN][entry.entry_id][
COORDINATOR_FORECAST
].data.position.get("dept")
if entry.runtime_data.alert_coordinator:
department = entry.runtime_data.forecast_coordinator.data.position.get("dept")
hass.data[DOMAIN][department] = False
_LOGGER.debug(
(
@@ -121,13 +118,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
return unload_ok
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def _async_update_listener(
hass: HomeAssistant, entry: MeteoFranceConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -23,9 +23,6 @@ from homeassistant.const import Platform
DOMAIN = "meteo_france"
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
COORDINATOR_FORECAST = "coordinator_forecast"
COORDINATOR_RAIN = "coordinator_rain"
COORDINATOR_ALERT = "coordinator_alert"
ATTRIBUTION = "Data provided by Météo-France"
MODEL = "Météo-France mobile API"
MANUFACTURER = "Météo-France"

View File

@@ -1,5 +1,8 @@
"""Support for Meteo-France weather data."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -13,6 +16,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type MeteoFranceConfigEntry = ConfigEntry[MeteoFranceData]
@dataclass
class MeteoFranceData:
"""Data for the Meteo-France integration."""
forecast_coordinator: MeteoFranceForecastUpdateCoordinator
rain_coordinator: MeteoFranceRainUpdateCoordinator | None
alert_coordinator: MeteoFranceAlertUpdateCoordinator | None
SCAN_INTERVAL_RAIN = timedelta(minutes=5)
SCAN_INTERVAL = timedelta(minutes=15)
@@ -20,12 +35,12 @@ SCAN_INTERVAL = timedelta(minutes=15)
class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
"""Coordinator for Meteo-France forecast data."""
config_entry: ConfigEntry
config_entry: MeteoFranceConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
@@ -50,12 +65,12 @@ class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
"""Coordinator for Meteo-France rain data."""
config_entry: ConfigEntry
config_entry: MeteoFranceConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
@@ -80,12 +95,12 @@ class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]):
"""Coordinator for Meteo-France alert data."""
config_entry: ConfigEntry
config_entry: MeteoFranceConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
client: MeteoFranceClient,
department: str,
) -> None:

View File

@@ -19,7 +19,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UV_INDEX,
@@ -41,18 +40,11 @@ from .const import (
ATTR_NEXT_RAIN_1_HOUR_FORECAST,
ATTR_NEXT_RAIN_DT_REF,
ATTRIBUTION,
COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN,
MANUFACTURER,
MODEL,
)
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
from .coordinator import MeteoFranceAlertUpdateCoordinator, MeteoFranceConfigEntry
@dataclass(frozen=True, kw_only=True)
@@ -188,20 +180,13 @@ SENSOR_TYPES_PROBABILITY: tuple[MeteoFranceSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France sensor platform."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[
COORDINATOR_FORECAST
]
coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get(
COORDINATOR_RAIN
)
coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get(
COORDINATOR_ALERT
)
coordinator_forecast = entry.runtime_data.forecast_coordinator
coordinator_rain = entry.runtime_data.rain_coordinator
coordinator_alert = entry.runtime_data.alert_coordinator
entities: list[MeteoFranceSensor[Any]] = [
MeteoFranceSensor(coordinator_forecast, description)

View File

@@ -18,7 +18,6 @@ from homeassistant.components.weather import (
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_MODE,
UnitOfPrecipitationDepth,
@@ -35,14 +34,13 @@ from homeassistant.util import dt as dt_util
from .const import (
ATTRIBUTION,
CONDITION_MAP,
COORDINATOR_FORECAST,
DOMAIN,
FORECAST_MODE_DAILY,
FORECAST_MODE_HOURLY,
MANUFACTURER,
MODEL,
)
from .coordinator import MeteoFranceForecastUpdateCoordinator
from .coordinator import MeteoFranceConfigEntry, MeteoFranceForecastUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -58,13 +56,11 @@ def format_condition(condition: str, force_day: bool = False) -> str:
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France weather platform."""
coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][COORDINATOR_FORECAST]
coordinator = entry.runtime_data.forecast_coordinator
async_add_entities(
[

View File

@@ -45,7 +45,7 @@ from homeassistant.components.webhook import (
async_register as webhook_register,
async_unregister as webhook_unregister,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, CONF_URL, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -80,7 +80,7 @@ from .const import (
WEB_HOOK_SENTINEL_KEY,
WEB_HOOK_SENTINEL_VALUE,
)
from .coordinator import MotionEyeUpdateCoordinator
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
@@ -134,7 +134,7 @@ def is_acceptable_camera(camera: dict[str, Any] | None) -> bool:
@callback
def listen_for_new_cameras(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
add_func: Callable,
) -> None:
"""Listen for new cameras."""
@@ -168,7 +168,7 @@ def _add_camera(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
client: MotionEyeClient,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
camera_id: int,
camera: dict[str, Any],
device_identifier: tuple[str, str],
@@ -274,9 +274,8 @@ def _add_camera(
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool:
"""Set up motionEye from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = create_motioneye_client(
entry.data[CONF_URL],
@@ -306,7 +305,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
coordinator = MotionEyeUpdateCoordinator(hass, entry, client)
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
current_cameras: set[tuple[str, str]] = set()
device_registry = dr.async_get(hass)
@@ -362,14 +361,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool:
"""Unload a config entry."""
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.client.async_client_close()
await entry.runtime_data.client.async_client_close()
return unload_ok
@@ -438,10 +436,14 @@ def _get_media_event_data(
event_file_type: int,
) -> dict[str, str]:
config_entry_id = next(iter(device.config_entries), None)
if not config_entry_id or config_entry_id not in hass.data[DOMAIN]:
if (
not config_entry_id
or not (entry := hass.config_entries.async_get_entry(config_entry_id))
or entry.state != ConfigEntryState.LOADED
):
return {}
coordinator = hass.data[DOMAIN][config_entry_id]
coordinator: MotionEyeUpdateCoordinator = entry.runtime_data
client = coordinator.client
for identifier in device.identifiers:

View File

@@ -30,7 +30,6 @@ from homeassistant.components.mjpeg import (
CONF_STILL_IMAGE_URL,
MjpegCamera,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_AUTHENTICATION,
CONF_NAME,
@@ -50,14 +49,13 @@ from .const import (
CONF_STREAM_URL_TEMPLATE,
CONF_SURVEILLANCE_PASSWORD,
CONF_SURVEILLANCE_USERNAME,
DOMAIN,
MOTIONEYE_MANUFACTURER,
SERVICE_ACTION,
SERVICE_SET_TEXT_OVERLAY,
SERVICE_SNAPSHOT,
TYPE_MOTIONEYE_MJPEG_CAMERA,
)
from .coordinator import MotionEyeUpdateCoordinator
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
PLATFORMS = [Platform.CAMERA]
@@ -92,11 +90,11 @@ SCHEMA_SERVICE_SET_TEXT = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -14,7 +14,6 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
@@ -39,6 +38,7 @@ from .const import (
DEFAULT_WEBHOOK_SET_OVERWRITE,
DOMAIN,
)
from .coordinator import MotionEyeConfigEntry
class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -180,7 +180,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: MotionEyeConfigEntry,
) -> MotionEyeOptionsFlow:
"""Get the Hyperion Options flow."""
return MotionEyeOptionsFlow()

View File

@@ -16,13 +16,16 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type MotionEyeConfigEntry = ConfigEntry[MotionEyeUpdateCoordinator]
class MotionEyeUpdateCoordinator(DataUpdateCoordinator[dict[str, Any] | None]):
"""Coordinator for motionEye data."""
config_entry: ConfigEntry
config_entry: MotionEyeConfigEntry
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, client: MotionEyeClient
self, hass: HomeAssistant, entry: MotionEyeConfigEntry, client: MotionEyeClient
) -> None:
"""Initialize the coordinator."""
super().__init__(

View File

@@ -17,12 +17,13 @@ from homeassistant.components.media_source import (
PlayMedia,
Unresolvable,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from . import get_media_url, split_motioneye_device_identifier
from .const import DOMAIN
from .coordinator import MotionEyeConfigEntry
MIME_TYPE_MAP = {
"movies": "video/mp4",
@@ -74,7 +75,7 @@ class MotionEyeMediaSource(MediaSource):
self._verify_kind_or_raise(kind)
url = get_media_url(
self.hass.data[DOMAIN][config.entry_id].client,
config.runtime_data.client,
self._get_camera_id_or_raise(config, device),
self._get_path_or_raise(path),
kind == "images",
@@ -120,10 +121,10 @@ class MotionEyeMediaSource(MediaSource):
return self._build_media_devices(config)
return self._build_media_configs()
def _get_config_or_raise(self, config_id: str) -> ConfigEntry:
def _get_config_or_raise(self, config_id: str) -> MotionEyeConfigEntry:
"""Get a config entry from a URL."""
entry = self.hass.config_entries.async_get_entry(config_id)
if not entry:
if not entry or entry.state != ConfigEntryState.LOADED:
raise MediaSourceError(f"Unable to find config entry with id: {config_id}")
return entry
@@ -154,7 +155,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _get_camera_id_or_raise(
cls, config: ConfigEntry, device: dr.DeviceEntry
cls, config: MotionEyeConfigEntry, device: dr.DeviceEntry
) -> int:
"""Get a config entry from a URL."""
for identifier in device.identifiers:
@@ -164,7 +165,7 @@ class MotionEyeMediaSource(MediaSource):
raise MediaSourceError(f"Could not find camera id for device id: {device.id}")
@classmethod
def _build_media_config(cls, config: ConfigEntry) -> BrowseMediaSource:
def _build_media_config(cls, config: MotionEyeConfigEntry) -> BrowseMediaSource:
return BrowseMediaSource(
domain=DOMAIN,
identifier=config.entry_id,
@@ -196,7 +197,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _build_media_device(
cls,
config: ConfigEntry,
config: MotionEyeConfigEntry,
device: dr.DeviceEntry,
full_title: bool = True,
) -> BrowseMediaSource:
@@ -211,7 +212,7 @@ class MotionEyeMediaSource(MediaSource):
children_media_class=MediaClass.DIRECTORY,
)
def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource:
def _build_media_devices(self, config: MotionEyeConfigEntry) -> BrowseMediaSource:
"""Build the media sources for device entries."""
device_registry = dr.async_get(self.hass)
devices = dr.async_entries_for_config_entry(device_registry, config.entry_id)
@@ -226,7 +227,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _build_media_kind(
cls,
config: ConfigEntry,
config: MotionEyeConfigEntry,
device: dr.DeviceEntry,
kind: str,
full_title: bool = True,
@@ -251,7 +252,7 @@ class MotionEyeMediaSource(MediaSource):
)
def _build_media_kinds(
self, config: ConfigEntry, device: dr.DeviceEntry
self, config: MotionEyeConfigEntry, device: dr.DeviceEntry
) -> BrowseMediaSource:
base = self._build_media_device(config, device)
base.children = [
@@ -262,7 +263,7 @@ class MotionEyeMediaSource(MediaSource):
async def _build_media_path(
self,
config: ConfigEntry,
config: MotionEyeConfigEntry,
device: dr.DeviceEntry,
kind: str,
path: str,
@@ -276,7 +277,7 @@ class MotionEyeMediaSource(MediaSource):
base.children = []
client = self.hass.data[DOMAIN][config.entry_id].client
client = config.runtime_data.client
camera_id = self._get_camera_id_or_raise(config, device)
if kind == "movies":
@@ -286,7 +287,7 @@ class MotionEyeMediaSource(MediaSource):
sub_dirs: set[str] = set()
parts = parsed_path.parts
media_list = resp.get(KEY_MEDIA_LIST, [])
media_list = resp.get(KEY_MEDIA_LIST, []) if resp else []
def get_media_sort_key(media: dict) -> str:
"""Get media sort key."""

View File

@@ -9,24 +9,23 @@ from motioneye_client.client import MotionEyeClient
from motioneye_client.const import KEY_ACTIONS
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import get_camera_from_cameras, listen_for_new_cameras
from .const import DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR
from .coordinator import MotionEyeUpdateCoordinator
from .const import TYPE_MOTIONEYE_ACTION_SENSOR
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -16,14 +16,13 @@ from motioneye_client.const import (
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import get_camera_from_cameras, listen_for_new_cameras
from .const import DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE
from .coordinator import MotionEyeUpdateCoordinator
from .const import TYPE_MOTIONEYE_SWITCH_BASE
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
MOTIONEYE_SWITCHES = [
@@ -68,11 +67,11 @@ MOTIONEYE_SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -311,6 +311,19 @@ def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Plat
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the actions and websocket API for the MQTT component."""
if config.get(DOMAIN) and not mqtt_config_entry_enabled(hass):
issue_registry = ir.async_get(hass)
issue_registry.async_get_or_create(
DOMAIN,
"yaml_setup_without_active_setup",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
"#configuration",
translation_key="yaml_setup_without_active_setup",
)
websocket_api.async_register_command(hass, websocket_subscribe)
websocket_api.async_register_command(hass, websocket_mqtt_info)

View File

@@ -18,6 +18,8 @@ ABBREVIATIONS = {
"bri_stat_t": "brightness_state_topic",
"bri_tpl": "brightness_template",
"bri_val_tpl": "brightness_value_template",
"cln_segmnts_cmd_t": "clean_segments_command_topic",
"cln_segmnts_cmd_tpl": "clean_segments_command_template",
"clr_temp_cmd_tpl": "color_temp_command_template",
"clrm_stat_t": "color_mode_state_topic",
"clrm_val_tpl": "color_mode_value_template",

View File

@@ -140,6 +140,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"entity_registry_enabled_default",
"extra_state_attributes",
"force_update",
"group_entities",
"icon",
"friendly_name",
"should_poll",

View File

@@ -1141,6 +1141,10 @@
}
},
"title": "MQTT device \"{name}\" subentry migration to YAML"
},
"yaml_setup_without_active_setup": {
"description": "Home Assistant detected manually configured MQTT items, but these items cannot be loaded because MQTT is not set up correctly. Make sure the MQTT broker is set up correctly, or remove the MQTT configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.",
"title": "MQTT is not set up correctly"
}
},
"options": {

View File

@@ -10,12 +10,13 @@ import voluptuous as vol
from homeassistant.components import vacuum
from homeassistant.components.vacuum import (
ENTITY_ID_FORMAT,
Segment,
StateVacuumEntity,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,13 +28,14 @@ from . import subscription
from .config import MQTT_BASE_SCHEMA
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import ReceiveMessage
from .models import MqttCommandTemplate, ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
PARALLEL_UPDATES = 0
FAN_SPEED = "fan_speed"
SEGMENTS = "segments"
STATE = "state"
STATE_IDLE = "idle"
@@ -52,6 +54,8 @@ POSSIBLE_STATES: dict[str, VacuumActivity] = {
STATE_CLEANING: VacuumActivity.CLEANING,
}
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic"
CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template"
CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
CONF_PAYLOAD_TURN_ON = "payload_turn_on"
CONF_PAYLOAD_TURN_OFF = "payload_turn_off"
@@ -137,8 +141,22 @@ MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset(
MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/"
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
def validate_clean_area_config(config: ConfigType) -> ConfigType:
"""Validate clean area configuration."""
if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config:
return config
if not config.get(CONF_UNIQUE_ID):
raise vol.Invalid(
f"Option `{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` requires `{CONF_UNIQUE_ID}` to be configured"
)
return config
_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend(
{
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
@@ -164,7 +182,10 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA)
PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config)
DISCOVERY_SCHEMA = vol.All(
_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config
)
async def async_setup_entry(
@@ -191,9 +212,11 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
_entity_id_format = ENTITY_ID_FORMAT
_attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED
_segments: list[Segment]
_command_topic: str | None
_set_fan_speed_topic: str | None
_send_command_topic: str | None
_clean_segments_command_topic: str | None = None
_payloads: dict[str, str | None]
def __init__(
@@ -229,6 +252,14 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
self._attr_supported_features = _strings_to_services(
supported_feature_strings, STRING_TO_SERVICE
)
self._clean_segments_command_topic = config.get(
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
)
self._clean_segments_command_template = MqttCommandTemplate(
config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE),
entity=self,
).async_render
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
self._command_topic = config.get(CONF_COMMAND_TOPIC)
self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC)
@@ -262,6 +293,24 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None
)
del payload[STATE]
if (
(segments_payload := payload.pop(SEGMENTS, None))
and self._clean_segments_command_topic is not None
and isinstance(segments_payload, dict)
and (
segments := [
Segment(id=segment_id, name=str(segment_name))
for segment_id, segment_name in segments_payload.items()
]
)
):
self._segments = segments
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
if (last_seen := self.last_seen_segments) is not None and {
s.id: s for s in last_seen
} != {s.id: s for s in self._segments}:
self.async_create_segments_issue()
self._update_state_attributes(payload)
@callback
@@ -277,6 +326,20 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
"""(Re)Subscribe to topics."""
subscription.async_subscribe_topics_internal(self.hass, self._sub_state)
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Perform an area clean."""
assert self._clean_segments_command_topic is not None
await self.async_publish_with_config(
self._clean_segments_command_topic,
self._clean_segments_command_template(
json_dumps(segment_ids), {"value": segment_ids}
),
)
async def async_get_segments(self) -> list[Segment]:
"""Return the available segments."""
return self._segments
async def _async_publish_command(self, feature: VacuumEntityFeature) -> None:
"""Publish a command."""
if self._command_topic is None:

View File

@@ -24,12 +24,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from homeassistant.helpers.typing import ConfigType
from . import api
from .const import DOMAIN, NEATO_LOGIN
from .const import DOMAIN
from .hub import NeatoHub
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
type NeatoConfigEntry = ConfigEntry[NeatoHub]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BUTTON,
@@ -46,9 +48,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool:
"""Set up config entry."""
hass.data.setdefault(DOMAIN, {})
if CONF_TOKEN not in entry.data:
raise ConfigEntryAuthFailed
@@ -69,7 +70,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from ex
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
hass.data[DOMAIN][entry.entry_id] = neato_session
hub = NeatoHub(hass, Account(neato_session))
await hub.async_update_entry_unique_id(entry)
@@ -80,17 +80,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Failed to connect to Neato API")
raise ConfigEntryNotReady from ex
hass.data[NEATO_LOGIN] = hub
entry.runtime_data = hub
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool:
"""Unload config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -5,22 +5,21 @@ from __future__ import annotations
from pybotvac import Robot
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_ROBOTS
from . import NeatoConfigEntry
from .entity import NeatoEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato button from config entry."""
entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]]
entities = [NeatoDismissAlertButton(robot) for robot in entry.runtime_data.robots]
async_add_entities(entities, True)

View File

@@ -11,11 +11,11 @@ from pybotvac.robot import Robot
from urllib3.response import HTTPResponse
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -27,15 +27,14 @@ ATTR_GENERATED_AT = "generated_at"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato camera with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
hub = entry.runtime_data
dev = [
NeatoCleaningMap(neato, robot, mapdata)
for robot in hass.data[NEATO_ROBOTS]
NeatoCleaningMap(hub, robot, hub.map_data)
for robot in hub.robots
if "maps" in robot.traits
]
@@ -51,9 +50,7 @@ class NeatoCleaningMap(NeatoEntity, Camera):
_attr_translation_key = "cleaning_map"
def __init__(
self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None
) -> None:
def __init__(self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any]) -> None:
"""Initialize Neato cleaning map."""
super().__init__(robot)
Camera.__init__(self)

View File

@@ -3,10 +3,6 @@
DOMAIN = "neato"
CONF_VENDOR = "vendor"
NEATO_LOGIN = "neato_login"
NEATO_MAP_DATA = "neato_map_data"
NEATO_PERSISTENT_MAPS = "neato_persistent_maps"
NEATO_ROBOTS = "neato_robots"
SCAN_INTERVAL_MINUTES = 1

View File

@@ -1,7 +1,10 @@
"""Support for Neato botvac connected vacuum cleaners."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pybotvac import Account
from urllib3.response import HTTPResponse
@@ -10,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.util import Throttle
from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS
_LOGGER = logging.getLogger(__name__)
@@ -22,14 +23,17 @@ class NeatoHub:
"""Initialize the Neato hub."""
self._hass = hass
self.my_neato: Account = neato
self.robots: set[Any] = set()
self.persistent_maps: dict[str, Any] = {}
self.map_data: dict[str, Any] = {}
@Throttle(timedelta(minutes=1))
def update_robots(self) -> None:
"""Update the robot states."""
_LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS))
self._hass.data[NEATO_ROBOTS] = self.my_neato.robots
self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps
self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps
_LOGGER.debug("Running HUB.update_robots %s", self.robots)
self.robots = self.my_neato.robots
self.persistent_maps = self.my_neato.persistent_maps
self.map_data = self.my_neato.maps
def download_map(self, url: str) -> HTTPResponse:
"""Download a new map image."""

View File

@@ -10,12 +10,12 @@ from pybotvac.exceptions import NeatoRobotException
from pybotvac.robot import Robot
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -28,12 +28,12 @@ BATTERY = "Battery"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Neato sensor using config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
dev = [NeatoSensor(neato, robot) for robot in hass.data[NEATO_ROBOTS]]
hub = entry.runtime_data
dev = [NeatoSensor(hub, robot) for robot in hub.robots]
if not dev:
return

View File

@@ -10,12 +10,12 @@ from pybotvac.exceptions import NeatoRobotException
from pybotvac.robot import Robot
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -30,14 +30,14 @@ SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato switch with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
hub = entry.runtime_data
dev = [
NeatoConnectedSwitch(neato, robot, type_name)
for robot in hass.data[NEATO_ROBOTS]
NeatoConnectedSwitch(hub, robot, type_name)
for robot in hub.robots
for type_name in SWITCH_TYPES
]

View File

@@ -15,22 +15,12 @@ from homeassistant.components.vacuum import (
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ACTION,
ALERTS,
ERRORS,
MODE,
NEATO_LOGIN,
NEATO_MAP_DATA,
NEATO_PERSISTENT_MAPS,
NEATO_ROBOTS,
SCAN_INTERVAL_MINUTES,
)
from . import NeatoConfigEntry
from .const import ACTION, ALERTS, ERRORS, MODE, SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -52,16 +42,16 @@ ATTR_LAUNCHED_FROM = "launched_from"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato vacuum with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS)
hub = entry.runtime_data
dev = [
NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps)
for robot in hass.data[NEATO_ROBOTS]
NeatoConnectedVacuum(
hub, robot, hub.map_data or None, hub.persistent_maps or None
)
for robot in hub.robots
]
if not dev:

View File

@@ -1,7 +1,7 @@
"""The NFAndroidTV integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
@@ -22,8 +22,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NFAndroidTV from a config entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data[CONF_HOST]
hass.async_create_task(
discovery.async_load_platform(

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aiontfy"],
"quality_scale": "platinum",
"requirements": ["aiontfy==0.8.3"]
"requirements": ["aiontfy==0.8.4"]
}

View File

@@ -6,13 +6,12 @@ import logging
import nuheat
import requests
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS
from .coordinator import NuHeatCoordinator
from .const import CONF_SERIAL_NUMBER, PLATFORMS
from .coordinator import NuHeatConfigEntry, NuHeatCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -23,7 +22,7 @@ def _get_thermostat(api: nuheat.NuHeat, serial_number: str) -> nuheat.NuHeatTher
return api.get_thermostat(serial_number)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: NuHeatConfigEntry) -> bool:
"""Set up NuHeat from a config entry."""
conf = entry.data
@@ -52,20 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("Failed to login to nuheat: %s", ex)
return False
coordinator = NuHeatCoordinator(hass, entry, thermostat)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = (thermostat, coordinator)
entry.runtime_data = NuHeatCoordinator(hass, entry, thermostat)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: NuHeatConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -18,7 +18,6 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import event as event_helper
@@ -27,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, NUHEAT_API_STATE_SHIFT_DELAY
from .coordinator import NuHeatCoordinator
from .coordinator import NuHeatConfigEntry, NuHeatCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -55,14 +54,15 @@ SCHEDULE_MODE_TO_PRESET_MODE_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: NuHeatConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the NuHeat thermostat(s)."""
thermostat, coordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
temperature_unit = hass.config.units.temperature_unit
entity = NuHeatThermostat(coordinator, thermostat, temperature_unit)
entity = NuHeatThermostat(coordinator, coordinator.thermostat, temperature_unit)
# No longer need a service as set_hvac_mode to auto does this
# since climate 1.0 has been implemented

View File

@@ -16,15 +16,18 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=5)
type NuHeatConfigEntry = ConfigEntry[NuHeatCoordinator]
class NuHeatCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for NuHeat thermostat data."""
config_entry: ConfigEntry
config_entry: NuHeatConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
entry: NuHeatConfigEntry,
thermostat: nuheat.NuHeatThermostat,
) -> None:
"""Initialize the coordinator."""

View File

@@ -1 +1 @@
"""The ohmconnect component."""
"""The OhmConnect integration."""

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.httpx_client import get_async_client
from .const import LOGGER
from .const import CONF_WEB_SEARCH, LOGGER
PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION]
@@ -56,3 +56,32 @@ async def _async_update_listener(
async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool:
"""Unload OpenRouter."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, entry: OpenRouterConfigEntry
) -> bool:
"""Migrate config entry."""
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version > 1 or (entry.version == 1 and entry.minor_version > 2):
return False
if entry.version == 1 and entry.minor_version < 2:
for subentry in entry.subentries.values():
if CONF_WEB_SEARCH in subentry.data:
continue
updated_data = {**subentry.data, CONF_WEB_SEARCH: False}
hass.config_entries.async_update_subentry(
entry, subentry, data=updated_data
)
hass.config_entries.async_update_entry(entry, minor_version=2)
LOGGER.info(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
return True

View File

@@ -27,6 +27,7 @@ from homeassistant.core import callback
from homeassistant.helpers import llm
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
BooleanSelector,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
@@ -34,7 +35,12 @@ from homeassistant.helpers.selector import (
TemplateSelector,
)
from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
from .const import (
CONF_PROMPT,
CONF_WEB_SEARCH,
DOMAIN,
RECOMMENDED_CONVERSATION_OPTIONS,
)
_LOGGER = logging.getLogger(__name__)
@@ -43,6 +49,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenRouter."""
VERSION = 1
MINOR_VERSION = 2
@classmethod
@callback
@@ -66,7 +73,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_API_KEY], async_get_clientsession(self.hass)
)
try:
await client.get_key_data()
key_data = await client.get_key_data()
except OpenRouterError:
errors["base"] = "cannot_connect"
except Exception:
@@ -74,7 +81,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="OpenRouter",
title=key_data.label,
data=user_input,
)
return self.async_show_form(
@@ -106,7 +113,7 @@ class OpenRouterSubentryFlowHandler(ConfigSubentryFlow):
class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
"""Handle subentry flow."""
"""Handle conversation subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
@@ -208,13 +215,20 @@ class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
vol.Optional(
CONF_WEB_SEARCH,
default=self.options.get(
CONF_WEB_SEARCH,
RECOMMENDED_CONVERSATION_OPTIONS[CONF_WEB_SEARCH],
),
): BooleanSelector(),
}
),
)
class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler):
"""Handle subentry flow."""
"""Handle AI task subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""

View File

@@ -9,9 +9,13 @@ DOMAIN = "open_router"
LOGGER = logging.getLogger(__package__)
CONF_RECOMMENDED = "recommended"
CONF_WEB_SEARCH = "web_search"
RECOMMENDED_WEB_SEARCH = False
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
CONF_WEB_SEARCH: RECOMMENDED_WEB_SEARCH,
}

View File

@@ -37,9 +37,8 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.json import json_dumps
from . import OpenRouterConfigEntry
from .const import DOMAIN, LOGGER
from .const import CONF_WEB_SEARCH, DOMAIN, LOGGER
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
@@ -52,7 +51,6 @@ def _adjust_schema(schema: dict[str, Any]) -> None:
if "required" not in schema:
schema["required"] = []
# Ensure all properties are required
for prop, prop_info in schema["properties"].items():
_adjust_schema(prop_info)
if prop not in schema["required"]:
@@ -233,14 +231,20 @@ class OpenRouterEntity(Entity):
) -> None:
"""Generate an answer for the chat log."""
model = self.model
if self.subentry.data.get(CONF_WEB_SEARCH):
model = f"{model}:online"
extra_body: dict[str, Any] = {"require_parameters": True}
model_args = {
"model": self.model,
"model": model,
"user": chat_log.conversation_id,
"extra_headers": {
"X-Title": "Home Assistant",
"HTTP-Referer": "https://www.home-assistant.io/integrations/open_router",
},
"extra_body": {"require_parameters": True},
"extra_body": extra_body,
}
tools: list[ChatCompletionFunctionToolParam] | None = None
@@ -296,6 +300,10 @@ class OpenRouterEntity(Entity):
LOGGER.error("Error talking to API: %s", err)
raise HomeAssistantError("Error talking to API") from err
if not result.choices:
LOGGER.error("API returned empty choices")
raise HomeAssistantError("API returned empty response")
result_message = result.choices[0].message
model_args["messages"].extend(

View File

@@ -2,7 +2,7 @@
"domain": "open_router",
"name": "OpenRouter",
"after_dependencies": ["assist_pipeline", "intent"],
"codeowners": ["@joostlek"],
"codeowners": ["@joostlek", "@ab3lson"],
"config_flow": true,
"dependencies": ["conversation"],
"documentation": "https://www.home-assistant.io/integrations/open_router",

View File

@@ -23,19 +23,18 @@
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"entry_type": "AI task",
"initiate_flow": {
"reconfigure": "Reconfigure AI task",
"user": "Add AI task"
},
"step": {
"init": {
"data": {
"model": "[%key:component::open_router::config_subentries::conversation::step::init::data::model%]"
},
"data_description": {
"model": "The model to use for the AI task"
"model": "[%key:common::generic::model%]"
},
"description": "Configure the AI task"
}
@@ -45,22 +44,27 @@
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"entry_not_loaded": "[%key:component::open_router::config_subentries::ai_task_data::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"entry_type": "Conversation agent",
"initiate_flow": {
"reconfigure": "Reconfigure conversation agent",
"user": "Add conversation agent"
},
"step": {
"init": {
"data": {
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"model": "Model",
"prompt": "[%key:common::config_flow::data::prompt%]"
"model": "[%key:common::generic::model%]",
"prompt": "[%key:common::config_flow::data::prompt%]",
"web_search": "Enable web search"
},
"data_description": {
"llm_hass_api": "Select which tools the model can use to interact with your devices and entities.",
"model": "The model to use for the conversation agent",
"prompt": "Instruct how the LLM should respond. This can be a template."
"prompt": "Instruct how the LLM should respond. This can be a template.",
"web_search": "Allow the model to search the web for answers"
},
"description": "Configure the conversation agent"
}

View File

@@ -346,7 +346,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
id=event.item.id,
tool_name="web_search_call",
tool_args={
"action": event.item.action.to_dict(),
"action": event.item.action.to_dict()
if event.item.action
else None,
},
external=True,
)
@@ -360,6 +362,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
}
last_role = "tool_result"
elif isinstance(event.item, ImageGenerationCall):
if last_summary_index is not None:
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
yield {"native": event.item}
last_summary_index = -1 # Trigger new assistant message on next turn
elif isinstance(event, ResponseTextDeltaEvent):

View File

@@ -1 +1 @@
"""The opple component."""
"""The Opple integration."""

View File

@@ -1 +1 @@
"""The panasonic_bluray component."""
"""The Panasonic Blu-Ray Player integration."""

View File

@@ -39,7 +39,7 @@ def setup_platform(
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Panasonic Blu-ray platform."""
"""Set up the Panasonic Blu-ray media player platform."""
conf = discovery_info or config
# Register configured device with Home Assistant.
@@ -59,7 +59,7 @@ class PanasonicBluRay(MediaPlayerEntity):
)
def __init__(self, ip, name):
"""Initialize the Panasonic Blue-ray device."""
"""Initialize the Panasonic Blu-ray device."""
self._device = PanasonicBD(ip)
self._attr_name = name
self._attr_state = MediaPlayerState.OFF

View File

@@ -17,7 +17,7 @@ ac_start:
when:
example: "2020-05-01T17:45:00"
selector:
text:
datetime:
ac_cancel:
fields:

View File

@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
@@ -163,7 +164,16 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]):
if (
reconfigure_entry.state is not ConfigEntryState.LOADED
or reconfigure_entry.data != user_input
):
if not await self.test_connection(
user_input[CONF_HOST], user_input[CONF_PORT]
):
errors["base"] = "cannot_connect"
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
@@ -171,11 +181,8 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: user_input[CONF_PORT],
},
title=user_input[CONF_HOST],
reload_even_if_entry_is_unchanged=False,
)
errors["base"] = "cannot_connect"
suggested_values: dict[str, Any] = {
**reconfigure_entry.data,
**(user_input or {}),

View File

@@ -1 +1 @@
"""The sky_hub component."""
"""The Sky Hub integration."""

View File

@@ -34,15 +34,13 @@ class SynologyDSMbuttonDescription(ButtonEntityDescription):
BUTTONS: Final = [
SynologyDSMbuttonDescription(
key="reboot",
name="Reboot",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action=lambda syno_api: syno_api.async_reboot,
),
SynologyDSMbuttonDescription(
key="shutdown",
name="Shutdown",
icon="mdi:power",
translation_key="shutdown",
entity_category=EntityCategory.CONFIG,
press_action=lambda syno_api: syno_api.async_shutdown,
),
@@ -63,6 +61,7 @@ class SynologyDSMButton(ButtonEntity):
"""Defines a Synology DSM button."""
entity_description: SynologyDSMbuttonDescription
_attr_has_entity_name = True
def __init__(
self,
@@ -75,7 +74,6 @@ class SynologyDSMButton(ButtonEntity):
if TYPE_CHECKING:
assert api.network is not None
assert api.information is not None
self._attr_name = f"{api.network.hostname} {description.name}"
self._attr_unique_id = f"{api.information.serial}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, api.information.serial)}

View File

@@ -1,5 +1,10 @@
{
"entity": {
"button": {
"shutdown": {
"default": "mdi:power"
}
},
"sensor": {
"cpu_15min_load": {
"default": "mdi:chip"

View File

@@ -76,6 +76,11 @@
"name": "Security status"
}
},
"button": {
"shutdown": {
"name": "Shutdown"
}
},
"sensor": {
"cpu_15min_load": {
"name": "CPU load average (15 min)"

View File

@@ -37,8 +37,6 @@ async def async_setup_bot_platform(
pushbot = PushBot(hass, bot, config, secret_token)
await pushbot.start_application()
webhook_registered = await pushbot.register_webhook()
if not webhook_registered:
raise RuntimeError("Failed to register webhook with Telegram")
@@ -49,6 +47,8 @@ async def async_setup_bot_platform(
get_base_url(bot),
)
await pushbot.start_application()
hass.http.register_view(
PushBotView(
hass,

View File

@@ -16,7 +16,7 @@ from tesla_fleet_api.exceptions import (
from tesla_fleet_api.tesla import VehicleFleet
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
@@ -121,7 +121,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
)
raise ConfigEntryAuthFailed from e
access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]
oauth_session = OAuth2Session(hass, entry, implementation)
try:
await oauth_session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed from err
except OAuth2TokenRequestError as err:
raise ConfigEntryNotReady from err
access_token = oauth_session.token[CONF_ACCESS_TOKEN]
session = async_get_clientsession(hass)
token = jwt.decode(access_token, options={"verify_signature": False})
@@ -129,8 +137,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
region_code = token["ou_code"].lower()
region = region_code if is_valid_region(region_code) else None
oauth_session = OAuth2Session(hass, entry, implementation)
async def _get_access_token() -> str:
await oauth_session.async_ensure_token_valid()
token: str = oauth_session.token[CONF_ACCESS_TOKEN]

View File

@@ -60,14 +60,14 @@
},
"services": {
"set_value": {
"description": "Sets the value.",
"description": "Sets the value of a text entity.",
"fields": {
"value": {
"description": "Enter your text.",
"name": "Value"
}
},
"name": "Set value"
"name": "Set text value"
}
},
"title": "Text",

View File

@@ -1 +1 @@
"""The thomson component."""
"""The Thomson integration."""

View File

@@ -4,14 +4,13 @@ from __future__ import annotations
from typing import Any
from tuya_device_handlers.device_wrapper import DEVICE_WARNINGS
from tuya_device_handlers.helpers.diagnostics import customer_device_as_dict
from tuya_sharing import CustomerDevice
from homeassistant.components.diagnostics import REDACTED
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.util import dt as dt_util
from . import TuyaConfigEntry
from .const import DOMAIN, DPCode
@@ -79,52 +78,13 @@ def _async_device_as_dict(
) -> dict[str, Any]:
"""Represent a Tuya device as a dictionary."""
# Base device information, without sensitive information.
data = {
"id": device.id,
"name": device.name,
"category": device.category,
"product_id": device.product_id,
"product_name": device.product_name,
"online": device.online,
"sub": device.sub,
"time_zone": device.time_zone,
"active_time": dt_util.utc_from_timestamp(device.active_time).isoformat(),
"create_time": dt_util.utc_from_timestamp(device.create_time).isoformat(),
"update_time": dt_util.utc_from_timestamp(device.update_time).isoformat(),
"function": {},
"status_range": {},
"status": {},
"home_assistant": {},
"set_up": device.set_up,
"support_local": device.support_local,
"local_strategy": device.local_strategy,
"warnings": DEVICE_WARNINGS.get(device.id),
}
# Base device information
data = customer_device_as_dict(device)
# Gather Tuya states
for dpcode, value in device.status.items():
# These statuses may contain sensitive information, redact these..
if dpcode in _REDACTED_DPCODES:
data["status"][dpcode] = REDACTED
continue
data["status"][dpcode] = value
# Gather Tuya functions
for function in device.function.values():
data["function"][function.code] = {
"type": function.type,
"value": function.values,
}
# Gather Tuya status ranges
for status_range in device.status_range.values():
data["status_range"][status_range.code] = {
"type": status_range.type,
"value": status_range.values,
"report_type": status_range.report_type,
}
# Redact sensitive information.
for key in data["status"]:
if key in _REDACTED_DPCODES:
data["status"][key] = REDACTED
# Gather information how this Tuya device is represented in Home Assistant
device_registry = dr.async_get(hass)

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