183 Commits

Author SHA1 Message Date
Commander1024 2cbb15a425 Inline Calendar Event Formatting 2026-07-10 23:25:15 +02:00
Commander1024 6a04638f59 Rename calendar input_text entities 2026-07-10 22:47:18 +02:00
Commander1024 a4a86272a9 Rename calendar helper IDs 2026-07-10 22:39:43 +02:00
Commander1024 4ae08e0400 Fixed stupid AI syntax errors ... 2026-07-10 22:36:38 +02:00
Commander1024 2b93cf16dc Readded FreeDNS config and reset date attribute 2026-07-10 16:27:58 +02:00
Commander1024 2879dbeaa0 Initial draft for calendar on eopaperframe by qwen3.6-30b 2026-07-10 15:16:38 +02:00
Commander1024 e4a4aacc63 Added missing unique_id 2026-07-10 01:16:51 +02:00
Commander1024 0d12317089 revert 746dd833c8
revert Add unique_ids and calendar text sensors
2026-07-10 00:54:27 +02:00
Commander1024 746dd833c8 Add unique_ids and calendar text sensors 2026-07-10 00:38:16 +02:00
Commander1024 3eb482e40b Added IPv6 support. 2026-07-09 09:03:24 +02:00
Commander1024 e9a6064b4b Updated HA. 2026-07-09 08:59:52 +02:00
Commander1024 38616556cb Updated HA. 2026-07-02 09:12:17 +02:00
Commander1024 bef1cfb1d0 Updated HA. 2026-06-23 09:12:01 +02:00
Commander1024 0a8364d7ed Changed annunciators for climate control. 2026-06-23 09:11:50 +02:00
Commander1024 3eb9eb3a2f Archived deprecated raspiaudio muse luxe config. 2026-06-23 09:11:01 +02:00
Commander1024 7407955465 Removed cached icons from repo. 2026-06-23 09:10:18 +02:00
Commander1024 2ff8b5cafe Merged owon-ai-test into main. 2026-06-01 18:45:27 +02:00
Commander1024 7e08c4a8b9 Updated HA. 2026-06-01 18:42:56 +02:00
Commander1024 855f013095 Freedns test 2026-06-01 18:42:43 +02:00
Commander1024 a68b527c48 Various entity changees 2026-06-01 18:41:43 +02:00
Commander1024 cecec14be1 Added usage info. 2026-05-30 19:12:03 +02:00
Commander1024 4e5df1ad0c Updated file comments 2026-05-30 19:00:18 +02:00
Commander1024 2c16ba1679 Separated long-press buttons into own HA entityies to make them usable 2026-05-30 18:49:16 +02:00
Commander1024 c21f2a5c7b Display aestethics: taller 7-segments, repositioned minus, mode and
icons
2026-05-30 17:49:56 +02:00
Commander1024 2a1902ccf1 Add reset all / reset energy function to also clear the local meter 2026-05-30 17:06:31 +02:00
Commander1024 31848bf26c Add energy monitor using riemann sum algorithm. 2026-05-30 16:49:57 +02:00
Commander1024 d7337fd634 Set (somewhat) appropiate mdi icons for OWON HA sensors. 2026-05-30 08:22:32 +02:00
Commander1024 5b287303f1 Add backlight idle script 2026-05-28 22:58:15 +02:00
Commander1024 816cc35861 Implement dc-load inactivity display 2026-05-28 22:49:32 +02:00
Commander1024 058c186d63 Remove dead values from HA and rendering code. Also increase font size. 2026-05-28 22:24:46 +02:00
Commander1024 22e5f9d767 Implemented rough 1st iteration of dc-load value screen. 2026-05-28 22:09:54 +02:00
Commander1024 f9b32d06a4 Expose buttons Owon multimeter buttons to HA. 2026-05-28 16:04:53 +02:00
Commander1024 3f890ae02f Intregrated Atorch BLE proxy and migrated device name to a generic one. 2026-05-28 14:35:12 +02:00
Commander1024 f0cb74cc27 Deactivate all indicator lights on meter disconnect. 2026-05-28 13:40:46 +02:00
Commander1024 6038075e9e Fixed off-by-one error in rendering of the 7-segment segments. 2026-05-28 13:29:39 +02:00
Commander1024 2dbdfd213c Deactivate bargraph after BLE disconnect 2026-05-28 12:29:52 +02:00
Commander1024 e2eb886eef Renamed .h file to match .yaml filename. 2026-05-26 19:35:45 +02:00
Commander1024 f4f19eb847 Added attribution to original author and AI usage hint. 2026-05-26 19:25:56 +02:00
Commander1024 b3e307dcba RAM optimization, deactivated recording of display values. 2026-05-26 19:19:22 +02:00
Commander1024 1b3b4870e7 Ported the code to M5Stack Core2 (different display) and removed reboot on disconnect 2026-05-26 19:10:30 +02:00
Commander1024 69c2166414 Archived the Core1 Owon multimeter config 2026-05-26 18:00:35 +02:00
Commander1024 2fcf71d8ce Added Bluetooth and battery icons instead of text indicators 2026-05-26 17:36:58 +02:00
Commander1024 676829a988 Remove mode from Reading value - already present in Mode. 2026-05-26 17:21:11 +02:00
Commander1024 08201f1bc1 Uniform separation of 7-segment display font elements. 2026-05-26 17:10:43 +02:00
Commander1024 23cbd520a6 Repositioned DIODE/BUZZ and moved them out of the meter bar. Also slightly reworked 7-segment display font. Not perfect, yet though 2026-05-26 17:03:52 +02:00
Commander1024 6f4e2b9319 Moved AC/DC out of the way of the minus sign, added an h glyph for hFE display. 2026-05-26 16:44:25 +02:00
Commander1024 e5104fc005 Removed 2 unneeded values from HA reporting, fixed mV reporting value 2026-05-26 16:03:25 +02:00
Commander1024 91f3f69bd9 Optimized for mem pressure, inverted colors. 2026-05-26 15:34:03 +02:00
Commander1024 4bd62f539c Initial AI-assisted esphome device code for Owon B35T+ multimeter. 2026-05-26 13:45:06 +02:00
Commander1024 a3bd9ff213 Removed cached png from repo. 2026-05-21 09:35:21 +02:00
Commander1024 24fc4bf9f4 Enabled ipv6 for all esphome devices. 2026-05-21 09:24:48 +02:00
Commander1024 0bfc2eff34 Changed little things... 2026-05-21 09:24:11 +02:00
Commander1024 8f18a043af Updated HA + gitignore 2026-05-21 09:21:52 +02:00
Commander1024 5b0a992686 Changed light automations from time offset to sun elevation. 2026-04-29 07:27:53 +02:00
Commander1024 ef233296e9 Updated HA. 2026-04-29 07:27:22 +02:00
Commander1024 c0af2b734b Added HA Icon cache. 2026-04-23 09:56:53 +02:00
Commander1024 a94386babe Deleted obsolete glances config 2026-04-23 09:56:06 +02:00
Commander1024 f23166b765 Updated HA. 2026-04-23 09:55:38 +02:00
Commander1024 8438785241 Changed dryer zigbee plug. 2026-04-23 09:55:21 +02:00
Commander1024 b0b6093619 Added IKEA Bilresa dual button blueprint 2026-04-23 09:54:49 +02:00
Commander1024 c463a5e28f Added kitchen Music switch, changed 420 players, light offset by sun elevation 2026-04-23 09:52:59 +02:00
Commander1024 a3e06a85d6 Changed lighting defaults. 2026-03-06 01:32:30 +01:00
Commander1024 6795c31d43 Updated HA. 2026-03-06 01:31:15 +01:00
Commander1024 e504cdf90c Updated ics_calendar. 2026-03-06 01:30:59 +01:00
Commander1024 c9807a7bf0 Updated epaper dashbord for new air quality sensor entities. 2026-02-14 15:08:14 +01:00
Commander1024 fdbbbb5300 Motion detection in the hallway, new lamp in the living room. 2026-02-14 14:58:06 +01:00
Commander1024 fda6e6abff Updated HA. 2026-02-14 14:56:24 +01:00
Commander1024 ee01b56fb6 Deprecated diy air quality sensor due to dead ESP32 2026-02-14 14:55:43 +01:00
Commander1024 db1edfaf24 Changed speakers for laundry automations. 2026-01-22 13:47:38 +01:00
Commander1024 e07c66563c Adoption of various Thread/Matter devices 2026-01-22 07:30:19 +01:00
Commander1024 2ff40a0da7 Updated HA. 2026-01-22 07:29:49 +01:00
Commander1024 24b4d07228 ESP32 ESPHome migration to esp-idf 2026-01-22 07:29:35 +01:00
Commander1024 8b6f3012f4 Updated HA. 2025-12-17 11:01:05 +01:00
Commander1024 792fc87166 Defined unused dummy TX pin as required by component. 2025-12-17 11:00:43 +01:00
Commander1024 67b2c2edde Updated ics_calendar 2025-12-08 12:06:06 +01:00
Commander1024 db9b8a73d3 Deleted ics_calendar obsolete Backup file. 2025-12-08 11:32:47 +01:00
Commander1024 093be6f2d2 Updated HA. 2025-12-08 11:32:20 +01:00
Commander1024 3d5693ceaa Migrated deprecated template sensors to modern way. 2025-12-04 08:47:45 +01:00
Commander1024 247c96df5d Updated HA. 2025-12-04 08:47:16 +01:00
Commander1024 13cdc7ee67 Updated HA. 2025-11-21 19:15:05 +01:00
Commander1024 24eecc7c97 Deprecation warnings fixed for esphome 2025.11 2025-11-21 19:14:49 +01:00
Commander1024 aea16e59c4 Changed doorbell text. 2025-11-20 15:37:25 +01:00
Commander1024 48bfa10bf7 Update HA. 2025-11-20 15:36:57 +01:00
Commander1024 80560ef9aa Moved DB size sensor due to HA breaking change. 2025-11-20 15:36:41 +01:00
Commander1024 ab46bff258 Moved DB size sensor due to HA breaking change. 2025-11-20 15:36:15 +01:00
Commander1024 fe081bf145 Updated Raspiaudio Muse Luxe to latest microww config. 2025-10-16 12:27:35 +02:00
Commander1024 85abd64ea0 Updated HA. 2025-10-16 12:26:56 +02:00
Commander1024 b7581d3bad GDoor: Fixed longpress on front door. 2025-10-16 12:24:01 +02:00
Commander1024 93b2d4f401 Added zone notification 2025-09-17 14:44:33 +02:00
Commander1024 ca0ef18df3 Added blackened theme for potential use in smartmirror. 2025-09-17 14:43:56 +02:00
Commander1024 cd6625b08e Increased camera resolution 2025-09-17 14:39:21 +02:00
Commander1024 4810652d99 Updated HA. 2025-09-17 14:38:44 +02:00
Commander1024 741b283218 Minor fixes and upgrades. 2025-09-10 17:24:57 +02:00
Commander1024 8800f7b847 Replaced tempsensor with cam with sensor. 2025-09-10 17:24:27 +02:00
Commander1024 a947806618 2 last minor fixes (typo and stuff) 2025-09-04 20:45:39 +02:00
Commander1024 439ead1047 Added back notification group, which got deleted. 2025-09-04 20:24:54 +02:00
Commander1024 20c86831f1 Minor tidy up 2025-09-04 20:21:51 +02:00
Commander1024 61b018270b Updated, modified and streamlined a whole bunch of stuff. 2025-09-04 14:18:29 +02:00
Commander1024 911477d381 Sleep as Android, Gdoor, fixes and update. 2025-09-03 23:57:54 +02:00
Commander1024 1ee475ee4a Added HomeAssitant Voice. 2025-09-01 21:36:09 +02:00
Commander1024 cae28341fe Added webserver image/stream. Added replacement for Außensensor (with cam). 2025-08-29 08:03:15 +02:00
Commander1024 17cfa429ea Added Mute logic (that does not fix newly introduced crashes). 2025-08-29 08:02:05 +02:00
Commander1024 791d7d504f Fixed comment. 2025-08-29 08:01:22 +02:00
Commander1024 631cdae751 Updated HA. 2025-08-29 08:00:51 +02:00
Commander1024 06321730e0 Changed Mobile IDs (Lineage OS) 2025-08-29 08:00:38 +02:00
Commander1024 5ac8ceacfe Remainders 2025-07-26 12:18:35 +02:00
Commander1024 4981cbfa2e Export HA entities to mqtt 2025-07-26 12:16:43 +02:00
Commander1024 e5ddf02c34 Updated HA, updated esphome devices. 2025-05-23 23:32:05 +02:00
Commander1024 ebf11021e2 Generated image 2025-05-12 11:55:24 +02:00
Commander1024 72ce055810 Updated hochwasserportal integration 2025-05-12 11:21:49 +02:00
Commander1024 8461f43bca Updated HA. 2025-05-12 11:17:29 +02:00
Commander1024 ca599eab7a Updated ics_calendar to restore compatibility with HA 2025-05-08 10:02:22 +02:00
Commander1024 fef90d5a78 Updated HA. 2025-05-08 09:30:16 +02:00
Commander1024 1f151a804e Migrated Raspiaudio Muse Luxe to esp-idf-based voice sattelite/speaker 2025-05-08 09:30:01 +02:00
Commander1024 31da165db1 Disabled BLE tracking. Degrades performance. 2025-05-07 09:41:50 +02:00
Commander1024 dd390001ee Updated HA. 2025-05-07 09:39:55 +02:00
Commander1024 7993a9174a Fixed changed rtl_433 ids. 2025-03-18 12:17:49 +01:00
Commander1024 fe40743fd1 Updated HA. 2025-03-18 12:17:25 +01:00
Commander1024 01bad587b8 Updated Sketch for voice assistant. 2025-03-08 16:57:37 +01:00
Commander1024 4888393a12 Updated on_click actions to dictionaries. 2025-03-08 14:53:19 +01:00
Commander1024 05f8cc1af8 New, generated image sizes. 2025-03-08 14:23:45 +01:00
Commander1024 5c98baa24b Inverted image colors. Changed default behavior in recent update. 2025-03-08 14:21:28 +01:00
Commander1024 490b00301b Updated HA. 2025-03-08 14:08:41 +01:00
Commander1024 c3e813551b Added / Renamed ab psu. 2025-03-08 14:08:24 +01:00
Commander1024 474805328b Enabled automation 2025-01-30 13:30:06 +01:00
Commander1024 fd18934e97 Added BLE Proxy for DC Load. 2025-01-30 13:29:04 +01:00
Commander1024 5e1d197e7a Updated HA. 2025-01-30 13:23:14 +01:00
Commander1024 ff59c494a7 Added invert binary sensor template. 2025-01-06 09:13:57 +01:00
Commander1024 c4d9c253f4 Updated HA. 2025-01-06 09:13:08 +01:00
Commander1024 99007cda76 Updated Voice Assistant sattelites 2025-01-06 09:12:47 +01:00
Commander1024 373675be4f Added additional, heated temp/humid sensor. 2025-01-06 09:12:11 +01:00
Commander1024 90ed6df217 XMas automations 2025-01-06 09:11:14 +01:00
Commander1024 e1984a199d Removed old pycache files. 2025-01-06 09:08:40 +01:00
Commander1024 3fcca0c3e1 Added unique_id to hygrostat. 2024-10-15 15:34:12 +02:00
Commander1024 5ddbe9962f Updated HA. 2024-10-15 15:33:10 +02:00
Commander1024 f05405bc75 Added unique_ids to derivative sensors. 2024-10-15 15:32:59 +02:00
Commander1024 5467789c71 Removed WLED effect template. Caused errors, when light was turned off. 2024-10-11 15:31:24 +02:00
Commander1024 7f9a366171 Made templates for WLED effect and palette working on its own. 2024-10-10 14:57:16 +02:00
Commander1024 bf3b4e5e81 Added unique_ids 2024-10-08 12:59:18 +02:00
Commander1024 083cdbb857 Added room pictures. 2024-10-08 11:52:41 +02:00
Commander1024 32e9c3af4d Added landesübergreifendes Hochwasserportal 2024-10-05 17:30:45 +02:00
Commander1024 a0d7950829 Updated HA. 2024-10-05 17:29:50 +02:00
Commander1024 8cd5ae5283 Notify about washing mashine / dryer finish. 2024-10-03 17:20:22 +02:00
Commander1024 574be34e36 Added additional power plugs to other/untracked power. 2024-10-03 12:43:06 +02:00
Commander1024 0dfe1950c9 Adopted bmp280 platform setting. 2024-10-03 12:42:22 +02:00
Commander1024 9def77c3f3 Updated HA. 2024-10-03 12:41:58 +02:00
Commander1024 fb027e0c45 Updated HA. 2024-09-15 00:33:33 +02:00
Commander1024 f744021925 Remove InfluxDB 2024-09-15 00:32:40 +02:00
Commander1024 daaec3cc60 Syntax adjustments. 2024-09-15 00:32:26 +02:00
Commander1024 683ffe7767 Updated HA. MPD config migrated to UI. 2024-07-05 21:30:05 +02:00
Commander1024 730d1cf338 Added unique_ids to utility meters. 2024-07-01 14:06:06 +02:00
Commander1024 27408f02dd Remove deprecated mqtt sensors. 2024-07-01 14:05:36 +02:00
Commander1024 624ee50d14 Disable M's out-of-home 2024-07-01 14:04:54 +02:00
Commander1024 48d29a6585 Updated ota platform config. 2024-07-01 14:02:31 +02:00
Commander1024 04eff2e382 Updated HA 2024-07-01 14:01:14 +02:00
Commander1024 8f63ede1f0 DWD logo, HA Update, WLED effect 2024-03-21 09:25:39 +01:00
Commander1024 d32328f1bb Added zigbee blueprints. 2024-03-01 15:48:55 +01:00
Commander1024 c15266fc20 Added IKEA zigbee buttons, changed hourly voice taunt. 2024-03-01 15:48:28 +01:00
Commander1024 9ec14848d5 Updated HA. 2024-03-01 15:47:44 +01:00
Commander1024 87868f079c Escaped sensor value. 2024-02-16 00:10:11 +01:00
Commander1024 c0755f5add Add NINA logo. 2024-02-16 00:09:52 +01:00
Commander1024 acb1c63483 Updated HA. 2024-02-16 00:08:26 +01:00
Commander1024 565b1b1b81 Added humidifier set humid automation. Removed states.state occurances to the less error-prone alternative. 2024-01-21 00:15:44 +01:00
Commander1024 392e8508d3 Removed and ignored pycache. 2024-01-20 00:12:05 +01:00
Commander1024 6985991d3a 1st working esp32-cam implementation. 2024-01-20 00:04:45 +01:00
Commander1024 0162c1074f Updated HA. 2024-01-20 00:03:53 +01:00
Commander1024 8a5ba47dfa Changes to serverroom sensor. 2024-01-20 00:03:43 +01:00
Commander1024 db6a6e9e30 Let's Encrypt automation. 2024-01-17 11:54:42 +01:00
Commander1024 8cad164008 IKEA blueprint, replaced bathroom sensor with ESPHome. 2024-01-17 11:50:02 +01:00
Commander1024 d8cb73ebf0 Added IKEA Somrig for sleeping room. 2024-01-17 11:46:54 +01:00
Commander1024 0ac87ad81c Updated HA. 2024-01-17 11:45:48 +01:00
Commander1024 395bf7bb9c Cleaned up duplicate pin usage. 2023-12-25 16:45:19 +01:00
Commander1024 c0ebd9188d Entity name changes (and typo) because of update. 2023-12-25 00:51:42 +01:00
Commander1024 f36ea1b591 Added ics_calender custom component. 2023-12-14 22:15:21 +01:00
Commander1024 ee90402c8b WIP junk for lights management using zigbee remote. 2023-12-14 18:41:43 +01:00
Commander1024 479a1cb8e1 Updated HA. 2023-12-14 14:36:54 +01:00
Commander1024 2cf58f3f34 Added IKEA Zigbee button blueprint/automation, changed automations for xmas. 2023-12-14 14:36:09 +01:00
Commander1024 0dff2d90ff Fixed Raspiaudio Muse luxe speaker due to ESPHome breaking change. 2023-12-14 14:34:30 +01:00
Commander1024 92f95f7bae Updated phone call notification. 2023-11-14 11:34:11 +01:00
Commander1024 d5be7fea6b Adopted 2. Awtrix Light (Desk). 2023-11-14 11:14:27 +01:00
Commander1024 8a446a727b Updated HA. 2023-11-14 11:13:05 +01:00
Commander1024 2d3c9cf674 Add RD6018 Lab PSU via ESPhome WiFi. 2023-11-06 23:22:06 +01:00
Commander1024 ce9ad46e5b Update HA. 2023-11-06 23:21:29 +01:00
112 changed files with 14315 additions and 1209 deletions
+1 -1
View File
@@ -1 +1 @@
2023.11.0 2026.7.1
+4 -1
View File
@@ -1,6 +1,7 @@
automations_webhooks.yaml automations_webhooks.yaml
spotify.yaml spotify.yaml
calendars.yaml calendars.yaml
ics_calendars.yaml
esphome/common/secrets.yaml esphome/common/secrets.yaml
esphome/secrets.yaml esphome/secrets.yaml
secrets.yaml secrets.yaml
@@ -10,4 +11,6 @@ media/
home-assistant.log* home-assistant.log*
.storage/ .storage/
zigbee.db* zigbee.db*
*__pycache__*
.ha_run.lock
.cache
+3
View File
@@ -0,0 +1,3 @@
[submodule "themes/blackened"]
path = themes/blackened
url = https://github.com/home-assistant-community-themes/blackened.git
+1072 -294
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,426 @@
blueprint:
name: Controller - IKEA E1743 TRÅDFRI On/Off Switch & Dimmer
description: "# Controller - IKEA E1743 TRÅDFRI On/Off Switch & Dimmer\n\nController
automation for executing any kind of action triggered by the provided IKEA E1743
TRÅDFRI On/Off Switch & Dimmer. Allows to optionally loop an action on a button
long press.\nSupports deCONZ, ZHA, Zigbee2MQTT.\n\nAutomations created with this
blueprint can be connected with one or more [Hooks](https://epmatt.github.io/awesome-ha-blueprints/docs/blueprints/hooks)
supported by this controller.\nHooks allow to easily create controller-based automations
for interacting with media players, lights, covers and more.\nSee the list of
[Hooks available for this controller](https://epmatt.github.io/awesome-ha-blueprints/docs/blueprints/controllers/ikea_e1743#available-hooks)
for additional details.\n\n\U0001F4D5 Full documentation regarding this blueprint
is available [here](https://epmatt.github.io/awesome-ha-blueprints/docs/blueprints/controllers/ikea_e1743).\n\n\U0001F680
This blueprint is part of the **[Awesome HA Blueprints](https://epmatt.github.io/awesome-ha-blueprints)
project**.\n\n️ Version 2022.08.08\n"
source_url: https://github.com/EPMatt/awesome-ha-blueprints/blob/main/blueprints/controllers/ikea_e1743/ikea_e1743.yaml
domain: automation
input:
integration:
name: (Required) Integration
description: Integration used for connecting the remote with Home Assistant.
Select one of the available values.
selector:
select:
options:
- deCONZ
- ZHA
- Zigbee2MQTT
custom_value: false
multiple: false
sort: false
controller_device:
name: (deCONZ, ZHA) Controller Device
description: The controller device to use for the automation. Choose a value
only if the remote is integrated with deCONZ, ZHA.
default: ''
selector:
device: {}
controller_entity:
name: (Zigbee2MQTT) Controller Entity
description: The action sensor of the controller to use for the automation.
Choose a value only if the remote is integrated with Zigbee2MQTT.
default: ''
selector:
entity:
domain:
- sensor
multiple: false
helper_last_controller_event:
name: (Required) Helper - Last Controller Event
description: Input Text used to store the last event fired by the controller.
You will need to manually create a text input entity for this, please read
the blueprint Additional Notes for more info.
default: ''
selector:
entity:
domain:
- input_text
multiple: false
action_button_up_short:
name: (Optional) Up button short press
description: Action to run on short up button press.
default: []
selector:
action: {}
action_button_up_long:
name: (Optional) Up button long press
description: Action to run on long up button press.
default: []
selector:
action: {}
action_button_up_release:
name: (Optional) Up button release
description: Action to run on up button release after long press.
default: []
selector:
action: {}
action_button_up_double:
name: (Optional) Up button double press
description: Action to run on double up button press.
default: []
selector:
action: {}
action_button_down_short:
name: (Optional) Down button short press
description: Action to run on short down button press.
default: []
selector:
action: {}
action_button_down_long:
name: (Optional) Down button long press
description: Action to run on long down button press.
default: []
selector:
action: {}
action_button_down_release:
name: (Optional) Down button release
description: Action to run on down button release after long press.
default: []
selector:
action: {}
action_button_down_double:
name: (Optional) Down button double press
description: Action to run on double down button press.
default: []
selector:
action: {}
button_up_long_loop:
name: (Optional) Up button long press - loop until release
description: Loop the up button action until the button is released.
default: false
selector:
boolean: {}
button_up_long_max_loop_repeats:
name: (Optional) Up button long press - Maximum loop repeats
description: Maximum number of repeats for the custom action, when looping is
enabled. Use it as a safety limit to prevent an endless loop in case the corresponding
stop event is not received.
default: 500
selector:
number:
min: 1.0
max: 5000.0
mode: slider
step: 1.0
button_down_long_loop:
name: (Optional) Down button long press - loop until release
description: Loop the down button action until the button is released.
default: false
selector:
boolean: {}
button_down_long_max_loop_repeats:
name: (Optional) Down button long press - Maximum loop repeats
description: Maximum number of repeats for the custom action, when looping is
enabled. Use it as a safety limit to prevent an endless loop in case the corresponding
stop event is not received.
default: 500
selector:
number:
min: 1.0
max: 5000.0
mode: slider
step: 1.0
button_up_double_press:
name: (Optional) Expose up button double press event
description: Choose whether or not to expose the virtual double press event
for the up button. Turn this on if you are providing an action for the up
button double press event.
default: false
selector:
boolean: {}
button_down_double_press:
name: (Optional) Expose down button double press event
description: Choose whether or not to expose the virtual double press event
for the down button. Turn this on if you are providing an action for the down
button double press event.
default: false
selector:
boolean: {}
helper_double_press_delay:
name: (Optional) Helper - Double Press delay
description: Max delay between the first and the second button press for the
double press event. Provide a value only if you are using a double press action.
Increase this value if you notice that the double press action is not triggered
properly.
default: 500
selector:
number:
min: 100.0
max: 5000.0
unit_of_measurement: milliseconds
mode: box
step: 10.0
helper_debounce_delay:
name: (Optional) Helper - Debounce delay
description: Delay used for debouncing RAW controller events, by default set
to 0. A value of 0 disables the debouncing feature. Increase this value if
you notice custom actions or linked Hooks running multiple times when interacting
with the device. When the controller needs to be debounced, usually a value
of 100 is enough to remove all duplicate events.
default: 0
selector:
number:
min: 0.0
max: 1000.0
unit_of_measurement: milliseconds
mode: box
step: 10.0
variables:
integration: !input integration
button_up_long_loop: !input button_up_long_loop
button_up_long_max_loop_repeats: !input button_up_long_max_loop_repeats
button_up_double_press: !input button_up_double_press
button_down_long_loop: !input button_down_long_loop
button_down_long_max_loop_repeats: !input button_down_long_max_loop_repeats
button_down_double_press: !input button_down_double_press
helper_last_controller_event: !input helper_last_controller_event
helper_double_press_delay: !input helper_double_press_delay
helper_debounce_delay: !input helper_debounce_delay
integration_id: '{{ integration | lower }}'
adjusted_double_press_delay: '{{ [helper_double_press_delay - helper_debounce_delay,
100] | max }}'
actions_mapping:
deconz:
button_up_short:
- '1002'
button_up_long:
- '1001'
button_up_release:
- '1003'
button_down_short:
- '2002'
button_down_long:
- '2001'
button_down_release:
- '2003'
zha:
button_up_short:
- 'on'
button_up_long:
- move_with_on_off_0_83
button_up_release:
- stop
button_down_short:
- 'off'
button_down_long:
- move_1_83
button_down_release:
- stop
zigbee2mqtt:
button_up_short:
- 'on'
button_up_long:
- brightness_move_up
button_up_release:
- brightness_stop
button_down_short:
- 'off'
button_down_long:
- brightness_move_down
button_down_release:
- brightness_stop
button_up_short: '{{ actions_mapping[integration_id]["button_up_short"] }}'
button_up_long: '{{ actions_mapping[integration_id]["button_up_long"] }}'
button_up_release: '{{ actions_mapping[integration_id]["button_up_release"] }}'
button_down_short: '{{ actions_mapping[integration_id]["button_down_short"] }}'
button_down_long: '{{ actions_mapping[integration_id]["button_down_long"] }}'
button_down_release: '{{ actions_mapping[integration_id]["button_down_release"]
}}'
integrations_with_prev_event_storage:
- zha
- zigbee2mqtt
controller_entity: !input controller_entity
controller_device: !input controller_device
controller_id: '{% if integration_id=="zigbee2mqtt" %}{{controller_entity}}{% else
%}{{controller_device}}{% endif %}'
mode: restart
max_exceeded: silent
trigger:
- platform: event
event_type: state_changed
event_data:
entity_id: !input controller_entity
- platform: event
event_type:
- deconz_event
- zha_event
event_data:
device_id: !input controller_device
condition:
- condition: and
conditions:
- '{%- set trigger_action -%} {%- if integration_id == "zigbee2mqtt" -%} {{ trigger.event.data.new_state.state
}} {%- elif integration_id == "deconz" -%} {{ trigger.event.data.event }} {%-
elif integration_id == "zha" -%} {{ trigger.event.data.command }}{{"_" if trigger.event.data.args|length
> 0}}{{ trigger.event.data.args|join("_") }} {%- endif -%} {%- endset -%} {{ trigger_action
not in ["","None"] }}'
- '{{ integration_id != "zigbee2mqtt" or trigger.event.data.new_state.state != trigger.event.data.old_state.state
}}'
action:
- delay:
milliseconds: !input helper_debounce_delay
- variables:
trigger_action: '{%- if integration_id == "zigbee2mqtt" -%} {{ trigger.event.data.new_state.state
}} {%- elif integration_id == "deconz" -%} {{ trigger.event.data.event }} {%-
elif integration_id == "zha" -%} {{ trigger.event.data.command }}{{"_" if trigger.event.data.args|length
> 0}}{{ trigger.event.data.args|join("_") }} {%- endif -%}'
trigger_delta: '{{ (as_timestamp(now()) - ((states(helper_last_controller_event)
| from_json).t if helper_last_controller_event is not none and (states(helper_last_controller_event)
| regex_match("^\{((\"a\": \".*\"|\"t\": \d+\.\d+)(, )?){2}\}$")) else as_timestamp("1970-01-01
00:00:00"))) * 1000 }}'
last_controller_event: '{{ (states(helper_last_controller_event) | from_json).a
if helper_last_controller_event is not none and (states(helper_last_controller_event)
| regex_match("^\{((\"a\": \".*\"|\"t\": \d+\.\d+)(, )?){2}\}$")) else "" }}'
- service: input_text.set_value
data:
entity_id: !input helper_last_controller_event
value: '{{ {"a":trigger_action,"t":as_timestamp(now())} | to_json }}'
- choose:
- conditions: '{{ trigger_action | string in button_up_short }}'
sequence:
- choose:
- conditions: '{{ button_up_double_press }}'
sequence:
- choose:
- conditions: '{{ trigger_action | string in states(helper_last_controller_event)
and trigger_delta | int <= helper_double_press_delay | int }}'
sequence:
- service: input_text.set_value
data:
entity_id: !input helper_last_controller_event
value: '{{ {"a":"double_press","t":as_timestamp(now())} | to_json
}}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_double
- choose:
- conditions: []
sequence: !input action_button_up_double
default:
- delay:
milliseconds: '{{ adjusted_double_press_delay }}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_short
- choose:
- conditions: []
sequence: !input action_button_up_short
default:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_short
- choose:
- conditions: []
sequence: !input action_button_up_short
- conditions: '{{ trigger_action | string in button_up_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_long
- choose:
- conditions: '{{ button_up_long_loop }}'
sequence:
- repeat:
while: '{{ repeat.index < button_up_long_max_loop_repeats | int }}'
sequence: !input action_button_up_long
default: !input action_button_up_long
- conditions:
- '{{ trigger_action | string in button_up_release }}'
- '{{ not integration_id in integrations_with_prev_event_storage or last_controller_event
| string in button_up_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_release
- choose:
- conditions: []
sequence: !input action_button_up_release
- conditions: '{{ trigger_action | string in button_down_short }}'
sequence:
- choose:
- conditions: '{{ button_down_double_press }}'
sequence:
- choose:
- conditions: '{{ trigger_action | string in states(helper_last_controller_event)
and trigger_delta | int <= helper_double_press_delay | int }}'
sequence:
- service: input_text.set_value
data:
entity_id: !input helper_last_controller_event
value: '{{ {"a":"double_press","t":as_timestamp(now())} | to_json
}}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_double
- choose:
- conditions: []
sequence: !input action_button_down_double
default:
- delay:
milliseconds: '{{ adjusted_double_press_delay }}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_short
- choose:
- conditions: []
sequence: !input action_button_down_short
default:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_short
- choose:
- conditions: []
sequence: !input action_button_down_short
- conditions: '{{ trigger_action | string in button_down_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_long
- choose:
- conditions: '{{ button_down_long_loop }}'
sequence:
- repeat:
while: '{{ repeat.index < button_down_long_max_loop_repeats | int }}'
sequence: !input action_button_down_long
default: !input action_button_down_long
- conditions:
- '{{ trigger_action | string in button_down_release }}'
- '{{ not integration_id in integrations_with_prev_event_storage or last_controller_event
| string in button_down_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_release
- choose:
- conditions: []
sequence: !input action_button_down_release
@@ -0,0 +1,704 @@
blueprint:
name: Controller - IKEA E2001/E2002 STYRBAR Remote control
description: "# Controller - IKEA E2001/E2002 STYRBAR Remote control\n\nController
automation for executing any kind of action triggered by the provided IKEA E2001/E2002
STYRBAR Remote control. Allows to optionally loop an action on a button long press.\nSupports
deCONZ, ZHA, Zigbee2MQTT.\n\nAutomations created with this blueprint can be connected
with one or more [Hooks](https://epmatt.github.io/awesome-ha-blueprints/docs/blueprints/hooks)
supported by this controller.\nHooks allow to easily create controller-based automations
for interacting with media players, lights, covers and more.\nSee the list of
[Hooks available for this controller](https://epmatt.github.io/awesome-ha-blueprints/docs/blueprints/controllers/ikea_e2001_e2002#available-hooks)
for additional details.\n\n\U0001F4D5 Full documentation regarding this blueprint
is available [here](https://epmatt.github.io/awesome-ha-blueprints/docs/blueprints/controllers/ikea_e2001_e2002).\n\n\U0001F680
This blueprint is part of the **[Awesome HA Blueprints](https://epmatt.github.io/awesome-ha-blueprints)
project**.\n\n️ Version 2022.08.08\n"
source_url: https://github.com/EPMatt/awesome-ha-blueprints/blob/main/blueprints/controllers/ikea_e2001_e2002/ikea_e2001_e2002.yaml
domain: automation
input:
integration:
name: (Required) Integration
description: Integration used for connecting the remote with Home Assistant.
Select one of the available values.
selector:
select:
options:
- deCONZ
- ZHA
- Zigbee2MQTT
sort: false
multiple: false
custom_value: false
controller_device:
name: (deCONZ, ZHA) Controller Device
description: The controller device to use for the automation. Choose a value
only if the remote is integrated with deCONZ, ZHA.
default: ''
selector:
device: {}
controller_entity:
name: (Zigbee2MQTT) Controller Entity
description: The action sensor of the controller to use for the automation.
Choose a value only if the remote is integrated with Zigbee2MQTT.
default: ''
selector:
entity:
domain:
- sensor
multiple: false
helper_last_controller_event:
name: (Required) Helper - Last Controller Event
description: Input Text used to store the last event fired by the controller.
You will need to manually create a text input entity for this, please read
the blueprint Additional Notes for more info.
default: ''
selector:
entity:
domain:
- input_text
multiple: false
action_button_left_short:
name: (Optional) Left button short press
description: Action to run on short left button press.
default: []
selector:
action: {}
action_button_left_long:
name: (Optional) Left button long press
description: Action to run on long left button press.
default: []
selector:
action: {}
action_button_left_release:
name: (Optional) Left button release
description: Action to run on left button release after long press.
default: []
selector:
action: {}
action_button_left_double:
name: (Optional) Left button double press
description: Action to run on double left button press.
default: []
selector:
action: {}
action_button_right_short:
name: (Optional) Right button short press
description: Action to run on short right button press.
default: []
selector:
action: {}
action_button_right_long:
name: (Optional) Right button long press
description: Action to run on long right button press.
default: []
selector:
action: {}
action_button_right_release:
name: (Optional) Right button release
description: Action to run on right button release after long press.
default: []
selector:
action: {}
action_button_right_double:
name: (Optional) Right button double press
description: Action to run on double right button press.
default: []
selector:
action: {}
action_button_up_short:
name: (Optional) Up button short press
description: Action to run on short up button press.
default: []
selector:
action: {}
action_button_up_long:
name: (Optional) Up button long press
description: Action to run on long up button press.
default: []
selector:
action: {}
action_button_up_release:
name: (Optional) Up button release
description: Action to run on up button release after long press.
default: []
selector:
action: {}
action_button_up_double:
name: (Optional) Up button double press
description: Action to run on double up button press.
default: []
selector:
action: {}
action_button_down_short:
name: (Optional) Down button short press
description: Action to run on short down button press.
default: []
selector:
action: {}
action_button_down_long:
name: (Optional) Down button long press
description: Action to run on long down button press.
default: []
selector:
action: {}
action_button_down_release:
name: (Optional) Down button release
description: Action to run on down button release after long press.
default: []
selector:
action: {}
action_button_down_double:
name: (Optional) Down button double press
description: Action to run on double down button press.
default: []
selector:
action: {}
button_left_long_loop:
name: (Optional) Left button long press - loop until release
description: Loop the left button action until the button is released.
default: false
selector:
boolean: {}
button_left_long_max_loop_repeats:
name: (Optional) Left button long press - Maximum loop repeats
description: Maximum number of repeats for the custom action, when looping is
enabled. Use it as a safety limit to prevent an endless loop in case the corresponding
stop event is not received.
default: 500
selector:
number:
min: 1.0
max: 5000.0
mode: slider
step: 1.0
button_right_long_loop:
name: (Optional) Right button long press - loop until release
description: Loop the right button action until the button is released.
default: false
selector:
boolean: {}
button_right_long_max_loop_repeats:
name: (Optional) Right button long press - Maximum loop repeats
description: Maximum number of repeats for the custom action, when looping is
enabled. Use it as a safety limit to prevent an endless loop in case the corresponding
stop event is not received.
default: 500
selector:
number:
min: 1.0
max: 5000.0
mode: slider
step: 1.0
button_up_long_loop:
name: (Optional) Up button long press - loop until release
description: Loop the up button action until the button is released.
default: false
selector:
boolean: {}
button_up_long_max_loop_repeats:
name: (Optional) Up button long press - Maximum loop repeats
description: Maximum number of repeats for the custom action, when looping is
enabled. Use it as a safety limit to prevent an endless loop in case the corresponding
stop event is not received.
default: 500
selector:
number:
min: 1.0
max: 5000.0
mode: slider
step: 1.0
button_down_long_loop:
name: (Optional) Down button long press - loop until release
description: Loop the down button action until the button is released.
default: false
selector:
boolean: {}
button_down_long_max_loop_repeats:
name: (Optional) Down button long press - Maximum loop repeats
description: Maximum number of repeats for the custom action, when looping is
enabled. Use it as a safety limit to prevent an endless loop in case the corresponding
stop event is not received.
default: 500
selector:
number:
min: 1.0
max: 5000.0
mode: slider
step: 1.0
button_left_double_press:
name: (Optional) Expose left button double press event
description: Choose whether or not to expose the virtual double press event
for the left button. Turn this on if you are providing an action for the left
button double press event.
default: false
selector:
boolean: {}
button_right_double_press:
name: (Optional) Expose right button double press event
description: Choose whether or not to expose the virtual double press event
for the right button. Turn this on if you are providing an action for the
right button double press event.
default: false
selector:
boolean: {}
button_up_double_press:
name: (Optional) Expose up button double press event
description: Choose whether or not to expose the virtual double press event
for the up button. Turn this on if you are providing an action for the up
button double press event.
default: false
selector:
boolean: {}
button_down_double_press:
name: (Optional) Expose down button double press event
description: Choose whether or not to expose the virtual double press event
for the down button. Turn this on if you are providing an action for the down
button double press event.
default: false
selector:
boolean: {}
helper_double_press_delay:
name: (Optional) Helper - Double Press delay
description: Max delay between the first and the second button press for the
double press event. Provide a value only if you are using a double press action.
Increase this value if you notice that the double press action is not triggered
properly.
default: 500
selector:
number:
min: 100.0
max: 5000.0
unit_of_measurement: milliseconds
mode: box
step: 10.0
helper_debounce_delay:
name: (Optional) Helper - Debounce delay
description: Delay used for debouncing RAW controller events, by default set
to 0. A value of 0 disables the debouncing feature. Increase this value if
you notice custom actions or linked Hooks running multiple times when interacting
with the device. When the controller needs to be debounced, usually a value
of 100 is enough to remove all duplicate events.
default: 0
selector:
number:
min: 0.0
max: 1000.0
unit_of_measurement: milliseconds
mode: box
step: 10.0
variables:
integration: !input integration
button_left_long_loop: !input button_left_long_loop
button_left_long_max_loop_repeats: !input button_left_long_max_loop_repeats
button_left_double_press: !input button_left_double_press
button_right_long_loop: !input button_right_long_loop
button_right_long_max_loop_repeats: !input button_right_long_max_loop_repeats
button_right_double_press: !input button_right_double_press
button_up_long_loop: !input button_up_long_loop
button_up_long_max_loop_repeats: !input button_up_long_max_loop_repeats
button_up_double_press: !input button_up_double_press
button_down_long_loop: !input button_down_long_loop
button_down_long_max_loop_repeats: !input button_down_long_max_loop_repeats
button_down_double_press: !input button_down_double_press
helper_last_controller_event: !input helper_last_controller_event
helper_double_press_delay: !input helper_double_press_delay
helper_debounce_delay: !input helper_debounce_delay
integration_id: '{{ integration | lower }}'
adjusted_double_press_delay: '{{ [helper_double_press_delay - helper_debounce_delay,
100] | max }}'
actions_mapping:
deconz:
button_left_short:
- '3002'
button_left_long:
- '3001'
button_left_release:
- '3003'
button_right_short:
- '4002'
button_right_long:
- '4001'
button_right_release:
- '4003'
button_up_short:
- '1002'
button_up_long:
- '1001'
button_up_release:
- '1003'
button_down_short:
- '2002'
button_down_long:
- '2001'
button_down_release:
- '2003'
zha:
button_left_short:
- press_257_13_0
button_left_long:
- hold_3329_0
button_left_release:
- release_1365
button_right_short:
- press_256_13_0
button_right_long:
- hold_3328_0
button_right_release:
- release_-27903
button_up_short:
- 'on'
button_up_long:
- move_with_on_off_0_83
button_up_release:
- stop
button_down_short:
- 'off'
button_down_long:
- move_1_83
button_down_release:
- stop
zigbee2mqtt:
button_left_short:
- arrow_left_click
button_left_long:
- arrow_left_hold
button_left_release:
- arrow_left_release
button_right_short:
- arrow_right_click
button_right_long:
- arrow_right_hold
button_right_release:
- arrow_right_release
button_up_short:
- 'on'
button_up_long:
- brightness_move_up
button_up_release:
- brightness_stop
button_down_short:
- 'off'
button_down_long:
- brightness_move_down
button_down_release:
- brightness_stop
button_left_short: '{{ actions_mapping[integration_id]["button_left_short"] }}'
button_left_long: '{{ actions_mapping[integration_id]["button_left_long"] }}'
button_left_release: '{{ actions_mapping[integration_id]["button_left_release"]
}}'
button_right_short: '{{ actions_mapping[integration_id]["button_right_short"] }}'
button_right_long: '{{ actions_mapping[integration_id]["button_right_long"] }}'
button_right_release: '{{ actions_mapping[integration_id]["button_right_release"]
}}'
button_up_short: '{{ actions_mapping[integration_id]["button_up_short"] }}'
button_up_long: '{{ actions_mapping[integration_id]["button_up_long"] }}'
button_up_release: '{{ actions_mapping[integration_id]["button_up_release"] }}'
button_down_short: '{{ actions_mapping[integration_id]["button_down_short"] }}'
button_down_long: '{{ actions_mapping[integration_id]["button_down_long"] }}'
button_down_release: '{{ actions_mapping[integration_id]["button_down_release"]
}}'
integrations_with_prev_event_storage:
- zha
- zigbee2mqtt
controller_entity: !input controller_entity
controller_device: !input controller_device
controller_id: '{% if integration_id=="zigbee2mqtt" %}{{controller_entity}}{% else
%}{{controller_device}}{% endif %}'
mode: restart
max_exceeded: silent
trigger:
- platform: event
event_type: state_changed
event_data:
entity_id: !input controller_entity
- platform: event
event_type:
- deconz_event
- zha_event
event_data:
device_id: !input controller_device
condition:
- condition: and
conditions:
- '{%- set trigger_action -%} {%- if integration_id == "zigbee2mqtt" -%} {{ trigger.event.data.new_state.state
}} {%- elif integration_id == "deconz" -%} {{ trigger.event.data.event }} {%-
elif integration_id == "zha" -%} {{ trigger.event.data.command }}{{"_" if trigger.event.data.args|length
> 0}}{{ trigger.event.data.args|join("_") }} {%- endif -%} {%- endset -%} {{ trigger_action
not in ["","None"] }}'
- '{{ integration_id != "zigbee2mqtt" or trigger.event.data.new_state.state != trigger.event.data.old_state.state
}}'
action:
- delay:
milliseconds: !input helper_debounce_delay
- variables:
trigger_action: '{%- if integration_id == "zigbee2mqtt" -%} {{ trigger.event.data.new_state.state
}} {%- elif integration_id == "deconz" -%} {{ trigger.event.data.event }} {%-
elif integration_id == "zha" -%} {{ trigger.event.data.command }}{{"_" if trigger.event.data.args|length
> 0}}{{ trigger.event.data.args|join("_") }} {%- endif -%}'
trigger_delta: '{{ (as_timestamp(now()) - ((states(helper_last_controller_event)
| from_json).t if helper_last_controller_event is not none and (states(helper_last_controller_event)
| regex_match("^\{((\"a\": \".*\"|\"t\": \d+\.\d+)(, )?){2}\}$")) else as_timestamp("1970-01-01
00:00:00"))) * 1000 }}'
last_controller_event: '{{ (states(helper_last_controller_event) | from_json).a
if helper_last_controller_event is not none and (states(helper_last_controller_event)
| regex_match("^\{((\"a\": \".*\"|\"t\": \d+\.\d+)(, )?){2}\}$")) else "" }}'
- service: input_text.set_value
data:
entity_id: !input helper_last_controller_event
value: '{{ {"a":trigger_action,"t":as_timestamp(now())} | to_json }}'
- choose:
- conditions: '{{ trigger_action | string in button_left_short }}'
sequence:
- choose:
- conditions: '{{ button_left_double_press }}'
sequence:
- choose:
- conditions: '{{ trigger_action | string in states(helper_last_controller_event)
and trigger_delta | int <= helper_double_press_delay | int }}'
sequence:
- service: input_text.set_value
data:
entity_id: !input helper_last_controller_event
value: '{{ {"a":"double_press","t":as_timestamp(now())} | to_json
}}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_left_double
- choose:
- conditions: []
sequence: !input action_button_left_double
default:
- delay:
milliseconds: '{{ adjusted_double_press_delay }}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_left_short
- choose:
- conditions: []
sequence: !input action_button_left_short
default:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_left_short
- choose:
- conditions: []
sequence: !input action_button_left_short
- conditions: '{{ trigger_action | string in button_left_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_left_long
- choose:
- conditions: '{{ button_left_long_loop }}'
sequence:
- repeat:
while: '{{ repeat.index < button_left_long_max_loop_repeats | int }}'
sequence: !input action_button_left_long
default: !input action_button_left_long
- conditions:
- '{{ trigger_action | string in button_left_release }}'
- '{{ not integration_id in integrations_with_prev_event_storage or last_controller_event
| string in button_left_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_left_release
- choose:
- conditions: []
sequence: !input action_button_left_release
- conditions: '{{ trigger_action | string in button_right_short }}'
sequence:
- choose:
- conditions: '{{ button_right_double_press }}'
sequence:
- choose:
- conditions: '{{ trigger_action | string in states(helper_last_controller_event)
and trigger_delta | int <= helper_double_press_delay | int }}'
sequence:
- service: input_text.set_value
data:
entity_id: !input helper_last_controller_event
value: '{{ {"a":"double_press","t":as_timestamp(now())} | to_json
}}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_right_double
- choose:
- conditions: []
sequence: !input action_button_right_double
default:
- delay:
milliseconds: '{{ adjusted_double_press_delay }}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_right_short
- choose:
- conditions: []
sequence: !input action_button_right_short
default:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_right_short
- choose:
- conditions: []
sequence: !input action_button_right_short
- conditions: '{{ trigger_action | string in button_right_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_right_long
- choose:
- conditions: '{{ button_right_long_loop }}'
sequence:
- repeat:
while: '{{ repeat.index < button_right_long_max_loop_repeats | int }}'
sequence: !input action_button_right_long
default: !input action_button_right_long
- conditions:
- '{{ trigger_action | string in button_right_release }}'
- '{{ not integration_id in integrations_with_prev_event_storage or last_controller_event
| string in button_right_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_right_release
- choose:
- conditions: []
sequence: !input action_button_right_release
- conditions: '{{ trigger_action | string in button_up_short }}'
sequence:
- choose:
- conditions: '{{ button_up_double_press }}'
sequence:
- choose:
- conditions: '{{ trigger_action | string in states(helper_last_controller_event)
and trigger_delta | int <= helper_double_press_delay | int }}'
sequence:
- service: input_text.set_value
data:
entity_id: !input helper_last_controller_event
value: '{{ {"a":"double_press","t":as_timestamp(now())} | to_json
}}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_double
- choose:
- conditions: []
sequence: !input action_button_up_double
default:
- delay:
milliseconds: '{{ adjusted_double_press_delay }}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_short
- choose:
- conditions: []
sequence: !input action_button_up_short
default:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_short
- choose:
- conditions: []
sequence: !input action_button_up_short
- conditions: '{{ trigger_action | string in button_up_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_long
- choose:
- conditions: '{{ button_up_long_loop }}'
sequence:
- repeat:
while: '{{ repeat.index < button_up_long_max_loop_repeats | int }}'
sequence: !input action_button_up_long
default: !input action_button_up_long
- conditions:
- '{{ trigger_action | string in button_up_release }}'
- '{{ not integration_id in integrations_with_prev_event_storage or last_controller_event
| string in button_up_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_up_release
- choose:
- conditions: []
sequence: !input action_button_up_release
- conditions: '{{ trigger_action | string in button_down_short }}'
sequence:
- choose:
- conditions: '{{ button_down_double_press }}'
sequence:
- choose:
- conditions: '{{ trigger_action | string in states(helper_last_controller_event)
and trigger_delta | int <= helper_double_press_delay | int }}'
sequence:
- service: input_text.set_value
data:
entity_id: !input helper_last_controller_event
value: '{{ {"a":"double_press","t":as_timestamp(now())} | to_json
}}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_double
- choose:
- conditions: []
sequence: !input action_button_down_double
default:
- delay:
milliseconds: '{{ adjusted_double_press_delay }}'
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_short
- choose:
- conditions: []
sequence: !input action_button_down_short
default:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_short
- choose:
- conditions: []
sequence: !input action_button_down_short
- conditions: '{{ trigger_action | string in button_down_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_long
- choose:
- conditions: '{{ button_down_long_loop }}'
sequence:
- repeat:
while: '{{ repeat.index < button_down_long_max_loop_repeats | int }}'
sequence: !input action_button_down_long
default: !input action_button_down_long
- conditions:
- '{{ trigger_action | string in button_down_release }}'
- '{{ not integration_id in integrations_with_prev_event_storage or last_controller_event
| string in button_down_long }}'
sequence:
- event: ahb_controller_event
event_data:
controller: '{{ controller_id }}'
action: button_down_release
- choose:
- conditions: []
sequence: !input action_button_down_release
@@ -0,0 +1,302 @@
mode: restart
blueprint:
name: IKEA BILRESA Dual Button
author: Skaronator
description: "# IKEA BILRESA Dual Button Remote Automation\n\n## Current Version
2.0.1\n\n## Features\n\nSimple automation blueprint for IKEA BILRESA dual-button
remote.\n\n### Supported Actions for Each Button\n\n* Single Press\n* Double Press\n*
Long Press (on press and release)\n* While Holding (with configurable interval)\n\n###
Configuration Options\n\n* Configurable interval for \"while holding\" actions.\n*
Safety limit on maximum iterations for \"while holding\" actions to prevent infinite
loops.\n\n## Changelog\n\n### 2.0.1\n - Removed Matter integration filter for
button event selector. This will allow users to use the Ikea BILRESA remote over
3rd party Matter integration.\n### 2.0.0\n - Added support for selecting multiple
entities for each button. Allowing to have multiple remotes triggering the same
actions.\n - See [these upgrade notes](https://community.home-assistant.io/t/ikea-bilresa-dual-button-remote/965815/28?u=skaronator)
for migrating existing automations using this blueprint.\n### 1.2.2\n - Improved
descriptions and documentation.\n### 1.2.1\n - Improved descriptions for blueprint
inputs.\n### 1.2.0\n - Added max iterations setting for \"while holding\" actions
to prevent infinite loops.\n### 1.1.0\n - Added \"while holding\" actions for
both buttons with configurable interval.\n### 1.0.0\n - Initial release with
support for all button actions.\n\n## Support and Feedback\n\n* GitHub: [Skaronator/home-assistant-blueprints](https://github.com/Skaronator/home-assistant-blueprints)\n*
Blueprint Exchange: [IKEA BILRESA Dual Button](https://community.home-assistant.io/t/ikea-bilresa-dual-button-remote/965815)\n\nThank
you for using this blueprint! Your feedback and contributions are welcome. \U0001F49A\n"
domain: automation
source_url: https://raw.githubusercontent.com/Skaronator/home-assistant-blueprints/refs/heads/main/automation/ikea-bilresa-dual-button.yaml
homeassistant:
min_version: 2025.12.0
input:
setup:
name: ⚙️ Button Setup
description: 'Select the event entities for all 2 buttons on the IKEA BILRESA
dual-button remote.
'
collapsed: true
input:
button1_event:
name: Entity
description: 'Select the event entity for Button 1
'
selector:
entity:
multiple: true
filter:
- domain:
- event
device_class:
- button
reorder: false
button2_event:
name: Entity
description: 'Select the event entity for Button 2
'
selector:
entity:
multiple: true
filter:
- domain:
- event
device_class:
- button
reorder: false
button1:
name: '1️⃣ Button 1 Actions
'
description: 'Define actions when pressing Button 1.
'
collapsed: true
input:
button1_single:
name: Single Press
description: Action for Button 1 single press.
default: []
selector:
action: {}
button1_double:
name: Double Press
description: Action for Button 1 double press.
default: []
selector:
action: {}
button1_long_press:
name: Long Press (on press)
description: Action for Button 1 long press completion.
default: []
selector:
action: {}
button1_long_release:
name: Long Press (on release)
description: Action for Button 1 long press completion.
default: []
selector:
action: {}
button1_holding_interval:
name: Holding Interval (seconds)
description: Interval in seconds for repeating "while holding" actions.
default: 0.2
selector:
number:
min: 0.05
max: 2.0
step: 0.05
mode: slider
button1_holding_max_iterations:
name: Holding Max Iterations
description: Maximum number of iterations for "while holding" actions. This
is a safety limit to prevent infinite loops.
default: 100
selector:
number:
min: 1.0
max: 1000.0
step: 1.0
mode: slider
button1_while_holding:
name: While Holding
description: Action for Button 1 while holding. This action will be repeated
at the defined interval while the button is held down.
default: []
selector:
action: {}
button2:
name: '2️⃣ Button 2 Actions
'
description: 'Define actions when pressing Button 2.
'
collapsed: true
input:
button2_single:
name: Single Press
description: Action for Button 2 single press.
default: []
selector:
action: {}
button2_double:
name: Double Press
description: Action for Button 2 double press.
default: []
selector:
action: {}
button2_long_press:
name: Long Press (on press)
description: Action for Button 2 long press completion.
default: []
selector:
action: {}
button2_long_release:
name: Long Press (on release)
description: Action for Button 2 long press completion.
default: []
selector:
action: {}
button2_holding_interval:
name: Holding Interval (seconds)
description: Interval in seconds for repeating "while holding" actions.
default: 0.2
selector:
number:
min: 0.05
max: 2.0
step: 0.05
mode: slider
button2_holding_max_iterations:
name: Holding Max Iterations
description: Maximum number of iterations for "while holding" actions. This
is a safety limit to prevent infinite loops.
default: 100
selector:
number:
min: 1.0
max: 1000.0
step: 1.0
mode: slider
button2_while_holding:
name: While Holding
description: Action for Button 2 while holding. This action will be repeated
at the defined interval while the button is held down.
default: []
selector:
action: {}
trigger:
- platform: state
id: button1
entity_id: !input button1_event
- platform: state
id: button2
entity_id: !input button2_event
condition:
- condition: template
value_template: '{{ trigger.from_state.state not in [''unknown'', ''unavailable'']
}}
'
- condition: template
value_template: '{{ trigger.to_state.state not in [''unknown'', ''unavailable'']
}}
'
- condition: template
value_template: '{{ trigger.from_state.state != trigger.to_state.state }}
'
action:
- variables:
trigger_id: '{{ trigger.id }}'
press_type: '{{ trigger.to_state.attributes.event_type }}'
button1_max_iterations: !input button1_holding_max_iterations
button2_max_iterations: !input button2_holding_max_iterations
- parallel:
- repeat:
while:
- condition: template
value_template: '{{ press_type == ''long_press'' and trigger_id == ''button1''
and states(trigger.entity_id) == trigger.to_state.state }}
'
- condition: template
value_template: '{{ repeat.index|default(0) <= button1_max_iterations }}
'
sequence:
- sequence: !input button1_while_holding
- delay:
seconds: !input button1_holding_interval
- repeat:
while:
- condition: template
value_template: '{{ press_type == ''long_press'' and trigger_id == ''button2''
and states(trigger.entity_id) == trigger.to_state.state }}
'
- condition: template
value_template: '{{ repeat.index|default(0) <= button2_max_iterations }}
'
sequence:
- sequence: !input button2_while_holding
- delay:
seconds: !input button2_holding_interval
- choose:
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button1'' and press_type == ''multi_press_1''
}}
'
sequence: !input button1_single
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button1'' and press_type == ''multi_press_2''
}}
'
sequence: !input button1_double
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button1'' and press_type == ''long_press''
}}
'
sequence: !input button1_long_press
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button1'' and press_type == ''long_release''
}}
'
sequence: !input button1_long_release
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button2'' and press_type == ''multi_press_1''
}}
'
sequence: !input button2_single
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button2'' and press_type == ''multi_press_2''
}}
'
sequence: !input button2_double
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button2'' and press_type == ''long_press''
}}
'
sequence: !input button2_long_press
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button2'' and press_type == ''long_release''
}}
'
sequence: !input button2_long_release
@@ -0,0 +1,232 @@
blueprint:
name: IKEA MYGGSPRAY E2494 Motion Sensor (Matter) v2.1
description: 'Full-featured automation for the IKEA MYGGSPRAY E2494 Matter motion
sensor. Supports independent actions for motion, illuminance (high/low), and battery
alerts. '
domain: automation
source_url: https://github.com/aledziko/HA-blueprints/blob/main/IKEA/Matter/ikea-myggspray-e2494/ikea-myggspray-e2494-matter-motion-sensor.yaml
author: aledziko
input:
motion_entity:
name: MYGGSPRAY Motion Sensor
description: Select the MYGGSPRAY motion sensor (binary_sensor).
selector:
entity:
filter:
- device_class:
- occupancy
domain:
- binary_sensor
- device_class:
- motion
domain:
- binary_sensor
reorder: false
multiple: false
use_time_window:
name: Enable Active Hours?
description: Restrict automation to a specific time window.
default: false
selector:
boolean: {}
active_start_time:
name: Active Start Time
description: Automation starts working at this time (if enabled).
default: 08:00:00
selector:
time: {}
active_end_time:
name: Active End Time
description: Automation stops working at this time (if enabled).
default: '22:00:00'
selector:
time: {}
no_motion_wait:
name: Wait time (Motion)
description: Time to wait after last motion is detected before running the "off"
action.
default: 120
selector:
number:
min: 0.0
max: 3600.0
unit_of_measurement: seconds
step: 1.0
mode: slider
motion_on_action:
name: 'Action: Motion Detected'
description: Run when motion is detected (and dark enough).
default: []
selector:
action: {}
motion_off_action:
name: 'Action: Motion Stopped'
description: Run after the sensor stops detecting motion and wait time elapsed.
default: []
selector:
action: {}
illuminance_entity:
name: (Optional) Illuminance Sensor
description: Select the light sensor (sensor.illuminance).
default: []
selector:
entity:
filter:
- domain:
- sensor
device_class:
- illuminance
reorder: false
multiple: false
motion_cutoff:
name: (Optional) Motion Light Cutoff
description: Only trigger "Motion Detected" action if LUX is BELOW this value
(0 to disable).
default: 0
selector:
number:
min: 0.0
max: 3000.0
unit_of_measurement: lux
step: 1.0
mode: slider
illuminance_high_threshold:
name: (Optional) Light High Threshold
description: Trigger "High Action" when LUX goes ABOVE this value (e.g. 500
lx).
default: 500
selector:
number:
min: 0.0
max: 3000.0
unit_of_measurement: lux
step: 1.0
mode: slider
high_action:
name: 'Action: Light High'
description: Run when light exceeds threshold (e.g. close curtains).
default: []
selector:
action: {}
illuminance_low_threshold:
name: (Optional) Light Low Threshold
description: Trigger "Low Action" when LUX goes BELOW this value (e.g. 5 lx).
default: 5
selector:
number:
min: 0.0
max: 3000.0
unit_of_measurement: lux
step: 1.0
mode: slider
low_action:
name: 'Action: Light Low'
description: Run when light drops below threshold (e.g. open curtains).
default: []
selector:
action: {}
battery_entity:
name: (Optional) Battery Sensor
description: Select the battery level sensor (sensor.battery).
default: []
selector:
entity:
filter:
- domain:
- sensor
device_class:
- battery
reorder: false
multiple: false
battery_low_threshold:
name: (Optional) Battery Low Level
description: Threshold for battery alert (%).
default: 30
selector:
number:
min: 0.0
max: 100.0
unit_of_measurement: '%'
step: 1.0
mode: slider
battery_low_action:
name: 'Action: Battery Low'
description: Run when battery drops below threshold.
default: []
selector:
action: {}
mode: parallel
max_exceeded: silent
trigger_variables:
motion_entity_input: !input motion_entity
illuminance_entity_input: !input illuminance_entity
motion_cutoff_input: !input motion_cutoff
high_threshold_input: !input illuminance_high_threshold
low_threshold_input: !input illuminance_low_threshold
battery_entity_input: !input battery_entity
battery_threshold_input: !input battery_low_threshold
use_time_window_input: !input use_time_window
triggers:
- trigger: state
entity_id: !input motion_entity
from: 'off'
to: 'on'
id: motion_on
- trigger: state
entity_id: !input motion_entity
from: 'on'
to: 'off'
for:
seconds: !input no_motion_wait
id: motion_off
- trigger: template
value_template: "{{ illuminance_entity_input != none and \n states(illuminance_entity_input)
| is_number and \n states(illuminance_entity_input) | float(0) > high_threshold_input
| float(100000) }}\n"
id: light_high
- trigger: template
value_template: "{{ illuminance_entity_input != none and \n states(illuminance_entity_input)
| is_number and \n states(illuminance_entity_input) | float(100000) < low_threshold_input
| float(-1) }}\n"
id: light_low
- trigger: template
value_template: "{{ battery_entity_input != none and \n states(battery_entity_input)
| is_number and \n states(battery_entity_input) | float(100) < battery_threshold_input
| float(-1) }}\n"
id: battery_low
condition:
- condition: or
conditions:
- condition: template
value_template: '{{ not use_time_window_input }}'
- condition: time
after: !input active_start_time
before: !input active_end_time
actions:
- choose:
- conditions:
- condition: trigger
id: motion_on
sequence:
- delay: 1.5
- condition: template
value_template: "{% set cutoff = motion_cutoff_input | float(0) %} {% if cutoff
== 0 %}\n true\n{% else %}\n {% set lux = states(illuminance_entity_input)
| float(-1) %}\n {{ lux == -1 or lux <= cutoff }}\n{% endif %}\n"
- sequence: !input motion_on_action
- conditions:
- condition: trigger
id: motion_off
sequence: !input motion_off_action
- conditions:
- condition: trigger
id: light_high
sequence: !input high_action
- conditions:
- condition: trigger
id: light_low
sequence: !input low_action
- conditions:
- condition: trigger
id: battery_low
sequence: !input battery_low_action
@@ -0,0 +1,110 @@
blueprint:
name: ZHA - IKEA Somrig remote dot-buttons control
description: Fully customisable dot-buttons, with options for single, double and
long press for each one.
domain: automation
input:
remote:
name: Remote
description: IKEA Somrig remote to use
selector:
device:
integration: zha
manufacturer: IKEA of Sweden
model: SOMRIG shortcut button
multiple: false
single_dot_single_press:
name: Single dot (Single press)
description: Action to run on single dot press
default: []
selector:
action: {}
single_dot_double_press:
name: Single dot (Double press)
description: Action to run on single dot double press
default: []
selector:
action: {}
single_dot_long_press:
name: Single dot (Long press)
description: Action to run on single dot long press
default: []
selector:
action: {}
single_dot_long_release:
name: Single dot (Release after long press)
description: Action to run on releasing after a long press on the single dot
default: []
selector:
action: {}
double_dot_single_press:
name: Double dot (Single press)
description: Action to run on double dot press
default: []
selector:
action: {}
double_dot_double_press:
name: Double dot (Double press)
description: Action to run on double dot double press
default: []
selector:
action: {}
double_dot_long_press:
name: Double dot (Long press)
description: Action to run on double dot long press
default: []
selector:
action: {}
double_dot_long_release:
name: Double dot (Release after long press)
description: Action to run on releasing after a long press on the double dot
default: []
selector:
action: {}
source_url: https://community.home-assistant.io/t/ikea-somrig-remote-e2213-zha/668671
mode: single
max_exceeded: silent
trigger:
- platform: event
event_type: zha_event
event_data:
device_id: !input remote
action:
- variables:
command: '{{ trigger.event.data.command }}'
cluster_id: '{{ trigger.event.data.cluster_id }}'
endpoint_id: '{{ trigger.event.data.endpoint_id }}'
args: '{{ trigger.event.data.args }}'
- choose:
- conditions:
- '{{ command == ''short_release'' }}'
- '{{ endpoint_id == 1 }}'
sequence: !input single_dot_single_press
- conditions:
- '{{ command == ''multi_press_complete'' }}'
- '{{ endpoint_id == 1 }}'
sequence: !input single_dot_double_press
- conditions:
- '{{ command == ''long_press'' }}'
- '{{ endpoint_id == 1 }}'
sequence: !input single_dot_long_press
- conditions:
- '{{ command == ''long_release'' }}'
- '{{ endpoint_id == 1 }}'
sequence: !input single_dot_long_release
- conditions:
- '{{ command == ''short_release'' }}'
- '{{ endpoint_id == 2 }}'
sequence: !input double_dot_single_press
- conditions:
- '{{ command == ''multi_press_complete'' }}'
- '{{ endpoint_id == 2 }}'
sequence: !input double_dot_double_press
- conditions:
- '{{ command == ''long_press'' }}'
- '{{ endpoint_id == 2 }}'
sequence: !input double_dot_long_press
- conditions:
- '{{ command == ''long_release'' }}'
- '{{ endpoint_id == 2 }}'
sequence: !input double_dot_long_release
@@ -0,0 +1,506 @@
blueprint:
name: Ikea_bilresa_scroll_wheel
description: 'Blueprint for Ikea Bilresa (button and scroll wheel for 3 different
channels)
Version: 2025-01-01
'
domain: automation
input:
remote:
name: Remote
description: Ikea Bilresa scroll wheel remote control
selector:
device:
multiple: false
channel1_section:
name: Channel 1
collapsed: true
input:
click_action_ch1:
name: 'Channel 1: <click> action'
default: []
selector:
action: {}
double_click_action_ch1:
name: 'Channel 1: <double-click> action'
default: []
selector:
action: {}
long_click_action_ch1:
name: 'Channel 1: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch1:
name: 'Channel 1: <on-hold> action'
description: repeat action until button is released, see also on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch1:
name: 'Channel 1: Scroll wheel target entity'
description: Light or media player to be controlled by scroll wheel for
channel 1
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
default: []
scroll_wheel_mode_ch1:
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
custom_value: false
sort: false
multiple: false
default: dim_only
name: 'Channel 1: Scroll wheel mode'
description: Select attribute of target entity controlled by scroll wheel
channel2_section:
name: Channel 2
collapsed: true
input:
click_action_ch2:
name: 'Channel 2: <click> action'
default: []
selector:
action: {}
double_click_action_ch2:
name: 'Channel 2: <double-click> action'
default: []
selector:
action: {}
long_click_action_ch2:
name: 'Channel 2: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch2:
name: 'Channel 2: <on-hold> action'
description: repeat action until button is released, see also on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch2:
name: 'Channel 2: Scroll wheel target entity'
description: Light or media player to be controlled by scroll wheel for
channel 2
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
default: []
scroll_wheel_mode_ch2:
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
custom_value: false
sort: false
multiple: false
default: dim_only
name: 'Channel 2: Scroll wheel mode'
description: Select attribute of target entity controlled by scroll wheel
channel3_section:
name: Channel 3
collapsed: true
input:
click_action_ch3:
name: 'Channel 3: <click> action'
default: []
selector:
action: {}
double_click_action_ch3:
name: 'Channel 3: <double-click> action'
default: []
selector:
action: {}
long_click_action_ch3:
name: 'Channel 3: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch3:
name: 'Channel 3: <on-hold> action'
description: repeat action until button is released, see also on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch3:
name: 'Channel 3: Scroll wheel target entity'
description: Light or media player to be controlled by scroll wheel for
channel 3
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
default: []
scroll_wheel_mode_ch3:
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
custom_value: false
sort: false
multiple: false
default: dim_only
name: 'Channel 3: Scroll wheel mode'
description: Select attribute of target entity controlled by scroll wheel
global_section:
name: Global settings
collapsed: true
input:
dim_step_pct:
name: Dimmer step percentage
description: change in brightness in %
default: 5
selector:
number:
mode: box
step: 1.0
dim_min_pct:
name: Minimum brightness
description: minimum brightness in %
default: 5
selector:
number:
mode: box
step: 1.0
min_color_temp:
name: Minimum color temperature
description: minimum color temperature in K (for color_mode = color_temp)
default: 2200
selector:
number:
mode: box
step: 1.0
max_color_temp:
name: Maximum color temperature
description: maximum color temperature in K (for color_mode = color_temp)
default: 4000
selector:
number:
mode: box
step: 1.0
color_temp_step:
name: Color temperature step
description: change in color temperature in K (for color_mode = color_temp)
default: 100
selector:
number:
mode: box
step: 1.0
color_hue_step:
name: Color hue step
description: change in color hue in degree (0..360) (for color_mode = color_hue)
default: 4
selector:
number:
mode: box
step: 1.0
color_saturation:
name: Color saturation
description: color saturation in % (for color_mode = color_hue)
default: 100
selector:
number:
mode: box
step: 1.0
on_hold_delay:
name: On hold delay
description: delay between separate action calls while on hold button
default: 0.1
selector:
number:
mode: box
step: 0.1
volume_step_pct:
name: Media player volume step percentage
description: change in volume in %
default: 2
selector:
number:
mode: box
step: 1.0
volume_max_pct:
name: Media player max volume percentage
description: max volume in %
default: 50
selector:
number:
mode: box
step: 1.0
source_url: https://gist.github.com/jhol-byte/b2731a4d2476f530d76b9ff409f7f3a4
alias: Ikea_bilresa_scroll_wheel
trigger:
- event_type: state_changed
event_data:
entity_id: '{{ var_button_ch1 }}'
trigger: event
id: button_ch1
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ch1 }}'
trigger: event
id: scroll_left_ch1
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ch1 }}'
trigger: event
id: scroll_right_ch1
- event_type: state_changed
event_data:
entity_id: '{{ var_button_ch2 }}'
trigger: event
id: button_ch2
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ch2 }}'
trigger: event
id: scroll_left_ch2
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ch2 }}'
trigger: event
id: scroll_right_ch2
- event_type: state_changed
event_data:
entity_id: '{{ var_button_ch3 }}'
trigger: event
id: button_ch3
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ch3 }}'
trigger: event
id: scroll_left_ch3
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ch3 }}'
trigger: event
id: scroll_right_ch3
condition:
- condition: template
value_template: "{{ trigger.event.data.old_state.state != 'unavailable' \n and
trigger.event.data.new_state.state != 'unavailable'\n}}"
action:
- variables:
channel: "{% if trigger.id in ['button_ch1', 'scroll_left_ch1', 'scroll_right_ch1']
%}\n ch1\n{% elif trigger.id in ['button_ch2', 'scroll_left_ch2', 'scroll_right_ch2']
%}\n ch2\n{% elif trigger.id in ['button_ch3', 'scroll_left_ch3', 'scroll_right_ch3']
%}\n ch3\n{% endif %}"
- choose:
- conditions:
- condition: template
value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type
== ''multi_press_1'' }}
'
sequence:
- if: '{{ channel == ''ch1''}}'
then: !input click_action_ch1
- if: '{{ channel == ''ch2''}}'
then: !input click_action_ch2
- if: '{{ channel == ''ch3''}}'
then: !input click_action_ch3
- conditions:
- condition: template
value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type
== ''multi_press_2'' }}
'
sequence:
- if: '{{ channel == ''ch1''}}'
then: !input double_click_action_ch1
- if: '{{ channel == ''ch2''}}'
then: !input double_click_action_ch2
- if: '{{ channel == ''ch3''}}'
then: !input double_click_action_ch3
- conditions:
- condition: template
value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type
== ''long_press'' }}
'
sequence:
- if: '{{ channel == ''ch1''}}'
then:
sequence:
- sequence: !input long_click_action_ch1
- repeat:
while: '{{ repeat.index < 100 and state_attr(var_button_ch1, ''event_type'')
== ''long_press'' }}'
sequence:
- sequence: !input on_hold_action_ch1
- delay: '{{ var_on_hold_delay }}'
- if: '{{ channel == ''ch2''}}'
then:
sequence:
- sequence: !input long_click_action_ch2
- repeat:
while: '{{ repeat.index < 100 and state_attr(var_button_ch2, ''event_type'')
== ''long_press'' }}'
sequence:
- sequence: !input on_hold_action_ch2
- delay: '{{ var_on_hold_delay }}'
- if: '{{ channel == ''ch3''}}'
then:
sequence:
- sequence: !input long_click_action_ch3
- repeat:
while: '{{ repeat.index < 100 and state_attr(var_button_ch3, ''event_type'')
== ''long_press'' }}'
sequence:
- sequence: !input on_hold_action_ch3
- delay: '{{ var_on_hold_delay }}'
- conditions:
- condition: template
value_template: '{{ trigger.id == (''scroll_left_'' ~ channel) or trigger.id
== (''scroll_right_'' ~ channel) }}
'
sequence:
- repeat:
for_each: '{{ vars[''targets_entities_'' ~ channel] }}'
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ vars[''scroll_wheel_mode_'' ~ channel] == ''lights:
dim''}}'
sequence:
- if:
- condition: template
value_template: '{{ is_state(repeat.item, ''on'') }}'
then:
- action: light.turn_on
target:
entity_id: '{{ repeat.item }}'
data:
brightness_step_pct: "{{ iif(trigger.id == 'scroll_right_' ~ channel,
trigger.event.data.new_state.attributes.totalNumberOfPressesCounted
* var_dim_step_pct, -1*([trigger.event.data.new_state.attributes.totalNumberOfPressesCounted
* var_dim_step_pct, state_attr(repeat.item, 'brightness')*100/254\n
- var_min_pct]|min)) \n}}"
transition: 0.1
- conditions:
- condition: template
value_template: '{{ vars[''scroll_wheel_mode_'' ~ channel] == ''lights:
color temp only'' or (vars[''scroll_wheel_mode_'' ~ channel] == ''lights:
color temp and color hue'' and state_attr(repeat.item, ''color_temp_kelvin'')
is not none ) }}'
sequence:
- action: script.light_color_temp_helper
data:
mode: '{{ iif(trigger.id == ''scroll_left_'' ~ channel, ''down'',
''up'') }}'
min_color_temp: '{{ var_min_color_temp }}'
max_color_temp: '{{ var_max_color_temp }}'
color_temp_step: '{{ var_color_temp_step * trigger.event.data.new_state.attributes.totalNumberOfPressesCounted
}}'
target_light: '{{ repeat.item }}'
- conditions:
- condition: template
value_template: '{{ vars[''scroll_wheel_mode_'' ~ channel] == ''lights:
color hue only'' or (vars[''scroll_wheel_mode_'' ~ channel] == ''lights:
color temp and color hue'' and state_attr(repeat.item, ''color_temp_kelvin'')
is none ) }}'
sequence:
- action: script.light_color_hs_helper
data:
target_light: '{{ repeat.item }}'
color_hue_step: '{{ var_color_hue_step * trigger.event.data.new_state.attributes.totalNumberOfPressesCounted
}}'
color_saturation: '{{ var_color_saturation }}'
mode: '{{ iif(trigger.id == ''scroll_left_'' ~ channel, ''down'',
''up'') }}'
- conditions:
- condition: template
value_template: '{{ vars[''scroll_wheel_mode_'' ~ channel] == ''media:
volume control'' and is_state(repeat.item, ''playing'')}}'
sequence:
- action: media_player.volume_set
target:
entity_id: '{{ repeat.item }}'
data:
volume_level: "{{ [[state_attr(repeat.item, 'volume_level')*100 +
\n iif(trigger.id == 'scroll_right_' ~ channel, +1, -1)* trigger.event.data.new_state.attributes.totalNumberOfPressesCounted
* var_volume_step_pct, var_volume_max_pct] | min, 0] | max / 100\n}}"
mode: single
max_exceeded: silent
variables:
vars:
targets_entities_ch1: !input scroll_wheel_target_ch1
targets_entities_ch2: !input scroll_wheel_target_ch2
targets_entities_ch3: !input scroll_wheel_target_ch3
scroll_wheel_mode_ch1: !input scroll_wheel_mode_ch1
scroll_wheel_mode_ch2: !input scroll_wheel_mode_ch2
scroll_wheel_mode_ch3: !input scroll_wheel_mode_ch3
var_min_pct: !input dim_min_pct
var_dim_step_pct: !input dim_step_pct
var_color_hue_step: !input color_hue_step
var_color_saturation: !input color_saturation
var_min_color_temp: !input min_color_temp
var_max_color_temp: !input max_color_temp
var_color_temp_step: !input color_temp_step
var_on_hold_delay: !input on_hold_delay
var_volume_step_pct: !input volume_step_pct
var_volume_max_pct: !input volume_max_pct
trigger_variables:
var_remote: !input remote
var_button_ch1: "{% for item in device_entities(var_remote) %} {% set match = item
| regex_findall(find='^event\\..+[a-zA-Z]_3.*$') %} {% if (match | length) > 0
%}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ch1: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_2.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ch1: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_1.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_button_ch2: "{% for item in device_entities(var_remote) %} {% set match = item
| regex_findall(find='^event\\..+[a-zA-Z]_6.*$') %} {% if (match | length) > 0
%}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ch2: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_5.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ch2: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_4.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_button_ch3: "{% for item in device_entities(var_remote) %} {% set match = item
| regex_findall(find='^event\\..+[a-zA-Z]_9.*$') %} {% if (match | length) > 0
%}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ch3: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_8.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ch3: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_7.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
@@ -0,0 +1,179 @@
blueprint:
source_url: https://github.com/niro1987/homeassistant-config/blob/main/blueprints/automation/niro1987/zha_ikea_tradfri_styrbar_color.yaml
name: ZHA - IKEA TRADFRI - STYRBAR - Color Lights
description: This automation simulates the use of the IKEA TRADFRI STYRBAR remote
control connected through ZHA.
domain: automation
input:
remote:
name: IKEA TRADFRI remote control
description: Select the remote control you wish to use.
selector:
device:
filter:
- integration: zha
manufacturer: IKEA of Sweden
model: Remote Control N2
multiple: false
light:
name: Light
description: Select the light entity you wish to control.
selector:
entity:
filter:
- domain:
- light
multiple: false
speed:
name: Speed
description: The speed in which to update the light when the button is held.
selector:
number:
min: 100.0
max: 1000.0
step: 100.0
unit_of_measurement: milliseconds
mode: slider
default: 500
mode: restart
max_exceeded: silent
variables:
var_light: !input light
var_speed: !input speed
trigger:
- platform: event
event_type: zha_event
event_data:
device_id: !input remote
action:
- choose:
- conditions:
- condition: template
value_template: '{{ trigger.event.data.command == "on" }}'
- condition: state
entity_id: !input light
state: 'off'
sequence:
- service: light.turn_on
target:
entity_id: !input light
data:
brightness: 254
hs_color:
- 38.222
- 52.941
transition: '{{ (var_speed / 1000)|float }}'
- conditions:
- condition: template
value_template: '{{ trigger.event.data.command == "move_with_on_off" }}'
sequence:
- repeat:
while: []
sequence:
- service: light.turn_on
target:
entity_id: !input light
data:
brightness_step_pct: 10
transition: '{{ (var_speed / 1000)|float }}'
- delay:
milliseconds: !input speed
- conditions:
- condition: template
value_template: '{{ trigger.event.data.command == "off" }}'
sequence:
- service: light.turn_off
target:
entity_id: !input light
data:
transition: '{{ (var_speed / 1000)|float }}'
- conditions:
- condition: template
value_template: '{{ trigger.event.data.command == "move" }}'
sequence:
- repeat:
while: []
sequence:
- service: light.turn_on
target:
entity_id: !input light
data:
brightness_step_pct: -10
transition: '{{ (var_speed / 1000)|float }}'
- delay:
milliseconds: !input speed
- conditions:
- condition: template
value_template: '{{ trigger.event.data.command == "press" }}'
- condition: template
value_template: '{{ trigger.event.data.args == [257,13,0] }}'
sequence:
- service: light.turn_on
target:
entity_id: !input light
data:
hs_color:
- '{{ state_attr(var_light, "hs_color")[0] }}'
- "{% if state_attr(var_light, \"hs_color\")[1] - 20 < 0 %}\n {{ state_attr(var_light,
\"hs_color\")[1] - 20 + 100 }}\n{% else %}\n {{ state_attr(var_light, \"hs_color\")[1]
- 20 }}\n{% endif %}"
transition: '{{ (var_speed / 1000)|float }}'
- conditions:
- condition: template
value_template: '{{ trigger.event.data.command == "hold" }}'
- condition: template
value_template: '{{ trigger.event.data.args == [3329,0] }}'
sequence:
- repeat:
while: []
sequence:
- service: light.turn_on
target:
entity_id: !input light
data:
hs_color:
- '{{ state_attr(var_light, "hs_color")[0] }}'
- "{% if state_attr(var_light, \"hs_color\")[1] - 10 < 0 %}\n {{ state_attr(var_light,
\"hs_color\")[1] - 10 + 100 }}\n{% else %}\n {{ state_attr(var_light,
\"hs_color\")[1] - 10 }}\n{% endif %}"
transition: '{{ (var_speed / 1000)|float }}'
- delay:
milliseconds: !input speed
- conditions:
- condition: template
value_template: '{{ trigger.event.data.command == "press" }}'
- condition: template
value_template: '{{ trigger.event.data.args == [256,13,0] }}'
sequence:
- service: light.turn_on
target:
entity_id: !input light
data:
hs_color:
- "{% if state_attr(var_light, \"hs_color\")[0] + 18 > 360 %}\n {{ state_attr(var_light,
\"hs_color\")[0] + 18 - 360 }}\n{% else %}\n {{ state_attr(var_light, \"hs_color\")[0]
+ 18 }}\n{% endif %}"
- '{{ state_attr(var_light, "hs_color")[1] }}'
transition: '{{ (var_speed / 1000)|float }}'
- conditions:
- condition: template
value_template: '{{ trigger.event.data.command == "hold" }}'
- condition: template
value_template: '{{ trigger.event.data.args == [3328,0] }}'
sequence:
- repeat:
while: []
sequence:
- service: light.turn_on
target:
entity_id: !input light
data:
hs_color:
- "{% if state_attr(var_light, \"hs_color\")[0] + 18 > 360 %}\n {{ state_attr(var_light,
\"hs_color\")[0] + 18 - 360 }}\n{% else %}\n {{ state_attr(var_light,
\"hs_color\")[0] + 18 }}\n{% endif %}"
- '{{ state_attr(var_light, "hs_color")[1] }}'
transition: '{{ (var_speed / 1000)|float }}'
- delay:
milliseconds: !input speed
default: []
@@ -0,0 +1,27 @@
blueprint:
name: Invert a binary sensor
description: Creates a binary_sensor which holds the inverted value of a reference binary_sensor
domain: template
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/inverted_binary_sensor.yaml
input:
reference_entity:
name: Binary sensor to be inverted
description: The binary_sensor which needs to have its value inverted
selector:
entity:
domain: binary_sensor
variables:
reference_entity: !input reference_entity
binary_sensor:
state: >
{% if states(reference_entity) == 'on' %}
off
{% elif states(reference_entity) == 'off' %}
on
{% else %}
{{ states(reference_entity) }}
{% endif %}
# delay_on: not_used in this example
# delay_off: not_used in this example
# auto_off: not_used in this example
availability: "{{ states(reference_entity) not in ('unknown', 'unavailable') }}"
-6
View File
@@ -1,6 +0,0 @@
- sensor:
# Raspberry Pi CPU temp
name: "CPU Temp"
command: "cat /sys/class/thermal/thermal_zone0/temp"
unit_of_measurement: "°C"
value_template: "{{ value | multiply(0.001) | round(1) }}"
+24 -23
View File
@@ -11,15 +11,13 @@ http:
tts: tts:
- platform: picotts_remote - platform: picotts_remote
language: "de-DE" language: "de-DE"
- platform: google_translate
language: "de"
# Include modules # Include modules
group: !include groups.yaml group: !include groups.yaml
automation: !include automations.yaml automation: !include automations.yaml
automation webhooks: !include automations_webhooks.yaml
script: !include scripts.yaml script: !include scripts.yaml
scene: !include scenes.yaml scene: !include scenes.yaml
notify: !include notify.yaml
# Enable additional integrations # Enable additional integrations
# Enable 'wake_on_lan' intrgration # Enable 'wake_on_lan' intrgration
@@ -31,49 +29,45 @@ homeassistant:
# Additional sensors # Additional sensors
sensor: !include sensors.yaml sensor: !include sensors.yaml
command_line: !include commandline.yaml
utility_meter: !include utility_meters.yaml utility_meter: !include utility_meters.yaml
# MQTT sensors # MQTT sensors
mqtt: !include mqtt.yaml mqtt: !include mqtt.yaml
mqtt_statestream: !include mqtt_statestream.yaml
# Template sensors # Template sensors
template: !include template.yaml template: !include template.yaml
# Database size sensor
sql:
- name: "Home Assistant Datenbankgröße"
db_url: !secret ha_recorder_dburl
query: 'SELECT table_schema "database", Round(Sum(data_length + index_length) / 1048576, 2) "value" FROM information_schema.tables WHERE table_schema="homeassistant" GROUP BY table_schema;'
column: 'value'
unit_of_measurement: MB
# calendar integration # calendar integration
calendar: !include calendars.yaml calendar: !include calendars.yaml
# Helpers for epaperframe calendar display
input_text: !include text_helpers.yaml
# DB-recorder configuration # DB-recorder configuration
recorder: !include recorder.yaml recorder: !include recorder.yaml
# Home Assistant InfluxDB integration
influxdb:
host: a0d7b954-influxdb
port: 8086
database: homeassistant
username: homeassistant
password: !secret influx_pass
max_retries: 3
default_measurement: state
# Enable Bluetooth # Enable Bluetooth
bluetooth: bluetooth:
# Bluetooth Low Energy tracker # Bluetooth Low Energy tracker
device_tracker: device_tracker:
- platform: bluetooth_le_tracker - platform: bluetooth_le_tracker
track_new_devices: true track_new_devices: false
# Configure MPD addon as media player
media_player:
platform: mpd
host: 192.168.122.48
scan_interval: 1
generic_hygrostat: generic_hygrostat:
- name: Badezimmer - name: Badezimmer
unique_id: '3728344225387'
humidifier: fan.badezimmer_ventilator humidifier: fan.badezimmer_ventilator
target_sensor: sensor.badezimmer_luftfeuchtigkeit target_sensor: sensor.bathroom_badezimmer_luftfeuchtigkeit
min_humidity: 30 min_humidity: 30
max_humidity: 70 max_humidity: 70
target_humidity: 50 target_humidity: 50
@@ -89,3 +83,10 @@ generic_hygrostat:
away_fixed: true away_fixed: true
sensor_stale_duration: 00:15:00 sensor_stale_duration: 00:15:00
frontend:
themes: !include_dir_merge_named themes
freedns:
url: !secret freedns6_url
# - name: IPv4
# url: !secret freedns4_url
@@ -0,0 +1,76 @@
"""The Länderübergreifendes Hochwasser Portal integration."""
from __future__ import annotations
from lhpapi import HochwasserPortalAPI, LHPError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import (
CONF_ADD_UNAVAILABLE,
CONF_PEGEL_IDENTIFIER,
DOMAIN,
LOGGER,
PLATFORMS,
)
from .coordinator import HochwasserPortalCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
pegel_identifier: str = entry.data[CONF_PEGEL_IDENTIFIER]
# Initialize the API and coordinator.
try:
api = await hass.async_add_executor_job(HochwasserPortalAPI, pegel_identifier)
coordinator = HochwasserPortalCoordinator(hass, api)
except LHPError as err:
LOGGER.exception("Setup of %s failed: %s", pegel_identifier, err)
return False
# No need to refresh via the following line because api runs
# update during init automatically
# await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
LOGGER.debug(
"Migrating %s from version %s.%s",
config_entry.title,
config_entry.version,
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
new = {**config_entry.data}
if config_entry.minor_version < 2:
new[CONF_ADD_UNAVAILABLE] = True # Behaviour as in 1.1
config_entry.minor_version = 2
hass.config_entries.async_update_entry(config_entry, data=new)
LOGGER.debug(
"Migration of %s to version %s.%s successful",
config_entry.title,
config_entry.version,
config_entry.minor_version,
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -0,0 +1,85 @@
"""Config flow for the hochwasserportal integration."""
from __future__ import annotations
from typing import Any
from lhpapi import HochwasserPortalAPI, LHPError, get_all_stations
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_ADD_UNAVAILABLE, CONF_PEGEL_IDENTIFIER, DOMAIN, LOGGER
class HochwasserPortalConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the config flow for the hochwasserportal integration."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict = {}
if user_input is not None:
pegel_identifier = user_input[CONF_PEGEL_IDENTIFIER]
# Validate pegel identifier using the API
try:
api = await self.hass.async_add_executor_job(
HochwasserPortalAPI, pegel_identifier
)
LOGGER.debug(
"%s (%s): Successfully added!",
api.ident,
api.name,
)
except LHPError as err:
LOGGER.exception("Setup of %s failed: %s", pegel_identifier, err)
errors["base"] = "invalid_identifier"
if not errors:
# Set the unique ID for this config entry.
await self.async_set_unique_id(f"{DOMAIN}_{pegel_identifier.lower()}")
self._abort_if_unique_id_configured()
return self.async_create_entry(title=f"{api.name}", data=user_input)
stations_dict = await self.hass.async_add_executor_job(get_all_stations)
LOGGER.debug(
"%i stations found on Github",
len(stations_dict),
)
stations = [SelectOptionDict(value="---", label="")]
stations.extend(
SelectOptionDict(value=k, label=f"{v} ({k})")
for k, v in stations_dict.items()
)
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(CONF_PEGEL_IDENTIFIER): SelectSelector(
SelectSelectorConfig(
options=stations,
mode=SelectSelectorMode.DROPDOWN,
sort=True,
custom_value=True,
)
),
vol.Required(CONF_ADD_UNAVAILABLE, default=False): cv.boolean,
}
),
)
@@ -0,0 +1,46 @@
"""Constants for the Länderübergreifendes Hochwasser Portal integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Final
from homeassistant.const import Platform
LOGGER = logging.getLogger(__package__)
DOMAIN: Final = "hochwasserportal"
CONF_PEGEL_IDENTIFIER: Final = "pegel_identifier"
CONF_ADD_UNAVAILABLE: Final = "add_unavailable"
ATTR_DATA_PROVIDERS: Final[dict[str, str]] = {
"BB": "LfU Brandenburg",
"BE": "SenMVKU Berlin",
"BW": "LUBW Baden-Württemberg",
"BY": "LfU Bayern",
"HB": "SUKW Bremen",
"HE": "HLNUG",
"HH": "LSBG Hamburg",
"MV": "LUNG Mecklenburg-Vorpommern",
"NI": "NLWKN",
"NW": "LANUV Nordrhein-Westfalen",
"RP": "Luf Rheinland-Pfalz",
"SH": "Luf Schleswig-Holstein",
"SL": "LUA Saarland",
"SN": "LfULG Sachsen",
"ST": "Land Sachsen-Anhalt",
"TH": "TLUBN",
}
ATTR_LAST_UPDATE: Final = "last_update"
ATTR_URL: Final = "url"
ATTR_HINT: Final = "hint"
LEVEL_SENSOR: Final = "level"
STAGE_SENSOR: Final = "stage"
FLOW_SENSOR: Final = "flow"
DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=15)
PLATFORMS: Final[list[Platform]] = [Platform.SENSOR]
@@ -0,0 +1,32 @@
"""Data coordinator for the hochwasserportal integration."""
from __future__ import annotations
from lhpapi import HochwasserPortalAPI, LHPError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
class HochwasserPortalCoordinator(DataUpdateCoordinator[None]):
"""Custom coordinator for the hochwasserportal integration."""
def __init__(self, hass: HomeAssistant, api: HochwasserPortalAPI) -> None:
"""Initialize the hochwasserportal coordinator."""
super().__init__(
hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL
)
self.api = api
LOGGER.debug("%s", repr(self.api))
async def _async_update_data(self) -> None:
"""Get the latest data from the hochwasserportal API."""
try:
await self.hass.async_add_executor_job(self.api.update)
LOGGER.debug("%s", repr(self.api))
except LHPError as err:
LOGGER.exception("Update of %s failed: %s", self.api.ident, err)
return False
@@ -0,0 +1,14 @@
{
"domain": "hochwasserportal",
"name": "Länderübergreifendes Hochwasser Portal",
"codeowners": ["@stephan192"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/stephan192/hochwasserportal",
"integration_type": "device",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/stephan192/hochwasserportal/issues",
"loggers": ["hochwasserportal"],
"requirements": ["lhpapi==1.0.5"],
"version": "1.0.3"
}
@@ -0,0 +1,148 @@
"""Platform for sensor integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from lhpapi import HochwasserPortalAPI
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_DATA_PROVIDERS,
ATTR_HINT,
ATTR_LAST_UPDATE,
ATTR_URL,
CONF_ADD_UNAVAILABLE,
DOMAIN,
FLOW_SENSOR,
LEVEL_SENSOR,
LOGGER,
STAGE_SENSOR,
)
from .coordinator import HochwasserPortalCoordinator
@dataclass(frozen=True, kw_only=True)
class HochwasserPortalSensorEntityDescription(SensorEntityDescription):
"""Describes HochwasserPortal sensor entity."""
value_fn: Callable[[HochwasserPortalAPI], int | float | None]
available_fn: Callable[[HochwasserPortalAPI], bool]
SENSOR_TYPES: tuple[HochwasserPortalSensorEntityDescription, ...] = (
HochwasserPortalSensorEntityDescription(
key=LEVEL_SENSOR,
translation_key=LEVEL_SENSOR,
icon="mdi:waves",
native_unit_of_measurement=UnitOfLength.CENTIMETERS,
device_class=None,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda api: api.level,
available_fn=lambda api: api.level is not None,
),
HochwasserPortalSensorEntityDescription(
key=STAGE_SENSOR,
translation_key=STAGE_SENSOR,
icon="mdi:waves-arrow-up",
native_unit_of_measurement=None,
device_class=None,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda api: api.stage,
available_fn=lambda api: api.stage is not None,
),
HochwasserPortalSensorEntityDescription(
key=FLOW_SENSOR,
translation_key=FLOW_SENSOR,
icon="mdi:waves-arrow-right",
native_unit_of_measurement="m³/s",
device_class=None,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda api: api.flow,
available_fn=lambda api: api.flow is not None,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up entities from config entry."""
coordinator: HochwasserPortalCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
HochwasserPortalSensor(coordinator, entry, description)
for description in SENSOR_TYPES
if description.available_fn(coordinator.api)
or entry.data.get(CONF_ADD_UNAVAILABLE, False)
]
)
class HochwasserPortalSensor(
CoordinatorEntity[HochwasserPortalCoordinator], SensorEntity
):
"""Sensor representation."""
entity_description: HochwasserPortalSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: HochwasserPortalCoordinator,
entry: ConfigEntry,
description: HochwasserPortalSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.api = coordinator.api
self.entity_description = description
self._attr_unique_id = f"{entry.unique_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"{entry.title}",
configuration_url=self.api.url,
manufacturer=f"{ATTR_DATA_PROVIDERS[self.api.ident[:2]]}",
model=f"{self.api.ident}",
)
self._attr_attribution = (
f"Data provided by {ATTR_DATA_PROVIDERS[self.api.ident[:2]]}"
)
LOGGER.debug("Setting up sensor: %s", self._attr_unique_id)
@property
def native_value(self):
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.api)
@property
def extra_state_attributes(self):
"""Return the state attributes of the sensor."""
data = {}
if self.api.last_update is not None:
data[ATTR_LAST_UPDATE] = self.api.last_update
if self.api.url is not None:
data[ATTR_URL] = self.api.url
if self.api.hint is not None:
data[ATTR_HINT] = self.api.hint
if bool(data):
return data
return None
@property
def available(self) -> bool:
"""Could the device be accessed during the last update call."""
return self.entity_description.available_fn(self.api)
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"description": "To identify the pegel, the pegel ID is required.",
"data": {
"pegel_identifier": "Pegel ID",
"add_unavailable": "Add unavailable entities anyway"
}
}
},
"error": {
"invalid_identifier": "The specified pegel identifier is invalid."
},
"abort": {
"already_configured": "Pegel is already configured.",
"invalid_identifier": "[%key:component::hochwasserportal::config::error::invalid_identifier%]"
}
},
"entity": {
"sensor": {
"level": {
"name": "Level"
},
"stage": {
"name": "Stage"
},
"flow": {
"name": "Flow"
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"description": "Um den Pegel zu identifizieren, ist die Pegel-ID erforderlich.",
"data": {
"pegel_identifier": "Pegel ID",
"add_unavailable": "Nicht verfügbare Entitäten trotzdem hinzufügen"
}
}
},
"error": {
"invalid_identifier": "Der angegebene Pegel ist ungültig."
},
"abort": {
"already_configured": "Pegel bereits konfiguriert.",
"invalid_identifier": "[%key:component::hochwasserportal::config::error::invalid_identifier%]"
}
},
"entity": {
"sensor": {
"level": {
"name": "Pegelstand"
},
"stage": {
"name": "Warnstufe"
},
"flow": {
"name": "Abfluss"
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"description": "To identify the pegel, the pegel ID is required.",
"data": {
"pegel_identifier": "Pegel ID",
"add_unavailable": "Add unavailable entities anyway"
}
}
},
"error": {
"invalid_identifier": "The specified pegel identifier is invalid."
},
"abort": {
"already_configured": "Pegel is already configured.",
"invalid_identifier": "[%key:component::hochwasserportal::config::error::invalid_identifier%]"
}
},
"entity": {
"sensor": {
"level": {
"name": "Level"
},
"stage": {
"name": "Stage"
},
"flow": {
"name": "Flow"
}
}
}
}
@@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Home Assistant Setup**
Please indicate your version of HA and how it is installed.
Version:
Installation Type (put an X between the square brackets for your HA):
[] Home Assistant OS
[] Home Assistant Supervised
[] Home Assistant Container
[] Home Assistant Core
Hardware platform:
[] ARM
[] x86-64
Are you running in a container environment like Docker or Kubernetes?
[] Yes
[] No
If running in a container, how is your image built?
[] Official HA container image
[] Official HA container image with customizations
[] Custom built container image
**Describe the bug**
A clear and concise description of the bug
+14
View File
@@ -0,0 +1,14 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
day: "monday"
reviewers:
- "franc6"
@@ -0,0 +1,10 @@
Fixes #
Description of change:
## Formatting, testing, and code coverage
Please note your pull request won't be accepted if you haven't properly formatted your source code, and ensured the unit tests are appropriate. Please note if you are not running on Windows, you can either run the scripts via a bash installation (like git-bash).
- [] formatstyle.sh reports no errors
- [] All unit tests pass (test.sh)
- [] Code coverage has not decreased (test.sh)
@@ -0,0 +1,21 @@
name: Validate with hassfest and run HACS action
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: '0 0 * * *'
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- name: Checkout
uses: "actions/checkout@v4"
- name: Validate with hassfest
uses: "home-assistant/actions/hassfest@master"
- name: HACS Action
uses: "hacs/action@main"
with:
category: integration
@@ -0,0 +1,46 @@
name: Lint
on:
pull_request:
branches: [releases]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.4.20"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install dependencies
run: |
uv sync --prerelease=allow --dev --extra tests
- name: Run isort --check
run: |
uv run --prerelease=allow isort --check custom_components/ics_calendar tests
- name: Run black --check
run: |
uv run --prerelease=allow black --check custom_components/ics_calendar tests
- name: Run flake8
run: |
uv run --prerelease=allow flake8
- name: Run pydocstyle
run: |
uv run --prerelease=allow pydocstyle -v custom_components/ics_calendar tests
- name: Run pylint
run: |
uv run --prerelease=allow pylint custom_components/ics_calendar
@@ -0,0 +1,44 @@
name: Run Tests
on:
push:
branches: [releases]
pull_request:
branches: [releases]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Set timezone
run: |
sudo timedatectl set-timezone America/New_York
timedatectl
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.4.20"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install dependencies
run: |
uv sync --prerelease=allow --extra tests
- name: Run pytest
run: |
PYTHONDONTWRITEBYTECODE=1 uv run --prerelease=allow pytest tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+260
View File
@@ -0,0 +1,260 @@
"""ics Calendar for Home Assistant."""
import logging
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_EXCLUDE,
CONF_INCLUDE,
CONF_NAME,
CONF_PASSWORD,
CONF_PREFIX,
CONF_URL,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import discovery
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
)
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_ACCEPT_HEADER,
CONF_ADV_CONNECT_OPTS,
CONF_CALENDARS,
CONF_CONNECTION_TIMEOUT,
CONF_DAYS,
CONF_DOWNLOAD_INTERVAL,
CONF_INCLUDE_ALL_DAY,
CONF_OFFSET_HOURS,
CONF_PARSER,
CONF_REQUIRES_AUTH,
CONF_SET_TIMEOUT,
CONF_SUMMARY_DEFAULT,
CONF_SUMMARY_DEFAULT_DEFAULT,
CONF_USER_AGENT,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CALENDAR]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
# pylint: disable=no-value-for-parameter
vol.Optional(CONF_CALENDARS, default=[]): vol.All(
cv.ensure_list,
vol.Schema(
[
vol.Schema(
{
vol.Required(CONF_URL): vol.Url(),
vol.Required(CONF_NAME): cv.string,
vol.Optional(
CONF_INCLUDE_ALL_DAY, default=False
): cv.boolean,
vol.Optional(
CONF_USERNAME, default=""
): cv.string,
vol.Optional(
CONF_PASSWORD, default=""
): cv.string,
vol.Optional(
CONF_PARSER, default="rie"
): cv.string,
vol.Optional(
CONF_PREFIX, default=""
): cv.string,
vol.Optional(
CONF_DAYS, default=1
): cv.positive_int,
vol.Optional(
CONF_DOWNLOAD_INTERVAL, default=15
): cv.positive_int,
vol.Optional(
CONF_USER_AGENT, default=""
): cv.string,
vol.Optional(
CONF_EXCLUDE, default=""
): cv.string,
vol.Optional(
CONF_INCLUDE, default=""
): cv.string,
vol.Optional(
CONF_OFFSET_HOURS, default=0
): int,
vol.Optional(
CONF_ACCEPT_HEADER, default=""
): cv.string,
vol.Optional(
CONF_CONNECTION_TIMEOUT, default=300
): cv.positive_float,
vol.Optional(
CONF_SUMMARY_DEFAULT,
default=CONF_SUMMARY_DEFAULT_DEFAULT,
): cv.string,
}
)
]
),
)
}
)
},
extra=vol.ALLOW_EXTRA,
)
STORAGE_KEY = DOMAIN
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 0
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up calendars."""
_LOGGER.debug("Setting up ics_calendar component")
hass.data.setdefault(DOMAIN, {})
if DOMAIN in config and config[DOMAIN]:
_LOGGER.debug("discovery.load_platform called")
discovery.load_platform(
hass=hass,
component=PLATFORMS[0],
platform=DOMAIN,
discovered=config[DOMAIN],
hass_config=config,
)
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_configuration",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="YAML_Warning",
)
_LOGGER.warning(
"YAML configuration of ics_calendar is deprecated and will be "
"removed in ics_calendar v5.0.0. Your configuration items have "
"been imported. Please remove them from your configuration.yaml "
"file."
)
config_entry = _async_find_matching_config_entry(hass)
if not config_entry:
if config[DOMAIN].get("calendars"):
for calendar in config[DOMAIN].get("calendars"):
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=dict(calendar),
)
)
return True
# update entry with any changes
if config[DOMAIN].get("calendars"):
for calendar in config[DOMAIN].get("calendars"):
hass.config_entries.async_update_entry(
config_entry, data=dict(calendar)
)
return True
@callback
def _async_find_matching_config_entry(hass):
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.source == SOURCE_IMPORT:
return entry
return None
async def async_migrate_entry(hass, entry: ConfigEntry):
"""Migrate old config entry."""
# Don't downgrade entries
if entry.version > STORAGE_VERSION_MAJOR:
return False
if entry.version == STORAGE_VERSION_MAJOR:
new_data = {**entry.data}
hass.config_entries.async_update_entry(
entry,
data=new_data,
minor_version=STORAGE_VERSION_MINOR,
version=STORAGE_VERSION_MAJOR,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Implement async_setup_entry."""
full_data: dict = add_missing_defaults(entry)
hass.config_entries.async_update_entry(entry=entry, data=full_data)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = full_data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
def add_missing_defaults(
entry: ConfigEntry,
) -> dict:
"""Initialize missing data."""
data = {
CONF_NAME: "",
CONF_URL: "",
CONF_ADV_CONNECT_OPTS: False,
CONF_SET_TIMEOUT: False,
CONF_REQUIRES_AUTH: False,
CONF_INCLUDE_ALL_DAY: False,
CONF_REQUIRES_AUTH: False,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_PARSER: "rie",
CONF_PREFIX: "",
CONF_DAYS: 1,
CONF_DOWNLOAD_INTERVAL: 15,
CONF_USER_AGENT: "",
CONF_EXCLUDE: "",
CONF_INCLUDE: "",
CONF_OFFSET_HOURS: 0,
CONF_ACCEPT_HEADER: "",
CONF_CONNECTION_TIMEOUT: 300.0,
CONF_SUMMARY_DEFAULT: CONF_SUMMARY_DEFAULT_DEFAULT,
}
data.update(entry.data)
if CONF_USERNAME in entry.data or CONF_PASSWORD in entry.data:
data[CONF_REQUIRES_AUTH] = True
if (
CONF_USER_AGENT in entry.data
or CONF_ACCEPT_HEADER in entry.data
or CONF_CONNECTION_TIMEOUT in entry.data
):
data[CONF_ADV_CONNECT_OPTS] = True
if CONF_CONNECTION_TIMEOUT in entry.data:
data[CONF_SET_TIMEOUT] = True
return data
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
+382
View File
@@ -0,0 +1,382 @@
"""Support for ICS Calendar."""
import logging
from datetime import datetime, timedelta
from typing import Any, Optional
# import homeassistant.helpers.config_validation as cv
# import voluptuous as vol
from homeassistant.components.calendar import (
ENTITY_ID_FORMAT,
CalendarEntity,
CalendarEvent,
extract_offset,
is_offset_reached,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_EXCLUDE,
CONF_INCLUDE,
CONF_NAME,
CONF_PASSWORD,
CONF_PREFIX,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.dt import now as hanow
from .calendardata import CalendarData
from .const import (
CONF_ACCEPT_HEADER,
CONF_CALENDARS,
CONF_CONNECTION_TIMEOUT,
CONF_DAYS,
CONF_DOWNLOAD_INTERVAL,
CONF_INCLUDE_ALL_DAY,
CONF_OFFSET_HOURS,
CONF_PARSER,
CONF_SET_TIMEOUT,
CONF_SUMMARY_DEFAULT,
CONF_USER_AGENT,
DOMAIN,
)
from .filter import Filter
from .getparser import GetParser
from .parserevent import ParserEvent
_LOGGER = logging.getLogger(__name__)
OFFSET = "!!"
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the calendar in background."""
hass.async_create_task(
_async_setup_entry_bg_task(hass, config_entry, async_add_entities)
)
async def _async_setup_entry_bg_task(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the calendar."""
data = hass.data[DOMAIN][config_entry.entry_id]
device_id = f"{data[CONF_NAME]}"
entity = ICSCalendarEntity(
hass,
generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass),
hass.data[DOMAIN][config_entry.entry_id],
config_entry.entry_id,
)
async_add_entities([entity])
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
):
"""Set up ics_calendar platform.
:param hass: Home Assistant object
:type hass: HomeAssistant
:param config: Config information for the platform
:type config: ConfigType
:param add_entities: Callback to add entities to HA
:type add_entities: AddEntitiesCallback
:param discovery_info: Config information for the platform
:type discovery_info: DiscoveryInfoType | None, optional
"""
_LOGGER.debug("Setting up ics calendars")
if discovery_info is not None:
_LOGGER.debug(
"setup_platform: ignoring discovery_info, already imported!"
)
# calendars: list = discovery_info.get(CONF_CALENDARS)
calendars = []
else:
_LOGGER.debug("setup_platform: discovery_info is None")
calendars: list = config.get(CONF_CALENDARS)
calendar_devices = []
for calendar in calendars:
device_data = {
CONF_NAME: calendar.get(CONF_NAME),
CONF_URL: calendar.get(CONF_URL),
CONF_INCLUDE_ALL_DAY: calendar.get(CONF_INCLUDE_ALL_DAY),
CONF_USERNAME: calendar.get(CONF_USERNAME),
CONF_PASSWORD: calendar.get(CONF_PASSWORD),
CONF_PARSER: calendar.get(CONF_PARSER),
CONF_PREFIX: calendar.get(CONF_PREFIX),
CONF_DAYS: calendar.get(CONF_DAYS),
CONF_DOWNLOAD_INTERVAL: calendar.get(CONF_DOWNLOAD_INTERVAL),
CONF_USER_AGENT: calendar.get(CONF_USER_AGENT),
CONF_EXCLUDE: calendar.get(CONF_EXCLUDE),
CONF_INCLUDE: calendar.get(CONF_INCLUDE),
CONF_OFFSET_HOURS: calendar.get(CONF_OFFSET_HOURS),
CONF_ACCEPT_HEADER: calendar.get(CONF_ACCEPT_HEADER),
CONF_CONNECTION_TIMEOUT: calendar.get(CONF_CONNECTION_TIMEOUT),
}
device_id = f"{device_data[CONF_NAME]}"
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
calendar_devices.append(
ICSCalendarEntity(hass, entity_id, device_data)
)
add_entities(calendar_devices)
class ICSCalendarEntity(CalendarEntity):
"""A CalendarEntity for an ICS Calendar."""
def __init__(
self,
hass: HomeAssistant,
entity_id: str,
device_data,
unique_id: str = None,
):
"""Construct ICSCalendarEntity.
:param entity_id: Entity id for the calendar
:type entity_id: str
:param device_data: dict describing the calendar
:type device_data: dict
"""
_LOGGER.debug(
"Initializing calendar: %s with URL: %s, uniqueid: %s",
device_data[CONF_NAME],
device_data[CONF_URL],
unique_id,
)
self.data = ICSCalendarData(hass, device_data)
self.entity_id = entity_id
self._attr_unique_id = f"ICSCalendar.{unique_id}"
self._event = None
self._attr_name = device_data[CONF_NAME]
self._last_call = None
@property
def event(self) -> Optional[CalendarEvent]:
"""Return the current or next upcoming event or None.
:return: The current event as a dict
:rtype: dict
"""
return self._event
@property
def should_poll(self) -> bool:
"""Indicate if the calendar should be polled.
If the last call to update or get_api_events was not within the minimum
update time, then async_schedule_update_ha_state(True) is also called.
:return: True
:rtype: boolean
"""
this_call = hanow()
if (
self._last_call is None
or (this_call - self._last_call) > MIN_TIME_BETWEEN_UPDATES
):
self._last_call = this_call
self.async_schedule_update_ha_state(True)
return True
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame.
:param hass: Home Assistant object
:type hass: HomeAssistant
:param start_date: The first starting date to consider
:type start_date: datetime
:param end_date: The last starting date to consider
:type end_date: datetime
"""
_LOGGER.debug(
"%s: async_get_events called; calling internal.", self.name
)
return await self.data.async_get_events(start_date, end_date)
async def async_update(self):
"""Get the current or next event."""
await self.data.async_update()
self._event: CalendarEvent | None = self.data.event
self._attr_extra_state_attributes = {
"offset_reached": (
is_offset_reached(
self._event.start_datetime_local, self.data.offset
)
if self._event
else False
)
}
async def async_create_event(self, **kwargs: Any):
"""Raise error, this is a read-only calendar."""
raise NotImplementedError()
async def async_delete_event(
self,
uid: str,
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Raise error, this is a read-only calendar."""
raise NotImplementedError()
async def async_update_event(
self,
uid: str,
event: dict[str, Any],
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Raise error, this is a read-only calendar."""
raise NotImplementedError()
class ICSCalendarData: # pylint: disable=R0902
"""Class to use the calendar ICS client object to get next event."""
def __init__(self, hass: HomeAssistant, device_data):
"""Set up how we are going to connect to the URL.
:param device_data Information about the calendar
"""
self.name = device_data[CONF_NAME]
self._days = device_data[CONF_DAYS]
self._offset_hours = device_data[CONF_OFFSET_HOURS]
self.include_all_day = device_data[CONF_INCLUDE_ALL_DAY]
self._summary_prefix: str = device_data[CONF_PREFIX]
self._summary_default: str = device_data[CONF_SUMMARY_DEFAULT]
self.parser = GetParser.get_parser(device_data[CONF_PARSER])
self.parser.set_filter(
Filter(device_data[CONF_EXCLUDE], device_data[CONF_INCLUDE])
)
self.offset = None
self.event = None
self._hass = hass
self._calendar_data = CalendarData(
get_async_client(hass),
_LOGGER,
{
"name": self.name,
"url": device_data[CONF_URL],
"min_update_time": timedelta(
minutes=device_data[CONF_DOWNLOAD_INTERVAL]
),
},
).headers(
device_data[CONF_USERNAME],
device_data[CONF_PASSWORD],
device_data[CONF_USER_AGENT],
device_data[CONF_ACCEPT_HEADER],
)
if device_data.get(CONF_SET_TIMEOUT):
self._calendar_data.timeout(
device_data.get(CONF_CONNECTION_TIMEOUT)
)
async def async_get_events(
self, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame.
:param start_date: The first starting date to consider
:type start_date: datetime
:param end_date: The last starting date to consider
:type end_date: datetime
"""
event_list: list[ParserEvent] = []
if await self._calendar_data.download_calendar():
_LOGGER.debug("%s: Setting calendar content", self.name)
await self._hass.async_add_executor_job(
lambda: self.parser.set_content(self._calendar_data.get())
)
try:
event_list = self.parser.get_event_list(
start=start_date,
end=end_date,
include_all_day=self.include_all_day,
offset_hours=self._offset_hours,
)
except: # pylint: disable=W0702
_LOGGER.error(
"async_get_events: %s: Failed to parse ICS!",
self.name,
exc_info=True,
)
event_list: list[ParserEvent] = []
for event in event_list:
event.summary = self._summary_prefix + event.summary
if not event.summary:
event.summary = self._summary_default
# Since we skipped the validation code earlier, invoke it now,
# before passing the object outside this component
event.validate()
return event_list
async def async_update(self):
"""Get the current or next event."""
_LOGGER.debug("%s: Update was called", self.name)
parser_event: ParserEvent | None = None
if await self._calendar_data.download_calendar():
_LOGGER.debug("%s: Setting calendar content", self.name)
await self._hass.async_add_executor_job(
lambda: self.parser.set_content(self._calendar_data.get())
)
try:
parser_event: ParserEvent | None = self.parser.get_current_event(
include_all_day=self.include_all_day,
now=hanow(),
days=self._days,
offset_hours=self._offset_hours,
)
except: # pylint: disable=W0702
_LOGGER.error(
"update: %s: Failed to parse ICS!", self.name, exc_info=True
)
if parser_event is not None:
_LOGGER.debug(
"%s: got event: %s; start: %s; end: %s; all_day: %s",
self.name,
parser_event.summary,
parser_event.start,
parser_event.end,
parser_event.all_day,
)
(summary, offset) = extract_offset(parser_event.summary, OFFSET)
parser_event.summary = self._summary_prefix + summary
if not parser_event.summary:
parser_event.summary = self._summary_default
self.offset = offset
# Invoke validation here, since it was skipped when creating the
# ParserEvent
parser_event.validate()
self.event: CalendarEvent = parser_event
return True
_LOGGER.debug("%s: No event found!", self.name)
return False
@@ -0,0 +1,226 @@
"""Provide CalendarData class."""
import re
from logging import Logger
from math import floor
import httpx
import httpx_auth
from homeassistant.util.dt import now as hanow
# from urllib.error import ContentTooShortError, HTTPError, URLError
class DigestWithMultiAuth(httpx.DigestAuth, httpx_auth.SupportMultiAuth):
"""Describes a DigestAuth authentication."""
def __init__(self, username: str, password: str):
"""Construct Digest authentication that supports Multi Auth."""
httpx.DigestAuth.__init__(self, username, password)
class CalendarData: # pylint: disable=R0902
"""CalendarData class.
The CalendarData class is used to download and cache calendar data from a
given URL. Use the get method to retrieve the data after constructing your
instance.
"""
def __init__(
self,
async_client: httpx.AsyncClient,
logger: Logger,
conf: dict,
):
"""Construct CalendarData object.
:param async_client: An httpx.AsyncClient object for requests
:type httpx.AsyncClient
:param logger: The logger for reporting problems
:type logger: Logger
:param conf: Configuration options
:type conf: dict
"""
self._auth = None
self._calendar_data = None
self._headers = []
self._last_download = None
self._min_update_time = conf["min_update_time"]
self.logger = logger
self.name = conf["name"]
self.url = conf["url"]
self.connection_timeout = None
self._httpx = async_client
async def download_calendar(self) -> bool:
"""Download the calendar data.
This only downloads data if self.min_update_time has passed since the
last download.
returns: True if data was downloaded, otherwise False.
rtype: bool
"""
self.logger.debug("%s: download_calendar start", self.name)
if (
self._calendar_data is None
or self._last_download is None
or (hanow() - self._last_download) > self._min_update_time
):
self._calendar_data = None
next_url: str = self._make_url()
self.logger.debug(
"%s: Downloading calendar data from: %s",
self.name,
next_url,
)
await self._download_data(next_url)
self._last_download = hanow()
self.logger.debug("%s: download_calendar done", self.name)
return self._calendar_data is not None
self.logger.debug("%s: download_calendar skipped download", self.name)
return False
def get(self) -> str:
"""Get the calendar data that was downloaded.
:return: The downloaded calendar data.
:rtype: str
"""
return self._calendar_data
def headers(
self,
user_name: str,
password: str,
user_agent: str,
accept_header: str,
):
"""Set a user agent, accept header, and/or user name and password.
The user name and password will be set into an auth object that
supports both Basic Auth and Digest Auth for httpx.
If the user_agent parameter is not "", a User-agent header will be
added to the urlopener.
:param user_name: The user name
:type user_name: str
:param password: The password
:type password: str
:param user_agent: The User Agent string to use or ""
:type user_agent: str
:param accept_header: The accept header string to use or ""
:type accept_header: str
"""
if user_name != "" and password != "":
self._auth = httpx_auth.Basic(
user_name, password
) + DigestWithMultiAuth(user_name, password)
if user_agent != "":
self._headers.append(("User-agent", user_agent))
if accept_header != "":
self._headers.append(("Accept", accept_header))
return self
def timeout(self, connection_timeout: float | None):
"""Set the connection timeout.
:param connection_timeout: The timeout value in seconds.
:type connection_timeout: float
"""
if connection_timeout:
self.connection_timeout = connection_timeout
return self
def _decode_data(self, data):
return data.replace("\0", "")
async def _download_data(self, url): # noqa: C901
"""Download the calendar data."""
self.logger.debug("%s: _download_data start", self.name)
try:
response = await self._httpx.get(
url,
auth=self._auth,
headers=self._headers,
follow_redirects=True,
timeout=self.connection_timeout,
)
if response.status_code >= 400:
raise httpx.HTTPStatusError(
"status error", request=None, response=response
)
self._calendar_data = self._decode_data(response.text)
self.logger.debug("%s: _download_data done", self.name)
except httpx.HTTPStatusError as http_status_error:
self.logger.error(
"%s: Failed to open url(%s): %s",
self.name,
self.url,
http_status_error.response.status_code,
)
except httpx.TimeoutException:
self.logger.error(
"%s: Timeout opening url: %s", self.name, self.url
)
except httpx.DecodingError:
self.logger.error(
"%s: Error decoding data from url: %s", self.name, self.url
)
except httpx.InvalidURL:
self.logger.error("%s: Invalid URL: %s", self.name, self.url)
except httpx.HTTPError:
self.logger.error(
"%s: Error decoding data from url: %s", self.name, self.url
)
except: # pylint: disable=W0702
self.logger.error(
"%s: Failed to open url!", self.name, exc_info=True
)
def _make_url(self):
"""Replace templates in url and encode."""
now = hanow()
year: int = now.year
month: int = now.month
url = self.url
(month, year, url) = self._get_month_year(url, month, year)
return url.replace("{year}", f"{year:04}").replace(
"{month}", f"{month:02}"
)
def _get_year_as_months(self, url: str, month: int) -> int:
year_match = re.search("\\{year([-+])([0-9]+)\\}", url)
if year_match:
if year_match.group(1) == "-":
month = month - (int(year_match.group(2)) * 12)
else:
month = month + (int(year_match.group(2)) * 12)
url = url.replace(year_match.group(0), "{year}")
return (month, url)
def _get_month_year(self, url: str, month: int, year: int) -> int:
(month, url) = self._get_year_as_months(url, month)
month_match = re.search("\\{month([-+])([0-9]+)\\}", url)
if month_match:
if month_match.group(1) == "-":
month = month - int(month_match.group(2))
else:
month = month + int(month_match.group(2))
if month < 1:
year -= floor(abs(month) / 12) + 1
month = month % 12
if month == 0:
month = 12
elif month > 12:
year += abs(floor(month / 12))
month = month % 12
if month == 0:
month = 12
year -= 1
url = url.replace(month_match.group(0), "{month}")
return (month, year, url)
@@ -0,0 +1,335 @@
"""Config Flow for ICS Calendar."""
import logging
import re
from typing import Any, Dict, Optional, Self
from urllib.parse import quote
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_EXCLUDE,
CONF_INCLUDE,
CONF_NAME,
CONF_PASSWORD,
CONF_PREFIX,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.helpers.selector import selector
from . import (
CONF_ACCEPT_HEADER,
CONF_ADV_CONNECT_OPTS,
CONF_CONNECTION_TIMEOUT,
CONF_DAYS,
CONF_DOWNLOAD_INTERVAL,
CONF_INCLUDE_ALL_DAY,
CONF_OFFSET_HOURS,
CONF_PARSER,
CONF_REQUIRES_AUTH,
CONF_SET_TIMEOUT,
CONF_SUMMARY_DEFAULT,
CONF_USER_AGENT,
)
from .const import CONF_SUMMARY_DEFAULT_DEFAULT, DOMAIN
_LOGGER = logging.getLogger(__name__)
CALENDAR_NAME_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_DAYS, default=1): cv.positive_int,
vol.Optional(CONF_INCLUDE_ALL_DAY, default=False): cv.boolean,
}
)
CALENDAR_OPTS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_EXCLUDE, default=""): cv.string,
vol.Optional(CONF_INCLUDE, default=""): cv.string,
vol.Optional(CONF_PREFIX, default=""): cv.string,
vol.Optional(CONF_DOWNLOAD_INTERVAL, default=15): cv.positive_int,
vol.Optional(CONF_OFFSET_HOURS, default=0): int,
vol.Optional(CONF_PARSER, default="rie"): selector(
{"select": {"options": ["rie", "ics"], "mode": "dropdown"}}
),
vol.Optional(
CONF_SUMMARY_DEFAULT, default=CONF_SUMMARY_DEFAULT_DEFAULT
): cv.string,
}
)
CONNECT_OPTS_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): cv.string,
vol.Optional(CONF_REQUIRES_AUTH, default=False): cv.boolean,
vol.Optional(CONF_ADV_CONNECT_OPTS, default=False): cv.boolean,
}
)
AUTH_OPTS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_USERNAME, default=""): cv.string,
vol.Optional(CONF_PASSWORD, default=""): cv.string,
}
)
ADVANCED_CONNECT_OPTS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ACCEPT_HEADER, default=""): cv.string,
vol.Optional(CONF_USER_AGENT, default=""): cv.string,
vol.Optional(CONF_SET_TIMEOUT, default=False): cv.boolean,
}
)
TIMEOUT_OPTS_SCHEMA = vol.Schema(
{vol.Optional(CONF_CONNECTION_TIMEOUT, default=None): cv.positive_float}
)
FILTER_DOC_URL = (
"https://github.com/franc6/ics_calendar/blob/releases/README.md#filters"
)
def is_array_string(arr_str: str) -> bool:
"""Return true if arr_str starts with [ and ends with ]."""
return arr_str.startswith("[") and arr_str.endswith("]")
def format_url(url: str) -> str:
"""Format a URL using quote() and ensure any templates are not quoted."""
is_quoted = bool(re.search("%[0-9A-Fa-f][0-9A-Fa-f]", url))
if not is_quoted:
year_match = re.search("\\{(year([-+][0-9]+)?)\\}", url)
month_match = re.search("\\{(month([-+][0-9]+)?)\\}", url)
has_template: bool = year_match or month_match
url = quote(url, safe=":/?&=")
if has_template:
year_template = year_match.group(1)
month_template = month_match.group(1)
year_template1 = year_template.replace("+", "%2[Bb]")
month_template1 = month_template.replace("+", "%2[Bb]")
url = re.sub(
f"%7[Bb]{year_template1}%7[Dd]",
f"{{{year_template}}}",
url,
)
url = re.sub(
f"%7[Bb]{month_template1}%7[Dd]",
f"{{{month_template}}}",
url,
)
if url.startswith("webcal://"):
url = re.sub("^webcal://", "https://", url)
return url
class ICSCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config Flow for ICS Calendar."""
VERSION = 1
MINOR_VERSION = 0
data: Optional[Dict[str, Any]]
def __init__(self):
"""Construct ICSCalendarConfigFlow."""
self.data = {}
def is_matching(self, _other_flow: Self) -> bool:
"""Match discovery method.
This method doesn't do anything, because this integration has no
discoverable components.
"""
return False
async def async_step_reauth(self, user_input=None):
"""Re-authenticateon auth error."""
# self.reauth_entry = self.hass.config_entries.async_get_entry(
# self.context["entry_id"]
# )
return await self.async_step_reauth_confirm(user_input)
async def async_step_reauth_confirm(
self, user_input=None
) -> ConfigFlowResult:
"""Dialog to inform user that reauthentication is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm", data_schema=vol.Schema({})
)
return await self.async_step_user()
# Don't allow reconfigure for now!
# async def async_step_reconfigure(
# self, user_input: dict[str, Any] | None = None
# ) -> ConfigFlowResult:
# """Reconfigure entry."""
# return await self.async_step_user(user_input)
async def async_step_user(
self, user_input: Optional[Dict[str, Any]] = None
) -> ConfigFlowResult:
"""Start of Config Flow."""
errors = {}
if user_input is not None:
user_input[CONF_NAME] = user_input[CONF_NAME].strip()
if not user_input[CONF_NAME]:
errors[CONF_NAME] = "empty_name"
else:
self._async_abort_entries_match(
{CONF_NAME: user_input[CONF_NAME]}
)
if not errors:
self.data = user_input
return await self.async_step_calendar_opts()
return self.async_show_form(
step_id="user",
data_schema=CALENDAR_NAME_SCHEMA,
errors=errors,
last_step=False,
)
async def async_step_calendar_opts( # noqa: R701,C901
self, user_input: Optional[Dict[str, Any]] = None
):
"""Calendar Options step for ConfigFlow."""
errors = {}
if user_input is not None:
user_input[CONF_EXCLUDE] = user_input[CONF_EXCLUDE].strip()
user_input[CONF_INCLUDE] = user_input[CONF_INCLUDE].strip()
if (
user_input[CONF_EXCLUDE]
and user_input[CONF_EXCLUDE] == user_input[CONF_INCLUDE]
):
errors[CONF_EXCLUDE] = "exclude_include_cannot_be_the_same"
else:
if user_input[CONF_EXCLUDE] and not is_array_string(
user_input[CONF_EXCLUDE]
):
errors[CONF_EXCLUDE] = "exclude_must_be_array"
if user_input[CONF_INCLUDE] and not is_array_string(
user_input[CONF_INCLUDE]
):
errors[CONF_INCLUDE] = "include_must_be_array"
if user_input[CONF_DOWNLOAD_INTERVAL] < 15:
_LOGGER.error("download_interval_too_small error")
errors[CONF_DOWNLOAD_INTERVAL] = "download_interval_too_small"
if not user_input[CONF_SUMMARY_DEFAULT]:
user_input[CONF_SUMMARY_DEFAULT] = CONF_SUMMARY_DEFAULT_DEFAULT
if not errors:
self.data.update(user_input)
return await self.async_step_connect_opts()
return self.async_show_form(
step_id="calendar_opts",
data_schema=CALENDAR_OPTS_SCHEMA,
errors=errors,
last_step=False,
description_placeholders={"filterdoc": FILTER_DOC_URL},
)
async def async_step_connect_opts(
self, user_input: Optional[Dict[str, Any]] = None
):
"""Connect Options step for ConfigFlow."""
errors = {}
if user_input is not None:
user_input[CONF_URL] = user_input[CONF_URL].strip()
if not user_input[CONF_URL]:
errors[CONF_URL] = "empty_url"
if not errors:
user_input[CONF_URL] = format_url(user_input[CONF_URL])
self.data.update(user_input)
if user_input.get(CONF_REQUIRES_AUTH, False):
return await self.async_step_auth_opts()
if user_input.get(CONF_ADV_CONNECT_OPTS, False):
return await self.async_step_adv_connect_opts()
return self.async_create_entry(
title=self.data[CONF_NAME],
data=self.data,
)
return self.async_show_form(
step_id="connect_opts",
data_schema=CONNECT_OPTS_SCHEMA,
errors=errors,
)
async def async_step_auth_opts(
self, user_input: Optional[Dict[str, Any]] = None
):
"""Auth Options step for ConfigFlow."""
if user_input is not None:
self.data.update(user_input)
if self.data.get(CONF_ADV_CONNECT_OPTS, False):
return await self.async_step_adv_connect_opts()
return self.async_create_entry(
title=self.data[CONF_NAME],
data=self.data,
)
return self.async_show_form(
step_id="auth_opts", data_schema=AUTH_OPTS_SCHEMA
)
async def async_step_adv_connect_opts(
self, user_input: Optional[Dict[str, Any]] = None
):
"""Advanced Connection Options step for ConfigFlow."""
errors = {}
if user_input is not None:
# if not errors:
self.data.update(user_input)
if user_input.get(CONF_SET_TIMEOUT, False):
return await self.async_step_timeout_opts()
return self.async_create_entry(
title=self.data[CONF_NAME],
data=self.data,
)
return self.async_show_form(
step_id="adv_connect_opts",
data_schema=ADVANCED_CONNECT_OPTS_SCHEMA,
errors=errors,
)
async def async_step_timeout_opts(
self, user_input: Optional[Dict[str, Any]] = None
):
"""Timeout Options step for ConfigFlow."""
errors = {}
if user_input is not None:
# if not errors:
self.data.update(user_input)
return self.async_create_entry(
title=self.data[CONF_NAME],
data=self.data,
)
return self.async_show_form(
step_id="timeout_opts",
data_schema=TIMEOUT_OPTS_SCHEMA,
errors=errors,
last_step=True,
)
async def async_step_import(self, import_data):
"""Import config from configuration.yaml."""
return self.async_create_entry(
title=import_data[CONF_NAME],
data=import_data,
)
+24
View File
@@ -0,0 +1,24 @@
"""Constants for ics_calendar platform."""
VERSION = "5.1.7"
DOMAIN = "ics_calendar"
CONF_DEVICE_ID = "device_id"
CONF_CALENDARS = "calendars"
CONF_DAYS = "days"
CONF_INCLUDE_ALL_DAY = "include_all_day"
CONF_PARSER = "parser"
CONF_DOWNLOAD_INTERVAL = "download_interval"
CONF_USER_AGENT = "user_agent"
CONF_OFFSET_HOURS = "offset_hours"
CONF_ACCEPT_HEADER = "accept_header"
CONF_CONNECTION_TIMEOUT = "connection_timeout"
CONF_SET_TIMEOUT = "set_connection_timeout"
CONF_REQUIRES_AUTH = "requires_auth"
CONF_ADV_CONNECT_OPTS = "advanced_connection_options"
CONF_SUMMARY_DEFAULT = "summary_default"
# It'd be really nifty if this could be a translatable string, but it seems
# that's not supported, unless I want to roll my own interpretation of the
# translate/*.json files. :(
# See also https://github.com/home-assistant/core/issues/125075
CONF_SUMMARY_DEFAULT_DEFAULT = "No title"
+125
View File
@@ -0,0 +1,125 @@
"""Provide Filter class."""
import re
from ast import literal_eval
from typing import List, Optional, Pattern
from .parserevent import ParserEvent
class Filter:
"""Filter class.
The Filter class is used to filter events according to the exclude and
include rules.
"""
def __init__(self, exclude: str, include: str):
"""Construct Filter class.
:param exclude: The exclude rules
:type exclude: str
:param include: The include rules
:type include: str
"""
self._exclude = Filter.set_rules(exclude)
self._include = Filter.set_rules(include)
@staticmethod
def set_rules(rules: str) -> List[Pattern]:
"""Set the given rules into an array which is returned.
:param rules: The rules to set
:type rules: str
:return: An array of regular expressions
:rtype: List[Pattern]
"""
arr = []
if rules != "":
for rule in literal_eval(rules):
if rule.startswith("/"):
re_flags = re.NOFLAG
[expr, flags] = rule[1:].split("/")
for flag in flags:
match flag:
case "i":
re_flags |= re.IGNORECASE
case "m":
re_flags |= re.MULTILINE
case "s":
re_flags |= re.DOTALL
arr.append(re.compile(expr, re_flags))
else:
arr.append(re.compile(rule, re.IGNORECASE))
return arr
def _is_match(
self, summary: str, description: Optional[str], regexes: List[Pattern]
) -> bool:
"""Indicate if the event matches the given list of regular expressions.
:param summary: The event summary to examine
:type summary: str
:param description: The event description summary to examine
:type description: Optional[str]
:param regexes: The regular expressions to match against
:type regexes: List[]
:return: True if the event matches the exclude filter
:rtype: bool
"""
for regex in regexes:
if regex.search(summary) or (
description and regex.search(description)
):
return True
return False
def _is_excluded(self, summary: str, description: Optional[str]) -> bool:
"""Indicate if the event should be excluded.
:param summary: The event summary to examine
:type summary: str
:param description: The event description summary to examine
:type description: Optional[str]
:return: True if the event matches the exclude filter
:rtype: bool
"""
return self._is_match(summary, description, self._exclude)
def _is_included(self, summary: str, description: Optional[str]) -> bool:
"""Indicate if the event should be included.
:param summary: The event summary to examine
:type summary: str
:param description: The event description summary to examine
:type description: Optional[str]
:return: True if the event matches the include filter
:rtype: bool
"""
return self._is_match(summary, description, self._include)
def filter(self, summary: str, description: Optional[str]) -> bool:
"""Check if the event should be included or not.
:param summary: The event summary to examine
:type summary: str
:param description: The event description summary to examine
:type description: Optional[str]
:return: true if the event should be included, otherwise false
:rtype: bool
"""
add_event = not self._is_excluded(summary, description)
if not add_event:
add_event = self._is_included(summary, description)
return add_event
def filter_event(self, event: ParserEvent) -> bool:
"""Check if the event should be included or not.
:param event: The event to examine
:type event: ParserEvent
:return: true if the event should be included, otherwise false
:rtype: bool
"""
return self.filter(event.summary, event.description)
@@ -0,0 +1,33 @@
"""Provide GetParser class."""
from .icalendarparser import ICalendarParser
class GetParser: # pylint: disable=R0903
"""Provide get_parser to return an instance of ICalendarParser.
The class provides a static method , get_instace, to get a parser instance.
The non static methods allow this class to act as an "interface" for the
parser classes.
"""
@staticmethod
def get_parser(parser: str, *args) -> ICalendarParser | None:
"""Get an instance of the requested parser."""
# parser_cls = ICalendarParser.get_class(parser)
# if parser_cls is not None:
# return parser_cls(*args)
if parser == "rie":
from .parsers.parser_rie import ( # pylint: disable=C0415
ParserRIE,
)
return ParserRIE(*args)
if parser == "ics":
from .parsers.parser_ics import ( # pylint: disable=C0415
ParserICS,
)
return ParserICS(*args)
return None
@@ -0,0 +1,72 @@
"""Provide ICalendarParser class."""
from datetime import datetime
from typing import Optional
from .filter import Filter
from .parserevent import ParserEvent
class ICalendarParser:
"""Provide interface for various parser classes."""
def set_content(self, content: str):
"""Parse content into a calendar object.
This must be called at least once before get_event_list or
get_current_event.
:param content is the calendar data
:type content str
"""
def set_filter(self, filt: Filter):
"""Set a Filter object to filter events.
:param filt: The Filter object
:type exclude: Filter
"""
def get_event_list(
self,
start: datetime,
end: datetime,
include_all_day: bool,
offset_hours: int = 0,
) -> list[ParserEvent]:
"""Get a list of events.
Gets the events from start to end, including or excluding all day
events.
:param start the earliest start time of events to return
:type start datetime
:param end the latest start time of events to return
:type end datetime
:param include_all_day if true, all day events will be included.
:type include_all_day boolean
:param offset_hours the number of hours to offset the event
:type offset_hours int
:returns a list of events, or an empty list
:rtype list[ParserEvent]
"""
def get_current_event(
self,
include_all_day: bool,
now: datetime,
days: int,
offset_hours: int = 0,
) -> Optional[ParserEvent]:
"""Get the current or next event.
Gets the current event, or the next upcoming event with in the
specified number of days, if there is no current event.
:param include_all_day if true, all day events will be included.
:type include_all_day boolean
:param now the current date and time
:type now datetime
:param days the number of days to check for an upcoming event
:type days int
:param offset_hours the number of hours to offset the event
:type offset_hours int
:returns a ParserEvent or None
"""
@@ -0,0 +1,13 @@
{
"domain": "ics_calendar",
"name": "ics Calendar",
"codeowners": ["@franc6"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/franc6/ics_calendar",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/franc6/ics_calendar/issues",
"requirements": ["icalendar~=6.1","python-dateutil>=2.9.0.post0","pytz>=2024.1","recurring_ical_events~=3.5,>=3.5.2","ics==0.7.2","arrow","httpx_auth>=0.22.0,<=0.23.1","tatsu>=4.2.6,<5.8.0"],
"version": "5.1.7"
}
@@ -0,0 +1,20 @@
"""Provide ParserEvent class."""
import dataclasses
from homeassistant.components.calendar import CalendarEvent
@dataclasses.dataclass
class ParserEvent(CalendarEvent):
"""Class to represent CalendarEvent without validation."""
def validate(self) -> None:
"""Invoke __post_init__ from CalendarEvent."""
return super().__post_init__()
def __post_init__(self) -> None:
"""Don't do validation steps for this class."""
# This is necessary to prevent problems when creating events that don't
# have a summary. We'll add a summary after the event is created, not
# before, to reduce code repitition.
@@ -0,0 +1 @@
"""Provide parsers."""
@@ -0,0 +1,217 @@
"""Support for ics parser."""
import re
from datetime import date, datetime, timedelta
from typing import Optional, Union
from arrow import Arrow, get as arrowget
from ics import Calendar
from ..filter import Filter
from ..icalendarparser import ICalendarParser
from ..parserevent import ParserEvent
from ..utility import compare_event_dates
class ParserICS(ICalendarParser):
"""Class to provide parser using ics module."""
def __init__(self):
"""Construct ParserICS."""
self._re_method = re.compile("^METHOD:.*$", flags=re.MULTILINE)
self._calendar = None
self._filter = Filter("", "")
def set_content(self, content: str):
"""Parse content into a calendar object.
This must be called at least once before get_event_list or
get_current_event.
:param content is the calendar data
:type content str
"""
self._calendar = Calendar(re.sub(self._re_method, "", content))
def set_filter(self, filt: Filter):
"""Set a Filter object to filter events.
:param filt: The Filter object
:type exclude: Filter
"""
self._filter = filt
def get_event_list(
self, start, end, include_all_day: bool, offset_hours: int = 0
) -> list[ParserEvent]:
"""Get a list of events.
Gets the events from start to end, including or excluding all day
events.
:param start the earliest start time of events to return
:type datetime
:param end the latest start time of events to return
:type datetime
:param include_all_day if true, all day events will be included.
:type boolean
:param offset_hours the number of hours to offset the event
:type offset_hours int
:returns a list of events, or an empty list
:rtype list[ParserEvent]
"""
event_list: list[ParserEvent] = []
if self._calendar is not None:
# ics 0.8 takes datetime not Arrow objects
# ar_start = start
# ar_end = end
ar_start = arrowget(start - timedelta(hours=offset_hours))
ar_end = arrowget(end - timedelta(hours=offset_hours))
for event in self._calendar.timeline.included(ar_start, ar_end):
if event.all_day and not include_all_day:
continue
summary: str = ""
# ics 0.8 uses 'summary' reliably, older versions use 'name'
# if hasattr(event, "summary"):
# summary = event.summary
# elif hasattr(event, "name"):
summary = event.name
rrule = None
for extra in event.extra:
if extra.name == "RRULE":
rrule = extra.value
calendar_event: ParserEvent = ParserEvent(
uid=event.uid,
summary=summary,
start=ParserICS.get_date(
event.begin, event.all_day, offset_hours
),
end=ParserICS.get_date(
event.end, event.all_day, offset_hours
),
location=event.location,
description=event.description,
rrule=rrule,
recurrence_id=(
ParserICS.get_date(
event.begin, event.all_day, offset_hours
)
if rrule
else None
),
)
if self._filter.filter_event(calendar_event):
event_list.append(calendar_event)
return event_list
def get_current_event( # noqa: $701
self,
include_all_day: bool,
now: datetime,
days: int,
offset_hours: int = 0,
) -> Optional[ParserEvent]:
"""Get the current or next event.
Gets the current event, or the next upcoming event with in the
specified number of days, if there is no current event.
:param include_all_day if true, all day events will be included.
:type boolean
:param now the current date and time
:type datetime
:param days the number of days to check for an upcoming event
:type int
:param offset_hours the number of hours to offset the event
:type int
:returns a ParserEvent or None
"""
if self._calendar is None:
return None
temp_event = None
now = now - timedelta(offset_hours)
end = now + timedelta(days=days)
for event in self._calendar.timeline.included(
arrowget(now), arrowget(end)
):
if event.all_day and not include_all_day:
continue
if not self._filter.filter(event.name, event.description):
continue
if temp_event is None or compare_event_dates(
now,
temp_event.end,
temp_event.begin,
temp_event.all_day,
event.end,
event.begin,
event.all_day,
):
temp_event = event
if temp_event is None:
return None
# if hasattr(event, "summary"):
# summary = temp_event.summary
# elif hasattr(event, "name"):
summary = temp_event.name
rrule = None
for extra in temp_event.extra:
if extra.name == "RRULE":
rrule = extra.value
return ParserEvent(
uid=temp_event.uid,
summary=summary,
start=ParserICS.get_date(
temp_event.begin, temp_event.all_day, offset_hours
),
end=ParserICS.get_date(
temp_event.end, temp_event.all_day, offset_hours
),
location=temp_event.location,
description=temp_event.description,
rrule=rrule,
recurrence_id=(
ParserICS.get_date(
temp_event.begin, temp_event.all_day, offset_hours
)
if rrule
else None
),
)
@staticmethod
def get_date(
arw: Arrow, is_all_day: bool, offset_hours: int
) -> Union[datetime, date]:
"""Get datetime.
:param arw The arrow object representing the date.
:type Arrow
:param is_all_day If true, the returned datetime will have the time
component set to 0.
:type: bool
:param offset_hours the number of hours to offset the event
:type int
:returns The datetime.
:rtype datetime
"""
# if isinstance(arw, Arrow):
if is_all_day:
return arw.date()
# else:
# if arw.tzinfo is None or arw.tzinfo.utcoffset(arw) is None
# or is_all_day:
# arw = arw.astimezone()
# if is_all_day:
# return arw.date()
#
arw = arw.shift(hours=offset_hours)
return_value = arw.datetime
if return_value.tzinfo is None:
return_value = return_value.astimezone()
return return_value
@@ -0,0 +1,209 @@
"""Support for recurring_ical_events parser."""
from datetime import date, datetime, timedelta
from typing import Optional, Union
import recurring_ical_events as rie
from icalendar import Calendar
from ..filter import Filter
from ..icalendarparser import ICalendarParser
from ..parserevent import ParserEvent
from ..utility import compare_event_dates
class ParserRIE(ICalendarParser):
"""Provide parser using recurring_ical_events."""
def __init__(self):
"""Construct ParserRIE."""
self._calendar = None
self.oneday = timedelta(days=1)
self.oneday2 = timedelta(hours=23, minutes=59, seconds=59)
self._filter = Filter("", "")
def set_content(self, content: str):
"""Parse content into a calendar object.
This must be called at least once before get_event_list or
get_current_event.
:param content is the calendar data
:type content str
"""
self._calendar = Calendar.from_ical(content)
def set_filter(self, filt: Filter):
"""Set a Filter object to filter events.
:param filt: The Filter object
:type exclude: Filter
"""
self._filter = filt
def get_event_list(
self,
start: datetime,
end: datetime,
include_all_day: bool,
offset_hours: int = 0,
) -> list[ParserEvent]:
"""Get a list of events.
Gets the events from start to end, including or excluding all day
events.
:param start the earliest start time of events to return
:type datetime
:param end the latest start time of events to return
:type datetime
:param include_all_day if true, all day events will be included.
:type boolean
:param offset_hours the number of hours to offset the event
:type offset_hours int
:returns a list of events, or an empty list
:rtype list[ParserEvent]
"""
event_list: list[ParserEvent] = []
if self._calendar is not None:
for event in rie.of(self._calendar, skip_bad_series=True).between(
start - timedelta(hours=offset_hours),
end - timedelta(hours=offset_hours),
):
start, end, all_day = self.is_all_day(event, offset_hours)
if all_day and not include_all_day:
continue
calendar_event: ParserEvent = ParserEvent(
uid=event.get("UID"),
summary=event.get("SUMMARY"),
start=start,
end=end,
location=event.get("LOCATION"),
description=event.get("DESCRIPTION"),
# rrule=event.get("RRULE"),
recurrence_id=ParserRIE.get_date(
event.get("RECURRENCE-ID").dt
),
)
if self._filter.filter_event(calendar_event):
event_list.append(calendar_event)
return event_list
def get_current_event( # noqa: R701
self,
include_all_day: bool,
now: datetime,
days: int,
offset_hours: int = 0,
) -> Optional[ParserEvent]:
"""Get the current or next event.
Gets the current event, or the next upcoming event with in the
specified number of days, if there is no current event.
:param include_all_day if true, all day events will be included.
:type boolean
:param now the current date and time
:type datetime
:param days the number of days to check for an upcoming event
:type int
:param offset_hours the number of hours to offset the event
:type offset_hours int
:returns a ParserEvent or None
"""
if self._calendar is None:
return None
temp_event = None
temp_start: date | datetime = None
temp_end: date | datetime = None
temp_all_day: bool = None
end: datetime = now + timedelta(days=days)
for event in rie.of(self._calendar, skip_bad_series=True).between(
now - timedelta(hours=offset_hours),
end - timedelta(hours=offset_hours),
):
start, end, all_day = self.is_all_day(event, offset_hours)
if all_day and not include_all_day:
continue
if not self._filter.filter(
event.get("SUMMARY"), event.get("DESCRIPTION")
):
continue
if temp_start is None or compare_event_dates(
now, temp_end, temp_start, temp_all_day, end, start, all_day
):
temp_event = event
temp_start = start
temp_end = end
temp_all_day = all_day
if temp_event is None:
return None
return ParserEvent(
uid=temp_event.get("UID"),
summary=temp_event.get("SUMMARY"),
start=temp_start,
end=temp_end,
location=temp_event.get("LOCATION"),
description=temp_event.get("DESCRIPTION"),
# rrule=temp_event.get("RRULE"),
recurrence_id=ParserRIE.get_date(
temp_event.get("RECURRENCE-ID").dt
),
)
@staticmethod
def get_date(date_time) -> Union[datetime, date]:
"""Get datetime with timezone information.
If a date object is passed, it will first have a time component added,
set to 0.
:param date_time The date or datetime object
:type date_time datetime or date
:type: bool
:returns The datetime.
:rtype datetime
"""
# Must use type here, since a datetime is also a date!
if isinstance(date_time, date) and not isinstance(date_time, datetime):
date_time = datetime.combine(date_time, datetime.min.time())
return date_time.astimezone()
def is_all_day(self, event, offset_hours: int):
"""Determine if the event is an all day event.
Return all day status and start and end times for the event.
:param event The event to examine
:param offset_hours the number of hours to offset the event
:type offset_hours int
"""
start: datetime | date = ParserRIE.get_date(event.get("DTSTART").dt)
end: datetime | date = ParserRIE.get_date(event.get("DTEND").dt)
all_day = False
diff = event.get("DURATION")
if diff is not None:
diff = diff.dt
else:
diff = end - start
if (start == end or diff in {self.oneday, self.oneday2}) and all(
x == 0 for x in [start.hour, start.minute, start.second]
):
# if all_day, start and end must be date, not datetime!
start = start.date()
end = end.date()
all_day = True
else:
start = start + timedelta(hours=offset_hours)
end = end + timedelta(hours=offset_hours)
if start.tzinfo is None:
start = start.astimezone()
if end.tzinfo is None:
end = end.astimezone()
return start, end, all_day
@@ -0,0 +1,77 @@
{
"issues": {
"YAML_Warning": {
"title": "YAML configuration is deprecated for ICS Calendar",
"description": "YAML configuration of ics_calendar is deprecated and will be removed in ics_calendar v5.0.0. Your configuration items have been imported. Please remove them from your configuration.yaml file."
}
},
"title": "ICS Calendar",
"config": {
"step": {
"user": {
"data": {
"name": "Name",
"days": "Days",
"include_all_day": "Include all day events?"
},
"title": "Add Calendar"
},
"calendar_opts": {
"data": {
"exclude": "Exclude filter",
"include": "Include filter",
"prefix": "String to prefix all event summaries",
"download_interval": "Download interval (minutes)",
"offset_hours": "Number of hours to offset event times",
"parser": "Parser (rie or ics)",
"summary_default": "Summary if event doesn't have one"
},
"title": "Calendar Options"
},
"connect_opts": {
"data": {
"url": "URL of ICS file",
"requires_auth": "Requires authentication?",
"advanced_connection_options": "Set advanced connection options?"
},
"title": "Connection Options"
},
"auth_opts": {
"data": {
"username": "Username",
"password": "Password"
},
"description": "Please note this component supports only HTTP Basic Auth and HTTP Digest Auth. More advanced authentication, like OAuth is not supported at this time.",
"title": "Authentication"
},
"adv_connect_opts": {
"data": {
"accept_header": "Custom Accept header for broken servers",
"user_agent": "Custom User-agent header",
"set_connection_timeout": "Change connection timeout?"
},
"title": "Advanced Connection Options"
},
"timeout_opts": {
"data": {
"connection_timeout": "Connection timeout in seconds"
},
"title": "Connection Timeout Options"
},
"reauth_confirm": {
"description": "Authorization failed for calendar. Please re-configured the calendar URL and/or authentication settings.",
"title": "Authorization Failure for ICS Calendar"
}
},
"error": {
"empty_name": "The calendar name must not be empty.",
"empty_url": "The url must not be empty.",
"download_interval_too_small": "The download interval must be at least 15.",
"exclude_include_cannot_be_the_same": "The exclude and include strings must not be the same",
"exclude_must_be_array": "The exclude option must be an array of strings or regular expressions. See {filterdoc} for more information.",
"include_must_be_array": "The include option must be an array of strings or regular expressions. See {filterdoc} for more information."
},
"abort": {
}
}
}
@@ -0,0 +1,76 @@
{
"issues": {
"YAML_Warning": {
"title": "YAML-Konfiguration für ICS-Kalender ist veraltet",
"description": "Die YAML-Konfiguration von ics_calendar ist veraltet und wird in ics_calendar v5.0.0 entfernt. Deine Konfigurationselemente wurden importiert. Bitte entferne sie aus deiner configuration.yaml-Datei."
}
},
"title": "ICS-Kalender",
"config": {
"step": {
"user": {
"data": {
"name": "Name",
"days": "Tage",
"include_all_day": "Ganztägige Ereignisse einbeziehen?"
},
"title": "Kalender hinzufügen"
},
"calendar_opts": {
"data": {
"exclude": "auszuschließende Ereignisse",
"include": "einzuschließende Ereignisse",
"prefix": "String, um allen Zusammenfassungen ein Präfix hinzuzufügen",
"download_interval": "Download-Intervall (Minuten)",
"offset_hours": "Anzahl der Stunden, um Ereigniszeiten zu versetzen",
"parser": "Parser (rie oder ics)"
},
"title": "Kalender-Optionen"
},
"connect_opts": {
"data": {
"url": "URL der ICS-Datei",
"requires_auth": "Erfordert Authentifizierung?",
"advanced_connection_options": "Erweiterte Verbindungsoptionen festlegen?"
},
"title": "Verbindungsoptionen"
},
"auth_opts": {
"data": {
"username": "Benutzername",
"password": "Passwort"
},
"description": "Bitte beachte, dass nur HTTP Basic Auth und HTTP Digest Auth unterstützt wird. Authentifizierungsmethoden wie OAuth werden derzeit nicht unterstützt.",
"title": "Authentifizierung"
},
"adv_connect_opts": {
"data": {
"accept_header": "Eigener Accept-Header für fehlerhafte Server",
"user_agent": "Eigener User-Agent-Header",
"set_connection_timeout": "Verbindungstimeout ändern?"
},
"title": "Erweiterte Verbindungsoptionen"
},
"timeout_opts": {
"data": {
"connection_timeout": "Verbindungstimeout in Sekunden"
},
"title": "Verbindungstimeout-Optionen"
},
"reauth_confirm": {
"description": "Die Autorisierung für den Kalender ist fehlgeschlagen. Bitte konfiguriere die Kalender-URL und/oder die Authentifizierungseinstellungen neu.",
"title": "Autorisierungsfehler für ICS-Kalender"
}
},
"error": {
"empty_name": "Der Kalendername darf nicht leer sein.",
"empty_url": "Die URL darf nicht leer sein.",
"download_interval_too_small": "Das Download-Intervall muss mindestens 15 betragen.",
"exclude_include_cannot_be_the_same": "Die Ausschluss- und Einschluss-Strings dürfen nicht identisch sein.",
"exclude_must_be_array": "Die \"auszuschließenden Ereignisse\" müssen ein Array von Zeichenfolgen oder regulären Ausdrücken sein. Weitere Informationen finden Sie unter {filterdoc}.",
"include_must_be_array": "Die \"einzuschließenden Ereignisse\" müssen ein Array von Zeichenfolgen oder regulären Ausdrücken sein. Weitere Informationen finden Sie unter {filterdoc}."
},
"abort": {
}
}
}
@@ -0,0 +1,77 @@
{
"issues": {
"YAML_Warning": {
"title": "YAML configuration is deprecated for ICS Calendar",
"description": "YAML configuration of ics_calendar is deprecated and will be removed in ics_calendar v5.0.0. Your configuration items have been imported. Please remove them from your configuration.yaml file."
}
},
"title": "ICS Calendar",
"config": {
"step": {
"user": {
"data": {
"name": "Name",
"days": "Days",
"include_all_day": "Include all day events?"
},
"title": "Add Calendar"
},
"calendar_opts": {
"data": {
"exclude": "Exclude filter",
"include": "Include filter",
"prefix": "String to prefix all event summaries",
"download_interval": "Download interval (minutes)",
"offset_hours": "Number of hours to offset event times",
"parser": "Parser (rie or ics)",
"summary_default": "Summary if event doesn't have one"
},
"title": "Calendar Options"
},
"connect_opts": {
"data": {
"url": "URL of ICS file",
"requires_auth": "Requires authentication?",
"advanced_connection_options": "Set advanced connection options?"
},
"title": "Connection Options"
},
"auth_opts": {
"data": {
"username": "Username",
"password": "Password"
},
"description": "Please note this component supports only HTTP Basic Auth and HTTP Digest Auth. More advanced authentication, like OAuth is not supported at this time.",
"title": "Authentication"
},
"adv_connect_opts": {
"data": {
"accept_header": "Custom Accept header for broken servers",
"user_agent": "Custom User-agent header",
"set_connection_timeout": "Change connection timeout?"
},
"title": "Advanced Connection Options"
},
"timeout_opts": {
"data": {
"connection_timeout": "Connection timeout in seconds"
},
"title": "Connection Timeout Options"
},
"reauth_confirm": {
"description": "Authorization failed for calendar. Please re-configured the calendar URL and/or authentication settings.",
"title": "Authorization Failure for ICS Calendar"
}
},
"error": {
"empty_name": "The calendar name must not be empty.",
"empty_url": "The url must not be empty.",
"download_interval_too_small": "The download interval must be at least 15.",
"exclude_include_cannot_be_the_same": "The exclude and include strings must not be the same",
"exclude_must_be_array": "The exclude option must be an array of strings or regular expressions. See {filterdoc} for more information.",
"include_must_be_array": "The include option must be an array of strings or regular expressions. See {filterdoc} for more information."
},
"abort": {
}
}
}
@@ -0,0 +1,77 @@
{
"issues": {
"YAML_Warning": {
"title": "La configuración YAML está obsoleta para ICS Calendar",
"description": "La configuración YAML de ics_calendar está obsoleta y se eliminará en ics_calendar v5.0.0. Sus elementos de configuración se han importado. Elimínelos de su archivo configuration.yaml."
}
},
"title": "ICS Calendar",
"config": {
"step": {
"user": {
"data": {
"name": "Nombre",
"days": "Días",
"include_all_day": "¿Incluir eventos de todo el día?"
},
"title": "Agregar calendario"
},
"calendar_opts": {
"data": {
"exclude": "Excluir filtro",
"include": "Incluir filtro",
"prefix": "Cadena que precederá a todos los resúmenes de eventos",
"download_interval": "Intervalo de descarga (minutos)",
"offset_hours": "Número de horas para compensar los tiempos del evento",
"parser": "Parser (rie or ics)",
"summary_default": "Resumen si el evento no tiene uno"
},
"title": "Opciones de calendario"
},
"connect_opts": {
"data": {
"url": "URL del archivo ICS",
"requires_auth": "¿Requiere autentificación?",
"advanced_connection_options": "¿Establecer opciones de conexión avanzadas?"
},
"title": "Opciones de conexión"
},
"auth_opts": {
"data": {
"username": "Nombre de usuario",
"password": "Contraseña"
},
"description": "Tenga en cuenta que este componente solo admite la autenticación básica HTTP y la autenticación HTTP Digest. Actualmente, no se admiten autenticaciones más avanzadas, como OAuth.",
"title": "Autentificación"
},
"adv_connect_opts": {
"data": {
"accept_header": "Encabezado Accept personalizado para servidores rotos",
"user_agent": "Encabezado de agente de usuario personalizado",
"set_connection_timeout": "¿Cambiar el tiempo de espera de la conexión?"
},
"title": "Opciones avanzadas de conexión"
},
"timeout_opts": {
"data": {
"connection_timeout": "Tiempo de espera de la conexión en segundos"
},
"title": "Opciones de tiempo de espera de la conexión"
},
"reauth_confirm": {
"description": "Error de autorización para el calendario. Vuelva a configurar la URL del calendario y/o los ajustes de autenticación.",
"title": "Fallo de autorización para ICS Calendar"
}
},
"error": {
"empty_name": "El nombre del calendario no debe estar vacío.",
"empty_url": "La url no debe estar vacía.",
"download_interval_too_small": "El intervalo de descarga debe ser de al menos 15.",
"exclude_include_cannot_be_the_same": "Las cadenas de exclusión e inclusión no deben ser las mismas",
"exclude_must_be_array": "La opción de exclusión debe ser una matriz de cadenas o expresiones regulares. Consulte {filterdoc} para obtener más información.",
"include_must_be_array": "La opción de inclusión debe ser un array de cadenas o expresiones regulares. Consulte {filterdoc} para obtener más información."
},
"abort": {
}
}
}
@@ -0,0 +1,76 @@
{
"issues": {
"YAML_Warning": {
"title": "La configuration YAML pour ICS Calendar est obsolète",
"description": "La configuration YAML d'ICS Calendar est obsolète et sera supprimée dans la version 5.0.0 d'ics_calendar. Les éléments de votre configuration ont été importés. Veuillez les supprimer de votre fichier configuration.yaml."
}
},
"title": "ICS Calendar",
"config": {
"step": {
"user": {
"data": {
"name": "Nom",
"days": "Jours",
"include_all_day": "Inclure les événements à la journée ?"
},
"title": "Ajouter un calendrier"
},
"calendar_opts": {
"data": {
"exclude": "Exclure les événements contenant",
"include": "Inclure les événements contenant",
"prefix": "Préfixer tous les résumés d'événements avec",
"download_interval": "Intervalle de téléchargement (minutes)",
"offset_hours": "Décalage à appliquer aux horaires des événements (heures)",
"parser": "Parseur (rie ou ics)"
},
"title": "Options du calendrier"
},
"connect_opts": {
"data": {
"url": "URL du fichier ICS",
"requires_auth": "Authentification requise ?",
"advanced_connection_options": "Définir les options avancées de la connexion ?"
},
"title": "Options de connexion"
},
"auth_opts": {
"data": {
"username": "Utilisateur",
"password": "Mot de passe"
},
"description": "Veuillez noter que cette intégration ne supporte que les modes d'authentification HTTP Basic et HTTP Digest. Les méthodes d'authentification plus avancées, telles que OAuth, ne sont pas supportées actuellement.",
"title": "Authentification"
},
"adv_connect_opts": {
"data": {
"accept_header": "Entête 'Accept' personnalisée pour les serveurs injoignables",
"user_agent": "Entête 'User-agent' personnalisée",
"set_connection_timeout": "Modifier le délai maximum autorisé pour la connexion ?"
},
"title": "Options avancées de connexion"
},
"timeout_opts": {
"data": {
"connection_timeout": "Délai maximum autorisé pour la connexion (secondes)"
},
"title": "Options de délai de connexion"
},
"reauth_confirm": {
"description": "L'autorisation a échoué pour le calendrier. Veuillez vérifier l'URL du calendrier et/ou les paramètres d'authentification.",
"title": "Échec d'autorisation pour ICS Calendar"
}
},
"error": {
"empty_name": "Le nom du calendrier doit être renseigné.",
"empty_url": "L'URL du calendrier doit être renseignée.",
"download_interval_too_small": "L'intervalle de téléchargement ne peut pas être inférieur à 15 minutes.",
"exclude_include_cannot_be_the_same": "Les valeurs d'exclusion et d'inclusion ne peuvent pas être identiques.",
"exclude_must_be_array": "L'option d'exclusion doit être un tableau de chaînes ou d'expressions régulières. Voir {filterdoc} pour plus d'informations.",
"include_must_be_array": "L'option d'inclusion doit être un tableau de chaînes ou d'expressions régulières. Voir {filterdoc} pour plus d'informations."
},
"abort": {
}
}
}
@@ -0,0 +1,77 @@
{
"issues": {
"YAML_Warning": {
"title": "A configuração YAML está obsoleta para o ICS Calendar",
"description": "A configuração YAML do ics_calendar está obsoleta e será removida na versão 5.0.0 do ics_calendar. Seus itens de configuração foram importados. Por favor, remova-os do seu arquivo configuration.yaml."
}
},
"title": "ICS Calendar",
"config": {
"step": {
"user": {
"data": {
"name": "Nome",
"days": "Dias",
"include_all_day": "Incluir eventos de dia inteiro?"
},
"title": "Adicionar Calendário"
},
"calendar_opts": {
"data": {
"exclude": "Filtro de exclusão",
"include": "Filtro de inclusão",
"prefix": "Texto para prefixar todos os resumos de eventos",
"download_interval": "Intervalo de download (minutos)",
"offset_hours": "Número de horas para ajustar os horários dos eventos",
"parser": "Parser (rie ou ics)",
"summary_default": "Resumo padrão se o evento não tiver um"
},
"title": "Opções do Calendário"
},
"connect_opts": {
"data": {
"url": "URL do arquivo ICS",
"requires_auth": "Requer autenticação?",
"advanced_connection_options": "Definir opções de conexão avançadas?"
},
"title": "Opções de Conexão"
},
"auth_opts": {
"data": {
"username": "Usuário",
"password": "Senha"
},
"description": "Este componente oferece suporte apenas para HTTP Basic Auth e HTTP Digest Auth. Métodos de autenticação mais avançados, como OAuth, ainda não são suportados.",
"title": "Autenticação"
},
"adv_connect_opts": {
"data": {
"accept_header": "Cabeçalho Accept personalizado para servidores com problemas",
"user_agent": "Cabeçalho User-agent personalizado",
"set_connection_timeout": "Alterar tempo limite de conexão?"
},
"title": "Opções Avançadas de Conexão"
},
"timeout_opts": {
"data": {
"connection_timeout": "Tempo limite de conexão em segundos"
},
"title": "Opções de Tempo Limite de Conexão"
},
"reauth_confirm": {
"description": "A autorização falhou para o calendário. Por favor, reconfigure a URL do calendário e/ou as configurações de autenticação.",
"title": "Falha de Autorização para o ICS Calendar"
}
},
"error": {
"empty_name": "O nome do calendário não pode estar vazio.",
"empty_url": "A URL não pode estar vazia.",
"download_interval_too_small": "O intervalo de download deve ser de pelo menos 15.",
"exclude_include_cannot_be_the_same": "As strings de exclusão e inclusão não podem ser as mesmas.",
"exclude_must_be_array": "A opção de exclusão deve ser um array de strings ou expressões regulares. Veja {filterdoc} para mais informações.",
"include_must_be_array": "A opção de inclusão deve ser um array de strings ou expressões regulares. Veja {filterdoc} para mais informações."
},
"abort": {
}
}
}
+38
View File
@@ -0,0 +1,38 @@
"""Utility methods."""
from datetime import date, datetime
def make_datetime(val):
"""Ensure val is a datetime, not a date."""
if isinstance(val, date) and not isinstance(val, datetime):
return datetime.combine(val, datetime.min.time()).astimezone()
return val
def compare_event_dates( # pylint: disable=R0913,R0917
now, end2, start2, all_day2, end, start, all_day
) -> bool:
"""Determine if end2 and start2 are newer than end and start."""
# Make sure we only compare datetime values, not dates with datetimes.
# Set each date object to a datetime at midnight.
end = make_datetime(end)
end2 = make_datetime(end2)
start = make_datetime(start)
start2 = make_datetime(start2)
if all_day2 == all_day:
if end2 == end:
return start2 > start
return end2 > end and start2 >= start
if now.tzinfo is None:
now = now.astimezone()
event2_current = start2 <= now <= end2
event_current = start <= now <= end
if event_current and event2_current:
return all_day
return start2 >= start or end2 >= end
+145
View File
@@ -0,0 +1,145 @@
substitutions:
name: "dc-load"
friendly_name: Atorch programmable DC load
external_components_source: github://syssi/esphome-atorch-dl24@main
dl24_mac_address: !secret dl24_mac_address
project_version: 2.1.0
device_description: "Monitor and control a Atorch meter via bluetooth BLE"
esphome:
name: ${name}
friendly_name: ${friendly_name}
comment: ${device_description}
min_version: 2024.6.0
project:
name: "syssi.esphome-atorch-dl24"
version: ${project_version}
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
external_components:
- source: ${external_components_source}
refresh: 0s
# Enable logging
logger:
level: DEBUG
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
# Enable over-the-air updates
ota:
platform: esphome
password: !secret ota
network:
enable_ipv6: true
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: atorch-dc-load.home
power_save_mode: high
fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "DC-Load Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
#esp32_ble_tracker:
# scan_parameters:
# active: true
# on_ble_advertise:
# then:
# - lambda: |-
# if (x.get_name().rfind("-BLE", 0) == 0) {
# ESP_LOGI("ble_adv", "New Atorch device found");
# ESP_LOGI("ble_adv", " Name: %s", x.get_name().c_str());
# ESP_LOGI("ble_adv", " MAC address: %s", x.address_str().c_str());
# ESP_LOGD("ble_adv", " Advertised service UUIDs:");
# for (auto uuid : x.get_service_uuids()) {
# ESP_LOGD("ble_adv", " - %s", uuid.to_string().c_str());
# }
# }
#
#text_sensor:
# - platform: ble_scanner
# name: "BLE Devices Scanner"
esp32_ble_tracker:
scan_parameters:
active: False
ble_client:
- mac_address: ${dl24_mac_address}
id: ble_client0
atorch_dl24:
- id: atorch0
ble_client_id: ble_client0
check_crc: false
# The meter publishes a status report per second via BLE notification. If you don't like this update interval
# you can use this setting to throttle the sensor updates by skipping some status reports.
throttle: 0s
binary_sensor:
- platform: atorch_dl24
atorch_dl24_id: atorch0
running:
name: "${name} running"
sensor:
- platform: atorch_dl24
atorch_dl24_id: atorch0
voltage:
name: "${name} voltage"
current:
name: "${name} current"
power:
name: "${name} power"
capacity:
name: "${name} capacity"
energy:
name: "${name} energy"
temperature:
name: "${name} temperature"
dim_backlight:
name: "${name} dim backlight"
runtime:
name: "${name} runtime"
text_sensor:
- platform: atorch_dl24
atorch_dl24_id: atorch0
runtime_formatted:
name: "${name} runtime formatted"
button:
- platform: atorch_dl24
atorch_dl24_id: atorch0
reset_energy:
name: "${name} reset energy"
reset_capacity:
name: "${name} reset capacity"
reset_runtime:
name: "${name} reset runtime"
reset_all:
name: "${name} reset all"
usb_plus:
name: "${name} plus"
usb_minus:
name: "${name} minus"
setup:
name: "${name} setup"
enter:
name: "${name} enter"
@@ -39,6 +39,7 @@ api:
key: !secret apikey key: !secret apikey
ota: ota:
platform: esphome
password: !secret ota password: !secret ota
# DHT22 sensor # DHT22 sensor
@@ -0,0 +1,293 @@
substitutions:
name: "owon-b35t"
friendly_name: "OWON B35T Multimeter"
device_description: "M5Stack Core 1 BLE client for OWON B35T/B35T+ multimeter with local graphical display"
owon_mac_address: !secret owon_b35t_mac_address
esphome:
name: ${name}
friendly_name: ${friendly_name}
comment: ${device_description}
min_version: 2024.6.0
includes:
- owon_b35t-m5stack-core1.h
project:
name: "custom.owon-b35t-m5stack"
version: "1.0"
esp32:
board: m5stack-core-esp32
framework:
type: esp-idf
advanced:
minimum_chip_revision: "3.1"
logger:
level: INFO
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: ${name}.home
power_save_mode: none
fast_connect: on
min_auth_mode: WPA2
ap:
ssid: "OWON B35T Fallback Hotspot"
password: !secret fallback_psk
# Disabled to save RAM on the M5Stack Core 1. Re-enable temporarily if Wi-Fi recovery is needed.
# captive_portal:
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("mem", "heap free=%u min_free=%u internal_free=%u internal_largest=%u dma_free=%u dma_largest=%u",
static_cast<unsigned>(esp_get_free_heap_size()),
static_cast<unsigned>(esp_get_minimum_free_heap_size()),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_DMA)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_DMA)));
esp32_ble_tracker:
scan_parameters:
active: true
continuous: true
ble_client:
- mac_address: ${owon_mac_address}
id: owon_ble_client
on_connect:
then:
- lambda: |-
owon_meter.on_connect();
on_disconnect:
then:
- lambda: |-
owon_meter.on_disconnect();
- logger.log:
level: WARN
format: "OWON BLE meter disconnected; restarting M5Stack to reclaim heap"
- delay: 1s
- lambda: |-
esp_restart();
spi:
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
output:
- platform: ledc
pin: GPIO32
id: lcd_backlight
light:
- platform: monochromatic
output: lcd_backlight
name: "${friendly_name} Backlight"
id: backlight
restore_mode: ALWAYS_ON
font:
- file: "fonts/Roboto-Regular.ttf"
id: meter_font
size: 15
glyphs:
[
" ",
"!",
"%",
"+",
"-",
".",
"/",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"<",
">",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"L",
"M",
"N",
"O",
"P",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"k",
"m",
"n",
"o",
"r",
"s",
"t",
"u",
"v",
"w",
"y",
"z",
"°",
"µ",
"Ω",
]
display:
- platform: ili9xxx
id: lcd
model: M5STACK
cs_pin: GPIO14
dc_pin: GPIO27
reset_pin: GPIO33
invert_colors: true
color_palette: 8BIT
rotation: 0
update_interval: 500ms
lambda: |-
owon_meter.render(it, id(meter_font));
binary_sensor:
- platform: gpio
id: button_a
pin:
number: GPIO39
inverted: true
internal: true
on_press:
then:
- lambda: |-
owon_meter.previous_button();
- platform: gpio
id: button_b
pin:
number: GPIO38
inverted: true
internal: true
on_click:
- min_length: 50ms
max_length: 1500ms
then:
- logger.log:
level: INFO
format: "OWON short press: %s"
args: ["owon_meter.selected_button_name()"]
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda |-
std::vector<uint8_t> data = {owon_meter.selected_button, 0x01};
return data;
- min_length: 1500ms
max_length: 5000ms
then:
- logger.log:
level: INFO
format: "OWON long press: %s"
args: ["owon_meter.selected_button_name()"]
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda |-
uint8_t press_type = (owon_meter.selected_button == 1 || owon_meter.selected_button == 5) ? 0x01 : 0x00;
std::vector<uint8_t> data = {owon_meter.selected_button, press_type};
return data;
- platform: gpio
id: button_c
pin:
number: GPIO37
inverted: true
internal: true
on_press:
then:
- lambda: |-
owon_meter.next_button();
- platform: template
name: "${friendly_name} Connected"
lambda: |-
return owon_meter.connected;
- platform: template
name: "${friendly_name} Overload"
lambda: |-
return owon_meter.overload;
- platform: template
name: "${friendly_name} Low Battery"
lambda: |-
return owon_meter.low_battery;
sensor:
- platform: ble_client
type: characteristic
ble_client_id: owon_ble_client
id: owon_notify_source
internal: true
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff4-0000-1000-8000-00805f9b34fb"
notify: true
update_interval: never
lambda: |-
owon_meter.handle_notify(x);
return owon_meter.value();
- platform: wifi_signal
name: "${friendly_name} WiFi Signal"
update_interval: 60s
text_sensor:
- platform: template
name: "${friendly_name} Reading"
update_interval: 2s
lambda: |-
return owon_meter.reading_text();
- platform: template
name: "${friendly_name} Unit"
update_interval: 2s
lambda: |-
return std::string(owon_meter.scale()) + owon_meter.unit();
- platform: template
name: "${friendly_name} Mode"
update_interval: 2s
lambda: |-
return owon_meter.mode_text();
+658
View File
@@ -0,0 +1,658 @@
/*
* ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 1.
* Parser is based on the standalone Arduino sketch by Reaper7
* (Beerware license, Revision 42) and Dean Cording's owonb35 notes.
*/
#pragma once
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include "esp_heap_caps.h"
#include "esp_system.h"
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/log.h"
#include "esphome/components/display/display.h"
namespace owon_b35t {
using esphome::Color;
using esphome::display::Display;
static const char *const TAG = "owon_b35t";
static const char *const POWER_TAG = "core2_power";
static constexpr uint8_t AXP192_ADDR = 0x34;
static esphome::i2c::I2CDevice axp192;
static bool axp192_ready = false;
static bool axp_write(uint8_t reg, uint8_t value) {
if (!axp192_ready) return false;
bool ok = axp192.write_byte(reg, value);
if (!ok) ESP_LOGW(POWER_TAG, "AXP192 write reg 0x%02X failed", reg);
return ok;
}
static bool axp_read(uint8_t reg, uint8_t *value) {
if (!axp192_ready) return false;
bool ok = axp192.read_byte(reg, value);
if (!ok) ESP_LOGW(POWER_TAG, "AXP192 read reg 0x%02X failed", reg);
return ok;
}
static void axp_update(uint8_t reg, uint8_t clear_mask, uint8_t set_mask) {
uint8_t value = 0;
if (!axp_read(reg, &value)) return;
value = (value & ~clear_mask) | set_mask;
axp_write(reg, value);
}
static uint8_t axp_dc_voltage_data(uint16_t millivolts) {
if (millivolts < 700) millivolts = 700;
if (millivolts > 3500) millivolts = 3500;
return static_cast<uint8_t>((millivolts - 700) / 25) & 0x7F;
}
static uint8_t axp_ldo_voltage_data(uint16_t millivolts) {
if (millivolts < 1800) millivolts = 1800;
if (millivolts > 3300) millivolts = 3300;
return static_cast<uint8_t>((millivolts - 1800) / 100) & 0x0F;
}
static void core2_axp192_set_lcd_voltage(uint16_t millivolts) {
uint8_t value = 0;
axp_read(0x27, &value);
axp_write(0x27, (value & 0x80) | axp_dc_voltage_data(millivolts)); // DCDC3, LCD backlight
}
static void core2_axp192_set_backlight(float brightness) {
if (brightness <= 0.0f) {
axp_update(0x12, 0x02, 0x00); // DCDC3 off
return;
}
if (brightness > 1.0f) brightness = 1.0f;
uint16_t millivolts = static_cast<uint16_t>(2400 + brightness * 900);
core2_axp192_set_lcd_voltage(millivolts);
axp_update(0x12, 0x00, 0x02); // DCDC3 on
}
static void core2_axp192_init(esphome::i2c::I2CBus *bus) {
axp192.set_i2c_bus(bus);
axp192.set_i2c_address(AXP192_ADDR);
axp192_ready = true;
ESP_LOGI(POWER_TAG, "Initializing M5Stack Core2 AXP192 LCD power");
axp_update(0x30, 0xF9, 0x02); // Disable VBUS current limit, preserve bit 2.
axp_update(0x92, 0x07, 0x00); // GPIO1 open-drain output.
axp_update(0x93, 0x07, 0x00); // GPIO2 open-drain output.
axp_write(0x35, 0xA2); // RTC battery charging.
uint8_t value = 0;
axp_read(0x26, &value);
axp_write(0x26, (value & 0x80) | axp_dc_voltage_data(3350)); // DCDC1 ESP32 VDD.
core2_axp192_set_lcd_voltage(2800);
uint8_t ldo2 = axp_ldo_voltage_data(3300);
uint8_t ldo3 = axp_ldo_voltage_data(2000);
axp_write(0x28, (ldo2 << 4) | ldo3); // LDO2 LCD logic/SD, LDO3 vibrator.
axp_update(0x12, 0x00, 0x07); // Enable DCDC1, DCDC3, LDO2.
axp_write(0x82, 0xFF); // ADCs on.
axp_update(0x95, 0x8D, 0x84); // GPIO4 setup, as M5Core2 library does.
axp_write(0x36, 0x4C); // Power key timing.
// LCD reset through AXP192 GPIO4.
axp_update(0x96, 0x02, 0x00);
delay(100);
axp_update(0x96, 0x00, 0x02);
delay(100);
core2_axp192_set_backlight(1.0f);
}
static const uint8_t ACCU_BMP[32] = {
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b11111111, 0b11111110,
0b10000000, 0b00000010,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000010,
0b11111111, 0b11111110,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
};
static const uint8_t BLE_BMP[32] = {
0b00000001, 0b10000000,
0b00000001, 0b11000000,
0b00010001, 0b01100000,
0b00011001, 0b00110000,
0b00001101, 0b00011000,
0b00000111, 0b00110000,
0b00000011, 0b01100000,
0b00000001, 0b11000000,
0b00000001, 0b11000000,
0b00000011, 0b01100000,
0b00000111, 0b00110000,
0b00001101, 0b00011000,
0b00011001, 0b00110000,
0b00010001, 0b01100000,
0b00000001, 0b11000000,
0b00000001, 0b10000000,
};
static const uint8_t DIODE_BMP[32] = {
0b00001000, 0b00011000,
0b00001100, 0b00011000,
0b00001110, 0b00011000,
0b00001111, 0b00011000,
0b00001111, 0b10011000,
0b00001111, 0b11011000,
0b00001111, 0b11111000,
0b11111111, 0b11111111,
0b11111111, 0b11111111,
0b00001111, 0b11111000,
0b00001111, 0b11011000,
0b00001111, 0b10011000,
0b00001111, 0b00011000,
0b00001110, 0b00011000,
0b00001100, 0b00011000,
0b00001000, 0b00011000,
};
static const uint8_t BUZZ_BMP[32] = {
0b00000000, 0b11000000,
0b00000001, 0b11000000,
0b00000011, 0b11000001,
0b00000111, 0b11000001,
0b00001111, 0b11000101,
0b11111111, 0b11000101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11000101,
0b00001111, 0b11000101,
0b00000111, 0b11000001,
0b00000011, 0b11000001,
0b00000001, 0b11000000,
0b00000000, 0b11000000,
};
class Meter {
public:
static constexpr uint8_t REGPLUSMINUS = 0x00;
static constexpr uint8_t FLAGPLUS = 0b00101011;
static constexpr uint8_t FLAGMINUS = 0b00101101;
static constexpr uint8_t REGDIG1 = 0x01;
static constexpr uint8_t REGDIG2 = 0x02;
static constexpr uint8_t REGDIG3 = 0x03;
static constexpr uint8_t REGDIG4 = 0x04;
static constexpr uint8_t REGPOINT = 0x06;
static constexpr uint8_t FLAGPOINT0 = 0b00110000;
static constexpr uint8_t FLAGPOINT1 = 0b00110001;
static constexpr uint8_t FLAGPOINT2 = 0b00110010;
static constexpr uint8_t FLAGPOINT3 = 0b00110100;
static constexpr uint8_t REGMODE = 0x07;
static constexpr uint8_t FLAGMODEHOLD = 0b00000010;
static constexpr uint8_t FLAGMODEREL = 0b00000100;
static constexpr uint8_t FLAGMODEAC = 0b00001000;
static constexpr uint8_t FLAGMODEDC = 0b00010000;
static constexpr uint8_t FLAGMODEAUTO = 0b00100000;
static constexpr uint8_t REGMINMAX = 0x08;
static constexpr uint8_t FLAGMIN = 0b00010000;
static constexpr uint8_t FLAGMAX = 0b00100000;
static constexpr uint8_t REGSCALE = 0x09;
static constexpr uint8_t FLAGSCALEDUTY = 0b00000010;
static constexpr uint8_t FLAGSCALEDIODE = 0b00000100;
static constexpr uint8_t FLAGSCALEBUZZ = 0b00001000;
static constexpr uint8_t FLAGSCALEMEGA = 0b00010000;
static constexpr uint8_t FLAGSCALEKILO = 0b00100000;
static constexpr uint8_t FLAGSCALEMILLI = 0b01000000;
static constexpr uint8_t FLAGSCALEMICRO = 0b10000000;
static constexpr uint8_t REGUNIT = 0x0a;
static constexpr uint8_t FLAGUNITFAHR = 0b00000001;
static constexpr uint8_t FLAGUNITGRAD = 0b00000010;
static constexpr uint8_t FLAGUNITNF = 0b00000100;
static constexpr uint8_t FLAGUNITHZ = 0b00001000;
static constexpr uint8_t FLAGUNITHFE = 0b00010000;
static constexpr uint8_t FLAGUNITOHM = 0b00100000;
static constexpr uint8_t FLAGUNITAMP = 0b01000000;
static constexpr uint8_t FLAGUNITVOLT = 0b10000000;
bool connected{false};
bool write_available{false};
bool is_plus{false};
bool low_battery{false};
bool overload{false};
bool has_reading{false};
uint8_t selected_button{1};
uint32_t last_notify_ms{0};
bool handle_notify(const std::vector<uint8_t> &data) {
if (data.size() > sizeof(this->raw_))
return false;
if (data.size() == 6 && data[1] >= 0xF0) {
memset(this->raw_, 0, sizeof(this->raw_));
memcpy(this->raw_, data.data(), data.size());
this->is_plus = true;
this->parse_plus_();
} else if (data.size() == 14 && data[12] == 0x0D && data[13] == 0x0A) {
memset(this->value_, 0, sizeof(this->value_));
memcpy(this->value_, data.data(), data.size());
this->is_plus = false;
} else {
ESP_LOGW(TAG, "Ignoring unexpected OWON frame length=%u", static_cast<unsigned>(data.size()));
return false;
}
this->overload = memcmp(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)) == 0;
this->display_value = this->calc_display_value_();
this->base_value = this->calc_base_value_();
this->has_reading = true;
this->last_notify_ms = millis();
return true;
}
void on_connect() {
this->connected = true;
this->write_available = true;
}
void on_disconnect() {
this->connected = false;
this->write_available = false;
}
float value() const { return this->display_value; }
float value_base() const { return this->base_value; }
bool negative() const { return (this->value_[REGPLUSMINUS] & FLAGMINUS) == FLAGMINUS; }
bool auto_range() const { return (this->value_[REGMODE] & FLAGMODEAUTO) == FLAGMODEAUTO; }
bool hold() const { return (this->value_[REGMODE] & FLAGMODEHOLD) == FLAGMODEHOLD; }
bool relative() const { return (this->value_[REGMODE] & FLAGMODEREL) == FLAGMODEREL; }
bool ac() const { return (this->value_[REGMODE] & FLAGMODEAC) == FLAGMODEAC; }
bool dc() const { return (this->value_[REGMODE] & FLAGMODEDC) == FLAGMODEDC; }
bool min_mode() const { return (this->value_[REGMINMAX] & FLAGMIN) == FLAGMIN; }
bool max_mode() const { return (this->value_[REGMINMAX] & FLAGMAX) == FLAGMAX; }
bool diode() const { return (this->value_[REGSCALE] & FLAGSCALEDIODE) == FLAGSCALEDIODE; }
bool continuity() const { return (this->value_[REGSCALE] & FLAGSCALEBUZZ) == FLAGSCALEBUZZ; }
const char *unit() const {
switch (this->value_[REGUNIT]) {
case FLAGUNITFAHR: return "°F";
case FLAGUNITGRAD: return "°C";
case FLAGUNITNF: return "nF";
case FLAGUNITHZ: return "Hz";
case FLAGUNITHFE: return "hFE";
case FLAGUNITOHM: return "Ω";
case FLAGUNITAMP: return "A";
case FLAGUNITVOLT: return "V";
default: return "";
}
}
const char *scale() const {
if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return "%";
if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return "M";
if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return "k";
if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return "m";
if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return "µ";
return "";
}
std::string mode_text() const {
std::string out;
if (this->dc()) out += "DC ";
if (this->ac()) out += "AC ";
if (this->auto_range()) out += "AUTO ";
if (this->hold()) out += "HOLD ";
if (this->relative()) out += "REL ";
if (this->min_mode()) out += "MIN ";
if (this->max_mode()) out += "MAX ";
if (this->diode()) out += "DIODE ";
if (this->continuity()) out += "CONT ";
if (!out.empty()) out.pop_back();
return out;
}
std::string reading_text() const {
if (!this->connected) return "Disconnected";
if (!this->has_reading) return "Waiting for data";
if (this->overload) return "OL " + std::string(this->scale()) + this->unit();
char buf[48];
snprintf(buf, sizeof(buf), "%s%.4g %s%s", this->negative() ? "-" : "", std::fabs(this->display_value), this->scale(), this->unit());
return std::string(buf);
}
enum Kind { KIND_OTHER, KIND_VOLTAGE, KIND_CURRENT, KIND_RESISTANCE, KIND_FREQUENCY, KIND_CAPACITANCE, KIND_TEMP_C, KIND_TEMP_F, KIND_DUTY };
Kind kind() const {
if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return KIND_DUTY;
switch (this->value_[REGUNIT]) {
case FLAGUNITVOLT: return KIND_VOLTAGE;
case FLAGUNITAMP: return KIND_CURRENT;
case FLAGUNITOHM: return KIND_RESISTANCE;
case FLAGUNITHZ: return KIND_FREQUENCY;
case FLAGUNITNF: return KIND_CAPACITANCE;
case FLAGUNITGRAD: return KIND_TEMP_C;
case FLAGUNITFAHR: return KIND_TEMP_F;
default: return KIND_OTHER;
}
}
const char *selected_button_name() const {
static const char *const names[] = {"SELECT", "RANGE", "HLD/LIG", "REL/BT", "HZ/DUTY", "MAX/MIN"};
uint8_t index = this->selected_button;
if (index < 1) index = 1;
if (index > 6) index = 6;
return names[index - 1];
}
void previous_button() {
if (this->selected_button > 1) this->selected_button--;
}
void next_button() {
if (this->selected_button < 6) this->selected_button++;
}
void render(Display &it, esphome::display::BaseFont *font) {
const Color bg(0, 0, 0);
const Color fg(210, 210, 210);
// Chosen to map to a neutral dark gray in the RGB332 8-bit display palette.
const Color inactive(80, 80, 80);
const Color yellow(255, 220, 0);
const Color blue(0, 80, 255);
const Color cyan(0, 255, 255);
const Color magenta(255, 0, 255);
const Color red(255, 0, 0);
const Color green(0, 220, 0);
const Color orange(255, 165, 0);
it.fill(bg);
bool status_active = this->connected && this->has_reading;
this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, status_active ? (this->low_battery ? red : green) : inactive);
this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive);
this->label_(it, font, 86, 8, "AUTO", status_active && this->auto_range() ? fg : inactive);
this->label_(it, font, 138, 8, "MAX", status_active && this->max_mode() ? red : inactive);
this->label_(it, font, 178, 8, "MIN", status_active && this->min_mode() ? green : inactive);
this->label_(it, font, 218, 8, "HOLD", status_active && this->hold() ? blue : inactive);
this->label_(it, font, 270, 8, "REL", status_active && this->relative() ? Color(128, 128, 0) : inactive);
this->label_(it, font, 8, 66, "DC", status_active && this->dc() ? cyan : inactive);
this->label_(it, font, 8, 102, "AC", status_active && this->ac() ? magenta : inactive);
if (!this->connected) {
this->draw_digits_(it, "----", false, inactive);
it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "scan/connect");
} else if (!this->has_reading) {
this->draw_digits_(it, "8888", false, inactive);
it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "waiting");
} else if (this->overload) {
this->draw_digits_(it, " OL ", false, fg);
} else {
char d[5];
d[0] = this->digit_char_(REGDIG1);
d[1] = this->digit_char_(REGDIG2);
d[2] = this->digit_char_(REGDIG3);
d[3] = this->digit_char_(REGDIG4);
d[4] = 0;
this->draw_digits_(it, d, this->negative(), fg);
this->draw_decimal_points_(it, fg);
}
if (status_active) {
std::string unit_line = std::string(this->scale()) + this->unit();
it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str());
}
bool bargraph_active = status_active && !this->overload;
this->draw_bargraph_(it, bargraph_active ? this->digits_from_buffer_() : 0, bargraph_active);
this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, status_active && this->diode() ? magenta : inactive);
this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, status_active && this->continuity() ? orange : inactive);
it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive);
it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive);
it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive);
it.print(54, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, "<");
it.print(158, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, this->selected_button_name());
it.print(262, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, ">");
}
private:
uint8_t raw_[14]{};
uint8_t value_[14]{};
float display_value{NAN};
float base_value{NAN};
static constexpr uint8_t OVERLOAD_FRAME[5] = {0x2B, 0x3F, 0x30, 0x3A, 0x3F};
uint16_t digits_from_buffer_() const {
uint16_t out = 0;
if (this->value_[REGDIG1] >= '0' && this->value_[REGDIG1] <= '9') out += (this->value_[REGDIG1] - '0') * 1000;
if (this->value_[REGDIG2] >= '0' && this->value_[REGDIG2] <= '9') out += (this->value_[REGDIG2] - '0') * 100;
if (this->value_[REGDIG3] >= '0' && this->value_[REGDIG3] <= '9') out += (this->value_[REGDIG3] - '0') * 10;
if (this->value_[REGDIG4] >= '0' && this->value_[REGDIG4] <= '9') out += (this->value_[REGDIG4] - '0');
return out;
}
float calc_display_value_() const {
if (this->overload) return NAN;
if (this->is_plus) {
uint16_t pair1 = static_cast<uint16_t>(this->raw_[0]) | (static_cast<uint16_t>(this->raw_[1]) << 8);
uint8_t decimal = pair1 & 0x07;
if (decimal >= 7) return NAN;
uint16_t pair3 = static_cast<uint16_t>(this->raw_[4]) | (static_cast<uint16_t>(this->raw_[5]) << 8);
bool negative = pair3 >= 0x7FFF;
uint16_t digits = negative ? (pair3 & 0x7FFF) : pair3;
float v = static_cast<float>(digits) / std::pow(10.0f, decimal);
return negative ? -v : v;
}
uint8_t decimal = 0;
switch (this->value_[REGPOINT] & 0x07) {
case 0b001: decimal = 1; break;
case 0b010: decimal = 2; break;
case 0b100: decimal = 3; break;
default: break;
}
float v = static_cast<float>(this->digits_from_buffer_()) / std::pow(10.0f, decimal);
return this->negative() ? -v : v;
}
float calc_base_value_() const {
if (std::isnan(this->display_value)) return NAN;
if (this->value_[REGUNIT] == FLAGUNITNF) return this->display_value * 1e-9f;
if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return this->display_value * 1e6f;
if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return this->display_value * 1e3f;
if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return this->display_value * 1e-3f;
if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return this->display_value * 1e-6f;
return this->display_value;
}
void parse_plus_() {
memset(this->value_, 0, sizeof(this->value_));
this->value_[5] = 0x20;
this->value_[12] = 0x0D;
this->value_[13] = 0x0A;
uint16_t pair1 = static_cast<uint16_t>(this->raw_[0]) | (static_cast<uint16_t>(this->raw_[1]) << 8);
uint8_t function = (pair1 >> 6) & 0x0F;
uint8_t scale = (pair1 >> 3) & 0x07;
uint8_t decimal = pair1 & 0x07;
switch (decimal) {
case 0: this->value_[REGPOINT] = FLAGPOINT0; break;
case 1: this->value_[REGPOINT] = FLAGPOINT3; break;
case 2: this->value_[REGPOINT] = FLAGPOINT2; break;
case 3: this->value_[REGPOINT] = FLAGPOINT1; break;
default: break;
}
switch (function) {
case 0: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEDC; break;
case 1: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEAC; break;
case 2: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEDC; break;
case 3: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEAC; break;
case 4: this->value_[REGUNIT] |= FLAGUNITOHM; break;
case 5: this->value_[REGUNIT] |= FLAGUNITNF; break;
case 6: this->value_[REGUNIT] |= FLAGUNITHZ; break;
case 7: this->value_[REGSCALE] |= FLAGSCALEDUTY; break;
case 8: this->value_[REGUNIT] |= FLAGUNITGRAD; break;
case 9: this->value_[REGUNIT] |= FLAGUNITFAHR; break;
case 10: this->value_[REGSCALE] |= FLAGSCALEDIODE; break;
case 11: this->value_[REGSCALE] |= FLAGSCALEBUZZ; break;
case 12: this->value_[REGUNIT] |= FLAGUNITHFE; break;
default: break;
}
switch (scale) {
case 2: this->value_[REGSCALE] |= FLAGSCALEMICRO; break;
case 3: this->value_[REGSCALE] |= FLAGSCALEMILLI; break;
case 5: this->value_[REGSCALE] |= FLAGSCALEKILO; break;
case 6: this->value_[REGSCALE] |= FLAGSCALEMEGA; break;
default: break;
}
uint16_t pair2 = static_cast<uint16_t>(this->raw_[2]) | (static_cast<uint16_t>(this->raw_[3]) << 8);
if (pair2 & (1 << 0)) this->value_[REGMODE] |= FLAGMODEHOLD;
if (pair2 & (1 << 1)) this->value_[REGMODE] |= FLAGMODEREL;
if (pair2 & (1 << 2)) this->value_[REGMODE] |= FLAGMODEAUTO;
this->low_battery = (pair2 & (1 << 3)) != 0;
if (pair2 & (1 << 4)) this->value_[REGMINMAX] |= FLAGMIN;
if (pair2 & (1 << 5)) this->value_[REGMINMAX] |= FLAGMAX;
uint16_t pair3 = static_cast<uint16_t>(this->raw_[4]) | (static_cast<uint16_t>(this->raw_[5]) << 8);
if (decimal < 7) {
uint16_t digits = pair3;
if (pair3 < 0x7FFF) {
this->value_[REGPLUSMINUS] = FLAGPLUS;
} else {
this->value_[REGPLUSMINUS] = FLAGMINUS;
digits = pair3 & 0x7FFF;
}
this->value_[REGDIG1] = '0' + ((digits / 1000) % 10);
this->value_[REGDIG2] = '0' + ((digits / 100) % 10);
this->value_[REGDIG3] = '0' + ((digits / 10) % 10);
this->value_[REGDIG4] = '0' + (digits % 10);
} else {
memcpy(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME));
}
}
char digit_char_(uint8_t reg) const {
uint8_t c = this->value_[reg];
return (c >= '0' && c <= '9') ? static_cast<char>(c) : ' ';
}
void label_(Display &it, esphome::display::BaseFont *font, int x, int y, const char *text, Color color) {
it.print(x, y, font, color, esphome::display::TextAlign::TOP_LEFT, text);
}
void draw_digits_(Display &it, const char *text, bool negative, Color color) {
if (negative) this->draw_segment_(it, 8, 88, 26, 9, true, color);
constexpr int digit_x = 40;
constexpr int digit_y = 35;
constexpr int digit_w = 50;
constexpr int digit_h = 88;
constexpr int digit_distance = 64;
for (int i = 0; i < 4; i++) {
this->draw_seven_segment_(it, digit_x + i * digit_distance, digit_y, digit_w, digit_h, text[i], color);
}
}
void draw_decimal_points_(Display &it, Color color) {
uint8_t p = this->value_[REGPOINT];
if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(95, 117, 8, 10, color);
if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(159, 117, 8, 10, color);
if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(223, 117, 8, 10, color);
}
void draw_segment_(Display &it, int x, int y, int w, int h, bool horizontal, Color color) {
if (horizontal) {
int cap = h / 2;
it.filled_rectangle(x + cap, y, w - 2 * cap, h +1, color);
it.filled_triangle(x, y + cap, x + cap, y, x + cap, y + h, color);
it.filled_triangle(x + w, y + cap, x + w - cap, y, x + w - cap, y + h, color);
} else {
int cap = w / 2;
it.filled_rectangle(x, y + cap, w +1, h - 2 * cap, color);
it.filled_triangle(x + cap, y, x, y + cap, x + w, y + cap, color);
it.filled_triangle(x + cap, y + h, x, y + h - cap, x + w, y + h - cap, color);
}
}
void draw_icon_(Display &it, int x, int y, int w, int h, const uint8_t *data, Color color) {
for (int row = 0; row < h; row++) {
for (int col = 0; col < w; col++) {
uint8_t byte = data[row * ((w + 7) / 8) + (col / 8)];
if ((byte & (0x80 >> (col % 8))) != 0) {
it.draw_pixel_at(x + col, y + row, color);
}
}
}
}
void draw_seven_segment_(Display &it, int x, int y, int w, int h, char ch, Color color) {
bool a=false,b=false,c=false,d=false,e=false,f=false,g=false;
switch (ch) {
case '0': a=b=c=d=e=f=true; break;
case '1': b=c=true; break;
case '2': a=b=d=e=g=true; break;
case '3': a=b=c=d=g=true; break;
case '4': b=c=f=g=true; break;
case '5': a=c=d=f=g=true; break;
case '6': a=c=d=e=f=g=true; break;
case '7': a=b=c=true; break;
case '8': a=b=c=d=e=f=g=true; break;
case '9': a=b=c=d=f=g=true; break;
case 'O': a=b=c=d=e=f=true; break;
case 'L': d=e=f=true; break;
case '-': g=true; break;
default: break;
}
int t = 10;
int half = h / 2;
int gap = 1;
int top_y = y;
int mid_y = y + half - t / 2;
int bot_y = y + h - t;
int upper_v_y = top_y + t + gap;
int upper_v_h = mid_y - gap - upper_v_y;
int lower_v_y = mid_y + t + gap;
int lower_v_h = bot_y - gap - lower_v_y;
if (a) this->draw_segment_(it, x + t / 2, top_y, w - t, t, true, color);
if (b && upper_v_h > 0) this->draw_segment_(it, x + w - t, upper_v_y, t, upper_v_h, false, color);
if (c && lower_v_h > 0) this->draw_segment_(it, x + w - t, lower_v_y, t, lower_v_h, false, color);
if (d) this->draw_segment_(it, x + t / 2, bot_y, w - t, t, true, color);
if (e && lower_v_h > 0) this->draw_segment_(it, x, lower_v_y, t, lower_v_h, false, color);
if (f && upper_v_h > 0) this->draw_segment_(it, x, upper_v_y, t, upper_v_h, false, color);
if (g) this->draw_segment_(it, x + t / 2, mid_y, w - t, t, true, color);
}
void draw_bargraph_(Display &it, uint16_t digits, bool active) {
const Color fg(255, 255, 255);
const Color inactive(80, 80, 80);
uint16_t mapped = active ? static_cast<uint16_t>(digits * 240 / 6000) : 0;
if (mapped > 240) mapped = 240;
for (uint16_t i = 0; i <= 240; i += 4) {
Color col = (active && i <= mapped) ? fg : inactive;
int h = (i % 40 == 0) ? 20 : ((i % 20 == 0) ? 15 : 10);
it.vertical_line(40 + i, 185 - h, h, col);
}
it.horizontal_line(35, 185, 250, inactive);
}
};
} // namespace owon_b35t
static owon_b35t::Meter owon_meter;
@@ -0,0 +1,316 @@
# Derived work based on https://github.com/reaper7/M5Stack_BLE_client_Owon_B35T by reaper7.
# AI (ChatGPT) has been used to adopt the Arduino sketch to ESPHome.
# Ported to M5Stack Core2 due to memory constraints.
substitutions:
name: "owon-b35t-m5stack-core2"
friendly_name: "OWON B35T Multimeter Core2"
device_description: "M5Stack Core2 BLE client for OWON B35T/B35T+ multimeter with local graphical display"
owon_mac_address: !secret owon_b35t_mac_address
esphome:
name: ${name}
friendly_name: ${friendly_name}
comment: ${device_description}
min_version: 2024.6.0
includes:
- owon-b35t-m5stack-core2.h
on_boot:
priority: 850
then:
- lambda: |-
owon_b35t::core2_axp192_init(id(core2_i2c));
project:
name: "custom.owon-b35t-m5stack-core2"
version: "1.0"
esp32:
board: m5stack-core2
flash_size: 16MB
framework:
type: esp-idf
advanced:
minimum_chip_revision: "3.1"
sram1_as_iram: true
psram:
mode: quad
speed: 80MHz
logger:
level: INFO
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: ${name}.home
power_save_mode: none
fast_connect: on
min_auth_mode: WPA2
ap:
ssid: "OWON B35T Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("mem", "heap free=%u min_free=%u internal_free=%u internal_largest=%u dma_free=%u dma_largest=%u psram_free=%u psram_largest=%u",
static_cast<unsigned>(esp_get_free_heap_size()),
static_cast<unsigned>(esp_get_minimum_free_heap_size()),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_DMA)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_DMA)),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)));
esp32_ble_tracker:
scan_parameters:
active: true
continuous: true
ble_client:
- mac_address: ${owon_mac_address}
id: owon_ble_client
on_connect:
then:
- lambda: |-
owon_meter.on_connect();
on_disconnect:
then:
- lambda: |-
owon_meter.on_disconnect();
spi:
clk_pin: GPIO18
mosi_pin: GPIO23
i2c:
id: core2_i2c
sda: GPIO21
scl: GPIO22
scan: true
output:
- platform: template
type: float
id: lcd_backlight
write_action:
- lambda: |-
owon_b35t::core2_axp192_set_backlight(state);
light:
- platform: monochromatic
output: lcd_backlight
name: "${friendly_name} Backlight"
id: backlight
restore_mode: ALWAYS_ON
font:
- file: "fonts/Roboto-Regular.ttf"
id: meter_font
size: 15
glyphs:
[
" ",
"!",
"%",
"+",
"-",
".",
"/",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"<",
">",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"L",
"M",
"N",
"O",
"P",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"k",
"m",
"n",
"o",
"r",
"s",
"t",
"u",
"v",
"w",
"y",
"z",
"°",
"µ",
"Ω",
]
display:
- platform: mipi_spi
id: lcd
model: M5CORE2
update_interval: 500ms
lambda: |-
owon_meter.render(it, id(meter_font));
touchscreen:
- platform: ft63x6
id: touch
display: lcd
binary_sensor:
- platform: touchscreen
id: button_a
touchscreen_id: touch
x_min: 34
x_max: 74
y_min: 212
y_max: 240
internal: true
on_press:
then:
- lambda: |-
owon_meter.previous_button();
- platform: touchscreen
id: button_b
touchscreen_id: touch
x_min: 108
x_max: 208
y_min: 212
y_max: 240
internal: true
on_click:
- min_length: 50ms
max_length: 1500ms
then:
- logger.log:
level: INFO
format: "OWON short press: %s"
args: ["owon_meter.selected_button_name()"]
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda |-
std::vector<uint8_t> data = {owon_meter.selected_button, 0x01};
return data;
- min_length: 1500ms
max_length: 5000ms
then:
- logger.log:
level: INFO
format: "OWON long press: %s"
args: ["owon_meter.selected_button_name()"]
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda |-
uint8_t press_type = (owon_meter.selected_button == 1 || owon_meter.selected_button == 5) ? 0x01 : 0x00;
std::vector<uint8_t> data = {owon_meter.selected_button, press_type};
return data;
- platform: touchscreen
id: button_c
touchscreen_id: touch
x_min: 242
x_max: 282
y_min: 212
y_max: 240
internal: true
on_press:
then:
- lambda: |-
owon_meter.next_button();
- platform: template
name: "${friendly_name} Connected"
lambda: |-
return owon_meter.connected;
- platform: template
name: "${friendly_name} Overload"
lambda: |-
return owon_meter.overload;
- platform: template
name: "${friendly_name} Low Battery"
lambda: |-
return owon_meter.low_battery;
sensor:
- platform: ble_client
type: characteristic
ble_client_id: owon_ble_client
id: owon_notify_source
internal: true
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff4-0000-1000-8000-00805f9b34fb"
notify: true
update_interval: never
lambda: |-
owon_meter.handle_notify(x);
return owon_meter.value();
- platform: wifi_signal
name: "${friendly_name} WiFi Signal"
update_interval: 60s
text_sensor:
- platform: template
name: "${friendly_name} Reading"
update_interval: 1s
lambda: |-
return owon_meter.reading_text();
- platform: template
name: "${friendly_name} Unit"
update_interval: 1s
lambda: |-
return std::string(owon_meter.scale()) + owon_meter.unit();
- platform: template
name: "${friendly_name} Mode"
update_interval: 1s
lambda: |-
return owon_meter.mode_text();
+567
View File
@@ -0,0 +1,567 @@
/*
* ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 1.
* Parser is based on the standalone Arduino sketch by Reaper7
* (Beerware license, Revision 42) and Dean Cording's owonb35 notes.
*/
#pragma once
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include "esp_heap_caps.h"
#include "esp_system.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/components/display/display.h"
namespace owon_b35t {
using esphome::Color;
using esphome::display::Display;
static const char *const TAG = "owon_b35t";
static const uint8_t ACCU_BMP[32] = {
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b11111111, 0b11111110,
0b10000000, 0b00000010,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000010,
0b11111111, 0b11111110,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
};
static const uint8_t BLE_BMP[32] = {
0b00000001, 0b10000000,
0b00000001, 0b11000000,
0b00010001, 0b01100000,
0b00011001, 0b00110000,
0b00001101, 0b00011000,
0b00000111, 0b00110000,
0b00000011, 0b01100000,
0b00000001, 0b11000000,
0b00000001, 0b11000000,
0b00000011, 0b01100000,
0b00000111, 0b00110000,
0b00001101, 0b00011000,
0b00011001, 0b00110000,
0b00010001, 0b01100000,
0b00000001, 0b11000000,
0b00000001, 0b10000000,
};
static const uint8_t DIODE_BMP[32] = {
0b00001000, 0b00011000,
0b00001100, 0b00011000,
0b00001110, 0b00011000,
0b00001111, 0b00011000,
0b00001111, 0b10011000,
0b00001111, 0b11011000,
0b00001111, 0b11111000,
0b11111111, 0b11111111,
0b11111111, 0b11111111,
0b00001111, 0b11111000,
0b00001111, 0b11011000,
0b00001111, 0b10011000,
0b00001111, 0b00011000,
0b00001110, 0b00011000,
0b00001100, 0b00011000,
0b00001000, 0b00011000,
};
static const uint8_t BUZZ_BMP[32] = {
0b00000000, 0b11000000,
0b00000001, 0b11000000,
0b00000011, 0b11000001,
0b00000111, 0b11000001,
0b00001111, 0b11000101,
0b11111111, 0b11000101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11000101,
0b00001111, 0b11000101,
0b00000111, 0b11000001,
0b00000011, 0b11000001,
0b00000001, 0b11000000,
0b00000000, 0b11000000,
};
class Meter {
public:
static constexpr uint8_t REGPLUSMINUS = 0x00;
static constexpr uint8_t FLAGPLUS = 0b00101011;
static constexpr uint8_t FLAGMINUS = 0b00101101;
static constexpr uint8_t REGDIG1 = 0x01;
static constexpr uint8_t REGDIG2 = 0x02;
static constexpr uint8_t REGDIG3 = 0x03;
static constexpr uint8_t REGDIG4 = 0x04;
static constexpr uint8_t REGPOINT = 0x06;
static constexpr uint8_t FLAGPOINT0 = 0b00110000;
static constexpr uint8_t FLAGPOINT1 = 0b00110001;
static constexpr uint8_t FLAGPOINT2 = 0b00110010;
static constexpr uint8_t FLAGPOINT3 = 0b00110100;
static constexpr uint8_t REGMODE = 0x07;
static constexpr uint8_t FLAGMODEHOLD = 0b00000010;
static constexpr uint8_t FLAGMODEREL = 0b00000100;
static constexpr uint8_t FLAGMODEAC = 0b00001000;
static constexpr uint8_t FLAGMODEDC = 0b00010000;
static constexpr uint8_t FLAGMODEAUTO = 0b00100000;
static constexpr uint8_t REGMINMAX = 0x08;
static constexpr uint8_t FLAGMIN = 0b00010000;
static constexpr uint8_t FLAGMAX = 0b00100000;
static constexpr uint8_t REGSCALE = 0x09;
static constexpr uint8_t FLAGSCALEDUTY = 0b00000010;
static constexpr uint8_t FLAGSCALEDIODE = 0b00000100;
static constexpr uint8_t FLAGSCALEBUZZ = 0b00001000;
static constexpr uint8_t FLAGSCALEMEGA = 0b00010000;
static constexpr uint8_t FLAGSCALEKILO = 0b00100000;
static constexpr uint8_t FLAGSCALEMILLI = 0b01000000;
static constexpr uint8_t FLAGSCALEMICRO = 0b10000000;
static constexpr uint8_t REGUNIT = 0x0a;
static constexpr uint8_t FLAGUNITFAHR = 0b00000001;
static constexpr uint8_t FLAGUNITGRAD = 0b00000010;
static constexpr uint8_t FLAGUNITNF = 0b00000100;
static constexpr uint8_t FLAGUNITHZ = 0b00001000;
static constexpr uint8_t FLAGUNITHFE = 0b00010000;
static constexpr uint8_t FLAGUNITOHM = 0b00100000;
static constexpr uint8_t FLAGUNITAMP = 0b01000000;
static constexpr uint8_t FLAGUNITVOLT = 0b10000000;
bool connected{false};
bool write_available{false};
bool is_plus{false};
bool low_battery{false};
bool overload{false};
bool has_reading{false};
uint8_t selected_button{1};
uint32_t last_notify_ms{0};
bool handle_notify(const std::vector<uint8_t> &data) {
if (data.size() > sizeof(this->raw_))
return false;
if (data.size() == 6 && data[1] >= 0xF0) {
memset(this->raw_, 0, sizeof(this->raw_));
memcpy(this->raw_, data.data(), data.size());
this->is_plus = true;
this->parse_plus_();
} else if (data.size() == 14 && data[12] == 0x0D && data[13] == 0x0A) {
memset(this->value_, 0, sizeof(this->value_));
memcpy(this->value_, data.data(), data.size());
this->is_plus = false;
} else {
ESP_LOGW(TAG, "Ignoring unexpected OWON frame length=%u", static_cast<unsigned>(data.size()));
return false;
}
this->overload = memcmp(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)) == 0;
this->display_value = this->calc_display_value_();
this->base_value = this->calc_base_value_();
this->has_reading = true;
this->last_notify_ms = millis();
return true;
}
void on_connect() {
this->connected = true;
this->write_available = true;
}
void on_disconnect() {
this->connected = false;
this->write_available = false;
}
float value() const { return this->display_value; }
float value_base() const { return this->base_value; }
bool negative() const { return (this->value_[REGPLUSMINUS] & FLAGMINUS) == FLAGMINUS; }
bool auto_range() const { return (this->value_[REGMODE] & FLAGMODEAUTO) == FLAGMODEAUTO; }
bool hold() const { return (this->value_[REGMODE] & FLAGMODEHOLD) == FLAGMODEHOLD; }
bool relative() const { return (this->value_[REGMODE] & FLAGMODEREL) == FLAGMODEREL; }
bool ac() const { return (this->value_[REGMODE] & FLAGMODEAC) == FLAGMODEAC; }
bool dc() const { return (this->value_[REGMODE] & FLAGMODEDC) == FLAGMODEDC; }
bool min_mode() const { return (this->value_[REGMINMAX] & FLAGMIN) == FLAGMIN; }
bool max_mode() const { return (this->value_[REGMINMAX] & FLAGMAX) == FLAGMAX; }
bool diode() const { return (this->value_[REGSCALE] & FLAGSCALEDIODE) == FLAGSCALEDIODE; }
bool continuity() const { return (this->value_[REGSCALE] & FLAGSCALEBUZZ) == FLAGSCALEBUZZ; }
const char *unit() const {
switch (this->value_[REGUNIT]) {
case FLAGUNITFAHR: return "°F";
case FLAGUNITGRAD: return "°C";
case FLAGUNITNF: return "nF";
case FLAGUNITHZ: return "Hz";
case FLAGUNITHFE: return "hFE";
case FLAGUNITOHM: return "Ω";
case FLAGUNITAMP: return "A";
case FLAGUNITVOLT: return "V";
default: return "";
}
}
const char *scale() const {
if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return "%";
if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return "M";
if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return "k";
if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return "m";
if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return "µ";
return "";
}
std::string mode_text() const {
std::string out;
if (this->dc()) out += "DC ";
if (this->ac()) out += "AC ";
if (this->auto_range()) out += "AUTO ";
if (this->hold()) out += "HOLD ";
if (this->relative()) out += "REL ";
if (this->min_mode()) out += "MIN ";
if (this->max_mode()) out += "MAX ";
if (this->diode()) out += "DIODE ";
if (this->continuity()) out += "CONT ";
if (!out.empty()) out.pop_back();
return out;
}
std::string reading_text() const {
if (!this->connected) return "Disconnected";
if (!this->has_reading) return "Waiting for data";
if (this->overload) return "OL " + std::string(this->scale()) + this->unit();
char buf[48];
snprintf(buf, sizeof(buf), "%s%.4g %s%s", this->negative() ? "-" : "", std::fabs(this->display_value), this->scale(), this->unit());
return std::string(buf);
}
enum Kind { KIND_OTHER, KIND_VOLTAGE, KIND_CURRENT, KIND_RESISTANCE, KIND_FREQUENCY, KIND_CAPACITANCE, KIND_TEMP_C, KIND_TEMP_F, KIND_DUTY };
Kind kind() const {
if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return KIND_DUTY;
switch (this->value_[REGUNIT]) {
case FLAGUNITVOLT: return KIND_VOLTAGE;
case FLAGUNITAMP: return KIND_CURRENT;
case FLAGUNITOHM: return KIND_RESISTANCE;
case FLAGUNITHZ: return KIND_FREQUENCY;
case FLAGUNITNF: return KIND_CAPACITANCE;
case FLAGUNITGRAD: return KIND_TEMP_C;
case FLAGUNITFAHR: return KIND_TEMP_F;
default: return KIND_OTHER;
}
}
const char *selected_button_name() const {
static const char *const names[] = {"SELECT", "RANGE", "HLD/LIG", "REL/BT", "HZ/DUTY", "MAX/MIN"};
uint8_t index = this->selected_button;
if (index < 1) index = 1;
if (index > 6) index = 6;
return names[index - 1];
}
void previous_button() {
if (this->selected_button > 1) this->selected_button--;
}
void next_button() {
if (this->selected_button < 6) this->selected_button++;
}
void render(Display &it, esphome::display::BaseFont *font) {
const Color bg(0, 0, 0);
const Color fg(210, 210, 210);
// Chosen to map to a neutral dark gray in the RGB332 8-bit display palette.
const Color inactive(80, 80, 80);
const Color yellow(255, 220, 0);
const Color blue(0, 80, 255);
const Color cyan(0, 255, 255);
const Color magenta(255, 0, 255);
const Color red(255, 0, 0);
const Color green(0, 220, 0);
const Color orange(255, 165, 0);
it.fill(bg);
this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, this->low_battery ? red : green);
this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive);
this->label_(it, font, 86, 8, "AUTO", this->auto_range() ? fg : inactive);
this->label_(it, font, 138, 8, "MAX", this->max_mode() ? red : inactive);
this->label_(it, font, 178, 8, "MIN", this->min_mode() ? green : inactive);
this->label_(it, font, 218, 8, "HOLD", this->hold() ? blue : inactive);
this->label_(it, font, 270, 8, "REL", this->relative() ? Color(128, 128, 0) : inactive);
this->label_(it, font, 8, 66, "DC", this->dc() ? cyan : inactive);
this->label_(it, font, 8, 102, "AC", this->ac() ? magenta : inactive);
if (!this->connected) {
this->draw_digits_(it, "----", false, inactive);
it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "scan/connect");
} else if (!this->has_reading) {
this->draw_digits_(it, "8888", false, inactive);
it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "waiting");
} else if (this->overload) {
this->draw_digits_(it, " OL ", false, fg);
} else {
char d[5];
d[0] = this->digit_char_(REGDIG1);
d[1] = this->digit_char_(REGDIG2);
d[2] = this->digit_char_(REGDIG3);
d[3] = this->digit_char_(REGDIG4);
d[4] = 0;
this->draw_digits_(it, d, this->negative(), fg);
this->draw_decimal_points_(it, fg);
}
std::string unit_line = std::string(this->scale()) + this->unit();
it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str());
this->draw_bargraph_(it, this->has_reading && !this->overload ? this->digits_from_buffer_() : 0, this->has_reading && !this->overload);
this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, this->diode() ? magenta : inactive);
this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, this->continuity() ? orange : inactive);
it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive);
it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive);
it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive);
it.print(54, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, "<");
it.print(158, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, this->selected_button_name());
it.print(262, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, ">");
}
private:
uint8_t raw_[14]{};
uint8_t value_[14]{};
float display_value{NAN};
float base_value{NAN};
static constexpr uint8_t OVERLOAD_FRAME[5] = {0x2B, 0x3F, 0x30, 0x3A, 0x3F};
uint16_t digits_from_buffer_() const {
uint16_t out = 0;
if (this->value_[REGDIG1] >= '0' && this->value_[REGDIG1] <= '9') out += (this->value_[REGDIG1] - '0') * 1000;
if (this->value_[REGDIG2] >= '0' && this->value_[REGDIG2] <= '9') out += (this->value_[REGDIG2] - '0') * 100;
if (this->value_[REGDIG3] >= '0' && this->value_[REGDIG3] <= '9') out += (this->value_[REGDIG3] - '0') * 10;
if (this->value_[REGDIG4] >= '0' && this->value_[REGDIG4] <= '9') out += (this->value_[REGDIG4] - '0');
return out;
}
float calc_display_value_() const {
if (this->overload) return NAN;
if (this->is_plus) {
uint16_t pair1 = static_cast<uint16_t>(this->raw_[0]) | (static_cast<uint16_t>(this->raw_[1]) << 8);
uint8_t decimal = pair1 & 0x07;
if (decimal >= 7) return NAN;
uint16_t pair3 = static_cast<uint16_t>(this->raw_[4]) | (static_cast<uint16_t>(this->raw_[5]) << 8);
bool negative = pair3 >= 0x7FFF;
uint16_t digits = negative ? (pair3 & 0x7FFF) : pair3;
float v = static_cast<float>(digits) / std::pow(10.0f, decimal);
return negative ? -v : v;
}
uint8_t decimal = 0;
switch (this->value_[REGPOINT] & 0x07) {
case 0b001: decimal = 1; break;
case 0b010: decimal = 2; break;
case 0b100: decimal = 3; break;
default: break;
}
float v = static_cast<float>(this->digits_from_buffer_()) / std::pow(10.0f, decimal);
return this->negative() ? -v : v;
}
float calc_base_value_() const {
if (std::isnan(this->display_value)) return NAN;
if (this->value_[REGUNIT] == FLAGUNITNF) return this->display_value * 1e-9f;
if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return this->display_value * 1e6f;
if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return this->display_value * 1e3f;
if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return this->display_value * 1e-3f;
if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return this->display_value * 1e-6f;
return this->display_value;
}
void parse_plus_() {
memset(this->value_, 0, sizeof(this->value_));
this->value_[5] = 0x20;
this->value_[12] = 0x0D;
this->value_[13] = 0x0A;
uint16_t pair1 = static_cast<uint16_t>(this->raw_[0]) | (static_cast<uint16_t>(this->raw_[1]) << 8);
uint8_t function = (pair1 >> 6) & 0x0F;
uint8_t scale = (pair1 >> 3) & 0x07;
uint8_t decimal = pair1 & 0x07;
switch (decimal) {
case 0: this->value_[REGPOINT] = FLAGPOINT0; break;
case 1: this->value_[REGPOINT] = FLAGPOINT3; break;
case 2: this->value_[REGPOINT] = FLAGPOINT2; break;
case 3: this->value_[REGPOINT] = FLAGPOINT1; break;
default: break;
}
switch (function) {
case 0: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEDC; break;
case 1: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEAC; break;
case 2: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEDC; break;
case 3: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEAC; break;
case 4: this->value_[REGUNIT] |= FLAGUNITOHM; break;
case 5: this->value_[REGUNIT] |= FLAGUNITNF; break;
case 6: this->value_[REGUNIT] |= FLAGUNITHZ; break;
case 7: this->value_[REGSCALE] |= FLAGSCALEDUTY; break;
case 8: this->value_[REGUNIT] |= FLAGUNITGRAD; break;
case 9: this->value_[REGUNIT] |= FLAGUNITFAHR; break;
case 10: this->value_[REGSCALE] |= FLAGSCALEDIODE; break;
case 11: this->value_[REGSCALE] |= FLAGSCALEBUZZ; break;
case 12: this->value_[REGUNIT] |= FLAGUNITHFE; break;
default: break;
}
switch (scale) {
case 2: this->value_[REGSCALE] |= FLAGSCALEMICRO; break;
case 3: this->value_[REGSCALE] |= FLAGSCALEMILLI; break;
case 5: this->value_[REGSCALE] |= FLAGSCALEKILO; break;
case 6: this->value_[REGSCALE] |= FLAGSCALEMEGA; break;
default: break;
}
uint16_t pair2 = static_cast<uint16_t>(this->raw_[2]) | (static_cast<uint16_t>(this->raw_[3]) << 8);
if (pair2 & (1 << 0)) this->value_[REGMODE] |= FLAGMODEHOLD;
if (pair2 & (1 << 1)) this->value_[REGMODE] |= FLAGMODEREL;
if (pair2 & (1 << 2)) this->value_[REGMODE] |= FLAGMODEAUTO;
this->low_battery = (pair2 & (1 << 3)) != 0;
if (pair2 & (1 << 4)) this->value_[REGMINMAX] |= FLAGMIN;
if (pair2 & (1 << 5)) this->value_[REGMINMAX] |= FLAGMAX;
uint16_t pair3 = static_cast<uint16_t>(this->raw_[4]) | (static_cast<uint16_t>(this->raw_[5]) << 8);
if (decimal < 7) {
uint16_t digits = pair3;
if (pair3 < 0x7FFF) {
this->value_[REGPLUSMINUS] = FLAGPLUS;
} else {
this->value_[REGPLUSMINUS] = FLAGMINUS;
digits = pair3 & 0x7FFF;
}
this->value_[REGDIG1] = '0' + ((digits / 1000) % 10);
this->value_[REGDIG2] = '0' + ((digits / 100) % 10);
this->value_[REGDIG3] = '0' + ((digits / 10) % 10);
this->value_[REGDIG4] = '0' + (digits % 10);
} else {
memcpy(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME));
}
}
char digit_char_(uint8_t reg) const {
uint8_t c = this->value_[reg];
return (c >= '0' && c <= '9') ? static_cast<char>(c) : ' ';
}
void label_(Display &it, esphome::display::BaseFont *font, int x, int y, const char *text, Color color) {
it.print(x, y, font, color, esphome::display::TextAlign::TOP_LEFT, text);
}
void draw_digits_(Display &it, const char *text, bool negative, Color color) {
if (negative) this->draw_segment_(it, 8, 88, 26, 9, true, color);
constexpr int digit_x = 40;
constexpr int digit_y = 35;
constexpr int digit_w = 50;
constexpr int digit_h = 88;
constexpr int digit_distance = 64;
for (int i = 0; i < 4; i++) {
this->draw_seven_segment_(it, digit_x + i * digit_distance, digit_y, digit_w, digit_h, text[i], color);
}
}
void draw_decimal_points_(Display &it, Color color) {
uint8_t p = this->value_[REGPOINT];
if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(95, 117, 8, 10, color);
if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(159, 117, 8, 10, color);
if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(223, 117, 8, 10, color);
}
void draw_segment_(Display &it, int x, int y, int w, int h, bool horizontal, Color color) {
if (horizontal) {
int cap = h / 2;
it.filled_rectangle(x + cap, y, w - 2 * cap, h, color);
it.filled_triangle(x, y + cap, x + cap, y, x + cap, y + h, color);
it.filled_triangle(x + w, y + cap, x + w - cap, y, x + w - cap, y + h, color);
} else {
int cap = w / 2;
it.filled_rectangle(x, y + cap, w, h - 2 * cap, color);
it.filled_triangle(x + cap, y, x, y + cap, x + w, y + cap, color);
it.filled_triangle(x + cap, y + h, x, y + h - cap, x + w, y + h - cap, color);
}
}
void draw_icon_(Display &it, int x, int y, int w, int h, const uint8_t *data, Color color) {
for (int row = 0; row < h; row++) {
for (int col = 0; col < w; col++) {
uint8_t byte = data[row * ((w + 7) / 8) + (col / 8)];
if ((byte & (0x80 >> (col % 8))) != 0) {
it.draw_pixel_at(x + col, y + row, color);
}
}
}
}
void draw_seven_segment_(Display &it, int x, int y, int w, int h, char ch, Color color) {
bool a=false,b=false,c=false,d=false,e=false,f=false,g=false;
switch (ch) {
case '0': a=b=c=d=e=f=true; break;
case '1': b=c=true; break;
case '2': a=b=d=e=g=true; break;
case '3': a=b=c=d=g=true; break;
case '4': b=c=f=g=true; break;
case '5': a=c=d=f=g=true; break;
case '6': a=c=d=e=f=g=true; break;
case '7': a=b=c=true; break;
case '8': a=b=c=d=e=f=g=true; break;
case '9': a=b=c=d=f=g=true; break;
case 'O': a=b=c=d=e=f=true; break;
case 'L': d=e=f=true; break;
case '-': g=true; break;
default: break;
}
int t = 10;
int half = h / 2;
int gap = 1;
int top_y = y;
int mid_y = y + half - t / 2;
int bot_y = y + h - t;
int upper_v_y = top_y + t + gap;
int upper_v_h = mid_y - gap - upper_v_y;
int lower_v_y = mid_y + t + gap;
int lower_v_h = bot_y - gap - lower_v_y;
if (a) this->draw_segment_(it, x + t / 2, top_y, w - t, t, true, color);
if (b && upper_v_h > 0) this->draw_segment_(it, x + w - t, upper_v_y, t, upper_v_h, false, color);
if (c && lower_v_h > 0) this->draw_segment_(it, x + w - t, lower_v_y, t, lower_v_h, false, color);
if (d) this->draw_segment_(it, x + t / 2, bot_y, w - t, t, true, color);
if (e && lower_v_h > 0) this->draw_segment_(it, x, lower_v_y, t, lower_v_h, false, color);
if (f && upper_v_h > 0) this->draw_segment_(it, x, upper_v_y, t, upper_v_h, false, color);
if (g) this->draw_segment_(it, x + t / 2, mid_y, w - t, t, true, color);
}
void draw_bargraph_(Display &it, uint16_t digits, bool active) {
const Color fg(255, 255, 255);
const Color inactive(80, 80, 80);
uint16_t mapped = active ? static_cast<uint16_t>(digits * 240 / 6000) : 0;
if (mapped > 240) mapped = 240;
for (uint16_t i = 0; i <= 240; i += 4) {
Color col = (active && i <= mapped) ? fg : inactive;
int h = (i % 40 == 0) ? 20 : ((i % 20 == 0) ? 15 : 10);
it.vertical_line(40 + i, 185 - h, h, col);
}
it.horizontal_line(35, 185, 250, inactive);
}
};
} // namespace owon_b35t
static owon_b35t::Meter owon_meter;
+529
View File
@@ -0,0 +1,529 @@
# Source: https://github.com/RASPIAUDIO/esphomeLuxe/blob/main/luxe_microWW.yaml
substitutions:
name: "raspiaudio-muse-luxe"
friendly_name: "RaspiAudio Muse Luxe"
#States
P_starting: "0"
P_waiting: "1"
P_playing: "2"
P_listening: "3"
P_answering: "4"
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
- platform: esphome
id: ota_esphome
password: !secret ota
external_components:
- source: github://RASPIAUDIO/esphomeLuxe@main
components: [es8388]
refresh: 0s
network:
enable_ipv6: true
wifi:
ssid: Voltage-legacy
password: !secret voltage_legacy_psk
#use_address: ${name}.home
power_save_mode: high
fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Raspiaudio Fallback Hotspot"
password: !secret fallback_psk
esphome:
name: ${name}
friendly_name: ${friendly_name}
min_version: 2025.2.0
name_add_mac_suffix: false
platformio_options:
board_build.flash_mode: dio
board_build.arduino.memory_type: qio_opi
project:
name: raspiaudio.voice-assistant
version: "2025.3.1"
on_boot:
priority: -100.0
then:
- lambda: id(phase) = 0;
- script.execute: update_led
esp32:
board: esp-wrover-kit
flash_size: 4MB
framework:
type: esp-idf
version: recommended
sdkconfig_options:
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
micro_wake_word:
id: mww
models:
- model: okay_nabu
- model: https://github.com/kahrendt/microWakeWord/releases/download/okay_nabu_20241226.3/okay_nabu.json
# - model: hey_jarvis
# - model: hey_mycroft
# - model: alexa
# vad:
microphone: luxe_mic
on_wake_word_detected:
- voice_assistant.start:
wake_word: !lambda return wake_word;
logger:
level: DEBUG
captive_portal:
improv_serial:
##########
# Hardware Configuration
es8388:
id: my_es8388
psram:
mode: quad
speed: 80MHz
#######
# Buses Configuration
i2c:
sda: GPIO18
scl: GPIO23
#####################
# Internal Components
output:
- platform: gpio
id: dac_mute
pin:
number: GPIO21
inverted: true
mode:
output: true
globals:
- id: Vol
type: float
initial_value: '0.6'
- id: phase
type: int
initial_value: '0'
- id: mute
type: bool
initial_value: 'false'
- id: muteH
type: bool
initial_value: 'false'
interval:
- interval: 0.1sec
then:
- if:
condition:
- speaker.is_stopped:
then:
- if:
condition:
- not:
- lambda: 'return(id(muteH));'
then:
- output.turn_on: dac_mute
- lambda: id(muteH) = true;
- logger.log: "====> hardware mute"
- if:
condition:
- speaker.is_playing:
then:
- if:
condition:
- lambda: 'return(id(muteH));'
then:
- output.turn_off: dac_mute
- lambda: id(muteH) = false;
- logger.log: "====> hardware unmute"
sensor:
- platform: adc
pin: GPIO33
name: Battery voltage
device_class: voltage
unit_of_measurement: "V"
accuracy_decimals: 2
state_class: measurement
entity_category: diagnostic
update_interval: 15s
attenuation: auto
filters:
- multiply: 2
- calibrate_linear:
- 0.0 -> 0.0
# Map measured full charge to real value to compensate divider/ADC bias
- 4.58 -> 4.20
- exponential_moving_average:
alpha: 0.2
send_every: 2
- delta: 0.002
on_value:
then:
- sensor.template.publish:
id: battery_percent
state: !lambda "return x ;"
- platform: template
name: Battery
id: battery_percent
device_class: battery
unit_of_measurement: "%"
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: 15s
filters:
- calibrate_polynomial:
degree: 3
datapoints:
- 4.20 -> 100.0
- 4.13 -> 97.1
- 4.10 -> 94.2
- 4.07 -> 88.4
- 4.05 -> 82.7
- 4.04 -> 76.9
- 4.04 -> 71.1
- 4.01 -> 65.3
- 3.99 -> 59.5
- 3.95 -> 53.8
- 3.93 -> 48.0
- 3.91 -> 42.2
- 3.88 -> 36.4
- 3.86 -> 30.6
- 3.84 -> 24.9
- 3.82 -> 19.1
- 3.76 -> 13.3
- 3.73 -> 10.4
- 3.70 -> 7.5
- 3.64 -> 4.6
- 3.50 -> 1.7
- 3.00 -> 0.0
- lambda: return clamp(x, 0.0f, 100.0f);
binary_sensor:
- platform: gpio
pin:
number: GPIO27
mode:
input: true
pullup: true
id: jack_detect
name: Jack Detect
internal: true
on_press:
- script.execute: jack_detect_high
on_release:
- script.execute: jack_detect_low
- platform: gpio
pin:
number: GPIO19
inverted: true
mode:
input: true
pullup: true
name: Volume Up
on_click:
- lambda: |-
id(Vol) += 0.05;
if(id(Vol) > 1) id(Vol) = 1;
- media_player.volume_set:
id: luxe_media_player
volume: !lambda return id(Vol);
- platform: gpio
pin:
number: GPIO32
inverted: true
mode:
input: true
pullup: true
name: Volume Down
on_click:
- lambda: |-
id(Vol) -= 0.05;
if(id(Vol) < 0) id(Vol) = 0;
- media_player.volume_set:
id: luxe_media_player
volume: !lambda return id(Vol);
- platform: gpio
pin:
number: GPIO12
inverted: true
mode:
input: true
pullup: true
name: Mute
on_click:
- if:
condition:
- lambda: 'return(id(mute));'
then:
- script.execute: mute_off
- lambda: id(mute) = false;
else:
- script.execute: mute_on
- lambda: id(mute) = true;
on_double_click:
- if:
condition:
- lambda: 'return(id(phase) == 2);'
then:
- media_player.stop:
light:
- platform: esp32_rmt_led_strip
name: None
id: top_led
pin: GPIO22
chipset: WS2812
num_leds: 1
rgb_order: grb
# rmt_channel: 0
default_transition_length: 0s
gamma_correct: 2.8
effects:
- pulse:
name: pulse
transition_length: 250ms
update_interval: 250ms
- pulse:
name: slow_pulse
transition_length: 1s
update_interval: 2s
i2s_audio:
id: i2s0
i2s_lrclk_pin: GPIO25
i2s_bclk_pin: GPIO5
i2s_mclk_pin: GPIO0
microphone:
- platform: i2s_audio
id: luxe_mic
i2s_audio_id: i2s0
sample_rate: 16000
i2s_din_pin: GPIO35
bits_per_sample: 16bit
channel: stereo
adc_type: external
speaker:
- platform: i2s_audio
id: luxe_speaker
i2s_dout_pin: GPIO26
dac_type: external
sample_rate: 48000
bits_per_sample: 16bit
num_channels: 2
buffer_duration: 100ms
media_player:
- platform: speaker
name: None
id: luxe_media_player
internal: false
# task_stack_in_psram: true
# volume_min: 0.5
# volume_max: 0.8
announcement_pipeline:
speaker: luxe_speaker
format: FLAC
sample_rate: 48000
num_channels: 1
files:
- id: little_sound
file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac
on_announcement:
- micro_wake_word.stop:
- if:
condition:
lambda: 'return(id(phase) != 2);'
then:
- lambda: |-
if(id(phase) == 1) id(phase) = 2;
- script.execute: mute_off
- script.execute: update_led
on_idle:
- wait_until:
and:
- not:
media_player.is_announcing:
- not:
voice_assistant.is_running:
- if:
condition:
lambda: 'return((id(phase) == 4) || (id(phase) == 2));'
then:
- lambda: |-
id(phase) = 1;
- micro_wake_word.start:
- script.execute: update_led
voice_assistant:
id: va
microphone: luxe_mic
media_player: luxe_media_player
use_wake_word: false
noise_suppression_level: 2
auto_gain: 31dBFS
volume_multiplier: 2.0
on_listening:
- logger.log: "listening 3 => phase"
- micro_wake_word.stop:
- lambda: |-
id(phase) = 3;
- script.execute: update_led
on_stt_end:
- media_player.play_media: !lambda return x;
- light.turn_on:
id: top_led
blue: 60%
red: 20%
green: 20%
effect: pulse
on_tts_start:
- logger.log: "answering 4 => phase"
- micro_wake_word.stop:
- lambda: |-
id(phase) = 4;
- script.execute: update_led
on_error:
- logger.log: "ERROR!!!!!!!!!!!!!!!!"
- light.turn_on:
id: top_led
blue: 0%
red: 100%
green: 0%
effect: pulse
- delay: 3s
- lambda: id(phase) = 1;
- script.execute: update_led
#########
# Scripts
script:
- id: jack_detect_high
then:
- logger.log: "Jack detect high -> route to speaker"
- output.turn_off: dac_mute
- lambda: |-
id(my_es8388).write_byte(0x1D, 0x20);
id(my_es8388).write_byte(0x1C, 0x10);
id(my_es8388).write_byte(0x04, 0x30);
- id: jack_detect_low
then:
- logger.log: "Jack detect low -> route to line out"
- output.turn_on: dac_mute
- lambda: |-
id(my_es8388).write_byte(0x1D, 0x00);
id(my_es8388).write_byte(0x1C, 0x00);
id(my_es8388).write_byte(0x04, 0x0C);
- id: update_led
then:
- logger.log: "==>>>update_led"
- lambda: |-
if(id(phase) == 0)id(start).execute();
if(id(phase) == 1)id(waiting).execute();
if(id(phase) == 2)id(external_player).execute();
if(id(phase) == 3)id(listening).execute();
if(id(phase) == 4)id(answering).execute();
- id: start
then:
- light.turn_on:
id: top_led
effect: slow_pulse
red: 80%
green: 0%
blue: 80%
- delay: 5sec
- lambda: id(my_es8388).setup();
- output.turn_off: dac_mute
- lambda: id(phase) = 1;
- media_player.speaker.play_on_device_media_file:
media_file: little_sound
announcement: false
- script.execute: update_led
- id: waiting
then:
- light.turn_on:
id: top_led
effect: pulse
red: 0%
green: 0%
blue: 100%
brightness: 100%
- voice_assistant.stop:
- micro_wake_word.start:
- id: listening
then:
- light.turn_on:
id: top_led
effect: pulse
red: 0%
green: 100%
blue: 0%
brightness: 100%
- id: answering
then:
- light.turn_on:
id: top_led
effect: none
red: 100%
green: 100%
blue: 0%
brightness: 100%
- id: external_player
then:
- light.turn_on:
id: top_led
effect: none
red: 80%
green: 40%
blue: 0%
- id: mute_on
then:
- media_player.volume_set:
volume: '0'
- lambda: id(mute) = true;
- id: mute_off
then:
- media_player.volume_set:
volume: !lambda return(id(Vol));
- lambda: id(mute) = false;
@@ -51,7 +51,7 @@ esphome:
esp32: esp32:
board: nodemcu-32s board: nodemcu-32s
framework: framework:
type: arduino type: esp-idf
wifi: wifi:
ssid: "Voltage-legacy" ssid: "Voltage-legacy"
@@ -59,6 +59,7 @@ wifi:
use_address: ${name}.home use_address: ${name}.home
power_save_mode: high power_save_mode: high
fast_connect: on fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails # Enable fallback hotspot (captive portal) in case wifi connection fails
ap: ap:
@@ -78,6 +79,7 @@ api:
# Enable over-the-air updates # Enable over-the-air updates
ota: ota:
platform: esphome
password: !secret ota password: !secret ota
# Initialize I²C # Initialize I²C
@@ -112,6 +114,7 @@ light:
# Initialize UART for Vindriktning sensor # Initialize UART for Vindriktning sensor
uart: uart:
id: uartPM
rx_pin: GPIO26 rx_pin: GPIO26
baud_rate: 9600 baud_rate: 9600
@@ -207,9 +210,10 @@ sensor:
ambient_pressure_compensation: 1 ambient_pressure_compensation: 1
automatic_self_calibration: True automatic_self_calibration: True
address: 0x61 address: 0x61
update_interval: 60s update_interval: 10s
# Vindriktning particulate matter sensor # Vindriktning particulate matter sensor
- platform: pm1006 - platform: pm1006
uart_id: uartPM
pm_2_5: pm_2_5:
name: "Feinstaub PM 2.5µm" name: "Feinstaub PM 2.5µm"
on_value_range: on_value_range:
@@ -234,7 +238,7 @@ sensor:
- platform: adc - platform: adc
pin: 32 pin: 32
name: "Spannung Photodiode" name: "Spannung Photodiode"
attenuation: 11db attenuation: 12db
update_interval: 1s update_interval: 1s
internal: true internal: true
on_value_range: on_value_range:
@@ -258,6 +262,7 @@ remote_receiver:
# IR Transmitter # IR Transmitter
remote_transmitter: remote_transmitter:
non_blocking: True
pin: GPIO22 pin: GPIO22
# Infrared remotes use a 50% carrier signal # Infrared remotes use a 50% carrier signal
carrier_duty_percent: 50% carrier_duty_percent: 50%
+80
View File
@@ -0,0 +1,80 @@
substitutions:
name: "bathroom"
friendly_name: "Bad"
esphome:
name: ${name}
friendly_name: ${friendly_name}
name_add_mac_suffix: false
project:
name: sensor.outdoor
version: "0.8"
min_version: 2022.1.0
esp32:
board: esp32-c3-devkitm-1
framework:
type: arduino
network:
enable_ipv6: true
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: bathroom.home
power_save_mode: high
fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Bad Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
# Initialize I²C
i2c:
- id: bus_a
sda: 6
scl: 7
scan: true
# Temp/humidity sensors
sensor:
- platform: dht
model: dht22
pin: 4
temperature:
name: "Badezimmer Temperatur DHT"
humidity:
name: "Badezimmer Luftfeuchtigkeit DHT"
update_interval: 60s
- platform: sht3xd
temperature:
name: "Badezimmer Temperatur"
filters:
- offset: -4.4
humidity:
name: "Badezimmer Luftfeuchtigkeit"
address: 0x44
heater_enabled: True
update_interval: 60s
# WiFi signal strength
- platform: wifi_signal
name: "WiFi Signalstärke"
update_interval: 60s
+700 -184
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -14,14 +14,18 @@ esphome:
esp32: esp32:
board: nodemcu-32s board: nodemcu-32s
framework: framework:
type: arduino type: esp-idf
network:
enable_ipv6: true
wifi: wifi:
ssid: "Voltage-legacy" ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk password: !secret voltage_legacy_psk
use_address: ${name}.home #use_address: ${name}.home
power_save_mode: high power_save_mode: high
fast_connect: on fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails # Enable fallback hotspot (captive portal) in case wifi connection fails
ap: ap:
@@ -39,6 +43,7 @@ api:
key: !secret apikey key: !secret apikey
ota: ota:
platform: esphome
password: !secret ota password: !secret ota
spi: spi:
+33
View File
@@ -0,0 +1,33 @@
substitutions:
name: home-assistant-voice-09c0e7
friendly_name: Home Assistant Voice Küche
packages:
Nabu Casa.Home Assistant Voice PE: github://esphome/home-assistant-voice-pe/home-assistant-voice.yaml
esphome:
name: ${name}
name_add_mac_suffix: false
friendly_name: ${friendly_name}
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: ${name}.home
#power_save_mode: high
#fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "HA Voice Fallback Hotspot"
password: !secret fallback_psk
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
http_request:
+33
View File
@@ -0,0 +1,33 @@
substitutions:
name: home-assistant-voice-0a7c6b
friendly_name: Home Assistant Voice Wohnzimmer
packages:
Nabu Casa.Home Assistant Voice PE: github://esphome/home-assistant-voice-pe/home-assistant-voice.yaml
esphome:
name: ${name}
name_add_mac_suffix: false
friendly_name: ${friendly_name}
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: ${name}.home
#power_save_mode: high
#fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "HA Voice Fallback Hotspot"
password: !secret fallback_psk
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
http_request:
+123
View File
@@ -0,0 +1,123 @@
substitutions:
name: kamera-balkon
friendly_name: Kamera Balkon
esphome:
name: ${name}
friendly_name: ${friendly_name}
name_add_mac_suffix: false
project:
name: sensor.camera
version: "1.0"
min_version: 2022.1.0
esp32:
board: esp32cam
framework:
type: esp-idf
network:
enable_ipv6: true
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
# use_address: cam-balcony.home
power_save_mode: high
fast_connect: on
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Cam Balkon Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
# Initialize I²C
i2c:
- id: bus_a
sda: GPIO3
#sda: GPIO13
#scl: GPIO14
scl: GPIO1
scan: true
- id: bus_c
sda: GPIO26
scl: GPIO27
psram:
speed: 80MHz
# Camera
esp32_camera:
frame_buffer_location: PSRAM
name: ${friendly_name}
external_clock:
pin: GPIO0
frequency: 20MHz
i2c_id:
bus_c
data_pins: [GPIO5, GPIO18, GPIO19, GPIO21, GPIO36, GPIO39, GPIO34, GPIO35]
vsync_pin: GPIO25
href_pin: GPIO23
pixel_clock_pin: GPIO22
power_down_pin: GPIO32
resolution: XGA
max_framerate: 24 fps
idle_framerate: 0.2 fps
jpeg_quality: 15
agc_mode: auto
agc_gain_ceiling: 8x
wb_mode: auto
vertical_flip: true
horizontal_mirror: true
esp32_camera_web_server:
- port: 8080
mode: stream
- port: 8081
mode: snapshot
# Temp/humidity sensors
sensor:
- platform: wifi_signal
name: "WiFi Signalstärke"
update_interval: 60s
- platform: sht3xd
i2c_id:
bus_a
temperature:
name: "Balkon Temperatur"
filters:
- offset: -4.4
humidity:
name: "Balkon Luftfeuchtigkeit"
address: 0x44
heater_enabled: True
update_interval: 60s
# Flash LED
output:
- platform: ledc
pin: GPIO4
id: flash
channel: 2
# Define RGB mode for LED
light:
- platform: monochromatic
id: flashlight
name: "Blitzlicht"
output: flash
+104
View File
@@ -0,0 +1,104 @@
substitutions:
name: kamera-wohnzimmer
friendly_name: Kamera Wohnzimmer
esphome:
name: ${name}
friendly_name: ${friendly_name}
name_add_mac_suffix: false
project:
name: sensor.camera
version: "1.0"
min_version: 2022.1.0
esp32:
board: esp32dev
framework:
type: esp-idf
network:
enable_ipv6: true
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: cam-livingroom.home
power_save_mode: high
fast_connect: on
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Cam Wohnzimmer Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
# Initialize I²C
i2c:
- id: bus_c
sda: GPIO26
scl: GPIO27
psram:
speed: 80MHz
# Camera
esp32_camera:
frame_buffer_location: PSRAM
name: ${friendly_name}
external_clock:
pin: GPIO0
frequency: 20MHz
i2c_id:
bus_c
data_pins: [GPIO5, GPIO18, GPIO19, GPIO21, GPIO36, GPIO39, GPIO34, GPIO35]
vsync_pin: GPIO25
href_pin: GPIO23
pixel_clock_pin: GPIO22
power_down_pin: GPIO32
resolution: XGA
max_framerate: 24 fps
idle_framerate: 0.2 fps
jpeg_quality: 15
agc_mode: auto
agc_gain_ceiling: 8x
wb_mode: auto
vertical_flip: true
horizontal_mirror: true
esp32_camera_web_server:
- port: 8080
mode: stream
- port: 8081
mode: snapshot
sensor:
- platform: wifi_signal
name: "WiFi Signalstärke"
update_interval: 60s
# Flash LED
output:
- platform: ledc
pin: GPIO4
id: flash
channel: 2
# Define RGB mode for LED
light:
- platform: monochromatic
id: flashlight
name: "Blitzlicht"
output: flash
+785
View File
@@ -0,0 +1,785 @@
/*
* ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 2.
* Parser is based on the standalone Arduino sketch by Reaper7
* (Beerware license, Revision 42) and Dean Cording's owonb35 notes.
* Rendering code and some functionality modified to suppport the
* Atorch DL24 dc load.
*/
#pragma once
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include "esp_heap_caps.h"
#include "esp_system.h"
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/log.h"
#include "esphome/components/display/display.h"
namespace owon_b35t {
using esphome::Color;
using esphome::display::Display;
static const char *const TAG = "owon_b35t";
static const char *const POWER_TAG = "core2_power";
static constexpr uint8_t AXP192_ADDR = 0x34;
static esphome::i2c::I2CDevice axp192;
static bool axp192_ready = false;
static bool axp_write(uint8_t reg, uint8_t value) {
if (!axp192_ready) return false;
bool ok = axp192.write_byte(reg, value);
if (!ok) ESP_LOGW(POWER_TAG, "AXP192 write reg 0x%02X failed", reg);
return ok;
}
static bool axp_read(uint8_t reg, uint8_t *value) {
if (!axp192_ready) return false;
bool ok = axp192.read_byte(reg, value);
if (!ok) ESP_LOGW(POWER_TAG, "AXP192 read reg 0x%02X failed", reg);
return ok;
}
static void axp_update(uint8_t reg, uint8_t clear_mask, uint8_t set_mask) {
uint8_t value = 0;
if (!axp_read(reg, &value)) return;
value = (value & ~clear_mask) | set_mask;
axp_write(reg, value);
}
static uint8_t axp_dc_voltage_data(uint16_t millivolts) {
if (millivolts < 700) millivolts = 700;
if (millivolts > 3500) millivolts = 3500;
return static_cast<uint8_t>((millivolts - 700) / 25) & 0x7F;
}
static uint8_t axp_ldo_voltage_data(uint16_t millivolts) {
if (millivolts < 1800) millivolts = 1800;
if (millivolts > 3300) millivolts = 3300;
return static_cast<uint8_t>((millivolts - 1800) / 100) & 0x0F;
}
static void core2_axp192_set_lcd_voltage(uint16_t millivolts) {
uint8_t value = 0;
axp_read(0x27, &value);
axp_write(0x27, (value & 0x80) | axp_dc_voltage_data(millivolts)); // DCDC3, LCD backlight
}
static void core2_axp192_set_backlight(float brightness) {
if (brightness <= 0.0f) {
axp_update(0x12, 0x02, 0x00); // DCDC3 off
return;
}
if (brightness > 1.0f) brightness = 1.0f;
uint16_t millivolts = static_cast<uint16_t>(2400 + brightness * 900);
core2_axp192_set_lcd_voltage(millivolts);
axp_update(0x12, 0x00, 0x02); // DCDC3 on
}
static void core2_axp192_init(esphome::i2c::I2CBus *bus) {
axp192.set_i2c_bus(bus);
axp192.set_i2c_address(AXP192_ADDR);
axp192_ready = true;
ESP_LOGI(POWER_TAG, "Initializing M5Stack Core2 AXP192 LCD power");
axp_update(0x30, 0xF9, 0x02); // Disable VBUS current limit, preserve bit 2.
axp_update(0x92, 0x07, 0x00); // GPIO1 open-drain output.
axp_update(0x93, 0x07, 0x00); // GPIO2 open-drain output.
axp_write(0x35, 0xA2); // RTC battery charging.
uint8_t value = 0;
axp_read(0x26, &value);
axp_write(0x26, (value & 0x80) | axp_dc_voltage_data(3350)); // DCDC1 ESP32 VDD.
core2_axp192_set_lcd_voltage(2800);
uint8_t ldo2 = axp_ldo_voltage_data(3300);
uint8_t ldo3 = axp_ldo_voltage_data(2000);
axp_write(0x28, (ldo2 << 4) | ldo3); // LDO2 LCD logic/SD, LDO3 vibrator.
axp_update(0x12, 0x00, 0x07); // Enable DCDC1, DCDC3, LDO2.
axp_write(0x82, 0xFF); // ADCs on.
axp_update(0x95, 0x8D, 0x84); // GPIO4 setup, as M5Core2 library does.
axp_write(0x36, 0x4C); // Power key timing.
// LCD reset through AXP192 GPIO4.
axp_update(0x96, 0x02, 0x00);
delay(100);
axp_update(0x96, 0x00, 0x02);
delay(100);
core2_axp192_set_backlight(1.0f);
}
static const uint8_t ACCU_BMP[32] = {
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b11111111, 0b11111110,
0b10000000, 0b00000010,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000011,
0b10000000, 0b00000010,
0b11111111, 0b11111110,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
};
static const uint8_t BLE_BMP[32] = {
0b00000001, 0b10000000,
0b00000001, 0b11000000,
0b00010001, 0b01100000,
0b00011001, 0b00110000,
0b00001101, 0b00011000,
0b00000111, 0b00110000,
0b00000011, 0b01100000,
0b00000001, 0b11000000,
0b00000001, 0b11000000,
0b00000011, 0b01100000,
0b00000111, 0b00110000,
0b00001101, 0b00011000,
0b00011001, 0b00110000,
0b00010001, 0b01100000,
0b00000001, 0b11000000,
0b00000001, 0b10000000,
};
static const uint8_t HOLD_BMP[32] = {
0b01111111, 0b11111110,
0b11111111, 0b11111111,
0b11100011, 0b11000111,
0b11100011, 0b11000111,
0b11100011, 0b11000111,
0b11100011, 0b11000111,
0b11100011, 0b11000111,
0b11100000, 0b00000111,
0b11100000, 0b00000111,
0b11100011, 0b11000111,
0b11100011, 0b11000111,
0b11100011, 0b11000111,
0b11100011, 0b11000111,
0b11100011, 0b11000111,
0b11111111, 0b11111111,
0b01111111, 0b11111110,
};
static const uint8_t REL_BMP[32] = {
0b00000001, 0b00000000,
0b00000001, 0b00000000,
0b00000011, 0b10000000,
0b00000011, 0b10000000,
0b00000110, 0b11000000,
0b00000110, 0b11000000,
0b00001100, 0b01100000,
0b00001100, 0b01100000,
0b00011000, 0b00110000,
0b00011000, 0b00110000,
0b00110000, 0b00011000,
0b00110000, 0b00011000,
0b01100000, 0b00001100,
0b01100000, 0b00001100,
0b11111111, 0b11111110,
0b11111111, 0b11111110,
};
static const uint8_t DIODE_BMP[32] = {
0b00001000, 0b00011000,
0b00001100, 0b00011000,
0b00001110, 0b00011000,
0b00001111, 0b00011000,
0b00001111, 0b10011000,
0b00001111, 0b11011000,
0b00001111, 0b11111000,
0b11111111, 0b11111111,
0b11111111, 0b11111111,
0b00001111, 0b11111000,
0b00001111, 0b11011000,
0b00001111, 0b10011000,
0b00001111, 0b00011000,
0b00001110, 0b00011000,
0b00001100, 0b00011000,
0b00001000, 0b00011000,
};
static const uint8_t BUZZ_BMP[32] = {
0b00000000, 0b11000000,
0b00000001, 0b11000000,
0b00000011, 0b11000001,
0b00000111, 0b11000001,
0b00001111, 0b11000101,
0b11111111, 0b11000101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11010101,
0b11111111, 0b11000101,
0b00001111, 0b11000101,
0b00000111, 0b11000001,
0b00000011, 0b11000001,
0b00000001, 0b11000000,
0b00000000, 0b11000000,
};
class Meter {
public:
static constexpr uint8_t REGPLUSMINUS = 0x00;
static constexpr uint8_t FLAGPLUS = 0b00101011;
static constexpr uint8_t FLAGMINUS = 0b00101101;
static constexpr uint8_t REGDIG1 = 0x01;
static constexpr uint8_t REGDIG2 = 0x02;
static constexpr uint8_t REGDIG3 = 0x03;
static constexpr uint8_t REGDIG4 = 0x04;
static constexpr uint8_t REGPOINT = 0x06;
static constexpr uint8_t FLAGPOINT0 = 0b00110000;
static constexpr uint8_t FLAGPOINT1 = 0b00110001;
static constexpr uint8_t FLAGPOINT2 = 0b00110010;
static constexpr uint8_t FLAGPOINT3 = 0b00110100;
static constexpr uint8_t REGMODE = 0x07;
static constexpr uint8_t FLAGMODEHOLD = 0b00000010;
static constexpr uint8_t FLAGMODEREL = 0b00000100;
static constexpr uint8_t FLAGMODEAC = 0b00001000;
static constexpr uint8_t FLAGMODEDC = 0b00010000;
static constexpr uint8_t FLAGMODEAUTO = 0b00100000;
static constexpr uint8_t REGMINMAX = 0x08;
static constexpr uint8_t FLAGMIN = 0b00010000;
static constexpr uint8_t FLAGMAX = 0b00100000;
static constexpr uint8_t REGSCALE = 0x09;
static constexpr uint8_t FLAGSCALEDUTY = 0b00000010;
static constexpr uint8_t FLAGSCALEDIODE = 0b00000100;
static constexpr uint8_t FLAGSCALEBUZZ = 0b00001000;
static constexpr uint8_t FLAGSCALEMEGA = 0b00010000;
static constexpr uint8_t FLAGSCALEKILO = 0b00100000;
static constexpr uint8_t FLAGSCALEMILLI = 0b01000000;
static constexpr uint8_t FLAGSCALEMICRO = 0b10000000;
static constexpr uint8_t REGUNIT = 0x0a;
static constexpr uint8_t FLAGUNITFAHR = 0b00000001;
static constexpr uint8_t FLAGUNITGRAD = 0b00000010;
static constexpr uint8_t FLAGUNITNF = 0b00000100;
static constexpr uint8_t FLAGUNITHZ = 0b00001000;
static constexpr uint8_t FLAGUNITHFE = 0b00010000;
static constexpr uint8_t FLAGUNITOHM = 0b00100000;
static constexpr uint8_t FLAGUNITAMP = 0b01000000;
static constexpr uint8_t FLAGUNITVOLT = 0b10000000;
bool connected{false};
bool write_available{false};
bool is_plus{false};
bool low_battery{false};
bool overload{false};
bool has_reading{false};
uint8_t selected_button{1};
uint32_t last_notify_ms{0};
bool handle_notify(const std::vector<uint8_t> &data) {
if (data.size() > sizeof(this->raw_))
return false;
if (data.size() == 6 && data[1] >= 0xF0) {
memset(this->raw_, 0, sizeof(this->raw_));
memcpy(this->raw_, data.data(), data.size());
this->is_plus = true;
this->parse_plus_();
} else if (data.size() == 14 && data[12] == 0x0D && data[13] == 0x0A) {
memset(this->value_, 0, sizeof(this->value_));
memcpy(this->value_, data.data(), data.size());
this->is_plus = false;
} else {
ESP_LOGW(TAG, "Ignoring unexpected OWON frame length=%u", static_cast<unsigned>(data.size()));
return false;
}
this->overload = memcmp(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)) == 0;
this->display_value = this->calc_display_value_();
this->base_value = this->calc_base_value_();
this->has_reading = true;
this->last_notify_ms = millis();
return true;
}
void on_connect() {
this->connected = true;
this->write_available = true;
}
void on_disconnect() {
this->connected = false;
this->write_available = false;
}
float value() const { return this->display_value; }
float value_base() const { return this->base_value; }
bool negative() const { return (this->value_[REGPLUSMINUS] & FLAGMINUS) == FLAGMINUS; }
bool auto_range() const { return (this->value_[REGMODE] & FLAGMODEAUTO) == FLAGMODEAUTO; }
bool hold() const { return (this->value_[REGMODE] & FLAGMODEHOLD) == FLAGMODEHOLD; }
bool relative() const { return (this->value_[REGMODE] & FLAGMODEREL) == FLAGMODEREL; }
bool ac() const { return (this->value_[REGMODE] & FLAGMODEAC) == FLAGMODEAC; }
bool dc() const { return (this->value_[REGMODE] & FLAGMODEDC) == FLAGMODEDC; }
bool min_mode() const { return (this->value_[REGMINMAX] & FLAGMIN) == FLAGMIN; }
bool max_mode() const { return (this->value_[REGMINMAX] & FLAGMAX) == FLAGMAX; }
bool diode() const { return (this->value_[REGSCALE] & FLAGSCALEDIODE) == FLAGSCALEDIODE; }
bool continuity() const { return (this->value_[REGSCALE] & FLAGSCALEBUZZ) == FLAGSCALEBUZZ; }
const char *unit() const {
switch (this->value_[REGUNIT]) {
case FLAGUNITFAHR: return "°F";
case FLAGUNITGRAD: return "°C";
case FLAGUNITNF: return "nF";
case FLAGUNITHZ: return "Hz";
case FLAGUNITHFE: return "hFE";
case FLAGUNITOHM: return "Ω";
case FLAGUNITAMP: return "A";
case FLAGUNITVOLT: return "V";
default: return "";
}
}
const char *scale() const {
if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return "%";
if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return "M";
if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return "k";
if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return "m";
if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return "µ";
return "";
}
std::string mode_text() const {
std::string out;
if (this->dc()) out += "DC ";
if (this->ac()) out += "AC ";
if (this->auto_range()) out += "AUTO ";
if (this->hold()) out += "HOLD ";
if (this->relative()) out += "REL ";
if (this->min_mode()) out += "MIN ";
if (this->max_mode()) out += "MAX ";
if (this->diode()) out += "DIODE ";
if (this->continuity()) out += "CONT ";
if (!out.empty()) out.pop_back();
return out;
}
std::string reading_text() const {
if (!this->connected) return "Disconnected";
if (!this->has_reading) return "Waiting for data";
if (this->overload) return "OL " + std::string(this->scale()) + this->unit();
char buf[48];
snprintf(buf, sizeof(buf), "%s%.4g %s%s", this->negative() ? "-" : "", std::fabs(this->display_value), this->scale(), this->unit());
return std::string(buf);
}
enum Kind { KIND_OTHER, KIND_VOLTAGE, KIND_CURRENT, KIND_RESISTANCE, KIND_FREQUENCY, KIND_CAPACITANCE, KIND_TEMP_C, KIND_TEMP_F, KIND_DUTY };
Kind kind() const {
if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return KIND_DUTY;
switch (this->value_[REGUNIT]) {
case FLAGUNITVOLT: return KIND_VOLTAGE;
case FLAGUNITAMP: return KIND_CURRENT;
case FLAGUNITOHM: return KIND_RESISTANCE;
case FLAGUNITHZ: return KIND_FREQUENCY;
case FLAGUNITNF: return KIND_CAPACITANCE;
case FLAGUNITGRAD: return KIND_TEMP_C;
case FLAGUNITFAHR: return KIND_TEMP_F;
default: return KIND_OTHER;
}
}
const char *selected_button_name() const {
static const char *const names[] = {"SELECT", "RANGE", "HLD/LIG", "REL/BT", "HZ/DUTY", "MAX/MIN"};
uint8_t index = this->selected_button;
if (index < 1) index = 1;
if (index > 6) index = 6;
return names[index - 1];
}
void previous_button() {
if (this->selected_button > 1) this->selected_button--;
}
void next_button() {
if (this->selected_button < 6) this->selected_button++;
}
void render(esphome::display::Display &it, esphome::display::BaseFont *font,
esphome::display::BaseFont *value_font, int display_page = 0,
bool atorch_connected = false,
float atorch_voltage = NAN, float atorch_current = NAN, float atorch_power = NAN,
float atorch_capacity = NAN, float atorch_energy = NAN, float atorch_temperature = NAN) {
const Color bg(0, 0, 0);
const Color fg(210, 210, 210);
// Chosen to map to a neutral dark gray in the RGB332 8-bit display palette.
const Color inactive(80, 80, 80);
const Color yellow(255, 220, 0);
const Color blue(0, 80, 255);
const Color cyan(0, 255, 255);
const Color magenta(255, 0, 255);
const Color red(255, 0, 0);
const Color green(0, 220, 0);
const Color orange(255, 165, 0);
if (display_page == 0) {
// --- PAGE 1: OWON Multimeter ---
it.fill(bg);
bool status_active = this->connected && this->has_reading;
this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, status_active ? (this->low_battery ? red : green) : inactive);
this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive);
this->label_(it, font, 86, 8, "AUTO", status_active && this->auto_range() ? fg : inactive);
this->label_(it, font, 138, 8, "MAX", status_active && this->max_mode() ? red : inactive);
this->label_(it, font, 178, 8, "MIN", status_active && this->min_mode() ? green : inactive);
this->draw_icon_(it, 218, 8, 16, 16, HOLD_BMP, status_active && this->hold() ? blue : inactive);
this->draw_icon_(it, 244, 8, 16, 16, REL_BMP, status_active && this->relative() ? Color(128, 128, 0) : inactive);
this->draw_icon_(it, 270, 8, 16, 16, DIODE_BMP, status_active && this->diode() ? magenta : inactive);
this->draw_icon_(it, 296, 8, 16, 16, BUZZ_BMP, status_active && this->continuity() ? orange : inactive);
this->label_(it, font, 8, 104, "DC", status_active && this->dc() ? cyan : inactive);
this->label_(it, font, 8, 124, "AC", status_active && this->ac() ? magenta : inactive);
if (!this->connected) {
this->draw_digits_(it, "----", false, inactive);
it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "scan/connect");
} else if (!this->has_reading) {
this->draw_digits_(it, "8888", false, inactive);
it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "waiting");
} else if (this->overload) {
this->draw_digits_(it, " OL ", false, fg);
} else {
char d[5];
d[0] = this->digit_char_(REGDIG1);
d[1] = this->digit_char_(REGDIG2);
d[2] = this->digit_char_(REGDIG3);
d[3] = this->digit_char_(REGDIG4);
d[4] = 0;
this->draw_digits_(it, d, this->negative(), fg);
this->draw_decimal_points_(it, fg);
}
if (status_active) {
std::string unit_line = std::string(this->scale()) + this->unit();
it.print(316, 141, font, yellow, esphome::display::TextAlign::BOTTOM_RIGHT, unit_line.c_str());
}
bool bargraph_active = status_active && !this->overload;
this->draw_bargraph_(it, bargraph_active ? this->digits_from_buffer_() : 0, bargraph_active);
it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive);
it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive);
it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive);
it.print(54, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, "<");
it.print(158, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, this->selected_button_name());
it.print(262, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, ">");
} else {
// --- PAGE 2: Atorch DL24 DC load ---
it.fill(bg);
const Color panel_active(18, 24, 32);
const Color panel_inactive(12, 12, 12);
const Color panel_dim(10, 14, 20);
const Color border_active(55, 70, 86);
const Color border_inactive(42, 42, 42);
const Color white(245, 245, 245);
const Color dim_value(110, 110, 110);
const Color dim_accent(65, 65, 65);
const Color panel = atorch_connected ? panel_active : panel_inactive;
const Color border = atorch_connected ? border_active : border_inactive;
const Color value_color = atorch_connected ? white : dim_value;
const Color voltage_color = atorch_connected ? cyan : dim_accent;
const Color current_color = atorch_connected ? orange : dim_accent;
const Color power_color = atorch_connected ? yellow : dim_accent;
const Color temp_color = atorch_connected ? magenta : dim_accent;
const Color header_color = atorch_connected ? cyan : dim_value;
char voltage_text[24];
char current_text[24];
char power_text[24];
char capacity_text[24];
char energy_text[24];
char temperature_text[24];
this->format_metric_(voltage_text, sizeof(voltage_text), atorch_voltage, "V", 2);
this->format_metric_(current_text, sizeof(current_text), atorch_current, "A", 3);
this->format_metric_(power_text, sizeof(power_text), atorch_power, "W", 2);
this->format_metric_(capacity_text, sizeof(capacity_text), atorch_capacity, "Ah", 3);
this->format_metric_(energy_text, sizeof(energy_text), atorch_energy, "Wh", 3);
this->format_metric_(temperature_text, sizeof(temperature_text), atorch_temperature, "°C", 1);
it.filled_rectangle(0, 0, 320, 30, atorch_connected ? panel_dim : panel_inactive);
it.print(10, 7, font, header_color, esphome::display::TextAlign::TOP_LEFT, "ATORCH DL24");
if (atorch_connected) {
this->draw_icon_(it, 152, 7, 16, 16, BLE_BMP, blue);
}
it.print(310, 7, font, inactive, esphome::display::TextAlign::TOP_RIGHT, "DC LOAD");
this->draw_metric_card_(it, font, value_font, 10, 42, 145, 76, "VOLTAGE", voltage_text, voltage_color, panel, border, value_color);
this->draw_metric_card_(it, font, value_font, 165, 42, 145, 76, "CURRENT", current_text, current_color, panel, border, value_color);
this->draw_metric_card_(it, font, value_font, 10, 128, 145, 76, "POWER", power_text, power_color, panel, border, value_color);
this->draw_metric_card_(it, font, value_font, 165, 128, 145, 76, "TEMP", temperature_text, temp_color, panel, border, value_color);
it.filled_rectangle(10, 212, 300, 22, atorch_connected ? panel_dim : panel_inactive);
it.filled_rectangle(10, 212, 300, 1, border);
it.filled_rectangle(10, 233, 300, 1, border);
it.filled_rectangle(10, 212, 1, 22, border);
it.filled_rectangle(309, 212, 1, 22, border);
it.print(20, 216, font, inactive, esphome::display::TextAlign::TOP_LEFT, "CAP");
it.print(64, 216, font, value_color, esphome::display::TextAlign::TOP_LEFT, capacity_text);
it.print(170, 216, font, inactive, esphome::display::TextAlign::TOP_LEFT, "ENERGY");
it.print(306, 216, font, value_color, esphome::display::TextAlign::TOP_RIGHT, energy_text);
}
}
private:
uint8_t raw_[14]{};
uint8_t value_[14]{};
float display_value{NAN};
float base_value{NAN};
static constexpr uint8_t OVERLOAD_FRAME[5] = {0x2B, 0x3F, 0x30, 0x3A, 0x3F};
uint16_t digits_from_buffer_() const {
uint16_t out = 0;
if (this->value_[REGDIG1] >= '0' && this->value_[REGDIG1] <= '9') out += (this->value_[REGDIG1] - '0') * 1000;
if (this->value_[REGDIG2] >= '0' && this->value_[REGDIG2] <= '9') out += (this->value_[REGDIG2] - '0') * 100;
if (this->value_[REGDIG3] >= '0' && this->value_[REGDIG3] <= '9') out += (this->value_[REGDIG3] - '0') * 10;
if (this->value_[REGDIG4] >= '0' && this->value_[REGDIG4] <= '9') out += (this->value_[REGDIG4] - '0');
return out;
}
float calc_display_value_() const {
if (this->overload) return NAN;
if (this->is_plus) {
uint16_t pair1 = static_cast<uint16_t>(this->raw_[0]) | (static_cast<uint16_t>(this->raw_[1]) << 8);
uint8_t decimal = pair1 & 0x07;
if (decimal >= 7) return NAN;
uint16_t pair3 = static_cast<uint16_t>(this->raw_[4]) | (static_cast<uint16_t>(this->raw_[5]) << 8);
bool negative = pair3 >= 0x7FFF;
uint16_t digits = negative ? (pair3 & 0x7FFF) : pair3;
float v = static_cast<float>(digits) / std::pow(10.0f, decimal);
return negative ? -v : v;
}
uint8_t decimal = 0;
switch (this->value_[REGPOINT] & 0x07) {
case 0b001: decimal = 1; break;
case 0b010: decimal = 2; break;
case 0b100: decimal = 3; break;
default: break;
}
float v = static_cast<float>(this->digits_from_buffer_()) / std::pow(10.0f, decimal);
return this->negative() ? -v : v;
}
float calc_base_value_() const {
if (std::isnan(this->display_value)) return NAN;
if (this->value_[REGUNIT] == FLAGUNITNF) return this->display_value * 1e-9f;
if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return this->display_value * 1e6f;
if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return this->display_value * 1e3f;
if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return this->display_value * 1e-3f;
if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return this->display_value * 1e-6f;
return this->display_value;
}
void parse_plus_() {
memset(this->value_, 0, sizeof(this->value_));
this->value_[5] = 0x20;
this->value_[12] = 0x0D;
this->value_[13] = 0x0A;
uint16_t pair1 = static_cast<uint16_t>(this->raw_[0]) | (static_cast<uint16_t>(this->raw_[1]) << 8);
uint8_t function = (pair1 >> 6) & 0x0F;
uint8_t scale = (pair1 >> 3) & 0x07;
uint8_t decimal = pair1 & 0x07;
switch (decimal) {
case 0: this->value_[REGPOINT] = FLAGPOINT0; break;
case 1: this->value_[REGPOINT] = FLAGPOINT3; break;
case 2: this->value_[REGPOINT] = FLAGPOINT2; break;
case 3: this->value_[REGPOINT] = FLAGPOINT1; break;
default: break;
}
switch (function) {
case 0: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEDC; break;
case 1: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEAC; break;
case 2: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEDC; break;
case 3: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEAC; break;
case 4: this->value_[REGUNIT] |= FLAGUNITOHM; break;
case 5: this->value_[REGUNIT] |= FLAGUNITNF; break;
case 6: this->value_[REGUNIT] |= FLAGUNITHZ; break;
case 7: this->value_[REGSCALE] |= FLAGSCALEDUTY; break;
case 8: this->value_[REGUNIT] |= FLAGUNITGRAD; break;
case 9: this->value_[REGUNIT] |= FLAGUNITFAHR; break;
case 10: this->value_[REGSCALE] |= FLAGSCALEDIODE; break;
case 11: this->value_[REGSCALE] |= FLAGSCALEBUZZ; break;
case 12: this->value_[REGUNIT] |= FLAGUNITHFE; break;
default: break;
}
switch (scale) {
case 2: this->value_[REGSCALE] |= FLAGSCALEMICRO; break;
case 3: this->value_[REGSCALE] |= FLAGSCALEMILLI; break;
case 5: this->value_[REGSCALE] |= FLAGSCALEKILO; break;
case 6: this->value_[REGSCALE] |= FLAGSCALEMEGA; break;
default: break;
}
uint16_t pair2 = static_cast<uint16_t>(this->raw_[2]) | (static_cast<uint16_t>(this->raw_[3]) << 8);
if (pair2 & (1 << 0)) this->value_[REGMODE] |= FLAGMODEHOLD;
if (pair2 & (1 << 1)) this->value_[REGMODE] |= FLAGMODEREL;
if (pair2 & (1 << 2)) this->value_[REGMODE] |= FLAGMODEAUTO;
this->low_battery = (pair2 & (1 << 3)) != 0;
if (pair2 & (1 << 4)) this->value_[REGMINMAX] |= FLAGMIN;
if (pair2 & (1 << 5)) this->value_[REGMINMAX] |= FLAGMAX;
uint16_t pair3 = static_cast<uint16_t>(this->raw_[4]) | (static_cast<uint16_t>(this->raw_[5]) << 8);
if (decimal < 7) {
uint16_t digits = pair3;
if (pair3 < 0x7FFF) {
this->value_[REGPLUSMINUS] = FLAGPLUS;
} else {
this->value_[REGPLUSMINUS] = FLAGMINUS;
digits = pair3 & 0x7FFF;
}
this->value_[REGDIG1] = '0' + ((digits / 1000) % 10);
this->value_[REGDIG2] = '0' + ((digits / 100) % 10);
this->value_[REGDIG3] = '0' + ((digits / 10) % 10);
this->value_[REGDIG4] = '0' + (digits % 10);
} else {
memcpy(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME));
}
}
char digit_char_(uint8_t reg) const {
uint8_t c = this->value_[reg];
return (c >= '0' && c <= '9') ? static_cast<char>(c) : ' ';
}
void format_metric_(char *buffer, size_t size, float value, const char *unit, uint8_t decimals) const {
if (!std::isfinite(value)) {
snprintf(buffer, size, "-- %s", unit);
return;
}
char format[12];
snprintf(format, sizeof(format), "%%.%uf %%s", decimals);
snprintf(buffer, size, format, value, unit);
}
void draw_metric_card_(Display &it, esphome::display::BaseFont *label_font, esphome::display::BaseFont *value_font,
int x, int y, int w, int h, const char *title, const char *value,
Color accent, Color fill, Color border, Color value_color) {
const Color bg(0, 0, 0);
const Color inactive(90, 100, 110);
it.filled_rectangle(x, y, w, h, fill);
it.filled_rectangle(x, y, w, 2, accent);
it.filled_rectangle(x, y + h - 1, w, 1, border);
it.filled_rectangle(x, y, 1, h, border);
it.filled_rectangle(x + w - 1, y, 1, h, border);
it.print(x + 10, y + 9, label_font, inactive, esphome::display::TextAlign::TOP_LEFT, title);
it.print(x + w / 2, y + 43, value_font, value_color, esphome::display::TextAlign::CENTER, value);
it.filled_rectangle(x + 10, y + h - 11, w - 20, 3, bg);
it.filled_rectangle(x + 10, y + h - 11, w - 20, 1, accent);
}
void label_(Display &it, esphome::display::BaseFont *font, int x, int y, const char *text, Color color) {
it.print(x, y, font, color, esphome::display::TextAlign::TOP_LEFT, text);
}
void draw_digits_(Display &it, const char *text, bool negative, Color color) {
if (negative) this->draw_segment_(it, 8, 83, 26, 9, true, color);
constexpr int digit_x = 40;
constexpr int digit_y = 35;
constexpr int digit_w = 50;
constexpr int digit_h = 106;
constexpr int digit_distance = 64;
for (int i = 0; i < 4; i++) {
this->draw_seven_segment_(it, digit_x + i * digit_distance, digit_y, digit_w, digit_h, text[i], color);
}
}
void draw_decimal_points_(Display &it, Color color) {
uint8_t p = this->value_[REGPOINT];
if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(95, 135, 8, 10, color);
if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(159, 135, 8, 10, color);
if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(223, 135, 8, 10, color);
}
void draw_segment_(Display &it, int x, int y, int w, int h, bool horizontal, Color color) {
if (horizontal) {
int cap = h / 2;
it.filled_rectangle(x + cap, y, w - 2 * cap, h +1, color);
it.filled_triangle(x, y + cap, x + cap, y, x + cap, y + h, color);
it.filled_triangle(x + w, y + cap, x + w - cap, y, x + w - cap, y + h, color);
} else {
int cap = w / 2;
it.filled_rectangle(x, y + cap, w +1, h - 2 * cap, color);
it.filled_triangle(x + cap, y, x, y + cap, x + w, y + cap, color);
it.filled_triangle(x + cap, y + h, x, y + h - cap, x + w, y + h - cap, color);
}
}
void draw_icon_(Display &it, int x, int y, int w, int h, const uint8_t *data, Color color) {
for (int row = 0; row < h; row++) {
for (int col = 0; col < w; col++) {
uint8_t byte = data[row * ((w + 7) / 8) + (col / 8)];
if ((byte & (0x80 >> (col % 8))) != 0) {
it.draw_pixel_at(x + col, y + row, color);
}
}
}
}
void draw_seven_segment_(Display &it, int x, int y, int w, int h, char ch, Color color) {
bool a=false,b=false,c=false,d=false,e=false,f=false,g=false;
switch (ch) {
case '0': a=b=c=d=e=f=true; break;
case '1': b=c=true; break;
case '2': a=b=d=e=g=true; break;
case '3': a=b=c=d=g=true; break;
case '4': b=c=f=g=true; break;
case '5': a=c=d=f=g=true; break;
case '6': a=c=d=e=f=g=true; break;
case '7': a=b=c=true; break;
case '8': a=b=c=d=e=f=g=true; break;
case '9': a=b=c=d=f=g=true; break;
case 'O': a=b=c=d=e=f=true; break;
case 'L': d=e=f=true; break;
case '-': g=true; break;
default: break;
}
int t = 10;
int half = h / 2;
int gap = 1;
int top_y = y;
int mid_y = y + half - t / 2;
int bot_y = y + h - t;
int upper_v_y = top_y + t + gap;
int upper_v_h = mid_y - gap - upper_v_y;
int lower_v_y = mid_y + t + gap;
int lower_v_h = bot_y - gap - lower_v_y;
if (a) this->draw_segment_(it, x + t / 2, top_y, w - t, t, true, color);
if (b && upper_v_h > 0) this->draw_segment_(it, x + w - t, upper_v_y, t, upper_v_h, false, color);
if (c && lower_v_h > 0) this->draw_segment_(it, x + w - t, lower_v_y, t, lower_v_h, false, color);
if (d) this->draw_segment_(it, x + t / 2, bot_y, w - t, t, true, color);
if (e && lower_v_h > 0) this->draw_segment_(it, x, lower_v_y, t, lower_v_h, false, color);
if (f && upper_v_h > 0) this->draw_segment_(it, x, upper_v_y, t, upper_v_h, false, color);
if (g) this->draw_segment_(it, x + t / 2, mid_y, w - t, t, true, color);
}
void draw_bargraph_(Display &it, uint16_t digits, bool active) {
const Color fg(255, 255, 255);
const Color inactive(80, 80, 80);
uint16_t mapped = active ? static_cast<uint16_t>(digits * 240 / 6000) : 0;
if (mapped > 240) mapped = 240;
for (uint16_t i = 0; i <= 240; i += 4) {
Color col = (active && i <= mapped) ? fg : inactive;
int h = (i % 40 == 0) ? 20 : ((i % 20 == 0) ? 15 : 10);
it.vertical_line(40 + i, 185 - h, h, col);
}
it.horizontal_line(35, 185, 250, inactive);
}
};
} // namespace owon_b35t
static owon_b35t::Meter owon_meter;
+628
View File
@@ -0,0 +1,628 @@
# Connects to an OWON BT35T/BT35T+ multimeter and an Atorch DL24 dc-load and mirrors their
# displays. Also sends those values to Home Assistant for logging and dashboard display.
# Also exposes actionable buttons to HA to control the devices.
#
# Derived work based on https://github.com/reaper7/M5Stack_BLE_client_Owon_B35T by reaper7.
# AI (ChatGPT (GPT5.5) has been used to adopt the Arduino sketch to ESPHome
# Ported to M5Stack Core2 due to memory constraints.
# Integrated Atorch BLE proxy functionality from https://github.com/syssi/esphome-atorch-dl24 by syssi.
# Soft buttons work as indicated for OWON meter. Left/right so select action, middle to execute.
# Long-press is supported. Display switches to last connected device, tap center of screen to
# switch. The dc-load has no on-device buttons, but both are available from HA.
substitutions:
name: "lab-ble-proxy"
friendly_name: "Lab BLE Proxy"
device_description: "M5Stack Core2 BLE client for OWON B35T/B35T+ multimeter and Atorch DL24 DC load"
owon_mac_address: !secret owon_b35t_mac_address
dl24_mac_address: !secret dl24_mac_address
external_components_source: github://syssi/esphome-atorch-dl24@main
atorch_project_version: "2.1.0"
esphome:
name: ${name}
friendly_name: ${friendly_name}
comment: ${device_description}
min_version: 2024.6.0
includes:
- lab-ble-proxy.h
on_boot:
priority: 850
then:
- lambda: |-
owon_b35t::core2_axp192_init(id(core2_i2c));
project:
name: "custom.lab-ble-proxy-m5stack-core2"
version: "1.0"
esp32:
board: m5stack-core2
flash_size: 16MB
framework:
type: esp-idf
advanced:
minimum_chip_revision: "3.1"
sram1_as_iram: true
psram:
mode: quad
speed: 80MHz
external_components:
- source: ${external_components_source}
refresh: 0s
logger:
level: INFO
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
network:
enable_ipv6: true
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: ${name}.home
power_save_mode: none
fast_connect: on
min_auth_mode: WPA2
ap:
ssid: "Lab BLE Proxy Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
globals:
- id: display_page
type: int
initial_value: "0"
restore_value: no
- id: atorch_connected
type: bool
initial_value: "false"
restore_value: no
script:
- id: wake_backlight
mode: restart
then:
- script.stop: backlight_idle
- light.turn_on:
id: backlight
brightness: 100%
- id: backlight_idle
mode: restart
then:
- delay: 2min
- if:
condition:
lambda: |-
return !owon_meter.connected && !id(atorch_connected);
then:
- light.turn_on:
id: backlight
brightness: 50%
- delay: 3min
- if:
condition:
lambda: |-
return !owon_meter.connected && !id(atorch_connected);
then:
- light.turn_off: backlight
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("mem", "heap free=%u min_free=%u internal_free=%u internal_largest=%u dma_free=%u dma_largest=%u psram_free=%u psram_largest=%u",
static_cast<unsigned>(esp_get_free_heap_size()),
static_cast<unsigned>(esp_get_minimum_free_heap_size()),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_DMA)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_DMA)),
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)));
esp32_ble_tracker:
scan_parameters:
active: true
continuous: true
ble_client:
- mac_address: ${owon_mac_address}
id: owon_ble_client
on_connect:
then:
- script.execute: wake_backlight
- lambda: |-
owon_meter.on_connect();
id(display_page) = 0;
id(lcd).update();
on_disconnect:
then:
- lambda: |-
owon_meter.on_disconnect();
if (id(display_page) == 0 && id(atorch_connected)) {
id(display_page) = 1;
}
id(lcd).update();
- if:
condition:
lambda: |-
return !owon_meter.connected && !id(atorch_connected);
then:
- script.execute: backlight_idle
- mac_address: ${dl24_mac_address}
id: atorch_ble_client
on_connect:
then:
- script.execute: wake_backlight
- lambda: |-
id(atorch_connected) = true;
id(display_page) = 1;
id(lcd).update();
on_disconnect:
then:
- lambda: |-
id(atorch_connected) = false;
if (id(display_page) == 1 && owon_meter.connected) {
id(display_page) = 0;
}
id(lcd).update();
- if:
condition:
lambda: |-
return !owon_meter.connected && !id(atorch_connected);
then:
- script.execute: backlight_idle
atorch_dl24:
- id: atorch0
ble_client_id: atorch_ble_client
check_crc: false
throttle: 0s
spi:
clk_pin: GPIO18
mosi_pin: GPIO23
i2c:
id: core2_i2c
sda: GPIO21
scl: GPIO22
scan: true
output:
- platform: template
type: float
id: lcd_backlight
write_action:
- lambda: |-
owon_b35t::core2_axp192_set_backlight(state);
light:
- platform: monochromatic
output: lcd_backlight
name: "${friendly_name} Backlight"
id: backlight
restore_mode: ALWAYS_ON
font:
- file: "fonts/Roboto-Regular.ttf"
id: meter_font
size: 15
glyphs:
[
" ",
"!",
"%",
"+",
"-",
".",
"/",
":",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"<",
">",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"L",
"M",
"N",
"O",
"P",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"k",
"l",
"m",
"n",
"o",
"p",
"r",
"s",
"t",
"u",
"v",
"w",
"y",
"z",
"°",
"µ",
"Ω",
]
- file: "fonts/Roboto-Medium.ttf"
id: atorch_value_font
size: 22
glyphs:
[
" ",
"+",
"-",
".",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"A",
"C",
"V",
"W",
"h",
"°",
]
display:
- platform: mipi_spi
id: lcd
model: M5CORE2
update_interval: 500ms
lambda: |-
owon_meter.render(
it,
id(meter_font),
id(atorch_value_font),
id(display_page),
id(atorch_connected),
id(atorch_voltage).has_state() ? id(atorch_voltage).state : NAN,
id(atorch_current).has_state() ? id(atorch_current).state : NAN,
id(atorch_power).has_state() ? id(atorch_power).state : NAN,
id(atorch_capacity).has_state() ? id(atorch_capacity).state : NAN,
id(atorch_energy_calculated).has_state() ? id(atorch_energy_calculated).state : NAN,
id(atorch_temperature).has_state() ? id(atorch_temperature).state : NAN
);
touchscreen:
- platform: ft63x6
id: touch
display: lcd
on_touch:
then:
- script.execute: wake_backlight
- if:
condition:
lambda: |-
return !owon_meter.connected && !id(atorch_connected);
then:
- script.execute: backlight_idle
binary_sensor:
- platform: touchscreen
touchscreen_id: touch
id: btn_toggle_page
x_min: 70
x_max: 250
y_min: 45
y_max: 195
on_press:
then:
- lambda: |-
id(display_page) = 1 - id(display_page);
id(lcd).update();
- platform: touchscreen
id: button_a
touchscreen_id: touch
x_min: 34
x_max: 74
y_min: 212
y_max: 240
internal: true
on_press:
then:
- lambda: |-
owon_meter.previous_button();
- platform: touchscreen
id: button_b
touchscreen_id: touch
x_min: 108
x_max: 208
y_min: 212
y_max: 240
internal: true
on_click:
- min_length: 50ms
max_length: 1500ms
then:
- logger.log:
level: INFO
format: "OWON short press: %s"
args: ["owon_meter.selected_button_name()"]
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda |-
std::vector<uint8_t> data = {owon_meter.selected_button, 0x01};
return data;
- min_length: 1500ms
max_length: 5000ms
then:
- logger.log:
level: INFO
format: "OWON long press: %s"
args: ["owon_meter.selected_button_name()"]
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda |-
uint8_t press_type = (owon_meter.selected_button == 1 || owon_meter.selected_button == 5) ? 0x01 : 0x00;
std::vector<uint8_t> data = {owon_meter.selected_button, press_type};
return data;
- platform: touchscreen
id: button_c
touchscreen_id: touch
x_min: 242
x_max: 282
y_min: 212
y_max: 240
internal: true
on_press:
then:
- lambda: |-
owon_meter.next_button();
- platform: template
name: "${friendly_name} OWON Connected"
icon: "mdi:bluetooth-connect"
lambda: |-
return owon_meter.connected;
- platform: template
name: "${friendly_name} OWON Overload"
icon: "mdi:debug-step-over"
lambda: |-
return owon_meter.overload;
- platform: template
name: "${friendly_name} OWON Low Battery"
icon: "mdi:battery-low"
lambda: |-
return owon_meter.low_battery;
- platform: template
name: "${friendly_name} Atorch Connected"
device_class: connectivity
icon: "mdi:bluetooth-connect"
lambda: |-
return id(atorch_connected);
sensor:
- platform: ble_client
type: characteristic
ble_client_id: owon_ble_client
id: owon_notify_source
internal: true
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff4-0000-1000-8000-00805f9b34fb"
notify: true
update_interval: never
lambda: |-
owon_meter.handle_notify(x);
return owon_meter.value();
- platform: wifi_signal
name: "${friendly_name} WiFi Signal"
update_interval: 60s
- platform: atorch_dl24
atorch_dl24_id: atorch0
voltage:
name: "${friendly_name} Atorch Voltage"
id: atorch_voltage
current:
name: "${friendly_name} Atorch Current"
id: atorch_current
power:
name: "${friendly_name} Atorch Power"
id: atorch_power
capacity:
name: "${friendly_name} Atorch Capacity"
id: atorch_capacity
temperature:
name: "${friendly_name} Atorch Temperature"
id: atorch_temperature
dim_backlight:
name: "${friendly_name} Atorch Dim Backlight"
id: atorch_dim_backlight
- platform: integration
name: "${friendly_name} Atorch Energy"
id: atorch_energy_calculated
sensor: atorch_power
time_unit: h
unit_of_measurement: "Wh"
icon: "mdi:lightning-bolt"
device_class: energy
state_class: total_increasing
accuracy_decimals: 3
restore: true
integration_method: trapezoid
text_sensor:
- platform: template
name: "${friendly_name} OWON Reading"
update_interval: 1s
icon: "mdi:gauge"
lambda: |-
return owon_meter.reading_text();
- platform: template
name: "${friendly_name} OWON Unit"
update_interval: 1s
icon: "mdi:ruler"
lambda: |-
return std::string(owon_meter.scale()) + owon_meter.unit();
- platform: template
name: "${friendly_name} OWON Mode"
icon: "mdi:knob"
update_interval: 1s
lambda: |-
return owon_meter.mode_text();
button:
- platform: atorch_dl24
atorch_dl24_id: atorch0
reset_energy:
name: "${friendly_name} Atorch Reset Energy"
on_press:
- sensor.integration.reset: atorch_energy_calculated
reset_capacity:
name: "${friendly_name} Atorch Reset Capacity"
reset_runtime:
name: "${friendly_name} Atorch Reset Runtime"
reset_all:
name: "${friendly_name} Atorch Reset All"
on_press:
- sensor.integration.reset: atorch_energy_calculated
usb_plus:
name: "${friendly_name} Atorch Plus"
usb_minus:
name: "${friendly_name} Atorch Minus"
setup:
name: "${friendly_name} Atorch Setup"
enter:
name: "${friendly_name} Atorch Enter"
- platform: template
name: "OWON SELECT"
id: owon_btn_select
on_press:
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda "return std::vector<uint8_t>({1, 0x01});"
- platform: template
name: "OWON RANGE"
id: owon_btn_range
on_press:
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda "return std::vector<uint8_t>({2, 0x01});"
- platform: template
name: "OWON HOLD "
id: owon_btn_hold
on_press:
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda "return std::vector<uint8_t>({3, 0x01});"
- platform: template
name: "OWON LIGHT"
id: owon_btn_light
on_press:
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda "return std::vector<uint8_t>({3, 0x00});"
- platform: template
name: "OWON RELATIVE"
id: owon_btn_rel
on_press:
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda "return std::vector<uint8_t>({4, 0x01});"
- platform: template
name: "OWON BT"
id: owon_btn_bt
on_press:
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda "return std::vector<uint8_t>({4, 0x00});"
- platform: template
name: "OWON HZ | DUTY"
id: owon_btn_hz
on_press:
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda "return std::vector<uint8_t>({5, 0x01});"
- platform: template
name: "OWON MAX | MIN"
id: owon_btn_maxmin
on_press:
- ble_client.ble_write:
id: owon_ble_client
service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb"
value: !lambda "return std::vector<uint8_t>({6, 0x01});"
+12 -4
View File
@@ -52,14 +52,20 @@ esphome:
esp32: esp32:
board: nodemcu-32s board: nodemcu-32s
framework: framework:
type: arduino type: esp-idf
advanced:
minimum_chip_revision: "3.0"
network:
enable_ipv6: true
wifi: wifi:
ssid: "Voltage-legacy" ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk password: !secret voltage_legacy_psk
use_address: ${name}.home # use_address: ${name}.home
power_save_mode: high power_save_mode: high
fast_connect: on fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails # Enable fallback hotspot (captive portal) in case wifi connection fails
ap: ap:
@@ -79,6 +85,7 @@ api:
# Enable over-the-air updates # Enable over-the-air updates
ota: ota:
platform: esphome
password: !secret ota password: !secret ota
# Initialize I²C # Initialize I²C
@@ -113,6 +120,7 @@ light:
# Initialize UART for Vindriktning sensor # Initialize UART for Vindriktning sensor
uart: uart:
id: uartPM
rx_pin: GPIO22 rx_pin: GPIO22
baud_rate: 9600 baud_rate: 9600
@@ -208,7 +216,7 @@ sensor:
ambient_pressure_compensation: 1 ambient_pressure_compensation: 1
automatic_self_calibration: True automatic_self_calibration: True
address: 0x61 address: 0x61
update_interval: 60s update_interval: 10s
# Vindriktning particulate matter sensor # Vindriktning particulate matter sensor
- platform: pm1006 - platform: pm1006
pm_2_5: pm_2_5:
@@ -235,7 +243,7 @@ sensor:
- platform: adc - platform: adc
pin: 34 pin: 34
name: "Spannung Photodiode" name: "Spannung Photodiode"
attenuation: 11db attenuation: 12db
update_interval: 1s update_interval: 1s
internal: true internal: true
on_value_range: on_value_range:
+277 -61
View File
@@ -1,34 +1,43 @@
# Source: https://github.com/esphome/wake-word-voice-assistants/blob/main/m5stack-atom-echo/m5stack-atom-echo.yaml
substitutions: substitutions:
name: "m5stack-atom-echo" name: m5stack-atom-echo
friendly_name: "M5Stack Atom Echo" friendly_name: M5Stack Atom Echo
micro_wake_word_model: okay_nabu # alexa, hey_jarvis, hey_mycroft are also supported
esphome: esphome:
name: ${name} name: ${name}
friendly_name: ${friendly_name}
name_add_mac_suffix: False name_add_mac_suffix: False
project: friendly_name: ${friendly_name}
name: m5stack.atom-echo min_version: 2025.2.0
version: "1.0"
min_version: 2023.5.0
esp32: esp32:
board: m5stack-atom board: m5stack-atom
framework: framework:
type: arduino type: esp-idf
# Enable Home Assistant API # Enable Home Assistant API
api: api:
encryption: encryption:
key: !secret apikey key: !secret apikey
ota: ota:
password: !secret ota - platform: esphome
id: ota_esphome
password: !secret ota
network:
enable_ipv6: true
wifi: wifi:
on_connect:
- delay: 5s # Gives time for improv results to be transmitted
ssid: Voltage-legacy ssid: Voltage-legacy
password: !secret voltage_legacy_psk password: !secret voltage_legacy_psk
use_address: ${name}.home #use_address: ${name}.home
power_save_mode: high power_save_mode: high
fast_connect: on fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails # Enable fallback hotspot (captive portal) in case wifi connection fails
ap: ap:
ssid: "Raspiaudio Fallback Hotspot" ssid: "Raspiaudio Fallback Hotspot"
@@ -36,16 +45,17 @@ wifi:
logger: logger:
dashboard_import:
package_import_url: github://esphome/media-players/m5stack-atom-echo.yaml@main
captive_portal: captive_portal:
improv_serial: button:
- platform: factory_reset
id: factory_reset_btn
name: Factory reset
i2s_audio: i2s_audio:
i2s_lrclk_pin: GPIO33 - id: i2s_audio_bus
i2s_bclk_pin: GPIO19 i2s_lrclk_pin: GPIO33
i2s_bclk_pin: GPIO19
microphone: microphone:
- platform: i2s_audio - platform: i2s_audio
@@ -54,95 +64,301 @@ microphone:
adc_type: external adc_type: external
pdm: true pdm: true
speaker:
- platform: i2s_audio
id: echo_speaker
i2s_dout_pin: GPIO22
dac_type: external
bits_per_sample: 32bit
channel: right
buffer_duration: 60ms
media_player:
- platform: speaker
name: None
id: echo_media_player
announcement_pipeline:
speaker: echo_speaker
format: WAV
codec_support_enabled: false
buffer_size: 6000
volume_min: 0.4
files:
- id: timer_finished_wave_file
file: https://github.com/esphome/wake-word-voice-assistants/raw/main/sounds/timer_finished.wav
on_announcement:
- if:
condition:
- microphone.is_capturing:
then:
- if:
condition:
lambda: return id(wake_word_engine_location).state == "On device";
then:
- micro_wake_word.stop:
else:
- voice_assistant.stop:
- script.execute: reset_led
- light.turn_on:
id: led
blue: 100%
red: 0%
green: 0%
brightness: 100%
effect: none
on_idle:
- script.execute: start_wake_word
voice_assistant: voice_assistant:
id: va
microphone: echo_microphone microphone: echo_microphone
speaker: atom_echo_speaker media_player: echo_media_player
on_start: noise_suppression_level: 2
auto_gain: 31dBFS
volume_multiplier: 2.0
on_listening:
- light.turn_on: - light.turn_on:
id: led id: led
blue: 100% blue: 100%
red: 0% red: 0%
green: 0% green: 0%
effect: none effect: "Slow Pulse"
on_stt_vad_end:
- light.turn_on:
id: led
blue: 100%
red: 0%
green: 0%
effect: "Fast Pulse"
on_tts_start: on_tts_start:
- light.turn_on: - light.turn_on:
id: led id: led
blue: 0% blue: 100%
red: 0% red: 0%
green: 100% green: 0%
brightness: 100%
effect: none effect: none
on_tts_end:
- light.turn_on:
id: led
blue: 0%
red: 0%
green: 100%
effect: pulse
on_end: on_end:
- delay: 1s - delay: 100ms
- wait_until: - script.execute: start_wake_word
not:
speaker.is_playing: atom_echo_speaker
- light.turn_off: led
on_error: on_error:
- light.turn_on: - light.turn_on:
id: led id: led
blue: 0%
red: 100% red: 100%
green: 0% green: 0%
blue: 0%
brightness: 100%
effect: none effect: none
- delay: 1s - delay: 2s
- script.execute: reset_led
on_client_connected:
- delay: 2s # Give the api server time to settle
- script.execute: start_wake_word
on_client_disconnected:
- voice_assistant.stop:
- micro_wake_word.stop:
on_timer_finished:
- voice_assistant.stop:
- micro_wake_word.stop:
- wait_until:
not:
microphone.is_capturing:
- switch.turn_on: timer_ringing
- light.turn_on:
id: led
red: 0%
green: 100%
blue: 0%
brightness: 100%
effect: "Fast Pulse"
- wait_until:
- switch.is_off: timer_ringing
- light.turn_off: led - light.turn_off: led
- switch.turn_off: timer_ringing
binary_sensor: binary_sensor:
# button does the following:
# short click - stop a timer
# if no timer then restart either microwakeword or voice assistant continuous
- platform: gpio - platform: gpio
pin: pin:
number: GPIO39 number: GPIO39
inverted: true inverted: true
name: Button name: Button
disabled_by_default: true
entity_category: diagnostic
id: echo_button id: echo_button
on_multi_click: on_multi_click:
- timing: - timing:
- ON FOR AT MOST 350ms - ON for at least 50ms
- OFF FOR AT LEAST 10ms - OFF for at least 50ms
then: then:
- media_player.toggle: media_out - if:
condition:
switch.is_on: timer_ringing
then:
- switch.turn_off: timer_ringing
else:
- script.execute: start_wake_word
- timing: - timing:
- ON FOR AT LEAST 350ms - ON for at least 10s
then: then:
- voice_assistant.start: - button.press: factory_reset_btn
- timing:
- ON FOR AT LEAST 350ms
- OFF FOR AT LEAST 10ms
then:
- voice_assistant.stop:
media_player:
- platform: i2s_audio
id: media_out
name: None
dac_type: external
i2s_dout_pin: GPIO22
mode: mono
speaker:
- platform: i2s_audio
id: atom_echo_speaker
dac_type: external
i2s_dout_pin: GPIO22
mode: mono
light: light:
- platform: esp32_rmt_led_strip - platform: esp32_rmt_led_strip
id: led id: led
name: None name: None
disabled_by_default: true
entity_category: config
pin: GPIO27 pin: GPIO27
default_transition_length: 0s default_transition_length: 0s
chipset: SK6812 chipset: SK6812
num_leds: 1 num_leds: 1
rgb_order: grb rgb_order: grb
rmt_channel: 0
effects: effects:
- pulse: - pulse:
name: "Slow Pulse"
transition_length: 250ms transition_length: 250ms
update_interval: 250ms update_interval: 250ms
min_brightness: 50%
max_brightness: 100%
- pulse:
name: "Fast Pulse"
transition_length: 100ms
update_interval: 100ms
min_brightness: 50%
max_brightness: 100%
script:
- id: reset_led
then:
- if:
condition:
- lambda: return id(wake_word_engine_location).state == "On device";
- switch.is_on: use_listen_light
then:
- light.turn_on:
id: led
red: 100%
green: 89%
blue: 71%
brightness: 60%
effect: none
else:
- if:
condition:
- lambda: return id(wake_word_engine_location).state != "On device";
- switch.is_on: use_listen_light
then:
- light.turn_on:
id: led
red: 0%
green: 100%
blue: 100%
brightness: 60%
effect: none
else:
- light.turn_off: led
- id: start_wake_word
then:
- wait_until:
and:
- media_player.is_idle:
- speaker.is_stopped:
- if:
condition:
lambda: return id(wake_word_engine_location).state == "On device";
then:
- voice_assistant.stop
- micro_wake_word.stop:
- delay: 1s
- script.execute: reset_led
- script.wait: reset_led
- micro_wake_word.start:
else:
- if:
condition: voice_assistant.is_running
then:
- voice_assistant.stop:
- script.execute: reset_led
- voice_assistant.start_continuous:
switch:
- platform: template
name: Use listen light
id: use_listen_light
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
entity_category: config
on_turn_on:
- script.execute: reset_led
on_turn_off:
- script.execute: reset_led
- platform: template
id: timer_ringing
optimistic: true
restore_mode: ALWAYS_OFF
on_turn_off:
# Turn off the repeat mode and disable the pause between playlist items
- lambda: |-
id(echo_media_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_OFF)
.set_announcement(true)
.perform();
id(echo_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 0);
# Stop playing the alarm
- media_player.stop:
announcement: true
on_turn_on:
# Turn on the repeat mode and pause for 1000 ms between playlist items/repeats
- lambda: |-
id(echo_media_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_ONE)
.set_announcement(true)
.perform();
id(echo_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 1000);
- media_player.speaker.play_on_device_media_file:
media_file: timer_finished_wave_file
announcement: true
- delay: 15min
- switch.turn_off: timer_ringing
select:
- platform: template
entity_category: config
name: Wake word engine location
id: wake_word_engine_location
optimistic: true
restore_value: true
options:
- In Home Assistant
- On device
initial_option: On device
on_value:
- if:
condition:
lambda: return x == "In Home Assistant";
then:
- micro_wake_word.stop
- delay: 500ms
- lambda: id(va).set_use_wake_word(true);
- voice_assistant.start_continuous:
- if:
condition:
lambda: return x == "On device";
then:
- lambda: id(va).set_use_wake_word(false);
- voice_assistant.stop
- delay: 500ms
- micro_wake_word.start
micro_wake_word:
on_wake_word_detected:
- voice_assistant.start:
wake_word: !lambda return wake_word;
vad:
models:
- model: ${micro_wake_word_model}
-208
View File
@@ -1,208 +0,0 @@
substitutions:
name: "raspiaudio-muse-luxe"
friendly_name: "RaspiAudio Muse Luxe"
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
password: !secret ota
wifi:
ssid: Voltage-legacy
password: !secret voltage_legacy_psk
use_address: ${name}.home
power_save_mode: high
fast_connect: on
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Raspiaudio Fallback Hotspot"
password: !secret fallback_psk
esphome:
name: ${name}
friendly_name: ${friendly_name}
name_add_mac_suffix: false
project:
name: raspiaudio.muse-luxe
version: "1.0"
min_version: 2023.5.0
on_boot:
then:
- media_player.volume_set:
id: luxe_out
volume: 50%
esp32:
board: esp-wrover-kit
framework:
type: arduino
logger:
i2c:
sda: GPIO18
scl: GPIO23
dashboard_import:
package_import_url: github://esphome/media-players/raspiaudio-muse-luxe.yaml@main
captive_portal:
improv_serial:
external_components:
- source: github://pr#3552 # DAC support https://github.com/esphome/esphome/pull/3552
components: [es8388]
refresh: 0s
es8388:
i2s_audio:
- i2s_lrclk_pin: GPIO25
i2s_bclk_pin: GPIO5
media_player:
- platform: i2s_audio
name: None
id: luxe_out
dac_type: external
i2s_dout_pin: GPIO26
mode: stereo
mute_pin:
number: GPIO21
inverted: true
speaker:
- platform: i2s_audio
id: luxe_out_speaker
dac_type: external
i2s_dout_pin: GPIO26
mode: stereo
microphone:
- platform: i2s_audio
id: luxe_microphone
i2s_din_pin: GPIO35
adc_type: external
pdm: false
voice_assistant:
microphone: luxe_microphone
speaker: luxe_out_speaker
on_start:
- light.turn_on:
id: top_led
blue: 100%
red: 0%
green: 0%
effect: none
on_tts_start:
- light.turn_on:
id: top_led
blue: 60%
red: 20%
green: 20%
effect: none
on_tts_end:
- light.turn_on:
id: top_led
blue: 60%
red: 20%
green: 20%
effect: pulse
on_end:
- delay: 1s
- wait_until:
not:
speaker.is_playing: luxe_out_speaker
- light.turn_off: top_led
on_error:
- light.turn_on:
id: top_led
blue: 0%
red: 100%
green: 0%
effect: none
- delay: 1s
- light.turn_off: top_led
sensor:
- platform: adc
pin: GPIO33
name: Battery
icon: "mdi:battery-outline"
device_class: voltage
state_class: measurement
entity_category: diagnostic
unit_of_measurement: V
update_interval: 15s
accuracy_decimals: 3
attenuation: 11db
raw: true
filters:
- multiply: 0.00173913 # 2300 -> 4, for attenuation 11db, based on Olivier's code
- exponential_moving_average:
alpha: 0.2
send_every: 2
- delta: 0.002
binary_sensor:
- platform: gpio
pin:
number: GPIO19
inverted: true
mode:
input: true
pullup: true
name: Volume Up
on_click:
- media_player.volume_up: luxe_out
- platform: gpio
pin:
number: GPIO32
inverted: true
mode:
input: true
pullup: true
name: Volume Down
on_click:
- media_player.volume_down: luxe_out
- platform: gpio
pin:
number: GPIO12
inverted: true
mode:
input: true
pullup: true
name: Action
on_multi_click:
- timing:
- ON FOR AT MOST 350ms
- OFF FOR AT LEAST 10ms
then:
- media_player.toggle: luxe_out
- timing:
- ON FOR AT LEAST 350ms
then:
- voice_assistant.start:
- timing:
- ON FOR AT LEAST 350ms
- OFF FOR AT LEAST 10ms
then:
- voice_assistant.stop:
light:
- platform: fastled_clockless
name: None
id: top_led
pin: GPIO22
chipset: SK6812
num_leds: 1
rgb_order: grb
gamma_correct: 2.8
effects:
- pulse:
transition_length: 250ms
update_interval: 250ms
+510
View File
@@ -0,0 +1,510 @@
substitutions:
name: "riden-labornetzteil-18a"
friendly_name: "Riden Labornetzteil 18A"
# Change this model to fit your particular one.
# You can find it in Home Assistant as the device diagnostic "Model Name".
model: "RD6018"
device_name: "rd6018-controller"
device_friendly_name: "Riden RD6018"
device_description: "Monitor and control a RD6018 PSU via WiFi"
time_timezone: "Europe/Berlin"
# Model specific settings (Don't change these!)
RD6006_voltage_maximum: "60"
RD6006_voltage_accuracy: "2"
RD6006_voltage_multiplier: "0.01"
RD6006_current_maximum: "6"
RD6006_current_accuracy: "3"
RD6006_current_multiplier: "0.001"
RD6006P_voltage_maximum: "60"
RD6006P_voltage_accuracy: "3"
RD6006P_voltage_multiplier: "0.001"
RD6006P_current_maximum: "6"
RD6006P_current_accuracy: "4"
RD6006P_current_multiplier: "0.0001"
RD6012_voltage_maximum: "60"
RD6012_voltage_accuracy: "2"
RD6012_voltage_multiplier: "0.01"
RD6012_current_maximum: "12"
RD6012_current_accuracy: "2"
RD6012_current_multiplier: "0.01"
RD6018_voltage_maximum: "60"
RD6018_voltage_accuracy: "2"
RD6018_voltage_multiplier: "0.01"
RD6018_current_maximum: "18"
RD6018_current_accuracy: "2"
RD6018_current_multiplier: "0.01"
esphome:
name: $device_name
friendly_name: $device_friendly_name
comment: $device_description
name_add_mac_suffix: false
project:
name: "wildekek.rd6006-controller"
version: "1.4.1"
esp8266:
board: esp12e
network:
enable_ipv6: true
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: riden-labornetzteil-18a.home
power_save_mode: high
fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "RD6018 Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
# Enable logging
logger:
level: INFO
# Disable logging via UART, since we're using this for modbus communication
baud_rate: 0
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
# Enable status LED
status_led:
pin:
number: GPIO2
inverted: true
uart:
id: mod_bus
tx_pin: GPIO1
rx_pin: GPIO3
baud_rate: 115200
data_bits: 8
stop_bits: 1
parity: none
modbus:
id: modbus1
modbus_controller:
- id: powersupply
## This address should be set to the "Address" value in the config menu
address: 0x01
modbus_id: modbus1
setup_priority: -10
update_interval: 5s
time:
# Get the time from HA, so we can use it for uptime
- platform: homeassistant
id: time_homeassistant
timezone: "${time_timezone}"
on_time_sync:
- logger.log: Time has been set and is valid!
- component.update: sensor_uptime_timestamp
- number.set:
id: date_year
value: !lambda return id(time_homeassistant).now().year;
- number.set:
id: date_month
value: !lambda return id(time_homeassistant).now().month;
- number.set:
id: date_day
value: !lambda return id(time_homeassistant).now().day_of_month;
- number.set:
id: date_hour
value: !lambda return id(time_homeassistant).now().hour;
- number.set:
id: date_minute
value: !lambda return id(time_homeassistant).now().minute;
- number.set:
id: date_second
value: !lambda return id(time_homeassistant).now().second;
sensor:
- platform: modbus_controller
id: model_number
name: "Model Number"
entity_category: diagnostic
disabled_by_default: True
modbus_controller_id: powersupply
address: 0
skip_updates: 10
unit_of_measurement: ""
register_type: holding
value_type: U_WORD
accuracy_decimals: 0
on_value:
then:
- lambda: |-
id(model_name).publish_state(value_accuracy_to_string(x, 0));
- platform: modbus_controller
name: "Serial Number"
entity_category: diagnostic
disabled_by_default: True
modbus_controller_id: powersupply
address: 1
skip_updates: 10
register_type: holding
value_type: U_DWORD
accuracy_decimals: 0
- platform: modbus_controller
modbus_controller_id: powersupply
address: 3
name: "Firmware version"
entity_category: diagnostic
disabled_by_default: True
unit_of_measurement: ""
register_type: holding
value_type: U_WORD
accuracy_decimals: 2
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: powersupply
address: 10
name: "Output voltage"
device_class: voltage
state_class: measurement
unit_of_measurement: "V"
register_type: holding
value_type: U_WORD
accuracy_decimals: ${${model}_voltage_accuracy}
filters:
- multiply: ${${model}_voltage_multiplier}
- platform: modbus_controller
modbus_controller_id: powersupply
address: 11
name: "Output current"
device_class: current
state_class: measurement
unit_of_measurement: "A"
register_type: holding
value_type: U_WORD
accuracy_decimals: ${${model}_current_accuracy}
filters:
- multiply: ${${model}_current_multiplier}
- platform: modbus_controller
modbus_controller_id: powersupply
address: 12
name: "Output Power"
device_class: power
state_class: measurement
unit_of_measurement: "W"
register_type: holding
value_type: U_DWORD
accuracy_decimals: 2
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: powersupply
address: 33
name: "Battery voltage"
device_class: voltage
state_class: measurement
unit_of_measurement: "V"
register_type: holding
value_type: U_WORD
accuracy_decimals: ${${model}_voltage_accuracy}
filters:
- multiply: ${${model}_voltage_multiplier}
- platform: modbus_controller
modbus_controller_id: powersupply
address: 38
name: "Battery charge"
device_class: "energy_storage"
state_class: measurement
unit_of_measurement: "Ah"
icon: "mdi:battery-60"
register_type: holding
value_type: U_DWORD
accuracy_decimals: 3
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: powersupply
address: 40
name: "Battery energy"
device_class: "energy_storage"
state_class: measurement
unit_of_measurement: "Wh"
icon: "mdi:battery-60"
register_type: holding
value_type: U_DWORD
accuracy_decimals: 3
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: powersupply
address: 14
name: "Input voltage"
device_class: voltage
unit_of_measurement: "V"
register_type: holding
value_type: U_WORD
accuracy_decimals: ${${model}_voltage_accuracy}
filters:
- multiply: ${${model}_voltage_multiplier}
- platform: modbus_controller
name: "Temperature"
device_class: temperature
state_class: measurement
modbus_controller_id: powersupply
register_type: holding
address: 4
value_type: S_DWORD
unit_of_measurement: "°C"
- platform: modbus_controller
name: "Temperature external"
state_class: measurement
modbus_controller_id: powersupply
register_type: holding
address: 34
value_type: S_DWORD
device_class: temperature
unit_of_measurement: "°C"
- platform: wifi_signal
name: "Wi-Fi Signal"
disabled_by_default: True
update_interval: 60s
# Uptime is used internally only
- platform: uptime
id: sensor_uptime
# This sensor is an alternative for the uptime sensor, which only sends the
# startup timestamp of the device to home assistant once
- platform: template
id: sensor_uptime_timestamp
name: "Uptime"
entity_category: diagnostic
device_class: "timestamp"
accuracy_decimals: 0
update_interval: never
lambda: |-
static float timestamp = (
id(time_homeassistant).utcnow().timestamp - id(sensor_uptime).state
);
return timestamp;
text_sensor:
- platform: wifi_info
ip_address:
name: "IP Address"
disabled_by_default: True
ssid:
name: "Wi-Fi SSID"
disabled_by_default: True
bssid:
name: "Wi-Fi BSSID"
disabled_by_default: True
- platform: template
id: model_name
name: "Model Name"
entity_category: diagnostic
# Updated by model number
update_interval: never
filters:
- map:
- 60062 -> RD6006
- 60065 -> RD6006P
- 60121 -> RD6012
- 60181 -> RD6018
binary_sensor:
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Keypad lock"
entity_category: diagnostic
device_class: lock
address: 15
register_type: holding
bitmask: 0x1
filters:
- invert:
- platform: modbus_controller
modbus_controller_id: powersupply
address: 32
name: "Battery mode"
device_class: connectivity
register_type: holding
bitmask: 0x1
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Over Voltage Protection"
device_class: problem
address: 16
register_type: holding
bitmask: 0x1
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Over Current Protection"
device_class: problem
address: 16
register_type: holding
bitmask: 0x2
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Constant Voltage"
address: 17
register_type: holding
bitmask: 0x1
filters:
- invert:
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Constant Current"
address: 17
register_type: holding
bitmask: 0x1
number:
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Backlight"
icon: "mdi:lightbulb"
entity_category: config
address: 72
value_type: U_WORD
min_value: 0
max_value: 5
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Output voltage"
device_class: voltage
unit_of_measurement: "V"
entity_category: config
address: 8
value_type: U_WORD
min_value: 0
max_value: ${${model}_voltage_maximum}
step: ${${model}_voltage_multiplier}
lambda: !lambda return x * ${${model}_voltage_multiplier};
write_lambda: !lambda return x * (1/${${model}_voltage_multiplier});
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Output current"
device_class: current
unit_of_measurement: "A"
entity_category: config
address: 9
value_type: U_WORD
min_value: 0
max_value: ${${model}_current_maximum}
step: ${${model}_current_multiplier}
lambda: !lambda return x * ${${model}_current_multiplier};
write_lambda: !lambda return x * (1/${${model}_current_multiplier});
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Over Voltage Protection"
device_class: voltage
unit_of_measurement: "V"
address: 82
entity_category: config
value_type: U_WORD
min_value: 0
max_value: ${${model}_voltage_maximum}
step: ${${model}_voltage_multiplier}
lambda: !lambda return x * ${${model}_voltage_multiplier};
write_lambda: !lambda return x * (1/${${model}_voltage_multiplier});
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Over Current Protection"
device_class: current
unit_of_measurement: "A"
address: 83
entity_category: config
value_type: U_WORD
min_value: 0
max_value: ${${model}_current_maximum}
step: ${${model}_current_multiplier}
lambda: !lambda return x * ${${model}_current_multiplier};
write_lambda: !lambda return x * (1/${${model}_current_multiplier});
# Date components are kept internal
- platform: modbus_controller
id: date_year
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 48
value_type: U_WORD
- platform: modbus_controller
id: date_month
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 49
value_type: U_WORD
- platform: modbus_controller
id: date_day
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 50
value_type: U_WORD
- platform: modbus_controller
id: date_hour
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 51
value_type: U_WORD
- platform: modbus_controller
id: date_minute
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 52
value_type: U_WORD
- platform: modbus_controller
id: date_second
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 53
value_type: U_WORD
switch:
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Output"
address: 18
register_type: holding
bitmask: 0x1
entity_category: config
button:
- platform: safe_mode
name: "Safe mode"
+510
View File
@@ -0,0 +1,510 @@
substitutions:
name: "riden-labornetzteil-6a"
friendly_name: "Riden Labornetzteil 6A"
# Change this model to fit your particular one.
# You can find it in Home Assistant as the device diagnostic "Model Name".
model: "RD6006"
device_name: "rd6006-controller"
device_friendly_name: "Riden RD6006"
device_description: "Monitor and control a RD6006 PSU via WiFi"
time_timezone: "Europe/Berlin"
# Model specific settings (Don't change these!)
RD6006_voltage_maximum: "60"
RD6006_voltage_accuracy: "2"
RD6006_voltage_multiplier: "0.01"
RD6006_current_maximum: "6"
RD6006_current_accuracy: "3"
RD6006_current_multiplier: "0.001"
RD6006P_voltage_maximum: "60"
RD6006P_voltage_accuracy: "3"
RD6006P_voltage_multiplier: "0.001"
RD6006P_current_maximum: "6"
RD6006P_current_accuracy: "4"
RD6006P_current_multiplier: "0.0001"
RD6012_voltage_maximum: "60"
RD6012_voltage_accuracy: "2"
RD6012_voltage_multiplier: "0.01"
RD6012_current_maximum: "12"
RD6012_current_accuracy: "2"
RD6012_current_multiplier: "0.01"
RD6018_voltage_maximum: "60"
RD6018_voltage_accuracy: "2"
RD6018_voltage_multiplier: "0.01"
RD6018_current_maximum: "18"
RD6018_current_accuracy: "2"
RD6018_current_multiplier: "0.01"
esphome:
name: $device_name
friendly_name: $device_friendly_name
comment: $device_description
name_add_mac_suffix: false
project:
name: "wildekek.rd6006-controller"
version: "1.4.1"
esp8266:
board: esp12e
network:
enable_ipv6: true
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: riden-labornetzteil-6a.home
power_save_mode: high
fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "RD6006 Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
# Enable logging
logger:
level: INFO
# Disable logging via UART, since we're using this for modbus communication
baud_rate: 0
# Enable Home Assistant API
api:
encryption:
key: !secret apikey
ota:
platform: esphome
password: !secret ota
# Enable status LED
status_led:
pin:
number: GPIO2
inverted: true
uart:
id: mod_bus
tx_pin: GPIO1
rx_pin: GPIO3
baud_rate: 115200
data_bits: 8
stop_bits: 1
parity: none
modbus:
id: modbus1
modbus_controller:
- id: powersupply
## This address should be set to the "Address" value in the config menu
address: 0x01
modbus_id: modbus1
setup_priority: -10
update_interval: 5s
time:
# Get the time from HA, so we can use it for uptime
- platform: homeassistant
id: time_homeassistant
timezone: "${time_timezone}"
on_time_sync:
- logger.log: Time has been set and is valid!
- component.update: sensor_uptime_timestamp
- number.set:
id: date_year
value: !lambda return id(time_homeassistant).now().year;
- number.set:
id: date_month
value: !lambda return id(time_homeassistant).now().month;
- number.set:
id: date_day
value: !lambda return id(time_homeassistant).now().day_of_month;
- number.set:
id: date_hour
value: !lambda return id(time_homeassistant).now().hour;
- number.set:
id: date_minute
value: !lambda return id(time_homeassistant).now().minute;
- number.set:
id: date_second
value: !lambda return id(time_homeassistant).now().second;
sensor:
- platform: modbus_controller
id: model_number
name: "Model Number"
entity_category: diagnostic
disabled_by_default: True
modbus_controller_id: powersupply
address: 0
skip_updates: 10
unit_of_measurement: ""
register_type: holding
value_type: U_WORD
accuracy_decimals: 0
on_value:
then:
- lambda: |-
id(model_name).publish_state(value_accuracy_to_string(x, 0));
- platform: modbus_controller
name: "Serial Number"
entity_category: diagnostic
disabled_by_default: True
modbus_controller_id: powersupply
address: 1
skip_updates: 10
register_type: holding
value_type: U_DWORD
accuracy_decimals: 0
- platform: modbus_controller
modbus_controller_id: powersupply
address: 3
name: "Firmware version"
entity_category: diagnostic
disabled_by_default: True
unit_of_measurement: ""
register_type: holding
value_type: U_WORD
accuracy_decimals: 2
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: powersupply
address: 10
name: "Output voltage"
device_class: voltage
state_class: measurement
unit_of_measurement: "V"
register_type: holding
value_type: U_WORD
accuracy_decimals: ${${model}_voltage_accuracy}
filters:
- multiply: ${${model}_voltage_multiplier}
- platform: modbus_controller
modbus_controller_id: powersupply
address: 11
name: "Output current"
device_class: current
state_class: measurement
unit_of_measurement: "A"
register_type: holding
value_type: U_WORD
accuracy_decimals: ${${model}_current_accuracy}
filters:
- multiply: ${${model}_current_multiplier}
- platform: modbus_controller
modbus_controller_id: powersupply
address: 12
name: "Output Power"
device_class: power
state_class: measurement
unit_of_measurement: "W"
register_type: holding
value_type: U_DWORD
accuracy_decimals: 2
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: powersupply
address: 33
name: "Battery voltage"
device_class: voltage
state_class: measurement
unit_of_measurement: "V"
register_type: holding
value_type: U_WORD
accuracy_decimals: ${${model}_voltage_accuracy}
filters:
- multiply: ${${model}_voltage_multiplier}
- platform: modbus_controller
modbus_controller_id: powersupply
address: 38
name: "Battery charge"
device_class: "energy_storage"
state_class: measurement
unit_of_measurement: "Ah"
icon: "mdi:battery-60"
register_type: holding
value_type: U_DWORD
accuracy_decimals: 3
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: powersupply
address: 40
name: "Battery energy"
device_class: "energy_storage"
state_class: measurement
unit_of_measurement: "Wh"
icon: "mdi:battery-60"
register_type: holding
value_type: U_DWORD
accuracy_decimals: 3
filters:
- multiply: 0.001
- platform: modbus_controller
modbus_controller_id: powersupply
address: 14
name: "Input voltage"
device_class: voltage
unit_of_measurement: "V"
register_type: holding
value_type: U_WORD
accuracy_decimals: ${${model}_voltage_accuracy}
filters:
- multiply: ${${model}_voltage_multiplier}
- platform: modbus_controller
name: "Temperature"
device_class: temperature
state_class: measurement
modbus_controller_id: powersupply
register_type: holding
address: 4
value_type: S_DWORD
unit_of_measurement: "°C"
- platform: modbus_controller
name: "Temperature external"
state_class: measurement
modbus_controller_id: powersupply
register_type: holding
address: 34
value_type: S_DWORD
device_class: temperature
unit_of_measurement: "°C"
- platform: wifi_signal
name: "Wi-Fi Signal"
disabled_by_default: True
update_interval: 60s
# Uptime is used internally only
- platform: uptime
id: sensor_uptime
# This sensor is an alternative for the uptime sensor, which only sends the
# startup timestamp of the device to home assistant once
- platform: template
id: sensor_uptime_timestamp
name: "Uptime"
entity_category: diagnostic
device_class: "timestamp"
accuracy_decimals: 0
update_interval: never
lambda: |-
static float timestamp = (
id(time_homeassistant).utcnow().timestamp - id(sensor_uptime).state
);
return timestamp;
text_sensor:
- platform: wifi_info
ip_address:
name: "IP Address"
disabled_by_default: True
ssid:
name: "Wi-Fi SSID"
disabled_by_default: True
bssid:
name: "Wi-Fi BSSID"
disabled_by_default: True
- platform: template
id: model_name
name: "Model Name"
entity_category: diagnostic
# Updated by model number
update_interval: never
filters:
- map:
- 60062 -> RD6006
- 60065 -> RD6006P
- 60121 -> RD6012
- 60181 -> RD6018
binary_sensor:
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Keypad lock"
entity_category: diagnostic
device_class: lock
address: 15
register_type: holding
bitmask: 0x1
filters:
- invert:
- platform: modbus_controller
modbus_controller_id: powersupply
address: 32
name: "Battery mode"
device_class: connectivity
register_type: holding
bitmask: 0x1
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Over Voltage Protection"
device_class: problem
address: 16
register_type: holding
bitmask: 0x1
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Over Current Protection"
device_class: problem
address: 16
register_type: holding
bitmask: 0x2
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Constant Voltage"
address: 17
register_type: holding
bitmask: 0x1
filters:
- invert:
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Constant Current"
address: 17
register_type: holding
bitmask: 0x1
number:
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Backlight"
icon: "mdi:lightbulb"
entity_category: config
address: 72
value_type: U_WORD
min_value: 0
max_value: 5
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Output voltage"
device_class: voltage
unit_of_measurement: "V"
entity_category: config
address: 8
value_type: U_WORD
min_value: 0
max_value: ${${model}_voltage_maximum}
step: ${${model}_voltage_multiplier}
lambda: !lambda return x * ${${model}_voltage_multiplier};
write_lambda: !lambda return x * (1/${${model}_voltage_multiplier});
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Output current"
device_class: current
unit_of_measurement: "A"
entity_category: config
address: 9
value_type: U_WORD
min_value: 0
max_value: ${${model}_current_maximum}
step: ${${model}_current_multiplier}
lambda: !lambda return x * ${${model}_current_multiplier};
write_lambda: !lambda return x * (1/${${model}_current_multiplier});
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Over Voltage Protection"
device_class: voltage
unit_of_measurement: "V"
address: 82
entity_category: config
value_type: U_WORD
min_value: 0
max_value: ${${model}_voltage_maximum}
step: ${${model}_voltage_multiplier}
lambda: !lambda return x * ${${model}_voltage_multiplier};
write_lambda: !lambda return x * (1/${${model}_voltage_multiplier});
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Over Current Protection"
device_class: current
unit_of_measurement: "A"
address: 83
entity_category: config
value_type: U_WORD
min_value: 0
max_value: ${${model}_current_maximum}
step: ${${model}_current_multiplier}
lambda: !lambda return x * ${${model}_current_multiplier};
write_lambda: !lambda return x * (1/${${model}_current_multiplier});
# Date components are kept internal
- platform: modbus_controller
id: date_year
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 48
value_type: U_WORD
- platform: modbus_controller
id: date_month
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 49
value_type: U_WORD
- platform: modbus_controller
id: date_day
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 50
value_type: U_WORD
- platform: modbus_controller
id: date_hour
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 51
value_type: U_WORD
- platform: modbus_controller
id: date_minute
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 52
value_type: U_WORD
- platform: modbus_controller
id: date_second
modbus_controller_id: powersupply
entity_category: diagnostic
register_type: holding
address: 53
value_type: U_WORD
switch:
- platform: modbus_controller
modbus_controller_id: powersupply
name: "Output"
address: 18
register_type: holding
bitmask: 0x1
entity_category: config
button:
- platform: safe_mode
name: "Safe mode"
+26 -10
View File
@@ -1,15 +1,31 @@
esphome: substitutions:
name: serverroom name: "serverroom"
friendly_name: "Serverraum Luft" friendly_name: "Serverraum Luft"
platform: ESP32
board: nodemcu-32s esphome:
name: ${name}
friendly_name: ${friendly_name}
name_add_mac_suffix: false
project:
name: sensor.outdoor
version: "0.8"
min_version: 2022.1.0
esp32:
board: esp32-c3-devkitm-1
framework:
type: esp-idf
network:
enable_ipv6: true
wifi: wifi:
ssid: "Voltage-legacy" ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk password: !secret voltage_legacy_psk
use_address: serverroom.home #use_address: serverroom.home
power_save_mode: high power_save_mode: high
fast_connect: on fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails # Enable fallback hotspot (captive portal) in case wifi connection fails
ap: ap:
@@ -23,23 +39,23 @@ logger:
# Enable Home Assistant API # Enable Home Assistant API
api: api:
password: !secret api
encryption: encryption:
key: !secret apikey key: !secret apikey
ota: ota:
platform: esphome
password: !secret ota password: !secret ota
# Initialize I²C # Initialize I²C
i2c: i2c:
- id: bus_a - id: bus_a
sda: 32 sda: 4
scl: 25 scl: 3
scan: true scan: true
# DHT22 sensor # BMP280 sensor
sensor: sensor:
- platform: bmp280 - platform: bmp280_i2c
i2c_id: bus_a i2c_id: bus_a
temperature: temperature:
name: "Serveraum Temperatur" name: "Serveraum Temperatur"
-321
View File
@@ -1,321 +0,0 @@
##############################################################################
# Globals Glances parameters
##############################################################################
[global]
# Does Glances should check if a newer version is available on PyPI ?
check_update=false
# History size (maximum number of values)
# Default glances history size to 0
# https://github.com/hassio-addons/addon-glances/issues/61
# https://github.com/nicolargo/glances/issues/1341
history_size=0
##############################################################################
# User interface
##############################################################################
[outputs]
# Theme name for the Curses interface: black or white
curse_theme=black
# Limit the number of processes to display in the WebUI
max_processes_display=30
##############################################################################
# plugins
##############################################################################
[quicklook]
# Define CPU, MEM and SWAP thresholds in %
cpu_careful=50
cpu_warning=70
cpu_critical=90
mem_careful=50
mem_warning=70
mem_critical=90
swap_careful=50
swap_warning=70
swap_critical=90
[cpu]
# Default values if not defined: 50/70/90 (except for iowait)
user_careful=50
user_warning=70
user_critical=90
#user_log=False
#user_critical_action=echo {{user}} {{value}} {{max}} > /tmp/cpu.alert
system_careful=50
system_warning=70
system_critical=90
steal_careful=50
steal_warning=70
steal_critical=90
#steal_log=True
# I/O wait percentage should be lower than 1/# (Logical CPU cores)
# Leave commented to just use the default config (1/#-20% / 1/#-10% / 1/#)
#iowait_careful=30
#iowait_warning=40
#iowait_critical=50
# Context switch limit (core / second)
# Leave commented to just use the default config (critical is 50000*# (Logical CPU cores)
#ctx_switches_careful=10000
#ctx_switches_warning=12000
#ctx_switches_critical=14000
[percpu]
# Define CPU thresholds in %
# Default values if not defined: 50/70/90
user_careful=50
user_warning=70
user_critical=90
iowait_careful=50
iowait_warning=70
iowait_critical=90
system_careful=50
system_warning=70
system_critical=90
[gpu]
# Default processor values if not defined: 50/70/90
proc_careful=50
proc_warning=70
proc_critical=90
# Default memory values if not defined: 50/70/90
mem_careful=50
mem_warning=70
mem_critical=90
[mem]
# Define RAM thresholds in %
# Default values if not defined: 50/70/90
careful=50
#careful_action_repeat=echo {{percent}} >> /tmp/memory.alert
warning=70
critical=90
[memswap]
# Define SWAP thresholds in %
# Default values if not defined: 50/70/90
careful=50
warning=70
critical=90
[load]
# Define LOAD thresholds
# Value * number of cores
# Default values if not defined: 0.7/1.0/5.0 per number of cores
# Source: http://blog.scoutapp.com/articles/2009/07/31/understanding-load-averages
# http://www.linuxjournal.com/article/9001
careful=0.7
warning=1.0
critical=5.0
#log=False
[network]
# Default bitrate thresholds in % of the network interface speed
# Default values if not defined: 70/80/90
rx_careful=70
rx_warning=80
rx_critical=90
tx_careful=70
tx_warning=80
tx_critical=90
# Define the list of hidden network interfaces (comma-separated regexp)
#hide=docker.*,lo
# WLAN 0 alias
#wlan0_alias=Wireless IF
# It is possible to overwrite the bitrate thresholds per interface
# WLAN 0 Default limits (in bits per second aka bps) for interface bitrate
#wlan0_rx_careful=4000000
#wlan0_rx_warning=5000000
#wlan0_rx_critical=6000000
#wlan0_rx_log=True
#wlan0_tx_careful=700000
#wlan0_tx_warning=900000
#wlan0_tx_critical=1000000
#wlan0_tx_log=True
[wifi]
# Define the list of hidden wireless network interfaces (comma-separated regexp)
hide=lo,docker.*
# Define SIGNAL thresholds in db (lower is better...)
# Based on: http://serverfault.com/questions/501025/industry-standard-for-minimum-wifi-signal-strength
careful=-65
warning=-75
critical=-85
[diskio]
# Define the list of hidden disks (comma-separated regexp)
#hide=sda2,sda5,loop.*
hide=loop.*
# Alias for sda1
#sda1_alias=IntDisk
[fs]
# Define the list of hidden file system (comma-separated regexp)
#hide=/boot.*
# Define filesystem space thresholds in %
# Default values if not defined: 50/70/90
# It is also possible to define per mount point value
# Example: /_careful=40
careful=50
warning=70
critical=90
# Allow additional file system types (comma-separated FS type)
#allow=zfs
[folders]
# Define a folder list to monitor
# The list is composed of items (list_#nb <= 10)
# An item is defined by:
# * path: absolute path
# * careful: optional careful threshold (in MB)
# * warning: optional warning threshold (in MB)
# * critical: optional critical threshold (in MB)
folder_1_path=/config
folder_1_careful=4096
folder_1_warning=8196
folder_1_critical=12228
folder_2_path=/share
folder_2_careful=4096
folder_2_warning=8196
folder_2_critical=12228
folder_3_path=/backup
folder_3_careful=4096
folder_3_warning=8196
folder_3_critical=12228
folder_4_path=/media
folder_4_careful=4096
folder_4_warning=8196
folder_4_critical=12228
[sensors]
# Sensors core thresholds (in Celsius...)
# Default values if not defined: 60/70/80
temperature_core_careful=60
temperature_core_warning=70
temperature_core_critical=80
# Temperatures threshold in °C for hddtemp
# Default values if not defined: 45/52/60
temperature_hdd_careful=45
temperature_hdd_warning=52
temperature_hdd_critical=60
# Battery threshold in %
battery_careful=80
battery_warning=90
battery_critical=95
# Sensors alias
#temp1_alias=Motherboard 0
#temp2_alias=Motherboard 1
#core 0_alias=CPU Core 0
#core 1_alias=CPU Core 1
[processlist]
# Define CPU/MEM (per process) thresholds in %
# Default values if not defined: 50/70/90
cpu_careful=50
cpu_warning=70
cpu_critical=90
mem_careful=50
mem_warning=70
mem_critical=90
#
# Nice priorities range from -20 to 19.
# Configure nice levels using a comma separated list.
#
# Nice: Example 1, non-zero is warning (default behavior)
nice_warning=-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19
#
# Nice: Example 2, low priority processes escalate from careful to critical
#nice_careful=1,2,3,4,5,6,7,8,9
#nice_warning=10,11,12,13,14
#nice_critical=15,16,17,18,19
[ports]
# Ports scanner plugin configuration
# Interval in second between two scans
refresh=30
# Set the default timeout (in second) for a scan (can be overwritten in the scan list)
timeout=3
# If port_default_gateway is True, add the default gateway on top of the scan list
port_default_gateway=True
#
# Define the scan list (1 < x < 255)
# port_x_host (name or IP) is mandatory
# port_x_port (TCP port number) is optional (if not set, use ICMP)
# port_x_description is optional (if not set, define to host:port)
# port_x_timeout is optional and overwrite the default timeout value
# port_x_rtt_warning is optional and defines the warning threshold in ms
#
#port_1_host=192.168.0.1
#port_1_port=80
#port_1_description=Home Box
#port_1_timeout=1
#port_2_host=www.free.fr
#port_2_description=My ISP
#port_3_host=www.google.com
#port_3_description=Internet ICMP
#port_3_rtt_warning=1000
#port_4_description=Internet Web
#port_4_host=www.google.com
#port_4_port=80
#port_4_rtt_warning=1000
#
# Define Web (URL) monitoring list (1 < x < 255)
# web_x_url is the URL to monitor (example: http://my.site.com/folder)
# web_x_description is optional (if not set, define to URL)
# web_x_timeout is optional and overwrite the default timeout value
# web_x_rtt_warning is optional and defines the warning respond time in ms (approximatively)
#
#web_1_url=https://blog.nicolargo.com
#web_1_description=My Blog
#web_1_rtt_warning=3000
#web_2_url=https://github.com
#web_3_url=http://www.google.fr
#web_3_description=Google Fr
#web_4_url=https://blog.nicolargo.com/nonexist
#web_4_description=Intranet
[docker]
# Thresholds for CPU and MEM (in %)
cpu_careful=50
cpu_warning=70
cpu_critical=90
mem_careful=20
mem_warning=50
mem_critical=70
#
# Per container thresholds
#containername_cpu_careful=10
#containername_cpu_warning=20
#containername_cpu_critical=30
#
# By default, Glances only display running containers
# Set the following key to True to display all containers
all=False
##############################################################################
# Client/server
##############################################################################
[serverlist]
# Define the static servers list
#server_1_name=localhost
#server_1_alias=My local PC
#server_1_port=61209
#server_2_name=localhost
#server_2_port=61235
#server_3_name=192.168.0.17
#server_3_alias=Another PC on my network
#server_3_port=61209
#server_4_name=pasbon
#server_4_port=61237
[passwords]
# Define the passwords list
# Syntax: host=password
# Where: host is the hostname
# password is the clear password
# Additionally (and optionally) a default password could be defined
#localhost=abc
#default=defaultpassword
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

-6
View File
@@ -1,6 +0,0 @@
bdm:
name: BDM
mac: BLE_34:14:B5:A0:99:BD
icon:
picture:
track: true
+158 -33
View File
@@ -1,33 +1,4 @@
sensor: sensor:
# MQTT bathroom sensors
- name: Temperatur
unique_id: bathroom.temperature
state_topic: "home/bathroom/climate/sensor/temperature"
device_class: 'temperature'
state_class: 'measurement'
unit_of_measurement: '°C'
last_reset_value_template: '1970-01-01T00:00:00+00:00'
device:
identifiers: MagicMirror-DHT22
name: MagicMirror-DHT22
model: DHT22
manufacturer: mqtt-io
force_update: true
- name: Luftfeuchtigkeit
unique_id: bathroom.humidity
state_topic: "home/bathroom/climate/sensor/humidity"
device_class: 'humidity'
state_class: 'measurement'
unit_of_measurement: '%'
last_reset_value_template: '1970-01-01T00:00:00+00:00'
device:
identifiers: MagicMirror-DHT22
name: MagicMirror-DHT22
model: DHT22
manufacturer: mqtt-io
force_update: true
# MQTT/RTL_433 (outdoor) sensors # MQTT/RTL_433 (outdoor) sensors
# Reliable sensor with good placement (good values) and good reception # Reliable sensor with good placement (good values) and good reception
- device_class: battery - device_class: battery
@@ -36,7 +7,7 @@
value_template: '{{ float(value) * 99 + 1 }}' value_template: '{{ float(value) * 99 + 1 }}'
state_class: measurement state_class: measurement
entity_category: diagnostic entity_category: diagnostic
state_topic: rtl_433/sdr/devices/Auriol-AHFL/1/244/battery_ok state_topic: rtl_433/sdr/devices/Auriol-AHFL/1/216/battery_ok
unique_id: Auriol-AHFL-1-106-B unique_id: Auriol-AHFL-1-106-B
device: device:
identifiers: Auriol-AHFL-1-106 identifiers: Auriol-AHFL-1-106
@@ -49,7 +20,7 @@
unit_of_measurement: °C unit_of_measurement: °C
value_template: '{{ value|float }}' value_template: '{{ value|float }}'
state_class: measurement state_class: measurement
state_topic: rtl_433/sdr/devices/Auriol-AHFL/1/244/temperature_C state_topic: rtl_433/sdr/devices/Auriol-AHFL/1/216/temperature_C
unique_id: Auriol-AHFL-1-106-T unique_id: Auriol-AHFL-1-106-T
device: device:
identifiers: Auriol-AHFL-1-106 identifiers: Auriol-AHFL-1-106
@@ -62,7 +33,7 @@
unit_of_measurement: '%' unit_of_measurement: '%'
value_template: '{{ value|float }}' value_template: '{{ value|float }}'
state_class: measurement state_class: measurement
state_topic: rtl_433/sdr/devices/Auriol-AHFL/1/244/humidity state_topic: rtl_433/sdr/devices/Auriol-AHFL/1/216/humidity
unique_id: Auriol-AHFL-1-106-H unique_id: Auriol-AHFL-1-106-H
device: device:
identifiers: Auriol-AHFL-1-106 identifiers: Auriol-AHFL-1-106
@@ -76,7 +47,7 @@
value_template: '{{ float(value) * 99 + 1 }}' value_template: '{{ float(value) * 99 + 1 }}'
state_class: measurement state_class: measurement
entity_category: diagnostic entity_category: diagnostic
state_topic: rtl_433/sdr/devices/Vauno-EN8822C/1/244/battery_ok state_topic: rtl_433/sdr/devices/Vauno-EN8822C/1/216/battery_ok
unique_id: Vauno-EN8822C-1-244-B unique_id: Vauno-EN8822C-1-244-B
device: device:
identifiers: Vauno-EN8822C-1-244 identifiers: Vauno-EN8822C-1-244
@@ -110,3 +81,157 @@
model: Vauno-EN8822C model: Vauno-EN8822C
manufacturer: rtl_433 manufacturer: rtl_433
- device_class: humidity
unit_of_measurement: '%'
value_template: '{{ value|float }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Nexus-TH/1/238/humidity
unique_id: Nexus-TH-1-238-H
device:
identifiers:
- Nexus-TH-1-238
model: Nexus-TH
manufacturer: rtl_433
name: Nexus-TH-1-238
name: Humidity
- device_class: humidity
unit_of_measurement: '%'
value_template: '{{ value|float }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Nexus-TH/1/238/humidity
unique_id: Nexus-TH-1-238-H
device:
identifiers:
- Nexus-TH-1-238
model: Nexus-TH
manufacturer: rtl_433
name: Nexus-TH-1-238
name: Humidity
# Cotech-367959
- device_class: battery
unit_of_measurement: '%'
value_template: '{{ ((float(value) * 99)|round(0)) + 1 }}'
state_class: measurement
entity_category: diagnostic
state_topic: rtl_433/sdr/devices/Cotech-367959/130/battery_ok
unique_id: Cotech-367959-130-B
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: Battery
- device_class: temperature
unit_of_measurement: °F
value_template: '{{ value|float|round(1) }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Cotech-367959/130/temperature_F
unique_id: Cotech-367959-130-F
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: Temperature
- device_class: humidity
unit_of_measurement: '%'
value_template: '{{ value|float }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Cotech-367959/130/humidity
unique_id: Cotech-367959-130-H
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: Humidity
- device_class: precipitation
unit_of_measurement: mm
value_template: '{{ value|float|round(2) }}'
state_class: total_increasing
state_topic: rtl_433/sdr/devices/Cotech-367959/130/rain_mm
unique_id: Cotech-367959-130-RT
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: Rain Total
- unit_of_measurement: °
value_template: '{{ value|float }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Cotech-367959/130/wind_dir_deg
unique_id: Cotech-367959-130-WD
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: Wind Direction
- device_class: wind_speed
unit_of_measurement: km/h
value_template: '{{ (float(value|float) * 3.6) | round(2) }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Cotech-367959/130/wind_avg_m_s
unique_id: Cotech-367959-130-WS
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: Wind Average
- device_class: wind_speed
unit_of_measurement: km/h
value_template: '{{ (float(value|float) * 3.6) | round(2) }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Cotech-367959/130/wind_max_m_s
unique_id: Cotech-367959-130-GS
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: Wind max
- device_class: illuminance
unit_of_measurement: lx
value_template: '{{ value|int }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Cotech-367959/130/light_lux
unique_id: Cotech-367959-130-lux
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: Outside Luminance
- unit_of_measurement: UV Index
value_template: '{{ value|float|round(1) }}'
state_class: measurement
state_topic: rtl_433/sdr/devices/Cotech-367959/130/uv
unique_id: Cotech-367959-130-uv
device:
identifiers:
- Cotech-367959-130
model: Cotech-367959
manufacturer: rtl_433
name: Cotech-367959-130
name: UV Index

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