Compare commits

..

91 Commits

Author SHA1 Message Date
Mike Degatano
695f3a3f99 Remove get_issues_info from hassio __all__ 2026-03-18 15:47:19 +00:00
Raj Laud
a63516ff71 Allow retry on invalid encryption key in victron_ble config flow (#165600)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-18 15:53:03 +01:00
Joost Lekkerkerker
55b082edb6 Add binary sensor for smartthings microfilter blockage (#165917) 2026-03-18 15:44:43 +01:00
Robert Resch
b0c3ede4fd Improve type hints for startca (#165720) 2026-03-18 15:43:50 +01:00
johanzander
84bd1cd336 growatt_server: use icon-translations instead of hardcoded _attr_icon (#165920)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:41:48 +01:00
Erik Montnemery
25bbfcc595 Add gate conditions (#165898) 2026-03-18 15:27:27 +01:00
johanzander
bf05925c8b growatt_server: replace custom precision with suggested_display_precision (#165858)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:20:30 +01:00
Jan Čermák
488d9ad75c Use new home-assistant/builder actions for image builds (#164756) 2026-03-18 14:44:53 +01:00
Vincent Le Ligeour
2dfad3d755 Add battery charge limit controls to Renault (#163079)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-03-18 14:29:36 +01:00
Stefan Agner
7e759bf730 Fix Abort exception caught by wrong handler in backup encrypt/decrypt (#165852)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:28:56 +01:00
Robert Svensson
9678049e72 Bump axis to v67 (#165840) 2026-03-18 14:25:54 +01:00
David Bonnes
8602ba2679 Extend Evohome tests to cover legacy service calls (#164316) 2026-03-18 13:57:31 +01:00
Paulus Schoutsen
78c3503b7d Remove unnecessary volume_up/volume_down overrides from ws66i media player (#164433)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 13:56:22 +01:00
Paulus Schoutsen
fbb3b81991 Remove unnecessary volume_up/volume_down overrides from songpal media player (#164432)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 13:56:04 +01:00
Paulus Schoutsen
26eaf510ee Remove unnecessary volume_up/volume_down overrides from clementine media player (#164427)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 13:55:50 +01:00
Erik Montnemery
5c83d16995 Add select triggers (#165378)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 13:55:02 +01:00
Joost Lekkerkerker
388b258d6c Add Zinvolt problem binary sensors (#164091) 2026-03-18 13:54:42 +01:00
Jeef
2c9a5c10da Add data-description strings to IntelliFire (#165910)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 13:49:04 +01:00
Erik Montnemery
5a68bafd69 Add garage_door conditions (#165897) 2026-03-18 13:29:13 +01:00
Erik Montnemery
33fce89a2b Add window conditions (#165899) 2026-03-18 13:16:11 +01:00
Erwin Douna
1932f61da3 Proxmox fix restart/reboot action (#165901) 2026-03-18 11:55:51 +01:00
Mike Degatano
5a231b27b9 Add repair for deprecated arch addon issue (#165511) 2026-03-18 11:53:09 +01:00
Steve Easley
5617e8c7bc Move jvc_projector sensor entities to select domain (#165194) 2026-03-18 11:34:03 +01:00
Emil Burzo
2b5b0e9d0f Add battery temperature sensor to Fully Kiosk Browser integration (#165714) 2026-03-18 11:22:25 +01:00
Josef Zweck
732f553b48 Safely consume events in hassio test (#165892) 2026-03-18 10:43:20 +01:00
Erik Montnemery
0a53b227ed Add door conditions (#165885) 2026-03-18 10:19:06 +01:00
Erik Montnemery
44b73ab7bd Add occupancy conditions (#165678) 2026-03-18 09:43:17 +01:00
Erik Montnemery
538061d512 Add motion conditions (#165677) 2026-03-18 09:23:32 +01:00
Raj Laud
e307ceccb5 Bump victron-ble-ha-parser to 0.6.2 (#165832)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-18 08:47:34 +01:00
Erik Montnemery
ea7558c0ad Improve naming in condition and trigger test helpers (#165847)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 08:44:14 +01:00
johanzander
c4399b5547 growatt_server: add serial_number to DeviceInfo (devices quality scale rule) (#165857)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:42:24 +01:00
Erwin Douna
d989a83d7b Remove NotImplementedError in Volvo integration (#165856) 2026-03-18 08:41:35 +01:00
mettolen
d04f3530df Remove the icon property from Huum climate entity (#165870) 2026-03-18 08:28:02 +01:00
mettolen
647d957ffe Removed redundant logging from Huum integration (#165868) 2026-03-18 08:27:13 +01:00
johanzander
a3f3c87b39 growatt_server: add EntityCategory.DIAGNOSTIC to diagnostic sensors (#165880)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:24:49 +01:00
Nathan Spencer
447b17a2a4 Bump pyweatherflowudp to 1.5.2 (#165874) 2026-03-18 08:24:24 +01:00
Joost Lekkerkerker
eb2b92687c Add camera fixture to SmartThings (#165809) 2026-03-18 08:23:44 +01:00
Jack Boswell
6424e3658e Remove myself from Starlink codeowners (#165883) 2026-03-18 08:22:18 +01:00
Erik Montnemery
d1d8754853 Fix type annotations for set_or_remove_state test helper (#165843) 2026-03-18 08:03:45 +01:00
Erik Montnemery
c4ff7fa676 Fix bug in assert_condition_behavior_any test helper (#165838) 2026-03-18 08:03:18 +01:00
balloob-travel
f1fe1d3956 Update config flow testing instructions for AI (#165873)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2026-03-18 06:39:15 +01:00
Christopher Fenner
fd0d60b787 Fix return type in ViCare integration (#165861) 2026-03-18 03:12:06 +01:00
Stefan Agner
9ddefaaacd Bump aiohasupervisor to 0.4.2 (#165854) 2026-03-17 23:08:57 +01:00
Ludovic BOUÉ
5c8df048b1 Fix timezone in account creation date in test snapshot (#165831) 2026-03-17 22:53:36 +01:00
Raj Laud
d86d85ec56 Fix victron_ble charger error sensor always showing unknown (#165713)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-17 21:45:51 +00:00
tronikos
660f12b683 Implement dynamic-devices and stale-devices in Opower to mark it platinum (#165121)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 22:43:57 +01:00
Jan Bouwhuis
b8238c86e6 Cleanup unused vacuum test helpers (#165851) 2026-03-17 22:36:24 +01:00
Raman Gupta
754828188e Refactor Vizio integration to use DataUpdateCoordinator (#162188)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:20:01 -04:00
Erik Montnemery
6992a3c72b Adjust name and docstring of some trigger tests (#165846) 2026-03-17 22:11:32 +01:00
Joost Lekkerkerker
738d4f662a Bump pySmartThings to 3.7.2 (#165810) 2026-03-17 21:57:20 +01:00
Carlos Sánchez López
7f33ac72ab Add alarm control panel support for Tuya WG2 alarm panel (Duosmart C30) (#165837) 2026-03-17 21:44:57 +01:00
Carlos Sánchez López
0891d814fa Add sensor support for Tuya WG2 alarm panel (Duosmart C30) (#165834) 2026-03-17 21:42:20 +01:00
Carlos Sánchez López
ddab50edcc Add binary sensor support for Tuya WG2 alarm panel (Duosmart C30) (#165833) 2026-03-17 21:41:57 +01:00
Erik Montnemery
c8ce4eb32d Deduplicate tests testing conditions in mode all (#165841) 2026-03-17 21:06:26 +01:00
Jan Bouwhuis
22aca8b7af Add clean segment support to MQTT vacuum entities (#164983)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-17 20:27:42 +01:00
Erik Montnemery
770864082f Deduplicate tests testing conditions in mode any (#165801) 2026-03-17 19:23:47 +00:00
Abílio Costa
14545660e2 Make TODO subscriptions use TodoItem instead of JSON (#165802) 2026-03-17 19:09:13 +00:00
Jamie Magee
836353015b Detect new garage doors automatically in aladdin_connect (#165004) 2026-03-17 20:04:31 +01:00
Allen Porter
c57ffd4d78 Update python-roborock dependency to 4.25.0. (#165800)
Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
2026-03-17 19:58:18 +01:00
prana-dev-official
cbebfdf149 Add number platform for Prana integration (#165816) 2026-03-17 19:53:50 +01:00
Ludovic BOUÉ
d8ed9ca66f Fix timestamps in chess_com test diagnostics (#165829) 2026-03-17 19:30:08 +01:00
Cody
5caf8a5b83 Make Season integration timezone aware (#164876) 2026-03-17 18:09:25 +01:00
Aidan Timson
c05210683e Demo valve registry entry and device (#165803)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 17:37:21 +01:00
Joost Lekkerkerker
aa8dd4bb66 Add microfiber filter fixture to SmartThings (#165808) 2026-03-17 17:21:51 +01:00
Joost Lekkerkerker
ee7d6157d9 Fix Indevolt button snapshot (#165812) 2026-03-17 17:19:03 +01:00
Manu
adec1d128c Add exception handling to media source in Radio Browser integration (#164653) 2026-03-17 17:13:11 +01:00
prana-dev-official
0a2fc97696 Import improvement for Prana integration (#165805) 2026-03-17 16:28:53 +01:00
Joost Lekkerkerker
447d616097 Add select for SmartThings RVC sound mode (#164519) 2026-03-17 15:57:59 +01:00
Norbert Rittel
d3102e718d Consistenly sentence-case "API token" in habitica (#165369)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-17 14:30:39 +00:00
Josef Zweck
69ee49735a Remove support for homeassistant.update_entity from mold_indicator (#165797) 2026-03-17 15:26:22 +01:00
Daniel Hjelseth Høyer
35a99dd4a4 Fix Tibber update token (#164295)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-03-17 15:11:51 +01:00
Ariel Ebersberger
51c3397be8 Refactor wemo integration to use async service action handlers (#165794)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 15:07:00 +01:00
Brett Adams
57f0fd2ed2 Tesla Fleet: fix malformed energy live response handling (#165101) 2026-03-17 15:04:35 +01:00
Erik Montnemery
fa7a216afe Use return value from target_entities directly in condition tests (#165791) 2026-03-17 14:55:17 +01:00
Josef Zweck
20f4426e1d Fix mold_indicator sensor update (#158996) 2026-03-17 14:28:50 +01:00
Erik Montnemery
ba30563772 Deduplicate tests testing triggers in mode last (#165789) 2026-03-17 14:28:10 +01:00
A. Gideonse
b807c104a3 Add button platform to Indevolt integration (#165283) 2026-03-17 13:59:18 +01:00
epenet
9e6abb719a Add fixture for Kerui/Tuya video doorbell (#165786) 2026-03-17 13:57:28 +01:00
jvmahon
ed2083a60d Limit color temperature to maximum Matter MIREDs value (#163892)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-17 12:49:18 +00:00
Kornel
94db0d5eab Handle timeout in HKDevice.async_update (#162071)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-03-17 12:42:43 +00:00
dckiller51
06eed998b9 Add platform attribute to Xbox sensors (#161661) 2026-03-17 12:40:42 +00:00
Andrej Walilko
fb5c2f2566 Add shuffle service and enqueue support to jellyfin media player (#161632)
Co-authored-by: Andrej Walilko <awalilko@liquidweb.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-17 12:17:55 +00:00
Dominik
4f7d065230 Fix fritz target selector for dial and set_guest_wifi_password (#165396) 2026-03-17 13:15:51 +01:00
Erwin Douna
d034df9b93 Add Portainer request timeout (#165785) 2026-03-17 12:58:55 +01:00
Erik Montnemery
6c9fc7c7a1 Deduplicate tests testing triggers in mode first (#165779)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 12:44:21 +01:00
Leon Grave
ba58ef23d8 Add reauthentication-flow to freshr (#165545)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-17 12:22:54 +01:00
johanzander
0a0fa96ac1 Add silver quality scale for growatt_server (#165500) 2026-03-17 12:18:11 +01:00
Erik Montnemery
9cc7ef75b0 Move cover.trigger.CoverDomainSpec to cover.models (#165774) 2026-03-17 12:11:11 +01:00
Carlos Sánchez López
2e0d6d2bbf Add fixture for Tuya wg2 alarm panel (Duosmart C30) (#165701)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-03-17 11:34:53 +01:00
Artur Pragacz
bafef2065f Rework user-given entity name logic (#162763) 2026-03-17 11:09:20 +01:00
Erik Montnemery
fdfe87de4c Move condition/trigger test helpers to test.components.common (#165777) 2026-03-17 11:08:38 +01:00
1270 changed files with 53063 additions and 32228 deletions

View File

@@ -620,12 +620,14 @@ rules:
### Config Flow Testing
- **100% Coverage Required**: All config flow paths must be tested
- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end.
- **Test Scenarios**:
- All flow initiation methods (user, discovery, import)
- Successful configuration paths
- Error recovery scenarios
- Prevention of duplicate entries
- Flow completion after errors
- Reauthentication/reconfigure flows
### Testing
- **Integration-specific tests** (recommended):

1
.gitattributes vendored
View File

@@ -16,6 +16,7 @@ Dockerfile.dev linguist-language=Dockerfile
CODEOWNERS linguist-generated=true
Dockerfile linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true

View File

@@ -35,6 +35,7 @@ jobs:
channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }}
architectures: ${{ env.ARCHITECTURES }}
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -100,7 +101,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- arch: amd64
os: ubuntu-latest
os: ubuntu-24.04
- arch: aarch64
os: ubuntu-24.04-arm
steps:
@@ -195,77 +196,20 @@ jobs:
run: |
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Cosign
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
with:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build variables
id: vars
shell: bash
env:
ARCH: ${{ matrix.arch }}
run: |
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
- name: Verify base image signature
env:
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
"${BASE_IMAGE}"
- name: Verify cache image signature
id: cache
continue-on-error: true
env:
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
"${CACHE_IMAGE}"
- name: Build base image
id: build
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
context: .
file: ./Dockerfile
platforms: ${{ steps.vars.outputs.platform }}
push: true
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
arch: ${{ matrix.arch }}
build-args: |
BUILD_FROM=${{ steps.vars.outputs.base_image }}
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
labels: |
io.hass.arch=${{ matrix.arch }}
io.hass.version=${{ needs.init.outputs.version }}
org.opencontainers.image.created=${{ steps.vars.outputs.created }}
org.opencontainers.image.version=${{ needs.init.outputs.version }}
- name: Sign image
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.init.outputs.version }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
image-tags: ${{ needs.init.outputs.version }}
push: true
version: ${{ needs.init.outputs.version }}
build_machine:
name: Build ${{ matrix.machine }} machine core image
@@ -314,35 +258,38 @@ jobs:
with:
persist-credentials: false
- name: Set build additional args
- name: Compute extra tags
id: tags
shell: bash
env:
VERSION: ${{ needs.init.outputs.version }}
run: |
# Create general tags
if [[ "${VERSION}" =~ d ]]; then
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
elif [[ "${VERSION}" =~ b ]]; then
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
else
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
- name: Build machine image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
with:
image: ${{ matrix.arch }}
args: |
$BUILD_ARGS \
--target /data/machine \
--cosign \
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
arch: ${{ matrix.arch }}
build-args: |
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
context: machine/
cosign-base-identity: "https://github.com/home-assistant/core/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
file: machine/${{ matrix.machine }}
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
image-tags: |
${{ needs.init.outputs.version }}
${{ steps.tags.outputs.extra_tags }}
push: true
version: ${{ needs.init.outputs.version }}
publish_ha:
name: Publish version files

2
CODEOWNERS generated
View File

@@ -1616,8 +1616,6 @@ build.json @home-assistant/supervisor
/tests/components/srp_energy/ @briglx
/homeassistant/components/starline/ @anonym-tsk
/tests/components/starline/ @anonym-tsk
/homeassistant/components/starlink/ @boswelja
/tests/components/starlink/ @boswelja
/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
/tests/components/statistics/ @ThomDietrich @gjohansson-ST
/homeassistant/components/steam_online/ @tkdrob

1
Dockerfile generated
View File

@@ -10,7 +10,6 @@ LABEL \
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.source="https://github.com/home-assistant/core" \
org.opencontainers.image.title="Home Assistant" \
org.opencontainers.image.url="https://www.home-assistant.io/"

View File

@@ -46,19 +46,10 @@ async def async_setup_entry(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
try:
doors = await client.get_doors()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
coordinator = AladdinConnectCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = {
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
for door in doors
}
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -100,7 +91,7 @@ def remove_stale_devices(
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
all_device_ids = set(config_entry.runtime_data)
all_device_ids = set(config_entry.runtime_data.data)
for device_entry in device_entries:
device_id: str | None = None

View File

@@ -11,22 +11,24 @@ from genie_partner_sdk.model import GarageDoor
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
SCAN_INTERVAL = timedelta(seconds=15)
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
class AladdinConnectCoordinator(DataUpdateCoordinator[dict[str, GarageDoor]]):
"""Coordinator for Aladdin Connect integration."""
config_entry: AladdinConnectConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
client: AladdinConnectClient,
garage_door: GarageDoor,
) -> None:
"""Initialize the coordinator."""
super().__init__(
@@ -37,18 +39,16 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
update_interval=SCAN_INTERVAL,
)
self.client = client
self.data = garage_door
async def _async_update_data(self) -> GarageDoor:
async def _async_update_data(self) -> dict[str, GarageDoor]:
"""Fetch data from the Aladdin Connect API."""
try:
await self.client.update_door(self.data.device_id, self.data.door_number)
doors = await self.client.get_doors()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(f"Error communicating with API: {err}") from err
except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
self.data.status = self.client.get_door_status(
self.data.device_id, self.data.door_number
)
self.data.battery_level = self.client.get_battery_status(
self.data.device_id, self.data.door_number
)
return self.data
return {door.unique_id: door for door in doors}

View File

@@ -7,7 +7,7 @@ from typing import Any
import aiohttp
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -24,11 +24,22 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the cover platform."""
coordinators = entry.runtime_data
coordinator = entry.runtime_data
known_devices: set[str] = set()
async_add_entities(
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
)
@callback
def _async_add_new_devices() -> None:
"""Detect and add entities for new doors."""
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AladdinCoverEntity(coordinator, door_id) for door_id in new_devices
)
_async_add_new_devices()
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
@@ -38,10 +49,10 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
_attr_supported_features = SUPPORTED_FEATURES
_attr_name = None
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
"""Initialize the Aladdin Connect cover."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.data.unique_id
super().__init__(coordinator, door_id)
self._attr_unique_id = door_id
async def async_open_cover(self, **kwargs: Any) -> None:
"""Issue open command to cover."""
@@ -66,16 +77,16 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
@property
def is_closed(self) -> bool | None:
"""Update is closed attribute."""
if (status := self.coordinator.data.status) is None:
if (status := self.door.status) is None:
return None
return status == "closed"
@property
def is_closing(self) -> bool | None:
"""Update is closing attribute."""
return self.coordinator.data.status == "closing"
return self.door.status == "closing"
@property
def is_opening(self) -> bool | None:
"""Update is opening attribute."""
return self.coordinator.data.status == "opening"
return self.door.status == "opening"

View File

@@ -20,13 +20,13 @@ async def async_get_config_entry_diagnostics(
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"doors": {
uid: {
"device_id": coordinator.data.device_id,
"door_number": coordinator.data.door_number,
"name": coordinator.data.name,
"status": coordinator.data.status,
"link_status": coordinator.data.link_status,
"battery_level": coordinator.data.battery_level,
"device_id": door.device_id,
"door_number": door.door_number,
"name": door.name,
"status": door.status,
"link_status": door.link_status,
"battery_level": door.battery_level,
}
for uid, coordinator in config_entry.runtime_data.items()
for uid, door in config_entry.runtime_data.data.items()
},
}

View File

@@ -1,6 +1,7 @@
"""Base class for Aladdin Connect entities."""
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -14,17 +15,28 @@ class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
_attr_has_entity_name = True
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
"""Initialize Aladdin Connect entity."""
super().__init__(coordinator)
device = coordinator.data
self._door_id = door_id
door = self.door
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
identifiers={(DOMAIN, door.unique_id)},
manufacturer="Aladdin Connect",
name=device.name,
name=door.name,
)
self._device_id = device.device_id
self._number = device.door_number
self._device_id = door.device_id
self._number = door.door_number
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._door_id in self.coordinator.data
@property
def door(self) -> GarageDoor:
"""Return the garage door data."""
return self.coordinator.data[self._door_id]
@property
def client(self) -> AladdinConnectClient:

View File

@@ -57,7 +57,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done

View File

@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
@@ -49,13 +49,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aladdin Connect sensor devices."""
coordinators = entry.runtime_data
coordinator = entry.runtime_data
known_devices: set[str] = set()
async_add_entities(
AladdinConnectSensor(coordinator, description)
for coordinator in coordinators.values()
for description in SENSOR_TYPES
)
@callback
def _async_add_new_devices() -> None:
"""Detect and add entities for new doors."""
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AladdinConnectSensor(coordinator, door_id, description)
for door_id in new_devices
for description in SENSOR_TYPES
)
_async_add_new_devices()
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
@@ -66,14 +77,15 @@ class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
def __init__(
self,
coordinator: AladdinConnectCoordinator,
door_id: str,
entity_description: AladdinConnectSensorEntityDescription,
) -> None:
"""Initialize the Aladdin Connect sensor."""
super().__init__(coordinator)
super().__init__(coordinator, door_id)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
self._attr_unique_id = f"{door_id}-{entity_description.key}"
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
return self.entity_description.value_fn(self.door)

View File

@@ -123,16 +123,22 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"climate",
"cover",
"device_tracker",
"door",
"fan",
"garage_door",
"gate",
"humidifier",
"lawn_mower",
"light",
"lock",
"media_player",
"motion",
"occupancy",
"person",
"siren",
"switch",
"vacuum",
"window",
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
@@ -159,6 +165,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"remote",
"scene",
"schedule",
"select",
"siren",
"switch",
"text",

View File

@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==66"],
"requirements": ["axis==67"],
"ssdp": [
{
"manufacturer": "AXIS"

View File

@@ -246,6 +246,8 @@ def decrypt_backup(
except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err)
error = err
except Abort:
raise
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
error = err
@@ -332,8 +334,10 @@ def encrypt_backup(
except (EncryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error encrypting backup: %s", err)
error = err
except Abort:
raise
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
LOGGER.exception("Unexpected error when encrypting backup: %s", err)
error = err
else:
# Pad the output stream to the requested minimum size

View File

@@ -66,6 +66,7 @@ class ClementineDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY
)
_attr_volume_step = 0.04
def __init__(self, client, name):
"""Initialize the Clementine device."""
@@ -124,16 +125,6 @@ class ClementineDevice(MediaPlayerEntity):
return None, None
def volume_up(self) -> None:
"""Volume up the media player."""
newvolume = min(self._client.volume + 4, 100)
self._client.set_volume(newvolume)
def volume_down(self) -> None:
"""Volume down media player."""
newvolume = max(self._client.volume - 4, 0)
self._client.set_volume(newvolume)
def mute_volume(self, mute: bool) -> None:
"""Send mute command."""
self._client.set_volume(0)

View File

@@ -153,8 +153,8 @@ def websocket_get_entities(
{
vol.Required("type"): "config/entity_registry/update",
vol.Required("entity_id"): cv.entity_id,
vol.Optional("aliases"): [vol.Any(str, None)],
# If passed in, we update value. Passing None will remove old value.
vol.Optional("aliases"): list,
vol.Optional("area_id"): vol.Any(str, None),
# Categories is a mapping of key/value (scope/category_id) pairs.
# If passed in, we update/adjust only the provided scope(s).
@@ -225,10 +225,15 @@ def websocket_update_entity(
changes[key] = msg[key]
if "aliases" in msg:
# Create a set for the aliases without:
# - Empty strings
# Sanitize aliases by removing:
# - Trailing and leading whitespace characters in the individual aliases
changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())}
# - Empty strings
changes["aliases"] = aliases = []
for alias in msg["aliases"]:
if alias is None:
aliases.append(er.COMPUTED_NAME)
elif alias := alias.strip():
aliases.append(alias)
if "labels" in msg:
# Convert labels to a set

View File

@@ -992,18 +992,11 @@ class DefaultAgent(ConversationEntity):
continue
context[attr] = state.attributes[attr]
if (
entity := entity_registry.async_get(state.entity_id)
) and entity.aliases:
for alias in entity.aliases:
alias = alias.strip()
if not alias:
continue
yield (alias, alias, context)
# Default name
yield (state.name, state.name, context)
entity_entry = entity_registry.async_get(state.entity_id)
for name in intent.async_get_entity_aliases(
self.hass, entity_entry, state=state
):
yield (name, name, context)
def _recognize_strict(
self,

View File

@@ -32,6 +32,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
from .condition import make_cover_is_closed_condition, make_cover_is_open_condition
from .const import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
@@ -80,6 +81,8 @@ __all__ = [
"CoverEntityFeature",
"CoverState",
"make_cover_closed_trigger",
"make_cover_is_closed_condition",
"make_cover_is_open_condition",
"make_cover_opened_trigger",
]

View File

@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.condition import Condition, EntityConditionBase
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .trigger import CoverDomainSpec
from .models import CoverDomainSpec
class CoverConditionBase(EntityConditionBase[CoverDomainSpec]):

View File

@@ -0,0 +1,12 @@
"""Data models for the cover integration."""
from dataclasses import dataclass
from homeassistant.helpers.automation import DomainSpec
@dataclass(frozen=True, slots=True)
class CoverDomainSpec(DomainSpec):
"""DomainSpec with a target value for comparison."""
target_value: str | bool | None = None

View File

@@ -1,20 +1,11 @@
"""Provides triggers for covers."""
from dataclasses import dataclass
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
@dataclass(frozen=True, slots=True)
class CoverDomainSpec(DomainSpec):
"""DomainSpec with a target value for comparison."""
target_value: str | bool | None = None
from .models import CoverDomainSpec
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):

View File

@@ -9,9 +9,12 @@ from typing import Any
from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_utc_time_change
from . import DOMAIN
OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend
@@ -23,10 +26,10 @@ async def async_setup_entry(
"""Set up the Demo config entry."""
async_add_entities(
[
DemoValve("Front Garden", ValveState.OPEN),
DemoValve("Orchard", ValveState.CLOSED),
DemoValve("Back Garden", ValveState.CLOSED, position=70),
DemoValve("Trees", ValveState.CLOSED, position=30),
DemoValve("valve_1", "Front Garden", ValveState.OPEN),
DemoValve("valve_2", "Orchard", ValveState.CLOSED),
DemoValve("valve_3", "Back Garden", ValveState.CLOSED, position=70),
DemoValve("valve_4", "Trees", ValveState.CLOSED, position=30),
]
)
@@ -34,17 +37,24 @@ async def async_setup_entry(
class DemoValve(ValveEntity):
"""Representation of a Demo valve."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(
self,
unique_id: str,
name: str,
state: str,
moveable: bool = True,
position: int | None = None,
) -> None:
"""Initialize the valve."""
self._attr_name = name
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=name,
)
if moveable:
self._attr_supported_features = (
ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE

View File

@@ -48,7 +48,7 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
return a.name != "_cache"
return a.name not in ("_cache", "compat_aliases", "compat_name")
@callback

View File

@@ -0,0 +1,29 @@
"""Provides conditions for doors."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_is_closed_condition,
make_cover_is_open_condition,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition
DEVICE_CLASSES_DOOR: dict[str, str] = {
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR,
COVER_DOMAIN: CoverDeviceClass.DOOR,
}
CONDITIONS: dict[str, type[Condition]] = {
"is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_DOOR),
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_DOOR),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for doors."""
return CONDITIONS

View File

@@ -0,0 +1,28 @@
.condition_common_fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_closed:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: door
- domain: cover
device_class: door
is_open:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: door
- domain: cover
device_class: door

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_closed": {
"condition": "mdi:door-closed"
},
"is_open": {
"condition": "mdi:door-open"
}
},
"triggers": {
"closed": {
"trigger": "mdi:door-closed"

View File

@@ -1,9 +1,39 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted doors.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted doors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_closed": {
"description": "Tests if one or more doors are closed.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::condition_behavior_description%]",
"name": "[%key:component::door::common::condition_behavior_name%]"
}
},
"name": "Door is closed"
},
"is_open": {
"description": "Tests if one or more doors are open.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::condition_behavior_description%]",
"name": "[%key:component::door::common::condition_behavior_name%]"
}
},
"name": "Door is open"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aiohttp import ClientError
@@ -56,3 +57,42 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, _user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except LoginError:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
errors=errors,
)

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/freshr",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pyfreshr==1.2.0"]
}

View File

@@ -36,7 +36,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -2,9 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_account": "Cannot change the account username."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -12,6 +10,15 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::freshr::config::step::user::data_description::password%]"
},
"description": "Re-enter the password for your Fresh-r account `{username}`."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -4,9 +4,9 @@ set_guest_wifi_password:
required: true
selector:
device:
integration: fritz
entity:
device_class: connectivity
integration: fritz
domain: update
password:
required: false
selector:
@@ -23,9 +23,9 @@ dial:
required: true
selector:
device:
integration: fritz
entity:
device_class: connectivity
integration: fritz
domain: update
number:
required: true
selector:

View File

@@ -12,7 +12,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfInformation,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -56,6 +61,14 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
FullySensorEntityDescription(
key="batteryTemperature",
translation_key="battery_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
FullySensorEntityDescription(
key="currentPage",
translation_key="current_page",

View File

@@ -118,6 +118,9 @@
}
},
"sensor": {
"battery_temperature": {
"name": "Battery temperature"
},
"current_page": {
"name": "Current page"
},

View File

@@ -0,0 +1,31 @@
"""Provides conditions for garage doors."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_is_closed_condition,
make_cover_is_open_condition,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition
DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.GARAGE_DOOR,
COVER_DOMAIN: CoverDeviceClass.GARAGE,
}
CONDITIONS: dict[str, type[Condition]] = {
"is_closed": make_cover_is_closed_condition(
device_classes=DEVICE_CLASSES_GARAGE_DOOR
),
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for garage doors."""
return CONDITIONS

View File

@@ -0,0 +1,28 @@
.condition_common_fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_closed:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: garage_door
- domain: cover
device_class: garage
is_open:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: garage_door
- domain: cover
device_class: garage

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_closed": {
"condition": "mdi:garage"
},
"is_open": {
"condition": "mdi:garage-open"
}
},
"triggers": {
"closed": {
"trigger": "mdi:garage"

View File

@@ -1,9 +1,39 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted garage doors.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_closed": {
"description": "Tests if one or more garage doors are closed.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::condition_behavior_description%]",
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
}
},
"name": "Garage door is closed"
},
"is_open": {
"description": "Tests if one or more garage doors are open.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::condition_behavior_description%]",
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
}
},
"name": "Garage door is open"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -0,0 +1,24 @@
"""Provides conditions for gates."""
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_is_closed_condition,
make_cover_is_open_condition,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition
DEVICE_CLASSES_GATE: dict[str, str] = {
COVER_DOMAIN: CoverDeviceClass.GATE,
}
CONDITIONS: dict[str, type[Condition]] = {
"is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_GATE),
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_GATE),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for gates."""
return CONDITIONS

View File

@@ -0,0 +1,24 @@
.condition_common_fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_closed:
fields: *condition_common_fields
target:
entity:
- domain: cover
device_class: gate
is_open:
fields: *condition_common_fields
target:
entity:
- domain: cover
device_class: gate

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_closed": {
"condition": "mdi:gate"
},
"is_open": {
"condition": "mdi:gate-open"
}
},
"triggers": {
"closed": {
"trigger": "mdi:gate"

View File

@@ -1,9 +1,39 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted gates.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_closed": {
"description": "Tests if one or more gates are closed.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::condition_behavior_description%]",
"name": "[%key:component::gate::common::condition_behavior_name%]"
}
},
"name": "Gate is closed"
},
"is_open": {
"description": "Tests if one or more gates are open.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::condition_behavior_description%]",
"name": "[%key:component::gate::common::condition_behavior_name%]"
}
},
"name": "Gate is open"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -29,6 +29,7 @@ from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
intent,
start,
)
from homeassistant.helpers.event import async_call_later
@@ -597,7 +598,6 @@ class GoogleEntity:
state = self.state
traits = self.traits()
entity_config = self.config.entity_config.get(state.entity_id, {})
name = (entity_config.get(CONF_NAME) or state.name).strip()
# Find entity/device/area registry entries
entity_entry, device_entry, area_entry = _get_registry_entries(
@@ -607,7 +607,6 @@ class GoogleEntity:
# Build the device info
device = {
"id": state.entity_id,
"name": {"name": name},
"attributes": {},
"traits": [trait.name for trait in traits],
"willReportState": self.config.should_report_state,
@@ -615,13 +614,18 @@ class GoogleEntity:
state.domain, state.attributes.get(ATTR_DEVICE_CLASS)
),
}
# Add aliases
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or (
entity_entry and entity_entry.aliases
):
device["name"]["nicknames"] = [name, *config_aliases]
if entity_entry:
device["name"]["nicknames"].extend(entity_entry.aliases)
# Add name and aliases.
# The entity's alias list is ordered: the first slot naturally serves
# as the primary name (set to the auto-generated full entity name by
# default), while the rest serve as alternative names (nicknames).
aliases = intent.async_get_entity_aliases(
self.hass, entity_entry, state=state, allow_empty=False
)
name, *aliases = aliases
name = entity_config.get(CONF_NAME) or name
device["name"] = {"name": name}
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or aliases:
device["name"]["nicknames"] = [name, *config_aliases, *aliases]
# Add local SDK info if enabled
if self.config.is_local_sdk_active and self.should_expose_local():

View File

@@ -251,7 +251,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def get_data(
self, entity_description: GrowattSensorEntityDescription
) -> str | int | float | None:
) -> str | int | float | datetime.datetime | datetime.date | None:
"""Get the data."""
variable = entity_description.api_key
api_value = self.data.get(variable)

View File

@@ -1,4 +1,17 @@
{
"entity": {
"sensor": {
"storage_load_consumption_solar_storage": {
"default": "mdi:lightning-bolt"
},
"total_money_today": {
"default": "mdi:cash"
},
"total_money_total": {
"default": "mdi:cash"
}
}
},
"services": {
"read_ac_charge_times": {
"service": "mdi:battery-clock-outline"

View File

@@ -7,5 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"quality_scale": "silver",
"requirements": ["growattServer==1.9.0"]
}

View File

@@ -17,7 +17,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
@@ -27,9 +26,10 @@ PARALLEL_UPDATES = (
@dataclass(frozen=True, kw_only=True)
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin):
class GrowattNumberEntityDescription(NumberEntityDescription):
"""Describes Growatt number entity."""
api_key: str
write_key: str | None = None # Parameter ID for writing (if different from api_key)
@@ -130,6 +130,7 @@ class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
identifiers={(DOMAIN, coordinator.device_id)},
manufacturer="Growatt",
name=coordinator.device_id,
serial_number=coordinator.device_id,
)
@property

View File

@@ -32,9 +32,7 @@ rules:
test-coverage: done
# Gold
devices:
status: todo
comment: Add serial_number field to DeviceInfo in sensor, number, and switch platforms using device_id/serial_id.
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
@@ -46,16 +44,12 @@ rules:
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category:
status: todo
comment: Add EntityCategory.DIAGNOSTIC to temperature and other diagnostic sensors. Merge GrowattRequiredKeysMixin into GrowattSensorEntityDescription using kw_only=True.
entity-device-class:
status: todo
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
entity-disabled-by-default: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt

View File

@@ -2,12 +2,14 @@
from __future__ import annotations
from datetime import date, datetime
import logging
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from ..const import DOMAIN
@@ -99,24 +101,18 @@ class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity):
self.entity_description = description
self._attr_unique_id = unique_id
self._attr_icon = "mdi:solar-power"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_id)},
manufacturer="Growatt",
name=name,
serial_number=serial_id,
)
@property
def native_value(self) -> str | int | float | None:
def native_value(self) -> StateType | date | datetime:
"""Return the state of the sensor."""
result = self.coordinator.get_data(self.entity_description)
if (
isinstance(result, (int, float))
and self.entity_description.precision is not None
):
result = round(result, self.entity_description.precision)
return result
return self.coordinator.get_data(self.entity_description)
@property
def native_unit_of_measurement(self) -> str | None:

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -22,7 +23,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_energy_total",
@@ -30,7 +31,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="powerTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
precision=1,
suggested_display_precision=1,
state_class=SensorStateClass.TOTAL,
),
GrowattSensorEntityDescription(
@@ -40,7 +41,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="inverter_amperage_input_1",
@@ -49,7 +50,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_wattage_input_1",
@@ -58,7 +59,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_voltage_input_2",
@@ -67,7 +68,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_amperage_input_2",
@@ -76,7 +77,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_wattage_input_2",
@@ -85,7 +86,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_voltage_input_3",
@@ -94,7 +95,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_amperage_input_3",
@@ -103,7 +104,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_wattage_input_3",
@@ -112,7 +113,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_internal_wattage",
@@ -121,7 +122,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_reactive_voltage",
@@ -130,7 +131,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="inverter_inverter_reactive_amperage",
@@ -139,7 +142,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="inverter_frequency",
@@ -148,7 +153,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="inverter_current_wattage",
@@ -157,7 +164,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="inverter_current_reactive_wattage",
@@ -166,7 +173,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="inverter_ipm_temperature",
@@ -175,7 +184,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="inverter_temperature",
@@ -184,6 +195,8 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)

View File

@@ -7,18 +7,11 @@ from dataclasses import dataclass
from homeassistant.components.sensor import SensorEntityDescription
@dataclass(frozen=True)
class GrowattRequiredKeysMixin:
"""Mixin for required keys."""
api_key: str
@dataclass(frozen=True)
class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin):
@dataclass(frozen=True, kw_only=True)
class GrowattSensorEntityDescription(SensorEntityDescription):
"""Describes Growatt sensor entity."""
precision: int | None = None
api_key: str
currency: bool = False
previous_value_drop_threshold: float | None = None
never_resets: bool = False

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
@@ -90,6 +91,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="sph_temperature_1",
@@ -98,6 +101,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="sph_temperature_2",
@@ -106,6 +111,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="sph_temperature_3",
@@ -114,6 +121,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="sph_temperature_4",
@@ -122,6 +131,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="sph_temperature_5",
@@ -130,6 +141,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
# Values from 'sph_energy' API call
GrowattSensorEntityDescription(

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -189,7 +190,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_pv_charging_voltage",
@@ -198,7 +199,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_pv_charging_voltage_2",
@@ -207,7 +208,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_ac_input_frequency_out",
@@ -216,7 +217,9 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="storage_output_voltage",
@@ -225,7 +228,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_ac_output_frequency",
@@ -234,7 +237,9 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="storage_current_PV",
@@ -243,7 +248,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_current_1",
@@ -252,7 +257,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_current_2",
@@ -261,7 +266,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_grid_amperage_input",
@@ -270,7 +275,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_grid_out_current",
@@ -279,7 +284,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_battery_voltage",
@@ -288,7 +293,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
GrowattSensorEntityDescription(
key="storage_load_percentage",
@@ -297,6 +302,6 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
precision=2,
suggested_display_precision=2,
),
)

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -26,7 +27,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_energy_total",
@@ -35,7 +36,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
never_resets=True,
),
GrowattSensorEntityDescription(
@@ -45,7 +46,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
never_resets=True,
),
GrowattSensorEntityDescription(
@@ -55,7 +56,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_voltage_input_1",
@@ -63,7 +64,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vpv1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_amperage_input_1",
@@ -71,7 +72,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="ipv1",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_wattage_input_1",
@@ -80,7 +81,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_energy_total_input_2",
@@ -89,7 +90,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
never_resets=True,
),
GrowattSensorEntityDescription(
@@ -99,7 +100,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_voltage_input_2",
@@ -107,7 +108,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vpv2",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_amperage_input_2",
@@ -115,7 +116,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="ipv2",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_wattage_input_2",
@@ -124,7 +125,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_energy_total_input_3",
@@ -133,7 +134,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
never_resets=True,
),
GrowattSensorEntityDescription(
@@ -143,7 +144,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_voltage_input_3",
@@ -151,7 +152,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vpv3",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_amperage_input_3",
@@ -159,7 +160,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="ipv3",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_wattage_input_3",
@@ -168,7 +169,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_energy_total_input_4",
@@ -177,7 +178,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
never_resets=True,
),
GrowattSensorEntityDescription(
@@ -187,7 +188,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_voltage_input_4",
@@ -195,7 +196,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vpv4",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_amperage_input_4",
@@ -203,7 +204,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="ipv4",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_wattage_input_4",
@@ -212,7 +213,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_solar_generation_today",
@@ -221,7 +222,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_solar_generation_total",
@@ -239,7 +240,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_reactive_voltage",
@@ -247,7 +248,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vacrs",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="tlx_frequency",
@@ -255,7 +258,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="fac",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="tlx_current_wattage",
@@ -264,7 +269,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_temperature_1",
@@ -272,7 +277,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp1",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="tlx_temperature_2",
@@ -280,7 +287,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp2",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="tlx_temperature_3",
@@ -288,7 +297,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp3",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="tlx_temperature_4",
@@ -296,7 +307,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp4",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="tlx_temperature_5",
@@ -304,7 +317,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp5",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
precision=1,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
GrowattSensorEntityDescription(
key="tlx_all_batteries_discharge_today",
@@ -456,7 +471,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_pac_to_user_total",
@@ -465,7 +480,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_pac_to_grid_total",
@@ -474,7 +489,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_system_production_today",
@@ -483,7 +498,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_system_production_total",
@@ -493,7 +508,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_self_consumption_today",
@@ -502,7 +517,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_self_consumption_total",
@@ -512,7 +527,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_import_from_grid_today",
@@ -521,7 +536,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_import_from_grid_total",
@@ -531,7 +546,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_batteries_charged_from_grid_today",
@@ -540,7 +555,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_batteries_charged_from_grid_total",
@@ -550,7 +565,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_p_system",
@@ -559,7 +574,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
GrowattSensorEntityDescription(
key="tlx_p_self",
@@ -568,6 +583,6 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
precision=1,
suggested_display_precision=1,
),
)

View File

@@ -18,7 +18,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
@@ -28,9 +27,10 @@ PARALLEL_UPDATES = (
@dataclass(frozen=True, kw_only=True)
class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKeysMixin):
class GrowattSwitchEntityDescription(SwitchEntityDescription):
"""Describes Growatt switch entity."""
api_key: str
write_key: str | None = None # Parameter ID for writing (if different from api_key)
@@ -87,6 +87,7 @@ class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
identifiers={(DOMAIN, coordinator.device_id)},
manufacturer="Growatt",
name=coordinator.device_id,
serial_number=coordinator.device_id,
)
@property

View File

@@ -89,18 +89,18 @@
"step": {
"advanced": {
"data": {
"api_key": "API Token",
"api_key": "API token",
"api_user": "User ID",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_key": "API Token of the Habitica account",
"api_key": "API token of the Habitica account",
"api_user": "User ID of your Habitica account",
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
},
"description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to",
"description": "You can retrieve your 'User ID' and 'API token' from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to",
"title": "[%key:component::habitica::config::step::user::menu_options::advanced%]"
},
"login": {
@@ -126,7 +126,7 @@
"api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]"
},
"description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'",
"name": "Re-authorize via API Token"
"name": "Re-authorize via API token"
},
"reauth_login": {
"data": {

View File

@@ -119,7 +119,6 @@ from .coordinator import (
get_core_stats,
get_host_info,
get_info,
get_issues_info,
get_network_info,
get_os_info,
get_store,
@@ -158,7 +157,6 @@ __all__ = [
"get_core_stats",
"get_host_info",
"get_info",
"get_issues_info",
"get_network_info",
"get_os_info",
"get_store",

View File

@@ -132,6 +132,7 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
ISSUE_KEY_ADDON_DEPRECATED_ARCH = "issue_addon_deprecated_arch_addon"
ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed"
@@ -172,6 +173,7 @@ EXTRA_PLACEHOLDERS = {
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
},
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
ISSUE_KEY_ADDON_DEPRECATED_ARCH: HELP_URLS,
}

View File

@@ -47,6 +47,7 @@ from .const import (
EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
@@ -90,6 +91,7 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
}
_LOGGER = logging.getLogger(__name__)
@@ -253,9 +255,10 @@ class SupervisorIssues:
def add_issue(self, issue: Issue) -> None:
"""Add or update an issue in the list. Create or update a repair if necessary."""
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
placeholders: dict[str, str] = {}
if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
placeholders |= EXTRA_PLACEHOLDERS[issue.key]
placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy()
else:
placeholders = {}
if issue.reference:
placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["aiohasupervisor==0.4.1"],
"requirements": ["aiohasupervisor==0.4.2"],
"single_config_entry": true
}

View File

@@ -21,6 +21,7 @@ from .const import (
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
@@ -64,11 +65,16 @@ class SupervisorIssueRepairFlow(RepairsFlow):
@property
def description_placeholders(self) -> dict[str, str] | None:
"""Get description placeholders for steps."""
placeholders = {}
if self.issue:
placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {})
if self.issue.reference:
placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference}
if not self.issue:
return None
if self.issue.key in EXTRA_PLACEHOLDERS:
placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[self.issue.key].copy()
else:
placeholders = {}
if self.issue.reference:
placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference}
return placeholders or None
@@ -232,6 +238,7 @@ async def async_create_fix_flow(
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
}:
return AddonIssueRepairFlow(hass, issue_id)

View File

@@ -85,6 +85,19 @@
},
"title": "Installed app is deprecated"
},
"issue_addon_deprecated_arch_addon": {
"fix_flow": {
"abort": {
"apply_suggestion_fail": "Could not uninstall the app. Check the Supervisor logs for more details."
},
"step": {
"addon_execute_remove": {
"description": "App {addon} only supports architectures and/or machines which are no longer supported by Home Assistant. It will stop working in a future release.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
}
}
},
"title": "Installed app is built for unsupported architectures and/or machines"
},
"issue_addon_detached_addon_missing": {
"description": "Repository for app {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [app's documentation]({addon_url}) for installation instructions and add the repository to the store.",
"title": "Missing repository for an installed app"

View File

@@ -965,7 +965,7 @@ class HKDevice:
# visible on the network.
self.async_set_available_state(False)
return
except AccessoryDisconnectedError, EncryptionError:
except AccessoryDisconnectedError, EncryptionError, TimeoutError:
# Temporary connection failure. Device may still available but our
# connection was dropped or we are reconnecting
self._poll_failures += 1

View File

@@ -10,7 +10,6 @@ from functools import partial
from ipaddress import IPv4Network, IPv6Network, ip_network
import logging
import os
from pathlib import Path
import socket
import ssl
from tempfile import NamedTemporaryFile
@@ -34,7 +33,6 @@ from homeassistant.components.network import async_get_source_ip
from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
HASSIO_USER_NAME,
SERVER_PORT,
)
from homeassistant.core import Event, HomeAssistant, callback
@@ -71,7 +69,7 @@ from .headers import setup_headers
from .request_context import setup_request_context
from .security_filter import setup_security_filter
from .static import CACHE_HEADERS, CachingStaticResource
from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite
from .web_runner import HomeAssistantTCPSite
CONF_SERVER_HOST: Final = "server_host"
CONF_SERVER_PORT: Final = "server_port"
@@ -237,16 +235,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
source_ip_task = create_eager_task(async_get_source_ip(hass))
unix_socket_path: Path | None = None
if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"):
socket_path = Path(socket_env)
if socket_path.is_absolute():
unix_socket_path = socket_path
else:
_LOGGER.error(
"Invalid unix socket path %s: path must be absolute", socket_env
)
server = HomeAssistantHTTP(
hass,
server_host=server_host,
@@ -256,7 +244,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
ssl_key=ssl_key,
trusted_proxies=trusted_proxies,
ssl_profile=ssl_profile,
unix_socket_path=unix_socket_path,
)
await server.async_initialize(
cors_origins=cors_origins,
@@ -280,21 +267,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_when_setup_or_start(hass, "frontend", start_server)
if server.unix_socket_path is not None:
async def start_unix_socket(*_: Any) -> None:
"""Start the Unix socket after the Supervisor user is available."""
if any(
user
for user in await hass.auth.async_get_users()
if user.system_generated and user.name == HASSIO_USER_NAME
):
await server.async_start_unix_socket()
else:
_LOGGER.error("Supervisor user not found; not starting Unix socket")
async_when_setup_or_start(hass, "hassio", start_unix_socket)
hass.http = server
local_ip = await source_ip_task
@@ -394,7 +366,6 @@ class HomeAssistantHTTP:
server_port: int,
trusted_proxies: list[IPv4Network | IPv6Network],
ssl_profile: str,
unix_socket_path: Path | None = None,
) -> None:
"""Initialize the HTTP Home Assistant server."""
self.app = HomeAssistantApplication(
@@ -413,10 +384,8 @@ class HomeAssistantHTTP:
self.server_port = server_port
self.trusted_proxies = trusted_proxies
self.ssl_profile = ssl_profile
self.unix_socket_path = unix_socket_path
self.runner: web.AppRunner | None = None
self.site: HomeAssistantTCPSite | None = None
self.unix_site: HomeAssistantUnixSite | None = None
self.context: ssl.SSLContext | None = None
async def async_initialize(
@@ -641,29 +610,6 @@ class HomeAssistantHTTP:
context.load_cert_chain(cert_pem.name, key_pem.name)
return context
async def async_start_unix_socket(self) -> None:
"""Start listening on the Unix socket.
This is called separately from start() to delay serving the Unix
socket until the Supervisor user exists (created by the hassio
integration). Without this delay, Supervisor could connect before
its user is available and receive 401 responses it won't retry.
"""
if self.unix_socket_path is None or self.runner is None:
return
self.unix_site = HomeAssistantUnixSite(self.runner, self.unix_socket_path)
try:
await self.unix_site.start()
except OSError as error:
_LOGGER.error(
"Failed to create HTTP server on unix socket %s: %s",
self.unix_socket_path,
error,
)
self.unix_site = None
else:
_LOGGER.info("Now listening on unix socket %s", self.unix_socket_path)
async def start(self) -> None:
"""Start the aiohttp server."""
# Aiohttp freezes apps after start so that no changes can be made.
@@ -691,19 +637,6 @@ class HomeAssistantHTTP:
async def stop(self) -> None:
"""Stop the aiohttp server."""
if self.unix_site is not None:
await self.unix_site.stop()
if self.unix_socket_path is not None:
try:
await self.hass.async_add_executor_job(
self.unix_socket_path.unlink, True
)
except OSError as err:
_LOGGER.warning(
"Could not remove unix socket %s: %s",
self.unix_socket_path,
err,
)
if self.site is not None:
await self.site.stop()
if self.runner is not None:

View File

@@ -11,13 +11,7 @@ import time
from typing import Any, Final
from aiohttp import hdrs
from aiohttp.web import (
Application,
HTTPInternalServerError,
Request,
StreamResponse,
middleware,
)
from aiohttp.web import Application, Request, StreamResponse, middleware
import jwt
from jwt import api_jws
from yarl import URL
@@ -26,7 +20,6 @@ from homeassistant.auth import jwt_wrapper
from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.auth.models import User
from homeassistant.components import websocket_api
from homeassistant.const import HASSIO_USER_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.http import current_request
from homeassistant.helpers.json import json_bytes
@@ -34,12 +27,7 @@ from homeassistant.helpers.network import is_cloud_connection
from homeassistant.helpers.storage import Store
from homeassistant.util.network import is_local
from .const import (
KEY_AUTHENTICATED,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
is_unix_socket_request,
)
from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
_LOGGER = logging.getLogger(__name__)
@@ -129,7 +117,7 @@ def async_user_not_allowed_do_auth(
return "User cannot authenticate remotely"
async def async_setup_auth( # noqa: C901
async def async_setup_auth(
hass: HomeAssistant,
app: Application,
) -> None:
@@ -219,41 +207,6 @@ async def async_setup_auth( # noqa: C901
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
return True
supervisor_user_id: str | None = None
async def async_authenticate_unix_socket(request: Request) -> bool:
"""Authenticate a request from a Unix socket as the Supervisor user.
The Unix Socket is dedicated and only available to Supervisor. To
avoid the extra overhead and round trips for the authentication and
refresh tokens, we directly authenticate requests from the socket as
the Supervisor user.
"""
nonlocal supervisor_user_id
# Fast path: use cached user ID
if supervisor_user_id is not None:
if user := await hass.auth.async_get_user(supervisor_user_id):
request[KEY_HASS_USER] = user
return True
supervisor_user_id = None
# Slow path: find the Supervisor user by name
for user in await hass.auth.async_get_users():
if user.system_generated and user.name == HASSIO_USER_NAME:
supervisor_user_id = user.id
# Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user
# doesn't use refresh tokens.
request[KEY_HASS_USER] = user
return True
# The Unix socket should not be serving before the hassio integration
# has created the Supervisor user. If we get here, something is wrong.
_LOGGER.error(
"Supervisor user not found; cannot authenticate Unix socket request"
)
raise HTTPInternalServerError
@middleware
async def auth_middleware(
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
@@ -261,11 +214,7 @@ async def async_setup_auth( # noqa: C901
"""Authenticate as middleware."""
authenticated = False
if is_unix_socket_request(request):
authenticated = await async_authenticate_unix_socket(request)
auth_type = "unix socket"
elif hdrs.AUTHORIZATION in request.headers and async_validate_auth_header(
if hdrs.AUTHORIZATION in request.headers and async_validate_auth_header(
request
):
authenticated = True
@@ -284,7 +233,7 @@ async def async_setup_auth( # noqa: C901
if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Authenticated %s for %s using %s",
request.remote or "unknown",
request.remote,
request.path,
auth_type,
)

View File

@@ -30,7 +30,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio
from homeassistant.util import dt as dt_util, yaml as yaml_util
from .const import KEY_HASS, is_unix_socket_request
from .const import KEY_HASS
from .view import HomeAssistantView
_LOGGER: Final = logging.getLogger(__name__)
@@ -72,10 +72,6 @@ async def ban_middleware(
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
"""IP Ban middleware."""
# Unix socket connections are trusted, skip ban checks
if is_unix_socket_request(request):
return await handler(request)
if (ban_manager := request.app.get(KEY_BAN_MANAGER)) is None:
_LOGGER.error("IP Ban middleware loaded but banned IPs not loaded")
return await handler(request)

View File

@@ -1,22 +1,10 @@
"""HTTP specific constants."""
import socket
from typing import Final
from aiohttp.web import Request
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401
DOMAIN: Final = "http"
KEY_HASS_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
def is_unix_socket_request(request: Request) -> bool:
"""Check if request arrived over a Unix socket."""
if (transport := request.transport) is None:
return False
if (sock := transport.get_extra_info("socket")) is None:
return False
return bool(sock.family == socket.AF_UNIX)

View File

@@ -3,8 +3,6 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import socket
from ssl import SSLContext
from aiohttp import web
@@ -70,62 +68,3 @@ class HomeAssistantTCPSite(web.BaseSite):
reuse_address=self._reuse_address,
reuse_port=self._reuse_port,
)
class HomeAssistantUnixSite(web.BaseSite):
"""HomeAssistant specific aiohttp UnixSite.
Listens on a Unix socket for local inter-process communication,
used for Supervisor to Core communication.
"""
__slots__ = ("_path",)
def __init__(
self,
runner: web.BaseRunner,
path: Path,
*,
backlog: int = 128,
) -> None:
"""Initialize HomeAssistantUnixSite."""
super().__init__(
runner,
backlog=backlog,
)
self._path = path
@property
def name(self) -> str:
"""Return server URL."""
return f"http://unix:{self._path}:"
def _create_unix_socket(self) -> socket.socket:
"""Create and bind a Unix domain socket.
Performs blocking filesystem I/O (mkdir, unlink, chmod) and is
intended to be run in an executor. Permissions are set after bind
but before the socket is handed to the event loop, so no
connections can arrive on an unrestricted socket.
"""
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.unlink(missing_ok=True)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.bind(str(self._path))
except OSError:
sock.close()
raise
self._path.chmod(0o600)
return sock
async def start(self) -> None:
"""Start server."""
await super().start()
loop = asyncio.get_running_loop()
sock = await loop.run_in_executor(None, self._create_unix_socket)
server = self._runner.server
assert server is not None
self._server = await loop.create_unix_server(
server, sock=sock, backlog=self._backlog
)

View File

@@ -72,13 +72,6 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
return HVACMode.HEAT
return HVACMode.OFF
@property
def icon(self) -> str:
"""Return nice icon for heater."""
if self.hvac_mode == HVACMode.HEAT:
return "mdi:radiator"
return "mdi:radiator-off"
@property
def current_temperature(self) -> int | None:
"""Return the current temperature."""

View File

@@ -45,8 +45,6 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN):
)
await huum.status()
except Forbidden, NotAuthenticated:
# Most likely Forbidden as that is what is returned from `.status()` with bad creds
_LOGGER.error("Could not log in to Huum with given credentials")
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unknown error")

View File

@@ -54,7 +54,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
try:
return await self.huum.status()
except (Forbidden, NotAuthenticated) as err:
_LOGGER.error("Could not log in to Huum with given credentials")
raise UpdateFailed(
"Could not log in to Huum with given credentials"
) from err

View File

@@ -7,11 +7,7 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow:
status: done
comment: |
PLANNED: Remove _LOGGER.error call from config_flow.py — the error
message is redundant with the errors dict entry.
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
@@ -40,11 +36,7 @@ rules:
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: |
PLANNED: Remove _LOGGER.error from coordinator.py — the message is already
passed to UpdateFailed, so logging it separately is redundant.
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage:
@@ -74,11 +66,7 @@ rules:
comment: All entities are core functionality.
entity-translations: done
exception-translations: todo
icon-translations:
status: done
comment: |
PLANNED: Remove the icon property from climate.py — entities should not set
custom icons. Use HA defaults or icon translations instead.
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt

View File

@@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import IndevoltConfigEntry, IndevoltCoordinator
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,

View File

@@ -0,0 +1,70 @@
"""Button platform for Indevolt integration."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Final
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IndevoltConfigEntry
from .coordinator import IndevoltCoordinator
from .entity import IndevoltEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class IndevoltButtonEntityDescription(ButtonEntityDescription):
"""Custom entity description class for Indevolt button entities."""
generation: list[int] = field(default_factory=lambda: [1, 2])
BUTTONS: Final = (
IndevoltButtonEntityDescription(
key="stop",
translation_key="stop",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: IndevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the button platform for Indevolt."""
coordinator = entry.runtime_data
device_gen = coordinator.generation
# Button initialization
async_add_entities(
IndevoltButtonEntity(coordinator=coordinator, description=description)
for description in BUTTONS
if device_gen in description.generation
)
class IndevoltButtonEntity(IndevoltEntity, ButtonEntity):
"""Represents a button entity for Indevolt devices."""
entity_description: IndevoltButtonEntityDescription
def __init__(
self,
coordinator: IndevoltCoordinator,
description: IndevoltButtonEntityDescription,
) -> None:
"""Initialize the Indevolt button entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{self.serial_number}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press."""
await self.coordinator.async_execute_realtime_action([0, 0, 0])

View File

@@ -1,16 +1,27 @@
"""Constants for the Indevolt integration."""
DOMAIN = "indevolt"
from typing import Final
DOMAIN: Final = "indevolt"
# Default configurations
DEFAULT_PORT: Final = 8080
# Config entry fields
CONF_SERIAL_NUMBER = "serial_number"
CONF_GENERATION = "generation"
CONF_SERIAL_NUMBER: Final = "serial_number"
CONF_GENERATION: Final = "generation"
# Default values
DEFAULT_PORT = 8080
# API write/read keys for energy and value for outdoor/portable mode
ENERGY_MODE_READ_KEY: Final = "7101"
ENERGY_MODE_WRITE_KEY: Final = "47005"
PORTABLE_MODE: Final = 0
# API write key and value for real-time control mode
REALTIME_ACTION_KEY: Final = "47015"
REALTIME_ACTION_MODE: Final = 4
# API key fields
SENSOR_KEYS = {
SENSOR_KEYS: Final[dict[int, list[str]]] = {
1: [
"606",
"7101",

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from typing import Any, Final
from aiohttp import ClientError
from indevolt_api import IndevoltAPI, TimeOutException
@@ -21,20 +21,37 @@ from .const import (
CONF_SERIAL_NUMBER,
DEFAULT_PORT,
DOMAIN,
ENERGY_MODE_READ_KEY,
ENERGY_MODE_WRITE_KEY,
PORTABLE_MODE,
REALTIME_ACTION_KEY,
REALTIME_ACTION_MODE,
SENSOR_KEYS,
)
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = 30
SCAN_INTERVAL: Final = 30
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
class DeviceTimeoutError(HomeAssistantError):
"""Raised when device push times out."""
class DeviceConnectionError(HomeAssistantError):
"""Raised when device push fails due to connection issues."""
class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for fetching and pushing data to indevolt devices."""
friendly_name: str
config_entry: IndevoltConfigEntry
firmware_version: str | None
serial_number: str
device_model: str
generation: int
def __init__(self, hass: HomeAssistant, entry: IndevoltConfigEntry) -> None:
"""Initialize the indevolt coordinator."""
@@ -53,6 +70,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
session=async_get_clientsession(hass),
)
self.friendly_name = entry.title
self.serial_number = entry.data[CONF_SERIAL_NUMBER]
self.device_model = entry.data[CONF_MODEL]
self.generation = entry.data[CONF_GENERATION]
@@ -85,6 +103,67 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
return await self.api.set_data(sensor_key, value)
except TimeOutException as err:
raise HomeAssistantError(f"Device push timed out: {err}") from err
raise DeviceTimeoutError(f"Device push timed out: {err}") from err
except (ClientError, ConnectionError, OSError) as err:
raise HomeAssistantError(f"Device push failed: {err}") from err
raise DeviceConnectionError(f"Device push failed: {err}") from err
async def async_switch_energy_mode(
self, target_mode: int, refresh: bool = True
) -> None:
"""Attempt to switch device to given energy mode."""
current_mode = self.data.get(ENERGY_MODE_READ_KEY)
# Ensure current energy mode is known
if current_mode is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_retrieve_current_energy_mode",
)
# Ensure device is not in "Outdoor/Portable mode"
if current_mode == PORTABLE_MODE:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="energy_mode_change_unavailable_outdoor_portable",
)
# Switch energy mode if required
if current_mode != target_mode:
try:
success = await self.async_push_data(ENERGY_MODE_WRITE_KEY, target_mode)
except (DeviceTimeoutError, DeviceConnectionError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_switch_energy_mode",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_switch_energy_mode",
)
if refresh:
await self.async_request_refresh()
async def async_execute_realtime_action(self, action: list[int]) -> None:
"""Switch mode, execute action, and refresh for real-time control."""
await self.async_switch_energy_mode(REALTIME_ACTION_MODE, refresh=False)
try:
success = await self.async_push_data(REALTIME_ACTION_KEY, action)
except (DeviceTimeoutError, DeviceConnectionError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_execute_realtime_action",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_execute_realtime_action",
)
await self.async_request_refresh()

View File

@@ -35,6 +35,11 @@
}
},
"entity": {
"button": {
"stop": {
"name": "Enable standby mode"
}
},
"number": {
"discharge_limit": {
"name": "Discharge limit"
@@ -289,5 +294,19 @@
"name": "LED indicator"
}
}
},
"exceptions": {
"energy_mode_change_unavailable_outdoor_portable": {
"message": "Energy mode cannot be changed when the device is in outdoor/portable mode"
},
"failed_to_execute_realtime_action": {
"message": "Failed to execute real-time action"
},
"failed_to_retrieve_current_energy_mode": {
"message": "Failed to retrieve current energy mode"
},
"failed_to_switch_energy_mode": {
"message": "Failed to switch to requested energy mode"
}
}
}

View File

@@ -22,7 +22,13 @@
"description": "Authenticate against IntelliFire cloud"
},
"pick_cloud_device": {
"description": "Select fireplace by serial number:",
"data": {
"serial": "Fireplace serial number"
},
"data_description": {
"serial": "Serial number of the fireplace to configure"
},
"description": "Select fireplace by serial number.",
"title": "Configure fireplace"
}
}
@@ -159,6 +165,10 @@
"control_mode": "Send commands to",
"read_mode": "Read data from"
},
"data_description": {
"control_mode": "Whether to send fireplace commands via the `Local` or `Cloud` API",
"read_mode": "Whether to read fireplace state via the `Local` or `Cloud` API"
},
"description": "Some users find that their fireplace hardware prioritizes `Cloud` communication and may experience timeouts with `Local` control. If you encounter connectivity issues, try switching to `Cloud` for the affected endpoint.",
"title": "Endpoint selection"
}

View File

@@ -4,11 +4,21 @@ from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input
from .const import CONF_CLIENT_DEVICE_ID, DEFAULT_NAME, DOMAIN, PLATFORMS
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Jellyfin component."""
await async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> bool:

View File

@@ -38,6 +38,8 @@ PLAYABLE_MEDIA_TYPES = [
MediaType.EPISODE,
MediaType.MOVIE,
MediaType.MUSIC,
MediaType.SEASON,
MediaType.TVSHOW,
]
@@ -98,8 +100,8 @@ async def build_item_response(
media_content_id: str,
) -> BrowseMedia:
"""Create response payload for the provided media query."""
title, media, thumbnail = await get_media_info(
hass, client, user_id, media_content_type, media_content_id
title, media, thumbnail, media_type = await get_media_info(
hass, client, user_id, media_content_id
)
if title is None or media is None:
@@ -111,12 +113,12 @@ async def build_item_response(
response = BrowseMedia(
media_class=CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get(
str(media_content_type), MediaClass.DIRECTORY
str(media_type), MediaClass.DIRECTORY
),
media_content_id=media_content_id,
media_content_type=str(media_content_type),
media_content_type=str(media_type),
title=title,
can_play=bool(media_content_type in PLAYABLE_MEDIA_TYPES and media_content_id),
can_play=bool(media_type in PLAYABLE_MEDIA_TYPES and media_content_id),
can_expand=True,
children=children,
thumbnail=thumbnail,
@@ -207,18 +209,18 @@ async def get_media_info(
hass: HomeAssistant,
client: JellyfinClient,
user_id: str,
media_content_type: str | None,
media_content_id: str,
) -> tuple[str | None, list[dict[str, Any]] | None, str | None]:
) -> tuple[str | None, list[dict[str, Any]] | None, str | None, str | None]:
"""Fetch media info."""
thumbnail: str | None = None
title: str | None = None
media: list[dict[str, Any]] | None = None
media_type: str | None = None
item = await hass.async_add_executor_job(fetch_item, client, media_content_id)
if item is None:
return None, None, None
return None, None, None, None
title = item["Name"]
thumbnail = get_artwork_url(client, item)
@@ -231,4 +233,6 @@ async def get_media_info(
if not media or len(media) == 0:
media = None
return title, media, thumbnail
media_type = CONTENT_TYPE_MAP.get(item["Type"], MEDIA_TYPE_NONE)
return title, media, thumbnail, media_type

View File

@@ -74,9 +74,10 @@ MEDIA_CLASS_MAP = {
"MusicAlbum": MediaClass.ALBUM,
"MusicArtist": MediaClass.ARTIST,
"Audio": MediaClass.MUSIC,
"Series": MediaClass.DIRECTORY,
"Series": MediaClass.TV_SHOW,
"Movie": MediaClass.MOVIE,
"CollectionFolder": MediaClass.DIRECTORY,
"AggregateFolder": MediaClass.DIRECTORY,
"Folder": MediaClass.DIRECTORY,
"BoxSet": MediaClass.DIRECTORY,
"Episode": MediaClass.EPISODE,

View File

@@ -5,5 +5,10 @@
"default": "mdi:television-play"
}
}
},
"services": {
"play_media_shuffle": {
"service": "mdi:shuffle-variant"
}
}
}

View File

@@ -6,7 +6,9 @@ import logging
from typing import Any
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
BrowseMedia,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
@@ -203,6 +205,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.SEARCH_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
)
if "Mute" in commands and "Unmute" in commands:
@@ -245,8 +248,20 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play a piece of media."""
command = "PlayNow"
enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE)
if enqueue == MediaPlayerEnqueue.NEXT:
command = "PlayNext"
elif enqueue == MediaPlayerEnqueue.ADD:
command = "PlayLast"
self.coordinator.api_client.jellyfin.remote_play_media(
self.session_id, [media_id]
self.session_id, [media_id], command
)
def play_media_shuffle(self, media_content_id: str) -> None:
"""Play a piece of media on shuffle."""
self.coordinator.api_client.jellyfin.remote_play_media(
self.session_id, [media_content_id], "PlayShuffle"
)
def set_volume_level(self, volume: float) -> None:

View File

@@ -0,0 +1,55 @@
"""Services for the Jellyfin integration."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.media_player import (
ATTR_MEDIA,
ATTR_MEDIA_CONTENT_ID,
DOMAIN as MP_DOMAIN,
MediaPlayerEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
JELLYFIN_PLAY_MEDIA_SHUFFLE_SCHEMA = {
vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string,
}
def _promote_media_fields(data: dict[str, Any]) -> dict[str, Any]:
"""If 'media' key exists, promote its fields to the top level."""
if ATTR_MEDIA in data and isinstance(data[ATTR_MEDIA], dict):
if ATTR_MEDIA_CONTENT_ID in data:
raise vol.Invalid(
f"Play media cannot contain both '{ATTR_MEDIA}' and '{ATTR_MEDIA_CONTENT_ID}'"
)
media_data = data[ATTR_MEDIA]
if ATTR_MEDIA_CONTENT_ID in media_data:
data[ATTR_MEDIA_CONTENT_ID] = media_data[ATTR_MEDIA_CONTENT_ID]
del data[ATTR_MEDIA]
return data
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for the Jellyfin component."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
"play_media_shuffle",
entity_domain=MP_DOMAIN,
schema=vol.All(
_promote_media_fields,
cv.make_entity_service_schema(JELLYFIN_PLAY_MEDIA_SHUFFLE_SCHEMA),
),
func="play_media_shuffle",
required_features=MediaPlayerEntityFeature.PLAY_MEDIA,
)

View File

@@ -0,0 +1,11 @@
play_media_shuffle:
target:
entity:
integration: jellyfin
domain: media_player
fields:
media:
required: true
selector:
media:
example: '{"media_content_id": "a656b907eb3a73532e40e44b968d0225"}'

View File

@@ -42,5 +42,17 @@
}
}
}
},
"services": {
"play_media_shuffle": {
"description": "Starts playing specified media shuffled. Overwrites current play queue.",
"fields": {
"media": {
"description": "The media selected to play.",
"name": "Media"
}
},
"name": "Play media shuffled"
}
}
}

View File

@@ -151,7 +151,9 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
return value
def get_options_map(self, command: str) -> dict[str, str]:
def get_options_map(
self, command: str, *, snake_case: bool = False
) -> dict[str, str]:
"""Get the available options for a command."""
capabilities = self.capabilities.get(command, {})
@@ -162,7 +164,10 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
values = list(capabilities.get("parameter", {}).get("read", {}).values())
return {v: v.translate(TRANSLATIONS) for v in values}
options = {v: v.translate(TRANSLATIONS) for v in values}
if snake_case:
return {k: v.replace("-", "_") for k, v in options.items()}
return options
def supports(self, command: type[Command]) -> bool:
"""Check if the device supports a command."""

View File

@@ -18,6 +18,9 @@
"dynamic_control": {
"default": "mdi:lightbulb-on-outline"
},
"hdr_processing": {
"default": "mdi:image-filter-hdr-outline"
},
"input": {
"default": "mdi:hdmi-port"
},
@@ -26,6 +29,9 @@
},
"light_power": {
"default": "mdi:lightbulb-on-outline"
},
"picture_mode": {
"default": "mdi:movie-roll"
}
},
"sensor": {

View File

@@ -20,6 +20,7 @@ class JvcProjectorSelectDescription(SelectEntityDescription):
"""Describes JVC Projector select entities."""
command: type[Command]
snake_case_states: bool = False
SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
@@ -49,6 +50,18 @@ SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
command=cmd.Anamorphic,
entity_registry_enabled_default=False,
),
JvcProjectorSelectDescription(
key="hdr_processing",
command=cmd.HdrProcessing,
entity_registry_enabled_default=False,
snake_case_states=True,
),
JvcProjectorSelectDescription(
key="picture_mode",
command=cmd.PictureMode,
entity_registry_enabled_default=False,
snake_case_states=True,
),
)
@@ -84,7 +97,8 @@ class JvcProjectorSelectEntity(JvcProjectorEntity, SelectEntity):
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
self._options_map: dict[str, str] = coordinator.get_options_map(
self.command.name
self.command.name,
snake_case=description.snake_case_states,
)
@property

View File

@@ -7,16 +7,19 @@ from dataclasses import dataclass
from jvcprojector import Command, command as cmd
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
from .util import deprecate_entity
@dataclass(frozen=True, kw_only=True)
@@ -84,12 +87,29 @@ async def async_setup_entry(
) -> None:
"""Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data
entity_registry = er.async_get(hass)
async_add_entities(
JvcProjectorSensorEntity(coordinator, description)
for description in SENSORS
if coordinator.supports(description.command)
)
entities: list[JvcProjectorSensorEntity] = []
for description in SENSORS:
if not coordinator.supports(description.command):
continue
if description.key in (
"hdr_processing",
"picture_mode",
) and not deprecate_entity(
hass,
entity_registry,
SENSOR_DOMAIN,
f"{coordinator.unique_id}_{description.key}",
f"deprecated_sensor_{entry.entry_id}_{description.key}",
"deprecated_sensor",
f"{coordinator.unique_id}_{description.key}",
f"select.jvc_projector_{description.key}",
):
continue
entities.append(JvcProjectorSensorEntity(coordinator, description))
async_add_entities(entities)
class JvcProjectorSensorEntity(JvcProjectorEntity, SensorEntity):

View File

@@ -71,6 +71,15 @@
"off": "[%key:common::state::off%]"
}
},
"hdr_processing": {
"name": "HDR Processing",
"state": {
"frame_by_frame": "Frame-by-Frame",
"hdr10p": "HDR10+",
"scene_by_scene": "Scene-by-Scene",
"static": "Static"
}
},
"input": {
"name": "Input",
"state": {
@@ -101,6 +110,23 @@
"mid": "[%key:common::state::medium%]",
"normal": "[%key:common::state::normal%]"
}
},
"picture_mode": {
"name": "Picture Mode",
"state": {
"frame_adapt_hdr": "Frame Adapt HDR",
"frame_adapt_hdr2": "Frame Adapt HDR2",
"frame_adapt_hdr3": "Frame Adapt HDR3",
"hdr1": "HDR1",
"hdr10": "HDR10",
"hdr10_ll": "HDR10 LL",
"hdr2": "HDR2",
"last_setting": "Last setting",
"pana_pq": "Pana PQ",
"user_4": "User 4",
"user_5": "User 5",
"user_6": "User 6"
}
}
},
"sensor": {
@@ -156,7 +182,7 @@
"hdr10": "HDR10",
"hdr10-ll": "HDR10 LL",
"hdr2": "HDR2",
"last-setting": "Last Setting",
"last-setting": "Last setting",
"pana-pq": "Pana PQ",
"user-4": "User 4",
"user-5": "User 5",
@@ -182,5 +208,15 @@
"name": "Low latency mode"
}
}
},
"issues": {
"deprecated_sensor": {
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nUpdate your dashboards, templates, automations and scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
"title": "Deprecated sensor detected"
},
"deprecated_sensor_scripts": {
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nUpdate the above automations or scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.",
"title": "[%key:component::jvc_projector::issues::deprecated_sensor::title%]"
}
}
}

View File

@@ -0,0 +1,104 @@
"""Utility helpers for the jvc_projector integration."""
from __future__ import annotations
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .const import DOMAIN
def deprecate_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
platform_domain: str,
entity_unique_id: str,
issue_id: str,
issue_string: str,
replacement_entity_unique_id: str,
replacement_entity_id: str,
version: str = "2026.9.0",
) -> bool:
"""Create an issue for deprecated entities."""
if entity_id := entity_registry.async_get_entity_id(
platform_domain, DOMAIN, entity_unique_id
):
entity_entry = entity_registry.async_get(entity_id)
if not entity_entry:
async_delete_issue(hass, DOMAIN, issue_id)
return False
items = get_automations_and_scripts_using_entity(hass, entity_id)
if entity_entry.disabled and not items:
entity_registry.async_remove(entity_id)
async_delete_issue(hass, DOMAIN, issue_id)
return False
translation_key = issue_string
placeholders = {
"entity_id": entity_id,
"entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
"replacement_entity_id": (
entity_registry.async_get_entity_id(
Platform.SELECT, DOMAIN, replacement_entity_unique_id
)
or replacement_entity_id
),
}
if items:
translation_key = f"{translation_key}_scripts"
placeholders["items"] = "\n".join(items)
async_create_issue(
hass,
DOMAIN,
issue_id,
breaks_in_ha_version=version,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=placeholders,
)
return True
async_delete_issue(hass, DOMAIN, issue_id)
return False
def get_automations_and_scripts_using_entity(
hass: HomeAssistant,
entity_id: str,
) -> list[str]:
"""Get automations and scripts using an entity."""
# These helpers return referencing automation/script entity IDs.
automations = automations_with_entity(hass, entity_id)
scripts = scripts_with_entity(hass, entity_id)
if not automations and not scripts:
return []
entity_registry = er.async_get(hass)
items: list[str] = []
for integration, entities in (
("automation", automations),
("script", scripts),
):
for used_entity_id in entities:
# Prefer entity-registry metadata so we can render edit links.
if item := entity_registry.async_get(used_entity_id):
items.append(
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
)
else:
# Keep unresolved references as plain text so they still count as usage.
items.append(f"- `{used_entity_id}`")
return items

View File

@@ -47,6 +47,14 @@ COLOR_MODE_MAP = {
clusters.ColorControl.Enums.ColorModeEnum.kColorTemperatureMireds: ColorMode.COLOR_TEMP,
}
# Maximum Mireds value per the Matter spec is 65279
# Conversion between Kelvin and Mireds is 1,000,000 / Kelvin, so this corresponds to a minimum color temperature of ~15.3K
# Which is shown in UI as 15 Kelvin due to rounding.
# But converting 15 Kelvin back to Mireds gives 66666 which is above the maximum,
# and causes Invoke error, so cap values over maximum when sending
MATTER_MAX_MIREDS = 65279
# there's a bug in (at least) Espressif's implementation of light transitions
# on devices based on Matter 1.0. Mark potential devices with this issue.
# https://github.com/home-assistant/core/issues/113775
@@ -152,7 +160,7 @@ class MatterLight(MatterEntity, LightEntity):
)
await self.send_device_command(
clusters.ColorControl.Commands.MoveToColorTemperature(
colorTemperatureMireds=color_temp_mired,
colorTemperatureMireds=min(color_temp_mired, MATTER_MAX_MIREDS),
# transition in matter is measured in tenths of a second
transitionTime=int(transition * 10),
# allow setting the color while the light is off,

View File

@@ -203,105 +203,80 @@ class MoldIndicator(SensorEntity):
def _async_setup_sensor(self) -> None:
"""Set up the sensor and start tracking state changes."""
@callback
def mold_indicator_sensors_state_listener(
event: Event[EventStateChangedData],
) -> None:
"""Handle for state changes for dependent sensors."""
new_state = event.data["new_state"]
old_state = event.data["old_state"]
entity = event.data["entity_id"]
_LOGGER.debug(
"Sensor state change for %s that had old state %s and new state %s",
entity,
old_state,
new_state,
)
if self._update_sensor(entity, old_state, new_state):
if self._preview_callback:
calculated_state = self._async_calculate_state()
self._preview_callback(
calculated_state.state, calculated_state.attributes
)
# only write state to the state machine if we are not in preview mode
else:
self.async_schedule_update_ha_state(True)
@callback
def mold_indicator_startup() -> None:
"""Add listeners and get 1st state."""
_LOGGER.debug("Startup for %s", self.entity_id)
self.async_on_remove(
async_track_state_change_event(
self.hass,
list(self._entities.values()),
mold_indicator_sensors_state_listener,
self._entities.values(),
self._async_mold_indicator_sensor_state_listener,
)
)
# Replay current state of source entities
for entity_id in self._entities.values():
state = self.hass.states.get(entity_id)
state_event: Event[EventStateChangedData] = Event(
"", {"entity_id": entity_id, "new_state": state, "old_state": None}
)
self._async_mold_indicator_sensor_state_listener(
state_event, update_state=False
)
# Read initial state
indoor_temp = self.hass.states.get(self._entities[CONF_INDOOR_TEMP])
outdoor_temp = self.hass.states.get(self._entities[CONF_OUTDOOR_TEMP])
indoor_hum = self.hass.states.get(self._entities[CONF_INDOOR_HUMIDITY])
self._recalculate()
schedule_update = self._update_sensor(
self._entities[CONF_INDOOR_TEMP], None, indoor_temp
)
if self._preview_callback:
calculated_state = self._async_calculate_state()
self._preview_callback(calculated_state.state, calculated_state.attributes)
schedule_update = (
False
if not self._update_sensor(
self._entities[CONF_OUTDOOR_TEMP], None, outdoor_temp
)
else schedule_update
)
@callback
def _async_mold_indicator_sensor_state_listener(
self, event: Event[EventStateChangedData], update_state: bool = True
) -> None:
"""Handle state changes for dependent sensors."""
entity_id = event.data["entity_id"]
new_state = event.data["new_state"]
schedule_update = (
False
if not self._update_sensor(
self._entities[CONF_INDOOR_HUMIDITY], None, indoor_hum
)
else schedule_update
)
_LOGGER.debug(
"Sensor state change for %s that had old state %s and new state %s",
entity_id,
event.data["old_state"],
new_state,
)
if schedule_update and not self._preview_callback:
self.async_schedule_update_ha_state(True)
if self._preview_callback:
# re-calculate dewpoint and mold indicator
self._calc_dewpoint()
self._calc_moldindicator()
if self._attr_native_value is None:
self._attr_available = False
else:
self._attr_available = True
calculated_state = self._async_calculate_state()
self._preview_callback(
calculated_state.state, calculated_state.attributes
)
mold_indicator_startup()
def _update_sensor(
self, entity: str, old_state: State | None, new_state: State | None
) -> bool:
"""Update information based on new sensor states."""
_LOGGER.debug("Sensor update for %s", entity)
if new_state is None:
return False
# If old_state is not set and new state is unknown then it means
# that the sensor just started up
if old_state is None and new_state.state == STATE_UNKNOWN:
return False
if entity == self._entities[CONF_INDOOR_TEMP]:
# update state depending on which sensor changed
if entity_id == self._entities[CONF_INDOOR_TEMP]:
self._indoor_temp = self._get_temperature_from_state(new_state)
elif entity == self._entities[CONF_OUTDOOR_TEMP]:
elif entity_id == self._entities[CONF_OUTDOOR_TEMP]:
self._outdoor_temp = self._get_temperature_from_state(new_state)
elif entity == self._entities[CONF_INDOOR_HUMIDITY]:
elif entity_id == self._entities[CONF_INDOOR_HUMIDITY]:
self._indoor_hum = self._get_humidity_from_state(new_state)
return True
if not update_state:
return
self._recalculate()
if self._preview_callback:
calculated_state = self._async_calculate_state()
self._preview_callback(calculated_state.state, calculated_state.attributes)
# only write state to the state machine if we are not in preview mode
else:
self.async_write_ha_state()
@callback
def _recalculate(self) -> None:
"""Recalculate mold indicator from cached sensor values."""
# Check if all sensors are available
if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp):
self._attr_available = False
self._attr_native_value = None
self._dewpoint = None
self._crit_temp = None
return
# Calculate dewpoint and mold indicator
self._calc_dewpoint()
self._calc_moldindicator()
self._attr_available = self._attr_native_value is not None
def _get_value_from_state(
self,
@@ -376,26 +351,6 @@ class MoldIndicator(SensorEntity):
return self._get_value_from_state(state, validate_humidity)
async def async_update(self) -> None:
"""Calculate latest state."""
_LOGGER.debug("Update state for %s", self.entity_id)
# check all sensors
if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp):
self._attr_available = False
self._dewpoint = None
self._crit_temp = None
return
# re-calculate dewpoint and mold indicator
self._calc_dewpoint()
self._calc_moldindicator()
if self._attr_native_value is None:
self._attr_available = False
self._dewpoint = None
self._crit_temp = None
else:
self._attr_available = True
def _calc_dewpoint(self) -> None:
"""Calculate the dewpoint for the indoor air."""
# Use magnus approximation to calculate the dew point

View File

@@ -0,0 +1,25 @@
"""Provides conditions for motion."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_state_condition
_MOTION_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
CONDITIONS: dict[str, type[Condition]] = {
"is_detected": make_entity_state_condition(_MOTION_DOMAIN_SPECS, STATE_ON),
"is_not_detected": make_entity_state_condition(_MOTION_DOMAIN_SPECS, STATE_OFF),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for motion."""
return CONDITIONS

View File

@@ -0,0 +1,24 @@
.condition_common_fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_detected:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion
is_not_detected:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_detected": {
"condition": "mdi:motion-sensor"
},
"is_not_detected": {
"condition": "mdi:motion-sensor-off"
}
},
"triggers": {
"cleared": {
"trigger": "mdi:motion-sensor-off"

View File

@@ -1,9 +1,39 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted motion sensors.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_detected": {
"description": "Tests if one or more motion sensors are detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::condition_behavior_description%]",
"name": "[%key:component::motion::common::condition_behavior_name%]"
}
},
"name": "Motion is detected"
},
"is_not_detected": {
"description": "Tests if one or more motion sensors are not detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::condition_behavior_description%]",
"name": "[%key:component::motion::common::condition_behavior_name%]"
}
},
"name": "Motion is not detected"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -18,6 +18,8 @@ ABBREVIATIONS = {
"bri_stat_t": "brightness_state_topic",
"bri_tpl": "brightness_template",
"bri_val_tpl": "brightness_value_template",
"cln_segmnts_cmd_t": "clean_segments_command_topic",
"cln_segmnts_cmd_tpl": "clean_segments_command_template",
"clr_temp_cmd_tpl": "color_temp_command_template",
"clrm_stat_t": "color_mode_state_topic",
"clrm_val_tpl": "color_mode_value_template",
@@ -185,6 +187,7 @@ ABBREVIATIONS = {
"rgbww_cmd_t": "rgbww_command_topic",
"rgbww_stat_t": "rgbww_state_topic",
"rgbww_val_tpl": "rgbww_value_template",
"segmnts": "segments",
"send_cmd_t": "send_command_topic",
"send_if_off": "send_if_off",
"set_fan_spd_t": "set_fan_speed_topic",

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