Compare commits

..

343 Commits

Author SHA1 Message Date
085fa659f9 Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2025-09-28 08:10:59 -07:00
70b0a4d8a5 feat: migrate to SVG Icons 2025-09-28 08:10:53 -07:00
36bb43728d fix: migrate Nightstand Buttons to Device triggers 2025-09-28 08:07:27 -07:00
3c704d0fbc feat: add OpenID Connect auth provider configuration 2025-08-16 11:10:51 -07:00
b29d58748b feat: Update laundry notifications for Miele 2025-07-17 10:25:04 -07:00
66b5f5c8b8 feat: Update laundry notifications for Miele 2025-07-17 10:12:42 -07:00
572e331147 feat: Remove bedroom window sensors 2025-04-15 10:37:35 -07:00
d0439f2d78 fix: Fix ntfy notification scripts 2025-02-15 06:12:51 -08:00
b02e3c0ac7 maint: Update HACS to 2.0.5 2025-01-28 05:49:12 -08:00
97d51536a9 fix: Update proximity sensors to new sensor names 2025-01-26 08:38:05 -08:00
39ba9e4ddf maint: Update unit_system to US Customary 2025-01-08 15:36:08 -08:00
443d12c4dc maint: Roll HACS to 2.0.2 2025-01-05 12:13:25 -08:00
dc3392db6f feat: Add low battery alert 2025-01-04 14:06:28 -08:00
acfcbc1ee7 fix: Use safe garage door close script in security automations 2025-01-04 11:41:02 -08:00
2decb4cf94 fix: Fix issues in trigger processing 2025-01-04 11:15:08 -08:00
5d677c5583 fix: Fix issue in trigger processing 2025-01-04 11:12:25 -08:00
a34278cef4 fix: Fix issue in trigger 2025-01-04 11:06:57 -08:00
b097fcb933 fix: Fix issue in trigger 2025-01-04 11:04:32 -08:00
d7a4640cf7 fix: Fix issue in trigger 2025-01-04 11:00:38 -08:00
05b4bedc07 fix: Fix issue in timeout 2025-01-04 10:58:25 -08:00
d73369842f Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2025-01-04 10:56:54 -08:00
de6a22dcf0 feat: Add Garage Door Close script with checking 2025-01-04 10:56:24 -08:00
facde37e2d feat: Add Garage Door Close script with checking 2025-01-04 10:46:38 -08:00
4c6af73504 fix: Fix syntax error 2024-12-30 10:31:32 -08:00
0e6b76e35a feat: Add Shield TV to Media Package 2024-12-30 10:28:47 -08:00
4dc4f93453 fix: Use doorbell_button_pressed for Doorbell Events 2024-11-30 13:00:43 -08:00
eb8f59060e feat: Add Doorbell Chime REST Command 2024-11-30 12:52:47 -08:00
9a5366c959 fix: Fix syntax errors 2024-11-24 16:00:25 -08:00
25c3a5838e feat: Add ntfy to Security automations 2024-11-24 15:57:58 -08:00
a3088d1993 feat: Add persistent notification to all security notifications 2024-11-17 17:30:29 -08:00
bb7af1a4f9 feat: Change ntfy from REST to Shell Command 2024-11-17 17:03:26 -08:00
6eb13f90e3 fix: Update doorbell media target 2024-11-17 16:18:20 -08:00
666cbb14a1 fix: Revert internal URL 2024-11-17 16:15:53 -08:00
0d85635345 fix: Revert to bearer token for ntfy 2024-11-17 14:53:44 -08:00
46a578137d fix: Use HTTP Basic authentication for ntfy 2024-11-17 14:46:22 -08:00
fc250d4c58 feat: Add ntfy notifier 2024-11-17 14:43:26 -08:00
4bb1ee483a maint: Add Clock Weather Card to Resources 2024-11-11 10:38:21 -08:00
ceea4d6729 feat: Add doorbell chime automation 2024-11-11 09:21:39 -08:00
af705c019e feat: Add Media Directory 2024-11-11 08:30:15 -08:00
358622b374 feat: Disable forced holiday lighting when Christmas Season starts 2024-11-09 08:05:39 -08:00
1672388604 Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2024-11-09 07:59:48 -08:00
9cba82fe6b maint: Upgrade HACS and FontAwesome 2024-11-09 07:59:13 -08:00
4bd1eeca00 feat: Force holiday light turn-on outside of Christmas Season 2024-11-09 07:56:42 -08:00
d32dfcebcb feat: Add front door unlocked on arming automation 2024-09-21 17:58:54 -07:00
22e4607716 feat: Update garage door close script to cancel on disarm 2024-09-21 17:48:22 -07:00
08816f2b9b fix: Fix garage door close script 2024-09-21 17:10:59 -07:00
784fead4aa fix: Fix garage door notification automation 2024-09-08 17:21:40 -07:00
16c63ee7c1 fix: Fix includes for UI managed files 2024-09-08 17:03:28 -07:00
3c94dd1007 fix: Fix trigger syntax 2024-09-08 16:45:15 -07:00
f1d03ca398 feat: Add Garage Door Open Notification on Alarm Arming 2024-09-08 16:42:48 -07:00
202a4395da fix: Fix bad commit 2024-09-03 06:10:43 -07:00
3da40af450 Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2024-09-03 06:10:18 -07:00
c7042584f7 feat: Add both phones to security notifications 2024-09-03 06:10:09 -07:00
15718a2bb3 feat: Add both phones to security notifications 2024-09-03 06:09:13 -07:00
3c55adf23e fix: Fix arming state for armed_night automations 2024-09-03 06:08:45 -07:00
0610c8fdd3 feat: Add automations for armed_night 2024-09-02 17:53:07 -07:00
5b738a7853 fix: Fix syntax error in binary sensor 2024-09-02 17:01:18 -07:00
0a8d1ee4bd fix: Fix syntax error in conditional 2024-09-02 16:53:48 -07:00
59dc066efc feat: Add armed_night configuration hooks 2024-09-02 16:51:40 -07:00
485fb3be32 feat: Start implementing HVAC controls (migrate pause/restore to script) 2024-09-02 15:33:46 -07:00
3361337966 fix: Fix timeouts on camera events re-enable 2024-09-02 13:45:52 -07:00
5e20ccfef8 feat: Normalize notification sinks 2024-09-02 11:28:05 -07:00
4885f3d993 feat: Migrate all notifications to Home Assistant native 2024-09-02 11:14:24 -07:00
48d56ff832 maint: Remove unneeded Commute package 2024-09-02 10:58:11 -07:00
f698ca4575 fix: Reduce delay on resuming camera events 2024-09-02 10:44:26 -07:00
f21c85d996 feat: Add Guest Mode 2024-09-02 10:35:18 -07:00
2665413b60 maint: Bump HACS to 2.0.0 2024-09-02 08:36:59 -07:00
1b64ea6cc9 maint: Update Configuration and Dining Room Light Scenes 2024-08-07 07:21:58 -07:00
39f2a22cc5 Update Work Zones 2024-05-26 07:33:06 -07:00
f9b63a427a Add Doorbell Snapshot Service 2024-03-30 08:21:35 -07:00
98abb037a9 Add Notification for Doorbell 2024-03-20 11:37:39 -07:00
670a125b8f Remove Proximity Integration from YAML 2024-02-26 08:38:58 -08:00
195dc27e29 Upgrade HACS 2024-02-26 07:59:30 -08:00
6dbd89b476 Add Archway Light Automations 2023-11-30 16:55:52 -08:00
a308a9d423 Add Group Toggle Script 2023-11-29 10:30:09 -08:00
efe9d73117 Add Triple-Click Action for Dining Room 2023-11-27 16:49:49 -08:00
1a1e95803f Add UI Label for Automation and Friends 2023-11-24 13:08:11 -07:00
d0c31ac39e Remove YAML Dashboard 2023-11-24 12:51:33 -07:00
b9699b4dfd Update Holiday Groups 2023-11-24 12:38:45 -07:00
ed36ab28b8 Add Proximity Trigger for Vacation Mode 2023-11-21 15:52:37 -07:00
456d18d969 Add Vacation Mode Switch 2023-11-21 15:23:24 -07:00
fdbe8ecb11 Add Vacation Mode Switch 2023-11-21 15:22:40 -07:00
9775011c23 Add Vacation Mode Switch 2023-11-21 15:20:02 -07:00
cc72610f05 Update Security Scripts 2023-11-21 14:51:29 -07:00
1a1e416e15 Update Security Scripts 2023-11-21 14:44:03 -07:00
2488c9965d Update Security Scripts 2023-11-21 14:41:36 -07:00
9db36a217d Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2023-11-20 14:23:20 -08:00
3cd183c32e Fix Syntax Error 2023-11-20 13:55:43 -08:00
212adaabef Fix Syntax Error 2023-11-20 13:52:06 -08:00
c1d8dae4e4 Add Vacuum Scripts 2023-11-20 09:23:13 -08:00
2c52a941a5 Update gitignore 2023-11-20 05:49:07 -08:00
84218ee788 Fix Segment Groups 2023-11-19 15:23:28 -08:00
b0e1d75efc Add Segment Groups 2023-11-19 15:20:54 -08:00
d0c44d4cc9 Add VSIC Resource 2023-11-19 14:20:27 -08:00
1bfc33084a Fix Customize Syntax 2023-11-12 15:04:33 -08:00
33966c8c51 Fix Customize Syntax 2023-11-12 15:01:04 -08:00
f71a709f7f Add Vacuum Room Helpers 2023-11-12 14:54:41 -08:00
5a9f690552 Add Deploy Script 2023-11-12 14:43:26 -08:00
2d7eae9419 Remove Nighstand Dashboards 2023-11-12 14:22:17 -08:00
root
b8fc375552 Update HACS to 1.33.0 2023-11-02 06:16:52 -07:00
efe293b40b Remove Xiaomi Vacuum Card 2023-10-31 17:18:57 -07:00
63e5007232 Fix Helpers 2023-10-18 11:30:19 -07:00
c269335a66 Fix Helpers 2023-10-18 09:56:37 -07:00
556633db8f Fix Helpers 2023-10-18 09:53:48 -07:00
7b32d96611 Fix Helpers 2023-10-18 09:49:33 -07:00
e7c5e543b4 Add Bar Card to Resources 2023-10-18 09:47:46 -07:00
fe51dac8b4 Fix Helpers 2023-10-18 09:38:51 -07:00
e3a69f28fc Fix Helpers 2023-10-18 09:21:57 -07:00
cb0a8c73f1 Add Helpers for Vacuum Consumables 2023-10-18 09:18:37 -07:00
daad0f6553 Fix Resources 2023-10-17 15:02:52 -07:00
c9d168ca5b Fix Automation 2023-10-17 11:23:51 -07:00
1b631ca798 Fix Automation 2023-10-17 10:25:02 -07:00
f59daeee1c Add Vacuum Package 2023-10-17 09:47:35 -07:00
4928faf744 Add Valetudo Map Card to Resources 2023-10-16 16:55:03 -07:00
58200ceec2 Fix Notification Target 2023-09-16 18:32:59 -07:00
996e8c11c9 Update for 2023.8 2023-09-11 15:58:10 -07:00
97d44d93f9 Remove YAML configuration for workday sensor (migrated to UI) 2023-08-04 16:44:27 -07:00
996aa0b9f3 Update Vacation Automations 2023-07-11 10:03:01 -07:00
5a9e5cce3a Add HVAC Vacation Automation 2023-07-11 08:27:07 -07:00
3e64835a30 Add Garage Chest Freezer Set Point 2023-07-10 17:50:43 -07:00
0a563f653f Automatically turn camera events back on after one hour 2023-07-10 14:47:38 -07:00
5d95452c5b Automatically turn camera events back on after one hour 2023-06-26 09:52:38 -07:00
b036708350 Fix comparison 2023-06-16 12:54:52 -07:00
b9e1fff69f Add Camera Notifications 2023-06-16 12:41:12 -07:00
root
df90dcc6cb Update HACS 2023-04-13 05:55:06 -07:00
80dbf737ee Update Lighting Scenes 2023-04-13 05:54:01 -07:00
2910e239c5 Update Lighting Scenes 2023-04-11 20:51:12 -07:00
f8132fb67f Add New Energy Sensors 2023-03-28 11:45:42 -07:00
5e61b25d28 Add New Energy Sensors 2023-03-28 11:38:18 -07:00
74c45ae6a2 Add Hourly Weather Card 2023-03-28 10:27:52 -07:00
0a043443d9 Change Weather from OWM to NWS 2023-03-24 15:10:31 -07:00
156d1b7123 Add Lovelace State Switch 2023-03-22 16:44:58 -07:00
e90a172d9f Fix Syntax 2023-03-22 16:43:18 -07:00
c1a455fe5d Add Hour/Minute Template Sensors 2023-03-22 15:39:29 -07:00
469e52dd33 Add Wall Panel 2023-03-22 15:16:54 -07:00
9ab9840395 Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2023-03-12 18:11:57 -07:00
ea0d96503f Update Vacation Package 2023-03-12 18:11:50 -07:00
2dcb9d2300 Update Vacation Package 2023-03-12 18:11:06 -07:00
895c881bb5 Update Climate Package 2023-03-12 18:09:23 -07:00
root
4d6665c472 Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2023-03-04 12:16:29 -08:00
root
8056390e49 Roll HACS to 1.31.0 2023-03-04 12:15:50 -08:00
5d28b2c4ee Fix Security Package 2023-03-04 12:14:32 -08:00
39fb3ccd1d Update Energy Sensor 2023-03-04 12:06:49 -08:00
f659406986 Update Security Package 2023-03-04 11:55:56 -08:00
0795ee10f7 Add Apex Charts Card 2023-02-26 08:17:06 -08:00
6faef407df Add UI Minimalist Dependencies 2023-02-25 14:36:44 -08:00
d5b4b832a3 Add UI Minimalist Dependencies 2023-02-25 14:29:06 -08:00
d8cea6916a Update Security Package 2023-02-25 12:31:47 -08:00
c1ce7e0fc9 Update Lighting Package 2023-02-11 10:21:34 -08:00
2736246bc6 Fix Lighting Package 2023-02-11 09:23:14 -08:00
2e3e8ff5d2 Fix Lighting Package 2023-02-11 09:22:02 -08:00
8c23240a66 Update ZHA Configuration 2023-02-11 09:19:49 -08:00
82ea7d6c42 Fix Wine Fridge Package 2023-02-11 07:11:39 -08:00
0e4f8703d8 Fix Wine Fridge Package 2023-02-10 17:54:03 -08:00
aae5a70261 Update Lighting Package 2023-02-10 17:41:04 -08:00
722c64072b Add Wine Fridge Package 2023-02-10 17:40:34 -08:00
09b3b0adca Update Lighting Triggers 2023-02-05 12:50:44 -08:00
326f99a5e8 Update Lighting Triggers 2023-02-04 12:18:51 -08:00
6b27e19e2d Update Lighting Triggers 2023-02-04 12:17:33 -08:00
23b5852b46 Update Lighting Triggers 2023-02-04 11:57:59 -08:00
77fb36c9a0 Add Simple Weather Card 2023-02-03 15:59:09 -08:00
4c49804e5e Add Mushroom Cards 2023-02-03 14:42:00 -08:00
root
b873ba0ef0 Bump HACS to 1.30.1 2023-01-30 16:34:17 -08:00
7c560d76e4 Update base configuration for 2023.1 2023-01-30 09:55:19 -08:00
5722ed52e6 Reduce threshold for porch light 2023-01-29 07:53:45 -08:00
f29b58be9a Update Lighting 2023-01-13 17:26:27 -08:00
ee48bff488 Update Lighting 2023-01-13 17:11:34 -08:00
62013c02c3 Update Alerts 2023-01-13 17:01:42 -08:00
1fad220911 Update ZHA IDs 2023-01-13 09:05:44 -08:00
4c1c7dec90 Add Check for Garage Fridge Unavailable 2023-01-08 20:36:47 -08:00
9c0ce83e02 Update Alerts Package 2022-12-13 19:49:55 -08:00
eb308e9cff Update Holiday Package 2022-12-09 16:10:59 -08:00
deb84217e3 Update Holiday Package 2022-12-06 16:09:16 -08:00
387a240d31 Update Lighting Package 2022-12-02 16:40:50 -08:00
fa4047ad7c Update Security Package 2022-12-02 13:29:56 -08:00
b32ff66572 Fix Volume Adjusting 2022-11-29 19:45:26 -08:00
003fe98ecf Fix Holiday Automations 2022-11-26 16:45:31 -08:00
3e71db7665 Fix Christmas Season Sensor 2022-11-26 16:41:05 -08:00
229e2ba82f Add Christmas Season Sensor 2022-11-26 16:38:21 -08:00
d1591f7a2a Add Holiday Automations 2022-11-26 16:12:21 -08:00
41e84335d5 Do not adjust volume if muted 2022-11-26 08:28:40 -07:00
e234f0ffe7 Add Vacation Package 2022-11-23 09:46:02 -07:00
7745da43fd Fix Alert Targets 2022-11-20 19:58:26 -08:00
47290b03a5 Add Garage Alerting 2022-11-20 10:09:39 -08:00
c6a41814a3 Update Weather Sensor Logging 2022-11-20 08:30:08 -08:00
5899df15fc Update Security Package 2022-11-13 08:33:44 -08:00
3442af63b0 Add Garden Package 2022-11-05 14:57:59 -07:00
61dec6c4d6 Add ZHA Switch Automations 2022-11-04 11:04:46 -07:00
201513180d Fix iPhone Entity Names 2022-10-30 13:14:21 -07:00
7b81862a1a Update Dashboards 2022-10-30 11:23:46 -07:00
0b474c92fd Fix Syntax Error 2022-10-30 11:06:27 -07:00
26622384e7 Add Templates for Climate Change Times 2022-10-30 11:04:42 -07:00
6e5eddc243 Add Templates for Climate Setpoints 2022-10-30 09:58:08 -07:00
root
108f9c24cc Update HACS 2022-10-30 09:47:33 -07:00
791369c66a Remove Python cache files 2022-10-30 07:40:45 -07:00
root
cf09f24303 Bump to Home Assistant 2022.7 2022-10-30 07:37:46 -07:00
8d41efc3eb Update Frigate Notifications 2022-10-28 16:23:29 -07:00
49f1db47d6 Update Presence Package 2022-10-13 15:47:40 -07:00
24d1c24b92 Update Presence Package 2022-10-12 16:34:39 -07:00
de014d785d Add Frigate Card Resources 2022-10-11 11:31:51 -07:00
a177a85a10 Update Frigate Notifications 2022-10-10 17:33:23 -07:00
46019507ec Update Frigate Notifications 2022-10-07 11:18:12 -07:00
d374ab8249 Update Frigate Notifications 2022-10-07 11:17:51 -07:00
591d484d8f Update Frigate Notifications 2022-10-07 11:14:27 -07:00
e5856e9488 Update Frigate Notifications 2022-10-07 11:06:27 -07:00
ac37a3a125 Update Frigate Notifications 2022-10-07 10:44:59 -07:00
c797f43378 Add Frigate Notifications 2022-10-07 09:15:28 -07:00
ad56dda579 Update Climate Package 2022-09-13 05:46:08 -07:00
73a6ef512f Update Energy Package 2022-09-04 08:10:55 -07:00
7ede540ae8 Update Energy Package 2022-09-04 08:09:41 -07:00
86bfdef296 Update Energy Package 2022-09-04 08:06:29 -07:00
0160866a94 Update Energy Package 2022-09-04 08:01:45 -07:00
4513653fb7 Update Energy Package 2022-09-04 08:00:12 -07:00
b02fc6783a Update Energy Package 2022-09-04 07:58:23 -07:00
242b2389e5 Update Energy Package 2022-09-04 07:55:14 -07:00
530035e01a Update Energy Package 2022-09-04 07:52:18 -07:00
65d9b7ab83 Update Lighting Package 2022-08-28 09:10:58 -07:00
f7f0aadf21 Update Lighting Package 2022-08-27 15:37:27 -07:00
24ab9bf6ec Update Laundry Package 2022-08-27 08:23:02 -07:00
07f4e6b2c2 Update Laundry Package 2022-08-21 14:23:07 -07:00
142adbc036 Update Alerts Package 2022-08-21 12:32:56 -07:00
88431c3d8c Add Alerts Package 2022-08-21 12:31:01 -07:00
aff800904d Update Laundry Package 2022-08-21 12:13:41 -07:00
e347e74505 Update Security Package 2022-08-20 15:36:53 -07:00
6e65862bc2 Update Security Package 2022-08-20 10:38:14 -07:00
dbebdeadc1 Update Security Package 2022-08-20 08:43:15 -07:00
deafee5faa Update Security Package 2022-08-20 08:36:32 -07:00
4ff27b23df Update Security Package 2022-08-20 08:35:24 -07:00
f105b0d902 Update Security Package 2022-08-20 08:33:35 -07:00
70d856c92e Update Security Package 2022-08-20 08:26:16 -07:00
bc22e2d5a7 Update Security Package 2022-08-20 08:23:34 -07:00
a8af72541d Update Climate Package 2022-08-18 15:17:28 -07:00
9323fffa5b Update Laundry Package 2022-08-16 05:37:48 -07:00
28303fa0fd Update Security Package 2022-08-15 18:11:54 -07:00
bb98e94d28 Update Laundry Package 2022-08-14 15:33:01 -07:00
97300b755d Update Laundry Package 2022-08-14 12:37:10 -07:00
beb1c164c4 Update Laundry Package 2022-08-14 11:43:24 -07:00
348f607b1d Update Laundry Package 2022-08-14 11:33:50 -07:00
0c37c7029d Update Laundry Package 2022-08-14 11:31:23 -07:00
6cdfa36d53 Update Laundry Package 2022-08-14 11:30:15 -07:00
b86e1983f1 Update Laundry Package 2022-08-14 11:28:28 -07:00
b2446aa9ac Update Energy Package 2022-08-10 12:56:32 -07:00
a993e0110f Update Configuration 2022-08-08 14:52:37 -07:00
668eeb0d15 Update Configuration 2022-08-08 12:02:09 -07:00
0b53292b04 Update Notifications Package 2022-08-05 14:03:06 -07:00
4eaa6055b9 Update Energy Package 2022-08-03 17:04:21 -07:00
2cfe5288de Update Energy Package 2022-08-03 17:02:18 -07:00
829a6f2cf4 Update Energy Package 2022-08-03 16:53:36 -07:00
a765f93b7d Update Energy Package 2022-08-03 15:59:11 -07:00
ad3f691f84 Update Energy Package 2022-08-03 15:58:27 -07:00
5ee48054dd Update Energy Package 2022-08-03 15:57:31 -07:00
2d19d2249b Update Energy Package 2022-08-03 11:48:39 -07:00
c0943b69b8 Update Energy Package 2022-08-03 11:47:25 -07:00
c15f4e8ec4 Update Energy Package 2022-08-03 11:41:07 -07:00
64d015b4bc Update Energy Package 2022-08-03 11:38:00 -07:00
d459d808dc Update Energy Package 2022-08-03 11:36:33 -07:00
f95fffe056 Update Energy Package 2022-08-03 11:34:46 -07:00
57af7235dc Update Energy Package 2022-08-03 11:31:43 -07:00
bed51650a0 Update Presence Package 2022-08-01 16:45:01 -07:00
4ef3c20c70 Update Laundry Package 2022-07-31 15:00:27 -07:00
1e4192c206 Update Laundry Package 2022-07-31 14:59:25 -07:00
b400486526 Update Laundry Package 2022-07-31 14:57:44 -07:00
38176c839b Fix Notification 2022-07-27 10:26:37 -07:00
a4e9c14709 Add Laundry Package 2022-07-27 10:24:37 -07:00
71bd2e796e Update Energy Package 2022-07-24 11:48:31 -07:00
f7a1292526 Update Energy Package 2022-07-22 09:01:10 -07:00
205e5562fc Update Energy Package 2022-07-22 08:56:13 -07:00
b2f7786ff5 Update Energy Package 2022-07-22 08:48:20 -07:00
91e4efd531 Update Energy Package 2022-07-22 08:42:12 -07:00
6023c81531 Update Energy Package 2022-07-22 08:16:47 -07:00
f192f6a45f Update Energy Package 2022-07-22 08:00:16 -07:00
7b0386798e Update Energy Package 2022-07-22 07:59:08 -07:00
2102bcf67c Update Energy Package 2022-07-21 14:31:19 -07:00
87a8791001 Update Dashboard 2022-07-19 17:23:37 -07:00
04ea888ec2 Update Security Package 2022-07-19 17:18:08 -07:00
56fa04ffc7 Fix Syntax Error 2022-07-19 17:16:32 -07:00
2eef1192ed Fix Syntax Error 2022-07-19 17:14:51 -07:00
1fef521fbd Fix Syntax Error 2022-07-19 17:13:50 -07:00
ed91ffe899 Fix Syntax Error 2022-07-19 17:12:29 -07:00
90322fde2a Update Dashboard 2022-07-19 17:11:20 -07:00
3071c96a9e Update Security Package 2022-07-19 17:10:59 -07:00
4a00e1f036 Update Dashboard 2022-07-19 17:04:21 -07:00
c0d48c220e Fix Syntax Error 2022-07-19 16:56:38 -07:00
1fbfcd49c7 Update Dashboard 2022-07-19 16:55:44 -07:00
1833c91d0e Fix Syntax Error 2022-07-19 16:53:17 -07:00
73460d5c3c Add Date/Time Package 2022-07-19 16:52:21 -07:00
4981bb797a Add System Package 2022-07-19 16:40:37 -07:00
4ca66c4186 Update Dashboards 2022-07-19 16:35:23 -07:00
39ddea7900 Update Lighting Package 2022-07-19 16:30:17 -07:00
233ac972a7 Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2022-07-19 16:07:41 -07:00
53d06da51b Remove Empty Resources 2022-07-19 16:07:37 -07:00
root
2d1e374e3a Update HACS 2022-07-19 16:05:32 -07:00
1970e01e48 Change to YAML Dashboards 2022-07-19 16:04:47 -07:00
2b55d0b167 Update Energy Sensor 2022-07-15 12:59:34 -07:00
53351f865f Update Energy Sensor 2022-07-15 12:57:22 -07:00
83e7352547 Add Energy Package 2022-07-15 12:47:55 -07:00
97de261573 Update Media Package 2022-07-12 18:34:23 -07:00
81f8f5b768 Update Media Package 2022-07-10 17:43:35 -07:00
bd8b0bcefc Update Media Package 2022-07-10 17:31:55 -07:00
cd794a8e3c Update Media Package 2022-07-10 17:24:23 -07:00
4cdded6752 Fix Automation Syntax 2022-07-10 17:14:21 -07:00
a1e6033fcd Update Media Package 2022-07-10 17:13:26 -07:00
4065c97139 Fix Automation Syntax 2022-07-10 17:08:08 -07:00
9c75e6b278 Add HVAC Volume Adjustment to Media Package 2022-07-10 17:06:30 -07:00
root
c91b0de496 Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2022-07-10 15:45:00 -07:00
root
bb67b3d22b Update HACS 2022-07-10 15:44:36 -07:00
5eca7623dd Add Energy Dashboard 2022-07-10 15:43:23 -07:00
ac8cf8f906 Update Notifications 2022-07-05 17:06:49 -07:00
6543f8dfeb Update Security Package 2022-07-05 17:02:57 -07:00
0dc53bab0b Update Security Package 2022-07-05 16:30:29 -07:00
81b4166cfe Update Security Package 2022-07-05 16:27:22 -07:00
2bef2a1781 Update Security Package 2022-07-05 16:25:00 -07:00
e506cbbe06 Update Security Package 2022-07-05 16:20:16 -07:00
c672700323 Update Security Package 2022-07-05 16:08:22 -07:00
98dac1c994 Update Security Package 2022-07-05 16:06:26 -07:00
2a5fcc8af6 Update Security Package 2022-07-05 16:01:52 -07:00
b26f01d8cd Update Security Package 2022-07-05 15:58:07 -07:00
549032857f Update Notifications 2022-07-05 15:37:52 -07:00
7c0bd90b1f Update Notifications 2022-07-05 15:35:50 -07:00
1735720adc Update Notifications 2022-07-05 15:31:02 -07:00
b3b12deace Update Notifications 2022-07-05 15:27:28 -07:00
6823dc8596 Update Notifications 2022-07-05 15:14:40 -07:00
f02b8dcd31 Update Notifications 2022-07-05 15:00:51 -07:00
815151f959 Update Irrigation Package 2022-07-05 12:43:02 -07:00
1bea5c4df4 Update Irrigation Package 2022-06-24 16:07:12 -07:00
dbcef1896a Update Security Package 2022-06-20 06:59:34 -07:00
2a59c8f937 Update Security Package 2022-06-18 14:24:36 -07:00
7452ccefc1 Update Security Package 2022-06-18 13:55:58 -07:00
6a0fbc2541 Update Security Package 2022-06-18 11:46:57 -07:00
509bfa4849 Update Climate Package 2022-06-04 15:17:18 -07:00
04e1b0ecc8 Update Climate Package 2022-06-04 15:01:51 -07:00
76121c7b98 Update Climate Package 2022-06-04 14:48:08 -07:00
f76c9dfba8 Update Climate Package 2022-06-04 14:36:49 -07:00
root
926459c9fd Merge branch 'main' of https://git.asymworks.com/jkrauss/home-assistant 2022-06-04 14:24:14 -07:00
root
e6757e2cbd Update HACS 2022-06-04 14:23:34 -07:00
e01a037d8d Add Climate Package 2022-06-04 14:22:22 -07:00
1dd97a14c2 Update Lighting Package 2022-06-03 13:00:06 -07:00
8b53669400 Fix Syntax Error 2022-05-28 10:43:23 -07:00
e929517799 Fix Syntax Error 2022-05-28 10:40:40 -07:00
e157001dd9 Fix Syntax Error 2022-05-28 10:38:13 -07:00
b0d8c661ef Fix Syntax Error 2022-05-28 10:36:34 -07:00
13751ee291 Update Security Package 2022-05-28 10:30:23 -07:00
92eda1c045 Fix Syntax Error 2022-05-28 09:54:54 -07:00
95 changed files with 5917 additions and 1402 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.HA_VERSION .HA_VERSION
.cloud .cloud
.private
.storage .storage
.uuid .uuid
*.conf *.conf
@@ -7,9 +8,11 @@
*.crt *.crt
*.key *.key
*.tar.gz *.tar.gz
*.pyc
adb/ adb/
deps/ deps/
tts/ tts/
www/
__pycache__/ __pycache__/
blueprints/ blueprints/
custom_components/ custom_components/

View File

