Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e46f60b65 | |||
| 9e7684e294 | |||
| 818b06f3d7 | |||
| 99bb2594e2 | |||
| 9104cd8fdc | |||
| 96b88624d9 | |||
| c1ba3012d4 | |||
| e77d9ee8d2 | |||
| bf49633fac | |||
| 99579a30f7 | |||
| 250bfa94bd | |||
| c342efc08f | |||
| 13e58355da | |||
| ee814c4afe | |||
| 6149a9e994 | |||
| 3b7c30634e | |||
| 38da2cebdd | |||
| 3da69c19ab | |||
| 35e7f81224 | |||
| e4a4aacc63 | |||
| 0d12317089 | |||
| 746dd833c8 | |||
| 3eb482e40b | |||
| e9a6064b4b | |||
| 38616556cb | |||
| bef1cfb1d0 | |||
| 0a8364d7ed | |||
| 3eb9eb3a2f | |||
| 7407955465 | |||
| 2ff8b5cafe | |||
| 7e08c4a8b9 | |||
| 855f013095 | |||
| a68b527c48 | |||
| cecec14be1 | |||
| 4e5df1ad0c | |||
| 2c16ba1679 | |||
| c21f2a5c7b | |||
| 2a1902ccf1 | |||
| 31848bf26c | |||
| d7337fd634 | |||
| 5b287303f1 | |||
| 816cc35861 | |||
| 058c186d63 | |||
| 22e5f9d767 | |||
| f9b32d06a4 | |||
| 3f890ae02f | |||
| f0cb74cc27 | |||
| 6038075e9e | |||
| 2dbdfd213c | |||
| e2eb886eef | |||
| f4f19eb847 | |||
| b3e307dcba | |||
| 1b3b4870e7 | |||
| 69c2166414 | |||
| 2fcf71d8ce | |||
| 676829a988 | |||
| 08201f1bc1 | |||
| 23cbd520a6 | |||
| 6f4e2b9319 | |||
| e5104fc005 | |||
| 91f3f69bd9 | |||
| 4bd62f539c | |||
| a3bd9ff213 | |||
| 24fc4bf9f4 | |||
| 0bfc2eff34 | |||
| 8f18a043af | |||
| 5b0a992686 | |||
| ef233296e9 | |||
| c0af2b734b | |||
| a94386babe | |||
| f23166b765 | |||
| 8438785241 | |||
| b0b6093619 | |||
| c463a5e28f | |||
| a3e06a85d6 | |||
| 6795c31d43 | |||
| e504cdf90c | |||
| c9807a7bf0 | |||
| fdbbbb5300 | |||
| fda6e6abff | |||
| ee01b56fb6 | |||
| db1edfaf24 | |||
| e07c66563c | |||
| 2ff40a0da7 | |||
| 24b4d07228 | |||
| 8b6f3012f4 | |||
| 792fc87166 | |||
| 67b2c2edde | |||
| db9b8a73d3 | |||
| 093be6f2d2 | |||
| 3d5693ceaa | |||
| 247c96df5d | |||
| 13cdc7ee67 | |||
| 24eecc7c97 | |||
| aea16e59c4 | |||
| 48bfa10bf7 | |||
| 80560ef9aa | |||
| ab46bff258 | |||
| fe081bf145 | |||
| 85abd64ea0 | |||
| b7581d3bad | |||
| 93b2d4f401 | |||
| ca0ef18df3 | |||
| cd6625b08e | |||
| 4810652d99 | |||
| 741b283218 | |||
| 8800f7b847 | |||
| a947806618 | |||
| 439ead1047 | |||
| 20c86831f1 | |||
| 61b018270b | |||
| 911477d381 | |||
| 1ee475ee4a | |||
| cae28341fe | |||
| 17cfa429ea | |||
| 791d7d504f | |||
| 631cdae751 | |||
| 06321730e0 | |||
| 5ac8ceacfe | |||
| 4981cbfa2e | |||
| e5ddf02c34 | |||
| ebf11021e2 | |||
| 72ce055810 | |||
| 8461f43bca | |||
| ca599eab7a | |||
| fef90d5a78 | |||
| 1f151a804e | |||
| 31da165db1 | |||
| dd390001ee | |||
| 7993a9174a | |||
| fe40743fd1 | |||
| 01bad587b8 | |||
| 4888393a12 | |||
| 05f8cc1af8 | |||
| 5c98baa24b | |||
| 490b00301b | |||
| c3e813551b | |||
| 474805328b | |||
| fd18934e97 | |||
| 5e1d197e7a | |||
| ff59c494a7 | |||
| c4d9c253f4 | |||
| 99007cda76 | |||
| 373675be4f | |||
| 90ed6df217 | |||
| e1984a199d | |||
| 3fcca0c3e1 | |||
| 5ddbe9962f | |||
| f05405bc75 | |||
| 5467789c71 | |||
| 7f9a366171 | |||
| bf3b4e5e81 | |||
| 083cdbb857 | |||
| 32e9c3af4d | |||
| a0d7950829 | |||
| 8cd5ae5283 | |||
| 574be34e36 | |||
| 0dfe1950c9 | |||
| 9def77c3f3 | |||
| fb027e0c45 | |||
| f744021925 | |||
| daaec3cc60 | |||
| 683ffe7767 | |||
| 730d1cf338 | |||
| 27408f02dd | |||
| 624ee50d14 | |||
| 48d29a6585 | |||
| 04eff2e382 | |||
| 8f63ede1f0 |
@@ -1 +1 @@
|
||||
2024.2.5
|
||||
2026.7.2
|
||||
@@ -12,3 +12,5 @@ home-assistant.log*
|
||||
.storage/
|
||||
zigbee.db*
|
||||
*__pycache__*
|
||||
.ha_run.lock
|
||||
.cache
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "themes/blackened"]
|
||||
path = themes/blackened
|
||||
url = https://github.com/home-assistant-community-themes/blackened.git
|
||||
@@ -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,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,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') }}"
|
||||
@@ -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) }}"
|
||||
@@ -11,15 +11,13 @@ http:
|
||||
tts:
|
||||
- platform: picotts_remote
|
||||
language: "de-DE"
|
||||
- platform: google_translate
|
||||
language: "de"
|
||||
|
||||
# Include modules
|
||||
group: !include groups.yaml
|
||||
automation: !include automations.yaml
|
||||
automation webhooks: !include automations_webhooks.yaml
|
||||
script: !include scripts.yaml
|
||||
scene: !include scenes.yaml
|
||||
notify: !include notify.yaml
|
||||
|
||||
# Enable additional integrations
|
||||
# Enable 'wake_on_lan' intrgration
|
||||
@@ -31,48 +29,46 @@ homeassistant:
|
||||
|
||||
# Additional sensors
|
||||
sensor: !include sensors.yaml
|
||||
command_line: !include commandline.yaml
|
||||
utility_meter: !include utility_meters.yaml
|
||||
|
||||
# MQTT sensors
|
||||
mqtt: !include mqtt.yaml
|
||||
mqtt: !include mqtt.yaml
|
||||
mqtt_statestream: !include mqtt_statestream.yaml
|
||||
|
||||
# Calendar data is normalised in a sandboxed script before ESPHome receives it.
|
||||
python_script:
|
||||
|
||||
# Home Assistant-managed e-paper quote library
|
||||
input_select: !include input_select.yaml
|
||||
|
||||
# Template sensors
|
||||
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: !include calendars.yaml
|
||||
ics_calendar: !include ics_calendars.yaml
|
||||
|
||||
# DB-recorder configuration
|
||||
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
|
||||
bluetooth:
|
||||
|
||||
# Bluetooth Low Energy tracker
|
||||
device_tracker:
|
||||
- platform: bluetooth_le_tracker
|
||||
track_new_devices: true
|
||||
|
||||
# Configure MPD addon as media player
|
||||
media_player:
|
||||
platform: mpd
|
||||
host: 192.168.122.48
|
||||
scan_interval: 1
|
||||
track_new_devices: false
|
||||
|
||||
generic_hygrostat:
|
||||
- name: Badezimmer
|
||||
unique_id: '3728344225387'
|
||||
humidifier: fan.badezimmer_ventilator
|
||||
target_sensor: sensor.bathroom_badezimmer_luftfeuchtigkeit
|
||||
min_humidity: 30
|
||||
@@ -90,3 +86,10 @@ generic_hygrostat:
|
||||
away_fixed: true
|
||||
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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -14,24 +15,35 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
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 DOMAIN, UPGRADE_URL
|
||||
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]
|
||||
|
||||
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"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
@@ -81,6 +93,13 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
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,
|
||||
}
|
||||
)
|
||||
]
|
||||
@@ -92,22 +111,150 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION_MAJOR = 1
|
||||
STORAGE_VERSION_MINOR = 0
|
||||
|
||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
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]:
|
||||
hass.helpers.discovery.load_platform(
|
||||
PLATFORMS[0], DOMAIN, config[DOMAIN], config
|
||||
_LOGGER.debug("discovery.load_platform called")
|
||||
discovery.load_platform(
|
||||
hass=hass,
|
||||
component=PLATFORMS[0],
|
||||
platform=DOMAIN,
|
||||
discovered=config[DOMAIN],
|
||||
hass_config=config,
|
||||
)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"No configuration found! If you upgraded from ics_calendar v3.2.0 "
|
||||
"or older, you need to update your configuration! See "
|
||||
"%s for more information.",
|
||||
UPGRADE_URL,
|
||||
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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Support for ICS Calendar."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
# import homeassistant.helpers.config_validation as cv
|
||||
# import voluptuous as vol
|
||||
@@ -12,6 +13,7 @@ from homeassistant.components.calendar import (
|
||||
extract_offset,
|
||||
is_offset_reached,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_EXCLUDE,
|
||||
CONF_INCLUDE,
|
||||
@@ -24,23 +26,28 @@ from homeassistant.const import (
|
||||
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 import Throttle
|
||||
from homeassistant.util.dt import now as hanow
|
||||
|
||||
from . import (
|
||||
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 .calendardata import CalendarData
|
||||
from .filter import Filter
|
||||
from .icalendarparser import ICalendarParser
|
||||
from .getparser import GetParser
|
||||
from .parserevent import ParserEvent
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,6 +58,34 @@ 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,
|
||||
@@ -70,8 +105,13 @@ def setup_platform(
|
||||
"""
|
||||
_LOGGER.debug("Setting up ics calendars")
|
||||
if discovery_info is not None:
|
||||
calendars: list = discovery_info.get(CONF_CALENDARS)
|
||||
_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 = []
|
||||
@@ -91,10 +131,13 @@ def setup_platform(
|
||||
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(entity_id, device_data))
|
||||
calendar_devices.append(
|
||||
ICSCalendarEntity(hass, entity_id, device_data)
|
||||
)
|
||||
|
||||
add_entities(calendar_devices)
|
||||
|
||||
@@ -102,7 +145,13 @@ def setup_platform(
|
||||
class ICSCalendarEntity(CalendarEntity):
|
||||
"""A CalendarEntity for an ICS Calendar."""
|
||||
|
||||
def __init__(self, entity_id: str, device_data):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
device_data,
|
||||
unique_id: str = None,
|
||||
):
|
||||
"""Construct ICSCalendarEntity.
|
||||
|
||||
:param entity_id: Entity id for the calendar
|
||||
@@ -111,14 +160,16 @@ class ICSCalendarEntity(CalendarEntity):
|
||||
:type device_data: dict
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Initializing calendar: %s with URL: %s",
|
||||
"Initializing calendar: %s with URL: %s, uniqueid: %s",
|
||||
device_data[CONF_NAME],
|
||||
device_data[CONF_URL],
|
||||
unique_id,
|
||||
)
|
||||
self.data = ICSCalendarData(device_data)
|
||||
self.data = ICSCalendarData(hass, device_data)
|
||||
self.entity_id = entity_id
|
||||
self._attr_unique_id = f"ICSCalendar.{unique_id}"
|
||||
self._event = None
|
||||
self._name = device_data[CONF_NAME]
|
||||
self._attr_name = device_data[CONF_NAME]
|
||||
self._last_call = None
|
||||
|
||||
@property
|
||||
@@ -131,12 +182,7 @@ class ICSCalendarEntity(CalendarEntity):
|
||||
return self._event
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the calendar."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
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
|
||||
@@ -168,25 +214,50 @@ class ICSCalendarEntity(CalendarEntity):
|
||||
_LOGGER.debug(
|
||||
"%s: async_get_events called; calling internal.", self.name
|
||||
)
|
||||
return await self.data.async_get_events(hass, start_date, end_date)
|
||||
return await self.data.async_get_events(start_date, end_date)
|
||||
|
||||
def update(self):
|
||||
async def async_update(self):
|
||||
"""Get the current or next event."""
|
||||
self.data.update()
|
||||
self._event = self.data.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
|
||||
"offset_reached": (
|
||||
is_offset_reached(
|
||||
self._event.start_datetime_local, self.data.offset
|
||||
)
|
||||
if self._event
|
||||
else False
|
||||
)
|
||||
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, device_data):
|
||||
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
|
||||
@@ -196,45 +267,52 @@ class ICSCalendarData: # pylint: disable=R0902
|
||||
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.parser = ICalendarParser.get_instance(device_data[CONF_PARSER])
|
||||
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,
|
||||
self.name,
|
||||
device_data[CONF_URL],
|
||||
timedelta(minutes=device_data[CONF_DOWNLOAD_INTERVAL]),
|
||||
)
|
||||
|
||||
self._calendar_data.set_headers(
|
||||
{
|
||||
"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, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
self, 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
|
||||
"""
|
||||
event_list = []
|
||||
if await hass.async_add_executor_job(
|
||||
self._calendar_data.download_calendar
|
||||
):
|
||||
event_list: list[ParserEvent] = []
|
||||
if await self._calendar_data.download_calendar():
|
||||
_LOGGER.debug("%s: Setting calendar content", self.name)
|
||||
self.parser.set_content(self._calendar_data.get())
|
||||
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,
|
||||
@@ -248,22 +326,29 @@ class ICSCalendarData: # pylint: disable=R0902
|
||||
self.name,
|
||||
exc_info=True,
|
||||
)
|
||||
event_list = []
|
||||
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
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
async def async_update(self):
|
||||
"""Get the current or next event."""
|
||||
_LOGGER.debug("%s: Update was called", self.name)
|
||||
if self._calendar_data.download_calendar():
|
||||
parser_event: ParserEvent | None = None
|
||||
if await self._calendar_data.download_calendar():
|
||||
_LOGGER.debug("%s: Setting calendar content", self.name)
|
||||
self.parser.set_content(self._calendar_data.get())
|
||||
await self._hass.async_add_executor_job(
|
||||
lambda: self.parser.set_content(self._calendar_data.get())
|
||||
)
|
||||
try:
|
||||
self.event = self.parser.get_current_event(
|
||||
parser_event: ParserEvent | None = self.parser.get_current_event(
|
||||
include_all_day=self.include_all_day,
|
||||
now=hanow(),
|
||||
days=self._days,
|
||||
@@ -273,18 +358,24 @@ class ICSCalendarData: # pylint: disable=R0902
|
||||
_LOGGER.error(
|
||||
"update: %s: Failed to parse ICS!", self.name, exc_info=True
|
||||
)
|
||||
if self.event is not None:
|
||||
if parser_event is not None:
|
||||
_LOGGER.debug(
|
||||
"%s: got event: %s; start: %s; end: %s; all_day: %s",
|
||||
self.name,
|
||||
self.event.summary,
|
||||
self.event.start,
|
||||
self.event.end,
|
||||
self.event.all_day,
|
||||
parser_event.summary,
|
||||
parser_event.start,
|
||||
parser_event.end,
|
||||
parser_event.all_day,
|
||||
)
|
||||
(summary, offset) = extract_offset(self.event.summary, OFFSET)
|
||||
self.event.summary = self._summary_prefix + summary
|
||||
(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)
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
"""Provide CalendarData class."""
|
||||
import zlib
|
||||
from datetime import timedelta
|
||||
from gzip import BadGzipFile, GzipFile
|
||||
from logging import Logger
|
||||
from threading import Lock
|
||||
from urllib.error import ContentTooShortError, HTTPError, URLError
|
||||
from urllib.request import (
|
||||
HTTPBasicAuthHandler,
|
||||
HTTPDigestAuthHandler,
|
||||
HTTPPasswordMgrWithDefaultRealm,
|
||||
build_opener,
|
||||
install_opener,
|
||||
urlopen,
|
||||
)
|
||||
|
||||
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 CalendarData:
|
||||
|
||||
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
|
||||
@@ -25,32 +27,33 @@ class CalendarData:
|
||||
instance.
|
||||
"""
|
||||
|
||||
opener_lock = Lock()
|
||||
|
||||
def __init__(
|
||||
self, logger: Logger, name: str, url: str, min_update_time: timedelta
|
||||
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 name: The name of the calendar (used for reporting problems)
|
||||
:type name: str
|
||||
:param url: The URL of the calendar
|
||||
:type url: str
|
||||
:param min_update_time: The minimum time between downloading data from
|
||||
the URL when requested
|
||||
:type min_update_time: timedelta
|
||||
:param conf: Configuration options
|
||||
:type conf: dict
|
||||
"""
|
||||
self._auth = None
|
||||
self._calendar_data = None
|
||||
self._headers = []
|
||||
self._last_download = None
|
||||
self._min_update_time = min_update_time
|
||||
self._opener = None
|
||||
self._min_update_time = conf["min_update_time"]
|
||||
self.logger = logger
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.name = conf["name"]
|
||||
self.url = conf["url"]
|
||||
self.connection_timeout = None
|
||||
self._httpx = async_client
|
||||
|
||||
def download_calendar(self) -> bool:
|
||||
async def download_calendar(self) -> bool:
|
||||
"""Download the calendar data.
|
||||
|
||||
This only downloads data if self.min_update_time has passed since the
|
||||
@@ -59,20 +62,25 @@ class CalendarData:
|
||||
returns: True if data was downloaded, otherwise False.
|
||||
rtype: bool
|
||||
"""
|
||||
now = hanow()
|
||||
self.logger.debug("%s: download_calendar start", self.name)
|
||||
if (
|
||||
self._calendar_data is None
|
||||
or self._last_download is None
|
||||
or (now - self._last_download) > self._min_update_time
|
||||
or (hanow() - self._last_download) > self._min_update_time
|
||||
):
|
||||
self._last_download = now
|
||||
self._calendar_data = None
|
||||
next_url: str = self._make_url()
|
||||
self.logger.debug(
|
||||
"%s: Downloading calendar data from: %s", self.name, self.url
|
||||
"%s: Downloading calendar data from: %s",
|
||||
self.name,
|
||||
next_url,
|
||||
)
|
||||
self._download_data()
|
||||
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:
|
||||
@@ -83,7 +91,7 @@ class CalendarData:
|
||||
"""
|
||||
return self._calendar_data
|
||||
|
||||
def set_headers(
|
||||
def headers(
|
||||
self,
|
||||
user_name: str,
|
||||
password: str,
|
||||
@@ -92,10 +100,8 @@ class CalendarData:
|
||||
):
|
||||
"""Set a user agent, accept header, and/or user name and password.
|
||||
|
||||
The user name and password will be set into an HTTPBasicAuthHandler an
|
||||
an HTTPDigestAuthHandler. Both are attached to a new urlopener, so
|
||||
that HTTP Basic Auth and HTTP Digest Auth will be supported when
|
||||
opening the URL.
|
||||
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.
|
||||
@@ -110,81 +116,66 @@ class CalendarData:
|
||||
:type accept_header: str
|
||||
"""
|
||||
if user_name != "" and password != "":
|
||||
passman = HTTPPasswordMgrWithDefaultRealm()
|
||||
passman.add_password(None, self.url, user_name, password)
|
||||
basic_auth_handler = HTTPBasicAuthHandler(passman)
|
||||
digest_auth_handler = HTTPDigestAuthHandler(passman)
|
||||
self._opener = build_opener(
|
||||
digest_auth_handler, basic_auth_handler
|
||||
)
|
||||
self._auth = httpx_auth.Basic(
|
||||
user_name, password
|
||||
) + DigestWithMultiAuth(user_name, password)
|
||||
|
||||
additional_headers = []
|
||||
if user_agent != "":
|
||||
additional_headers.append(("User-agent", user_agent))
|
||||
self._headers.append(("User-agent", user_agent))
|
||||
if accept_header != "":
|
||||
additional_headers.append(("Accept", accept_header))
|
||||
if len(additional_headers) > 0:
|
||||
if self._opener is None:
|
||||
self._opener = build_opener()
|
||||
self._opener.addheaders = additional_headers
|
||||
self._headers.append(("Accept", accept_header))
|
||||
return self
|
||||
|
||||
def _decode_data(self, conn):
|
||||
if (
|
||||
"Content-Encoding" in conn.headers
|
||||
and conn.headers["Content-Encoding"] == "gzip"
|
||||
):
|
||||
reader = GzipFile(fileobj=conn)
|
||||
else:
|
||||
reader = conn
|
||||
try:
|
||||
return self._decode_stream(reader.read()).replace("\0", "")
|
||||
except zlib.error:
|
||||
self.logger.error(
|
||||
"%s: Failed to uncompress gzip data from url(%s): zlib",
|
||||
self.name,
|
||||
self.url,
|
||||
)
|
||||
except BadGzipFile as gzip_error:
|
||||
self.logger.error(
|
||||
"%s: Failed to uncompress gzip data from url(%s): %s",
|
||||
self.name,
|
||||
self.url,
|
||||
gzip_error.strerror,
|
||||
)
|
||||
return None
|
||||
def timeout(self, connection_timeout: float | None):
|
||||
"""Set the connection timeout.
|
||||
|
||||
def _decode_stream(self, strm):
|
||||
for encoding in "utf-8-sig", "utf-8", "utf-16":
|
||||
try:
|
||||
return strm.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return None
|
||||
:param connection_timeout: The timeout value in seconds.
|
||||
:type connection_timeout: float
|
||||
"""
|
||||
if connection_timeout:
|
||||
self.connection_timeout = connection_timeout
|
||||
return self
|
||||
|
||||
def _download_data(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:
|
||||
with CalendarData.opener_lock:
|
||||
if self._opener is not None:
|
||||
install_opener(self._opener)
|
||||
with urlopen(self._make_url()) as conn:
|
||||
self._calendar_data = self._decode_data(conn)
|
||||
except HTTPError as http_error:
|
||||
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_error.reason,
|
||||
http_status_error.response.status_code,
|
||||
)
|
||||
except ContentTooShortError as content_too_short_error:
|
||||
except httpx.TimeoutException:
|
||||
self.logger.error(
|
||||
"%s: Could not download calendar data: %s",
|
||||
self.name,
|
||||
content_too_short_error.reason,
|
||||
"%s: Timeout opening url: %s", self.name, self.url
|
||||
)
|
||||
except URLError as url_error:
|
||||
except httpx.DecodingError:
|
||||
self.logger.error(
|
||||
"%s: Failed to open url: %s", self.name, url_error.reason
|
||||
"%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(
|
||||
@@ -192,7 +183,44 @@ class CalendarData:
|
||||
)
|
||||
|
||||
def _make_url(self):
|
||||
"""Replace templates in url and encode."""
|
||||
now = hanow()
|
||||
return self.url.replace("{year}", f"{now.year:04}").replace(
|
||||
"{month}", f"{now.month:02}"
|
||||
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,
|
||||
)
|
||||
@@ -1,7 +1,24 @@
|
||||
"""Constants for ics_calendar platform."""
|
||||
VERSION = "4.1.0"
|
||||
|
||||
VERSION = "5.1.7"
|
||||
DOMAIN = "ics_calendar"
|
||||
UPGRADE_URL = (
|
||||
"https://github.com/franc6/ics_calendar/blob/releases/"
|
||||
"UpgradeTo4.0AndLater.md"
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Provide Filter class."""
|
||||
|
||||
import re
|
||||
from ast import literal_eval
|
||||
from typing import List, Optional, Pattern
|
||||
|
||||
from homeassistant.components.calendar import CalendarEvent
|
||||
from .parserevent import ParserEvent
|
||||
|
||||
|
||||
class Filter:
|
||||
@@ -113,11 +114,11 @@ class Filter:
|
||||
add_event = self._is_included(summary, description)
|
||||
return add_event
|
||||
|
||||
def filter_event(self, event: CalendarEvent) -> bool:
|
||||
def filter_event(self, event: ParserEvent) -> bool:
|
||||
"""Check if the event should be included or not.
|
||||
|
||||
:param event: The event to examine
|
||||
:type event: CalendarEvent
|
||||
:type event: ParserEvent
|
||||
:return: true if the event should be included, otherwise false
|
||||
:rtype: bool
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -1,39 +1,14 @@
|
||||
"""Provide ICalendarParser class."""
|
||||
import importlib
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from homeassistant.components.calendar import CalendarEvent
|
||||
|
||||
from .filter import Filter
|
||||
from .parserevent import ParserEvent
|
||||
|
||||
|
||||
class ICalendarParser:
|
||||
"""Provide interface for various parser classes.
|
||||
|
||||
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_class(parser: str):
|
||||
"""Get the class of the requested parser."""
|
||||
parser_module_name = ".parsers.parser_" + parser
|
||||
parser = "Parser" + parser.upper()
|
||||
try:
|
||||
module = importlib.import_module(parser_module_name, __package__)
|
||||
return getattr(module, parser)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_instance(parser: str, *args):
|
||||
"""Get an instance of the requested parser."""
|
||||
parser_cls = ICalendarParser.get_class(parser)
|
||||
if parser_cls is not None:
|
||||
return parser_cls(*args)
|
||||
return None
|
||||
"""Provide interface for various parser classes."""
|
||||
|
||||
def set_content(self, content: str):
|
||||
"""Parse content into a calendar object.
|
||||
@@ -57,7 +32,7 @@ class ICalendarParser:
|
||||
end: datetime,
|
||||
include_all_day: bool,
|
||||
offset_hours: int = 0,
|
||||
) -> list[CalendarEvent]:
|
||||
) -> list[ParserEvent]:
|
||||
"""Get a list of events.
|
||||
|
||||
Gets the events from start to end, including or excluding all day
|
||||
@@ -71,7 +46,7 @@ class ICalendarParser:
|
||||
: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[CalendarEvent]
|
||||
:rtype list[ParserEvent]
|
||||
"""
|
||||
|
||||
def get_current_event(
|
||||
@@ -80,7 +55,7 @@ class ICalendarParser:
|
||||
now: datetime,
|
||||
days: int,
|
||||
offset_hours: int = 0,
|
||||
) -> Optional[CalendarEvent]:
|
||||
) -> Optional[ParserEvent]:
|
||||
"""Get the current or next event.
|
||||
|
||||
Gets the current event, or the next upcoming event with in the
|
||||
@@ -93,5 +68,5 @@ class ICalendarParser:
|
||||
:type days int
|
||||
:param offset_hours the number of hours to offset the event
|
||||
:type offset_hours int
|
||||
:returns a CalendarEvent or None
|
||||
:returns a ParserEvent or None
|
||||
"""
|
||||
|
||||
@@ -1,13 +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": ["ics>=0.7.2", "recurring_ical_events>=2.0.2", "icalendar>=5.0.4"],
|
||||
"version": "4.1.0"
|
||||
"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.
|
||||
@@ -1,14 +1,15 @@
|
||||
"""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 homeassistant.components.calendar import CalendarEvent
|
||||
from ics import Calendar
|
||||
|
||||
from ..filter import Filter
|
||||
from ..icalendarparser import ICalendarParser
|
||||
from ..parserevent import ParserEvent
|
||||
from ..utility import compare_event_dates
|
||||
|
||||
|
||||
@@ -41,7 +42,7 @@ class ParserICS(ICalendarParser):
|
||||
|
||||
def get_event_list(
|
||||
self, start, end, include_all_day: bool, offset_hours: int = 0
|
||||
) -> list[CalendarEvent]:
|
||||
) -> list[ParserEvent]:
|
||||
"""Get a list of events.
|
||||
|
||||
Gets the events from start to end, including or excluding all day
|
||||
@@ -55,9 +56,9 @@ class ParserICS(ICalendarParser):
|
||||
: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[CalendarEvent]
|
||||
:rtype list[ParserEvent]
|
||||
"""
|
||||
event_list: list[CalendarEvent] = []
|
||||
event_list: list[ParserEvent] = []
|
||||
|
||||
if self._calendar is not None:
|
||||
# ics 0.8 takes datetime not Arrow objects
|
||||
@@ -75,7 +76,12 @@ class ParserICS(ICalendarParser):
|
||||
# summary = event.summary
|
||||
# elif hasattr(event, "name"):
|
||||
summary = event.name
|
||||
calendar_event: CalendarEvent = CalendarEvent(
|
||||
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
|
||||
@@ -85,6 +91,14 @@ class ParserICS(ICalendarParser):
|
||||
),
|
||||
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)
|
||||
@@ -97,7 +111,7 @@ class ParserICS(ICalendarParser):
|
||||
now: datetime,
|
||||
days: int,
|
||||
offset_hours: int = 0,
|
||||
) -> Optional[CalendarEvent]:
|
||||
) -> Optional[ParserEvent]:
|
||||
"""Get the current or next event.
|
||||
|
||||
Gets the current event, or the next upcoming event with in the
|
||||
@@ -110,7 +124,7 @@ class ParserICS(ICalendarParser):
|
||||
:type int
|
||||
:param offset_hours the number of hours to offset the event
|
||||
:type int
|
||||
:returns a CalendarEvent or None
|
||||
:returns a ParserEvent or None
|
||||
"""
|
||||
if self._calendar is None:
|
||||
return None
|
||||
@@ -144,7 +158,12 @@ class ParserICS(ICalendarParser):
|
||||
# summary = temp_event.summary
|
||||
# elif hasattr(event, "name"):
|
||||
summary = temp_event.name
|
||||
return CalendarEvent(
|
||||
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
|
||||
@@ -154,6 +173,14 @@ class ParserICS(ICalendarParser):
|
||||
),
|
||||
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
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Support for recurring_ical_events parser."""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, Union
|
||||
|
||||
import recurring_ical_events as rie
|
||||
from homeassistant.components.calendar import CalendarEvent
|
||||
from icalendar import Calendar
|
||||
|
||||
from ..filter import Filter
|
||||
from ..icalendarparser import ICalendarParser
|
||||
from ..parserevent import ParserEvent
|
||||
from ..utility import compare_event_dates
|
||||
|
||||
|
||||
@@ -45,7 +46,7 @@ class ParserRIE(ICalendarParser):
|
||||
end: datetime,
|
||||
include_all_day: bool,
|
||||
offset_hours: int = 0,
|
||||
) -> list[CalendarEvent]:
|
||||
) -> list[ParserEvent]:
|
||||
"""Get a list of events.
|
||||
|
||||
Gets the events from start to end, including or excluding all day
|
||||
@@ -59,12 +60,12 @@ class ParserRIE(ICalendarParser):
|
||||
: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[CalendarEvent]
|
||||
:rtype list[ParserEvent]
|
||||
"""
|
||||
event_list: list[CalendarEvent] = []
|
||||
event_list: list[ParserEvent] = []
|
||||
|
||||
if self._calendar is not None:
|
||||
for event in rie.of(self._calendar).between(
|
||||
for event in rie.of(self._calendar, skip_bad_series=True).between(
|
||||
start - timedelta(hours=offset_hours),
|
||||
end - timedelta(hours=offset_hours),
|
||||
):
|
||||
@@ -73,12 +74,17 @@ class ParserRIE(ICalendarParser):
|
||||
if all_day and not include_all_day:
|
||||
continue
|
||||
|
||||
calendar_event: CalendarEvent = CalendarEvent(
|
||||
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)
|
||||
@@ -91,7 +97,7 @@ class ParserRIE(ICalendarParser):
|
||||
now: datetime,
|
||||
days: int,
|
||||
offset_hours: int = 0,
|
||||
) -> Optional[CalendarEvent]:
|
||||
) -> Optional[ParserEvent]:
|
||||
"""Get the current or next event.
|
||||
|
||||
Gets the current event, or the next upcoming event with in the
|
||||
@@ -104,17 +110,17 @@ class ParserRIE(ICalendarParser):
|
||||
:type int
|
||||
:param offset_hours the number of hours to offset the event
|
||||
:type offset_hours int
|
||||
:returns a CalendarEvent or None
|
||||
:returns a ParserEvent or None
|
||||
"""
|
||||
if self._calendar is None:
|
||||
return None
|
||||
|
||||
temp_event: CalendarEvent = 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).between(
|
||||
for event in rie.of(self._calendar, skip_bad_series=True).between(
|
||||
now - timedelta(hours=offset_hours),
|
||||
end - timedelta(hours=offset_hours),
|
||||
):
|
||||
@@ -139,12 +145,17 @@ class ParserRIE(ICalendarParser):
|
||||
if temp_event is None:
|
||||
return None
|
||||
|
||||
return CalendarEvent(
|
||||
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
|
||||
|
||||
@@ -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": {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Utility methods."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
@@ -9,7 +10,7 @@ def make_datetime(val):
|
||||
return val
|
||||
|
||||
|
||||
def compare_event_dates( # pylint: disable=R0913
|
||||
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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
ota:
|
||||
platform: esphome
|
||||
password: !secret ota
|
||||
|
||||
# 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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
board: nodemcu-32s
|
||||
framework:
|
||||
type: arduino
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "Voltage-legacy"
|
||||
@@ -59,6 +59,7 @@ wifi:
|
||||
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:
|
||||
@@ -78,6 +79,7 @@ api:
|
||||
|
||||
# Enable over-the-air updates
|
||||
ota:
|
||||
platform: esphome
|
||||
password: !secret ota
|
||||
|
||||
# Initialize I²C
|
||||
@@ -112,6 +114,7 @@ light:
|
||||
|
||||
# Initialize UART for Vindriktning sensor
|
||||
uart:
|
||||
id: uartPM
|
||||
rx_pin: GPIO26
|
||||
baud_rate: 9600
|
||||
|
||||
@@ -207,9 +210,10 @@ sensor:
|
||||
ambient_pressure_compensation: 1
|
||||
automatic_self_calibration: True
|
||||
address: 0x61
|
||||
update_interval: 60s
|
||||
update_interval: 10s
|
||||
# Vindriktning particulate matter sensor
|
||||
- platform: pm1006
|
||||
uart_id: uartPM
|
||||
pm_2_5:
|
||||
name: "Feinstaub PM 2.5µm"
|
||||
on_value_range:
|
||||
@@ -234,7 +238,7 @@ sensor:
|
||||
- platform: adc
|
||||
pin: 32
|
||||
name: "Spannung Photodiode"
|
||||
attenuation: 11db
|
||||
attenuation: 12db
|
||||
update_interval: 1s
|
||||
internal: true
|
||||
on_value_range:
|
||||
@@ -258,6 +262,7 @@ remote_receiver:
|
||||
|
||||
# IR Transmitter
|
||||
remote_transmitter:
|
||||
non_blocking: True
|
||||
pin: GPIO22
|
||||
# Infrared remotes use a 50% carrier signal
|
||||
carrier_duty_percent: 50%
|
||||
@@ -16,12 +16,16 @@ esp32:
|
||||
framework:
|
||||
type: arduino
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
wifi:
|
||||
ssid: "Voltage-legacy"
|
||||
password: !secret voltage_legacy_psk
|
||||
use_address: bathroom.home
|
||||
#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:
|
||||
@@ -39,18 +43,37 @@ api:
|
||||
key: !secret apikey
|
||||
|
||||
ota:
|
||||
platform: esphome
|
||||
password: !secret ota
|
||||
|
||||
# DHT22 sensor
|
||||
# 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"
|
||||
|
||||
@@ -14,14 +14,18 @@ esphome:
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
framework:
|
||||
type: arduino
|
||||
type: esp-idf
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
wifi:
|
||||
ssid: "Voltage-legacy"
|
||||
password: !secret voltage_legacy_psk
|
||||
use_address: ${name}.home
|
||||
#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:
|
||||
@@ -39,6 +43,7 @@ api:
|
||||
key: !secret apikey
|
||||
|
||||
ota:
|
||||
platform: esphome
|
||||
password: !secret ota
|
||||
|
||||
spi:
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -14,12 +14,15 @@ esphome:
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: arduino
|
||||
type: esp-idf
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
wifi:
|
||||
ssid: "Voltage-legacy"
|
||||
password: !secret voltage_legacy_psk
|
||||
use_address: ${name}.home
|
||||
#use_address: cam-livingroom.home
|
||||
power_save_mode: high
|
||||
fast_connect: on
|
||||
|
||||
@@ -39,29 +42,53 @@ api:
|
||||
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_pins:
|
||||
sda: GPIO26
|
||||
scl: GPIO27
|
||||
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: SVGA
|
||||
resolution: XGA
|
||||
max_framerate: 24 fps
|
||||
idle_framerate: 0.2 fps
|
||||
jpeg_quality: 30
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
@@ -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});"
|
||||
@@ -52,14 +52,20 @@ esphome:
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
framework:
|
||||
type: arduino
|
||||
type: esp-idf
|
||||
advanced:
|
||||
minimum_chip_revision: "3.0"
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
wifi:
|
||||
ssid: "Voltage-legacy"
|
||||
password: !secret voltage_legacy_psk
|
||||
use_address: ${name}.home
|
||||
# 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:
|
||||
@@ -79,6 +85,7 @@ api:
|
||||
|
||||
# Enable over-the-air updates
|
||||
ota:
|
||||
platform: esphome
|
||||
password: !secret ota
|
||||
|
||||
# Initialize I²C
|
||||
@@ -113,6 +120,7 @@ light:
|
||||
|
||||
# Initialize UART for Vindriktning sensor
|
||||
uart:
|
||||
id: uartPM
|
||||
rx_pin: GPIO22
|
||||
baud_rate: 9600
|
||||
|
||||
@@ -208,7 +216,7 @@ sensor:
|
||||
ambient_pressure_compensation: 1
|
||||
automatic_self_calibration: True
|
||||
address: 0x61
|
||||
update_interval: 60s
|
||||
update_interval: 10s
|
||||
# Vindriktning particulate matter sensor
|
||||
- platform: pm1006
|
||||
pm_2_5:
|
||||
@@ -235,7 +243,7 @@ sensor:
|
||||
- platform: adc
|
||||
pin: 34
|
||||
name: "Spannung Photodiode"
|
||||
attenuation: 11db
|
||||
attenuation: 12db
|
||||
update_interval: 1s
|
||||
internal: true
|
||||
on_value_range:
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
# Source: https://github.com/esphome/wake-word-voice-assistants/blob/main/m5stack-atom-echo/m5stack-atom-echo.yaml
|
||||
|
||||
substitutions:
|
||||
name: "m5stack-atom-echo"
|
||||
friendly_name: "M5Stack Atom Echo"
|
||||
name: m5stack-atom-echo
|
||||
friendly_name: M5Stack Atom Echo
|
||||
micro_wake_word_model: okay_nabu # alexa, hey_jarvis, hey_mycroft are also supported
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
friendly_name: ${friendly_name}
|
||||
name_add_mac_suffix: False
|
||||
project:
|
||||
name: m5stack.atom-echo
|
||||
version: "1.0"
|
||||
min_version: 2023.5.0
|
||||
friendly_name: ${friendly_name}
|
||||
min_version: 2025.2.0
|
||||
|
||||
esp32:
|
||||
board: m5stack-atom
|
||||
framework:
|
||||
type: arduino
|
||||
type: esp-idf
|
||||
|
||||
# Enable Home Assistant API
|
||||
api:
|
||||
encryption:
|
||||
key: !secret apikey
|
||||
|
||||
ota:
|
||||
password: !secret ota
|
||||
- platform: esphome
|
||||
id: ota_esphome
|
||||
password: !secret ota
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
wifi:
|
||||
on_connect:
|
||||
- delay: 5s # Gives time for improv results to be transmitted
|
||||
ssid: Voltage-legacy
|
||||
password: !secret voltage_legacy_psk
|
||||
use_address: ${name}.home
|
||||
#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"
|
||||
@@ -36,16 +45,17 @@ wifi:
|
||||
|
||||
logger:
|
||||
|
||||
dashboard_import:
|
||||
package_import_url: github://esphome/media-players/m5stack-atom-echo.yaml@main
|
||||
|
||||
captive_portal:
|
||||
|
||||
improv_serial:
|
||||
button:
|
||||
- platform: factory_reset
|
||||
id: factory_reset_btn
|
||||
name: Factory reset
|
||||
|
||||
i2s_audio:
|
||||
i2s_lrclk_pin: GPIO33
|
||||
i2s_bclk_pin: GPIO19
|
||||
- id: i2s_audio_bus
|
||||
i2s_lrclk_pin: GPIO33
|
||||
i2s_bclk_pin: GPIO19
|
||||
|
||||
microphone:
|
||||
- platform: i2s_audio
|
||||
@@ -54,88 +64,301 @@ microphone:
|
||||
adc_type: external
|
||||
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:
|
||||
id: va
|
||||
microphone: echo_microphone
|
||||
on_start:
|
||||
media_player: echo_media_player
|
||||
noise_suppression_level: 2
|
||||
auto_gain: 31dBFS
|
||||
volume_multiplier: 2.0
|
||||
on_listening:
|
||||
- light.turn_on:
|
||||
id: led
|
||||
blue: 100%
|
||||
red: 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:
|
||||
- light.turn_on:
|
||||
id: led
|
||||
blue: 0%
|
||||
blue: 100%
|
||||
red: 0%
|
||||
green: 100%
|
||||
green: 0%
|
||||
brightness: 100%
|
||||
effect: none
|
||||
on_tts_end:
|
||||
- media_player.play_media: !lambda return x;
|
||||
- light.turn_on:
|
||||
id: led
|
||||
blue: 0%
|
||||
red: 0%
|
||||
green: 100%
|
||||
effect: pulse
|
||||
on_end:
|
||||
- delay: 1s
|
||||
- wait_until:
|
||||
not:
|
||||
media_player.is_playing: media_out
|
||||
- light.turn_off: led
|
||||
- delay: 100ms
|
||||
- script.execute: start_wake_word
|
||||
on_error:
|
||||
- light.turn_on:
|
||||
id: led
|
||||
blue: 0%
|
||||
red: 100%
|
||||
green: 0%
|
||||
blue: 0%
|
||||
brightness: 100%
|
||||
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
|
||||
- switch.turn_off: timer_ringing
|
||||
|
||||
binary_sensor:
|
||||
# button does the following:
|
||||
# short click - stop a timer
|
||||
# if no timer then restart either microwakeword or voice assistant continuous
|
||||
- platform: gpio
|
||||
pin:
|
||||
number: GPIO39
|
||||
inverted: true
|
||||
name: Button
|
||||
disabled_by_default: true
|
||||
entity_category: diagnostic
|
||||
id: echo_button
|
||||
on_multi_click:
|
||||
- timing:
|
||||
- ON FOR AT MOST 350ms
|
||||
- OFF FOR AT LEAST 10ms
|
||||
- ON for at least 50ms
|
||||
- OFF for at least 50ms
|
||||
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:
|
||||
- ON FOR AT LEAST 350ms
|
||||
- ON for at least 10s
|
||||
then:
|
||||
- voice_assistant.start:
|
||||
- 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
|
||||
- button.press: factory_reset_btn
|
||||
|
||||
light:
|
||||
- platform: esp32_rmt_led_strip
|
||||
id: led
|
||||
name: None
|
||||
disabled_by_default: true
|
||||
entity_category: config
|
||||
pin: GPIO27
|
||||
default_transition_length: 0s
|
||||
chipset: SK6812
|
||||
num_leds: 1
|
||||
rgb_order: grb
|
||||
rmt_channel: 0
|
||||
effects:
|
||||
- pulse:
|
||||
name: "Slow Pulse"
|
||||
transition_length: 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}
|
||||
|
||||
@@ -1,201 +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
|
||||
|
||||
microphone:
|
||||
- platform: i2s_audio
|
||||
id: luxe_microphone
|
||||
i2s_din_pin: GPIO35
|
||||
adc_type: external
|
||||
pdm: false
|
||||
|
||||
voice_assistant:
|
||||
microphone: luxe_microphone
|
||||
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:
|
||||
- media_player.play_media: !lambda return x;
|
||||
- light.turn_on:
|
||||
id: top_led
|
||||
blue: 60%
|
||||
red: 20%
|
||||
green: 20%
|
||||
effect: pulse
|
||||
on_end:
|
||||
- delay: 1s
|
||||
- wait_until:
|
||||
not:
|
||||
media_player.is_playing: luxe_out
|
||||
- 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
|
||||
@@ -1,12 +1,12 @@
|
||||
substitutions:
|
||||
name: "riden-labornetzteil"
|
||||
friendly_name: "Riden Labornetzteil"
|
||||
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: "Labornetzteil"
|
||||
device_description: "Monitor and control a RD6018 PSU via WiFi"
|
||||
time_timezone: "Europe/Berlin"
|
||||
|
||||
# Model specific settings (Don't change these!)
|
||||
@@ -50,12 +50,16 @@ esphome:
|
||||
esp8266:
|
||||
board: esp12e
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
wifi:
|
||||
ssid: "Voltage-legacy"
|
||||
password: !secret voltage_legacy_psk
|
||||
use_address: riden-labornetzteil.home
|
||||
#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:
|
||||
@@ -76,6 +80,7 @@ api:
|
||||
key: !secret apikey
|
||||
|
||||
ota:
|
||||
platform: esphome
|
||||
password: !secret ota
|
||||
|
||||
# Enable status LED
|
||||
@@ -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"
|
||||
@@ -14,14 +14,18 @@ esphome:
|
||||
esp32:
|
||||
board: esp32-c3-devkitm-1
|
||||
framework:
|
||||
type: arduino
|
||||
type: esp-idf
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
wifi:
|
||||
ssid: "Voltage-legacy"
|
||||
password: !secret voltage_legacy_psk
|
||||
use_address: serverroom.home
|
||||
#use_address: serverroom.home
|
||||
power_save_mode: high
|
||||
fast_connect: on
|
||||
min_auth_mode: WPA2
|
||||
|
||||
# Enable fallback hotspot (captive portal) in case wifi connection fails
|
||||
ap:
|
||||
@@ -39,6 +43,7 @@ api:
|
||||
key: !secret apikey
|
||||
|
||||
ota:
|
||||
platform: esphome
|
||||
password: !secret ota
|
||||
|
||||
# Initialize I²C
|
||||
@@ -50,7 +55,7 @@ i2c:
|
||||
|
||||
# BMP280 sensor
|
||||
sensor:
|
||||
- platform: bmp280
|
||||
- platform: bmp280_i2c
|
||||
i2c_id: bus_a
|
||||
temperature:
|
||||
name: "Serveraum Temperatur"
|
||||
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 623 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 685 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,40 @@
|
||||
epaper_demotivationszitat:
|
||||
name: Epaper Demotivationszitat
|
||||
icon: mdi:emoticon-sad-outline
|
||||
options:
|
||||
- "Erwarte nichts. Dann kann dich auch wenig enttäuschen."
|
||||
- "Du musst nicht scheitern. Es reicht, es nie zu versuchen."
|
||||
- "Der Weg ist das Ziel. Leider führt er im Kreis."
|
||||
- "Auch ein Rückschritt ist Bewegung. Nur eben rückwärts."
|
||||
- "Nicht jeder Tag muss gut sein. Dieser hier zum Beispiel nicht."
|
||||
- "Dein Potenzial ist beeindruckend. Seine Nutzung bleibt optional."
|
||||
- "Morgen wird alles besser. Wahrscheinlich aber auch nicht."
|
||||
- "Motivation ist vergänglich. Frust ist zuverlässig."
|
||||
- "Man kann nicht alles haben. Nicht einmal gute Laune."
|
||||
- "Wenn Plan A scheitert, war Plan B vermutlich auch keine Hilfe."
|
||||
- "Es ist nie zu spät, die Erwartungen zu senken."
|
||||
- "Du bist nicht zu spät. Die anderen sind nur früher gescheitert."
|
||||
- "Das wird schon. Nur eben nicht gut."
|
||||
- "Ziele sind wichtig. Besonders wenn man sie verfehlt."
|
||||
- "Die Konkurrenz schläft nicht. Du darfst trotzdem weitermachen."
|
||||
- "Du gibst dein Bestes. Das erklärt einiges."
|
||||
- "Jeder Fehler ist eine Lektion. Du bist bald Experte."
|
||||
- "Heute ist ein neuer Tag. Die Probleme sind dieselben."
|
||||
- "Erfolg braucht Geduld. Enttäuschung ist sofort verfügbar."
|
||||
- "Ein kleiner Schritt für dich. Ein kaum bemerkbarer für alle anderen."
|
||||
- "Wer früh aufgibt, hat länger frei."
|
||||
- "Es gibt keine dummen Fragen. Nur ungünstige Zeitpunkte."
|
||||
- "Deine Komfortzone vermisst dich nicht."
|
||||
- "Manchmal gewinnt man. Meistens lernt man daraus."
|
||||
- "Der erste Schritt ist der schwerste. Die anderen sind nur länger."
|
||||
- "Arbeite hart. Vielleicht merkt es niemand."
|
||||
- "Nicht aufgeben. Es könnte noch schlimmer werden."
|
||||
- "Träume groß. Die Realität übernimmt den Rest."
|
||||
- "Das Glas ist halb voll. Mit Problemen."
|
||||
- "Jeder Tag bietet Chancen. Manche davon sind Termine."
|
||||
- "Du kannst alles schaffen. Außer vielleicht heute."
|
||||
- "Das Leben ist kurz. Besprechungen sind länger."
|
||||
- "Mut steht am Anfang. Die Rechnung kommt später."
|
||||
- "Wenn es einfach wäre, würde es jemand anders machen."
|
||||
- "Deine To-do-Liste glaubt an dich. Sie muss ja nicht mitmachen."
|
||||
- "Perfektion ist erreichbar. Nur nicht von hier."
|
||||
@@ -1,6 +0,0 @@
|
||||
bdm:
|
||||
name: BDM
|
||||
mac: BLE_34:14:B5:A0:99:BD
|
||||
icon:
|
||||
picture:
|
||||
track: true
|
||||