Compare commits

..

136 Commits

Author SHA1 Message Date
Erik
a1e4c75bd6 Add additional numerical climate triggers 2025-12-19 21:57:53 +01:00
Erik Montnemery
24b6b5452b Add trigger climate.target_temperature_crossed_threshold (#159461)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-12-19 21:57:10 +01:00
Erik Montnemery
8b91ebfe30 Add test of error handling in numerical_attribute_changed triggers (#159469) 2025-12-19 21:40:56 +01:00
Matthias Alphart
37d3b73c1b Support KNX sensor entity configuration from UI (#158498) 2025-12-19 19:20:14 +01:00
Matthias Alphart
c881d9809e Update knx-frontend to 2025.12.19.150946 (#159446) 2025-12-19 19:09:19 +01:00
Erik Montnemery
85dfe3a107 Add trigger climate.target_temperature_changed (#159434) 2025-12-19 18:39:53 +01:00
Pierre PÉRONNET
d8a468833e Bump renault-api to 0.5.2 (#159448) 2025-12-19 18:25:46 +01:00
Raphael Hehl
5bbd56b8e6 Add exception handling to UniFi Protect entity commands (#159292)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-19 07:10:32 -10:00
Bram Kragten
d0411b6613 Update frontend to 20251203.3 (#159451) 2025-12-19 17:57:27 +01:00
Abílio Costa
293fbebef2 Modernize calendar trigger (#159395)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-12-19 17:41:30 +01:00
Erik Montnemery
cfe542acb9 Fix hassfest support for choose selector translations (#159453) 2025-12-19 17:31:57 +01:00
Bram Kragten
8da323d4b7 Add support for choose selector (#159412) 2025-12-19 16:49:04 +01:00
Zoltán Farkasdi
b2edf637cc Netatmo camera webhook refactor (#159359) 2025-12-19 16:41:22 +01:00
Erik Montnemery
de61a45de1 Add humidifier triggers (#159163) 2025-12-19 16:38:30 +01:00
Erik Montnemery
d9324cb0e4 Improve docstrings in climate trigger tests (#159438) 2025-12-19 16:10:07 +01:00
Robert Resch
4a464f601c Remove users refresh tokens when the user get's deactivated (#159443) 2025-12-19 15:50:47 +01:00
Thomas D
43e9c24c18 Adjust volvo update interval (#159200) 2025-12-19 15:38:50 +01:00
Matthias Alphart
1c3492b4c2 KNX Fan: Add support for switch addresses (#159367) 2025-12-19 15:37:50 +01:00
johanzander
e0cb56a38c Improve Growatt Server config flow with region dropdown (#159329)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-19 15:33:35 +01:00
Erik Montnemery
6e05cc4898 Enable multiple states in trigger climate.hvac_mode_changed (#159435) 2025-12-19 15:14:55 +01:00
MoonDevLT
6f9dc2e5a2 Add a DALI line into the device hierarchy with a broadcast entity (#156570)
Co-authored-by: Tom <CoMPaTech@users.noreply.github.com>
2025-12-19 14:57:51 +01:00
Petro31
ddb1ae371d Add new template entity framework to template alarm control panel (#156614) 2025-12-19 14:41:45 +01:00
J. Diego Rodríguez Royo
6553337b79 Add entities related to the new data from aiohomeconnect 0.22.0 (#154717) 2025-12-19 14:33:28 +01:00
Thomas55555
aedc729d57 Only allow unique location names in google air quality (#159285) 2025-12-19 14:33:16 +01:00
Erik Montnemery
31fa69b609 Fix evict_faked_translations fixture (#159419)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-12-19 14:00:58 +01:00
Brett Adams
b819a866b9 Bump tesla-fleet-api to 1.3.2 (#159430) 2025-12-19 13:19:45 +01:00
Erik Montnemery
6cc7d83def Add trigger climate.hvac_mode_changed (#159358) 2025-12-19 12:57:01 +01:00
Joost Lekkerkerker
5154418051 Add integration_type device to incomfort (#159173) 2025-12-19 12:34:16 +01:00
Josef Zweck
7e63c12b95 Add entity picture to lamarzocco (#158518) 2025-12-19 11:59:51 +01:00
puddly
d17e951591 Bump ZHA to 0.0.81 (#159396) 2025-12-19 10:27:50 +01:00
dependabot[bot]
9198e5f56d Bump actions/attest-build-provenance from 3.0.0 to 3.1.0 (#159405)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 10:06:42 +01:00
Ludovic BOUÉ
97d7e0e01e Matter Speaker volume LevelControl (#149490)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 10:01:55 +01:00
Ravaka Razafimanantsoa
4d5b8c4b08 Bump momonga to 0.3.0 (#159350) 2025-12-19 08:35:05 +01:00
martinkiska
abb011311e bump nibe to 2.20.0 (#159392) 2025-12-19 08:33:48 +01:00
Allen Porter
92cf7623fa Bump python-roborock to 3.19.0 (#159404) 2025-12-19 08:33:34 +01:00
Allen Porter
aedf4c881b Fix AttributeError in Roborock Empty Mode entity (#159278)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-18 20:09:22 -08:00
Lukas
74baf44c83 Pooldose: Add select platform (#159240) 2025-12-19 00:13:26 +01:00
Raphael Hehl
9afb4a9eb8 Improve UniFi Protect test quality and fixtures (#159316)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-18 22:23:44 +00:00
Simone Chemelli
e1967bef9a Align format of voltmeter strings for Shelly (#159394) 2025-12-18 23:22:43 +01:00
Paul Tarjan
f17b6aa9e4 Bump pyHik to 0.3.4 (#159380) 2025-12-18 20:28:13 +01:00
Paul Tarjan
dd6d7397d9 Suppress verbose UPnP subscription error logs (#158677) 2025-12-18 20:02:48 +01:00
Paul Tarjan
aeabd2d2cc Add @ptarjan as code owner for hikvision integration (#159381) 2025-12-18 19:58:48 +01:00
Ludovic BOUÉ
d7af2f39c2 Move Matter DoorLock mode selection in control section (#158920) 2025-12-18 19:51:14 +01:00
Keir Stiegler
a674ad11bc Map Z-Wave Jasco model 14314 fan speed to low/medium/high (#155817)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-12-18 19:50:53 +01:00
Artur Pragacz
ccb64d7fd8 Add integration type to workday (#157731)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-18 19:50:10 +01:00
Matthias Alphart
36691e2a3d Update KNX quality scale to platinum (#159379) 2025-12-18 19:47:30 +01:00
Paul Tarjan
8971f75f13 Fix Sonos speaker async_offline assertion failure (#158764) 2025-12-18 19:46:39 +01:00
Duco Sebel
173db170af Remove 'energy' name from HomeWizard (#159089) 2025-12-18 19:46:07 +01:00
Jordan Harvey
881851a4f6 Add statistics importing for Anglian Water (#157757) 2025-12-18 19:45:24 +01:00
MarkGodwin
4b4b64e939 Achieve Bronze quality rating for TP-Link Omada (#156697) 2025-12-18 19:44:08 +01:00
rlippmann
e721c1a092 Simplisafe: Trigger binary sensor from secret alerts (#156848)
Co-authored-by: Aaron Bach <bachya1208@gmail.com>
2025-12-18 19:42:55 +01:00
Joost Lekkerkerker
0933c9fe51 Add integration_type device to hyperion (#159139) 2025-12-18 19:31:43 +01:00
Joost Lekkerkerker
632b3e5dc3 Add integration_type service to huisbaasje (#159133) 2025-12-18 19:31:15 +01:00
Joost Lekkerkerker
434cb48344 Add integration_type device to gogogate2 (#159082) 2025-12-18 19:30:36 +01:00
Joost Lekkerkerker
86d4c3cbbf Add integration_type hub to freebox (#159023) 2025-12-18 19:30:10 +01:00
Joost Lekkerkerker
3019f9041c Add integration_type hub to enocean (#159005) 2025-12-18 19:29:32 +01:00
Hans Fehrmann
9e0a3dee08 Enable name alias when sending a notification for google_mail (#157927)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-18 19:28:23 +01:00
Joost Lekkerkerker
fefe7d9e5d Add integration_type device to hisense_aehw4a1 (#159125) 2025-12-18 19:26:39 +01:00
Joost Lekkerkerker
4c382cedff Add integration_type hub to libre_hardware_monitor (#159301) 2025-12-18 19:24:58 +01:00
Joost Lekkerkerker
6ffd05313b Add integration_type device to lookin (#159304) 2025-12-18 19:24:26 +01:00
Joost Lekkerkerker
9be0214021 Add integration_type hub to lutron_caseta (#159308) 2025-12-18 19:23:53 +01:00
epenet
54300430b7 Use common read_device_status method in Tuya light wrapper (#159156) 2025-12-18 19:14:23 +01:00
Paul Tarjan
a25038259e Fix generic camera preview stream URL to be absolute (#159113)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-18 19:14:13 +01:00
Kurt Chrisford
81be14c8f1 Actron Air: Add switch entity platform (#158087)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-18 19:11:39 +01:00
airwoflgh
62464b83dc Add preset default to radiotherm (#159335)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-18 19:07:45 +01:00
epenet
beb909528c Use common options attribute in Tuya cover wrapper (#159147) 2025-12-18 19:00:57 +01:00
Richard
ef28715360 Mill: Add ability to set heating device to AUTO (#157745) 2025-12-18 19:00:30 +01:00
ashalita
78cc41fdc0 CoolMasterNet: Send wakeup prompt (#156116) 2025-12-18 18:59:35 +01:00
Matthias Alphart
6a868ca5cc Add repair issue for KNX DataSecure key issues (#157843) 2025-12-18 18:23:30 +01:00
Joost Lekkerkerker
f43dead38c Add temperature entities to SmartThings One Door fridge (#158457) 2025-12-18 18:19:01 +01:00
Petro31
86163252e1 Update template switch tests to use new framework (#159215) 2025-12-18 18:10:43 +01:00
Petro31
0cd5202596 Update template update tests to use new framework (#159207) 2025-12-18 18:09:32 +01:00
Anton Dalgren
33dcde7de1 Add sensor platform for AirPatrol (#158726) 2025-12-18 18:00:58 +01:00
Bouwe Westerdijk
c449b2e2e8 Improve Plugwise coordinator code (#158983) 2025-12-18 18:00:42 +01:00
Matthias Alphart
f40f7072c8 Update xknx to 3.13.0 (#159371) 2025-12-18 16:45:40 +00:00
Abílio Costa
4163ecd833 Improve typing for get_x_for_target commands (#159279) 2025-12-18 16:42:40 +00:00
Niracler
9c59d528af Add scene platform for Sunricher DALI integration (#157808)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-18 17:42:30 +01:00
theobld-ww
c2440c4ebd Add Watts Vision + integration with tests (#153022)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-18 17:41:23 +01:00
Anthony Garera
cb275f65ba Adding AmGarera as a code owner for Overseerr integration (#159373) 2025-12-18 17:36:00 +01:00
Paul Tarjan
b1923df3ca Pass ssl parameter to pyhik HikCamera (#159256) 2025-12-18 17:35:55 +01:00
Paul Tarjan
7ddfd155ca Fix hikvision camera.get_id (#159257) 2025-12-18 17:17:27 +01:00
Paul Tarjan
e01df6d10d Add more docs to Withings webhook log (#158748) 2025-12-18 16:50:23 +01:00
Artur Pragacz
54010728d5 Do not trigger reauth for addon in Music Assistant (#159372) 2025-12-18 16:49:01 +01:00
Kurt Chrisford
62a3b3827f Actron Air Integration: Fix fan mode mapping and update actron-neo-api requirement (#159195) 2025-12-18 16:48:00 +01:00
Joost Lekkerkerker
b9abfba20f Add integration_type service to met_eireann (#159314)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-12-18 16:32:09 +01:00
PaulCavill
eca9f36e55 Improve icloud reauth flow (#159081) 2025-12-18 16:01:20 +01:00
Matthias Alphart
3c865c6f41 Support KNX fan entity configuration from UI (#159167) 2025-12-18 15:54:55 +01:00
Raphael Hehl
3b32c4bcbf Remove custom device_class from unifiprotect doorbell_text select entity (#159366)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-18 15:51:16 +01:00
dependabot[bot]
fcdc1cfed9 Bump github/codeql-action from 4.31.8 to 4.31.9 (#159248)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-18 15:45:47 +01:00
wollew
0fd782c4ab Raise exception if velux integration setup fails because of connection erros (#159231) 2025-12-18 15:44:42 +01:00
adam-the-hero
bbcaf69973 Bump quality scale for watergate to silver (#155353) 2025-12-18 15:30:15 +01:00
Denis Shulyaka
f2b713acac Exclude gpt-4o model from extended caching (#159362) 2025-12-18 08:48:01 -05:00
LG-ThinQ-Integration
6c944d6b15 Adds a delay to the continuous control of the climate (#151177)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2025-12-18 14:46:11 +01:00
Raphael Hehl
4dd3abb16a Fix device classes in unifiprotect integration (#159281)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-18 14:43:44 +01:00
adam-the-hero
d2672b9ddf Introduce session inject to watergate integration (#159360) 2025-12-18 14:08:57 +01:00
Matthias Alphart
ff30492919 KNX unit tests: patch CEMIHandler at class level (#159317) 2025-12-18 14:02:02 +01:00
Duco Sebel
b5ccdf8165 Implement new battery charge modes in HomeWizard (#159107) 2025-12-18 14:01:37 +01:00
Robert Resch
b3c745cfa7 Bump go2rtc to 1.9.13 (#159043) 2025-12-18 14:00:50 +01:00
Robert Resch
67aeafa797 Add advanced section for generic camera config flow (#148430) 2025-12-18 13:30:25 +01:00
Michael
3d71b6de44 Add support for FRITZ! Smarthome routines (#158947) 2025-12-18 13:09:06 +01:00
Luke Lashley
5349045932 Add basic support for Q7 devices (#159274) 2025-12-18 12:30:20 +01:00
epenet
4960871c84 Revert name change in meteo_france (#159352) 2025-12-18 11:04:34 +01:00
epenet
af3861cd6b Rename attribute in Tuya climate wrapper (#159145) 2025-12-18 10:02:38 +01:00
epenet
f9a070e9b3 Use common options attribute in Tuya event wrapper (#159119) 2025-12-18 09:33:53 +01:00
epenet
fd503b2e33 Make VacuumEntityFeature.STATE conditional in Tuya vacuum (#159254) 2025-12-18 09:32:13 +01:00
epenet
e5a73fcf57 Disable blackbird integration (#157817) 2025-12-18 08:51:54 +01:00
Andre Lengwenus
6991e01489 Fix incorrect status updates for lcn (#159251) 2025-12-18 08:11:22 +01:00
Simone Chemelli
c8636ee6f3 Add missing strings for Shelly voltmeter sensor (#159332) 2025-12-18 08:05:46 +01:00
J. Nick Koston
52229dc5a8 Bump aioesphomeapi to 43.3.0 (#159141) 2025-12-17 20:22:38 -10:00
Andre Lengwenus
f013455843 Bump pypck to 0.9.8 (#159277) 2025-12-18 06:54:56 +01:00
Joost Lekkerkerker
cae5bca546 Add integration_type device to kostal_plenticore (#159288) 2025-12-17 21:00:27 +01:00
Joost Lekkerkerker
49299b06c6 Add integration_type device to kmtronic (#159286) 2025-12-17 20:58:58 +01:00
Paul Tarjan
8e39027ad5 Add guidance to not amend commits after review starts (#158804) 2025-12-17 20:58:43 +01:00
Joost Lekkerkerker
2a1ce2df61 Add integration_type service to kodi (#159287) 2025-12-17 20:57:47 +01:00
Joost Lekkerkerker
7a6d929150 Add integration_type device to kulersky (#159290) 2025-12-17 20:56:52 +01:00
Joost Lekkerkerker
6f4a112dbb Add integration_type hub to lacrosse_view (#159291) 2025-12-17 20:56:14 +01:00
Joost Lekkerkerker
2197b910fb Add integration_type device to landisgyr_heat_meter (#159293) 2025-12-17 20:55:11 +01:00
Joost Lekkerkerker
7e2a9cd7f9 Add integration_type hub to laundrify (#159295) 2025-12-17 20:54:20 +01:00
Joost Lekkerkerker
e7ed7a8ed2 Add integration_type hub to lcn (#159296) 2025-12-17 20:53:41 +01:00
Joost Lekkerkerker
9ba2d0defe Add integration_type device to leaone (#159297) 2025-12-17 20:52:35 +01:00
Joost Lekkerkerker
231300919c Add integration_type device to led_ble (#159298) 2025-12-17 20:51:45 +01:00
Joost Lekkerkerker
664c50586f Add integration_type device to lg_soundbar (#159299) 2025-12-17 20:51:00 +01:00
Joost Lekkerkerker
43b9ecfc2b Add integration_type device to lifx (#159302) 2025-12-17 20:48:33 +01:00
Joost Lekkerkerker
f1237ed52a Add integration_type hub to livisi (#159303) 2025-12-17 20:47:39 +01:00
Joost Lekkerkerker
ecf8f55cc4 Add integration_type device to loqed (#159305) 2025-12-17 20:45:23 +01:00
Joost Lekkerkerker
ff36693057 Add integration_type hub to lupusec (#159306) 2025-12-17 20:44:29 +01:00
Joost Lekkerkerker
005785997c Add integration_type hub to lutron (#159307) 2025-12-17 20:43:47 +01:00
Joost Lekkerkerker
9917b82b66 Add integration_type hub to lyric (#159309) 2025-12-17 20:42:30 +01:00
Joost Lekkerkerker
9c927406ac Add integration_type service to mailgun (#159310) 2025-12-17 20:41:43 +01:00
Joost Lekkerkerker
972d95602a Add integration_type hub to meater (#159311) 2025-12-17 20:41:10 +01:00
Joost Lekkerkerker
5e0549a18f Add integration_type device to medcom_ble (#159312) 2025-12-17 20:39:39 +01:00
Joost Lekkerkerker
bcbb159fb2 Add integration_type device to melnor (#159313) 2025-12-17 20:38:23 +01:00
Joost Lekkerkerker
0123ca656a Add integration_type hub to lg_thinq (#159300) 2025-12-17 20:34:25 +01:00
Joost Lekkerkerker
1f699c729c Add integration_type service to lastfm (#159294) 2025-12-17 20:33:49 +01:00
Joost Lekkerkerker
50c3fcfeba Add integration_type service to kraken (#159289) 2025-12-17 20:33:17 +01:00
Raphael Hehl
2af1e098cc Improve debug logging in UniFi Protect integration (#159318) 2025-12-17 20:31:33 +01:00
371 changed files with 17510 additions and 2786 deletions

View File

@@ -51,6 +51,9 @@ rules:
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+

View File

@@ -551,7 +551,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: "/language:python"

View File

@@ -567,6 +567,7 @@ homeassistant.components.wake_word.*
homeassistant.components.wallbox.*
homeassistant.components.waqi.*
homeassistant.components.water_heater.*
homeassistant.components.watts.*
homeassistant.components.watttime.*
homeassistant.components.weather.*
homeassistant.components.webhook.*

10
CODEOWNERS generated
View File

@@ -664,8 +664,8 @@ build.json @home-assistant/supervisor
/tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger
/tests/components/here_travel_time/ @eifinger
/homeassistant/components/hikvision/ @mezz64
/tests/components/hikvision/ @mezz64
/homeassistant/components/hikvision/ @mezz64 @ptarjan
/tests/components/hikvision/ @mezz64 @ptarjan
/homeassistant/components/hikvisioncam/ @fbradyirl
/homeassistant/components/hisense_aehw4a1/ @bannhead
/tests/components/hisense_aehw4a1/ @bannhead
@@ -1195,8 +1195,8 @@ build.json @home-assistant/supervisor
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek
/tests/components/overseerr/ @joostlek
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1798,6 +1798,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai
/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/homeassistant/components/watttime/ @bachya
/tests/components/watttime/ @bachya
/homeassistant/components/waze_travel_time/ @eifinger

2
Dockerfile generated
View File

@@ -24,7 +24,7 @@ ENV \
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
RUN \
# Verify go2rtc can be executed

View File

@@ -402,6 +402,8 @@ class AuthManager:
if user.is_owner:
raise ValueError("Unable to deactivate the owner")
await self._store.async_deactivate_user(user)
for refresh_token in list(user.refresh_tokens.values()):
self.async_remove_refresh_token(refresh_token)
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
"""Remove credentials."""

View File

@@ -18,7 +18,7 @@ from .coordinator import (
ActronAirSystemCoordinator,
)
PLATFORM = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
@@ -50,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators=system_coordinators,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -148,7 +148,7 @@ class ActronSystemClimate(BaseClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self._status.user_aircon_settings.fan_mode
fan_mode = self._status.user_aircon_settings.base_fan_mode
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
@property

View File

@@ -0,0 +1,30 @@
{
"entity": {
"switch": {
"away_mode": {
"default": "mdi:home-export-outline",
"state": {
"off": "mdi:home-import-outline"
}
},
"continuous_fan": {
"default": "mdi:fan",
"state": {
"off": "mdi:fan-off"
}
},
"quiet_mode": {
"default": "mdi:volume-low",
"state": {
"off": "mdi:volume-high"
}
},
"turbo_mode": {
"default": "mdi:fan-plus",
"state": {
"off": "mdi:fan"
}
}
}
}
}

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["actron-neo-api==0.2.0"]
"requirements": ["actron-neo-api==0.4.1"]
}

View File

@@ -32,6 +32,22 @@
}
}
},
"entity": {
"switch": {
"away_mode": {
"name": "Away mode"
},
"continuous_fan": {
"name": "Continuous fan"
},
"quiet_mode": {
"name": "Quiet mode"
},
"turbo_mode": {
"name": "Turbo mode"
}
}
},
"exceptions": {
"auth_error": {
"message": "Authentication failed, please reauthenticate"

View File

@@ -0,0 +1,110 @@
"""Switch platform for Actron Air integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ActronAirSwitchEntityDescription(SwitchEntityDescription):
"""Class describing Actron Air switch entities."""
is_on_fn: Callable[[ActronAirSystemCoordinator], bool]
set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]]
is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True
SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
ActronAirSwitchEntityDescription(
key="away_mode",
translation_key="away_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="continuous_fan",
translation_key="continuous_fan",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="quiet_mode",
translation_key="quiet_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="turbo_mode",
translation_key="turbo_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled),
is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air switch entities."""
system_coordinators = entry.runtime_data.system_coordinators
async_add_entities(
ActronAirSwitch(coordinator, description)
for coordinator in system_coordinators.values()
for description in SWITCHES
if description.is_supported_fn(coordinator)
)
class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntity):
"""Actron Air switch."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
entity_description: ActronAirSwitchEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
description: ActronAirSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.serial_number)},
manufacturer="Actron Air",
name=coordinator.data.ac_system.system_name,
)
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -88,21 +88,11 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
super().__init__(coordinator, unit_id)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data."""
return self.device_data.get("climate") or {}
@property
def params(self) -> dict[str, Any]:
"""Return the current parameters for the climate entity."""
return self.climate_data.get("ParametersData") or {}
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and bool(self.climate_data)
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""

View File

@@ -10,7 +10,7 @@ from homeassistant.const import Platform
DOMAIN = "airpatrol"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
SCAN_INTERVAL = timedelta(minutes=1)
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)

View File

@@ -38,7 +38,17 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
"""Return the device data."""
return self.coordinator.data[self._unit_id]
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data for this unit."""
return self.device_data["climate"]
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._unit_id in self.coordinator.data
return (
super().available
and self._unit_id in self.coordinator.data
and "climate" in self.device_data
and self.climate_data is not None
)

View File

@@ -0,0 +1,89 @@
"""Sensors for AirPatrol integration."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirPatrolConfigEntry
from .coordinator import AirPatrolDataUpdateCoordinator
from .entity import AirPatrolEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirPatrolSensorEntityDescription(SensorEntityDescription):
"""Describes AirPatrol sensor entity."""
data_field: str
SENSOR_DESCRIPTIONS = (
AirPatrolSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
data_field="RoomTemp",
),
AirPatrolSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
data_field="RoomHumidity",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirPatrolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirPatrol sensors."""
coordinator = config_entry.runtime_data
units = coordinator.data
async_add_entities(
AirPatrolSensor(coordinator, unit_id, description)
for unit_id, unit in units.items()
for description in SENSOR_DESCRIPTIONS
if "climate" in unit and unit["climate"] is not None
)
class AirPatrolSensor(AirPatrolEntity, SensorEntity):
"""AirPatrol sensor entity."""
entity_description: AirPatrolSensorEntityDescription
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
description: AirPatrolSensorEntityDescription,
) -> None:
"""Initialize AirPatrol sensor."""
super().__init__(coordinator, unit_id)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}-{unit_id}-{description.key}"
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
if value := self.climate_data.get(self.entity_description.data_field):
return float(value)
return None

View File

@@ -45,7 +45,7 @@ def make_entity_state_trigger_required_features(
"""Trigger for entity state changes."""
_domain = domain
_to_state = to_state
_to_states = {to_state}
_required_features = required_features
return CustomTrigger

View File

@@ -4,13 +4,28 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyanglianwater import AnglianWater
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import VolumeConverter
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
@@ -44,6 +59,107 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Update data from Anglian Water's API."""
try:
return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
await self._insert_statistics()
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
raise UpdateFailed from err
async def _insert_statistics(self) -> None:
"""Insert statistics for water meters into Home Assistant."""
for meter in self.api.meters.values():
id_prefix = (
f"{self.config_entry.data[CONF_ACCOUNT_NUMBER]}_{meter.serial_number}"
)
usage_statistic_id = f"{DOMAIN}:{id_prefix}_usage".lower()
_LOGGER.debug("Updating statistics for meter %s", meter.serial_number)
name_prefix = (
f"Anglian Water {self.config_entry.data[CONF_ACCOUNT_NUMBER]} "
f"{meter.serial_number}"
)
usage_metadata = StatisticMetaData(
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} Usage",
source=DOMAIN,
statistic_id=usage_statistic_id,
unit_class=VolumeConverter.UNIT_CLASS,
unit_of_measurement=UnitOfVolume.CUBIC_METERS,
)
last_stat = await get_instance(self.hass).async_add_executor_job(
get_last_statistics, self.hass, 1, usage_statistic_id, True, set()
)
if not last_stat:
_LOGGER.debug("Updating statistics for the first time")
usage_sum = 0.0
last_stats_time = None
else:
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
continue
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping update",
meter.readings[0]["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
_LOGGER.debug("Getting statistics at %s", start)
for end in (start + timedelta(seconds=1), None):
stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
self.hass,
start,
end,
{
usage_statistic_id,
},
"hour",
None,
{"sum"},
)
if stats:
break
if end:
_LOGGER.debug(
"Not found, trying to find oldest statistic after %s",
start,
)
assert stats
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
last_stats_time = stats[usage_statistic_id][0]["start"]
usage_statistics = []
for read in meter.readings:
parsed_read_at = dt_util.parse_datetime(read["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping reading",
read["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
if last_stats_time is not None and start.timestamp() <= last_stats_time:
continue
usage_state = max(0, read["consumption"] / 1000)
usage_sum = max(0, read["read"])
usage_statistics.append(
StatisticData(
start=start,
state=usage_state,
sum=usage_sum,
)
)
_LOGGER.debug(
"Adding %s statistics for %s", len(usage_statistics), usage_statistic_id
)
async_add_external_statistics(self.hass, usage_metadata, usage_statistics)

View File

@@ -1,6 +1,7 @@
{
"domain": "anglian_water",
"name": "Anglian Water",
"after_dependencies": ["recorder"],
"codeowners": ["@pantherale0"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anglian_water",

View File

@@ -27,6 +27,7 @@ from homeassistant.const import (
CONF_EVENT_DATA,
CONF_ID,
CONF_MODE,
CONF_OPTIONS,
CONF_PATH,
CONF_PLATFORM,
CONF_TRIGGERS,
@@ -130,10 +131,10 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"cover",
"device_tracker",
"fan",
"humidifier",
"lawn_mower",
"light",
"media_player",
"schedule",
"switch",
"text",
"update",
@@ -1215,7 +1216,7 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
if trigger_conf[CONF_PLATFORM] == "calendar":
return [trigger_conf[CONF_ENTITY_ID]]
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
if trigger_conf[CONF_PLATFORM] == "zone":
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]

View File

@@ -47,7 +47,7 @@ def make_binary_sensor_trigger(
"""Trigger for entity state changes."""
_device_class = device_class
_to_state = to_state
_to_states = {to_state}
return CustomTrigger

View File

@@ -2,6 +2,7 @@
"domain": "blackbird",
"name": "Monoprice Blackbird Matrix Switch",
"codeowners": [],
"disabled": "This integration is disabled because it references pyserial-asyncio, which does blocking I/O in the asyncio loop and is not maintained.",
"documentation": "https://www.home-assistant.io/integrations/blackbird",
"iot_class": "local_polling",
"loggers": ["pyblackbird"],

View File

@@ -2,29 +2,30 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import datetime
import logging
from typing import Any
from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_OPTIONS
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_time_interval,
)
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import CalendarEntity, CalendarEvent
from .const import DATA_COMPONENT, DOMAIN
from .const import DATA_COMPONENT
_LOGGER = logging.getLogger(__name__)
@@ -32,13 +33,17 @@ EVENT_START = "start"
EVENT_END = "end"
UPDATE_INTERVAL = datetime.timedelta(minutes=15)
TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
_OPTIONS_SCHEMA_DICT = {
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
}
_CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_PLATFORM): DOMAIN,
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
}
vol.Required(CONF_OPTIONS): _OPTIONS_SCHEMA_DICT,
},
)
# mypy: disallow-any-generics
@@ -169,14 +174,14 @@ class CalendarEventListener:
def __init__(
self,
hass: HomeAssistant,
job: HassJob[..., Coroutine[Any, Any, None] | Any],
trigger_data: dict[str, Any],
action_runner: TriggerActionRunner,
trigger_payload: dict[str, Any],
fetcher: QueuedEventFetcher,
) -> None:
"""Initialize CalendarEventListener."""
self._hass = hass
self._job = job
self._trigger_data = trigger_data
self._action_runner = action_runner
self._trigger_payload = trigger_payload
self._unsub_event: CALLBACK_TYPE | None = None
self._unsub_refresh: CALLBACK_TYPE | None = None
self._fetcher = fetcher
@@ -233,15 +238,11 @@ class CalendarEventListener:
while self._events and self._events[0].trigger_time <= now:
queued_event = self._events.pop(0)
_LOGGER.debug("Dispatching event: %s", queued_event.event)
self._hass.async_run_hass_job(
self._job,
{
"trigger": {
**self._trigger_data,
"calendar_event": queued_event.event.as_dict(),
}
},
)
payload = {
**self._trigger_payload,
"calendar_event": queued_event.event.as_dict(),
}
self._action_runner(payload, "calendar event state change")
async def _handle_refresh(self, now_utc: datetime.datetime) -> None:
"""Handle core config update."""
@@ -259,31 +260,69 @@ class CalendarEventListener:
self._listen_next_calendar_event()
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach trigger for the specified calendar."""
entity_id = config[CONF_ENTITY_ID]
event_type = config[CONF_EVENT]
offset = config[CONF_OFFSET]
class EventTrigger(Trigger):
"""Calendar event trigger."""
# Validate the entity id is valid
get_entity(hass, entity_id)
_options: dict[str, Any]
trigger_data = {
**trigger_info["trigger_data"],
"platform": DOMAIN,
"event": event_type,
"offset": offset,
}
listener = CalendarEventListener(
hass,
HassJob(action),
trigger_data,
queued_event_fetcher(event_fetcher(hass, entity_id), event_type, offset),
)
await listener.async_attach()
return listener.async_detach
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
) -> ConfigType:
"""Validate complete config."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _OPTIONS_SCHEMA_DICT
)
return await super().async_validate_complete_config(hass, complete_config)
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _CONFIG_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
entity_id = self._options[CONF_ENTITY_ID]
event_type = self._options[CONF_EVENT]
offset = self._options[CONF_OFFSET]
# Validate the entity id is valid
get_entity(self._hass, entity_id)
trigger_data = {
"event": event_type,
"offset": offset,
}
listener = CalendarEventListener(
self._hass,
run_action,
trigger_data,
queued_event_fetcher(
event_fetcher(self._hass, entity_id), event_type, offset
),
)
await listener.async_attach()
return listener.async_detach
TRIGGERS: dict[str, type[Trigger]] = {
"_": EventTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for calendars."""
return TRIGGERS

View File

@@ -98,6 +98,21 @@
}
},
"triggers": {
"current_humidity_changed": {
"trigger": "mdi:water-percent"
},
"current_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"current_temperature_changed": {
"trigger": "mdi:thermometer"
},
"current_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"hvac_mode_changed": {
"trigger": "mdi:thermostat"
},
"started_cooling": {
"trigger": "mdi:snowflake"
},
@@ -107,6 +122,18 @@
"started_heating": {
"trigger": "mdi:fire"
},
"target_humidity_changed": {
"trigger": "mdi:water-percent"
},
"target_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"target_temperature_changed": {
"trigger": "mdi:thermometer"
},
"target_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"turned_off": {
"trigger": "mdi:power-off"
},

View File

@@ -192,12 +192,26 @@
"off": "[%key:common::state::off%]"
}
},
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above a value",
"below": "Below a value",
"between": "In a range",
"outside": "Outside a range"
}
}
},
"services": {
@@ -298,6 +312,92 @@
},
"title": "Climate",
"triggers": {
"current_humidity_changed": {
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the humidity is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current humidity changed"
},
"current_humidity_crossed_threshold": {
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current humidity crossed threshold"
},
"current_temperature_changed": {
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the temperature is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the temperature is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current temperature changed"
},
"current_temperature_crossed_threshold": {
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current temperature crossed threshold"
},
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"hvac_mode": {
"description": "The HVAC modes to trigger on.",
"name": "Modes"
}
},
"name": "Climate-control device mode changed"
},
"started_cooling": {
"description": "Triggers after one or more climate-control devices start cooling.",
"fields": {
@@ -328,6 +428,78 @@
},
"name": "Climate-control device started heating"
},
"target_humidity_changed": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the target humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the target humidity is below this value.",
"name": "Below"
}
},
"name": "Climate-control device target humidity changed"
},
"target_humidity_crossed_threshold": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device target humidity crossed threshold"
},
"target_temperature_changed": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the target temperature is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the target temperature is below this value.",
"name": "Below"
}
},
"name": "Climate-control device target temperature changed"
},
"target_temperature_crossed_threshold": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device target temperature crossed threshold"
},
"turned_off": {
"description": "Triggers after one or more climate-control devices turn off.",
"fields": {

View File

@@ -1,22 +1,89 @@
"""Provides triggers for climates."""
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
DOMAIN,
HVACAction,
HVACMode,
)
CONF_HVAC_MODE = "hvac_mode"
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [HVACMode]
),
},
}
)
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain = DOMAIN
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
self._to_states = set(self._options[CONF_HVAC_MODE])
TRIGGERS: dict[str, type[Trigger]] = {
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_HUMIDITY
),
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_HUMIDITY
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_TEMPERATURE
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_TEMPERATURE
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,

View File

@@ -1,9 +1,9 @@
.trigger_common: &trigger_common
target:
target: &trigger_climate_target
entity:
domain: climate
fields:
behavior:
behavior: &trigger_behavior
required: true
default: any
selector:
@@ -14,8 +14,109 @@
- last
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
entity:
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
number:
selector:
number:
mode: box
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
started_cooling: *trigger_common
started_drying: *trigger_common
started_heating: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
hvac_mode_changed:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
hvac_mode:
context:
filter_target: target
required: true
selector:
state:
hide_states:
- unavailable
- unknown
multiple: true
current_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
target_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
current_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
target_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import CONF_SWING_SUPPORT, DOMAIN
from .const import CONF_SEND_WAKEUP_PROMPT, CONF_SWING_SUPPORT, DOMAIN
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
@@ -17,10 +17,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
"""Set up Coolmaster from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
send_wakeup_prompt = entry.data.get(CONF_SEND_WAKEUP_PROMPT, False)
if not entry.data.get(CONF_SWING_SUPPORT):
coolmaster = CoolMasterNet(
host,
port,
send_initial_line_feed=send_wakeup_prompt,
)
else:
# Swing support adds an additional request per unit. The requests are
@@ -29,6 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
coolmaster = CoolMasterNet(
host,
port,
send_initial_line_feed=send_wakeup_prompt,
read_timeout=5,
swing_support=True,
)

View File

@@ -12,7 +12,13 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import callback
from .const import CONF_SUPPORTED_MODES, CONF_SWING_SUPPORT, DEFAULT_PORT, DOMAIN
from .const import (
CONF_SEND_WAKEUP_PROMPT,
CONF_SUPPORTED_MODES,
CONF_SWING_SUPPORT,
DEFAULT_PORT,
DOMAIN,
)
AVAILABLE_MODES = [
HVACMode.OFF.value,
@@ -25,17 +31,15 @@ AVAILABLE_MODES = [
MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES}
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
**MODES_SCHEMA,
vol.Required(CONF_SWING_SUPPORT, default=False): bool,
}
)
DATA_SCHEMA = {
vol.Required(CONF_HOST): str,
**MODES_SCHEMA,
vol.Required(CONF_SWING_SUPPORT, default=False): bool,
}
async def _validate_connection(host: str) -> bool:
cool = CoolMasterNet(host, DEFAULT_PORT)
async def _validate_connection(host: str, send_wakeup_prompt: bool) -> bool:
cool = CoolMasterNet(host, DEFAULT_PORT, send_initial_line_feed=send_wakeup_prompt)
units = await cool.status()
return bool(units)
@@ -45,6 +49,14 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
def _get_data_schema(self) -> vol.Schema:
schema_dict = DATA_SCHEMA.copy()
if self.show_advanced_options:
schema_dict[vol.Required(CONF_SEND_WAKEUP_PROMPT, default=False)] = bool
return vol.Schema(schema_dict)
@callback
def _async_get_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
supported_modes = [
@@ -57,6 +69,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: DEFAULT_PORT,
CONF_SUPPORTED_MODES: supported_modes,
CONF_SWING_SUPPORT: data[CONF_SWING_SUPPORT],
CONF_SEND_WAKEUP_PROMPT: data.get(CONF_SEND_WAKEUP_PROMPT, False),
},
)
@@ -64,15 +77,19 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
data_schema = self._get_data_schema()
if user_input is None:
return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
return self.async_show_form(step_id="user", data_schema=data_schema)
errors = {}
host = user_input[CONF_HOST]
try:
result = await _validate_connection(host)
result = await _validate_connection(
host, user_input.get(CONF_SEND_WAKEUP_PROMPT, False)
)
if not result:
errors["base"] = "no_units"
except OSError:
@@ -80,7 +97,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
if errors:
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
step_id="user", data_schema=data_schema, errors=errors
)
return self._async_get_entry(user_input)

View File

@@ -6,5 +6,6 @@ DEFAULT_PORT = 10102
CONF_SUPPORTED_MODES = "supported_modes"
CONF_SWING_SUPPORT = "swing_support"
CONF_SEND_WAKEUP_PROMPT = "send_wakeup_prompt"
MAX_RETRIES = 3
BACKOFF_BASE_DELAY = 2

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pycoolmasternet_async"],
"requirements": ["pycoolmasternet-async==0.2.2"]
"requirements": ["pycoolmasternet-async==0.2.4"]
}

View File

@@ -14,10 +14,12 @@
"heat_cool": "Support automatic heat/cool mode",
"host": "[%key:common::config_flow::data::host%]",
"off": "Can be turned off",
"send_wakeup_prompt": "Send wakeup prompt",
"swing_support": "Control swing mode"
},
"data_description": {
"host": "The hostname or IP address of your CoolMasterNet device."
"host": "The hostname or IP address of your CoolMasterNet device.",
"send_wakeup_prompt": "Send the coolmaster unit an empty commaand before issuing any actual command. This is required for serial models."
},
"description": "Set up your CoolMasterNet connection details."
}

View File

@@ -9,7 +9,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["async-upnp-client==0.46.0"],
"requirements": ["async-upnp-client==0.46.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/enocean",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["enocean"],
"requirements": ["enocean==0.50"],

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==43.0.0",
"aioesphomeapi==43.3.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/freebox",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["freebox_api"],
"requirements": ["freebox-api==1.2.2"],

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import timedelta
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
from pyfritzhome.devicetypes import FritzhomeTemplate
from pyfritzhome.devicetypes import FritzhomeTemplate, FritzhomeTrigger
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
from homeassistant.config_entries import ConfigEntry
@@ -27,6 +27,7 @@ class FritzboxCoordinatorData:
devices: dict[str, FritzhomeDevice]
templates: dict[str, FritzhomeTemplate]
triggers: dict[str, FritzhomeTrigger]
supported_color_properties: dict[str, tuple[dict, list]]
@@ -37,6 +38,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
configuration_url: str
fritz: Fritzhome
has_templates: bool
has_triggers: bool
def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
"""Initialize the Fritzbox Smarthome device coordinator."""
@@ -50,8 +52,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.new_devices: set[str] = set()
self.new_templates: set[str] = set()
self.new_triggers: set[str] = set()
self.data = FritzboxCoordinatorData({}, {}, {})
self.data = FritzboxCoordinatorData({}, {}, {}, {})
async def async_setup(self) -> None:
"""Set up the coordinator."""
@@ -74,6 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
)
LOGGER.debug("enable smarthome templates: %s", self.has_templates)
self.has_triggers = await self.hass.async_add_executor_job(
self.fritz.has_triggers
)
LOGGER.debug("enable smarthome triggers: %s", self.has_triggers)
self.configuration_url = self.fritz.get_prefixed_host()
await self.async_config_entry_first_refresh()
@@ -92,7 +100,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
available_main_ains = [
ain
for ain, dev in data.devices.items() | data.templates.items()
for ain, dev in (data.devices | data.templates | data.triggers).items()
if dev.device_and_unit_id[1] is None
]
device_reg = dr.async_get(self.hass)
@@ -112,6 +120,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
except RequestConnectionError as ex:
raise UpdateFailed from ex
except HTTPError:
@@ -123,6 +134,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
devices = self.fritz.get_devices()
device_data = {}
@@ -156,12 +169,20 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
for template in templates:
template_data[template.ain] = template
trigger_data = {}
if self.has_triggers:
triggers = self.fritz.get_triggers()
for trigger in triggers:
trigger_data[trigger.ain] = trigger
self.new_devices = device_data.keys() - self.data.devices.keys()
self.new_templates = template_data.keys() - self.data.templates.keys()
self.new_triggers = trigger_data.keys() - self.data.triggers.keys()
return FritzboxCoordinatorData(
devices=device_data,
templates=template_data,
triggers=trigger_data,
supported_color_properties=supported_color_properties,
)
@@ -193,6 +214,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
if (
self.data.devices.keys() - new_data.devices.keys()
or self.data.templates.keys() - new_data.templates.keys()
or self.data.triggers.keys() - new_data.triggers.keys()
):
self.cleanup_removed_devices(new_data)

View File

@@ -4,14 +4,17 @@ from __future__ import annotations
from typing import Any
from pyfritzhome.devicetypes import FritzhomeTrigger
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import FritzboxConfigEntry
from .entity import FritzBoxDeviceEntity
from .entity import FritzBoxDeviceEntity, FritzBoxEntity
# Coordinator handles data updates, so we can allow unlimited parallel updates
PARALLEL_UPDATES = 0
@@ -26,21 +29,27 @@ async def async_setup_entry(
coordinator = entry.runtime_data
@callback
def _add_entities(devices: set[str] | None = None) -> None:
"""Add devices."""
def _add_entities(
devices: set[str] | None = None, triggers: set[str] | None = None
) -> None:
"""Add devices and triggers."""
if devices is None:
devices = coordinator.new_devices
if not devices:
if triggers is None:
triggers = coordinator.new_triggers
if not devices and not triggers:
return
async_add_entities(
entities = [
FritzboxSwitch(coordinator, ain)
for ain in devices
if coordinator.data.devices[ain].has_switch
)
] + [FritzboxTrigger(coordinator, ain) for ain in triggers]
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
_add_entities(set(coordinator.data.devices))
_add_entities(set(coordinator.data.devices), set(coordinator.data.triggers))
class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
@@ -70,3 +79,42 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
translation_domain=DOMAIN,
translation_key="manual_switching_disabled",
)
class FritzboxTrigger(FritzBoxEntity, SwitchEntity):
"""The switch class for FRITZ!SmartHome triggers."""
@property
def data(self) -> FritzhomeTrigger:
"""Return the trigger data entity."""
return self.coordinator.data.triggers[self.ain]
@property
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
return DeviceInfo(
name=self.data.name,
identifiers={(DOMAIN, self.ain)},
configuration_url=self.coordinator.configuration_url,
manufacturer="FRITZ!",
model="SmartHome Routine",
)
@property
def is_on(self) -> bool:
"""Return true if the trigger is active."""
return self.data.active # type: ignore [no-any-return]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Activate the trigger."""
await self.hass.async_add_executor_job(
self.coordinator.fritz.set_trigger_active, self.ain
)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Deactivate the trigger."""
await self.hass.async_add_executor_job(
self.coordinator.fritz.set_trigger_inactive, self.ain
)
await self.coordinator.async_refresh()

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251203.2"]
"requirements": ["home-assistant-frontend==20251203.3"]
}

View File

@@ -2,15 +2,23 @@
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.stream import (
CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import CONF_AUTHENTICATION, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from .const import CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, SECTION_ADVANCED
DOMAIN = "generic"
PLATFORMS = [Platform.CAMERA]
_LOGGER = logging.getLogger(__name__)
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -47,3 +55,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 1:
# Migrate to advanced section
new_options = {**entry.options}
advanced = new_options[SECTION_ADVANCED] = {
CONF_FRAMERATE: new_options.pop(CONF_FRAMERATE),
CONF_VERIFY_SSL: new_options.pop(CONF_VERIFY_SSL),
}
# migrate optional fields
for key in (
CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
CONF_AUTHENTICATION,
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
):
if key in new_options:
advanced[key] = new_options.pop(key)
hass.config_entries.async_update_entry(entry, options=new_options, version=2)
_LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
return True

View File

@@ -41,6 +41,7 @@ from .const import (
CONF_STILL_IMAGE_URL,
CONF_STREAM_SOURCE,
GET_IMAGE_TIMEOUT,
SECTION_ADVANCED,
)
_LOGGER = logging.getLogger(__name__)
@@ -62,9 +63,11 @@ def generate_auth(device_info: Mapping[str, Any]) -> httpx.Auth | None:
"""Generate httpx.Auth object from credentials."""
username: str | None = device_info.get(CONF_USERNAME)
password: str | None = device_info.get(CONF_PASSWORD)
authentication = device_info.get(CONF_AUTHENTICATION)
if username and password:
if authentication == HTTP_DIGEST_AUTHENTICATION:
if (
device_info[SECTION_ADVANCED].get(CONF_AUTHENTICATION)
== HTTP_DIGEST_AUTHENTICATION
):
return httpx.DigestAuth(username=username, password=password)
return httpx.BasicAuth(username=username, password=password)
return None
@@ -99,14 +102,16 @@ class GenericCamera(Camera):
if self._stream_source:
self._stream_source = Template(self._stream_source, hass)
self._attr_supported_features = CameraEntityFeature.STREAM
self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False)
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
self._limit_refetch = device_info[SECTION_ADVANCED].get(
CONF_LIMIT_REFETCH_TO_URL_CHANGE, False
)
self._attr_frame_interval = 1 / device_info[SECTION_ADVANCED][CONF_FRAMERATE]
self.content_type = device_info[CONF_CONTENT_TYPE]
self.verify_ssl = device_info[CONF_VERIFY_SSL]
if device_info.get(CONF_RTSP_TRANSPORT):
self.stream_options[CONF_RTSP_TRANSPORT] = device_info[CONF_RTSP_TRANSPORT]
self.verify_ssl = device_info[SECTION_ADVANCED][CONF_VERIFY_SSL]
if rtsp_transport := device_info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
self.stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
self._auth = generate_auth(device_info)
if device_info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
if device_info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
self._last_url = None

View File

@@ -50,10 +50,18 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import config_validation as cv, template as template_helper
from homeassistant.helpers.entity_platform import PlatformData
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.network import get_url
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.util import slugify
from .camera import GenericCamera, generate_auth
@@ -67,17 +75,20 @@ from .const import (
DEFAULT_NAME,
DOMAIN,
GET_IMAGE_TIMEOUT,
SECTION_ADVANCED,
)
_LOGGER = logging.getLogger(__name__)
DEFAULT_DATA = {
CONF_NAME: DEFAULT_NAME,
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
CONF_FRAMERATE: 2,
CONF_VERIFY_SSL: True,
CONF_RTSP_TRANSPORT: "tcp",
SECTION_ADVANCED: {
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
CONF_FRAMERATE: 2,
CONF_VERIFY_SSL: True,
CONF_RTSP_TRANSPORT: "tcp",
},
}
SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
@@ -94,58 +105,47 @@ class InvalidStreamException(HomeAssistantError):
def build_schema(
user_input: Mapping[str, Any],
is_options_flow: bool = False,
show_advanced_options: bool = False,
) -> vol.Schema:
"""Create schema for camera config setup."""
rtsp_options = [
SelectOptionDict(
value=value,
label=name,
)
for value, name in RTSP_TRANSPORTS.items()
]
advanced_section = {
vol.Required(CONF_FRAMERATE): vol.All(
vol.Range(min=0, min_included=False), cv.positive_float
),
vol.Required(CONF_VERIFY_SSL): bool,
vol.Optional(CONF_RTSP_TRANSPORT): SelectSelector(
SelectSelectorConfig(
options=rtsp_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_AUTHENTICATION): vol.In(
[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]
),
}
spec = {
vol.Optional(
CONF_STILL_IMAGE_URL,
description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")},
): str,
vol.Optional(
CONF_STREAM_SOURCE,
description={"suggested_value": user_input.get(CONF_STREAM_SOURCE, "")},
): str,
vol.Optional(
CONF_RTSP_TRANSPORT,
description={"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)},
): vol.In(RTSP_TRANSPORTS),
vol.Optional(
CONF_AUTHENTICATION,
description={"suggested_value": user_input.get(CONF_AUTHENTICATION)},
): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
vol.Optional(
CONF_USERNAME,
description={"suggested_value": user_input.get(CONF_USERNAME, "")},
): str,
vol.Optional(
CONF_PASSWORD,
description={"suggested_value": user_input.get(CONF_PASSWORD, "")},
): str,
vol.Required(
CONF_FRAMERATE,
description={"suggested_value": user_input.get(CONF_FRAMERATE, 2)},
): vol.All(vol.Range(min=0, min_included=False), cv.positive_float),
vol.Required(
CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)
): bool,
vol.Optional(CONF_STREAM_SOURCE): str,
vol.Optional(CONF_STILL_IMAGE_URL): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED): section(
vol.Schema(advanced_section), {"collapsed": True}
),
}
if is_options_flow:
spec[
vol.Required(
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False),
)
] = bool
advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool
if show_advanced_options:
spec[
vol.Required(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False),
)
] = bool
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
return vol.Schema(spec)
@@ -187,7 +187,7 @@ async def async_test_still(
return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
if not yarl_url.is_absolute():
return {CONF_STILL_IMAGE_URL: "relative_url"}, None
verify_ssl = info[CONF_VERIFY_SSL]
verify_ssl = info[SECTION_ADVANCED][CONF_VERIFY_SSL]
auth = generate_auth(info)
try:
async_client = get_async_client(hass, verify_ssl=verify_ssl)
@@ -268,9 +268,9 @@ async def async_test_and_preview_stream(
_LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
raise InvalidStreamException("template_error") from err
stream_options: dict[str, str | bool | float] = {}
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
if rtsp_transport := info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
if info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
try:
@@ -326,7 +326,7 @@ def register_still_preview(hass: HomeAssistant) -> None:
class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for generic IP camera."""
VERSION = 1
VERSION = 2
def __init__(self) -> None:
"""Initialize Generic ConfigFlow."""
@@ -381,7 +381,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
user_input = DEFAULT_DATA.copy()
return self.async_show_form(
step_id="user",
data_schema=build_schema(user_input),
data_schema=self.add_suggested_values_to_schema(build_schema(), user_input),
errors=errors,
)
@@ -449,13 +449,19 @@ class GenericOptionsFlowHandler(OptionsFlow):
self.preview_stream = None
if not errors:
data = {
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
),
**user_input,
CONF_CONTENT_TYPE: still_format
or self.config_entry.options.get(CONF_CONTENT_TYPE),
}
if (
CONF_USE_WALLCLOCK_AS_TIMESTAMPS
not in user_input[SECTION_ADVANCED]
):
data[SECTION_ADVANCED][CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = (
self.config_entry.options[SECTION_ADVANCED].get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
)
)
self.user_input = data
# temporary preview for user to check the image
self.preview_image_settings = data
@@ -464,10 +470,12 @@ class GenericOptionsFlowHandler(OptionsFlow):
user_input = self.user_input
return self.async_show_form(
step_id="init",
data_schema=build_schema(
data_schema=self.add_suggested_values_to_schema(
build_schema(
True,
self.show_advanced_options,
),
user_input or self.config_entry.options,
True,
self.show_advanced_options,
),
errors=errors,
)
@@ -583,7 +591,8 @@ async def ws_start_preview(
_LOGGER.debug("Got preview still URL: %s", ha_still_url)
if ha_stream := flow.preview_stream:
ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER)
# HLS player needs an absolute URL as base for constructing child playlist URLs
ha_stream_url = f"{get_url(hass)}{ha_stream.endpoint_url(HLS_PROVIDER)}"
_LOGGER.debug("Got preview stream URL: %s", ha_stream_url)
connection.send_message(

View File

@@ -9,3 +9,4 @@ CONF_STILL_IMAGE_URL = "still_image_url"
CONF_STREAM_SOURCE = "stream_source"
CONF_FRAMERATE = "framerate"
GET_IMAGE_TIMEOUT = 10
SECTION_ADVANCED = "advanced"

View File

@@ -26,17 +26,24 @@
"step": {
"user": {
"data": {
"authentication": "Authentication",
"framerate": "Frame rate (Hz)",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"password": "[%key:common::config_flow::data::password%]",
"rtsp_transport": "RTSP transport protocol",
"still_image_url": "Still image URL (e.g. http://...)",
"stream_source": "Stream source URL (e.g. rtsp://...)",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Enter the settings to connect to the camera."
"sections": {
"advanced": {
"data": {
"authentication": "Authentication",
"framerate": "Frame rate (Hz)",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"rtsp_transport": "RTSP transport protocol",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Advanced settings are only needed for special cases. Leave them unchanged unless you know what you are doing.",
"name": "Advanced settings"
}
}
},
"user_confirm": {
"data": {
@@ -70,19 +77,27 @@
"step": {
"init": {
"data": {
"authentication": "[%key:component::generic::config::step::user::data::authentication%]",
"framerate": "[%key:component::generic::config::step::user::data::framerate%]",
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::data::limit_refetch_to_url_change%]",
"password": "[%key:common::config_flow::data::password%]",
"rtsp_transport": "[%key:component::generic::config::step::user::data::rtsp_transport%]",
"still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]",
"stream_source": "[%key:component::generic::config::step::user::data::stream_source%]",
"use_wallclock_as_timestamps": "Use wallclock as timestamps",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
"sections": {
"advanced": {
"data": {
"authentication": "[%key:component::generic::config::step::user::sections::advanced::data::authentication%]",
"framerate": "[%key:component::generic::config::step::user::sections::advanced::data::framerate%]",
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::sections::advanced::data::limit_refetch_to_url_change%]",
"rtsp_transport": "[%key:component::generic::config::step::user::sections::advanced::data::rtsp_transport%]",
"use_wallclock_as_timestamps": "Use wallclock as timestamps",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
},
"description": "[%key:component::generic::config::step::user::sections::advanced::description%]",
"name": "[%key:component::generic::config::step::user::sections::advanced::name%]"
}
}
},
"user_confirm": {

View File

@@ -8,4 +8,4 @@ HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
# in script/hassfest/docker.py.
RECOMMENDED_VERSION = "1.9.12"
RECOMMENDED_VERSION = "1.9.13"

View File

@@ -12,6 +12,7 @@
"homekit": {
"models": ["iSmartGate"]
},
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["ismartgate"],
"requirements": ["ismartgate==5.0.2"]

View File

@@ -101,6 +101,15 @@ def _is_location_already_configured(
return False
def _is_location_name_already_configured(hass: HomeAssistant, new_data: str) -> bool:
"""Check if the location name is already configured."""
for entry in hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if subentry.title.lower() == new_data.lower():
return True
return False
class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google AirQuality."""
@@ -178,8 +187,19 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
description_placeholders: dict[str, str] = {}
if user_input is not None:
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
errors["base"] = "location_already_configured"
if _is_location_name_already_configured(self.hass, user_input[CONF_NAME]):
errors["base"] = "location_name_already_configured"
api: GoogleAirQualityApi = self._get_entry().runtime_data.api
if errors:
return self.async_show_form(
step_id="location",
data_schema=self.add_suggested_values_to_schema(
_get_location_schema(self.hass), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title=user_input[CONF_NAME],

View File

@@ -47,12 +47,12 @@
"config_subentries": {
"location": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"unable_to_fetch": "[%key:component::google_air_quality::common::unable_to_fetch%]"
},
"entry_type": "Air quality location",
"error": {
"no_data_for_location": "Information is unavailable for this location. Please try a different location.",
"location_already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"location_name_already_configured": "Location name already configured.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {

View File

@@ -7,6 +7,7 @@ ATTR_CC = "cc"
ATTR_ENABLED = "enabled"
ATTR_END = "end"
ATTR_FROM = "from"
ATTR_ALIAS_FROM = "alias_from"
ATTR_ME = "me"
ATTR_MESSAGE = "message"
ATTR_PLAIN_TEXT = "plain_text"

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import base64
from email.mime.text import MIMEText
from email.utils import formataddr
from typing import Any
from googleapiclient.http import HttpRequest
@@ -17,10 +18,20 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .api import AsyncConfigEntryAuth
from .const import ATTR_BCC, ATTR_CC, ATTR_FROM, ATTR_ME, ATTR_SEND, DATA_AUTH
from .const import (
ATTR_ALIAS_FROM,
ATTR_BCC,
ATTR_CC,
ATTR_FROM,
ATTR_ME,
ATTR_SEND,
DATA_AUTH,
DOMAIN,
)
async def async_get_service(
@@ -47,7 +58,17 @@ class GMailNotificationService(BaseNotificationService):
email = MIMEText(message, "html")
if to_addrs := kwargs.get(ATTR_TARGET):
email["To"] = ", ".join(to_addrs)
email["From"] = data.get(ATTR_FROM, ATTR_ME)
email_from = data.get(ATTR_FROM, ATTR_ME)
if alias := data.get(ATTR_ALIAS_FROM):
if email_from == ATTR_ME:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_from_for_alias",
)
email["From"] = formataddr((alias, email_from))
else:
email["From"] = email_from
email["Subject"] = title
email[ATTR_CC] = ", ".join(data.get(ATTR_CC, []))
email[ATTR_BCC] = ", ".join(data.get(ATTR_BCC, []))
@@ -57,9 +78,9 @@ class GMailNotificationService(BaseNotificationService):
msg: HttpRequest
users = (await self.auth.get_resource()).users()
if data.get(ATTR_SEND) is False:
msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body})
msg = users.drafts().create(userId=email_from, body={ATTR_MESSAGE: body})
else:
if not to_addrs:
raise ValueError("recipient address required")
msg = users.messages().send(userId=email["From"], body=body)
msg = users.messages().send(userId=email_from, body=body)
await self.hass.async_add_executor_job(msg.execute)

View File

@@ -47,6 +47,11 @@
}
}
},
"exceptions": {
"missing_from_for_alias": {
"message": "Missing 'from' email when setting an alias to show. You have to provide a 'from' email"
}
},
"services": {
"set_vacation": {
"description": "Sets vacation responder settings for Google Mail.",

View File

@@ -16,6 +16,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import (
ABORT_NO_PLANTS,
@@ -23,12 +24,13 @@ from .const import (
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
CONF_REGION,
DEFAULT_URL,
DOMAIN,
ERROR_CANNOT_CONNECT,
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS,
SERVER_URLS_NAMES,
)
_LOGGER = logging.getLogger(__name__)
@@ -67,10 +69,13 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
self.auth_type = AUTH_PASSWORD
# Traditional username/password authentication
# Convert region name to URL - guaranteed to exist since vol.In validates it
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
)
self.api.server_url = user_input[CONF_URL]
self.api.server_url = server_url
try:
login_response = await self.hass.async_add_executor_job(
@@ -91,6 +96,8 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
self.user_id = login_response["user"]["id"]
self.data = user_input
# Store the actual URL, not the region name
self.data[CONF_URL] = server_url
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
@@ -104,8 +111,11 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
self.auth_type = AUTH_API_TOKEN
# Using token authentication
token = user_input[CONF_TOKEN]
self.api = growattServer.OpenApiV1(token=token)
# Convert region name to URL - guaranteed to exist since vol.In validates it
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
self.api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
self.api.server_url = server_url
# Verify token by fetching plant list
try:
@@ -127,6 +137,8 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
self.data = user_input
# Store the actual URL, not the region name
self.data[CONF_URL] = server_url
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
@@ -139,7 +151,12 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_URL, default=DEFAULT_URL): vol.In(SERVER_URLS),
vol.Required(CONF_REGION, default=DEFAULT_URL): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)
@@ -155,6 +172,12 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_REGION, default=DEFAULT_URL): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)

View File

@@ -3,6 +3,7 @@
from homeassistant.const import Platform
CONF_PLANT_ID = "plant_id"
CONF_REGION = "region"
# API key support
@@ -18,13 +19,14 @@ DEFAULT_PLANT_ID = "0"
DEFAULT_NAME = "Growatt"
SERVER_URLS = [
"https://openapi.growatt.com/", # Other regional server
"https://openapi-cn.growatt.com/", # Chinese server
"https://openapi-us.growatt.com/", # North American server
"https://openapi-au.growatt.com/", # Australia Server
"http://server.smten.com/", # smten server
]
SERVER_URLS_NAMES = {
"north_america": "https://openapi-us.growatt.com/",
"australia_new_zealand": "https://openapi-au.growatt.com/",
"china": "https://openapi-cn.growatt.com/",
"other_regions": "https://openapi.growatt.com/",
"smten_server": "http://server.smten.com/",
"era_server": "http://ess-server.atesspower.com/",
}
DEPRECATED_URLS = [
"https://server.growatt.com/",
@@ -32,7 +34,7 @@ DEPRECATED_URLS = [
"https://server-us.growatt.com/",
]
DEFAULT_URL = SERVER_URLS[0]
DEFAULT_URL = "other_regions"
DOMAIN = "growatt_server"

View File

@@ -24,9 +24,7 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: todo
comment: Update server URL dropdown to show regional descriptions (e.g., 'China', 'United States') instead of raw URLs.
docs-configuration-parameters: done
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done

View File

@@ -13,7 +13,7 @@
"password_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"url": "[%key:common::config_flow::data::url%]",
"url": "Server region",
"username": "[%key:common::config_flow::data::username%]"
},
"title": "Enter your Growatt login credentials"
@@ -26,7 +26,8 @@
},
"token_auth": {
"data": {
"token": "API Token"
"token": "API Token",
"url": "Server region"
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
@@ -530,6 +531,16 @@
"grid_first": "Grid first",
"load_first": "Load first"
}
},
"region": {
"options": {
"australia_new_zealand": "Australia and New Zealand",
"china": "China",
"era_server": "Era server (Atess Power)",
"north_america": "North America",
"other_regions": "Other regions",
"smten_server": "SMTEN server"
}
}
},
"services": {

View File

@@ -51,12 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
try:
camera = await hass.async_add_executor_job(
HikCamera, url, port, username, password
HikCamera, url, port, username, password, ssl
)
except requests.exceptions.RequestException as err:
raise ConfigEntryNotReady(f"Unable to connect to {host}") from err
device_id = camera.get_id()
device_id = camera.get_id
if device_id is None:
raise ConfigEntryNotReady(f"Unable to get device ID from {host}")

View File

@@ -49,14 +49,14 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
try:
camera = await self.hass.async_add_executor_job(
HikCamera, url, port, username, password
HikCamera, url, port, username, password, ssl
)
device_id = camera.get_id()
device_name = camera.get_name
except requests.exceptions.RequestException:
_LOGGER.exception("Error connecting to Hikvision device")
errors["base"] = "cannot_connect"
else:
device_id = camera.get_id
device_name = camera.get_name
if device_id is None:
errors["base"] = "cannot_connect"
else:
@@ -102,16 +102,16 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
try:
camera = await self.hass.async_add_executor_job(
HikCamera, url, port, username, password
HikCamera, url, port, username, password, ssl
)
device_id = camera.get_id()
device_name = camera.get_name
except requests.exceptions.RequestException:
_LOGGER.exception(
"Error connecting to Hikvision device during import, aborting"
)
return self.async_abort(reason="cannot_connect")
device_id = camera.get_id
device_name = camera.get_name
if device_id is None:
return self.async_abort(reason="cannot_connect")

View File

@@ -1,12 +1,12 @@
{
"domain": "hikvision",
"name": "Hikvision",
"codeowners": ["@mezz64"],
"codeowners": ["@mezz64", "@ptarjan"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hikvision",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",
"requirements": ["pyHik==0.3.2"]
"requirements": ["pyHik==0.3.4"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@bannhead"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hisense_aehw4a1",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyaehw4a1"],
"requirements": ["pyaehw4a1==0.3.9"]

View File

@@ -65,6 +65,11 @@ BINARY_SENSORS = (
},
translation_key="charging_connection",
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.BSH_COMMON_INTERIOR_ILLUMINATION_ACTIVE,
translation_key="interior_illumination_active",
device_class=BinarySensorDeviceClass.LIGHT,
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED,
translation_key="dust_box_inserted",

View File

@@ -270,6 +270,10 @@ WARMING_LEVEL_OPTIONS = {
)
}
RINSE_PLUS_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in ("LaundryCare.Washer.EnumType.RinsePlus.Off",)
}
TEMPERATURE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
@@ -309,6 +313,12 @@ SPIN_SPEED_OPTIONS = {
)
}
STAINS_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in ("LaundryCare.Washer.EnumType.Stains.Off",)
}
VARIO_PERFECT_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
@@ -363,8 +373,10 @@ PROGRAM_ENUM_OPTIONS = {
(OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS),
(OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS),
(OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_RINSE_PLUS, RINSE_PLUS_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_STAINS, STAINS_OPTIONS),
(OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS),
)
}

View File

@@ -4,6 +4,12 @@
"dust_box_inserted": {
"default": "mdi:download"
},
"interior_illumination_active": {
"default": "mdi:lightbulb-on",
"state": {
"off": "mdi:lightbulb-off"
}
},
"lifted": {
"default": "mdi:arrow-up-right-bold"
},

View File

@@ -29,7 +29,9 @@ from .const import (
HOT_WATER_TEMPERATURE_OPTIONS,
INTENSIVE_LEVEL_OPTIONS,
PROGRAMS_TRANSLATION_KEYS_MAP,
RINSE_PLUS_OPTIONS,
SPIN_SPEED_OPTIONS,
STAINS_OPTIONS,
SUCTION_POWER_OPTIONS,
TEMPERATURE_OPTIONS,
TRANSLATION_KEYS_PROGRAMS_MAP,
@@ -279,6 +281,16 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
for translation_key, value in WARMING_LEVEL_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_RINSE_PLUS,
translation_key="rinse_plus",
options=list(RINSE_PLUS_OPTIONS),
translation_key_values=RINSE_PLUS_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in RINSE_PLUS_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE,
translation_key="washer_temperature",
@@ -299,6 +311,15 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
for translation_key, value in SPIN_SPEED_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_STAINS,
translation_key="auto_stain",
options=list(STAINS_OPTIONS),
translation_key_values=STAINS_OPTIONS,
values_translation_key={
value: translation_key for translation_key, value in STAINS_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT,
translation_key="vario_perfect",

View File

@@ -524,6 +524,15 @@ set_program_and_options:
washer_options:
collapsed: true
fields:
laundry_care_washer_option_rinse_plus:
example: laundry_care_washer_enum_type_rinse_plus_off
required: false
selector:
select:
mode: dropdown
translation_key: rinse_plus
options:
- laundry_care_washer_enum_type_rinse_plus_off
laundry_care_washer_option_temperature:
example: laundry_care_washer_enum_type_temperature_g_c_40
required: false
@@ -567,6 +576,15 @@ set_program_and_options:
- laundry_care_washer_enum_type_spin_speed_ul_low
- laundry_care_washer_enum_type_spin_speed_ul_medium
- laundry_care_washer_enum_type_spin_speed_ul_high
laundry_care_washer_option_stains:
example: laundry_care_washer_enum_type_stains_off
required: false
selector:
select:
mode: dropdown
translation_key: stains
options:
- laundry_care_washer_enum_type_stains_off
b_s_h_common_option_finish_in_relative:
example: 3600
required: false
@@ -576,6 +594,11 @@ set_program_and_options:
step: 1
mode: box
unit_of_measurement: s
laundry_care_common_option_silent_mode:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_i_dos1_active:
example: false
required: false
@@ -586,6 +609,41 @@ set_program_and_options:
required: false
selector:
boolean:
laundry_care_washer_option_intensive_plus:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_less_ironing:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_mini_load:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_prewash:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_rinse_hold:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_soak:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_water_plus:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_vario_perfect:
example: laundry_care_common_enum_type_vario_perfect_eco_perfect
required: false

View File

@@ -70,6 +70,9 @@
"freezer_door": {
"name": "Freezer door"
},
"interior_illumination_active": {
"name": "Interior illumination active"
},
"left_chiller_door": {
"name": "Left chiller door"
},
@@ -359,6 +362,12 @@
"b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom"
}
},
"auto_stain": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_stains::name%]",
"state": {
"laundry_care_washer_enum_type_stains_off": "[%key:common::state::off%]"
}
},
"bean_amount": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]",
"state": {
@@ -523,6 +532,12 @@
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
}
},
"rinse_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_rinse_plus::name%]",
"state": {
"laundry_care_washer_enum_type_rinse_plus_off": "[%key:common::state::off%]"
}
},
"selected_program": {
"name": "Selected program",
"state": {
@@ -1212,27 +1227,51 @@
"intensiv_zone": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
},
"intensive_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_intensive_plus::name%]"
},
"less_ironing": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_less_ironing::name%]"
},
"mini_load": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_mini_load::name%]"
},
"multiple_beverages": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]"
},
"power": {
"name": "Power"
},
"prewash": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_prewash::name%]"
},
"refrigerator_super_mode": {
"name": "Refrigerator super mode"
},
"rinse_hold": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_rinse_hold::name%]"
},
"sabbath_mode": {
"name": "Sabbath mode"
},
"silence_on_demand": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]"
},
"silent_mode": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_silent_mode::name%]"
},
"soaking": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_soak::name%]"
},
"vacation_mode": {
"name": "Vacation mode"
},
"vario_speed_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]"
},
"water_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_water_plus::name%]"
},
"zeolite_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]"
}
@@ -1654,6 +1693,11 @@
"laundry_care_washer_program_wool": "Wool"
}
},
"rinse_plus": {
"options": {
"laundry_care_washer_enum_type_rinse_plus_off": "[%key:common::state::off%]"
}
},
"spin_speed": {
"options": {
"laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
@@ -1672,6 +1716,11 @@
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]"
}
},
"stains": {
"options": {
"laundry_care_washer_enum_type_stains_off": "[%key:common::state::off%]"
}
},
"suction_power": {
"options": {
"consumer_products_cleaning_robot_enum_type_suction_power_max": "Max",
@@ -1865,6 +1914,10 @@
"description": "Setting to adjust the venting level of the air conditioner as a percentage.",
"name": "Fan speed percentage"
},
"laundry_care_common_option_silent_mode": {
"description": "Defines if the silent mode is activated.",
"name": "Silent mode"
},
"laundry_care_dryer_option_drying_target": {
"description": "Describes the drying target for a dryer program.",
"name": "Drying target"
@@ -1877,10 +1930,42 @@
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)",
"name": "i-Dos 2 Active"
},
"laundry_care_washer_option_intensive_plus": {
"description": "Defines if the intensive washing is enabled for heavily soiled laundry.",
"name": "Intensive +"
},
"laundry_care_washer_option_less_ironing": {
"description": "Defines if the laundry is treated gently to reduce creasing and make ironing easier.",
"name": "Less ironing"
},
"laundry_care_washer_option_mini_load": {
"description": "Defines if the mini load option is activated.",
"name": "Mini load"
},
"laundry_care_washer_option_prewash": {
"description": "Defines if an additional prewash cycle is added to the program.",
"name": "Prewash"
},
"laundry_care_washer_option_rinse_hold": {
"description": "Defines if the rinse hold option is activated.",
"name": "Rinse hold"
},
"laundry_care_washer_option_rinse_plus": {
"description": "Defines if an additional rinse cycle is added to the program.",
"name": "Extra rinse"
},
"laundry_care_washer_option_soak": {
"description": "Defines if the soaking is activated.",
"name": "Soaking"
},
"laundry_care_washer_option_spin_speed": {
"description": "Defines the spin speed of a washer program.",
"name": "Spin speed"
},
"laundry_care_washer_option_stains": {
"description": "Defines the type of stains to be treated.",
"name": "Auto stain"
},
"laundry_care_washer_option_temperature": {
"description": "Defines the temperature of the washing program.",
"name": "Temperature"
@@ -1889,6 +1974,10 @@
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
"name": "Vario perfect"
},
"laundry_care_washer_option_water_plus": {
"description": "Defines if the water plus option is activated.",
"name": "Water +"
},
"program": {
"description": "Program to select",
"name": "Program"

View File

@@ -124,6 +124,10 @@ SWITCH_OPTIONS = (
key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT,
translation_key="fast_pre_heat",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE,
translation_key="silent_mode",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE,
translation_key="i_dos1_active",
@@ -132,6 +136,34 @@ SWITCH_OPTIONS = (
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE,
translation_key="i_dos2_active",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS,
translation_key="intensive_plus",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING,
translation_key="less_ironing",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD,
translation_key="mini_load",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_PREWASH,
translation_key="prewash",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD,
translation_key="rinse_hold",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_SOAK,
translation_key="soaking",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS,
translation_key="water_plus",
),
)

View File

@@ -16,7 +16,7 @@ from .entity import HomeWizardEntity
def homewizard_exception_handler[_HomeWizardEntityT: HomeWizardEntity, **_P](
func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate HomeWizard Energy calls to handle HomeWizardEnergy exceptions.
"""Decorate HomeWizard calls to handle HomeWizardEnergy exceptions.
A decorator that wraps the passed in function, catches HomeWizardEnergy errors,
and reloads the integration when the API was disabled so the reauth flow is

View File

@@ -1,6 +1,6 @@
{
"domain": "homewizard",
"name": "HomeWizard Energy",
"name": "HomeWizard",
"codeowners": ["@DCSBL"],
"config_flow": true,
"dhcp": [
@@ -13,6 +13,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==9.3.0"],
"requirements": ["python-homewizard-energy==10.0.0"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}

View File

@@ -2,12 +2,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from homewizard_energy import HomeWizardEnergy
from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry
from homewizard_energy.models import Batteries
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
@@ -21,69 +16,59 @@ from .helpers import homewizard_exception_handler
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class HomeWizardSelectEntityDescription(SelectEntityDescription):
"""Class describing HomeWizard select entities."""
available_fn: Callable[[DeviceResponseEntry], bool]
create_fn: Callable[[DeviceResponseEntry], bool]
current_fn: Callable[[DeviceResponseEntry], str | None]
set_fn: Callable[[HomeWizardEnergy, str], Awaitable[Any]]
DESCRIPTIONS = [
HomeWizardSelectEntityDescription(
key="battery_group_mode",
translation_key="battery_group_mode",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
options=[Batteries.Mode.ZERO, Batteries.Mode.STANDBY, Batteries.Mode.TO_FULL],
available_fn=lambda x: x.batteries is not None,
create_fn=lambda x: x.batteries is not None,
current_fn=lambda x: x.batteries.mode if x.batteries else None,
set_fn=lambda api, mode: api.batteries(mode=Batteries.Mode(mode)),
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeWizardConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up HomeWizard select based on a config entry."""
async_add_entities(
HomeWizardSelectEntity(
coordinator=entry.runtime_data,
description=description,
if entry.runtime_data.data.device.supports_batteries():
async_add_entities(
[
HomeWizardBatteryModeSelectEntity(
coordinator=entry.runtime_data,
)
]
)
for description in DESCRIPTIONS
if description.create_fn(entry.runtime_data.data)
)
class HomeWizardSelectEntity(HomeWizardEntity, SelectEntity):
class HomeWizardBatteryModeSelectEntity(HomeWizardEntity, SelectEntity):
"""Defines a HomeWizard select entity."""
entity_description: HomeWizardSelectEntityDescription
entity_description: SelectEntityDescription
def __init__(
self,
coordinator: HWEnergyDeviceUpdateCoordinator,
description: HomeWizardSelectEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
description = SelectEntityDescription(
key="battery_group_mode",
translation_key="battery_group_mode",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
options=[
str(mode)
for mode in (coordinator.data.device.supported_battery_modes() or [])
],
)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
return self.entity_description.current_fn(self.coordinator.data)
return (
self.coordinator.data.batteries.mode
if self.coordinator.data.batteries and self.coordinator.data.batteries.mode
else None
)
@homewizard_exception_handler
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.set_fn(self.coordinator.api, option)
await self.coordinator.api.batteries(Batteries.Mode(option))
await self.coordinator.async_request_refresh()

View File

@@ -12,13 +12,13 @@
"wrong_device": "The configured device is not the same found on this IP address."
},
"error": {
"api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.",
"api_not_enabled": "The local API is disabled. Go to the HomeWizard app and enable the API in the device settings.",
"authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds",
"network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network"
},
"step": {
"authorize": {
"description": "Press the button on the HomeWizard Energy device for two seconds, then select the button below.",
"description": "Press the button on the HomeWizard device for two seconds, then select the button below.",
"title": "Authorize"
},
"discovery_confirm": {
@@ -30,7 +30,7 @@
"title": "Re-authenticate"
},
"reauth_enable_api": {
"description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings."
"description": "The local API is disabled. Go to the HomeWizard app and enable the API in the device settings."
},
"reconfigure": {
"data": {
@@ -46,9 +46,9 @@
"ip_address": "[%key:common::config_flow::data::ip%]"
},
"data_description": {
"ip_address": "The IP address of your HomeWizard Energy device."
"ip_address": "The IP address of your HomeWizard device."
},
"description": "Enter the IP address of your HomeWizard Energy device to integrate with Home Assistant.",
"description": "Enter the IP address of your HomeWizard device to integrate with Home Assistant.",
"title": "Configure device"
}
}
@@ -65,7 +65,9 @@
"state": {
"standby": "Standby",
"to_full": "Manual charge mode",
"zero": "Zero mode"
"zero": "Zero mode",
"zero_charge_only": "Zero mode (charge only)",
"zero_discharge_only": "Zero mode (discharge only)"
}
}
},
@@ -172,7 +174,7 @@
"message": "The local API is unauthorized. Restore API access by following the instructions in the repair issue."
},
"communication_error": {
"message": "An error occurred while communicating with your HomeWizard Energy device"
"message": "An error occurred while communicating with your HomeWizard device"
}
},
"issues": {

View File

@@ -1,4 +1,4 @@
"""Creates HomeWizard Energy switch entities."""
"""Creates HomeWizard switch entities."""
from __future__ import annotations

View File

@@ -4,6 +4,7 @@
"codeowners": ["@dennisschroer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huisbaasje",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["energyflip"],
"requirements": ["energyflip-client==0.2.2"]

View File

@@ -48,5 +48,19 @@
"turn_on": {
"service": "mdi:air-humidifier"
}
},
"triggers": {
"started_drying": {
"trigger": "mdi:arrow-down-bold"
},
"started_humidifying": {
"trigger": "mdi:arrow-up-bold"
},
"turned_off": {
"trigger": "mdi:air-humidifier-off"
},
"turned_on": {
"trigger": "mdi:air-humidifier-on"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"set_humidity": "Set humidity for {entity_name}",
@@ -86,6 +90,15 @@
"message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"set_humidity": {
"description": "Sets the target humidity.",
@@ -120,5 +133,47 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Humidifier"
"title": "Humidifier",
"triggers": {
"started_drying": {
"description": "Triggers after one or more humidifiers start drying.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
"name": "Humidifier started drying"
},
"started_humidifying": {
"description": "Triggers after one or more humidifiers start humidifying.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
"name": "Humidifier started humidifying"
},
"turned_off": {
"description": "Triggers after one or more humidifiers turn off.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
"name": "Humidifier turned off"
},
"turned_on": {
"description": "Triggers after one or more humidifiers turn on.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
"name": "Humidifier turned on"
}
}
}

View File

@@ -0,0 +1,27 @@
"""Provides triggers for humidifiers."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
)
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
),
"started_humidifying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for humidifiers."""
return TRIGGERS

View File

@@ -1,18 +1,20 @@
.trigger_common: &trigger_common
target:
entity:
domain: schedule
domain: humidifier
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
started_drying: *trigger_common
started_humidifying: *trigger_common
turned_on: *trigger_common
turned_off: *trigger_common

View File

@@ -4,6 +4,7 @@
"codeowners": ["@dermotduffy"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hyperion",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["hyperion"],
"requirements": ["hyperion-py==0.7.6"],

View File

@@ -16,7 +16,7 @@ from pyicloud.exceptions import (
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.storage import Store
@@ -155,8 +155,8 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold,
}
# If this is a password update attempt, update the entry instead of creating one
if step_id == "user":
# If this is a password update attempt, don't try and creating one
if self.source == SOURCE_USER:
return self.async_create_entry(title=self._username, data=data)
entry = await self.async_set_unique_id(self.unique_id)

View File

@@ -8,6 +8,7 @@
{ "registered_devices": true }
],
"documentation": "https://www.home-assistant.io/integrations/incomfort",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["incomfortclient"],
"quality_scale": "platinum",

View File

@@ -4,6 +4,7 @@
"codeowners": ["@dgomes"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/kmtronic",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pykmtronic"],
"requirements": ["pykmtronic==0.3.0"]

View File

@@ -94,6 +94,8 @@ SERVICE_KNX_EVENT_REGISTER: Final = "event_register"
SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register"
SERVICE_KNX_READ: Final = "read"
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue"
class KNXConfigEntryData(TypedDict, total=False):
"""Config entry for the KNX integration."""
@@ -163,8 +165,10 @@ SUPPORTED_PLATFORMS_UI: Final = {
Platform.CLIMATE,
Platform.COVER,
Platform.DATE,
Platform.FAN,
Platform.DATETIME,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
}
@@ -217,3 +221,9 @@ class ClimateConf:
FAN_MAX_STEP: Final = "fan_max_step"
FAN_SPEED_MODE: Final = "fan_speed_mode"
FAN_ZERO_MODE: Final = "fan_zero_mode"
class FanConf:
"""Common config keys for fan."""
MAX_STEP: Final = "max_step"

View File

@@ -0,0 +1,146 @@
"""KNX DPT serializer."""
from collections.abc import Mapping
from functools import cache
from typing import Literal, TypedDict
from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric
from xknx.dpt.dpt_16 import DPTString
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
HaDptClass = Literal["numeric", "enum", "complex", "string"]
class DPTInfo(TypedDict):
"""DPT information."""
dpt_class: HaDptClass
main: int
sub: int | None
name: str | None
unit: str | None
sensor_device_class: SensorDeviceClass | None
sensor_state_class: SensorStateClass | None
@cache
def get_supported_dpts() -> Mapping[str, DPTInfo]:
"""Return a mapping of supported DPTs with HA specific attributes."""
dpts = {}
for dpt_class in DPTBase.dpt_class_tree():
dpt_number_str = dpt_class.dpt_number_str()
ha_dpt_class = _ha_dpt_class(dpt_class)
dpts[dpt_number_str] = DPTInfo(
dpt_class=ha_dpt_class,
main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests
sub=dpt_class.dpt_sub_number,
name=dpt_class.value_type,
unit=dpt_class.unit,
sensor_device_class=_sensor_device_classes.get(dpt_number_str),
sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str),
)
return dpts
def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass:
"""Return the DPT class identifier string."""
if issubclass(dpt_cls, DPTNumeric):
return "numeric"
if issubclass(dpt_cls, DPTEnum):
return "enum"
if issubclass(dpt_cls, DPTComplex):
return "complex"
if issubclass(dpt_cls, DPTString):
return "string"
raise ValueError("Unsupported DPT class")
_sensor_device_classes: Mapping[str, SensorDeviceClass] = {
"7.011": SensorDeviceClass.DISTANCE,
"7.012": SensorDeviceClass.CURRENT,
"7.013": SensorDeviceClass.ILLUMINANCE,
"8.012": SensorDeviceClass.DISTANCE,
"9.001": SensorDeviceClass.TEMPERATURE,
"9.002": SensorDeviceClass.TEMPERATURE_DELTA,
"9.004": SensorDeviceClass.ILLUMINANCE,
"9.005": SensorDeviceClass.WIND_SPEED,
"9.006": SensorDeviceClass.PRESSURE,
"9.007": SensorDeviceClass.HUMIDITY,
"9.020": SensorDeviceClass.VOLTAGE,
"9.021": SensorDeviceClass.CURRENT,
"9.024": SensorDeviceClass.POWER,
"9.025": SensorDeviceClass.VOLUME_FLOW_RATE,
"9.027": SensorDeviceClass.TEMPERATURE,
"9.028": SensorDeviceClass.WIND_SPEED,
"9.029": SensorDeviceClass.ABSOLUTE_HUMIDITY,
"12.1200": SensorDeviceClass.VOLUME,
"12.1201": SensorDeviceClass.VOLUME,
"13.002": SensorDeviceClass.VOLUME_FLOW_RATE,
"13.010": SensorDeviceClass.ENERGY,
"13.012": SensorDeviceClass.REACTIVE_ENERGY,
"13.013": SensorDeviceClass.ENERGY,
"13.015": SensorDeviceClass.REACTIVE_ENERGY,
"13.016": SensorDeviceClass.ENERGY,
"13.1200": SensorDeviceClass.VOLUME,
"13.1201": SensorDeviceClass.VOLUME,
"14.010": SensorDeviceClass.AREA,
"14.019": SensorDeviceClass.CURRENT,
"14.027": SensorDeviceClass.VOLTAGE,
"14.028": SensorDeviceClass.VOLTAGE,
"14.030": SensorDeviceClass.VOLTAGE,
"14.031": SensorDeviceClass.ENERGY,
"14.033": SensorDeviceClass.FREQUENCY,
"14.037": SensorDeviceClass.ENERGY_STORAGE,
"14.039": SensorDeviceClass.DISTANCE,
"14.051": SensorDeviceClass.WEIGHT,
"14.056": SensorDeviceClass.POWER,
"14.057": SensorDeviceClass.POWER_FACTOR,
"14.058": SensorDeviceClass.PRESSURE,
"14.065": SensorDeviceClass.SPEED,
"14.068": SensorDeviceClass.TEMPERATURE,
"14.069": SensorDeviceClass.TEMPERATURE,
"14.070": SensorDeviceClass.TEMPERATURE_DELTA,
"14.076": SensorDeviceClass.VOLUME,
"14.077": SensorDeviceClass.VOLUME_FLOW_RATE,
"14.080": SensorDeviceClass.APPARENT_POWER,
"14.1200": SensorDeviceClass.VOLUME_FLOW_RATE,
"14.1201": SensorDeviceClass.VOLUME_FLOW_RATE,
"29.010": SensorDeviceClass.ENERGY,
"29.012": SensorDeviceClass.REACTIVE_ENERGY,
}
_sensor_state_class_overrides: Mapping[str, SensorStateClass | None] = {
"5.003": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngle
"5.006": None, # DPTTariff
"7.010": None, # DPTPropDataType
"8.011": SensorStateClass.MEASUREMENT_ANGLE, # DPTRotationAngle
"9.026": SensorStateClass.TOTAL_INCREASING, # DPTRainAmount
"12.1200": SensorStateClass.TOTAL, # DPTVolumeLiquidLitre
"12.1201": SensorStateClass.TOTAL, # DPTVolumeM3
"13.010": SensorStateClass.TOTAL, # DPTActiveEnergy
"13.011": SensorStateClass.TOTAL, # DPTApparantEnergy
"13.012": SensorStateClass.TOTAL, # DPTReactiveEnergy
"14.007": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngleDeg
"14.037": SensorStateClass.TOTAL, # DPTHeatQuantity
"14.051": SensorStateClass.TOTAL, # DPTMass
"14.055": SensorStateClass.MEASUREMENT_ANGLE, # DPTPhaseAngleDeg
"14.031": SensorStateClass.TOTAL_INCREASING, # DPTEnergy
"17.001": None, # DPTSceneNumber
"29.010": SensorStateClass.TOTAL, # DPTActiveEnergy8Byte
"29.011": SensorStateClass.TOTAL, # DPTApparantEnergy8Byte
"29.012": SensorStateClass.TOTAL, # DPTReactiveEnergy8Byte
}
def _get_sensor_state_class(
ha_dpt_class: HaDptClass, dpt_number_str: str
) -> SensorStateClass | None:
"""Return the SensorStateClass for a given DPT."""
if ha_dpt_class != "numeric":
return None
return _sensor_state_class_overrides.get(
dpt_number_str,
SensorStateClass.MEASUREMENT,
)

View File

@@ -77,6 +77,11 @@ class _KnxEntityBase(Entity):
"""Store register state change callback and start device object."""
self._device.register_device_updated_cb(self.after_update_callback)
self._device.xknx.devices.async_add(self._device)
if uid := self.unique_id:
self._knx_module.add_to_group_address_entities(
group_addresses=self._device.group_addresses(),
identifier=(self.platform_data.domain, uid),
)
# super call needed to have methods of multi-inherited classes called
# eg. for restoring state (like _KNXSwitch)
await super().async_added_to_hass()
@@ -85,6 +90,11 @@ class _KnxEntityBase(Entity):
"""Disconnect device object when removed."""
self._device.unregister_device_updated_cb(self.after_update_callback)
self._device.xknx.devices.async_remove(self._device)
if uid := self.unique_id:
self._knx_module.remove_from_group_address_entities(
group_addresses=self._device.group_addresses(),
identifier=(self.platform_data.domain, uid),
)
class KnxYamlEntity(_KnxEntityBase):

View File

@@ -3,15 +3,19 @@
from __future__ import annotations
import math
from typing import Any, Final
from typing import Any
from propcache.api import cached_property
from xknx.devices import Fan as XknxFan
from homeassistant import config_entries
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.percentage import (
percentage_to_ranged_value,
@@ -19,12 +23,19 @@ from homeassistant.util.percentage import (
)
from homeassistant.util.scaling import int_states_in_range
from .const import KNX_ADDRESS, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, FanConf
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .knx_module import KNXModule
from .schema import FanSchema
DEFAULT_PERCENTAGE: Final = 50
from .storage.const import (
CONF_ENTITY,
CONF_GA_OSCILLATION,
CONF_GA_SPEED,
CONF_GA_STEP,
CONF_GA_SWITCH,
CONF_SPEED,
)
from .storage.util import ConfigExtractor
async def async_setup_entry(
@@ -34,61 +45,55 @@ async def async_setup_entry(
) -> None:
"""Set up fan(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
config: list[ConfigType] = knx_module.config_yaml[Platform.FAN]
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.FAN,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiFan,
),
)
async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config)
entities: list[_KnxFan] = []
if yaml_platform_config := knx_module.config_yaml.get(Platform.FAN):
entities.extend(
KnxYamlFan(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.FAN):
entities.extend(
KnxUiFan(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
if entities:
async_add_entities(entities)
class KNXFan(KnxYamlEntity, FanEntity):
class _KnxFan(FanEntity):
"""Representation of a KNX fan."""
_device: XknxFan
_step_range: tuple[int, int] | None
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
max_step = config.get(FanSchema.CONF_MAX_STEP)
super().__init__(
knx_module=knx_module,
device=XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(
FanSchema.CONF_OSCILLATION_ADDRESS
),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
max_step=max_step,
),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.speed.group_address)
def _get_knx_speed(self, percentage: int) -> int:
"""Convert percentage to KNX speed value."""
if self._step_range is not None:
return math.ceil(percentage_to_ranged_value(self._step_range, percentage))
return percentage
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
if self._step_range:
step = math.ceil(percentage_to_ranged_value(self._step_range, percentage))
await self._device.set_speed(step)
else:
await self._device.set_speed(percentage)
await self._device.set_speed(self._get_knx_speed(percentage))
@property
@cached_property
def supported_features(self) -> FanEntityFeature:
"""Flag supported features."""
flags = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
flags = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
if self._device.speed.initialized:
flags |= FanEntityFeature.SET_SPEED
if self._device.supports_oscillation:
flags |= FanEntityFeature.OSCILLATE
return flags
@property
@@ -103,13 +108,18 @@ class KNXFan(KnxYamlEntity, FanEntity):
)
return self._device.current_speed
@property
@cached_property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
if self._step_range is None:
return super().speed_count
return int_states_in_range(self._step_range)
@property
def is_on(self) -> bool:
"""Return the current fan state of the device."""
return self._device.is_on
async def async_turn_on(
self,
percentage: int | None = None,
@@ -117,14 +127,12 @@ class KNXFan(KnxYamlEntity, FanEntity):
**kwargs: Any,
) -> None:
"""Turn on the fan."""
if percentage is None:
await self.async_set_percentage(DEFAULT_PERCENTAGE)
else:
await self.async_set_percentage(percentage)
speed = self._get_knx_speed(percentage) if percentage is not None else None
await self._device.turn_on(speed)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self.async_set_percentage(0)
await self._device.turn_off()
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
@@ -134,3 +142,83 @@ class KNXFan(KnxYamlEntity, FanEntity):
def oscillating(self) -> bool | None:
"""Return whether or not the fan is currently oscillating."""
return self._device.current_oscillation
class KnxYamlFan(_KnxFan, KnxYamlEntity):
"""Representation of a KNX fan configured from YAML."""
_device: XknxFan
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
max_step = config.get(FanConf.MAX_STEP)
super().__init__(
knx_module=knx_module,
device=XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(
FanSchema.CONF_OSCILLATION_ADDRESS
),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(
FanSchema.CONF_SWITCH_STATE_ADDRESS
),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.speed.group_address)
class KnxUiFan(_KnxFan, KnxUiEntity):
"""Representation of a KNX fan configured from UI."""
_device: XknxFan
def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
) -> None:
"""Initialize of KNX fan."""
knx_conf = ConfigExtractor(config[DOMAIN])
# max_step is required for step mode, thus can be used to differentiate modes
max_step: int | None = knx_conf.get(CONF_SPEED, FanConf.MAX_STEP)
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
if max_step:
# step control
speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_STEP)
speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_STEP)
else:
# percentage control
speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_SPEED)
speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_SPEED)
self._device = XknxFan(
xknx=knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address_speed=speed_write,
group_address_speed_state=speed_state,
group_address_oscillation=knx_conf.get_write(CONF_GA_OSCILLATION),
group_address_oscillation_state=knx_conf.get_state_and_passive(
CONF_GA_OSCILLATION
),
group_address_switch=knx_conf.get_write(CONF_GA_SWITCH),
group_address_switch_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH),
max_step=max_step,
sync_state=knx_conf.get(CONF_SYNC_STATE),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None

View File

@@ -56,6 +56,7 @@ from .const import (
from .device import KNXInterfaceDevice
from .expose import KNXExposeSensor, KNXExposeTime
from .project import KNXProject
from .repairs import data_secure_group_key_issue_dispatcher
from .storage.config_store import KNXConfigStore
from .telegrams import Telegrams
@@ -107,8 +108,12 @@ class KNXModule:
self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {}
self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {}
self.group_address_entities: dict[
DeviceGroupAddress, set[tuple[str, str]] # {(platform, unique_id),}
] = {}
self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback()
self.entry.async_on_unload(data_secure_group_key_issue_dispatcher(self))
self.entry.async_on_unload(
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
)
@@ -225,6 +230,29 @@ class KNXModule:
threaded=True,
)
def add_to_group_address_entities(
self,
group_addresses: set[DeviceGroupAddress],
identifier: tuple[str, str], # (platform, unique_id)
) -> None:
"""Register entity in group_address_entities map."""
for ga in group_addresses:
if ga not in self.group_address_entities:
self.group_address_entities[ga] = set()
self.group_address_entities[ga].add(identifier)
def remove_from_group_address_entities(
self,
group_addresses: set[DeviceGroupAddress],
identifier: tuple[str, str],
) -> None:
"""Unregister entity from group_address_entities map."""
for ga in group_addresses:
if ga in self.group_address_entities:
self.group_address_entities[ga].discard(identifier)
if not self.group_address_entities[ga]:
del self.group_address_entities[ga]
def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
"""Call invoked after a KNX connection state change was received."""
self.connected = state == XknxConnectionState.CONNECTED

View File

@@ -9,11 +9,11 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["xknx", "xknxproject"],
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": [
"xknx==3.12.0",
"xknx==3.13.0",
"xknxproject==3.8.2",
"knx-frontend==2025.10.31.195356"
"knx-frontend==2025.12.19.150946"
],
"single_config_entry": true
}

View File

@@ -105,7 +105,7 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: todo
repair-issues: done
stale-devices:
status: exempt
comment: |

View File

@@ -0,0 +1,175 @@
"""Repairs for KNX integration."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol
from xknx.exceptions.exception import InvalidSecureConfiguration
from xknx.telegram import GroupAddress, IndividualAddress, Telegram
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir, selector
from homeassistant.helpers.dispatcher import async_dispatcher_connect
if TYPE_CHECKING:
from .knx_module import KNXModule
from .const import (
CONF_KNX_KNXKEY_PASSWORD,
DOMAIN,
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
KNXConfigEntryData,
)
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .telegrams import SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, TelegramDict
CONF_KEYRING_FILE: Final = "knxkeys_file"
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if issue_id == REPAIR_ISSUE_DATA_SECURE_GROUP_KEY:
return DataSecureGroupIssueRepairFlow()
# If KNX adds confirm-only repairs in the future, this should be changed
# to return a ConfirmRepairFlow instead of raising a ValueError
raise ValueError(f"unknown repair {issue_id}")
######################
# DataSecure key issue
######################
@callback
def data_secure_group_key_issue_dispatcher(knx_module: KNXModule) -> Callable[[], None]:
"""Watcher for DataSecure group key issues."""
return async_dispatcher_connect(
knx_module.hass,
signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
target=partial(_data_secure_group_key_issue_handler, knx_module),
)
@callback
def _data_secure_group_key_issue_handler(
knx_module: KNXModule, telegram: Telegram, telegram_dict: TelegramDict
) -> None:
"""Handle DataSecure group key issue telegrams."""
if telegram.destination_address not in knx_module.group_address_entities:
# Only report issues for configured group addresses
return
issue_registry = ir.async_get(knx_module.hass)
new_ga = str(telegram.destination_address)
new_ia = str(telegram.source_address)
new_data = {new_ga: new_ia}
if existing_issue := issue_registry.async_get_issue(
DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY
):
assert isinstance(existing_issue.data, dict)
existing_data: dict[str, str] = existing_issue.data # type: ignore[assignment]
if new_ga in existing_data:
current_ias = existing_data[new_ga].split(", ")
if new_ia in current_ias:
return
current_ias = sorted([*current_ias, new_ia], key=IndividualAddress)
new_data[new_ga] = ", ".join(current_ias)
new_data_unsorted = existing_data | new_data
new_data = {
key: new_data_unsorted[key]
for key in sorted(new_data_unsorted, key=GroupAddress)
}
issue_registry.async_get_or_create(
DOMAIN,
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
data=new_data, # type: ignore[arg-type]
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key=REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
translation_placeholders={
"addresses": "\n".join(
f"`{ga}` from {ias}" for ga, ias in new_data.items()
),
"interface": str(knx_module.xknx.current_address),
},
)
class DataSecureGroupIssueRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow for outdated DataSecure keys."""
@callback
def _async_get_placeholders(self) -> dict[str, str]:
issue_registry = ir.async_get(self.hass)
issue = issue_registry.async_get_issue(self.handler, self.issue_id)
assert issue is not None
return issue.translation_placeholders or {}
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_secure_knxkeys()
async def async_step_secure_knxkeys(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
"""Manage upload of new KNX Keyring file."""
errors: dict[str, str] = {}
if user_input is not None:
password = user_input[CONF_KNX_KNXKEY_PASSWORD]
keyring = None
try:
keyring = await save_uploaded_knxkeys_file(
self.hass,
uploaded_file_id=user_input[CONF_KEYRING_FILE],
password=password,
)
except InvalidSecureConfiguration:
errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature"
if not errors and keyring:
new_entry_data = KNXConfigEntryData(
knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}",
knxkeys_password=password,
)
return self.finish_flow(new_entry_data)
fields = {
vol.Required(CONF_KEYRING_FILE): selector.FileSelector(
config=selector.FileSelectorConfig(accept=".knxkeys")
),
vol.Required(CONF_KNX_KNXKEY_PASSWORD): selector.TextSelector(),
}
return self.async_show_form(
step_id="secure_knxkeys",
data_schema=vol.Schema(fields),
description_placeholders=self._async_get_placeholders(),
errors=errors,
)
@callback
def finish_flow(
self, new_entry_data: KNXConfigEntryData
) -> data_entry_flow.FlowResult:
"""Finish the repair flow. Reload the config entry."""
knx_config_entries = self.hass.config_entries.async_entries(DOMAIN)
if knx_config_entries:
config_entry = knx_config_entries[0] # single_config_entry
new_data = {**config_entry.data, **new_entry_data}
self.hass.config_entries.async_update_entry(config_entry, data=new_data)
self.hass.config_entries.async_schedule_reload(config_entry.entry_id)
return self.async_create_entry(data={})

View File

@@ -59,6 +59,7 @@ from .const import (
ClimateConf,
ColorTempModes,
CoverConf,
FanConf,
FanZeroMode,
)
from .validation import (
@@ -575,20 +576,40 @@ class FanSchema(KNXPlatformSchema):
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
CONF_OSCILLATION_ADDRESS = "oscillation_address"
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
CONF_MAX_STEP = "max_step"
CONF_SWITCH_ADDRESS = "switch_address"
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
DEFAULT_NAME = "KNX Fan"
ENTITY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
ENTITY_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
}
),
vol.Any(
vol.Schema(
{vol.Required(KNX_ADDRESS): object},
extra=vol.ALLOW_EXTRA,
),
vol.Schema(
{vol.Required(CONF_SWITCH_ADDRESS): object},
extra=vol.ALLOW_EXTRA,
),
msg=(
f"At least one of '{KNX_ADDRESS}' or"
f" '{CONF_SWITCH_ADDRESS}' is required."
),
),
)

View File

@@ -6,8 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from functools import partial
from typing import Any
from xknx import XKNX
from xknx.core.connection_state import XknxConnectionState, XknxConnectionType
from xknx.devices import Device as XknxDevice, Sensor as XknxSensor
@@ -25,20 +25,32 @@ from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_NAME,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
EntityCategory,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType, StateType
from homeassistant.util.enum import try_parse_enum
from .const import ATTR_SOURCE, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .const import ATTR_SOURCE, CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY
from .dpt import get_supported_dpts
from .entity import (
KnxUiEntity,
KnxUiEntityPlatformController,
KnxYamlEntity,
_KnxEntityBase,
)
from .knx_module import KNXModule
from .schema import SensorSchema
from .storage.const import CONF_ALWAYS_CALLBACK, CONF_ENTITY, CONF_GA_SENSOR
from .storage.util import ConfigExtractor
SCAN_INTERVAL = timedelta(seconds=10)
@@ -122,58 +134,41 @@ async def async_setup_entry(
config_entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor(s) for KNX platform."""
"""Set up entities for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.SENSOR,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiSensor,
),
)
entities: list[SensorEntity] = []
entities.extend(
KNXSystemSensor(knx_module, description)
for description in SYSTEM_ENTITY_DESCRIPTIONS
)
config: list[ConfigType] | None = knx_module.config_yaml.get(Platform.SENSOR)
if config:
if yaml_platform_config := knx_module.config_yaml.get(Platform.SENSOR):
entities.extend(
KNXSensor(knx_module, entity_config) for entity_config in config
KnxYamlSensor(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.SENSOR):
entities.extend(
KnxUiSensor(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
async_add_entities(entities)
def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
"""Return a KNX sensor to be used within XKNX."""
return XknxSensor(
xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[SensorSchema.CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
)
class KNXSensor(KnxYamlEntity, RestoreSensor):
class _KnxSensor(RestoreSensor, _KnxEntityBase):
"""Representation of a KNX sensor."""
_device: XknxSensor
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
super().__init__(
knx_module=knx_module,
device=_create_sensor(knx_module.xknx, config),
)
if device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = device_class
else:
self._attr_device_class = try_parse_enum(
SensorDeviceClass, self._device.ha_device_class()
)
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
self._attr_state_class = config.get(CONF_STATE_CLASS)
self._attr_extra_state_attributes = {}
async def async_added_to_hass(self) -> None:
"""Restore last state."""
if (
@@ -198,6 +193,89 @@ class KNXSensor(KnxYamlEntity, RestoreSensor):
super().after_update_callback(device)
class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
"""Representation of a KNX sensor configured from YAML."""
_device: XknxSensor
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
super().__init__(
knx_module=knx_module,
device=XknxSensor(
knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
),
)
if device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = device_class
else:
self._attr_device_class = try_parse_enum(
SensorDeviceClass, self._device.ha_device_class()
)
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
self._attr_state_class = config.get(CONF_STATE_CLASS)
self._attr_extra_state_attributes = {}
class KnxUiSensor(_KnxSensor, KnxUiEntity):
"""Representation of a KNX sensor configured from the UI."""
_device: XknxSensor
def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
) -> None:
"""Initialize KNX sensor."""
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
dpt_string = knx_conf.get_dpt(CONF_GA_SENSOR)
assert dpt_string is not None # required for sensor
dpt_info = get_supported_dpts()[dpt_string]
self._device = XknxSensor(
knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR),
sync_state=knx_conf.get(CONF_SYNC_STATE),
always_callback=True,
value_type=dpt_string,
)
if device_class_override := knx_conf.get(CONF_DEVICE_CLASS):
self._attr_device_class = try_parse_enum(
SensorDeviceClass, device_class_override
)
else:
self._attr_device_class = dpt_info["sensor_device_class"]
if state_class_override := knx_conf.get(CONF_STATE_CLASS):
self._attr_state_class = try_parse_enum(
SensorStateClass, state_class_override
)
else:
self._attr_state_class = dpt_info["sensor_state_class"]
self._attr_native_unit_of_measurement = (
knx_conf.get(CONF_UNIT_OF_MEASUREMENT) or dpt_info["unit"]
)
self._attr_force_update = knx_conf.get(CONF_ALWAYS_CALLBACK, default=False)
self._attr_extra_state_attributes = {}
class KNXSystemSensor(SensorEntity):
"""Representation of a KNX system sensor."""

View File

@@ -17,6 +17,8 @@ CONF_GA_DATE: Final = "ga_date"
CONF_GA_DATETIME: Final = "ga_datetime"
CONF_GA_TIME: Final = "ga_time"
CONF_GA_STEP: Final = "ga_step"
# Climate
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
@@ -42,11 +44,15 @@ CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal"
# Cover
CONF_GA_UP_DOWN: Final = "ga_up_down"
CONF_GA_STOP: Final = "ga_stop"
CONF_GA_STEP: Final = "ga_step"
CONF_GA_POSITION_SET: Final = "ga_position_set"
CONF_GA_POSITION_STATE: Final = "ga_position_state"
CONF_GA_ANGLE: Final = "ga_angle"
# Fan
CONF_SPEED: Final = "speed"
CONF_GA_SPEED: Final = "ga_speed"
CONF_GA_OSCILLATION: Final = "ga_oscillation"
# Light
CONF_COLOR_TEMP_MIN: Final = "color_temp_min"
CONF_COLOR_TEMP_MAX: Final = "color_temp_max"
@@ -65,3 +71,6 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness"
CONF_GA_WHITE_SWITCH: Final = "ga_white_switch"
CONF_GA_HUE: Final = "ga_hue"
CONF_GA_SATURATION: Final = "ga_saturation"
# Sensor
CONF_ALWAYS_CALLBACK: Final = "always_callback"

View File

@@ -5,11 +5,21 @@ from enum import StrEnum, unique
import voluptuous as vol
from homeassistant.components.climate import HVACMode
from homeassistant.components.sensor import (
CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS,
DEVICE_CLASS_STATE_CLASSES,
DEVICE_CLASS_UNITS,
STATE_CLASS_UNITS,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_ENTITY_CATEGORY,
CONF_ENTITY_ID,
CONF_NAME,
CONF_PLATFORM,
CONF_UNIT_OF_MEASUREMENT,
Platform,
)
from homeassistant.helpers import config_validation as cv, selector
@@ -28,14 +38,18 @@ from ..const import (
ClimateConf,
ColorTempModes,
CoverConf,
FanConf,
FanZeroMode,
)
from ..dpt import get_supported_dpts
from .const import (
CONF_ALWAYS_CALLBACK,
CONF_COLOR,
CONF_COLOR_TEMP_MAX,
CONF_COLOR_TEMP_MIN,
CONF_DATA,
CONF_DEVICE_INFO,
CONF_DPT,
CONF_ENTITY,
CONF_GA_ACTIVE,
CONF_GA_ANGLE,
@@ -62,6 +76,7 @@ from .const import (
CONF_GA_OP_MODE_PROTECTION,
CONF_GA_OP_MODE_STANDBY,
CONF_GA_OPERATION_MODE,
CONF_GA_OSCILLATION,
CONF_GA_POSITION_SET,
CONF_GA_POSITION_STATE,
CONF_GA_RED_BRIGHTNESS,
@@ -69,6 +84,7 @@ from .const import (
CONF_GA_SATURATION,
CONF_GA_SENSOR,
CONF_GA_SETPOINT_SHIFT,
CONF_GA_SPEED,
CONF_GA_STEP,
CONF_GA_STOP,
CONF_GA_SWITCH,
@@ -80,6 +96,7 @@ from .const import (
CONF_GA_WHITE_BRIGHTNESS,
CONF_GA_WHITE_SWITCH,
CONF_IGNORE_AUTO_MODE,
CONF_SPEED,
CONF_TARGET_TEMPERATURE,
)
from .knx_selector import (
@@ -220,6 +237,60 @@ DATETIME_KNX_SCHEMA = vol.Schema(
}
)
FAN_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
vol.Optional(CONF_GA_SWITCH): GASelector(
write_required=True, valid_dpt="1"
),
vol.Optional(CONF_SPEED): GroupSelect(
GroupSelectOption(
translation_key="percentage_mode",
schema={
vol.Required(CONF_GA_SPEED): GASelector(
write_required=True, valid_dpt="5.001"
),
},
),
GroupSelectOption(
translation_key="step_mode",
schema={
vol.Required(CONF_GA_STEP): GASelector(
write_required=True, valid_dpt="5.010"
),
vol.Required(
FanConf.MAX_STEP, default=3
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=100,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
},
),
collapsible=False,
),
vol.Optional(CONF_GA_OSCILLATION): GASelector(
write_required=True, valid_dpt="1"
),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
}
),
vol.Any(
vol.Schema(
{vol.Required(CONF_GA_SWITCH): object},
extra=vol.ALLOW_EXTRA,
),
vol.Schema(
{vol.Required(CONF_SPEED): object},
extra=vol.ALLOW_EXTRA,
),
msg=("At least one of 'Switch' or 'Fan speed' is required."),
),
)
@unique
class LightColorMode(StrEnum):
@@ -507,13 +578,123 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
},
)
def _validate_sensor_attributes(config: dict) -> dict:
"""Validate that state_class is compatible with device_class and unit_of_measurement."""
dpt = config[CONF_GA_SENSOR][CONF_DPT]
dpt_metadata = get_supported_dpts()[dpt]
state_class = config.get(
CONF_SENSOR_STATE_CLASS,
dpt_metadata["sensor_state_class"],
)
device_class = config.get(
CONF_DEVICE_CLASS,
dpt_metadata["sensor_device_class"],
)
unit_of_measurement = config.get(
CONF_UNIT_OF_MEASUREMENT,
dpt_metadata["unit"],
)
if (
state_class
and device_class
and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None
and state_class not in state_classes
):
raise vol.Invalid(
f"State class '{state_class}' is not valid for device class '{device_class}'. "
f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}",
path=[CONF_SENSOR_STATE_CLASS],
)
if (
device_class
and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None
and unit_of_measurement not in d_c_units
):
raise vol.Invalid(
f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. "
f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}",
path=(
[CONF_DEVICE_CLASS]
if CONF_DEVICE_CLASS in config
else [CONF_UNIT_OF_MEASUREMENT]
),
)
if (
state_class
and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None
and unit_of_measurement not in s_c_units
):
raise vol.Invalid(
f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. "
f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}",
path=(
[CONF_SENSOR_STATE_CLASS]
if CONF_SENSOR_STATE_CLASS in config
else [CONF_UNIT_OF_MEASUREMENT]
),
)
return config
SENSOR_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
vol.Required(CONF_GA_SENSOR): GASelector(
write=False, state_required=True, dpt=["numeric", "string"]
),
"section_advanced_options": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector(
selector.SelectSelectorConfig(
options=sorted(
{
str(unit)
for units in DEVICE_CLASS_UNITS.values()
for unit in units
if unit is not None
}
),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="component.knx.selector.sensor_unit_of_measurement",
custom_value=True,
),
),
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
cls.value
for cls in SensorDeviceClass
if cls != SensorDeviceClass.ENUM
],
translation_key="component.knx.selector.sensor_device_class",
sort=True,
)
),
vol.Optional(CONF_SENSOR_STATE_CLASS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(SensorStateClass),
translation_key="component.knx.selector.sensor_state_class",
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_ALWAYS_CALLBACK): selector.BooleanSelector(),
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(
allow_false=True
),
},
),
_validate_sensor_attributes,
)
KNX_SCHEMA_FOR_PLATFORM = {
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
Platform.COVER: COVER_KNX_SCHEMA,
Platform.DATE: DATE_KNX_SCHEMA,
Platform.DATETIME: DATETIME_KNX_SCHEMA,
Platform.FAN: FAN_KNX_SCHEMA,
Platform.LIGHT: LIGHT_KNX_SCHEMA,
Platform.SENSOR: SENSOR_KNX_SCHEMA,
Platform.SWITCH: SWITCH_KNX_SCHEMA,
Platform.TIME: TIME_KNX_SCHEMA,
}

View File

@@ -10,9 +10,10 @@ from xknx.secure.keyring import Keyring, sync_load_keyring
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.storage import STORAGE_DIR
from ..const import DOMAIN
from ..const import DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY
_LOGGER = logging.getLogger(__name__)
@@ -45,4 +46,11 @@ async def save_uploaded_knxkeys_file(
shutil.move(file_path, dest_file)
return keyring
return await hass.async_add_executor_job(_process_upload)
keyring = await hass.async_add_executor_job(_process_upload)
# If there is an existing DataSecure group key issue, remove it.
# GAs might not be DataSecure anymore after uploading a valid keyring,
# if they are, we raise the issue again when receiving a telegram.
ir.async_delete_issue(hass, DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY)
return keyring

View File

@@ -6,6 +6,7 @@ from typing import Any
import voluptuous as vol
from ..dpt import HaDptClass, get_supported_dpts
from ..validation import ga_validator, maybe_ga_validator, sync_state_validator
from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE
from .util import dpt_string_to_dict
@@ -162,7 +163,7 @@ class GASelector(KNXSelectorBase):
passive: bool = True,
write_required: bool = False,
state_required: bool = False,
dpt: type[Enum] | None = None,
dpt: type[Enum] | list[HaDptClass] | None = None,
valid_dpt: str | Iterable[str] | None = None,
) -> None:
"""Initialize the group address selector."""
@@ -186,14 +187,17 @@ class GASelector(KNXSelectorBase):
"passive": self.passive,
}
if self.dpt is not None:
options["dptSelect"] = [
{
"value": item.value,
"translation_key": item.value.replace(".", "_"),
"dpt": dpt_string_to_dict(item.value), # used for filtering GAs
}
for item in self.dpt
]
if isinstance(self.dpt, list):
options["dptClasses"] = self.dpt
else:
options["dptSelect"] = [
{
"value": item.value,
"translation_key": item.value.replace(".", "_"),
"dpt": dpt_string_to_dict(item.value), # used for filtering GAs
}
for item in self.dpt
]
if self.valid_dpt is not None:
options["validDPTs"] = [dpt_string_to_dict(dpt) for dpt in self.valid_dpt]
@@ -254,7 +258,12 @@ class GASelector(KNXSelectorBase):
def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None:
"""Add DPT validator to the schema."""
if self.dpt is not None:
schema[vol.Required(CONF_DPT)] = vol.In({item.value for item in self.dpt})
if isinstance(self.dpt, list):
schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts())
else:
schema[vol.Required(CONF_DPT)] = vol.In(
{item.value for item in self.dpt}
)
else:
schema[vol.Remove(CONF_DPT)] = object

View File

@@ -154,6 +154,183 @@
}
},
"config_panel": {
"dpt": {
"options": {
"5": "Generic 1-byte unsigned integer",
"5_001": "Percent (0 … 100)",
"5_003": "Angle",
"5_004": "Percent (0 … 255)",
"5_005": "Decimal factor",
"5_006": "Tariff",
"5_010": "Counter (0 … 255)",
"6": "Generic 1-byte signed integer",
"6_001": "Percent (-128 … 127)",
"6_010": "Counter (-128 … 127)",
"7": "Generic 2-byte unsigned integer",
"7_001": "Counter (0 … 65535)",
"7_002": "Time period",
"7_003": "Time period (10 ms)",
"7_004": "Time period (100 ms)",
"7_005": "[%key:component::knx::config_panel::dpt::options::7_002%]",
"7_006": "[%key:component::knx::config_panel::dpt::options::7_002%]",
"7_007": "[%key:component::knx::config_panel::dpt::options::7_002%]",
"7_010": "Interface Object Property",
"7_011": "Length",
"7_012": "Electrical current",
"7_013": "Brightness",
"7_600": "Color temperature",
"8": "Generic 2-byte signed integer",
"8_001": "Counter (-32 768 … 32 767)",
"8_002": "Delta time",
"8_003": "Delta time (10 ms)",
"8_004": "Delta time (100 ms)",
"8_005": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_006": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_007": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_010": "Percent (-327,68 … 327,67)",
"8_011": "Rotation angle",
"8_012": "Length (Altitude)",
"9": "Generic 2-byte floating point",
"9_001": "Temperature",
"9_002": "Temperature difference",
"9_003": "Temperature change",
"9_004": "Illuminance",
"9_005": "Wind speed",
"9_006": "Pressure (2-byte)",
"9_007": "Humidity",
"9_008": "Air quality",
"9_009": "Air flow",
"9_010": "Time",
"9_011": "[%key:component::knx::config_panel::dpt::options::9_010%]",
"9_020": "Voltage",
"9_021": "Current",
"9_022": "Power density",
"9_023": "Temperature sensitivity",
"9_024": "Power (2-byte)",
"9_025": "Volume flow",
"9_026": "Rain amount",
"9_027": "[%key:component::knx::config_panel::dpt::options::9_001%]",
"9_028": "[%key:component::knx::config_panel::dpt::options::9_005%]",
"9_029": "Absolute humidity",
"9_030": "Concentration",
"9_60000": "Enthalpy",
"12": "Generic 4-byte unsigned integer",
"12_001": "Counter (0 … 4 294 967 295)",
"12_100": "Time period (4-byte)",
"12_101": "[%key:component::knx::config_panel::dpt::options::12_100%]",
"12_102": "[%key:component::knx::config_panel::dpt::options::12_100%]",
"12_1200": "Liquid volume",
"12_1201": "Volume",
"13": "Generic 4-byte signed integer",
"13_001": "Counter (-2 147 483 648 … 2 147 483 647)",
"13_002": "Flow rate",
"13_010": "Active energy",
"13_011": "Apparent energy",
"13_012": "Reactive energy",
"13_013": "[%key:component::knx::config_panel::dpt::options::13_010%]",
"13_014": "[%key:component::knx::config_panel::dpt::options::13_011%]",
"13_015": "[%key:component::knx::config_panel::dpt::options::13_012%]",
"13_016": "[%key:component::knx::config_panel::dpt::options::13_010%]",
"13_100": "Operating hours",
"13_1200": "Delta liquid volume",
"13_1201": "Delta volume",
"14": "Generic 4-byte floating point",
"14_000": "Acceleration",
"14_001": "Angular acceleration",
"14_002": "Activation energy",
"14_003": "Activity (radioactive)",
"14_004": "Amount of substance",
"14_005": "Amplitude",
"14_006": "Angle",
"14_007": "[%key:component::knx::config_panel::dpt::options::14_006%]",
"14_008": "Angular momentum",
"14_009": "Angular velocity",
"14_010": "Area",
"14_011": "Capacitance",
"14_012": "Charge density (surface)",
"14_013": "Charge density (volume)",
"14_014": "Compressibility",
"14_015": "Conductance",
"14_016": "Electrical conductivity",
"14_017": "Density",
"14_018": "Electric charge",
"14_019": "Electric current",
"14_020": "Electric current density",
"14_021": "Electric dipole moment",
"14_022": "Electric displacement",
"14_023": "Electric field strength",
"14_024": "Electric flux",
"14_025": "Electric flux density",
"14_026": "Electric polarization",
"14_027": "Electric potential",
"14_028": "Potential difference",
"14_029": "Electromagnetic moment",
"14_030": "Electromotive force",
"14_031": "Energy",
"14_032": "Force",
"14_033": "Frequency",
"14_034": "Angular frequency",
"14_035": "Heat capacity",
"14_036": "Heat flow rate",
"14_037": "Heat quantity",
"14_038": "Impedance",
"14_039": "Length",
"14_040": "Light quantity",
"14_041": "Luminance",
"14_042": "Luminous flux",
"14_043": "Luminous intensity",
"14_044": "Magnetic field strength",
"14_045": "Magnetic flux",
"14_046": "Magnetic flux density",
"14_047": "Magnetic moment",
"14_048": "Magnetic polarization",
"14_049": "Magnetization",
"14_050": "Magnetomotive force",
"14_051": "Mass",
"14_052": "Mass flux",
"14_053": "Momentum",
"14_054": "Phase angle",
"14_055": "[%key:component::knx::config_panel::dpt::options::14_054%]",
"14_056": "Power (4-byte)",
"14_057": "Power factor",
"14_058": "Pressure (4-byte)",
"14_059": "Reactance",
"14_060": "Resistance",
"14_061": "Resistivity",
"14_062": "Self inductance",
"14_063": "Solid angle",
"14_064": "Sound intensity",
"14_065": "Speed",
"14_066": "Stress",
"14_067": "Surface tension",
"14_068": "Common temperature",
"14_069": "Absolute temperature",
"14_070": "[%key:component::knx::config_panel::dpt::options::9_002%]",
"14_071": "Thermal capacity",
"14_072": "Thermal conductivity",
"14_073": "Thermoelectric power",
"14_074": "[%key:component::knx::config_panel::dpt::options::9_010%]",
"14_075": "Torque",
"14_076": "[%key:component::knx::config_panel::dpt::options::12_1201%]",
"14_077": "Volume flux",
"14_078": "Weight",
"14_079": "Work",
"14_080": "Apparent power",
"14_1200": "Meter flow",
"14_1201": "[%key:component::knx::config_panel::dpt::options::14_1200%]",
"16_000": "String (ASCII)",
"16_001": "String (Latin-1)",
"17_001": "Scene number",
"29": "Generic 8-byte signed integer",
"29_010": "Active energy (8-byte)",
"29_011": "Apparent energy (8-byte)",
"29_012": "Reactive energy (8-byte)"
},
"selector": {
"label": "Select a datapoint type",
"no_selection": "No DPT selected"
}
},
"entities": {
"create": {
"_": {
@@ -460,6 +637,45 @@
}
}
},
"fan": {
"description": "The KNX fan platform is used as an interface to fan actuators.",
"knx": {
"ga_oscillation": {
"description": "Toggle oscillation of the fan.",
"label": "Oscillation"
},
"ga_switch": {
"description": "Group address to turn the fan on/off.",
"label": "Switch"
},
"speed": {
"description": "Control the speed of the fan.",
"ga_speed": {
"description": "Group address to control the current speed of the fan as a percentage value.",
"label": "Speed"
},
"ga_step": {
"description": "Group address to control the current speed step.",
"label": "Step"
},
"max_step": {
"description": "Number of discrete fan speed steps (Off excluded).",
"label": "Fan steps"
},
"options": {
"percentage_mode": {
"description": "Set the fan speed as a percentage value (0-100%).",
"label": "Percentage"
},
"step_mode": {
"description": "Set the fan speed in discrete steps.",
"label": "Steps"
}
},
"title": "Fan speed"
}
}
},
"header": "Create new entity",
"light": {
"description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.",
@@ -558,6 +774,35 @@
}
}
},
"sensor": {
"description": "Read-only entity for numeric or string datapoints. Temperature, percent etc.",
"knx": {
"always_callback": {
"description": "Write each update to the state machine, even if the data is the same.",
"label": "Force update"
},
"device_class": {
"description": "Override the DPTs default device class.",
"label": "Device class"
},
"ga_sensor": {
"description": "Group address representing state.",
"label": "State"
},
"section_advanced_options": {
"description": "Override default DPT-based sensor attributes.",
"title": "Overrides"
},
"state_class": {
"description": "Override the DPTs default state class.",
"label": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]"
},
"unit_of_measurement": {
"description": "Override the DPTs default unit of measurement.",
"label": "Unit of measurement"
}
}
},
"switch": {
"description": "The KNX switch platform is used as an interface to switching actuators.",
"knx": {
@@ -675,6 +920,30 @@
"message": "Invalid type for `knx.send` service: {type}"
}
},
"issues": {
"data_secure_group_key_issue": {
"fix_flow": {
"error": {
"keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]"
},
"step": {
"secure_knxkeys": {
"data": {
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]",
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]"
},
"data_description": {
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]",
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
},
"description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated KNX Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).",
"title": "Update KNX Keyring"
}
}
},
"title": "KNX Data Secure telegrams can't be decrypted"
}
},
"options": {
"step": {
"communication_settings": {
@@ -692,6 +961,79 @@
}
}
},
"selector": {
"sensor_device_class": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"sensor_state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
}
},
"services": {
"event_register": {
"description": "Adds or removes group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this action can be removed.",

View File

@@ -26,6 +26,9 @@ STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json"
# dispatcher signal for KNX interface device triggers
SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram")
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType(
"knx_data_secure_issue_telegram"
)
class DecodedTelegramPayload(TypedDict):
@@ -74,6 +77,11 @@ class Telegrams:
match_for_outgoing=True,
)
)
self._xknx_data_secure_group_key_issue_cb_handle = (
xknx.telegram_queue.register_data_secure_group_key_issue_cb(
self._xknx_data_secure_group_key_issue_cb,
)
)
self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
self.last_ga_telegrams: dict[str, TelegramDict] = {}
@@ -107,6 +115,14 @@ class Telegrams:
self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict)
def _xknx_data_secure_group_key_issue_cb(self, telegram: Telegram) -> None:
"""Handle telegrams with undecodable data secure payload from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict)
async_dispatcher_send(
self.hass, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, telegram, telegram_dict
)
def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
"""Convert a Telegram to a dict."""
dst_name = ""

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from contextlib import ExitStack
from functools import wraps
import inspect
from typing import TYPE_CHECKING, Any, Final, overload
@@ -22,6 +23,7 @@ from homeassistant.helpers.typing import UNDEFINED
from homeassistant.util.ulid import ulid_now
from .const import DOMAIN, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI
from .dpt import get_supported_dpts
from .storage.config_store import ConfigStoreException
from .storage.const import CONF_DATA
from .storage.entity_store_schema import (
@@ -34,7 +36,11 @@ from .storage.entity_store_validation import (
validate_entity_data,
)
from .storage.serialize import get_serialized_schema
from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict
from .telegrams import (
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
TelegramDict,
)
if TYPE_CHECKING:
from .knx_module import KNXModule
@@ -186,6 +192,7 @@ def ws_get_base_data(
msg["id"],
{
"connection_info": connection_info,
"dpt_metadata": get_supported_dpts(),
"project_info": _project_info,
"supported_platforms": sorted(SUPPORTED_PLATFORMS_UI),
},
@@ -334,11 +341,23 @@ def ws_subscribe_telegram(
telegram_dict,
)
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
hass,
signal=SIGNAL_KNX_TELEGRAM,
target=forward_telegram,
stack = ExitStack()
stack.callback(
async_dispatcher_connect(
hass,
signal=SIGNAL_KNX_TELEGRAM,
target=forward_telegram,
)
)
stack.callback(
async_dispatcher_connect(
hass,
signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
target=forward_telegram,
)
)
connection.subscriptions[msg["id"]] = stack.close
connection.send_result(msg["id"])

View File

@@ -5,6 +5,7 @@
"codeowners": ["@OnFreund"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/kodi",
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["jsonrpc_async", "jsonrpc_base", "jsonrpc_websocket", "pykodi"],
"requirements": ["pykodi==0.2.7"],

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