@@ -5,13 +5,14 @@ homeassistant:
name: Home name: Home
# Location and Time Zone # Location and Time Zone
country: US
latitude: !secret home_latitude # dummy: 0.0 latitude: !secret home_latitude # dummy: 0.0
longitude: !secret home_longitude # dummy: 0.0 longitude: !secret home_longitude # dummy: 0.0
elevation: 10 # meters, WGS84 elevation: 10 # meters, WGS84
time_zone: America/Los_Angeles time_zone: America/Los_Angeles
# Measurement System # Measurement System
unit_system: imperial unit_system: us_customary
currency: USD currency: USD
# URL and Access Control # URL and Access Control
@@ -31,6 +32,19 @@ homeassistant:
customize: !include include/customize.yaml customize: !include include/customize.yaml
packages: !include_dir_named packages packages: !include_dir_named packages
# OpenID Connect
auth_oidc:
display_name: KraussNet SSO
client_id: "hass"
discovery_url: "https://idm.kraussnet.com/oauth2/openid/hass/.well-known/openid-configuration"
features:
automatic_person_creation: false
automatic_user_linking: true
id_token_signing_alg: "ES256"
roles:
admin: "hass_admins@idm.kraussnet.com"
user: "hass_users@idm.kraussnet.com"
# HTTP Access # HTTP Access
http: http:
ip_ban_enabled: true ip_ban_enabled: true
@@ -42,14 +56,13 @@ http:
- !secret gateway_ip4 - !secret gateway_ip4
# Configure Lovelace/Dashboards for YAML Configuration # Configure Lovelace/Dashboards for YAML Configuration
# lovelace: lovelace:
# mode: yaml mode: yaml
# resources: !include lovelace/resources.yaml resources: !include lovelace/resources.yaml
# dashboards: !include lovelace/dashboards.yaml dashboards: !include lovelace/dashboards.yaml
# MQTT Broker Connection # MQTT Broker Connection
mqtt: mqtt:
certificate: /config/network-ca-chain.pem
# Configure Logging # Configure Logging
logger: logger:
@@ -65,9 +78,17 @@ logger:
# Enable the Configuration UI # Enable the Configuration UI
config: config:
# Enable the Energy Dashboard
energy:
# Enable FFmpeg for Streaming
ffmpeg:
# Enable the Front End # Enable the Front End
frontend: frontend:
themes: !include_dir_merge_named themes themes: !include_dir_merge_named themes
extra_module_url:
- /hacsfiles/lovelace-card-mod/card-mod.js
# Configure FontAwesome Icons # Configure FontAwesome Icons
fontawesome: fontawesome:
@@ -81,7 +102,8 @@ history:
logbook: logbook:
# Enables a map showing the location of tracked devices # Enables a map showing the location of tracked devices
map: # NOTE: Removed from YAML in 2024.10.0
# map:
# Enable the Home Assistant Companion Apps # Enable the Home Assistant Companion Apps
mobile_app: mobile_app:
@@ -97,6 +119,9 @@ recorder:
# Used by Philips Hue (disable if moving to Zigbee) # Used by Philips Hue (disable if moving to Zigbee)
ssdp: ssdp:
# Enable streaming support
stream:
# Track the sun # Track the sun
sun: sun:
@@ -106,14 +131,14 @@ system_health:
# Enable UPnP/Zeroconf Service Discovery and Advertising # Enable UPnP/Zeroconf Service Discovery and Advertising
zeroconf: zeroconf:
# Discover some devices automatically # Discover some devices automatically (deprecated in 2023.8)
discovery: # discovery:
# Setup Includes for UI # Setup Includes for UI
automation: !include include/automations.yaml automation ui: !include automations.yaml
scene: !include include/scenes.yaml scene ui: !include scenes.yaml
script: !include include/scripts.yaml script ui: !include scripts.yaml
zone: !include include/zones.yaml zone ui: !include zones.yaml
# Setup InfluxDB Logging # Setup InfluxDB Logging
influxdb: influxdb:
@@ -141,3 +166,12 @@ influxdb:
- script - script
- weather - weather
- zone - zone
# Force ZHA to use Channel 15 or 20
zha:
zigpy_config:
network:
channel: 15
channels: [15, 20]
ota:
inovelli_provider: true

View File

@@ -1,78 +1,58 @@
""" """HACS gives you a powerful UI to handle downloads of all your custom needs.
HACS gives you a powerful UI to handle downloads of all your custom needs.
For more details about this integration, please refer to the documentation at For more details about this integration, please refer to the documentation at
https://hacs.xyz/ https://hacs.xyz/
""" """
from __future__ import annotations
import os from __future__ import annotations
from typing import Any
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
from aiogithubapi.const import ACCEPT_HEADERS from aiogithubapi.const import ACCEPT_HEADERS
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from homeassistant.components.frontend import async_remove_panel
from homeassistant.components.lovelace.system_health import system_health_info from homeassistant.components.lovelace.system_health import system_health_info
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform, __version__ as HAVERSION from homeassistant.const import Platform, __version__ as HAVERSION
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_start from homeassistant.helpers.start import async_at_start
from homeassistant.loader import async_get_integration from homeassistant.loader import async_get_integration
import voluptuous as vol
from custom_components.hacs.frontend import async_register_frontend
from .base import HacsBase from .base import HacsBase
from .const import DOMAIN, MINIMUM_HA_VERSION, STARTUP from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION, STARTUP
from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode from .data_client import HacsDataClient
from .utils.configuration_schema import hacs_config_combined from .enums import HacsDisabledReason, HacsStage, LovelaceMode
from .frontend import async_register_frontend
from .utils.data import HacsData from .utils.data import HacsData
from .utils.queue_manager import QueueManager from .utils.queue_manager import QueueManager
from .utils.version import version_left_higher_or_equal_then_right from .utils.version import version_left_higher_or_equal_then_right
from .websocket import async_register_websocket_commands from .websocket import async_register_websocket_commands
CONFIG_SCHEMA = vol.Schema({DOMAIN: hacs_config_combined()}, extra=vol.ALLOW_EXTRA) PLATFORMS = [Platform.SWITCH, Platform.UPDATE]
async def async_initialize_integration( async def _async_initialize_integration(
hass: HomeAssistant, hass: HomeAssistant,
*, config_entry: ConfigEntry,
config_entry: ConfigEntry | None = None,
config: dict[str, Any] | None = None,
) -> bool: ) -> bool:
"""Initialize the integration""" """Initialize the integration"""
hass.data[DOMAIN] = hacs = HacsBase() hass.data[DOMAIN] = hacs = HacsBase()
hacs.enable_hacs() hacs.enable_hacs()
if config is not None: if config_entry.source == SOURCE_IMPORT:
if DOMAIN not in config: # Import is not supported
return True hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY: return False
return True
hacs.configuration.update_from_dict(
{
"config_type": ConfigurationType.YAML,
**config[DOMAIN],
"config": config[DOMAIN],
}
)
if config_entry is not None: hacs.configuration.update_from_dict(
if config_entry.source == SOURCE_IMPORT: {
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id)) "config_entry": config_entry,
return False **config_entry.data,
**config_entry.options,
hacs.configuration.update_from_dict( },
{ )
"config_entry": config_entry,
"config_type": ConfigurationType.CONFIG_ENTRY,
**config_entry.data,
**config_entry.options,
}
)
integration = await async_get_integration(hass, DOMAIN) integration = await async_get_integration(hass, DOMAIN)
@@ -88,6 +68,10 @@ async def async_initialize_integration(
hacs.hass = hass hacs.hass = hass
hacs.queue = QueueManager(hass=hass) hacs.queue = QueueManager(hass=hass)
hacs.data = HacsData(hacs=hacs) hacs.data = HacsData(hacs=hacs)
hacs.data_client = HacsDataClient(
session=clientsession,
client_name=f"HACS/{integration.version}",
)
hacs.system.running = True hacs.system.running = True
hacs.session = clientsession hacs.session = clientsession
@@ -98,7 +82,6 @@ async def async_initialize_integration(
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
# If this happens, the users YAML is not valid, we assume YAML mode # If this happens, the users YAML is not valid, we assume YAML mode
pass pass
hacs.log.debug("Configuration type: %s", hacs.configuration.config_type)
hacs.core.config_path = hacs.hass.config.path() hacs.core.config_path = hacs.hass.config.path()
if hacs.core.ha_version is None: if hacs.core.ha_version is None:
@@ -125,19 +108,18 @@ async def async_initialize_integration(
"""HACS startup tasks.""" """HACS startup tasks."""
hacs.enable_hacs() hacs.enable_hacs()
for location in ( try:
hass.config.path("custom_components/custom_updater.py"), import custom_components.custom_updater
hass.config.path("custom_components/custom_updater/__init__.py"), except ImportError:
): pass
if os.path.exists(location): else:
hacs.log.critical( hacs.log.critical(
"This cannot be used with custom_updater. " "HACS cannot be used with custom_updater. "
"To use this you need to remove custom_updater form %s", "To use HACS you need to remove custom_updater from `custom_components`",
location, )
)
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS) hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False return False
if not version_left_higher_or_equal_then_right( if not version_left_higher_or_equal_then_right(
hacs.core.ha_version.string, hacs.core.ha_version.string,
@@ -154,40 +136,23 @@ async def async_initialize_integration(
hacs.disable_hacs(HacsDisabledReason.RESTORE) hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False return False
can_update = await hacs.async_can_update()
hacs.log.debug("Can update %s repositories", can_update)
hacs.set_active_categories() hacs.set_active_categories()
async_register_websocket_commands(hass) async_register_websocket_commands(hass)
async_register_frontend(hass, hacs) await async_register_frontend(hass, hacs)
if hacs.configuration.config_type == ConfigurationType.YAML: await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
hass.async_create_task(
async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, hacs.configuration.config)
)
hacs.log.info("Update entities are only supported when using UI configuration")
else:
if hacs.configuration.experimental:
hass.config_entries.async_setup_platforms(
hacs.configuration.config_entry, [Platform.SENSOR, Platform.UPDATE]
)
else:
hass.config_entries.async_setup_platforms(
hacs.configuration.config_entry, [Platform.SENSOR]
)
hacs.set_stage(HacsStage.SETUP) hacs.set_stage(HacsStage.SETUP)
if hacs.system.disabled: if hacs.system.disabled:
return False return False
# Schedule startup tasks
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
hacs.set_stage(HacsStage.WAITING) hacs.set_stage(HacsStage.WAITING)
hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts") hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
# Schedule startup tasks
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
return not hacs.system.disabled return not hacs.system.disabled
async def async_try_startup(_=None): async def async_try_startup(_=None):
@@ -197,10 +162,7 @@ async def async_initialize_integration(
except AIOGitHubAPIException: except AIOGitHubAPIException:
startup_result = False startup_result = False
if not startup_result: if not startup_result:
if ( if hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN:
hacs.configuration.config_type == ConfigurationType.YAML
or hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN
):
hacs.log.info("Could not setup HACS, trying again in 15 min") hacs.log.info("Could not setup HACS, trying again in 15 min")
async_call_later(hass, 900, async_try_startup) async_call_later(hass, 900, async_try_startup)
return return
@@ -208,19 +170,19 @@ async def async_initialize_integration(
await async_try_startup() await async_try_startup()
# Remove old (v0-v1) sensor if it exists, can be removed in v3
er = async_get_entity_registry(hass)
if old_sensor := er.async_get_entity_id("sensor", DOMAIN, HACS_SYSTEM_ID):
er.async_remove(old_sensor)
# Mischief managed! # Mischief managed!
return True return True
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up this integration using yaml."""
return await async_initialize_integration(hass=hass, config=config)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up this integration using UI.""" """Set up this integration using UI."""
config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry))
setup_result = await async_initialize_integration(hass=hass, config_entry=config_entry) setup_result = await _async_initialize_integration(hass=hass, config_entry=config_entry)
hacs: HacsBase = hass.data[DOMAIN] hacs: HacsBase = hass.data[DOMAIN]
return setup_result and not hacs.system.disabled return setup_result and not hacs.system.disabled
@@ -229,10 +191,14 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"""Handle removal of an entry.""" """Handle removal of an entry."""
hacs: HacsBase = hass.data[DOMAIN] hacs: HacsBase = hass.data[DOMAIN]
if hacs.queue.has_pending_tasks:
hacs.log.warning("Pending tasks, can not unload, try again later.")
return False
# Clear out pending queue # Clear out pending queue
hacs.queue.clear() hacs.queue.clear()
for task in hacs.recuring_tasks: for task in hacs.recurring_tasks:
# Cancel all pending tasks # Cancel all pending tasks
task() task()
@@ -242,15 +208,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
try: try:
if hass.data.get("frontend_panels", {}).get("hacs"): if hass.data.get("frontend_panels", {}).get("hacs"):
hacs.log.info("Removing sidepanel") hacs.log.info("Removing sidepanel")
hass.components.frontend.async_remove_panel("hacs") async_remove_panel(hass, "hacs")
except AttributeError: except AttributeError:
pass pass
platforms = ["sensor"] unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
if hacs.configuration.experimental:
platforms.append("update")
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, platforms)
hacs.set_stage(None) hacs.set_stage(None)
hacs.disable_hacs(HacsDisabledReason.REMOVED) hacs.disable_hacs(HacsDisabledReason.REMOVED)
@@ -262,5 +224,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Reload the HACS config entry.""" """Reload the HACS config entry."""
await async_unload_entry(hass, config_entry) if not await async_unload_entry(hass, config_entry):
return
await async_setup_entry(hass, config_entry) await async_setup_entry(hass, config_entry)

View File

@@ -1,17 +1,17 @@
"""Base HACS class.""" """Base HACS class."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import timedelta from datetime import timedelta
import gzip import gzip
import json
import logging
import math import math
import os import os
import pathlib import pathlib
import shutil import shutil
from typing import TYPE_CHECKING, Any, Awaitable, Callable from typing import TYPE_CHECKING, Any
from aiogithubapi import ( from aiogithubapi import (
AIOGitHubAPIException, AIOGitHubAPIException,
@@ -25,16 +25,22 @@ from aiogithubapi import (
from aiogithubapi.objects.repository import AIOGitHubAPIRepository from aiogithubapi.objects.repository import AIOGitHubAPIRepository
from aiohttp.client import ClientSession, ClientTimeout from aiohttp.client import ClientSession, ClientTimeout
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.components.persistent_notification import (
async_create as async_create_persistent_notification,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.loader import Integration from homeassistant.loader import Integration
from homeassistant.util import dt from homeassistant.util import dt
from .const import TV from .const import DOMAIN, TV, URL_BASE
from .coordinator import HacsUpdateCoordinator
from .data_client import HacsDataClient
from .enums import ( from .enums import (
ConfigurationType,
HacsCategory, HacsCategory,
HacsDisabledReason, HacsDisabledReason,
HacsDispatchEvent, HacsDispatchEvent,
@@ -47,15 +53,19 @@ from .exceptions import (
HacsException, HacsException,
HacsExecutionStillInProgress, HacsExecutionStillInProgress,
HacsExpectedException, HacsExpectedException,
HacsNotModifiedException,
HacsRepositoryArchivedException, HacsRepositoryArchivedException,
HacsRepositoryExistException, HacsRepositoryExistException,
HomeAssistantCoreRepositoryException, HomeAssistantCoreRepositoryException,
) )
from .repositories import RERPOSITORY_CLASSES from .repositories import REPOSITORY_CLASSES
from .utils.decode import decode_content from .repositories.base import HACS_MANIFEST_KEYS_TO_EXPORT, REPOSITORY_KEYS_TO_EXPORT
from .utils.logger import get_hacs_logger from .utils.file_system import async_exists
from .utils.json import json_loads
from .utils.logger import LOGGER
from .utils.queue_manager import QueueManager from .utils.queue_manager import QueueManager
from .utils.store import async_load_from_store, async_save_to_store from .utils.store import async_load_from_store, async_save_to_store
from .utils.workarounds import async_register_static_path
if TYPE_CHECKING: if TYPE_CHECKING:
from .repositories.base import HacsRepository from .repositories.base import HacsRepository
@@ -105,15 +115,11 @@ class HacsConfiguration:
appdaemon: bool = False appdaemon: bool = False
config: dict[str, Any] = field(default_factory=dict) config: dict[str, Any] = field(default_factory=dict)
config_entry: ConfigEntry | None = None config_entry: ConfigEntry | None = None
config_type: ConfigurationType | None = None
country: str = "ALL" country: str = "ALL"
debug: bool = False debug: bool = False
dev: bool = False dev: bool = False
experimental: bool = False
frontend_repo_url: str = "" frontend_repo_url: str = ""
frontend_repo: str = "" frontend_repo: str = ""
netdaemon_path: str = "netdaemon/apps/"
netdaemon: bool = False
plugin_path: str = "www/community/" plugin_path: str = "www/community/"
python_script_path: str = "python_scripts/" python_script_path: str = "python_scripts/"
python_script: bool = False python_script: bool = False
@@ -134,6 +140,8 @@ class HacsConfiguration:
raise HacsException("Configuration is not valid.") raise HacsException("Configuration is not valid.")
for key in data: for key in data:
if key in {"experimental", "netdaemon", "release_limit", "debug"}:
continue
self.__setattr__(key, data[key]) self.__setattr__(key, data[key])
@@ -152,9 +160,9 @@ class HacsCommon:
categories: set[str] = field(default_factory=set) categories: set[str] = field(default_factory=set)
renamed_repositories: dict[str, str] = field(default_factory=dict) renamed_repositories: dict[str, str] = field(default_factory=dict)
archived_repositories: list[str] = field(default_factory=list) archived_repositories: set[str] = field(default_factory=set)
ignored_repositories: list[str] = field(default_factory=list) ignored_repositories: set[str] = field(default_factory=set)
skip: list[str] = field(default_factory=list) skip: set[str] = field(default_factory=set)
@dataclass @dataclass
@@ -163,8 +171,9 @@ class HacsStatus:
startup: bool = True startup: bool = True
new: bool = False new: bool = False
reloading_data: bool = False active_frontend_endpoint_plugin: bool = False
upgrading_all: bool = False active_frontend_endpoint_theme: bool = False
inital_fetch_done: bool = False
@dataclass @dataclass
@@ -175,6 +184,7 @@ class HacsSystem:
running: bool = False running: bool = False
stage = HacsStage.SETUP stage = HacsStage.SETUP
action: bool = False action: bool = False
generator: bool = False
@property @property
def disabled(self) -> bool: def disabled(self) -> bool:
@@ -187,26 +197,33 @@ class HacsRepositories:
"""HACS Repositories.""" """HACS Repositories."""
_default_repositories: set[str] = field(default_factory=set) _default_repositories: set[str] = field(default_factory=set)
_repositories: list[HacsRepository] = field(default_factory=list) _repositories: set[HacsRepository] = field(default_factory=set)
_repositories_by_full_name: dict[str, str] = field(default_factory=dict) _repositories_by_full_name: dict[str, HacsRepository] = field(default_factory=dict)
_repositories_by_id: dict[str, str] = field(default_factory=dict) _repositories_by_id: dict[str, HacsRepository] = field(default_factory=dict)
_removed_repositories: list[RemovedRepository] = field(default_factory=list) _removed_repositories_by_full_name: dict[str, RemovedRepository] = field(default_factory=dict)
@property @property
def list_all(self) -> list[HacsRepository]: def list_all(self) -> list[HacsRepository]:
"""Return a list of repositories.""" """Return a list of repositories."""
return self._repositories return list(self._repositories)
@property @property
def list_removed(self) -> list[RemovedRepository]: def list_removed(self) -> list[RemovedRepository]:
"""Return a list of removed repositories.""" """Return a list of removed repositories."""
return self._removed_repositories return list(self._removed_repositories_by_full_name.values())
@property @property
def list_downloaded(self) -> list[HacsRepository]: def list_downloaded(self) -> list[HacsRepository]:
"""Return a list of downloaded repositories.""" """Return a list of downloaded repositories."""
return [repo for repo in self._repositories if repo.data.installed] return [repo for repo in self._repositories if repo.data.installed]
def category_downloaded(self, category: HacsCategory) -> bool:
"""Check if a given category has been downloaded."""
for repository in self.list_downloaded:
if repository.data.category == category:
return True
return False
def register(self, repository: HacsRepository, default: bool = False) -> None: def register(self, repository: HacsRepository, default: bool = False) -> None:
"""Register a repository.""" """Register a repository."""
repo_id = str(repository.data.id) repo_id = str(repository.data.id)
@@ -214,11 +231,18 @@ class HacsRepositories:
if repo_id == "0": if repo_id == "0":
return return
if self.is_registered(repository_id=repo_id): if registered_repo := self._repositories_by_id.get(repo_id):
return if registered_repo.data.full_name == repository.data.full_name:
return
self.unregister(registered_repo)
registered_repo.data.full_name = repository.data.full_name
registered_repo.data.new = False
repository = registered_repo
if repository not in self._repositories: if repository not in self._repositories:
self._repositories.append(repository) self._repositories.add(repository)
self._repositories_by_id[repo_id] = repository self._repositories_by_id[repo_id] = repository
self._repositories_by_full_name[repository.data.full_name_lower] = repository self._repositories_by_full_name[repository.data.full_name_lower] = repository
@@ -257,7 +281,7 @@ class HacsRepositories:
self._default_repositories.add(repo_id) self._default_repositories.add(repo_id)
def set_repository_id(self, repository, repo_id): def set_repository_id(self, repository: HacsRepository, repo_id: str):
"""Update a repository id.""" """Update a repository id."""
existing_repo_id = str(repository.data.id) existing_repo_id = str(repository.data.id)
if existing_repo_id == repo_id: if existing_repo_id == repo_id:
@@ -316,48 +340,46 @@ class HacsRepositories:
def is_removed(self, repository_full_name: str) -> bool: def is_removed(self, repository_full_name: str) -> bool:
"""Check if a repository is removed.""" """Check if a repository is removed."""
return repository_full_name in ( return repository_full_name in self._removed_repositories_by_full_name
repository.repository for repository in self._removed_repositories
)
def removed_repository(self, repository_full_name: str) -> RemovedRepository: def removed_repository(self, repository_full_name: str) -> RemovedRepository:
"""Get repository by full name.""" """Get repository by full name."""
if self.is_removed(repository_full_name): if removed := self._removed_repositories_by_full_name.get(repository_full_name):
if removed := [ return removed
repository
for repository in self._removed_repositories
if repository.repository == repository_full_name
]:
return removed[0]
removed = RemovedRepository(repository=repository_full_name) removed = RemovedRepository(repository=repository_full_name)
self._removed_repositories.append(removed) self._removed_repositories_by_full_name[repository_full_name] = removed
return removed return removed
class HacsBase: class HacsBase:
"""Base HACS class.""" """Base HACS class."""
common = HacsCommon()
configuration = HacsConfiguration()
core = HacsCore()
data: HacsData | None = None data: HacsData | None = None
data_client: HacsDataClient | None = None
frontend_version: str | None = None frontend_version: str | None = None
github: GitHub | None = None github: GitHub | None = None
githubapi: GitHubAPI | None = None githubapi: GitHubAPI | None = None
hass: HomeAssistant | None = None hass: HomeAssistant | None = None
integration: Integration | None = None integration: Integration | None = None
log: logging.Logger = get_hacs_logger()
queue: QueueManager | None = None queue: QueueManager | None = None
recuring_tasks = []
repositories: HacsRepositories = HacsRepositories()
repository: AIOGitHubAPIRepository | None = None repository: AIOGitHubAPIRepository | None = None
session: ClientSession | None = None session: ClientSession | None = None
stage: HacsStage | None = None stage: HacsStage | None = None
status = HacsStatus()
system = HacsSystem()
validation: ValidationManager | None = None validation: ValidationManager | None = None
version: str | None = None version: AwesomeVersion | None = None
def __init__(self) -> None:
"""Initialize."""
self.common = HacsCommon()
self.configuration = HacsConfiguration()
self.coordinators: dict[HacsCategory, HacsUpdateCoordinator] = {}
self.core = HacsCore()
self.log = LOGGER
self.recurring_tasks: list[Callable[[], None]] = []
self.repositories = HacsRepositories()
self.status = HacsStatus()
self.system = HacsSystem()
@property @property
def integration_dir(self) -> pathlib.Path: def integration_dir(self) -> pathlib.Path:
@@ -383,12 +405,7 @@ class HacsBase:
if reason != HacsDisabledReason.REMOVED: if reason != HacsDisabledReason.REMOVED:
self.log.error("HACS is disabled - %s", reason) self.log.error("HACS is disabled - %s", reason)
if ( if reason == HacsDisabledReason.INVALID_TOKEN:
reason == HacsDisabledReason.INVALID_TOKEN
and self.configuration.config_type == ConfigurationType.CONFIG_ENTRY
):
self.configuration.config_entry.state = ConfigEntryState.SETUP_ERROR
self.configuration.config_entry.reason = "Authentication failed"
self.hass.add_job(self.configuration.config_entry.async_start_reauth, self.hass) self.hass.add_job(self.configuration.config_entry.async_start_reauth, self.hass)
def enable_hacs(self) -> None: def enable_hacs(self) -> None:
@@ -402,12 +419,14 @@ class HacsBase:
if category not in self.common.categories: if category not in self.common.categories:
self.log.info("Enable category: %s", category) self.log.info("Enable category: %s", category)
self.common.categories.add(category) self.common.categories.add(category)
self.coordinators[category] = HacsUpdateCoordinator()
def disable_hacs_category(self, category: HacsCategory) -> None: def disable_hacs_category(self, category: HacsCategory) -> None:
"""Disable HACS category.""" """Disable HACS category."""
if category in self.common.categories: if category in self.common.categories:
self.log.info("Disabling category: %s", category) self.log.info("Disabling category: %s", category)
self.common.categories.pop(category) self.common.categories.pop(category)
self.coordinators.pop(category)
async def async_save_file(self, file_path: str, content: Any) -> bool: async def async_save_file(self, file_path: str, content: Any) -> bool:
"""Save a file.""" """Save a file."""
@@ -439,11 +458,14 @@ class HacsBase:
try: try:
await self.hass.async_add_executor_job(_write_file) await self.hass.async_add_executor_job(_write_file)
except BaseException as error: # lgtm [py/catch-base-exception] pylint: disable=broad-except except (
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as error:
self.log.error("Could not write data to %s - %s", file_path, error) self.log.error("Could not write data to %s - %s", file_path, error)
return False return False
return os.path.exists(file_path) return await async_exists(self.hass, file_path)
async def async_can_update(self) -> int: async def async_can_update(self) -> int:
"""Helper to calculate the number of repositories we can fetch data for.""" """Helper to calculate the number of repositories we can fetch data for."""
@@ -458,23 +480,14 @@ class HacsBase:
f"{reset.hour}:{reset.minute}:{reset.second}", f"{reset.hour}:{reset.minute}:{reset.second}",
) )
self.disable_hacs(HacsDisabledReason.RATE_LIMIT) self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except except (
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
self.log.exception(exception) self.log.exception(exception)
return 0 return 0
async def async_github_get_hacs_default_file(self, filename: str) -> list:
"""Get the content of a default file."""
response = await self.async_github_api_method(
method=self.githubapi.repos.contents.get,
repository=HacsGitHubRepo.DEFAULT,
path=filename,
)
if response is None:
return []
return json.loads(decode_content(response.data.content))
async def async_github_api_method( async def async_github_api_method(
self, self,
method: Callable[[], Awaitable[TV]], method: Callable[[], Awaitable[TV]],
@@ -497,7 +510,10 @@ class HacsBase:
raise exception raise exception
except GitHubException as exception: except GitHubException as exception:
_exception = exception _exception = exception
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except except (
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
self.log.exception(exception) self.log.exception(exception)
_exception = exception _exception = exception
@@ -528,49 +544,56 @@ class HacsBase:
): ):
raise AddonRepositoryException() raise AddonRepositoryException()
if category not in RERPOSITORY_CLASSES: if category not in REPOSITORY_CLASSES:
raise HacsException(f"{category} is not a valid repository category.") self.log.warning(
"%s is not a valid repository category, %s will not be registered.",
category,
repository_full_name,
)
return
if (renamed := self.common.renamed_repositories.get(repository_full_name)) is not None: if (renamed := self.common.renamed_repositories.get(repository_full_name)) is not None:
repository_full_name = renamed repository_full_name = renamed
repository: HacsRepository = RERPOSITORY_CLASSES[category](self, repository_full_name) repository: HacsRepository = REPOSITORY_CLASSES[category](self, repository_full_name)
if check: if check:
try: try:
await repository.async_registration(ref) await repository.async_registration(ref)
if self.status.new:
repository.data.new = False
if repository.validate.errors: if repository.validate.errors:
self.common.skip.append(repository.data.full_name) self.common.skip.add(repository.data.full_name)
if not self.status.startup: if not self.status.startup:
self.log.error("Validation for %s failed.", repository_full_name) self.log.error("Validation for %s failed.", repository_full_name)
if self.system.action: if self.system.action:
raise HacsException( raise HacsException(
f"::error:: Validation for {repository_full_name} failed." f"::error:: Validation for {
repository_full_name} failed."
) )
return repository.validate.errors return repository.validate.errors
if self.system.action: if self.system.action:
repository.logger.info("%s Validation completed", repository.string) repository.logger.info("%s Validation completed", repository.string)
else: else:
repository.logger.info("%s Registration completed", repository.string) repository.logger.info("%s Registration completed", repository.string)
except (HacsRepositoryExistException, HacsRepositoryArchivedException): except (HacsRepositoryExistException, HacsRepositoryArchivedException) as exception:
if self.system.generator:
repository.logger.error(
"%s Registration Failed - %s", repository.string, exception
)
return return
except AIOGitHubAPIException as exception: except AIOGitHubAPIException as exception:
self.common.skip.append(repository.data.full_name) self.common.skip.add(repository.data.full_name)
raise HacsException( raise HacsException(
f"Validation for {repository_full_name} failed with {exception}." f"Validation for {
repository_full_name} failed with {exception}."
) from exception ) from exception
if self.status.new:
repository.data.new = False
if repository_id is not None: if repository_id is not None:
repository.data.id = repository_id repository.data.id = repository_id
if str(repository.data.id) != "0" and (
exists := self.repositories.get_by_id(repository.data.id)
):
self.repositories.unregister(exists)
else: else:
if self.hass is not None and ((check and repository.data.new) or self.status.new): if self.hass is not None and check and repository.data.new:
self.async_dispatch( self.async_dispatch(
HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.REPOSITORY,
{ {
@@ -585,104 +608,90 @@ class HacsBase:
async def startup_tasks(self, _=None) -> None: async def startup_tasks(self, _=None) -> None:
"""Tasks that are started after setup.""" """Tasks that are started after setup."""
self.set_stage(HacsStage.STARTUP) self.set_stage(HacsStage.STARTUP)
await self.async_load_hacs_from_github()
try:
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
if repository is None:
await self.async_register_repository(
repository_full_name=HacsGitHubRepo.INTEGRATION,
category=HacsCategory.INTEGRATION,
default=True,
)
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
if repository is None:
raise HacsException("Unknown error")
repository.data.installed = True
repository.data.installed_version = self.integration.version.string
repository.data.new = False
repository.data.releases = True
self.repository = repository.repository_object
self.repositories.mark_default(repository)
except HacsException as exception:
if "403" in str(exception):
self.log.critical(
"GitHub API is ratelimited, or the token is wrong.",
)
else:
self.log.critical("Could not load HACS! - %s", exception)
self.disable_hacs(HacsDisabledReason.LOAD_HACS)
if critical := await async_load_from_store(self.hass, "critical"): if critical := await async_load_from_store(self.hass, "critical"):
for repo in critical: for repo in critical:
if not repo["acknowledged"]: if not repo["acknowledged"]:
self.log.critical("URGENT!: Check the HACS panel!") self.log.critical("URGENT!: Check the HACS panel!")
self.hass.components.persistent_notification.create( async_create_persistent_notification(
title="URGENT!", message="**Check the HACS panel!**" self.hass, title="URGENT!", message="**Check the HACS panel!**"
) )
break break
self.recuring_tasks.append( self.recurring_tasks.append(
self.hass.helpers.event.async_track_time_interval( async_track_time_interval(
self.async_get_all_category_repositories, timedelta(hours=3) self.hass,
) self.async_load_hacs_from_github,
) timedelta(hours=48),
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_update_all_repositories, timedelta(hours=25)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_check_rate_limit, timedelta(minutes=5)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_prosess_queue, timedelta(minutes=10)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_update_downloaded_repositories, timedelta(hours=2)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_handle_critical_repositories, timedelta(hours=2)
) )
) )
self.hass.bus.async_listen_once( self.recurring_tasks.append(
async_track_time_interval(
self.hass, self.async_update_downloaded_custom_repositories, timedelta(hours=48)
)
)
self.recurring_tasks.append(
async_track_time_interval(
self.hass, self.async_get_all_category_repositories, timedelta(hours=6)
)
)
self.recurring_tasks.append(
async_track_time_interval(self.hass, self.async_check_rate_limit, timedelta(minutes=5))
)
self.recurring_tasks.append(
async_track_time_interval(self.hass, self.async_process_queue, timedelta(minutes=10))
)
self.recurring_tasks.append(
async_track_time_interval(
self.hass, self.async_handle_critical_repositories, timedelta(hours=6)
)
)
unsub = self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write
) )
if config_entry := self.configuration.config_entry:
config_entry.async_on_unload(unsub)
self.log.debug("There are %s scheduled recurring tasks", len(self.recurring_tasks))
self.status.startup = False self.status.startup = False
self.async_dispatch(HacsDispatchEvent.STATUS, {}) self.async_dispatch(HacsDispatchEvent.STATUS, {})
await self.async_handle_removed_repositories() await self.async_handle_removed_repositories()
await self.async_get_all_category_repositories() await self.async_get_all_category_repositories()
await self.async_update_downloaded_repositories()
self.set_stage(HacsStage.RUNNING) self.set_stage(HacsStage.RUNNING)
self.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True}) self.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True})
await self.async_handle_critical_repositories() await self.async_handle_critical_repositories()
await self.async_prosess_queue() await self.async_process_queue()
self.async_dispatch(HacsDispatchEvent.STATUS, {}) self.async_dispatch(HacsDispatchEvent.STATUS, {})
async def async_download_file(self, url: str, *, headers: dict | None = None) -> bytes | None: async def async_download_file(
self,
url: str,
*,
headers: dict | None = None,
keep_url: bool = False,
nolog: bool = False,
**_,
) -> bytes | None:
"""Download files, and return the content.""" """Download files, and return the content."""
if url is None: if url is None:
return None return None
if "tags/" in url: if not keep_url and "tags/" in url:
url = url.replace("tags/", "") url = url.replace("tags/", "")
self.log.debug("Downloading %s", url) self.log.debug("Trying to download %s", url)
timeouts = 0 timeouts = 0
while timeouts < 5: while timeouts < 5:
@@ -698,9 +707,10 @@ class HacsBase:
return await request.read() return await request.read()
raise HacsException( raise HacsException(
f"Got status code {request.status} when trying to download {url}" f"Got status code {
request.status} when trying to download {url}"
) )
except asyncio.TimeoutError: except TimeoutError:
self.log.warning( self.log.warning(
"A timeout of 60! seconds was encountered while downloading %s, " "A timeout of 60! seconds was encountered while downloading %s, "
"using over 60 seconds to download a single file is not normal. " "using over 60 seconds to download a single file is not normal. "
@@ -715,25 +725,39 @@ class HacsBase:
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except except (
self.log.exception("Download failed - %s", exception) # lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
if not nolog:
self.log.exception("Download failed - %s", exception)
return None return None
async def async_recreate_entities(self) -> None: async def async_recreate_entities(self) -> None:
"""Recreate entities.""" """Recreate entities."""
if self.configuration == ConfigurationType.YAML or not self.configuration.experimental: platforms = [Platform.UPDATE]
return
platforms = [Platform.SENSOR, Platform.UPDATE] # Workaround for core versions without https://github.com/home-assistant/core/pull/117084
if self.core.ha_version < AwesomeVersion("2024.6.0"):
await self.hass.config_entries.async_unload_platforms( unload_platforms_lock = asyncio.Lock()
entry=self.configuration.config_entry, async with unload_platforms_lock:
platforms=platforms, on_unload = self.configuration.config_entry._on_unload
self.configuration.config_entry._on_unload = []
await self.hass.config_entries.async_unload_platforms(
entry=self.configuration.config_entry,
platforms=platforms,
)
self.configuration.config_entry._on_unload = on_unload
else:
await self.hass.config_entries.async_unload_platforms(
entry=self.configuration.config_entry,
platforms=platforms,
)
await self.hass.config_entries.async_forward_entry_setups(
self.configuration.config_entry, platforms
) )
self.hass.config_entries.async_setup_platforms(self.configuration.config_entry, platforms)
@callback @callback
def async_dispatch(self, signal: HacsDispatchEvent, data: dict | None = None) -> None: def async_dispatch(self, signal: HacsDispatchEvent, data: dict | None = None) -> None:
"""Dispatch a signal with data.""" """Dispatch a signal with data."""
@@ -742,19 +766,63 @@ class HacsBase:
def set_active_categories(self) -> None: def set_active_categories(self) -> None:
"""Set the active categories.""" """Set the active categories."""
self.common.categories = set() self.common.categories = set()
for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN): for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN, HacsCategory.TEMPLATE):
self.enable_hacs_category(HacsCategory(category)) self.enable_hacs_category(HacsCategory(category))
if HacsCategory.PYTHON_SCRIPT in self.hass.config.components: if (
HacsCategory.PYTHON_SCRIPT in self.hass.config.components
or self.repositories.category_downloaded(HacsCategory.PYTHON_SCRIPT)
):
self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT) self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT)
if self.hass.services.has_service("frontend", "reload_themes"): if self.hass.services.has_service(
"frontend", "reload_themes"
) or self.repositories.category_downloaded(HacsCategory.THEME):
self.enable_hacs_category(HacsCategory.THEME) self.enable_hacs_category(HacsCategory.THEME)
if self.configuration.appdaemon: if self.configuration.appdaemon:
self.enable_hacs_category(HacsCategory.APPDAEMON) self.enable_hacs_category(HacsCategory.APPDAEMON)
if self.configuration.netdaemon:
self.enable_hacs_category(HacsCategory.NETDAEMON) async def async_load_hacs_from_github(self, _=None) -> None:
"""Load HACS from GitHub."""
if self.status.inital_fetch_done:
return
try:
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
should_recreate_entities = False
if repository is None:
should_recreate_entities = True
await self.async_register_repository(
repository_full_name=HacsGitHubRepo.INTEGRATION,
category=HacsCategory.INTEGRATION,
default=True,
)
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
elif not self.status.startup:
self.log.error("Scheduling update of hacs/integration")
self.queue.add(repository.common_update())
if repository is None:
raise HacsException("Unknown error")
repository.data.installed = True
repository.data.installed_version = self.integration.version.string
repository.data.new = False
repository.data.releases = True
if should_recreate_entities:
await self.async_recreate_entities()
self.repository = repository.repository_object
self.repositories.mark_default(repository)
except HacsException as exception:
if "403" in str(exception):
self.log.critical(
"GitHub API is ratelimited, or the token is wrong.",
)
else:
self.log.critical("Could not load HACS! - %s", exception)
self.disable_hacs(HacsDisabledReason.LOAD_HACS)
async def async_get_all_category_repositories(self, _=None) -> None: async def async_get_all_category_repositories(self, _=None) -> None:
"""Get all category repositories.""" """Get all category repositories."""
@@ -763,55 +831,62 @@ class HacsBase:
self.log.info("Loading known repositories") self.log.info("Loading known repositories")
await asyncio.gather( await asyncio.gather(
*[ *[
self.async_get_category_repositories(HacsCategory(category)) self.async_get_category_repositories_experimental(category)
for category in self.common.categories or [] for category in self.common.categories or []
] ]
) )
async def async_get_category_repositories(self, category: HacsCategory) -> None: async def async_get_category_repositories_experimental(self, category: str) -> None:
"""Get repositories from category.""" """Update all category repositories."""
if self.system.disabled: self.log.debug("Fetching updated content for %s", category)
return
try: try:
repositories = await self.async_github_get_hacs_default_file(category) category_data = await self.data_client.get_data(category, validate=True)
except HacsException: except HacsNotModifiedException:
self.log.debug("No updates for %s", category)
return
except HacsException as exception:
self.log.error("Could not update %s - %s", category, exception)
return return
for repo in repositories: await self.data.register_unknown_repositories(category_data, category)
if self.common.renamed_repositories.get(repo):
repo = self.common.renamed_repositories[repo] for repo_id, repo_data in category_data.items():
if self.repositories.is_removed(repo): repo_name = repo_data["full_name"]
if self.common.renamed_repositories.get(repo_name):
repo_name = self.common.renamed_repositories[repo_name]
if self.repositories.is_removed(repo_name):
continue continue
if repo in self.common.archived_repositories: if repo_name in self.common.archived_repositories:
continue continue
repository = self.repositories.get_by_full_name(repo) if repository := self.repositories.get_by_full_name(repo_name):
if repository is not None: self.repositories.set_repository_id(repository, repo_id)
self.repositories.mark_default(repository) self.repositories.mark_default(repository)
if self.status.new and self.configuration.dev: if repository.data.last_fetched is None or (
# Force update for new installations repository.data.last_fetched.timestamp() < repo_data["last_fetched"]
self.queue.add(repository.common_update()) ):
continue repository.data.update_data({**dict(REPOSITORY_KEYS_TO_EXPORT), **repo_data})
if (manifest := repo_data.get("manifest")) is not None:
repository.repository_manifest.update_data(
{**dict(HACS_MANIFEST_KEYS_TO_EXPORT), **manifest}
)
self.queue.add( if category == "integration":
self.async_register_repository( self.status.inital_fetch_done = True
repository_full_name=repo,
category=category,
default=True,
)
)
async def async_update_all_repositories(self, _=None) -> None: if self.stage == HacsStage.STARTUP:
"""Update all repositories.""" for repository in self.repositories.list_all:
if self.system.disabled: if (
return repository.data.category == category
self.log.debug("Starting recurring background task for all repositories") and not repository.data.installed
and not self.repositories.is_default(repository.data.id)
):
repository.logger.debug(
"%s Unregister stale custom repository", repository.string
)
self.repositories.unregister(repository)
for repository in self.repositories.list_all: self.async_dispatch(HacsDispatchEvent.REPOSITORY, {})
if repository.data.category in self.common.categories: self.coordinators[category].async_update_listeners()
self.queue.add(repository.common_update())
self.async_dispatch(HacsDispatchEvent.REPOSITORY, {"action": "reload"})
self.log.debug("Recurring background task for all repositories done")
async def async_check_rate_limit(self, _=None) -> None: async def async_check_rate_limit(self, _=None) -> None:
"""Check rate limit.""" """Check rate limit."""
@@ -823,9 +898,9 @@ class HacsBase:
self.log.debug("Ratelimit indicate we can update %s", can_update) self.log.debug("Ratelimit indicate we can update %s", can_update)
if can_update > 0: if can_update > 0:
self.enable_hacs() self.enable_hacs()
await self.async_prosess_queue() await self.async_process_queue()
async def async_prosess_queue(self, _=None) -> None: async def async_process_queue(self, _=None) -> None:
"""Process the queue.""" """Process the queue."""
if self.system.disabled: if self.system.disabled:
self.log.debug("HACS is disabled") self.log.debug("HACS is disabled")
@@ -843,7 +918,7 @@ class HacsBase:
return return
can_update = await self.async_can_update() can_update = await self.async_can_update()
self.log.debug( self.log.debug(
"Can update %s repositories, " "items in queue %s", "Can update %s repositories, items in queue %s",
can_update, can_update,
self.queue.pending_tasks, self.queue.pending_tasks,
) )
@@ -865,9 +940,7 @@ class HacsBase:
self.log.info("Loading removed repositories") self.log.info("Loading removed repositories")
try: try:
removed_repositories = await self.async_github_get_hacs_default_file( removed_repositories = await self.data_client.get_data("removed", validate=True)
HacsCategory.REMOVED
)
except HacsException: except HacsException:
return return
@@ -880,14 +953,29 @@ class HacsBase:
continue continue
if repository.data.full_name in self.common.ignored_repositories: if repository.data.full_name in self.common.ignored_repositories:
continue continue
if repository.data.installed and removed.removal_type != "critical": if repository.data.installed:
self.log.warning( if removed.removal_type != "critical":
"You have '%s' installed with HACS " async_create_issue(
"this repository has been removed from HACS, please consider removing it. " hass=self.hass,
"Removal reason (%s)", domain=DOMAIN,
repository.data.full_name, issue_id=f"removed_{repository.data.id}",
removed.reason, is_fixable=False,
) issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="removed",
translation_placeholders={
"name": repository.data.full_name,
"reason": removed.reason,
"repositry_id": repository.data.id,
},
)
self.log.warning(
"You have '%s' installed with HACS "
"this repository has been removed from HACS, please consider removing it. "
"Removal reason (%s)",
repository.data.full_name,
removed.reason,
)
else: else:
need_to_save = True need_to_save = True
repository.remove() repository.remove()
@@ -895,17 +983,45 @@ class HacsBase:
if need_to_save: if need_to_save:
await self.data.async_write() await self.data.async_write()
async def async_update_downloaded_repositories(self, _=None) -> None: async def async_update_downloaded_custom_repositories(self, _=None) -> None:
"""Execute the task.""" """Execute the task."""
if self.system.disabled: if self.system.disabled:
return return
self.log.info("Starting recurring background task for downloaded repositories") self.log.info("Starting recurring background task for downloaded custom repositories")
repositories_to_update = 0
repositories_updated = asyncio.Event()
async def update_repository(repository: HacsRepository) -> None:
"""Update a repository"""
nonlocal repositories_to_update
await repository.update_repository(ignore_issues=True)
repositories_to_update -= 1
if not repositories_to_update:
repositories_updated.set()
for repository in self.repositories.list_downloaded: for repository in self.repositories.list_downloaded:
if repository.data.category in self.common.categories: if (
self.queue.add(repository.update_repository()) repository.data.category in self.common.categories
and not self.repositories.is_default(repository.data.id)
):
repositories_to_update += 1
self.queue.add(update_repository(repository))
self.log.debug("Recurring background task for downloaded repositories done") async def update_coordinators() -> None:
"""Update all coordinators."""
await repositories_updated.wait()
for coordinator in self.coordinators.values():
coordinator.async_update_listeners()
if config_entry := self.configuration.config_entry:
config_entry.async_create_background_task(
self.hass, update_coordinators(), "update_coordinators"
)
else:
self.hass.async_create_background_task(update_coordinators(), "update_coordinators")
self.log.debug("Recurring background task for downloaded custom repositories done")
async def async_handle_critical_repositories(self, _=None) -> None: async def async_handle_critical_repositories(self, _=None) -> None:
"""Handle critical repositories.""" """Handle critical repositories."""
@@ -915,8 +1031,8 @@ class HacsBase:
was_installed = False was_installed = False
try: try:
critical = await self.async_github_get_hacs_default_file("critical") critical = await self.data_client.get_data("critical", validate=True)
except GitHubNotModifiedException: except (GitHubNotModifiedException, HacsNotModifiedException):
return return
except HacsException: except HacsException:
pass pass
@@ -968,3 +1084,27 @@ class HacsBase:
if was_installed: if was_installed:
self.log.critical("Restarting Home Assistant") self.log.critical("Restarting Home Assistant")
self.hass.async_create_task(self.hass.async_stop(100)) self.hass.async_create_task(self.hass.async_stop(100))
async def async_setup_frontend_endpoint_plugin(self) -> None:
"""Setup the http endpoints for plugins if its not already handled."""
if self.status.active_frontend_endpoint_plugin or not await async_exists(
self.hass, self.hass.config.path("www/community")
):
return
self.log.info("Setting up plugin endpoint")
use_cache = self.core.lovelace_mode == "storage"
self.log.info(
"<HacsFrontend> %s mode, cache for /hacsfiles/: %s",
self.core.lovelace_mode,
use_cache,
)
await async_register_static_path(
self.hass,
URL_BASE,
self.hass.config.path("www/community"),
cache_headers=use_cache,
)
self.status.active_frontend_endpoint_plugin = True

View File

@@ -1,37 +1,58 @@
"""Adds config flow for HACS.""" """Adds config flow for HACS."""
from aiogithubapi import GitHubDeviceAPI, GitHubException
from __future__ import annotations
import asyncio
from contextlib import suppress
from typing import TYPE_CHECKING
from aiogithubapi import (
GitHubDeviceAPI,
GitHubException,
GitHubLoginDeviceModel,
GitHubLoginOauthModel,
)
from aiogithubapi.common.const import OAUTH_USER_LOGIN from aiogithubapi.common.const import OAUTH_USER_LOGIN
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from homeassistant import config_entries from homeassistant.config_entries import ConfigFlow, OptionsFlow
from homeassistant.const import __version__ as HAVERSION from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import UnknownFlow
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.event import async_call_later
from homeassistant.loader import async_get_integration from homeassistant.loader import async_get_integration
import voluptuous as vol import voluptuous as vol
from .base import HacsBase from .base import HacsBase
from .const import CLIENT_ID, DOMAIN, MINIMUM_HA_VERSION from .const import CLIENT_ID, DOMAIN, LOCALE, MINIMUM_HA_VERSION
from .enums import ConfigurationType from .utils.configuration_schema import (
from .utils.configuration_schema import RELEASE_LIMIT, hacs_config_option_schema APPDAEMON,
from .utils.logger import get_hacs_logger COUNTRY,
SIDEPANEL_ICON,
SIDEPANEL_TITLE,
)
from .utils.logger import LOGGER
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class HacsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for HACS.""" """Config flow for HACS."""
VERSION = 1 VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self): hass: HomeAssistant
activation_task: asyncio.Task | None = None
device: GitHubDeviceAPI | None = None
_registration: GitHubLoginDeviceModel | None = None
_activation: GitHubLoginOauthModel | None = None
_reauth: bool = False
def __init__(self) -> None:
"""Initialize.""" """Initialize."""
self._errors = {} self._errors = {}
self.device = None self._user_input = {}
self.activation = None
self.log = get_hacs_logger()
self._progress_task = None
self._login_device = None
self._reauth = False
async def async_step_user(self, user_input): async def async_step_user(self, user_input):
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
@@ -42,54 +63,63 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="single_instance_allowed") return self.async_abort(reason="single_instance_allowed")
if user_input: if user_input:
if [x for x in user_input if not user_input[x]]: if [x for x in user_input if x.startswith("acc_") and not user_input[x]]:
self._errors["base"] = "acc" self._errors["base"] = "acc"
return await self._show_config_form(user_input) return await self._show_config_form(user_input)
self._user_input = user_input
return await self.async_step_device(user_input) return await self.async_step_device(user_input)
## Initial form # Initial form
return await self._show_config_form(user_input) return await self._show_config_form(user_input)
async def async_step_device(self, _user_input): async def async_step_device(self, _user_input):
"""Handle device steps""" """Handle device steps."""
async def _wait_for_activation(_=None): async def _wait_for_activation() -> None:
if self._login_device is None or self._login_device.expires_in is None: try:
async_call_later(self.hass, 1, _wait_for_activation) response = await self.device.activation(device_code=self._registration.device_code)
return self._activation = response.data
finally:
response = await self.device.activation(device_code=self._login_device.device_code) async def _progress():
self.activation = response.data with suppress(UnknownFlow):
self.hass.async_create_task( await self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
)
if not self.activation: if not self.device:
integration = await async_get_integration(self.hass, DOMAIN) integration = await async_get_integration(self.hass, DOMAIN)
if not self.device: self.device = GitHubDeviceAPI(
self.device = GitHubDeviceAPI( client_id=CLIENT_ID,
client_id=CLIENT_ID, session=aiohttp_client.async_get_clientsession(self.hass),
session=aiohttp_client.async_get_clientsession(self.hass), **{"client_name": f"HACS/{integration.version}"},
**{"client_name": f"HACS/{integration.version}"}, )
)
async_call_later(self.hass, 1, _wait_for_activation)
try: try:
response = await self.device.register() response = await self.device.register()
self._login_device = response.data self._registration = response.data
return self.async_show_progress(
step_id="device",
progress_action="wait_for_device",
description_placeholders={
"url": OAUTH_USER_LOGIN,
"code": self._login_device.user_code,
},
)
except GitHubException as exception: except GitHubException as exception:
self.log.error(exception) LOGGER.exception(exception)
return self.async_abort(reason="github") return self.async_abort(reason="could_not_register")
return self.async_show_progress_done(next_step_id="device_done") if self.activation_task is None:
self.activation_task = self.hass.async_create_task(_wait_for_activation())
if self.activation_task.done():
if (exception := self.activation_task.exception()) is not None:
LOGGER.exception(exception)
return self.async_show_progress_done(next_step_id="could_not_register")
return self.async_show_progress_done(next_step_id="device_done")
show_progress_kwargs = {
"step_id": "device",
"progress_action": "wait_for_device",
"description_placeholders": {
"url": OAUTH_USER_LOGIN,
"code": self._registration.user_code,
},
"progress_task": self.activation_task,
}
return self.async_show_progress(**show_progress_kwargs)
async def _show_config_form(self, user_input): async def _show_config_form(self, user_input):
"""Show the configuration form to edit location data.""" """Show the configuration form to edit location data."""
@@ -117,19 +147,31 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=self._errors, errors=self._errors,
) )
async def async_step_device_done(self, _user_input): async def async_step_device_done(self, user_input: dict[str, bool] | None = None):
"""Handle device steps""" """Handle device steps"""
if self._reauth: if self._reauth:
existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
existing_entry, data={"token": self.activation.access_token} existing_entry, data={**existing_entry.data, "token": self._activation.access_token}
) )
await self.hass.config_entries.async_reload(existing_entry.entry_id) await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful") return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title="", data={"token": self.activation.access_token}) return self.async_create_entry(
title="",
data={
"token": self._activation.access_token,
},
options={
"experimental": True,
},
)
async def async_step_reauth(self, user_input=None): async def async_step_could_not_register(self, _user_input=None):
"""Handle issues that need transition await from progress step."""
return self.async_abort(reason="could_not_register")
async def async_step_reauth(self, _user_input=None):
"""Perform reauth upon an API authentication error.""" """Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm() return await self.async_step_reauth_confirm()
@@ -149,12 +191,13 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return HacsOptionsFlowHandler(config_entry) return HacsOptionsFlowHandler(config_entry)
class HacsOptionsFlowHandler(config_entries.OptionsFlow): class HacsOptionsFlowHandler(OptionsFlow):
"""HACS config flow options handler.""" """HACS config flow options handler."""
def __init__(self, config_entry): def __init__(self, config_entry):
"""Initialize HACS options flow.""" """Initialize HACS options flow."""
self.config_entry = config_entry if AwesomeVersion(HAVERSION) < "2024.11.99":
self.config_entry = config_entry
async def async_step_init(self, _user_input=None): async def async_step_init(self, _user_input=None):
"""Manage the options.""" """Manage the options."""
@@ -164,19 +207,19 @@ class HacsOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
hacs: HacsBase = self.hass.data.get(DOMAIN) hacs: HacsBase = self.hass.data.get(DOMAIN)
if user_input is not None: if user_input is not None:
limit = int(user_input.get(RELEASE_LIMIT, 5)) return self.async_create_entry(title="", data={**user_input, "experimental": True})
if limit <= 0 or limit > 100:
return self.async_abort(reason="release_limit_value")
return self.async_create_entry(title="", data=user_input)
if hacs is None or hacs.configuration is None: if hacs is None or hacs.configuration is None:
return self.async_abort(reason="not_setup") return self.async_abort(reason="not_setup")
if hacs.configuration.config_type == ConfigurationType.YAML: if hacs.queue.has_pending_tasks:
schema = {vol.Optional("not_in_use", default=""): str} return self.async_abort(reason="pending_tasks")
else:
schema = hacs_config_option_schema(self.config_entry.options) schema = {
del schema["frontend_repo"] vol.Optional(SIDEPANEL_TITLE, default=hacs.configuration.sidepanel_title): str,
del schema["frontend_repo_url"] vol.Optional(SIDEPANEL_ICON, default=hacs.configuration.sidepanel_icon): str,
vol.Optional(COUNTRY, default=hacs.configuration.country): vol.In(LOCALE),
vol.Optional(APPDAEMON, default=hacs.configuration.appdaemon): bool,
}
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema)) return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))

