Compare commits

..

76 Commits

Author SHA1 Message Date
Abílio Costa 9430bb2e32 Add Edifier Infrared integration (#172342)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-03 22:50:35 +01:00
J. Nick Koston 1283420fc6 Bump onvif-zeep-async to 4.2.0 (#172957) 2026-06-04 00:49:14 +03:00
starkillerOG 88e85e4325 Add more Reolink diagnostic info (#172945) 2026-06-03 23:34:18 +02:00
Erwin Douna 7cb08fdabc Incomfort refactor coordinator (#160953)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-03 23:31:18 +02:00
Tom 0cde867f93 Bump airOS to add insecure ssl detection (#172947) 2026-06-03 22:24:09 +02:00
J. Diego Rodríguez Royo 149daf4f97 Bump aiohomeconnect to 0.36.1 (#172946) 2026-06-03 22:13:25 +02:00
Thomas55555 6ffc32159b Bump aioautomower to 2.7.6 (#172937) 2026-06-03 22:00:04 +02:00
Ronald van der Meer 1c2d1013e6 Refactor Duco config flow tests to use small helpers (#172498) 2026-06-03 21:57:12 +02:00
Markus Adrario af4eaed5ed Homee: Use constants for cover states for readability (#172840) 2026-06-03 21:34:56 +02:00
Erik Montnemery dba09c334a Use zone DOMAIN constant in zone conditions (#172940) 2026-06-03 20:44:35 +02:00
Erik Montnemery c329bb4000 Don't log configuration errors when executing WS subscribe_trigger (#172918) 2026-06-03 19:58:32 +02:00
Josef Zweck 2e041dd45f Add reason for unvailability to opendisplay (#172909) 2026-06-03 19:48:56 +02:00
rjones-gentex 8ba3e6c8c1 Upgrade HomeLink package, set integration type (#172371) 2026-06-03 19:43:17 +02:00
Chris 8db064c929 Add binary sensor platform to openevse (#172924) 2026-06-03 19:37:55 +02:00
Kurt Chrisford 8124544125 Bump actron-neo-api to 0.5.12 (#172902) 2026-06-03 19:15:09 +02:00
Mark 6291179292 Add Rabbit Air fan preset icons (#172931) 2026-06-03 19:00:45 +02:00
Joost Lekkerkerker 6f880ac8a9 Remove state attributes from OPNsense (#172930) 2026-06-03 18:29:14 +02:00
Paulus Schoutsen 7babb2423b Migrate itach to pyitachip2ir2==0.0.8 (#172908)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 18:16:42 +02:00
Rasmus Graham 7e1874ae96 Bump vsure to 2.7.0 (#172856) 2026-06-03 17:08:34 +01:00
starkillerOG 1f50582a16 Bump reolink_aio to 0.20.1 (#172927) 2026-06-03 18:04:56 +02:00
Markus Tuominen 53211759cb Document missing pylint rules in plugin README (#172925) 2026-06-03 18:54:20 +03:00
Abílio Costa 4593059db2 Add "review" claude skill and use it in "gitbhub-pr-review" (#172797)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-03 15:57:21 +01:00
epenet 593ae9eb80 Add pylint plugin for correct use of DOMAIN constants in tests (#172693)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
2026-06-03 16:53:15 +02:00
Erik Montnemery 37b4bcaa39 Don't log condition errors when executing WS test_condition (#172897) 2026-06-03 16:11:54 +02:00
Bram Kragten 6bda3ea3a5 Update frontend to 20260527.4 (#172907) 2026-06-03 14:17:30 +02:00
Sören f4db5fb346 Add Avea Bluetooth reachability diagnostics (#172898) 2026-06-03 13:43:33 +02:00
Heikki Henriksen f04b0ee2c6 prusalink: guard non-string original in config_flow workaround (#172375) 2026-06-03 11:47:19 +02:00
Erik Montnemery 52c3e17de9 Add zone occupancy conditions (#172896) 2026-06-03 11:20:13 +02:00
Imou-OpenPlatform 96c286f2e0 Add Imou integration (#161412)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-03 11:18:31 +02:00
renovate[bot] 3e356de4e1 Update pytest-asyncio to 1.4.0 (#172886) 2026-06-03 11:17:32 +02:00
Erik Montnemery 90a874d81b Use zone DOMAIN constant in zone triggers (#172894) 2026-06-03 11:15:50 +02:00
Erik Montnemery 165024c6c9 Catch errors when setting up condition in WS subscribe_condition (#172895) 2026-06-03 11:06:18 +02:00
Erik Montnemery 66e4db3c0e Add zone conditions in / not in zone (#172810) 2026-06-03 10:40:43 +02:00
Franck Nijhof 0e6128c657 Fix SwitchBot Blind Tilt KeyError on idle BLE advertisements (#172816) 2026-06-03 10:37:16 +02:00
Wendelin 16febb36ba Automation choose: Add optional note to options (#172837) 2026-06-03 10:12:52 +02:00
Erik Montnemery dd7bd0c8a4 Prevent log spam when WS subscribe_condition is active (#172832) 2026-06-03 10:10:30 +02:00
Petro31 c462a1c188 Add translations for template device trackers in_zones option (#172850) 2026-06-03 08:38:18 +02:00
fdebrus 96c5110b7e Vistapool: flip docs-related quality-scale rules to done (#172827)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 08:04:52 +02:00
Colin 64e8ed2737 Add missing translation keys to openevse (#172802) 2026-06-03 08:03:30 +02:00
renovate[bot] 4171d092f7 Update coverage to 7.14.1 (#172878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 07:59:49 +02:00
J. Nick Koston 7af867ad4d Avoid double-decoding websocket_api TEXT frames with decode_text (#172891) 2026-06-03 07:52:02 +02:00
Paulus Schoutsen 907fe40304 Regenerate mdi_icons.py for frontend 20260527.3 (#172887) 2026-06-03 05:56:21 +02:00
Petro31 261914c592 Use dt_util.utcnow() instead of datetime.now(UTC) in template tests (#172852) 2026-06-03 05:32:12 +02:00
David Bonnes 09637c1a3a Use dt_util.utcnow() instead of datetime.now(UTC) in evohome (#172868) 2026-06-03 05:31:07 +02:00
Bram Kragten 0816385185 Bump frontend to 20260527.3 (#172873) 2026-06-03 05:20:42 +02:00
renovate[bot] 4b64b26870 Update infrared-protocols to 5.8.1 (#172870) 2026-06-02 22:43:22 +01:00
Joost Lekkerkerker b20f9ad40a Bump pySmartThings to 4.0.0 (#172858) 2026-06-02 22:36:15 +02:00
Ronald van der Meer 99d279bdd8 Simplify Duco sensor tests (#172501) 2026-06-02 22:25:34 +02:00
jameson_uk 69e0e11077 Bump aioamazondevices to 14.0.0 (#172857) 2026-06-02 20:45:55 +01:00
Pete Sage 9e3c143bd0 Log warning on unsupported announce media formats for Sonos (#172614)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-02 21:27:54 +02:00
Rayman223 45c55543e9 Add EcoSmart resume schedule button to Wallbox (#171847)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:15:40 +02:00
Chris Caron fb02e93a0c Bump version of Apprise to v1.11.0 (#172622) 2026-06-02 21:10:15 +02:00
Marc Hörsken a54b97eeca Bump pywmspro to 0.4.0 for persistence support (#172193) 2026-06-02 21:09:32 +02:00
J. Nick Koston 61c196405b Bump aiohttp to 3.14.0 (#172838) 2026-06-02 21:05:57 +02:00
Tom Schneider 9a047ad115 Type hvv_departures integration (#172595) 2026-06-02 20:55:38 +02:00
Daniel Bergmann 07a584057c Add integration for the device Envertech EVT800 (#149456)
Co-authored-by: Dani <danigta2020@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-02 19:04:26 +02:00
Michael Hansen 5873dff1d9 Bump intents to 2026.6.1 (#172842) 2026-06-02 11:31:08 -05:00
Denys Karabetskyi 30a2bd9b92 Add button event entity to SwitchBot Contact Sensor. (#171876) 2026-06-02 17:48:58 +02:00
fdebrus 1065dce882 Add number platform to Vistapool (#172542)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-02 17:13:11 +02:00
johanzander 878a39194a Promote growatt_server to Gold quality scale (#171623)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 13:52:57 +02:00
Tom Schneider 2e2f4a7dcb Bump pygti to 1.1.1 (#172613) 2026-06-02 13:50:16 +02:00
Denis Shulyaka 46627984f8 Use homeassistant.util.dt.utcnow instead of datetime.now(UTC) in Anthropic (#172826) 2026-06-02 13:48:20 +02:00
Tomer 5445f9e42b Bump victron-mqtt to 2026.6.1 (#172676) 2026-06-02 13:33:02 +02:00
Ermanno Baschiera 8ce2a5257d Add Helty Flow temperature and humidity sensors (#172813)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:27:05 +02:00
bkobus-bbx 787828d7de Add reconfiguration flow for Blebox integration (#172569) 2026-06-02 13:26:38 +02:00
zhangluofeng 9e96912a1e Add xthings cloud switch (#172119)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 13:20:43 +02:00
zhangluofeng fd578cfd4c Don't create switch entity for switch device type in XThings Cloud (#172828) 2026-06-02 13:05:00 +02:00
Simon Lamon 94de8646c6 Modify stale policies for PRs and issues (#172812) 2026-06-02 12:52:49 +02:00
Sean Dague 2d19e84d15 Use arwn-client library in arwn (#172264) 2026-06-02 12:32:16 +02:00
Erik Montnemery a17cfbc2a5 Make the renamed trigger behavior options backwards compatible (#172822) 2026-06-02 12:31:57 +02:00
fdebrus c552b0a067 Vistapool: add DHCP discovery on SugarWIFI hostname (#172820)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 12:10:48 +02:00
Samuel Xiao 80241a44d9 Switchbot Cloud: Enable Webhook for sensor devices (#172814) 2026-06-02 11:02:08 +02:00
fdebrus d8b02ea6d6 Add button platform to Vistapool (#172550)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 10:21:16 +02:00
jameson_uk 36d2e85351 alexa devices - media player code quality (#172650) 2026-06-02 10:04:20 +02:00
epenet 174ac9eafe Deprecate single-use CONCENTRATION_PARTS_PER_CUBIC_METER constant (#172553) 2026-06-02 09:42:33 +02:00
Erik Montnemery 772c426d5d Add zone triggers occupancy detected/cleared (#172438) 2026-06-02 09:34:12 +02:00
213 changed files with 10935 additions and 1092 deletions
+2 -33
View File
@@ -8,39 +8,8 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.
3. Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
- Performance implications
- Security concerns
- Test coverage
- Documentation updates if needed
4. Ensure any existing review comments have been addressed.
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
## Verification:
- After the review, run parallel subagents for each finding to double check it.
- Spawn up to a maximum of 10 parallel subagents at a time.
- Gather the results from the subagents and summarize them in the final review comments.
3. Review the changes following the `review` skill. It is VERY IMPORTANT to follow the `review` skill instructions.
4. Check if all existing review comments have been addressed.
## IMPORTANT:
- Just review. DO NOT make any changes
- Be constructive and specific in your comments
- Suggest improvements where appropriate
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
- No need to run tests or linters, just review the code changes.
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention.
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] sensor.py:143 - Memory leak
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
- [SUGGESTION] test_init.py:45 - Improve x variable name
```
- Make sure to include the file and line number when possible in the bullet points.
+38
View File
@@ -0,0 +1,38 @@
---
name: review
description: Reviews code changes and provides constructive feedback. Should be used when a review is requested to provide a consistent review behavior and output format. This skill can be used for code reviews in general, not just for GitHub pull requests.
---
# Review Code Changes
## Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
- Performance implications
- Security concerns
- Test coverage
- Documentation updates if needed
## Verification:
- After the review, run parallel subagents for each finding to double-check it.
- Spawn up to a maximum of 10 parallel subagents at a time.
- Gather the results from the subagents and summarize them in the final review comments.
## IMPORTANT:
- Just review. DO NOT make any changes.
- Be constructive and specific in your comments.
- Suggest improvements where appropriate.
- No need to run tests or linters, just review the code changes.
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention.
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] sensor.py:143 - Memory leak
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
- [SUGGESTION] test_init.py:45 - Improve x variable name
```
- Make sure to include the file and line number when possible in the bullet points.
+24 -67
View File
@@ -22,21 +22,34 @@ jobs:
pull-requests: write # To label and close stale PRs
actions: write # To delete stalebot state
steps:
# Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit.
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 60 day stale policy for PRs
# Used for:
# - PRs
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
# The 90 day stale policy for issues
# Used for:
# - Issues
# - No issues marked as no-stale or help-wanted
- name: 60 days stale PRs policy and 90 days stale issue policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
days-before-close: 7
days-before-issue-stale: -1
days-before-issue-close: -1
operations-per-run: 150
repo-token: ${{ steps.token.outputs.token }}
remove-stale-when-updated: true
operations-per-run: 350
# pr policy
days-before-pr-stale: 60
days-before-pr-close: 7
stale-pr-label: "stale"
exempt-pr-labels: "no-stale"
stale-pr-message: >
@@ -49,65 +62,9 @@ jobs:
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
# Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit.
# This is only used for issues.
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 90 day stale policy for issues
# Used for:
# - Issues
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
days-before-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 250
remove-stale-when-updated: true
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Please make sure to update to the latest Home Assistant version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
# The 30 day stale policy for issues
# Used for:
# - Issues that are pending more information (incomplete issues)
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
days-before-stale: 14
days-before-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 250
remove-stale-when-updated: true
# issue policy
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted"
stale-issue-message: >
+1
View File
@@ -286,6 +286,7 @@ homeassistant.components.huawei_lte.*
homeassistant.components.humidifier.*
homeassistant.components.husqvarna_automower.*
homeassistant.components.huum.*
homeassistant.components.hvv_departures.*
homeassistant.components.hydrawise.*
homeassistant.components.hyperion.*
homeassistant.components.hypontech.*
Generated
+8 -2
View File
@@ -453,6 +453,8 @@ CLAUDE.md @home-assistant/core
/tests/components/ecovacs/ @mib1185 @edenhaus @Augar
/homeassistant/components/ecowitt/ @pvizeli
/tests/components/ecowitt/ @pvizeli
/homeassistant/components/edifier_infrared/ @abmantis
/tests/components/edifier_infrared/ @abmantis
/homeassistant/components/efergy/ @tkdrob
/tests/components/efergy/ @tkdrob
/homeassistant/components/egardia/ @jeroenterheerdt
@@ -501,6 +503,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
/homeassistant/components/envertech_evt800/ @daniel-bergmann-00
/tests/components/envertech_evt800/ @daniel-bergmann-00
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 @roberty99
@@ -623,8 +627,8 @@ CLAUDE.md @home-assistant/core
/tests/components/generic_hygrostat/ @Shulyaka
/homeassistant/components/geniushub/ @manzanotti
/tests/components/geniushub/ @manzanotti
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
/homeassistant/components/geo_json_events/ @exxamalte
/tests/components/geo_json_events/ @exxamalte
/homeassistant/components/geo_location/ @home-assistant/core
@@ -838,6 +842,8 @@ CLAUDE.md @home-assistant/core
/tests/components/imgw_pib/ @bieniu
/homeassistant/components/immich/ @mib1185
/tests/components/immich/ @mib1185
/homeassistant/components/imou/ @Imou-OpenPlatform
/tests/components/imou/ @Imou-OpenPlatform
/homeassistant/components/improv_ble/ @emontnemery
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
@@ -11,7 +11,6 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_CUBIC_METER,
PERCENTAGE,
UV_INDEX,
UnitOfIrradiance,
@@ -47,6 +46,8 @@ from .coordinator import (
PARALLEL_UPDATES = 1
PARTS_PER_CUBIC_METER = "p/m³"
@dataclass(frozen=True, kw_only=True)
class AccuWeatherSensorDescription(SensorEntityDescription):
@@ -81,7 +82,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Grass",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
@@ -107,7 +108,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Mold",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
@@ -116,7 +117,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="Ragweed",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
@@ -184,7 +185,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="Tree",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.6"]
"requirements": ["actron-neo-api==0.5.12"]
}
+1 -1
View File
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["airos==0.6.5"]
"requirements": ["airos==0.6.8"]
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.2"]
"requirements": ["aioamazondevices==14.0.0"]
}
@@ -1,8 +1,7 @@
"""Media player platform for Alexa Devices."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Final
from typing import Any
from aioamazondevices.structures import (
AmazonMediaControls,
@@ -38,18 +37,6 @@ STANDARD_SUPPORTED_FEATURES = (
)
@dataclass(frozen=True, kw_only=True)
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
"""Describes an Alexa Devices media player entity."""
MEDIA_PLAYERS: Final = (
AmazonDevicesMediaPlayerEntityDescription(
key="media",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
@@ -69,9 +56,10 @@ async def async_setup_entry(
continue
known_devices.add(serial_num)
new_entities.extend(
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
for description in MEDIA_PLAYERS
new_entities.append(
AlexaDevicesMediaPlayer(
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
)
)
if new_entities:
@@ -85,8 +73,6 @@ async def async_setup_entry(
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
"""Representation of an Alexa device media player."""
entity_description: AmazonDevicesMediaPlayerEntityDescription
_attr_name = None # Uses the device name
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_volume_step = 0.05
@@ -95,7 +81,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: AmazonDevicesMediaPlayerEntityDescription,
description: MediaPlayerEntityDescription,
) -> None:
"""Initialize."""
self._prev_volume: int | None = None
@@ -214,7 +200,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
@property
def media_content_type(self) -> MediaType | None:
"""Content type — tells HA what kind of media is playing."""
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
return MediaType.MUSIC
return None
@@ -227,7 +213,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
**kwargs: Any,
) -> None:
"""Play a piece of media."""
await self.async_call_alexa_music(media_id, media_type)
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
@alexa_api_call
async def async_call_alexa_music(
+2 -3
View File
@@ -4,7 +4,6 @@ import base64
from collections import deque
from collections.abc import AsyncIterator, Callable, Iterable
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
from mimetypes import guess_file_type
from pathlib import Path
@@ -114,7 +113,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from homeassistant.util import dt as dt_util, slugify
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .const import (
@@ -372,7 +371,7 @@ def _convert_content( # noqa: C901
)
if (
content.native.container is not None
and content.native.container.expires_at > datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
and content.native.container.expires_at > dt_util.utcnow()
):
container_id = content.native.container.id
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["apprise"],
"quality_scale": "legacy",
"requirements": ["apprise==1.9.1"]
"requirements": ["apprise==1.11.0"]
}
+3 -2
View File
@@ -4,6 +4,7 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/arwn",
"iot_class": "local_polling",
"quality_scale": "legacy"
"iot_class": "local_push",
"quality_scale": "legacy",
"requirements": ["arwn-client==0.2.1"]
}
+80 -121
View File
@@ -3,113 +3,26 @@
import logging
from typing import Any
from arwn_client import parse_message
from homeassistant.components import mqtt
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
from homeassistant.util.json import json_loads_object
_LOGGER = logging.getLogger(__name__)
DOMAIN = "arwn"
DATA_ARWN = "arwn"
TOPIC = "arwn/#"
def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None:
"""Given a topic, dynamically create the right sensor type.
Async friendly.
"""
parts = topic.split("/")
unit = payload.get("units", "")
domain = parts[1]
if domain == "temperature":
name = parts[2]
if unit == "F":
unit = UnitOfTemperature.FAHRENHEIT
else:
unit = UnitOfTemperature.CELSIUS
return [
ArwnSensor(
topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE
)
]
if domain == "moisture":
name = f"{parts[2]} Moisture"
return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")]
if domain == "rain":
if len(parts) >= 3 and parts[2] == "today":
return [
ArwnSensor(
topic,
"Rain Since Midnight",
"since_midnight",
UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
)
]
return [
ArwnSensor(
topic + "/total",
"Total Rainfall",
"total",
unit,
device_class=SensorDeviceClass.PRECIPITATION,
),
ArwnSensor(
topic + "/rate",
"Rainfall Rate",
"rate",
unit,
device_class=SensorDeviceClass.PRECIPITATION,
),
]
if domain == "barometer":
return [
ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines")
]
if domain == "wind":
return [
ArwnSensor(
topic + "/speed",
"Wind Speed",
"speed",
unit,
device_class=SensorDeviceClass.WIND_SPEED,
),
ArwnSensor(
topic + "/gust",
"Wind Gust",
"gust",
unit,
device_class=SensorDeviceClass.WIND_SPEED,
),
ArwnSensor(
topic + "/dir",
"Wind Direction",
"direction",
DEGREE,
"mdi:compass",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
]
return None
def _slug(name: str) -> str:
return f"sensor.arwn_{slugify(name)}"
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -118,28 +31,25 @@ async def async_setup_platform(
) -> None:
"""Set up the ARWN platform."""
# Make sure MQTT integration is enabled and the client is available
if not await mqtt.async_wait_for_mqtt_client(hass):
_LOGGER.error("MQTT integration is not available")
return
@callback
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
"""Process events as sensors.
"""Process MQTT events as sensors."""
try:
event = json_loads_object(msg.payload)
device = parse_message(msg.topic, event)
except Exception: # noqa: BLE001
_LOGGER.debug(
"Failed to parse ARWN message on topic %s",
msg.topic,
exc_info=True,
)
return
When a new event on our topic (arwn/#) is received we map it
into a known kind of sensor based on topic name. If we've
never seen this before, we keep this sensor around in a global
cache. If we have seen it before, we update the values of the
existing sensor. Either way, we push an ha state update at the
end for the new event we've seen.
This lets us dynamically incorporate sensors without any
configuration on our side.
"""
event = json_loads_object(msg.payload)
sensors = discover_sensors(msg.topic, event)
if not sensors:
if device is None:
return
if (store := hass.data.get(DATA_ARWN)) is None:
@@ -148,22 +58,71 @@ async def async_setup_platform(
if "timestamp" in event:
del event["timestamp"]
for sensor in sensors:
if sensor.name not in store:
sensor.hass = hass
sensor.set_event(event)
store[sensor.name] = sensor
new_sensors: list[ArwnSensor] = []
for reading in device.readings:
if not reading.expose:
continue
unique_id = (
f"{msg.topic}/{reading.sensor_key}"
if len(device.readings) > 1
else msg.topic
)
try:
device_class = (
SensorDeviceClass(reading.device_class)
if reading.device_class
else None
)
except ValueError:
_LOGGER.debug(
"Unknown device_class=%s for sensor %s",
reading.device_class,
reading.sensor_name,
)
device_class = None
try:
state_class = (
SensorStateClass(reading.state_class)
if reading.state_class
else None
)
except ValueError:
_LOGGER.debug(
"Unknown state_class=%s for sensor %s",
reading.state_class,
reading.sensor_name,
)
state_class = None
if unique_id not in store:
sensor = ArwnSensor(
unique_id=unique_id,
name=reading.sensor_name,
state_key=reading.sensor_key,
units=reading.unit,
icon=reading.icon,
device_class=device_class,
state_class=state_class,
event=event,
)
store[unique_id] = sensor
_LOGGER.debug(
"Registering sensor %(name)s => %(event)s",
{"name": sensor.name, "event": event},
{"name": reading.sensor_name, "event": event},
)
async_add_entities((sensor,), True)
new_sensors.append(sensor)
else:
_LOGGER.debug(
"Recording sensor %(name)s => %(event)s",
{"name": sensor.name, "event": event},
{"name": reading.sensor_name, "event": event},
)
store[sensor.name].set_event(event)
store[unique_id].set_event(event)
if new_sensors:
async_add_entities(new_sensors, True)
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
@@ -175,29 +134,29 @@ class ArwnSensor(SensorEntity):
def __init__(
self,
topic: str,
unique_id: str,
name: str,
state_key: str,
units: str,
icon: str | None = None,
device_class: SensorDeviceClass | None = None,
state_class: SensorStateClass | None = None,
event: dict[str, Any] | None = None,
) -> None:
"""Initialize the sensor."""
self.entity_id = _slug(name)
self._attr_name = name
# This mqtt topic for the sensor which is its uid
self._attr_unique_id = topic
self._attr_unique_id = unique_id
self._state_key = state_key
self._attr_native_unit_of_measurement = units
self._attr_icon = icon
self._attr_device_class = device_class
self._attr_state_class = state_class
if event is not None:
self._attr_extra_state_attributes = dict(event)
self._attr_native_value = event.get(state_key)
def set_event(self, event: dict[str, Any]) -> None:
"""Update the sensor with the most recent event."""
ev: dict[str, Any] = {}
ev.update(event)
self._attr_extra_state_attributes = ev
self._attr_native_value = ev.get(self._state_key)
self._attr_extra_state_attributes = dict(event)
self._attr_native_value = event.get(self._state_key)
self.async_write_ha_state()
+19 -5
View File
@@ -2,12 +2,18 @@
import avea
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.components.bluetooth import (
BluetoothReachabilityIntent,
async_address_reachability_diagnostics,
async_ble_device_from_address,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
type AveaConfigEntry = ConfigEntry[avea.Bulb]
PLATFORMS: list[Platform] = [Platform.LIGHT]
@@ -15,12 +21,20 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Set up Avea from a config entry."""
ble_device = async_ble_device_from_address(
hass, entry.data[CONF_ADDRESS], connectable=True
)
address = entry.data[CONF_ADDRESS]
ble_device = async_ble_device_from_address(hass, address, connectable=True)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
entry.runtime_data = avea.Bulb(ble_device)
@@ -22,6 +22,11 @@
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find Avea device with address {address}: {reason}"
}
},
"issues": {
"deprecated_yaml": {
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
+88 -48
View File
@@ -33,23 +33,14 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
def create_schema(previous_input=None):
"""Create a schema with given values as default."""
if previous_input is not None:
host = previous_input[CONF_HOST]
port = previous_input[CONF_PORT]
else:
host = DEFAULT_HOST
port = DEFAULT_PORT
return vol.Schema(
{
vol.Required(CONF_HOST, default=host): str,
vol.Required(CONF_PORT, default=port): int,
vol.Inclusive(CONF_USERNAME, "auth"): str,
vol.Inclusive(CONF_PASSWORD, "auth"): str,
}
)
STEP_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Inclusive(CONF_USERNAME, "auth"): str,
vol.Inclusive(CONF_PASSWORD, "auth"): str,
}
)
LOG_MSG = {
@@ -69,18 +60,44 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
self.device_config: dict[str, Any] = {}
def handle_step_exception(
self, step, exception, schema, host, port, message_id, log_fn
self, exception, schema, host, port, message_id, log_fn, step_id
):
"""Handle step exceptions."""
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
return self.async_show_form(
step_id="user",
step_id=step_id,
data_schema=schema,
errors={"base": message_id},
description_placeholders={"address": f"{host}:{port}"},
)
async def _async_from_host_or_form(
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
"""Try to connect to the device; return product or an error form."""
schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input)
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
try:
return await Box.async_from_host(api_host), None
except UnsupportedBoxVersion as ex:
return None, self.handle_step_exception(
ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id
)
except UnauthorizedRequest as ex:
return None, self.handle_step_exception(
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
)
except Error as ex:
return None, self.handle_step_exception(
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id
)
except RuntimeError as ex:
return None, self.handle_step_exception(
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -145,12 +162,11 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle initial user-triggered config step."""
hass = self.hass
schema = create_schema(user_input)
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=schema,
data_schema=STEP_SCHEMA,
errors={},
description_placeholders={},
)
@@ -173,36 +189,60 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
)
try:
product = await Box.async_from_host(api_host)
except UnsupportedBoxVersion as ex:
return self.handle_step_exception(
"user",
ex,
schema,
host,
port,
UNSUPPORTED_VERSION,
_LOGGER.debug,
)
except UnauthorizedRequest as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error
)
except Error as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning
)
except RuntimeError as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, UNKNOWN, _LOGGER.error
)
product, error = await self._async_from_host_or_form(
api_host, user_input, step_id="user"
)
if error is not None:
return error
assert product is not None
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=product.name, data=user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of a BleBox device."""
reconfigure_entry = self._get_reconfigure_entry()
if user_input is None:
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_SCHEMA, reconfigure_entry.data
),
)
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
websession = get_maybe_authenticated_session(self.hass, password, username)
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
product, error = await self._async_from_host_or_form(
api_host, user_input, step_id="reconfigure"
)
if error is not None:
return error
assert product is not None
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
self._abort_if_unique_id_mismatch()
data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
if username is not None:
data_updates[CONF_USERNAME] = username
if password is not None:
data_updates[CONF_PASSWORD] = password
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates=data_updates,
)
+13 -1
View File
@@ -2,7 +2,9 @@
"config": {
"abort": {
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device identifier does not match the previously configured device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -11,6 +13,16 @@
},
"flow_title": "{name} ({host})",
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Update the connection settings for your BleBox device.",
"title": "Reconfigure BleBox device"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::ip%]",
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
}
@@ -0,0 +1,18 @@
"""Edifier infrared integration for Home Assistant."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Edifier IR from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Edifier IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,77 @@
"""Config flow for Edifier infrared integration."""
from typing import Any
from infrared_protocols.codes.edifier.models import MODEL_TO_COMMAND_SET, EdifierModel
import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_MODEL
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, DOMAIN
class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Edifier IR."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step - select IR entity and speaker model."""
emitter_entity_ids = async_get_emitters(self.hass)
if not emitter_entity_ids:
return self.async_abort(reason="no_emitters")
if user_input is not None:
infrared_entity_id = user_input[CONF_INFRARED_ENTITY_ID]
model = EdifierModel(user_input[CONF_MODEL])
command_set = MODEL_TO_COMMAND_SET[model]
await self.async_set_unique_id(f"{command_set}_{infrared_entity_id}")
self._abort_if_unique_id_configured()
entity_name = infrared_entity_id
if state := self.hass.states.get(infrared_entity_id):
entity_name = state.name or infrared_entity_id
return self.async_create_entry(
title=f"Edifier {model.value} via {entity_name}",
data={
CONF_INFRARED_ENTITY_ID: infrared_entity_id,
CONF_MODEL: model.value,
CONF_COMMAND_SET: command_set.value,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN, include_entities=emitter_entity_ids
)
),
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=[model.value for model in EdifierModel],
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
)
@@ -0,0 +1,19 @@
"""Constants for the Edifier infrared integration."""
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
DOMAIN = "edifier_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_COMMAND_SET = "command_set"
type EdifierCode = (
EdifierR1700BTCode
| EdifierR1280DBCode
| EdifierR1280TCode
| EdifierS360DBCode
| EdifierRC20GCode
)
@@ -0,0 +1,27 @@
"""Common entity for Edifier infrared integration."""
from infrared_protocols.codes.edifier.models import EdifierModel
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class EdifierIrEntity(Entity):
"""Edifier IR base entity providing common device info."""
_attr_has_entity_name = True
def __init__(
self, entry: ConfigEntry, model: EdifierModel, unique_id_suffix: str
) -> None:
"""Initialize Edifier IR entity."""
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"Edifier {model.value}",
manufacturer="Edifier",
model=model.value,
)
@@ -0,0 +1,11 @@
{
"domain": "edifier_infrared",
"name": "Edifier Infrared",
"codeowners": ["@abmantis"],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/edifier_infrared",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze"
}
@@ -0,0 +1,174 @@
"""Media player platform for Edifier infrared integration."""
from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode
from .entity import EdifierIrEntity
PARALLEL_UPDATES = 1
COMMAND_SET_COMMANDS: dict[
EdifierCommandSet,
dict[
MediaPlayerEntityFeature,
tuple[EdifierCode | tuple[EdifierCode, ...], ...],
],
] = {
EdifierCommandSet.R1700BT: {
MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1700BTCode.VOLUME_UP,),
(EdifierR1700BTCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,),
},
EdifierCommandSet.R1280DB: {
MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1280DBCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1280DBCode.VOLUME_UP,),
(EdifierR1280DBCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280DBCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierR1280DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierR1280DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1280DBCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1280DBCode.BACK,),
},
EdifierCommandSet.R1280T: {
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1280TCode.VOLUME_UP,),
(EdifierR1280TCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,),
},
EdifierCommandSet.S360DB: {
MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierS360DBCode.VOLUME_UP,),
(EdifierS360DBCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.PLAY: (EdifierS360DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierS360DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS360DBCode.NEXT,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS360DBCode.PREVIOUS,),
},
EdifierCommandSet.RC20G: {
MediaPlayerEntityFeature.TURN_ON: (EdifierRC20GCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierRC20GCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT),
(EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierRC20GCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierRC20GCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierRC20GCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,),
},
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Edifier IR media player."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET])
model = EdifierModel(entry.data[CONF_MODEL])
async_add_entities(
[EdifierIrMediaPlayer(entry, model, infrared_entity_id, command_set)]
)
class EdifierIrMediaPlayer(
EdifierIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity
):
"""Edifier IR media player entity."""
_attr_name = None
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
def __init__(
self,
entry: ConfigEntry,
model: EdifierModel,
infrared_entity_id: str,
command_set: EdifierCommandSet,
) -> None:
"""Initialize Edifier IR media player."""
super().__init__(entry, model, unique_id_suffix="media_player")
self._infrared_emitter_entity_id = infrared_entity_id
self._commands = COMMAND_SET_COMMANDS[command_set]
self._attr_state = MediaPlayerState.ON
self._attr_supported_features = MediaPlayerEntityFeature(0)
for feature in self._commands:
self._attr_supported_features |= feature
async def _send_codes(self, *codes: EdifierCode) -> None:
"""Send one or more IR commands."""
for code in codes:
await self._send_command(code.to_command())
async def async_turn_on(self) -> None:
"""Turn on the speaker."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_ON])
async def async_turn_off(self) -> None:
"""Turn off the speaker."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_OFF])
async def async_volume_up(self) -> None:
"""Send volume up command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][0])
async def async_volume_down(self) -> None:
"""Send volume down command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][1])
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_MUTE])
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PLAY])
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PAUSE])
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.NEXT_TRACK])
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PREVIOUS_TRACK])
@@ -0,0 +1,114 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
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:
status: exempt
comment: |
This integration does not store runtime data.
test-before-configure: done
test-before-setup:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no separate connection to validate during setup.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
Discovery is not supported for infrared integrations.
docs-data-update:
status: exempt
comment: |
This integration does not fetch data from devices.
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category:
status: exempt
comment: |
The media player entity is the primary entity and does not need a category.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
The media player entity is the primary entity and should be enabled by default.
entity-translations: done
exception-translations:
status: exempt
comment: |
This integration does not raise exceptions.
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration depends on infrared_protocols which provides only code
definitions with no I/O, so async dependency does not apply.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: todo
@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "This Edifier device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"infrared_entity_id": "IR transmitter",
"model": "Speaker model"
},
"data_description": {
"infrared_entity_id": "Select the infrared transmitter entity to use.",
"model": "Choose your Edifier speaker model from the list."
},
"description": "Configure your Edifier speaker for IR control.",
"title": "Set up Edifier IR speaker"
}
}
}
}
-6
View File
@@ -172,9 +172,6 @@ class BatterySourceType(TypedDict):
# statistic_id of a sensor (unit %) reporting the battery state of charge
stat_soc: NotRequired[str]
# usable capacity in kWh, used to weight the combined state of charge
capacity: NotRequired[float]
# An optional custom name for display in energy graphs
name: NotRequired[str]
@@ -509,9 +506,6 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
vol.Optional("stat_soc"): str,
vol.Optional("capacity"): vol.All(
vol.Coerce(float), vol.Range(min=0, min_included=False)
),
vol.Optional("name"): str,
}
)
@@ -0,0 +1,37 @@
"""Envertech EVT800 integration."""
from pyenvertechevt800 import EnvertechEVT800
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from .const import PLATFORMS
from .coordinator import EnvertechEVT800Coordinator
type EnvertechEVT800ConfigEntry = ConfigEntry[EnvertechEVT800Coordinator]
async def async_setup_entry(
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
) -> bool:
"""Set up Envertech EVT800 from a config entry."""
evt800 = EnvertechEVT800(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT])
evt800.start()
coordinator = EnvertechEVT800Coordinator(hass, evt800, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,60 @@
"""Config flow for the ENVERTECH EVT800 integration."""
from typing import Any
from pyenvertechevt800 import EnvertechEVT800
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE
from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_PORT, DOMAIN, TYPE_TCP_SERVER_MODE
SCHEMA_DEVICE = vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
)
class EnvertechFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Envertech EVT800."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""First step in config flow."""
errors: dict[str, str] = {}
if user_input is not None:
ip_address = user_input[CONF_IP_ADDRESS]
port = user_input[CONF_PORT]
self._async_abort_entries_match(
{
CONF_IP_ADDRESS: ip_address,
CONF_PORT: port,
}
)
evt800 = EnvertechEVT800(ip_address, port)
can_connect = await evt800.test_connection()
if not can_connect:
errors["base"] = "cannot_connect"
if not errors:
return self.async_create_entry(
title="Envertech EVT800",
data={CONF_TYPE: TYPE_TCP_SERVER_MODE, **user_input},
)
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
errors=errors,
)
@@ -0,0 +1,11 @@
"""Constants for the ENVERTECH EVT800 integration."""
from homeassistant.const import Platform
DOMAIN = "envertech_evt800"
PLATFORMS = [Platform.SENSOR]
DEFAULT_PORT = 14889
TYPE_TCP_SERVER_MODE = ["TCP_SERVER"]
DEFAULT_SCAN_INTERVAL = 60
@@ -0,0 +1,44 @@
"""Coordinator for Envertech EVT800 integration."""
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
import pyenvertechevt800
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
if TYPE_CHECKING:
from . import EnvertechEVT800ConfigEntry
_LOGGER = logging.getLogger(__name__)
class EnvertechEVT800Coordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Data update coordinator for Envertech EVT800."""
config_entry: EnvertechEVT800ConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: pyenvertechevt800.EnvertechEVT800,
config_entry: EnvertechEVT800ConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
config_entry=config_entry,
)
self.client = client
client.set_data_listener(self.async_set_updated_data)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the device."""
return self.client.data
@@ -0,0 +1,29 @@
"""Envertech EVT800 entity."""
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import EnvertechEVT800Coordinator
class EnvertechEVT800Entity(CoordinatorEntity[EnvertechEVT800Coordinator]):
"""Envertech EVT800 entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: EnvertechEVT800Coordinator) -> None:
"""Initialize Envertech EVT800 entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
configuration_url=f"http://{coordinator.config_entry.data[CONF_IP_ADDRESS]}/",
manufacturer="Envertech",
model_id="EVT800",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.client.online
@@ -0,0 +1,12 @@
{
"domain": "envertech_evt800",
"name": "ENVERTECH EVT800",
"codeowners": ["@daniel-bergmann-00"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/envertech_evt800",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyenvertechevt800"],
"quality_scale": "bronze",
"requirements": ["pyenvertechevt800==0.2.4"]
}
@@ -0,0 +1,90 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any 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: |
The integration does not provide any additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: done
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: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: todo
comment: |
The integration does not have any authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
Integration connects to a single device
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations:
status: exempt
comment: |
The integration does not have any own exceptions.
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
The integration does not support repairing issues.
stale-devices:
status: exempt
comment: |
This integration connects to a single device per configuration entry.
# Platinum
async-dependency: todo
inject-websession:
status: exempt
comment: |
No websession is used
strict-typing: todo
@@ -0,0 +1,185 @@
"""Envertech EVT800 sensor."""
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import EnvertechEVT800ConfigEntry
from .coordinator import EnvertechEVT800Coordinator
from .entity import EnvertechEVT800Entity
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="id_1",
entity_registry_enabled_default=False,
translation_key="mppt_id_1",
),
SensorEntityDescription(
key="id_2",
entity_registry_enabled_default=False,
translation_key="mppt_id_2",
),
SensorEntityDescription(
key="input_voltage_1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=2,
translation_key="input_voltage_1",
),
SensorEntityDescription(
key="input_voltage_2",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=2,
translation_key="input_voltage_2",
),
SensorEntityDescription(
key="power_1",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_display_precision=0,
translation_key="power_1",
),
SensorEntityDescription(
key="power_2",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_display_precision=0,
translation_key="power_2",
),
SensorEntityDescription(
key="current_1",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_display_precision=2,
translation_key="current_1",
),
SensorEntityDescription(
key="current_2",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_display_precision=2,
translation_key="current_2",
),
SensorEntityDescription(
key="ac_frequency_1",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
translation_key="ac_frequency_1",
),
SensorEntityDescription(
key="ac_frequency_2",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
translation_key="ac_frequency_2",
),
SensorEntityDescription(
key="ac_voltage_1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=0,
translation_key="ac_voltage_1",
),
SensorEntityDescription(
key="ac_voltage_2",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=0,
translation_key="ac_voltage_2",
),
SensorEntityDescription(
key="temperature_1",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
translation_key="temperature_1",
),
SensorEntityDescription(
key="temperature_2",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
translation_key="temperature_2",
),
SensorEntityDescription(
key="total_energy_1",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=2,
translation_key="total_energy_1",
),
SensorEntityDescription(
key="total_energy_2",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=2,
translation_key="total_energy_2",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnvertechEVT800ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Envertech EVT800 sensors."""
coordinator = config_entry.runtime_data
async_add_entities(
EnvertechEVT800Sensor(coordinator, description) for description in SENSORS
)
class EnvertechEVT800Sensor(EnvertechEVT800Entity, SensorEntity):
"""Representation of an Envertech EVT800 sensor."""
def __init__(
self,
coordinator: EnvertechEVT800Coordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.coordinator.client.data.get(self.entity_description.key)
@property
def available(self) -> bool:
"""Unavailable if evt800 isn't connected."""
return super().available and self.native_value is not None
@@ -0,0 +1,76 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"ip_address": "The IP address of your Envertech EVT800 device.",
"port": "The Port of your Envertech EVT800 device."
},
"description": "Enter your EVT800 device information.",
"title": "Setup EVT800 device"
}
}
},
"entity": {
"sensor": {
"ac_frequency_1": {
"name": "AC Frequency MPPT 1"
},
"ac_frequency_2": {
"name": "AC Frequency MPPT 2"
},
"ac_voltage_1": {
"name": "AC Voltage MPPT 1"
},
"ac_voltage_2": {
"name": "AC Voltage MPPT 2"
},
"current_1": {
"name": "DC Current MPPT 1"
},
"current_2": {
"name": "DC Current MPPT 2"
},
"input_voltage_1": {
"name": "DC Voltage MPPT 1"
},
"input_voltage_2": {
"name": "DC Voltage MPPT 2"
},
"mppt_id_1": {
"name": "MPPT ID 1"
},
"mppt_id_2": {
"name": "MPPT ID 2"
},
"power_1": {
"name": "DC Power MPPT 1"
},
"power_2": {
"name": "DC Power MPPT 2"
},
"temperature_1": {
"name": "Temperature MPPT 1"
},
"temperature_2": {
"name": "Temperature MPPT 2"
},
"total_energy_1": {
"name": "Total Energy MPPT 1"
},
"total_energy_2": {
"name": "Total Energy MPPT 2"
}
}
}
}
+2 -2
View File
@@ -1,7 +1,6 @@
"""Support for entities of the Evohome integration."""
from collections.abc import Mapping
from datetime import UTC, datetime
import logging
from typing import Any
@@ -14,6 +13,7 @@ from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
from homeassistant.core import callback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .coordinator import EvoDataUpdateCoordinator
@@ -161,7 +161,7 @@ class EvoChild(EvoEntity):
or self._schedule is None
or (
(until := self._setpoints.get("next_sp_from")) is not None
and until < datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
and until < dt_util.utcnow()
)
): # must use self._setpoints, not self.setpoints
await get_schedule()
+3 -3
View File
@@ -1,6 +1,6 @@
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from typing import Any, NotRequired, TypedDict
from evohomeasync.auth import (
@@ -12,6 +12,7 @@ from evohomeasync2.auth import AbstractTokenManager
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from .const import STORAGE_KEY, STORAGE_VER
@@ -91,8 +92,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
if session_id_expires is None:
# pylint: disable-next=home-assistant-enforce-utcnow
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15)
else:
self._session_id_expires = datetime.fromisoformat(session_id_expires)
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.2"]
"requirements": ["home-assistant-frontend==20260527.4"]
}
@@ -1,11 +1,12 @@
{
"domain": "gentex_homelink",
"name": "HomeLink",
"codeowners": ["@niaexa", "@ryanjones-gentex"],
"codeowners": ["@Gentex-Corporation/Homelink", "@rjones-gentex"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["homelink-integration-api==0.0.1"]
"requirements": ["homelink-integration-api==0.0.5"]
}
@@ -238,15 +238,24 @@ def _login_classic_api(
login_response = api.login(username, password)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during login: {ex}"
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(ex)},
) from ex
if not login_response.get("success"):
msg = login_response.get("msg", "Unknown error")
_LOGGER.debug("Growatt login failed: %s", msg)
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
raise ConfigEntryError(f"Growatt login failed: {msg}")
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_credentials",
)
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="login_failed",
translation_placeholders={"message": msg},
)
return login_response
@@ -266,15 +275,23 @@ def get_device_list_v1(
except growattServer.GrowattV1ApiError as e:
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": e.error_msg or str(e)},
) from e
if e.error_code == GrowattV1ApiErrorCode.RATE_LIMITED:
raise ConfigEntryNotReady(
f"Growatt API rate limited, will retry: {e.error_msg or str(e)}"
translation_domain=DOMAIN,
translation_key="rate_limited",
translation_placeholders={"error": e.error_msg or str(e)},
) from e
raise ConfigEntryError(
f"API error during device list: {e.error_msg or str(e)}"
f" (Code: {e.error_code})"
translation_domain=DOMAIN,
translation_key="api_error_with_code",
translation_placeholders={
"error": e.error_msg or str(e),
"code": str(e.error_code),
},
) from e
devices = devices_dict.get("devices", [])
supported_devices = [
@@ -348,10 +365,15 @@ async def async_setup_entry(
devices = await hass.async_add_executor_job(api.device_list, plant_id)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during device list: {ex}"
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(ex)},
) from ex
else:
raise ConfigEntryError("Unknown authentication type in config entry.")
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="unknown_auth_type",
)
# Create a coordinator for the total sensors
total_coordinator = GrowattCoordinator(
@@ -115,7 +115,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
) from err
_LOGGER.debug("Failed to fetch V1 device list during scan: %s", err)
self.device_list = None
@@ -157,9 +159,14 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
msg = login_response.get("msg", "Unknown error")
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed(
"Username, password, or URL may be incorrect"
translation_domain=DOMAIN,
translation_key="invalid_credentials",
)
raise UpdateFailed(f"Growatt login failed: {msg}")
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="login_failed",
translation_placeholders={"message": msg},
)
if self.device_type == "total":
if self.api_version == "v1":
@@ -181,11 +188,16 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={
"error": err.error_msg or str(err)
},
) from err
raise UpdateFailed(
f"Error fetching plant energy overview: {err}"
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
@@ -214,10 +226,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
) from err
raise UpdateFailed(f"Error fetching min device data: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
min_info = {**min_details, **min_settings, **min_energy}
self.data = min_info
@@ -242,10 +259,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
) from err
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
combined = {**sph_detail, **sph_energy}
@@ -313,7 +335,11 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
return await self.hass.async_add_executor_job(self._sync_update_data)
except json.decoder.JSONDecodeError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
def request_device_list_scan(self) -> None:
"""Request that the next _sync_update_data also fetches the device list.
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["growattServer==2.1.0"]
}
@@ -56,7 +56,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
@@ -595,6 +595,15 @@
"api_error": {
"message": "Growatt API error: {error}"
},
"api_error_with_code": {
"message": "API error: {error} (Code: {code})"
},
"auth_failed": {
"message": "Authentication failed for Growatt API: {error}"
},
"communication_error": {
"message": "Error communicating with Growatt API: {error}"
},
"device_not_configured": {
"message": "{device_type} device {serial_number} is not configured for actions."
},
@@ -604,6 +613,9 @@
"device_not_growatt": {
"message": "Device {device_id} is not a Growatt device."
},
"fetch_data_failed": {
"message": "Error fetching data from Growatt API: {error}"
},
"invalid_batt_mode": {
"message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}."
},
@@ -613,6 +625,9 @@
"invalid_charge_stop_soc": {
"message": "'Charge stop SOC' must be between 0 and 100, got {value}."
},
"invalid_credentials": {
"message": "Username, password, or URL may be incorrect"
},
"invalid_discharge_power": {
"message": "'Discharge power' must be between 0 and 100, got {value}."
},
@@ -634,11 +649,20 @@
"invalid_time_format_start_time": {
"message": "'Start time' must be in HH:MM or HH:MM:SS format."
},
"login_failed": {
"message": "Growatt login failed: {message}"
},
"no_devices_configured": {
"message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access."
},
"rate_limited": {
"message": "Growatt API rate limited, will retry: {error}"
},
"token_auth_required": {
"message": "This action requires token authentication (V1 API)."
},
"unknown_auth_type": {
"message": "Unknown authentication type in config entry"
}
},
"selector": {
+1 -1
View File
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.FAN]
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
@@ -66,14 +66,8 @@ rules:
comment: A config entry represents a single fixed device.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: The only entity is the primary fan, which is enabled by default.
entity-translations:
status: exempt
comment: |
The only entity is the primary fan, which uses the device name and has
no name of its own to translate.
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations:
status: exempt
+87
View File
@@ -0,0 +1,87 @@
"""Sensor platform for the Helty Flow integration."""
from collections.abc import Callable
from dataclasses import dataclass
from pyhelty import HeltyData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
from .entity import HeltyEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class HeltySensorEntityDescription(SensorEntityDescription):
"""Describes a Helty sensor."""
value_fn: Callable[[HeltyData], float | None]
SENSORS: tuple[HeltySensorEntityDescription, ...] = (
HeltySensorEntityDescription(
key="indoor_temperature",
translation_key="indoor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.indoor_temperature,
),
HeltySensorEntityDescription(
key="outdoor_temperature",
translation_key="outdoor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.outdoor_temperature,
),
HeltySensorEntityDescription(
key="indoor_humidity",
translation_key="indoor_humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.indoor_humidity,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HeltyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Helty sensors."""
coordinator = entry.runtime_data
async_add_entities(HeltySensor(coordinator, description) for description in SENSORS)
class HeltySensor(HeltyEntity, SensorEntity):
"""An environmental sensor reported by the ventilation unit."""
entity_description: HeltySensorEntityDescription
def __init__(
self,
coordinator: HeltyDataUpdateCoordinator,
description: HeltySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{self._device_id}_{description.key}"
@property
def native_value(self) -> float | None:
"""Return the current sensor reading."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -18,5 +18,18 @@
"title": "Connect to your Helty Flow"
}
}
},
"entity": {
"sensor": {
"indoor_humidity": {
"name": "Indoor humidity"
},
"indoor_temperature": {
"name": "Indoor temperature"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
}
}
}
}
@@ -23,6 +23,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.36.0"],
"requirements": ["aiohomeconnect==0.36.1"],
"zeroconf": ["_homeconnect._tcp.local."]
}
+43 -21
View File
@@ -1,5 +1,6 @@
"""The homee cover platform."""
from enum import Enum
import logging
from typing import TYPE_CHECKING, Any, cast
@@ -24,12 +25,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
OPEN_CLOSE_ATTRIBUTES = [
AttributeType.OPEN_CLOSE,
AttributeType.SLAT_ROTATION_IMPULSE,
AttributeType.UP_DOWN,
]
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
@@ -43,6 +38,23 @@ IS_CLOSED_ATTRIBUTES = [
]
class HomeeCoverState(float, Enum):
"""Open/closed states for covers in homee."""
OPEN = 0.0
CLOSED = 1.0
STOPPED = 2.0
OPENING = 3.0
CLOSING = 4.0
class HomeeSlatState(float, Enum):
"""Slat states for covers in homee."""
CLOSED = 1.0
OPEN = 2.0
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
"""Return the attribute used for opening/closing the cover."""
# We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them.
@@ -187,9 +199,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
"""Return the opening status of the cover."""
if self._open_close_attribute is not None:
return (
self._open_close_attribute.get_value() == 3
self._open_close_attribute.get_value() == HomeeCoverState.OPENING
if not self._open_close_attribute.is_reversed
else self._open_close_attribute.get_value() == 4
else self._open_close_attribute.get_value() == HomeeCoverState.CLOSING
)
return None
@@ -199,9 +211,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
"""Return the closing status of the cover."""
if self._open_close_attribute is not None:
return (
self._open_close_attribute.get_value() == 4
self._open_close_attribute.get_value() == HomeeCoverState.CLOSING
if not self._open_close_attribute.is_reversed
else self._open_close_attribute.get_value() == 3
else self._open_close_attribute.get_value() == HomeeCoverState.OPENING
)
return None
@@ -216,9 +228,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
if self._open_close_attribute is not None:
if not self._open_close_attribute.is_reversed:
return self._open_close_attribute.get_value() == 1
return self._open_close_attribute.get_value() == HomeeCoverState.CLOSED
return self._open_close_attribute.get_value() == 0
return self._open_close_attribute.get_value() == HomeeCoverState.OPEN
# If none of the above is present, it will be a slat only cover.
attribute = self._node.get_attribute_by_type(
@@ -235,17 +247,25 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
"""Open the cover."""
assert self._open_close_attribute is not None
if not self._open_close_attribute.is_reversed:
await self.async_set_homee_value(self._open_close_attribute, 0)
await self.async_set_homee_value(
self._open_close_attribute, HomeeCoverState.OPEN
)
else:
await self.async_set_homee_value(self._open_close_attribute, 1)
await self.async_set_homee_value(
self._open_close_attribute, HomeeCoverState.CLOSED
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
assert self._open_close_attribute is not None
if not self._open_close_attribute.is_reversed:
await self.async_set_homee_value(self._open_close_attribute, 1)
await self.async_set_homee_value(
self._open_close_attribute, HomeeCoverState.CLOSED
)
else:
await self.async_set_homee_value(self._open_close_attribute, 0)
await self.async_set_homee_value(
self._open_close_attribute, HomeeCoverState.OPEN
)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
@@ -265,7 +285,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
if self._open_close_attribute is not None:
await self.async_set_homee_value(self._open_close_attribute, 2)
await self.async_set_homee_value(
self._open_close_attribute, HomeeCoverState.STOPPED
)
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
@@ -275,9 +297,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
)
) is not None:
if not slat_attribute.is_reversed:
await self.async_set_homee_value(slat_attribute, 2)
await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN)
else:
await self.async_set_homee_value(slat_attribute, 1)
await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
@@ -287,9 +309,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
)
) is not None:
if not slat_attribute.is_reversed:
await self.async_set_homee_value(slat_attribute, 1)
await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED)
else:
await self.async_set_homee_value(slat_attribute, 2)
await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.5"]
"requirements": ["aioautomower==2.7.6"]
}
@@ -6,7 +6,14 @@ import logging
from typing import Any
from aiohttp import ClientConnectorError
from pygti.exceptions import InvalidAuth
from pygti.exceptions import GTIError
from pygti.models import (
ElevatorState,
SDName,
SDNameType,
StationInformationRequest,
StationInformationResponse,
)
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -38,20 +45,21 @@ async def async_setup_entry(
station = entry.data[CONF_STATION]
def get_elevator_entities_from_station_information(
station_name, station_information
):
station_name: str,
station_information: StationInformationResponse | None,
) -> dict[str, Any]:
"""Convert station information into a list of elevators."""
elevators = {}
if station_information is None:
return {}
for partial_station in station_information.get("partialStations", []):
for elevator in partial_station.get("elevators", []):
state = elevator.get("state") != "READY"
available = elevator.get("state") != "UNKNOWN"
label = elevator.get("label")
description = elevator.get("description")
for partial_station in station_information.partialStations or []:
for elevator in partial_station.elevators or []:
state = elevator.state != ElevatorState.READY
available = elevator.state != ElevatorState.UNKNOWN
label = elevator.label
description = elevator.description
if label is not None:
name = f"Elevator {label}"
@@ -61,7 +69,7 @@ async def async_setup_entry(
if description is not None:
name += f" ({description})"
lines = elevator.get("lines")
lines = elevator.lines
idx = f"{station_name}-{label}-{lines}"
@@ -70,33 +78,35 @@ async def async_setup_entry(
"name": name,
"available": available,
"attributes": {
"cabin_width": elevator.get("cabinWidth"),
"cabin_length": elevator.get("cabinLength"),
"door_width": elevator.get("doorWidth"),
"elevator_type": elevator.get("elevatorType"),
"button_type": elevator.get("buttonType"),
"cause": elevator.get("cause"),
"cabin_width": elevator.cabinWidth,
"cabin_length": elevator.cabinLength,
"door_width": elevator.doorWidth,
"elevator_type": elevator.elevatorType,
"button_type": elevator.buttonType,
"cause": elevator.cause,
"lines": lines,
},
}
return elevators
async def async_update_data():
async def async_update_data() -> dict[str, Any]:
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
payload = {"station": {"id": station["id"], "type": station["type"]}}
payload = StationInformationRequest(
station=SDName(id=station["id"], type=SDNameType(station["type"]))
)
try:
async with asyncio.timeout(10):
return get_elevator_entities_from_station_information(
station_name, await hub.gti.stationInformation(payload)
station_name, await hub.gti.getStationInformation(payload)
)
except InvalidAuth as err:
raise UpdateFailed(f"Authentication failed: {err}") from err
except GTIError as err:
raise UpdateFailed(f"GTI API error: {err}") from err
except ClientConnectorError as err:
raise UpdateFailed(f"Network not available: {err}") from err
except Exception as err:
@@ -129,7 +139,12 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(self, coordinator, idx, config_entry):
def __init__(
self,
coordinator: DataUpdateCoordinator[dict[str, Any]],
idx: str,
config_entry: HVVConfigEntry,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.coordinator = coordinator
@@ -140,7 +155,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={
(
( # type: ignore[arg-type]
DOMAIN,
config_entry.entry_id,
config_entry.data[CONF_STATION]["id"],
@@ -154,7 +169,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return entity state."""
return self.coordinator.data[self.idx]["state"]
return bool(self.coordinator.data[self.idx]["state"])
@property
def available(self) -> bool:
@@ -3,8 +3,17 @@
import logging
from typing import Any
from aiohttp import ClientConnectorError
from pygti.auth import GTI_DEFAULT_HOST
from pygti.exceptions import CannotConnect, InvalidAuth
from pygti.exceptions import GTIError, GTIUnauthorizedError
from pygti.models import (
CNRequest,
DLRequest,
GTITime,
RegionalSDNameType,
SDName,
SDNameType,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
@@ -66,10 +75,10 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
try:
response = await self.hub.authenticate()
_LOGGER.debug("Init gti: %r", response)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
except GTIUnauthorizedError:
errors["base"] = "invalid_auth"
except GTIError, ClientConnectorError:
errors["base"] = "cannot_connect"
if not errors:
self.data = user_input
@@ -87,15 +96,14 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
check_name = await self.hub.gti.checkName(
{"theName": {"name": user_input[CONF_STATION]}, "maxList": 20}
CNRequest(theName=SDName(name=user_input[CONF_STATION]), maxList=20)
)
stations = check_name.get("results")
self.stations = {
f"{station.get('name')}": station
for station in stations
if station.get("type") == "STATION"
station.name: station
for station in (check_name.results or [])
if station.type == RegionalSDNameType.STATION
and station.name is not None
}
if not self.stations:
@@ -121,7 +129,13 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="station_select", data_schema=schema)
self.data.update({"station": self.stations[user_input[CONF_STATION]]})
self.data.update(
{
"station": self.stations[user_input[CONF_STATION]].model_dump(
mode="json", exclude_none=True
)
}
)
title = self.data[CONF_STATION]["name"]
@@ -151,32 +165,30 @@ class OptionsFlowHandler(OptionsFlow):
"""Manage the options."""
errors = {}
if not self.departure_filters:
departure_list = {}
hub = self.config_entry.runtime_data
try:
departure_list = await hub.gti.departureList(
{
"station": {
"type": "STATION",
"id": self.config_entry.data[CONF_STATION].get("id"),
},
"time": {"date": "heute", "time": "jetzt"},
"maxList": 5,
"maxTimeOffset": 200,
"useRealtime": True,
"returnFilters": True,
}
DLRequest(
station=SDName(
id=self.config_entry.data[CONF_STATION].get("id"),
type=SDNameType.STATION,
),
time=GTITime(date="heute", time="jetzt"),
maxList=5,
maxTimeOffset=200,
useRealtime=True,
returnFilters=True,
)
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
except GTIUnauthorizedError:
errors["base"] = "invalid_auth"
if not errors:
except GTIError, ClientConnectorError:
errors["base"] = "cannot_connect"
else:
self.departure_filters = {
str(i): departure_filter
for i, departure_filter in enumerate(departure_list["filter"])
str(i): f.model_dump(mode="json", exclude_none=True)
for i, f in enumerate(departure_list.filter or [])
}
if user_input is not None and not errors:
@@ -206,8 +218,8 @@ class OptionsFlowHandler(OptionsFlow):
vol.Optional(CONF_FILTER, default=old_filter): cv.multi_select(
{
key: (
f"{departure_filter['serviceName']},"
f" {departure_filter['label']}"
f"{departure_filter.get('serviceName', '')},"
f" {departure_filter.get('label', '')}"
)
for key, departure_filter in self.departure_filters.items()
}
@@ -1,6 +1,8 @@
"""Hub."""
from aiohttp import ClientSession
from pygti.gti import GTI, Auth
from pygti.models import InitRequest, InitResponse
from homeassistant.config_entries import ConfigEntry
@@ -10,7 +12,9 @@ type HVVConfigEntry = ConfigEntry[GTIHub]
class GTIHub:
"""GTI Hub."""
def __init__(self, host, username, password, session):
def __init__(
self, host: str, username: str, password: str, session: ClientSession
) -> None:
"""Initialize."""
self.host = host
self.username = username
@@ -18,7 +22,7 @@ class GTIHub:
self.gti = GTI(Auth(session, self.username, self.password, self.host))
async def authenticate(self):
async def authenticate(self) -> InitResponse:
"""Test if we can authenticate with the host."""
return await self.gti.init()
return await self.gti.init(InitRequest())
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pygti"],
"requirements": ["pygti==0.9.4"]
"requirements": ["pygti==1.1.1"]
}
@@ -4,8 +4,9 @@ from datetime import timedelta
import logging
from typing import Any
from aiohttp import ClientConnectorError
from pygti.exceptions import InvalidAuth
from aiohttp import ClientConnectorError, ClientSession
from pygti.exceptions import GTIError, GTIUnauthorizedError
from pygti.models import DLRequest, GTITime, SDName, SDNameType
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import ATTR_ID, CONF_OFFSET
@@ -16,8 +17,15 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from homeassistant.util.dt import get_time_zone, utcnow
from .const import ATTRIBUTION, CONF_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTURER
from .hub import HVVConfigEntry
from .const import (
ATTRIBUTION,
CONF_FILTER,
CONF_REAL_TIME,
CONF_STATION,
DOMAIN,
MANUFACTURER,
)
from .hub import GTIHub, HVVConfigEntry
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
MAX_LIST = 20
@@ -62,11 +70,17 @@ class HVVDepartureSensor(SensorEntity):
_attr_has_entity_name = True
_attr_available = False
def __init__(self, hass, config_entry, session, hub):
def __init__(
self,
hass: HomeAssistant,
config_entry: HVVConfigEntry,
session: ClientSession,
hub: GTIHub,
) -> None:
"""Initialize."""
self.config_entry = config_entry
self.station_name = self.config_entry.data[CONF_STATION]["name"]
self._last_error = None
self._last_error: type[Exception] | Exception | None = None
self._attr_extra_state_attributes = {}
self.gti = hub.gti
@@ -77,7 +91,7 @@ class HVVDepartureSensor(SensorEntity):
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={
(
( # type: ignore[arg-type]
DOMAIN,
config_entry.entry_id,
config_entry.data[CONF_STATION]["id"],
@@ -99,39 +113,46 @@ class HVVDepartureSensor(SensorEntity):
station = self.config_entry.data[CONF_STATION]
payload = {
"station": {"id": station["id"], "type": station["type"]},
"time": {
"date": departure_time_tz_berlin.strftime("%d.%m.%Y"),
"time": departure_time_tz_berlin.strftime("%H:%M"),
},
"maxList": MAX_LIST,
"maxTimeOffset": MAX_TIME_OFFSET,
"useRealtime": self.config_entry.options.get(CONF_REAL_TIME, False),
}
if "filter" in self.config_entry.options:
payload.update({"filter": self.config_entry.options["filter"]})
request = DLRequest(
station=SDName(id=station["id"], type=SDNameType(station["type"])),
time=GTITime(
date=departure_time_tz_berlin.strftime("%d.%m.%Y"),
time=departure_time_tz_berlin.strftime("%H:%M"),
),
maxList=MAX_LIST,
maxTimeOffset=MAX_TIME_OFFSET,
useRealtime=self.config_entry.options.get(CONF_REAL_TIME, False),
filter=self.config_entry.options.get(CONF_FILTER),
)
try:
data = await self.gti.departureList(payload)
except InvalidAuth as error:
if self._last_error != InvalidAuth:
data = await self.gti.departureList(request)
except GTIUnauthorizedError as error:
if self._last_error != GTIUnauthorizedError:
_LOGGER.error("Authentication failed: %r", error)
self._last_error = InvalidAuth
self._last_error = GTIUnauthorizedError
self._attr_available = False
return
except GTIError as error:
if self._last_error != GTIError:
_LOGGER.warning("GTI API error: %r", error)
self._last_error = GTIError
self._attr_available = False
return
except ClientConnectorError as error:
if self._last_error != ClientConnectorError:
_LOGGER.warning("Network unavailable: %r", error)
self._last_error = ClientConnectorError
self._attr_available = False
return
except Exception as error: # noqa: BLE001
if self._last_error != error:
_LOGGER.error("Error occurred while fetching data: %r", error)
self._last_error = error
self._attr_available = False
return
if not (data["returnCode"] == "OK" and data.get("departures")):
if not data.departures:
self._attr_available = False
return
@@ -140,25 +161,27 @@ class HVVDepartureSensor(SensorEntity):
self._last_error = None
departure = data["departures"][0]
line = departure["line"]
delay = departure.get("delay", 0)
cancelled = departure.get("cancelled", False)
extra = departure.get("extra", False)
departure = data.departures[0]
line = departure.line
delay = departure.delay if departure.delay is not None else 0
cancelled = departure.cancelled if departure.cancelled is not None else False
extra = departure.extra if departure.extra is not None else False
self._attr_available = True
self._attr_native_value = (
departure_time
+ timedelta(minutes=departure["timeOffset"])
+ timedelta(
minutes=departure.timeOffset if departure.timeOffset is not None else 0
)
+ timedelta(seconds=delay)
)
self._attr_extra_state_attributes.update(
{
ATTR_LINE: line["name"],
ATTR_ORIGIN: line["origin"],
ATTR_DIRECTION: line["direction"],
ATTR_TYPE: line["type"]["shortInfo"],
ATTR_ID: line["id"],
ATTR_LINE: line.name,
ATTR_ORIGIN: line.origin,
ATTR_DIRECTION: line.direction,
ATTR_TYPE: line.type.shortInfo,
ATTR_ID: line.id,
ATTR_DELAY: delay,
ATTR_CANCELLED: cancelled,
ATTR_EXTRA: extra,
@@ -166,21 +189,27 @@ class HVVDepartureSensor(SensorEntity):
)
departures = []
for departure in data["departures"]:
line = departure["line"]
delay = departure.get("delay", 0)
cancelled = departure.get("cancelled", False)
extra = departure.get("extra", False)
for departure in data.departures:
line = departure.line
delay = departure.delay if departure.delay is not None else 0
cancelled = (
departure.cancelled if departure.cancelled is not None else False
)
extra = departure.extra if departure.extra is not None else False
departures.append(
{
ATTR_DEPARTURE: departure_time
+ timedelta(minutes=departure["timeOffset"])
+ timedelta(
minutes=departure.timeOffset
if departure.timeOffset is not None
else 0
)
+ timedelta(seconds=delay),
ATTR_LINE: line["name"],
ATTR_ORIGIN: line["origin"],
ATTR_DIRECTION: line["direction"],
ATTR_TYPE: line["type"]["shortInfo"],
ATTR_ID: line["id"],
ATTR_LINE: line.name,
ATTR_ORIGIN: line.origin,
ATTR_DIRECTION: line.direction,
ATTR_TYPE: line.type.shortInfo,
ATTR_ID: line.id,
ATTR_DELAY: delay,
ATTR_CANCELLED: cancelled,
ATTR_EXTRA: extra,
+42
View File
@@ -0,0 +1,42 @@
"""Support for Imou devices."""
from pyimouapi.device import ImouDeviceManager
from pyimouapi.ha_device import ImouHaDeviceManager
from pyimouapi.openapi import ImouOpenApiClient
from homeassistant.core import HomeAssistant, callback
from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, PLATFORMS
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool:
"""Set up Imou integration from a config entry."""
imou_client = ImouOpenApiClient(
entry.data[CONF_APP_ID],
entry.data[CONF_APP_SECRET],
API_URLS[entry.data[CONF_API_URL]],
)
device_manager = ImouDeviceManager(imou_client)
imou_device_manager = ImouHaDeviceManager(device_manager)
imou_coordinator = ImouDataUpdateCoordinator(hass, imou_device_manager, entry)
await imou_coordinator.async_config_entry_first_refresh()
entry.runtime_data = imou_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# DataUpdateCoordinator schedules periodic refreshes only when it has
# listeners. With zero entities (e.g. an empty account at setup), register a
# no-op listener so polling continues and later devices are discovered via
# new_device_callbacks.
@callback
def _async_keep_polling() -> None:
"""Keep periodic polling when no entities are registered yet."""
entry.async_on_unload(imou_coordinator.async_add_listener(_async_keep_polling))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool:
"""Handle removal of an entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+109
View File
@@ -0,0 +1,109 @@
"""Support for Imou button controls."""
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import PTZ_MOVE_DURATION_MS, imou_device_identifier
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
from .entity import ImouEntity
PARALLEL_UPDATES = 1
# Button types
PARAM_RESTART_DEVICE = "restart_device"
PARAM_MUTE = "mute"
PARAM_PTZ_UP = "ptz_up"
PARAM_PTZ_DOWN = "ptz_down"
PARAM_PTZ_LEFT = "ptz_left"
PARAM_PTZ_RIGHT = "ptz_right"
BUTTON_TYPES = (
PARAM_RESTART_DEVICE,
PARAM_MUTE,
PARAM_PTZ_UP,
PARAM_PTZ_DOWN,
PARAM_PTZ_LEFT,
PARAM_PTZ_RIGHT,
)
PTZ_BUTTON_TYPES = (
PARAM_PTZ_UP,
PARAM_PTZ_DOWN,
PARAM_PTZ_LEFT,
PARAM_PTZ_RIGHT,
)
BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = {
PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART,
}
def _iter_buttons(
coordinator: ImouDataUpdateCoordinator,
) -> list[tuple[str, ImouHaDevice]]:
"""Return (button_type, device) pairs for supported buttons."""
return [
(button_type, device)
for device in coordinator.devices
for button_type in device.buttons
if button_type in BUTTON_TYPES
]
async def async_setup_entry(
hass: HomeAssistant,
entry: ImouConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Imou button entities."""
coordinator = entry.runtime_data
def _add_buttons(new_devices: list[ImouHaDevice]) -> None:
device_keys = {imou_device_identifier(device) for device in new_devices}
async_add_entities(
ImouButton(coordinator, button_type, device)
for button_type, device in _iter_buttons(coordinator)
if imou_device_identifier(device) in device_keys
)
coordinator.new_device_callbacks.append(_add_buttons)
@callback
def _remove_new_device_callback() -> None:
if _add_buttons in coordinator.new_device_callbacks:
coordinator.new_device_callbacks.remove(_add_buttons)
entry.async_on_unload(_remove_new_device_callback)
_add_buttons(coordinator.devices)
class ImouButton(ImouEntity, ButtonEntity):
"""Imou button entity."""
def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
entity_type: str,
device: ImouHaDevice,
) -> None:
"""Initialize the Imou button entity."""
super().__init__(coordinator, entity_type, device)
if device_class := BUTTON_DEVICE_CLASS.get(entity_type):
self._attr_device_class = device_class
self._attr_translation_key = None
async def async_press(self) -> None:
"""Handle button press."""
duration = PTZ_MOVE_DURATION_MS if self._entity_type in PTZ_BUTTON_TYPES else 0
try:
await self.coordinator.device_manager.async_press_button(
self.device,
self._entity_type,
duration,
)
except ImouException as e:
raise HomeAssistantError(str(e)) from e
@@ -0,0 +1,80 @@
"""Config flow for Imou."""
import logging
from typing import Any
from pyimouapi.exceptions import (
ConnectFailedException,
ImouException,
InvalidAppIdOrSecretException,
RequestFailedException,
)
from pyimouapi.openapi import ImouOpenApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN
_LOGGER = logging.getLogger(__name__)
class ImouConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Imou integration."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the config flow."""
errors: dict[str, str] = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_APP_ID])
self._abort_if_unique_id_configured()
api_client = ImouOpenApiClient(
user_input[CONF_APP_ID],
user_input[CONF_APP_SECRET],
API_URLS[user_input[CONF_API_URL]],
)
try:
await api_client.async_get_token()
except InvalidAppIdOrSecretException:
errors["base"] = "invalid_auth"
except ConnectFailedException, RequestFailedException:
errors["base"] = "cannot_connect"
except ImouException as exception:
_LOGGER.debug("Imou error during config flow: %s", exception)
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="Imou",
data={
CONF_APP_ID: user_input[CONF_APP_ID],
CONF_APP_SECRET: user_input[CONF_APP_SECRET],
CONF_API_URL: user_input[CONF_API_URL],
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_APP_ID): str,
vol.Required(CONF_APP_SECRET): str,
vol.Required(CONF_API_URL, default="sg"): SelectSelector(
SelectSelectorConfig(
options=list(API_URLS),
translation_key="api_url",
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
+39
View File
@@ -0,0 +1,39 @@
"""Constants."""
from pyimouapi.ha_device import ImouHaDevice
from homeassistant.const import Platform
DOMAIN = "imou"
def imou_device_identifier(device: ImouHaDevice) -> str:
"""Return a device registry identifier (device_id + channel when present)."""
if device.channel_id is not None:
return f"{device.device_id}_{device.channel_id}"
return device.device_id
# API URL region mapping
API_URLS: dict[str, str] = {
"sg": "openapi-sg.easy4ip.com",
"eu": "openapi-or.easy4ip.com",
"na": "openapi-fk.easy4ip.com",
"cn": "openapi.lechange.cn",
}
CONF_API_URL = "api_url"
CONF_APP_ID = "app_id"
CONF_APP_SECRET = "app_secret"
PARAM_STATUS = "status"
PARAM_STATE = "state"
# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API).
PTZ_MOVE_DURATION_MS = 500
# Upper bound for a full coordinator refresh (device list + status for all devices).
UPDATE_TIMEOUT = 300
PLATFORMS = [Platform.BUTTON]
@@ -0,0 +1,152 @@
"""Provides the Imou DataUpdateCoordinator."""
import asyncio
from collections.abc import Callable
from datetime import timedelta
import logging
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_TIMEOUT, imou_device_identifier
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=120)
type ImouConfigEntry = ConfigEntry[ImouDataUpdateCoordinator]
class ImouDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Data update coordinator for Imou devices."""
config_entry: ImouConfigEntry
def __init__(
self,
hass: HomeAssistant,
device_manager: ImouHaDeviceManager,
config_entry: ImouConfigEntry,
) -> None:
"""Initialize the Imou data update coordinator."""
super().__init__(
hass,
_LOGGER,
name="ImouDataUpdateCoordinator",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
always_update=True,
)
self._device_manager = device_manager
self.devices_by_key: dict[str, ImouHaDevice] = {}
self._devices_initialized = False
self.new_device_callbacks: list[Callable[[list[ImouHaDevice]], None]] = []
@property
def devices(self) -> list[ImouHaDevice]:
"""Return the list of devices."""
return list(self.devices_by_key.values())
@property
def device_manager(self) -> ImouHaDeviceManager:
"""Return the device manager."""
return self._device_manager
def get_device(self, device_key: str) -> ImouHaDevice | None:
"""Return the current device for device_key, if still on the account."""
return self.devices_by_key.get(device_key)
async def _async_update_data(self) -> None:
"""Update coordinator data."""
try:
async with asyncio.timeout(UPDATE_TIMEOUT):
fresh_devices = await self._device_manager.async_get_devices()
except TimeoutError as err:
raise UpdateFailed(f"Timeout while fetching data: {err}") from err
except ImouException as err:
raise UpdateFailed(f"Error fetching Imou devices: {err}") from err
fresh_by_key = {
imou_device_identifier(device): device for device in fresh_devices
}
self._async_add_remove_devices(fresh_by_key)
devices = list(self.devices_by_key.values())
if not devices:
return
try:
async with asyncio.timeout(UPDATE_TIMEOUT):
results = await asyncio.gather(
*(
self._device_manager.async_update_device_status(device)
for device in devices
),
return_exceptions=True,
)
except TimeoutError as err:
raise UpdateFailed(f"Timeout while fetching data: {err}") from err
failures: list[Exception] = []
for device, result in zip(devices, results, strict=True):
if isinstance(result, BaseException) and not isinstance(result, Exception):
# Propagate CancelledError and other BaseExceptions instead of
# swallowing them as a regular device failure.
raise result
if not isinstance(result, Exception):
continue
device_key = imou_device_identifier(device)
_LOGGER.warning(
"Error updating status for Imou device %s: %s",
device_key,
result,
)
failures.append(result)
if failures and len(failures) == len(devices):
raise UpdateFailed(
f"Error updating Imou devices: {failures[0]}"
) from failures[0]
def _async_add_remove_devices(self, fresh_by_key: dict[str, ImouHaDevice]) -> None:
"""Add new devices, remove devices no longer in the account.
This only tracks which devices exist on the account; per-device state
is updated in place by `async_update_device_status`, so devices that
remain on the account keep their existing object and are not replaced.
"""
if not self._devices_initialized:
self.devices_by_key = fresh_by_key
self._devices_initialized = True
return
current_keys = set(fresh_by_key)
known_keys = set(self.devices_by_key)
if current_keys == known_keys:
return
if removed_keys := known_keys - current_keys:
_LOGGER.debug("Removed Imou device(s): %s", ", ".join(removed_keys))
device_registry = dr.async_get(self.hass)
for device_key in removed_keys:
del self.devices_by_key[device_key]
if device := device_registry.async_get_device(
identifiers={(DOMAIN, device_key)}
):
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
if new_keys := current_keys - known_keys:
_LOGGER.debug("New Imou device(s) found: %s", ", ".join(new_keys))
new_devices = []
for device_key in new_keys:
self.devices_by_key[device_key] = fresh_by_key[device_key]
new_devices.append(fresh_by_key[device_key])
for callback in self.new_device_callbacks:
callback(new_devices)
+59
View File
@@ -0,0 +1,59 @@
"""An abstract class common to all Imou entities."""
from pyimouapi.ha_device import DeviceStatus, ImouHaDevice
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier
from .coordinator import ImouDataUpdateCoordinator
class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]):
"""Base class for all Imou entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
entity_type: str,
device: ImouHaDevice,
) -> None:
"""Initialize the Imou entity."""
super().__init__(coordinator)
self._entity_type = entity_type
self._device_key = imou_device_identifier(device)
self._attr_unique_id = f"{self._device_key}${entity_type}"
self._attr_translation_key = entity_type
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_key)},
name=device.channel_name or device.device_name,
manufacturer=device.manufacturer,
model=device.model,
sw_version=device.swversion,
serial_number=device.device_id,
)
@property
def device(self) -> ImouHaDevice:
"""Return the live device from the coordinator.
Callers must guard with `available` first; accessing this for a device
that has left the account raises `KeyError`.
"""
return self.coordinator.devices_by_key[self._device_key]
@property
def available(self) -> bool:
"""Return if the entity is available."""
if (
not super().available
or self._device_key not in self.coordinator.devices_by_key
):
return False
if PARAM_STATUS not in self.device.sensors:
return False
return (
self.device.sensors[PARAM_STATUS][PARAM_STATE] != DeviceStatus.OFFLINE.value
)
+18
View File
@@ -0,0 +1,18 @@
{
"entity": {
"button": {
"ptz_down": {
"default": "mdi:arrow-down-bold"
},
"ptz_left": {
"default": "mdi:arrow-left-bold"
},
"ptz_right": {
"default": "mdi:arrow-right-bold"
},
"ptz_up": {
"default": "mdi:arrow-up-bold"
}
}
}
}
@@ -0,0 +1,11 @@
{
"domain": "imou",
"name": "Imou",
"codeowners": ["@Imou-OpenPlatform"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/imou",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyimouapi==1.2.7"]
}
@@ -0,0 +1,73 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Cloud service integration, does not support discovery.
discovery:
status: exempt
comment: >-
Devices are reached via Imou Open Platform cloud APIs (App ID / secret). No
supported local discovery flow today; example cues if investigated later:
hostname `IPC-ABCD.imou.local`, MAC `aa:bb:cc:dd:ee:ff`.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: done
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,56 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"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%]"
},
"step": {
"user": {
"data": {
"api_url": "Server region",
"app_id": "App ID",
"app_secret": "App secret"
},
"data_description": {
"api_url": "Select the server region closest to your location",
"app_id": "The app ID obtained from the Imou cloud platform",
"app_secret": "The app secret obtained from the Imou cloud platform"
},
"title": "Log in to Imou cloud"
}
}
},
"entity": {
"button": {
"mute": {
"name": "Mute"
},
"ptz_down": {
"name": "PTZ down"
},
"ptz_left": {
"name": "PTZ left"
},
"ptz_right": {
"name": "PTZ right"
},
"ptz_up": {
"name": "PTZ up"
}
}
},
"selector": {
"api_url": {
"options": {
"cn": "China",
"eu": "Europe",
"na": "North America",
"sg": "Singapore (Asia-Pacific)"
}
}
}
}
+11 -79
View File
@@ -1,21 +1,12 @@
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
from aiohttp import ClientResponseError
from incomfortclient import InvalidGateway, InvalidHeaterList
from incomfortclient import Gateway as InComfortGateway
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import (
InComfortConfigEntry,
InComfortData,
InComfortDataCoordinator,
async_connect_gateway,
)
from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound
from .coordinator import InComfortConfigEntry, InComfortDataCoordinator
PLATFORMS = (
Platform.WATER_HEATER,
@@ -27,75 +18,16 @@ PLATFORMS = (
INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
@callback
def async_cleanup_stale_devices(
hass: HomeAssistant,
entry: InComfortConfigEntry,
data: InComfortData,
gateway_device: dr.DeviceEntry,
) -> None:
"""Cleanup stale heater devices and climates."""
heater_serial_numbers = {heater.serial_no for heater in data.heaters}
device_registry = dr.async_get(hass)
device_entries = device_registry.devices.get_devices_for_config_entry_id(
entry.entry_id
)
stale_heater_serial_numbers: list[str] = [
device_entry.serial_number
for device_entry in device_entries
if device_entry.id != gateway_device.id
and device_entry.serial_number is not None
and device_entry.serial_number not in heater_serial_numbers
]
if not stale_heater_serial_numbers:
return
cleanup_devices: list[str] = []
# Find stale heater and climate devices
for serial_number in stale_heater_serial_numbers:
cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)]
cleanup_list.append(serial_number)
cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list]
cleanup_devices.extend(
device_entry.id
for device_entry in device_entries
if device_entry.identifiers in cleanup_identifiers
)
for device_id in cleanup_devices:
device_registry.async_remove_device(device_id)
async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool:
"""Set up a config entry."""
try:
data = await async_connect_gateway(hass, dict(entry.data))
for heater in data.heaters:
await heater.update()
except InvalidHeaterList as exc:
raise NoHeaters from exc
except InvalidGateway as exc:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="incorrect_credentials"
) from exc
except ClientResponseError as exc:
if exc.status == 404:
raise NotFound from exc
raise InComfortUnknownError from exc
except TimeoutError as exc:
raise InComfortTimeout from exc
# Register discovered gateway device
device_registry = dr.async_get(hass)
gateway_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.entry_id)},
connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
if entry.unique_id is not None
else set(),
manufacturer="Intergas",
name="RFGateway",
credentials = dict(entry.data)
hostname = credentials.pop(CONF_HOST)
client = InComfortGateway(
hostname, **credentials, session=async_get_clientsession(hass)
)
async_cleanup_stale_devices(hass, entry, data, gateway_device)
coordinator = InComfortDataCoordinator(hass, entry, data)
coordinator = InComfortDataCoordinator(hass, entry, client)
entry.runtime_data = coordinator
await coordinator.async_config_entry_first_refresh()
@@ -4,7 +4,11 @@ from collections.abc import Mapping
import logging
from typing import Any, override
from incomfortclient import InvalidGateway, InvalidHeaterList
from incomfortclient import (
Gateway as InComfortGateway,
InvalidGateway,
InvalidHeaterList,
)
import voluptuous as vol
from homeassistant.config_entries import (
@@ -17,6 +21,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
BooleanSelector,
@@ -28,7 +33,7 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import InComfortConfigEntry, async_connect_gateway
from .coordinator import InComfortConfigEntry
_LOGGER = logging.getLogger(__name__)
TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
@@ -81,7 +86,13 @@ async def async_try_connect_gateway(
) -> dict[str, str] | None:
"""Try to connect to the Lan2RF gateway."""
try:
await async_connect_gateway(hass, config)
client = InComfortGateway(
hostname=config[CONF_HOST],
username=config.get(CONF_USERNAME),
password=config.get(CONF_PASSWORD),
session=async_get_clientsession(hass),
)
await client.heaters()
except InvalidGateway:
return {"base": "auth_error"}
except InvalidHeaterList:
@@ -2,21 +2,22 @@
from dataclasses import dataclass, field
from datetime import timedelta
from http import HTTPStatus
import logging
from typing import Any, override
from typing import override
from aiohttp import ClientResponseError
from incomfortclient import (
Gateway as InComfortGateway,
Heater as InComfortHeater,
InvalidGateway,
InvalidHeaterList,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -36,20 +37,41 @@ class InComfortData:
heaters: list[InComfortHeater] = field(default_factory=list)
async def async_connect_gateway(
@callback
def async_cleanup_stale_devices(
hass: HomeAssistant,
entry_data: dict[str, Any],
) -> InComfortData:
"""Validate the configuration."""
credentials = dict(entry_data)
hostname = credentials.pop(CONF_HOST)
client = InComfortGateway(
hostname, **credentials, session=async_get_clientsession(hass)
entry: InComfortConfigEntry,
data: InComfortData,
gateway_device: dr.DeviceEntry,
) -> None:
"""Cleanup stale heater devices and climates."""
heater_serial_numbers = {heater.serial_no for heater in data.heaters}
device_registry = dr.async_get(hass)
device_entries = device_registry.devices.get_devices_for_config_entry_id(
entry.entry_id
)
heaters = await client.heaters()
return InComfortData(client=client, heaters=heaters)
stale_heater_serial_numbers: list[str] = [
device_entry.serial_number
for device_entry in device_entries
if device_entry.id != gateway_device.id
and device_entry.serial_number is not None
and device_entry.serial_number not in heater_serial_numbers
]
if not stale_heater_serial_numbers:
return
cleanup_devices: list[str] = []
# Find stale heater and climate devices
for serial_number in stale_heater_serial_numbers:
cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)]
cleanup_list.append(serial_number)
cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list]
cleanup_devices.extend(
device_entry.id
for device_entry in device_entries
if device_entry.identifiers in cleanup_identifiers
)
for device_id in cleanup_devices:
device_registry.async_remove_device(device_id)
class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
@@ -61,10 +83,9 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
self,
hass: HomeAssistant,
config_entry: InComfortConfigEntry,
incomfort_data: InComfortData,
client: InComfortGateway,
) -> None:
"""Initialize coordinator."""
self.unique_id = config_entry.unique_id
super().__init__(
hass,
_LOGGER,
@@ -72,28 +93,65 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
name="InComfort datacoordinator",
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
self.incomfort_data = incomfort_data
self.client = client
self.unique_id = config_entry.unique_id
@override
async def _async_update_data(self) -> InComfortData:
"""Fetch data from API endpoint."""
"""Fetch data from Incomfort."""
try:
for heater in self.incomfort_data.heaters:
heaters = await self.client.heaters()
for heater in heaters:
await heater.update()
except ClientResponseError as exc:
if exc.status == 401:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="incorrect_credentials"
) from exc
except InvalidGateway as exc:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
) from exc
except TimeoutError as exc:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_with_error_message",
translation_placeholders={"error": exc.message},
translation_key="timeout_error",
) from exc
except ClientResponseError as exc:
if exc.status == HTTPStatus.UNAUTHORIZED:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
) from exc
_LOGGER.exception("Error communicating with InComfort gateway")
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unknown",
) from exc
except InvalidHeaterList as exc:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_with_error_message",
translation_placeholders={"error": exc.message},
translation_key="no_heaters",
) from exc
return self.incomfort_data
incomfort_data = InComfortData(
client=self.client,
heaters=heaters,
)
# Register discovered gateway device
# Respect this as it is. Maybe later...
device_registry = dr.async_get(self.hass)
gateway_device = device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
identifiers={(DOMAIN, self.config_entry.entry_id)},
connections={(dr.CONNECTION_NETWORK_MAC, self.config_entry.unique_id)}
if self.config_entry.unique_id is not None
else set(),
manufacturer="Intergas",
name="RFGateway",
)
async_cleanup_stale_devices(
self.hass,
self.config_entry,
incomfort_data,
gateway_device,
)
return incomfort_data
@@ -27,15 +27,14 @@ def _async_get_diagnostics(
redacted_config = async_redact_data(entry.data | entry.options, REDACT_CONFIG)
coordinator = entry.runtime_data
nr_heaters = len(coordinator.incomfort_data.heaters)
nr_heaters = len(coordinator.data.heaters)
status: dict[str, Any] = {
f"heater_{n}": coordinator.incomfort_data.heaters[n].status
for n in range(nr_heaters)
f"heater_{n}": coordinator.data.heaters[n].status for n in range(nr_heaters)
}
for n in range(nr_heaters):
status[f"heater_{n}"]["rooms"] = {
m: dict(coordinator.incomfort_data.heaters[n].rooms[m].status)
for m in range(len(coordinator.incomfort_data.heaters[n].rooms))
m: dict(coordinator.data.heaters[n].rooms[m].status)
for m in range(len(coordinator.data.heaters[n].rooms))
}
return {
"config": redacted_config,
@@ -1,33 +0,0 @@
"""Exceptions raised by Intergas InComfort integration."""
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from .const import DOMAIN
class NotFound(HomeAssistantError):
"""Raise exception if no Lan2RF Gateway was found."""
translation_domain = DOMAIN
translation_key = "not_found"
class NoHeaters(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
translation_domain = DOMAIN
translation_key = "no_heaters"
class InComfortTimeout(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
translation_domain = DOMAIN
translation_key = "timeout_error"
class InComfortUnknownError(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
translation_domain = DOMAIN
translation_key = "unknown"
@@ -131,7 +131,9 @@
}
},
"exceptions": {
"incorrect_credentials": { "message": "Incorrect credentials." },
"invalid_auth": {
"message": "[%key:component::incomfort::config::error::auth_error%]"
},
"no_heaters": {
"message": "[%key:component::incomfort::config::error::no_heaters%]"
},
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==5.8.0"]
"requirements": ["infrared-protocols==5.8.1"]
}
+1 -1
View File
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/itach",
"iot_class": "assumed_state",
"quality_scale": "legacy",
"requirements": ["pyitachip2ir==0.0.7"]
"requirements": ["pyitachip2ir2==0.0.8"]
}
+1 -1
View File
@@ -14,7 +14,7 @@
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": [
"onvif-zeep-async==4.1.1",
"onvif-zeep-async==4.2.0",
"onvif_parsers==2.3.0",
"WSDiscovery==2.1.2"
]
@@ -15,7 +15,11 @@ from opendisplay import (
OpenDisplayError,
)
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.components.bluetooth import (
BluetoothReachabilityIntent,
async_address_reachability_diagnostics,
async_ble_device_from_address,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -83,9 +87,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry)
ble_device = async_ble_device_from_address(hass, address, connectable=True)
if ble_device is None:
raise ConfigEntryNotReady(
f"Could not find OpenDisplay device with address {address}"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
encryption_key = _get_encryption_key(entry)
try:
@@ -22,7 +22,11 @@ from opendisplay import (
from PIL import Image as PILImage, ImageOps
import voluptuous as vol
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.components.bluetooth import (
BluetoothReachabilityIntent,
async_address_reachability_diagnostics,
async_ble_device_from_address,
)
from homeassistant.components.http.auth import async_sign_path
from homeassistant.components.media_source import async_resolve_media
from homeassistant.config_entries import ConfigEntryState
@@ -108,7 +112,7 @@ def _get_entry_for_device(call: ServiceCall) -> OpenDisplayConfigEntry:
if entry is None or entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_key="config_entry_not_found",
translation_placeholders={"address": mac_address},
)
@@ -171,7 +175,14 @@ async def _async_upload_image(call: ServiceCall) -> None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"address": address},
translation_placeholders={
"address": address,
"reason": async_address_reachability_diagnostics(
call.hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
current = asyncio.current_task()
@@ -74,8 +74,11 @@
"authentication_error": {
"message": "Authentication failed. Please update the encryption key."
},
"config_entry_not_found": {
"message": "Config entry not found: `{address}`"
},
"device_not_found": {
"message": "Could not find Bluetooth device with address `{address}`."
"message": "Could not find Bluetooth device with address `{address}`. Reason: {reason}"
},
"invalid_device_id": {
"message": "Device `{device_id}` is not a valid OpenDisplay device."
+10 -3
View File
@@ -8,9 +8,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
PLATFORMS = [Platform.NUMBER, Platform.SENSOR]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
@@ -25,9 +26,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) ->
try:
await charger.test_and_get()
except TimeoutError as ex:
raise ConfigEntryNotReady("Unable to connect to charger") from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="communication_error",
) from ex
except AuthenticationError as ex:
raise ConfigEntryAuthFailed("Invalid credentials for charger") from ex
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from ex
coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger)
await coordinator.async_config_entry_first_refresh()
@@ -0,0 +1,120 @@
"""Support for monitoring OpenEVSE Charger binary sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from openevsehttp.__main__ import OpenEVSE
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OpenEVSEBinarySensorDescription(BinarySensorEntityDescription):
"""Describes an OpenEVSE binary sensor entity."""
value_fn: Callable[[OpenEVSE], bool | None]
BINARY_SENSOR_TYPES: tuple[OpenEVSEBinarySensorDescription, ...] = (
OpenEVSEBinarySensorDescription(
key="vehicle",
translation_key="vehicle",
device_class=BinarySensorDeviceClass.PLUG,
value_fn=lambda ev: ev.vehicle,
),
OpenEVSEBinarySensorDescription(
key="divert_active",
translation_key="divert_active",
value_fn=lambda ev: ev.divert_active,
),
OpenEVSEBinarySensorDescription(
key="using_ethernet",
translation_key="using_ethernet",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ev: ev.using_ethernet,
),
OpenEVSEBinarySensorDescription(
key="shaper_active",
translation_key="shaper_active",
value_fn=lambda ev: ev.shaper_active,
),
OpenEVSEBinarySensorDescription(
key="has_limit",
translation_key="has_limit",
entity_registry_enabled_default=False,
value_fn=lambda ev: ev.has_limit,
),
OpenEVSEBinarySensorDescription(
key="mqtt_connected",
translation_key="mqtt_connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ev: ev.mqtt_connected,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OpenEVSEConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OpenEVSE binary sensors based on config entry."""
coordinator = entry.runtime_data
identifier = entry.unique_id or entry.entry_id
async_add_entities(
OpenEVSEBinarySensor(coordinator, description, identifier, entry.unique_id)
for description in BINARY_SENSOR_TYPES
)
class OpenEVSEBinarySensor(
CoordinatorEntity[OpenEVSEDataUpdateCoordinator], BinarySensorEntity
):
"""Implementation of an OpenEVSE binary sensor."""
_attr_has_entity_name = True
entity_description: OpenEVSEBinarySensorDescription
def __init__(
self,
coordinator: OpenEVSEDataUpdateCoordinator,
description: OpenEVSEBinarySensorDescription,
identifier: str,
unique_id: str | None,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{identifier}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, identifier)},
manufacturer="OpenEVSE",
)
if unique_id:
self._attr_device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, unique_id)
}
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
@property
def is_on(self) -> bool | None:
"""Return True if the binary sensor is on."""
return self.entity_description.value_fn(self.coordinator.charger)
@@ -63,7 +63,11 @@ class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]):
await self.charger.update()
except TimeoutError as error:
raise UpdateFailed(
f"Timeout communicating with charger: {error}"
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
except AuthenticationError as error:
raise ConfigEntryAuthFailed("Invalid credentials for charger") from error
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from error
+22 -2
View File
@@ -42,6 +42,26 @@
}
},
"entity": {
"binary_sensor": {
"divert_active": {
"name": "Divert active"
},
"has_limit": {
"name": "Limit active"
},
"mqtt_connected": {
"name": "MQTT connected"
},
"shaper_active": {
"name": "Shaper active"
},
"using_ethernet": {
"name": "Ethernet connected"
},
"vehicle": {
"name": "Vehicle connected"
}
},
"number": {
"charge_rate": {
"name": "Charge rate"
@@ -168,10 +188,10 @@
},
"exceptions": {
"authentication_error": {
"message": "Authentication failed while communicating with the charger."
"message": "Authentication failed"
},
"communication_error": {
"message": "Failed to communicate with the charger."
"message": "Failed to communicate with the charger"
},
"invalid_value": {
"message": "Value {value} is invalid for the charger."
@@ -1,7 +1,5 @@
"""Device tracker support for OPNsense routers."""
from typing import Any
from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -98,20 +96,3 @@ class OPNsenseDeviceTrackerEntity(
hostname = device_data.get("hostname")
return hostname or None
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
device_data = self.device_data
if not device_data:
return {}
attrs = {}
if manufacturer := device_data.get("manufacturer"):
attrs["manufacturer"] = manufacturer
if interface := device_data.get("intf_description"):
attrs["interface"] = interface
if expires := device_data.get("expires"):
attrs["expires"] = expires
return attrs
@@ -2,7 +2,7 @@
import asyncio
import logging
from typing import Any, cast
from typing import Any
from awesomeversion import AwesomeVersion, AwesomeVersionException
from httpx import HTTPError, InvalidURL
@@ -41,7 +41,8 @@ def ensure_printer_is_supported(version: VersionInfo) -> None:
# Workaround to allow PrusaLink 0.7.2 on MK3 and MK2.5 that supports
# the 2.0.0 API, but doesn't advertise it yet
original = cast(str, version.get("original", ""))
original_value = version.get("original")
original = original_value if isinstance(original_value, str) else ""
if original.startswith(("PrusaLink I3MK3", "PrusaLink I3MK2")) and (
AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"])
):
+4 -3
View File
@@ -23,9 +23,9 @@ SPEED_LIST = [
Speed.Turbo,
]
PRESET_MODE_AUTO = "Auto"
PRESET_MODE_MANUAL = "Manual"
PRESET_MODE_POLLEN = "Pollen"
PRESET_MODE_AUTO = "auto"
PRESET_MODE_MANUAL = "manual"
PRESET_MODE_POLLEN = "pollen"
PRESET_MODES = {
PRESET_MODE_AUTO: Mode.Auto,
@@ -46,6 +46,7 @@ async def async_setup_entry(
class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity):
"""Fan control functions of the Rabbit Air air purifier."""
_attr_translation_key = "rabbitair"
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED
@@ -0,0 +1,17 @@
{
"entity": {
"fan": {
"rabbitair": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "mdi:fan-auto",
"manual": "mdi:fan",
"pollen": "mdi:flower-pollen"
}
}
}
}
}
}
}
@@ -18,5 +18,20 @@
}
}
}
},
"entity": {
"fan": {
"rabbitair": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]",
"pollen": "Pollen"
}
}
}
}
}
}
}
@@ -41,6 +41,7 @@ async def async_get_config_entry_diagnostics(
"HTTP(S) port": api.port,
"Baichuan port": api.baichuan.port,
"Baichuan only": api.baichuan_only,
"Baichuan connection": api.baichuan.connection_type.value,
"WiFi connection": api.wifi_connection(),
"WiFi signal": api.wifi_signal(),
"RTMP enabled": api.rtmp_enabled,
@@ -48,10 +49,15 @@ async def async_get_config_entry_diagnostics(
"ONVIF enabled": api.onvif_enabled,
"event connection": host.event_connection,
"stream protocol": api.protocol,
"is NVR": api.is_nvr,
"is Hub": api.is_hub,
"is Battery": api.is_battery,
"channels": api.channels,
"stream channels": api.stream_channels,
"IPC cams": ipc_cam,
"Chimes": chimes,
"Broken cmds": api.broken_cmds,
"Baichuan fallbacks": api.baichuan_cmds,
"capabilities": api.capabilities,
"cmd list": host.update_cmd,
"firmware ch list": host.firmware_ch_list,
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.20.0"]
"requirements": ["reolink-aio==0.20.1"]
}
@@ -38,5 +38,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.7.3"]
"requirements": ["pysmartthings==4.0.0"]
}
@@ -3,7 +3,9 @@
import datetime
from functools import partial
import logging
import os
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from soco import SoCo, alarms
from soco.core import (
@@ -90,6 +92,7 @@ SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()}
UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"]
ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: frozenset[str] = frozenset({".mp3", ".wav"})
async def async_setup_entry(
@@ -460,6 +463,15 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
volume = kwargs.get("extra", {}).get("volume")
ext = os.path.splitext(urlparse(media_id).path)[1].lower()
if ext and ext not in ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS:
_LOGGER.warning(
"Sonos AudioClip announce only supports MP3 and WAV; "
"%s has extension %s and will be attempted as a clip anyway on %s",
media_id,
ext,
self.speaker.zone_name,
)
_LOGGER.debug("Playing %s using websocket audioclip", media_id)
try:
assert self.speaker.websocket
@@ -61,7 +61,11 @@ PLATFORMS_BY_TYPE = {
Platform.SENSOR,
Platform.SELECT,
],
SupportedModels.CONTACT.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.CONTACT.value: [
Platform.BINARY_SENSOR,
Platform.EVENT,
Platform.SENSOR,
],
SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.PRESENCE_SENSOR.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.HUMIDIFIER.value: [Platform.HUMIDIFIER, Platform.SENSOR],
+2 -2
View File
@@ -218,8 +218,8 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or (
_tilt > self.CLOSED_UP_THRESHOLD
)
self._attr_is_opening = self.parsed_data["motionDirection"]["opening"]
self._attr_is_closing = self.parsed_data["motionDirection"]["closing"]
self._attr_is_opening = self._device.is_opening()
self._attr_is_closing = self._device.is_closing()
self.async_write_ha_state()
+36 -16
View File
@@ -1,5 +1,7 @@
"""Support for SwitchBot event entities."""
from dataclasses import dataclass
from homeassistant.components.event import (
EventDeviceClass,
EventEntity,
@@ -13,13 +15,31 @@ from .entity import SwitchbotEntity
PARALLEL_UPDATES = 0
EVENT_TYPES = {
"doorbell": EventEntityDescription(
@dataclass(frozen=True, kw_only=True)
class SwitchbotEventEntityDescription(EventEntityDescription):
"""Describes a Switchbot event entity."""
counter_key: str
fire_event: str
EVENT_DESCRIPTIONS: tuple[SwitchbotEventEntityDescription, ...] = (
SwitchbotEventEntityDescription(
key="doorbell",
device_class=EventDeviceClass.DOORBELL,
event_types=["ring"],
counter_key="doorbell_seq",
fire_event="ring",
),
}
SwitchbotEventEntityDescription(
key="button",
device_class=EventDeviceClass.BUTTON,
event_types=["press"],
counter_key="button_count",
fire_event="press",
),
)
async def async_setup_entry(
@@ -30,34 +50,34 @@ async def async_setup_entry(
"""Set up the SwitchBot event platform."""
coordinator = config_entry.runtime_data
async_add_entities(
SwitchbotEventEntity(coordinator, event, description)
for event, description in EVENT_TYPES.items()
if event in coordinator.device.parsed_data
SwitchbotEventEntity(coordinator, description)
for description in EVENT_DESCRIPTIONS
if description.counter_key in coordinator.device.parsed_data
)
class SwitchbotEventEntity(SwitchbotEntity, EventEntity):
"""Representation of a SwitchBot event."""
entity_description: SwitchbotEventEntityDescription
def __init__(
self,
coordinator: SwitchbotDataUpdateCoordinator,
event: str,
description: EventEntityDescription,
description: SwitchbotEventEntityDescription,
) -> None:
"""Initialize the SwitchBot event."""
super().__init__(coordinator)
self._event = event
self.entity_description = description
self._attr_unique_id = f"{coordinator.base_unique_id}-{event}"
self._previous_doorbell_seq = int(
coordinator.device.parsed_data.get("doorbell_seq", 0)
self._attr_unique_id = f"{coordinator.base_unique_id}-{description.key}"
self._previous_counter = int(
coordinator.device.parsed_data.get(description.counter_key, 0)
)
@callback
def _async_update_attrs(self) -> None:
"""Update the entity attributes."""
seq = int(self.parsed_data.get("doorbell_seq", 0))
if seq not in (0, self._previous_doorbell_seq):
self._trigger_event("ring")
self._previous_doorbell_seq = seq
counter = int(self.parsed_data.get(self.entity_description.counter_key, 0))
if counter not in (0, self._previous_counter):
self._trigger_event(self.entity_description.fire_event)
self._previous_counter = counter
@@ -110,17 +110,15 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = {
True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR)
),
"Home Climate Panel": SwitchbotCloudDeviceConfig(
False, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR)
True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR)
),
"WeatherStation": SwitchbotCloudDeviceConfig(
False, entity_config=(Platform.SENSOR,)
),
"Meter": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"MeterPlus": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"WoIOSensor": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"Hub 2": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"MeterPro": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"MeterPro(CO2)": SwitchbotCloudDeviceConfig(
False, entity_config=(Platform.SENSOR,)
True, entity_config=(Platform.SENSOR,)
),
"Meter": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"MeterPlus": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"WoIOSensor": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
}
@@ -139,12 +139,14 @@
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"in_zones": "Zones",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"in_zones": "Defines a template that returns a list of zones the device tracker is currently in. The template should return a list of zone entity IDs. If the device tracker is not in any zone, the template should return an empty list.",
"latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.",
"longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.",
"name": "[%key:common::config_flow::data::name%]"
@@ -715,11 +717,13 @@
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"in_zones": "[%key:component::template::config::step::device_tracker::data::in_zones%]",
"latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"in_zones": "[%key:component::template::config::step::device_tracker::data_description::in_zones%]",
"latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]"
},
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["verisure"],
"requirements": ["vsure==2.6.7"]
"requirements": ["vsure==2.7.0"]
}
+27 -5
View File
@@ -3,7 +3,11 @@
from abc import abstractmethod
from typing import Any
from victron_mqtt import Device as VictronVenusDevice, Metric as VictronVenusMetric
from victron_mqtt import (
Device as VictronVenusDevice,
Metric as VictronVenusMetric,
MetricType,
)
from homeassistant.const import EntityCategory
from homeassistant.core import callback
@@ -14,6 +18,8 @@ from homeassistant.helpers.entity import Entity
ENTITIES_CATEGORY_DIAGNOSTIC = ["system_heartbeat", "platform_device_reboot"]
# Entities that should be disabled by default
ENTITIES_DISABLE_BY_DEFAULT = ["system_heartbeat", "platform_device_reboot"]
# Units that must be provided directly instead of via localization.
SPECIAL_NATIVE_UNITS = {"%", "Ah"}
class VictronBaseEntity(Entity):
@@ -46,10 +52,6 @@ class VictronBaseEntity(Entity):
if metric.main_topic:
self._attr_name = None
# Special case for "%" as it should not be coming from the localization file
self._attr_native_unit_of_measurement = (
"%" if metric.unit_of_measurement == "%" else None
)
self._attr_entity_category = (
EntityCategory.DIAGNOSTIC
if metric.generic_short_id in ENTITIES_CATEGORY_DIAGNOSTIC
@@ -59,6 +61,26 @@ class VictronBaseEntity(Entity):
metric.generic_short_id not in ENTITIES_DISABLE_BY_DEFAULT
)
def _native_unit_of_measurement(self) -> str | None:
unit_of_measurement = self._metric.unit_of_measurement
# We need to provide a native unit in three cases:
if (
# 1. Special units which will never need a translation and therefore will not be included in the translation file.
unit_of_measurement in SPECIAL_NATIVE_UNITS
# 2. When there is known device class which support multiple units. In this case
# we publish what we have and HA will allow conversion to other supported units.
# We specifically don't put those cases in the translation file by the merge script
# not to waste translation resources so it has to come from here.
or self._attr_device_class is not None
# 3. Dynamic units come from user-configured MQTT topics (e.g.
# SwitchableOutput Settings/Unit) and have no translation file
# entry, so we must set the unit programmatically.
or self._metric.metric_type == MetricType.DYNAMIC
):
return unit_of_measurement
return None
@callback
@abstractmethod
def _on_update_cb(self, value: Any) -> None:

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