mirror of
https://github.com/home-assistant/core.git
synced 2025-09-30 15:29:30 +00:00
Compare commits
2 Commits
mqtt-subsc
...
media-sour
Author | SHA1 | Date | |
---|---|---|---|
![]() |
38869c37e9 | ||
![]() |
29f650565b |
@@ -1,77 +0,0 @@
|
||||
---
|
||||
name: quality-scale-rule-verifier
|
||||
description: |
|
||||
Use this agent when you need to verify that a Home Assistant integration follows a specific quality scale rule. This includes checking if the integration implements required patterns, configurations, or code structures defined by the quality scale system.
|
||||
|
||||
<example>
|
||||
Context: The user wants to verify if an integration follows a specific quality scale rule.
|
||||
user: "Check if the peblar integration follows the config-flow rule"
|
||||
assistant: "I'll use the quality scale rule verifier to check if the peblar integration properly implements the config-flow rule."
|
||||
<commentary>
|
||||
Since the user is asking to verify a quality scale rule implementation, use the quality-scale-rule-verifier agent.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
Context: The user is reviewing if an integration reaches a specific quality scale level.
|
||||
user: "Verify that this integration reaches the bronze quality scale"
|
||||
assistant: "Let me use the quality scale rule verifier to check the bronze quality scale implementation."
|
||||
<commentary>
|
||||
The user wants to verify the integration has reached a certain quality level, so use multiple quality-scale-rule-verifier agents to verify each bronze rule.
|
||||
</commentary>
|
||||
</example>
|
||||
model: inherit
|
||||
color: yellow
|
||||
tools: Read, Bash, Grep, Glob, WebFetch
|
||||
---
|
||||
|
||||
You are an expert Home Assistant integration quality scale auditor specializing in verifying compliance with specific quality scale rules. You have deep knowledge of Home Assistant's architecture, best practices, and the quality scale system that ensures integration consistency and reliability.
|
||||
|
||||
You will verify if an integration follows a specific quality scale rule by:
|
||||
|
||||
1. **Fetching Rule Documentation**: Retrieve the official rule documentation from:
|
||||
`https://raw.githubusercontent.com/home-assistant/developers.home-assistant/refs/heads/master/docs/core/integration-quality-scale/rules/{rule_name}.md`
|
||||
where `{rule_name}` is the rule identifier (e.g., 'config-flow', 'entity-unique-id', 'parallel-updates')
|
||||
|
||||
2. **Understanding Rule Requirements**: Parse the rule documentation to identify:
|
||||
- Core requirements and mandatory implementations
|
||||
- Specific code patterns or configurations required
|
||||
- Common violations and anti-patterns
|
||||
- Exemption criteria (when a rule might not apply)
|
||||
- The quality tier this rule belongs to (Bronze, Silver, Gold, Platinum)
|
||||
|
||||
3. **Analyzing Integration Code**: Examine the integration's codebase at `homeassistant/components/<integration domain>` focusing on:
|
||||
- `manifest.json` for quality scale declaration and configuration
|
||||
- `quality_scale.yaml` for rule status (done, todo, exempt)
|
||||
- Relevant Python modules based on the rule requirements
|
||||
- Configuration files and service definitions as needed
|
||||
|
||||
4. **Verification Process**:
|
||||
- Check if the rule is marked as 'done', 'todo', or 'exempt' in quality_scale.yaml
|
||||
- If marked 'exempt', verify the exemption reason is valid
|
||||
- If marked 'done', verify the actual implementation matches requirements
|
||||
- Identify specific files and code sections that demonstrate compliance or violations
|
||||
- Consider the integration's declared quality tier when applying rules
|
||||
- To fetch the integration docs, use WebFetch to fetch from `https://raw.githubusercontent.com/home-assistant/home-assistant.io/refs/heads/current/source/_integrations/<integration domain>.markdown`
|
||||
- To fetch information about a PyPI package, use the URL `https://pypi.org/pypi/<package>/json`
|
||||
|
||||
5. **Reporting Findings**: Provide a comprehensive verification report that includes:
|
||||
- **Rule Summary**: Brief description of what the rule requires
|
||||
- **Compliance Status**: Clear pass/fail/exempt determination
|
||||
- **Evidence**: Specific code examples showing compliance or violations
|
||||
- **Issues Found**: Detailed list of any non-compliance issues with file locations
|
||||
- **Recommendations**: Actionable steps to achieve compliance if needed
|
||||
- **Exemption Analysis**: If applicable, whether the exemption is justified
|
||||
|
||||
When examining code, you will:
|
||||
- Look for exact implementation patterns specified in the rule
|
||||
- Verify all required components are present and properly configured
|
||||
- Check for common mistakes and anti-patterns
|
||||
- Consider edge cases and error handling requirements
|
||||
- Validate that implementations follow Home Assistant conventions
|
||||
|
||||
You will be thorough but focused, examining only the aspects relevant to the specific rule being verified. You will provide clear, actionable feedback that helps developers understand both what needs to be fixed and why it matters for integration quality.
|
||||
|
||||
If you cannot access the rule documentation or find the integration code, clearly state what information is missing and what you would need to complete the verification.
|
||||
|
||||
Remember that quality scale rules are cumulative - Bronze rules apply to all integrations with a quality scale, Silver rules apply to Silver+ integrations, and so on. Always consider the integration's target quality level when determining which rules should be enforced.
|
@@ -58,7 +58,6 @@ base_platforms: &base_platforms
|
||||
# Extra components that trigger the full suite
|
||||
components: &components
|
||||
- homeassistant/components/alexa/**
|
||||
- homeassistant/components/analytics/**
|
||||
- homeassistant/components/application_credentials/**
|
||||
- homeassistant/components/assist_pipeline/**
|
||||
- homeassistant/components/auth/**
|
||||
|
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -55,12 +55,8 @@
|
||||
creating the PR. If you're unsure about any of them, don't hesitate to ask.
|
||||
We're here to help! This is simply a reminder of what we are going to look
|
||||
for before merging your code.
|
||||
|
||||
AI tools are welcome, but contributors are responsible for *fully*
|
||||
understanding the code before submitting a PR.
|
||||
-->
|
||||
|
||||
- [ ] I understand the code I am submitting and can explain how it works.
|
||||
- [ ] The code change is tested and works locally.
|
||||
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
|
||||
- [ ] There is no commented out code in this PR.
|
||||
@@ -68,7 +64,6 @@
|
||||
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
|
||||
- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`)
|
||||
- [ ] Tests have been added to verify that the new code works.
|
||||
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
|
||||
|
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -198,7 +198,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
uses: home-assistant/builder@2025.03.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -265,7 +265,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
uses: home-assistant/builder@2025.03.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -326,7 +326,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
|
25
.github/workflows/ci.yaml
vendored
25
.github/workflows/ci.yaml
vendored
@@ -523,24 +523,22 @@ jobs:
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
|
||||
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
- name: Restore apt cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: cache-apt
|
||||
uses: actions/cache@v4.2.4
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
if: |
|
||||
steps.cache-venv.outputs.cache-hit != 'true'
|
||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
|
||||
if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
|
||||
mkdir -p ${{ env.APT_CACHE_DIR }}
|
||||
mkdir -p ${{ env.APT_LIST_CACHE_DIR }}
|
||||
fi
|
||||
@@ -565,18 +563,9 @@ jobs:
|
||||
libswscale-dev \
|
||||
libudev-dev
|
||||
|
||||
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
|
||||
if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
|
||||
sudo chmod -R 755 ${{ env.APT_CACHE_BASE }}
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -140,5 +140,5 @@ tmp_cache
|
||||
pytest_buckets.txt
|
||||
|
||||
# AI tooling
|
||||
.claude/settings.local.json
|
||||
.claude
|
||||
|
||||
|
@@ -142,7 +142,6 @@ homeassistant.components.cloud.*
|
||||
homeassistant.components.co2signal.*
|
||||
homeassistant.components.comelit.*
|
||||
homeassistant.components.command_line.*
|
||||
homeassistant.components.compit.*
|
||||
homeassistant.components.config.*
|
||||
homeassistant.components.configurator.*
|
||||
homeassistant.components.cookidoo.*
|
||||
@@ -443,7 +442,6 @@ homeassistant.components.rituals_perfume_genie.*
|
||||
homeassistant.components.roborock.*
|
||||
homeassistant.components.roku.*
|
||||
homeassistant.components.romy.*
|
||||
homeassistant.components.route_b_smart_meter.*
|
||||
homeassistant.components.rpi_power.*
|
||||
homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.russound_rio.*
|
||||
|
33
CODEOWNERS
generated
33
CODEOWNERS
generated
@@ -107,8 +107,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/ambient_station/ @bachya
|
||||
/tests/components/ambient_station/ @bachya
|
||||
/homeassistant/components/amcrest/ @flacjacket
|
||||
/homeassistant/components/analytics/ @home-assistant/core
|
||||
/tests/components/analytics/ @home-assistant/core
|
||||
/homeassistant/components/analytics/ @home-assistant/core @ludeeus
|
||||
/tests/components/analytics/ @home-assistant/core @ludeeus
|
||||
/homeassistant/components/analytics_insights/ @joostlek
|
||||
/tests/components/analytics_insights/ @joostlek
|
||||
/homeassistant/components/android_ip_webcam/ @engrbm87
|
||||
@@ -292,8 +292,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/command_line/ @gjohansson-ST
|
||||
/homeassistant/components/compensation/ @Petro31
|
||||
/tests/components/compensation/ @Petro31
|
||||
/homeassistant/components/compit/ @Przemko92
|
||||
/tests/components/compit/ @Przemko92
|
||||
/homeassistant/components/config/ @home-assistant/core
|
||||
/tests/components/config/ @home-assistant/core
|
||||
/homeassistant/components/configurator/ @home-assistant/core
|
||||
@@ -316,8 +314,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/crownstone/ @Crownstone @RicArch97
|
||||
/homeassistant/components/cups/ @fabaff
|
||||
/tests/components/cups/ @fabaff
|
||||
/homeassistant/components/cync/ @Kinachi249
|
||||
/tests/components/cync/ @Kinachi249
|
||||
/homeassistant/components/daikin/ @fredrike
|
||||
/tests/components/daikin/ @fredrike
|
||||
/homeassistant/components/date/ @home-assistant/core
|
||||
@@ -412,8 +408,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
/homeassistant/components/eheimdigital/ @autinerd
|
||||
/tests/components/eheimdigital/ @autinerd
|
||||
/homeassistant/components/ekeybionyx/ @richardpolzer
|
||||
/tests/components/ekeybionyx/ @richardpolzer
|
||||
/homeassistant/components/electrasmart/ @jafar-atili
|
||||
/tests/components/electrasmart/ @jafar-atili
|
||||
/homeassistant/components/electric_kiwi/ @mikey0000
|
||||
@@ -776,8 +770,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/iqvia/ @bachya
|
||||
/tests/components/iqvia/ @bachya
|
||||
/homeassistant/components/irish_rail_transport/ @ttroy50
|
||||
/homeassistant/components/irm_kmi/ @jdejaegh
|
||||
/tests/components/irm_kmi/ @jdejaegh
|
||||
/homeassistant/components/iron_os/ @tr4nt0r
|
||||
/tests/components/iron_os/ @tr4nt0r
|
||||
/homeassistant/components/isal/ @bdraco
|
||||
@@ -976,6 +968,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/moat/ @bdraco
|
||||
/homeassistant/components/mobile_app/ @home-assistant/core
|
||||
/tests/components/mobile_app/ @home-assistant/core
|
||||
/homeassistant/components/modbus/ @janiversen
|
||||
/tests/components/modbus/ @janiversen
|
||||
/homeassistant/components/modem_callerid/ @tkdrob
|
||||
/tests/components/modem_callerid/ @tkdrob
|
||||
/homeassistant/components/modern_forms/ @wonderslug
|
||||
@@ -1023,8 +1017,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nanoleaf/ @milanmeu @joostlek
|
||||
/homeassistant/components/nasweb/ @nasWebio
|
||||
/tests/components/nasweb/ @nasWebio
|
||||
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM
|
||||
/homeassistant/components/ness_alarm/ @nickw444
|
||||
/tests/components/ness_alarm/ @nickw444
|
||||
/homeassistant/components/nest/ @allenporter
|
||||
@@ -1334,8 +1327,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
|
||||
/homeassistant/components/roon/ @pavoni
|
||||
/tests/components/roon/ @pavoni
|
||||
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
|
||||
/tests/components/route_b_smart_meter/ @SeraphicRav
|
||||
/homeassistant/components/rpi_power/ @shenxn @swetoast
|
||||
/tests/components/rpi_power/ @shenxn @swetoast
|
||||
/homeassistant/components/rss_feed_template/ @home-assistant/core
|
||||
@@ -1358,8 +1349,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
/homeassistant/components/sanix/ @tomaszsluszniak
|
||||
/tests/components/sanix/ @tomaszsluszniak
|
||||
/homeassistant/components/satel_integra/ @Tommatheussen
|
||||
/tests/components/satel_integra/ @Tommatheussen
|
||||
/homeassistant/components/scene/ @home-assistant/core
|
||||
/tests/components/scene/ @home-assistant/core
|
||||
/homeassistant/components/schedule/ @home-assistant/core
|
||||
@@ -1541,8 +1530,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/switchbee/ @jafar-atili
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
||||
/tests/components/switcher_kis/ @thecode @YogevBokobza
|
||||
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
|
||||
@@ -1687,8 +1676,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/uptime_kuma/ @tr4nt0r
|
||||
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
|
||||
/tests/components/uptimerobot/ @ludeeus @chemelli74
|
||||
/homeassistant/components/usage_prediction/ @home-assistant/core
|
||||
/tests/components/usage_prediction/ @home-assistant/core
|
||||
/homeassistant/components/usb/ @bdraco
|
||||
/tests/components/usb/ @bdraco
|
||||
/homeassistant/components/usgs_earthquakes_feed/ @exxamalte
|
||||
@@ -1718,8 +1705,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
||||
/homeassistant/components/vicare/ @CFenner
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
@@ -1735,8 +1720,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/volumio/ @OnFreund
|
||||
/homeassistant/components/volvo/ @thomasddn
|
||||
/tests/components/volvo/ @thomasddn
|
||||
/homeassistant/components/volvooncall/ @molobrakos @svrooij
|
||||
/tests/components/volvooncall/ @molobrakos @svrooij
|
||||
/homeassistant/components/volvooncall/ @molobrakos
|
||||
/tests/components/volvooncall/ @molobrakos
|
||||
/homeassistant/components/wake_on_lan/ @ntilley905
|
||||
/tests/components/wake_on_lan/ @ntilley905
|
||||
/homeassistant/components/wake_word/ @home-assistant/core @synesthesiam
|
||||
|
@@ -8,7 +8,6 @@ import logging
|
||||
from aioacaia.acaiascale import AcaiaScale
|
||||
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
|
||||
|
||||
from homeassistant.components.bluetooth import async_get_scanner
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,7 +42,6 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
|
||||
name=entry.title,
|
||||
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
|
||||
notify_callback=self.async_update_listeners,
|
||||
scanner=async_get_scanner(hass),
|
||||
)
|
||||
|
||||
@property
|
||||
|
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.1.17"]
|
||||
"requirements": ["aioacaia==0.1.14"]
|
||||
}
|
||||
|
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
|
||||
@@ -23,8 +22,6 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for AccuWeather."""
|
||||
|
||||
VERSION = 1
|
||||
_latitude: float | None = None
|
||||
_longitude: float | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -77,46 +74,3 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self._latitude = entry_data[CONF_LATITUDE]
|
||||
self._longitude = entry_data[CONF_LONGITUDE]
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
websession = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with timeout(10):
|
||||
accuweather = AccuWeather(
|
||||
user_input[CONF_API_KEY],
|
||||
websession,
|
||||
latitude=self._latitude,
|
||||
longitude=self._longitude,
|
||||
)
|
||||
await accuweather.async_get_location()
|
||||
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidApiKeyError:
|
||||
errors["base"] = "invalid_api_key"
|
||||
except RequestsExceededError:
|
||||
errors["base"] = "requests_exceeded"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
@@ -15,7 +15,6 @@ from aiohttp.client_exceptions import ClientConnectorError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
@@ -31,7 +30,7 @@ from .const import (
|
||||
UPDATE_INTERVAL_OBSERVATION,
|
||||
)
|
||||
|
||||
EXCEPTIONS = (ApiError, ClientConnectorError, RequestsExceededError)
|
||||
EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,8 +52,6 @@ class AccuWeatherObservationDataUpdateCoordinator(
|
||||
):
|
||||
"""Class to manage fetching AccuWeather data API."""
|
||||
|
||||
config_entry: AccuWeatherConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -90,12 +87,6 @@ class AccuWeatherObservationDataUpdateCoordinator(
|
||||
translation_key="current_conditions_update_error",
|
||||
translation_placeholders={"error": repr(error)},
|
||||
) from error
|
||||
except InvalidApiKeyError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
translation_placeholders={"entry": self.config_entry.title},
|
||||
) from err
|
||||
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
|
||||
@@ -107,8 +98,6 @@ class AccuWeatherForecastDataUpdateCoordinator(
|
||||
):
|
||||
"""Base class for AccuWeather forecast."""
|
||||
|
||||
config_entry: AccuWeatherConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -148,12 +137,6 @@ class AccuWeatherForecastDataUpdateCoordinator(
|
||||
translation_key="forecast_update_error",
|
||||
translation_placeholders={"error": repr(error)},
|
||||
) from error
|
||||
except InvalidApiKeyError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
translation_placeholders={"entry": self.config_entry.title},
|
||||
) from err
|
||||
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
return result
|
||||
|
@@ -7,17 +7,6 @@
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "API key generated in the AccuWeather APIs portal."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::accuweather::config::step::user::data_description::api_key%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -30,8 +19,7 @@
|
||||
"requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -251,9 +239,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_error": {
|
||||
"message": "Authentication failed for {entry}, please update your API key"
|
||||
},
|
||||
"current_conditions_update_error": {
|
||||
"message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}"
|
||||
},
|
||||
|
@@ -2,31 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.media_source import MediaSource, local_source
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||
"""Set up local media source."""
|
||||
media_dirs = list(hass.config.media_dirs.values())
|
||||
|
||||
if not media_dirs:
|
||||
raise HomeAssistantError(
|
||||
"AI Task media source requires at least one media directory configured"
|
||||
)
|
||||
|
||||
media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR
|
||||
media_dir = hass.config.path(f"{DOMAIN}/{IMAGE_DIR}")
|
||||
|
||||
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"AI Generated Images",
|
||||
{IMAGE_DIR: str(media_dir)},
|
||||
{IMAGE_DIR: media_dir},
|
||||
False,
|
||||
f"/{DOMAIN}",
|
||||
)
|
||||
return source
|
||||
|
@@ -12,7 +12,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import camera, conversation, image, media_source
|
||||
from homeassistant.components import camera, conversation, media_source
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -31,14 +31,14 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
def _save_camera_snapshot(image_data: camera.Image | image.Image) -> Path:
|
||||
def _save_camera_snapshot(image: camera.Image) -> Path:
|
||||
"""Save camera snapshot to temp file."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
suffix=mimetypes.guess_extension(image_data.content_type, False),
|
||||
suffix=mimetypes.guess_extension(image.content_type, False),
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.write(image_data.content)
|
||||
temp_file.write(image.content)
|
||||
return Path(temp_file.name)
|
||||
|
||||
|
||||
@@ -54,31 +54,26 @@ async def _resolve_attachments(
|
||||
for attachment in attachments or []:
|
||||
media_content_id = attachment["media_content_id"]
|
||||
|
||||
# Special case for certain media sources
|
||||
for integration in camera, image:
|
||||
media_source_prefix = f"media-source://{integration.DOMAIN}/"
|
||||
if not media_content_id.startswith(media_source_prefix):
|
||||
continue
|
||||
|
||||
# Special case for camera media sources
|
||||
if media_content_id.startswith("media-source://camera/"):
|
||||
# Extract entity_id from the media content ID
|
||||
entity_id = media_content_id.removeprefix(media_source_prefix)
|
||||
entity_id = media_content_id.removeprefix("media-source://camera/")
|
||||
|
||||
# Get snapshot from entity
|
||||
image_data = await integration.async_get_image(hass, entity_id)
|
||||
# Get snapshot from camera
|
||||
image = await camera.async_get_image(hass, entity_id)
|
||||
|
||||
temp_filename = await hass.async_add_executor_job(
|
||||
_save_camera_snapshot, image_data
|
||||
_save_camera_snapshot, image
|
||||
)
|
||||
created_files.append(temp_filename)
|
||||
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=image_data.content_type,
|
||||
mime_type=image.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
# Handle regular media sources
|
||||
media = await media_source.async_resolve_media(hass, media_content_id, None)
|
||||
|
@@ -6,19 +6,17 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final
|
||||
|
||||
from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout
|
||||
from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout
|
||||
from aioairzone.const import (
|
||||
API_COLD_ANGLE,
|
||||
API_HEAT_ANGLE,
|
||||
API_MODE,
|
||||
API_Q_ADAPT,
|
||||
API_SLEEP,
|
||||
AZD_COLD_ANGLE,
|
||||
AZD_HEAT_ANGLE,
|
||||
AZD_MASTER,
|
||||
AZD_MODE,
|
||||
AZD_MODES,
|
||||
AZD_Q_ADAPT,
|
||||
AZD_SLEEP,
|
||||
AZD_ZONES,
|
||||
)
|
||||
@@ -67,14 +65,6 @@ SLEEP_DICT: Final[dict[str, int]] = {
|
||||
"90m": SleepTimeout.SLEEP_90,
|
||||
}
|
||||
|
||||
Q_ADAPT_DICT: Final[dict[str, int]] = {
|
||||
"standard": QAdapt.STANDARD,
|
||||
"power": QAdapt.POWER,
|
||||
"silence": QAdapt.SILENCE,
|
||||
"minimum": QAdapt.MINIMUM,
|
||||
"maximum": QAdapt.MAXIMUM,
|
||||
}
|
||||
|
||||
|
||||
def main_zone_options(
|
||||
zone_data: dict[str, Any],
|
||||
@@ -93,14 +83,6 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
options_fn=main_zone_options,
|
||||
translation_key="modes",
|
||||
),
|
||||
AirzoneSelectDescription(
|
||||
api_param=API_Q_ADAPT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
key=AZD_Q_ADAPT,
|
||||
options=list(Q_ADAPT_DICT),
|
||||
options_dict=Q_ADAPT_DICT,
|
||||
translation_key="q_adapt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -63,16 +63,6 @@
|
||||
"stop": "Stop"
|
||||
}
|
||||
},
|
||||
"q_adapt": {
|
||||
"name": "Q-Adapt",
|
||||
"state": {
|
||||
"standard": "Standard",
|
||||
"power": "Power",
|
||||
"silence": "Silence",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
}
|
||||
},
|
||||
"sleep_times": {
|
||||
"name": "Sleep",
|
||||
"state": {
|
||||
|
@@ -94,24 +94,12 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
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(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in new_devices
|
||||
if sensor_desc.is_supported(
|
||||
coordinator.data[serial_num], sensor_desc.key
|
||||
)
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
||||
)
|
||||
|
||||
|
||||
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||
|
@@ -57,23 +57,13 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
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(
|
||||
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in NOTIFY
|
||||
for serial_num in new_devices
|
||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in NOTIFY
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||
)
|
||||
|
||||
|
||||
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
||||
|
@@ -53,7 +53,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
|
@@ -62,22 +62,12 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
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(
|
||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in SENSORS
|
||||
for serial_num in new_devices
|
||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
||||
)
|
||||
|
||||
|
||||
class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
||||
|
@@ -48,22 +48,12 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
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(
|
||||
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||
for switch_desc in SWITCHES
|
||||
for serial_num in new_devices
|
||||
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||
for switch_desc in SWITCHES
|
||||
for serial_num in coordinator.data
|
||||
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
)
|
||||
|
||||
|
||||
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
|
@@ -41,7 +41,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
|
||||
return []
|
||||
|
||||
call_ids = await async_extract_entity_ids(call)
|
||||
call_ids = await async_extract_entity_ids(hass, call)
|
||||
entity_ids = []
|
||||
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
|
||||
if entity_id not in call_ids:
|
||||
|
@@ -12,25 +12,10 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .analytics import (
|
||||
Analytics,
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
DeviceAnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from .analytics import Analytics
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
__all__ = [
|
||||
"AnalyticsInput",
|
||||
"AnalyticsModifications",
|
||||
"DeviceAnalyticsModifications",
|
||||
"EntityAnalyticsModifications",
|
||||
"async_devices_payload",
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
|
@@ -4,10 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
@@ -36,14 +35,11 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.helpers.typing import UNDEFINED
|
||||
from homeassistant.loader import (
|
||||
Integration,
|
||||
IntegrationNotFound,
|
||||
async_get_integration,
|
||||
async_get_integrations,
|
||||
)
|
||||
from homeassistant.setup import async_get_loaded_integrations
|
||||
@@ -79,115 +75,12 @@ from .const import (
|
||||
ATTR_USER_COUNT,
|
||||
ATTR_UUID,
|
||||
ATTR_VERSION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PREFERENCE_SCHEMA,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
|
||||
DATA_ANALYTICS_MODIFIERS = "analytics_modifiers"
|
||||
|
||||
type AnalyticsModifier = Callable[
|
||||
[HomeAssistant, AnalyticsInput], Awaitable[AnalyticsModifications]
|
||||
]
|
||||
|
||||
|
||||
@singleton(DATA_ANALYTICS_MODIFIERS)
|
||||
def _async_get_modifiers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, AnalyticsModifier | None]:
|
||||
"""Return the analytics modifiers."""
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsInput:
|
||||
"""Analytics input for a single integration.
|
||||
|
||||
This is sent to integrations that implement the platform.
|
||||
"""
|
||||
|
||||
device_ids: Iterable[str] = field(default_factory=list)
|
||||
entity_ids: Iterable[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsModifications:
|
||||
"""Analytics config for a single integration.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
devices: Mapping[str, DeviceAnalyticsModifications] | None = None
|
||||
entities: Mapping[str, EntityAnalyticsModifications] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceAnalyticsModifications:
|
||||
"""Analytics config for a single device.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityAnalyticsModifications:
|
||||
"""Analytics config for a single entity.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
|
||||
|
||||
class AnalyticsPlatformProtocol(Protocol):
|
||||
"""Define the format of analytics platforms."""
|
||||
|
||||
async def async_modify_analytics(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
analytics_input: AnalyticsInput,
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
|
||||
|
||||
async def _async_get_analytics_platform(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> AnalyticsPlatformProtocol | None:
|
||||
"""Get analytics platform."""
|
||||
try:
|
||||
integration = await async_get_integration(hass, domain)
|
||||
except IntegrationNotFound:
|
||||
return None
|
||||
try:
|
||||
return await integration.async_get_platform(DOMAIN)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
async def _async_get_modifier(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> AnalyticsModifier | None:
|
||||
"""Get analytics modifier."""
|
||||
modifiers = _async_get_modifiers(hass)
|
||||
modifier = modifiers.get(domain, UNDEFINED)
|
||||
|
||||
if modifier is not UNDEFINED:
|
||||
return modifier
|
||||
|
||||
platform = await _async_get_analytics_platform(hass, domain)
|
||||
if platform is None:
|
||||
modifiers[domain] = None
|
||||
return None
|
||||
|
||||
modifier = getattr(platform, "async_modify_analytics", None)
|
||||
modifiers[domain] = modifier
|
||||
return modifier
|
||||
|
||||
|
||||
def gen_uuid() -> str:
|
||||
"""Generate a new UUID."""
|
||||
@@ -500,20 +393,17 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
||||
return domains
|
||||
|
||||
|
||||
DEFAULT_ANALYTICS_CONFIG = AnalyticsModifications()
|
||||
DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"""Return detailed information about entities and devices."""
|
||||
integrations_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
|
||||
integration_configs: dict[str, AnalyticsModifications] = {}
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Get device list
|
||||
for device_entry in dev_reg.devices.values():
|
||||
if not device_entry.primary_config_entry:
|
||||
continue
|
||||
@@ -526,113 +416,27 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
continue
|
||||
|
||||
integration_domain = config_entry.domain
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
integration_input[0].append(device_entry.id)
|
||||
|
||||
# Get entity list
|
||||
for entity_entry in ent_reg.entities.values():
|
||||
integration_domain = entity_entry.platform
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
integration_input[1].append(entity_entry.entity_id)
|
||||
|
||||
integrations = {
|
||||
domain: integration
|
||||
for domain, integration in (
|
||||
await async_get_integrations(hass, integration_inputs.keys())
|
||||
).items()
|
||||
if isinstance(integration, Integration)
|
||||
}
|
||||
|
||||
# Filter out custom integrations and integrations that are not device or hub type
|
||||
integration_inputs = {
|
||||
domain: integration_info
|
||||
for domain, integration_info in integration_inputs.items()
|
||||
if (integration := integrations.get(domain)) is not None
|
||||
and integration.is_built_in
|
||||
and integration.integration_type in ("device", "hub")
|
||||
}
|
||||
|
||||
# Call integrations that implement the analytics platform
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
if (
|
||||
modifier := await _async_get_modifier(hass, integration_domain)
|
||||
) is not None:
|
||||
try:
|
||||
integration_config = await modifier(
|
||||
hass, AnalyticsInput(*integration_input)
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception(
|
||||
"Calling async_modify_analytics for integration '%s' failed: %s",
|
||||
integration_domain,
|
||||
err,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
remove=True
|
||||
)
|
||||
continue
|
||||
|
||||
if not isinstance(integration_config, AnalyticsModifications):
|
||||
LOGGER.error( # type: ignore[unreachable]
|
||||
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
|
||||
integration_domain,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
remove=True
|
||||
)
|
||||
continue
|
||||
|
||||
integration_configs[integration_domain] = integration_config
|
||||
|
||||
integrations_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Fill out information about devices
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
integration_config = integration_configs.get(
|
||||
integration_domain, DEFAULT_ANALYTICS_CONFIG
|
||||
)
|
||||
|
||||
if integration_config.remove:
|
||||
continue
|
||||
|
||||
integration_info = integrations_info.setdefault(
|
||||
integration_domain, {"devices": [], "entities": []}
|
||||
)
|
||||
|
||||
devices_info = integration_info["devices"]
|
||||
|
||||
for device_id in integration_input[0]:
|
||||
device_config = DEFAULT_DEVICE_ANALYTICS_CONFIG
|
||||
if integration_config.devices is not None:
|
||||
device_config = integration_config.devices.get(device_id, device_config)
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
|
||||
if device_config.remove:
|
||||
continue
|
||||
|
||||
device_entry = dev_reg.devices[device_id]
|
||||
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
}
|
||||
)
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Fill out via_device with new device ids
|
||||
for integration_info in integrations_info.values():
|
||||
@@ -641,15 +445,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
continue
|
||||
device_info["via_device"] = device_id_mapping.get(device_info["via_device"])
|
||||
|
||||
# Fill out information about entities
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
integration_config = integration_configs.get(
|
||||
integration_domain, DEFAULT_ANALYTICS_CONFIG
|
||||
)
|
||||
|
||||
if integration_config.remove:
|
||||
continue
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
for entity_entry in ent_reg.entities.values():
|
||||
integration_domain = entity_entry.platform
|
||||
integration_info = integrations_info.setdefault(
|
||||
integration_domain, {"devices": [], "entities": []}
|
||||
)
|
||||
@@ -657,49 +456,53 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
devices_info = integration_info["devices"]
|
||||
entities_info = integration_info["entities"]
|
||||
|
||||
for entity_id in integration_input[1]:
|
||||
entity_config = DEFAULT_ENTITY_ANALYTICS_CONFIG
|
||||
if integration_config.entities is not None:
|
||||
entity_config = integration_config.entities.get(
|
||||
entity_id, entity_config
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
# It is also not present, if entity is not in the state machine,
|
||||
# which can happen for disabled entities.
|
||||
"assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
|
||||
if entity_state is not None
|
||||
else None,
|
||||
"capabilities": entity_entry.capabilities,
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"original_device_class": entity_entry.original_device_class,
|
||||
# LIMITATION: `unit_of_measurement` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
|
||||
integrations = {
|
||||
domain: integration
|
||||
for domain, integration in (
|
||||
await async_get_integrations(hass, integrations_info.keys())
|
||||
).items()
|
||||
if isinstance(integration, Integration)
|
||||
}
|
||||
|
||||
for domain, integration_info in integrations_info.items():
|
||||
if integration := integrations.get(domain):
|
||||
integration_info["is_custom_integration"] = not integration.is_built_in
|
||||
# Include version for custom integrations
|
||||
if not integration.is_built_in and integration.version:
|
||||
integration_info["custom_integration_version"] = str(
|
||||
integration.version
|
||||
)
|
||||
|
||||
if entity_config.remove:
|
||||
continue
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
# It is also not present, if entity is not in the state machine,
|
||||
# which can happen for disabled entities.
|
||||
"assumed_state": (
|
||||
entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
|
||||
if entity_state is not None
|
||||
else None
|
||||
),
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"original_device_class": entity_entry.original_device_class,
|
||||
# LIMITATION: `unit_of_measurement` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id_ := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
|
||||
return {
|
||||
"version": "home-assistant:1",
|
||||
"home_assistant": HA_VERSION,
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "analytics",
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
|
@@ -467,10 +467,7 @@ async def async_setup_entry(
|
||||
# periodical (or manual) self test since last daemon restart. It might not be available
|
||||
# when we set up the integration, and we do not know if it would ever be available. Here we
|
||||
# add it anyway and mark it as unknown initially.
|
||||
#
|
||||
# We also sort the resources to ensure the order of entities created is deterministic since
|
||||
# "APCMODEL" and "MODEL" resources map to the same "Model" name.
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
for resource in available_resources | {LAST_S_TEST}:
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
continue
|
||||
|
@@ -109,7 +109,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
)
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if (state is not None) and (state.state in self.options):
|
||||
if state is not None and state.state in self.options:
|
||||
self._attr_current_option = state.state
|
||||
|
||||
if self.registry_entry and (device_id := self.registry_entry.device_id):
|
||||
@@ -119,7 +119,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
|
||||
def cleanup() -> None:
|
||||
"""Clean up registered device."""
|
||||
pipeline_data.pipeline_devices.pop(device_id, None)
|
||||
pipeline_data.pipeline_devices.pop(device_id)
|
||||
|
||||
self.async_on_remove(cleanup)
|
||||
|
||||
|
@@ -120,7 +120,6 @@ class AsusWrtBridge(ABC):
|
||||
|
||||
def __init__(self, host: str) -> None:
|
||||
"""Initialize Bridge."""
|
||||
self._configuration_url = f"http://{host}"
|
||||
self._host = host
|
||||
self._firmware: str | None = None
|
||||
self._label_mac: str | None = None
|
||||
@@ -128,11 +127,6 @@ class AsusWrtBridge(ABC):
|
||||
self._model_id: str | None = None
|
||||
self._serial_number: str | None = None
|
||||
|
||||
@property
|
||||
def configuration_url(self) -> str:
|
||||
"""Return configuration URL."""
|
||||
return self._configuration_url
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""Return hostname."""
|
||||
@@ -377,7 +371,6 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
||||
# get main router properties
|
||||
if mac := _identity.mac:
|
||||
self._label_mac = format_mac(mac)
|
||||
self._configuration_url = self._api.webpanel
|
||||
self._firmware = str(_identity.firmware)
|
||||
self._model = _identity.model
|
||||
self._model_id = _identity.product_id
|
||||
|
@@ -388,13 +388,13 @@ class AsusWrtRouter:
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device information."""
|
||||
info = DeviceInfo(
|
||||
configuration_url=self._api.configuration_url,
|
||||
identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")},
|
||||
name=self.host,
|
||||
model=self._api.model or "Asus Router",
|
||||
model_id=self._api.model_id,
|
||||
serial_number=self._api.serial_number,
|
||||
manufacturer="Asus",
|
||||
configuration_url=f"http://{self.host}",
|
||||
)
|
||||
if self._api.firmware:
|
||||
info["sw_version"] = self._api.firmware
|
||||
|
@@ -2,12 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from yalexs.activity import ActivityType
|
||||
from yalexs.lock import Lock, LockOperation, LockStatus
|
||||
from yalexs.activity import ActivityType, ActivityTypes
|
||||
from yalexs.lock import Lock, LockStatus
|
||||
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
|
||||
|
||||
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
|
||||
@@ -49,25 +50,30 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity):
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the device."""
|
||||
await self._perform_lock_operation(LockOperation.LOCK)
|
||||
if self._data.push_updates_connected:
|
||||
await self._data.async_lock_async(self._device_id, self._hyper_bridge)
|
||||
return
|
||||
await self._call_lock_operation(self._data.async_lock)
|
||||
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
"""Open/unlatch the device."""
|
||||
await self._perform_lock_operation(LockOperation.OPEN)
|
||||
if self._data.push_updates_connected:
|
||||
await self._data.async_unlatch_async(self._device_id, self._hyper_bridge)
|
||||
return
|
||||
await self._call_lock_operation(self._data.async_unlatch)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the device."""
|
||||
await self._perform_lock_operation(LockOperation.UNLOCK)
|
||||
if self._data.push_updates_connected:
|
||||
await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
|
||||
return
|
||||
await self._call_lock_operation(self._data.async_unlock)
|
||||
|
||||
async def _perform_lock_operation(self, operation: LockOperation) -> None:
|
||||
"""Perform a lock operation."""
|
||||
async def _call_lock_operation(
|
||||
self, lock_operation: Callable[[str], Coroutine[Any, Any, list[ActivityTypes]]]
|
||||
) -> None:
|
||||
try:
|
||||
activities = await self._data.async_operate_lock(
|
||||
self._device_id,
|
||||
operation,
|
||||
self._data.push_updates_connected,
|
||||
self._hyper_bridge,
|
||||
)
|
||||
activities = await lock_operation(self._device_id)
|
||||
except ClientResponseError as err:
|
||||
if err.status == LOCK_JAMMED_ERR:
|
||||
self._detail.lock_status = LockStatus.JAMMED
|
||||
|
@@ -29,5 +29,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"]
|
||||
"requirements": ["yalexs==9.0.1", "yalexs-ble==3.1.2"]
|
||||
}
|
||||
|
@@ -92,11 +92,7 @@ from homeassistant.components.http.ban import (
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.network import (
|
||||
NoURLAvailableError,
|
||||
get_url,
|
||||
is_cloud_connection,
|
||||
)
|
||||
from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
from . import indieauth
|
||||
@@ -129,18 +125,11 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Return the well known OAuth2 authorization info."""
|
||||
hass = request.app[KEY_HASS]
|
||||
# Some applications require absolute urls, so we prefer using the
|
||||
# current requests url if possible, with fallback to a relative url.
|
||||
try:
|
||||
url_prefix = get_url(hass, require_current_request=True)
|
||||
except NoURLAvailableError:
|
||||
url_prefix = ""
|
||||
return self.json(
|
||||
{
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
"authorization_endpoint": "/auth/authorize",
|
||||
"token_endpoint": "/auth/token",
|
||||
"revocation_endpoint": "/auth/revoke",
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
|
@@ -26,6 +26,7 @@ EXCLUDE_FROM_BACKUP = [
|
||||
"tmp_backups/*.tar",
|
||||
"OZW_Log.txt",
|
||||
"tts/*",
|
||||
"ai_task/*",
|
||||
]
|
||||
|
||||
EXCLUDE_DATABASE_FROM_BACKUP = [
|
||||
|
@@ -8,7 +8,7 @@ import threading
|
||||
from typing import IO, cast
|
||||
|
||||
from aiohttp import BodyPartReader
|
||||
from aiohttp.hdrs import CONTENT_DISPOSITION, CONTENT_TYPE
|
||||
from aiohttp.hdrs import CONTENT_DISPOSITION
|
||||
from aiohttp.web import FileResponse, Request, Response, StreamResponse
|
||||
from multidict import istr
|
||||
|
||||
@@ -76,8 +76,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
return Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
headers = {
|
||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar",
|
||||
CONTENT_TYPE: "application/x-tar",
|
||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
|
||||
}
|
||||
|
||||
try:
|
||||
|
@@ -13,30 +13,20 @@ from bluecurrent_api.exceptions import (
|
||||
RequestLimitReached,
|
||||
WebsocketError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
BCU_APP,
|
||||
CHARGEPOINT_SETTINGS,
|
||||
CHARGEPOINT_STATUS,
|
||||
CHARGING_CARD_ID,
|
||||
DOMAIN,
|
||||
EVSE_ID,
|
||||
LOGGER,
|
||||
PLUG_AND_CHARGE,
|
||||
SERVICE_START_CHARGE_SESSION,
|
||||
VALUE,
|
||||
)
|
||||
|
||||
@@ -44,7 +34,6 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector]
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
|
||||
CHARGE_POINTS = "CHARGE_POINTS"
|
||||
CHARGE_CARDS = "CHARGE_CARDS"
|
||||
DATA = "data"
|
||||
DELAY = 5
|
||||
|
||||
@@ -52,16 +41,6 @@ GRID = "GRID"
|
||||
OBJECT = "object"
|
||||
VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE_ID): cv.string,
|
||||
# When no charging card is provided, use no charging card (BCU_APP = no charging card).
|
||||
vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
|
||||
@@ -88,66 +67,6 @@ async def async_setup_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Blue Current."""
|
||||
|
||||
async def start_charge_session(service_call: ServiceCall) -> None:
|
||||
"""Start a charge session with the provided device and charge card ID."""
|
||||
# When no charge card is provided, use the default charge card set in the config flow.
|
||||
charging_card_id = service_call.data[CHARGING_CARD_ID]
|
||||
device_id = service_call.data[CONF_DEVICE_ID]
|
||||
|
||||
# Get the device based on the given device ID.
|
||||
device = dr.async_get(hass).devices.get(device_id)
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_device_id"
|
||||
)
|
||||
|
||||
blue_current_config_entry: ConfigEntry | None = None
|
||||
|
||||
for config_entry_id in device.config_entries:
|
||||
config_entry = hass.config_entries.async_get_entry(config_entry_id)
|
||||
if not config_entry or config_entry.domain != DOMAIN:
|
||||
# Not the blue_current config entry.
|
||||
continue
|
||||
|
||||
if config_entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
|
||||
)
|
||||
|
||||
blue_current_config_entry = config_entry
|
||||
break
|
||||
|
||||
if not blue_current_config_entry:
|
||||
# The device is not connected to a valid blue_current config entry.
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="no_config_entry"
|
||||
)
|
||||
|
||||
connector = blue_current_config_entry.runtime_data
|
||||
|
||||
# Get the evse_id from the identifier of the device.
|
||||
evse_id = next(
|
||||
identifier[1]
|
||||
for identifier in device.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
)
|
||||
|
||||
await connector.client.start_session(evse_id, charging_card_id)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_START_CHARGE_SESSION,
|
||||
start_charge_session,
|
||||
SERVICE_START_CHARGE_SESSION_SCHEMA,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
|
||||
) -> bool:
|
||||
@@ -168,7 +87,6 @@ class Connector:
|
||||
self.client = client
|
||||
self.charge_points: dict[str, dict] = {}
|
||||
self.grid: dict[str, Any] = {}
|
||||
self.charge_cards: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def on_data(self, message: dict) -> None:
|
||||
"""Handle received data."""
|
||||
|
@@ -8,12 +8,6 @@ LOGGER = logging.getLogger(__package__)
|
||||
|
||||
EVSE_ID = "evse_id"
|
||||
MODEL_TYPE = "model_type"
|
||||
CARD = "card"
|
||||
UID = "uid"
|
||||
BCU_APP = "BCU-APP"
|
||||
WITHOUT_CHARGING_CARD = "without_charging_card"
|
||||
CHARGING_CARD_ID = "charging_card_id"
|
||||
SERVICE_START_CHARGE_SESSION = "start_charge_session"
|
||||
PLUG_AND_CHARGE = "plug_and_charge"
|
||||
VALUE = "value"
|
||||
PERMISSION = "permission"
|
||||
|
@@ -42,10 +42,5 @@
|
||||
"default": "mdi:lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"start_charge_session": {
|
||||
"service": "mdi:play"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +0,0 @@
|
||||
start_charge_session:
|
||||
fields:
|
||||
device_id:
|
||||
selector:
|
||||
device:
|
||||
integration: blue_current
|
||||
required: true
|
||||
|
||||
charging_card_id:
|
||||
selector:
|
||||
text:
|
||||
required: false
|
@@ -22,16 +22,6 @@
|
||||
"wrong_account": "Wrong account: Please authenticate with the API token for {email}."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"card": "Card"
|
||||
},
|
||||
"description": "Select the default charging card you want to use"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"activity": {
|
||||
@@ -146,39 +136,5 @@
|
||||
"name": "Block charge point"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"select_charging_card": {
|
||||
"options": {
|
||||
"without_charging_card": "Without charging card"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"start_charge_session": {
|
||||
"name": "Start charge session",
|
||||
"description": "Starts a new charge session on a specified charge point.",
|
||||
"fields": {
|
||||
"charging_card_id": {
|
||||
"name": "Charging card ID",
|
||||
"description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used."
|
||||
},
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"description": "The ID of the Blue Current charge point."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID given."
|
||||
},
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded."
|
||||
},
|
||||
"no_config_entry": {
|
||||
"message": "Device has not a valid blue_current config entry."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -10,7 +10,6 @@ from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from bleak import BleakScanner
|
||||
from habluetooth import (
|
||||
BaseHaScanner,
|
||||
BluetoothScannerDevice,
|
||||
@@ -39,16 +38,13 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_get_scanner(hass: HomeAssistant) -> BleakScanner:
|
||||
"""Return a HaBleakScannerWrapper cast to BleakScanner.
|
||||
def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
|
||||
"""Return a HaBleakScannerWrapper.
|
||||
|
||||
This is a wrapper around our BleakScanner singleton that allows
|
||||
multiple integrations to share the same BleakScanner.
|
||||
|
||||
The wrapper is cast to BleakScanner for type compatibility with
|
||||
libraries expecting a BleakScanner instance.
|
||||
"""
|
||||
return cast(BleakScanner, HaBleakScannerWrapper())
|
||||
return HaBleakScannerWrapper()
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
@@ -205,7 +205,6 @@ class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]
|
||||
|
||||
async def _async_update_data(self) -> dict[str, BringActivityData]:
|
||||
"""Fetch activity data from bring."""
|
||||
self.lists = self.coordinator.lists
|
||||
|
||||
list_dict: dict[str, BringActivityData] = {}
|
||||
for lst in self.lists:
|
||||
|
@@ -43,7 +43,7 @@ async def async_setup_entry(
|
||||
)
|
||||
lists_added |= new_lists
|
||||
|
||||
coordinator.data.async_add_listener(add_entities)
|
||||
coordinator.activity.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
|
||||
@@ -67,8 +67,7 @@ class BringEventEntity(BringBaseEntity, EventEntity):
|
||||
|
||||
def _async_handle_event(self) -> None:
|
||||
"""Handle the activity event."""
|
||||
if (bring_list := self.coordinator.data.get(self._list_uuid)) is None:
|
||||
return
|
||||
bring_list = self.coordinator.data[self._list_uuid]
|
||||
last_event_triggered = self.state
|
||||
if bring_list.activity.timeline and (
|
||||
last_event_triggered is None
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.14.2"]
|
||||
"requirements": ["bthome-ble==3.13.1"]
|
||||
}
|
||||
|
@@ -25,7 +25,6 @@ from homeassistant.const import (
|
||||
DEGREE,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
REVOLUTIONS_PER_MINUTE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfConductivity,
|
||||
@@ -270,15 +269,6 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=DEGREE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Rotational speed (rpm)
|
||||
(
|
||||
BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED,
|
||||
Units.REVOLUTIONS_PER_MINUTE,
|
||||
): SensorEntityDescription(
|
||||
key=f"{BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED}_{Units.REVOLUTIONS_PER_MINUTE}",
|
||||
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Signal Strength (RSSI) (dB)
|
||||
(
|
||||
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
|
||||
|
@@ -37,10 +37,6 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.loader import (
|
||||
async_get_custom_components,
|
||||
async_get_loaded_integration,
|
||||
)
|
||||
from homeassistant.util.location import async_detect_location_info
|
||||
|
||||
from .alexa_config import entity_supported as entity_supported_by_alexa
|
||||
@@ -435,79 +431,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
url = "/api/cloud/support_package"
|
||||
name = "api:cloud:support_package"
|
||||
|
||||
async def _get_integration_info(self, hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Collect information about active and custom integrations."""
|
||||
# Get loaded components from hass.config.components
|
||||
loaded_components = hass.config.components.copy()
|
||||
|
||||
# Get custom integrations
|
||||
custom_domains = set()
|
||||
with suppress(Exception):
|
||||
custom_domains = set(await async_get_custom_components(hass))
|
||||
|
||||
# Separate built-in and custom integrations
|
||||
builtin_integrations = []
|
||||
custom_integrations = []
|
||||
|
||||
for domain in sorted(loaded_components):
|
||||
try:
|
||||
integration = async_get_loaded_integration(hass, domain)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package
|
||||
# generation. If we can't get integration info,
|
||||
# just add the domain
|
||||
if domain in custom_domains:
|
||||
custom_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": "Unknown",
|
||||
"version": "Unknown",
|
||||
"documentation": "Unknown",
|
||||
}
|
||||
)
|
||||
else:
|
||||
builtin_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": "Unknown",
|
||||
}
|
||||
)
|
||||
else:
|
||||
if domain in custom_domains:
|
||||
# This is a custom integration
|
||||
# include version and documentation link
|
||||
version = (
|
||||
str(integration.version) if integration.version else "Unknown"
|
||||
)
|
||||
if not (documentation := integration.documentation):
|
||||
documentation = "Unknown"
|
||||
|
||||
custom_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": integration.name,
|
||||
"version": version,
|
||||
"documentation": documentation,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# This is a built-in integration.
|
||||
# No version needed, as it is always the same as the
|
||||
# Home Assistant version
|
||||
builtin_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": integration.name,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"builtin_count": len(builtin_integrations),
|
||||
"builtin_integrations": builtin_integrations,
|
||||
"custom_count": len(custom_integrations),
|
||||
"custom_integrations": custom_integrations,
|
||||
}
|
||||
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -530,38 +453,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
markdown = "## System Information\n\n"
|
||||
markdown += get_domain_table_markdown(hass_info)
|
||||
|
||||
# Add integration information
|
||||
try:
|
||||
integration_info = await self._get_integration_info(hass)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package generation
|
||||
# If there's any error getting integration info, just note it
|
||||
markdown += "## Active integrations\n\n"
|
||||
markdown += "Unable to collect integration information\n\n"
|
||||
else:
|
||||
markdown += "## Active Integrations\n\n"
|
||||
markdown += f"Built-in integrations: {integration_info['builtin_count']}\n"
|
||||
markdown += f"Custom integrations: {integration_info['custom_count']}\n\n"
|
||||
|
||||
# Built-in integrations
|
||||
if integration_info["builtin_integrations"]:
|
||||
markdown += "<details><summary>Built-in integrations</summary>\n\n"
|
||||
markdown += "Domain | Name\n"
|
||||
markdown += "--- | ---\n"
|
||||
for integration in integration_info["builtin_integrations"]:
|
||||
markdown += f"{integration['domain']} | {integration['name']}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Custom integrations
|
||||
if integration_info["custom_integrations"]:
|
||||
markdown += "<details><summary>Custom integrations</summary>\n\n"
|
||||
markdown += "Domain | Name | Version | Documentation\n"
|
||||
markdown += "--- | --- | --- | ---\n"
|
||||
for integration in integration_info["custom_integrations"]:
|
||||
doc_url = integration.get("documentation") or "N/A"
|
||||
markdown += f"{integration['domain']} | {integration['name']} | {integration['version']} | {doc_url}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
@@ -25,11 +25,7 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo
|
||||
return await cloud.payments.subscription_info()
|
||||
except PaymentsApiError as exception:
|
||||
_LOGGER.error("Failed to fetch subscription information - %s", exception)
|
||||
except TimeoutError:
|
||||
_LOGGER.error(
|
||||
"A timeout of %s was reached while trying to fetch subscription information",
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
@@ -29,23 +29,10 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoBinarySensorEntity(
|
||||
coordinator, device, config_entry.entry_id
|
||||
)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoBinarySensorEntity(
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import timedelta
|
||||
from typing import Any, TypeVar
|
||||
from typing import TypeVar
|
||||
|
||||
from aiocomelit.api import (
|
||||
AlarmDataObject,
|
||||
@@ -13,16 +13,7 @@ from aiocomelit.api import (
|
||||
ComelitVedoAreaObject,
|
||||
ComelitVedoZoneObject,
|
||||
)
|
||||
from aiocomelit.const import (
|
||||
BRIDGE,
|
||||
CLIMATE,
|
||||
COVER,
|
||||
IRRIGATION,
|
||||
LIGHT,
|
||||
OTHER,
|
||||
SCENARIO,
|
||||
VEDO,
|
||||
)
|
||||
from aiocomelit.const import BRIDGE, VEDO
|
||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
|
||||
from aiohttp import ClientSession
|
||||
|
||||
@@ -120,32 +111,6 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
async def _async_update_system_data(self) -> T:
|
||||
"""Class method for updating data."""
|
||||
|
||||
async def _async_remove_stale_devices(
|
||||
self,
|
||||
previous_list: dict[int, Any],
|
||||
current_list: dict[int, Any],
|
||||
dev_type: str,
|
||||
) -> None:
|
||||
"""Remove stale devices."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
|
||||
for i in previous_list:
|
||||
if i not in current_list:
|
||||
_LOGGER.debug(
|
||||
"Detected change in %s devices: index %s removed",
|
||||
dev_type,
|
||||
i,
|
||||
)
|
||||
identifier = f"{self.config_entry.entry_id}-{dev_type}-{i}"
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, identifier)}
|
||||
)
|
||||
if device:
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
|
||||
class ComelitSerialBridge(
|
||||
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
|
||||
@@ -172,15 +137,7 @@ class ComelitSerialBridge(
|
||||
self,
|
||||
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
|
||||
"""Specific method for updating data."""
|
||||
data = await self.api.get_all_devices()
|
||||
|
||||
if self.data:
|
||||
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
|
||||
await self._async_remove_stale_devices(
|
||||
self.data[dev_type], data[dev_type], dev_type
|
||||
)
|
||||
|
||||
return data
|
||||
return await self.api.get_all_devices()
|
||||
|
||||
|
||||
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
|
||||
@@ -206,14 +163,4 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
|
||||
self,
|
||||
) -> AlarmDataObject:
|
||||
"""Specific method for updating data."""
|
||||
data = await self.api.get_all_areas_and_zones()
|
||||
|
||||
if self.data:
|
||||
for obj_type in ("alarm_areas", "alarm_zones"):
|
||||
await self._async_remove_stale_devices(
|
||||
self.data[obj_type],
|
||||
data[obj_type],
|
||||
"area" if obj_type == "alarm_areas" else "zone",
|
||||
)
|
||||
|
||||
return data
|
||||
return await self.api.get_all_areas_and_zones()
|
||||
|
@@ -29,21 +29,10 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[COVER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[COVER].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[COVER].values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
|
@@ -27,21 +27,10 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[LIGHT])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[LIGHT].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[LIGHT].values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
|
||||
|
@@ -57,7 +57,9 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: missing implementation
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: no config or diagnostic entities
|
||||
@@ -70,7 +72,9 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
stale-devices: done
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: missing implementation
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Final, cast
|
||||
|
||||
from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject
|
||||
from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
|
||||
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -65,24 +65,15 @@ async def async_setup_bridge_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[OTHER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
for device in coordinator.data[OTHER].values()
|
||||
if device.index in new_devices
|
||||
entities: list[ComelitBridgeSensorEntity] = []
|
||||
for device in coordinator.data[OTHER].values():
|
||||
entities.extend(
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
async def async_setup_vedo_entry(
|
||||
@@ -94,24 +85,15 @@ async def async_setup_vedo_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
entities: list[ComelitVedoSensorEntity] = []
|
||||
for device in coordinator.data["alarm_zones"].values():
|
||||
entities.extend(
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
|
@@ -39,25 +39,6 @@ async def async_setup_entry(
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
known_devices: dict[str, set[int]] = {
|
||||
dev_type: set() for dev_type in (IRRIGATION, OTHER)
|
||||
}
|
||||
|
||||
def _check_device() -> None:
|
||||
for dev_type in (IRRIGATION, OTHER):
|
||||
current_devices = set(coordinator.data[dev_type])
|
||||
new_devices = current_devices - known_devices[dev_type]
|
||||
if new_devices:
|
||||
known_devices[dev_type].update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
|
||||
"""Switch device."""
|
||||
|
@@ -1,45 +0,0 @@
|
||||
"""The Compit integration."""
|
||||
|
||||
from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
|
||||
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
|
||||
"""Set up Compit from a config entry."""
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
connector = CompitApiConnector(session)
|
||||
try:
|
||||
connected = await connector.init(
|
||||
entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], hass.config.language
|
||||
)
|
||||
except CannotConnect as e:
|
||||
raise ConfigEntryNotReady(f"Error while connecting to Compit: {e}") from e
|
||||
except InvalidAuth as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Invalid credentials for {entry.data[CONF_EMAIL]}"
|
||||
) from e
|
||||
|
||||
if not connected:
|
||||
raise ConfigEntryAuthFailed("Authentication API error")
|
||||
|
||||
coordinator = CompitDataUpdateCoordinator(hass, entry, connector)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
|
||||
"""Unload an entry for the Compit integration."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
@@ -1,265 +0,0 @@
|
||||
"""Module contains the CompitClimate class for controlling climate entities."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api import Param, Parameter
|
||||
from compit_inext_api.consts import (
|
||||
CompitFanMode,
|
||||
CompitHVACMode,
|
||||
CompitParameter,
|
||||
CompitPresetMode,
|
||||
)
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_OFF,
|
||||
PRESET_AWAY,
|
||||
PRESET_ECO,
|
||||
PRESET_HOME,
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER_NAME
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
# Device class for climate devices in Compit system
|
||||
CLIMATE_DEVICE_CLASS = 10
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
COMPIT_MODE_MAP = {
|
||||
CompitHVACMode.COOL: HVACMode.COOL,
|
||||
CompitHVACMode.HEAT: HVACMode.HEAT,
|
||||
CompitHVACMode.OFF: HVACMode.OFF,
|
||||
}
|
||||
|
||||
COMPIT_FANSPEED_MAP = {
|
||||
CompitFanMode.OFF: FAN_OFF,
|
||||
CompitFanMode.AUTO: FAN_AUTO,
|
||||
CompitFanMode.LOW: FAN_LOW,
|
||||
CompitFanMode.MEDIUM: FAN_MEDIUM,
|
||||
CompitFanMode.HIGH: FAN_HIGH,
|
||||
CompitFanMode.HOLIDAY: FAN_AUTO,
|
||||
}
|
||||
|
||||
COMPIT_PRESET_MAP = {
|
||||
CompitPresetMode.AUTO: PRESET_HOME,
|
||||
CompitPresetMode.HOLIDAY: PRESET_ECO,
|
||||
CompitPresetMode.MANUAL: PRESET_NONE,
|
||||
CompitPresetMode.AWAY: PRESET_AWAY,
|
||||
}
|
||||
|
||||
HVAC_MODE_TO_COMPIT_MODE = {v: k for k, v in COMPIT_MODE_MAP.items()}
|
||||
FAN_MODE_TO_COMPIT_FAN_MODE = {v: k for k, v in COMPIT_FANSPEED_MAP.items()}
|
||||
PRESET_MODE_TO_COMPIT_PRESET_MODE = {v: k for k, v in COMPIT_PRESET_MAP.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CompitConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the CompitClimate platform from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
climate_entities = []
|
||||
for device_id in coordinator.connector.all_devices:
|
||||
device = coordinator.connector.all_devices[device_id]
|
||||
|
||||
if device.definition.device_class == CLIMATE_DEVICE_CLASS:
|
||||
climate_entities.append(
|
||||
CompitClimate(
|
||||
coordinator,
|
||||
device_id,
|
||||
{
|
||||
parameter.parameter_code: parameter
|
||||
for parameter in device.definition.parameters
|
||||
},
|
||||
device.definition.name,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_devices(climate_entities)
|
||||
|
||||
|
||||
class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntity):
|
||||
"""Representation of a Compit climate device."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_modes = [*COMPIT_MODE_MAP.values()]
|
||||
_attr_name = None
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CompitDataUpdateCoordinator,
|
||||
device_id: int,
|
||||
parameters: dict[str, Parameter],
|
||||
device_name: str,
|
||||
) -> None:
|
||||
"""Initialize the climate device."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{device_name}_{device_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(device_id))},
|
||||
name=device_name,
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
model=device_name,
|
||||
)
|
||||
|
||||
self.parameters = parameters
|
||||
self.device_id = device_id
|
||||
self.available_presets: Parameter | None = self.parameters.get(
|
||||
CompitParameter.PRESET_MODE.value
|
||||
)
|
||||
self.available_fan_modes: Parameter | None = self.parameters.get(
|
||||
CompitParameter.FAN_MODE.value
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.device_id in self.coordinator.connector.all_devices
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE)
|
||||
if value is None:
|
||||
return None
|
||||
return float(value.value)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE)
|
||||
if value is None:
|
||||
return None
|
||||
return float(value.value)
|
||||
|
||||
@cached_property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return the available preset modes."""
|
||||
if self.available_presets is None or self.available_presets.details is None:
|
||||
return []
|
||||
|
||||
preset_modes = []
|
||||
for item in self.available_presets.details:
|
||||
if item is not None:
|
||||
ha_preset = COMPIT_PRESET_MAP.get(CompitPresetMode(item.state))
|
||||
if ha_preset and ha_preset not in preset_modes:
|
||||
preset_modes.append(ha_preset)
|
||||
|
||||
return preset_modes
|
||||
|
||||
@cached_property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return the available fan modes."""
|
||||
if self.available_fan_modes is None or self.available_fan_modes.details is None:
|
||||
return []
|
||||
|
||||
fan_modes = []
|
||||
for item in self.available_fan_modes.details:
|
||||
if item is not None:
|
||||
ha_fan_mode = COMPIT_FANSPEED_MAP.get(CompitFanMode(item.state))
|
||||
if ha_fan_mode and ha_fan_mode not in fan_modes:
|
||||
fan_modes.append(ha_fan_mode)
|
||||
|
||||
return fan_modes
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
|
||||
|
||||
if preset_mode:
|
||||
compit_preset_mode = CompitPresetMode(preset_mode.value)
|
||||
return COMPIT_PRESET_MAP.get(compit_preset_mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
|
||||
if fan_mode:
|
||||
compit_fan_mode = CompitFanMode(fan_mode.value)
|
||||
return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
|
||||
if hvac_mode:
|
||||
compit_hvac_mode = CompitHVACMode(hvac_mode.value)
|
||||
return COMPIT_MODE_MAP.get(compit_hvac_mode)
|
||||
return None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temp is None:
|
||||
raise ServiceValidationError("Temperature argument missing")
|
||||
await self.set_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE, temp)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target HVAC mode."""
|
||||
|
||||
if not (mode := HVAC_MODE_TO_COMPIT_MODE.get(hvac_mode)):
|
||||
raise ServiceValidationError(f"Invalid hvac mode {hvac_mode}")
|
||||
|
||||
await self.set_parameter_value(CompitParameter.HVAC_MODE, mode.value)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
|
||||
compit_preset = PRESET_MODE_TO_COMPIT_PRESET_MODE.get(preset_mode)
|
||||
if compit_preset is None:
|
||||
raise ServiceValidationError(f"Invalid preset mode: {preset_mode}")
|
||||
|
||||
await self.set_parameter_value(CompitParameter.PRESET_MODE, compit_preset.value)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
|
||||
compit_fan_mode = FAN_MODE_TO_COMPIT_FAN_MODE.get(fan_mode)
|
||||
if compit_fan_mode is None:
|
||||
raise ServiceValidationError(f"Invalid fan mode: {fan_mode}")
|
||||
|
||||
await self.set_parameter_value(CompitParameter.FAN_MODE, compit_fan_mode.value)
|
||||
|
||||
async def set_parameter_value(self, parameter: CompitParameter, value: int) -> None:
|
||||
"""Call the API to set a parameter to a new value."""
|
||||
await self.coordinator.connector.set_device_parameter(
|
||||
self.device_id, parameter, value
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
|
||||
"""Get the parameter value from the device state."""
|
||||
return self.coordinator.connector.get_device_parameter(
|
||||
self.device_id, parameter
|
||||
)
|
@@ -1,110 +0,0 @@
|
||||
"""Config flow for Compit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CompitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Compit."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
session = async_create_clientsession(self.hass)
|
||||
api = CompitApiConnector(session)
|
||||
success = False
|
||||
try:
|
||||
success = await api.init(
|
||||
user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD],
|
||||
self.hass.config.language,
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if not success:
|
||||
# Api returned unexpected result but no exception
|
||||
_LOGGER.error("Compit api returned unexpected result")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_EMAIL])
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle re-auth."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm re-authentication."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
reauth_entry_data = reauth_entry.data
|
||||
|
||||
if user_input:
|
||||
# Reuse async_step_user with combined credentials
|
||||
return await self.async_step_user(
|
||||
{
|
||||
CONF_EMAIL: reauth_entry_data[CONF_EMAIL],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_SCHEMA,
|
||||
description_placeholders={CONF_EMAIL: reauth_entry_data[CONF_EMAIL]},
|
||||
errors=errors,
|
||||
)
|
@@ -1,4 +0,0 @@
|
||||
"""Constants for the Compit integration."""
|
||||
|
||||
DOMAIN = "compit"
|
||||
MANUFACTURER_NAME = "Compit"
|
@@ -1,43 +0,0 @@
|
||||
"""Define an object to manage fetching Compit data."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from compit_inext_api import CompitApiConnector, DeviceInstance
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
type CompitConfigEntry = ConfigEntry[CompitDataUpdateCoordinator]
|
||||
|
||||
|
||||
class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance]]):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
connector: CompitApiConnector,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.connector = connector
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[int, DeviceInstance]:
|
||||
"""Update data via library."""
|
||||
await self.connector.update_state(device_id=None) # Update all devices
|
||||
return self.connector.all_devices
|
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "compit",
|
||||
"name": "Compit",
|
||||
"codeowners": ["@Przemko92"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/compit",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.3.1"]
|
||||
}
|
@@ -1,86 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use any common modules.
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and does not support discovery.
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any entities that should disabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no need for icon translations.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: done
|
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Please enter your https://inext.compit.pl/ credentials.",
|
||||
"title": "Connect to Compit iNext",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "The email address of your inext.compit.pl account",
|
||||
"password": "The password of your inext.compit.pl account"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Please update your password for {email}",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::compit::config::step::user::data_description::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -50,13 +50,14 @@ from .const import (
|
||||
ATTR_LANGUAGE,
|
||||
ATTR_TEXT,
|
||||
DATA_COMPONENT,
|
||||
DATA_DEFAULT_ENTITY,
|
||||
DOMAIN,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .default_agent import async_setup_default_agent
|
||||
from .default_agent import DefaultAgent, async_setup_default_agent
|
||||
from .entity import ConversationEntity
|
||||
from .http import async_setup as async_setup_conversation_http
|
||||
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||
@@ -141,7 +142,7 @@ def async_unset_agent(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Unset the agent to handle the conversations."""
|
||||
"""Set the agent to handle the conversations."""
|
||||
get_agent_manager(hass).async_unset_agent(config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -240,10 +241,10 @@ async def async_handle_sentence_triggers(
|
||||
|
||||
Returns None if no match occurred.
|
||||
"""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
default_agent = async_get_agent(hass)
|
||||
assert isinstance(default_agent, DefaultAgent)
|
||||
|
||||
return await agent.async_handle_sentence_triggers(user_input)
|
||||
return await default_agent.async_handle_sentence_triggers(user_input)
|
||||
|
||||
|
||||
async def async_handle_intents(
|
||||
@@ -256,10 +257,12 @@ async def async_handle_intents(
|
||||
|
||||
Returns None if no match occurred.
|
||||
"""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
default_agent = async_get_agent(hass)
|
||||
assert isinstance(default_agent, DefaultAgent)
|
||||
|
||||
return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
|
||||
return await default_agent.async_handle_intents(
|
||||
user_input, intent_filter=intent_filter
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -295,9 +298,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def handle_reload(service: ServiceCall) -> None:
|
||||
"""Reload intents."""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
if agent is not None:
|
||||
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
|
||||
await hass.data[DATA_DEFAULT_ENTITY].async_reload(
|
||||
language=service.data.get(ATTR_LANGUAGE)
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
@@ -4,21 +4,15 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Context,
|
||||
HomeAssistant,
|
||||
async_get_hass,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent, singleton
|
||||
|
||||
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT
|
||||
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT
|
||||
from .entity import ConversationEntity
|
||||
from .models import (
|
||||
AbstractConversationAgent,
|
||||
@@ -34,10 +28,6 @@ from .trace import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .default_agent import DefaultAgent
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
|
||||
@singleton.singleton("conversation_agent")
|
||||
@callback
|
||||
@@ -59,10 +49,8 @@ def async_get_agent(
|
||||
hass: HomeAssistant, agent_id: str | None = None
|
||||
) -> AbstractConversationAgent | ConversationEntity | None:
|
||||
"""Get specified agent."""
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
if agent_id is None or agent_id == HOME_ASSISTANT_AGENT:
|
||||
return manager.default_agent
|
||||
return hass.data[DATA_DEFAULT_ENTITY]
|
||||
|
||||
if "." in agent_id:
|
||||
return hass.data[DATA_COMPONENT].get_entity(agent_id)
|
||||
@@ -146,8 +134,6 @@ class AgentManager:
|
||||
"""Initialize the conversation agents."""
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
self.triggers_details: list[TriggerDetails] = []
|
||||
|
||||
@callback
|
||||
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
|
||||
@@ -196,23 +182,3 @@ class AgentManager:
|
||||
def async_unset_agent(self, agent_id: str) -> None:
|
||||
"""Unset the agent."""
|
||||
self._agents.pop(agent_id, None)
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
agent.update_triggers(self.triggers_details)
|
||||
self.default_agent = agent
|
||||
|
||||
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||
"""Register a trigger."""
|
||||
self.triggers_details.append(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
|
||||
@callback
|
||||
def unregister_trigger() -> None:
|
||||
"""Unregister the trigger."""
|
||||
self.triggers_details.remove(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
|
||||
return unregister_trigger
|
||||
|
@@ -10,9 +10,11 @@ from homeassistant.util.hass_dict import HassKey
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from .default_agent import DefaultAgent
|
||||
from .entity import ConversationEntity
|
||||
|
||||
DOMAIN = "conversation"
|
||||
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
HOME_ASSISTANT_AGENT = "conversation.home_assistant"
|
||||
|
||||
ATTR_TEXT = "text"
|
||||
@@ -24,6 +26,7 @@ SERVICE_PROCESS = "process"
|
||||
SERVICE_RELOAD = "reload"
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
|
||||
DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity")
|
||||
|
||||
|
||||
class ConversationEntityFeature(IntFlag):
|
||||
|
@@ -4,11 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import IO, Any, cast
|
||||
|
||||
@@ -51,7 +53,6 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||
from homeassistant.core import Event, callback
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
@@ -67,22 +68,25 @@ from homeassistant.helpers.event import async_track_state_added_domain
|
||||
from homeassistant.util import language as language_util
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent_manager import get_agent_manager
|
||||
from .chat_log import AssistantContent, ChatLog
|
||||
from .const import DOMAIN, ConversationEntityFeature
|
||||
from .const import (
|
||||
DATA_DEFAULT_ENTITY,
|
||||
DEFAULT_EXPOSED_ATTRIBUTES,
|
||||
DOMAIN,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
REGEX_TYPE = type(re.compile(""))
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
]
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
||||
@@ -108,6 +112,14 @@ class LanguageIntents:
|
||||
fuzzy_responses: FuzzyLanguageResponses | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerData:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SentenceTriggerResult:
|
||||
"""Result when matching a sentence trigger in an automation."""
|
||||
@@ -143,8 +155,8 @@ class IntentCacheKey:
|
||||
language: str
|
||||
"""Language of text."""
|
||||
|
||||
satellite_id: str | None
|
||||
"""Satellite id from user input."""
|
||||
device_id: str | None
|
||||
"""Device id from user input."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -197,9 +209,9 @@ async def async_setup_default_agent(
|
||||
config_intents: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set up entity registry listener for the default agent."""
|
||||
agent = DefaultAgent(hass, config_intents)
|
||||
await entity_component.async_add_entities([agent])
|
||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||
entity = DefaultAgent(hass, config_intents)
|
||||
await entity_component.async_add_entities([entity])
|
||||
hass.data[DATA_DEFAULT_ENTITY] = entity
|
||||
|
||||
@core.callback
|
||||
def async_entity_state_listener(
|
||||
@@ -230,23 +242,21 @@ class DefaultAgent(ConversationEntity):
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# intent -> [sentences]
|
||||
self._config_intents: dict[str, Any] = config_intents
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self._triggers_details: list[TriggerDetails] = []
|
||||
self._trigger_intents: Intents | None = None
|
||||
|
||||
# Slot lists for entities, areas, etc.
|
||||
self._slot_lists: dict[str, SlotList] | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
|
||||
# Used to filter slot lists before intent matching
|
||||
self._exposed_names_trie: Trie | None = None
|
||||
self._unexposed_names_trie: Trie | None = None
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self.trigger_sentences: list[TriggerData] = []
|
||||
self._trigger_intents: Intents | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# LRU cache to avoid unnecessary intent matching
|
||||
self._intent_cache = IntentCache(capacity=128)
|
||||
|
||||
@@ -435,15 +445,9 @@ class DefaultAgent(ConversationEntity):
|
||||
}
|
||||
for entity in result.entities_list
|
||||
}
|
||||
|
||||
satellite_id = user_input.satellite_id
|
||||
device_id = user_input.device_id
|
||||
satellite_area, device_id = self._get_satellite_area_and_device(
|
||||
satellite_id, device_id
|
||||
)
|
||||
if satellite_area is not None:
|
||||
slots["preferred_area_id"] = {"value": satellite_area.id}
|
||||
|
||||
device_area = self._get_device_area(user_input.device_id)
|
||||
if device_area:
|
||||
slots["preferred_area_id"] = {"value": device_area.id}
|
||||
async_conversation_trace_append(
|
||||
ConversationTraceEventType.TOOL_CALL,
|
||||
{
|
||||
@@ -465,8 +469,8 @@ class DefaultAgent(ConversationEntity):
|
||||
user_input.context,
|
||||
language,
|
||||
assistant=DOMAIN,
|
||||
device_id=device_id,
|
||||
satellite_id=satellite_id,
|
||||
device_id=user_input.device_id,
|
||||
satellite_id=user_input.satellite_id,
|
||||
conversation_agent_id=user_input.agent_id,
|
||||
)
|
||||
except intent.MatchFailedError as match_error:
|
||||
@@ -532,9 +536,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
# Try cache first
|
||||
cache_key = IntentCacheKey(
|
||||
text=user_input.text,
|
||||
language=language,
|
||||
satellite_id=user_input.satellite_id,
|
||||
text=user_input.text, language=language, device_id=user_input.device_id
|
||||
)
|
||||
cache_value = self._intent_cache.get(cache_key)
|
||||
if cache_value is not None:
|
||||
@@ -844,7 +846,7 @@ class DefaultAgent(ConversationEntity):
|
||||
context = {"domain": state.domain}
|
||||
if state.attributes:
|
||||
# Include some attributes
|
||||
for attr in _DEFAULT_EXPOSED_ATTRIBUTES:
|
||||
for attr in DEFAULT_EXPOSED_ATTRIBUTES:
|
||||
if attr not in state.attributes:
|
||||
continue
|
||||
context[attr] = state.attributes[attr]
|
||||
@@ -1190,8 +1192,8 @@ class DefaultAgent(ConversationEntity):
|
||||
fuzzy_responses=fuzzy_responses,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_clear_slot_list(self, event: Event[Any] | None = None) -> None:
|
||||
@core.callback
|
||||
def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None:
|
||||
"""Clear slot lists when a registry has changed."""
|
||||
# Two subscribers can be scheduled at same time
|
||||
_LOGGER.debug("Clearing slot lists")
|
||||
@@ -1304,40 +1306,28 @@ class DefaultAgent(ConversationEntity):
|
||||
self, user_input: ConversationInput
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return intent recognition context for user input."""
|
||||
satellite_area, _ = self._get_satellite_area_and_device(
|
||||
user_input.satellite_id, user_input.device_id
|
||||
)
|
||||
if satellite_area is None:
|
||||
if not user_input.device_id:
|
||||
return None
|
||||
|
||||
return {"area": {"value": satellite_area.name, "text": satellite_area.name}}
|
||||
device_area = self._get_device_area(user_input.device_id)
|
||||
if device_area is None:
|
||||
return None
|
||||
|
||||
def _get_satellite_area_and_device(
|
||||
self, satellite_id: str | None, device_id: str | None = None
|
||||
) -> tuple[ar.AreaEntry | None, str | None]:
|
||||
"""Return area entry and device id."""
|
||||
hass = self.hass
|
||||
return {"area": {"value": device_area.name, "text": device_area.name}}
|
||||
|
||||
area_id: str | None = None
|
||||
def _get_device_area(self, device_id: str | None) -> ar.AreaEntry | None:
|
||||
"""Return area object for given device identifier."""
|
||||
if device_id is None:
|
||||
return None
|
||||
|
||||
if (
|
||||
satellite_id is not None
|
||||
and (entity_entry := er.async_get(hass).async_get(satellite_id)) is not None
|
||||
):
|
||||
area_id = entity_entry.area_id
|
||||
device_id = entity_entry.device_id
|
||||
devices = dr.async_get(self.hass)
|
||||
device = devices.async_get(device_id)
|
||||
if (device is None) or (device.area_id is None):
|
||||
return None
|
||||
|
||||
if (
|
||||
area_id is None
|
||||
and device_id is not None
|
||||
and (device_entry := dr.async_get(hass).async_get(device_id)) is not None
|
||||
):
|
||||
area_id = device_entry.area_id
|
||||
areas = ar.async_get(self.hass)
|
||||
|
||||
if area_id is None:
|
||||
return None, device_id
|
||||
|
||||
return ar.async_get(hass).async_get_area(area_id), device_id
|
||||
return areas.async_get_area(device.area_id)
|
||||
|
||||
def _get_error_text(
|
||||
self,
|
||||
@@ -1361,14 +1351,22 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
return response_template.async_render(response_args)
|
||||
|
||||
@callback
|
||||
def update_triggers(self, triggers_details: list[TriggerDetails]) -> None:
|
||||
"""Update triggers."""
|
||||
self._triggers_details = triggers_details
|
||||
@core.callback
|
||||
def register_trigger(
|
||||
self,
|
||||
sentences: list[str],
|
||||
callback: TRIGGER_CALLBACK_TYPE,
|
||||
) -> core.CALLBACK_TYPE:
|
||||
"""Register a list of sentences that will trigger a callback when recognized."""
|
||||
trigger_data = TriggerData(sentences=sentences, callback=callback)
|
||||
self.trigger_sentences.append(trigger_data)
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
return functools.partial(self._unregister_trigger, trigger_data)
|
||||
|
||||
@core.callback
|
||||
def _rebuild_trigger_intents(self) -> None:
|
||||
"""Rebuild the HassIL intents object from the current trigger sentences."""
|
||||
intents_dict = {
|
||||
@@ -1377,8 +1375,8 @@ class DefaultAgent(ConversationEntity):
|
||||
# Use trigger data index as a virtual intent name for HassIL.
|
||||
# This works because the intents are rebuilt on every
|
||||
# register/unregister.
|
||||
str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]}
|
||||
for trigger_id, trigger_details in enumerate(self._triggers_details)
|
||||
str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]}
|
||||
for trigger_id, trigger_data in enumerate(self.trigger_sentences)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1398,6 +1396,14 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
|
||||
|
||||
@core.callback
|
||||
def _unregister_trigger(self, trigger_data: TriggerData) -> None:
|
||||
"""Unregister a set of trigger sentences."""
|
||||
self.trigger_sentences.remove(trigger_data)
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
async def async_recognize_sentence_trigger(
|
||||
self, user_input: ConversationInput
|
||||
) -> SentenceTriggerResult | None:
|
||||
@@ -1406,7 +1412,7 @@ class DefaultAgent(ConversationEntity):
|
||||
Calls the registered callbacks if there's a match and returns a sentence
|
||||
trigger result.
|
||||
"""
|
||||
if not self._triggers_details:
|
||||
if not self.trigger_sentences:
|
||||
# No triggers registered
|
||||
return None
|
||||
|
||||
@@ -1451,7 +1457,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
# Gather callback responses in parallel
|
||||
trigger_callbacks = [
|
||||
self._triggers_details[trigger_id].callback(user_input, trigger_result)
|
||||
self.trigger_sentences[trigger_id].callback(user_input, trigger_result)
|
||||
for trigger_id, trigger_result in result.matched_triggers.items()
|
||||
]
|
||||
|
||||
|
@@ -25,7 +25,7 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .const import DATA_COMPONENT
|
||||
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
|
||||
from .default_agent import (
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
@@ -169,11 +169,11 @@ async def websocket_list_sentences(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""List custom registered sentences."""
|
||||
manager = get_agent_manager(hass)
|
||||
agent = hass.data[DATA_DEFAULT_ENTITY]
|
||||
|
||||
sentences = []
|
||||
for trigger_details in manager.triggers_details:
|
||||
sentences.extend(trigger_details.sentences)
|
||||
for trigger_data in agent.trigger_sentences:
|
||||
sentences.extend(trigger_data.sentences)
|
||||
|
||||
connection.send_result(msg["id"], {"trigger_sentences": sentences})
|
||||
|
||||
@@ -191,8 +191,7 @@ async def websocket_hass_agent_debug(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Return intents that would be matched by the default agent for a list of sentences."""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
agent = hass.data[DATA_DEFAULT_ENTITY]
|
||||
|
||||
# Return results for each sentence in the same order as the input.
|
||||
result_dicts: list[dict[str, Any] | None] = []
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"]
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"]
|
||||
}
|
||||
|
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hassil.recognize import RecognizeResult
|
||||
@@ -17,27 +15,14 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.script import ScriptRunResult
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType
|
||||
|
||||
from .agent_manager import get_agent_manager
|
||||
from .const import DOMAIN
|
||||
from .const import DATA_DEFAULT_ENTITY, DOMAIN
|
||||
from .models import ConversationInput
|
||||
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerDetails:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
@@ -85,8 +70,6 @@ async def async_attach_trigger(
|
||||
trigger_data = trigger_info["trigger_data"]
|
||||
sentences = config.get(CONF_COMMAND, [])
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
job = HassJob(action)
|
||||
|
||||
async def call_action(
|
||||
@@ -108,14 +91,6 @@ async def async_attach_trigger(
|
||||
for entity_name, entity in result.entities.items()
|
||||
}
|
||||
|
||||
satellite_id = user_input.satellite_id
|
||||
device_id = user_input.device_id
|
||||
if (
|
||||
satellite_id is not None
|
||||
and (satellite_entry := ent_reg.async_get(satellite_id)) is not None
|
||||
):
|
||||
device_id = satellite_entry.device_id
|
||||
|
||||
trigger_input: dict[str, Any] = { # Satisfy type checker
|
||||
**trigger_data,
|
||||
"platform": DOMAIN,
|
||||
@@ -124,8 +99,8 @@ async def async_attach_trigger(
|
||||
"slots": { # direct access to values
|
||||
entity_name: entity["value"] for entity_name, entity in details.items()
|
||||
},
|
||||
"device_id": device_id,
|
||||
"satellite_id": satellite_id,
|
||||
"device_id": user_input.device_id,
|
||||
"satellite_id": user_input.satellite_id,
|
||||
"user_input": user_input.as_dict(),
|
||||
}
|
||||
|
||||
@@ -148,6 +123,4 @@ async def async_attach_trigger(
|
||||
# two trigger copies for who will provide a response.
|
||||
return None
|
||||
|
||||
return get_agent_manager(hass).register_trigger(
|
||||
TriggerDetails(sentences=sentences, callback=call_action)
|
||||
)
|
||||
return hass.data[DATA_DEFAULT_ENTITY].register_trigger(sentences, call_action)
|
||||
|
@@ -1,58 +0,0 @@
|
||||
"""The Cync integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycync import Auth, Cync, User
|
||||
from pycync.exceptions import AuthFailedError, CyncError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZE_STRING,
|
||||
CONF_EXPIRES_AT,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_USER_ID,
|
||||
)
|
||||
from .coordinator import CyncConfigEntry, CyncCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
|
||||
"""Set up Cync from a config entry."""
|
||||
user_info = User(
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
entry.data[CONF_REFRESH_TOKEN],
|
||||
entry.data[CONF_AUTHORIZE_STRING],
|
||||
entry.data[CONF_USER_ID],
|
||||
expires_at=entry.data[CONF_EXPIRES_AT],
|
||||
)
|
||||
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
||||
|
||||
try:
|
||||
cync = await Cync.create(cync_auth)
|
||||
except AuthFailedError as ex:
|
||||
raise ConfigEntryAuthFailed("User token invalid") from ex
|
||||
except CyncError as ex:
|
||||
raise ConfigEntryNotReady("Unable to connect to Cync") from ex
|
||||
|
||||
devices_coordinator = CyncCoordinator(hass, entry, cync)
|
||||
|
||||
cync.set_update_callback(devices_coordinator.on_data_update)
|
||||
|
||||
await devices_coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = devices_coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
cync = entry.runtime_data.cync
|
||||
await cync.shut_down()
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
@@ -1,118 +0,0 @@
|
||||
"""Config flow for the Cync integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pycync import Auth
|
||||
from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZE_STRING,
|
||||
CONF_EXPIRES_AT,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TWO_FACTOR_CODE,
|
||||
CONF_USER_ID,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str})
|
||||
|
||||
|
||||
class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cync."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
cync_auth: Auth
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt login with user credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
self.cync_auth = Auth(
|
||||
async_get_clientsession(self.hass),
|
||||
username=user_input[CONF_EMAIL],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
await self.cync_auth.login()
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
except CyncError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_two_factor(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt login with the two factor auth code sent to the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
|
||||
)
|
||||
try:
|
||||
await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE])
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CyncError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def _create_config_entry(self, user_email: str) -> ConfigFlowResult:
|
||||
"""Create the Cync config entry using input user data."""
|
||||
|
||||
cync_user = self.cync_auth.user
|
||||
await self.async_set_unique_id(str(cync_user.user_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
config = {
|
||||
CONF_USER_ID: cync_user.user_id,
|
||||
CONF_AUTHORIZE_STRING: cync_user.authorize,
|
||||
CONF_EXPIRES_AT: cync_user.expires_at,
|
||||
CONF_ACCESS_TOKEN: cync_user.access_token,
|
||||
CONF_REFRESH_TOKEN: cync_user.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(title=user_email, data=config)
|
@@ -1,9 +0,0 @@
|
||||
"""Constants for the Cync integration."""
|
||||
|
||||
DOMAIN = "cync"
|
||||
|
||||
CONF_TWO_FACTOR_CODE = "two_factor_code"
|
||||
CONF_USER_ID = "user_id"
|
||||
CONF_AUTHORIZE_STRING = "authorize_string"
|
||||
CONF_EXPIRES_AT = "expires_at"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
@@ -1,87 +0,0 @@
|
||||
"""Coordinator to handle keeping device states up to date."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import time
|
||||
|
||||
from pycync import Cync, CyncDevice, User
|
||||
from pycync.exceptions import AuthFailedError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type CyncConfigEntry = ConfigEntry[CyncCoordinator]
|
||||
|
||||
|
||||
class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]):
|
||||
"""Coordinator to handle updating Cync device states."""
|
||||
|
||||
config_entry: CyncConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync
|
||||
) -> None:
|
||||
"""Initialize the Cync coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Cync Data Coordinator",
|
||||
config_entry=config_entry,
|
||||
update_interval=timedelta(seconds=30),
|
||||
always_update=True,
|
||||
)
|
||||
self.cync = cync
|
||||
|
||||
async def on_data_update(self, data: dict[int, CyncDevice]) -> None:
|
||||
"""Update registered devices with new data."""
|
||||
merged_data = self.data | data if self.data else data
|
||||
self.async_set_updated_data(merged_data)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator with initial device states."""
|
||||
logged_in_user = self.cync.get_logged_in_user()
|
||||
if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]:
|
||||
await self._update_config_cync_credentials(logged_in_user)
|
||||
|
||||
async def _async_update_data(self) -> dict[int, CyncDevice]:
|
||||
"""First, refresh the user's auth token if it is set to expire in less than one hour.
|
||||
|
||||
Then, fetch all current device states.
|
||||
"""
|
||||
|
||||
logged_in_user = self.cync.get_logged_in_user()
|
||||
if logged_in_user.expires_at - time.time() < 3600:
|
||||
await self._async_refresh_cync_credentials()
|
||||
|
||||
self.cync.update_device_states()
|
||||
current_device_states = self.cync.get_devices()
|
||||
|
||||
return {device.device_id: device for device in current_device_states}
|
||||
|
||||
async def _async_refresh_cync_credentials(self) -> None:
|
||||
"""Attempt to refresh the Cync user's authentication token."""
|
||||
|
||||
try:
|
||||
refreshed_user = await self.cync.refresh_credentials()
|
||||
except AuthFailedError as ex:
|
||||
raise ConfigEntryAuthFailed("Unable to refresh user token") from ex
|
||||
else:
|
||||
await self._update_config_cync_credentials(refreshed_user)
|
||||
|
||||
async def _update_config_cync_credentials(self, user_info: User) -> None:
|
||||
"""Update the config entry with current user info."""
|
||||
|
||||
new_data = {**self.config_entry.data}
|
||||
new_data[CONF_ACCESS_TOKEN] = user_info.access_token
|
||||
new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token
|
||||
new_data[CONF_EXPIRES_AT] = user_info.expires_at
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
|
@@ -1,45 +0,0 @@
|
||||
"""Setup for a generic entity type for the Cync integration."""
|
||||
|
||||
from pycync.devices import CyncDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CyncCoordinator
|
||||
|
||||
|
||||
class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]):
|
||||
"""Generic base entity for Cync devices."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CyncDevice,
|
||||
coordinator: CyncCoordinator,
|
||||
room_name: str | None = None,
|
||||
) -> None:
|
||||
"""Pass coordinator to CoordinatorEntity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._cync_device_id = device.device_id
|
||||
self._attr_unique_id = device.unique_id
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
manufacturer="GE Lighting",
|
||||
name=device.name,
|
||||
suggested_area=room_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Determines whether this device is currently available."""
|
||||
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data is not None
|
||||
and self._cync_device_id in self.coordinator.data
|
||||
and self.coordinator.data[self._cync_device_id].is_online
|
||||
)
|
@@ -1,180 +0,0 @@
|
||||
"""Support for Cync light entities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pycync import CyncLight
|
||||
from pycync.devices.capabilities import CyncCapability
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.color import value_to_brightness
|
||||
from homeassistant.util.scaling import scale_ranged_value_to_int_range
|
||||
|
||||
from .coordinator import CyncConfigEntry, CyncCoordinator
|
||||
from .entity import CyncBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CyncConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Cync lights from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
cync = coordinator.cync
|
||||
|
||||
entities_to_add = []
|
||||
|
||||
for home in cync.get_homes():
|
||||
for room in home.rooms:
|
||||
room_lights = [
|
||||
CyncLightEntity(device, coordinator, room.name)
|
||||
for device in room.devices
|
||||
if isinstance(device, CyncLight)
|
||||
]
|
||||
entities_to_add.extend(room_lights)
|
||||
|
||||
group_lights = [
|
||||
CyncLightEntity(device, coordinator, room.name)
|
||||
for group in room.groups
|
||||
for device in group.devices
|
||||
if isinstance(device, CyncLight)
|
||||
]
|
||||
entities_to_add.extend(group_lights)
|
||||
|
||||
async_add_entities(entities_to_add)
|
||||
|
||||
|
||||
class CyncLightEntity(CyncBaseEntity, LightEntity):
|
||||
"""Representation of a Cync light."""
|
||||
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_min_color_temp_kelvin = 2000
|
||||
_attr_max_color_temp_kelvin = 7000
|
||||
_attr_translation_key = "light"
|
||||
_attr_name = None
|
||||
|
||||
BRIGHTNESS_SCALE = (0, 100)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CyncLight,
|
||||
coordinator: CyncCoordinator,
|
||||
room_name: str | None = None,
|
||||
) -> None:
|
||||
"""Set up base attributes."""
|
||||
super().__init__(device, coordinator, room_name)
|
||||
|
||||
supported_color_modes = {ColorMode.ONOFF}
|
||||
if device.supports_capability(CyncCapability.CCT_COLOR):
|
||||
supported_color_modes.add(ColorMode.COLOR_TEMP)
|
||||
if device.supports_capability(CyncCapability.DIMMING):
|
||||
supported_color_modes.add(ColorMode.BRIGHTNESS)
|
||||
if device.supports_capability(CyncCapability.RGB_COLOR):
|
||||
supported_color_modes.add(ColorMode.RGB)
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(
|
||||
supported_color_modes
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the light is on."""
|
||||
return self._device.is_on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Provide the light's current brightness."""
|
||||
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int:
|
||||
"""Return color temperature in kelvin."""
|
||||
return scale_ranged_value_to_int_range(
|
||||
(1, 100),
|
||||
(self.min_color_temp_kelvin, self.max_color_temp_kelvin),
|
||||
self._device.color_temp,
|
||||
)
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int]:
|
||||
"""Provide the light's current color in RGB format."""
|
||||
return self._device.rgb
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the active color mode."""
|
||||
|
||||
if (
|
||||
self._device.supports_capability(CyncCapability.CCT_COLOR)
|
||||
and self._device.color_mode > 0
|
||||
and self._device.color_mode <= 100
|
||||
):
|
||||
return ColorMode.COLOR_TEMP
|
||||
if (
|
||||
self._device.supports_capability(CyncCapability.RGB_COLOR)
|
||||
and self._device.color_mode == 254
|
||||
):
|
||||
return ColorMode.RGB
|
||||
if self._device.supports_capability(CyncCapability.DIMMING):
|
||||
return ColorMode.BRIGHTNESS
|
||||
|
||||
return ColorMode.ONOFF
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Process an action on the light."""
|
||||
if not kwargs:
|
||||
await self._device.turn_on()
|
||||
|
||||
elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None:
|
||||
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
converted_color_temp = self._normalize_color_temp(color_temp)
|
||||
|
||||
await self._device.set_color_temp(converted_color_temp)
|
||||
elif kwargs.get(ATTR_RGB_COLOR) is not None:
|
||||
rgb = kwargs.get(ATTR_RGB_COLOR)
|
||||
|
||||
await self._device.set_rgb(rgb)
|
||||
elif kwargs.get(ATTR_BRIGHTNESS) is not None:
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
converted_brightness = self._normalize_brightness(brightness)
|
||||
|
||||
await self._device.set_brightness(converted_brightness)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await self._device.turn_off()
|
||||
|
||||
def _normalize_brightness(self, brightness: float | None) -> int | None:
|
||||
"""Return calculated brightness value scaled between 0-100."""
|
||||
if brightness is not None:
|
||||
return int((brightness / 255) * 100)
|
||||
|
||||
return None
|
||||
|
||||
def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None:
|
||||
"""Return calculated color temp value scaled between 1-100."""
|
||||
if color_temp_kelvin is not None:
|
||||
kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin
|
||||
scaled_kelvin = int(
|
||||
((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100
|
||||
)
|
||||
if scaled_kelvin == 0:
|
||||
scaled_kelvin += 1
|
||||
|
||||
return scaled_kelvin
|
||||
return None
|
||||
|
||||
@property
|
||||
def _device(self) -> CyncLight:
|
||||
"""Fetch the reference to the backing Cync light for this device."""
|
||||
|
||||
return self.coordinator.data[self._cync_device_id]
|
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "cync",
|
||||
"name": "Cync",
|
||||
"codeowners": ["@Kinachi249"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cync",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.0"]
|
||||
}
|
@@ -1,69 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Your Cync account's email address",
|
||||
"password": "Your Cync account's password"
|
||||
}
|
||||
},
|
||||
"two_factor": {
|
||||
"data": {
|
||||
"two_factor_code": "Two-factor code"
|
||||
},
|
||||
"data_description": {
|
||||
"two_factor_code": "The two-factor code sent to your Cync account's email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -9,7 +9,6 @@
|
||||
"conversation",
|
||||
"dhcp",
|
||||
"energy",
|
||||
"file",
|
||||
"go2rtc",
|
||||
"history",
|
||||
"homeassistant_alerts",
|
||||
@@ -20,7 +19,6 @@
|
||||
"ssdp",
|
||||
"stream",
|
||||
"sun",
|
||||
"usage_prediction",
|
||||
"usb",
|
||||
"webhook",
|
||||
"zeroconf"
|
||||
|
@@ -43,5 +43,3 @@ class DelugeSensorType(enum.StrEnum):
|
||||
UPLOAD_SPEED_SENSOR = "upload_speed"
|
||||
PROTOCOL_TRAFFIC_UPLOAD_SPEED_SENSOR = "protocol_traffic_upload_speed"
|
||||
PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR = "protocol_traffic_download_speed"
|
||||
DOWNLOADING_COUNT_SENSOR = "downloading_count"
|
||||
SEEDING_COUNT_SENSOR = "seeding_count"
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
from ssl import SSLError
|
||||
from typing import Any
|
||||
@@ -15,22 +14,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import LOGGER, DelugeGetSessionStatusKeys, DelugeSensorType
|
||||
from .const import LOGGER, DelugeGetSessionStatusKeys
|
||||
|
||||
type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator]
|
||||
|
||||
|
||||
def count_states(data: dict[str, Any]) -> dict[str, int]:
|
||||
"""Count the states of the provided torrents."""
|
||||
|
||||
counts = Counter(torrent[b"state"].decode() for torrent in data.values())
|
||||
|
||||
return {
|
||||
DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value: counts.get("Downloading", 0),
|
||||
DelugeSensorType.SEEDING_COUNT_SENSOR.value: counts.get("Seeding", 0),
|
||||
}
|
||||
|
||||
|
||||
class DelugeDataUpdateCoordinator(
|
||||
DataUpdateCoordinator[dict[Platform, dict[str, Any]]]
|
||||
):
|
||||
@@ -51,22 +39,19 @@ class DelugeDataUpdateCoordinator(
|
||||
)
|
||||
self.api = api
|
||||
|
||||
def _get_deluge_data(self):
|
||||
"""Get the latest data from Deluge."""
|
||||
|
||||
async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
|
||||
"""Get the latest data from Deluge and updates the state."""
|
||||
data = {}
|
||||
try:
|
||||
data["session_status"] = self.api.call(
|
||||
_data = await self.hass.async_add_executor_job(
|
||||
self.api.call,
|
||||
"core.get_session_status",
|
||||
[iter_member.value for iter_member in list(DelugeGetSessionStatusKeys)],
|
||||
)
|
||||
data["torrents_status_state"] = self.api.call(
|
||||
"core.get_torrents_status", {}, ["state"]
|
||||
data[Platform.SENSOR] = {k.decode(): v for k, v in _data.items()}
|
||||
data[Platform.SWITCH] = await self.hass.async_add_executor_job(
|
||||
self.api.call, "core.get_torrents_status", {}, ["paused"]
|
||||
)
|
||||
data["torrents_status_paused"] = self.api.call(
|
||||
"core.get_torrents_status", {}, ["paused"]
|
||||
)
|
||||
|
||||
except (
|
||||
ConnectionRefusedError,
|
||||
TimeoutError,
|
||||
@@ -81,18 +66,4 @@ class DelugeDataUpdateCoordinator(
|
||||
) from ex
|
||||
LOGGER.error("Unknown error connecting to Deluge: %s", ex)
|
||||
raise
|
||||
|
||||
return data
|
||||
|
||||
async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
|
||||
"""Get the latest data from Deluge and updates the state."""
|
||||
|
||||
deluge_data = await self.hass.async_add_executor_job(self._get_deluge_data)
|
||||
|
||||
data = {}
|
||||
data[Platform.SENSOR] = {
|
||||
k.decode(): v for k, v in deluge_data["session_status"].items()
|
||||
}
|
||||
data[Platform.SENSOR].update(count_states(deluge_data["torrents_status_state"]))
|
||||
data[Platform.SWITCH] = deluge_data["torrents_status_paused"]
|
||||
return data
|
||||
|
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"downloading_count": {
|
||||
"default": "mdi:download"
|
||||
},
|
||||
"seeding_count": {
|
||||
"default": "mdi:upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -110,18 +110,6 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = (
|
||||
data, DelugeSensorType.PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR.value
|
||||
),
|
||||
),
|
||||
DelugeSensorEntityDescription(
|
||||
key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
|
||||
translation_key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value=lambda data: data[DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value],
|
||||
),
|
||||
DelugeSensorEntityDescription(
|
||||
key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
|
||||
translation_key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value=lambda data: data[DelugeSensorType.SEEDING_COUNT_SENSOR.value],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -36,10 +36,6 @@
|
||||
"idle": "[%key:common::state::idle%]"
|
||||
}
|
||||
},
|
||||
"downloading_count": {
|
||||
"name": "Downloading count",
|
||||
"unit_of_measurement": "torrents"
|
||||
},
|
||||
"download_speed": {
|
||||
"name": "Download speed"
|
||||
},
|
||||
@@ -49,10 +45,6 @@
|
||||
"protocol_traffic_upload_speed": {
|
||||
"name": "Protocol traffic upload speed"
|
||||
},
|
||||
"seeding_count": {
|
||||
"name": "Seeding count",
|
||||
"unit_of_measurement": "[%key:component::deluge::entity::sensor::downloading_count::unit_of_measurement%]"
|
||||
},
|
||||
"upload_speed": {
|
||||
"name": "Upload speed"
|
||||
}
|
||||
|
@@ -1,23 +0,0 @@
|
||||
"""Diagnostics support for derivative."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
registry = er.async_get(hass)
|
||||
entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
|
||||
|
||||
return {
|
||||
"config_entry": config_entry.as_dict(),
|
||||
"entity": [entity.extended_dict for entity in entities],
|
||||
}
|
@@ -227,28 +227,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
weight = calculate_weight(start, end, current_time)
|
||||
derivative = derivative + (value * Decimal(weight))
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Calculated new derivative as %f from %d segments",
|
||||
self.entity_id,
|
||||
derivative,
|
||||
len(self._state_list),
|
||||
)
|
||||
|
||||
return derivative
|
||||
|
||||
def _prune_state_list(self, current_time: datetime) -> None:
|
||||
# filter out all derivatives older than `time_window` from our window list
|
||||
old_len = len(self._state_list)
|
||||
self._state_list = [
|
||||
(time_start, time_end, state)
|
||||
for time_start, time_end, state in self._state_list
|
||||
if (current_time - time_end).total_seconds() < self._time_window
|
||||
]
|
||||
_LOGGER.debug(
|
||||
"%s: Pruned %d elements from state list",
|
||||
self.entity_id,
|
||||
old_len - len(self._state_list),
|
||||
)
|
||||
|
||||
def _handle_invalid_source_state(self, state: State | None) -> bool:
|
||||
# Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false.
|
||||
@@ -305,10 +292,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
) -> None:
|
||||
"""Calculate derivative based on time and reschedule."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Recalculating derivative due to max_sub_interval time elapsed",
|
||||
self.entity_id,
|
||||
)
|
||||
self._prune_state_list(now)
|
||||
derivative = self._calc_derivative_from_state_list(now)
|
||||
self._write_native_value(derivative)
|
||||
@@ -317,11 +300,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
if derivative != 0:
|
||||
schedule_max_sub_interval_exceeded(source_state)
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Scheduling max_sub_interval_callback in %s",
|
||||
self.entity_id,
|
||||
self._max_sub_interval,
|
||||
)
|
||||
self._cancel_max_sub_interval_exceeded_callback = async_call_later(
|
||||
self.hass,
|
||||
self._max_sub_interval,
|
||||
@@ -331,9 +309,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
@callback
|
||||
def on_state_reported(event: Event[EventStateReportedData]) -> None:
|
||||
"""Handle constant sensor state."""
|
||||
_LOGGER.debug(
|
||||
"%s: New state reported event: %s", self.entity_id, event.data
|
||||
)
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
if not self._handle_invalid_source_state(new_state):
|
||||
@@ -355,7 +330,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
@callback
|
||||
def on_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle changed sensor state."""
|
||||
_LOGGER.debug("%s: New state changed event: %s", self.entity_id, event.data)
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
if not self._handle_invalid_source_state(new_state):
|
||||
@@ -408,32 +382,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
/ Decimal(self._unit_prefix)
|
||||
* Decimal(self._unit_time)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s: Calculated new derivative segment as %f / %f / %f * %f = %f",
|
||||
self.entity_id,
|
||||
delta_value,
|
||||
elapsed_time,
|
||||
self._unit_prefix,
|
||||
self._unit_time,
|
||||
new_derivative,
|
||||
)
|
||||
|
||||
except ValueError as err:
|
||||
_LOGGER.warning(
|
||||
"%s: While calculating derivative: %s", self.entity_id, err
|
||||
)
|
||||
_LOGGER.warning("While calculating derivative: %s", err)
|
||||
except DecimalException as err:
|
||||
_LOGGER.warning(
|
||||
"%s: Invalid state (%s > %s): %s",
|
||||
self.entity_id,
|
||||
old_value,
|
||||
new_state.state,
|
||||
err,
|
||||
"Invalid state (%s > %s): %s", old_value, new_state.state, err
|
||||
)
|
||||
except AssertionError as err:
|
||||
_LOGGER.error(
|
||||
"%s: Could not calculate derivative: %s", self.entity_id, err
|
||||
)
|
||||
_LOGGER.error("Could not calculate derivative: %s", err)
|
||||
|
||||
# For total inreasing sensors, the value is expected to continuously increase.
|
||||
# A negative derivative for a total increasing sensor likely indicates the
|
||||
@@ -443,10 +400,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
== SensorStateClass.TOTAL_INCREASING
|
||||
and new_derivative < 0
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"%s: Dropping sample as source total_increasing sensor decreased",
|
||||
self.entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
# add latest derivative to the window list
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/droplet",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pydroplet==2.3.3"],
|
||||
"requirements": ["pydroplet==2.3.2"],
|
||||
"zeroconf": ["_droplet._tcp.local."]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==14.0.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.7.0"]
|
||||
}
|
||||
|
@@ -5,11 +5,9 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from deebot_client.capabilities import CapabilityNumber, CapabilitySet
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.capabilities import CapabilitySet
|
||||
from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
|
||||
from deebot_client.events.base import Event
|
||||
from deebot_client.events.water_info import WaterCustomAmountEvent
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
@@ -77,19 +75,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = (
|
||||
native_step=1.0,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
EcovacsNumberEntityDescription[WaterCustomAmountEvent](
|
||||
capability_fn=lambda caps: (
|
||||
caps.water.amount
|
||||
if caps.water and isinstance(caps.water.amount, CapabilityNumber)
|
||||
else None
|
||||
),
|
||||
value_fn=lambda e: e.value,
|
||||
key="water_amount",
|
||||
translation_key="water_amount",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=1.0,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -115,18 +100,6 @@ class EcovacsNumberEntity[EventT: Event](
|
||||
|
||||
entity_description: EcovacsNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
capability: CapabilitySet[EventT, [int]],
|
||||
entity_description: EcovacsNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(device, capability, entity_description)
|
||||
if isinstance(capability, CapabilityNumber):
|
||||
self._attr_native_min_value = capability.min
|
||||
self._attr_native_max_value = capability.max
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
await super().async_added_to_hass()
|
||||
|
@@ -33,11 +33,7 @@ class EcovacsSelectEntityDescription[EventT: Event](
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
|
||||
EcovacsSelectEntityDescription[WaterAmountEvent](
|
||||
capability_fn=lambda caps: (
|
||||
caps.water.amount
|
||||
if caps.water and isinstance(caps.water.amount, CapabilitySetTypes)
|
||||
else None
|
||||
),
|
||||
capability_fn=lambda caps: caps.water.amount if caps.water else None,
|
||||
current_option_fn=lambda e: get_name_key(e.value),
|
||||
options_fn=lambda water: [get_name_key(amount) for amount in water.types],
|
||||
key="water_amount",
|
||||
|
@@ -102,9 +102,6 @@
|
||||
},
|
||||
"volume": {
|
||||
"name": "Volume"
|
||||
},
|
||||
"water_amount": {
|
||||
"name": "Water flow level"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -155,10 +152,8 @@
|
||||
"station_state": {
|
||||
"name": "Station state",
|
||||
"state": {
|
||||
"drying_mop": "Drying mop",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"emptying_dustbin": "Emptying dustbin",
|
||||
"washing_mop": "Washing mop"
|
||||
"emptying_dustbin": "Emptying dustbin"
|
||||
}
|
||||
},
|
||||
"stats_area": {
|
||||
@@ -179,7 +174,7 @@
|
||||
},
|
||||
"select": {
|
||||
"water_amount": {
|
||||
"name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
|
||||
"name": "Water flow level",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
|
@@ -7,6 +7,8 @@ import random
|
||||
import string
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deebot_client.events.station import State
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import slugify
|
||||
|
||||
@@ -47,6 +49,9 @@ def get_supported_entities(
|
||||
@callback
|
||||
def get_name_key(enum: Enum) -> str:
|
||||
"""Return the lower case name of the enum."""
|
||||
if enum is State.EMPTYING:
|
||||
# Will be fixed in the next major release of deebot-client
|
||||
return "emptying_dustbin"
|
||||
return enum.name.lower()
|
||||
|
||||
|
||||
|
@@ -234,17 +234,6 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.DISTANCE_MM: SensorEntityDescription(
|
||||
key="DISTANCE_MM",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.HEAT_COUNT: SensorEntityDescription(
|
||||
key="HEAT_COUNT",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
EcoWittSensorTypes.PM1: SensorEntityDescription(
|
||||
key="PM1",
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
@@ -253,7 +242,6 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
),
|
||||
EcoWittSensorTypes.PM4: SensorEntityDescription(
|
||||
key="PM4",
|
||||
device_class=SensorDeviceClass.PM4,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
|
@@ -1,24 +0,0 @@
|
||||
"""The Ekey Bionyx integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.EVENT]
|
||||
|
||||
|
||||
type EkeyBionyxConfigEntry = ConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
|
||||
"""Set up the Ekey Bionyx config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
@@ -1,14 +0,0 @@
|
||||
"""application_credentials platform the Ekey Bionyx integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
)
|
@@ -1,271 +0,0 @@
|
||||
"""Config flow for ekey bionyx."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
import aiohttp
|
||||
import ekey_bionyxpy
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.webhook import (
|
||||
async_generate_id as webhook_generate_id,
|
||||
async_generate_path as webhook_generate_path,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.selector import SelectOptionDict, SelectSelector
|
||||
|
||||
from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE
|
||||
|
||||
# Valid webhook name: starts with letter or underscore, contains letters, digits, spaces, dots, and underscores, does not end with space or dot
|
||||
VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
|
||||
|
||||
|
||||
class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
|
||||
"""ekey bionyx authentication before a ConfigEntry exists.
|
||||
|
||||
This implementation directly provides the token without supporting refresh.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: aiohttp.ClientSession,
|
||||
token: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize ConfigFlowEkeyApi."""
|
||||
super().__init__(websession, API_URL)
|
||||
self._token = token
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return the token for the Ekey API."""
|
||||
return self._token["access_token"]
|
||||
|
||||
|
||||
class EkeyFlowData(TypedDict):
|
||||
"""Type for Flow Data."""
|
||||
|
||||
api: NotRequired[ekey_bionyxpy.BionyxAPI]
|
||||
system: NotRequired[ekey_bionyxpy.System]
|
||||
systems: NotRequired[list[ekey_bionyxpy.System]]
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Config flow to handle ekey bionyx OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
check_deletion_task: asyncio.Task[None] | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize OAuth2FlowHandler."""
|
||||
super().__init__()
|
||||
self._data: EkeyFlowData = {}
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"scope": SCOPE}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Start the user facing flow by initializing the API and getting the systems."""
|
||||
client = ConfigFlowEkeyApi(async_get_clientsession(self.hass), data[CONF_TOKEN])
|
||||
ap = ekey_bionyxpy.BionyxAPI(client)
|
||||
self._data["api"] = ap
|
||||
try:
|
||||
system_res = await ap.get_systems()
|
||||
except aiohttp.ClientResponseError:
|
||||
return self.async_abort(
|
||||
reason="cannot_connect",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
system = [s for s in system_res if s.own_system]
|
||||
if len(system) == 0:
|
||||
return self.async_abort(reason="no_own_systems")
|
||||
self._data["systems"] = system
|
||||
if len(system) == 1:
|
||||
# skipping choose_system since there is only one
|
||||
self._data["system"] = system[0]
|
||||
return await self.async_step_check_system(user_input=None)
|
||||
return await self.async_step_choose_system(user_input=None)
|
||||
|
||||
async def async_step_choose_system(
|
||||
self, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog to choose System if multiple systems are present."""
|
||||
if user_input is None:
|
||||
options: list[SelectOptionDict] = [
|
||||
{"value": s.system_id, "label": s.system_name}
|
||||
for s in self._data["systems"]
|
||||
]
|
||||
data_schema = {vol.Required("system"): SelectSelector({"options": options})}
|
||||
return self.async_show_form(
|
||||
step_id="choose_system",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
self._data["system"] = [
|
||||
s for s in self._data["systems"] if s.system_id == user_input["system"]
|
||||
][0]
|
||||
return await self.async_step_check_system(user_input=None)
|
||||
|
||||
async def async_step_check_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Check if system has open webhooks."""
|
||||
system = self._data["system"]
|
||||
await self.async_set_unique_id(system.system_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if (
|
||||
system.function_webhook_quotas["free"] == 0
|
||||
and system.function_webhook_quotas["used"] == 0
|
||||
):
|
||||
return self.async_abort(
|
||||
reason="no_available_webhooks",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
|
||||
if system.function_webhook_quotas["used"] > 0:
|
||||
return await self.async_step_delete_webhooks()
|
||||
return await self.async_step_webhooks(user_input=None)
|
||||
|
||||
async def async_step_webhooks(
|
||||
self, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog to setup webhooks."""
|
||||
system = self._data["system"]
|
||||
|
||||
errors: dict[str, str] | None = None
|
||||
if user_input is not None:
|
||||
errors = {}
|
||||
for key, webhook_name in user_input.items():
|
||||
if key == CONF_URL:
|
||||
continue
|
||||
if not re.match(VALID_NAME_PATTERN, webhook_name):
|
||||
errors.update({key: "invalid_name"})
|
||||
try:
|
||||
cv.url(user_input[CONF_URL])
|
||||
except vol.Invalid:
|
||||
errors[CONF_URL] = "invalid_url"
|
||||
if set(user_input) == {CONF_URL}:
|
||||
errors["base"] = "no_webhooks_provided"
|
||||
|
||||
if not errors:
|
||||
webhook_data = [
|
||||
{
|
||||
"auth": secrets.token_hex(32),
|
||||
"name": webhook_name,
|
||||
"webhook_id": webhook_generate_id(),
|
||||
}
|
||||
for key, webhook_name in user_input.items()
|
||||
if key != CONF_URL
|
||||
]
|
||||
for webhook in webhook_data:
|
||||
wh_def: ekey_bionyxpy.WebhookData = {
|
||||
"integrationName": "Home Assistant",
|
||||
"functionName": webhook["name"],
|
||||
"locationName": "Home Assistant",
|
||||
"definition": {
|
||||
"url": user_input[CONF_URL]
|
||||
+ webhook_generate_path(webhook["webhook_id"]),
|
||||
"authentication": {"apiAuthenticationType": "None"},
|
||||
"securityLevel": "AllowHttp",
|
||||
"method": "Post",
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"content": json.dumps({"auth": webhook["auth"]}),
|
||||
},
|
||||
},
|
||||
}
|
||||
webhook["ekey_id"] = (await system.add_webhook(wh_def)).webhook_id
|
||||
return self.async_create_entry(
|
||||
title=self._data["system"].system_name,
|
||||
data={"webhooks": webhook_data},
|
||||
)
|
||||
|
||||
data_schema: dict[Any, Any] = {
|
||||
vol.Optional(f"webhook{i + 1}"): vol.All(str, vol.Length(max=50))
|
||||
for i in range(self._data["system"].function_webhook_quotas["free"])
|
||||
}
|
||||
data_schema[vol.Required(CONF_URL)] = str
|
||||
return self.async_show_form(
|
||||
step_id="webhooks",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(data_schema),
|
||||
{
|
||||
CONF_URL: get_url(
|
||||
self.hass,
|
||||
allow_ip=True,
|
||||
prefer_external=False,
|
||||
)
|
||||
}
|
||||
| (user_input or {}),
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"webhooks_available": str(
|
||||
self._data["system"].function_webhook_quotas["free"]
|
||||
),
|
||||
"ekeybionyx": INTEGRATION_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_delete_webhooks(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Form to delete Webhooks."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="delete_webhooks")
|
||||
for webhook in await self._data["system"].get_webhooks():
|
||||
await webhook.delete()
|
||||
return await self.async_step_wait_for_deletion(user_input=None)
|
||||
|
||||
async def async_step_wait_for_deletion(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Wait for webhooks to be deleted in another flow."""
|
||||
uncompleted_task: asyncio.Task[None] | None = None
|
||||
|
||||
if not self.check_deletion_task:
|
||||
self.check_deletion_task = self.hass.async_create_task(
|
||||
self.async_check_deletion_status()
|
||||
)
|
||||
if not self.check_deletion_task.done():
|
||||
progress_action = "check_deletion_status"
|
||||
uncompleted_task = self.check_deletion_task
|
||||
if uncompleted_task:
|
||||
return self.async_show_progress(
|
||||
step_id="wait_for_deletion",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
progress_action=progress_action,
|
||||
progress_task=uncompleted_task,
|
||||
)
|
||||
self.check_deletion_task = None
|
||||
return self.async_show_progress_done(next_step_id="webhooks")
|
||||
|
||||
async def async_check_deletion_status(self) -> None:
|
||||
"""Check if webhooks have been deleted."""
|
||||
while True:
|
||||
self._data["systems"] = await self._data["api"].get_systems()
|
||||
self._data["system"] = [
|
||||
s
|
||||
for s in self._data["systems"]
|
||||
if s.system_id == self._data["system"].system_id
|
||||
][0]
|
||||
if self._data["system"].function_webhook_quotas["used"] == 0:
|
||||
break
|
||||
await asyncio.sleep(5)
|
@@ -1,13 +0,0 @@
|
||||
"""Constants for the Ekey Bionyx integration."""
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "ekeybionyx"
|
||||
INTEGRATION_NAME = "ekey bionyx"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/authorize"
|
||||
OAUTH2_TOKEN = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/token"
|
||||
API_URL = "https://api.bionyx.io/3rd-party/api"
|
||||
SCOPE = "https://ekeybionyxprod.onmicrosoft.com/3rd-party-api/api-access"
|
@@ -1,70 +0,0 @@
|
||||
"""Event platform for ekey bionyx integration."""
|
||||
|
||||
from aiohttp.hdrs import METH_POST
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass, EventEntity
|
||||
from homeassistant.components.webhook import (
|
||||
async_register as webhook_register,
|
||||
async_unregister as webhook_unregister,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EkeyBionyxConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EkeyBionyxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Ekey event."""
|
||||
async_add_entities(EkeyEvent(data) for data in entry.data["webhooks"])
|
||||
|
||||
|
||||
class EkeyEvent(EventEntity):
|
||||
"""Ekey Event."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_event_types = ["event happened"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: dict[str, str],
|
||||
) -> None:
|
||||
"""Initialise a Ekey event entity."""
|
||||
self._attr_name = data["name"]
|
||||
self._attr_unique_id = data["ekey_id"]
|
||||
self._webhook_id = data["webhook_id"]
|
||||
self._auth = data["auth"]
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self) -> None:
|
||||
"""Handle the webhook event."""
|
||||
self._trigger_event("event happened")
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks with your device API/library."""
|
||||
|
||||
async def async_webhook_handler(
|
||||
hass: HomeAssistant, webhook_id: str, request: Request
|
||||
) -> Response | None:
|
||||
if (await request.json())["auth"] == self._auth:
|
||||
self._async_handle_event()
|
||||
return None
|
||||
|
||||
webhook_register(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"Ekey {self._attr_name}",
|
||||
self._webhook_id,
|
||||
async_webhook_handler,
|
||||
allowed_methods=[METH_POST],
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister Webhook."""
|
||||
webhook_unregister(self.hass, self._webhook_id)
|
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "ekeybionyx",
|
||||
"name": "ekey bionyx",
|
||||
"codeowners": ["@richardpolzer"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ekeybionyx",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["ekey-bionyxpy==1.0.0"]
|
||||
}
|
@@ -1,92 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: This integration has no way of knowing if the fingerprint reader is offline.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: This integration has no way of knowing if the fingerprint reader is offline.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: This integration does not store the tokens.
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This integration does not support discovery.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: This integration has no entities that should be disabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user