├── custom_components ├── presence_combo │ ├── __init__.py │ ├── presence_combo.h │ ├── binary_sensor.py │ └── presence_combo.cpp ├── irk_enrollment │ ├── README.md │ ├── irk_enrollment.h │ ├── __init__.py │ └── irk_enrollment.cpp ├── nau8810 │ ├── nau8810.h │ ├── __init__.py │ ├── nau8810.cpp │ ├── nau881x_regs.h │ └── nau881x.h └── drv2605 │ ├── __init__.py │ ├── drv2605.h │ └── drv2605.cpp ├── influx.py ├── ble_listener.py ├── ld2410ble_mac_discovery.yaml ├── irk_locator.yaml ├── dac_light.yaml ├── lirr_fetcher.py ├── lightboard.yaml ├── goportparking.py ├── irk_resolver.h ├── underbed.yaml ├── lightswitch.yaml ├── ld2410ble.yaml ├── radiant_controller.yaml ├── state_mgmt.py ├── cleaning_queue.py ├── apps.yaml ├── README.md └── lights.py /custom_components/presence_combo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /influx.py: -------------------------------------------------------------------------------- 1 | from influxdb_client import InfluxDBClient 2 | 3 | client = InfluxDBClient(url="http://192.168.0.112:8086", token='appdaemon-dev:opennow', org='-') 4 | query_api = client.query_api() 5 | -------------------------------------------------------------------------------- /custom_components/irk_enrollment/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Stick something like: 4 | 5 | ```yaml 6 | irk_enrollment: 7 | latest_irk: 8 | name: Latest IRK 9 | ``` 10 | 11 | in your esphome config. Pair a phone or tablet that needs to be using resolvable private address passive fingerprinting with the esphome device (it should appear as a bluetooth keyboard or something). Then, the `Latest IRK` sensor will contain the IRK that you can use with the rest of my fingerprinting suite. 12 | -------------------------------------------------------------------------------- /custom_components/presence_combo/presence_combo.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/defines.h" 4 | #include "esphome/core/component.h" 5 | #include "esphome/core/automation.h" 6 | #include "esphome/core/helpers.h" 7 | #include "esphome/core/preferences.h" 8 | #include "esphome/components/binary_sensor/binary_sensor.h" 9 | 10 | 11 | namespace esphome { 12 | namespace presence_combo { 13 | 14 | class PresenceComboComponent : public esphome::Component, public esphome::binary_sensor::BinarySensor 15 | { 16 | public: 17 | PresenceComboComponent() {} 18 | 19 | void dump_config() override; 20 | void loop() override; 21 | void setup() override; 22 | 23 | float get_setup_priority() const; 24 | 25 | void add_child_sensor(binary_sensor::BinarySensor * child) { 26 | children_.push_back(child); 27 | } 28 | 29 | protected: 30 | std::vector children_; 31 | bool state_; 32 | 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /custom_components/presence_combo/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | import esphome.config_validation as cv 3 | from esphome.components import binary_sensor 4 | from esphome.const import CONF_ID 5 | 6 | CODEOWNERS = ["@dgrnbrg"] 7 | 8 | presence_combo_ns = cg.esphome_ns.namespace("presence_combo") 9 | PresenceComboComponent = presence_combo_ns.class_("PresenceComboComponent", 10 | binary_sensor.BinarySensor, 11 | cg.Component, 12 | ) 13 | 14 | CONF_IDS = "ids" 15 | 16 | CONFIG_SCHEMA = ( 17 | binary_sensor.BINARY_SENSOR_SCHEMA 18 | .extend( 19 | { 20 | cv.GenerateID(): cv.declare_id(PresenceComboComponent), 21 | cv.Required(CONF_IDS): cv.All( 22 | cv.ensure_list(cv.use_id(binary_sensor.BinarySensor)), 23 | cv.Length(min=1), 24 | ), 25 | } 26 | ) 27 | .extend(cv.COMPONENT_SCHEMA) 28 | ) 29 | 30 | async def to_code(config): 31 | var = await binary_sensor.new_binary_sensor(config) 32 | await cg.register_component(var, config) 33 | for x in config[CONF_IDS]: 34 | child_var = await cg.get_variable(x) 35 | cg.add(var.add_child_sensor(child_var)) 36 | -------------------------------------------------------------------------------- /custom_components/presence_combo/presence_combo.cpp: -------------------------------------------------------------------------------- 1 | #include "presence_combo.h" 2 | #include "esphome/core/log.h" 3 | 4 | namespace esphome { 5 | namespace presence_combo { 6 | 7 | static const char *const TAG = "presence_combo"; 8 | 9 | void PresenceComboComponent::setup() { 10 | this->state_ = false; 11 | for (auto& c : children_) { 12 | if (c->state) { 13 | this->state_ = true; 14 | } 15 | } 16 | this->publish_initial_state(this->state_); 17 | } 18 | 19 | void PresenceComboComponent::dump_config() { 20 | LOG_BINARY_SENSOR("", "Presence Combo Sensor", this); 21 | for (auto& c : children_) { 22 | LOG_BINARY_SENSOR(" ", "Sub-sensor", c); 23 | } 24 | } 25 | 26 | float PresenceComboComponent::get_setup_priority() const { 27 | return setup_priority::DATA; 28 | } 29 | 30 | void PresenceComboComponent::loop() { 31 | this->state_ = false; 32 | for (auto& c : children_) { 33 | if (c->state) { 34 | this->state_ = true; 35 | } 36 | } 37 | this->publish_state(this->state_); 38 | } 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/nau8810/nau8810.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/core/hal.h" 5 | #include "esphome/core/defines.h" 6 | #include "esphome/core/preferences.h" 7 | #include "esphome/core/automation.h" 8 | #include "esphome/components/i2c/i2c.h" 9 | #include "nau881x.h" 10 | 11 | namespace esphome { 12 | namespace nau8810 { 13 | 14 | class NAU8810Component : public i2c::I2CDevice, public Component { 15 | public: 16 | void setup() override; 17 | void loop() override; 18 | void dump_config() override; 19 | void set_speaker_volume(uint8_t); 20 | uint8_t get_speaker_volume(); 21 | void set_speaker_mute(bool muted); 22 | protected: 23 | NAU881x_t nau8810; 24 | uint8_t silicon_revision_; 25 | }; 26 | 27 | template class SetSpeakerVolumeAction : public Action { 28 | public: 29 | SetSpeakerVolumeAction(NAU8810Component *parent) : parent_(parent) {} 30 | TEMPLATABLE_VALUE(uint8_t, volume); 31 | 32 | void play(Ts... x) override { 33 | uint8_t volume = this->volume_.value(x...); 34 | this->parent_->set_speaker_volume(volume); 35 | } 36 | 37 | NAU8810Component *parent_; 38 | }; 39 | 40 | } // namespace nau8810 41 | } // namespace esphome 42 | 43 | -------------------------------------------------------------------------------- /custom_components/nau8810/__init__.py: -------------------------------------------------------------------------------- 1 | from esphome import pins 2 | import esphome.codegen as cg 3 | from esphome import automation 4 | import esphome.config_validation as cv 5 | from esphome.components import i2c, sensor 6 | from esphome.const import CONF_ID 7 | import math 8 | 9 | DEPENDENCIES = ['i2c'] 10 | 11 | CONF_I2C_ADDR = 0x1A 12 | CONF_VOLUME = "volume" 13 | 14 | nau8810_ns = cg.esphome_ns.namespace('nau8810') 15 | NAU8810Component = nau8810_ns.class_('NAU8810Component', cg.Component, i2c.I2CDevice) 16 | SetSpeakerVolumeAction = nau8810_ns.class_('SetSpeakerVolumeAction', automation.Action) 17 | 18 | CONFIG_SCHEMA = cv.Schema({ 19 | cv.GenerateID(): cv.declare_id(NAU8810Component), 20 | }).extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(CONF_I2C_ADDR)) 21 | 22 | @automation.register_action("nau8810.set_speaker_volume", SetSpeakerVolumeAction, 23 | cv.Schema( 24 | { 25 | cv.Required(CONF_ID): cv.use_id(NAU8810Component), 26 | cv.Required(CONF_VOLUME): cv.templatable(cv.int_range(0,255)), 27 | } 28 | ) 29 | ) 30 | async def nau8810_set_speaker_volume_to_code(config, action_id, template_arg, args): 31 | paren = await cg.get_variable(config[CONF_ID]) 32 | var = cg.new_Pvariable(action_id, template_arg, paren) 33 | template_ = await cg.templatable(config[CONF_VOLUME], args, int) 34 | cg.add(var.set_volume(template_)) 35 | return var 36 | 37 | async def to_code(config): 38 | var = cg.new_Pvariable(config[CONF_ID]) 39 | await cg.register_component(var, config) 40 | await i2c.register_i2c_device(var, config) 41 | 42 | 43 | -------------------------------------------------------------------------------- /custom_components/irk_enrollment/irk_enrollment.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/defines.h" 4 | #include "esphome/core/component.h" 5 | #include "esphome/core/automation.h" 6 | #include "esphome/core/helpers.h" 7 | #include "esphome/core/preferences.h" 8 | #include "esphome/components/text_sensor/text_sensor.h" 9 | #include "esphome/components/esp32_ble/ble.h" 10 | #include "esphome/components/esp32_ble/ble_advertising.h" 11 | #include "esphome/components/esp32_ble/ble_uuid.h" 12 | #include "esphome/components/esp32_ble/queue.h" 13 | #include "esphome/components/esp32_ble_server/ble_service.h" 14 | #include "esphome/components/esp32_ble_server/ble_server.h" 15 | #include "esphome/components/esp32_ble_server/ble_characteristic.h" 16 | 17 | 18 | #ifdef USE_ESP32 19 | 20 | #include 21 | #include 22 | 23 | 24 | namespace esphome { 25 | namespace irk_enrollment { 26 | 27 | class IrkEnrollmentComponent : 28 | public esphome::Component, 29 | public esp32_ble_server::BLEServiceComponent, 30 | public esp32_ble::GATTsEventHandler 31 | { 32 | public: 33 | IrkEnrollmentComponent() {} 34 | void dump_config() override; 35 | void loop() override; 36 | void setup() override; 37 | void set_latest_irk(text_sensor::TextSensor *latest_irk) { latest_irk_ = latest_irk; } 38 | 39 | 40 | float get_setup_priority() const; 41 | void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, 42 | esp_ble_gatts_cb_param_t *param) override; 43 | 44 | void start() override; 45 | void stop() override; 46 | 47 | 48 | protected: 49 | esp32_ble_server::BLEService *service_{nullptr}; 50 | text_sensor::TextSensor *latest_irk_{nullptr}; 51 | }; 52 | 53 | } // namespace irk_enrollment 54 | } // namespace esphome 55 | 56 | #endif 57 | -------------------------------------------------------------------------------- /custom_components/irk_enrollment/__init__.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | import esphome.config_validation as cv 3 | from esphome.components import esp32_ble_server, esp32_ble, text_sensor 4 | from esphome.const import CONF_ID, ENTITY_CATEGORY_DIAGNOSTIC 5 | from esphome.components.esp32 import add_idf_sdkconfig_option 6 | 7 | AUTO_LOAD = ["esp32_ble_server", "text_sensor"] 8 | CODEOWNERS = ["@dgrnbrg"] 9 | CONFLICTS_WITH = ["esp32_ble_beacon"] 10 | DEPENDENCIES = ["esp32", "text_sensor"] 11 | 12 | CONF_BLE_SERVER_ID = "ble_server_id" 13 | CONF_LATEST_IRK = "latest_irk" 14 | CONF_PREPARE = "enroll_button" 15 | 16 | irk_enrollment_ns = cg.esphome_ns.namespace("irk_enrollment") 17 | IrkEnrollmentComponent = irk_enrollment_ns.class_("IrkEnrollmentComponent", 18 | cg.Component, 19 | esp32_ble.GATTsEventHandler, 20 | esp32_ble_server.BLEServiceComponent, 21 | ) 22 | 23 | CONFIG_SCHEMA = ( 24 | cv.Schema( 25 | { 26 | cv.GenerateID(): cv.declare_id(IrkEnrollmentComponent), 27 | cv.GenerateID(CONF_BLE_SERVER_ID): cv.use_id(esp32_ble_server.BLEServer), 28 | cv.GenerateID(esp32_ble.CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE), 29 | cv.Optional(CONF_LATEST_IRK): text_sensor.text_sensor_schema( 30 | entity_category=ENTITY_CATEGORY_DIAGNOSTIC, 31 | icon="mdi:cellphone-key", 32 | ), 33 | } 34 | ) 35 | .extend(cv.COMPONENT_SCHEMA) 36 | ) 37 | 38 | 39 | async def to_code(config): 40 | var = cg.new_Pvariable(config[CONF_ID]) 41 | await cg.register_component(var, config) 42 | 43 | ble_server = await cg.get_variable(config[CONF_BLE_SERVER_ID]) 44 | cg.add(ble_server.register_service_component(var)) 45 | 46 | ble_master = await cg.get_variable(config[esp32_ble.CONF_BLE_ID]) 47 | cg.add(ble_master.register_gatts_event_handler(var)) 48 | 49 | if CONF_LATEST_IRK in config: 50 | latest_irk = await text_sensor.new_text_sensor(config[CONF_LATEST_IRK]) 51 | cg.add(var.set_latest_irk(latest_irk)) 52 | -------------------------------------------------------------------------------- /ble_listener.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aiohttp 3 | from bleak import BleakScanner 4 | import sys 5 | import argparse 6 | 7 | parser = argparse.ArgumentParser(description='stream ble advertisements to homeassistant') 8 | parser.add_argument('--access-token', help='Long lived home assistant access token', required=True) 9 | parser.add_argument('--homeassistant', default='homeassistant.local', help='address of home assistant') 10 | parser.add_argument('--event', default='esphome.ble_tracking_beacon', help="Event to publish (you probably don't want to change this") 11 | parser.add_argument('--source', help='source to label the advertisements with (probably the room or device name)', required=True) 12 | parser.add_argument('-v', '--verbose', help='Output debugging info', action='store_true') 13 | args=parser.parse_args() 14 | 15 | if args.verbose: 16 | print(f'Launching with args {args}') 17 | 18 | steps = [] 19 | for i in range(20): 20 | step = ['['] + [' '] * 20 + [']'] 21 | step[i + 1] = 'x' 22 | steps.append(''.join(step)) 23 | steps_rev = [] 24 | for s in steps[1:-1]: 25 | steps_rev.append(s) 26 | steps_rev.reverse() 27 | steps.extend(steps_rev) 28 | 29 | async def main(): 30 | stop_event = asyncio.Event() 31 | 32 | # TODO: add something that calls stop_event.set() 33 | 34 | url = f"http://{args.homeassistant}:8123/api/events/{args.event}" 35 | counter = 0 36 | async with aiohttp.ClientSession() as session: 37 | async def callback(device, advertising_data): 38 | data = { 39 | "addr": device.address, 40 | "source": args.source, 41 | "rssi": device.rssi, 42 | } 43 | nonlocal counter 44 | counter += 1 45 | if args.verbose: 46 | print(f"recieving {steps[counter % len(steps)]}\r", end='') 47 | async with session.post(url, json = data, headers={'Authorization': f'Bearer {args.access_token}'}) as response: 48 | data = await response.text() 49 | 50 | async with BleakScanner(callback) as scanner: 51 | # Important! Wait for an event to trigger stop, otherwise scanner 52 | # will stop immediately. 53 | await stop_event.wait() 54 | 55 | # scanner stops when block exits 56 | pass 57 | 58 | asyncio.run(main()) 59 | 60 | -------------------------------------------------------------------------------- /ld2410ble_mac_discovery.yaml: -------------------------------------------------------------------------------- 1 | esp32_ble_tracker: 2 | on_ble_advertise: 3 | - then: 4 | - lambda: |- 5 | static std::vector known_ld2410_addrs; 6 | if (x.get_name().rfind("HLK-LD2410", 0) == 0) { 7 | //if (true) { 8 | if (std::find(known_ld2410_addrs.begin(), known_ld2410_addrs.end(), x.address_str()) != known_ld2410_addrs.end()) { 9 | // already seen 10 | return; 11 | } 12 | known_ld2410_addrs.push_back(x.address_str()); 13 | ESP_LOGW("ble_adv", "New LD2410 device"); 14 | ESP_LOGW("ble_adv", " address: %s", x.address_str().c_str()); 15 | ESP_LOGW("ble_adv", " name: %s", x.get_name().c_str()); 16 | ESP_LOGD("ble_adv", " Advertised service UUIDs:"); 17 | for (auto uuid : x.get_service_uuids()) { 18 | ESP_LOGD("ble_adv", " - %s", uuid.to_string().c_str()); 19 | } 20 | ESP_LOGD("ble_adv", " Advertised service data:"); 21 | for (auto data : x.get_service_datas()) { 22 | ESP_LOGD("ble_adv", " - %s: (length %i)", data.uuid.to_string().c_str(), data.data.size()); 23 | //for (auto b : data.data) { 24 | // ESP_LOGD("ble_svc_adv", " %x", b); 25 | //} 26 | } 27 | ESP_LOGD("ble_adv", " Advertised manufacturer data:"); 28 | for (auto data : x.get_manufacturer_datas()) { 29 | ESP_LOGD("ble_adv", " - %s: (length %i)", data.uuid.to_string().c_str(), data.data.size()); 30 | //for (auto b : data.data) { 31 | // ESP_LOGD("ble_mfg_adv", " %x", b); 32 | //} 33 | 34 | } 35 | std::string tmp; 36 | int min_idx = known_ld2410_addrs.size() - 3; 37 | if (min_idx < 0) { 38 | min_idx = 0; 39 | } 40 | for (int i = min_idx; i < known_ld2410_addrs.size(); i++) { 41 | tmp += known_ld2410_addrs[i]; 42 | if (i != known_ld2410_addrs.size() - 1) { 43 | tmp += ", "; 44 | } 45 | } 46 | id(last_few_ld2410_addrs).publish_state(tmp); 47 | } 48 | 49 | text_sensor: 50 | - platform: template 51 | name: Last few LD2410 MAC addresses 52 | id: last_few_ld2410_addrs 53 | entity_category: config 54 | -------------------------------------------------------------------------------- /irk_locator.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | irk_prefilter_entity: sensor.irk_prefilter 3 | irk_source: ${device_name} 4 | 5 | esphome: 6 | includes: 7 | - irk_resolver.h 8 | 9 | esp32: 10 | framework: 11 | sdkconfig_options: 12 | CONFIG_MBEDTLS_HARDWARE_AES: y 13 | 14 | bluetooth_proxy: 15 | 16 | esp32_ble_tracker: 17 | on_ble_advertise: 18 | - then: 19 | - if: 20 | condition: 21 | or: 22 | - switch.is_on: skip_irk_prefilter 23 | - lambda: |- 24 | uint8_t addr[6]; 25 | for (int i = 0; i < irk_prefilters.size(); i++) { 26 | auto& irk_vec = irk_prefilters[i]; 27 | uint8_t* irk = irk_vec.data(); 28 | auto addr64 = x.address_uint64(); 29 | addr[5] = (addr64 >> 40) & 0xff; 30 | addr[4] = (addr64 >> 32) & 0xff; 31 | addr[3] = (addr64 >> 24) & 0xff; 32 | addr[2] = (addr64 >> 16) & 0xff; 33 | addr[1] = (addr64 >> 8) & 0xff; 34 | addr[0] = (addr64) & 0xff; 35 | if (ble_ll_resolv_rpa((const uint8_t *)addr, irk)) { 36 | ESP_LOGD("local_irk", "Resolved idx %d from ${irk_source}", i); 37 | return true; 38 | } 39 | } 40 | return false; 41 | then: 42 | - homeassistant.event: 43 | event: esphome.ble_tracking_beacon 44 | data: 45 | source: ${irk_source} 46 | rssi: !lambda |- 47 | return x.get_rssi(); 48 | addr: !lambda |- 49 | return x.address_str(); 50 | 51 | switch: 52 | - platform: template 53 | name: Skip IRK prefiltering 54 | id: skip_irk_prefilter 55 | restore_state: true 56 | optimistic: true 57 | entity_category: config 58 | 59 | text_sensor: 60 | - platform: homeassistant 61 | internal: true 62 | entity_id: ${irk_prefilter_entity} 63 | id: irk_prefilter 64 | on_value: 65 | then: 66 | - lambda: |- 67 | uint8_t output[16]; 68 | size_t outlen; 69 | size_t pos = 0; 70 | std::string token; 71 | irk_prefilters.clear(); 72 | while ((pos = x.find(":")) != std::string::npos) { 73 | token = x.substr(0, pos); 74 | mbedtls_base64_decode(output, 16, &outlen, (const uint8_t *)token.c_str(), token.length()); 75 | std::vector ov; 76 | for (int i = 0; i < 16; i++) { 77 | ov.push_back(output[i]); 78 | } 79 | irk_prefilters.push_back(ov); 80 | x.erase(0, pos + 1); 81 | } 82 | -------------------------------------------------------------------------------- /dac_light.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | clamp_max_power: "1" 3 | 4 | dac7678: 5 | - address: ${address} 6 | id: dac7678_${id}_hub 7 | internal_reference: true 8 | 9 | output: 10 | - platform: dac7678 11 | dac7678_id: dac7678_${id}_hub 12 | channel: 0 13 | id: dac7678_${id}_0 14 | max_power: ${clamp_max_power} 15 | - platform: dac7678 16 | dac7678_id: dac7678_${id}_hub 17 | channel: 1 18 | id: dac7678_${id}_1 19 | max_power: ${clamp_max_power} 20 | - platform: dac7678 21 | dac7678_id: dac7678_${id}_hub 22 | channel: 2 23 | id: dac7678_${id}_2 24 | max_power: ${clamp_max_power} 25 | - platform: dac7678 26 | dac7678_id: dac7678_${id}_hub 27 | channel: 3 28 | id: dac7678_${id}_3 29 | max_power: ${clamp_max_power} 30 | - platform: dac7678 31 | dac7678_id: dac7678_${id}_hub 32 | channel: 4 33 | id: dac7678_${id}_4 34 | max_power: ${clamp_max_power} 35 | - platform: dac7678 36 | dac7678_id: dac7678_${id}_hub 37 | channel: 5 38 | id: dac7678_${id}_5 39 | max_power: ${clamp_max_power} 40 | - platform: dac7678 41 | dac7678_id: dac7678_${id}_hub 42 | channel: 6 43 | id: dac7678_${id}_6_real 44 | max_power: ${clamp_max_power} 45 | - platform: dac7678 46 | dac7678_id: dac7678_${id}_hub 47 | channel: 7 48 | id: dac7678_${id}_7 49 | max_power: ${clamp_max_power} 50 | - platform: template 51 | id: dac7678_${id}_6 52 | type: float 53 | write_action: 54 | - output.set_level: 55 | id: dac7678_${id}_6_real 56 | level: !lambda ESP_LOGD("template_intercept", "id 6 has level %f", state); return state; 57 | 58 | light: 59 | - platform: color_temperature 60 | name: "${light1}" 61 | color_temperature: dac7678_${id}_4 62 | brightness: dac7678_${id}_6 63 | cold_white_color_temperature: ${light1_warm_ct} 64 | warm_white_color_temperature: ${light1_cool_ct} 65 | gamma_correct: 1 66 | - platform: color_temperature 67 | name: "${light2}" 68 | color_temperature: dac7678_${id}_0 69 | brightness: dac7678_${id}_2 70 | cold_white_color_temperature: ${light2_warm_ct} 71 | warm_white_color_temperature: ${light2_cool_ct} 72 | gamma_correct: 1 73 | - platform: color_temperature 74 | name: "${light3}" 75 | color_temperature: dac7678_${id}_3 76 | brightness: dac7678_${id}_1 77 | cold_white_color_temperature: ${light3_warm_ct} 78 | warm_white_color_temperature: ${light3_cool_ct} 79 | gamma_correct: 1 80 | - platform: color_temperature 81 | name: "${light4}" 82 | color_temperature: dac7678_${id}_7 83 | brightness: dac7678_${id}_5 84 | cold_white_color_temperature: ${light4_warm_ct} 85 | warm_white_color_temperature: ${light4_cool_ct} 86 | gamma_correct: 1 87 | -------------------------------------------------------------------------------- /custom_components/nau8810/nau8810.cpp: -------------------------------------------------------------------------------- 1 | 2 | #include "esphome/core/log.h" 3 | #include "nau8810.h" 4 | 5 | extern "C" { 6 | 7 | static const char *TAG = "nau8810.c_bridge"; 8 | 9 | void nau8810_I2C_Write(void * p, uint8_t i2c_address, uint8_t reg, uint16_t value) 10 | { 11 | esphome::nau8810::NAU8810Component * c = (esphome::nau8810::NAU8810Component *)p; 12 | uint8_t reg_, value_; 13 | reg_ = reg * 2; 14 | reg_ |= (value >> 8) & 0x1; 15 | value_ = (uint8_t) value; 16 | ESP_LOGD(TAG, "Writing byte via %p to reg 0x%x with value 0x%x (addr=0x%x data=0x%x)", c, reg, value, reg_, value_); 17 | if (!c->write_byte(reg_, value_)) { 18 | c->status_set_warning(); 19 | } 20 | } 21 | 22 | uint16_t nau8810_I2C_Read(void * p, uint8_t i2c_address, uint8_t reg) 23 | { 24 | esphome::nau8810::NAU8810Component * c = (esphome::nau8810::NAU8810Component *)p; 25 | reg *= 2; 26 | uint16_t result; 27 | if (!c->read_byte_16(reg, &result)) { 28 | c->status_set_warning(); 29 | } 30 | return result; 31 | } 32 | 33 | } 34 | 35 | namespace esphome { 36 | namespace nau8810 { 37 | 38 | static const char *TAG = "nau8810.component"; 39 | 40 | void NAU8810Component::setup() { 41 | bool done = false; 42 | uint16_t b; 43 | nau8810.comm_handle = (void*)this; 44 | NAU881x_Init(&nau8810); 45 | NAU881x_Get_SiliconRevision(&nau8810, &this->silicon_revision_); 46 | // Make audio codec functional 47 | // Enable micbias (should default to 0.9*va) 48 | NAU881x_Set_MicBias_Enable(&nau8810, 1); 49 | // Code below will route the audio from MICN to Speaker via Bypass (refer to General Block Diagram) 50 | NAU881x_Set_PGA_Input(&nau8810, NAU881X_INPUT_MICN); 51 | NAU881x_Set_Output_Enable(&nau8810, NAU881X_OUTPUT_SPK); 52 | 53 | // David's customizations 54 | NAU881x_Set_Speaker_Source(&nau8810, NAU881X_OUTPUT_FROM_DAC); 55 | // Enable DAC 56 | NAU881x_Set_DAC_Enable(&nau8810, 1); 57 | // Enable automute for DAC output when idle 58 | NAU881x_Set_DAC_AutoMute(&nau8810, 1); 59 | // Enable PGA 60 | NAU881x_Set_PGA_Enable(&nau8810, 1); 61 | // Enable autoleveling 62 | //NAU881x_Set_ALC_Enable(&nau8810, 1); 63 | NAU881x_Set_PGA_Gain(&nau8810, 0x3f); 64 | // Enable the ADC 65 | NAU881x_Set_ADC_Enable(&nau8810, 1); 66 | // Send mic data to left & right channels 67 | NAU881x_Set_LOUTR(&nau8810, 1); 68 | // Use I2S for audio data 69 | NAU881x_Set_AudioInterfaceFormat(&nau8810, NAU881X_AUDIO_IFACE_FMT_I2S, NAU881X_AUDIO_IFACE_WL_16BITS); 70 | // Configure slave clocking 71 | NAU881x_Set_Clock(&nau8810, 0, NAU881X_BCLKDIV_1, NAU881X_MCLKDIV_1, NAU881X_CLKSEL_MCLK); 72 | #if 0 73 | for (int i = 0; i < 80; i++) { 74 | this->read_byte_16(i*2, &b); 75 | ESP_LOGD(TAG, "[3rd] NAU8810 regsiter 0x%x(%d) has value 0x%x", i, i, b); 76 | } 77 | #endif 78 | } 79 | 80 | void NAU8810Component::set_speaker_mute(bool state) { 81 | if (!this->is_ready()) { 82 | return; 83 | } 84 | NAU881x_Set_Speaker_Mute(&nau8810, state); 85 | } 86 | 87 | uint8_t NAU8810Component::get_speaker_volume() { 88 | return NAU881x_Get_Speaker_Volume(&nau8810); 89 | } 90 | 91 | void NAU8810Component::set_speaker_volume(uint8_t volume) { 92 | if (!this->is_ready()) { 93 | return; 94 | } 95 | NAU881x_Set_Speaker_Volume(&nau8810, volume); 96 | } 97 | 98 | void NAU8810Component::loop() { 99 | // TODO add mic & speaker media controls (volume + mute) 100 | } 101 | 102 | void NAU8810Component::dump_config(){ 103 | ESP_LOGCONFIG(TAG, "NAU8810, silicon rev 0x%x", this->silicon_revision_); 104 | } 105 | 106 | 107 | } // namespace nau8810 108 | } // namespace esphome 109 | -------------------------------------------------------------------------------- /lirr_fetcher.py: -------------------------------------------------------------------------------- 1 | import hassapi as hass 2 | import traceback 3 | import adbase as ad 4 | import datetime 5 | import time 6 | import requests 7 | 8 | 9 | def fetch_data(from_sta, to_sta, label): 10 | def parse_trip(trip): 11 | duration_min = (trip['trip_end'] - trip['trip_start']) // 60 12 | legs = trip['legs'] 13 | train = legs[0]['train'] 14 | details = train['details'] 15 | try: 16 | stop = details['stops'][0] 17 | sched_time = datetime.datetime.fromtimestamp(stop['sched_time']) 18 | sched_time_fmt = sched_time.strftime('%-I:%M %p') 19 | except: 20 | sched_time = '??? AM/PM' 21 | try: 22 | status = stop['stop_status'] 23 | except: 24 | status = 'unknown' 25 | try: 26 | track = stop['t2s_track'] 27 | except: 28 | track = 'Track unknown' 29 | return {'time': sched_time_fmt, 'status': status, 'track': track, 'num_legs': len(legs), 'shuttle': legs[0]['is_shuttle'], 'duration': duration_min, 'numeric_time': stop['sched_time'], 'label': label} 30 | try: 31 | u = f'https://backend-unified.mylirr.org/plan?from={from_sta}&to={to_sta}&fares=ALL&time={round(time.time() * 1000)}&arrive_by=false' 32 | r = requests.get(u, headers={'accept': 'application/json', 'accept-version': '3.0', 'dnt': '1'}) 33 | except: 34 | traceback.print_exc() 35 | try: 36 | parsed_trips = [parse_trip(t) for t in r.json()['trips']] 37 | except: 38 | for t in parsed_trips: 39 | t['label'] = '(Error) ' + t['label'] 40 | traceback.print_exc() 41 | parsed_trips = [t for t in parsed_trips if t['num_legs'] == 1] 42 | return parsed_trips 43 | 44 | class LirrFetcher(hass.Hass): 45 | def initialize(self): 46 | # Run every 5 minutes starting now 47 | self.run_every(self.update_lirr_data, "now", 300) 48 | self.ordinals = ['First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth'] 49 | 50 | def update_lirr_data(self, kwargs): 51 | def publish_entities(entity_prefix, trips): 52 | for i, trip in enumerate(trips): 53 | ent = self.get_entity(f'sensor.{entity_prefix}_{i}') 54 | state = trip['time'] 55 | if trip['shuttle']: 56 | state += ' (shuttle)' 57 | ent.set_state(state = trip['time'], attributes = { 58 | 'track': trip['track'], 59 | 'stop_status': trip['status'].replace('_', ' ').capitalize(), 60 | 'duration': f"{trip['duration']} min", 61 | 'friendly_name': trip['label'],#f'{self.ordinals[i]} Train ({trip["label"]})', 62 | 'icon': 'mdi:train', 63 | }) 64 | def merge_routes(x, y): 65 | routes = x + y 66 | routes.sort(key=lambda x: x['numeric_time']) 67 | # drop the departed trips except the most recent 68 | num_departed = len([t for t in routes if t['status'] == 'DEPARTED']) 69 | for i in range(num_departed-1): 70 | routes.pop(0) 71 | while routes[0]['numeric_time'] + 60*int(self.args.get('max_lookback_mins', 45)) < time.time(): 72 | routes.pop(0) 73 | return routes[:6] 74 | to_penn = fetch_data(from_sta='PWS', to_sta='NYK', label='Penn Station') 75 | to_gc = fetch_data(from_sta='PWS', to_sta='_GC', label='Grand Central') 76 | publish_entities('lirr_penn', merge_routes(to_penn, to_gc)) 77 | from_penn = fetch_data(from_sta='NYK', to_sta='PWS', label='Penn Station') 78 | from_gc = fetch_data(from_sta='_GC', to_sta='PWS', label='Grand Central') 79 | publish_entities('lirr_pw', merge_routes(from_penn, from_gc)) 80 | -------------------------------------------------------------------------------- /custom_components/drv2605/__init__.py: -------------------------------------------------------------------------------- 1 | from esphome import pins 2 | import hashlib 3 | import esphome.codegen as cg 4 | from esphome import automation 5 | import esphome.config_validation as cv 6 | from esphome.components import i2c, sensor 7 | from esphome.const import CONF_ID 8 | import math 9 | 10 | DEPENDENCIES = ['i2c'] 11 | 12 | CONF_I2C_ADDR = 0x5A 13 | CONF_LRA_WAVEFORM = "waveform" 14 | CONF_EN_PIN = "en_pin" 15 | CONF_RATED_VOLTAGE = "rated_voltage" 16 | CONF_RESONANT_FREQUENCY = "resonant_frequency" 17 | 18 | drv2605_ns = cg.esphome_ns.namespace('drv2605') 19 | DRV2605Component = drv2605_ns.class_('DRV2605Component', cg.Component, i2c.I2CDevice) 20 | FireHapticAction = drv2605_ns.class_("FireHapticAction", automation.Action) 21 | CalibrateAction = drv2605_ns.class_("CalibrateAction", automation.Action) 22 | ResetAction = drv2605_ns.class_("ResetAction", automation.Action) 23 | 24 | CONFIG_SCHEMA = cv.Schema({ 25 | cv.GenerateID(): cv.declare_id(DRV2605Component), 26 | cv.Required(CONF_EN_PIN): pins.internal_gpio_output_pin_schema, 27 | cv.Required(CONF_RATED_VOLTAGE): cv.voltage, 28 | cv.Required(CONF_RESONANT_FREQUENCY): cv.frequency, 29 | }).extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(CONF_I2C_ADDR)) 30 | 31 | @automation.register_action("drv2605.fire_haptic", FireHapticAction, 32 | cv.Schema( 33 | { 34 | cv.Required(CONF_ID): cv.use_id(DRV2605Component), 35 | cv.Required(CONF_LRA_WAVEFORM): cv.templatable(cv.int_range(1,127)), 36 | } 37 | ) 38 | ) 39 | async def drv2605_fire_haptic_to_code(config, action_id, template_arg, args): 40 | paren = await cg.get_variable(config[CONF_ID]) 41 | var = cg.new_Pvariable(action_id, template_arg, paren) 42 | template_ = await cg.templatable(config[CONF_LRA_WAVEFORM], args, int) 43 | cg.add(var.set_waveform_id(template_)) 44 | return var 45 | 46 | @automation.register_action("drv2605.calibrate", CalibrateAction, 47 | cv.Schema( 48 | { 49 | cv.Required(CONF_ID): cv.use_id(DRV2605Component), 50 | } 51 | ) 52 | ) 53 | async def drv2605_calibrate_to_code(config, action_id, template_arg, args): 54 | paren = await cg.get_variable(config[CONF_ID]) 55 | var = cg.new_Pvariable(action_id, template_arg, paren) 56 | return var 57 | 58 | @automation.register_action("drv2605.reset", ResetAction, 59 | cv.Schema( 60 | { 61 | cv.Required(CONF_ID): cv.use_id(DRV2605Component), 62 | } 63 | ) 64 | ) 65 | async def drv2605_reset_to_code(config, action_id, template_arg, args): 66 | paren = await cg.get_variable(config[CONF_ID]) 67 | var = cg.new_Pvariable(action_id, template_arg, paren) 68 | return var 69 | 70 | 71 | async def to_code(config): 72 | lra_rated_voltage = config[CONF_RATED_VOLTAGE] 73 | lra_freq = config[CONF_RESONANT_FREQUENCY] 74 | print(f"rated voltage = {lra_rated_voltage} resonant freq = {lra_freq}") 75 | rated_voltage_reg = lra_rated_voltage / 20.58e-3 * math.sqrt(1-(4*300e-6+300e-6) * lra_freq) 76 | # 72.78418503101999 77 | print(f"Calculated LRA rated voltage register to be set to {rated_voltage_reg}") 78 | #overdrive_reg = lra_rated_voltage / (21.32e-3 * math.sqrt(1 - 800e-6 * 205)) 79 | overdrive_reg = lra_rated_voltage * 255 / 5.6 # from some xlsx helper? 80 | print(f"Calculated LRA overdrive register to be set to {overdrive_reg}") 81 | # 92.33836194508126 82 | lra_period_ms = (1.0 / lra_freq) * 1000 83 | optimum_drive_time = lra_period_ms * 0.5; 84 | drive_time_reg = (optimum_drive_time - 0.5) / 0.1 85 | print(f"Calculated drive time register to be set to {drive_time_reg}") 86 | cv.int_range(0,255)(int(overdrive_reg)) 87 | cv.int_range(0,255)(int(rated_voltage_reg)) 88 | cv.int_range(0,31)(int(drive_time_reg)) 89 | var = cg.new_Pvariable(config[CONF_ID]) 90 | await cg.register_component(var, config) 91 | await i2c.register_i2c_device(var, config) 92 | en_pin_var = await cg.gpio_pin_expression(config[CONF_EN_PIN]) 93 | 94 | # hash the name to save prefs 95 | hash_ = int(hashlib.md5(config[CONF_ID].id.encode()).hexdigest()[:8], 16) 96 | print(f"DRV2605 name hash is {hex(hash_)}") 97 | cg.add(var.set_name_hash(hash_)) 98 | 99 | cg.add(var.set_en_pin(en_pin_var)) 100 | cg.add(var.set_rated_voltage_reg(int(rated_voltage_reg))) 101 | cg.add(var.set_overdrive_reg(int(overdrive_reg))) 102 | cg.add(var.set_drive_time_reg_value(int(drive_time_reg))) 103 | 104 | -------------------------------------------------------------------------------- /lightboard.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | device_name: lightyio 3 | 4 | esphome: 5 | name: lightyio 6 | friendly_name: lightyio 7 | 8 | esp32: 9 | board: wesp32 10 | # framework: 11 | # type: esp-idf 12 | # version: recommended 13 | framework: 14 | type: arduino 15 | 16 | # Enable logging 17 | logger: 18 | level: DEBUG 19 | 20 | packages: 21 | #irk_locator: !include irk_locator.yaml 22 | dac1: !include {file: dac_light.yaml, vars: { address: 0x4C, id: U11, 23 | light1: "Light 1", light1_warm_ct: "5000 K", light1_cool_ct: "3000 K", 24 | light2: "Light 2", light2_warm_ct: "5000 K", light2_cool_ct: "3000 K", 25 | light3: "Light 3", light3_warm_ct: "5000 K", light3_cool_ct: "3000 K", 26 | light4: "Light 4", light4_warm_ct: "5000 K", light4_cool_ct: "3000 K", 27 | }} 28 | dac2: !include {file: dac_light.yaml, vars: { address: 0x48, id: U9, 29 | light1: "Light 5", light1_warm_ct: "5000 K", light1_cool_ct: "3000 K", 30 | light2: "Light 6", light2_warm_ct: "5000 K", light2_cool_ct: "3000 K", 31 | light3: "Light 7", light3_warm_ct: "5000 K", light3_cool_ct: "3000 K", 32 | light4: "Light 8", light4_warm_ct: "5000 K", light4_cool_ct: "3000 K", 33 | }} 34 | dac3: !include {file: dac_light.yaml, vars: { address: 0x4A, id: U7, 35 | light1: "Light 9", light1_warm_ct: "5000 K", light1_cool_ct: "3000 K", 36 | light2: "Light 10", light2_warm_ct: "5000 K", light2_cool_ct: "3000 K", 37 | light3: "Light 11", light3_warm_ct: "5000 K", light3_cool_ct: "3000 K", 38 | light4: "Light 12", light4_warm_ct: "5000 K", light4_cool_ct: "3000 K", 39 | }} 40 | 41 | # Enable Home Assistant API 42 | api: 43 | 44 | ota: 45 | password: "" 46 | 47 | ethernet: 48 | type: RTL8201 49 | mdc_pin: GPIO16 50 | mdio_pin: GPIO17 51 | clk_mode: GPIO0_IN 52 | phy_addr: 0 53 | 54 | i2c: 55 | scl: GPIO4 56 | sda: GPIO15 57 | scan: true 58 | id: i2c_bus 59 | 60 | binary_sensor: 61 | - platform: status 62 | name: ${device_name} status 63 | - platform: gpio 64 | pin: 65 | number: GPIO23 66 | mode: 67 | input: true 68 | pullup: true 69 | name: Pushbutton 70 | filters: # debounce 71 | - delayed_on_off: 10ms 72 | # on_press: 73 | # then: 74 | # - switch.turn_off: en_5v 75 | # - delay: 500ms 76 | # - button.press: restart_internal 77 | 78 | switch: 79 | # - platform: gpio 80 | # id: en_5v 81 | # pin: GPIO12 82 | # name: Enable 5V 83 | - platform: gpio 84 | pin: GPIO32 85 | id: ntc_vcc 86 | internal: true 87 | name: NTC VCC switch 88 | 89 | 90 | output: 91 | - platform: gpio 92 | id: ldac 93 | pin: GPIO2 94 | 95 | button: 96 | - platform: restart 97 | name: Restart ESP32 98 | id: restart_internal 99 | entity_category: config 100 | - platform: output 101 | name: LDAC 102 | output: ldac 103 | duration: 1ms 104 | 105 | sensor: 106 | - platform: internal_temperature 107 | name: "Internal Temperature" 108 | entity_category: diagnostic 109 | - platform: ntc 110 | name: "Isolated Section Left" 111 | sensor: left_resistance 112 | entity_category: diagnostic 113 | calibration: 114 | b_constant: 3435 115 | reference_temperature: 25C 116 | reference_resistance: 10kOhm 117 | - platform: resistance 118 | id: left_resistance 119 | sensor: left_source 120 | configuration: UPSTREAM 121 | resistor: 10kOhm 122 | - platform: adc 123 | pin: GPIO34 124 | id: left_source 125 | update_interval: never 126 | attenuation: 11db 127 | internal: true 128 | name: Left NTC ADC 129 | - platform: ntc 130 | name: "Isolated Section Right" 131 | sensor: right_resistance 132 | entity_category: diagnostic 133 | calibration: 134 | b_constant: 3435 135 | reference_temperature: 25C 136 | reference_resistance: 10kOhm 137 | - platform: resistance 138 | id: right_resistance 139 | sensor: right_source 140 | configuration: UPSTREAM 141 | resistor: 10kOhm 142 | - platform: adc 143 | pin: GPIO39 144 | id: right_source 145 | update_interval: never 146 | attenuation: 11db 147 | name: Right NTC ADC 148 | internal: true 149 | 150 | interval: 151 | - interval: 60s 152 | then: 153 | - switch.turn_on: ntc_vcc 154 | - component.update: left_source 155 | - component.update: right_source 156 | - switch.turn_off: ntc_vcc 157 | -------------------------------------------------------------------------------- /custom_components/irk_enrollment/irk_enrollment.cpp: -------------------------------------------------------------------------------- 1 | #include "irk_enrollment.h" 2 | #include "esphome/components/esp32_ble/ble.h" 3 | #include "esphome/core/application.h" 4 | #include "esphome/core/log.h" 5 | #include "esphome/core/version.h" 6 | 7 | 8 | #ifdef USE_ESP32 9 | 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | 19 | 20 | namespace esphome { 21 | namespace irk_enrollment { 22 | 23 | static const char *const TAG = "irk_enrollment.component"; 24 | 25 | constexpr char hexmap[] = {'0', '1', '2', '3', '4', '5', '6', '7', 26 | '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; 27 | 28 | static std::string hexStr(unsigned char *data, int len) 29 | { 30 | std::string s(len * 2, ' '); 31 | for (int i = 0; i < len; ++i) { 32 | s[2 * i] = hexmap[(data[len-1-i] & 0xF0) >> 4]; 33 | s[2 * i + 1] = hexmap[data[len-1-i] & 0x0F]; 34 | } 35 | return s; 36 | } 37 | 38 | void IrkEnrollmentComponent::setup() { 39 | auto service_uuid = esphome::esp32_ble::ESPBTUUID::from_uint16(0x1812); 40 | esp32_ble_server::global_ble_server->create_service(service_uuid, true); 41 | this->service_ = esp32_ble_server::global_ble_server->get_service(service_uuid); 42 | esp32_ble::global_ble->advertising_add_service_uuid(service_uuid); 43 | // TODO seems like the below configuration is unneeded, but need to confirm with a "clean" device 44 | //esp_ble_auth_req_t auth_req = ESP_LE_AUTH_BOND; //bonding with peer device after authentication 45 | //uint8_t key_size = 16; //the key size should be 7~16 bytes 46 | //uint8_t init_key = ESP_BLE_ID_KEY_MASK; 47 | //uint8_t rsp_key = ESP_BLE_ID_KEY_MASK; 48 | //esp_ble_gap_set_security_param(ESP_BLE_SM_AUTHEN_REQ_MODE, &auth_req, sizeof(uint8_t)); 49 | //esp_ble_gap_set_security_param(ESP_BLE_SM_MAX_KEY_SIZE, &key_size, sizeof(uint8_t)); 50 | //esp_ble_gap_set_security_param(ESP_BLE_SM_SET_INIT_KEY, &init_key, sizeof(uint8_t)); 51 | //esp_ble_gap_set_security_param(ESP_BLE_SM_SET_RSP_KEY, &rsp_key, sizeof(uint8_t)); 52 | //delay(200); // NOLINT 53 | } 54 | 55 | void IrkEnrollmentComponent::dump_config() { 56 | ESP_LOGCONFIG(TAG, "ESP32 IRK Enrollment:"); 57 | LOG_TEXT_SENSOR(" ", "Latest IRK", this->latest_irk_); 58 | } 59 | 60 | void IrkEnrollmentComponent::loop() { 61 | //ESP_LOGD(TAG, " dumping bonds:"); 62 | int dev_num = esp_ble_get_bond_device_num(); 63 | if (dev_num > 1) { 64 | ESP_LOGW(TAG, "We have %d bonds, where we expect to only ever have 0 or 1", dev_num); 65 | } 66 | esp_ble_bond_dev_t bond_devs[dev_num]; 67 | esp_ble_get_bond_device_list(&dev_num, bond_devs); 68 | for (int i = 0; i < dev_num; i++) { 69 | ESP_LOGI(TAG, " remote DB_ADDR: %08x%04x", 70 | (bond_devs[i].bd_addr[0] << 24) + (bond_devs[i].bd_addr[1] << 16) + (bond_devs[i].bd_addr[2] << 8) + 71 | bond_devs[i].bd_addr[3], 72 | (bond_devs[i].bd_addr[4] << 8) + bond_devs[i].bd_addr[5]); 73 | auto irkStr = hexStr((unsigned char *) &bond_devs[i].bond_key.pid_key.irk, 16); 74 | ESP_LOGI(TAG, " irk: %s", irkStr.c_str()); 75 | if (this->latest_irk_ != nullptr && this->latest_irk_->get_state() != irkStr) { 76 | this->latest_irk_->publish_state(irkStr); 77 | } 78 | esp_ble_gap_disconnect(bond_devs[i].bd_addr); 79 | esp_ble_remove_bond_device(bond_devs[i].bd_addr); 80 | ESP_LOGI(TAG, " Disconnected and removed bond"); 81 | } 82 | } 83 | void IrkEnrollmentComponent::start() {} 84 | void IrkEnrollmentComponent::stop() {} 85 | float IrkEnrollmentComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } 86 | 87 | 88 | void IrkEnrollmentComponent::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, 89 | esp_ble_gatts_cb_param_t *param) { 90 | //ESP_LOGD(TAG, "in gatts event handler"); 91 | switch (event) { 92 | case ESP_GATTS_CONNECT_EVT: 93 | //start security connect with peer device when receive the connect event sent by the master. 94 | esp_ble_set_encryption(param->connect.remote_bda, ESP_BLE_SEC_ENCRYPT_MITM); 95 | //ESP_LOGD(TAG, " connect evt"); 96 | break; 97 | case ESP_GAP_BLE_KEY_EVT: 98 | //shows the ble key info share with peer device to the user. 99 | //ESP_LOGI(TAG, "key type = %s", esp_key_type_to_str(param->ble_security.ble_key.key_type)); 100 | //ESP_LOGD(TAG, " ble key evt"); 101 | break; 102 | case ESP_GAP_BLE_AUTH_CMPL_EVT: { 103 | //ESP_LOGD(TAG, " auth cmpl evt"); 104 | //esp_bd_addr_t bd_addr; 105 | //memcpy(bd_addr, param->ble_security.auth_cmpl.bd_addr, 106 | // sizeof(esp_bd_addr_t)); 107 | //ESP_LOGI(TAG, "remote BD_ADDR: %08x%04x", 108 | // (bd_addr[0] << 24) + (bd_addr[1] << 16) + (bd_addr[2] << 8) + 109 | // bd_addr[3], 110 | // (bd_addr[4] << 8) + bd_addr[5]); 111 | //ESP_LOGI(TAG, "address type = %d", 112 | // param->ble_security.auth_cmpl.addr_type); 113 | //ESP_LOGI(TAG, "pair status = %s", 114 | // param->ble_security.auth_cmpl.success ? "success" : "fail"); 115 | break; 116 | default: 117 | //ESP_LOGD(TAG, " other evt"); 118 | break; 119 | } 120 | } 121 | } 122 | 123 | 124 | } // namespace irk_enrollment 125 | } // namespace esphome 126 | 127 | #endif 128 | -------------------------------------------------------------------------------- /goportparking.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from selenium import webdriver 4 | from selenium.webdriver.chrome.options import Options 5 | from selenium.webdriver.chrome.service import Service 6 | from selenium.webdriver.common.by import By 7 | from selenium.common.exceptions import NoSuchElementException 8 | 9 | import hassapi as hass 10 | import traceback 11 | import adbase as ad 12 | 13 | JS_WAIT = 1 14 | WEB_WAIT = 5 15 | 16 | class GoPortParkingController(hass.Hass): 17 | def initialize(self): 18 | chrome_options = Options() 19 | chrome_options.add_argument('--headless') 20 | chrome_options.add_argument('--no-sandbox') 21 | chrome_options.add_argument('--disable-gpu') 22 | chrome_options.add_argument('--disable-dev-shm-usage') 23 | chrome_options.add_argument('--disable-extensions') 24 | chrome_service = Service(executable_path='/usr/bin/chromedriver') 25 | self.driver = webdriver.Chrome(service=chrome_service, options=chrome_options) 26 | self.log(f"initialized parking driver") 27 | self.buttons = [] 28 | for plate in self.args['plates']: 29 | entity_id = f"button.quick_buy_daily_{plate}" 30 | self.buttons.append(entity_id) 31 | entity = self.get_entity(entity_id) 32 | entity.set_state(state='unknown', attributes={'friendly_name': f"Quick buy daily goportparking pass for {plate}", 'plate': plate, 'detail': 'Ready to buy'}) 33 | def filter_quick_buy_button(x): 34 | entity = x.get('entity_id') 35 | if isinstance(entity, list) and len(entity) == 1: 36 | entity = entity[0] 37 | if isinstance(entity, str): 38 | return entity.startswith('button.quick_buy_daily_') 39 | return False 40 | self.listen_event(self.book_daily, "call_service", domain="button", service="press", service_data=filter_quick_buy_button) 41 | self.run_daily(self.reset_state, '3:00:00') 42 | 43 | def terminate(self): 44 | try: 45 | self.driver.close() 46 | except Exception: 47 | self.log(f"failed to terminate properly: {traceback.format_exc()}") 48 | 49 | def reset_state(self, kwargs): 50 | for plate in self.args['plates']: 51 | entity_id = f"button.quick_buy_daily_{plate}" 52 | entity = self.get_entity(entity_id) 53 | entity.set_state(state='unknown', attributes={'detail': 'Ready to buy'}) 54 | 55 | def book_daily(self, event_name, data, kwargs): 56 | entity = data['service_data']['entity_id'] 57 | if isinstance(entity, list) and len(entity) == 1: 58 | entity = entity[0] 59 | if not isinstance(entity, str) or not entity.startswith('button.quick_buy_daily_'): 60 | self.log(f"entity wasn't expected: {entity}") 61 | return 62 | button_attrs = self.get_state(entity, attribute="all") 63 | self.log(f"attrs of {entity} = {button_attrs}") 64 | plate = button_attrs['attributes']['plate'] 65 | self.get_entity(entity).set_state(state='opening_portal', attributes={'detail': 'Opening portal'}) 66 | self.log(f"buying daily: navigating to login") 67 | self.driver.get("https://goportparking.org/rppportal/login.xhtml") 68 | time.sleep(WEB_WAIT) 69 | self.log(f"buying daily: logging in") 70 | username = self.driver.find_element(By.ID, "username") 71 | username.clear() 72 | username.send_keys(self.args['username']) 73 | password = self.driver.find_element(By.ID, "password") 74 | password.clear() 75 | password.send_keys(self.args['password']) 76 | self.driver.find_element(By.ID, "login").click() 77 | self.get_entity(entity).set_state(state='logging_in', attributes={'detail': 'Logging in...'}) 78 | time.sleep(WEB_WAIT) 79 | if self.driver.current_url != 'https://goportparking.org/rppportal/index.xhtml': 80 | self.log(f"Login seems to have failed") 81 | self.get_entity(entity).set_state(state='login_failed', attributes={'detail': 'Login failed'}) 82 | return 83 | self.log(f"buying daily: activating quick-buy (on url {self.driver.current_url})") 84 | self.get_entity(entity).set_state(state='purchasing_daily', attributes={'detail': 'Purchasing daily pass...'}) 85 | try: 86 | quick_buy = self.driver.find_element(By.PARTIAL_LINK_TEXT, plate) 87 | except NoSuchElementException: 88 | drop_down = self.driver.find_element(By.CLASS_NAME, 'caret') 89 | drop_down.click() 90 | time.sleep(JS_WAIT) 91 | quick_buy = self.driver.find_element(By.PARTIAL_LINK_TEXT, plate) 92 | quick_buy.click() 93 | time.sleep(WEB_WAIT) 94 | self.log(f"buying daily: confirming quick-buy") 95 | self.get_entity(entity).set_state(state='confirming_purchase', attributes={'detail': 'Confirming purchase...'}) 96 | quick_buy_confirm = self.driver.find_element(By.XPATH, "//span[@id='quickBuyConfirmPanel']//input[@Value='Yes']") 97 | quick_buy_confirm.click() 98 | print(f"bought the daily pass (confirm={quick_buy_confirm})") 99 | 100 | time.sleep(WEB_WAIT) 101 | self.log(f"Next, navigate to the portal's index page") 102 | self.driver.get("https://goportparking.org/rppportal/index.xhtml") 103 | self.log(f"Navigating to the portal's index page") 104 | time.sleep(WEB_WAIT) 105 | try: 106 | xpath = f'//div[contains(@class, "panel") and .//h3[contains(text(), "Your RPPs")] and .//li[contains(@class, "active") and ./a[contains(text(), "Current RPPs")]] and .//td[./span[contains(text(), "Plate")] and ./span[contains(text(), "{plate}") and contains(@class, "text-success")]]]' 107 | self.log(f"Find element by xpath {xpath}") 108 | self.driver.find_element(By.XPATH, xpath) 109 | self.log(f"Found active parking pass on RPP portal for {plate}") 110 | self.get_entity(entity).set_state(state='successfully_purchased', attributes={'detail': 'Purchase successfully completed.'}) 111 | except NoSuchElementException as nse: 112 | self.log(f"Unexpected state from RPP portal for {plate}: {nse}") 113 | self.get_entity(entity).set_state(state='error', attributes={'detail': 'No active parking passes, check the website.'}) 114 | 115 | -------------------------------------------------------------------------------- /custom_components/drv2605/drv2605.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/core/hal.h" 5 | #include "esphome/core/defines.h" 6 | #include "esphome/core/preferences.h" 7 | #include "esphome/core/automation.h" 8 | #include "esphome/components/i2c/i2c.h" 9 | 10 | //The Status Register (0x00): The Device ID is bits 7-5. For DRV2605L it should be 7 or 111. 11 | //bits 4 and 2 are reserved. Bit 3 is the diagnostic result. You want to see 0. 12 | //bit 1 is the over temp flag, you want this to be 0 13 | //bit 0 is over current flag, you want this to be zero. 14 | // Ideally the register will read 0xE0. 15 | #define STATUS_REG 0x00 16 | 17 | //The Mode Register (0x01): 18 | //Default 010000000 -- Need to get it out of Standby 19 | //Set to 0000 0000=0x00 to use Internal Trigger 20 | //Set to 0000 0001=0x01 to use External Trigger (edge mode)(like a switch on the IN pin) 21 | //Set to 0000 0010=0x02 to use External Trigger (level mode) 22 | //Set to 0000 0011=0x03 to use PWM input and analog output 23 | //Set to 0000 0100=0x04 to use Audio to Vibe 24 | //Set to 0000 0101=0x05 to use Real-Time Playback 25 | //Set to 0000 0110=0x06 to perform a diagnostic test - result stored in Diagnostic bit in register 0x00 26 | //Set to 0000 0111 =0x07 to run auto calibration 27 | #define MODE_REG 0x01 28 | 29 | //The Feedback Control Register (0x1A) 30 | //bit 7: 0 for ERM, 1 for LRA -- Default is 0 31 | //Bits 6-4 control brake factor 32 | //bits 3-2 control the Loop gain 33 | //bit 1-0 control the BEMF gain 34 | #define FEEDBACK_REG 0x1A 35 | 36 | //The Real-Time Playback Register (0x02) 37 | //There are 6 ERM libraries. 38 | #define RTP_REG 0x02 39 | 40 | //The Library Selection Register (0x03) 41 | //See table 1 in Data Sheet for 42 | #define LIB_REG 0x03 43 | 44 | //The waveform Sequencer Register (0X04 to 0x0B) 45 | #define WAVESEQ1 0x04 //Bit 7: set this include a wait time between playback 46 | #define WAVESEQ2 0x05 47 | #define WAVESEQ3 0x06 48 | #define WAVESEQ4 0x07 49 | #define WAVESEQ5 0x08 50 | #define WAVESEQ6 0x09 51 | #define WAVESEQ7 0x0A 52 | #define WAVESEQ8 0x0B 53 | 54 | //The Go register (0x0C) 55 | //Set to 0000 0001=0x01 to set the go bit 56 | #define GO_REG 0x0C 57 | 58 | //The Overdrive Time Offset Register (0x0D) 59 | //Only useful in open loop mode 60 | #define OVERDRIVE_REG 0x0D 61 | 62 | //The Sustain Time Offset, Positive Register (0x0E) 63 | #define SUSTAINOFFSETPOS_REG 0x0E 64 | 65 | //The Sustain Time Offset, Negative Register (0x0F) 66 | #define SUSTAINOFFSETNEG_REG 0x0F 67 | 68 | //The Break Time Offset Register (0x10) 69 | #define BREAKTIME_REG 0x10 70 | 71 | //The Audio to Vibe control Register (0x11) 72 | #define AUDIOCTRL_REG 0x11 73 | 74 | //The Audio to vibe minimum input level Register (0x12) 75 | #define AUDMINLVL_REG 0x12 76 | 77 | //The Audio to Vibe maximum input level Register (0x13) 78 | #define AUDMAXLVL_REG 0x13 79 | 80 | // Audio to Vibe minimum output Drive Register (0x14) 81 | #define AUDMINDRIVE_REG 0x14 82 | 83 | //Audio to Vibe maximum output Drive Register (0x15) 84 | #define AUDMAXDRIVE_REG 0X15 85 | 86 | //The rated Voltage Register (0x16) 87 | #define RATEDVOLT_REG 0x16 88 | 89 | //The Overdive clamp Voltage (0x17) 90 | #define OVERDRIVECLAMP_REG 0x17 91 | 92 | //The Auto-Calibration Compensation - Result Register (0x18) 93 | #define COMPRESULT_REG 0x18 94 | 95 | //The Auto-Calibration Back-EMF Result Register (0x19) 96 | #define BACKEMF_REG 0x19 97 | 98 | //The Control1 Register (0x1B) 99 | //For AC coupling analog inputs and 100 | //Controlling Drive time 101 | #define CONTROL1_REG 0x1B 102 | 103 | //The Control2 Register (0x1C) 104 | //See Data Sheet page 45 105 | #define CONTROL2_REG 0x1C 106 | 107 | //The COntrol3 Register (0x1D) 108 | //See data sheet page 48 109 | #define CONTROL3_REG 0x1D 110 | 111 | //The Control4 Register (0x1E) 112 | //See Data sheet page 49 113 | #define CONTROL4_REG 0x1E 114 | 115 | //The Control5 Register (0x1F) 116 | //See Data Sheet page 50 117 | #define CONTROL5_REG 0X1F 118 | 119 | //The LRA Open Loop Period Register (0x20) 120 | //This register sets the period to be used for driving an LRA when 121 | //Open Loop mode is selected: see data sheet page 50. 122 | #define OLP_REG 0x20 123 | 124 | //The V(Batt) Voltage Monitor Register (0x21) 125 | //This bit provides a real-time reading of the supply voltage 126 | //at the VDD pin. The Device must be actively sending a waveform to take 127 | //reading Vdd=Vbatt[7:0]*5.6V/255 128 | #define VBATMONITOR_REG 0x21 129 | 130 | //The LRA Resonance-Period Register 131 | //This bit reports the measurement of the LRA resonance period 132 | #define LRARESPERIOD_REG 0x22 133 | 134 | namespace esphome { 135 | namespace drv2605 { 136 | 137 | struct DRV2605CalibrationData { 138 | uint8_t bemf_gain; 139 | uint8_t compensation; 140 | uint8_t backemf; 141 | }; 142 | 143 | class DRV2605Component : public i2c::I2CDevice, public Component { 144 | public: 145 | void setup() override; 146 | void loop() override; 147 | void dump_config() override; 148 | void set_en_pin(GPIOPin *pin) { this->en_pin_ = pin; } 149 | void set_rated_voltage_reg(uint8_t x) { this->rated_voltage_reg_value = x; } 150 | void set_overdrive_reg(uint8_t x) { this->overdrive_reg_value = x; } 151 | void set_drive_time_reg_value(uint8_t x) { this->drive_time_reg_value = x; } 152 | void fire_waveform(uint8_t waveform_id); 153 | void calibrate(); 154 | void reset(); 155 | void set_name_hash(uint32_t name_hash) { this->name_hash_ = name_hash; } 156 | 157 | protected: 158 | void populate_config_regs(); 159 | GPIOPin *en_pin_; 160 | bool en_pending_deassert_; 161 | bool pending_reset_; 162 | bool pending_calibrate_; 163 | uint8_t rated_voltage_reg_value; 164 | uint8_t overdrive_reg_value; 165 | uint8_t drive_time_reg_value; 166 | ESPPreferenceObject pref_; 167 | DRV2605CalibrationData calibration_data_; 168 | bool has_calibration; 169 | uint32_t name_hash_{}; 170 | }; 171 | 172 | 173 | template class FireHapticAction : public Action { 174 | public: 175 | FireHapticAction(DRV2605Component *parent) : parent_(parent) {} 176 | TEMPLATABLE_VALUE(uint8_t, waveform_id); 177 | 178 | void play(Ts... x) override { 179 | uint8_t waveform_id = this->waveform_id_.value(x...); 180 | this->parent_->fire_waveform(waveform_id); 181 | } 182 | 183 | DRV2605Component *parent_; 184 | }; 185 | 186 | template class CalibrateAction : public Action { 187 | public: 188 | CalibrateAction(DRV2605Component *parent) : parent_(parent) {} 189 | 190 | void play(Ts... x) override { 191 | this->parent_->calibrate(); 192 | } 193 | 194 | DRV2605Component *parent_; 195 | }; 196 | 197 | template class ResetAction : public Action { 198 | public: 199 | ResetAction(DRV2605Component *parent) : parent_(parent) {} 200 | 201 | void play(Ts... x) override { 202 | this->parent_->reset(); 203 | } 204 | 205 | DRV2605Component *parent_; 206 | }; 207 | 208 | } // namespace drv2605 209 | } // namespace esphome 210 | 211 | -------------------------------------------------------------------------------- /irk_resolver.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #ifdef USE_ARDUINO 4 | #include "mbedtls/aes.h" 5 | #include "mbedtls/base64.h" 6 | #endif 7 | 8 | #ifdef USE_ESP_IDF 9 | #define MBEDTLS_AES_ALT 1 10 | #include 11 | #endif 12 | 13 | //#ifdef USE_ARDUINO 14 | //#elif defined(USE_ESP_IDF) 15 | //#endif 16 | 17 | int bt_encrypt_be(const uint8_t *key, const uint8_t *plaintext, uint8_t *enc_data) { 18 | mbedtls_aes_context s = { 19 | 0 20 | #ifdef USE_ESP_IDF 21 | , 0, 0 22 | #endif 23 | }; 24 | mbedtls_aes_init(&s); 25 | 26 | if (mbedtls_aes_setkey_enc(&s, key, 128) != 0) { 27 | mbedtls_aes_free(&s); 28 | return -1; 29 | } 30 | 31 | if (mbedtls_aes_crypt_ecb(&s, 32 | #ifdef USE_ARDUINO 33 | MBEDTLS_AES_ENCRYPT, 34 | #elif defined(USE_ESP_IDF) 35 | ESP_AES_ENCRYPT, 36 | #endif 37 | plaintext, enc_data) != 0) { 38 | mbedtls_aes_free(&s); 39 | return -1; 40 | } 41 | 42 | mbedtls_aes_free(&s); 43 | return 0; 44 | } 45 | 46 | struct encryption_block { 47 | uint8_t key[16]; 48 | uint8_t plain_text[16]; 49 | uint8_t cipher_text[16]; 50 | }; 51 | 52 | bool ble_ll_resolv_rpa(const uint8_t *rpa, const uint8_t *irk) { 53 | struct encryption_block ecb; 54 | 55 | auto irk32 = (const uint32_t *)irk; 56 | auto key32 = (uint32_t *)&ecb.key[0]; 57 | auto pt32 = (uint32_t *)&ecb.plain_text[0]; 58 | 59 | key32[0] = irk32[0]; 60 | key32[1] = irk32[1]; 61 | key32[2] = irk32[2]; 62 | key32[3] = irk32[3]; 63 | 64 | pt32[0] = 0; 65 | pt32[1] = 0; 66 | pt32[2] = 0; 67 | pt32[3] = 0; 68 | 69 | ecb.plain_text[15] = rpa[3]; 70 | ecb.plain_text[14] = rpa[4]; 71 | ecb.plain_text[13] = rpa[5]; 72 | 73 | auto err = bt_encrypt_be(ecb.key, ecb.plain_text, ecb.cipher_text); 74 | 75 | if (err) { 76 | ESP_LOGW("irk_resolve", "AES failure"); 77 | return false; 78 | } 79 | 80 | if (ecb.cipher_text[15] != rpa[0] || ecb.cipher_text[14] != rpa[1] || ecb.cipher_text[13] != rpa[2]) return false; 81 | 82 | // Serial.printf("RPA resolved %d %02x%02x%02x %02x%02x%02x\n", err, rpa[0], rpa[1], rpa[2], ecb.cipher_text[15], ecb.cipher_text[14], ecb.cipher_text[13]); 83 | 84 | return true; 85 | } 86 | 87 | static std::vector> irk_prefilters; 88 | 89 | 90 | #ifdef USE_ESP_IDF 91 | /** Output buffer too small. */ 92 | #define MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL -0x002A 93 | /** Invalid character in input. */ 94 | #define MBEDTLS_ERR_BASE64_INVALID_CHARACTER -0x002C 95 | /** Byte Reading Macros 96 | * 97 | * Given a multi-byte integer \p x, MBEDTLS_BYTE_n retrieves the n-th 98 | * byte from x, where byte 0 is the least significant byte. 99 | */ 100 | #define MBEDTLS_BYTE_0(x) ((uint8_t) ((x) & 0xff)) 101 | #define MBEDTLS_BYTE_1(x) ((uint8_t) (((x) >> 8) & 0xff)) 102 | #define MBEDTLS_BYTE_2(x) ((uint8_t) (((x) >> 16) & 0xff)) 103 | 104 | /* Return 0xff if low <= c <= high, 0 otherwise. 105 | * 106 | * Constant flow with respect to c. 107 | */ 108 | unsigned char mbedtls_ct_uchar_mask_of_range(unsigned char low, 109 | unsigned char high, 110 | unsigned char c) 111 | { 112 | /* low_mask is: 0 if low <= c, 0x...ff if low > c */ 113 | unsigned low_mask = ((unsigned) c - low) >> 8; 114 | /* high_mask is: 0 if c <= high, 0x...ff if c > high */ 115 | unsigned high_mask = ((unsigned) high - c) >> 8; 116 | return ~(low_mask | high_mask) & 0xff; 117 | } 118 | 119 | 120 | signed char mbedtls_ct_base64_dec_value(unsigned char c) 121 | { 122 | unsigned char val = 0; 123 | /* For each range of digits, if c is in that range, mask val with 124 | * the corresponding value. Since c can only be in a single range, 125 | * only at most one masking will change val. Set val to one plus 126 | * the desired value so that it stays 0 if c is in none of the ranges. */ 127 | val |= mbedtls_ct_uchar_mask_of_range('A', 'Z', c) & (c - 'A' + 0 + 1); 128 | val |= mbedtls_ct_uchar_mask_of_range('a', 'z', c) & (c - 'a' + 26 + 1); 129 | val |= mbedtls_ct_uchar_mask_of_range('0', '9', c) & (c - '0' + 52 + 1); 130 | val |= mbedtls_ct_uchar_mask_of_range('+', '+', c) & (c - '+' + 62 + 1); 131 | val |= mbedtls_ct_uchar_mask_of_range('/', '/', c) & (c - '/' + 63 + 1); 132 | /* At this point, val is 0 if c is an invalid digit and v+1 if c is 133 | * a digit with the value v. */ 134 | return val - 1; 135 | } 136 | 137 | /* 138 | * Decode a base64-formatted buffer 139 | */ 140 | int mbedtls_base64_decode(unsigned char *dst, size_t dlen, size_t *olen, 141 | const unsigned char *src, size_t slen) 142 | { 143 | size_t i; /* index in source */ 144 | size_t n; /* number of digits or trailing = in source */ 145 | uint32_t x; /* value accumulator */ 146 | unsigned accumulated_digits = 0; 147 | unsigned equals = 0; 148 | int spaces_present = 0; 149 | unsigned char *p; 150 | 151 | /* First pass: check for validity and get output length */ 152 | for (i = n = 0; i < slen; i++) { 153 | /* Skip spaces before checking for EOL */ 154 | spaces_present = 0; 155 | while (i < slen && src[i] == ' ') { 156 | ++i; 157 | spaces_present = 1; 158 | } 159 | 160 | /* Spaces at end of buffer are OK */ 161 | if (i == slen) { 162 | break; 163 | } 164 | 165 | if ((slen - i) >= 2 && 166 | src[i] == '\r' && src[i + 1] == '\n') { 167 | continue; 168 | } 169 | 170 | if (src[i] == '\n') { 171 | continue; 172 | } 173 | 174 | /* Space inside a line is an error */ 175 | if (spaces_present) { 176 | return MBEDTLS_ERR_BASE64_INVALID_CHARACTER; 177 | } 178 | 179 | if (src[i] > 127) { 180 | return MBEDTLS_ERR_BASE64_INVALID_CHARACTER; 181 | } 182 | 183 | if (src[i] == '=') { 184 | if (++equals > 2) { 185 | return MBEDTLS_ERR_BASE64_INVALID_CHARACTER; 186 | } 187 | } else { 188 | if (equals != 0) { 189 | return MBEDTLS_ERR_BASE64_INVALID_CHARACTER; 190 | } 191 | if (mbedtls_ct_base64_dec_value(src[i]) < 0) { 192 | return MBEDTLS_ERR_BASE64_INVALID_CHARACTER; 193 | } 194 | } 195 | n++; 196 | } 197 | 198 | if (n == 0) { 199 | *olen = 0; 200 | return 0; 201 | } 202 | 203 | /* The following expression is to calculate the following formula without 204 | * risk of integer overflow in n: 205 | * n = ( ( n * 6 ) + 7 ) >> 3; 206 | */ 207 | n = (6 * (n >> 3)) + ((6 * (n & 0x7) + 7) >> 3); 208 | n -= equals; 209 | 210 | if (dst == NULL || dlen < n) { 211 | *olen = n; 212 | return MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL; 213 | } 214 | 215 | equals = 0; 216 | for (x = 0, p = dst; i > 0; i--, src++) { 217 | if (*src == '\r' || *src == '\n' || *src == ' ') { 218 | continue; 219 | } 220 | 221 | x = x << 6; 222 | if (*src == '=') { 223 | ++equals; 224 | } else { 225 | x |= mbedtls_ct_base64_dec_value(*src); 226 | } 227 | 228 | if (++accumulated_digits == 4) { 229 | accumulated_digits = 0; 230 | *p++ = MBEDTLS_BYTE_2(x); 231 | if (equals <= 1) { 232 | *p++ = MBEDTLS_BYTE_1(x); 233 | } 234 | if (equals <= 0) { 235 | *p++ = MBEDTLS_BYTE_0(x); 236 | } 237 | } 238 | } 239 | 240 | *olen = p - dst; 241 | 242 | return 0; 243 | } 244 | #endif 245 | -------------------------------------------------------------------------------- /custom_components/nau8810/nau881x_regs.h: -------------------------------------------------------------------------------- 1 | #ifndef MR_NAU881X_REGS_H 2 | #define MR_NAU881X_REGS_H 3 | 4 | #ifdef __cplusplus 5 | extern "C" { 6 | #endif 7 | 8 | #include 9 | 10 | #define NAU881X_I2C_ADDRESS (0x1A << 1) 11 | 12 | #define NAU881X_PART_NAU8810 0 13 | #define NAU881X_PART_NAU8814 1 14 | 15 | typedef enum _nau881x_register 16 | { 17 | NAU881X_REG_SOFTWARE_RESET = 0, 18 | NAU881X_REG_POWER_MANAGEMENT_1 = 1, 19 | NAU881X_REG_POWER_MANAGEMENT_2 = 2, 20 | NAU881X_REG_POWER_MANAGEMENT_3 = 3, 21 | NAU881X_REG_AUDIO_INTERFACE = 4, 22 | NAU881X_REG_COMPANDING_CTRL = 5, 23 | NAU881X_REG_CLOCK_CTRL_1 = 6, 24 | NAU881X_REG_CLOCK_CTRL_2 = 7, 25 | NAU8814_REG_GPIO_CTRL = 8, 26 | 27 | NAU881X_REG_DAC_CTRL = 10, 28 | NAU881X_REG_DAC_VOL = 11, 29 | 30 | NAU881X_REG_ADC_CTRL = 14, 31 | NAU881X_REG_ADC_VOL = 15, 32 | 33 | NAU881X_REG_EQ_1 = 18, 34 | NAU881X_REG_EQ_2 = 19, 35 | NAU881X_REG_EQ_3 = 20, 36 | NAU881X_REG_EQ_4 = 21, 37 | NAU881X_REG_EQ_5 = 22, 38 | 39 | NAU881X_REG_DAC_LIMITER_1 = 24, 40 | NAU881X_REG_DAC_LIMITER_2 = 25, 41 | 42 | NAU881X_REG_NOTCH_FILTER_0_H = 27, 43 | NAU881X_REG_NOTCH_FILTER_0_L = 28, 44 | NAU881X_REG_NOTCH_FILTER_1_H = 29, 45 | NAU881X_REG_NOTCH_FILTER_1_L = 30, 46 | 47 | NAU881X_REG_ALC_CTRL_1 = 32, 48 | NAU881X_REG_ALC_CTRL_2 = 33, 49 | NAU881X_REG_ALC_CTRL_3 = 34, 50 | NAU881X_REG_NOISE_GATE = 35, 51 | NAU881X_REG_PLL_N = 36, 52 | NAU881X_REG_PLL_K1 = 37, 53 | NAU881X_REG_PLL_K2 = 38, 54 | NAU881X_REG_PLL_K3 = 39, 55 | NAU881X_REG_ATTN_CTRL = 40, 56 | 57 | NAU881X_REG_INPUT_CTRL = 44, 58 | NAU881X_REG_PGA_GAIN_CTRL = 45, 59 | 60 | NAU881X_REG_ADC_BOOST_CTRL = 47, 61 | 62 | NAU881X_REG_OUTPUT_CTRL = 49, 63 | NAU881X_REG_SPK_MIXER_CTRL = 50, 64 | 65 | NAU881X_REG_SPK_VOL_CTRL = 54, 66 | 67 | NAU881X_REG_MONO_MIXER_CTRL = 56, 68 | 69 | NAU881X_REG_POWER_MANAGEMENT_4 = 58, 70 | NAU881X_REG_PCM_TIMESLOT = 59, 71 | NAU881X_REG_ADCOUT_DRIVE = 60, 72 | 73 | NAU881X_REG_SILICON_REV = 62, 74 | NAU881X_REG_2WIRE_ID = 63, 75 | NAU881X_REG_ADDITIONAL_ID = 64, 76 | 77 | NAU881X_REG_HIGH_VOLTAGE_CTRL = 69, 78 | NAU881X_REG_ALC_ENHANCEMENTS_1 = 70, 79 | NAU881X_REG_ALC_ENHANCEMENTS_2 = 71, 80 | 81 | NAU881X_REG_ADDITIONAL_IF_CTRL = 73, 82 | 83 | NAU881X_REG_POWER_TIE_OFF_CTRL = 75, 84 | NAU881X_REG_AGC_P2P_DET = 76, 85 | NAU881X_REG_AGC_PEAK_DET = 77, 86 | NAU881X_REG_CTRL_AND_STATUS = 78, 87 | NAU881X_REG_OUTPUT_TIE_OFF_CTRL = 79 88 | } nau881x_register_t; 89 | 90 | typedef enum _nau881x_input 91 | { 92 | NAU881X_INPUT_NONE = 0, 93 | NAU881X_INPUT_MICP = (1 << 0), 94 | NAU881X_INPUT_MICN = (1 << 1), 95 | NAU8814_INPUT_AUX = (1 << 2) 96 | } nau881x_input_t; 97 | 98 | typedef enum _nau881x_aux_mode 99 | { 100 | NAU881X_AUXMODE_INVERTING = 0, 101 | NAU881X_AUXMODE_MIXER = 1 102 | } nau881x_aux_mode_t; 103 | 104 | typedef enum _nau881x_adc_oversamplerate 105 | { 106 | NAU881X_ADC_OVERSAMPLE_64X = 0, 107 | NAU881X_ADC_OVERSAMPLE_128X = 1 108 | } nau881x_adc_oversamplerate_t; 109 | 110 | typedef enum _nau881x_hpf_mode 111 | { 112 | NAU881X_HPF_MODE_AUDIO = 0, 113 | NAU881X_HPF_MODE_APP = 1 114 | } nau881x_hpf_mode_t; 115 | 116 | typedef enum _nau881x_alc_mode 117 | { 118 | NAU881X_ALC_MODE_NORMAL = 0, 119 | NAU881X_ALC_MODE_LIMITER = 1 120 | } nau881x_alc_mode_t; 121 | 122 | typedef enum _nau881x_dac_samplerate 123 | { 124 | NAU881X_DAC_SAMPLERATE_NO_DEEMPHASIS = 0, 125 | NAU881X_DAC_SAMPLERATE_32KHZ = 1, 126 | NAU881X_DAC_SAMPLERATE_44KHZ = 2, 127 | NAU881X_DAC_SAMPLERATE_48KHZ = 3 128 | } nau881x_dac_samplerate_t; 129 | 130 | typedef enum _nau881x_eq_path 131 | { 132 | NAU881X_EQ_PATH_ADC = 0, 133 | NAU881X_EQ_PATH_DAC = 1 134 | } nau881x_eq_path_t; 135 | 136 | typedef enum _nau881x_eq_bandwidth 137 | { 138 | NAU881X_EQ_BW_NARROW = 0, 139 | NAU881X_EQ_BW_WIDE = 1 140 | } nau881x_eq_bandwidth_t; 141 | 142 | typedef enum _nau881x_eq1_cutoff_freq 143 | { 144 | NAU881X_EQ1_CUTOFF_80HZ = 0, 145 | NAU881X_EQ1_CUTOFF_105HZ = 1, 146 | NAU881X_EQ1_CUTOFF_135HZ = 2, 147 | NAU881X_EQ1_CUTOFF_175HZ = 3 148 | } nau881x_eq1_cutoff_freq_t; 149 | 150 | typedef enum _nau881x_eq2_center_freq 151 | { 152 | NAU881X_EQ2_CENTER_230HZ = 0, 153 | NAU881X_EQ2_CENTER_300HZ = 1, 154 | NAU881X_EQ2_CENTER_385HZ = 2, 155 | NAU881X_EQ2_CENTER_500HZ = 3 156 | } nau881x_eq2_center_freq_t; 157 | 158 | typedef enum _nau881x_eq3_center_freq 159 | { 160 | NAU881X_EQ3_CENTER_650HZ = 0, 161 | NAU881X_EQ3_CENTER_850HZ = 1, 162 | NAU881X_EQ3_CENTER_1100HZ = 2, 163 | NAU881X_EQ3_CENTER_1400HZ = 3 164 | } nau881x_eq3_center_freq_t; 165 | 166 | typedef enum _nau881x_eq4_center_freq 167 | { 168 | NAU881X_EQ4_CENTER_1800HZ = 0, 169 | NAU881X_EQ4_CENTER_2400HZ = 1, 170 | NAU881X_EQ4_CENTER_3200HZ = 2, 171 | NAU881X_EQ4_CENTER_4100HZ = 3 172 | } nau881x_eq4_center_freq_t; 173 | 174 | typedef enum _nau881x_eq5_cutoff_freq 175 | { 176 | NAU881X_EQ5_CUTOFF_5300HZ = 0, 177 | NAU881X_EQ5_CUTOFF_6900HZ = 1, 178 | NAU881X_EQ5_CUTOFF_9000HZ = 2, 179 | NAU881X_EQ5_CUTOFF_11700HZ = 3 180 | } nau881x_eq5_cutoff_freq_t; 181 | 182 | typedef enum _nau881x_output_source 183 | { 184 | NAU881X_OUTPUT_FROM_NONE = 0, 185 | NAU881X_OUTPUT_FROM_DAC = (1 << 0), 186 | NAU881X_OUTPUT_FROM_BYPASS = (1 << 1), 187 | NAU8814_OUTPUT_FROM_AUX = (1 << 2) 188 | } nau881x_output_source_t; 189 | 190 | typedef enum _nau881x_output 191 | { 192 | NAU881X_OUTPUT_NONE = 0, 193 | NAU881X_OUTPUT_SPK = (1 << 0), 194 | NAU881X_OUTPUT_MOUT = (1 << 1), 195 | NAU881X_OUTPUT_MOUT_DIFFERENTIAL = (1 << 2) 196 | } nau881x_output_t; 197 | 198 | typedef enum _nau8814_gpio_function 199 | { 200 | NAU8814_GPIO_FUNCTION_CSB_INPUT = 0, 201 | NAU8814_GPIO_FUNCTION_JACK_DETECT = 1, 202 | NAU8814_GPIO_FUNCTION_TEMP_OK = 2, 203 | NAU8814_GPIO_FUNCTION_AMUTE_ACTIVE = 3, 204 | NAU8814_GPIO_FUNCTION_PLL_FREQUENCY_OUTPUT = 4, 205 | NAU8814_GPIO_FUNCTION_PLL_LOCK = 5, 206 | NAU8814_GPIO_FUNCTION_HIGH = 6, 207 | NAU8814_GPIO_FUNCTION_LOW = 7 208 | } nau8814_gpio_function_t; 209 | 210 | // See Digital Audio Interfaces section in the datasheet for more details 211 | typedef enum _nau881x_audio_iface_fmt 212 | { 213 | NAU881X_AUDIO_IFACE_FMT_RIGHT_JUSTIFIED = 0, 214 | NAU881X_AUDIO_IFACE_FMT_LEFT_JUSTIFIED = 1, 215 | NAU881X_AUDIO_IFACE_FMT_I2S = 2, 216 | NAU881X_AUDIO_IFACE_FMT_PCM_A = 3, 217 | NAU881X_AUDIO_IFACE_FMT_PCM_B = 0b0100, // PCMB bit set 218 | NAU881X_AUDIO_IFACE_FMT_PCM_TIMESLOT = 0b1011 // PCMTSEN bit set 219 | } nau881x_audio_iface_fmt_t; 220 | 221 | typedef enum _nau881x_audio_iface_wl 222 | { 223 | NAU881X_AUDIO_IFACE_WL_16BITS = 0, 224 | NAU881X_AUDIO_IFACE_WL_20BITS = 1, 225 | NAU881X_AUDIO_IFACE_WL_24BITS = 2, 226 | NAU881X_AUDIO_IFACE_WL_32BITS = 3, 227 | NAU881X_AUDIO_IFACE_WL_8BITS = 4 228 | } nau881x_audio_iface_wl_t; 229 | 230 | typedef enum _nau881x_bclkdiv 231 | { 232 | NAU881X_BCLKDIV_1 = 0, 233 | NAU881X_BCLKDIV_2 = 1, 234 | NAU881X_BCLKDIV_4 = 2, 235 | NAU881X_BCLKDIV_8 = 3, 236 | NAU881X_BCLKDIV_16 = 4, 237 | NAU881X_BCLKDIV_32 = 5 238 | } nau881x_bclkdiv_t; 239 | 240 | typedef enum _nau881x_mclkdiv 241 | { 242 | NAU881X_MCLKDIV_1 = 0, 243 | NAU881X_MCLKDIV_1HALF = 1, 244 | NAU881X_MCLKDIV_2 = 2, 245 | NAU881X_MCLKDIV_3 = 3, 246 | NAU881X_MCLKDIV_4 = 4, 247 | NAU881X_MCLKDIV_6 = 5, 248 | NAU881X_MCLKDIV_8 = 6, 249 | NAU881X_MCLKDIV_12 = 7 250 | } nau881x_mclkdiv_t; 251 | 252 | typedef enum _nau881x_clksel 253 | { 254 | NAU881X_CLKSEL_MCLK = 0, 255 | NAU881X_CLKSEL_PLL = 1 256 | } nau881x_clksel_t; 257 | 258 | typedef enum _nau881x_companding 259 | { 260 | NAU881X_COMPANDING_OFF = 0, 261 | NAU881X_COMPANDING_ALAW = 2, 262 | NAU881X_COMPANDING_ULAW = 3 263 | } nau881x_companding_t; 264 | 265 | typedef enum _nau881x_power_management_3 266 | { 267 | NAU881X_PM3_DAC = (1 << 0), 268 | NAU881X_PM3_SPKMIX = (1 << 2), 269 | NAU881X_PM3_MOUTMIX = (1 << 3), 270 | NAU881X_PM3_SPKP = (1 << 5), 271 | NAU881X_PM3_SPKN = (1 << 6), 272 | NAU881X_PM3_MOUT = (1 << 7) 273 | } nau881x_pm3_t; 274 | 275 | #ifdef __cplusplus 276 | } 277 | #endif 278 | 279 | #endif // MR_NAU881X_REGS_H 280 | -------------------------------------------------------------------------------- /underbed.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | friendly_name: "Under Bed" 3 | device_name: "underbed" 4 | 5 | packages: 6 | base: !include device-base.yaml 7 | irk_locator: !include irk_locator.yaml 8 | 9 | esp32: 10 | board: esp32dev 11 | framework: 12 | type: arduino 13 | 14 | 15 | ota: 16 | 17 | 18 | light: 19 | # - platform: fastled_clockless 20 | # rgb_order: GRB 21 | # chipset: WS2812B 22 | # pin: GPIO25 23 | # num_leds: 192 24 | # name: "Under Bed Light" 25 | # id: under_bed_light 26 | # color_correct: [50%, 50%, 50%] 27 | - platform: neopixelbus 28 | type: GRB 29 | variant: WS2812 30 | pin: GPIO23 31 | # method: 32 | # type: esp32_rmt 33 | num_leds: 192 34 | name: "Under Bed Light" 35 | id: under_bed_light 36 | color_correct: [50%, 50%, 50%] 37 | esp32_touch: 38 | #setup_mode: true 39 | # low_voltage_reference: 0.5V 40 | # high_voltage_reference: 2.4V 41 | # voltage_attenuation: 1.5V 42 | # iir_filter: 50ms 43 | 44 | binary_sensor: 45 | - platform: esp32_touch 46 | name: "David Feet" 47 | pin: GPIO15 48 | threshold: 8 49 | id: david_feet 50 | - platform: esp32_touch 51 | name: "Middle Pillow" 52 | pin: GPIO2 53 | threshold: 5 54 | id: middle_pillow 55 | - platform: esp32_touch 56 | name: "Aysylu Feet" 57 | pin: GPIO4 58 | threshold: 6 59 | id: aysylu_feet 60 | - platform: esp32_touch 61 | name: "David Pillow" 62 | pin: GPIO12 63 | threshold: 8 64 | id: david_pillow 65 | - platform: esp32_touch 66 | name: "Aysylu Pillow" 67 | pin: GPIO13 68 | threshold: 6 69 | id: aysylu_pillow 70 | - platform: esp32_touch 71 | name: "Middle Feet" 72 | pin: GPIO14 73 | threshold: 6 74 | id: middle_feet 75 | - platform: template 76 | name: "Aysylu's Side" 77 | id: aysylu_side 78 | lambda: |- 79 | if (id(aysylu_feet).state || 80 | id(aysylu_pillow).state) { 81 | return true; 82 | } else { 83 | return false; 84 | } 85 | filters: 86 | - delayed_off: 30s 87 | - platform: template 88 | name: "David's Side" 89 | id: david_side 90 | lambda: |- 91 | if (id(david_feet).state || 92 | id(david_pillow).state) { 93 | return true; 94 | } else { 95 | return false; 96 | } 97 | filters: 98 | - delayed_off: 30s 99 | - platform: template 100 | name: "Middle of Bed" 101 | id: middle_side 102 | lambda: |- 103 | if (id(middle_feet).state || 104 | id(middle_pillow).state) { 105 | return true; 106 | } else { 107 | return false; 108 | } 109 | filters: 110 | - delayed_off: 30s 111 | - platform: template 112 | name: "Someone in Bed" 113 | lambda: |- 114 | if (id(david_side).state || 115 | id(middle_side).state || 116 | id(aysylu_side).state) { 117 | return true; 118 | } else { 119 | return false; 120 | } 121 | - platform: template 122 | name: "Both in Bed" 123 | lambda: |- 124 | if ((id(david_side).state && 125 | id(middle_side).state) || 126 | (id(aysylu_side).state && 127 | id(middle_side).state) || 128 | (id(aysylu_side).state && 129 | id(david_side).state)) { 130 | return true; 131 | } else { 132 | return false; 133 | } 134 | - platform: gpio 135 | pin: 136 | number: GPIO19 137 | mode: 138 | input: true 139 | name: "David PIR Under Bed" 140 | id: david_pir 141 | filters: 142 | - delayed_off: 30s 143 | on_press: 144 | then: 145 | - script.execute: 146 | id: turn_on_underbed_light 147 | activate: true 148 | on_release: 149 | then: 150 | - script.execute: 151 | id: turn_on_underbed_light 152 | activate: false 153 | - platform: gpio 154 | pin: 155 | number: GPIO21 156 | mode: 157 | input: true 158 | name: "Aysylu PIR Under Bed" 159 | id: aysylu_pir 160 | filters: 161 | - delayed_off: 30s 162 | on_press: 163 | then: 164 | - script.execute: 165 | id: turn_on_underbed_light 166 | activate: true 167 | on_release: 168 | then: 169 | - script.execute: 170 | id: turn_on_underbed_light 171 | activate: false 172 | - platform: homeassistant 173 | entity_id: input_boolean.dna_sleeping 174 | id: ha_dna_sleeping 175 | 176 | 177 | script: 178 | - id: turn_on_underbed_light 179 | mode: restart 180 | parameters: 181 | activate: bool 182 | then: 183 | - if: 184 | condition: 185 | binary_sensor.is_on: ha_dna_sleeping 186 | then: 187 | - if: 188 | condition: 189 | lambda: |- 190 | return activate; 191 | then: 192 | - light.turn_on: 193 | id: under_bed_light 194 | brightness: 100% 195 | red: 100% 196 | green: 85% 197 | blue: 0% 198 | transition_length: 500ms 199 | else: 200 | - if: 201 | condition: 202 | and: 203 | - binary_sensor.is_off: david_pir 204 | - binary_sensor.is_off: aysylu_pir 205 | then: 206 | - light.turn_off: 207 | id: under_bed_light 208 | transition_length: 250ms 209 | 210 | 211 | 212 | 213 | # number: 214 | # - platform: template 215 | # name: David Pillow Level 216 | # id: david_pillow_level 217 | # entity_category: config 218 | # min_value: 1 219 | # max_value: 20 220 | # initial_value: 6 221 | # optimistic: true 222 | # step: 1 223 | # restore_value: true 224 | # mode: slider 225 | # set_action: 226 | # - lambda: |- 227 | # id(david_pillow).set_threshold(id(david_pillow_level).state); 228 | # - platform: template 229 | # name: David Feet Level 230 | # id: david_feet_level 231 | # entity_category: config 232 | # min_value: 1 233 | # max_value: 20 234 | # initial_value: 6 235 | # optimistic: true 236 | # step: 1 237 | # restore_value: true 238 | # mode: slider 239 | # set_action: 240 | # - lambda: |- 241 | # id(david_feet).set_threshold(id(david_feet_level).state); 242 | # - platform: template 243 | # name: Aysylu Pillow Level 244 | # id: aysylu_pillow_level 245 | # entity_category: config 246 | # min_value: 1 247 | # max_value: 20 248 | # initial_value: 6 249 | # optimistic: true 250 | # step: 1 251 | # restore_value: true 252 | # mode: slider 253 | # set_action: 254 | # - lambda: |- 255 | # id(aysylu_pillow).set_threshold(id(aysylu_pillow_level).state); 256 | # - platform: template 257 | # name: Aysylu Feet Level 258 | # id: aysylu_feet_level 259 | # entity_category: config 260 | # min_value: 1 261 | # max_value: 20 262 | # initial_value: 6 263 | # optimistic: true 264 | # step: 1 265 | # restore_value: true 266 | # mode: slider 267 | # set_action: 268 | # - lambda: |- 269 | # id(aysylu_feet).set_threshold(id(aysylu_feet_level).state); 270 | # - platform: template 271 | # name: Middle Pillow Level 272 | # id: middle_pillow_level 273 | # entity_category: config 274 | # min_value: 1 275 | # max_value: 20 276 | # initial_value: 6 277 | # optimistic: true 278 | # step: 1 279 | # restore_value: true 280 | # mode: slider 281 | # set_action: 282 | # - lambda: |- 283 | # id(middle_pillow).set_threshold(id(middle_pillow_level).state); 284 | # - platform: template 285 | # name: Middle Feet Level 286 | # id: middle_feet_level 287 | # entity_category: config 288 | # min_value: 1 289 | # max_value: 20 290 | # initial_value: 6 291 | # optimistic: true 292 | # step: 1 293 | # restore_value: true 294 | # mode: slider 295 | # set_action: 296 | # - lambda: |- 297 | # id(middle_feet).set_threshold(id(middle_feet_level).state); 298 | -------------------------------------------------------------------------------- /custom_components/nau8810/nau881x.h: -------------------------------------------------------------------------------- 1 | #ifndef MR_NAU881X_H 2 | #define MR_NAU881X_H 3 | 4 | #ifdef __cplusplus 5 | extern "C" { 6 | #endif 7 | 8 | #include 9 | #include "nau881x_regs.h" 10 | 11 | extern void nau8810_I2C_Write(void * p, uint8_t i2c_address, uint8_t reg, uint16_t value); 12 | extern uint16_t nau8810_I2C_Read(void * p, uint8_t i2c_address, uint8_t reg); 13 | 14 | #define NAU881X_REG_WRITE(handle, reg, val) nau8810_I2C_Write(handle, NAU881X_I2C_ADDRESS, reg, val) 15 | #define NAU881X_REG_READ(handle, reg) nau8810_I2C_Read(handle, NAU881X_I2C_ADDRESS, reg) 16 | 17 | // Volume range: -57 - +6 dB 18 | #define NAU881X_SPKVOL_DB_TO_REG_VALUE(vol_db) ((vol_db + 57) & 0x3F) 19 | #define NAU881X_SPKVOL_REG_VALUE_TO_DB(vol_regval) (vol_regval - 57) 20 | 21 | 22 | typedef struct _NAU881x 23 | { 24 | void * comm_handle; 25 | uint16_t _register[80]; 26 | } NAU881x_t; 27 | 28 | 29 | typedef enum _nau881x_status 30 | { 31 | NAU881X_STATUS_OK = 0, 32 | NAU881X_STATUS_ERROR = 1, 33 | NAU881X_STATUS_INVALID = 2 34 | } nau881x_status_t; 35 | 36 | 37 | nau881x_status_t NAU881x_Init(NAU881x_t* nau881x); 38 | 39 | // Input path 40 | nau881x_status_t NAU881x_Get_PGA_Input(NAU881x_t* nau881x, nau881x_input_t* input); 41 | nau881x_status_t NAU881x_Set_PGA_Input(NAU881x_t* nau881x, nau881x_input_t input); 42 | uint8_t NAU881x_Get_PGA_Gain(NAU881x_t* nau881x); 43 | nau881x_status_t NAU881x_Set_PGA_Gain(NAU881x_t* nau881x, uint8_t vol); 44 | nau881x_status_t NAU881x_Set_PGA_Gain_db(NAU881x_t* nau881x, float vol_db); 45 | nau881x_status_t NAU881x_Set_PGA_Mute(NAU881x_t* nau881x, uint8_t state); 46 | nau881x_status_t NAU881x_Set_PGA_ZeroCross(NAU881x_t* nau881x, uint8_t state); 47 | nau881x_status_t NAU881x_Set_PGA_Enable(NAU881x_t* nau881x, uint8_t enable); 48 | nau881x_status_t NAU8814_Set_Aux_Enable(NAU881x_t* nau8814, uint8_t enable); 49 | nau881x_status_t NAU8814_Set_Aux_Mode(NAU881x_t* nau8814, nau881x_aux_mode_t mode); 50 | nau881x_status_t NAU881x_Set_PGA_Boost(NAU881x_t* nau881x, uint8_t state); 51 | nau881x_status_t NAU881x_Set_Boost_Volume(NAU881x_t* nau881x, nau881x_input_t input, uint8_t vol); 52 | nau881x_status_t NAU881x_Set_Boost_Enable(NAU881x_t* nau881x, uint8_t enable); 53 | nau881x_status_t NAU881x_Set_MicBias_Enable(NAU881x_t* nau881x, uint8_t enable); 54 | nau881x_status_t NAU881x_Set_MicBias_Voltage(NAU881x_t* nau881x, uint8_t val); 55 | nau881x_status_t NAU881x_Set_MicBiasMode_Enable(NAU881x_t* nau881x, uint8_t enable); 56 | 57 | // ADC digital filter 58 | nau881x_status_t NAU881x_Set_ADC_Enable(NAU881x_t* nau881x, uint8_t enable); 59 | nau881x_status_t NAU881x_Set_ADC_Polarity(NAU881x_t* nau881x, uint8_t invert); 60 | nau881x_status_t NAU881x_Set_ADC_OverSampleRate(NAU881x_t* nau881x, nau881x_adc_oversamplerate_t rate); 61 | nau881x_status_t NAU881x_Set_ADC_HighPassFilter(NAU881x_t* nau881x, uint8_t enable, nau881x_hpf_mode_t mode, uint8_t freq_regval); 62 | nau881x_status_t NAU881x_Set_ADC_Gain(NAU881x_t* nau881x, uint8_t regval); 63 | 64 | // ALC 65 | nau881x_status_t NAU881x_Set_ALC_Enable(NAU881x_t* nau881x, uint8_t enable); 66 | nau881x_status_t NAU881x_Set_ALC_Gain(NAU881x_t* nau881x, uint8_t minval, uint8_t maxval); 67 | nau881x_status_t NAU881x_Set_ALC_TargetLevel(NAU881x_t* nau881x, uint8_t val); 68 | nau881x_status_t NAU881x_Set_ALC_Hold(NAU881x_t* nau881x, uint8_t val); 69 | nau881x_status_t NAU881x_Set_ALC_Mode(NAU881x_t* nau881x, nau881x_alc_mode_t mode); 70 | nau881x_status_t NAU881x_Set_ALC_AttackTime(NAU881x_t* nau881x, uint8_t val); 71 | nau881x_status_t NAU881x_Set_ALC_DecayTime(NAU881x_t* nau881x, uint8_t val); 72 | nau881x_status_t NAU881x_Set_ALC_ZeroCross(NAU881x_t* nau881x, uint8_t state); 73 | nau881x_status_t NAU881x_Set_ALC_NoiseGate_Threshold(NAU881x_t* nau881x, uint8_t val); 74 | nau881x_status_t NAU881x_Set_ALC_NoiseGate_Enable(NAU881x_t* nau881x, uint8_t enable); 75 | 76 | // DAC digital filter 77 | nau881x_status_t NAU881x_Set_ADC_DAC_Passthrough(NAU881x_t* nau881x, uint8_t enable); 78 | nau881x_status_t NAU881x_Set_DAC_Enable(NAU881x_t* nau881x, uint8_t enable); 79 | nau881x_status_t NAU881x_Set_DAC_Polarity(NAU881x_t* nau881x, uint8_t invert); 80 | nau881x_status_t NAU881x_Set_DAC_Gain(NAU881x_t* nau8810, uint8_t val); 81 | nau881x_status_t NAU881x_Set_DAC_SoftMute(NAU881x_t* nau881x, uint8_t state); 82 | nau881x_status_t NAU881x_Set_DAC_AutoMute(NAU881x_t* nau881x, uint8_t state); 83 | nau881x_status_t NAU881x_Set_DAC_SampleRate(NAU881x_t* nau881x, nau881x_dac_samplerate_t rate); 84 | nau881x_status_t NAU881x_Set_DAC_Limiter_Enable(NAU881x_t* nau881x, uint8_t enable); 85 | nau881x_status_t NAU881x_Set_DAC_Limiter_AttackTime(NAU881x_t* nau881x, uint8_t val); 86 | nau881x_status_t NAU881x_Set_DAC_Limiter_DecayTime(NAU881x_t* nau881x, uint8_t val); 87 | nau881x_status_t NAU881x_Set_DAC_Limiter_VolumeBoost(NAU881x_t* nau881x, uint8_t value); 88 | nau881x_status_t NAU881x_Set_DAC_Limiter_Threshold(NAU881x_t* nau881x, int8_t value); 89 | nau881x_status_t NAU881x_Set_Equalizer_Path(NAU881x_t* nau881x, nau881x_eq_path_t path); 90 | nau881x_status_t NAU881x_Set_Equalizer_Bandwidth(NAU881x_t* nau881x, uint8_t equalizer_no, nau881x_eq_bandwidth_t bandwidth); 91 | nau881x_status_t NAU881x_Set_Equalizer_Gain(NAU881x_t* nau881x, uint8_t equalizer_no, int8_t value); 92 | nau881x_status_t NAU881x_Set_Equalizer1_Frequency(NAU881x_t* nau881x, nau881x_eq1_cutoff_freq_t cutoff_freq); 93 | nau881x_status_t NAU881x_Set_Equalizer2_Frequency(NAU881x_t* nau881x, nau881x_eq2_center_freq_t center_freq); 94 | nau881x_status_t NAU881x_Set_Equalizer3_Frequency(NAU881x_t* nau881x, nau881x_eq3_center_freq_t center_freq); 95 | nau881x_status_t NAU881x_Set_Equalizer4_Frequency(NAU881x_t* nau881x, nau881x_eq4_center_freq_t center_freq); 96 | nau881x_status_t NAU881x_Set_Equalizer5_Frequency(NAU881x_t* nau881x, nau881x_eq5_cutoff_freq_t cutoff_freq); 97 | 98 | // Analog outputs 99 | nau881x_status_t NAU881x_Set_Output_Enable(NAU881x_t* nau881x, nau881x_output_t output); 100 | nau881x_status_t NAU881x_Set_Speaker_Source(NAU881x_t* nau881x, nau881x_output_source_t source); 101 | nau881x_status_t NAU881x_Set_Speaker_FromBypass_Attenuation(NAU881x_t* nau881x, uint8_t enable); 102 | nau881x_status_t NAU881x_Set_Speaker_Boost(NAU881x_t* nau881x, uint8_t enable); 103 | nau881x_status_t NAU881x_Set_Speaker_ZeroCross(NAU881x_t* nau881x, uint8_t state); 104 | nau881x_status_t NAU881x_Set_Speaker_Mute(NAU881x_t* nau881x, uint8_t state); 105 | nau881x_status_t NAU881x_Set_Speaker_Volume(NAU881x_t* nau881x, uint8_t val); 106 | nau881x_status_t NAU881x_Set_Speaker_Volume_db(NAU881x_t* nau881x, int8_t vol_db); 107 | uint8_t NAU881x_Get_Speaker_Volume(NAU881x_t* nau881x); 108 | uint8_t NAU881x_Get_Speaker_Volume_db(NAU881x_t* nau881x); 109 | nau881x_status_t NAU881x_Set_Mono_Source(NAU881x_t* nau881x, nau881x_output_source_t source); 110 | nau881x_status_t NAU881x_Set_Mono_FromBypass_Attenuation(NAU881x_t* nau881x, uint8_t enable); 111 | nau881x_status_t NAU881x_Set_Mono_Boost(NAU881x_t* nau881x, uint8_t enable); 112 | nau881x_status_t NAU881x_Set_Mono_Mute(NAU881x_t* nau881x, uint8_t state); 113 | 114 | // General purpose control 115 | nau881x_status_t NAU881x_Set_SlowClock_Enable(NAU881x_t* nau881x, uint8_t enable); 116 | nau881x_status_t NAU8814_Set_GPIO_Control(NAU881x_t* nau8814, nau8814_gpio_function_t function, uint8_t invert_polarity); 117 | nau881x_status_t NAU8814_Set_ThermalShutdown_Enable(NAU881x_t* nau8814, uint8_t enable); 118 | 119 | // Clock generation 120 | nau881x_status_t NAU881x_Set_PLL_Enable(NAU881x_t* nau881x, uint8_t enable); 121 | nau881x_status_t NAU881x_Set_PLL_FrequencyRatio(NAU881x_t* nau881x, uint8_t mclk_div2, uint8_t N, uint32_t K); 122 | 123 | // Control interface 124 | nau881x_status_t NAU8814_Set_ControlInterface_SPI24bit(NAU881x_t* nau8814, uint8_t enable); 125 | 126 | // Digital audio interface 127 | nau881x_status_t NAU881x_Set_AudioInterfaceFormat(NAU881x_t* nau881x, nau881x_audio_iface_fmt_t format, nau881x_audio_iface_wl_t word_length); 128 | nau881x_status_t NAU881x_Set_PCM_Timeslot(NAU881x_t* nau881x, uint16_t timeslot); 129 | nau881x_status_t NAU881x_Set_FrameClock_Polarity(NAU881x_t* nau881x, uint8_t invert); 130 | nau881x_status_t NAU881x_Set_BCLK_Polarity(NAU881x_t* nau881x, uint8_t invert); 131 | nau881x_status_t NAU881x_Set_ADC_Data_Phase(NAU881x_t* nau8814, uint8_t in_right_phase_of_frame); 132 | nau881x_status_t NAU881x_Set_DAC_Data_Phase(NAU881x_t* nau8814, uint8_t in_right_phase_of_frame); 133 | nau881x_status_t NAU881x_Set_Clock(NAU881x_t* nau881x, uint8_t is_master, nau881x_bclkdiv_t bclk_divider, nau881x_mclkdiv_t mclk_divider, nau881x_clksel_t clock_source); 134 | nau881x_status_t NAU881x_Set_LOUTR(NAU881x_t* nau881x, uint8_t enable); 135 | nau881x_status_t NAU881x_Set_ADC_Companding(NAU881x_t* nau881x, nau881x_companding_t companding); 136 | nau881x_status_t NAU881x_Set_DAC_Companding(NAU881x_t* nau881x, nau881x_companding_t companding); 137 | nau881x_status_t NAU881x_Set_Companding_WordLength_8bit(NAU881x_t* nau881x, uint8_t enable); 138 | 139 | // Other 140 | nau881x_status_t NAU881x_Get_SiliconRevision(NAU881x_t* nau881x, uint8_t* silicon_revision); 141 | 142 | #ifdef __cplusplus 143 | } 144 | #endif 145 | 146 | #endif // MR_NAU881X_H 147 | -------------------------------------------------------------------------------- /custom_components/drv2605/drv2605.cpp: -------------------------------------------------------------------------------- 1 | #include "esphome/core/log.h" 2 | #include "drv2605.h" 3 | 4 | namespace esphome { 5 | namespace drv2605 { 6 | 7 | static const char *TAG = "drv2605.component"; 8 | 9 | void DRV2605Component::setup() { 10 | this->pref_ = global_preferences->make_preference(this->name_hash_); 11 | if (!this->pref_.load(&this->calibration_data_)) { 12 | ESP_LOGW(TAG, "Calibration data not found. Please run calibration before proceeding"); 13 | this->has_calibration = false; 14 | } else { 15 | this->has_calibration = true; 16 | ESP_LOGW(TAG, "Calibration data found."); 17 | } 18 | 19 | this->en_pin_->setup(); 20 | this->en_pin_->digital_write(0); 21 | delay(25); 22 | this->pending_reset_ = false; 23 | this->en_pending_deassert_ = false; 24 | this->pending_calibrate_ = false; 25 | this->reset(); 26 | } 27 | 28 | void DRV2605Component::reset() { 29 | this->en_pin_->digital_write(1); 30 | delay(25); 31 | this->write_byte(MODE_REG, 0x80); // Perform a reset 32 | this->pending_reset_ = true; 33 | ESP_LOGD(TAG, "Initiated reset"); 34 | } 35 | 36 | void DRV2605Component::populate_config_regs() { 37 | uint8_t feedback_reg; 38 | this->read_byte(FEEDBACK_REG, &feedback_reg); 39 | // populate ERM_LRA 40 | feedback_reg |= 0x80; // enable LRA mode 41 | // populate FB_BRAKE_FACTOR - "2 for most actuators" 42 | feedback_reg &= ~0x70; 43 | feedback_reg |= 0x3 << 4; // deviate to 3 44 | // populate LOOP_GAIN - "2 for most actuators" 45 | feedback_reg &= ~0x0C; 46 | feedback_reg |= 0x1 << 2; // deviate to 1 47 | if (this->has_calibration) { 48 | // include bemf_gain 49 | feedback_reg &= ~0x3; 50 | feedback_reg |= this->calibration_data_.bemf_gain & 0x3; 51 | } 52 | ESP_LOGD(TAG, "Feedback reg (0x%x) = 0x%x", FEEDBACK_REG, feedback_reg); 53 | this->write_byte(FEEDBACK_REG, feedback_reg); 54 | 55 | // populate RATED_VOLTAGE 56 | this->write_byte(RATEDVOLT_REG, this->rated_voltage_reg_value); 57 | 58 | // populate OD_CLAMP 59 | this->write_byte(OVERDRIVECLAMP_REG, this->overdrive_reg_value); 60 | 61 | // popluate control1 register 62 | uint8_t control1_reg; 63 | this->read_byte(CONTROL1_REG, &control1_reg); 64 | // populate DRIVE_TIME 65 | control1_reg &= ~0x1F; 66 | control1_reg |= this->drive_time_reg_value & 0x1F; 67 | ESP_LOGD(TAG, "Control1 reg (0x%x) = 0x%x", CONTROL1_REG, control1_reg); 68 | this->write_byte(CONTROL1_REG, control1_reg); 69 | 70 | // populate control2 register 71 | uint8_t control2_reg; 72 | this->read_byte(CONTROL2_REG, &control2_reg); 73 | // populate SAMPLE_TIME - "3 for most actuators" 74 | control2_reg |= 0x30; 75 | // populate BLANKING_TIME - "1 for most actuators" 76 | control2_reg &= ~0x0C; 77 | control2_reg |= 0x1 << 2; 78 | // populate IDISS_TIME - "1 for most actuators" 79 | control2_reg &= ~0x03; 80 | control2_reg |= 0x1; 81 | ESP_LOGD(TAG, "Control2 reg (0x%x) = 0x%x", CONTROL2_REG, control2_reg); 82 | this->write_byte(CONTROL2_REG, control2_reg); 83 | 84 | // populate control3 register 85 | uint8_t control3_reg; 86 | this->read_byte(CONTROL3_REG, &control3_reg); 87 | // Turn off ERM open loop mode 88 | control3_reg &= ~0x20; 89 | ESP_LOGD(TAG, "Control3 reg (0x%x) = 0x%x", CONTROL3_REG, control3_reg); 90 | this->write_byte(CONTROL3_REG, control3_reg); 91 | 92 | // popluate control4 register 93 | uint8_t control4_reg; 94 | this->read_byte(CONTROL4_REG, &control4_reg); 95 | // populate AUTO_CAL_TIME - "3 for most actuators" 96 | control4_reg |= 0x3 << 4; 97 | // populate ZC_DET_TIME - "0 for most actuators" 98 | control4_reg &= ~0x80; 99 | ESP_LOGD(TAG, "Control4 reg (0x%x) = 0x%x", CONTROL4_REG, control4_reg); 100 | this->write_byte(CONTROL4_REG, control4_reg); 101 | 102 | if (this->has_calibration) { 103 | ESP_LOGD(TAG, "Including calibration regs"); 104 | // include other calibration regs 105 | this->write_byte(COMPRESULT_REG, this->calibration_data_.compensation); 106 | this->write_byte(BACKEMF_REG, this->calibration_data_.backemf); 107 | } 108 | } 109 | 110 | void DRV2605Component::calibrate() { 111 | this->en_pin_->digital_write(1); 112 | this->write_byte(MODE_REG, 0x0); // Move to out of standby 113 | delay(25); 114 | this->write_byte(MODE_REG, 0x7); // Move from standby to autocalibration 115 | 116 | this->has_calibration = false; // ensure we recalibrate 117 | this->populate_config_regs(); 118 | 119 | // Start autocalibration 120 | this->write_byte(GO_REG, 0x01); 121 | this->pending_calibrate_ = true; 122 | ESP_LOGD(TAG, "Started calibration"); 123 | } 124 | 125 | void DRV2605Component::fire_waveform(uint8_t waveform_id) { 126 | // Here's how to fire a waveform 127 | ESP_LOGD(TAG, "Firing a waveform %d", waveform_id); 128 | // pull EN pin high 129 | this->en_pin_->digital_write(1); 130 | delay(25); 131 | this->write_byte(MODE_REG, 0x0); // Wake up from standby to internal trigger 132 | delay(25); 133 | this->write_byte(WAVESEQ1, waveform_id); 134 | delay(25); 135 | this->write_byte(GO_REG, 0x01); 136 | // We'll deassert the enable pin in the loop 137 | this->en_pending_deassert_ = true; 138 | } 139 | 140 | void DRV2605Component::loop() { 141 | if (this->pending_reset_) { 142 | uint8_t status; 143 | this->read_byte(MODE_REG, &status); 144 | if (status & 0x80) { 145 | // reset still in progress 146 | ESP_LOGD(TAG, "waiting for reset, mode is %x", status); 147 | } else { 148 | this->read_byte(STATUS_REG, &status); 149 | if (status != 0xE0) { 150 | ESP_LOGW(TAG, "status register %X in error", status); 151 | this->mark_failed(); 152 | return; 153 | } 154 | ESP_LOGI(TAG, "drv2605 reset completed"); 155 | this->write_byte(MODE_REG, 0x0); // Wake up from standby 156 | 157 | if (this->has_calibration) { 158 | ESP_LOGD(TAG, "populating config after reset"); 159 | this->populate_config_regs(); 160 | } else { 161 | ESP_LOGD(TAG, "don't forget to run autocalibration"); 162 | } 163 | // pull EN pin low 164 | this->en_pin_->digital_write(0); 165 | this->pending_reset_ = false; 166 | } 167 | } 168 | if (this->en_pending_deassert_) { 169 | uint8_t go_bit; 170 | this->read_byte(GO_REG, &go_bit); 171 | if (!go_bit) { 172 | this->write_byte(MODE_REG, 0x0); // Move from autocalibration to internal trigger 173 | // pull EN pin low 174 | this->en_pin_->digital_write(0); 175 | this->en_pending_deassert_ = false; 176 | } 177 | } 178 | if (this->pending_calibrate_) { 179 | uint8_t status; 180 | this->read_byte(GO_REG, &status); 181 | if (!status) { 182 | ESP_LOGD(TAG, "Autocalibration complete"); 183 | 184 | // Check that DIAG_RESULT in register 0x0 says autocalibration completed successfully 185 | this->read_byte(STATUS_REG, &status); 186 | if (status != 0xE0) { 187 | ESP_LOGW(TAG, "status register %X in error, calibration failed", status); 188 | this->mark_failed(); 189 | return; 190 | } 191 | 192 | this->read_byte(FEEDBACK_REG, &this->calibration_data_.bemf_gain); 193 | this->calibration_data_.bemf_gain &= 0x3; 194 | ESP_LOGI(TAG, "BEMF gain = %d", this->calibration_data_.bemf_gain); 195 | this->read_byte(COMPRESULT_REG, &this->calibration_data_.compensation); 196 | ESP_LOGI(TAG, "Autocalibration compensation = %d", this->calibration_data_.compensation); 197 | this->read_byte(BACKEMF_REG, &this->calibration_data_.backemf); 198 | ESP_LOGI(TAG, "Autocalibration back emf = %d", this->calibration_data_.backemf); 199 | this->pref_.save(&this->calibration_data_); 200 | ESP_LOGI(TAG, "Saved autocalibration data"); 201 | 202 | this->write_byte(MODE_REG, 0x0); // Move from autocalibration to internal trigger 203 | 204 | this->write_byte(LIB_REG, 6); // Select the tuned LRA library 205 | 206 | // pull EN pin low 207 | this->en_pin_->digital_write(0); 208 | this->pending_calibrate_ = false; 209 | } else { 210 | ESP_LOGD(TAG, "Still waiting for calibration to complete"); 211 | } 212 | } 213 | //uint8_t status; 214 | //this->read_byte(STATUS_REG, &status); 215 | //ESP_LOGW(TAG, "status register %X", status); 216 | } 217 | 218 | void DRV2605Component::dump_config(){ 219 | ESP_LOGCONFIG(TAG, "DRV2605"); 220 | ESP_LOGCONFIG(TAG, " Overdrive reg = %d", this->overdrive_reg_value); 221 | ESP_LOGCONFIG(TAG, " Rated voltage reg = %d", this->rated_voltage_reg_value); 222 | LOG_PIN(" EN pin:", this->en_pin_); 223 | if (!this->has_calibration) { 224 | ESP_LOGCONFIG(TAG, " No calibration data found"); 225 | } else { 226 | ESP_LOGCONFIG(TAG, " Calibration data:"); 227 | ESP_LOGCONFIG(TAG, " bemf gain = 0x%x", this->calibration_data_.bemf_gain); 228 | ESP_LOGCONFIG(TAG, " compensation = %d", this->calibration_data_.compensation); 229 | ESP_LOGCONFIG(TAG, " backemf = %d", this->calibration_data_.backemf); 230 | } 231 | } 232 | 233 | 234 | } // namespace drv2605 235 | } // namespace esphome 236 | -------------------------------------------------------------------------------- /lightswitch.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | device_name: lightswitch 3 | 4 | esphome: 5 | name: lightswitch 6 | friendly_name: lightswitch 7 | 8 | external_components: 9 | - source: github://ayufan/esphome-components 10 | 11 | drv2605: 12 | id: haptic 13 | rated_voltage: 1.8V 14 | resonant_frequency: 205Hz 15 | 16 | esp32: 17 | # board: wesp32 18 | board: esp32-poe-iso 19 | # framework: 20 | # type: esp-idf 21 | # version: recommended 22 | framework: 23 | type: arduino 24 | 25 | # Enable logging 26 | logger: 27 | level: DEBUG 28 | 29 | # Enable Home Assistant API 30 | api: 31 | 32 | ota: 33 | password: "" 34 | 35 | wifi: 36 | ssid: 74ax4k 37 | password: twosigma 38 | #ethernet: 39 | # type: LAN8720 40 | # mdc_pin: GPIO23 41 | # mdio_pin: GPIO18 42 | # clk_mode: GPIO17_OUT 43 | # phy_addr: 0 44 | # power_pin: GPIO12 45 | # manual_ip: 46 | # static_ip: 192.168.20.70 47 | # gateway: 192.168.20.1 48 | # subnet: 255.255.255.0 49 | # use_address: 192.168.20.70 50 | 51 | i2c: 52 | scl: GPIO16 53 | sda: GPIO13 54 | scan: true 55 | id: i2c_bus 56 | 57 | memory: # from ayufan/esphome-components; periodically prints free memory 58 | 59 | i2s_audio: 60 | # - id: i2s_mic 61 | # i2s_lrclk_pin: GPIO4 62 | - id: i2s_amp 63 | i2s_lrclk_pin: GPIO2 64 | i2s_bclk_pin: GPIO14 65 | 66 | microphone: 67 | - platform: i2s_audio 68 | i2s_din_pin: GPIO35 69 | adc_type: external 70 | pdm: true 71 | id: mic_id 72 | # i2s_audio_id: i2s_mic 73 | i2s_audio_id: i2s_amp 74 | 75 | voice_assistant: 76 | microphone: mic_id 77 | speaker: amp_id 78 | on_start: 79 | then: 80 | - output.turn_on: amp_en 81 | - drv2605.fire_haptic: 82 | id: haptic 83 | waveform: 27 84 | on_error: 85 | then: 86 | - drv2605.fire_haptic: 87 | id: haptic 88 | waveform: 56 89 | on_end: 90 | then: 91 | - output.turn_off: amp_en 92 | - drv2605.fire_haptic: 93 | id: haptic 94 | waveform: 44 95 | 96 | speaker: 97 | - platform: i2s_audio 98 | dac_type: external 99 | i2s_dout_pin: GPIO3 100 | mode: mono 101 | id: amp_id 102 | i2s_audio_id: i2s_amp 103 | 104 | #media_player: 105 | # - platform: i2s_audio 106 | # name: ESPHome I2S Media Player 107 | # dac_type: external 108 | # i2s_dout_pin: GPIO3 109 | # i2s_audio_id: i2s_amp 110 | # mode: mono 111 | # on_play: 112 | # output.turn_on: amp_en 113 | # on_pause: 114 | # output.turn_off: amp_en 115 | # on_idle: 116 | # output.turn_off: amp_en 117 | 118 | number: 119 | - platform: template 120 | name: waveform of choice press 121 | id: wv_o_c 122 | min_value: 1 123 | max_value: 255 124 | step: 1 125 | optimistic: true 126 | - platform: template 127 | name: waveform of choice release 128 | id: wv_o_c2 129 | min_value: 1 130 | max_value: 255 131 | step: 1 132 | optimistic: true 133 | 134 | output: 135 | - platform: gpio 136 | pin: GPIO32 137 | id: ntc_vcc 138 | - platform: gpio 139 | pin: 140 | number: GPIO4 141 | mode: OUTPUT_OPEN_DRAIN 142 | id: amp_en 143 | 144 | light: 145 | - platform: neopixelbus 146 | type: GRB 147 | variant: SK6812 148 | pin: GPIO15 149 | num_leds: 42 150 | internal: true 151 | id: led_pixels 152 | color_correct: [40%, 40%, 40% ] # limit max brightness 153 | - platform: partition 154 | name: Light Divider 155 | segments: 156 | - id: led_pixels 157 | from: 10 158 | to: 14 159 | effects: 160 | - addressable_twinkle: 161 | - addressable_flicker: 162 | - addressable_fireworks: 163 | - platform: partition 164 | name: Environment Divider 165 | segments: 166 | - id: led_pixels 167 | from: 23 168 | to: 31 169 | effects: 170 | - addressable_twinkle: 171 | - addressable_flicker: 172 | - addressable_fireworks: 173 | - platform: partition 174 | name: Vertical Line 175 | segments: 176 | - id: led_pixels 177 | from: 0 178 | to: 9 179 | - id: led_pixels 180 | from: 15 181 | to: 22 182 | - id: led_pixels 183 | from: 32 184 | to: 41 185 | effects: 186 | - addressable_twinkle: 187 | - addressable_flicker: 188 | - addressable_fireworks: 189 | 190 | 191 | mpr121: 192 | id: mpr121_ 193 | address: 0x5C 194 | touch_debounce: 1 195 | release_debounce: 1 196 | touch_threshold: 10 197 | release_threshold: 7 198 | 199 | button: 200 | - platform: restart 201 | name: Restart device 202 | id: restart_internal 203 | entity_category: config 204 | - platform: safe_mode 205 | name: Safe Mode Boot 206 | entity_category: diagnostic 207 | - platform: template 208 | name: Reset DRV2605L 209 | entity_category: diagnostic 210 | on_press: 211 | then: 212 | - drv2605.reset: 213 | id: haptic 214 | - platform: template 215 | name: Calibrate DRV2605L 216 | entity_category: diagnostic 217 | on_press: 218 | then: 219 | - drv2605.calibrate: 220 | id: haptic 221 | 222 | binary_sensor: 223 | - platform: gpio 224 | name: physical button 225 | pin: 226 | number: GPIO34 227 | inverted: true 228 | # on_press: 229 | # then: 230 | # - voice_assistant.start: 231 | # on_release: 232 | # then: 233 | # - voice_assistant.stop: 234 | - platform: gpio 235 | name: LED Power Fault 236 | pin: GPIO36 237 | - platform: mpr121 238 | id: button_bottom_left 239 | channel: 0 240 | name: Light down 241 | on_press: 242 | then: 243 | - drv2605.fire_haptic: 244 | id: haptic 245 | waveform: !lambda return id(wv_o_c).state; 246 | on_release: 247 | then: 248 | - drv2605.fire_haptic: 249 | id: haptic 250 | waveform: !lambda return id(wv_o_c2).state; 251 | - platform: mpr121 252 | id: button_top_left 253 | channel: 1 254 | name: Light up 255 | on_press: 256 | then: 257 | - drv2605.fire_haptic: 258 | id: haptic 259 | waveform: 4 260 | - platform: mpr121 261 | id: button_bottom_right 262 | channel: 2 263 | name: Environment down 264 | on_press: 265 | then: 266 | - drv2605.fire_haptic: 267 | id: haptic 268 | waveform: 7 269 | - platform: mpr121 270 | id: button_top_right 271 | channel: 3 272 | name: Environment up 273 | on_press: 274 | then: 275 | - voice_assistant.start: 276 | on_release: 277 | then: 278 | - voice_assistant.stop: 279 | # - drv2605.fire_haptic: 280 | # id: haptic 281 | # waveform: 54 282 | - platform: mpr121 283 | id: button_proximity 284 | channel: 12 285 | name: Proximity 286 | # touch_threshold: 5 287 | # release_threshold: 5 288 | - platform: status 289 | name: "${device_name} Status" 290 | 291 | 292 | bme680_bsec: 293 | 294 | sensor: 295 | - platform: bme680_bsec 296 | temperature: 297 | name: "BME680 Temperature" 298 | pressure: 299 | name: "BME680 Pressure" 300 | humidity: 301 | name: "BME680 Humidity" 302 | iaq: 303 | name: "BME680 IAQ" 304 | id: iaq 305 | co2_equivalent: 306 | name: "BME680 CO2 Equivalent" 307 | breath_voc_equivalent: 308 | name: "BME680 Breath VOC Equivalent" 309 | 310 | - platform: bh1750 311 | name: "BH1750 Illuminance" 312 | address: 0x23 313 | update_interval: 60s 314 | 315 | - platform: ntc 316 | name: "Board temperature" 317 | sensor: board_temp_resistance 318 | entity_category: diagnostic 319 | calibration: 320 | b_constant: 3435 321 | reference_temperature: 25C 322 | reference_resistance: 10kOhm 323 | - platform: resistance 324 | id: board_temp_resistance 325 | sensor: board_temp_source 326 | configuration: UPSTREAM 327 | resistor: 10kOhm 328 | - platform: adc 329 | pin: GPIO33 330 | id: board_temp_source 331 | update_interval: never 332 | attenuation: 11db 333 | internal: true 334 | 335 | interval: 336 | - interval: 30s 337 | then: 338 | - output.turn_on: ntc_vcc 339 | - component.update: board_temp_source 340 | - output.turn_off: ntc_vcc 341 | 342 | text_sensor: 343 | - platform: bme680_bsec 344 | iaq_accuracy: 345 | name: "BME680 IAQ Accuracy" 346 | 347 | - platform: template 348 | name: "BME680 IAQ Classification" 349 | icon: "mdi:checkbox-marked-circle-outline" 350 | lambda: |- 351 | if ( int(id(iaq).state) <= 50) { 352 | return {"Excellent"}; 353 | } 354 | else if (int(id(iaq).state) >= 51 && int(id(iaq).state) <= 100) { 355 | return {"Good"}; 356 | } 357 | else if (int(id(iaq).state) >= 101 && int(id(iaq).state) <= 150) { 358 | return {"Lightly polluted"}; 359 | } 360 | else if (int(id(iaq).state) >= 151 && int(id(iaq).state) <= 200) { 361 | return {"Moderately polluted"}; 362 | } 363 | else if (int(id(iaq).state) >= 201 && int(id(iaq).state) <= 250) { 364 | return {"Heavily polluted"}; 365 | } 366 | else if (int(id(iaq).state) >= 251 && int(id(iaq).state) <= 350) { 367 | return {"Severely polluted"}; 368 | } 369 | else if (int(id(iaq).state) >= 351) { 370 | return {"Extremely polluted"}; 371 | } 372 | else { 373 | return {"error"}; 374 | } 375 | 376 | 377 | -------------------------------------------------------------------------------- /ld2410ble.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | ld2410_id: 'ld2410_id' 3 | ld2410_name: "" 4 | ld2410_password: "HiLink" 5 | sensor_throttle: '500ms' 6 | binary_sensor_debounce: '250ms' 7 | 8 | 9 | esphome: 10 | on_boot: 11 | priority: -100 12 | then: 13 | - switch.turn_on: "${ld2410_id}_enable_switch" 14 | # This will cause the startup script to run 15 | - binary_sensor.template.publish: 16 | id: "${ld2410_id}_ble_connected" 17 | state: OFF 18 | 19 | esp32: 20 | framework: 21 | sdkconfig_options: 22 | CONFIG_BT_BLE_42_FEATURES_SUPPORTED: y 23 | 24 | script: 25 | - id: ${ld2410_id}_start_after_delay 26 | then: 27 | - if: 28 | condition: 29 | switch.is_on: ${ld2410_id}_enable_switch 30 | then: 31 | # random 1-15s delay 32 | - delay: !lambda 'int d = (rand() % 14000) + 1000; ESP_LOGD("${ld2410_id}", "starting after %d millis", d); return d;' 33 | - lambda: |- 34 | id(${ld2410_id})->set_enabled(true); 35 | 36 | ble_client: 37 | - mac_address: ${mac_address} 38 | id: ${ld2410_id} 39 | on_disconnect: 40 | then: 41 | - binary_sensor.template.publish: 42 | id: "${ld2410_id}_ble_connected" 43 | state: OFF 44 | - logger.log: 45 | format: "disconnected" 46 | tag: "${ld2410_id}_connect" 47 | - delay: 100ms 48 | - lambda: |- 49 | id(${ld2410_id}_motion_detected).publish_state(false); 50 | id(${ld2410_id}_occupancy_detected).publish_state(false); 51 | id(${ld2410_id}_static_distance).publish_state(NAN); 52 | id(${ld2410_id}_motion_distance).publish_state(NAN); 53 | id(${ld2410_id}_static_energy).publish_state(NAN); 54 | id(${ld2410_id}_moving_energy).publish_state(NAN); 55 | id(${ld2410_id}_detection_distance).publish_state(NAN); 56 | on_connect: 57 | then: 58 | - logger.log: 59 | format: "connecting" 60 | tag: "${ld2410_id}_connect" 61 | - delay: 100ms 62 | - ble_client.ble_write: 63 | id: ${ld2410_id} 64 | service_uuid: 0000fff0-0000-1000-8000-00805f9b34fb 65 | characteristic_uuid: 0000fff2-0000-1000-8000-00805f9b34fb 66 | value: !lambda |- 67 | // data starts with preamble 68 | std::vector data = { 0xfd, 0xfc, 0xfb, 0xfa, 0x08, 0x00, 0xa8, 0x00 }; 69 | // postamble 70 | std::vector postamble = { 0x04, 0x03, 0x02, 0x01 }; 71 | std::string pw = {"${ld2410_password}"}; 72 | ESP_LOGD("2410_ble_auth", "password for ${mac_address} = %s", pw.c_str()); 73 | for (auto& c : pw) { 74 | data.push_back(c); 75 | } 76 | data.insert(data.end(), postamble.begin(), postamble.end()); 77 | return data; 78 | - delay: 250ms 79 | - ble_client.ble_write: 80 | id: ${ld2410_id} 81 | service_uuid: 0000fff0-0000-1000-8000-00805f9b34fb 82 | characteristic_uuid: 0000fff2-0000-1000-8000-00805f9b34fb 83 | # enable config 84 | value: [0xfd, 0xfc, 0xfb, 0xfa, 0x04, 0x00, 0xff, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] 85 | - delay: 250ms 86 | - ble_client.ble_write: 87 | id: ${ld2410_id} 88 | service_uuid: 0000fff0-0000-1000-8000-00805f9b34fb 89 | characteristic_uuid: 0000fff2-0000-1000-8000-00805f9b34fb 90 | # enable engineering mode 91 | value: [0xfd, 0xfc, 0xfb, 0xfa, 0x02, 0x00b, 0x00, 0x04, 0x03, 0x02, 0x01] 92 | - delay: 250ms 93 | - ble_client.ble_write: 94 | id: ${ld2410_id} 95 | service_uuid: 0000fff0-0000-1000-8000-00805f9b34fb 96 | characteristic_uuid: 0000fff2-0000-1000-8000-00805f9b34fb 97 | # diable config 98 | value: [0xfd, 0xfc, 0xfb, 0xfa, 0x02, 0x00, 0xfe, 0x00, 0x04, 0x03, 0x02, 0x01] 99 | - delay: 250ms 100 | - logger.log: 101 | format: "completed ble connection process, waiting for updates..." 102 | tag: "${ld2410_id}_connect" 103 | - lambda: |- 104 | auto *x = id(${ld2410_id}); 105 | //auto *chr = x->get_characteristic(ESPBTUUID::from_raw()); 106 | //auto handle = chr->handle; 107 | auto handle = id(${ld2410_id}_internal_parser)->handle; 108 | auto status = esp_ble_gattc_register_for_notify(x->get_gattc_if(), x->get_remote_bda(), handle); 109 | ESP_LOGD("${ld2410_id}_notify", "registered for notify, handle = %d, status = %d", handle, status); 110 | # automatically try to toggle if it didn't connect 111 | - delay: 20s # we give it 20 seconds to connect 112 | - if: 113 | condition: 114 | binary_sensor.is_off: ${ld2410_id}_ble_connected 115 | then: # we need to toggle it 116 | - logger.log: 117 | format: "Failed to connect within 20s to push updates, retrying..." 118 | tag: "${ld2410_id}_connect" 119 | - switch.turn_off: "${ld2410_id}_enable_switch_internal" 120 | - script.execute: ${ld2410_id}_start_after_delay 121 | 122 | 123 | switch: 124 | - platform: template 125 | name: "${ld2410_name}Enabled" 126 | id: "${ld2410_id}_enable_switch" 127 | entity_category: diagnostic 128 | optimistic: True 129 | turn_on_action: 130 | switch.turn_on: "${ld2410_id}_enable_switch_internal" 131 | turn_off_action: 132 | switch.turn_off: "${ld2410_id}_enable_switch_internal" 133 | - platform: template 134 | id: "${ld2410_id}_enable_switch_internal" 135 | internal: True 136 | turn_on_action: 137 | lambda: |- 138 | ESP_LOGD("${ld2410_id}", "Enabling ble client"); 139 | id(${ld2410_id})->set_enabled(true); 140 | turn_off_action: 141 | lambda: |- 142 | ESP_LOGD("${ld2410_id}", "Disabling ble client"); 143 | id(${ld2410_id})->set_enabled(false); 144 | lambda: |- 145 | return id(${ld2410_id})->enabled; 146 | 147 | 148 | binary_sensor: 149 | - platform: template 150 | name: "${ld2410_name}Motion Detected" 151 | id: ${ld2410_id}_motion_detected 152 | device_class: motion 153 | filters: 154 | - delayed_on_off: ${binary_sensor_debounce} 155 | - platform: template 156 | name: "${ld2410_name}Occupancy Detected" 157 | id: ${ld2410_id}_occupancy_detected 158 | device_class: occupancy 159 | filters: 160 | - delayed_on_off: ${binary_sensor_debounce} 161 | - platform: template 162 | name: "${ld2410_name}LD2410 Connected" 163 | id: ${ld2410_id}_ble_connected 164 | device_class: connectivity 165 | on_release: 166 | then: 167 | - if: 168 | condition: 169 | switch.is_on: ${ld2410_id}_enable_switch 170 | then: # we need to toggle it 171 | - logger.log: 172 | format: "Connection lost, reconnecting..." 173 | tag: "${ld2410_id}" 174 | - switch.turn_off: "${ld2410_id}_enable_switch_internal" 175 | - script.execute: ${ld2410_id}_start_after_delay 176 | 177 | sensor: 178 | - platform: template 179 | name: "${ld2410_name}Motion Distance" 180 | id: ${ld2410_id}_motion_distance 181 | update_interval: never 182 | device_class: distance 183 | unit_of_measurement: "cm" 184 | filters: 185 | - throttle: ${sensor_throttle} 186 | - platform: template 187 | name: "${ld2410_name}Static Distance" 188 | id: ${ld2410_id}_static_distance 189 | update_interval: never 190 | device_class: distance 191 | unit_of_measurement: "cm" 192 | filters: 193 | - throttle: ${sensor_throttle} 194 | - platform: template 195 | name: "${ld2410_name}Static Energy" 196 | id: ${ld2410_id}_static_energy 197 | update_interval: never 198 | unit_of_measurement: "%" 199 | filters: 200 | - throttle: ${sensor_throttle} 201 | - platform: template 202 | name: "${ld2410_name}Moving Energy" 203 | id: ${ld2410_id}_moving_energy 204 | update_interval: never 205 | unit_of_measurement: "%" 206 | filters: 207 | - throttle: ${sensor_throttle} 208 | - platform: template 209 | name: "${ld2410_name}Detection Distance" 210 | id: ${ld2410_id}_detection_distance 211 | update_interval: never 212 | device_class: distance 213 | unit_of_measurement: "cm" 214 | filters: 215 | - throttle: ${sensor_throttle} 216 | - platform: ble_client 217 | type: characteristic 218 | ble_client_id: ${ld2410_id} 219 | service_uuid: '0000fff0-0000-1000-8000-00805f9b34fb' 220 | characteristic_uuid: '0000fff1-0000-1000-8000-00805f9b34fb' 221 | update_interval: never 222 | notify: False 223 | internal: True 224 | filters: 225 | - throttle: ${sensor_throttle} 226 | id: ${ld2410_id}_internal_parser 227 | lambda: |- 228 | // Only report connected once we have seen the first update 229 | if (!${ld2410_id}_ble_connected->state) { 230 | id(${ld2410_id}_ble_connected).publish_state(true); 231 | } 232 | if (x.size() < 16) { 233 | ESP_LOGD("${ld2410_id}_notify", "notify was given too little data (%d bytes)", x.size()); 234 | return NAN; 235 | } 236 | if (x[0] != 0xf4 || x[1] != 0xf3 || x[2] != 0xf2 || x[3] != 0xf1) { 237 | return NAN; 238 | } 239 | bool moving = x[8] & 0x1; 240 | bool stationary = x[8] & 0x2; 241 | float detect_distance = x[15]; 242 | detect_distance += x[16] << 8; 243 | float static_distance = x[12]; 244 | static_distance += x[13] << 8; 245 | float motion_distance = x[9]; 246 | motion_distance += x[10] << 8; 247 | float static_energy = x[14]; 248 | float motion_energy = x[11]; 249 | // if (!stationary) { 250 | // static_distance = NAN; 251 | // static_energy = NAN; 252 | // } 253 | // if (!moving) { 254 | // motion_distance = NAN; 255 | // motion_energy = NAN; 256 | // } 257 | if (!moving && !stationary) { 258 | detect_distance = NAN; 259 | } 260 | id(${ld2410_id}_motion_detected).publish_state(moving); 261 | id(${ld2410_id}_occupancy_detected).publish_state(stationary); 262 | 263 | id(${ld2410_id}_static_distance).publish_state(static_distance); 264 | id(${ld2410_id}_motion_distance).publish_state(motion_distance); 265 | id(${ld2410_id}_static_energy).publish_state(motion_energy); 266 | id(${ld2410_id}_moving_energy).publish_state(static_energy); 267 | id(${ld2410_id}_detection_distance).publish_state(detect_distance); 268 | return NAN; 269 | -------------------------------------------------------------------------------- /radiant_controller.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | device_name: radiant_controller 3 | 4 | esphome: 5 | name: radiant_controller 6 | friendly_name: Radiant Controller 7 | on_boot: 8 | then: 9 | pcf85063.read_time: # read RTC time once the system boots 10 | 11 | esp32: 12 | board: esp32dev 13 | 14 | api: 15 | 16 | ota: 17 | 18 | # see https://github.com/dtlzp/relay_dev_demo 19 | 20 | # Enable logging 21 | logger: 22 | level: DEBUG 23 | baud_rate: 0 24 | 25 | #wifi: 26 | # ssid: 74ax4k 27 | # password: twosigma 28 | ethernet: 29 | type: JL1101 30 | mdc_pin: GPIO23 31 | mdio_pin: GPIO18 32 | power_pin: GPIO12 33 | clk_mode: GPIO17_OUT 34 | phy_addr: 0 35 | 36 | uart: 37 | tx_pin: GPIO1 38 | rx_pin: GPIO3 39 | baud_rate: 9600 40 | stop_bits: 1 41 | 42 | modbus: 43 | flow_control_pin: GPIO33 44 | id: modbus1 45 | 46 | modbus_controller: 47 | id: modbus_ctl 48 | address: 0x1 49 | modbus_id: modbus1 50 | setup_priority: -10 51 | 52 | i2c: 53 | scl: GPIO32 54 | sda: GPIO16 55 | scan: true 56 | id: i2c_bus 57 | 58 | sn74hc595: 59 | - id: sn74hc595_hub 60 | data_pin: GPIO13 #SER 61 | clock_pin: GPIO14 #SRCLK 62 | latch_pin: GPIO15 #RCLK 63 | oe_pin: GPIO0 64 | sr_count: 2 65 | 66 | #sn74hc165: 67 | # - id: sn74hc165_hub 68 | # clock_pin: GPIO14 69 | # data_pin: GPIO35 70 | # load_pin: GPIO0 71 | # clock_inhibit_pin: GPIO15 72 | # sr_count: 2 73 | 74 | time: 75 | - platform: pcf85063 76 | update_interval: never # internal clock is fine for general use 77 | - platform: homeassistant 78 | on_time_sync: 79 | then: 80 | pcf85063.write_time: # sync RTC to HA when available 81 | 82 | sensor: 83 | - platform: modbus_controller 84 | modbus_controller_id: modbus_ctl 85 | name: NTC modbus address 86 | register_type: holding 87 | address: 0x0 88 | value_type: U_WORD 89 | - platform: modbus_controller 90 | modbus_controller_id: modbus_ctl 91 | name: Baud Rate 92 | register_type: holding 93 | address: 0x1 94 | value_type: U_WORD 95 | - platform: modbus_controller 96 | modbus_controller_id: modbus_ctl 97 | name: NTC B Value 98 | register_type: holding 99 | address: 0x2 100 | value_type: U_WORD 101 | - platform: modbus_controller 102 | modbus_controller_id: modbus_ctl 103 | name: Room 0 Temperature 104 | id: temp_room0 105 | register_type: holding 106 | address: 1024 107 | unit_of_measurement: ºC 108 | value_type: U_WORD 109 | filters: 110 | - multiply: 0.1 111 | - platform: modbus_controller 112 | modbus_controller_id: modbus_ctl 113 | name: Room 1 Temperature 114 | id: temp_room1 115 | register_type: holding 116 | address: 1025 117 | unit_of_measurement: ºC 118 | value_type: U_WORD 119 | filters: 120 | - multiply: 0.1 121 | # TODO here we need to check if the floor is overheating, and if so, turn off the pump 122 | - platform: modbus_controller 123 | modbus_controller_id: modbus_ctl 124 | name: Room 2 Temperature 125 | id: temp_room2 126 | register_type: holding 127 | address: 1026 128 | unit_of_measurement: ºC 129 | value_type: U_WORD 130 | filters: 131 | - multiply: 0.1 132 | - platform: modbus_controller 133 | modbus_controller_id: modbus_ctl 134 | name: Room 3 Temperature 135 | id: temp_room3 136 | register_type: holding 137 | address: 1027 138 | unit_of_measurement: ºC 139 | value_type: U_WORD 140 | filters: 141 | - multiply: 0.1 142 | - platform: modbus_controller 143 | modbus_controller_id: modbus_ctl 144 | name: Room 4 Temperature 145 | id: temp_room4 146 | register_type: holding 147 | address: 1028 148 | unit_of_measurement: ºC 149 | value_type: U_WORD 150 | filters: 151 | - multiply: 0.1 152 | - platform: modbus_controller 153 | modbus_controller_id: modbus_ctl 154 | name: Room 5 Temperature 155 | id: temp_room5 156 | register_type: holding 157 | address: 1029 158 | unit_of_measurement: ºC 159 | value_type: U_WORD 160 | filters: 161 | - multiply: 0.1 162 | - platform: modbus_controller 163 | modbus_controller_id: modbus_ctl 164 | name: Room 6 Temperature 165 | id: temp_room6 166 | register_type: holding 167 | address: 1030 168 | unit_of_measurement: ºC 169 | value_type: U_WORD 170 | filters: 171 | - multiply: 0.1 172 | - platform: modbus_controller 173 | modbus_controller_id: modbus_ctl 174 | name: Room 7 Temperature 175 | id: temp_room7 176 | register_type: holding 177 | address: 1031 178 | unit_of_measurement: ºC 179 | value_type: U_WORD 180 | filters: 181 | - multiply: 0.1 182 | - platform: modbus_controller 183 | modbus_controller_id: modbus_ctl 184 | name: Room 8 Temperature 185 | id: temp_room8 186 | register_type: holding 187 | address: 1032 188 | unit_of_measurement: ºC 189 | value_type: U_WORD 190 | filters: 191 | - multiply: 0.1 192 | - platform: modbus_controller 193 | modbus_controller_id: modbus_ctl 194 | name: Room 9 Temperature 195 | id: temp_room9 196 | register_type: holding 197 | address: 1033 198 | unit_of_measurement: ºC 199 | value_type: U_WORD 200 | filters: 201 | - multiply: 0.1 202 | - platform: modbus_controller 203 | modbus_controller_id: modbus_ctl 204 | name: Room 10 Temperature 205 | id: temp_room10 206 | register_type: holding 207 | address: 1034 208 | unit_of_measurement: ºC 209 | value_type: U_WORD 210 | filters: 211 | - multiply: 0.1 212 | - platform: modbus_controller 213 | modbus_controller_id: modbus_ctl 214 | name: Room 11 Temperature 215 | id: temp_room11 216 | register_type: holding 217 | address: 1035 218 | unit_of_measurement: ºC 219 | value_type: U_WORD 220 | filters: 221 | - multiply: 0.1 222 | 223 | switch: 224 | - platform: gpio 225 | name: Factory LED 226 | pin: 227 | number: GPIO2 228 | - platform: gpio 229 | name: Relay 16 control 230 | id: relay16ctl 231 | pin: 232 | sn74hc595: sn74hc595_hub 233 | number: 0 234 | inverted: false 235 | - platform: gpio 236 | name: Relay 15 control 237 | id: relay15ctl 238 | pin: 239 | sn74hc595: sn74hc595_hub 240 | number: 1 241 | inverted: false 242 | - platform: gpio 243 | name: Relay 14 control 244 | id: relay14ctl 245 | pin: 246 | sn74hc595: sn74hc595_hub 247 | number: 2 248 | inverted: false 249 | - platform: gpio 250 | name: Relay 13 control 251 | id: relay13ctl 252 | pin: 253 | sn74hc595: sn74hc595_hub 254 | number: 3 255 | inverted: false 256 | - platform: gpio 257 | name: Relay 12 control 258 | id: relay12ctl 259 | pin: 260 | sn74hc595: sn74hc595_hub 261 | number: 4 262 | inverted: false 263 | - platform: gpio 264 | name: Relay 11 control 265 | id: relay11ctl 266 | pin: 267 | sn74hc595: sn74hc595_hub 268 | number: 5 269 | inverted: false 270 | - platform: gpio 271 | name: Relay 10 control 272 | id: relay10ctl 273 | pin: 274 | sn74hc595: sn74hc595_hub 275 | number: 6 276 | inverted: false 277 | - platform: gpio 278 | name: Relay 9 control 279 | id: relay9ctl 280 | pin: 281 | sn74hc595: sn74hc595_hub 282 | number: 7 283 | inverted: false 284 | - platform: gpio 285 | name: Relay 8 control 286 | id: relay8ctl 287 | pin: 288 | sn74hc595: sn74hc595_hub 289 | number: 8 290 | inverted: false 291 | - platform: gpio 292 | name: Relay 7 control 293 | id: relay7ctl 294 | pin: 295 | sn74hc595: sn74hc595_hub 296 | number: 9 297 | inverted: false 298 | - platform: gpio 299 | name: Relay 6 control 300 | id: relay6ctl 301 | pin: 302 | sn74hc595: sn74hc595_hub 303 | number: 10 304 | inverted: false 305 | - platform: gpio 306 | name: Relay 5 control 307 | id: relay5ctl 308 | pin: 309 | sn74hc595: sn74hc595_hub 310 | number: 11 311 | inverted: false 312 | - platform: gpio 313 | name: Relay 4 control 314 | id: relay4ctl 315 | pin: 316 | sn74hc595: sn74hc595_hub 317 | number: 12 318 | inverted: false 319 | - platform: gpio 320 | name: Relay 3 control 321 | id: relay3ctl 322 | pin: 323 | sn74hc595: sn74hc595_hub 324 | number: 13 325 | inverted: false 326 | - platform: gpio 327 | name: Relay 2 control 328 | id: relay2ctl 329 | pin: 330 | sn74hc595: sn74hc595_hub 331 | number: 14 332 | inverted: false 333 | - platform: gpio 334 | name: Relay 1 control 335 | id: relay1ctl 336 | pin: 337 | sn74hc595: sn74hc595_hub 338 | number: 15 339 | inverted: false 340 | 341 | output: 342 | - platform: sigma_delta_output 343 | update_interval: 30s 344 | id: heater_room1 345 | turn_on_action: 346 | # TODO here we need to check that the floor isn't overheating 347 | # really should register intention in a template 348 | - switch.turn_on: relay1ctl 349 | turn_off_action: 350 | - switch.turn_off: relay1ctl 351 | 352 | climate: 353 | platform: pid 354 | name: PID Climate Controller 355 | sensor: temp_room1 356 | default_target_temperature: 21 357 | heat_output: heater_room1 358 | control_parameters: 359 | kp: 0 360 | ki: 0 361 | kd: 0 362 | output_averaging_samples: 5 363 | derivative_averaging_samples: 5 364 | deadband_parameters: 365 | threshold_high: 0.5 366 | threshold_low: -0.5 367 | 368 | binary_sensor: 369 | - platform: gpio 370 | name: Factory Button 371 | pin: 372 | number: GPIO34 373 | inverted: true 374 | # - platform: gpio 375 | # name: input8 376 | # pin: 377 | # sn74hc165: sn74hc165_hub 378 | # number: 0 379 | # inverted: true 380 | # - platform: gpio 381 | # name: input7 382 | # pin: 383 | # sn74hc165: sn74hc165_hub 384 | # number: 1 385 | # inverted: true 386 | # - platform: gpio 387 | # name: input6 388 | # pin: 389 | # sn74hc165: sn74hc165_hub 390 | # number: 2 391 | # inverted: true 392 | # - platform: gpio 393 | # name: input5 394 | # pin: 395 | # sn74hc165: sn74hc165_hub 396 | # number: 3 397 | # inverted: true 398 | # - platform: gpio 399 | # name: input4 400 | # pin: 401 | # sn74hc165: sn74hc165_hub 402 | # number: 4 403 | # inverted: true 404 | # - platform: gpio 405 | # name: input3 406 | # pin: 407 | # sn74hc165: sn74hc165_hub 408 | # number: 5 409 | # inverted: true 410 | # - platform: gpio 411 | # name: input2 412 | # pin: 413 | # sn74hc165: sn74hc165_hub 414 | # number: 6 415 | # inverted: true 416 | # - platform: gpio 417 | # name: input1 418 | # pin: 419 | # sn74hc165: sn74hc165_hub 420 | # number: 7 421 | # inverted: true 422 | # - platform: gpio 423 | # name: input16 424 | # pin: 425 | # sn74hc165: sn74hc165_hub 426 | # number: 8 427 | # inverted: true 428 | # - platform: gpio 429 | # name: input15 430 | # pin: 431 | # sn74hc165: sn74hc165_hub 432 | # number: 9 433 | # inverted: true 434 | # - platform: gpio 435 | # name: input14 436 | # pin: 437 | # sn74hc165: sn74hc165_hub 438 | # number: 10 439 | # inverted: true 440 | # - platform: gpio 441 | # name: input13 442 | # pin: 443 | # sn74hc165: sn74hc165_hub 444 | # number: 11 445 | # inverted: true 446 | # - platform: gpio 447 | # name: input12 448 | # pin: 449 | # sn74hc165: sn74hc165_hub 450 | # number: 12 451 | # inverted: true 452 | # - platform: gpio 453 | # name: input11 454 | # pin: 455 | # sn74hc165: sn74hc165_hub 456 | # number: 13 457 | # inverted: true 458 | # - platform: gpio 459 | # name: input10 460 | # pin: 461 | # sn74hc165: sn74hc165_hub 462 | # number: 14 463 | # inverted: true 464 | # - platform: gpio 465 | # name: input9 466 | # pin: 467 | # sn74hc165: sn74hc165_hub 468 | # number: 15 469 | # inverted: true 470 | -------------------------------------------------------------------------------- /state_mgmt.py: -------------------------------------------------------------------------------- 1 | import hassapi as hass 2 | import adbase as ad 3 | import datetime 4 | import math 5 | import time 6 | 7 | class EveningTracker(hass.Hass): 8 | def initialize(self): 9 | self.run_at_sunset(self.dusk_cb, offset=int(self.args.get('sunset_offset','0'))) 10 | self.run_at_sunrise(self.morning_cb, offset=int(self.args.get('sunrise_offset','0'))) 11 | 12 | def morning_cb(self, kwargs): 13 | self.turn_off(self.args['tracker']) 14 | 15 | def dusk_cb(self, kwargs): 16 | self.turn_on(self.args['tracker']) 17 | 18 | class RoomAugmenter(hass.Hass): 19 | 20 | def get_arg_as_list(self, name): 21 | x = self.args.get(name, []) 22 | if isinstance(x, str): 23 | x = [x] 24 | return x 25 | 26 | def initialize(self): 27 | self.grace_token = None 28 | self.trapped_token = None 29 | self.debug_mode = self.args.get('debug', False) 30 | self.current_state = 'unknown' 31 | self.retaining_irks = [] 32 | self.sensor_id = self.args['sensor_id'] 33 | self.room_names = set(self.get_arg_as_list('room')) 34 | self.entity_states = {} 35 | self.tracker_ents = self.get_arg_as_list('irk_trackers') 36 | for tracker in self.tracker_ents: 37 | if self.debug_mode: 38 | self.log(f"listening to {tracker}") 39 | self.listen_state(self.irk_tracked, tracker, duration=self.args.get('irk_stability_duration', 30)) 40 | self.entity_states[tracker] = self.get_state(tracker) 41 | self.opening_ents = self.get_arg_as_list('openings') 42 | for opening in self.opening_ents: 43 | if self.debug_mode: 44 | self.log(f"listening to {opening}") 45 | self.listen_state(self.opening_state, opening, immediate=True) 46 | self.entity_states[opening] = 'unknown' 47 | self.border_ents = self.get_arg_as_list('border') 48 | for border in self.border_ents: 49 | if self.debug_mode: 50 | self.log(f"listening to {border}") 51 | self.listen_state(self.border_crossed_state, border, immediate=True) 52 | self.entity_states[border] = 'unknown' 53 | self.interior_ents = self.get_arg_as_list('interior') 54 | for interior in self.interior_ents: 55 | if self.debug_mode: 56 | self.log(f"listening to {interior}") 57 | self.listen_state(self.interior_detected_state, interior, immediate=True) 58 | self.entity_states[interior] = 'unknown' 59 | if self.interior_ents and not self.border_ents: 60 | raise ValueError('If you only have activities and no borders, make them all borders and zero acivities please') 61 | ent = self.get_entity(self.sensor_id) 62 | ent.set_state(state = 'on' if self.any_borders_on() or self.any_interior_on() else 'off', attributes={'current_state': 'init'}) 63 | if self.debug_mode: 64 | self.log('finish init') 65 | 66 | def border_crossed_state(self, entity, attr, old, new, kwargs): 67 | if new == 'unavailable': 68 | return 69 | old_agg_state = self.any_borders_on() 70 | self.entity_states[entity] = new 71 | if new == 'on': # we must cancel close grace even if we're not firing the state machine 72 | self.cancel_close_grace() 73 | if self.current_state.startswith('interior'): 74 | return # higher priority, so disregard 75 | did_update = False 76 | if old_agg_state != self.any_borders_on(): 77 | did_update = True 78 | if self.any_borders_on(): 79 | self.update_state('border on') 80 | else: 81 | self.update_state('border off') 82 | if self.debug_mode: 83 | self.log(f'border {entity}={new} any-borders={self.any_borders_on()} {self.current_state} (did_update={did_update})') 84 | 85 | def any_borders_on(self): 86 | for b in self.border_ents: 87 | if self.entity_states[b] == 'on': 88 | return True 89 | return False 90 | 91 | def interior_detected_state(self, entity, attr, old, new, kwargs): 92 | if new == 'unavailable': 93 | return 94 | old_agg_state = self.any_interior_on() 95 | self.entity_states[entity] = new 96 | if new == 'on': # we must cancel close grace even if we're not firing the state machine 97 | self.cancel_close_grace() 98 | did_update = False 99 | if old_agg_state != self.any_interior_on(): 100 | did_update = True 101 | if self.any_interior_on(): 102 | self.update_state('interior on') 103 | else: 104 | self.update_state('interior off') 105 | if self.debug_mode: 106 | self.log(f'interior {entity}={new} any-interior={self.any_interior_on()} {self.current_state} (did_update={did_update})') 107 | 108 | def opening_is_open(self): 109 | if not self.opening_ents: 110 | return True 111 | for opening in self.opening_ents: 112 | if self.entity_states[opening] == 'on': 113 | return True 114 | return False 115 | 116 | def opening_state(self, entity, attr, old, new, kwargs): 117 | if new == 'unavailable': 118 | return 119 | self.entity_states[entity] = new 120 | if new == 'on': 121 | self.update_state('just opened') 122 | if new == 'off': 123 | self.update_state('just closed') 124 | 125 | def irk_tracked(self, entity, attr, old, new, kwargs): 126 | self.entity_states[entity] = new 127 | if new not in self.room_names: 128 | if entity in self.retaining_irks: 129 | self.retaining_irks.remove(entity) 130 | self.current_state = f'retained by {self.retaining_irks}' 131 | if not self.retaining_irks: 132 | self.update_state('no retaining irks') 133 | if self.debug_mode: 134 | self.log(f'irk {entity}={new} {self.current_state}') 135 | 136 | def any_interior_on(self): 137 | for i in self.interior_ents: 138 | if self.entity_states[i] == 'on': 139 | return True 140 | return False 141 | 142 | def close_grace_expired(self, kwargs): 143 | self.grace_token = None # otherwise, we'll try to cancel ourself later, but we can't because we already fired 144 | self.update_state('close grace expired') 145 | 146 | def trapped_wait_expired(self, kwargs): 147 | self.trapped_token = None 148 | self.update_state('trapped expired') 149 | 150 | def update_state(self, new_state): 151 | old_state = self.current_state 152 | publish_state = None 153 | if new_state == 'interior on': 154 | # Activity in the interior is retained until we see possible exit motion 155 | self.current_state = 'interior on' 156 | publish_state = 'on' 157 | elif new_state == 'interior off': 158 | # if any borders are on, we downgrade to them. otherwise, stay in interior off 159 | if self.any_borders_on(): 160 | self.current_state = 'border on' 161 | elif not self.any_interior_on() and old_state != 'unknown': 162 | self.current_state = 'interior off' 163 | # [test: when should we actually publish?] we only copy the existing state here, b/c it should already be on (or off at initialization) 164 | if old_state == 'unknown': 165 | publish_state = self.get_state(self.sensor_id) 166 | else: 167 | publish_state = 'on' 168 | elif new_state == 'border on': 169 | # If we're not in an interior state, we'll move to the border state 170 | if not self.current_state.startswith('interior '): 171 | self.current_state = 'border on' 172 | publish_state = 'on' 173 | elif new_state == 'border off' and self.current_state == 'border on': 174 | if self.opening_is_open(): 175 | self.current_state = 'off' 176 | self.retaining_irks = [x for x in self.tracker_ents if self.entity_states[x] in self.room_names] 177 | if self.debug_mode: 178 | self.log(f'border on->off, retain = {self.retaining_irks}, rooms={self.room_names}, trackers={self.tracker_ents} tracker_states = {[self.entity_states[x] for x in self.tracker_ents]}') 179 | if self.retaining_irks: 180 | self.current_state = f'retained by {self.retaining_irks}' 181 | publish_state = 'on' 182 | else: 183 | self.current_state = 'off' 184 | publish_state = 'off' 185 | else: 186 | self.current_state = 'trapped' 187 | publish_state = 'on' 188 | self.trapped_token = self.run_in(self.trapped_wait_expired, delay=self.args.get('trapped_max_period_seconds', 60*30)) 189 | elif new_state == 'no retaining irks' and self.current_state.startswith('retained by '): 190 | self.current_state = 'off' 191 | publish_state = 'off' 192 | elif new_state == 'just opened': 193 | self.current_state = 'border on' 194 | publish_state = 'on' 195 | elif new_state == 'just closed': 196 | self.grace_token = self.run_in(self.close_grace_expired, delay=self.args.get('closing_grace_period_seconds', 5)) 197 | elif new_state == 'close grace expired': 198 | self.current_state = 'off' 199 | publish_state = 'off' 200 | elif new_state == 'trapped expired': 201 | self.current_state = 'off' 202 | publish_state = 'off' 203 | # Cancel a delayed off if there is one 204 | if self.grace_token is not None and new_state != 'just closed': 205 | if self.current_state == 'trapped': 206 | if self.debug_mode: 207 | self.log(f"we can't be trapped when on grace period, reverting") 208 | self.current_state = old_state 209 | publish_state = None 210 | else: 211 | self.cancel_close_grace() 212 | if self.trapped_token is not None and new_state != 'trapped': 213 | self.cancel_timer(self.trapped_token) 214 | self.trapped_token = None 215 | if self.debug_mode: 216 | self.log(f'Updated state due to {new_state} from {old_state} to {self.current_state}, publishing "{publish_state}"') 217 | if publish_state == 'off' and (self.any_borders_on() or self.any_interior_on()): 218 | self.log(f'INCORRECT--not publishing: due to {new_state} from {old_state} to {self.current_state}, publishing "{publish_state}"') 219 | publish_state = None 220 | if publish_state is not None: 221 | ent = self.get_entity(self.sensor_id) 222 | attrs = {'current_state': self.current_state} 223 | for k,v in self.entity_states.items(): 224 | attrs[k] = v 225 | ent.set_state(state = publish_state, attributes=attrs) 226 | 227 | def cancel_close_grace(self): 228 | if self.grace_token is not None: 229 | self.cancel_timer(self.grace_token) 230 | self.grace_token = None 231 | 232 | 233 | class BedStateManager(hass.Hass): 234 | def initialize(self): 235 | self.listen_event(self.ios_wake_cb, "ios.action_fired", actionName=self.args['wake_event']) 236 | self.ssids = self.args['home_ssids'] 237 | self.persons_asleep = {} 238 | self.persons_away = {} 239 | runtime = datetime.time(0, 0, 0) 240 | self.bed_presence = {} 241 | bp_cfg = self.args['bed_presence'] 242 | for person, cfg in self.args['iphones'].items(): 243 | self.listen_state(self.sleep_check_cb, cfg['charging'], new=lambda x: x.lower() in ['charging', 'full'], immediate=True, person=person, cfg=cfg, constrain_start_time=self.args['bedtime_start'], constrain_end_time=self.args['bedtime_end']) 244 | self.listen_state(self.sleep_check_cb, cfg['ssid'], new=lambda x: x in self.args['home_ssids'], person=person, cfg=cfg, constrain_start_time=self.args['bedtime_start'], constrain_end_time=self.args['bedtime_end']) 245 | self.bed_presence[person] = bp_cfg.get(person, bp_cfg['default']) if not isinstance(bp_cfg, str) else bp_cfg 246 | self.listen_state(self.sleep_check_cb, self.bed_presence[person], new='on', person=person, cfg=cfg, constrain_start_time=self.args['bedtime_start'], constrain_end_time=self.args['bedtime_end']) 247 | self.persons_asleep[person] = False 248 | self.persons_away[person] = False 249 | self.run_hourly(self.check_far_away, runtime, person=person, cfg=cfg) 250 | self.run_in(self.check_far_away, delay=0, person=person, cfg=cfg) 251 | self.log(f"bed presence cfg worked out to {self.bed_presence}") 252 | 253 | def check_far_away(self, kwargs): 254 | person = kwargs['person'] 255 | cfg = kwargs['cfg'] 256 | orig_away = self.persons_away[person] 257 | if float(self.get_state(cfg['distance'])) > float(self.args['away_distance']): 258 | self.persons_away[person] = True 259 | else: 260 | self.persons_away[person] = False 261 | if orig_away != self.persons_away[person]: 262 | msg = 'away' if self.persons_away[person] else 'nearby' 263 | self.log(f"[check far away] {person} changed to {msg}") 264 | 265 | def ios_wake_cb(self, event_name, data, kwargs): 266 | person = None 267 | cfg = None 268 | for p in self.args['iphones']: 269 | if p in data['sourceDeviceID']: 270 | person = p 271 | cfg = self.args['iphones'][p] 272 | break 273 | if person is None: 274 | self.log(f"ios wake event didn't match any person: {data}") 275 | return 276 | self.persons_asleep[person] = False 277 | self.turn_off(cfg['bed_tracker']) 278 | self.log(f"ios wake event for {person} registered") 279 | # if everyone here is awake 280 | all_awake = True 281 | for p in [k for k,v in self.persons_away.items() if v == False]: 282 | if self.persons_asleep[p]: 283 | all_awake = False 284 | break 285 | if all_awake: # everyone home is awake now 286 | self.turn_off(self.args['bed_tracker']) 287 | self.log(f"also, now everyone is awake") 288 | 289 | def sleep_check_cb(self, entity, attr, old, new, kwargs): 290 | person = kwargs['person'] 291 | cfg = kwargs['cfg'] 292 | if self.get_state(self.bed_presence[person]) == 'off': 293 | # someone must be in bed 294 | self.log(f"saw {entity} become {new}, but not activating sleep for {person} because {self.bed_presence[person]} isn't in bed") 295 | return 296 | if self.get_state(cfg['ssid']) not in self.args['home_ssids']: 297 | # we must be connected to home wifi 298 | self.log(f"saw {entity} become {new}, but not activating sleep for {person} because they're not connected to wifi") 299 | return 300 | if self.get_state(cfg['charging']).lower() not in ['charging', 'full']: 301 | # we must be charging 302 | self.log(f"saw {entity} become {new}, but not activating sleep for {person} because they're not charging") 303 | return 304 | self.turn_on(cfg['bed_tracker']) 305 | self.persons_asleep[person] = True 306 | self.log(f"sleep for {person} registered") 307 | for p in [k for k,v in self.persons_away.items() if v == False]: 308 | if not self.persons_asleep[p]: 309 | self.log(f"{p} is not away and not asleep") 310 | return 311 | self.turn_on(self.args['bed_tracker']) 312 | self.log(f"also, now everyone is asleep") 313 | -------------------------------------------------------------------------------- /cleaning_queue.py: -------------------------------------------------------------------------------- 1 | import math 2 | import pytz 3 | import numpy as np 4 | import pandas as pd 5 | import hassapi as hass 6 | import adbase as ad 7 | import datetime 8 | import influx 9 | from collections import defaultdict 10 | from pprint import pprint 11 | 12 | 13 | def parse_conditional_expr(cause): 14 | """ 15 | Copied from lights.py 16 | """ 17 | present_state = 'on' 18 | absent_state = 'off' 19 | entity = cause 20 | if '==' in cause: 21 | xs = [x.strip() for x in cause.split('==')] 22 | #print(f"parsing a state override light trigger {xs}") 23 | entity = xs[0] 24 | present_state = xs[1] 25 | absent_state = None 26 | elif '!=' in cause: 27 | xs = [x.strip() for x in cause.split('!=')] 28 | #print(f"parsing a negative state override light trigger") 29 | entity = xs[0] 30 | present_state = None 31 | absent_state = xs[1] 32 | elif ' not in ' in cause: 33 | xs = [x.strip() for x in cause.split(' not in ')] 34 | entity = xs[0] 35 | present_state = None 36 | absent_state = [x.strip() for x in xs[1].strip('[]').split(',')] 37 | elif ' in ' in cause: 38 | xs = [x.strip() for x in cause.split(' in ')] 39 | entity = xs[0] 40 | present_state = [x.strip() for x in xs[1].strip('[]').split(',')] 41 | absent_state = None 42 | return present_state, absent_state, entity 43 | 44 | 45 | class CleaningManager(hass.Hass): 46 | def initialize(self): 47 | self.debug_enabled = self.args.get('debug', False) 48 | self.ready_service_args = None 49 | self.vacuum = self.args['vacuum'] 50 | self.vacuum_map = self.args['vacuum_map'] 51 | self.pending_actions = [] # list of maps that describe pending cleaning actions 52 | self.areas = self.args['areas'] 53 | self.home_area = None 54 | presence_sensors = set() 55 | for area,cfg in self.areas.items(): 56 | if cfg.get('home'): 57 | if self.home_area is not None: 58 | raise ValueError(f"Should only be one home area") 59 | self.home_area = area 60 | for s in cfg.get('presence', []): 61 | presence_sensors.add(s) 62 | self.sensor_listen_tokens = [] 63 | for src_area, cfgs in self.args['pathways'].items(): 64 | if self.debug_enabled: 65 | self.log(f"processing pathway from {src_area}, cfg={cfgs}") 66 | for dest_cfg in cfgs: 67 | if isinstance(dest_cfg, str): 68 | dest_area = dest_cfg 69 | dest_cfg = {'area': dest_area, 'always_open': True} 70 | else: 71 | dest_area = dest_cfg['area'] 72 | del dest_cfg['area'] 73 | dest_cfg['always_open'] = False 74 | dest_cfg['before_coord'] = tuple(dest_cfg['before_coord']) 75 | dest_cfg['after_coord'] = tuple(dest_cfg['after_coord']) 76 | if self.debug_enabled: 77 | self.log(f"processing pathway from {src_area} to {dest_area} with cfg={dest_cfg}") 78 | src_conns = self.areas[src_area].get('connections', {}) 79 | if dest_area in src_conns: 80 | raise ValueError(f"{dest_area} already in {list(src_conns.keys())}") 81 | src_conns[dest_area] = dest_cfg.copy() 82 | self.areas[src_area]['connections'] = src_conns 83 | dest_conns = self.areas[dest_area].get('connections', {}) 84 | if src_area in dest_conns: 85 | raise ValueError(f"{dest_area} already in {list(src_conns.keys())}") 86 | if 'before_coord' in dest_cfg: 87 | tmp = dest_cfg['before_coord'] 88 | dest_cfg['before_coord'] = dest_cfg['after_coord'] 89 | dest_cfg['after_coord'] = tmp 90 | dest_conns[src_area] = dest_cfg 91 | self.areas[dest_area]['connections'] = dest_conns 92 | # compute "openings_from_home" for all areas. This is needed to be practical about cleaning past sometimes-opened doors 93 | self.areas[self.home_area]['openings_from_home'] = 0 94 | seen = set() 95 | worklist = [self.home_area] 96 | openings = set() 97 | while worklist: 98 | cur = worklist.pop(0) 99 | seen.add(cur) 100 | cur_cfg = self.areas[cur] 101 | conns = cur_cfg.get('connections', {}) 102 | for dest,conn in conns.items(): 103 | if dest not in seen: 104 | if conn['always_open']: 105 | self.areas[dest]['openings_from_home'] = cur_cfg['openings_from_home'] 106 | else: 107 | self.areas[dest]['openings_from_home'] = cur_cfg['openings_from_home'] + 1 108 | if 'opening' in conn: 109 | openings.add(conn['opening']) 110 | worklist.append(dest) 111 | if self.debug_enabled: 112 | pprint(self.areas) 113 | # try to reschedule as soon as a door is opened for a bit 114 | for opening in openings: 115 | self.listen_state(self.schedule_on_state_change, opening, duration=15) 116 | # try to reschedule as soon as presence signals change 117 | for s in presence_sensors: 118 | self.listen_state(self.schedule_on_state_change, s, duration=3) 119 | # validate we have full reachability for all areas 120 | areas_not_connected = [] 121 | for area, cfg in self.areas.items(): 122 | if 'openings_from_home' not in cfg: 123 | areas_not_connected.append(area) 124 | if areas_not_connected: 125 | raise ValueError(f"The following areas don't have pathways configured correctly: {areas_not_connected}") 126 | # Start looking to clean 127 | runtime = datetime.time(0, 0, 0) 128 | self.listen_event(self.clean_event_cb, "cleaner.clean_area") 129 | self.run_minutely(self.next_job, runtime) 130 | 131 | def clean_area(self, area, custom_args): 132 | action = {} 133 | action['area'] = area 134 | action['args'] = custom_args 135 | if self.debug_enabled: 136 | self.log(f"Enqueuing clean area: {action}") 137 | self.pending_actions.append(action) 138 | 139 | def clean_event_cb(self, event_name, data, kwargs): 140 | self.clean_area(data['area'], data.get('args', {})) 141 | # Immediately try scheduling (but give a short grace period so that we can batch up) 142 | self.run_in(self.next_job, 5) 143 | 144 | def get_directly_connected_set(self, area, include_currently_open=False, min_openings_from_home=0): 145 | connected = set() 146 | seen = set() 147 | connected.add(area) 148 | seen.add(area) 149 | worklist = [area] 150 | while worklist: 151 | cur = worklist.pop(0) 152 | for dst_area, cfg in self.areas[cur].get('connections', {}).items(): 153 | now_open = False 154 | if include_currently_open and 'opening' in cfg: 155 | now_open = self.get_state(cfg['opening']) == 'on' 156 | if self.areas[dst_area]['openings_from_home'] >= min_openings_from_home and cfg['always_open'] or now_open: 157 | connected.add(dst_area) 158 | if dst_area not in seen: 159 | worklist.append(dst_area) 160 | seen.add(dst_area) 161 | return connected 162 | 163 | def is_zone(self, area): 164 | return 'zone' in self.areas[area] 165 | 166 | def vacuum_close_to(self, coord, distance = 150): 167 | cur_pos = self.get_state(self.vacuum_map, attribute='vacuum_position') 168 | tx,ty = coord 169 | cx,cy = (cur_pos['x'], cur_pos['y']) 170 | dist = math.sqrt((tx-cx)**2 + (ty-cy)**2) 171 | if self.debug_enabled: 172 | self.log(f"close to check: current={(cx,cy)} target={(tx,ty)} dist={dist}") 173 | return dist < distance 174 | 175 | def find_path_between(self, start, end): 176 | predecessors = {} 177 | seen = set() 178 | seen.add(start) 179 | worklist = [start] 180 | while worklist and end not in seen: 181 | cur = worklist.pop(0) 182 | for dst_area, cfg in self.areas[cur].get('connections', {}).items(): 183 | now_open = False 184 | if dst_area not in predecessors: 185 | predecessors[dst_area] = cur 186 | if dst_area not in seen: 187 | worklist.append(dst_area) 188 | seen.add(dst_area) 189 | path = [end] 190 | while start not in path: 191 | path.append(predecessors[path[-1]]) 192 | if self.debug_enabled: 193 | self.log(f"computed path from {start} to {end}: {path[::-1]}") 194 | return path[::-1] 195 | 196 | def schedule_on_state_change(self, entity, attribute, old, new, kwargs): 197 | self.next_job({}) 198 | 199 | def next_job(self, kwargs): 200 | if self.ready_service_args: 201 | self.log(f"not trying to schedule because we've already got a job running (sensors = {self.sensor_states})") 202 | return 203 | if self.get_state(self.vacuum) not in ['docked', 'idle']: 204 | self.log(f"not trying to schedule because we're doing something now") 205 | return 206 | if not self.pending_actions: 207 | self.log(f"not trying to schedule b/c no pending actions") 208 | return 209 | self.log(f"Starting to compute next job") 210 | min_openings_from_home = 0 211 | # First, check if we're right by something's before_coord 212 | for area, cfg in self.areas.items(): 213 | for dst_area,dst_cfg in cfg.get('connections',{}).items(): 214 | # If we are, we should try to clean rooms at least that many openings from home 215 | if 'before_coord' in dst_cfg and self.vacuum_close_to(dst_cfg['before_coord']): 216 | min_openings_from_home = max(min_openings_from_home, self.areas[dst_area]['openings_from_home']) 217 | # We'll prefer cleaning that won't require asking for help to open a door 218 | current_area_id = self.get_state(self.vacuum_map, attribute='vacuum_room') 219 | current_area = self.home_area # default to home area if we don't localize 220 | for area, cfg in self.areas.items(): 221 | if 'id' in cfg and current_area_id == cfg['id']: 222 | current_area = area 223 | self.log(f"Vacuum in {current_area} after localizing, looking for areas at least {min_openings_from_home} openings from home away") 224 | areas_accessible_from_current = self.get_directly_connected_set(current_area, include_currently_open=True, min_openings_from_home=min_openings_from_home) 225 | preferred_pending_actions = [a for a in self.pending_actions if a['area'] in areas_accessible_from_current] 226 | if self.debug_enabled: 227 | self.log(f"first attempt for preferred pending: {preferred_pending_actions} based on accessible area list: {areas_accessible_from_current}") 228 | if not preferred_pending_actions: # nothing is readily accessible 229 | coords_and_votes = defaultdict(lambda: 0) 230 | coords_to_transition = {} 231 | # here we find the before_coord for a pending area that is directly accessible. Then just head there. 232 | pending_areas = [x['area'] for x in self.pending_actions] 233 | for pending_area in pending_areas: 234 | area_path = self.find_path_between(current_area, pending_area) 235 | for i,area in enumerate(area_path[:-1]): 236 | cfg = self.areas[area] 237 | next_area = area_path[i+1] 238 | conn = cfg['connections'][next_area] 239 | if 'opening' in conn: 240 | if self.get_state(conn['opening']) != 'on': # not currently open 241 | coords_and_votes[conn['before_coord']] += 1 242 | coords_to_transition[conn['before_coord']] = (area, next_area) 243 | self.log(f"coords_and_votes = {coords_and_votes}; coords_to_transition = {coords_to_transition}") 244 | pprint(coords_and_votes) 245 | target_coords = max(coords_and_votes, key=lambda k: coords_and_votes[k]) 246 | #self.log(f"self.call_service('roborock/vacuum_goto', x_coord={target_coords[0]}, y_coord={target_coords[1]})") 247 | area, next_area = coords_to_transition[target_coords] 248 | if self.vacuum_close_to(target_coords): 249 | self.log(f"vacuum already close to {target_coords} (to go from {area} to {next_area}), so holding tight") 250 | else: 251 | self.call_service('roborock/vacuum_goto', x_coord=target_coords[0], y_coord=target_coords[1], entity_id= self.vacuum) 252 | self.log(f"sending vacuum to the opening between {area} and {next_area} to wait") 253 | return 254 | init_action = preferred_pending_actions.pop(0) 255 | next_actions = [init_action] 256 | merge_candidate_areas = self.get_directly_connected_set(init_action['area'], include_currently_open=True, min_openings_from_home=min_openings_from_home) 257 | for action in self.pending_actions: 258 | if (action['area'] in merge_candidate_areas # is freely connected, for no prompting/assistance 259 | and action['args'] == init_action['args'] # same configuration 260 | and self.is_zone(action['area']) == self.is_zone(init_action['area']) # ensure cleanable in the same API call (zones or rooms) 261 | and action not in next_actions): # we don't handle duplicates b/c it's easier 262 | next_actions.append(action) 263 | for action in next_actions: 264 | self.pending_actions.remove(action) 265 | self.log(f"Next actions:") 266 | pprint(next_actions) 267 | if self.is_zone(init_action['area']): 268 | self.do_room_cleaning([a['area'] for a in next_actions], target_key='zone', service='roborock/vacuum_clean_zone', service_arg='zone', **init_action['args']) 269 | else: # rooms 270 | self.do_room_cleaning([a['area'] for a in next_actions], target_key='id', service='roborock/vacuum_clean_segment', service_arg='segments', **init_action['args']) 271 | 272 | def stop_sensor_listening(self): 273 | for token in self.sensor_listen_tokens: 274 | self.cancel_listen_state(token) 275 | self.sensor_listen_tokens = [] 276 | self.sensor_states = {} 277 | 278 | def sensor_state_changed(self, entity, attribute, old, new, kwargs): 279 | if new in ['unknown', 'unavailable']: 280 | new = 'off' 281 | self.sensor_states[entity] = new 282 | self.clean_if_ready() 283 | 284 | def clean_if_ready(self): 285 | waiting_for_sensors = [] 286 | all_off = True 287 | for k,v in self.sensor_states.items(): 288 | if v != 'off': 289 | all_off = False 290 | waiting_for_sensors.append(k) 291 | if all_off: 292 | # everything is unoccupied 293 | self.log(f"starting clean: {self.ready_service_args}") 294 | self.call_service(**self.ready_service_args) 295 | self.stop_sensor_listening() 296 | # now that the cleaning started, we need to wait until it's done, then clear the ready_service_args 297 | self.vacuum_listen_token = self.listen_state(self.vacuum_state_changed, self.vacuum, attribute='status') 298 | else: 299 | self.log(f"Waiting for sensors: {waiting_for_sensors}") 300 | 301 | def vacuum_state_changed(self, entity, attribute, old, new, kwargs): 302 | # statuses seen: 'washing_the_map', 'idle', 'segment_cleaning', 'going_to_wash_the_mop', 'washing_the_mop', 'zoned_cleaning' 303 | if self.debug_enabled: 304 | self.log(f"observed state change for {entity} from {old} to {new}") 305 | # TODO looks like we want a 3 state track of 'returning_home' -> 'emptying_the_bin' -> 'charging' 306 | if new == 'charging': 307 | if self.debug_enabled: 308 | self.log(f"Finished cleaning and returning to dock, job is done") 309 | self.ready_service_args = None 310 | self.cancel_listen_state(self.vacuum_listen_token) 311 | 312 | def do_room_cleaning(self, rooms, repeats=None, target_key='id', service='roborock/vacuum_clean_segment', service_arg='segments'): 313 | presence_sensors = [] # wait for rooms to seem empty 314 | ids = [] # things to clean 315 | for room in rooms: 316 | cfg = self.areas[room] 317 | presence = cfg.get('presence', []) 318 | if not isinstance(presence, list): 319 | presence = [presence] 320 | presence_sensors.extend(presence) 321 | ids.append(cfg[target_key]) 322 | self.stop_sensor_listening() 323 | # build the service call to do the work 324 | self.ready_service_args = { 325 | 'service': service, 326 | service_arg: ids, 327 | 'entity_id': self.vacuum, 328 | } 329 | if repeats is not None: 330 | self.ready_service_args['repeats'] = repeats 331 | self.log(f'Waiting for {set(presence_sensors)} to be all off to start the cleaning job {self.ready_service_args}') 332 | # start listening for sensors to be ready 333 | for sensor in set(presence_sensors): 334 | self.sensor_states[sensor] = self.get_state(sensor) 335 | token = self.listen_state(self.sensor_state_changed, sensor) 336 | self.sensor_listen_tokens.append(token) 337 | self.clean_if_ready() 338 | -------------------------------------------------------------------------------- /apps.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | global_modules: 3 | - global_user_id 4 | 5 | global_user_id: 6 | class: GlobalUserInfo 7 | module: lights 8 | user_id: 5851a5ea934047d5943e66b3012ac053 9 | 10 | tracker: 11 | class: IrkTracker 12 | module: irk_tracker 13 | #log: irk_tracker_log 14 | training_input_text: input_text.irk_tag 15 | data_loc: tracker_logs/ 16 | rows_per_flush: 100 17 | identities: !secret irk_tracker_identities 18 | # Example -- TODO document how to retrieve IRK for devices 19 | # - device_name: 'david phone' 20 | # irk: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXF 21 | # person: david 22 | # - device_name: 'david watch' 23 | # irk: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 24 | # person: david 25 | # - device_name: 'partner phone' 26 | # irk: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 27 | # person: aysylu 28 | # - device_name: 'partner watch' 29 | # irk: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 30 | # person: aysylu 31 | device_deactivators: # TODO lol 32 | 'david watch': sensor.davids_apple_watch_battery_state.battery_status in [NotCharging, Charging] 33 | away_tracker_arrival_delay_secs: 120 34 | away_trackers: 35 | - person: david 36 | tracker: device_tracker.david_iphone 37 | home_focused_tracker: device_tracker.david_fused 38 | - person: aysylu 39 | tracker: device_tracker.aysylu_iphone 40 | home_focused_tracker: device_tracker.aysylu_fused 41 | pullout_sensors: 42 | - entity: binary_sensor.garage_occupied 43 | from: 'on' 44 | to: 'off' 45 | within_top: 2 46 | nearest_beacons: 47 | - garage_assistant 48 | rssi_adjustments: 49 | bedroom_blinds: 15 50 | living_room_blinds: 12 51 | tracking_window_minutes: 3 52 | tracking_min_superplurality: 1.3 53 | ping_halflife_seconds: 60 54 | # uncomment to enable the coupling of irk to presence sensing 55 | #room_presence: 56 | # downstairs: 57 | # - binary_sensor.basement_occupancy 58 | # david-office: 59 | # - binary_sensor.david_office_occupancy 60 | # aysylu-office: 61 | # - binary_sensor.aysylu_office_occupancy 62 | # bedroom: 63 | # - binary_sensor.bedroom_occupancy 64 | # main-floor: 65 | # - binary_sensor.main_floor_occupancy 66 | # - binary_sensor.kitchen_occupancy 67 | room_aliases: 68 | basement_beacon: downstairs 69 | ep1-david-office: 70 | secondary_clarifiers: 71 | - basement_beacon: main-floor 72 | - ep1-bedroom: david-office 73 | - underbed: david-office 74 | default: david-office 75 | ep1-aysylu-office: aysylu-office 76 | living_room_blinds: main-floor 77 | garage_assistant: 78 | secondary_clarifiers: 79 | - downstairs 80 | - main-floor 81 | - ep1-aysylu-office: main-floor 82 | - ep1-david-office: main-floor 83 | ep1-main-floor: main-floor 84 | bedroom_blinds: bedroom 85 | ep1-bedroom: bedroom 86 | ep1-kitchen: 87 | secondary_clarifiers: 88 | - main-floor 89 | - aysylu-office 90 | - basement_beacon: main-floor 91 | underbed: 92 | secondary_clarifiers: 93 | - main-floor 94 | - bedroom 95 | - garage_assistant: main-floor 96 | default: bedroom 97 | 98 | #example_light: 99 | # class: foo 100 | # module: foo 101 | # triggers: 102 | # - presence: 103 | # - sensor1 104 | # - sensor2 105 | # delay_on: 0s 106 | # delay_off: 30s 107 | # turns_on: True 108 | # turns_off: True 109 | # transition: 4 110 | # - presence: 111 | # - adjacent_room_sensor1 112 | # - adjacent_room_sensor2 113 | # delay_on: 5s 114 | # delay_off: 0s 115 | # max_brightness: 30% 116 | # turns_on: True 117 | # turns_off: True 118 | # transition: 5 119 | # - task: doing_a_thing 120 | # turns_on: True 121 | # turns_off: False 122 | # fake_when_away: True 123 | # people_trackers: 124 | # - device_tracker.david_iphone 125 | # - device_tracker.aysylu_iphone 126 | # daily_off_time: 04:00:00 127 | # adaptive_lighting: switch.adaptive_lighting_default 128 | 129 | 130 | 131 | living_room_lights: 132 | class: LightController 133 | module: lights 134 | triggers: 135 | - task: 136 | - input_boolean.dna_sleeping 137 | state: turned_off 138 | - task: input_boolean.watching_tv 139 | max_brightness: 30% 140 | transition: 8 141 | - presence: 142 | - binary_sensor.main_floor_presence_augmented 143 | delay_off: 120 144 | 145 | # - presence: 146 | # - device_tracker.david_fused in [home, just_arrived] 147 | # - device_tracker.aysylu_fused in [home, just_arrived] 148 | adaptive_lighting: switch.adaptive_lighting_default 149 | light: light.living_room 150 | 151 | curio_light: 152 | class: LightController 153 | module: lights 154 | triggers: 155 | - task: 156 | - input_boolean.dna_sleeping 157 | state: turned_off 158 | - presence: 159 | #- binary_sensor.main_floor_occupancy 160 | - device_tracker.david_fused in [home, just_arrived] 161 | - device_tracker.aysylu_fused in [home, just_arrived] 162 | #- input_text.test_helper_text == sleep 163 | condition: input_boolean.it_is_evening 164 | adaptive_lighting: switch.adaptive_lighting_default 165 | light: light.curio_lights 166 | #debug: True 167 | 168 | dining_room_accent_light: 169 | class: LightController 170 | module: lights 171 | triggers: 172 | - task: 173 | - input_boolean.dna_sleeping 174 | state: turned_off 175 | - task: input_boolean.watching_tv 176 | state: turned_off 177 | transition: 8 178 | - presence: 179 | - device_tracker.david_fused in [home, just_arrived] 180 | - device_tracker.aysylu_fused in [home, just_arrived] 181 | condition: input_boolean.it_is_evening 182 | adaptive_lighting: switch.adaptive_lighting_default 183 | light: light.dimmable_light_30 184 | 185 | 186 | dining_room_lights: 187 | class: LightController 188 | module: lights 189 | triggers: 190 | - task: 191 | - input_boolean.dna_sleeping 192 | - input_boolean.david_in_bed 193 | state: turned_off 194 | - task: input_boolean.watching_tv 195 | state: turned_off 196 | transition: 8 197 | - presence: 198 | - binary_sensor.main_floor_presence_augmented 199 | delay_off: 120 200 | #- device_tracker.david_fused in [home, just_arrived] 201 | #- device_tracker.aysylu_fused in [home, just_arrived] 202 | adaptive_lighting: switch.adaptive_lighting_default 203 | light: light.dining_table_fan_fixture 204 | 205 | david_office_lights: 206 | class: LightController 207 | module: lights 208 | triggers: 209 | - presence: 210 | - binary_sensor.david_office_occupancy 211 | #- input_text.test_helper_text == home 212 | delay_off: 30 213 | adaptive_lighting: switch.adaptive_lighting_default 214 | light: light.david_office 215 | 216 | aysylu_office_lights: 217 | class: LightController 218 | module: lights 219 | triggers: 220 | - presence: 221 | - binary_sensor.aysylu_office_occupancy 222 | delay_off: 30 223 | adaptive_lighting: switch.adaptive_lighting_default 224 | light: light.aysylu_office_ceiling 225 | #debug: true 226 | 227 | aysylu_office_lamp: 228 | class: LightController 229 | module: lights 230 | triggers: 231 | - presence: 232 | - binary_sensor.aysylu_office_occupancy 233 | condition: input_boolean.it_is_evening 234 | delay_off: 30 235 | adaptive_lighting: switch.adaptive_lighting_default 236 | light: light.aysylu_office_dim_lamp 237 | # debug: true 238 | 239 | stairs_lights: 240 | class: LightController 241 | module: lights 242 | triggers: 243 | - task: 244 | - input_boolean.dna_sleeping 245 | - input_boolean.david_in_bed 246 | state: turned_off 247 | - task: input_boolean.watching_tv 248 | state: turned_off 249 | transition: 8 250 | - presence: 251 | - device_tracker.david_fused in [home, just_arrived] 252 | - device_tracker.aysylu_fused in [home, just_arrived] 253 | adaptive_lighting: switch.adaptive_lighting_default 254 | light: light.stairs 255 | 256 | coffee_table_light: 257 | class: LightController 258 | module: lights 259 | triggers: 260 | - task: 261 | - input_boolean.dna_sleeping 262 | - input_boolean.david_in_bed 263 | state: turned_off 264 | - presence: 265 | - device_tracker.david_fused in [home, just_arrived] 266 | - device_tracker.aysylu_fused in [home, just_arrived] 267 | condition: input_boolean.it_is_evening 268 | adaptive_lighting: switch.adaptive_lighting_default 269 | light: light.coffee_table 270 | #debug: True 271 | 272 | kitchen_lights: 273 | class: LightController 274 | module: lights 275 | triggers: 276 | - task: 277 | - input_boolean.dna_sleeping 278 | - input_boolean.david_in_bed 279 | #- input_text.test_helper_text == sleep 280 | state: turned_off 281 | - presence: 282 | - binary_sensor.main_floor_presence_augmented 283 | - input_boolean.watching_tv 284 | delay_off: 120 285 | #- device_tracker.david_fused in [home, just_arrived] 286 | #- device_tracker.aysylu_fused in [home, just_arrived] 287 | adaptive_lighting: switch.adaptive_lighting_default 288 | light: light.kitchen 289 | 290 | downstairs_lights: 291 | class: LightController 292 | module: lights 293 | triggers: 294 | - task: 295 | - input_boolean.dna_sleeping 296 | - input_boolean.david_in_bed 297 | state: turned_off 298 | - presence: 299 | - binary_sensor.downstairs_presence_augmented 300 | #- binary_sensor.basement_occupancy 301 | #- device_tracker.david_fused in [home, just_arrived] 302 | #- device_tracker.aysylu_fused in [home, just_arrived] 303 | adaptive_lighting: switch.adaptive_lighting_default 304 | light: light.downstairs 305 | #debug: True 306 | 307 | downstairs_geoleaf_light: 308 | class: LightController 309 | module: lights 310 | triggers: 311 | - task: 312 | - binary_sensor.desktop_kl479rj_power_status != unavailable 313 | - binary_sensor.supercomputer_power_status != unavailable 314 | adaptive_lighting: switch.adaptive_lighting_default 315 | light: light.wled 316 | 317 | #underbed_light: 318 | # class: LightController 319 | # module: lights 320 | # triggers: 321 | # - presence: 322 | # - binary_sensor.bedroom_occupancy 323 | # delay_off: 30 324 | # adaptive_lighting: switch.adaptive_lighting_default 325 | # light: light.under_bed_light 326 | 327 | bedroom_near_light: 328 | class: LightController 329 | module: lights 330 | triggers: 331 | - task: 332 | - input_boolean.dna_sleeping 333 | - input_boolean.david_in_bed 334 | - input_boolean.aysylu_in_bed 335 | state: turned_off 336 | - task: 337 | - binary_sensor.someone_in_bed 338 | max_brightness: 30% 339 | - presence: 340 | - binary_sensor.bedroom_presence_augmented 341 | delay_off: 30 342 | adaptive_lighting: switch.adaptive_lighting_default 343 | light: light.bedroom_near 344 | #debug: True 345 | 346 | bedroom_far_light: 347 | class: LightController 348 | module: lights 349 | triggers: 350 | - task: 351 | - input_boolean.dna_sleeping 352 | state: turned_off 353 | - task: 354 | - input_boolean.david_in_bed 355 | - input_boolean.aysylu_in_bed 356 | max_brightness: 1% 357 | - presence: 358 | - binary_sensor.bedroom_presence_augmented 359 | delay_off: 30 360 | adaptive_lighting: switch.adaptive_lighting_default 361 | light: light.bedroom_far 362 | #debug: True 363 | 364 | garage_light: 365 | class: LightController2 366 | module: lights2 367 | light: light.neopixel_light 368 | #debug: True 369 | 370 | basic_thermostat: 371 | class: BasicThermostatController 372 | module: temperature 373 | report_entity: sensor.basic_thermostat_controller 374 | climate_entity: climate.thermostat_2 375 | hourly_weather: weather.home_hourly # optional/default 376 | max_diff_for_heat_pump: 4 377 | heat: 378 | outside_splitpoint: 62 379 | warm_day: 70 380 | cool_day: 72 381 | sleep: 68 382 | away: 64 383 | cool: 384 | outside_splitpoint: 70 385 | warm_day: 74 386 | cool_day: 72 387 | sleep: 68 388 | away: 76 389 | events: 390 | sleep: 391 | name: ios.action_fired 392 | actionName: wind_down 393 | wake: 394 | name: ios.action_fired 395 | actionName: morning_alarm 396 | sleep_fallback_time: "22:00:00" 397 | presence: 398 | - device_tracker.david_fused in [home, just_arrived] 399 | - device_tracker.aysylu_fused in [home, just_arrived] 400 | #- input_boolean.test_helper 401 | outside_openings: 402 | - binary_sensor.patio_door 403 | debug: true 404 | 405 | in_bed_tracker: 406 | class: BedStateManager 407 | module: state_mgmt 408 | wake_event: wake_up 409 | away_distance: 60 410 | #TODO this calc distance can be very wrong 411 | #it is 0 when it's wrong? 412 | #device_tracker.aysylu_iphone_app has latitude, longitude, and gps accuracy 413 | iphones: 414 | david: 415 | ssid: sensor.davids_iphone_ssid 416 | charging: sensor.davids_iphone_battery_state 417 | distance: sensor.david_iphone_calc_distance 418 | bed_tracker: input_boolean.david_in_bed 419 | aysylu: 420 | ssid: sensor.aysylu_iphone_ssid 421 | charging: sensor.aysylu_iphone_battery_state 422 | distance: sensor.aysylu_iphone_calc_distance 423 | bed_tracker: input_boolean.aysylu_in_bed 424 | bed_tracker: input_boolean.dna_sleeping 425 | bed_presence: 426 | default: binary_sensor.someone_in_bed 427 | david: binary_sensor.david_s_side 428 | aysylu: binary_sensor.aysylu_s_side 429 | bedtime_start: '21:00:00' 430 | bedtime_end: '5:00:00' 431 | home_ssids: 432 | - 74ax4k 433 | - internetofshxt 434 | 435 | evening_tracking: 436 | class: EveningTracker 437 | module: state_mgmt 438 | tracker: input_boolean.it_is_evening 439 | sunset_offset: 0 440 | sunrise_offset: 0 441 | 442 | lirr_fetcher: 443 | class: LirrFetcher 444 | module: lirr_fetcher 445 | max_lookback_mins: 45 446 | 447 | parking_booker: 448 | class: GoPortParkingController 449 | module: goportparking 450 | username: !secret goportparking_username 451 | password: !secret goportparking_password 452 | parking_pass_email_sensor: sensor.port_washington_parking_pass 453 | plates: 454 | - !secret goportparking_car1 455 | - !secret goportparking_carr2 456 | 457 | bedroom_presence: 458 | class: RoomAugmenter 459 | module: state_mgmt 460 | sensor_id: binary_sensor.bedroom_presence_augmented 461 | irk_trackers: 462 | - device_tracker.david_irk 463 | - device_tracker.aysylu_irk 464 | border: 465 | #- binary_sensor.bedroom_occupancy 466 | - binary_sensor.pir_ep1_bedroom 467 | - binary_sensor.david_pir_under_bed 468 | - binary_sensor.aysylu_pir_under_bed 469 | openings: binary_sensor.bedroom_door 470 | closing_grace_period_seconds: 45 471 | room: downstairs 472 | #debug: true 473 | 474 | basement_presence: 475 | class: RoomAugmenter 476 | module: state_mgmt 477 | sensor_id: binary_sensor.downstairs_presence_augmented 478 | irk_trackers: 479 | - device_tracker.david_irk 480 | - device_tracker.aysylu_irk 481 | interior: binary_sensor.computer_area_occupancy_detected 482 | border: binary_sensor.garage_entrance_occupancy_detected 483 | room: downstairs 484 | #debug: true 485 | 486 | main_floor_presence: 487 | class: RoomAugmenter 488 | module: state_mgmt 489 | sensor_id: binary_sensor.main_floor_presence_augmented 490 | irk_trackers: 491 | - device_tracker.david_irk 492 | - device_tracker.aysylu_irk 493 | border: binary_sensor.main_floor_occupancy 494 | interior: 495 | - binary_sensor.kitchen_occupancy 496 | - binary_sensor.dining_table_occupancy_detected 497 | room: main-floor 498 | #debug: true 499 | 500 | #cleaner: 501 | # class: CleaningManager 502 | # module: cleaning_queue 503 | # debug: true 504 | # vacuum: vacuum.roborock_s7_maxv_2 505 | # vacuum_map: camera.roborock_s7_maxv_map 506 | # readiness_timeout_mins: 60 507 | # maintainence_states: 508 | # - 38 # refill clean water 509 | # - 27 # empty dirty 510 | # - 10 # stuck 511 | # pathways: 512 | # "living room": 513 | # # default to open connections 514 | # - dining room 515 | # - litter box 516 | # dining room: 517 | # - area: kitchen 518 | # opening: input_boolean.kitchen_false_door 519 | # before_coord: [ 25477, 30469 ] 520 | # after_coord: [ 26003, 30648 ] 521 | # #- foyer 522 | # # foyer: 523 | # # - opening: binary_sensor.bedroom_hall_door 524 | # # area: bedroom hallway 525 | # # bedroom hallway: 526 | # # - opening: binary_sensor.bedroom_1_door 527 | # # area: bedroom 1 528 | # # before_coord: [ 22, 22 ] 529 | # # after_coord: [ 22, 20 ] 530 | # # - opening: binary_sensor.bedroom_2_door 531 | # # area: bedroom 2 532 | # # before_coord: [ 14, 22 ] 533 | # # after_coord: [ 14, 20 ] 534 | # # - opening: binary_sensor.bedroom_3_door 535 | # # area: bedroom 3 536 | # # before_coord: [ 10, 22 ] 537 | # # after_coord: [ 10, 20 ] 538 | # # - opening: binary_sensor.master_bedroom_door 539 | # # area: master bedroom 540 | # # before_coord: [ 22, 30 ] 541 | # # after_coord: [ 22, 28 ] 542 | # # gated: 543 | # # opening: 544 | # # - binary_sensor.bedroom_1_door 545 | # areas: 546 | # "living room": 547 | # id: 18 548 | # presence: 549 | # - binary_sensor.main_floor_presence_augmented 550 | # - binary_sensor.yet_to_tidy_living_room 551 | # home: true # makes this the area which contains the base 552 | # kitchen: 553 | # id: 16 554 | # presence: binary_sensor.main_floor_presence_augmented 555 | # "dining room": 556 | # id: 17 557 | # presence: binary_sensor.main_floor_presence_augmented 558 | # "litter box": 559 | # zone: 560 | # - 24964 561 | # - 25210 562 | # - 27594 563 | # - 28426 564 | # presence: binary_sensor.main_floor_presence_augmented 565 | # 566 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository contains systems for a few nice things: 2 | 3 | - BLE-based room-level presence with ESPHome for iOS devices 4 | - Presence based light controller, with many useful affordances (guest mode, reautomation, manual control, adaptive lighting, and more) 5 | - Presence and event based thermostat controller, with many useful affordances (heat pump awareness, drafty-home awareness, sleep mode, and more) 6 | - ESPHome binary sensor for occupancy--allows you to combine multiple binary sensors for presence (it's on when any are on, off when all are off) 7 | - ESPHome LD2410B/C bluetooth integration, so that you can directly connect & automate up to 3 LD2410B/C to an ESPHome device 8 | - ESPHome LD2410B/C bluetooth provisioning helper, so that you can quickly get the MAC address of each of your LD2410 sensors 9 | - ESPHome IRK provisioning helper, so that you can pair your phone to an ESPHome device and extract the IRK for passive BLE tracking 10 | 11 | # Bluetooth room-level tracking for iPhones, Apple watches, and other "untrackable" devices using ESPHome 12 | 13 | This code implements the cryptographic identification algorithm, plus some nifty signal processing, to get a pretty reliable indicator of which room a device is in. 14 | Then, it links multiple devices & a GPS-based device tracker together, to determine which room a person is in, or whether they've left the building. 15 | It combines multiple devices by assuming that the most recently moved device is being carried, while stationary devices may have been left behind. 16 | It also fuses data from GPS tracking, BLE passive scans, and door/garage sensors to create better signals for arrival & leaving, even when the GPS tracker breaks. 17 | 18 | **Note** You'll need to use AppDaemon from the `dev` branch, because AppDaemon/appdaemon#1626 hasn't been release to the home assistant addon yet. 19 | 20 | ## Ideas for Use cases 21 | 22 | You can use the person tracker entities with cards like State Switch and Tab Redirect so that dashboards show content relevant to the room you're in. 23 | 24 | If you have multi-zone HVAC, you can automatically adjust rooms to match the temperature preferences of their occupants. 25 | 26 | You can glance to see which room you left your phone/watch in when you took it off & forgot. 27 | 28 | I wouldn't use this for presence detection, because it updates on the scale of dozens of seconds (due to the signal cleaning). 29 | 30 | Here's an example of a tab redirect configuration that I use (I repeat this for each other user, using their device tracker): 31 | 32 | ```yaml 33 | type: custom:tab-redirect-card 34 | redirect: 35 | - user: David 36 | entity_id: device_tracker.david_fused 37 | entity_state: away 38 | redirect_to_tab_index: 4 39 | - user: David 40 | entity_id: device_tracker.david_fused 41 | entity_state: just_left 42 | redirect_to_tab_index: 4 43 | - user: David 44 | entity_id: device_tracker.david_irk 45 | entity_state: main-floor 46 | redirect_to_tab_index: 1 47 | - user: David 48 | entity_id: device_tracker.david_irk 49 | entity_state: bedroom 50 | redirect_to_tab_index: 2 51 | - user: David 52 | entity_id: device_tracker.david_irk 53 | entity_state: downstairs 54 | redirect_to_tab_index: 3 55 | - user: Person2 56 | entity_id: device_tracker.person2_irk 57 | entity_state: main-floor 58 | redirect_to_tab_index: 1 59 | # ...etc... 60 | ``` 61 | 62 | ### Status viewing 63 | 64 | I use `multiple-entity-row` to show the current tracker status. 65 | This type of configuration allows you to see the states of all the underlying & sensor-fused trackers. 66 | I also configure a tap action for the devices so that I can override the automatic device detection, since sometimes an RF glitch can make the system think that you're with the wrong device. 67 | 68 | ``` 69 | type: vertical-stack 70 | cards: 71 | - type: entities 72 | title: People 73 | entities: 74 | - entity: device_tracker.david_irk 75 | type: custom:multiple-entity-row 76 | name: David IRK 77 | state_header: Person 78 | secondary_info: 79 | attribute: last-updated 80 | name: false 81 | entities: 82 | - entity: device_tracker.david_phone_irk 83 | name: Phone 84 | tap_action: 85 | action: call-service 86 | service: button.press 87 | service_data: 88 | entity_id: button.irk_tracker_make_primary_david_david_phone 89 | - entity: device_tracker.david_watch_irk 90 | name: Watch 91 | tap_action: 92 | action: call-service 93 | service: button.press 94 | service_data: 95 | entity_id: button.irk_tracker_make_primary_david_david_watch 96 | - entity: device_tracker.david_iphone 97 | name: David Tracker 98 | icon: mdi:human 99 | type: custom:multiple-entity-row 100 | state_header: iCloud3 101 | secondary_info: 102 | attribute: last-updated 103 | name: false 104 | entities: 105 | - entity: device_tracker.david_fused 106 | name: Fused 107 | - type: horizontal-stack 108 | cards: 109 | - type: custom:mushroom-entity-card 110 | entity: button.irk_tracker_make_primary_david_david_phone 111 | secondary_info: none 112 | name: David phone primary 113 | tap_action: 114 | action: toggle 115 | - type: custom:mushroom-entity-card 116 | entity: button.irk_tracker_make_primary_david_david_watch 117 | secondary_info: none 118 | name: David watch primary 119 | tap_action: 120 | action: toggle 121 | ``` 122 | 123 | ## Appdaemon Configuration 124 | 125 | You can probably use the tracker configuration in apps.yaml without many changes, however here are some things to consider: 126 | 127 | - `away_tracker_arrival_delay_secs` should last for the duration from when you come into GPS zone range, and when you want to be considered "home" (i.e. I like the garage door card to come up when I'm right in front of my house). 128 | - `away_trackers` could be from a phone app, or better yet, iCloud3. 129 | - `rssi_adjustments` are constant offsets so that if you have different generations of ESP32 devices, or some cases affect the signal more than others, you can shift them to be comparable. 130 | - `pullout_sensors` are for your front door, garage, etc, so that you can tell when you've just left. You should configure which ESP devices are nearest to the exit point & sensor, so that only the household members who are actually heading out are identified as leaving. `within_top` allows for the sensor to be within that many of the closest sensor, depending on how quickly you'll pass by sensors on your way out of that exit. 131 | 132 | You'll need to set up your room aliases, which map ESP32 devices to the room they belong to. 133 | If you notice that a device is read equally from multiple rooms, use the `secondary_clarifiers` to list the rooms that ESP32 could be associated with, and then the next-most strong signal will disambiguate. 134 | This may require you to add more ESP32s to rooms for clarifying purposes. 135 | You can still include a `default` with the `secondary_clarifiers`, so that localization defaults to that room rather than `unknown`. 136 | Clarifiers can also map from a specific ESP32 device to a different room, so that you can mark hallways or others "spaces between" two ESP32 devices. 137 | 138 | Other settings aren't likely to need to change. 139 | 140 | ## Building your own Automations 141 | 142 | Once you've configured the system, you'll want to create automations based on the `device_tracker.${person}_irk` and `home_focused_tracker` that you configure. 143 | 144 | The `_irk` tracker gives room-level positioning data, or `away` when that person's not at home. 145 | This can be used for automating preferences and views based on what room you're in. 146 | 147 | The fused tracker gives 4 states: `home`, `away`, `just_arrived`, and `just_left`. 148 | This can be used for automating views & events when coming and going from the home. 149 | 150 | ## Advanced Appdaemon irk tracker settings 151 | 152 | - `tracking_window_minutes` is the duration that we consider observations of BLE beacons "active" 153 | - `tracking_min_superplurality` is the amount of signal strength by which a new "strongest room" must exceed the previous room to consider the change to occur (this provides hysteresis) 154 | - `ping_halflife_seconds` is the halflife by which we downweight old observations within the tracking window. 155 | 156 | ## ESPHome config 157 | 158 | Any ESP32 with bluetooth will work for this. 159 | You should copy `irk_resolver.h` and `irk_locator.yaml` into your homeassistant's `config/esphome` folder. 160 | Then, you'll just need to add the following to your config: 161 | 162 | 163 | ```yaml 164 | packages: 165 | irk_locator: !include {file: irk_locator.yaml, irk_source: $device_name} 166 | ``` 167 | 168 | **Note**: You must specify `irk_source` to be the source that will be used in the appdaemon config. 169 | 170 | ### Protect your HomeAssistant database 171 | 172 | You will really want to add this to your `configuration.yaml`, so that you don't overload your database saving these events. 173 | They will be excessively numerous. 174 | 175 | ```yaml 176 | recorder: 177 | exclude: 178 | event_types: 179 | - esphome.ble_tracking_beacon 180 | ``` 181 | 182 | ## Getting your device keys for identification 183 | 184 | See https://espresense.com/beacons/apple for how to get the IRKs for apple watches. 185 | 186 | I pair my iPhone with Windows, and then use https://www.reddit.com/r/hackintosh/comments/mtvj5m/howto_keep_bluetooth_devices_paired_across_macos/ (or a similar approach with pstools + regedit) to get the Identity Resolving Key for each phone. 187 | 188 | # Presence & Task based lighting automation 189 | 190 | `lights.py` is a system for controlling lights, where we define a priority sequence of triggers (such as presence detection, activities like watching tv, etc), and the light is automatically controlled by these. 191 | The light can also be linked to "Adaptive Lighting" for automatic circadian control. 192 | If a light is ever controlled by an external switch or app, it switches to manual mode, which can be canceled by toggling it off & on, or by pressing a button entity for reautomation that's created by the light (I make these buttons appear on my dashboard only when the light is in manual mode). 193 | Lights also generate a "Guest Mode" switch (for obvious reasons). 194 | 195 | ## Priority triggers 196 | The key idea with this system is that presence based lighting should be driven by "triggers"--things that we are doing or places we are. 197 | Some examples of tasks are: 198 | - Being in a room 199 | - Watching the TV 200 | - Playing a video game 201 | - Cooking dinner 202 | - Sleeping 203 | 204 | You can refer to `apps.yaml` for inspiration with configurations I use at my house. 205 | 206 | For example, the lights in the living room should be on when we're at home (there's no presence detection in this room), but they should be dimmed while we're watching TV. 207 | Another example is that the bedroom lights should be on when we're in the room, but they should be very dimmed once one partner gets in bed. 208 | As a last example, a room may turn on to a lower brightness when presence is detected in an adjacent room, so that you always walk in to a dimly lit room (rather than a dark room). 209 | 210 | These triggers are listed in their precedence order, and triggers can specify additional modifiers. 211 | For example, lights can have their brightness capped (e.g. for mood lighting while watching TV, irrespective of adaptive lighting's state), and triggers can include transition times and delay durations (so that lights can remain on at a lower setting for several minutes after a room has been vacated, so you don't walk back into a dark room). 212 | 213 | ## Lighting details 214 | 215 | You can specify a different instance of adaptive lighting for each light, so that you can compensate for brightness & color temperature differences between different brands of bulbs. 216 | 217 | Each light will have an `input_boolean.guest_mode_${light}` automatically created, which disables the automation entirely. 218 | 219 | When you adjust a light (either at a switch or via HomeAssistant), it will flip to "manual mode". 220 | If you toggle the light off & on again, it will return to automatic mode. 221 | I have found that it can be unclear whether a light is manually controlled or not. 222 | I use mushroom cards, and this is the conditional chip I add to each room's card for each light in the room: 223 | 224 | ```yaml 225 | type: conditional 226 | conditions: 227 | - entity: sensor.light_state_living_room 228 | state: manual 229 | chip: 230 | type: template 231 | tap_action: 232 | action: call-service 233 | service: button.press 234 | data: {} 235 | target: 236 | entity_id: button.reautomate_living_room 237 | icon: mdi:lightbulb-auto 238 | icon_color: yellow 239 | content: Re-automate Living Room 240 | ``` 241 | 242 | This way, you can just tap the chip to re-automate the light, and it is hidden while the light is automatic. 243 | 244 | # Thermostat Control 245 | 246 | `temperature.py` hass a (bunch of) systems for controlling thermostats, but I only use one now. 247 | 248 | The Basic Thermostat takes control of a climate entity and automates normal thermostat interactions. 249 | It makes the assumption that you'll manually set your thermostat to heat or cool mode depending on the season, and it automates the temperature adjustments. 250 | 251 | ## Away mode 252 | 253 | The thermostat uses presence trackers to determine if you're home or away. 254 | When you're away, it stores the current setpoint for when you return, and turns the thermostat up or down as needed. 255 | So, if you change the thermostat because things feel out of the ordinary today, that will persist for the rest of the day. 256 | 257 | ## Draft compensation 258 | 259 | It chooses the day's target temperature based on the outdoor conditions. 260 | You specify the splitpoint and the warm & cool day settings for your thermostat's modes. 261 | 262 | This way, you can have your AC cooler on very hot days (since a lukewarm day might not need to be cooled as much), or you may want your heat higher on cold days to compensate for the draft. 263 | 264 | ## Heat Pump 265 | 266 | If your heat pump can't keep up, you want your backup heat to kick in. 267 | But, if you let your house cool when you're not at home, and then you return, your thermostat might immediately fire up the backup heat. 268 | If this isn't what you want, the controller will "walk" the temperature up to the target when you arrive home so that you don't trigger the backup heat unnecessarily. 269 | 270 | ## Setting up sleep mode detection with iOS (if you use the daily alarm & wind down features) 271 | It listens to events generated by your phone (like your alarm going off or evening wind-down mode) to move into & out of sleep mode. 272 | You can change the names of these events in the configuration if you like. 273 | 274 | First, go to Companion App settings in the iOS app, and add an action `morning_alarm` and an action `wind_down` (feel free to fill in the rest, but it doesn't affect the functionality). 275 | 276 | Next, go to the Shortcuts app (built in to iOS). Go to the Automation tab, and create a new automation: When my wake-up alarm goes off, "Perform Action" `morning_alarm`. Uncheck the "Ask Before Running" so that it happens every time. 277 | 278 | Again, we'll make one more Automation in Shortcuts; this time, When Wind Down starts, "Perform Action" `wind_down`. Once again, you'll want to uncheck "Ask Before Running". 279 | 280 | # Occupancy Combining Sensor 281 | 282 | This is something you'll want to use to combine multiple presence detectors on-device, such as LD2410b, other mmWave sensors, and PIR sensors. 283 | 284 | ```yaml 285 | external_components: 286 | - source: github://dgrnbrg/appdaemon-configs 287 | 288 | binary_sensor: 289 | - platform: presence_combo 290 | name: Basement Occupancy 291 | device_class: occupancy 292 | filters: 293 | - delayed_off: ${occupancy_delay_off} 294 | ids: 295 | - computer_area_id_occupancy_detected 296 | - entrance_id_occupancy_detected 297 | - workout_id_occupancy_detected 298 | ``` 299 | 300 | # IRK Provisioning Helper 301 | 302 | This creates a text sensor that will show the IRK of the most recently paired device. Just find the ESPHome device by its name in your phone's bluetooth. 303 | 304 | ```yaml 305 | external_components: 306 | - source: github://dgrnbrg/appdaemon-configs 307 | 308 | irk_enrollment: 309 | ``` 310 | 311 | # LD2410B/C Provisioning Helper 312 | 313 | Just copy `ld2410ble_mac_discovery.yaml` into your esphome folder. Then, this will print each LD2410 to the ESPHome log (as they are detected), and it will show the last 3 detected sensors in a text sensor. 314 | 315 | I would recommend powering up each LD2410B and waiting to see it pop up, then copy & pasting its MAC address before moving on. 316 | 317 | ```yaml 318 | packages: 319 | ld2410_provisioning: !include ld2410ble_mac_discovery.yaml 320 | ``` 321 | 322 | # LD2410B/C ESPHome driver 323 | 324 | This will allow you to directly pair LD2410B/C with your ESPHome device, so that you can run local automations with them. 325 | Once you copied the yaml file into your esphome folder, see below for an example configuration. 326 | You may need to wait up to a minute for the connection to be established--this driver automatically attempts to recover lost connections. 327 | 328 | Use `HLKRadarTool` on your phone's app store to change settings on the sensors, such as their password or detection timeouts. 329 | If you need to connect with `HLKRadarTool` and you don't see your sensor, you may need to turn off the enable switch that was added to ESPHome--the sensor can only pair with one other device at a time. 330 | 331 | If you specify a name, make sure to include a trailing space (` `) so that the name is formatted correctly. At a minimum, provide the `ld2410_id` and `mac_address` for your sensor. 332 | You can also specify `sensor_throttle` and `binary_sensor_debounce` to reduce the update rate (these devices update hundredes of times per second). 333 | 334 | ```yaml 335 | packages: 336 | base: !include device-base.yaml 337 | ld2410: !include {file: ld2410ble.yaml, vars: { mac_address: 'XX:XX:XX:XX:XX:XX', ld2410_password: "newpw1", ld2410_id: "computer_area_id" } } 338 | ld2410_2: !include {file: ld2410ble.yaml, vars: { mac_address: 'XX:XX:XX:XX:XX:XX', ld2410_id: "entrance_id", ld2410_name: "Garage Entrance " } } 339 | ld2410_3: !include {file: ld2410ble.yaml, vars: { mac_address: 'XX:XX:XX:XX:XX:XX', ld2410_id: "workout_id", ld2410_name: "Workout Area " } } 340 | ``` 341 | 342 | # Deployment (reminder for myself) 343 | 344 | ``` 345 | scp irk_tracker.py homeassistant:/config/appdaemon/apps/ 346 | scp temperature.py homeassistant:/config/appdaemon/apps/ 347 | scp lights.py homeassistant:/config/appdaemon/apps/ 348 | scp apps.yaml homeassistant:/config/appdaemon/apps/ 349 | ``` 350 | 351 | -------------------------------------------------------------------------------- /lights.py: -------------------------------------------------------------------------------- 1 | import hassapi as hass 2 | import adbase as ad 3 | import datetime 4 | import math 5 | 6 | class GlobalUserInfo(hass.Hass): 7 | def initialize(self): 8 | self.user_id = self.args['user_id'] 9 | 10 | 11 | def parse_conditional_expr(cause): 12 | present_state = 'on' 13 | absent_state = 'off' 14 | entity = cause 15 | if '==' in cause: 16 | xs = [x.strip() for x in cause.split('==')] 17 | #print(f"parsing a state override light trigger {xs}") 18 | entity = xs[0] 19 | present_state = xs[1] 20 | absent_state = None 21 | elif '!=' in cause: 22 | xs = [x.strip() for x in cause.split('!=')] 23 | #print(f"parsing a negative state override light trigger") 24 | entity = xs[0] 25 | present_state = None 26 | absent_state = xs[1] 27 | elif ' not in ' in cause: 28 | xs = [x.strip() for x in cause.split(' not in ')] 29 | entity = xs[0] 30 | present_state = None 31 | absent_state = [x.strip() for x in xs[1].strip('[]').split(',')] 32 | elif ' in ' in cause: 33 | xs = [x.strip() for x in cause.split(' in ')] 34 | entity = xs[0] 35 | present_state = [x.strip() for x in xs[1].strip('[]').split(',')] 36 | absent_state = None 37 | return present_state, absent_state, entity 38 | 39 | class LightController(hass.Hass): 40 | """ 41 | This does presence-based light control. 42 | 43 | First, for global settings: 44 | - adaptive_lighting is used as a source for automatic brightness and color temperature adjustment for a circadian household 45 | - light is the actual controlled light entity for this 46 | - off_transition is the number of seconds to fade to off, by default 47 | - daily_off_time is when the light is reset to its automatic state 48 | 49 | Then, we have triggers. The triggers each have their constituent information continuously updated. 50 | The first active trigger is applied to the light, so that triggers can preempt others. 51 | No active triggers means the light is off. 52 | Triggers have many configurable settings: 53 | - transition is the duration that the light fades when activating this trigger 54 | - delay_on/delay_off requires that the trigger's presence/task inputs to be on/off for that many seconds before triggering on/off 55 | - state can be "turned_off" for a trigger that turns the light off completely 56 | - max_brightness clamps the brightness of the circadian lighting (for mood, e.g. when watching tv or going to the bathroom late at night) 57 | - presence/task are the same. When any of the conditions are true (they can be on/off entities, or a text entity with == or !=), the trigger turns on. When they're all false, the trigger turns off. 58 | - condition is similar to presence, except that every condition must be true for the trigger to turn on. 59 | 60 | Presence and condition can be thought of as the following: 61 | presence is used for whether something is being done, such as being in a space, sleeping, or doing an activity 62 | condition is used to validate that it's an appropriate time, such as whether it's dark out 63 | """ 64 | 65 | def setup_listen_state(self, cb, present_state, absent_state, entity, **kwargs): 66 | cur_state = self.get_state(entity) 67 | def delegate(_): 68 | cb(entity, 'state', None, cur_state, kwargs) 69 | if present_state: 70 | if isinstance(present_state, list): 71 | if self.debug_enabled: 72 | self.log(f"present {entity} in {present_state} for {cb}") 73 | def present_check(n): 74 | if self.debug_enabled: 75 | self.log(f"present {entity} in {present_state} for {cb} checking = {n in present_state}") 76 | return n in present_state 77 | self.listen_state(cb, entity, new=present_check, **kwargs) 78 | if kwargs.get('immediate') and present_check(cur_state): 79 | self.run_in(delegate, 0) 80 | else: 81 | if self.debug_enabled: 82 | self.log(f"present {entity} = {present_state} for {cb}") 83 | self.listen_state(cb, entity, new=present_state, **kwargs) 84 | else: 85 | if isinstance(absent_state, list): 86 | if self.debug_enabled: 87 | self.log(f"absent {entity} not in {absent_state} for {cb}") 88 | def absent_check(n): 89 | return n not in absent_state 90 | self.listen_state(cb, entity, new=absent_check, **kwargs) 91 | if kwargs.get('immediate') and absent_check(cur_state): 92 | def delegate(_): 93 | cb(entity, 'state', None, cur_state, kwargs) 94 | self.run_in(delegate, 0) 95 | else: 96 | if self.debug_enabled: 97 | self.log(f"absent {entity} != {absent_state} for {cb}") 98 | def absent_check(n): 99 | return n != absent_state 100 | self.listen_state(cb, entity, new=absent_check, **kwargs) 101 | if kwargs.get('immediate') and absent_check(cur_state): 102 | def delegate(_): 103 | cb(entity, 'state', None, cur_state, kwargs) 104 | self.run_in(delegate, 0) 105 | 106 | @ad.app_lock 107 | def initialize(self): 108 | self.debug_enabled = self.args.get('debug', False) 109 | # TODO this isn't working properly; it's not triggering restarts as I want 110 | self.depends_on_module('global_user_id') 111 | global_user_id_app = self.get_app('global_user_id') 112 | self.user_id = global_user_id_app.user_id 113 | if self.debug_enabled: 114 | self.log(f"user id is {self.user_id}") 115 | self.light = self.args['light'] 116 | self.light_name = self.light.split('.')[1] # drop the domain 117 | self.reautomate_button = f'button.reautomate_{self.light_name}' 118 | self.guest_mode_switch = f'input_boolean.guest_mode_{self.light_name}' 119 | self.do_update = set() 120 | self.target_brightness = 0 121 | self.off_transition = self.args.get('off_transition', 5) 122 | self.fake_when_away = self.args.get('fake_when_away', True) 123 | self.state = 'init' 124 | #print(f"light controller args: {self.args}") 125 | self.daily_off_time = self.args.get('daily_off_time', '04:00:00') 126 | self.triggers = [] 127 | for i, t in enumerate(self.args.get('triggers',[]) or []): 128 | if 'presence' in t and 'task' in t: 129 | self.error(f"Trigger {t} should have presence or task as the trigger") 130 | trigger = {'index': i} 131 | trigger['max_brightness'] = t.get('max_brightness', 100) 132 | if trigger['max_brightness'] and isinstance(trigger['max_brightness'], str): 133 | if trigger['max_brightness'].endswith('%'): 134 | trigger['max_brightness'] = int(trigger['max_brightness'][:-1]) 135 | else: 136 | trigger['max_brightness'] = int(trigger['max_brightness']) 137 | trigger['transition'] = t.get('transition', 3) 138 | trigger['target_state'] = t.get('state', 'turned_on') 139 | trigger['on_timers'] = [] 140 | trigger['off_timers'] = [] 141 | trigger['states'] = {} 142 | trigger['state'] = 'init' 143 | # causes can have 2 subheadings, "presence/task" and "condition". 144 | # At least one from "presence/task" must be true, and everything from 145 | # "condition" must be true, in order to activate the trigger 146 | any_causes = t.get('task', t.get('presence', None)) 147 | if not isinstance(any_causes, list): 148 | any_causes = [any_causes] 149 | trigger['any_causes'] = any_causes 150 | #self.log(f"on {self.light}, looking at {any_causes}") 151 | all_causes = t.get('condition', []) 152 | if not isinstance(all_causes, list): 153 | all_causes = [all_causes] 154 | any_causes = [parse_conditional_expr(x) for x in any_causes] 155 | all_causes = [parse_conditional_expr(x) for x in all_causes] 156 | pes = trigger['presence_entities'] = [e for (_,_,e) in any_causes] 157 | ces = trigger['condition_entities'] = [e for (_,_,e) in all_causes] 158 | if len(pes) + len(ces) != len(set(ces + pes)): 159 | raise ValueError(f"Condition and presence entities must appear only once each") 160 | for present_state, absent_state, entity in any_causes + all_causes: 161 | if t.get('turns_on', True): 162 | if entity in pes: 163 | duration = t.get('delay_on', 0) 164 | else: 165 | duration = 0 166 | self.setup_listen_state(cb=self.trigger_on, entity=entity, present_state=present_state, absent_state=absent_state, duration=duration, trigger=i, immediate=True) 167 | if t.get('turns_off', True): 168 | if entity in pes: 169 | duration = t.get('delay_off', 0) 170 | else: 171 | duration = 0 172 | self.setup_listen_state(cb=self.trigger_off, entity=entity, present_state=absent_state, absent_state=present_state, duration=duration, trigger=i, immediate=True) 173 | self.triggers.append(trigger) 174 | if 'adaptive_lighting' in self.args: 175 | self.listen_state(self.on_adaptive_lighting_temp, self.args['adaptive_lighting'], attribute='color_temp_kelvin', immediate=True) 176 | self.listen_state(self.on_adaptive_lighting_brightness, self.args['adaptive_lighting'], attribute='brightness_pct', immediate=True) 177 | self.listen_event(self.service_snoop, "call_service", domain="button", service="press", service_data=self.service_entity_matcher(self.reautomate_button)) 178 | self.listen_event(self.service_snoop, "call_service", domain="light", service_data=self.service_entity_matcher(self.light)) 179 | self.listen_event(self.service_snoop, "call_service", domain="input_boolean", service_data=self.service_entity_matcher(self.guest_mode_switch)) 180 | self.run_daily(self.reset_manual, self.daily_off_time) 181 | self.log(f"Completed initialization for {self.light} (debug enabled: {self.debug_enabled})") 182 | self.get_entity(self.reautomate_button).set_state(state='unknown', attributes={'friendly_name': f'Reautomate {self.light}'}) 183 | guest_switch_ent = self.get_entity(self.guest_mode_switch) 184 | if not guest_switch_ent.exists(): 185 | guest_switch_ent.set_state(state='off', attributes={'friendly_name': f'Guest mode for {self.light}'}) 186 | elif guest_switch_ent.get_state() == 'on': 187 | self.state = 'guest' 188 | runtime = datetime.time(0, 0, 0) 189 | self.run_minutely(self.update_light, runtime) 190 | 191 | @ad.app_lock 192 | def reset_manual(self, kwargs): 193 | if self.get_state(self.guest_mode_switch) == 'on': 194 | # skip reset to manual when the guest mode is on 195 | return 196 | if self.state == 'manual' or self.state == 'manual_off': 197 | self.state = 'returning' 198 | self.update_light({}) 199 | 200 | @ad.app_lock 201 | def trigger_off(self, entity, attr, old, new, kwargs): 202 | trigger = self.triggers[kwargs['trigger']] 203 | old_state = trigger['state'] 204 | trigger['states'][entity] = 'off' 205 | all_presence_off = True 206 | any_condition_off = False 207 | for t,v in trigger['states'].items(): 208 | if v != 'off' and t in trigger['presence_entities']: 209 | all_presence_off = False 210 | if v == 'off' and t in trigger['condition_entities']: 211 | any_condition_off = True 212 | if self.debug_enabled: 213 | self.log(f'trigger off for {self.light} because {entity} is off. all presence off={all_presence_off}. all condition off={any_condition_off}. prev={old_state}. states = {trigger["states"]}') 214 | if all_presence_off or any_condition_off: 215 | trigger['state'] = 'off' 216 | if old_state != 'off': 217 | self.update_light({}) 218 | 219 | @ad.app_lock 220 | def trigger_on(self, entity, attr, old, new, kwargs): 221 | if self.debug_enabled: 222 | self.log(f"Trigger on running for the {kwargs['trigger']} trigger, and the length is {len(self.triggers)}") 223 | trigger = self.triggers[kwargs['trigger']] 224 | old_state = trigger['state'] 225 | trigger['states'][entity] = 'on' 226 | all_conditions_on = True 227 | any_presence_on = False 228 | for t,v in trigger['states'].items(): 229 | if v == 'on' and t in trigger['presence_entities']: 230 | any_presence_on = True 231 | if v != 'on' and t in trigger['condition_entities']: 232 | all_conditions_on = False 233 | if all_conditions_on and any_presence_on: 234 | trigger['state'] = 'on' 235 | if self.debug_enabled: 236 | self.log(f'trigger on for {self.light} because {entity} went on. any presence on={any_presence_on}. all conditions on={all_conditions_on}. prev={old_state}') 237 | if old_state != 'on': 238 | self.update_light({}) 239 | 240 | @ad.app_lock 241 | def on_adaptive_lighting_brightness(self, entity, attribute, old, new, kwargs): 242 | self.do_update.add('bright') 243 | self.brightness = new 244 | self.update_light({}) 245 | 246 | @ad.app_lock 247 | def on_adaptive_lighting_temp(self, entity, attribute, old, new, kwargs): 248 | self.do_update.add('temp') 249 | self.color_temp = new 250 | self.update_light({}) 251 | 252 | def service_entity_matcher(self, target_id): 253 | def match(service_data): 254 | has = False 255 | if 'entity_id' in service_data: 256 | entity_id = service_data['entity_id'] 257 | if isinstance(entity_id, list): 258 | has = target_id in entity_id 259 | else: 260 | has = target_id == entity_id 261 | return has 262 | return match 263 | 264 | @ad.app_lock 265 | def service_snoop(self, event_name, data, kwargs): 266 | if 'domain' not in data or data['domain'] != 'light' and data['domain'] != 'button' and data['domain'] != 'input_boolean': 267 | return 268 | #print(f"service snooped {data}") 269 | endogenous = data['metadata']['context']['user_id'] == self.user_id 270 | service_data = data['service_data'] 271 | has = False 272 | if 'entity_id' in service_data: 273 | entity_id = service_data['entity_id'] 274 | if isinstance(entity_id, list): 275 | has = self.light in entity_id 276 | else: 277 | has = self.light == entity_id 278 | if data['domain'] == 'button' and data['service'] == 'press' and (entity_id == self.reautomate_button or entity_id == [self.reautomate_button]): 279 | self.state = 'returning' 280 | self.update_light({}) 281 | self.log(f'reautomating {self.light}') 282 | return 283 | guest_switch = self.get_entity(self.guest_mode_switch) 284 | if data['domain'] == 'input_boolean' and (entity_id == self.guest_mode_switch or entity_id == [self.guest_mode_switch]): 285 | if data['service'] == 'turn_on': 286 | guest_switch.set_state(state='on') 287 | self.state = 'guest' 288 | elif data['service'] == 'turn_off': 289 | guest_switch.set_state(state='off') 290 | self.state = 'returning' 291 | elif data['service'] == 'toggle': 292 | if guest_switch.get_state() == 'on': 293 | guest_switch.set_state(state='off') 294 | self.state = 'returning' 295 | elif guest_switch.get_state() == 'off': 296 | self.state = 'guest' 297 | guest_switch.set_state(state='on') 298 | self.update_light({}) 299 | return 300 | if guest_switch.get_state() == 'on': 301 | # we shouldn't do any of the manual control stuff 302 | if self.debug_enabled: 303 | self.log(f"not handling manual overrides due to guest mode {self.light}") 304 | return 305 | if has and not endogenous: 306 | if self.debug_enabled: 307 | self.log(f"got a matched event (endo={endogenous}), info: {event_name} {data} {kwargs}") 308 | # janky support for toggle 309 | if data['domain'] == 'light' and data['service'] == 'toggle': 310 | cur_state = self.get_state(self.light) 311 | print(f"toggle reveals the entity is {cur_state}") 312 | if cur_state == 'on': 313 | data['service'] = 'turn_off' 314 | elif cur_state == 'off': 315 | data['service'] = 'turn_on' 316 | else: 317 | self.log(f"Unexpected state for {self.light}: {cur_state}") 318 | if self.debug_enabled: 319 | self.log(f"saw {self.light} [domain==light is {data['domain'] == 'light'}] [service==turn_on is {data['service'] == 'turn_on'}]") 320 | if data['domain'] == 'light' and data['service'] == 'turn_on': 321 | if self.debug_enabled: 322 | self.log(f"saw {self.light} turn on") 323 | if 'brightness_pct' in service_data or 'brightness' in service_data: 324 | if 'brightness_pct' in service_data: 325 | new_brightness = service_data['brightness_pct'] 326 | elif 'brightness' in service_data: 327 | new_brightness = service_data['brightness'] / 255 * 100 328 | delta = abs(new_brightness - self.target_brightness) 329 | if delta > 5: 330 | # probably was a manual override 331 | self.state = 'manual' 332 | self.update_light({}) 333 | #self.log(f'saw a change in brightness. delta is {delta}. state is now {self.state}') 334 | elif 'color_temp' in service_data: 335 | new_color_temp = service_data['color_temp'] 336 | delta = abs(new_color_temp - self.color_temp) / new_color_temp 337 | if delta > 0.05: 338 | # probably was a manual override 339 | self.state = 'manual' 340 | self.update_light({}) 341 | #self.log(f'saw a change in color temp. delta is {delta}. state is now {self.state}') 342 | elif 'color_temp_kelvin' in service_data or 'kelvin' in service_data: 343 | new_color_temp = service_data.get('kelvin', service_data.get('color_temp_kelvin')) 344 | delta = abs(new_color_temp - self.color_temp) / new_color_temp 345 | if delta > 0.05: 346 | # probably was a manual override 347 | self.state = 'manual' 348 | self.update_light({}) 349 | #self.log(f'saw a change in color temp (kelvin). delta is {delta}. state is now {self.state}') 350 | else: 351 | if self.debug_enabled: 352 | self.log(f"saw {self.light} turn on without settings.") 353 | if self.state == 'manual_off': 354 | if self.debug_enabled: 355 | self.log(f"from on: Returning to automatic {service_data}.") 356 | self.state = 'returning' 357 | self.update_light({}) 358 | elif self.state == 'off' or isinstance(self.state, int) and self.triggers[self.state]['target_state'] == 'turned_off': # it turned on but it should be off 359 | if self.debug_enabled: 360 | self.log(f"saw an unexpected change to on, going to manual") 361 | self.state = 'manual' 362 | # When we turn on to manual mode with no other settings, we'll start with the current settings 363 | light_ent = self.get_entity(self.light) 364 | light_ent.turn_on(kelvin=self.color_temp, brightness_pct=self.brightness) 365 | self.update_light({}) 366 | # check if we did a turn off, and 367 | elif data['domain'] == 'light' and data['service'] == 'turn_off' : 368 | #self.log(f"saw {self.light} turn off without settings (cur state = {self.state}).") 369 | # if the state isn't off or a trigger that is supposed to be turned off 370 | if self.state != 'off' and isinstance(self.state, int) and self.triggers[self.state]['target_state'] != 'turned_off': 371 | #self.log(f"saw an unexpected change to off, going to manual") 372 | self.state = 'manual_off' 373 | self.update_light({}) 374 | elif self.state == 'manual': # does turning off mean we return to auto? 375 | #self.log(f"from off: Returning to automatic {service_data}.") 376 | self.state = 'returning' 377 | self.update_light({}) 378 | 379 | def update_light(self, kwargs): 380 | if len(self.do_update) != 2: 381 | return 382 | def update_stored_state(): 383 | # make sure we have the up-to-date state stored 384 | state_entity = self.get_entity(f"sensor.light_state_{self.light_name}") 385 | if str(self.state) != state_entity.get_state(): 386 | old_state = state_entity.get_state() 387 | state_repr = str(self.state) 388 | if state_repr == 'manual_off': 389 | state_repr = 'manual' 390 | attrs = {'old_state': old_state, 'active_triggers': [x['index'] for x in self.triggers if x['state'] == 'on'], 'internal_state': self.state} 391 | if state_repr == 'manual': 392 | attrs['manual_activated'] = datetime.datetime.now() 393 | state_entity.set_state(state=state_repr, attributes=attrs) 394 | # check each trigger to see if it's enabled. 395 | # also handle the delay functions 396 | if self.state == 'manual' or self.state == 'manual_off': 397 | # don't be automatic in this case 398 | if self.debug_enabled: 399 | self.log(f"not updating light b/c it's in manual mode") 400 | update_stored_state() 401 | return 402 | if self.state == 'guest': 403 | # don't be automatic, except match color temperature 404 | update_stored_state() 405 | light_ent = self.get_entity(self.light) 406 | light_ent_state = light_ent.get_state() 407 | if light_ent_state == 'on': 408 | if self.debug_enabled: 409 | self.log(f'updating color temperature for guest mode light {self.light} = {light_ent_state} to {self.color_temp}') 410 | light_ent.turn_on(color_temp_kelvin=self.color_temp) 411 | else: 412 | pass 413 | #self.log(f'guest mode light active {self.light} = {light_ent_state}') 414 | return 415 | for trigger in self.triggers: 416 | if trigger['state'] == 'on': 417 | #if self.state != trigger['index']: 418 | self.state = trigger['index'] 419 | brightness = min(self.brightness, trigger['max_brightness']) 420 | self.target_brightness = brightness 421 | if trigger['target_state'] == 'turned_on': 422 | if self.debug_enabled: 423 | self.log(f"Matched {self.light} trigger {trigger}, setting brightness to {brightness} and temp to {self.color_temp}") 424 | self.get_entity(self.light).turn_on(brightness_pct=brightness, kelvin=self.color_temp, transition=trigger['transition']) 425 | elif trigger['target_state'] == 'turned_off': 426 | self.get_entity(self.light).turn_off(transition=trigger['transition']) 427 | if self.debug_enabled: 428 | self.log(f"Matched {self.light} trigger {trigger}, turning off") 429 | else: 430 | self.log(f"Matched {self.light} trigger {trigger}, but the target_state wasn't understood") 431 | update_stored_state() 432 | return 433 | self.state = 'off' 434 | # no triggers were active, so either we're off or we're faking 435 | self.get_entity(self.light).turn_off(transition=self.off_transition) 436 | if self.debug_enabled: 437 | self.log(f"no triggers active for {self.light}, turning off") 438 | update_stored_state() 439 | 440 | --------------------------------------------------------------------------------