View File

@@ -1,4 +1,5 @@
"""Constants for HACS""" """Constants for HACS"""
from typing import TypeVar from typing import TypeVar
from aiogithubapi.common.const import ACCEPT_HEADERS from aiogithubapi.common.const import ACCEPT_HEADERS
@@ -6,7 +7,9 @@ from aiogithubapi.common.const import ACCEPT_HEADERS
NAME_SHORT = "HACS" NAME_SHORT = "HACS"
DOMAIN = "hacs" DOMAIN = "hacs"
CLIENT_ID = "395a8e669c5de9f7c6e8" CLIENT_ID = "395a8e669c5de9f7c6e8"
MINIMUM_HA_VERSION = "2022.4.0" MINIMUM_HA_VERSION = "2024.4.1"
URL_BASE = "/hacsfiles"
TV = TypeVar("TV") TV = TypeVar("TV")
@@ -15,6 +18,8 @@ PACKAGE_NAME = "custom_components.hacs"
DEFAULT_CONCURRENT_TASKS = 15 DEFAULT_CONCURRENT_TASKS = 15
DEFAULT_CONCURRENT_BACKOFF_TIME = 1 DEFAULT_CONCURRENT_BACKOFF_TIME = 1
HACS_REPOSITORY_ID = "172733314"
HACS_ACTION_GITHUB_API_HEADERS = { HACS_ACTION_GITHUB_API_HEADERS = {
"User-Agent": "HACS/action", "User-Agent": "HACS/action",
"Accept": ACCEPT_HEADERS["preview"], "Accept": ACCEPT_HEADERS["preview"],

View File

@@ -1,4 +1,5 @@
"""Diagnostics support for HACS.""" """Diagnostics support for HACS."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
@@ -10,7 +11,6 @@ from homeassistant.core import HomeAssistant
from .base import HacsBase from .base import HacsBase
from .const import DOMAIN from .const import DOMAIN
from .utils.configuration_schema import TOKEN
async def async_get_config_entry_diagnostics( async def async_get_config_entry_diagnostics(
@@ -48,8 +48,6 @@ async def async_get_config_entry_diagnostics(
"country", "country",
"debug", "debug",
"dev", "dev",
"experimental",
"netdaemon",
"python_script", "python_script",
"release_limit", "release_limit",
"theme", "theme",
@@ -79,4 +77,4 @@ async def async_get_config_entry_diagnostics(
except GitHubException as exception: except GitHubException as exception:
data["rate_limit"] = str(exception) data["rate_limit"] = str(exception)
return async_redact_data(data, (TOKEN,)) return async_redact_data(data, ("token",))

View File

@@ -1,4 +1,5 @@
"""HACS Base entities.""" """HACS Base entities."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -7,8 +8,10 @@ from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity
from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
from .coordinator import HacsUpdateCoordinator
from .enums import HacsDispatchEvent, HacsGitHubRepo from .enums import HacsDispatchEvent, HacsGitHubRepo
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -39,6 +42,10 @@ class HacsBaseEntity(Entity):
"""Initialize.""" """Initialize."""
self.hacs = hacs self.hacs = hacs
class HacsDispatcherEntity(HacsBaseEntity):
"""Base HACS entity listening to dispatcher signals."""
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register for status events.""" """Register for status events."""
self.async_on_remove( self.async_on_remove(
@@ -64,7 +71,7 @@ class HacsBaseEntity(Entity):
self.async_write_ha_state() self.async_write_ha_state()
class HacsSystemEntity(HacsBaseEntity): class HacsSystemEntity(HacsDispatcherEntity):
"""Base system entity.""" """Base system entity."""
_attr_icon = "hacs:hacs" _attr_icon = "hacs:hacs"
@@ -76,7 +83,7 @@ class HacsSystemEntity(HacsBaseEntity):
return system_info(self.hacs) return system_info(self.hacs)
class HacsRepositoryEntity(HacsBaseEntity): class HacsRepositoryEntity(BaseCoordinatorEntity[HacsUpdateCoordinator], HacsBaseEntity):
"""Base repository entity.""" """Base repository entity."""
def __init__( def __init__(
@@ -85,9 +92,11 @@ class HacsRepositoryEntity(HacsBaseEntity):
repository: HacsRepository, repository: HacsRepository,
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
super().__init__(hacs=hacs) BaseCoordinatorEntity.__init__(self, hacs.coordinators[repository.data.category])
HacsBaseEntity.__init__(self, hacs=hacs)
self.repository = repository self.repository = repository
self._attr_unique_id = str(repository.data.id) self._attr_unique_id = str(repository.data.id)
self._repo_last_fetched = repository.data.last_fetched
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -100,20 +109,35 @@ class HacsRepositoryEntity(HacsBaseEntity):
if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION: if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION:
return system_info(self.hacs) return system_info(self.hacs)
def _manufacturer():
if authors := self.repository.data.authors:
return ", ".join(author.replace("@", "") for author in authors)
return self.repository.data.full_name.split("/")[0]
return { return {
"identifiers": {(DOMAIN, str(self.repository.data.id))}, "identifiers": {(DOMAIN, str(self.repository.data.id))},
"name": self.repository.display_name, "name": self.repository.display_name,
"model": self.repository.data.category, "model": self.repository.data.category,
"manufacturer": ", ".join( "manufacturer": _manufacturer(),
author.replace("@", "") for author in self.repository.data.authors "configuration_url": f"homeassistant://hacs/repository/{self.repository.data.id}",
),
"configuration_url": "homeassistant://hacs",
"entry_type": DeviceEntryType.SERVICE, "entry_type": DeviceEntryType.SERVICE,
} }
@callback @callback
def _update_and_write_state(self, data: dict) -> None: def _handle_coordinator_update(self) -> None:
"""Update the entity and write state.""" """Handle updated data from the coordinator."""
if data.get("repository_id") == self.repository.data.id: if (
self._update() self._repo_last_fetched is not None
self.async_write_ha_state() and self.repository.data.last_fetched is not None
and self._repo_last_fetched >= self.repository.data.last_fetched
):
return
self._repo_last_fetched = self.repository.data.last_fetched
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
"""

View File

@@ -1,22 +1,23 @@
"""Helper constants.""" """Helper constants."""
# pylint: disable=missing-class-docstring # pylint: disable=missing-class-docstring
from enum import Enum from enum import StrEnum
class HacsGitHubRepo(str, Enum): class HacsGitHubRepo(StrEnum):
"""HacsGitHubRepo.""" """HacsGitHubRepo."""
DEFAULT = "hacs/default" DEFAULT = "hacs/default"
INTEGRATION = "hacs/integration" INTEGRATION = "hacs/integration"
class HacsCategory(str, Enum): class HacsCategory(StrEnum):
APPDAEMON = "appdaemon" APPDAEMON = "appdaemon"
INTEGRATION = "integration" INTEGRATION = "integration"
LOVELACE = "lovelace" LOVELACE = "lovelace"
PLUGIN = "plugin" # Kept for legacy purposes PLUGIN = "plugin" # Kept for legacy purposes
NETDAEMON = "netdaemon"
PYTHON_SCRIPT = "python_script" PYTHON_SCRIPT = "python_script"
TEMPLATE = "template"
THEME = "theme" THEME = "theme"
REMOVED = "removed" REMOVED = "removed"
@@ -24,7 +25,7 @@ class HacsCategory(str, Enum):
return str(self.value) return str(self.value)
class HacsDispatchEvent(str, Enum): class HacsDispatchEvent(StrEnum):
"""HacsDispatchEvent.""" """HacsDispatchEvent."""
CONFIG = "hacs_dispatch_config" CONFIG = "hacs_dispatch_config"
@@ -37,19 +38,14 @@ class HacsDispatchEvent(str, Enum):
STATUS = "hacs_dispatch_status" STATUS = "hacs_dispatch_status"
class RepositoryFile(str, Enum): class RepositoryFile(StrEnum):
"""Repository file names.""" """Repository file names."""
HACS_JSON = "hacs.json" HACS_JSON = "hacs.json"
MAINIFEST_JSON = "manifest.json" MAINIFEST_JSON = "manifest.json"
class ConfigurationType(str, Enum): class LovelaceMode(StrEnum):
YAML = "yaml"
CONFIG_ENTRY = "config_entry"
class LovelaceMode(str, Enum):
"""Lovelace Modes.""" """Lovelace Modes."""
STORAGE = "storage" STORAGE = "storage"
@@ -58,7 +54,7 @@ class LovelaceMode(str, Enum):
YAML = "yaml" YAML = "yaml"
class HacsStage(str, Enum): class HacsStage(StrEnum):
SETUP = "setup" SETUP = "setup"
STARTUP = "startup" STARTUP = "startup"
WAITING = "waiting" WAITING = "waiting"
@@ -66,7 +62,7 @@ class HacsStage(str, Enum):
BACKGROUND = "background" BACKGROUND = "background"
class HacsDisabledReason(str, Enum): class HacsDisabledReason(StrEnum):
RATE_LIMIT = "rate_limit" RATE_LIMIT = "rate_limit"
REMOVED = "removed" REMOVED = "removed"
INVALID_TOKEN = "invalid_token" INVALID_TOKEN = "invalid_token"

View File

@@ -0,0 +1,67 @@
"""Starting setup task: Frontend."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from homeassistant.components.frontend import (
add_extra_js_url,
async_register_built_in_panel,
)
from .const import DOMAIN, URL_BASE
from .hacs_frontend import VERSION as FE_VERSION, locate_dir
from .utils.workarounds import async_register_static_path
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .base import HacsBase
async def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
"""Register the frontend."""
# Register frontend
if hacs.configuration.dev and (frontend_path := os.getenv("HACS_FRONTEND_DIR")):
hacs.log.warning(
"<HacsFrontend> Frontend development mode enabled. Do not run in production!"
)
await async_register_static_path(
hass, f"{URL_BASE}/frontend", f"{frontend_path}/hacs_frontend", cache_headers=False
)
hacs.frontend_version = "dev"
else:
await async_register_static_path(
hass, f"{URL_BASE}/frontend", locate_dir(), cache_headers=False
)
hacs.frontend_version = FE_VERSION
# Custom iconset
await async_register_static_path(
hass, f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
)
add_extra_js_url(hass, f"{URL_BASE}/iconset.js")
# Add to sidepanel if needed
if DOMAIN not in hass.data.get("frontend_panels", {}):
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title=hacs.configuration.sidepanel_title,
sidebar_icon=hacs.configuration.sidepanel_icon,
frontend_url_path=DOMAIN,
config={
"_panel_custom": {
"name": "hacs-frontend",
"embed_iframe": True,
"trust_external": False,
"js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={hacs.frontend_version}",
}
},
require_admin=True,
)
# Setup plugin endpoint if needed
await hacs.async_setup_frontend_endpoint_plugin()

View File

@@ -1,10 +1 @@
!function(){function n(n){var e=document.createElement("script");e.src=n,document.body.appendChild(e)}if(/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent))n("/hacsfiles/frontend/frontend_es5/entrypoint.c180d0b256f9b6d0.js");else try{new Function("import('/hacsfiles/frontend/frontend_latest/entrypoint.bb9d28f38e9fba76.js')")()}catch(e){n("/hacsfiles/frontend/frontend_es5/entrypoint.c180d0b256f9b6d0.js")}}()
try {
new Function("import('/hacsfiles/frontend/main-150a7578.js')")();
} catch (err) {
var el = document.createElement('script');
el.src = '/hacsfiles/frontend/main-150a7578.js';
el.type = 'module';
document.body.appendChild(el);
}

View File

@@ -1,3 +0,0 @@
{
"./src/main.ts": "main-150a7578.js"
}

View File

@@ -1 +1 @@
VERSION="20220522162559" VERSION="20250128065759"

View File

@@ -1,4 +1,9 @@
{ {
"domain": "hacs",
"name": "HACS",
"after_dependencies": [
"python_script"
],
"codeowners": [ "codeowners": [
"@ludeeus" "@ludeeus"
], ],
@@ -8,15 +13,14 @@
"websocket_api", "websocket_api",
"frontend", "frontend",
"persistent_notification", "persistent_notification",
"lovelace" "lovelace",
"repairs"
], ],
"documentation": "https://hacs.xyz/docs/configuration/start", "documentation": "https://hacs.xyz/docs/use/",
"domain": "hacs",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/hacs/integration/issues", "issue_tracker": "https://github.com/hacs/integration/issues",
"name": "HACS",
"requirements": [ "requirements": [
"aiogithubapi>=22.2.4" "aiogithubapi>=22.10.1"
], ],
"version": "1.25.0" "version": "2.0.5"
} }

View File

@@ -1,20 +1,21 @@
"""Initialize repositories.""" """Initialize repositories."""
from __future__ import annotations from __future__ import annotations
from ..enums import HacsCategory from ..enums import HacsCategory
from .appdaemon import HacsAppdaemonRepository from .appdaemon import HacsAppdaemonRepository
from .base import HacsRepository from .base import HacsRepository
from .integration import HacsIntegrationRepository from .integration import HacsIntegrationRepository
from .netdaemon import HacsNetdaemonRepository
from .plugin import HacsPluginRepository from .plugin import HacsPluginRepository
from .python_script import HacsPythonScriptRepository from .python_script import HacsPythonScriptRepository
from .template import HacsTemplateRepository
from .theme import HacsThemeRepository from .theme import HacsThemeRepository
RERPOSITORY_CLASSES: dict[HacsCategory, HacsRepository] = { REPOSITORY_CLASSES: dict[HacsCategory, HacsRepository] = {
HacsCategory.THEME: HacsThemeRepository, HacsCategory.THEME: HacsThemeRepository,
HacsCategory.INTEGRATION: HacsIntegrationRepository, HacsCategory.INTEGRATION: HacsIntegrationRepository,
HacsCategory.PYTHON_SCRIPT: HacsPythonScriptRepository, HacsCategory.PYTHON_SCRIPT: HacsPythonScriptRepository,
HacsCategory.APPDAEMON: HacsAppdaemonRepository, HacsCategory.APPDAEMON: HacsAppdaemonRepository,
HacsCategory.NETDAEMON: HacsNetdaemonRepository,
HacsCategory.PLUGIN: HacsPluginRepository, HacsCategory.PLUGIN: HacsPluginRepository,
HacsCategory.TEMPLATE: HacsTemplateRepository,
} }

View File

@@ -1,4 +1,5 @@
"""Class for appdaemon apps in HACS.""" """Class for appdaemon apps in HACS."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -40,11 +41,11 @@ class HacsAppdaemonRepository(HacsRepository):
addir = await self.repository_object.get_contents("apps", self.ref) addir = await self.repository_object.get_contents("apps", self.ref)
except AIOGitHubAPIException: except AIOGitHubAPIException:
raise HacsException( raise HacsException(
f"Repository structure for {self.ref.replace('tags/','')} is not compliant" f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
) from None ) from None
if not isinstance(addir, list): if not isinstance(addir, list):
self.validate.errors.append("Repository structure not compliant") self.validate.errors.append(f"{self.string} Repository structure not compliant")
self.content.path.remote = addir[0].path self.content.path.remote = addir[0].path
self.content.objects = await self.repository_object.get_contents( self.content.objects = await self.repository_object.get_contents(
@@ -79,7 +80,7 @@ class HacsAppdaemonRepository(HacsRepository):
# Set local path # Set local path
self.content.path.local = self.localpath self.content.path.local = self.localpath
# Signal entities to refresh # Signal frontend to refresh
if self.data.installed: if self.data.installed:
self.hacs.async_dispatch( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.REPOSITORY,

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,19 @@
"""Class for integrations in HACS.""" """Class for integrations in HACS."""
from __future__ import annotations from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.loader import async_get_custom_components from homeassistant.loader import async_get_custom_components
from ..const import DOMAIN
from ..enums import HacsCategory, HacsDispatchEvent, HacsGitHubRepo, RepositoryFile from ..enums import HacsCategory, HacsDispatchEvent, HacsGitHubRepo, RepositoryFile
from ..exceptions import AddonRepositoryException, HacsException from ..exceptions import AddonRepositoryException, HacsException
from ..utils.decode import decode_content from ..utils.decode import decode_content
from ..utils.decorator import concurrent from ..utils.decorator import concurrent
from ..utils.filters import get_first_directory_in_directory from ..utils.filters import get_first_directory_in_directory
from ..utils.json import json_loads
from .base import HacsRepository from .base import HacsRepository
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -36,13 +39,34 @@ class HacsIntegrationRepository(HacsRepository):
async def async_post_installation(self): async def async_post_installation(self):
"""Run post installation steps.""" """Run post installation steps."""
self.pending_restart = True
if self.data.config_flow: if self.data.config_flow:
if self.data.full_name != HacsGitHubRepo.INTEGRATION: if self.data.full_name != HacsGitHubRepo.INTEGRATION:
await self.reload_custom_components() await self.reload_custom_components()
if self.data.first_install: if self.data.first_install:
self.pending_restart = False self.pending_restart = False
return
self.pending_restart = True if self.pending_restart:
self.logger.debug("%s Creating restart_required issue", self.string)
async_create_issue(
hass=self.hacs.hass,
domain=DOMAIN,
issue_id=f"restart_required_{self.data.id}_{self.ref}",
is_fixable=True,
issue_domain=self.data.domain or DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="restart_required",
translation_placeholders={
"name": self.display_name,
},
)
async def async_post_uninstall(self) -> None:
"""Run post uninstall steps."""
if self.data.config_flow:
await self.reload_custom_components()
else:
self.pending_restart = True
async def validate_repository(self): async def validate_repository(self):
"""Validate.""" """Validate."""
@@ -62,7 +86,8 @@ class HacsIntegrationRepository(HacsRepository):
): ):
raise AddonRepositoryException() raise AddonRepositoryException()
raise HacsException( raise HacsException(
f"Repository structure for {self.ref.replace('tags/','')} is not compliant" f"{self.string} Repository structure for {
self.ref.replace('tags/', '')} is not compliant"
) )
self.content.path.remote = f"custom_components/{name}" self.content.path.remote = f"custom_components/{name}"
@@ -70,14 +95,15 @@ class HacsIntegrationRepository(HacsRepository):
if manifest := await self.async_get_integration_manifest(): if manifest := await self.async_get_integration_manifest():
try: try:
self.integration_manifest = manifest self.integration_manifest = manifest
self.data.authors = manifest["codeowners"] self.data.authors = manifest.get("codeowners", [])
self.data.domain = manifest["domain"] self.data.domain = manifest["domain"]
self.data.manifest_name = manifest["name"] self.data.manifest_name = manifest.get("name")
self.data.config_flow = manifest.get("config_flow", False) self.data.config_flow = manifest.get("config_flow", False)
except KeyError as exception: except KeyError as exception:
self.validate.errors.append( self.validate.errors.append(
f"Missing expected key '{exception}' in { RepositoryFile.MAINIFEST_JSON}" f"Missing expected key '{exception}' in {
RepositoryFile.MAINIFEST_JSON}"
) )
self.hacs.log.error( self.hacs.log.error(
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON "Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
@@ -110,14 +136,15 @@ class HacsIntegrationRepository(HacsRepository):
if manifest := await self.async_get_integration_manifest(): if manifest := await self.async_get_integration_manifest():
try: try:
self.integration_manifest = manifest self.integration_manifest = manifest
self.data.authors = manifest["codeowners"] self.data.authors = manifest.get("codeowners", [])
self.data.domain = manifest["domain"] self.data.domain = manifest["domain"]
self.data.manifest_name = manifest["name"] self.data.manifest_name = manifest.get("name")
self.data.config_flow = manifest.get("config_flow", False) self.data.config_flow = manifest.get("config_flow", False)
except KeyError as exception: except KeyError as exception:
self.validate.errors.append( self.validate.errors.append(
f"Missing expected key '{exception}' in { RepositoryFile.MAINIFEST_JSON}" f"Missing expected key '{exception}' in {
RepositoryFile.MAINIFEST_JSON}"
) )
self.hacs.log.error( self.hacs.log.error(
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON "Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
@@ -126,7 +153,7 @@ class HacsIntegrationRepository(HacsRepository):
# Set local path # Set local path
self.content.path.local = self.localpath self.content.path.local = self.localpath
# Signal entities to refresh # Signal frontend to refresh
if self.data.installed: if self.data.installed:
self.hacs.async_dispatch( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.REPOSITORY,
@@ -163,4 +190,28 @@ class HacsIntegrationRepository(HacsRepository):
**{"params": {"ref": ref or self.version_to_download()}}, **{"params": {"ref": ref or self.version_to_download()}},
) )
if response: if response:
return json.loads(decode_content(response.data.content)) return json_loads(decode_content(response.data.content))
async def get_integration_manifest(self, *, version: str, **kwargs) -> dict[str, Any] | None:
"""Get the content of the manifest.json file."""
manifest_path = (
"manifest.json"
if self.repository_manifest.content_in_root
else f"{self.content.path.remote}/{RepositoryFile.MAINIFEST_JSON}"
)
if manifest_path not in (x.full_path for x in self.tree):
raise HacsException(f"No {RepositoryFile.MAINIFEST_JSON} file found '{manifest_path}'")
self.logger.debug("%s Getting manifest.json for version=%s", self.string, version)
try:
result = await self.hacs.async_download_file(
f"https://raw.githubusercontent.com/{
self.data.full_name}/{version}/{manifest_path}",
nolog=True,
)
if result is None:
return None
return json_loads(result)
except Exception: # pylint: disable=broad-except
return None

View File

@@ -1,104 +0,0 @@
"""Class for netdaemon apps in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils import filters
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsNetdaemonRepository(HacsRepository):
"""Netdaemon apps in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.NETDAEMON
self.content.path.local = self.localpath
self.content.path.remote = "apps"
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/netdaemon/apps/{self.data.name}"
async def validate_repository(self):
"""Validate."""
await self.common_validate()
# Custom step 1: Validate content.
if self.repository_manifest:
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
if self.content.path.remote == "apps":
self.data.domain = filters.get_first_directory_in_directory(
self.tree, self.content.path.remote
)
self.content.path.remote = f"apps/{self.data.name}"
compliant = False
for treefile in self.treefiles:
if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".cs"):
compliant = True
break
if not compliant:
raise HacsException(
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get appdaemon objects.
if self.repository_manifest:
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
if self.content.path.remote == "apps":
self.data.domain = filters.get_first_directory_in_directory(
self.tree, self.content.path.remote
)
self.content.path.remote = f"apps/{self.data.name}"
# Set local path
self.content.path.local = self.localpath
# Signal entities to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
async def async_post_installation(self):
"""Run post installation steps."""
try:
await self.hacs.hass.services.async_call(
"hassio", "addon_restart", {"addon": "c6a2317c_netdaemon"}
)
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
pass

View File

@@ -1,15 +1,21 @@
"""Class for plugins in HACS.""" """Class for plugins in HACS."""
from __future__ import annotations from __future__ import annotations
import json import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..enums import HacsCategory, HacsDispatchEvent from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException from ..exceptions import HacsException
from ..utils.decorator import concurrent from ..utils.decorator import concurrent
from ..utils.json import json_loads
from .base import HacsRepository from .base import HacsRepository
HACSTAG_REPLACER = re.compile(r"\D+")
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.components.lovelace.resources import ResourceStorageCollection
from ..base import HacsBase from ..base import HacsBase
@@ -40,7 +46,7 @@ class HacsPluginRepository(HacsRepository):
if self.content.path.remote is None: if self.content.path.remote is None:
raise HacsException( raise HacsException(
f"Repository structure for {self.ref.replace('tags/','')} is not compliant" f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
) )
if self.content.path.remote == "release": if self.content.path.remote == "release":
@@ -53,6 +59,15 @@ class HacsPluginRepository(HacsRepository):
self.logger.error("%s %s", self.string, error) self.logger.error("%s %s", self.string, error)
return self.validate.success return self.validate.success
async def async_post_installation(self):
"""Run post installation steps."""
await self.hacs.async_setup_frontend_endpoint_plugin()
await self.update_dashboard_resources()
async def async_post_uninstall(self):
"""Run post uninstall steps."""
await self.remove_dashboard_resources()
@concurrent(concurrenttasks=10, backoff_time=5) @concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False): async def update_repository(self, ignore_issues=False, force=False):
"""Update.""" """Update."""
@@ -64,13 +79,13 @@ class HacsPluginRepository(HacsRepository):
if self.content.path.remote is None: if self.content.path.remote is None:
self.validate.errors.append( self.validate.errors.append(
f"Repository structure for {self.ref.replace('tags/','')} is not compliant" f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
) )
if self.content.path.remote == "release": if self.content.path.remote == "release":
self.content.single = True self.content.single = True
# Signal entities to refresh # Signal frontend to refresh
if self.data.installed: if self.data.installed:
self.hacs.async_dispatch( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.REPOSITORY,
@@ -86,7 +101,7 @@ class HacsPluginRepository(HacsRepository):
"""Get package content.""" """Get package content."""
try: try:
package = await self.repository_object.get_contents("package.json", self.ref) package = await self.repository_object.get_contents("package.json", self.ref)
package = json.loads(package.content) package = json_loads(package.content)
if package: if package:
self.data.authors = package["author"] self.data.authors = package["author"]
@@ -95,13 +110,9 @@ class HacsPluginRepository(HacsRepository):
def update_filenames(self) -> None: def update_filenames(self) -> None:
"""Get the filename to target.""" """Get the filename to target."""
possible_locations = ( content_in_root = self.repository_manifest.content_in_root
("",) if self.repository_manifest.content_in_root else ("release", "dist", "") if specific_filename := self.repository_manifest.filename:
) valid_filenames = (specific_filename,)
# Handler for plug requirement 3
if self.repository_manifest.filename:
valid_filenames = (self.repository_manifest.filename,)
else: else:
valid_filenames = ( valid_filenames = (
f"{self.data.name.replace('lovelace-', '')}.js", f"{self.data.name.replace('lovelace-', '')}.js",
@@ -110,25 +121,126 @@ class HacsPluginRepository(HacsRepository):
f"{self.data.name}-bundle.js", f"{self.data.name}-bundle.js",
) )
for location in possible_locations: if not content_in_root:
if location == "release": if self.releases.objects:
if not self.releases.objects:
continue
release = self.releases.objects[0] release = self.releases.objects[0]
if not release.assets: if release.assets:
continue if assetnames := [
asset = release.assets[0] filename
for filename in valid_filenames: for filename in valid_filenames
if filename == asset.name: for asset in release.assets
self.data.file_name = filename if filename == asset.name
self.content.path.remote = "release"
break
else:
for filename in valid_filenames:
if f"{location+'/' if location else ''}{filename}" in [
x.full_path for x in self.tree
]: ]:
self.data.file_name = filename.split("/")[-1] self.data.file_name = assetnames[0]
self.content.path.remote = location self.content.path.remote = "release"
break return
all_paths = {x.full_path for x in self.tree}
for filename in valid_filenames:
if filename in all_paths:
self.data.file_name = filename
self.content.path.remote = ""
return
if not content_in_root and f"dist/{filename}" in all_paths:
self.data.file_name = filename.split("/")[-1]
self.content.path.remote = "dist"
return
def generate_dashboard_resource_hacstag(self) -> str:
"""Get the HACS tag used by dashboard resources."""
version = (
self.display_installed_version
or self.data.selected_tag
or self.display_available_version
)
return f"{self.data.id}{HACSTAG_REPLACER.sub('', version)}"
def generate_dashboard_resource_namespace(self) -> str:
"""Get the dashboard resource namespace."""
return f"/hacsfiles/{self.data.full_name.split("/")[1]}"
def generate_dashboard_resource_url(self) -> str:
"""Get the dashboard resource namespace."""
filename = self.data.file_name
if "/" in filename:
self.logger.warning("%s have defined an invalid file name %s", self.string, filename)
filename = filename.split("/")[-1]
return (
f"{self.generate_dashboard_resource_namespace()}/{filename}"
f"?hacstag={self.generate_dashboard_resource_hacstag()}"
)
def _get_resource_handler(self) -> ResourceStorageCollection | None:
"""Get the resource handler."""
resources: ResourceStorageCollection | None
if not (hass_data := self.hacs.hass.data):
self.logger.error("%s Can not access the hass data", self.string)
return
if (lovelace_data := hass_data.get("lovelace")) is None:
self.logger.warning("%s Can not access the lovelace integration data", self.string)
return
if self.hacs.core.ha_version > "2025.1.99":
# Changed to 2025.2.0
# Changed in https://github.com/home-assistant/core/pull/136313
resources = lovelace_data.resources
else:
resources = lovelace_data.get("resources")
if resources is None:
self.logger.warning("%s Can not access the dashboard resources", self.string)
return
if not hasattr(resources, "store") or resources.store is None:
self.logger.info("%s YAML mode detected, can not update resources", self.string)
return
if resources.store.key != "lovelace_resources" or resources.store.version != 1:
self.logger.warning("%s Can not use the dashboard resources", self.string)
return
return resources
async def update_dashboard_resources(self) -> None:
"""Update dashboard resources."""
if not (resources := self._get_resource_handler()):
return
if not resources.loaded:
await resources.async_load()
namespace = self.generate_dashboard_resource_namespace()
url = self.generate_dashboard_resource_url()
for entry in resources.async_items():
if (entry_url := entry["url"]).startswith(namespace):
if entry_url != url:
self.logger.info(
"%s Updating existing dashboard resource from %s to %s",
self.string,
entry_url,
url,
)
await resources.async_update_item(entry["id"], {"url": url})
return
# Nothing was updated, add the resource
self.logger.info("%s Adding dashboard resource %s", self.string, url)
await resources.async_create_item({"res_type": "module", "url": url})
async def remove_dashboard_resources(self) -> None:
"""Remove dashboard resources."""
if not (resources := self._get_resource_handler()):
return
if not resources.loaded:
await resources.async_load()
namespace = self.generate_dashboard_resource_namespace()
for entry in resources.async_items():
if entry["url"].startswith(namespace):
self.logger.info("%s Removing dashboard resource %s", self.string, entry["url"])
await resources.async_delete_item(entry["id"])
return

View File

@@ -1,4 +1,5 @@
"""Class for python_scripts in HACS.""" """Class for python_scripts in HACS."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -48,7 +49,7 @@ class HacsPythonScriptRepository(HacsRepository):
break break
if not compliant: if not compliant:
raise HacsException( raise HacsException(
f"Repository structure for {self.ref.replace('tags/','')} is not compliant" f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
) )
# Handle potential errors # Handle potential errors
@@ -63,6 +64,9 @@ class HacsPythonScriptRepository(HacsRepository):
# Set name # Set name
self.update_filenames() self.update_filenames()
if self.hacs.system.action:
await self.hacs.validation.async_run_repository_checks(self)
@concurrent(concurrenttasks=10, backoff_time=5) @concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False): async def update_repository(self, ignore_issues=False, force=False):
"""Update.""" """Update."""
@@ -80,13 +84,13 @@ class HacsPythonScriptRepository(HacsRepository):
break break
if not compliant: if not compliant:
raise HacsException( raise HacsException(
f"Repository structure for {self.ref.replace('tags/','')} is not compliant" f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
) )
# Update name # Update name
self.update_filenames() self.update_filenames()
# Signal entities to refresh # Signal frontend to refresh
if self.data.installed: if self.data.installed:
self.hacs.async_dispatch( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.REPOSITORY,

View File

@@ -1,8 +1,11 @@
"""Class for themes in HACS.""" """Class for themes in HACS."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from homeassistant.exceptions import HomeAssistantError
from ..enums import HacsCategory, HacsDispatchEvent from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException from ..exceptions import HacsException
from ..utils.decorator import concurrent from ..utils.decorator import concurrent
@@ -32,10 +35,7 @@ class HacsThemeRepository(HacsRepository):
async def async_post_installation(self): async def async_post_installation(self):
"""Run post installation steps.""" """Run post installation steps."""
try: await self._reload_frontend_themes()
await self.hacs.hass.services.async_call("frontend", "reload_themes", {})
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
pass
async def validate_repository(self): async def validate_repository(self):
"""Validate.""" """Validate."""
@@ -50,7 +50,7 @@ class HacsThemeRepository(HacsRepository):
break break
if not compliant: if not compliant:
raise HacsException( raise HacsException(
f"Repository structure for {self.ref.replace('tags/','')} is not compliant" f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
) )
if self.repository_manifest.content_in_root: if self.repository_manifest.content_in_root:
@@ -69,6 +69,21 @@ class HacsThemeRepository(HacsRepository):
self.update_filenames() self.update_filenames()
self.content.path.local = self.localpath self.content.path.local = self.localpath
if self.hacs.system.action:
await self.hacs.validation.async_run_repository_checks(self)
async def _reload_frontend_themes(self) -> None:
"""Reload frontend themes."""
self.logger.debug("%s Reloading frontend themes", self.string)
try:
await self.hacs.hass.services.async_call("frontend", "reload_themes", {})
except HomeAssistantError as exception:
self.logger.exception("%s %s", self.string, exception)
async def async_post_uninstall(self) -> None:
"""Run post uninstall steps."""
await self._reload_frontend_themes()
@concurrent(concurrenttasks=10, backoff_time=5) @concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False): async def update_repository(self, ignore_issues=False, force=False):
"""Update.""" """Update."""
@@ -83,7 +98,7 @@ class HacsThemeRepository(HacsRepository):
self.update_filenames() self.update_filenames()
self.content.path.local = self.localpath self.content.path.local = self.localpath
# Signal entities to refresh # Signal frontend to refresh
if self.data.installed: if self.data.installed:
self.hacs.async_dispatch( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.REPOSITORY,

View File

@@ -1,53 +0,0 @@
"""Sensor platform for HACS."""
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import callback
from .const import DOMAIN
from .entity import HacsSystemEntity
from .enums import ConfigurationType
async def async_setup_platform(hass, _config, async_add_entities, _discovery_info=None):
"""Setup sensor platform."""
async_add_entities([HACSSensor(hacs=hass.data.get(DOMAIN))])
async def async_setup_entry(hass, _config_entry, async_add_devices):
"""Setup sensor platform."""
async_add_devices([HACSSensor(hacs=hass.data.get(DOMAIN))])
class HACSSensor(HacsSystemEntity, SensorEntity):
"""HACS Sensor class."""
_attr_name = "hacs"
_attr_native_unit_of_measurement = "pending update(s)"
_attr_native_value = None
@callback
def _update(self) -> None:
"""Update the sensor."""
repositories = [
repository
for repository in self.hacs.repositories.list_all
if repository.pending_update
]
self._attr_native_value = len(repositories)
if (
self.hacs.configuration.config_type == ConfigurationType.YAML
and not self.hacs.configuration.experimental
):
self._attr_extra_state_attributes = {
"repositories": [
{
"name": repository.data.full_name,
"display_name": repository.display_name,
"installed_version": repository.display_installed_version,
"available_version": repository.display_available_version,
}
for repository in repositories
]
}

View File

@@ -1,4 +1,7 @@
"""Provide info to system health.""" """Provide info to system health."""
from typing import Any
from aiogithubapi.common.const import BASE_API_URL from aiogithubapi.common.const import BASE_API_URL
from homeassistant.components import system_health from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -7,6 +10,7 @@ from .base import HacsBase
from .const import DOMAIN from .const import DOMAIN
GITHUB_STATUS = "https://www.githubstatus.com/" GITHUB_STATUS = "https://www.githubstatus.com/"
CLOUDFLARE_STATUS = "https://www.cloudflarestatus.com/"
@callback @callback
@@ -16,8 +20,11 @@ def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegi
register.async_register_info(system_health_info, "/hacs") register.async_register_info(system_health_info, "/hacs")
async def system_health_info(hass): async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page.""" """Get info for the info page."""
if DOMAIN not in hass.data:
return {"Disabled": "HACS is not loaded, but HA still requests this information..."}
hacs: HacsBase = hass.data[DOMAIN] hacs: HacsBase = hass.data[DOMAIN]
response = await hacs.githubapi.rate_limit() response = await hacs.githubapi.rate_limit()
@@ -29,6 +36,9 @@ async def system_health_info(hass):
"GitHub Web": system_health.async_check_can_reach_url( "GitHub Web": system_health.async_check_can_reach_url(
hass, "https://github.com/", GITHUB_STATUS hass, "https://github.com/", GITHUB_STATUS
), ),
"HACS Data": system_health.async_check_can_reach_url(
hass, "https://data-v2.hacs.xyz/data.json", CLOUDFLARE_STATUS
),
"GitHub API Calls Remaining": response.data.resources.core.remaining, "GitHub API Calls Remaining": response.data.resources.core.remaining,
"Installed Version": hacs.version, "Installed Version": hacs.version,
"Stage": hacs.stage, "Stage": hacs.stage,

View File

@@ -16,7 +16,7 @@
"data": { "data": {
"acc_logs": "I know how to access Home Assistant logs", "acc_logs": "I know how to access Home Assistant logs",
"acc_addons": "I know that there are no add-ons in HACS", "acc_addons": "I know that there are no add-ons in HACS",
"acc_untested": "I know that everything inside HACS is custom and untested by Home Assistant", "acc_untested": "I know that everything inside HACS including HACS itself is custom and untested by Home Assistant",
"acc_disable": "I know that if I get issues with Home Assistant I should disable all my custom_components" "acc_disable": "I know that if I get issues with Home Assistant I should disable all my custom_components"
}, },
"description": "Before you can setup HACS you need to acknowledge the following" "description": "Before you can setup HACS you need to acknowledge the following"
@@ -30,28 +30,55 @@
} }
}, },
"progress": { "progress": {
"wait_for_device": "1. Open {url} \n2.Paste the following key to authorize HACS: \n```\n{code}\n```\n" "wait_for_device": "1. Open {url} \n2. Paste the following key to authorize HACS: \n```\n{code}\n```"
} }
}, },
"options": { "options": {
"abort": { "abort": {
"not_setup": "HACS is not setup.", "not_setup": "HACS is not setup.",
"release_limit_value": "The release limit needs to be between 1 and 100" "pending_tasks": "There are pending tasks. Try again later.",
"release_limit_value": "The release limit needs to be between 1 and 100."
}, },
"step": { "step": {
"user": { "user": {
"data": { "data": {
"not_in_use": "Not in use with YAML", "not_in_use": "Not in use with YAML",
"country": "Filter with country code.", "country": "Filter with country code",
"experimental": "Enable experimental features", "release_limit": "Number of releases to show",
"release_limit": "Number of releases to show.", "debug": "Enable debug",
"debug": "Enable debug.",
"appdaemon": "Enable AppDaemon apps discovery & tracking", "appdaemon": "Enable AppDaemon apps discovery & tracking",
"netdaemon": "Enable NetDaemon apps discovery & tracking",
"sidepanel_icon": "Side panel icon", "sidepanel_icon": "Side panel icon",
"sidepanel_title": "Side panel title" "sidepanel_title": "Side panel title"
} }
} }
} }
},
"issues": {
"restart_required": {
"title": "Restart required",
"fix_flow": {
"step": {
"confirm_restart": {
"title": "Restart required",
"description": "Restart of Home Assistant is required to finish download/update of {name}, click submit to restart now."
}
}
}
},
"removed": {
"title": "Repository removed from HACS",
"description": "Because {reason}, `{name}` has been removed from HACS. Please visit the [HACS Panel](/hacs/repository/{repositry_id}) to remove it."
}
},
"entity": {
"switch": {
"pre-release": {
"name": "Pre-release",
"state": {
"off": "No pre-releases",
"on": "Pre-releases preferred"
}
}
}
} }
} }

View File

@@ -1,22 +1,28 @@
"""Update entities for HACS.""" """Update entities for HACS."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from homeassistant.components.update import UpdateEntity from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import callback from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, HomeAssistantError, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .base import HacsBase from .base import HacsBase
from .const import DOMAIN from .const import DOMAIN
from .entity import HacsRepositoryEntity from .entity import HacsRepositoryEntity
from .enums import HacsCategory, HacsDispatchEvent from .enums import HacsCategory, HacsDispatchEvent
from .exceptions import HacsException
async def async_setup_entry(hass, _config_entry, async_add_devices): async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Setup update platform.""" """Setup update platform."""
hacs: HacsBase = hass.data.get(DOMAIN) hacs: HacsBase = hass.data[DOMAIN]
async_add_devices( async_add_entities(
HacsRepositoryUpdateEntity(hacs=hacs, repository=repository) HacsRepositoryUpdateEntity(hacs=hacs, repository=repository)
for repository in hacs.repositories.list_downloaded for repository in hacs.repositories.list_downloaded
) )
@@ -25,13 +31,12 @@ async def async_setup_entry(hass, _config_entry, async_add_devices):
class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity): class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
"""Update entities for repositories downloaded with HACS.""" """Update entities for repositories downloaded with HACS."""
@property _attr_supported_features = (
def supported_features(self) -> int | None: UpdateEntityFeature.INSTALL
"""Return the supported features of the entity.""" | UpdateEntityFeature.SPECIFIC_VERSION
features = 4 | 16 | UpdateEntityFeature.PROGRESS
if self.repository.can_download: | UpdateEntityFeature.RELEASE_NOTES
features = features | 1 )
return features
@property @property
def name(self) -> str | None: def name(self) -> str | None:
@@ -58,8 +63,6 @@ class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
@property @property
def release_summary(self) -> str | None: def release_summary(self) -> str | None:
"""Return the release summary.""" """Return the release summary."""
if not self.repository.can_download:
return f"<ha-alert alert-type='warning'>Requires Home Assistant {self.repository.repository_manifest.homeassistant}</ha-alert>"
if self.repository.pending_restart: if self.repository.pending_restart:
return "<ha-alert alert-type='error'>Restart of Home Assistant required</ha-alert>" return "<ha-alert alert-type='error'>Restart of Home Assistant required</ha-alert>"
return None return None
@@ -77,23 +80,44 @@ class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None: async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
"""Install an update.""" """Install an update."""
if self.repository.display_version_or_commit == "version": to_download = version or self.latest_version
self._update_in_progress(progress=10) if to_download == self.installed_version:
self.repository.data.selected_tag = self.latest_version raise HomeAssistantError(f"Version {self.installed_version} of {
await self.repository.update_repository(force=True) self.repository.data.full_name} is already downloaded")
self._update_in_progress(progress=20) try:
await self.repository.async_install() await self.repository.async_download_repository(ref=version or self.latest_version)
self._update_in_progress(progress=False) except HacsException as exception:
raise HomeAssistantError(exception) from exception
async def async_release_notes(self) -> str | None: async def async_release_notes(self) -> str | None:
"""Return the release notes.""" """Return the release notes."""
if self.repository.pending_restart or not self.repository.can_download: if self.repository.pending_restart:
return None return None
if self.latest_version not in self.repository.data.published_tags:
releases = await self.repository.get_releases(
prerelease=self.repository.data.show_beta,
returnlimit=self.hacs.configuration.release_limit,
)
if releases:
self.repository.data.releases = True
self.repository.releases.objects = releases
self.repository.data.published_tags = [x.tag_name for x in releases]
self.repository.data.last_version = next(iter(self.repository.data.published_tags))
release_notes = "" release_notes = ""
if len(self.repository.releases.objects) > 0: # Compile release notes from installed version up to the latest
release = self.repository.releases.objects[0] if self.installed_version in self.repository.data.published_tags:
release_notes += release.body for release in self.repository.releases.objects:
if release.tag_name == self.installed_version:
break
release_notes += f"# {release.tag_name}"
if release.tag_name != release.name:
release_notes += f" - {release.name}"
release_notes += f"\n\n{release.body}"
release_notes += "\n\n---\n\n"
elif any(self.repository.releases.objects):
release_notes += self.repository.releases.objects[0].body
if self.repository.pending_update: if self.repository.pending_update:
if self.repository.data.category == HacsCategory.INTEGRATION: if self.repository.data.category == HacsCategory.INTEGRATION:

View File

@@ -1,4 +1,5 @@
"""Backup.""" """Backup."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -27,7 +28,7 @@ class Backup:
backup_path: str = DEFAULT_BACKUP_PATH, backup_path: str = DEFAULT_BACKUP_PATH,
repository: HacsRepository | None = None, repository: HacsRepository | None = None,
) -> None: ) -> None:
"""initialize.""" """Initialize."""
self.hacs = hacs self.hacs = hacs
self.repository = repository self.repository = repository
self.local_path = local_path or repository.content.path.local self.local_path = local_path or repository.content.path.local
@@ -74,7 +75,9 @@ class Backup:
self.local_path, self.local_path,
self.backup_path_full, self.backup_path_full,
) )
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception:
self.hacs.log.warning("Could not create backup: %s", exception) self.hacs.log.warning("Could not create backup: %s", exception)
def restore(self) -> None: def restore(self) -> None:
@@ -105,33 +108,3 @@ class Backup:
while os.path.exists(self.backup_path): while os.path.exists(self.backup_path):
sleep(0.1) sleep(0.1)
self.hacs.log.debug("Backup dir %s cleared", self.backup_path) self.hacs.log.debug("Backup dir %s cleared", self.backup_path)
class BackupNetDaemon(Backup):
"""BackupNetDaemon."""
def create(self) -> None:
"""Create a backup in /tmp"""
if not self._init_backup_dir():
return
for filename in os.listdir(self.repository.content.path.local):
if not filename.endswith(".yaml"):
continue
source_file_name = f"{self.repository.content.path.local}/{filename}"
target_file_name = f"{self.backup_path}/{filename}"
shutil.copyfile(source_file_name, target_file_name)
def restore(self) -> None:
"""Create a backup in /tmp"""
if not os.path.exists(self.backup_path):
return
for filename in os.listdir(self.backup_path):
if not filename.endswith(".yaml"):
continue
source_file_name = f"{self.backup_path}/{filename}"
target_file_name = f"{self.repository.content.path.local}/{filename}"
shutil.copyfile(source_file_name, target_file_name)

