Compare commits

...

152 Commits

Author SHA1 Message Date
Paulus Schoutsen
5cc54618c5 Merge pull request #55969 from home-assistant/rc 2021-09-08 13:42:09 -07:00
Erik Montnemery
8f344252c4 Add significant change support to AQI type sensors (#55833) 2021-09-08 12:47:59 -07:00
Erik Montnemery
cbe4b2dc1d Add support for state class measurement to energy cost sensor (#55962) 2021-09-08 12:46:43 -07:00
Paulus Schoutsen
a17d2d7c71 Fix gas validation (#55886) 2021-09-08 12:45:41 -07:00
Ruslan Sayfutdinov
e3815c6c2e Pin setuptools<58 2021-09-08 12:04:32 -07:00
Paulus Schoutsen
5cba7932f3 Bumped version to 2021.9.5 2021-09-08 08:22:38 -07:00
Erik Montnemery
413430bdba Fix handling of imperial units in long term statistics (#55959) 2021-09-08 08:22:34 -07:00
Erik Montnemery
81462d8655 Do not allow inf or nan sensor states in statistics (#55943) 2021-09-08 08:22:33 -07:00
Erik Montnemery
8ee4b49aa9 Do not let one bad statistic spoil the bunch (#55942) 2021-09-08 08:22:32 -07:00
Shay Levy
19d7cb4439 Bump aioswitcher to 2.0.5 (#55934) 2021-09-08 08:22:31 -07:00
Raman Gupta
21ebf4f3e6 Allow multiple template.select platform entries (#55908) 2021-09-08 08:22:31 -07:00
Maciej Bieniek
980fcef36f Fix available property for Xiaomi Miio fan platform (#55889)
* Fix available

* Suggested change
2021-09-08 08:22:30 -07:00
Diogo Gomes
e7fd24eade Integration Sensor Initial State (#55875)
* initial state is UNAVAILABLE

* update tests
2021-09-08 08:22:08 -07:00
Pascal Winters
9ecb75dc70 Edit unit of measurement for gas/electricity supplier prices (#55771)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-09-08 08:20:49 -07:00
RDFurman
7f3adce675 Try to avoid rate limiting in honeywell (#55304)
* Limit parallel update and sleep loop

* Use asyncio sleep instead

* Extract sleep to const for testing

* Make loop sleep 0 in test
2021-09-08 08:20:48 -07:00
Paulus Schoutsen
f0649855f9 Merge pull request #55870 from home-assistant/rc 2021-09-06 16:05:08 -07:00
Paulus Schoutsen
823c3735ce Bumped version to 2021.9.4 2021-09-06 13:41:39 -07:00
Diogo Gomes
68131a5c00 Integration Sensor unit of measurement overwrite (#55869) 2021-09-06 13:41:33 -07:00
J. Nick Koston
be0f767c34 Fix exception during rediscovery of ignored zha config entries (#55859)
Fixes #55709
2021-09-06 13:41:33 -07:00
Maciej Bieniek
450652a501 Fix target humidity step for Xiaomi MJJSQ humidifiers (#55858) 2021-09-06 13:41:32 -07:00
Daniel Hjelseth Høyer
8523f569c0 Surepetcare, bug fix (#55842) 2021-09-06 13:41:31 -07:00
mrwhite31
5f289434d3 Fix typo in in rfxtrx Barometer sensor (#55839)
Fix typo in sensor.py to fix barometer unavailability
2021-09-06 13:41:31 -07:00
Maciej Bieniek
3df6dfecab Fix a lazy preset mode update for Xiaomi Miio fans (#55837) 2021-09-06 13:41:30 -07:00
Martin Hjelmare
d6eda65302 Bump zwave-js-server-python to 0.30.0 (#55831) 2021-09-06 13:41:29 -07:00
Brandon Rothweiler
7a5bc2784a Upgrade pymazda to 0.2.1 (#55820) 2021-09-06 13:41:28 -07:00
David Bonnes
00878467cc Fix incomfort min/max temperatures (#55806) 2021-09-06 13:41:28 -07:00
Maciej Bieniek
899d8164b0 Fix xiaomi miio Air Quality Monitor initialization (#55773) 2021-09-06 13:41:27 -07:00
jan iversen
823fd60991 Allow same address different register types in modbus (#55767) 2021-09-06 13:41:26 -07:00
jan iversen
a6bb0eadca Allow same IP if ports are different on modbus (#55766) 2021-09-06 13:41:25 -07:00
Tatham Oddie
eb70354ee7 Fix logbook entity_matches_only query mode (#55761)
The string matching template needs to match the same compact JSON format
as the data is now written in.
2021-09-06 13:41:25 -07:00
Joshi
bd53185bed Fix switch name attribute for thinkingcleaner (#55730) 2021-09-06 13:41:24 -07:00
Paulus Schoutsen
df9a899bbd Merge pull request #55753 from home-assistant/rc 2021-09-04 14:51:15 -07:00
Paulus Schoutsen
37cf295e20 Bumped version to 2021.9.3 2021-09-04 14:13:37 -07:00
Simone Chemelli
04816fe26d Fix SamsungTV sendkey when not connected (#55723) 2021-09-04 14:13:34 -07:00
Anders Melchiorsen
eb48e75fc5 Fix LIFX firmware version information (#55713) 2021-09-04 14:13:33 -07:00
Simone Chemelli
9d5431fba1 Handle Fritz InternalError (#55711) 2021-09-04 14:13:32 -07:00
Erik Montnemery
a4f2c5583d Handle negative numbers in sensor long term statistics (#55708)
* Handle negative numbers in sensor long term statistics

* Use negative states in tests
2021-09-04 14:13:32 -07:00
Paulus Schoutsen
a37c3af2b4 better detect legacy eagly devices (#55706) 2021-09-04 14:13:31 -07:00
Paulus Schoutsen
33047d7260 Merge pull request #55673 from home-assistant/rc 2021-09-03 10:53:16 -07:00
Paulus Schoutsen
e3405d226a Bumped version to 2021.9.2 2021-09-03 10:16:36 -07:00
Paulus Schoutsen
3008ff03b2 Guard for doRollover failing (#55669) 2021-09-03 10:16:31 -07:00
Joakim Sørensen
8592d94a3c Fix hdmi_cec switches (#55666) 2021-09-03 10:16:30 -07:00
Nikolay Vasilchuk
b36e86d95c Fix Starline sensor state AttributeError (#55654)
* Fix starline sensors state

* Black
2021-09-03 10:16:29 -07:00
Paulus Schoutsen
f61a1ecae7 Guard for unexpected exceptions in device automation (#55639)
* Guard for unexpected exceptions in device automation

* merge

Co-authored-by: J. Nick Koston <nick@koston.org>
2021-09-03 10:16:28 -07:00
Paulus Schoutsen
80c074ca82 Better handle invalid trigger config (#55637) 2021-09-03 10:16:28 -07:00
Paulus Schoutsen
ff91ff4cd2 Fix template sensor availability (#55635) 2021-09-03 10:16:27 -07:00
J. Nick Koston
93c2a7dd70 Narrow zwave_js USB discovery (#55613)
- Avoid triggering discovery when we can know in advance the
  device is not a Z-Wave stick
2021-09-03 10:16:26 -07:00
Michael
da3ee9ed4b Fix CONFIG_SCHEMA validation in Speedtest.net (#55612) 2021-09-03 10:16:25 -07:00
Pascal Vizeli
2ef607651d Disable observer for USB on containers (#55570)
* Disable observer for USB on containers

* remove operating system test

Co-authored-by: J. Nick Koston <nick@koston.org>
2021-09-03 10:16:24 -07:00
J. Nick Koston
88ca83a30b Ignore missing devices when in ssdp unsee (#55553) 2021-09-03 10:16:24 -07:00
Paulus Schoutsen
f883fa9eef Merge pull request #55608 from home-assistant/rc 2021-09-02 13:35:47 -07:00
Paulus Schoutsen
5b705dba36 Bumped version to 2021.9.1 2021-09-02 12:50:56 -07:00
Pascal Vizeli
1592408a4b Downgrade sqlite-libs on docker image (#55591) 2021-09-02 12:50:48 -07:00
jan iversen
89b7be52af Correct duplicate address. (#55578) 2021-09-02 12:50:47 -07:00
Alexei Chetroi
8f85472df3 Pick right coordinator (#55555) 2021-09-02 12:50:45 -07:00
Teemu R
6aa771e5e8 xiaomi_miio: bump python-miio dependency (#55549) 2021-09-02 12:50:44 -07:00
Joakim Sørensen
7193e82963 Bump pyuptimerobot to 21.9.0 (#55546) 2021-09-02 12:50:43 -07:00
Paulus Schoutsen
245eec7041 Merge pull request #55532 from home-assistant/rc 2021-09-01 11:28:21 -07:00
Bram Kragten
493309daa7 Bumped version to 2021.9.0 2021-09-01 19:40:48 +02:00
Paulus Schoutsen
af68802c17 Tweaks for the iotawatt integration (#55510) 2021-09-01 19:37:43 +02:00
Brian Egge
576cece7a9 Fix None support_color_modes TypeError (#55497)
* Fix None support_color_modes TypeError 

https://github.com/home-assistant/core/issues/55451

* Update __init__.py
2021-09-01 19:37:43 +02:00
Otto Winter
3b9859940f ESPHome light color mode use capabilities (#55206)
Co-authored-by: Oxan van Leeuwen <oxan@oxanvanleeuwen.nl>
2021-09-01 19:37:41 +02:00
Paulus Schoutsen
a315fd059a Bumped version to 2021.9.0b7 2021-08-31 22:57:33 -07:00
Brett Adams
ba9ef004c8 Add missing device class for temperature sensor in Advantage Air (#55508) 2021-08-31 22:57:13 -07:00
Felipe Martins Diel
22f745b17c Fix BroadlinkSwitch._attr_assumed_state (#55505) 2021-08-31 22:57:12 -07:00
muppet3000
05cf223146 Added trailing slash to US growatt URL (#55504) 2021-08-31 22:57:12 -07:00
Erik Montnemery
d4aadd8af0 Improve log for sum statistics (#55502) 2021-08-31 22:56:28 -07:00
Erik Montnemery
4045eee2e5 Correct sum statistics when only last_reset has changed (#55498)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-08-31 22:53:58 -07:00
Joakim Sørensen
83a51f7f30 Add cache-control headers to supervisor entrypoint (#55493) 2021-08-31 22:52:05 -07:00
gjong
29110fe157 Remove Youless native unit of measurement (#55492) 2021-08-31 22:52:05 -07:00
gjong
e87b7e24b4 Increase YouLess polling interval (#55490) 2021-08-31 22:52:04 -07:00
uvjustin
d9056c01a6 Fix ArestSwitchBase missing is on attribute (#55483) 2021-08-31 22:52:03 -07:00
Matthew Garrett
a724bc21b6 Assistant sensors (#55480) 2021-08-31 22:52:03 -07:00
Paulus Schoutsen
ef00178339 Add Eagle 200 name back (#55477)
* Add Eagle 200 name back

* add comment

* update tests
2021-08-31 22:52:02 -07:00
Erik Montnemery
b8770c3958 Make new cycles for sensor sum statistics start with 0 as zero-point (#55473) 2021-08-31 22:52:01 -07:00
Eric Severance
f0c0cfcac0 Wemo Insight devices need polling when off (#55348) 2021-08-31 22:52:00 -07:00
Bram Kragten
4c48ad9108 Bumped version to Bumped version to 2021.9.0b6 2021-08-30 23:35:50 +02:00
Bram Kragten
92b0453749 Update frontend to 20210830.0 (#55472) 2021-08-30 23:33:47 +02:00
Raman Gupta
8ab801a7b4 Fix area_id and area_name template functions (#55470) 2021-08-30 23:33:46 +02:00
Aaron Bach
f92c7b1aea Bump aioambient to 1.3.0 (#55468) 2021-08-30 23:33:45 +02:00
Aaron Bach
0d9fbf864f Bump pyiqvia to 1.1.0 (#55466) 2021-08-30 23:33:44 +02:00
Aaron Bach
275f9c8a28 Bump pyopenuv to 2.2.0 (#55464) 2021-08-30 23:33:43 +02:00
Erik Montnemery
84f3b1514f Fix race in MQTT sensor when last_reset_topic is configured (#55463) 2021-08-30 23:33:43 +02:00
Greg
802f5613c4 Add IoTaWatt integration (#55364)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-08-30 23:33:42 +02:00
Paulus Schoutsen
8be40cbb00 Bumped version to 2021.9.0b5 2021-08-30 09:41:51 -07:00
Raman Gupta
46ce4e92f6 Bump zwave-js-server-python to 0.29.1 (#55460) 2021-08-30 09:41:42 -07:00
J. Nick Koston
39f11bb46d Bump zeroconf to 0.36.2 (#55459)
- Now sends NSEC records when requesting non-existent address types
  Implements RFC6762 sec 6.2 (http://datatracker.ietf.org/doc/html/rfc6762#section-6.2)

- This solves a case where a HomeKit bridge can take a while to update
  because it is waiting to see if an AAAA (IPv6) address is available
2021-08-30 09:41:42 -07:00
Erik Montnemery
3b0fe9adde Revert "Deprecate last_reset options in MQTT sensor" (#55457)
This reverts commit f9fa5fa804.
2021-08-30 09:41:41 -07:00
Simone Chemelli
707778229b Fix noise/attenuation units to UI display for Fritz (#55447) 2021-08-30 09:41:40 -07:00
Erik Montnemery
a474534c08 Fix exception when shutting down DSMR (#55441)
* Fix exception when shutting down DSMR

* Update homeassistant/components/dsmr/sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2021-08-30 09:41:39 -07:00
Erik Montnemery
65ad99d51c Fix crash in buienradar sensor due to self.hass not set (#55438) 2021-08-30 09:41:39 -07:00
Erik Montnemery
4052a0db89 Improve statistics error messages when sensor's unit is changing (#55436)
* Improve error messages when sensor's unit is changing

* Improve test coverage
2021-08-30 09:41:38 -07:00
Raman Gupta
b546fc5067 Don't set zwave_js sensor device class to energy when unit is wrong (#55434) 2021-08-30 09:41:37 -07:00
Christopher Kochan
5dcc760755 Add Sense energy sensors (#54833)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2021-08-30 09:41:36 -07:00
Paulus Schoutsen
fb06acf39d Bumped version to 2021.9.0b4 2021-08-29 20:45:45 -07:00
Raman Gupta
948f191f16 Make zwave_js discovery log message more descriptive (#55432) 2021-08-29 20:45:33 -07:00
Klaas Schoute
2c0d9105ac Update entity names for P1 Monitor integration (#55430) 2021-08-29 20:45:32 -07:00
J. Nick Koston
10df9f3542 Bump zeroconf to 0.36.1 (#55425)
- Fixes duplicate records in the cache

- Changelog: https://github.com/jstasiak/python-zeroconf/compare/0.36.0...0.36.1
2021-08-29 20:45:32 -07:00
Aaron Bach
6cf799459b Ensure ReCollect Waste shows pickups for midnight on the actual day (#55424) 2021-08-29 20:44:57 -07:00
Marc Mueller
47e2d1caa5 Fix device_class - qnap drive_temp sensor (#55409) 2021-08-29 20:41:25 -07:00
J. Nick Koston
69d8f94e3b Show device_id in HomeKit when the device registry entry is missing a name (#55391)
- Reported at: https://community.home-assistant.io/t/homekit-unknown-error-occurred/333385
2021-08-29 20:41:24 -07:00
Aaron Bach
4b7803ed03 Bump simplisafe-python to 11.0.6 (#55385) 2021-08-29 20:41:24 -07:00
J. Nick Koston
ff6015ff89 Implement import of consider_home in nmap_tracker to avoid breaking change (#55379) 2021-08-29 20:41:23 -07:00
Matt Krasowski
fbd144de46 Handle incorrect values reported by some Shelly devices (#55042) 2021-08-29 20:41:22 -07:00
Paulus Schoutsen
adaebdeea8 Bumped version to 2021.9.0b3 2021-08-28 08:59:25 -07:00
Maciej Bieniek
910cb5865a Address late review for Tractive integration (#55371) 2021-08-28 08:58:38 -07:00
Joakim Sørensen
baf0d9b2d9 Pin regex to 2021.8.28 (#55368) 2021-08-28 08:58:37 -07:00
Jason Hunter
c1bce68549 close connection on connection retry, bump onvif lib (#55363) 2021-08-28 08:58:36 -07:00
Nathan Spencer
bde4c0e46f Bump pylitterbot to 2021.8.1 (#55360) 2021-08-28 08:58:35 -07:00
Paulus Schoutsen
a275e7aa67 Fix wolflink super call (#55359) 2021-08-28 08:58:35 -07:00
Aaron Bach
d96e416d26 Ensure ReCollect Waste starts up even if no future pickup is found (#55349) 2021-08-28 08:58:34 -07:00
Paulus Schoutsen
efc3894303 Convert solarlog to coordinator (#55345) 2021-08-28 08:58:33 -07:00
Daniel Hjelseth Høyer
06b47ee2f5 Tractive name (#55342) 2021-08-28 08:58:33 -07:00
Raman Gupta
08ca43221f Listen to node events in the zwave_js node status sensor (#55341) 2021-08-28 08:58:32 -07:00
J. Nick Koston
8641740ed8 Ensure yeelights resync state if they are busy on first connect (#55333) 2021-08-28 08:58:31 -07:00
Paulus Schoutsen
d0ada6c6e2 Bumped version to 2021.9.0b2 2021-08-27 10:00:20 -07:00
Anders Melchiorsen
76bb036968 Upgrade aiolifx to 0.6.10 (#55344) 2021-08-27 10:00:00 -07:00
J. Nick Koston
d8b64be41c Retrigger config flow when the ssdp location changes for a UDN (#55343)
Fixes #55229
2021-08-27 09:59:59 -07:00
jan iversen
b3e0b7b86e Add modbus name to log_error (#55336) 2021-08-27 09:59:59 -07:00
Chris Talkington
e097e4c1c2 Fix reauth for sonarr (#55329)
* fix reauth for sonarr

* Update config_flow.py

* Update config_flow.py

* Update config_flow.py

* Update test_config_flow.py

* Update config_flow.py

* Update test_config_flow.py

* Update config_flow.py
2021-08-27 09:59:58 -07:00
Robert Hillis
34f0fecef8 Fix sonos alarm schema (#55318) 2021-08-27 09:59:57 -07:00
Erik Montnemery
f53a10d39a Handle statistics for sensor with changing state class (#55316) 2021-08-27 09:59:56 -07:00
J. Nick Koston
5b993129d6 Fix lifx model to be a string (#55309)
Fixes #55307
2021-08-27 09:59:56 -07:00
J. Nick Koston
865656d436 Always send powerview move command in case shade is out of sync (#55308) 2021-08-27 09:59:55 -07:00
Aaron Bach
fb25c6c115 Bump simplisafe-python to 11.0.5 (#55306) 2021-08-27 09:59:54 -07:00
Aaron Bach
c963cf8743 Bump aiorecollect to 1.0.8 (#55300) 2021-08-27 09:59:54 -07:00
rikroe
ddb28db21a Bump bimmer_connected to 0.7.20 (#55299) 2021-08-27 09:59:53 -07:00
J. Nick Koston
bfc98b444f Fix creation of new nmap tracker entities (#55297) 2021-08-27 09:59:52 -07:00
realPy
f9a0f44137 Correct flash light livarno when use hue (#55294)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2021-08-27 09:59:51 -07:00
J. Nick Koston
93750d71ce Gracefully handle pyudev failing to filter on WSL (#55286)
* Gracefully handle pyudev failing to filter on WSL

* add debug message

* add mocks so we reach the new check
2021-08-27 09:59:51 -07:00
Paulus Schoutsen
06e4003640 Bump ring to 0.7.1 (#55282) 2021-08-27 09:59:50 -07:00
J. Nick Koston
97ff5e2085 Set yeelight capabilities from external discovery (#55280) 2021-08-27 09:59:49 -07:00
J. Nick Koston
8a2c07ce19 Ensure yeelight model is set in the config entry (#55281)
* Ensure yeelight model is set in the config entry

- If the model was not set in the config entry the light could
  be sent commands it could not handle

* update tests

* fix test
2021-08-27 09:59:21 -07:00
J. Nick Koston
9f7398e0df Fix yeelight brightness when nightlight switch is disabled (#55278) 2021-08-27 09:57:07 -07:00
J. Nick Koston
7df84dadad Fix some yeelights showing wrong state after on/off (#55279) 2021-08-27 09:56:22 -07:00
Chris
2a1e943b18 Fix unique_id conflict in smarttthings (#55235) 2021-08-27 09:54:26 -07:00
prwood80
e6e72bfa82 Improve performance of ring camera still images (#53803)
Co-authored-by: Pat Wood <prwood80@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2021-08-27 09:54:25 -07:00
Paulus Schoutsen
219868b308 Bumped version to 2021.9.0b1 2021-08-26 09:37:25 -07:00
Maciej Bieniek
67dd861d8c Fix AttributeError for non-MIOT Xiaomi Miio purifiers (#55271) 2021-08-26 09:37:20 -07:00
Florian Gareis
f2765ba320 Don't create DSL sensor for devices that don't support DSL (#55269) 2021-08-26 09:37:19 -07:00
Erik Montnemery
aefd3df914 Warn if a sensor with state_class_total has a decreasing value twice (#55251) 2021-08-26 09:37:18 -07:00
Franck Nijhof
3658eeb8d1 Fix MQTT add-on discovery to be ignorable (#55250) 2021-08-26 09:37:07 -07:00
Erik Montnemery
080cb6b6e9 Fix double precision float for postgresql (#55249) 2021-08-26 09:37:06 -07:00
Joakim Sørensen
20796303da Only postfix image name for container (#55248) 2021-08-26 09:37:06 -07:00
J. Nick Koston
dff6151ff4 Abort zha usb discovery if deconz is setup (#55245)
* Abort zha usb discovery if deconz is setup

* Update tests/components/zha/test_config_flow.py

* add deconz domain const

* Update homeassistant/components/zha/config_flow.py

Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>

Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>
2021-08-26 09:37:05 -07:00
Alexei Chetroi
6f24f4e302 Bump up ZHA dependencies (#55242)
* Bump up ZHA dependencies

* Bump up zha-device-handlers
2021-08-26 09:37:04 -07:00
J. Nick Koston
175febe635 Defer zha auto configure probe until after clicking configure (#55239) 2021-08-26 09:37:03 -07:00
J. Nick Koston
aa907f4d10 Only warn once per entity when the async_camera_image signature needs to be updated (#55238) 2021-08-26 09:37:02 -07:00
J. Nick Koston
3d09478aea Limit USB discovery to specific manufacturer/description/serial_number matches (#55236)
* Limit USB discovery to specific manufacturer/description/serial_number matches

* test for None case
2021-08-26 09:37:01 -07:00
Marc Mueller
05df9b4b8b Remove temperature conversion - tado (#55231) 2021-08-26 09:37:01 -07:00
jjlawren
1865a28083 Set up polling task with subscriptions in Sonos (#54355) 2021-08-26 09:37:00 -07:00
Franck Nijhof
f78d57515a Bumped version to 2021.9.0b0 2021-08-25 22:11:21 +02:00
197 changed files with 4726 additions and 1102 deletions

View File

@@ -580,7 +580,7 @@ jobs:
python -m venv venv
. venv/bin/activate
pip install -U "pip<20.3" setuptools wheel
pip install -U "pip<20.3" "setuptools<58" wheel
pip install -r requirements_all.txt
pip install -r requirements_test.txt
pip install -e .

View File

@@ -248,6 +248,7 @@ homeassistant/components/integration/* @dgomes
homeassistant/components/intent/* @home-assistant/core
homeassistant/components/intesishome/* @jnimmo
homeassistant/components/ios/* @robbiet480
homeassistant/components/iotawatt/* @gtdiehl
homeassistant/components/iperf3/* @rohankapoorcom
homeassistant/components/ipma/* @dgomes @abmantis
homeassistant/components/ipp/* @ctalkington

View File

@@ -16,6 +16,21 @@ RUN \
-e ./homeassistant \
&& python3 -m compileall homeassistant/homeassistant
# Fix Bug with Alpine 3.14 and sqlite 3.35
# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524
ARG BUILD_ARCH
RUN \
if [ "${BUILD_ARCH}" = "amd64" ]; then \
export APK_ARCH=x86_64; \
elif [ "${BUILD_ARCH}" = "i386" ]; then \
export APK_ARCH=x86; \
else \
export APK_ARCH=${BUILD_ARCH}; \
fi \
&& curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \
&& apk add --no-cache sqlite-libs-3.34.1-r0.apk \
&& rm -f sqlite-libs-3.34.1-r0.apk
# Home Assistant S6-Overlay
COPY rootfs /

View File

@@ -342,7 +342,11 @@ def async_enable_logging(
err_log_path, backupCount=1
)
err_handler.doRollover()
try:
err_handler.doRollover()
except OSError as err:
_LOGGER.error("Error rolling over log file: %s", err)
err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt))

View File

@@ -1,7 +1,11 @@
"""Sensor platform for Advantage Air integration."""
import voluptuous as vol
from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity
from homeassistant.components.sensor import (
DEVICE_CLASS_TEMPERATURE,
STATE_CLASS_MEASUREMENT,
SensorEntity,
)
from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
from homeassistant.helpers import config_validation as cv, entity_platform
@@ -138,11 +142,11 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity):
class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity):
"""Representation of Advantage Air Zone wireless signal sensor."""
"""Representation of Advantage Air Zone temperature sensor."""
_attr_native_unit_of_measurement = TEMP_CELSIUS
_attr_device_class = DEVICE_CLASS_TEMPERATURE
_attr_state_class = STATE_CLASS_MEASUREMENT
_attr_icon = "mdi:thermometer"
_attr_entity_registry_enabled_default = False
def __init__(self, instance, ac_key, zone_key):

View File

@@ -319,6 +319,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
config_entry.data[CONF_API_KEY],
config_entry.data[CONF_APP_KEY],
session=session,
logger=LOGGER,
),
)
hass.loop.create_task(ambient.ws_connect())

View File

@@ -3,7 +3,7 @@
"name": "Ambient Weather Station",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ambient_station",
"requirements": ["aioambient==1.2.6"],
"requirements": ["aioambient==1.3.0"],
"codeowners": ["@bachya"],
"iot_class": "cloud_push"
}

View File

@@ -88,6 +88,7 @@ class ArestSwitchBase(SwitchEntity):
self._resource = resource
self._attr_name = f"{location.title()} {name.title()}"
self._attr_available = True
self._attr_is_on = False
class ArestSwitchFunction(ArestSwitchBase):

View File

@@ -2,7 +2,7 @@
"domain": "bmw_connected_drive",
"name": "BMW Connected Drive",
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"requirements": ["bimmer_connected==0.7.19"],
"requirements": ["bimmer_connected==0.7.20"],
"codeowners": ["@gerard33", "@rikroe"],
"config_flow": true,
"iot_class": "cloud_polling"

View File

@@ -142,9 +142,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC):
super().__init__(device)
self._command_on = command_on
self._command_off = command_off
self._attr_assumed_state = True
self._attr_device_class = DEVICE_CLASS_SWITCH
self._attr_name = f"{device.name} Switch"
async def async_added_to_hass(self):

View File

@@ -699,7 +699,7 @@ class BrSensor(SensorEntity):
@callback
def data_updated(self, data):
"""Update data."""
if self._load_data(data) and self.hass:
if self.hass and self._load_data(data):
self.async_write_ha_state()
@callback

View File

@@ -165,10 +165,7 @@ async def _async_get_image(
width=width, height=height
)
else:
_LOGGER.warning(
"The camera entity %s does not support requesting width and height, please open an issue with the integration author",
camera.entity_id,
)
camera.async_warn_old_async_camera_image_signature()
image_bytes = await camera.async_camera_image()
if image_bytes:
@@ -381,6 +378,7 @@ class Camera(Entity):
self.stream_options: dict[str, str] = {}
self.content_type: str = DEFAULT_CONTENT_TYPE
self.access_tokens: collections.deque = collections.deque([], 2)
self._warned_old_signature = False
self.async_update_token()
@property
@@ -455,11 +453,20 @@ class Camera(Entity):
return await self.hass.async_add_executor_job(
partial(self.camera_image, width=width, height=height)
)
self.async_warn_old_async_camera_image_signature()
return await self.hass.async_add_executor_job(self.camera_image)
# Remove in 2022.1 after all custom components have had a chance to change their signature
@callback
def async_warn_old_async_camera_image_signature(self) -> None:
"""Warn once when calling async_camera_image with the function old signature."""
if self._warned_old_signature:
return
_LOGGER.warning(
"The camera entity %s does not support requesting width and height, please open an issue with the integration author",
self.entity_id,
)
return await self.hass.async_add_executor_job(self.camera_image)
self._warned_old_signature = True
async def handle_async_still_stream(
self, request: web.Request, interval: float

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Iterable, Mapping
from functools import wraps
import logging
from types import ModuleType
from typing import Any
@@ -27,7 +28,6 @@ from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig
DOMAIN = "device_automation"
DEVICE_TRIGGER_BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_PLATFORM): "device",
@@ -174,6 +174,13 @@ async def _async_get_device_automations(
device_results, InvalidDeviceAutomationConfig
):
continue
if isinstance(device_results, Exception):
logging.getLogger(__name__).error(
"Unexpected error fetching device %ss",
automation_type,
exc_info=device_results,
)
continue
for automation in device_results:
combined_results[automation["device_id"]].append(automation)

View File

@@ -7,6 +7,8 @@ from homeassistant.components.device_automation import (
)
from homeassistant.const import CONF_DOMAIN
from .exceptions import InvalidDeviceAutomationConfig
# mypy: allow-untyped-defs, no-check-untyped-defs
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
@@ -17,10 +19,13 @@ async def async_validate_trigger_config(hass, config):
platform = await async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "trigger"
)
if hasattr(platform, "async_validate_trigger_config"):
return await getattr(platform, "async_validate_trigger_config")(hass, config)
if not hasattr(platform, "async_validate_trigger_config"):
return platform.TRIGGER_SCHEMA(config)
return platform.TRIGGER_SCHEMA(config)
try:
return await getattr(platform, "async_validate_trigger_config")(hass, config)
except InvalidDeviceAutomationConfig as err:
raise vol.Invalid(str(err) or "Invalid trigger configuration") from err
async def async_attach_trigger(hass, config, action, automation_info):

View File

@@ -25,7 +25,7 @@ from homeassistant.const import (
from homeassistant.core import CoreState, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, StateType
from homeassistant.helpers.typing import ConfigType, EventType, StateType
from homeassistant.util import Throttle
from .const import (
@@ -146,8 +146,15 @@ async def async_setup_entry(
if transport:
# Register listener to close transport on HA shutdown
@callback
def close_transport(_event: EventType) -> None:
"""Close the transport on HA shutdown."""
if not transport:
return
transport.close()
stop_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, transport.close
EVENT_HOMEASSISTANT_STOP, close_transport
)
# Wait for reader to close

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from typing import Callable, Final
from homeassistant.components.sensor import (
STATE_CLASS_MEASUREMENT,
@@ -24,6 +24,9 @@ from homeassistant.const import (
VOLUME_CUBIC_METERS,
)
PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}"
PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}"
def dsmr_transform(value):
"""Transform DSMR version value to right format."""
@@ -301,31 +304,31 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1",
name="Low tariff delivered price",
icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO,
native_unit_of_measurement=PRICE_EUR_KWH,
),
DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2",
name="High tariff delivered price",
icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO,
native_unit_of_measurement=PRICE_EUR_KWH,
),
DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1",
name="Low tariff returned price",
icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO,
native_unit_of_measurement=PRICE_EUR_KWH,
),
DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2",
name="High tariff returned price",
icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO,
native_unit_of_measurement=PRICE_EUR_KWH,
),
DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/energy_supplier_price_gas",
name="Gas price",
icon="mdi:currency-eur",
native_unit_of_measurement=CURRENCY_EURO,
native_unit_of_measurement=PRICE_EUR_M3,
),
DSMRReaderSensorEntityDescription(
key="dsmr/day-consumption/fixed_cost",

View File

@@ -1,13 +1,16 @@
"""Helper sensor for calculating utility costs."""
from __future__ import annotations
import copy
from dataclasses import dataclass
import logging
from typing import Any, Final, Literal, TypeVar, cast
from homeassistant.components.sensor import (
ATTR_LAST_RESET,
ATTR_STATE_CLASS,
DEVICE_CLASS_MONETARY,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
SensorEntity,
)
@@ -18,14 +21,19 @@ from homeassistant.const import (
ENERGY_WATT_HOUR,
VOLUME_CUBIC_METERS,
)
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util
from .const import DOMAIN
from .data import EnergyManager, async_get_manager
SUPPORTED_STATE_CLASSES = [
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
]
_LOGGER = logging.getLogger(__name__)
@@ -206,15 +214,16 @@ class EnergyCostSensor(SensorEntity):
f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}"
)
self._attr_device_class = DEVICE_CLASS_MONETARY
self._attr_state_class = STATE_CLASS_TOTAL_INCREASING
self._attr_state_class = STATE_CLASS_MEASUREMENT
self._config = config
self._last_energy_sensor_state: StateType | None = None
self._last_energy_sensor_state: State | None = None
self._cur_value = 0.0
def _reset(self, energy_state: StateType) -> None:
def _reset(self, energy_state: State) -> None:
"""Reset the cost sensor."""
self._attr_native_value = 0.0
self._cur_value = 0.0
self._attr_last_reset = dt_util.utcnow()
self._last_energy_sensor_state = energy_state
self.async_write_ha_state()
@@ -228,9 +237,8 @@ class EnergyCostSensor(SensorEntity):
if energy_state is None:
return
if (
state_class := energy_state.attributes.get(ATTR_STATE_CLASS)
) != STATE_CLASS_TOTAL_INCREASING:
state_class = energy_state.attributes.get(ATTR_STATE_CLASS)
if state_class not in SUPPORTED_STATE_CLASSES:
if not self._wrong_state_class_reported:
self._wrong_state_class_reported = True
_LOGGER.warning(
@@ -240,6 +248,13 @@ class EnergyCostSensor(SensorEntity):
)
return
# last_reset must be set if the sensor is STATE_CLASS_MEASUREMENT
if (
state_class == STATE_CLASS_MEASUREMENT
and ATTR_LAST_RESET not in energy_state.attributes
):
return
try:
energy = float(energy_state.state)
except ValueError:
@@ -273,7 +288,7 @@ class EnergyCostSensor(SensorEntity):
if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities are in place.
self._reset(energy_state.state)
self._reset(energy_state)
return
energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@@ -298,20 +313,29 @@ class EnergyCostSensor(SensorEntity):
)
return
if reset_detected(
if state_class != STATE_CLASS_TOTAL_INCREASING and energy_state.attributes.get(
ATTR_LAST_RESET
) != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET):
# Energy meter was reset, reset cost sensor too
energy_state_copy = copy.copy(energy_state)
energy_state_copy.state = "0.0"
self._reset(energy_state_copy)
elif state_class == STATE_CLASS_TOTAL_INCREASING and reset_detected(
self.hass,
cast(str, self._config[self._adapter.entity_energy_key]),
energy,
float(self._last_energy_sensor_state),
float(self._last_energy_sensor_state.state),
):
# Energy meter was reset, reset cost sensor too
self._reset(0)
energy_state_copy = copy.copy(energy_state)
energy_state_copy.state = "0.0"
self._reset(energy_state_copy)
# Update with newly incurred cost
old_energy_value = float(self._last_energy_sensor_state)
old_energy_value = float(self._last_energy_sensor_state.state)
self._cur_value += (energy - old_energy_value) * energy_price
self._attr_native_value = round(self._cur_value, 2)
self._last_energy_sensor_state = energy_state.state
self._last_energy_sensor_state = energy_state
async def async_added_to_hass(self) -> None:
"""Register callbacks."""

View File

@@ -1,6 +1,7 @@
"""Validate the energy preferences provide valid data."""
from __future__ import annotations
from collections.abc import Sequence
import dataclasses
from typing import Any
@@ -10,12 +11,24 @@ from homeassistant.const import (
ENERGY_WATT_HOUR,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
VOLUME_CUBIC_FEET,
VOLUME_CUBIC_METERS,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
from . import data
from .const import DOMAIN
ENERGY_USAGE_UNITS = (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR)
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
GAS_USAGE_UNITS = (
ENERGY_WATT_HOUR,
ENERGY_KILO_WATT_HOUR,
VOLUME_CUBIC_METERS,
VOLUME_CUBIC_FEET,
)
GAS_UNIT_ERROR = "entity_unexpected_unit_gas"
@dataclasses.dataclass
class ValidationIssue:
@@ -43,8 +56,12 @@ class EnergyPreferencesValidation:
@callback
def _async_validate_energy_stat(
hass: HomeAssistant, stat_value: str, result: list[ValidationIssue]
def _async_validate_usage_stat(
hass: HomeAssistant,
stat_value: str,
allowed_units: Sequence[str],
unit_error: str,
result: list[ValidationIssue],
) -> None:
"""Validate a statistic."""
has_entity_source = valid_entity_id(stat_value)
@@ -91,14 +108,16 @@ def _async_validate_energy_stat(
unit = state.attributes.get("unit_of_measurement")
if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR):
result.append(
ValidationIssue("entity_unexpected_unit_energy", stat_value, unit)
)
if unit not in allowed_units:
result.append(ValidationIssue(unit_error, stat_value, unit))
state_class = state.attributes.get("state_class")
if state_class != sensor.STATE_CLASS_TOTAL_INCREASING:
supported_state_classes = [
sensor.STATE_CLASS_MEASUREMENT,
sensor.STATE_CLASS_TOTAL_INCREASING,
]
if state_class not in supported_state_classes:
result.append(
ValidationIssue(
"entity_unexpected_state_class_total_increasing",
@@ -125,16 +144,13 @@ def _async_validate_price_entity(
return
try:
value: float | None = float(state.state)
float(state.state)
except ValueError:
result.append(
ValidationIssue("entity_state_non_numeric", entity_id, state.state)
)
return
if value is not None and value < 0:
result.append(ValidationIssue("entity_negative_state", entity_id, value))
unit = state.attributes.get("unit_of_measurement")
if unit is None or not unit.endswith(
@@ -188,7 +204,11 @@ def _async_validate_cost_entity(
state_class = state.attributes.get("state_class")
if state_class != sensor.STATE_CLASS_TOTAL_INCREASING:
supported_state_classes = [
sensor.STATE_CLASS_MEASUREMENT,
sensor.STATE_CLASS_TOTAL_INCREASING,
]
if state_class not in supported_state_classes:
result.append(
ValidationIssue(
"entity_unexpected_state_class_total_increasing", entity_id, state_class
@@ -211,8 +231,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
if source["type"] == "grid":
for flow in source["flow_from"]:
_async_validate_energy_stat(
hass, flow["stat_energy_from"], source_result
_async_validate_usage_stat(
hass,
flow["stat_energy_from"],
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
if flow.get("stat_cost") is not None:
@@ -229,7 +253,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
)
for flow in source["flow_to"]:
_async_validate_energy_stat(hass, flow["stat_energy_to"], source_result)
_async_validate_usage_stat(
hass,
flow["stat_energy_to"],
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
if flow.get("stat_compensation") is not None:
_async_validate_cost_stat(
@@ -247,7 +277,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
)
elif source["type"] == "gas":
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
_async_validate_usage_stat(
hass,
source["stat_energy_from"],
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
if source.get("stat_cost") is not None:
_async_validate_cost_stat(hass, source["stat_cost"], source_result)
@@ -263,15 +299,39 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
)
elif source["type"] == "solar":
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
_async_validate_usage_stat(
hass,
source["stat_energy_from"],
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
elif source["type"] == "battery":
_async_validate_energy_stat(hass, source["stat_energy_from"], source_result)
_async_validate_energy_stat(hass, source["stat_energy_to"], source_result)
_async_validate_usage_stat(
hass,
source["stat_energy_from"],
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
_async_validate_usage_stat(
hass,
source["stat_energy_to"],
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
for device in manager.data["device_consumption"]:
device_result: list[ValidationIssue] = []
result.device_consumption.append(device_result)
_async_validate_energy_stat(hass, device["stat_consumption"], device_result)
_async_validate_usage_stat(
hass,
device["stat_consumption"],
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
device_result,
)
return result

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any, cast
from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState
from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -34,12 +34,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
EsphomeEntity,
EsphomeEnumMapper,
esphome_state_property,
platform_async_setup_entry,
)
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10}
@@ -59,20 +54,81 @@ async def async_setup_entry(
)
_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper(
{
LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN,
LightColorMode.ON_OFF: COLOR_MODE_ONOFF,
LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS,
LightColorMode.WHITE: COLOR_MODE_WHITE,
LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP,
LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP,
LightColorMode.RGB: COLOR_MODE_RGB,
LightColorMode.RGB_WHITE: COLOR_MODE_RGBW,
LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW,
LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW,
}
)
_COLOR_MODE_MAPPING = {
COLOR_MODE_ONOFF: [
LightColorCapability.ON_OFF,
],
COLOR_MODE_BRIGHTNESS: [
LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS,
# for compatibility with older clients (2021.8.x)
LightColorCapability.BRIGHTNESS,
],
COLOR_MODE_COLOR_TEMP: [
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.COLOR_TEMPERATURE,
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.COLD_WARM_WHITE,
],
COLOR_MODE_RGB: [
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB,
],
COLOR_MODE_RGBW: [
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB
| LightColorCapability.WHITE,
],
COLOR_MODE_RGBWW: [
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB
| LightColorCapability.WHITE
| LightColorCapability.COLOR_TEMPERATURE,
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.RGB
| LightColorCapability.COLD_WARM_WHITE,
],
COLOR_MODE_WHITE: [
LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS
| LightColorCapability.WHITE
],
}
def _color_mode_to_ha(mode: int) -> str:
"""Convert an esphome color mode to a HA color mode constant.
Choses the color mode that best matches the feature-set.
"""
candidates = []
for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items():
for caps in cap_lists:
if caps == mode:
# exact match
return ha_mode
if (mode & caps) == caps:
# all requirements met
candidates.append((ha_mode, caps))
if not candidates:
return COLOR_MODE_UNKNOWN
# choose the color mode with the most bits set
candidates.sort(key=lambda key: bin(key[1]).count("1"))
return candidates[-1][0]
def _filter_color_modes(
supported: list[int], features: LightColorCapability
) -> list[int]:
"""Filter the given supported color modes, excluding all values that don't have the requested features."""
return [mode for mode in supported if mode & features]
# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property
@@ -95,10 +151,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
data: dict[str, Any] = {"key": self._static_info.key, "state": True}
# The list of color modes that would fit this service call
color_modes = self._native_supported_color_modes
try_keep_current_mode = True
# rgb/brightness input is in range 0-255, but esphome uses 0-1
if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None:
data["brightness"] = brightness_ha / 255
color_modes = _filter_color_modes(
color_modes, LightColorCapability.BRIGHTNESS
)
if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None:
rgb = tuple(x / 255 for x in rgb_ha)
@@ -106,8 +169,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
# normalize rgb
data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
data["color_brightness"] = color_bri
if self._supports_color_mode:
data["color_mode"] = LightColorMode.RGB
color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB)
try_keep_current_mode = False
if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None:
# pylint: disable=invalid-name
@@ -117,8 +180,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
data["white"] = w
data["color_brightness"] = color_bri
if self._supports_color_mode:
data["color_mode"] = LightColorMode.RGB_WHITE
color_modes = _filter_color_modes(
color_modes, LightColorCapability.RGB | LightColorCapability.WHITE
)
try_keep_current_mode = False
if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None:
# pylint: disable=invalid-name
@@ -126,14 +191,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
color_bri = max(rgb)
# normalize rgb
data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
modes = self._native_supported_color_modes
if (
self._supports_color_mode
and LightColorMode.RGB_COLD_WARM_WHITE in modes
):
color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB)
if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE):
# Device supports setting cwww values directly
data["cold_white"] = cw
data["warm_white"] = ww
target_mode = LightColorMode.RGB_COLD_WARM_WHITE
color_modes = _filter_color_modes(
color_modes, LightColorCapability.COLD_WARM_WHITE
)
else:
# need to convert cw+ww part to white+color_temp
white = data["white"] = max(cw, ww)
@@ -142,11 +207,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
max_ct = self.max_mireds
ct_ratio = ww / (cw + ww)
data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct)
target_mode = LightColorMode.RGB_COLOR_TEMPERATURE
color_modes = _filter_color_modes(
color_modes,
LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE,
)
try_keep_current_mode = False
data["color_brightness"] = color_bri
if self._supports_color_mode:
data["color_mode"] = target_mode
if (flash := kwargs.get(ATTR_FLASH)) is not None:
data["flash_length"] = FLASH_LENGTHS[flash]
@@ -156,12 +223,15 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None:
data["color_temperature"] = color_temp
if self._supports_color_mode:
supported_modes = self._native_supported_color_modes
if LightColorMode.COLOR_TEMPERATURE in supported_modes:
data["color_mode"] = LightColorMode.COLOR_TEMPERATURE
elif LightColorMode.COLD_WARM_WHITE in supported_modes:
data["color_mode"] = LightColorMode.COLD_WARM_WHITE
if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE):
color_modes = _filter_color_modes(
color_modes, LightColorCapability.COLOR_TEMPERATURE
)
else:
color_modes = _filter_color_modes(
color_modes, LightColorCapability.COLD_WARM_WHITE
)
try_keep_current_mode = False
if (effect := kwargs.get(ATTR_EFFECT)) is not None:
data["effect"] = effect
@@ -171,7 +241,30 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
# HA only sends `white` in turn_on, and reads total brightness through brightness property
data["brightness"] = white_ha / 255
data["white"] = 1.0
data["color_mode"] = LightColorMode.WHITE
color_modes = _filter_color_modes(
color_modes,
LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE,
)
try_keep_current_mode = False
if self._supports_color_mode and color_modes:
# try the color mode with the least complexity (fewest capabilities set)
# popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671
color_modes.sort(key=lambda mode: bin(mode).count("1"))
data["color_mode"] = color_modes[0]
if self._supports_color_mode and color_modes:
if (
try_keep_current_mode
and self._state is not None
and self._state.color_mode in color_modes
):
# if possible, stay with the color mode that is already set
data["color_mode"] = self._state.color_mode
else:
# otherwise try the color mode with the least complexity (fewest capabilities set)
# popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671
color_modes.sort(key=lambda mode: bin(mode).count("1"))
data["color_mode"] = color_modes[0]
await self._client.light_command(**data)
@@ -198,7 +291,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
return None
return next(iter(supported))
return _COLOR_MODES.from_esphome(self._state.color_mode)
return _color_mode_to_ha(self._state.color_mode)
@esphome_state_property
def rgb_color(self) -> tuple[int, int, int] | None:
@@ -227,9 +320,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
def rgbww_color(self) -> tuple[int, int, int, int, int] | None:
"""Return the rgbww color value [int, int, int, int, int]."""
rgb = cast("tuple[int, int, int]", self.rgb_color)
if (
not self._supports_color_mode
or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE
if not _filter_color_modes(
self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE
):
# Try to reverse white + color temp to cwww
min_ct = self._static_info.min_mireds
@@ -262,7 +354,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
return self._state.effect
@property
def _native_supported_color_modes(self) -> list[LightColorMode]:
def _native_supported_color_modes(self) -> list[int]:
return self._static_info.supported_color_modes_compat(self._api_version)
@property
@@ -272,7 +364,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
# All color modes except UNKNOWN,ON_OFF support transition
modes = self._native_supported_color_modes
if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes):
if any(m not in (0, LightColorCapability.ON_OFF) for m in modes):
flags |= SUPPORT_TRANSITION
if self._static_info.effects:
flags |= SUPPORT_EFFECT
@@ -281,7 +373,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
@property
def supported_color_modes(self) -> set[str] | None:
"""Flag supported color modes."""
return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes))
supported = set(map(_color_mode_to_ha, self._native_supported_color_modes))
if COLOR_MODE_ONOFF in supported and len(supported) > 1:
supported.remove(COLOR_MODE_ONOFF)
if COLOR_MODE_BRIGHTNESS in supported and len(supported) > 1:
supported.remove(COLOR_MODE_BRIGHTNESS)
if COLOR_MODE_WHITE in supported and len(supported) == 1:
supported.remove(COLOR_MODE_WHITE)
return supported
@property
def effect_list(self) -> list[str]:

View File

@@ -3,7 +3,7 @@
"name": "ESPHome",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/esphome",
"requirements": ["aioesphomeapi==7.0.0"],
"requirements": ["aioesphomeapi==8.0.0"],
"zeroconf": ["_esphomelib._tcp.local."],
"codeowners": ["@OttoWinter", "@jesserockz"],
"after_dependencies": ["zeroconf", "tag"],

View File

@@ -5,7 +5,13 @@ import datetime
import logging
from typing import Callable, TypedDict
from fritzconnection.core.exceptions import FritzConnectionException
from fritzconnection.core.exceptions import (
FritzActionError,
FritzActionFailedError,
FritzConnectionException,
FritzInternalError,
FritzServiceError,
)
from fritzconnection.lib.fritzstatus import FritzStatus
from homeassistant.components.sensor import (
@@ -108,28 +114,28 @@ def _retrieve_link_noise_margin_sent_state(
status: FritzStatus, last_value: str
) -> float:
"""Return upload noise margin."""
return status.noise_margin[0] # type: ignore[no-any-return]
return status.noise_margin[0] / 10 # type: ignore[no-any-return]
def _retrieve_link_noise_margin_received_state(
status: FritzStatus, last_value: str
) -> float:
"""Return download noise margin."""
return status.noise_margin[1] # type: ignore[no-any-return]
return status.noise_margin[1] / 10 # type: ignore[no-any-return]
def _retrieve_link_attenuation_sent_state(
status: FritzStatus, last_value: str
) -> float:
"""Return upload line attenuation."""
return status.attenuation[0] # type: ignore[no-any-return]
return status.attenuation[0] / 10 # type: ignore[no-any-return]
def _retrieve_link_attenuation_received_state(
status: FritzStatus, last_value: str
) -> float:
"""Return download line attenuation."""
return status.attenuation[1] # type: ignore[no-any-return]
return status.attenuation[1] / 10 # type: ignore[no-any-return]
class SensorData(TypedDict, total=False):
@@ -260,12 +266,21 @@ async def async_setup_entry(
return
entities = []
dslinterface = await hass.async_add_executor_job(
fritzbox_tools.connection.call_action,
"WANDSLInterfaceConfig:1",
"GetInfo",
)
dsl: bool = dslinterface["NewEnable"]
dsl: bool = False
try:
dslinterface = await hass.async_add_executor_job(
fritzbox_tools.connection.call_action,
"WANDSLInterfaceConfig:1",
"GetInfo",
)
dsl = dslinterface["NewEnable"]
except (
FritzInternalError,
FritzActionError,
FritzActionFailedError,
FritzServiceError,
):
pass
for sensor_type, sensor_data in SENSOR_DATA.items():
if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION:

View File

@@ -3,7 +3,7 @@
"name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": [
"home-assistant-frontend==20210825.0"
"home-assistant-frontend==20210830.0"
],
"dependencies": [
"api",

View File

@@ -133,6 +133,7 @@ DOMAIN_TO_GOOGLE_TYPES = {
media_player.DOMAIN: TYPE_SETTOP,
scene.DOMAIN: TYPE_SCENE,
script.DOMAIN: TYPE_SCENE,
sensor.DOMAIN: TYPE_SENSOR,
select.DOMAIN: TYPE_SENSOR,
switch.DOMAIN: TYPE_SWITCH,
vacuum.DOMAIN: TYPE_VACUUM,

View File

@@ -108,6 +108,7 @@ TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState"
TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel"
TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator"
TRAIT_ENERGYSTORAGE = f"{PREFIX_TRAITS}EnergyStorage"
TRAIT_SENSOR_STATE = f"{PREFIX_TRAITS}SensorState"
PREFIX_COMMANDS = "action.devices.commands."
COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff"
@@ -2286,3 +2287,57 @@ class ChannelTrait(_Trait):
blocking=True,
context=data.context,
)
@register_trait
class SensorStateTrait(_Trait):
"""Trait to get sensor state.
https://developers.google.com/actions/smarthome/traits/sensorstate
"""
sensor_types = {
sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"),
sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"),
sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"),
sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"),
sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"),
sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: (
"VolatileOrganicCompounds",
"PARTS_PER_MILLION",
),
}
name = TRAIT_SENSOR_STATE
commands = []
@classmethod
def supported(cls, domain, features, device_class, _):
"""Test if state is supported."""
return (
domain == sensor.DOMAIN
and device_class in SensorStateTrait.sensor_types.keys()
)
def sync_attributes(self):
"""Return attributes for a sync request."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
data = self.sensor_types.get(device_class)
if data is not None:
return {
"sensorStatesSupported": {
"name": data[0],
"numericCapabilities": {"rawValueUnit": data[1]},
}
}
def query_attributes(self):
"""Return the attributes of this trait for this entity."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
data = self.sensor_types.get(device_class)
if data is not None:
return {
"currentSensorStateData": [
{"name": data[0], "rawValue": self.state.state}
]
}

View File

@@ -7,7 +7,7 @@ DEFAULT_NAME = "Growatt"
SERVER_URLS = [
"https://server.growatt.com/",
"https://server-us.growatt.com",
"https://server-us.growatt.com/",
"http://server.smten.com/",
]

View File

@@ -10,6 +10,7 @@ import aiohttp
from aiohttp import web
from aiohttp.client import ClientTimeout
from aiohttp.hdrs import (
CACHE_CONTROL,
CONTENT_ENCODING,
CONTENT_LENGTH,
CONTENT_TYPE,
@@ -51,6 +52,8 @@ NO_AUTH = re.compile(
r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$"
)
NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$")
class HassIOView(HomeAssistantView):
"""Hass.io view to handle base part."""
@@ -104,7 +107,7 @@ class HassIOView(HomeAssistantView):
# Stream response
response = web.StreamResponse(
status=client.status, headers=_response_header(client)
status=client.status, headers=_response_header(client, path)
)
response.content_type = client.content_type
@@ -139,7 +142,7 @@ def _init_header(request: web.Request) -> dict[str, str]:
return headers
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]:
"""Create response header."""
headers = {}
@@ -153,6 +156,9 @@ def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
continue
headers[name] = value
if NO_STORE.match(path):
headers[CACHE_CONTROL] = "no-store, max-age=0"
return headers

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
from homeassistant.components.switch import DOMAIN, SwitchEntity
from homeassistant.const import STATE_OFF, STATE_ON
from . import ATTR_NEW, CecEntity
@@ -34,17 +35,25 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
def turn_on(self, **kwargs) -> None:
"""Turn device on."""
self._device.turn_on()
self._attr_is_on = True
self._state = STATE_ON
self.schedule_update_ha_state(force_refresh=False)
def turn_off(self, **kwargs) -> None:
"""Turn device off."""
self._device.turn_off()
self._attr_is_on = False
self._state = STATE_OFF
self.schedule_update_ha_state(force_refresh=False)
def toggle(self, **kwargs):
"""Toggle the entity."""
self._device.toggle()
self._attr_is_on = not self._attr_is_on
if self._state == STATE_ON:
self._state = STATE_OFF
else:
self._state = STATE_ON
self.schedule_update_ha_state(force_refresh=False)
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self._state == STATE_ON

View File

@@ -498,7 +498,10 @@ async def _async_get_supported_devices(hass):
"""Return all supported devices."""
results = await device_automation.async_get_device_automations(hass, "trigger")
dev_reg = device_registry.async_get(hass)
unsorted = {device_id: dev_reg.async_get(device_id).name for device_id in results}
unsorted = {
device_id: dev_reg.async_get(device_id).name or device_id
for device_id in results
}
return dict(sorted(unsorted.items(), key=lambda item: item[1]))

View File

@@ -1,4 +1,5 @@
"""Support for Honeywell (US) Total Connect Comfort climate systems."""
import asyncio
from datetime import timedelta
import somecomfort
@@ -9,7 +10,8 @@ from homeassistant.util import Throttle
from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
UPDATE_LOOP_SLEEP_TIME = 5
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300)
PLATFORMS = ["climate"]
@@ -42,7 +44,7 @@ async def async_setup_entry(hass, config):
return False
data = HoneywellData(hass, client, username, password, devices)
await data.update()
await data.async_update()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config.entry_id] = data
hass.config_entries.async_setup_platforms(config, PLATFORMS)
@@ -102,18 +104,19 @@ class HoneywellData:
self.devices = devices
return True
def _refresh_devices(self):
async def _refresh_devices(self):
"""Refresh each enabled device."""
for device in self.devices:
device.refresh()
await self._hass.async_add_executor_job(device.refresh)
await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME)
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def update(self) -> None:
async def async_update(self) -> None:
"""Update the state."""
retries = 3
while retries > 0:
try:
await self._hass.async_add_executor_job(self._refresh_devices)
await self._refresh_devices()
break
except (
somecomfort.client.APIRateLimited,
@@ -124,7 +127,7 @@ class HoneywellData:
if retries == 0:
raise exp
result = await self._hass.async_add_executor_job(self._retry())
result = await self._retry()
if not result:
raise exp

View File

@@ -107,6 +107,8 @@ HW_FAN_MODE_TO_HA = {
"follow schedule": FAN_AUTO,
}
PARALLEL_UPDATES = 1
async def async_setup_entry(hass, config, async_add_entities, discovery_info=None):
"""Set up the Honeywell thermostat."""
@@ -384,4 +386,4 @@ class HoneywellUSThermostat(ClimateEntity):
async def async_update(self):
"""Get the latest state from the service."""
await self._data.update()
await self._data.async_update()

View File

@@ -118,12 +118,16 @@ async def async_validate_trigger_config(hass, config):
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
if (
not device
or device.model not in REMOTES
or trigger not in REMOTES[device.model]
):
raise InvalidDeviceAutomationConfig
if not device:
raise InvalidDeviceAutomationConfig("Device {config[CONF_DEVICE_ID]} not found")
if device.model not in REMOTES:
raise InvalidDeviceAutomationConfig(
f"Device model {device.model} is not a remote"
)
if trigger not in REMOTES[device.model]:
raise InvalidDeviceAutomationConfig("Device does not support trigger {trigger}")
return config

View File

@@ -282,12 +282,14 @@ class HueLight(CoordinatorEntity, LightEntity):
self.is_osram = False
self.is_philips = False
self.is_innr = False
self.is_livarno = False
self.gamut_typ = GAMUT_TYPE_UNAVAILABLE
self.gamut = None
else:
self.is_osram = light.manufacturername == "OSRAM"
self.is_philips = light.manufacturername == "Philips"
self.is_innr = light.manufacturername == "innr"
self.is_livarno = light.manufacturername.startswith("_TZ3000_")
self.gamut_typ = self.light.colorgamuttype
self.gamut = self.light.colorgamut
_LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut))
@@ -383,6 +385,8 @@ class HueLight(CoordinatorEntity, LightEntity):
"""Return the warmest color_temp that this light supports."""
if self.is_group:
return super().max_mireds
if self.is_livarno:
return 500
max_mireds = self.light.controlcapabilities.get("ct", {}).get("max")
@@ -493,7 +497,7 @@ class HueLight(CoordinatorEntity, LightEntity):
elif flash == FLASH_SHORT:
command["alert"] = "select"
del command["on"]
elif not self.is_innr:
elif not self.is_innr and not self.is_livarno:
command["alert"] = "none"
if ATTR_EFFECT in kwargs:
@@ -532,7 +536,7 @@ class HueLight(CoordinatorEntity, LightEntity):
elif flash == FLASH_SHORT:
command["alert"] = "select"
del command["on"]
elif not self.is_innr:
elif not self.is_innr and not self.is_livarno:
command["alert"] = "none"
if self.is_group:

View File

@@ -177,8 +177,6 @@ class PowerViewShade(ShadeEntity, CoverEntity):
"""Move the shade to a position."""
current_hass_position = hd_position_to_hass(self._current_cover_position)
steps_to_move = abs(current_hass_position - target_hass_position)
if not steps_to_move:
return
self._async_schedule_update_for_transition(steps_to_move)
self._async_update_from_command(
await self._shade.move(

View File

@@ -67,13 +67,13 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity):
@property
def min_temp(self) -> float:
"""Return max valid temperature that can be set."""
return 80.0
"""Return min valid temperature that can be set."""
return 30.0
@property
def max_temp(self) -> float:
"""Return max valid temperature that can be set."""
return 30.0
return 80.0
@property
def temperature_unit(self) -> str:

View File

@@ -106,20 +106,14 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
"""Initialize the integration sensor."""
self._sensor_source_id = source_entity
self._round_digits = round_digits
self._state = 0
self._state = STATE_UNAVAILABLE
self._method = integration_method
self._name = name if name is not None else f"{source_entity} integral"
if unit_of_measurement is None:
self._unit_template = (
f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}"
)
# we postpone the definition of unit_of_measurement to later
self._unit_of_measurement = None
else:
self._unit_of_measurement = unit_of_measurement
self._unit_template = (
f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}"
)
self._unit_of_measurement = unit_of_measurement
self._unit_prefix = UNIT_PREFIXES[unit_prefix]
self._unit_time = UNIT_TIME[unit_time]
self._attr_state_class = STATE_CLASS_TOTAL_INCREASING
@@ -135,10 +129,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
_LOGGER.warning("Could not restore last state: %s", err)
else:
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
self._unit_of_measurement = state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT
)
if self._unit_of_measurement is None:
self._unit_of_measurement = state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT
)
@callback
def calc_integration(event):
@@ -193,7 +187,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
except AssertionError as err:
_LOGGER.error("Could not calculate integral: %s", err)
else:
self._state += integral
if isinstance(self._state, Decimal):
self._state += integral
else:
self._state = integral
self.async_write_ha_state()
async_track_state_change_event(
@@ -208,7 +205,9 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
@property
def native_value(self):
"""Return the state of the sensor."""
return round(self._state, self._round_digits)
if isinstance(self._state, Decimal):
return round(self._state, self._round_digits)
return self._state
@property
def native_unit_of_measurement(self):

View File

@@ -0,0 +1,24 @@
"""The iotawatt integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import IotawattUpdater
PLATFORMS = ("sensor",)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up iotawatt from a config entry."""
coordinator = IotawattUpdater(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -0,0 +1,107 @@
"""Config flow for iotawatt integration."""
from __future__ import annotations
import logging
from iotawattpy.iotawatt import Iotawatt
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import httpx_client
from .const import CONNECTION_ERRORS, DOMAIN
_LOGGER = logging.getLogger(__name__)
async def validate_input(
hass: core.HomeAssistant, data: dict[str, str]
) -> dict[str, str]:
"""Validate the user input allows us to connect."""
iotawatt = Iotawatt(
"",
data[CONF_HOST],
httpx_client.get_async_client(hass),
data.get(CONF_USERNAME),
data.get(CONF_PASSWORD),
)
try:
is_connected = await iotawatt.connect()
except CONNECTION_ERRORS:
return {"base": "cannot_connect"}
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return {"base": "unknown"}
if not is_connected:
return {"base": "invalid_auth"}
return {}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for iotawatt."""
VERSION = 1
def __init__(self):
"""Initialize."""
self._data = {}
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
if user_input is None:
user_input = {}
schema = vol.Schema(
{
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
}
)
if not user_input:
return self.async_show_form(step_id="user", data_schema=schema)
if not (errors := await validate_input(self.hass, user_input)):
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
if errors == {"base": "invalid_auth"}:
self._data.update(user_input)
return await self.async_step_auth()
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_auth(self, user_input=None):
"""Authenticate user if authentication is enabled on the IoTaWatt device."""
if user_input is None:
user_input = {}
data_schema = vol.Schema(
{
vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
): str,
vol.Required(
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
): str,
}
)
if not user_input:
return self.async_show_form(step_id="auth", data_schema=data_schema)
data = {**self._data, **user_input}
if errors := await validate_input(self.hass, data):
return self.async_show_form(
step_id="auth", data_schema=data_schema, errors=errors
)
return self.async_create_entry(title=data[CONF_HOST], data=data)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@@ -0,0 +1,12 @@
"""Constants for the IoTaWatt integration."""
from __future__ import annotations
import json
import httpx
DOMAIN = "iotawatt"
VOLT_AMPERE_REACTIVE = "VAR"
VOLT_AMPERE_REACTIVE_HOURS = "VARh"
CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError)

View File

@@ -0,0 +1,56 @@
"""IoTaWatt DataUpdateCoordinator."""
from __future__ import annotations
from datetime import timedelta
import logging
from iotawattpy.iotawatt import Iotawatt
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import httpx_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONNECTION_ERRORS
_LOGGER = logging.getLogger(__name__)
class IotawattUpdater(DataUpdateCoordinator):
"""Class to manage fetching update data from the IoTaWatt Energy Device."""
api: Iotawatt | None = None
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize IotaWattUpdater object."""
self.entry = entry
super().__init__(
hass=hass,
logger=_LOGGER,
name=entry.title,
update_interval=timedelta(seconds=30),
)
async def _async_update_data(self):
"""Fetch sensors from IoTaWatt device."""
if self.api is None:
api = Iotawatt(
self.entry.title,
self.entry.data[CONF_HOST],
httpx_client.get_async_client(self.hass),
self.entry.data.get(CONF_USERNAME),
self.entry.data.get(CONF_PASSWORD),
)
try:
is_authenticated = await api.connect()
except CONNECTION_ERRORS as err:
raise UpdateFailed("Connection failed") from err
if not is_authenticated:
raise UpdateFailed("Authentication error")
self.api = api
await self.api.update()
return self.api.getSensors()

View File

@@ -0,0 +1,13 @@
{
"domain": "iotawatt",
"name": "IoTaWatt",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iotawatt",
"requirements": [
"iotawattpy==0.0.8"
],
"codeowners": [
"@gtdiehl"
],
"iot_class": "local_polling"
}

View File

@@ -0,0 +1,218 @@
"""Support for IoTaWatt Energy monitor."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from iotawattpy.sensor import Sensor
from homeassistant.components.sensor import (
STATE_CLASS_MEASUREMENT,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import (
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_VOLTAGE,
ELECTRIC_CURRENT_AMPERE,
ELECTRIC_POTENTIAL_VOLT,
ENERGY_WATT_HOUR,
FREQUENCY_HERTZ,
PERCENTAGE,
POWER_VOLT_AMPERE,
POWER_WATT,
)
from homeassistant.core import callback
from homeassistant.helpers import entity, entity_registry, update_coordinator
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS
from .coordinator import IotawattUpdater
@dataclass
class IotaWattSensorEntityDescription(SensorEntityDescription):
"""Class describing IotaWatt sensor entities."""
value: Callable | None = None
ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = {
"Amps": IotaWattSensorEntityDescription(
"Amps",
native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_CURRENT,
entity_registry_enabled_default=False,
),
"Hz": IotaWattSensorEntityDescription(
"Hz",
native_unit_of_measurement=FREQUENCY_HERTZ,
state_class=STATE_CLASS_MEASUREMENT,
icon="mdi:flash",
entity_registry_enabled_default=False,
),
"PF": IotaWattSensorEntityDescription(
"PF",
native_unit_of_measurement=PERCENTAGE,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_POWER_FACTOR,
value=lambda value: value * 100,
entity_registry_enabled_default=False,
),
"Watts": IotaWattSensorEntityDescription(
"Watts",
native_unit_of_measurement=POWER_WATT,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_POWER,
),
"WattHours": IotaWattSensorEntityDescription(
"WattHours",
native_unit_of_measurement=ENERGY_WATT_HOUR,
device_class=DEVICE_CLASS_ENERGY,
),
"VA": IotaWattSensorEntityDescription(
"VA",
native_unit_of_measurement=POWER_VOLT_AMPERE,
state_class=STATE_CLASS_MEASUREMENT,
icon="mdi:flash",
entity_registry_enabled_default=False,
),
"VAR": IotaWattSensorEntityDescription(
"VAR",
native_unit_of_measurement=VOLT_AMPERE_REACTIVE,
state_class=STATE_CLASS_MEASUREMENT,
icon="mdi:flash",
entity_registry_enabled_default=False,
),
"VARh": IotaWattSensorEntityDescription(
"VARh",
native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS,
state_class=STATE_CLASS_MEASUREMENT,
icon="mdi:flash",
entity_registry_enabled_default=False,
),
"Volts": IotaWattSensorEntityDescription(
"Volts",
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_VOLTAGE,
entity_registry_enabled_default=False,
),
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add sensors for passed config_entry in HA."""
coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id]
created = set()
@callback
def _create_entity(key: str) -> IotaWattSensor:
"""Create a sensor entity."""
created.add(key)
return IotaWattSensor(
coordinator=coordinator,
key=key,
mac_address=coordinator.data["sensors"][key].hub_mac_address,
name=coordinator.data["sensors"][key].getName(),
entity_description=ENTITY_DESCRIPTION_KEY_MAP.get(
coordinator.data["sensors"][key].getUnit(),
IotaWattSensorEntityDescription("base_sensor"),
),
)
async_add_entities(_create_entity(key) for key in coordinator.data["sensors"])
@callback
def new_data_received():
"""Check for new sensors."""
entities = [
_create_entity(key)
for key in coordinator.data["sensors"]
if key not in created
]
if entities:
async_add_entities(entities)
coordinator.async_add_listener(new_data_received)
class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity):
"""Defines a IoTaWatt Energy Sensor."""
entity_description: IotaWattSensorEntityDescription
_attr_force_update = True
def __init__(
self,
coordinator,
key,
mac_address,
name,
entity_description: IotaWattSensorEntityDescription,
):
"""Initialize the sensor."""
super().__init__(coordinator=coordinator)
self._key = key
data = self._sensor_data
if data.getType() == "Input":
self._attr_unique_id = (
f"{data.hub_mac_address}-input-{data.getChannel()}-{data.getUnit()}"
)
self.entity_description = entity_description
@property
def _sensor_data(self) -> Sensor:
"""Return sensor data."""
return self.coordinator.data["sensors"][self._key]
@property
def name(self) -> str | None:
"""Return name of the entity."""
return self._sensor_data.getName()
@property
def device_info(self) -> entity.DeviceInfo | None:
"""Return device info."""
return {
"connections": {
(CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address)
},
"manufacturer": "IoTaWatt",
"model": "IoTaWatt",
}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self._key not in self.coordinator.data["sensors"]:
if self._attr_unique_id:
entity_registry.async_get(self.hass).async_remove(self.entity_id)
else:
self.hass.async_create_task(self.async_remove())
return
super()._handle_coordinator_update()
@property
def extra_state_attributes(self):
"""Return the extra state attributes of the entity."""
data = self._sensor_data
attrs = {"type": data.getType()}
if attrs["type"] == "Input":
attrs["channel"] = data.getChannel()
return attrs
@property
def native_value(self) -> entity.StateType:
"""Return the state of the sensor."""
if func := self.entity_description.value:
return func(self._sensor_data.getValue())
return self._sensor_data.getValue()

View File

@@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"auth": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"config": {
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"auth": {
"data": {
"password": "Password",
"username": "Username"
},
"description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button."
},
"user": {
"data": {
"host": "Host"
}
}
}
},
"title": "iotawatt"
}

View File

@@ -3,7 +3,7 @@
"name": "IQVIA",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iqvia",
"requirements": ["numpy==1.21.1", "pyiqvia==1.0.0"],
"requirements": ["numpy==1.21.1", "pyiqvia==1.1.0"],
"codeowners": ["@bachya"],
"iot_class": "cloud_polling"
}

View File

@@ -470,7 +470,7 @@ class LIFXLight(LightEntity):
model = product_map.get(self.bulb.product) or self.bulb.product
if model is not None:
info["model"] = model
info["model"] = str(model)
return info

View File

@@ -3,7 +3,7 @@
"name": "LIFX",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lifx",
"requirements": ["aiolifx==0.6.9", "aiolifx_effects==0.2.2"],
"requirements": ["aiolifx==0.7.0", "aiolifx_effects==0.2.2"],
"homekit": {
"models": ["LIFX"]
},

View File

@@ -445,7 +445,11 @@ async def async_setup(hass, config): # noqa: C901
)
# If both white and brightness are specified, override white
if ATTR_WHITE in params and COLOR_MODE_WHITE in supported_color_modes:
if (
supported_color_modes
and ATTR_WHITE in params
and COLOR_MODE_WHITE in supported_color_modes
):
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
# Remove deprecated white value if the light supports color mode

View File

@@ -4,10 +4,7 @@ from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.significant_change import (
check_numeric_changed,
either_one_none,
)
from homeassistant.helpers.significant_change import check_absolute_change
from . import (
ATTR_BRIGHTNESS,
@@ -37,24 +34,21 @@ def async_check_significant_change(
old_color = old_attrs.get(ATTR_HS_COLOR)
new_color = new_attrs.get(ATTR_HS_COLOR)
if either_one_none(old_color, new_color):
return True
if old_color and new_color:
# Range 0..360
if check_numeric_changed(old_color[0], new_color[0], 5):
if check_absolute_change(old_color[0], new_color[0], 5):
return True
# Range 0..100
if check_numeric_changed(old_color[1], new_color[1], 3):
if check_absolute_change(old_color[1], new_color[1], 3):
return True
if check_numeric_changed(
if check_absolute_change(
old_attrs.get(ATTR_BRIGHTNESS), new_attrs.get(ATTR_BRIGHTNESS), 3
):
return True
if check_numeric_changed(
if check_absolute_change(
# Default range 153..500
old_attrs.get(ATTR_COLOR_TEMP),
new_attrs.get(ATTR_COLOR_TEMP),
@@ -62,7 +56,7 @@ def async_check_significant_change(
):
return True
if check_numeric_changed(
if check_absolute_change(
# Range 0..255
old_attrs.get(ATTR_WHITE_VALUE),
new_attrs.get(ATTR_WHITE_VALUE),

View File

@@ -3,7 +3,7 @@
"name": "Litter-Robot",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/litterrobot",
"requirements": ["pylitterbot==2021.8.0"],
"requirements": ["pylitterbot==2021.8.1"],
"codeowners": ["@natekspencer"],
"iot_class": "cloud_polling"
}

View File

@@ -48,7 +48,7 @@ from homeassistant.helpers.integration_platform import (
from homeassistant.loader import bind_hass
import homeassistant.util.dt as dt_util
ENTITY_ID_JSON_TEMPLATE = '"entity_id": ?"{}"'
ENTITY_ID_JSON_TEMPLATE = '"entity_id":"{}"'
ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"')
DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"')
ICON_JSON_EXTRACT = re.compile('"icon": ?"([^"]+)"')

View File

@@ -3,7 +3,7 @@
"name": "Mazda Connected Services",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mazda",
"requirements": ["pymazda==0.2.0"],
"requirements": ["pymazda==0.2.1"],
"codeowners": ["@bdr99"],
"quality_scale": "platinum",
"iot_class": "cloud_polling"

View File

@@ -243,7 +243,7 @@ class ModbusHub:
self._msg_wait = 0
def _log_error(self, text: str, error_state=True):
log_text = f"Pymodbus: {text}"
log_text = f"Pymodbus: {self.name}: {text}"
if self._in_error:
_LOGGER.debug(log_text)
else:

View File

@@ -10,6 +10,8 @@ import voluptuous as vol
from homeassistant.const import (
CONF_ADDRESS,
CONF_COMMAND_OFF,
CONF_COMMAND_ON,
CONF_COUNT,
CONF_HOST,
CONF_NAME,
@@ -23,9 +25,11 @@ from homeassistant.const import (
from .const import (
CONF_DATA_TYPE,
CONF_INPUT_TYPE,
CONF_SWAP,
CONF_SWAP_BYTE,
CONF_SWAP_NONE,
CONF_WRITE_TYPE,
DATA_TYPE_CUSTOM,
DATA_TYPE_FLOAT,
DATA_TYPE_FLOAT16,
@@ -201,15 +205,23 @@ def scan_interval_validator(config: dict) -> dict:
def duplicate_entity_validator(config: dict) -> dict:
"""Control scan_interval."""
for hub_index, hub in enumerate(config):
addresses: set[str] = set()
for component, conf_key in PLATFORMS:
if conf_key not in hub:
continue
names: set[str] = set()
errors: list[int] = []
addresses: set[str] = set()
for index, entry in enumerate(hub[conf_key]):
name = entry[CONF_NAME]
addr = str(entry[CONF_ADDRESS])
if CONF_INPUT_TYPE in entry:
addr += "_" + str(entry[CONF_INPUT_TYPE])
elif CONF_WRITE_TYPE in entry:
addr += "_" + str(entry[CONF_WRITE_TYPE])
if CONF_COMMAND_ON in entry:
addr += "_" + str(entry[CONF_COMMAND_ON])
if CONF_COMMAND_OFF in entry:
addr += "_" + str(entry[CONF_COMMAND_OFF])
if CONF_SLAVE in entry:
addr += "_" + str(entry[CONF_SLAVE])
if addr in addresses:
@@ -236,7 +248,10 @@ def duplicate_modbus_validator(config: list) -> list:
errors = []
for index, hub in enumerate(config):
name = hub.get(CONF_NAME, DEFAULT_HUB)
host = hub[CONF_PORT] if hub[CONF_TYPE] == SERIAL else hub[CONF_HOST]
if hub[CONF_TYPE] == SERIAL:
host = hub[CONF_PORT]
else:
host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}"
if host in hosts:
err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!"
_LOGGER.warning(err)

View File

@@ -95,8 +95,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_hassio(self, discovery_info):
"""Receive a Hass.io discovery."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
await self._async_handle_discovery_without_unique_id()
self._hassio_discovery = discovery_info

View File

@@ -53,10 +53,34 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset(
DEFAULT_NAME = "MQTT Sensor"
DEFAULT_FORCE_UPDATE = False
def validate_options(conf):
"""Validate options.
If last reset topic is present it must be same as the state topic.
"""
if (
CONF_LAST_RESET_TOPIC in conf
and CONF_STATE_TOPIC in conf
and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC]
):
_LOGGER.warning(
"'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC
)
if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf:
_LOGGER.warning(
"'%s' must be set if '%s' is set",
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_LAST_RESET_TOPIC,
)
return conf
PLATFORM_SCHEMA = vol.All(
# Deprecated, remove in Home Assistant 2021.11
cv.deprecated(CONF_LAST_RESET_TOPIC),
cv.deprecated(CONF_LAST_RESET_VALUE_TEMPLATE),
mqtt.MQTT_RO_PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
@@ -69,6 +93,7 @@ PLATFORM_SCHEMA = vol.All(
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema),
validate_options,
)
@@ -132,10 +157,7 @@ class MqttSensor(MqttEntity, SensorEntity):
"""(Re)Subscribe to topics."""
topics = {}
@callback
@log_messages(self.hass, self.entity_id)
def message_received(msg):
"""Handle new MQTT messages."""
def _update_state(msg):
payload = msg.payload
# auto-expire enabled?
expire_after = self._config.get(CONF_EXPIRE_AFTER)
@@ -164,18 +186,8 @@ class MqttSensor(MqttEntity, SensorEntity):
variables=variables,
)
self._state = payload
self.async_write_ha_state()
topics["state_topic"] = {
"topic": self._config[CONF_STATE_TOPIC],
"msg_callback": message_received,
"qos": self._config[CONF_QOS],
}
@callback
@log_messages(self.hass, self.entity_id)
def last_reset_message_received(msg):
"""Handle new last_reset messages."""
def _update_last_reset(msg):
payload = msg.payload
template = self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE)
@@ -198,9 +210,36 @@ class MqttSensor(MqttEntity, SensorEntity):
_LOGGER.warning(
"Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic
)
@callback
@log_messages(self.hass, self.entity_id)
def message_received(msg):
"""Handle new MQTT messages."""
_update_state(msg)
if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and (
CONF_LAST_RESET_TOPIC not in self._config
or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC]
):
_update_last_reset(msg)
self.async_write_ha_state()
if CONF_LAST_RESET_TOPIC in self._config:
topics["state_topic"] = {
"topic": self._config[CONF_STATE_TOPIC],
"msg_callback": message_received,
"qos": self._config[CONF_QOS],
}
@callback
@log_messages(self.hass, self.entity_id)
def last_reset_message_received(msg):
"""Handle new last_reset messages."""
_update_last_reset(msg)
self.async_write_ha_state()
if (
CONF_LAST_RESET_TOPIC in self._config
and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC]
):
topics["last_reset_topic"] = {
"topic": self._config[CONF_LAST_RESET_TOPIC],
"msg_callback": last_reset_message_received,

View File

@@ -20,6 +20,7 @@
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {

View File

@@ -1,6 +1,7 @@
{
"config": {
"abort": {
"already_configured": "Service is already configured",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"error": {

View File

@@ -14,7 +14,11 @@ from getmac import get_mac_address
from mac_vendor_lookup import AsyncMacLookup
from nmap import PortScanner, PortScannerError
from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL
from homeassistant.components.device_tracker.const import (
CONF_CONSIDER_HOME,
CONF_SCAN_INTERVAL,
DEFAULT_CONSIDER_HOME,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState, HomeAssistant, callback
@@ -37,7 +41,6 @@ from .const import (
# Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n'
NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true"
MAX_SCAN_ATTEMPTS: Final = 16
OFFLINE_SCANS_TO_MARK_UNAVAILABLE: Final = 3
def short_hostname(hostname: str) -> str:
@@ -65,7 +68,7 @@ class NmapDevice:
manufacturer: str
reason: str
last_update: datetime
offline_scans: int
first_offline: datetime | None
class NmapTrackedDevices:
@@ -137,6 +140,7 @@ class NmapDeviceScanner:
"""Initialize the scanner."""
self.devices = devices
self.home_interval = None
self.consider_home = DEFAULT_CONSIDER_HOME
self._hass = hass
self._entry = entry
@@ -170,6 +174,10 @@ class NmapDeviceScanner:
self.home_interval = timedelta(
minutes=cv.positive_int(config[CONF_HOME_INTERVAL])
)
if config.get(CONF_CONSIDER_HOME):
self.consider_home = timedelta(
seconds=cv.positive_float(config[CONF_CONSIDER_HOME])
)
self._scan_lock = asyncio.Lock()
if self._hass.state == CoreState.running:
await self._async_start_scanner()
@@ -320,16 +328,35 @@ class NmapDeviceScanner:
return result
@callback
def _async_increment_device_offline(self, ipv4, reason):
def _async_device_offline(self, ipv4: str, reason: str, now: datetime) -> None:
"""Mark an IP offline."""
if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)):
return
if not (device := self.devices.tracked.get(formatted_mac)):
# Device was unloaded
return
device.offline_scans += 1
if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE:
if not device.first_offline:
_LOGGER.debug(
"Setting first_offline for %s (%s) to: %s", ipv4, formatted_mac, now
)
device.first_offline = now
return
if device.first_offline + self.consider_home > now:
_LOGGER.debug(
"Device %s (%s) has NOT been offline (first offline at: %s) long enough to be considered not home: %s",
ipv4,
formatted_mac,
device.first_offline,
self.consider_home,
)
return
_LOGGER.debug(
"Device %s (%s) has been offline (first offline at: %s) long enough to be considered not home: %s",
ipv4,
formatted_mac,
device.first_offline,
self.consider_home,
)
device.reason = reason
async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False)
del self.devices.ipv4_last_mac[ipv4]
@@ -347,7 +374,7 @@ class NmapDeviceScanner:
status = info["status"]
reason = status["reason"]
if status["state"] != "up":
self._async_increment_device_offline(ipv4, reason)
self._async_device_offline(ipv4, reason, now)
continue
# Mac address only returned if nmap ran as root
mac = info["addresses"].get(
@@ -356,19 +383,11 @@ class NmapDeviceScanner:
partial(get_mac_address, ip=ipv4)
)
if mac is None:
self._async_increment_device_offline(ipv4, "No MAC address found")
self._async_device_offline(ipv4, "No MAC address found", now)
_LOGGER.info("No MAC address found for %s", ipv4)
continue
formatted_mac = format_mac(mac)
new = formatted_mac not in devices.tracked
if (
new
and formatted_mac not in devices.tracked
and formatted_mac not in self._known_mac_addresses
):
continue
if (
devices.config_entry_owner.setdefault(formatted_mac, entry_id)
!= entry_id
@@ -379,9 +398,10 @@ class NmapDeviceScanner:
vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac)
name = human_readable_name(hostname, vendor, mac)
device = NmapDevice(
formatted_mac, hostname, name, ipv4, vendor, reason, now, 0
formatted_mac, hostname, name, ipv4, vendor, reason, now, None
)
new = formatted_mac not in devices.tracked
devices.tracked[formatted_mac] = device
devices.ipv4_last_mac[ipv4] = formatted_mac
self._last_results.append(device)

View File

@@ -8,7 +8,11 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import network
from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL
from homeassistant.components.device_tracker.const import (
CONF_CONSIDER_HOME,
CONF_SCAN_INTERVAL,
DEFAULT_CONSIDER_HOME,
)
from homeassistant.components.network.const import MDNS_TARGET_IP
from homeassistant.config_entries import ConfigEntry, OptionsFlow
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
@@ -24,6 +28,8 @@ from .const import (
TRACKER_SCAN_INTERVAL,
)
MAX_SCAN_INTERVAL = 3600
MAX_CONSIDER_HOME = MAX_SCAN_INTERVAL * 6
DEFAULT_NETWORK_PREFIX = 24
@@ -116,7 +122,12 @@ async def _async_build_schema_with_user_input(
vol.Optional(
CONF_SCAN_INTERVAL,
default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL),
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
): vol.All(vol.Coerce(int), vol.Range(min=10, max=MAX_SCAN_INTERVAL)),
vol.Optional(
CONF_CONSIDER_HOME,
default=user_input.get(CONF_CONSIDER_HOME)
or DEFAULT_CONSIDER_HOME.total_seconds(),
): vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_CONSIDER_HOME)),
}
)
return vol.Schema(schema)

View File

@@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import (
SOURCE_TYPE_ROUTER,
)
from homeassistant.components.device_tracker.config_entry import ScannerEntity
from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL
from homeassistant.components.device_tracker.const import (
CONF_CONSIDER_HOME,
CONF_SCAN_INTERVAL,
DEFAULT_CONSIDER_HOME,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
from homeassistant.core import HomeAssistant, callback
@@ -38,6 +42,9 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOSTS): cv.ensure_list,
vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int,
vol.Required(
CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds()
): cv.time_period,
vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string,
}
@@ -53,9 +60,15 @@ async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None:
else:
scan_interval = TRACKER_SCAN_INTERVAL
if CONF_CONSIDER_HOME in validated_config:
consider_home = validated_config[CONF_CONSIDER_HOME].total_seconds()
else:
consider_home = DEFAULT_CONSIDER_HOME.total_seconds()
import_config = {
CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]),
CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL],
CONF_CONSIDER_HOME: consider_home,
CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]),
CONF_OPTIONS: validated_config[CONF_OPTIONS],
CONF_SCAN_INTERVAL: scan_interval,

View File

@@ -7,6 +7,7 @@
"data": {
"hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]",
"home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]",
"consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.",
"exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]",
"scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]",
"interval_seconds": "Scan interval"

View File

@@ -25,12 +25,12 @@
"step": {
"init": {
"data": {
"consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.",
"exclude": "Network addresses (comma seperated) to exclude from scanning",
"home_interval": "Minimum number of minutes between scans of active devices (preserve battery)",
"hosts": "Network addresses (comma seperated) to scan",
"interval_seconds": "Scan interval",
"scan_options": "Raw configurable scan options for Nmap",
"track_new_devices": "Track new devices"
"scan_options": "Raw configurable scan options for Nmap"
},
"description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32)."
}

View File

@@ -130,6 +130,7 @@ class ONVIFDevice:
err,
)
self.available = False
await self.device.close()
except Fault as err:
LOGGER.error(
"Couldn't connect to camera '%s', please verify "

View File

@@ -2,11 +2,7 @@
"domain": "onvif",
"name": "ONVIF",
"documentation": "https://www.home-assistant.io/integrations/onvif",
"requirements": [
"onvif-zeep-async==1.0.0",
"WSDiscovery==2.0.0",
"zeep[async]==4.0.0"
],
"requirements": ["onvif-zeep-async==1.2.0", "WSDiscovery==2.0.0"],
"dependencies": ["ffmpeg"],
"codeowners": ["@hunterjm"],
"config_flow": true,

View File

@@ -66,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
config_entry.data.get(CONF_LONGITUDE, hass.config.longitude),
altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation),
session=websession,
logger=LOGGER,
),
)
await openuv.async_update()

View File

@@ -3,7 +3,7 @@
"name": "OpenUV",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/openuv",
"requirements": ["pyopenuv==2.1.0"],
"requirements": ["pyopenuv==2.2.0"],
"codeowners": ["@bachya"],
"iot_class": "cloud_polling"
}

View File

@@ -3,7 +3,7 @@
"name": "P1 Monitor",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/p1_monitor",
"requirements": ["p1monitor==0.2.0"],
"requirements": ["p1monitor==1.0.0"],
"codeowners": ["@klaasnicolaas"],
"quality_scale": "platinum",
"iot_class": "local_polling"

View File

@@ -192,33 +192,33 @@ SENSORS: dict[
),
SERVICE_SETTINGS: (
SensorEntityDescription(
key="gas_consumption_tariff",
name="Gas Consumption - Tariff",
key="gas_consumption_price",
name="Gas Consumption Price",
entity_registry_enabled_default=False,
device_class=DEVICE_CLASS_MONETARY,
native_unit_of_measurement=CURRENCY_EURO,
),
SensorEntityDescription(
key="energy_consumption_low_tariff",
name="Energy Consumption - Low Tariff",
key="energy_consumption_price_low",
name="Energy Consumption Price - Low",
device_class=DEVICE_CLASS_MONETARY,
native_unit_of_measurement=CURRENCY_EURO,
),
SensorEntityDescription(
key="energy_consumption_high_tariff",
name="Energy Consumption - High Tariff",
key="energy_consumption_price_high",
name="Energy Consumption Price - High",
device_class=DEVICE_CLASS_MONETARY,
native_unit_of_measurement=CURRENCY_EURO,
),
SensorEntityDescription(
key="energy_production_low_tariff",
name="Energy Production - Low Tariff",
key="energy_production_price_low",
name="Energy Production Price - Low",
device_class=DEVICE_CLASS_MONETARY,
native_unit_of_measurement=CURRENCY_EURO,
),
SensorEntityDescription(
key="energy_production_high_tariff",
name="Energy Production - High Tariff",
key="energy_production_price_high",
name="Energy Production Price - High",
device_class=DEVICE_CLASS_MONETARY,
native_unit_of_measurement=CURRENCY_EURO,
),

View File

@@ -91,7 +91,7 @@ _DRIVE_MON_COND = {
"mdi:checkbox-marked-circle-outline",
None,
],
"drive_temp": ["Temperature", TEMP_CELSIUS, None, None, DEVICE_CLASS_TEMPERATURE],
"drive_temp": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE],
}
_VOLUME_MON_COND = {
"volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie", None],

View File

@@ -54,7 +54,7 @@ async def async_get_type(hass, cloud_id, install_code, host):
meters = await hub.get_device_list()
except aioeagle.BadAuth as err:
raise InvalidAuth from err
except aiohttp.ClientError:
except (KeyError, aiohttp.ClientError):
# This can happen if it's an eagle-100
meters = None

View File

@@ -38,21 +38,22 @@ _LOGGER = logging.getLogger(__name__)
SENSORS = (
SensorEntityDescription(
key="zigbee:InstantaneousDemand",
name="Meter Power Demand",
# We can drop the "Eagle-200" part of the name in HA 2021.12
name="Eagle-200 Meter Power Demand",
native_unit_of_measurement=POWER_KILO_WATT,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
SensorEntityDescription(
key="zigbee:CurrentSummationDelivered",
name="Total Meter Energy Delivered",
name="Eagle-200 Total Meter Energy Delivered",
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
SensorEntityDescription(
key="zigbee:CurrentSummationReceived",
name="Total Meter Energy Received",
name="Eagle-200 Total Meter Energy Received",
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,

View File

@@ -59,7 +59,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
try:
await client.async_get_next_pickup_event()
await client.async_get_pickup_events()
except RecollectError as err:
LOGGER.error("Error during setup of integration: %s", err)
return self.async_show_form(

View File

@@ -3,7 +3,7 @@
"name": "ReCollect Waste",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/recollect_waste",
"requirements": ["aiorecollect==1.0.7"],
"requirements": ["aiorecollect==1.0.8"],
"codeowners": ["@bachya"],
"iot_class": "cloud_polling"
}

View File

@@ -1,6 +1,8 @@
"""Support for ReCollect Waste sensors."""
from __future__ import annotations
from datetime import date, datetime, time
from aiorecollect.client import PickupType
import voluptuous as vol
@@ -74,6 +76,12 @@ async def async_setup_platform(
)
@callback
def async_get_utc_midnight(target_date: date) -> datetime:
"""Get UTC midnight for a given date."""
return as_utc(datetime.combine(target_date, time(0)))
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
@@ -124,7 +132,9 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity):
ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names(
self._entry, next_pickup_event.pickup_types
),
ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(),
ATTR_NEXT_PICKUP_DATE: async_get_utc_midnight(
next_pickup_event.date
).isoformat(),
}
)
self._attr_native_value = as_utc(pickup_event.date).isoformat()
self._attr_native_value = async_get_utc_midnight(pickup_event.date).isoformat()

View File

@@ -70,7 +70,7 @@ DOUBLE_TYPE = (
Float()
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql")
.with_variant(oracle.DOUBLE_PRECISION(), "oracle")
.with_variant(postgresql.DOUBLE_PRECISION, "postgresql")
.with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
)
@@ -267,6 +267,7 @@ class Statistics(Base): # type: ignore
class StatisticMetaData(TypedDict, total=False):
"""Statistic meta data class."""
statistic_id: str
unit_of_measurement: str | None
has_mean: bool
has_sum: bool

View File

@@ -8,6 +8,7 @@ import logging
from typing import TYPE_CHECKING, Any, Callable
from sqlalchemy import bindparam
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext import baked
from sqlalchemy.orm.scoping import scoped_session
@@ -53,6 +54,13 @@ QUERY_STATISTIC_META = [
StatisticsMeta.id,
StatisticsMeta.statistic_id,
StatisticsMeta.unit_of_measurement,
StatisticsMeta.has_mean,
StatisticsMeta.has_sum,
]
QUERY_STATISTIC_META_ID = [
StatisticsMeta.id,
StatisticsMeta.statistic_id,
]
STATISTICS_BAKERY = "recorder_statistics_bakery"
@@ -124,33 +132,61 @@ def _get_metadata_ids(
) -> list[str]:
"""Resolve metadata_id for a list of statistic_ids."""
baked_query = hass.data[STATISTICS_META_BAKERY](
lambda session: session.query(*QUERY_STATISTIC_META)
lambda session: session.query(*QUERY_STATISTIC_META_ID)
)
baked_query += lambda q: q.filter(
StatisticsMeta.statistic_id.in_(bindparam("statistic_ids"))
)
result = execute(baked_query(session).params(statistic_ids=statistic_ids))
return [id for id, _, _ in result] if result else []
return [id for id, _ in result] if result else []
def _get_or_add_metadata_id(
def _update_or_add_metadata(
hass: HomeAssistant,
session: scoped_session,
statistic_id: str,
metadata: StatisticMetaData,
new_metadata: StatisticMetaData,
) -> str:
"""Get metadata_id for a statistic_id, add if it doesn't exist."""
metadata_id = _get_metadata_ids(hass, session, [statistic_id])
if not metadata_id:
unit = metadata["unit_of_measurement"]
has_mean = metadata["has_mean"]
has_sum = metadata["has_sum"]
old_metadata_dict = _get_metadata(hass, session, [statistic_id], None)
if not old_metadata_dict:
unit = new_metadata["unit_of_measurement"]
has_mean = new_metadata["has_mean"]
has_sum = new_metadata["has_sum"]
session.add(
StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum)
)
metadata_id = _get_metadata_ids(hass, session, [statistic_id])
return metadata_id[0]
metadata_ids = _get_metadata_ids(hass, session, [statistic_id])
_LOGGER.debug(
"Added new statistics metadata for %s, new_metadata: %s",
statistic_id,
new_metadata,
)
return metadata_ids[0]
metadata_id, old_metadata = next(iter(old_metadata_dict.items()))
if (
old_metadata["has_mean"] != new_metadata["has_mean"]
or old_metadata["has_sum"] != new_metadata["has_sum"]
or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"]
):
session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update(
{
StatisticsMeta.has_mean: new_metadata["has_mean"],
StatisticsMeta.has_sum: new_metadata["has_sum"],
StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"],
},
synchronize_session=False,
)
_LOGGER.debug(
"Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s",
statistic_id,
old_metadata,
new_metadata,
)
return metadata_id
@retryable_database_job("statistics")
@@ -177,10 +213,17 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool:
with session_scope(session=instance.get_session()) as session: # type: ignore
for stats in platform_stats:
for entity_id, stat in stats.items():
metadata_id = _get_or_add_metadata_id(
metadata_id = _update_or_add_metadata(
instance.hass, session, entity_id, stat["meta"]
)
session.add(Statistics.from_stats(metadata_id, start, stat["stat"]))
try:
session.add(Statistics.from_stats(metadata_id, start, stat["stat"]))
except SQLAlchemyError:
_LOGGER.exception(
"Unexpected exception when inserting statistics %s:%s ",
metadata_id,
stat,
)
session.add(StatisticsRuns(start=start))
return True
@@ -191,14 +234,19 @@ def _get_metadata(
session: scoped_session,
statistic_ids: list[str] | None,
statistic_type: str | None,
) -> dict[str, dict[str, str]]:
) -> dict[str, StatisticMetaData]:
"""Fetch meta data."""
def _meta(metas: list, wanted_metadata_id: str) -> dict[str, str] | None:
meta = None
for metadata_id, statistic_id, unit in metas:
def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None:
meta: StatisticMetaData | None = None
for metadata_id, statistic_id, unit, has_mean, has_sum in metas:
if metadata_id == wanted_metadata_id:
meta = {"unit_of_measurement": unit, "statistic_id": statistic_id}
meta = {
"statistic_id": statistic_id,
"unit_of_measurement": unit,
"has_mean": has_mean,
"has_sum": has_sum,
}
return meta
baked_query = hass.data[STATISTICS_META_BAKERY](
@@ -219,7 +267,7 @@ def _get_metadata(
return {}
metadata_ids = [metadata[0] for metadata in result]
metadata = {}
metadata: dict[str, StatisticMetaData] = {}
for _id in metadata_ids:
meta = _meta(result, _id)
if meta:
@@ -230,7 +278,7 @@ def _get_metadata(
def get_metadata(
hass: HomeAssistant,
statistic_id: str,
) -> dict[str, str] | None:
) -> StatisticMetaData | None:
"""Return metadata for a statistic_id."""
statistic_ids = [statistic_id]
with session_scope(hass=hass) as session:
@@ -255,7 +303,7 @@ def _configured_unit(unit: str, units: UnitSystem) -> str:
def list_statistic_ids(
hass: HomeAssistant, statistic_type: str | None = None
) -> list[dict[str, str] | None]:
) -> list[StatisticMetaData | None]:
"""Return statistic_ids and meta data."""
units = hass.config.units
statistic_ids = {}
@@ -263,7 +311,9 @@ def list_statistic_ids(
metadata = _get_metadata(hass, session, None, statistic_type)
for meta in metadata.values():
unit = _configured_unit(meta["unit_of_measurement"], units)
unit = meta["unit_of_measurement"]
if unit is not None:
unit = _configured_unit(unit, units)
meta["unit_of_measurement"] = unit
statistic_ids = {
@@ -277,7 +327,8 @@ def list_statistic_ids(
platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type)
for statistic_id, unit in platform_statistic_ids.items():
unit = _configured_unit(unit, units)
if unit is not None:
unit = _configured_unit(unit, units)
platform_statistic_ids[statistic_id] = unit
statistic_ids = {**statistic_ids, **platform_statistic_ids}
@@ -326,11 +377,11 @@ def statistics_during_period(
)
if not stats:
return {}
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata)
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata, True)
def get_last_statistics(
hass: HomeAssistant, number_of_stats: int, statistic_id: str
hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool
) -> dict[str, list[dict]]:
"""Return the last number_of_stats statistics for a statistic_id."""
statistic_ids = [statistic_id]
@@ -360,19 +411,26 @@ def get_last_statistics(
if not stats:
return {}
return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata)
return _sorted_statistics_to_dict(
hass, stats, statistic_ids, metadata, convert_units
)
def _sorted_statistics_to_dict(
hass: HomeAssistant,
stats: list,
statistic_ids: list[str] | None,
metadata: dict[str, dict[str, str]],
metadata: dict[str, StatisticMetaData],
convert_units: bool,
) -> dict[str, list[dict]]:
"""Convert SQL results into JSON friendly data structure."""
result: dict = defaultdict(list)
units = hass.config.units
def no_conversion(val: Any, _: Any) -> float | None:
"""Return x."""
return val # type: ignore
# Set all statistic IDs to empty lists in result set to maintain the order
if statistic_ids is not None:
for stat_id in statistic_ids:
@@ -385,9 +443,11 @@ def _sorted_statistics_to_dict(
for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore
unit = metadata[meta_id]["unit_of_measurement"]
statistic_id = metadata[meta_id]["statistic_id"]
convert: Callable[[Any, Any], float | None] = UNIT_CONVERSIONS.get(
unit, lambda x, units: x # type: ignore
)
convert: Callable[[Any, Any], float | None]
if convert_units:
convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) # type: ignore
else:
convert = no_conversion
ent_results = result[meta_id]
ent_results.extend(
{

View File

@@ -75,7 +75,7 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES = (
RfxtrxSensorEntityDescription(
key="Barameter",
key="Barometer",
device_class=DEVICE_CLASS_PRESSURE,
state_class=STATE_CLASS_MEASUREMENT,
native_unit_of_measurement=PRESSURE_HPA,

View File

@@ -52,6 +52,7 @@ class RingCam(RingEntityMixin, Camera):
self._last_event = None
self._last_video_id = None
self._video_url = None
self._image = None
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
async def async_added_to_hass(self):
@@ -80,6 +81,7 @@ class RingCam(RingEntityMixin, Camera):
self._last_event = None
self._last_video_id = None
self._video_url = None
self._image = None
self._expires_at = dt_util.utcnow()
self.async_write_ha_state()
@@ -106,12 +108,18 @@ class RingCam(RingEntityMixin, Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
if self._video_url is None:
return
if self._image is None and self._video_url:
image = await ffmpeg.async_get_image(
self.hass,
self._video_url,
width=width,
height=height,
)
return await ffmpeg.async_get_image(
self.hass, self._video_url, width=width, height=height
)
if image:
self._image = image
return self._image
async def handle_async_mjpeg_stream(self, request):
"""Generate an HTTP MJPEG stream from the camera."""
@@ -144,6 +152,9 @@ class RingCam(RingEntityMixin, Camera):
if self._last_video_id == self._last_event["id"] and utcnow <= self._expires_at:
return
if self._last_video_id != self._last_event["id"]:
self._image = None
try:
video_url = await self.hass.async_add_executor_job(
self._device.recording_url, self._last_event["id"]

View File

@@ -2,7 +2,7 @@
"domain": "ring",
"name": "Ring",
"documentation": "https://www.home-assistant.io/integrations/ring",
"requirements": ["ring_doorbell==0.6.2"],
"requirements": ["ring_doorbell==0.7.1"],
"dependencies": ["ffmpeg"],
"codeowners": ["@balloob"],
"config_flow": true,

View File

@@ -240,7 +240,8 @@ class SamsungTVLegacyBridge(SamsungTVBridge):
def _send_key(self, key):
"""Send the key using legacy protocol."""
self._get_remote().control(key)
if remote := self._get_remote():
remote.control(key)
def stop(self):
"""Stop Bridge."""
@@ -315,7 +316,8 @@ class SamsungTVWSBridge(SamsungTVBridge):
"""Send the key using websocket protocol."""
if key == "KEY_POWEROFF":
key = "KEY_POWER"
self._get_remote().send_key(key)
if remote := self._get_remote():
remote.send_key(key)
def _get_remote(self, avoid_open: bool = False):
"""Create or return a remote control instance."""

View File

@@ -23,6 +23,16 @@ CONSUMPTION_NAME = "Usage"
CONSUMPTION_ID = "usage"
PRODUCTION_NAME = "Production"
PRODUCTION_ID = "production"
PRODUCTION_PCT_NAME = "Net Production Percentage"
PRODUCTION_PCT_ID = "production_pct"
NET_PRODUCTION_NAME = "Net Production"
NET_PRODUCTION_ID = "net_production"
TO_GRID_NAME = "To Grid"
TO_GRID_ID = "to_grid"
FROM_GRID_NAME = "From Grid"
FROM_GRID_ID = "from_grid"
SOLAR_POWERED_NAME = "Solar Powered Percentage"
SOLAR_POWERED_ID = "solar_powered"
ICON = "mdi:flash"

View File

@@ -10,6 +10,7 @@ from homeassistant.const import (
DEVICE_CLASS_POWER,
ELECTRIC_POTENTIAL_VOLT,
ENERGY_KILO_WATT_HOUR,
PERCENTAGE,
POWER_WATT,
)
from homeassistant.core import callback
@@ -22,15 +23,25 @@ from .const import (
CONSUMPTION_ID,
CONSUMPTION_NAME,
DOMAIN,
FROM_GRID_ID,
FROM_GRID_NAME,
ICON,
MDI_ICONS,
NET_PRODUCTION_ID,
NET_PRODUCTION_NAME,
PRODUCTION_ID,
PRODUCTION_NAME,
PRODUCTION_PCT_ID,
PRODUCTION_PCT_NAME,
SENSE_DATA,
SENSE_DEVICE_UPDATE,
SENSE_DEVICES_DATA,
SENSE_DISCOVERED_DEVICES_DATA,
SENSE_TRENDS_COORDINATOR,
SOLAR_POWERED_ID,
SOLAR_POWERED_NAME,
TO_GRID_ID,
TO_GRID_NAME,
)
@@ -55,7 +66,16 @@ TRENDS_SENSOR_TYPES = {
}
# Production/consumption variants
SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID]
SENSOR_VARIANTS = [(PRODUCTION_ID, PRODUCTION_NAME), (CONSUMPTION_ID, CONSUMPTION_NAME)]
# Trend production/consumption variants
TREND_SENSOR_VARIANTS = SENSOR_VARIANTS + [
(PRODUCTION_PCT_ID, PRODUCTION_PCT_NAME),
(NET_PRODUCTION_ID, NET_PRODUCTION_NAME),
(FROM_GRID_ID, FROM_GRID_NAME),
(TO_GRID_ID, TO_GRID_NAME),
(SOLAR_POWERED_ID, SOLAR_POWERED_NAME),
]
def sense_to_mdi(sense_icon):
@@ -86,15 +106,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if device["tags"]["DeviceListAllowed"] == "true"
]
for var in SENSOR_VARIANTS:
for variant_id, variant_name in SENSOR_VARIANTS:
name = ACTIVE_SENSOR_TYPE.name
sensor_type = ACTIVE_SENSOR_TYPE.sensor_type
is_production = var == PRODUCTION_ID
unique_id = f"{sense_monitor_id}-active-{var}"
unique_id = f"{sense_monitor_id}-active-{variant_id}"
devices.append(
SenseActiveSensor(
data, name, sensor_type, is_production, sense_monitor_id, var, unique_id
data,
name,
sensor_type,
sense_monitor_id,
variant_id,
variant_name,
unique_id,
)
)
@@ -102,18 +127,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
devices.append(SenseVoltageSensor(data, i, sense_monitor_id))
for type_id, typ in TRENDS_SENSOR_TYPES.items():
for var in SENSOR_VARIANTS:
for variant_id, variant_name in TREND_SENSOR_VARIANTS:
name = typ.name
sensor_type = typ.sensor_type
is_production = var == PRODUCTION_ID
unique_id = f"{sense_monitor_id}-{type_id}-{var}"
unique_id = f"{sense_monitor_id}-{type_id}-{variant_id}"
devices.append(
SenseTrendsSensor(
data,
name,
sensor_type,
is_production,
variant_id,
variant_name,
trends_coordinator,
unique_id,
)
@@ -137,19 +162,19 @@ class SenseActiveSensor(SensorEntity):
data,
name,
sensor_type,
is_production,
sense_monitor_id,
sensor_id,
variant_id,
variant_name,
unique_id,
):
"""Initialize the Sense sensor."""
name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME
self._attr_name = f"{name} {name_type}"
self._attr_name = f"{name} {variant_name}"
self._attr_unique_id = unique_id
self._data = data
self._sense_monitor_id = sense_monitor_id
self._sensor_type = sensor_type
self._is_production = is_production
self._variant_id = variant_id
self._variant_name = variant_name
async def async_added_to_hass(self):
"""Register callbacks."""
@@ -166,7 +191,7 @@ class SenseActiveSensor(SensorEntity):
"""Update the sensor from the data. Must not do I/O."""
new_state = round(
self._data.active_solar_power
if self._is_production
if self._variant_id == PRODUCTION_ID
else self._data.active_power
)
if self._attr_available and self._attr_native_value == new_state:
@@ -235,24 +260,30 @@ class SenseTrendsSensor(SensorEntity):
data,
name,
sensor_type,
is_production,
variant_id,
variant_name,
trends_coordinator,
unique_id,
):
"""Initialize the Sense sensor."""
name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME
self._attr_name = f"{name} {name_type}"
self._attr_name = f"{name} {variant_name}"
self._attr_unique_id = unique_id
self._data = data
self._sensor_type = sensor_type
self._coordinator = trends_coordinator
self._is_production = is_production
self._variant_id = variant_id
self._had_any_update = False
if variant_id in [PRODUCTION_PCT_ID, SOLAR_POWERED_ID]:
self._attr_native_unit_of_measurement = PERCENTAGE
self._attr_entity_registry_enabled_default = False
self._attr_state_class = None
self._attr_device_class = None
@property
def native_value(self):
"""Return the state of the sensor."""
return round(self._data.get_trend(self._sensor_type, self._is_production), 1)
return round(self._data.get_trend(self._sensor_type, self._variant_id), 1)
@property
def available(self):

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import datetime
import itertools
import logging
import math
from typing import Callable
from homeassistant.components.recorder import history, statistics
@@ -108,6 +109,7 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = {
}
# Keep track of entities for which a warning about decreasing value has been logged
SEEN_DIP = "sensor_seen_total_increasing_dip"
WARN_DIP = "sensor_warn_total_increasing_dip"
# Keep track of entities for which a warning about unsupported unit has been logged
WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit"
@@ -128,13 +130,6 @@ def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None]]:
return entity_ids
# Faster than try/except
# From https://stackoverflow.com/a/23639915
def _is_number(s: str) -> bool: # pylint: disable=invalid-name
"""Return True if string is a number."""
return s.replace(".", "", 1).isdigit()
def _time_weighted_average(
fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime
) -> float:
@@ -178,6 +173,14 @@ def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]:
return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates}
def _parse_float(state: str) -> float:
"""Parse a float string, throw on inf or nan."""
fstate = float(state)
if math.isnan(fstate) or math.isinf(fstate):
raise ValueError
return fstate
def _normalize_states(
hass: HomeAssistant,
entity_history: list[State],
@@ -189,9 +192,14 @@ def _normalize_states(
if device_class not in UNIT_CONVERSIONS:
# We're not normalizing this device class, return the state as they are
fstates = [
(float(el.state), el) for el in entity_history if _is_number(el.state)
]
fstates = []
for state in entity_history:
try:
fstate = _parse_float(state.state)
except (ValueError, TypeError): # TypeError to guard for NULL state in DB
continue
fstates.append((fstate, state))
if fstates:
all_units = _get_units(fstates)
if len(all_units) > 1:
@@ -199,11 +207,18 @@ def _normalize_states(
hass.data[WARN_UNSTABLE_UNIT] = set()
if entity_id not in hass.data[WARN_UNSTABLE_UNIT]:
hass.data[WARN_UNSTABLE_UNIT].add(entity_id)
extra = ""
if old_metadata := statistics.get_metadata(hass, entity_id):
extra = (
" and matches the unit of already compiled statistics "
f"({old_metadata['unit_of_measurement']})"
)
_LOGGER.warning(
"The unit of %s is changing, got %s, generation of long term "
"statistics will be suppressed unless the unit is stable",
"The unit of %s is changing, got multiple %s, generation of long term "
"statistics will be suppressed unless the unit is stable%s",
entity_id,
all_units,
extra,
)
return None, []
unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@@ -212,11 +227,10 @@ def _normalize_states(
fstates = []
for state in entity_history:
# Exclude non numerical states from statistics
if not _is_number(state.state):
try:
fstate = _parse_float(state.state)
except ValueError:
continue
fstate = float(state.state)
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
# Exclude unsupported units from statistics
if unit not in UNIT_CONVERSIONS[device_class]:
@@ -233,7 +247,17 @@ def _normalize_states(
def warn_dip(hass: HomeAssistant, entity_id: str) -> None:
"""Log a warning once if a sensor with state_class_total has a decreasing value."""
"""Log a warning once if a sensor with state_class_total has a decreasing value.
The log will be suppressed until two dips have been seen to prevent warning due to
rounding issues with databases storing the state as a single precision float, which
was fixed in recorder DB version 20.
"""
if SEEN_DIP not in hass.data:
hass.data[SEEN_DIP] = set()
if entity_id not in hass.data[SEEN_DIP]:
hass.data[SEEN_DIP].add(entity_id)
return
if WARN_DIP not in hass.data:
hass.data[WARN_DIP] = set()
if entity_id not in hass.data[WARN_DIP]:
@@ -264,7 +288,22 @@ def reset_detected(
return state < 0.9 * previous_state
def compile_statistics(
def _wanted_statistics(
entities: list[tuple[str, str, str | None]]
) -> dict[str, set[str]]:
"""Prepare a dict with wanted statistics for entities."""
wanted_statistics = {}
for entity_id, state_class, device_class in entities:
if device_class in DEVICE_CLASS_STATISTICS[state_class]:
wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][
device_class
]
else:
wanted_statistics[entity_id] = DEFAULT_STATISTICS[state_class]
return wanted_statistics
def compile_statistics( # noqa: C901
hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime
) -> dict:
"""Compile statistics for all entities during start-end.
@@ -275,17 +314,32 @@ def compile_statistics(
entities = _get_entities(hass)
wanted_statistics = _wanted_statistics(entities)
# Get history between start and end
history_list = history.get_significant_states( # type: ignore
hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities]
)
entities_full_history = [i[0] for i in entities if "sum" in wanted_statistics[i[0]]]
history_list = {}
if entities_full_history:
history_list = history.get_significant_states( # type: ignore
hass,
start - datetime.timedelta.resolution,
end,
entity_ids=entities_full_history,
significant_changes_only=False,
)
entities_significant_history = [
i[0] for i in entities if "sum" not in wanted_statistics[i[0]]
]
if entities_significant_history:
_history_list = history.get_significant_states( # type: ignore
hass,
start - datetime.timedelta.resolution,
end,
entity_ids=entities_significant_history,
)
history_list = {**history_list, **_history_list}
for entity_id, state_class, device_class in entities:
if device_class in DEVICE_CLASS_STATISTICS[state_class]:
wanted_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class]
else:
wanted_statistics = DEFAULT_STATISTICS[state_class]
if entity_id not in history_list:
continue
@@ -309,7 +363,7 @@ def compile_statistics(
entity_id,
unit,
old_metadata["unit_of_measurement"],
unit,
old_metadata["unit_of_measurement"],
)
continue
@@ -318,30 +372,30 @@ def compile_statistics(
# Set meta data
result[entity_id]["meta"] = {
"unit_of_measurement": unit,
"has_mean": "mean" in wanted_statistics,
"has_sum": "sum" in wanted_statistics,
"has_mean": "mean" in wanted_statistics[entity_id],
"has_sum": "sum" in wanted_statistics[entity_id],
}
# Make calculations
stat: dict = {}
if "max" in wanted_statistics:
if "max" in wanted_statistics[entity_id]:
stat["max"] = max(*itertools.islice(zip(*fstates), 1))
if "min" in wanted_statistics:
if "min" in wanted_statistics[entity_id]:
stat["min"] = min(*itertools.islice(zip(*fstates), 1))
if "mean" in wanted_statistics:
if "mean" in wanted_statistics[entity_id]:
stat["mean"] = _time_weighted_average(fstates, start, end)
if "sum" in wanted_statistics:
if "sum" in wanted_statistics[entity_id]:
last_reset = old_last_reset = None
new_state = old_state = None
_sum = 0
last_stats = statistics.get_last_statistics(hass, 1, entity_id)
last_stats = statistics.get_last_statistics(hass, 1, entity_id, False)
if entity_id in last_stats:
# We have compiled history for this sensor before, use that as a starting point
last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"]
new_state = old_state = last_stats[entity_id][0]["state"]
_sum = last_stats[entity_id][0]["sum"]
_sum = last_stats[entity_id][0]["sum"] or 0
for fstate, state in fstates:
@@ -358,6 +412,19 @@ def compile_statistics(
and (last_reset := state.attributes.get("last_reset"))
!= old_last_reset
):
if old_state is None:
_LOGGER.info(
"Compiling initial sum statistics for %s, zero point set to %s",
entity_id,
fstate,
)
else:
_LOGGER.info(
"Detected new cycle for %s, last_reset set to %s (old last_reset %s)",
entity_id,
last_reset,
old_last_reset,
)
reset = True
elif old_state is None and last_reset is None:
reset = True
@@ -372,7 +439,7 @@ def compile_statistics(
):
reset = True
_LOGGER.info(
"Detected new cycle for %s, zero point set to %s (old zero point %s)",
"Detected new cycle for %s, value dropped from %s to %s",
entity_id,
fstate,
new_state,
@@ -385,11 +452,8 @@ def compile_statistics(
# ..and update the starting point
new_state = fstate
old_last_reset = last_reset
# Force a new cycle for STATE_CLASS_TOTAL_INCREASING to start at 0
if (
state_class == STATE_CLASS_TOTAL_INCREASING
and old_state is not None
):
# Force a new cycle for an existing sensor to start at 0
if old_state is not None:
old_state = 0.0
else:
old_state = new_state

View File

@@ -9,8 +9,33 @@ from homeassistant.const import (
TEMP_FAHRENHEIT,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.significant_change import (
check_absolute_change,
check_percentage_change,
)
from . import DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE
from . import (
DEVICE_CLASS_AQI,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CO,
DEVICE_CLASS_CO2,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM10,
DEVICE_CLASS_PM25,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
)
def _absolute_and_relative_change(
old_state: int | float | None,
new_state: int | float | None,
absolute_change: int | float,
percentage_change: int | float,
) -> bool:
return check_absolute_change(
old_state, new_state, absolute_change
) and check_percentage_change(old_state, new_state, percentage_change)
@callback
@@ -28,20 +53,35 @@ def async_check_significant_change(
if device_class is None:
return None
absolute_change: float | None = None
percentage_change: float | None = None
if device_class == DEVICE_CLASS_TEMPERATURE:
if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT:
change: float | int = 1
absolute_change = 1.0
else:
change = 0.5
old_value = float(old_state)
new_value = float(new_state)
return abs(old_value - new_value) >= change
absolute_change = 0.5
if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY):
old_value = float(old_state)
new_value = float(new_state)
absolute_change = 1.0
return abs(old_value - new_value) >= 1
if device_class in (
DEVICE_CLASS_AQI,
DEVICE_CLASS_CO,
DEVICE_CLASS_CO2,
DEVICE_CLASS_PM25,
DEVICE_CLASS_PM10,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
):
absolute_change = 1.0
percentage_change = 2.0
if absolute_change is not None and percentage_change is not None:
return _absolute_and_relative_change(
float(old_state), float(new_state), absolute_change, percentage_change
)
if absolute_change is not None:
return check_absolute_change(
float(old_state), float(new_state), absolute_change
)
return None

View File

@@ -1,7 +1,7 @@
"""Binary sensor for Shelly."""
from __future__ import annotations
from typing import Final
from typing import Final, cast
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_CONNECTIVITY,
@@ -46,7 +46,9 @@ SENSORS: Final = {
name="Overpowering", device_class=DEVICE_CLASS_PROBLEM
),
("sensor", "dwIsOpened"): BlockAttributeDescription(
name="Door", device_class=DEVICE_CLASS_OPENING
name="Door",
device_class=DEVICE_CLASS_OPENING,
available=lambda block: cast(bool, block.dwIsOpened != -1),
),
("sensor", "flood"): BlockAttributeDescription(
name="Flood", device_class=DEVICE_CLASS_MOISTURE

View File

@@ -40,6 +40,7 @@ SENSORS: Final = {
device_class=sensor.DEVICE_CLASS_BATTERY,
state_class=sensor.STATE_CLASS_MEASUREMENT,
removal_condition=lambda settings, _: settings.get("external_power") == 1,
available=lambda block: cast(bool, block.battery != -1),
),
("device", "deviceTemp"): BlockAttributeDescription(
name="Device Temperature",
@@ -176,6 +177,7 @@ SENSORS: Final = {
unit=LIGHT_LUX,
device_class=sensor.DEVICE_CLASS_ILLUMINANCE,
state_class=sensor.STATE_CLASS_MEASUREMENT,
available=lambda block: cast(bool, block.luminosity != -1),
),
("sensor", "tilt"): BlockAttributeDescription(
name="Tilt",

View File

@@ -3,7 +3,7 @@
"name": "SimpliSafe",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
"requirements": ["simplisafe-python==11.0.4"],
"requirements": ["simplisafe-python==11.0.6"],
"codeowners": ["@bachya"],
"iot_class": "cloud_polling"
}

View File

@@ -561,7 +561,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity):
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return f"{self._device.device_id}.{self.report_name}"
return f"{self._device.device_id}.{self.report_name}_meter"
@property
def native_value(self):

View File

@@ -1,12 +1,28 @@
"""Solar-Log integration."""
from datetime import timedelta
import logging
from urllib.parse import ParseResult, urlparse
from requests.exceptions import HTTPError, Timeout
from sunwatcher.solarlog.solarlog import SolarLog
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import update_coordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor"]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry for solarlog."""
coordinator = SolarlogData(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
@@ -14,3 +30,73 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
class SolarlogData(update_coordinator.DataUpdateCoordinator):
"""Get and update the latest data."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the data object."""
super().__init__(
hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60)
)
host_entry = entry.data[CONF_HOST]
url = urlparse(host_entry, "http")
netloc = url.netloc or url.path
path = url.path if url.netloc else ""
url = ParseResult("http", netloc, path, *url[3:])
self.unique_id = entry.entry_id
self.name = entry.title
self.host = url.geturl()
async def _async_update_data(self):
"""Update the data from the SolarLog device."""
try:
api = await self.hass.async_add_executor_job(SolarLog, self.host)
except (OSError, Timeout, HTTPError) as err:
raise update_coordinator.UpdateFailed(err)
if api.time.year == 1999:
raise update_coordinator.UpdateFailed(
"Invalid data returned (can happen after Solarlog restart)."
)
self.logger.debug(
"Connection to Solarlog successful. Retrieving latest Solarlog update of %s",
api.time,
)
data = {}
try:
data["TIME"] = api.time
data["powerAC"] = api.power_ac
data["powerDC"] = api.power_dc
data["voltageAC"] = api.voltage_ac
data["voltageDC"] = api.voltage_dc
data["yieldDAY"] = api.yield_day / 1000
data["yieldYESTERDAY"] = api.yield_yesterday / 1000
data["yieldMONTH"] = api.yield_month / 1000
data["yieldYEAR"] = api.yield_year / 1000
data["yieldTOTAL"] = api.yield_total / 1000
data["consumptionAC"] = api.consumption_ac
data["consumptionDAY"] = api.consumption_day / 1000
data["consumptionYESTERDAY"] = api.consumption_yesterday / 1000
data["consumptionMONTH"] = api.consumption_month / 1000
data["consumptionYEAR"] = api.consumption_year / 1000
data["consumptionTOTAL"] = api.consumption_total / 1000
data["totalPOWER"] = api.total_power
data["alternatorLOSS"] = api.alternator_loss
data["CAPACITY"] = round(api.capacity * 100, 0)
data["EFFICIENCY"] = round(api.efficiency * 100, 0)
data["powerAVAILABLE"] = api.power_available
data["USAGE"] = round(api.usage * 100, 0)
except AttributeError as err:
raise update_coordinator.UpdateFailed(
f"Missing details data in Solarlog response: {err}"
) from err
_LOGGER.debug("Updated Solarlog overview data: %s", data)
return data

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from homeassistant.components.sensor import (
STATE_CLASS_MEASUREMENT,
@@ -23,13 +22,10 @@ from homeassistant.const import (
DOMAIN = "solarlog"
"""Default config for solarlog."""
# Default config for solarlog.
DEFAULT_HOST = "http://solar-log"
DEFAULT_NAME = "solarlog"
"""Fixed constants."""
SCAN_INTERVAL = timedelta(seconds=60)
@dataclass
class SolarlogRequiredKeysMixin:

View File

@@ -1,133 +1,42 @@
"""Platform for solarlog sensors."""
import logging
from urllib.parse import ParseResult, urlparse
from requests.exceptions import HTTPError, Timeout
from sunwatcher.solarlog.solarlog import SolarLog
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import CONF_HOST
from homeassistant.util import Throttle
from homeassistant.helpers import update_coordinator
from homeassistant.helpers.entity import StateType
from .const import DOMAIN, SCAN_INTERVAL, SENSOR_TYPES, SolarLogSensorEntityDescription
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the solarlog platform."""
_LOGGER.warning(
"Configuration of the solarlog platform in configuration.yaml is deprecated "
"in Home Assistant 0.119. Please remove entry from your configuration"
)
from . import SolarlogData
from .const import DOMAIN, SENSOR_TYPES, SolarLogSensorEntityDescription
async def async_setup_entry(hass, entry, async_add_entities):
"""Add solarlog entry."""
host_entry = entry.data[CONF_HOST]
device_name = entry.title
url = urlparse(host_entry, "http")
netloc = url.netloc or url.path
path = url.path if url.netloc else ""
url = ParseResult("http", netloc, path, *url[3:])
host = url.geturl()
try:
api = await hass.async_add_executor_job(SolarLog, host)
_LOGGER.debug("Connected to Solar-Log device, setting up entries")
except (OSError, HTTPError, Timeout):
_LOGGER.error(
"Could not connect to Solar-Log device at %s, check host ip address", host
)
return
# Create solarlog data service which will retrieve and update the data.
data = await hass.async_add_executor_job(SolarlogData, hass, api, host)
# Create a new sensor for each sensor type.
entities = [
SolarlogSensor(entry.entry_id, device_name, data, description)
for description in SENSOR_TYPES
]
async_add_entities(entities, True)
return True
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
SolarlogSensor(coordinator, description) for description in SENSOR_TYPES
)
class SolarlogData:
"""Get and update the latest data."""
def __init__(self, hass, api, host):
"""Initialize the data object."""
self.api = api
self.hass = hass
self.host = host
self.update = Throttle(SCAN_INTERVAL)(self._update)
self.data = {}
def _update(self):
"""Update the data from the SolarLog device."""
try:
self.api = SolarLog(self.host)
response = self.api.time
_LOGGER.debug(
"Connection to Solarlog successful. Retrieving latest Solarlog update of %s",
response,
)
except (OSError, Timeout, HTTPError):
_LOGGER.error("Connection error, Could not retrieve data, skipping update")
return
try:
self.data["TIME"] = self.api.time
self.data["powerAC"] = self.api.power_ac
self.data["powerDC"] = self.api.power_dc
self.data["voltageAC"] = self.api.voltage_ac
self.data["voltageDC"] = self.api.voltage_dc
self.data["yieldDAY"] = self.api.yield_day / 1000
self.data["yieldYESTERDAY"] = self.api.yield_yesterday / 1000
self.data["yieldMONTH"] = self.api.yield_month / 1000
self.data["yieldYEAR"] = self.api.yield_year / 1000
self.data["yieldTOTAL"] = self.api.yield_total / 1000
self.data["consumptionAC"] = self.api.consumption_ac
self.data["consumptionDAY"] = self.api.consumption_day / 1000
self.data["consumptionYESTERDAY"] = self.api.consumption_yesterday / 1000
self.data["consumptionMONTH"] = self.api.consumption_month / 1000
self.data["consumptionYEAR"] = self.api.consumption_year / 1000
self.data["consumptionTOTAL"] = self.api.consumption_total / 1000
self.data["totalPOWER"] = self.api.total_power
self.data["alternatorLOSS"] = self.api.alternator_loss
self.data["CAPACITY"] = round(self.api.capacity * 100, 0)
self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0)
self.data["powerAVAILABLE"] = self.api.power_available
self.data["USAGE"] = round(self.api.usage * 100, 0)
_LOGGER.debug("Updated Solarlog overview data: %s", self.data)
except AttributeError:
_LOGGER.error("Missing details data in Solarlog response")
class SolarlogSensor(SensorEntity):
class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: SolarLogSensorEntityDescription
def __init__(
self,
entry_id: str,
device_name: str,
data: SolarlogData,
coordinator: SolarlogData,
description: SolarLogSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self.data = data
self._attr_name = f"{device_name} {description.name}"
self._attr_unique_id = f"{entry_id}_{description.key}"
self._attr_name = f"{coordinator.name} {description.name}"
self._attr_unique_id = f"{coordinator.unique_id}_{description.key}"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry_id)},
"name": device_name,
"identifiers": {(DOMAIN, coordinator.unique_id)},
"name": coordinator.name,
"manufacturer": "Solar-Log",
}
def update(self):
"""Get the latest data from the sensor and update the state."""
self.data.update()
self._attr_native_value = self.data.data[self.entity_description.json_key]
@property
def native_value(self) -> StateType:
"""Return the native sensor value."""
return self.coordinator.data[self.entity_description.json_key]

View File

@@ -64,9 +64,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self):
"""Initialize the flow."""
self._reauth = False
self._entry_id = None
self._entry_data = {}
self.entry = None
@staticmethod
@callback
@@ -76,10 +74,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult:
"""Handle configuration by re-auth."""
self._reauth = True
self._entry_data = dict(data)
entry = await self.async_set_unique_id(self.unique_id)
self._entry_id = entry.entry_id
self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
return await self.async_step_reauth_confirm()
@@ -90,7 +85,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={"host": self._entry_data[CONF_HOST]},
description_placeholders={"host": self.entry.data[CONF_HOST]},
data_schema=vol.Schema({}),
errors={},
)
@@ -104,8 +99,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
if self._reauth:
user_input = {**self._entry_data, **user_input}
if self.entry:
user_input = {**self.entry.data, **user_input}
if CONF_VERIFY_SSL not in user_input:
user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL
@@ -120,10 +115,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
else:
if self._reauth:
return await self._async_reauth_update_entry(
self._entry_id, user_input
)
if self.entry:
return await self._async_reauth_update_entry(user_input)
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
@@ -136,17 +129,16 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def _async_reauth_update_entry(self, entry_id: str, data: dict) -> FlowResult:
async def _async_reauth_update_entry(self, data: dict) -> FlowResult:
"""Update existing config entry."""
entry = self.hass.config_entries.async_get_entry(entry_id)
self.hass.config_entries.async_update_entry(entry, data=data)
await self.hass.config_entries.async_reload(entry.entry_id)
self.hass.config_entries.async_update_entry(self.entry, data=data)
await self.hass.config_entries.async_reload(self.entry.entry_id)
return self.async_abort(reason="reauth_successful")
def _get_user_data_schema(self) -> dict[str, Any]:
"""Get the data schema to display user form."""
if self._reauth:
if self.entry:
return {vol.Required(CONF_API_KEY): str}
data_schema = {

View File

@@ -223,6 +223,7 @@ async def async_setup_entry(
{
vol.Required(ATTR_ALARM_ID): cv.positive_int,
vol.Optional(ATTR_TIME): cv.time,
vol.Optional(ATTR_VOLUME): cv.small_float,
vol.Optional(ATTR_ENABLED): cv.boolean,
vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
},

View File

@@ -323,6 +323,18 @@ class SonosSpeaker:
async def async_subscribe(self) -> bool:
"""Initiate event subscriptions."""
_LOGGER.debug("Creating subscriptions for %s", self.zone_name)
# Create a polling task in case subscriptions fail or callback events do not arrive
if not self._poll_timer:
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
partial(
async_dispatcher_send,
self.hass,
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
),
SCAN_INTERVAL,
)
try:
await self.hass.async_add_executor_job(self.set_basic_info)
@@ -337,10 +349,10 @@ class SonosSpeaker:
for service in SUBSCRIPTION_SERVICES
]
await asyncio.gather(*subscriptions)
return True
except SoCoException as ex:
_LOGGER.warning("Could not connect %s: %s", self.zone_name, ex)
return False
return True
async def _subscribe(
self, target: SubscriptionBase, sub_callback: Callable
@@ -497,15 +509,6 @@ class SonosSpeaker:
self.soco.ip_address,
)
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
partial(
async_dispatcher_send,
self.hass,
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
),
SCAN_INTERVAL,
)
if self._is_ready and not self.subscriptions_failed:
done = await self.async_subscribe()
if not done:
@@ -567,15 +570,6 @@ class SonosSpeaker:
self._seen_timer = self.hass.helpers.event.async_call_later(
SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen
)
if not self._poll_timer:
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
partial(
async_dispatcher_send,
self.hass,
f"{SONOS_POLL_UPDATE}-{self.soco.uid}",
),
SCAN_INTERVAL,
)
self.async_write_entity_states()
#

View File

@@ -32,6 +32,8 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES]
CONFIG_SCHEMA = vol.Schema(
vol.All(
# Deprecated in Home Assistant 2021.6
@@ -46,8 +48,8 @@ CONFIG_SCHEMA = vol.Schema(
): cv.positive_time_period,
vol.Optional(CONF_MANUAL, default=False): cv.boolean,
vol.Optional(
CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)
): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]),
CONF_MONITORED_CONDITIONS, default=list(SENSOR_KEYS)
): vol.All(cv.ensure_list, [vol.In(list(SENSOR_KEYS))]),
}
)
},

View File

@@ -286,6 +286,11 @@ class Scanner:
if header_st is not None:
self.seen.add((header_st, header_location))
def _async_unsee(self, header_st: str | None, header_location: str | None) -> None:
"""If we see a device in a new location, unsee the original location."""
if header_st is not None:
self.seen.discard((header_st, header_location))
async def _async_process_entry(self, headers: Mapping[str, str]) -> None:
"""Process SSDP entries."""
_LOGGER.debug("_async_process_entry: %s", headers)
@@ -293,7 +298,12 @@ class Scanner:
h_location = headers.get("location")
if h_st and (udn := _udn_from_usn(headers.get("usn"))):
self.cache[(udn, h_st)] = headers
cache_key = (udn, h_st)
if old_headers := self.cache.get(cache_key):
old_h_location = old_headers.get("location")
if h_location != old_h_location:
self._async_unsee(old_headers.get("st"), old_h_location)
self.cache[cache_key] = headers
callbacks = self._async_get_matching_callbacks(headers)
if self._async_seen(h_st, h_location) and not callbacks:

View File

@@ -90,7 +90,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
sensor
for device in account.api.devices.values()
for description in SENSOR_TYPES
if (sensor := StarlineSensor(account, device, description)).state is not None
if (sensor := StarlineSensor(account, device, description)).native_value
is not None
]
async_add_entities(entities)

View File

@@ -144,7 +144,7 @@ class SurePetcareAPI:
"""Get the latest data from Sure Petcare."""
try:
self.states = await self.surepy.get_entities()
self.states = await self.surepy.get_entities(refresh=True)
except SurePetcareError as error:
_LOGGER.error("Unable to fetch data: %s", error)
return

View File

@@ -3,7 +3,7 @@
"name": "Switcher",
"documentation": "https://www.home-assistant.io/integrations/switcher_kis/",
"codeowners": ["@tomerfi","@thecode"],
"requirements": ["aioswitcher==2.0.4"],
"requirements": ["aioswitcher==2.0.5"],
"iot_class": "local_push",
"config_flow": true
}

View File

@@ -168,10 +168,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
return
if self.home_variable == "outdoor temperature":
self._state = self.hass.config.units.temperature(
self._tado_weather_data["outsideTemperature"]["celsius"],
TEMP_CELSIUS,
)
self._state = self._tado_weather_data["outsideTemperature"]["celsius"]
self._state_attributes = {
"time": self._tado_weather_data["outsideTemperature"]["timestamp"],
}
@@ -245,7 +242,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
if self.zone_variable == "temperature":
return self.hass.config.units.temperature_unit
return TEMP_CELSIUS
if self.zone_variable == "humidity":
return PERCENTAGE
if self.zone_variable == "heating":
@@ -277,9 +274,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
return
if self.zone_variable == "temperature":
self._state = self.hass.config.units.temperature(
self._tado_zone_data.current_temp, TEMP_CELSIUS
)
self._state = self._tado_zone_data.current_temp
self._state_attributes = {
"time": self._tado_zone_data.current_temp_timestamp,
"setting": 0, # setting is used in climate device

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