View File

@@ -1,74 +1,9 @@
"""HACS Configuration Schemas.""" """HACS Configuration Schemas."""
# pylint: disable=dangerous-default-value
import voluptuous as vol
from ..const import LOCALE
# Configuration: # Configuration:
TOKEN = "token"
SIDEPANEL_TITLE = "sidepanel_title" SIDEPANEL_TITLE = "sidepanel_title"
SIDEPANEL_ICON = "sidepanel_icon" SIDEPANEL_ICON = "sidepanel_icon"
FRONTEND_REPO = "frontend_repo"
FRONTEND_REPO_URL = "frontend_repo_url"
APPDAEMON = "appdaemon" APPDAEMON = "appdaemon"
NETDAEMON = "netdaemon"
# Options: # Options:
COUNTRY = "country" COUNTRY = "country"
DEBUG = "debug"
RELEASE_LIMIT = "release_limit"
EXPERIMENTAL = "experimental"
# Config group
PATH_OR_URL = "frontend_repo_path_or_url"
def hacs_base_config_schema(config: dict = {}) -> dict:
"""Return a shcema configuration dict for HACS."""
if not config:
config = {
TOKEN: "xxxxxxxxxxxxxxxxxxxxxxxxxxx",
}
return {
vol.Required(TOKEN, default=config.get(TOKEN)): str,
}
def hacs_config_option_schema(options: dict = {}) -> dict:
"""Return a shcema for HACS configuration options."""
if not options:
options = {
APPDAEMON: False,
COUNTRY: "ALL",
DEBUG: False,
EXPERIMENTAL: False,
NETDAEMON: False,
RELEASE_LIMIT: 5,
SIDEPANEL_ICON: "hacs:hacs",
SIDEPANEL_TITLE: "HACS",
FRONTEND_REPO: "",
FRONTEND_REPO_URL: "",
}
return {
vol.Optional(SIDEPANEL_TITLE, default=options.get(SIDEPANEL_TITLE)): str,
vol.Optional(SIDEPANEL_ICON, default=options.get(SIDEPANEL_ICON)): str,
vol.Optional(RELEASE_LIMIT, default=options.get(RELEASE_LIMIT)): int,
vol.Optional(COUNTRY, default=options.get(COUNTRY)): vol.In(LOCALE),
vol.Optional(APPDAEMON, default=options.get(APPDAEMON)): bool,
vol.Optional(NETDAEMON, default=options.get(NETDAEMON)): bool,
vol.Optional(DEBUG, default=options.get(DEBUG)): bool,
vol.Optional(EXPERIMENTAL, default=options.get(EXPERIMENTAL)): bool,
vol.Exclusive(FRONTEND_REPO, PATH_OR_URL): str,
vol.Exclusive(FRONTEND_REPO_URL, PATH_OR_URL): str,
}
def hacs_config_combined() -> dict:
"""Combine the configuration options."""
base = hacs_base_config_schema()
options = hacs_config_option_schema()
for option in options:
base[option] = options[option]
return base

View File

@@ -1,37 +1,45 @@
"""Data handler for HACS.""" """Data handler for HACS."""
from __future__ import annotations
import asyncio import asyncio
from datetime import datetime from datetime import UTC, datetime
from typing import Any
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.util import json as json_util from homeassistant.exceptions import HomeAssistantError
from ..base import HacsBase from ..base import HacsBase
from ..enums import HacsDispatchEvent, HacsGitHubRepo from ..const import HACS_REPOSITORY_ID
from ..enums import HacsDisabledReason, HacsDispatchEvent
from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository
from .logger import get_hacs_logger from .logger import LOGGER
from .path import is_safe from .path import is_safe
from .store import async_load_from_store, async_save_to_store from .store import async_load_from_store, async_save_to_store
DEFAULT_BASE_REPOSITORY_DATA = ( EXPORTED_BASE_DATA = (
("new", False),
("full_name", ""),
)
EXPORTED_REPOSITORY_DATA = EXPORTED_BASE_DATA + (
("authors", []), ("authors", []),
("category", ""), ("category", ""),
("description", ""), ("description", ""),
("domain", None), ("domain", None),
("downloads", 0), ("downloads", 0),
("etag_repository", None), ("etag_repository", None),
("full_name", ""),
("last_updated", 0),
("hide", False), ("hide", False),
("last_updated", 0),
("new", False), ("new", False),
("stargazers_count", 0), ("stargazers_count", 0),
("topics", []), ("topics", []),
) )
DEFAULT_EXTENDED_REPOSITORY_DATA = ( EXPORTED_DOWNLOADED_REPOSITORY_DATA = EXPORTED_REPOSITORY_DATA + (
("archived", False), ("archived", False),
("config_flow", False), ("config_flow", False),
("default_branch", None), ("default_branch", None),
("description", ""),
("first_install", False), ("first_install", False),
("installed_commit", None), ("installed_commit", None),
("installed", False), ("installed", False),
@@ -39,13 +47,11 @@ DEFAULT_EXTENDED_REPOSITORY_DATA = (
("last_version", None), ("last_version", None),
("manifest_name", None), ("manifest_name", None),
("open_issues", 0), ("open_issues", 0),
("prerelease", None),
("published_tags", []), ("published_tags", []),
("pushed_at", ""),
("releases", False), ("releases", False),
("selected_tag", None), ("selected_tag", None),
("show_beta", False), ("show_beta", False),
("stargazers_count", 0),
("topics", []),
) )
@@ -54,7 +60,7 @@ class HacsData:
def __init__(self, hacs: HacsBase): def __init__(self, hacs: HacsBase):
"""Initialize.""" """Initialize."""
self.logger = get_hacs_logger() self.logger = LOGGER
self.hacs = hacs self.hacs = hacs
self.content = {} self.content = {}
@@ -79,6 +85,7 @@ class HacsData:
"ignored_repositories": self.hacs.common.ignored_repositories, "ignored_repositories": self.hacs.common.ignored_repositories,
}, },
) )
await self._async_store_experimental_content_and_repos()
await self._async_store_content_and_repos() await self._async_store_content_and_repos()
async def _async_store_content_and_repos(self, _=None): # bb: ignore async def _async_store_content_and_repos(self, _=None): # bb: ignore
@@ -93,46 +100,96 @@ class HacsData:
for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG): for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG):
self.hacs.async_dispatch(event, {}) self.hacs.async_dispatch(event, {})
async def _async_store_experimental_content_and_repos(self, _=None):
"""Store the main repos file and each repo that is out of date."""
# Repositories
self.content = {}
for repository in self.hacs.repositories.list_all:
if repository.data.category in self.hacs.common.categories:
self.async_store_experimental_repository_data(repository)
await async_save_to_store(self.hacs.hass, "data", {"repositories": self.content})
@callback @callback
def async_store_repository_data(self, repository: HacsRepository) -> dict: def async_store_repository_data(self, repository: HacsRepository) -> dict:
"""Store the repository data.""" """Store the repository data."""
data = {"repository_manifest": repository.repository_manifest.manifest} data = {"repository_manifest": repository.repository_manifest.manifest}
for key, default_value in DEFAULT_BASE_REPOSITORY_DATA: for key, default in (
if (value := repository.data.__getattribute__(key)) != default_value: EXPORTED_DOWNLOADED_REPOSITORY_DATA
if repository.data.installed
else EXPORTED_REPOSITORY_DATA
):
if (value := getattr(repository.data, key, default)) != default:
data[key] = value data[key] = value
if repository.data.installed: if repository.data.installed_version:
for key, default_value in DEFAULT_EXTENDED_REPOSITORY_DATA:
if (value := repository.data.__getattribute__(key)) != default_value:
data[key] = value
data["version_installed"] = repository.data.installed_version data["version_installed"] = repository.data.installed_version
if repository.data.last_fetched: if repository.data.last_fetched:
data["last_fetched"] = repository.data.last_fetched.timestamp() data["last_fetched"] = repository.data.last_fetched.timestamp()
self.content[str(repository.data.id)] = data self.content[str(repository.data.id)] = data
@callback
def async_store_experimental_repository_data(self, repository: HacsRepository) -> None:
"""Store the experimental repository data for non downloaded repositories."""
data = {}
self.content.setdefault(repository.data.category, [])
if repository.data.installed:
data["repository_manifest"] = repository.repository_manifest.manifest
for key, default in EXPORTED_DOWNLOADED_REPOSITORY_DATA:
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
if repository.data.installed_version:
data["version_installed"] = repository.data.installed_version
if repository.data.last_fetched:
data["last_fetched"] = repository.data.last_fetched.timestamp()
else:
for key, default in EXPORTED_BASE_DATA:
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
self.content[repository.data.category].append({"id": str(repository.data.id), **data})
async def restore(self): async def restore(self):
"""Restore saved data.""" """Restore saved data."""
self.hacs.status.new = False self.hacs.status.new = False
hacs = await async_load_from_store(self.hacs.hass, "hacs") or {} repositories = {}
repositories = await async_load_from_store(self.hacs.hass, "repositories") or {} hacs = {}
try:
hacs = await async_load_from_store(self.hacs.hass, "hacs") or {}
except HomeAssistantError:
pass
try:
repositories = await async_load_from_store(self.hacs.hass, "repositories")
if not repositories and (data := await async_load_from_store(self.hacs.hass, "data")):
for category, entries in data.get("repositories", {}).items():
for repository in entries:
repositories[repository["id"]] = {"category": category, **repository}
except HomeAssistantError as exception:
self.hacs.log.error(
"Could not read %s, restore the file from a backup - %s",
self.hacs.hass.config.path(".storage/hacs.data"),
exception,
)
self.hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False
if not hacs and not repositories: if not hacs and not repositories:
# Assume new install # Assume new install
self.hacs.status.new = True self.hacs.status.new = True
self.logger.info("<HacsData restore> Loading base repository information") return True
repositories = await self.hacs.hass.async_add_executor_job(
json_util.load_json,
f"{self.hacs.core.config_path}/custom_components/hacs/utils/default.repositories",
)
self.logger.info("<HacsData restore> Restore started") self.logger.info("<HacsData restore> Restore started")
# Hacs # Hacs
self.hacs.common.archived_repositories = [] self.hacs.common.archived_repositories = set()
self.hacs.common.ignored_repositories = [] self.hacs.common.ignored_repositories = set()
self.hacs.common.renamed_repositories = {} self.hacs.common.renamed_repositories = {}
# Clear out doubble renamed values # Clear out doubble renamed values
@@ -143,14 +200,14 @@ class HacsData:
self.hacs.common.renamed_repositories[entry] = value self.hacs.common.renamed_repositories[entry] = value
# Clear out doubble archived values # Clear out doubble archived values
for entry in hacs.get("archived_repositories", []): for entry in hacs.get("archived_repositories", set()):
if entry not in self.hacs.common.archived_repositories: if entry not in self.hacs.common.archived_repositories:
self.hacs.common.archived_repositories.append(entry) self.hacs.common.archived_repositories.add(entry)
# Clear out doubble ignored values # Clear out doubble ignored values
for entry in hacs.get("ignored_repositories", []): for entry in hacs.get("ignored_repositories", set()):
if entry not in self.hacs.common.ignored_repositories: if entry not in self.hacs.common.ignored_repositories:
self.hacs.common.ignored_repositories.append(entry) self.hacs.common.ignored_repositories.add(entry)
try: try:
await self.register_unknown_repositories(repositories) await self.register_unknown_repositories(repositories)
@@ -165,41 +222,64 @@ class HacsData:
self.async_restore_repository(entry, repo_data) self.async_restore_repository(entry, repo_data)
self.logger.info("<HacsData restore> Restore done") self.logger.info("<HacsData restore> Restore done")
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except except (
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
self.logger.critical( self.logger.critical(
"<HacsData restore> [%s] Restore Failed!", exception, exc_info=exception "<HacsData restore> [%s] Restore Failed!", exception, exc_info=exception
) )
return False return False
return True return True
async def register_unknown_repositories(self, repositories): async def register_unknown_repositories(
self, repositories: dict[str, dict[str, Any]], category: str | None = None
):
"""Registry any unknown repositories.""" """Registry any unknown repositories."""
register_tasks = [ for repo_idx, (entry, repo_data) in enumerate(repositories.items()):
self.hacs.async_register_repository( # async_register_repository is awaited in a loop
# since its unlikely to ever suspend at startup
if (
entry == "0"
or repo_data.get("category", category) is None
or self.hacs.repositories.is_registered(repository_id=entry)
):
continue
await self.hacs.async_register_repository(
repository_full_name=repo_data["full_name"], repository_full_name=repo_data["full_name"],
category=repo_data["category"], category=repo_data.get("category", category),
check=False, check=False,
repository_id=entry, repository_id=entry,
) )
for entry, repo_data in repositories.items() if repo_idx % 100 == 0:
if entry != "0" and not self.hacs.repositories.is_registered(repository_id=entry) # yield to avoid blocking the event loop
] await asyncio.sleep(0)
if register_tasks:
await asyncio.gather(*register_tasks)
@callback @callback
def async_restore_repository(self, entry, repository_data): def async_restore_repository(self, entry: str, repository_data: dict[str, Any]):
"""Restore repository.""" """Restore repository."""
full_name = repository_data["full_name"] repository: HacsRepository | None = None
if not (repository := self.hacs.repositories.get_by_full_name(full_name)): if full_name := repository_data.get("full_name"):
self.logger.error("<HacsData restore> Did not find %s (%s)", full_name, entry) repository = self.hacs.repositories.get_by_full_name(full_name)
if not repository:
repository = self.hacs.repositories.get_by_id(entry)
if not repository:
return return
try:
self.hacs.repositories.set_repository_id(repository, entry)
except ValueError as exception:
self.logger.warning("<HacsData async_restore_repository> duplicate IDs %s", exception)
return
# Restore repository attributes # Restore repository attributes
self.hacs.repositories.set_repository_id(repository, entry)
repository.data.authors = repository_data.get("authors", []) repository.data.authors = repository_data.get("authors", [])
repository.data.description = repository_data.get("description", "") repository.data.description = repository_data.get("description", "")
repository.data.downloads = repository_data.get("downloads", 0) repository.data.downloads = repository_data.get("downloads", 0)
repository.data.last_updated = repository_data.get("last_updated", 0) repository.data.last_updated = repository_data.get("last_updated", 0)
if self.hacs.system.generator:
repository.data.etag_releases = repository_data.get("etag_releases")
repository.data.open_issues = repository_data.get("open_issues", 0)
repository.data.etag_repository = repository_data.get("etag_repository") repository.data.etag_repository = repository_data.get("etag_repository")
repository.data.topics = [ repository.data.topics = [
topic for topic in repository_data.get("topics", []) if topic not in TOPIC_FILTER topic for topic in repository_data.get("topics", []) if topic not in TOPIC_FILTER
@@ -210,24 +290,27 @@ class HacsData:
) or repository_data.get("stars", 0) ) or repository_data.get("stars", 0)
repository.releases.last_release = repository_data.get("last_release_tag") repository.releases.last_release = repository_data.get("last_release_tag")
repository.data.releases = repository_data.get("releases", False) repository.data.releases = repository_data.get("releases", False)
repository.data.hide = repository_data.get("hide", False)
repository.data.installed = repository_data.get("installed", False) repository.data.installed = repository_data.get("installed", False)
repository.data.new = repository_data.get("new", False) repository.data.new = repository_data.get("new", False)
repository.data.selected_tag = repository_data.get("selected_tag") repository.data.selected_tag = repository_data.get("selected_tag")
repository.data.show_beta = repository_data.get("show_beta", False) repository.data.show_beta = repository_data.get("show_beta", False)
repository.data.last_version = repository_data.get("last_release_tag") repository.data.last_version = repository_data.get("last_version")
repository.data.prerelease = repository_data.get("prerelease")
repository.data.last_commit = repository_data.get("last_commit") repository.data.last_commit = repository_data.get("last_commit")
repository.data.installed_version = repository_data.get("version_installed") repository.data.installed_version = repository_data.get("version_installed")
repository.data.installed_commit = repository_data.get("installed_commit") repository.data.installed_commit = repository_data.get("installed_commit")
repository.data.manifest_name = repository_data.get("manifest_name") repository.data.manifest_name = repository_data.get("manifest_name")
if last_fetched := repository_data.get("last_fetched"): if last_fetched := repository_data.get("last_fetched"):
repository.data.last_fetched = datetime.fromtimestamp(last_fetched) repository.data.last_fetched = datetime.fromtimestamp(last_fetched, UTC)
repository.repository_manifest = HacsManifest.from_dict( repository.repository_manifest = HacsManifest.from_dict(
repository_data.get("repository_manifest", {}) repository_data.get("manifest") or repository_data.get("repository_manifest") or {}
) )
if repository.data.prerelease == repository.data.last_version:
repository.data.prerelease = None
if repository.localpath is not None and is_safe(self.hacs, repository.localpath): if repository.localpath is not None and is_safe(self.hacs, repository.localpath):
# Set local path # Set local path
repository.content.path.local = repository.localpath repository.content.path.local = repository.localpath
@@ -235,6 +318,6 @@ class HacsData:
if repository.data.installed: if repository.data.installed:
repository.data.first_install = False repository.data.first_install = False
if full_name == HacsGitHubRepo.INTEGRATION: if entry == HACS_REPOSITORY_ID:
repository.data.installed_version = self.hacs.version repository.data.installed_version = self.hacs.version
repository.data.installed = True repository.data.installed = True

View File

@@ -1,4 +1,5 @@
"""Util to decode content from the github API.""" """Util to decode content from the github API."""
from base64 import b64decode from base64 import b64decode

View File

@@ -1,9 +1,11 @@
"""HACS Decorators.""" """HACS Decorators."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Coroutine
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, Coroutine from typing import TYPE_CHECKING, Any
from ..const import DEFAULT_CONCURRENT_BACKOFF_TIME, DEFAULT_CONCURRENT_TASKS from ..const import DEFAULT_CONCURRENT_BACKOFF_TIME, DEFAULT_CONCURRENT_TASKS

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
"""Filter functions.""" """Filter functions."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any

View File

@@ -1,11 +1,7 @@
"""Custom logger for HACS.""" """Custom logger for HACS."""
import logging import logging
from ..const import PACKAGE_NAME from ..const import PACKAGE_NAME
_HACSLogger: logging.Logger = logging.getLogger(PACKAGE_NAME) LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)
def get_hacs_logger() -> logging.Logger:
"""Return a Logger instance."""
return _HACSLogger

View File

@@ -1,6 +1,8 @@
"""Path utils""" """Path utils"""
from __future__ import annotations from __future__ import annotations
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -8,13 +10,32 @@ if TYPE_CHECKING:
from ..base import HacsBase from ..base import HacsBase
@lru_cache(maxsize=1)
def _get_safe_paths(
config_path: str,
appdaemon_path: str,
plugin_path: str,
python_script_path: str,
theme_path: str,
) -> set[str]:
"""Get safe paths."""
return {
Path(f"{config_path}/{appdaemon_path}").as_posix(),
Path(f"{config_path}/{plugin_path}").as_posix(),
Path(f"{config_path}/{python_script_path}").as_posix(),
Path(f"{config_path}/{theme_path}").as_posix(),
Path(f"{config_path}/custom_components/").as_posix(),
Path(f"{config_path}/custom_templates/").as_posix(),
}
def is_safe(hacs: HacsBase, path: str | Path) -> bool: def is_safe(hacs: HacsBase, path: str | Path) -> bool:
"""Helper to check if path is safe to remove.""" """Helper to check if path is safe to remove."""
return Path(path).as_posix() not in ( configuration = hacs.configuration
Path(f"{hacs.core.config_path}/{hacs.configuration.appdaemon_path}").as_posix(), return Path(path).as_posix() not in _get_safe_paths(
Path(f"{hacs.core.config_path}/{hacs.configuration.netdaemon_path}").as_posix(), hacs.core.config_path,
Path(f"{hacs.core.config_path}/{hacs.configuration.plugin_path}").as_posix(), configuration.appdaemon_path,
Path(f"{hacs.core.config_path}/{hacs.configuration.python_script_path}").as_posix(), configuration.plugin_path,
Path(f"{hacs.core.config_path}/{hacs.configuration.theme_path}").as_posix(), configuration.python_script_path,
Path(f"{hacs.core.config_path}/custom_components/").as_posix(), configuration.theme_path,
) )

View File

@@ -1,16 +1,17 @@
"""The QueueManager class.""" """The QueueManager class."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Coroutine
import time import time
from typing import Coroutine
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from ..exceptions import HacsExecutionStillInProgress from ..exceptions import HacsExecutionStillInProgress
from .logger import get_hacs_logger from .logger import LOGGER
_LOGGER = get_hacs_logger() _LOGGER = LOGGER
class QueueManager: class QueueManager:
@@ -60,9 +61,6 @@ class QueueManager:
for task in self.queue: for task in self.queue:
local_queue.append(task) local_queue.append(task)
for task in local_queue:
self.queue.remove(task)
_LOGGER.debug("<QueueManager> Starting queue execution for %s tasks", len(local_queue)) _LOGGER.debug("<QueueManager> Starting queue execution for %s tasks", len(local_queue))
start = time.time() start = time.time()
result = await asyncio.gather(*local_queue, return_exceptions=True) result = await asyncio.gather(*local_queue, return_exceptions=True)
@@ -71,6 +69,9 @@ class QueueManager:
_LOGGER.error("<QueueManager> %s", entry) _LOGGER.error("<QueueManager> %s", entry)
end = time.time() - start end = time.time() - start
for task in local_queue:
self.queue.remove(task)
_LOGGER.debug( _LOGGER.debug(
"<QueueManager> Queue execution finished for %s tasks finished in %.2f seconds", "<QueueManager> Queue execution finished for %s tasks finished in %.2f seconds",
len(local_queue), len(local_queue),

View File

@@ -1,4 +1,5 @@
"""Regex utils""" """Regex utils"""
from __future__ import annotations from __future__ import annotations
import re import re

View File

@@ -1,13 +1,14 @@
"""Storage handers.""" """Storage handers."""
from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.util import json as json_util from homeassistant.util import json as json_util
from ..const import VERSION_STORAGE from ..const import VERSION_STORAGE
from ..exceptions import HacsException from ..exceptions import HacsException
from .logger import get_hacs_logger from .logger import LOGGER
_LOGGER = get_hacs_logger() _LOGGER = LOGGER
class HACSStore(Store): class HACSStore(Store):
@@ -17,7 +18,9 @@ class HACSStore(Store):
"""Load the data from disk if version matches.""" """Load the data from disk if version matches."""
try: try:
data = json_util.load_json(self.path) data = json_util.load_json(self.path)
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception:
_LOGGER.critical( _LOGGER.critical(
"Could not load '%s', restore it from a backup or delete the file: %s", "Could not load '%s', restore it from a backup or delete the file: %s",
self.path, self.path,

View File

@@ -1,32 +0,0 @@
"""Custom template support."""
from __future__ import annotations
from typing import TYPE_CHECKING
from jinja2 import Template
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
def render_template(content: str, context: HacsRepository) -> str:
"""Render templates in content."""
# Fix None issues
if context.releases.last_release_object is not None:
prerelease = context.releases.last_release_object.prerelease
else:
prerelease = False
# Render the template
try:
return Template(content).render(
installed=context.data.installed,
pending_update=context.pending_update,
prerelease=prerelease,
selected_tag=context.data.selected_tag,
version_available=context.releases.last_release,
version_installed=context.display_installed_version,
)
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
context.logger.debug(exception)
return content

View File

@@ -1,6 +1,16 @@
"""Validation utilities."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
from awesomeversion import AwesomeVersion
from homeassistant.helpers.config_validation import url as url_validator
import voluptuous as vol
from ..const import LOCALE
@dataclass @dataclass
@@ -13,3 +23,193 @@ class Validate:
def success(self) -> bool: def success(self) -> bool:
"""Return bool if the validation was a success.""" """Return bool if the validation was a success."""
return len(self.errors) == 0 return len(self.errors) == 0
def _country_validator(values) -> list[str]:
"""Custom country validator."""
countries = []
if isinstance(values, str):
countries.append(values.upper())
elif isinstance(values, list):
for value in values:
countries.append(value.upper())
else:
raise vol.Invalid(f"Value '{values}' is not a string or list.", path=["country"])
for country in countries:
if country not in LOCALE:
raise vol.Invalid(f"Value '{country}' is not in {LOCALE}.", path=["country"])
return countries
HACS_MANIFEST_JSON_SCHEMA = vol.Schema(
{
vol.Optional("content_in_root"): bool,
vol.Optional("country"): _country_validator,
vol.Optional("filename"): str,
vol.Optional("hacs"): str,
vol.Optional("hide_default_branch"): bool,
vol.Optional("homeassistant"): str,
vol.Optional("persistent_directory"): str,
vol.Optional("render_readme"): bool,
vol.Optional("zip_release"): bool,
vol.Required("name"): str,
},
extra=vol.PREVENT_EXTRA,
)
INTEGRATION_MANIFEST_JSON_SCHEMA = vol.Schema(
{
vol.Required("codeowners"): list,
vol.Required("documentation"): url_validator,
vol.Required("domain"): str,
vol.Required("issue_tracker"): url_validator,
vol.Required("name"): str,
vol.Required("version"): vol.Coerce(AwesomeVersion),
},
extra=vol.ALLOW_EXTRA,
)
def validate_repo_data(schema: dict[str, Any], extra: int) -> Callable[[Any], Any]:
"""Return a validator for repo data.
This is used instead of vol.All to always try both the repo schema and
and the validate_version validator.
"""
_schema = vol.Schema(schema, extra=extra)
def validate_repo_data(data: Any) -> Any:
"""Validate integration repo data."""
schema_errors: vol.MultipleInvalid | None = None
try:
_schema(data)
except vol.MultipleInvalid as err:
schema_errors = err
try:
validate_version(data)
except vol.Invalid as err:
if schema_errors:
schema_errors.add(err)
else:
raise
if schema_errors:
raise schema_errors
return data
return validate_repo_data
def validate_version(data: Any) -> Any:
"""Ensure at least one of last_commit or last_version is present."""
if "last_commit" not in data and "last_version" not in data:
raise vol.Invalid("Expected at least one of [`last_commit`, `last_version`], got none")
return data
V2_COMMON_DATA_JSON_SCHEMA = {
vol.Required("description"): vol.Any(str, None),
vol.Optional("downloads"): int,
vol.Optional("etag_releases"): str,
vol.Required("etag_repository"): str,
vol.Required("full_name"): str,
vol.Optional("last_commit"): str,
vol.Required("last_fetched"): vol.Any(int, float),
vol.Required("last_updated"): str,
vol.Optional("last_version"): str,
vol.Optional("prerelease"): str,
vol.Required("manifest"): {
vol.Optional("country"): vol.Any([str], False),
vol.Optional("name"): str,
},
vol.Optional("open_issues"): int,
vol.Optional("stargazers_count"): int,
vol.Optional("topics"): [str],
}
V2_INTEGRATION_DATA_JSON_SCHEMA = {
**V2_COMMON_DATA_JSON_SCHEMA,
vol.Required("domain"): str,
vol.Required("manifest_name"): str,
}
_V2_REPO_SCHEMAS = {
"appdaemon": V2_COMMON_DATA_JSON_SCHEMA,
"integration": V2_INTEGRATION_DATA_JSON_SCHEMA,
"plugin": V2_COMMON_DATA_JSON_SCHEMA,
"python_script": V2_COMMON_DATA_JSON_SCHEMA,
"template": V2_COMMON_DATA_JSON_SCHEMA,
"theme": V2_COMMON_DATA_JSON_SCHEMA,
}
# Used when validating repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_REPO_DATA = {
category: validate_repo_data(schema, vol.REMOVE_EXTRA)
for category, schema in _V2_REPO_SCHEMAS.items()
}
# Used when validating repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_REPO_DATA = {
category: vol.Schema({str: validate_repo_data(schema, vol.PREVENT_EXTRA)})
for category, schema in _V2_REPO_SCHEMAS.items()
}
V2_CRITICAL_REPO_DATA_SCHEMA = {
vol.Required("link"): str,
vol.Required("reason"): str,
vol.Required("repository"): str,
}
# Used when validating critical repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
V2_CRITICAL_REPO_DATA_SCHEMA,
extra=vol.REMOVE_EXTRA,
)
# Used when validating critical repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
[
vol.Schema(
V2_CRITICAL_REPO_DATA_SCHEMA,
extra=vol.PREVENT_EXTRA,
)
]
)
V2_REMOVED_REPO_DATA_SCHEMA = {
vol.Optional("link"): str,
vol.Optional("reason"): str,
vol.Required("removal_type"): vol.In(
[
"Integration is missing a version, and is abandoned.",
"Remove",
"archived",
"blacklist",
"critical",
"deprecated",
"removal",
"remove",
"removed",
"replaced",
"repository",
]
),
vol.Required("repository"): str,
}
# Used when validating removed repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
V2_REMOVED_REPO_DATA_SCHEMA,
extra=vol.REMOVE_EXTRA,
)
# Used when validating removed repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
[
vol.Schema(
V2_REMOVED_REPO_DATA_SCHEMA,
extra=vol.PREVENT_EXTRA,
)
]
)

View File

@@ -1,4 +1,5 @@
"""Version utils.""" """Version utils."""
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
@@ -21,7 +22,7 @@ def version_left_higher_then_right(left: str, right: str) -> bool | None:
and right_version.strategy != AwesomeVersionStrategy.UNKNOWN and right_version.strategy != AwesomeVersionStrategy.UNKNOWN
): ):
return left_version > right_version return left_version > right_version
except (AwesomeVersionException, AttributeError): except (AwesomeVersionException, AttributeError, KeyError):
pass pass
return None return None

View File

@@ -1,7 +1,37 @@
"""Workarounds for issues that should not be fixed.""" """Workarounds."""
from homeassistant.core import HomeAssistant
DOMAIN_OVERRIDES = { DOMAIN_OVERRIDES = {
# https://github.com/hacs/integration/issues/2465 # https://github.com/hacs/integration/issues/2465
"custom-components/sensor.custom_aftership": "custom_aftership" "custom-components/sensor.custom_aftership": "custom_aftership"
} }
try:
from homeassistant.components.http import StaticPathConfig
async def async_register_static_path(
hass: HomeAssistant,
url_path: str,
path: str,
cache_headers: bool = True,
) -> None:
"""Register a static path with the HTTP component."""
await hass.http.async_register_static_paths(
[StaticPathConfig(url_path, path, cache_headers)]
)
except ImportError:
async def async_register_static_path(
hass: HomeAssistant,
url_path: str,
path: str,
cache_headers: bool = True,
) -> None:
"""Register a static path with the HTTP component.
Legacy: Can be removed when min version is 2024.7
https://developers.home-assistant.io/blog/2024/06/18/async_register_static_paths/
"""
hass.http.register_static_path(url_path, path, cache_headers)

View File

@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
from ..enums import RepositoryFile from typing import TYPE_CHECKING
from ..repositories.base import HacsRepository
from .base import ActionValidationBase, ValidationException from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator: async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator.""" """Set up this validator."""
@@ -13,7 +16,10 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
class Validator(ActionValidationBase): class Validator(ActionValidationBase):
"""Validate the repository.""" """Validate the repository."""
async def async_validate(self): more_info = "https://hacs.xyz/docs/publish/include#check-archived"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository.""" """Validate the repository."""
if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]: if self.repository.data.archived:
raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file") raise ValidationException("The repository is archived")

View File

@@ -1,12 +1,13 @@
"""Base class for validation.""" """Base class for validation."""
from __future__ import annotations from __future__ import annotations
from time import monotonic from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from ..exceptions import HacsException from ..exceptions import HacsException
if TYPE_CHECKING: if TYPE_CHECKING:
from ..enums import HacsCategory
from ..repositories.base import HacsRepository from ..repositories.base import HacsRepository
@@ -17,7 +18,9 @@ class ValidationException(HacsException):
class ActionValidationBase: class ActionValidationBase:
"""Base class for action validation.""" """Base class for action validation."""
category: str = "common" categories: tuple[HacsCategory, ...] = ()
allow_fork: bool = True
more_info: str = "https://hacs.xyz/docs/publish/action"
def __init__(self, repository: HacsRepository) -> None: def __init__(self, repository: HacsRepository) -> None:
self.hacs = repository.hacs self.hacs = repository.hacs
@@ -32,20 +35,20 @@ class ActionValidationBase:
async def async_validate(self) -> None: async def async_validate(self) -> None:
"""Validate the repository.""" """Validate the repository."""
async def execute_validation(self, *_, **__) -> None: async def execute_validation(self, *_: Any, **__: Any) -> None:
"""Execute the task defined in subclass.""" """Execute the task defined in subclass."""
self.hacs.log.info("<Validation %s> Starting validation", self.slug)
start_time = monotonic()
self.failed = False self.failed = False
try: try:
await self.async_validate() await self.async_validate()
except ValidationException as exception: except ValidationException as exception:
self.failed = True self.failed = True
self.hacs.log.error("<Validation %s> failed: %s", self.slug, exception) self.hacs.log.error(
"<Validation %s> failed: %s (More info: %s )",
self.slug,
exception,
self.more_info,
)
else: else:
self.hacs.log.debug( self.hacs.log.info("<Validation %s> completed", self.slug)
"<Validation %s> took %.3f seconds to complete", self.slug, monotonic() - start_time
)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.hacs.enums import HacsCategory
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
URL = "https://brands.home-assistant.io/domains.json"
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-brands"
categories = (HacsCategory.INTEGRATION,)
async def async_validate(self) -> None:
"""Validate the repository."""
response = await self.hacs.session.get(URL)
content = await response.json()
if self.repository.data.domain not in content["custom"]:
raise ValidationException(
"The repository has not been added as a custom domain to the brands repo"
)

View File

@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
from ..repositories.base import HacsRepository from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator: async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator.""" """Set up this validator."""
@@ -12,7 +16,10 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
class Validator(ActionValidationBase): class Validator(ActionValidationBase):
"""Validate the repository.""" """Validate the repository."""
async def async_validate(self): more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository.""" """Validate the repository."""
if not self.repository.data.description: if not self.repository.data.description:
raise ValidationException("The repository has no description") raise ValidationException("The repository has no description")

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from voluptuous.error import Invalid
from voluptuous.humanize import humanize_error
from ..enums import HacsCategory, RepositoryFile
from ..repositories.base import HacsManifest, HacsRepository
from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA
from .base import ActionValidationBase, ValidationException
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest"
async def async_validate(self) -> None:
"""Validate the repository."""
if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]:
raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file")
content = await self.repository.async_get_hacs_json(self.repository.ref)
try:
hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(content))
except Invalid as exception:
raise ValidationException(humanize_error(content, exception)) from exception
if self.repository.data.category == HacsCategory.INTEGRATION:
if hacsjson.zip_release and not hacsjson.filename:
raise ValidationException("zip_release is True, but filename is not set")

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ..enums import HacsCategory
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
IGNORED = ["-shield", "img.shields.io", "buymeacoffee.com"]
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
categories = (HacsCategory.PLUGIN, HacsCategory.THEME)
more_info = "https://hacs.xyz/docs/publish/include#check-images"
async def async_validate(self) -> None:
"""Validate the repository."""
info = await self.repository.async_get_info_file_contents(version=self.repository.ref)
for line in info.split("\n"):
if "<img" in line or "![" in line:
if [ignore for ignore in IGNORED if ignore in line]:
continue
return
raise ValidationException("The repository does not have images in the Readme file")

View File

@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
from ..repositories.base import HacsRepository from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator: async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator.""" """Set up this validator."""
@@ -12,7 +16,9 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
class Validator(ActionValidationBase): class Validator(ActionValidationBase):
"""Validate the repository.""" """Validate the repository."""
async def async_validate(self): more_info = "https://hacs.xyz/docs/publish/include#check-info"
async def async_validate(self) -> None:
"""Validate the repository.""" """Validate the repository."""
filenames = [x.filename.lower() for x in self.repository.tree] filenames = [x.filename.lower() for x in self.repository.tree]
if "readme" in filenames: if "readme" in filenames:

View File

@@ -1,9 +1,17 @@
from __future__ import annotations from __future__ import annotations
from ..enums import RepositoryFile from typing import TYPE_CHECKING
from ..repositories.base import HacsRepository
from voluptuous.error import Invalid
from ..enums import HacsCategory, RepositoryFile
from ..utils.validate import INTEGRATION_MANIFEST_JSON_SCHEMA
from .base import ActionValidationBase, ValidationException from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
from ..repositories.integration import HacsIntegrationRepository
async def async_setup_validator(repository: HacsRepository) -> Validator: async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator.""" """Set up this validator."""
@@ -13,11 +21,19 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
class Validator(ActionValidationBase): class Validator(ActionValidationBase):
"""Validate the repository.""" """Validate the repository."""
category = "integration" repository: HacsIntegrationRepository
more_info = "https://hacs.xyz/docs/publish/include#check-manifest"
categories = (HacsCategory.INTEGRATION,)
async def async_validate(self): async def async_validate(self) -> None:
"""Validate the repository.""" """Validate the repository."""
if RepositoryFile.MAINIFEST_JSON not in [x.filename for x in self.repository.tree]: if RepositoryFile.MAINIFEST_JSON not in [x.filename for x in self.repository.tree]:
raise ValidationException( raise ValidationException(
f"The repository has no '{RepositoryFile.MAINIFEST_JSON}' file" f"The repository has no '{RepositoryFile.MAINIFEST_JSON}' file"
) )
content = await self.repository.get_integration_manifest(version=self.repository.ref)
try:
INTEGRATION_MANIFEST_JSON_SCHEMA(content)
except Invalid as exception:
raise ValidationException(exception) from exception

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if not self.repository.data.has_issues:
raise ValidationException("The repository does not have issues enabled")

View File

@@ -1,19 +1,19 @@
"""Hacs validation manager.""" """Hacs validation manager."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from importlib import import_module from importlib import import_module
import os
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant
from custom_components.hacs.repositories.base import HacsRepository
from .base import ActionValidationBase
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..base import HacsBase from ..base import HacsBase
from ..repositories.base import HacsRepository
from .base import ActionValidationBase
class ValidationManager: class ValidationManager:
@@ -23,16 +23,16 @@ class ValidationManager:
"""Initialize the setup manager class.""" """Initialize the setup manager class."""
self.hacs = hacs self.hacs = hacs
self.hass = hass self.hass = hass
self._validatiors: dict[str, ActionValidationBase] = {} self._validators: dict[str, ActionValidationBase] = {}
@property @property
def validatiors(self) -> dict[str, ActionValidationBase]: def validators(self) -> list[ActionValidationBase]:
"""Return all list of all tasks.""" """Return all list of all tasks."""
return list(self._validatiors.values()) return list(self._validators.values())
async def async_load(self, repository: HacsRepository) -> None: async def async_load(self, repository: HacsRepository) -> None:
"""Load all tasks.""" """Load all tasks."""
self._validatiors = {} self._validators = {}
validator_files = Path(__file__).parent validator_files = Path(__file__).parent
validator_modules = ( validator_modules = (
module.stem module.stem
@@ -40,13 +40,12 @@ class ValidationManager:
if module.name not in ("base.py", "__init__.py", "manager.py") if module.name not in ("base.py", "__init__.py", "manager.py")
) )
async def _load_module(module: str): async def _load_module(module: str) -> None:
task_module = import_module(f"{__package__}.{module}") task_module = import_module(f"{__package__}.{module}")
if task := await task_module.async_setup_validator(repository=repository): if task := await task_module.async_setup_validator(repository=repository):
self._validatiors[task.slug] = task self._validators[task.slug] = task
await asyncio.gather(*[_load_module(task) for task in validator_modules]) await asyncio.gather(*[_load_module(task) for task in validator_modules])
self.hacs.log.debug("Loaded %s validators for %s", len(self.validatiors), repository)
async def async_run_repository_checks(self, repository: HacsRepository) -> None: async def async_run_repository_checks(self, repository: HacsRepository) -> None:
"""Run all validators for a repository.""" """Run all validators for a repository."""
@@ -55,21 +54,28 @@ class ValidationManager:
await self.async_load(repository) await self.async_load(repository)
await asyncio.gather( is_pull_from_fork = (
*[ not os.getenv("INPUT_REPOSITORY")
validator.execute_validation() and os.getenv("GITHUB_REPOSITORY") != repository.data.full_name
for validator in self.validatiors or []
if (
validator.category == "common" or validator.category == repository.data.category
)
]
) )
total = len(self.validatiors) validators = [
failed = len([x for x in self.validatiors if x.failed]) validator
for validator in self.validators or []
if (
(not validator.categories or repository.data.category in validator.categories)
and validator.slug not in os.getenv("INPUT_IGNORE", "").split(" ")
and (not is_pull_from_fork or validator.allow_fork)
)
]
await asyncio.gather(*[validator.execute_validation() for validator in validators])
total = len(validators)
failed = len([x for x in validators if x.failed])
if failed != 0: if failed != 0:
repository.logger.error("%s %s/%s checks failed", repository.string, failed, total) repository.logger.error("%s %s/%s checks failed", repository.string, failed, total)
exit(1) exit(1)
else: else:
repository.logger.debug("%s All (%s) checks passed", repository.string, total) repository.logger.info("%s All (%s) checks passed", repository.string, total)

View File

@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
from ..repositories.base import HacsRepository from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator: async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator.""" """Set up this validator."""
@@ -12,7 +16,10 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
class Validator(ActionValidationBase): class Validator(ActionValidationBase):
"""Validate the repository.""" """Validate the repository."""
async def async_validate(self): more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository.""" """Validate the repository."""
if not self.repository.data.topics: if not self.repository.data.topics:
raise ValidationException("The repository has no topics") raise ValidationException("The repository has no valid topics")

3
deploy.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
ansible-playbook -bK -i .private/inventory -u ansible --key-file .private/id_ansible deploy.yaml

18
deploy.yaml Normal file
View File

@@ -0,0 +1,18 @@
---
# Ansible Playbook to deploy Home Assistant Configuration
- name: Home Assistant | Update Configuration
hosts: hass
tasks:
- name: Home Assistant | Update Configuration
ansible.builtin.git:
repo: https://git.asymworks.com/jkrauss/home-assistant.git
dest: /srv/hass/config
notify: Home Assistant | Restart Home Assistant
handlers:
- name: Home Assistant | Restart Home Assistant
community.docker.docker_container:
name: hass
state: started
restart: true

View File

@@ -0,0 +1,23 @@
# Home Assistant Dashboards
# krauss-home:
# mode: yaml
# filename: lovelace/krauss-home.yaml
# title: Krauss Home
# icon: mdi:home
# show_in_sidebar: true
# clock-ulm:
# mode: yaml
# filename: lovelace/clock-nightstand-jp-ulm.yaml
# title: Nightstand Clock
# icon: mdi:moon
# show_in_sidebar: true
# lovelace-generated:
# mode: yaml
# filename: notexist.yaml
# title: Generated
# icon: mdi:tools
# show_in_sidebar: false
# require_admin: true

99
lovelace/krauss-home.yaml Normal file
View File

@@ -0,0 +1,99 @@
# Default Dashboard
title: Krauss Home
views:
# --------------------------------------------------
# Overview Panel
- id: 0
icon: mdi:home
title: Overview
cards:
# Early Morning Scenes (conditional)
- type: conditional
conditions:
- entity: binary_sensor.before_dawn
state: "on"
card:
type: entities
title: Morning Scenes
entities:
- scene.dining_room_dim
# Evening Scenes (conditional)
- type: conditional
conditions:
- entity: binary_sensor.evening
state: "on"
card:
type: entities
title: Evening Scenes
entities:
- scene.dining_room_full
- scene.pergola_low
- scene.pergola_full
# Nighttime Scenes (conditional)
- type: conditional
conditions:
- entity: binary_sensor.night
state: "on"
card:
type: entities
title: Night Scenes
entities:
- scene.dining_room_off
- scene.home_theater_off
- scene.pergola_off
# House Status Glances
- type: glance
entities:
- entity: sensor.house_presence_status
name: Presence
- entity: binary_sensor.all_doors
name: Doors
- entity: binary_sensor.all_windows
name: Windows
- entity: binary_sensor.garage_door_open
name: Garage
hold_action:
action: call-service
service: switch.turn_on
data:
entity_id: switch.garage_door_relay_1
# --------------------------------------------------
# Climate Control Panel
- path: climate
icon: mdi:home-thermometer
title: Climate Control
cards:
- type: thermostat
entity: climate.thermostat
- type: entities
entities:
- entity: input_select.climate_mode
- entity: automation.pause_hvac_when_doors_windows_open
- entity: automation.restore_hvac_when_doors_windows_closed
show_header_toggle: false
title: HVAC Control
- type: entities
entities:
- entity: automation.narrow_hvac_range_during_day
- entity: input_datetime.hvac_start_day
- entity: input_number.temp_setpoint_day_high
- entity: input_number.temp_setpoint_day_low
show_header_toggle: false
title: Daytime
- type: entities
entities:
- entity: automation.widen_hvac_range_at_night
- entity: input_datetime.hvac_start_night
- entity: input_number.temp_setpoint_night_high
- entity: input_number.temp_setpoint_night_low
show_header_toggle: false
title: Nighttime

View File

@@ -0,0 +1,73 @@
# Home Assistant Dashboard Resources (installed via HACS)
# Apex Charts Card
- url: /hacsfiles/apexcharts-card/apexcharts-card.js
type: module
# Auto Entities
- url: /hacsfiles/lovelace-auto-entities/auto-entities.js
type: module
# Bar Card
- url: /hacsfiles/bar-card/bar-card.js
type: module
# Button Card
- url: /hacsfiles/button-card/button-card.js
type: module
# Clock Weather Card
- url: /hacsfiles/clock-weather-card/clock-weather-card.js
type: module
# Frigate Card
- url: /hacsfiles/frigate-hass-card/frigate-hass-card.js
type: module
# Hourly Weather Card
- url: /hacsfiles/lovelace-hourly-weather/hourly-weather.js
type: module
# Layout Card
- url: /hacsfiles/lovelace-layout-card/layout-card.js
type: module
# Light Entity Card
- url: /hacsfiles/light-entity-card/light-entity-card.js
type: module
# Mini Graph Card
- url: /hacsfiles/mini-graph-card/mini-graph-card-bundle.js
type: module
# Mini Media Player
- url: /hacsfiles/mini-media-player/mini-media-player-bundle.js
type: module
# Mushroom Card
- url: /hacsfiles/lovelace-mushroom/mushroom.js
type: module
# My Cards
- url: /hacsfiles/my-cards/my-cards.js
type: module
# Simple Weather Card
- url: /hacsfiles/simple-weather-card/simple-weather-card-bundle.js
type: module
# State Switch
- url: /hacsfiles/lovelace-state-switch/state-switch.js
type: module
# Wall Panel
- url: /hacsfiles/lovelace-wallpanel/wallpanel.js
type: module
# Valetudo Map Card
- url: /hacsfiles/lovelace-valetudo-map-card/valetudo-map-card.js
type: module
# Vertical Stack in Card
- url: /hacsfiles/vertical-stack-in-card/vertical-stack-in-card.js
type: module

0
media/.gitkeep Normal file
View File

View File

@@ -0,0 +1,4 @@
# Alerts Package
This package contains configuration information for various uncategorized
alerts for the home.

130
packages/alerts/alerts.yaml Normal file
View File

@@ -0,0 +1,130 @@
# Alerts Package
# -----------------------------------------------------------------------------
# Alert Automations
automation:
- alias: Notify if Leak Sensors Trigger
mode: queued
trigger:
- platform: state
entity_id: binary_sensor.dishwasher_leak_detected
to: 'on'
- platform: state
entity_id: binary_sensor.washer_leak_detected
to: 'on'
action:
- service: notify.everyone
data:
message: "{{ trigger.to_state.name }} Triggered"
- alias: Notify if Garage Fridge Temperature is High
mode: queued
trigger:
- platform: numeric_state
entity_id: sensor.garage_fridge_refrigerator_temperature
above: 26.0
- platform: numeric_state
entity_id: sensor.garage_fridge_freezer_temperature
above: 26.0
action:
- service: notify.everyone
data:
message: "{{ trigger.to_state.name }} is too high, check the power and doors."
- alias: Notify if Garage Fridge Sensor is Unavailable
mode: queued
trigger:
- platform: state
entity_id:
- sensor.garage_fridge_refrigerator_temperature
- sensor.garage_fridge_freezer_temperature
to:
- 'none'
- 'unavailable'
- 'unknown'
for: '00:05:00'
action:
- service: notify.everyone
data:
message: "{{ trigger.to_state.name }} is unavilable, check the power."
- alias: Notify if Garage Door Left Open
mode: queued
trigger:
- platform: numeric_state
entity_id: sun.sun
attribute: elevation
below: -4.0
- platform: state
entity_id: binary_sensor.house_presence
to: 'off'
condition:
- "{{ is_state('binary_sensor.garage_door', 'on') }}"
action:
- service: notify.everyone
data:
message: Garage Door is Open
- alias: Notify on Low Battery Level
mode: queued
trigger:
- platform: numeric_state
entity_id:
# Door Locks
- sensor.front_door_lock_battery
# Door Sensors
- sensor.front_door_battery
- sensor.laundry_door_battery
- sensor.living_room_door_battery
# Window Sensors
- sensor.bathroom_window_battery
- sensor.bedroom_back_window_battery
- sensor.bedroom_side_window_battery
- sensor.dining_room_front_window_battery
- sensor.dining_room_side_window_battery
- sensor.guest_room_front_window_battery
- sensor.guest_room_side_window_battery
- sensor.kitchen_left_window_battery
- sensor.kitchen_right_window_battery
- sensor.laundry_window_battery
- sensor.living_room_left_window_battery
- sensor.living_room_right_window_battery
- sensor.office_window_battery
# Leak Sensors
- sensor.dishwasher_leak_sensor_battery_level
- sensor.washer_leak_sensor_battery_level
# PIR Sensors
- sensor.entry_sensor_battery_level
- sensor.garage_sensor_battery_level
- sensor.guest_room_sensor_battery_level
- sensor.hallway_sensor_battery_level
- sensor.office_sensor_battery_level
# Temp Sensors
- sensor.attic_sensor_battery
- sensor.server_rack_sensor_battery
- sensor.smc_sensor_battery
- sensor.wine_fridge_sensor_battery
# Nightstand Buttons
- sensor.jen_nightstand_button_battery
- sensor.jp_nightstand_button_battery
# bhyve
- sensor.patio_containers_battery_level
- sensor.smart_hose_timer_battery_level
# Other
- sensor.thermostat_battery_level
below: 30
action:
- alias: Send Low Battery Notification to ntfy
action: shell_command.ntfy
data:
topic: home_assistant
tags:
- battery
title: Low Battery Alert
message: "Low battery on {{ trigger.to_state.name }}"
- alias: Setup a Persistent Notification
action: persistent_notification.create
data:
title: "Low Battery on {{ trigger.to_state.name }}"
message: Battery level on {{ trigger.to_state.name }} is now {{ trigger.to_state.state }}
notification_id: "LOW_BATTERY_{{ trigger.entity_id | replace('.', '_') }}"

View File

@@ -0,0 +1,4 @@
# Climate Package
This package contains configuration information for the Z-wave smart thermostat
system. The thermostat should be set up via the UI as `climate.thermostat`.

View File

@@ -0,0 +1,252 @@
# Climate Package
# -----------------------------------------------------------------------------
# Climate State Helpers
input_select:
climate_mode:
name: Climate Control Mode
options:
- 'Off'
- Auto
- Manual
- Paused
input_number:
temp_setpoint_day_high:
name: Temperature Setpoint Day (High)
initial: 74
min: 60
max: 90
step: 1
temp_setpoint_day_low:
name: Temperature Setpoint Day (Low)
initial: 70
min: 60
max: 90
step: 1
temp_setpoint_night_high:
name: Temperature Setpoint Night (High)
initial: 76
min: 60
max: 90
step: 1
temp_setpoint_night_low:
name: Temperature Setpoint Night (Low)
initial: 70
min: 60
max: 90
step: 1
temp_setpoint_away_high:
name: Temperature Setpoint Away (High)
initial: 80
min: 60
max: 90
step: 1
temp_setpoint_away_low:
name: Temperature Setpoint Away (Low)
initial: 66
min: 60
max: 90
step: 1
input_datetime:
hvac_start_day:
name: HVAC Daytime Start
initial: '10:00'
has_date: false
has_time: true
hvac_start_night:
name: HVAC Nighttime Start
initial: '20:00'
has_date: false
has_time: true
input_text:
hvac_last_mode:
template:
binary_sensor:
- name: HVAC Fan Running
state: >
{{ 'Running' in state_attr('climate.thermostat', 'fan_state') }}
# -----------------------------------------------------------------------------
# Climate Scripts
script:
hvac_pause:
alias: Pause HVAC
mode: restart
sequence:
# Save the current thermostat mode because it may not be captured properly
# by `scene.create` (see https://github.com/home-assistant/core/issues/69925)
- action: input_text.set_value
target:
entity_id: input_text.hvac_last_mode
data:
value: "{{ states('climate.thermostat') }}"
# Snapshot the current mode and thermostat status
- action: scene.create
data:
scene_id: hvac_restore_state
snapshot_entities:
- climate.thermostat
- input_select.climate_mode
- input_text.hvac_last_mode
# Turn off the thermostat and set the mode to Paused
- action: climate.set_hvac_mode
target:
entity_id: climate.thermostat
data:
hvac_mode: "off"
- service: input_select.select_option
target:
entity_id: input_select.climate_mode
data:
option: Paused
hvac_restore:
alias: Resume HVAC
mode: restart
sequence:
# Restore the snapshot
- action: scene.turn_on
target:
entity_id: scene.hvac_restore_state
# Restore the thermostat mode
- action: climate.set_hvac_mode
target:
entity_id: climate.thermostat
data:
hvac_mode: "{{ states('input_text.hvac_last_mode') }}"
# -----------------------------------------------------------------------------
# Climate Automations
automation:
# Automations for Open Windows/Doors
- alias: Pause HVAC when Doors/Windows Open
mode: queued
trigger:
- platform: state
entity_id: binary_sensor.all_doors
to: 'on'
for:
minutes: 5
- platform: state
entity_id: binary_sensor.all_windows
to: 'on'
for:
minutes: 5
condition:
- "{{ not is_state('input_select.climate_mode', 'Manual') }}"
- "{{ not is_state('input_select.climate_mode', 'Paused') }}"
- "{{ is_state('input_boolean.guest_mode', 'off') }}"
action:
# - service: scene.create
# data:
# scene_id: hvac_restore_state
# snapshot_entities:
# # See https://github.com/home-assistant/core/issues/69925
# # - climate.thermostat
# - input_select.climate_mode
# - service: climate.set_hvac_mode
# target:
# entity_id: climate.thermostat
# data:
# hvac_mode: "off"
# - service: input_select.select_option
# target:
# entity_id: input_select.climate_mode
# data:
# option: Paused
- action: script.hvac_pause
- action: notify.status
data:
message: "HVAC Paused ({{ states('sensor.open_windows_doors') }} open)"
- alias: Restore HVAC when Doors/Windows Closed
mode: queued
trigger:
- platform: state
entity_id: binary_sensor.all_doors
from: 'on'
to: 'off'
for:
minutes: 5
- platform: state
entity_id: binary_sensor.all_windows
from: 'on'
to: 'off'
for:
minutes: 5
condition:
- "{{ is_state('input_select.climate_mode', 'Paused') }}"
- "{{ is_state('binary_sensor.all_doors', 'off') }}"
- "{{ is_state('binary_sensor.all_windows', 'off') }}"
action:
# - service: scene.turn_on
# target:
# entity_id: scene.hvac_restore_state
# # See https://github.com/home-assistant/core/issues/69925
# # Assume thermostat is always heat_cool when not off
# - service: climate.set_hvac_mode
# target:
# entity_id: climate.thermostat
# data:
# hvac_mode: "{{ 'off' if is_state('input_select.climate_mode', 'Off') else 'heat_cool' }}"
- action: script.hvac_restore
- action: notify.status
data:
message: HVAC Restored (Windows and Doors closed)
- alias: Widen HVAC Range at Night
mode: queued
trigger:
- platform: time
at: input_datetime.hvac_start_night
condition:
- "{{ not is_state('input_select.climate_mode', 'Manual') }}"
- "{{ not is_state('input_select.house_presence_state', 'Extended Away') }}"
action:
- service: climate.set_temperature
target:
entity_id: climate.thermostat
data:
target_temp_high: "{{ states('input_number.temp_setpoint_night_high')|float }}"
target_temp_low: "{{ states('input_number.temp_setpoint_night_low')|float }}"
- alias: Narrow HVAC Range during Day
mode: queued
trigger:
- platform: time
at: input_datetime.hvac_start_day
condition:
- "{{ not is_state('input_select.climate_mode', 'Manual') }}"
- "{{ not is_state('input_select.house_presence_state', 'Extended Away') }}"
action:
- service: climate.set_temperature
target:
entity_id: climate.thermostat
data:
target_temp_high: "{{ states('input_number.temp_setpoint_day_high')|float }}"
target_temp_low: "{{ states('input_number.temp_setpoint_day_low')|float }}"
- alias: Widen HVAC Range during Vacation
mode: queued
trigger:
- platform: state
entity_id: input_select.house_presence_state
to: 'Extended Away'
condition:
- "{{ not is_state('input_select.climate_mode', 'Manual') }}"
action:
- service: climate.set_temperature
target:
entity_id: climate.thermostat
data:
target_temp_high: "{{ states('input_number.temp_setpoint_away_high')|float }}"
target_temp_low: "{{ states('input_number.temp_setpoint_away_low')|float }}"

View File

@@ -1,3 +0,0 @@
# Commute Package
This package contains configuration information for commute time tracking. Its primary job is to create auxiliary sensors for the Waze Drive Time sensors that have the correct device class for long-term statistic calculations.

View File

@@ -1,36 +0,0 @@
# Commute Package
# -----------------------------------------------------------------------------
# Commute Time Helper Customization
homeassistant:
customize_glob:
sensor.*_commute_*_meas:
device_class: duration
icon: mdi:car
# -----------------------------------------------------------------------------
# Commute Time Helpers
template:
- sensor:
- name: "JP Commute Home Meas"
unit_of_measurement: min
state: "{{ states('sensor.jp_commute_home') }}"
state_class: measurement
- sensor:
- name: "JP Commute Work Meas"
unit_of_measurement: min
state: "{{ states('sensor.jp_commute_work') }}"
state_class: measurement
- sensor:
- name: "Jen Commute Home Meas"
unit_of_measurement: min
state: "{{ states('sensor.jen_commute_home') }}"
state_class: measurement
- sensor:
- name: "Jen Commute Work Meas"
unit_of_measurement: min
state: "{{ states('sensor.jen_commute_work') }}"
state_class: measurement

View File

@@ -0,0 +1,3 @@
# Date/Time Package
This package contains helper sensors for dates and times.

View File

@@ -0,0 +1,46 @@
# Date/Time Package
# -----------------------------------------------------------------------------
# Time of Day Sensors and Helpers
sensor:
- platform: time_date
display_options:
- 'time'
- 'date'
- 'date_time'
template:
- sensor:
- name: hour
state: "{{ now().strftime('%I') }}"
- name: minute
state: "{{ now().strftime('%M') }}"
binary_sensor:
- platform: tod
name: Before Dawn
after: "00:00"
before: sunrise
- platform: tod
name: Morning
after: "04:00"
before: "12:00"
- platform: tod
name: Afternoon
after: "12:00"
before: "17:00"
- platform: tod
name: Evening
after: "17:00"
before: "21:00"
- platform: tod
name: Night
after: "21:00"
before: "04:00"
# - platform: workday
# country: US

View File

@@ -0,0 +1,5 @@
# Energy Package
This package contains configuration information for energy monitoring helpers.
The Enlighten Envoy integration should be set up and configured for this
package to work.

614
packages/energy/energy.yaml Normal file
View File

@@ -0,0 +1,614 @@
# Energy Package
# -----------------------------------------------------------------------------
# Energy Integration Sensors
sensor:
- name: Envoy Total Energy
platform: integration
source: sensor.envoy_202221032900_current_power_production
unit_prefix: k
unit_time: h
- name: Emporia Net Energy
platform: integration
source: sensor.emporia_d937d0_1min
unit_prefix: k
unit_time: h
- name: Total Energy From Grid
platform: integration
source: sensor.emporia_vue_net_power_from_grid
unit_prefix: k
unit_time: h
- name: Clean Energy From Grid
platform: integration
source: sensor.emporia_vue_clean_power_from_grid
unit_prefix: k
unit_time: h
- name: Total Energy To Grid
platform: integration
source: sensor.emporia_vue_net_power_to_grid
unit_prefix: k
unit_time: h
- name: Home Total Energy
platform: integration
source: sensor.home_power_consumption
unit_prefix: k
unit_time: h
- name: Flex Alert RSS
platform: rest
resource: 'http://content.caiso.com/awe/noticeflexRSS.xml'
value_template: >
{{
(
value_json['rss']['channel']['item']
| selectattr('title', 'search', 'NOTICE')
| list
)[0]['title']
}}
json_attributes_path: '$.rss.channel'
json_attributes:
- item
# -----------------------------------------------------------------------------
# Average Panel Power Sensor
- name: Envoy Inverter Average Power
platform: min_max
type: mean
entity_ids:
- sensor.envoy_202221032900_inverter_202147113780
- sensor.envoy_202221032900_inverter_202147116830
- sensor.envoy_202221032900_inverter_202147117162
- sensor.envoy_202221032900_inverter_202147117631
- sensor.envoy_202221032900_inverter_202147122858
- sensor.envoy_202221032900_inverter_202147123517
- sensor.envoy_202221032900_inverter_202147125027
- sensor.envoy_202221032900_inverter_202147125590
- sensor.envoy_202221032900_inverter_202147125734
- sensor.envoy_202221032900_inverter_202147125902
- sensor.envoy_202221032900_inverter_202147126079
- sensor.envoy_202221032900_inverter_202147126357
- sensor.envoy_202221032900_inverter_202147126997
- sensor.envoy_202221032900_inverter_202147128369
- sensor.envoy_202221032900_inverter_202147129445
- sensor.envoy_202221032900_inverter_202147130152
- sensor.envoy_202221032900_inverter_202147130290
- name: Envoy 202221032900 Inverter 202147113780 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147113780_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147116830 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147116830_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147117162 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147117162_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147117631 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147117631_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147122858 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147122858_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147123517 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147123517_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147125027 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147125027_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147125590 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147125590_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147125734 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147125734_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147125902 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147125902_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147126079 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147126079_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147126357 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147126357_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147126997 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147126997_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147128369 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147128369_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147129445 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147129445_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147130152 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147130152_power_share
state_characteristic: mean
max_age:
hours: 24
- name: Envoy 202221032900 Inverter 202147130290 Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_202147130290_power_share
state_characteristic: mean
max_age:
hours: 24
# -----------------------------------------------------------------------------
# Panel/Inverter Power Share Sensors
template:
sensor:
- name: Envoy 202221032900 Inverter 202147113780 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147113780')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147116830 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147116830')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147117162 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147117162')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147117631 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147117631')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147122858 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147122858')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147123517 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147123517')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147125027 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147125027')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147125590 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147125590')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147125734 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147125734')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147125902 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147125902')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147126079 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147126079')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147126357 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147126357')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147126997 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147126997')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147128369 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147128369')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147129445 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147129445')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147130152 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147130152')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
- name: Envoy 202221032900 Inverter 202147130290 Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_202147130290')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
# -----------------------------------------------------------------------------
# Emporia Vue To/From Grid Sensors
- name: Emporia Vue Net Power From Grid
device_class: power
unit_of_measurement: W
state: >
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
iif(net_power > 0, net_power, 0)
}}
- name: Emporia Vue Clean Power From Grid
device_class: power
unit_of_measurement: W
state: >
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
iif(net_power > 0, net_power, 0) * states('sensor.grid_fossil_fuel_percentage', 0)|float / 100.0
}}
- name: Emporia Vue Net Power To Grid
device_class: power
unit_of_measurement: W
state: >
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
iif(net_power < 0, -net_power, 0)
}}
# -----------------------------------------------------------------------------
# Home Total Power Consumption Sensor
- name: Home Power Consumption
device_class: power
unit_of_measurement: W
state: >
{{
states('sensor.envoy_202221032900_current_power_production')|float
+ states('sensor.emporia_d937d0_1min')|float
}}
- name: Home Clean Power Consumption
device_class: power
unit_of_measurement: W
state: >
{{
states('sensor.emporia_vue_clean_power_from_grid')|float
+ states('sensor.envoy_202221032900_current_power_production')|float
}}
# -----------------------------------------------------------------------------
# TOU Schedules
- name: TOU Season
state: "{{ ['Winter', 'Summer'][now().month >= 6 and now().month < 10] }}"
icon: mdi:weather-cloudy-clock
- name: TOU Period
icon: mdi:calendar-clock
state: >
{% set is_weekend = now().strftime("%w") == 0 or now().strftime("%w") == 6 %}
{% if states('sensor.tou_season') == "Summer" %}
{% if now().hour >= 16 and now().hour < 21 %}
{% if is_weekend %}
{{ "Mid-Peak" }}
{% else %}
{{ "On-Peak" }}
{% endif %}
{% else %}
{{ "Off-Peak" }}
{% endif %}
{% else %}
{% if now().hour >= 16 and now().hour < 21 %}
{{ "Mid-Peak" }}
{% elif now().hour >= 21 or now().hour < 8 %}
{{ "Off-Peak" }}
{% else %}
{{ "Super Off-Peak" }}
{% endif %}
{% endif %}
# -----------------------------------------------------------------------------
# Utility Meters
utility_meter:
daily_energy:
source: sensor.total_energy_from_grid
name: Daily Energy Consumption
cycle: daily
tariffs:
- Super Off-Peak
- Off-Peak
- Mid-Peak
- On-Peak
daily_energy_total:
source: sensor.total_energy_from_grid
name: Daily Energy Consumption Total
cycle: daily
daily_low_carbon:
source: sensor.clean_energy_from_grid
name: Daily Low Carbon Consumption
cycle: daily
daily_generated:
source: sensor.total_energy_to_grid
name: Daily Energy Generation
cycle: daily
daily_consumption:
source: sensor.home_total_energy
name: Daily Home Consumption
cycle: daily
daily_solar:
source: sensor.envoy_total_energy
name: Daily Solar Production
cycle: daily
monthly_consumed:
source: sensor.total_energy_from_grid
name: Monthly Energy Consumption
cron: 0 0 10 * *
tariffs:
- Super Off-Peak
- Off-Peak
- Mid-Peak
- On-Peak
monthly_generated:
source: sensor.total_energy_to_grid
name: Monthly Energy Generation
cron: 0 0 10 * *
yearly_consumed:
source: sensor.total_energy_from_grid
name: Yearly Energy Consumption
cron: 0 0 10 7 *
tariffs:
- Super Off-Peak
- Off-Peak
- Mid-Peak
- On-Peak
yearly_generated:
source: sensor.total_energy_to_grid
name: Yearly Energy Generation
cron: 0 0 10 7 *
automation:
# -----------------------------------------------------------------------------
# Utility Meter Automations
- alias: Set Utility Meter TOU Tariff
trigger:
- platform: state
entity_id: sensor.tou_period
- platform: homeassistant
event: start
action:
- service: select.select_option
target:
entity_id: select.daily_energy
data:
option: "{{ states('sensor.tou_period') }}"
- service: select.select_option
target:
entity_id: select.monthly_consumed
data:
option: "{{ states('sensor.tou_period') }}"
- service: select.select_option
target:
entity_id: select.yearly_consumed
data:
option: "{{ states('sensor.tou_period') }}"

View File

@@ -0,0 +1,218 @@
# Energy Package
# -----------------------------------------------------------------------------
# Energy Integration Sensors
sensor:
- name: Envoy Total Energy
platform: integration
source: sensor.envoy_202221032900_current_power_production
unit_prefix: k
unit_time: h
- name: Emporia Net Energy
platform: integration
source: sensor.emporia_d937d0_1min
unit_prefix: k
unit_time: h
- name: Total Energy From Grid
platform: integration
source: sensor.emporia_vue_net_power_from_grid
unit_prefix: k
unit_time: h
- name: Total Energy To Grid
platform: integration
source: sensor.emporia_vue_net_power_to_grid
unit_prefix: k
unit_time: h
- name: Home Total Energy
platform: integration
source: sensor.home_power_consumption
unit_prefix: k
unit_time: h
- name: Flex Alert RSS
platform: rest
resource: 'http://content.caiso.com/awe/noticeflexRSS.xml'
value_template: >
{{
(
value_json['rss']['channel']['item']
| selectattr('title', 'search', 'NOTICE')
| list
)[0]['title']
}}
json_attributes_path: '$.rss.channel'
json_attributes:
- item
# -----------------------------------------------------------------------------
# Average Panel Power Sensor
- name: Envoy Inverter Average Power
platform: min_max
type: mean
entity_ids:[% for sn in serial_numbers %]
- sensor.envoy_202221032900_inverter_[[sn]]
[%- endfor %]
[%- for sn in serial_numbers %]
- name: Envoy 202221032900 Inverter [[sn]] Average Power Share
platform: statistics
entity_id: sensor.envoy_202221032900_inverter_[[sn]]_power_share
state_characteristic: mean
max_age:
hours: 24
[%- endfor %]
# -----------------------------------------------------------------------------
# Panel/Inverter Power Share Sensors
template:
sensor:[% for sn in serial_numbers %]
- name: Envoy 202221032900 Inverter [[sn]] Power Share
unit_of_measurement: "%"
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
state: >
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
none
{% else %}
{{ (100 *
states('sensor.envoy_202221032900_inverter_[[sn]]')|default(0)|float
/ states('sensor.envoy_inverter_average_power')|float
) | round(1)
}}
{% endif %}
[% endfor %]
# -----------------------------------------------------------------------------
# Emporia Vue To/From Grid Sensors
- name: Emporia Vue Net Power From Grid
device_class: power
unit_of_measurement: W
state: >
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
iif(net_power > 0, net_power, 0)
}}
- name: Emporia Vue Net Power To Grid
device_class: power
unit_of_measurement: W
state: >
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
iif(net_power < 0, -net_power, 0)
}}
# -----------------------------------------------------------------------------
# Home Total Power Consumption Sensor
- name: Home Power Consumption
device_class: power
unit_of_measurement: W
state: >
{{
states('sensor.envoy_202221032900_current_power_production')|float
+ states('sensor.emporia_d937d0_1min')|float
}}
# -----------------------------------------------------------------------------
# TOU Schedules
- name: TOU Season
state: "{{ ['Winter', 'Summer'][now().month >= 6 and now().month < 10] }}"
icon: mdi:weather-cloudy-clock
- name: TOU Period
icon: mdi:calendar-clock
state: >
{% set is_weekend = now().strftime("%w") == "0" or now().strftime("%w") == "6" %}
{% if states('sensor.tou_season') == "Summer" %}
{% if now().hour >= 16 and now().hour < 21 %}
{% if is_weekend %}
{{ "Mid-Peak" }}
{% else %}
{{ "On-Peak" }}
{% endif %}
{% else %}
{{ "Off-Peak" }}
{% endif %}
{% else %}
{% if now().hour >= 16 and now().hour < 21 %}
{{ "Mid-Peak" }}
{% elif now().hour >= 21 or now().hour < 8 %}
{{ "Off-Peak" }}
{% else %}
{{ "Super Off-Peak" }}
{% endif %}
{% endif %}
# -----------------------------------------------------------------------------
# Utility Meters
utility_meter:
daily_energy:
source: sensor.total_energy_from_grid
name: Daily Energy Consumption
cycle: daily
tariffs:
- Super Off-Peak
- Off-Peak
- Mid-Peak
- On-Peak
monthly_consumed:
source: sensor.total_energy_from_grid
name: Monthly Energy Consumption
cron: 0 0 8 * *
tariffs:
- Super Off-Peak
- Off-Peak
- Mid-Peak
- On-Peak
monthly_generated:
source: sensor.total_energy_to_grid
name: Monthly Energy Generation
cron: 0 0 8 * *
yearly_consumed:
source: sensor.total_energy_from_grid
name: Yearly Energy Consumption
cron: 0 0 15 7 *
tariffs:
- Super Off-Peak
- Off-Peak
- Mid-Peak
- On-Peak
yearly_generated:
source: sensor.total_energy_to_grid
name: Yearly Energy Generation
cron: 0 0 15 7 *
automation:
# -----------------------------------------------------------------------------
# Utility Meter Automations
- alias: Set Utility Meter TOU Tariff
trigger:
- platform: state
entity_id: sensor.tou_period
- platform: homeassistant
event: start
action:
- service: select.select_option
target:
entity_id: select.daily_energy
data:
option: "{{ states('sensor.tou_period') }}"
- service: select.select_option
target:
entity_id: select.monthly_consumed
data:
option: "{{ states('sensor.tou_period') }}"
- service: select.select_option
target:
entity_id: select.yearly_consumed
data:
option: "{{ states('sensor.tou_period') }}"

View File

@@ -0,0 +1,46 @@
#!/usr/bin/python3
# ---
# Script to generate the energy.yaml package file.
import jinja2 as j2
# Serial Numbers for Panel Inverters
INVERTER_SNS = [
202147113780,
202147116830,
202147117162,
202147117631,
202147122858,
202147123517,
202147125027,
202147125590,
202147125734,
202147125902,
202147126079,
202147126357,
202147126997,
202147128369,
202147129445,
202147130152,
202147130290,
]
def main():
"""Generate the YAML File."""
env = j2.Environment(
block_start_string='[%',
block_end_string='%]',
variable_start_string='[[',
variable_end_string=']]',
comment_start_string='[#',
comment_end_string='#]',
loader=j2.FileSystemLoader('.'),
)
with open('energy.yaml', 'w') as f:
tmpl = env.get_template('energy.yaml.j2')
f.write(tmpl.render(serial_numbers=INVERTER_SNS))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,6 @@
# Garden Package
This package contains configuration information for garden sensors. The Plaid
Spruce Sensors should be joined via ZHA or Z2M and available as
`sensor.pepper_soil_sensor_mqtt_humidity` and
`sensor.tomato_soil_sensor_mqtt_humidity`.

View File

@@ -0,0 +1,26 @@
# Garden Package
# -----------------------------------------------------------------------------
# Garden Automations
automation:
# Notify on Low Soil Moisture
- alias: Notify on Low Soil Moisture
mode: queued
trigger:
- platform: numeric_state
entity_id: sensor.pepper_soil_sensor_mqtt_humidity
below: 15
- platform: numeric_state
entity_id: sensor.tomato_soil_sensor_mqtt_humidity
below: 15
action:
- service: persistent_notification.create
data:
title: Low Soil Mosture
message: >
Low Soil Moisture on {{ trigger.from_state.attributes.friendly_name }}
- service: notify.general
data:
message: >
Low Soil Moisture on {{ trigger.from_state.attributes.friendly_name }}

View File

@@ -0,0 +1,3 @@
# Holiday Package
This package contains automations related to the holidays, such as Christmas Light schedules. It expects the entities `swtich.christmas_lights_front` and `switch.christmas_lights_pergola` to exist.

View File

@@ -0,0 +1,179 @@
# Holiday Package
# -----------------------------------------------------------------------------
# Holiday State Helpers
input_boolean:
holiday_override_lights_night:
name: Always Turn On Holiday Lights at Night
holiday_override_lights_morning:
name: Always Turn On Holiday Lights in the Morning
# -----------------------------------------------------------------------------
# Holiday Sensors
template:
- binary_sensor:
# Christmas Season is the day after Thanksgiving through New Years Eve
- name: Is Christmas Season
state: >
{% set xmas_start = as_datetime('%.4d-11-%.2d'|format(now().year, 22 + (3 - as_datetime(now().year|string + '-11-01').weekday()) % 7 + 1))|as_local %}
{% set xmas_end = as_datetime('%.4d-01-01'|format(now().year + 1))|as_local %}
{% set today = today_at('00:00') %}
{{ xmas_start <= today and today < xmas_end }}
# -----------------------------------------------------------------------------
# Holiday Groups
group:
holiday_outdoor_lights:
name: Holiday Outdoor Lights
entities:
- switch.christmas_lights_roof
- switch.christmas_lights_shrubs
- switch.christmas_lights_front
holiday_arch_lights:
name: Holiday Archway Lights
entities:
- light.arches_east
- light.arches_west
holiday_indoor_lights:
name: Holiday Indoor Lights
entities:
- switch.christmas_tree
# -----------------------------------------------------------------------------
# Holiday Automations
automation:
- alias: Holiday - Turn On Christmas Lights at Sunset
trigger:
- platform: sun
event: sunset
condition:
- alias: Christmas Season or Light Override Enabled
condition: or
conditions:
- "{{ is_state('binary_sensor.is_christmas_season', 'on') }}"
- "{{ is_state('input_boolean.holiday_override_lights_night', 'on') }}"
action:
- service: switch.turn_on
target:
entity_id:
- switch.christmas_lights_roof
- switch.christmas_lights_shrubs
- switch.christmas_lights_front
- alias: Holiday - Turn On Archway Lights at Sunset
trigger:
- platform: sun
event: sunset
condition:
- alias: Christmas Season or Light Override Enabled
condition: or
conditions:
- "{{ is_state('binary_sensor.is_christmas_season', 'on') }}"
- "{{ is_state('input_boolean.holiday_override_lights_night', 'on') }}"
action:
- service: light.turn_on
data:
brightness_pct: 100
target:
entity_id:
- light.arches_east
- light.arches_west
- alias: Holiday - Turn On Christmas Lights in Morning
trigger:
- platform: time
at: '04:30:00'
condition:
- alias: Christmas Season or Light Override Enabled
condition: or
conditions:
- "{{ is_state('binary_sensor.is_christmas_season', 'on') }}"
- "{{ is_state('input_boolean.holiday_override_lights_morning', 'on') }}"
action:
- service: switch.turn_on
target:
entity_id:
- switch.christmas_lights_roof
- switch.christmas_lights_shrubs
- switch.christmas_lights_front
- alias: Holiday - Turn On Archway Lights in Morning
trigger:
- platform: time
at: '04:30:00'
condition:
- alias: Christmas Season or Light Override Enabled
condition: or
conditions:
- "{{ is_state('binary_sensor.is_christmas_season', 'on') }}"
- "{{ is_state('input_boolean.holiday_override_lights_morning', 'on') }}"
action:
- service: light.turn_on
data:
brightness_pct: 100
target:
entity_id:
- light.arches_east
- light.arches_west
- alias: Holiday - Turn Off Christmas Lights at Midnight
trigger:
- platform: time
at: '00:00:00'
action:
- service: switch.turn_off
target:
entity_id:
- switch.christmas_lights_roof
- switch.christmas_lights_shrubs
- switch.christmas_lights_front
- alias: Holiday - Turn Off Archway Lights at Midnight
trigger:
- platform: time
at: '00:00:00'
action:
- service: light.turn_off
target:
entity_id:
- light.arches_east
- light.arches_west
- alias: Holiday - Turn Off Christmas Lights at Sunrise
trigger:
- platform: sun
event: sunrise
offset: '-00:30:00'
action:
- service: switch.turn_off
target:
entity_id:
- switch.christmas_lights_roof
- switch.christmas_lights_shrubs
- switch.christmas_lights_front
- alias: Holiday - Turn Off Archway Lights at Sunrise
trigger:
- platform: sun
event: sunrise
offset: '-00:30:00'
action:
- service: light.turn_off
target:
entity_id:
- light.arches_east
- light.arches_west
- alias: Holiday - Turn Off Overrides when Christmas Season Starts
trigger:
- platform: state
entity_id: binary_sensor.is_christmas_season
to: 'on'
action:
- service: input_boolean.turn_off
target:
entity_id:
- input_boolean.holiday_override_lights_night
- input_boolean.holiday_override_lights_morning

View File

@@ -1,4 +1,5 @@
# Irrigation Package # Irrigation Package
This package contains configuration information for the Rachio irrigation system. The Rachio This package contains configuration information for the Rachio irrigation system. The Rachio
integration should be set up via the Configuration UI. integration should be set up via the Configuration UI. The Orbit B-hyve integration should
be installed via HACS (https://github.com/sebr/bhyve-home-assistant).

View File

@@ -1,5 +1,8 @@
# Irrigation Package # Irrigation Package
# -----------------------------------------------------------------------------
# Orbit B-hyve Faucet Valve (moved to UI config flow)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Irrigation Sprinkler Groups # Irrigation Sprinkler Groups
switch: switch:

View File

@@ -0,0 +1,3 @@
# Laundry Package
This package contains automations for laundry monitoring and notification.

View File

@@ -0,0 +1,181 @@
# Laundry Package
# -----------------------------------------------------------------------------
# Washer Final Pump-Out State Machine
# OBSOLETE with new Miele W/D!!
# input_select:
# washer_complete_detect_state:
# name: Washer Complete Detect State
# options:
# - Idle
# - Stage 1 # Iw > 4A, <70s
# - Stage 2 # Iw ~ [2A, 4A], 60s
# - Stage 3 # dIw ~ [-1.0A, -0.4A], 45s
# - Triggered # Iw < 0.2A, 60s
# input_number:
# washer_complete_threshold_mid:
# min: 0.0
# max: 4.0
# step: 0.1
# -----------------------------------------------------------------------------
# Laundry Automations
automation:
- alias: Notify when Washer Done
mode: single
trigger:
- platform: state
entity_id: sensor.washing_machine_program_phase
to: finished
- platform: state
entity_id: sensor.washing_machine_program_phase
to: anti_crease
action:
- service: notify.everyone
data:
message: Washer is Done
- alias: Notify when Dryer Done
mode: single
trigger:
- platform: state
entity_id: sensor.tumble_dryer_program_phase
to: finished
action:
- service: notify.everyone
data:
message: Dryer is Done
# - alias: Notify when Dryer Done
# mode: single
# trigger:
# - platform: numeric_state
# entity_id: sensor.dryer_power_electric_consumption_a
# below: 1.0
# for:
# minutes: 1
# action:
# - service: notify.everyone
# data:
# message: Dryer is done
# - alias: Washer Complete Detect - Stage 1
# mode: single
# trigger:
# - platform: numeric_state
# entity_id: sensor.washer_power_electric_consumption_a
# above: 4
# condition:
# - "{{ is_state('input_select.washer_complete_detect_state', 'Idle') }}"
# action:
# - service: input_select.select_option
# target:
# entity_id: input_select.washer_complete_detect_state
# data:
# option: Stage 1
# - alias: Washer Complete Detect - Stage 2
# mode: single
# trigger:
# - platform: numeric_state
# entity_id: sensor.washer_power_electric_consumption_a
# above: 2
# below: 4
# for:
# seconds: 60
# condition:
# - "{{ is_state('input_select.washer_complete_detect_state', 'Stage 1') }}"
# action:
# - service: input_select.select_option
# target:
# entity_id: input_select.washer_complete_detect_state
# data:
# option: Stage 2
# - service: input_number.set_value
# target:
# entity_id: input_number.washer_complete_threshold_mid
# data:
# value: "{{ states('sensor.washer_power_electric_consumption_a')|float }}"
# - alias: Washer Complete Detect - Stage 3
# mode: single
# trigger:
# - platform: numeric_state
# entity_id: sensor.washer_power_electric_consumption_a
# value_template: "{{
# states('sensor.washer_power_electric_consumption_a')|float
# - states('input_number.washer_complete_threshold_mid')|float
# }}"
# above: -1.0
# below: -0.4
# for:
# seconds: 45
# condition:
# - "{{ is_state('input_select.washer_complete_detect_state', 'Stage 2') }}"
# action:
# - service: input_select.select_option
# target:
# entity_id: input_select.washer_complete_detect_state
# data:
# option: Stage 3
# - alias: Washer Complete Detect - Trigger
# mode: single
# trigger:
# - platform: numeric_state
# entity_id: sensor.washer_power_electric_consumption_a
# below: 0.2
# for:
# seconds: 60
# condition:
# - "{{ is_state('input_select.washer_complete_detect_state', 'Stage 3') }}"
# action:
# - service: input_select.select_option
# target:
# entity_id: input_select.washer_complete_detect_state
# data:
# option: Triggered
# - alias: Washer Complete Detect - Timeout
# mode: single
# trigger:
# - platform: state
# entity_id: input_select.washer_complete_detect_state
# to: Stage 1
# for:
# seconds: 120
# - platform: state
# entity_id: input_select.washer_complete_detect_state
# to: Stage 2
# for:
# seconds: 240
# - platform: state
# entity_id: input_select.washer_complete_detect_state
# to: Stage 3
# for:
# seconds: 180
# - platform: state
# entity_id: input_select.washer_complete_detect_state
# to: Triggered
# for:
# seconds: 60
# action:
# - service: input_select.select_option
# target:
# entity_id: input_select.washer_complete_detect_state
# data:
# option: Idle
# - alias: Notify when Washer Done
# mode: single
# trigger:
# - platform: state
# entity_id: input_select.washer_complete_detect_state
# to: Triggered
# action:
# # The trigger occurs about 2 minutes before the lid unlocks
# - delay: 120
# - service: notify.everyone
# data:
# message: Washer is Done

View File

@@ -113,18 +113,54 @@ scene:
entities: entities:
light.living_room_light: light.living_room_light:
state: "on" state: "on"
brightness: 100 brightness: 50
- name: Home Theater Dim - name: Home Theater Dim
entities: entities:
light.living_room_light: light.living_room_light:
state: "on" state: "on"
brightness: 50 brightness: 20
- name: Home Theater Off - name: Home Theater Off
entities: entities:
light.living_room_light: "off" light.living_room_light: "off"
# Dining Room Scenes
- name: Dining Room Dim
entities:
light.dining_room_light:
state: "on"
brightness: 90
- name: Dining Room Full
entities:
light.dining_room_light:
state: "on"
brightness: 255
- name: Dining Room Off
entities:
light.dining_room_light:
state: "off"
# Outdoor/Patio Scenes
- name: Pergola Low
entities:
light.pergola_lights:
state: "on"
brightness: 128
- name: Pergola Full
entities:
light.pergola_lights:
state: "on"
brightness: 255
- name: Pergola Off
entities:
light.pergola_lights:
state: "off"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Lighting Scripts # Lighting Scripts
script: script:
@@ -149,14 +185,14 @@ script:
target: target:
entity_id: scene.home_theater_dim entity_id: scene.home_theater_dim
data: data:
transition: 3 transition: 6
- conditions: "{{ is_state('input_select.ht_lighting_mode', 'Theater') }}" - conditions: "{{ is_state('input_select.ht_lighting_mode', 'Theater') }}"
sequence: sequence:
- service: scene.turn_on - service: scene.turn_on
target: target:
entity_id: scene.home_theater_off entity_id: scene.home_theater_off
data: data:
transition: 3 transition: 6
# Paused # Paused
- conditions: "{{ is_state('input_select.ht_player_state', 'Paused') }}" - conditions: "{{ is_state('input_select.ht_player_state', 'Paused') }}"
@@ -168,14 +204,14 @@ script:
target: target:
entity_id: scene.home_theater_dim entity_id: scene.home_theater_dim
data: data:
transition: 3 transition: 6
- conditions: "{{ is_state('input_select.ht_lighting_mode', 'Theater') }}" - conditions: "{{ is_state('input_select.ht_lighting_mode', 'Theater') }}"
sequence: sequence:
- service: scene.turn_on - service: scene.turn_on
target: target:
entity_id: scene.home_theater_dim entity_id: scene.home_theater_dim
data: data:
transition: 3 transition: 6
# Stopped # Stopped
- conditions: "{{ is_state('input_select.ht_player_state', 'Stopped') }}" - conditions: "{{ is_state('input_select.ht_player_state', 'Stopped') }}"
@@ -187,7 +223,7 @@ script:
target: target:
entity_id: scene.home_theater_low entity_id: scene.home_theater_low
data: data:
transition: 3 transition: 6
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Lighting Automations # Lighting Automations
@@ -214,11 +250,11 @@ automation:
- alias: Jen Nightstand Lamp - alias: Jen Nightstand Lamp
mode: single mode: single
trigger: trigger:
- device_id: 98d9a86c7838fdbfcbdaeceef9a98f36 - trigger: device
domain: zha domain: mqtt
platform: device device_id: fc6ade603bf6978d6a357f19aee52aea
type: remote_button_short_press type: action
subtype: remote_button_short_press subtype: single
condition: [] condition: []
action: action:
- type: toggle - type: toggle
@@ -229,11 +265,11 @@ automation:
- alias: J.P. Nightstand Lamp - alias: J.P. Nightstand Lamp
mode: single mode: single
trigger: trigger:
- device_id: f223ed24fe135bfd6bb7006dddb6b9f8 - trigger: device
domain: zha domain: mqtt
platform: device device_id: d7247f6ae03db605aad8a5ff7de60331
type: remote_button_short_press type: action
subtype: remote_button_short_press subtype: single
condition: [] condition: []
action: action:
- type: toggle - type: toggle
@@ -257,26 +293,53 @@ automation:
entity_id: scene.jen_nightstand_high entity_id: scene.jen_nightstand_high
# Automations for Presence Changes # Automations for Presence Changes
- alias: Turn On Porch Light after Chorale - alias: Turn On Porch Light when Arriving Home at Night
trigger: trigger:
- platform: zone - platform: numeric_state
entity_id: person.jpk entity_id: sensor.jen_nearest_distance
zone: zone.chorale_performance below: 6500
event: leave - platform: numeric_state
- platform: zone entity_id: sensor.jp_nearest_distance
entity_id: person.jpk below: 6500
zone: zone.chorale_rehearsal condition: "{{ state_attr('sun.sun', 'elevation') < 5 }}"
event: leave
- platform: zone
entity_id: person.jpk
zone: zone.chorale_rehearsal_alt
event: leave
condition:
- condition: state
alias: "Sun down"
entity_id: sun.sun
state: "below_horizon"
mode: queued mode: queued
action: action:
- service: light.turn_on - service: light.turn_on
entity_id: light.porch_light entity_id: light.porch_light
# Automations for Dining Room Switch
- alias: Dining Room Dim on Double Up Press
mode: single
trigger:
# - platform: state
# entity_id: light.dining_room_light_action
# to: 'up_double'
- device_id: cab3202d50d68353c9d8a7648c93052f
domain: zha
platform: device
type: remote_button_double_press
subtype: Up
action:
- service: scene.turn_on
target:
entity_id: scene.dining_room_dim
data:
transition: 6
- alias: Dining Room Full on Triple Up Press
mode: single
trigger:
# - platform: state
# entity_id: light.dining_room_light_action
# to: 'up_double'
- device_id: cab3202d50d68353c9d8a7648c93052f
domain: zha
platform: device
type: remote_button_triple_press
subtype: Up
action:
- service: scene.turn_on
target:
entity_id: scene.dining_room_full
data:
transition: 6

View File

@@ -15,6 +15,15 @@ input_select:
- Unknown - Unknown
initial: Idle initial: Idle
# -----------------------------------------------------------------------------
# Media Player Configuration Switches
input_boolean:
ht_adjust_volume_hvac:
name: Home Theater Adjust Volume for HVAC
ht_volume_adjusted:
name: Home Theater Volume is Adjusted
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Media Player Scripts # Media Player Scripts
script: script:
@@ -36,6 +45,91 @@ script:
data: data:
option: Idle option: Idle
# Shield TV Input
#
# Very annoying since apps don't necessarily tell Android if they are playing
# (looking at you, Jellyfin) and we need two integrations to even get the full
# story for the Android TV base status.
#
# ATV (app ID) | Cast | JF | State
# ----------------------------|---------|---------|--------------------
# off (XX) | XX | XX | Idle (system off)
# on (com.spocky.projengmenu) | XX | XX | Idle (Projectivity Launcher)
# on (org.jellyfin.androidtv) | XX | idle | Stopped (Jellyfin Home)
# on (org.jellyfin.androidtv) | XX | playing | Playing (Jellyfin)
# on (org.jellyfin.androidtv) | XX | paused | Paused (Jellyfin)
# on (others) | idle | XX | Stopped (App Home)
# on (others) | playing | XX | Playing (App)
# on (others) | paused | XX | Paused (App)
#
- conditions: "{{ state_attr('media_player.living_room_receiver', 'source') == 'SHIELD' }}"
sequence:
- choose:
- conditions:
- condition: or
conditions:
- condition: template
value_template: "{{ is_state('media_player.shield_tv_living_room_atv', 'off') }}"
- condition: template
value_template: "{{ state_attr('media_player.shield_tv_living_room_atv', 'app_id') == 'com.spocky.projengmenu' }}"
sequence:
- service: input_select.select_option
target:
entity_id: input_select.ht_player_state
data:
option: Idle
- conditions: "{{ state_attr('media_player.shield_tv_living_room_atv', 'app_id') == 'org.jellyfin.androidtv' }}"
sequence:
- choose:
- conditions: "{{ is_state('media_player.shield_tv_living_room_jellyfin', 'playing') }}"
sequence:
- service: input_select.select_option
target:
entity_id: input_select.ht_player_state
data:
option: Playing
- conditions: "{{ is_state('media_player.shield_tv_living_room_jellyfin', 'paused') }}"
sequence:
- service: input_select.select_option
target:
entity_id: input_select.ht_player_state
data:
option: Paused
- conditions: "{{ is_state('media_player.shield_tv_living_room_jellyfin', 'idle') }}"
sequence:
- service: input_select.select_option
target:
entity_id: input_select.ht_player_state
data:
option: Stopped
- conditions: "{{ is_state('media_player.shield_tv_living_room_cast', 'playing') }}"
sequence:
- service: input_select.select_option
target:
entity_id: input_select.ht_player_state
data:
option: Playing
- conditions: "{{ is_state('media_player.shield_tv_living_room_cast', 'paused') }}"
sequence:
- service: input_select.select_option
target:
entity_id: input_select.ht_player_state
data:
option: Paused
- conditions: "{{ is_state('media_player.shield_tv_living_room_cast', 'idle') }}"
sequence:
- service: input_select.select_option
target:
entity_id: input_select.ht_player_state
data:
option: Stopped
# Roku Input # Roku Input
- conditions: "{{ state_attr('media_player.living_room_receiver', 'source') == 'Roku Ultra' }}" - conditions: "{{ state_attr('media_player.living_room_receiver', 'source') == 'Roku Ultra' }}"
sequence: sequence:
@@ -111,5 +205,62 @@ automation:
entity_id: media_player.living_room_receiver entity_id: media_player.living_room_receiver
- platform: state - platform: state
entity_id: media_player.living_room_roku entity_id: media_player.living_room_roku
- platform: state
entity_id: media_player.shield_tv_living_room_atv
- platform: state
entity_id: media_player.shield_tv_living_room_cast
- platform: state
entity_id: media_player.shield_tv_living_room_jellyfin
action: action:
- service: script.ht_player_state_update - service: script.ht_player_state_update
- alias: Turn Up Volume when HVAC Fan Starts
trigger:
- platform: state
entity_id: binary_sensor.hvac_fan_running
to: "on"
condition:
- "{{ is_state('input_boolean.ht_adjust_volume_hvac', 'on') }}"
- "{{ is_state('input_boolean.ht_volume_adjusted', 'off') }}"
- "{{ is_state('media_player.living_room_receiver', 'on') }}"
- "{{ is_state_attr('media_player.living_room_receiver', 'is_volume_muted', false) }}"
action:
- repeat:
count: 6
sequence:
- delay:
milliseconds: 250
- service: media_player.volume_up
target:
entity_id: media_player.living_room_receiver
- service: input_boolean.turn_on
target:
entity_id: input_boolean.ht_volume_adjusted
- alias: Turn Down Volume when HVAC Fan Stops
trigger:
- platform: state
entity_id: binary_sensor.hvac_fan_running
from: "on"
# The fan_state attribute turns off before the fan actually stops
for:
seconds: 45
condition:
- "{{ is_state('input_boolean.ht_adjust_volume_hvac', 'on') }}"
- "{{ is_state('input_boolean.ht_volume_adjusted', 'on') }}"
- "{{ is_state('media_player.living_room_receiver', 'on') }}"
- "{{ is_state_attr('media_player.living_room_receiver', 'is_volume_muted', false) }}"
action:
- repeat:
count: 6
sequence:
- delay:
milliseconds: 250
- service: media_player.volume_down
target:
entity_id: media_player.living_room_receiver
- service: input_boolean.turn_off
target:
entity_id: input_boolean.ht_volume_adjusted

View File

@@ -1,16 +1,68 @@
# Notification Package # Notification Package
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Notification Platforms # Main Notification Sinks
telegram_bot: notify:
- platform: broadcast - name: iphone_jp
api_key: !secret telegram_api_key platform: group
allowed_chat_ids: services:
- !secret telegram_chat_id - action: mobile_app_j_p_s_iphone
data:
title: Home Assistant
- name: iphone_jen
platform: group
services:
- action: mobile_app_jennifer_s_iphone_2
data:
title: Home Assistant
# `notify.security` used for high-priority security reporting including
# arm/disarm and alarm events. Also used for all camera events and the
# doorbell function.
- name: security
platform: group
services:
- action: persistent_notification
- action: iphone_jp
data:
title: Home Security
data:
push:
interruption-level: time-sensitive
- action: iphone_jen
data:
title: Home Security
data:
push:
interruption-level: time-sensitive
# `notify.status` used for simple status reporting (e.g. washer done)
# Keeps these notifications separated from security alerts
- name: status
platform: group
services:
- action: iphone_jp
data:
data:
group: 'status-notification-group'
- action: iphone_jen
data:
data:
group: 'status-notification-group'
- name: general
platform: group
services:
- action: iphone_jp
- name: everyone
platform: group
services:
- action: iphone_jp
- action: iphone_jen
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Main Notification Sink # Shell Command for ntfy
notify: shell_command:
- name: telegram_home ntfy: !secret ntfy_command
platform: telegram
chat_id: !secret telegram_chat_id

View File

@@ -14,59 +14,27 @@ homeassistant:
sensor.jp_presence_status: sensor.jp_presence_status:
entity_picture: https://gravatar.com/avatar/e78e623948f3675cf1c51544f9bec928 entity_picture: https://gravatar.com/avatar/e78e623948f3675cf1c51544f9bec928
device_tracker.jp_p3a_gps:
friendly_name: J.P. Pixel 3a
icon: mdi:map-marker
device_tracker.jp_p3a_ip:
friendly_name: J.P. Pixel 3a
icon: mdi:wifi
device_tracker.jp_p3a_bt_entry:
friendly_name: J.P. Pixel 3a
source_type: bluetooth
icon: mdi:bluetooth
device_tracker.jp_gs8_ap:
friendly_name: J.P. Galaxy S8
icon: mdi:wifi
device_tracker.jen_iphone_ip:
friendly_name: Jen iPhone
icon: mdi:wifi
device_tracker.jen_iphone_bt_entry:
friendly_name: Jen iPhone
source_type: bluetooth
icon: mdi:bluetooth
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Manual Setup Device Trackers # Proximity Sensors for Zones (deprecated in HA 2024.2)
device_tracker: # proximity:
# home:
# devices:
# - device_tracker.jen_iphone
# - device_tracker.j_p_s_iphone
# tolerance: 50
# unit_of_measurement: km
# Ping Platform # jpk:
- platform: ping # devices:
hosts: # - device_tracker.j_p_s_iphone
jp_p3a_ip: !secret jp_p3a_ip # tolerance: 50
jp_gs8_ip: !secret jp_gs8_ip # unit_of_measurement: km
jen_iphone_ip: !secret jen_iphone_ip
# MQTT Platform (Bluetooth Presence Sensor) # jen:
- platform: mqtt # devices:
devices: # - device_tracker.jen_iphone
jp_gs8_bt_entry: monitor/entry/jp_gs8/device_tracker # tolerance: 50
jp_p3a_bt_entry: monitor/entry/jp_p3a/device_tracker # unit_of_measurement: km
jen_iphone_bt_entry: monitor/entry/jen_iphone/device_tracker
# -----------------------------------------------------------------------------
# Proximity Sensors for Zones
proximity:
home:
ignored_zones:
- !secret jp_work_name
- !secret jen_work_name
devices:
- device_tracker.jen_iphone
- device_tracker.jp_pixel3a
tolerance: 50
unit_of_measurement: km
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Extended Presence States # Extended Presence States
@@ -147,34 +115,6 @@ sensor:
# Automations for Presence Detection # Automations for Presence Detection
automation: automation:
# Run Bluetooth Arrival Scan at Home Assistant startup
- alias: Startup Arrival Scan
trigger:
- platform: homeassistant
event: start
mode: queued
action:
- service: mqtt.publish
data:
topic: monitor/scan/arrive
payload: scan
# Run Bluetooth Departure Scan if a Ping Device Tracker goes Away
- alias: Bluetooth Departure Scan
trigger:
- platform: homeassistant
event: start
- platform: state
entity_id: binary_sensor.front_door_sensor
- platform: time_pattern
minutes: "/10"
mode: single
action:
- service: mqtt.publish
data:
topic: monitor/scan/depart
payload: scan
# Handle Just Arrived/Just Left State Transitions # Handle Just Arrived/Just Left State Transitions
- alias: Mark Person as Just Arrived - alias: Mark Person as Just Arrived
trigger: trigger:

View File

@@ -19,3 +19,22 @@ This package contains configuration information for a Home Assistant powered hom
| Bathroom Window | Bathroom | binary_sensor.bathroom_window | | Bathroom Window | Bathroom | binary_sensor.bathroom_window |
| Bedroom Side Window | Bedroom | binary_sensor.bedroom_side_window | | Bedroom Side Window | Bedroom | binary_sensor.bedroom_side_window |
| Bedroom Back Window | Bedroom | binary_sensor.bedroom_back_window | | Bedroom Back Window | Bedroom | binary_sensor.bedroom_back_window |
## Camera Notes
The Amcrest AD410 doorbell camera expects to have an internet connection to use Amcrest's cloud services. If it detects it is disconnected, it will flash the ring light. The connection state can be read and set via the camera's REST interface. Since Home Assistant does not support digest authentication with the REST service, a command is used to set the connection state. The command is kept in a secret since it includes camera authentication data, but it uses the following cURL command:
```shellscript
$ curl -X POST --digest -u admin:{password} -v 'http://{camera_ip}/cgi-bin/configManager.cgi?action=setConfig&VSP_PaaS.Online=true'
```
## Security States
The security state machine uses the following arming states:
| Arming State | Trigger | Description |
| --- | --- | --- |
| `armed_home` | Manual | Manually set state |
| `armed_away` | House Presence Change | Automatically set when the house presence is set to `Away` |
| `armed_vacation` | House Presence Change | Automatically set when the house presence is `Extended Away` |
| `armed_night` | Timed | Sundown to Sunup when home |
The `armed_home` and `armed_night` modes enable

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
# System Package
This package contains configuration information for the Home Assistant system itself, as well as various helper entities.

View File

@@ -0,0 +1,31 @@
# System Package
# -----------------------------------------------------------------------------
# System Scripts
script system:
toggle_group:
alias: Toggle Group (Generic)
description: Toggle the on/off state of a Group
fields:
entity_id:
name: Group Entity
description: Group entity to toggle
required: true
selector:
entity:
filter:
- domain: group
mode: queued
sequence:
- if:
- condition: template
value_template: >
{{
entity_id is defined
and states(entity_id) != 'unknown'
and states[entity_id].domain == 'group'
}}
then:
- service: "homeassistant.turn_{{ iif(is_state(entity_id, 'off'), 'on', 'off') }}"
target:
entity_id: "{{ entity_id }}"

View File

@@ -0,0 +1,3 @@
# Vacation Package
This package contains automations and helpers for vacation mode.

View File

@@ -0,0 +1,108 @@
# Vacation Package
# -----------------------------------------------------------------------------
# Vacation Helpers
input_boolean:
vacation_mode:
name: Vacation Mode
input_number:
# Threshold for minimum distance to trigger vacation mode (set to 0 to disable)
vacation_proximity_threshold:
name: Vacation Proximity Threshold
mode: box
min: 0
max: 1000
initial: 250
step: 10
unit_of_measurement: km
# -----------------------------------------------------------------------------
# Vacation Automations
automation:
- alias: Vacation - Turn On Vacation Mode
trigger:
- platform: state
entity_id: input_select.house_presence_state
to: 'Extended Away'
- platform: state
entity_id: alarm.alarm_control_panel
to: armed_vacation
- platform: template
value_template: >-
{{
states('input_number.vacation_proximity_threshold')|default(0)|float > 0
and states('proximity.home')|default(0)|float >= states('input_number.vacation_proximity_threshold')|default(0)|float
}}
action:
- service: input_boolean.turn_on
target:
entity_id: input_boolean.vacation_mode
- alias: Vacation - Turn Off Vacation Mode
trigger:
- platform: state
entity_id: input_select.house_presence_state
to: 'Just Arrived'
- platform: state
entity_id: input_select.house_presence_state
to: 'Home'
action:
- service: input_boolean.turn_off
target:
entity_id: input_boolean.vacation_mode
- alias: Vacation - Turn On Dining and Kitchen Lights at Sunset
trigger:
- platform: sun
event: sunset
offset: '-00:30:00'
condition: "{{ is_state('input_select.house_presence_state', 'Extended Away') }}"
action:
- delay: "00:{{ range(0,59)|random|int }}:00"
- service: light.turn_on
target:
entity_id: light.dining_room_light
- service: light.turn_on
target:
entity_id: light.kitchen_light
- delay: '02:00:00'
- service: light.turn_off
target:
entity_id: light.dining_room_light
- service: light.turn_off
target:
entity_id: light.kitchen_light
- alias: Vacation - Turn On Living Room Lights after Dinner
trigger:
- platform: time
at: '18:30:00'
condition: "{{ is_state('input_select.house_presence_state', 'Extended Away') }}"
action:
- delay: "00:{{ range(0,59)|random|int }}:00"
- service: light.turn_off
target:
entity_id: light.dining_room_light
- service: light.turn_off
target:
entity_id: light.kitchen_light
- service: light.turn_on
target:
entity_id: light.living_room_light
- delay: '02:00:00'
- service: light.turn_off
target:
entity_id: light.living_room_light
- alias: Vacation - Turn Off Living Room Lights at Bedtime
trigger:
- platform: time
at: '20:30:00'
condition: "{{ is_state('input_select.house_presence_state', 'Extended Away') }}"
action:
- delay: "00:{{ range(0,59)|random|int }}:00"
- service: light.turn_off
target:
entity_id: light.living_room_light

View File

@@ -0,0 +1,7 @@
# Vacuum Package
This package contains scripts and automations for running robot vacuums. Assumes the following entities are set up and configured with MQTT:
| Vacuum | Model | Entity |
|---|---|---|
| Furbot | Dreame D10s Plus | `vacuum.valetudo_furbot` |

246
packages/vacuum/vacuum.yaml Normal file
View File

@@ -0,0 +1,246 @@
# Vacuum Package
# -----------------------------------------------------------------------------
# Vacuum Helpers
input_number:
furbot_main_brush_life_hours:
unit_of_measurement: h
initial: 300
min: 10
max: 300
mode: box
furbot_right_brush_life_hours:
unit_of_measurement: h
initial: 200
min: 10
max: 300
mode: box
furbot_filter_life_hours:
unit_of_measurement: h
initial: 150
min: 10
max: 300
mode: box
furbot_sensor_life_hours:
unit_of_measurement: h
initial: 30
min: 10
max: 300
mode: box
input_text:
furbot_last_fan_speed:
name: Furbot Last Fan Speed
initial: ""
# -----------------------------------------------------------------------------
# Room/Segment Helpers
input_boolean:
furbot_segment_dining_room:
name: Dining Room
icon: mdi:table-furniture
furbot_segment_kitchen:
name: Kitchen
icon: mdi:silverware
furbot_segment_laundry:
name: Laundry Room
icon: mdi:washing-machine
furbot_segment_entry:
name: Entry
icon: mdi:coat-rack
furbot_segment_office:
name: Office
icon: mdi:chair-rolling
furbot_segment_hallway:
name: Hallway
icon: mdi:shoe-print
furbot_segment_bathroom:
name: Bathroom
icon: mdi:shower
furbot_segment_bedroom:
name: Bedroom
icon: mdi:bed
furbot_segment_living_room:
name: Living Room
icon: mdi:television
furbot_segment_guest_room:
name: Guest Room
icon: mdi:account
homeassistant:
customize:
input_boolean.furbot_segment_bathroom:
segment_id: '1'
input_boolean.furbot_segment_bedroom:
segment_id: '2'
input_boolean.furbot_segment_guest_room:
segment_id: '3'
input_boolean.furbot_segment_office:
segment_id: '4'
input_boolean.furbot_segment_living_room:
segment_id: '5'
input_boolean.furbot_segment_laundry:
segment_id: '6'
input_boolean.furbot_segment_kitchen:
segment_id: '7'
input_boolean.furbot_segment_entry:
segment_id: '8'
input_boolean.furbot_segment_dining_room:
segment_id: '9'
input_boolean.furbot_segment_hallway:
segment_id: '10'
group:
furbot_segments:
name: Furbot Rooms
entities:
- input_boolean.furbot_segment_bathroom
- input_boolean.furbot_segment_bedroom
- input_boolean.furbot_segment_guest_room
- input_boolean.furbot_segment_office
- input_boolean.furbot_segment_living_room
- input_boolean.furbot_segment_laundry
- input_boolean.furbot_segment_kitchen
- input_boolean.furbot_segment_entry
- input_boolean.furbot_segment_dining_room
- input_boolean.furbot_segment_hallway
template:
# Consumable Helpers
sensor:
- name: Furbot Main Brush Life Pct
state_class: measurement
unit_of_measurement: "%"
state: "{{ ((states('sensor.valetudo_furbot_main_brush')|default(120)|float) / (states('input_number.furbot_main_brush_life_hours')|float * 60) * 100) | round | int }}"
- name: Furbot Right Brush Life Pct
state_class: measurement
unit_of_measurement: "%"
state: "{{ ((states('sensor.valetudo_furbot_right_brush')|default(120)|float) / (states('input_number.furbot_right_brush_life_hours')|float * 60) * 100) | round | int }}"
- name: Furbot Filter Life Pct
state_class: measurement
unit_of_measurement: "%"
state: "{{ ((states('sensor.valetudo_furbot_main_filter')|default(120)|float) / (states('input_number.furbot_filter_life_hours')|float * 60) * 100) | round | int }}"
- name: Furbot Sensor Cleaning Pct
state_class: measurement
unit_of_measurement: "%"
state: "{{ ((states('sensor.valetudo_furbot_sensor_cleaning')|default(120)|float) / (states('input_number.furbot_sensor_life_hours')|float * 60) * 100) | round | int }}"
# -----------------------------------------------------------------------------
# Vacuum Scripts
script:
# Vacuum the bedroom (room ) only with high suction and two passes
furbot_deep_clean_bedroom:
alias: "Furbot: Deep Clean Bedroom"
sequence:
# Cache the fan setting and reset it when cleaning finishes
- service: input_text.set_value
target:
entity_id: input_text.furbot_last_fan_speed
data:
value: "{{ states('select.valetudo_furbot_fan') }}"
# Start Cleaning
- service: mqtt.publish
data:
topic: valetudo/furbot/FanSpeedControlCapability/preset/set
payload: max
- service: mqtt.publish
data:
topic: valetudo/furbot/MapSegmentationCapability/clean/set
payload: '{"segment_ids": ["2"], "iterations": 2}'
# Vacuum Selected Segments
furbot_clean_segments:
alias: "Furbot: Clean Selected Rooms"
sequence:
- choose:
# Pause the robot if it is currently cleaning
- conditions:
- condition: template
value_template: "{{ states('vacuum.valetudo_furbot') == 'cleaning' }}"
sequence:
- service: mqtt.publish
data:
topic: valetudo/furbot/BasicControlCapability/operation/set
payload: 'PAUSE'
# Resume the robot if it is paused
- conditions:
- condition: template
value_template: "{{ states('vacuum.valetudo_furbot') == 'paused' }}"
sequence:
- service: mqtt.publish
data:
topic: valetudo/furbot/BasicControlCapability/operation/set
payload: 'START'
# Start a new cleaning if it is docked
default:
- if:
- condition: template
value_template: "{{ is_state('group.furbot_segments', 'off') }}"
then:
- service: mqtt.publish
data:
topic: valetudo/furbot/BasicControlCapability/operation/set
payload: 'START'
else:
- service: mqtt.publish
data:
topic: valetudo/furbot/MapSegmentationCapability/clean/set
payload: >-
{
"segment_ids": {{ expand('group.furbot_segments') |
selectattr('state','eq','on') |
map(attribute='attributes.segment_id') | list | to_json }}
}
# -----------------------------------------------------------------------------
# Vacuum Automations
automation:
# Reset the Fan Speed when cleaning is complete (if cached)
- alias: "Furbot: Reset Fan Speed"
trigger:
- platform: state
entity_id: vacuum.valetudo_furbot
to: docked
condition: "{{ states('input_text.furbot_last_fan_speed') != '' }}"
action:
- service: mqtt.publish
data:
topic: valetudo/furbot/FanSpeedControlCapability/preset/set
payload_template: "{{ states('input_text.furbot_last_fan_speed') }}"
- service: input_text.reload
target:
entity_id: input_text.furbot_last_fan_speed
# Reset the Cleaning Segments when cleaning is complete
- alias: "Furbot: Reset Rooms"
trigger:
- platform: state
entity_id: vacuum.valetudo_furbot
to: docked
action:
- service: homeassistant.turn_off
target:
entity_id: group.furbot_segments

View File

@@ -3,17 +3,33 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Recorder Customization # Recorder Customization
recorder: recorder:
include:
entities:
- sensor.klgb_relative_humidity
- sensor.klgb_temperature
- sensor.klgb_dew_point
- sensor.klgb_barometric_pressure
- sensor.klgb_wind_direction
- sensor.klgb_wind_speed
exclude: exclude:
domains: domains:
- weather - weather
entity_globs: entity_globs:
- sensor.owm_* - sensor.klgb_*
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# InfluxDB Customization # InfluxDB Customization
influxdb: influxdb:
include:
entities:
- sensor.klgb_relative_humidity
- sensor.klgb_temperature
- sensor.klgb_dew_point
- sensor.klgb_barometric_pressure
- sensor.klgb_wind_direction
- sensor.klgb_wind_speed
exclude: exclude:
domains: domains:
- weather - weather
entity_globs: entity_globs:
- sensor.owm_* - sensor.klgb_*

View File

@@ -0,0 +1,3 @@
# Wine Fridge Package
This package contains automations to control the wine fridge outlet in the garage.

View File

@@ -0,0 +1,68 @@
# Wine Fridge Package
# -----------------------------------------------------------------------------
# Wine Fridge Helpers
input_number:
wine_fridge_setpoint:
name: Wine Fridge Set Point
initial: 65
min: 45
max: 70
step: 1
unit_of_measurement: °F
wine_fridge_delta_t:
name: Wine Fridge Delta T
initial: 5
min: 2
max: 15
unit_of_measurement: °F
template:
binary_sensor:
# Start wine fridge when dT is high enough to make the compressor happy
- name: Wine Fridge Should Start
state: >
{{
states('sensor.wine_fridge_sensor_temperature')|float > states('input_number.wine_fridge_setpoint')|float + 1
and states('sensor.garage_sensor_air_temperature')|float > states('sensor.wine_fridge_sensor_temperature')|float + states('input_number.wine_fridge_delta_t')|float
and states('switch.garage_wine_fridge_outlet') == 'off'
}}
# Stop wine fridge when temperature falls and the compressor is stopped
- name: Wine Fridge Should Stop
state: >
{{
states('sensor.garage_sensor_air_temperature')|float < states('sensor.wine_fridge_sensor_temperature')|float + states('input_number.wine_fridge_delta_t')|float
and states('sensor.garage_wine_fridge_outlet_electric_consumption_a')|float < 0.5
and states('switch.garage_wine_fridge_outlet') == 'on'
}}
# -----------------------------------------------------------------------------
# Wine Fridge Automations
automation:
- alias: Turn On Wine Fridge
mode: single
trigger:
- platform: state
entity_id: binary_sensor.wine_fridge_should_start
to: 'on'
for:
minutes: 5
action:
- service: switch.turn_on
target:
entity_id: switch.garage_wine_fridge_outlet
- alias: Turn Off Wine Fridge
mode: single
trigger:
- platform: state
entity_id: binary_sensor.wine_fridge_should_stop
to: 'on'
for:
minutes: 5
action:
- service: switch.turn_off
target:
entity_id: switch.garage_wine_fridge_outlet

View File

@@ -15,11 +15,11 @@ zone:
radius: 250 radius: 250
icon: mdi:briefcase icon: mdi:briefcase
- name: !secret jen_work_name # - name: !secret jen_work_name
latitude: !secret jen_work_latitude # latitude: !secret jen_work_latitude
longitude: !secret jen_work_longitude # longitude: !secret jen_work_longitude
radius: 250 # radius: 250
icon: mdi:briefcase # icon: mdi:briefcase
- name: Chorale (Performance) - name: Chorale (Performance)
latitude: !secret chorale_perf_latitude latitude: !secret chorale_perf_latitude