diff --git a/basementdoorlock.yaml b/basementdoorlock.yaml new file mode 100644 index 0000000..41836a9 --- /dev/null +++ b/basementdoorlock.yaml @@ -0,0 +1,46 @@ +esphome: + name: basementdoorlock + friendly_name: "Basement door lock" + area: "Basement" + + project: + name: dasfoo.basementdoorlock + version: "1.0" + + libraries: + - Preferences + - https://github.com/vinmenn/Crc16.git + - https://github.com/I-Connect/NukiBleEsp32 + +external_components: + - source: components + +packages: + device_base: !include templates/esp32-poe.yaml + +button: + - platform: factory_reset + name: Restart with Factory Default Settings + +lock: + - platform: nuki_lock + name: None + request_battery_reports: true + beacon_rssi: + name: "Beacon RSSI" + heartbeat_latency: + name: "Heartbeat latency" + beacon_latency: + name: "Beacon latency" + beacon_ble_address: + name: "Beacon BLE address" + lock_current_datetime: + name: "Lock current datetime" + config_update_count: + name: "Config update count" + last_action: + name: "Last action" + last_action_trigger: + name: "Last action trigger" + last_action_completion_status: + name: "Last action completion status" diff --git a/components/nuki_lock/__init__.py b/components/nuki_lock/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/nuki_lock/lock.py b/components/nuki_lock/lock.py new file mode 100644 index 0000000..0d3db08 --- /dev/null +++ b/components/nuki_lock/lock.py @@ -0,0 +1,271 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +import esphome.const as c +from esphome.components import lock, binary_sensor, text_sensor, sensor, button + +AUTO_LOAD = ["binary_sensor", "text_sensor", "sensor", "button"] + +CONF_PAIRED = "paired" +CONF_ERROR = "error" +CONF_BATTERY_CRITICAL = "battery_critical" +CONF_DOOR_CONTACT = "door_contact" +CONF_DOOR_CONTACT_SENSOR = "door_contact_sensor" +CONF_BEACON_RSSI = "beacon_rssi" +CONF_BEACON_LATENCY = "beacon_latency" +CONF_HEARTBEAT_LATENCY = "heartbeat_latency" +CONF_BEACON_BLE_ADDRESS = "beacon_ble_address" +CONF_LOCK_CURRENT_DATETIME = "lock_current_datetime" +CONF_TRIGGER = "trigger" +CONF_CONFIG_UPDATE_COUNT = "config_update_count" +CONF_LAST_ACTION = "last_action" +CONF_LAST_ACTION_TRIGGER = "last_action_trigger" +CONF_LAST_ACTION_COMPLETION_STATUS = "last_action_completion_status" +CONF_NIGHT_MODE_ACTIVE = "night_mode_active" +CONF_BATTERY_RESISTANCE = "battery_resistance" +CONF_BATTERY_LOWEST_VOLTAGE = "battery_lowest_voltage" +CONF_LAST_ACTION_START_VOLTAGE = "last_action_start_voltage" +CONF_LAST_ACTION_LOCK_DISTANCE = "last_action_lock_distance" +CONF_LAST_ACTION_START_TEMPERATURE = "last_action_start_temperature" +CONF_LAST_ACTION_BATTERY_DRAIN = "last_action_battery_drain" +CONF_LAST_ACTION_MAX_TURN_CURRENT = "last_action_max_turn_current" +CONF_REQUEST_BATTERY_REPORTS = "request_battery_reports" +CONF_RESTART_AFTER_BEACON_LATENCY = "restart_after_beacon_latency" +CONF_REQUEST_STATE = "request_state" +CONF_UNPAIR = "unpair" + +nuki_lock_ns = cg.esphome_ns.namespace("nuki_lock") +NukiLockComponent = nuki_lock_ns.class_("NukiLockComponent", lock.Lock, cg.PollingComponent) +RequestStateButton = nuki_lock_ns.class_("RequestStateButton", button.Button) +UnpairButton = nuki_lock_ns.class_("UnpairButton", button.Button) + +CONFIG_SCHEMA = lock.LOCK_SCHEMA.extend({ + cv.GenerateID(): cv.declare_id(NukiLockComponent), + + # Required sensors (if unconfigured, defaults are used). + cv.Optional(CONF_PAIRED, default={ + "name": "Paired", + }): binary_sensor.binary_sensor_schema( + device_class=c.DEVICE_CLASS_CONNECTIVITY, + ), + cv.Optional(CONF_ERROR, default={ + "name": "Error", + }): text_sensor.text_sensor_schema( + icon="mdi:alert", + ), + cv.Optional(CONF_BATTERY_CRITICAL, default={ + "name": "Battery critical", + }): binary_sensor.binary_sensor_schema( + device_class=c.DEVICE_CLASS_BATTERY, + ), + cv.Optional(c.CONF_BATTERY_LEVEL, default={ + "name": "Battery", + }): sensor.sensor_schema( + device_class=c.DEVICE_CLASS_BATTERY, + unit_of_measurement=c.UNIT_PERCENT, + ), + cv.Optional(CONF_DOOR_CONTACT, default={ + "name": "Door contact", + }): binary_sensor.binary_sensor_schema( + device_class=c.DEVICE_CLASS_DOOR, + ), + cv.Optional(CONF_DOOR_CONTACT_SENSOR, default={ + "name": "Door contact sensor", + }): text_sensor.text_sensor_schema(), + cv.Optional(CONF_TRIGGER, default={ + "name": "Trigger", + }): text_sensor.text_sensor_schema(), + cv.Optional(CONF_NIGHT_MODE_ACTIVE, default={ + "name": "Night mode active", + }): binary_sensor.binary_sensor_schema( + icon="mdi:weather-night", + ), + + # Optional sensors. + cv.Optional(CONF_BEACON_RSSI): sensor.sensor_schema( + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + state_class=c.STATE_CLASS_MEASUREMENT, + device_class=c.DEVICE_CLASS_SIGNAL_STRENGTH, + unit_of_measurement=c.UNIT_DECIBEL_MILLIWATT, + ), + cv.Optional(CONF_BEACON_LATENCY): sensor.sensor_schema( + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + state_class=c.STATE_CLASS_MEASUREMENT, + unit_of_measurement=c.UNIT_MILLISECOND, + ), + cv.Optional(CONF_HEARTBEAT_LATENCY): sensor.sensor_schema( + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + state_class=c.STATE_CLASS_MEASUREMENT, + unit_of_measurement=c.UNIT_MILLISECOND, + ), + cv.Optional(CONF_BEACON_BLE_ADDRESS): text_sensor.text_sensor_schema( + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + icon="mdi:bluetooth", + ), + cv.Optional(CONF_LOCK_CURRENT_DATETIME): text_sensor.text_sensor_schema( + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + device_class=c.DEVICE_CLASS_TIMESTAMP, + ), + cv.Optional(CONF_CONFIG_UPDATE_COUNT): sensor.sensor_schema( + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + state_class=c.STATE_CLASS_TOTAL, + ), + cv.Optional(CONF_LAST_ACTION): text_sensor.text_sensor_schema(), + cv.Optional(CONF_LAST_ACTION_TRIGGER): text_sensor.text_sensor_schema(), + cv.Optional(CONF_LAST_ACTION_COMPLETION_STATUS): text_sensor.text_sensor_schema(), + + # Optional sensors - battery report. + cv.Optional(CONF_REQUEST_BATTERY_REPORTS, default=False): cv.boolean, + cv.Optional(c.CONF_BATTERY_VOLTAGE, default={ + "name": "Battery voltage", + }): sensor.sensor_schema( + state_class=c.STATE_CLASS_MEASUREMENT, + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + device_class=c.DEVICE_CLASS_VOLTAGE, + unit_of_measurement=c.UNIT_VOLT, + accuracy_decimals=3, + ), + cv.Optional(CONF_BATTERY_RESISTANCE, default={ + "name": "Battery resistance", + }): sensor.sensor_schema( + state_class=c.STATE_CLASS_MEASUREMENT, + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + unit_of_measurement=c.UNIT_OHM, + accuracy_decimals=3, + icon="mdi:resistor", + ), + cv.Optional(CONF_BATTERY_LOWEST_VOLTAGE, default={ + "name": "Battery lowest voltage", + }): sensor.sensor_schema( + state_class=c.STATE_CLASS_MEASUREMENT, + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + device_class=c.DEVICE_CLASS_VOLTAGE, + unit_of_measurement=c.UNIT_VOLT, + accuracy_decimals=3, + ), + cv.Optional(CONF_LAST_ACTION_START_VOLTAGE, default={ + "name": "Last action start voltage", + }): sensor.sensor_schema( + state_class=c.STATE_CLASS_MEASUREMENT, + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + device_class=c.DEVICE_CLASS_VOLTAGE, + unit_of_measurement=c.UNIT_VOLT, + accuracy_decimals=3, + ), + cv.Optional(CONF_LAST_ACTION_LOCK_DISTANCE, default={ + "name": "Last action lock distance", + }): sensor.sensor_schema( + state_class=c.STATE_CLASS_MEASUREMENT, + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + unit_of_measurement=c.UNIT_DEGREES, + icon="mdi:rotate-360", + ), + cv.Optional(CONF_LAST_ACTION_START_TEMPERATURE, default={ + "name": "Last action start temperature", + }): sensor.sensor_schema( + state_class=c.STATE_CLASS_MEASUREMENT, + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + unit_of_measurement=c.UNIT_DEGREES, + device_class=c.DEVICE_CLASS_TEMPERATURE, + ), + cv.Optional(CONF_LAST_ACTION_BATTERY_DRAIN, default={ + "name": "Last action battery drain", + }): sensor.sensor_schema( + state_class=c.STATE_CLASS_MEASUREMENT, + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + unit_of_measurement=c.UNIT_WATT_HOURS, + device_class=c.DEVICE_CLASS_ENERGY, + accuracy_decimals=3, + ), + cv.Optional(CONF_LAST_ACTION_MAX_TURN_CURRENT, default={ + "name": "Last action max turn current", + }): sensor.sensor_schema( + state_class=c.STATE_CLASS_MEASUREMENT, + entity_category=c.ENTITY_CATEGORY_DIAGNOSTIC, + unit_of_measurement=c.UNIT_AMPERE, + device_class=c.DEVICE_CLASS_CURRENT, + accuracy_decimals=3, + ), + + # Configuration. + cv.Optional(CONF_RESTART_AFTER_BEACON_LATENCY, default="10min"): cv.positive_time_period_milliseconds, + + # Actions. + cv.Optional(CONF_REQUEST_STATE, default={ + "name": "Request state", + }): button.button_schema(RequestStateButton, + entity_category=c.ENTITY_CATEGORY_CONFIG, + icon="mdi:refresh", + ), + cv.Optional(CONF_UNPAIR, default={ + "name": "Unpair", + }): button.button_schema( + UnpairButton, + entity_category=c.ENTITY_CATEGORY_CONFIG, + icon="mdi:close", + ), +}).extend(cv.polling_component_schema("30min")) + + +async def to_code(config): + var = cg.new_Pvariable(config[c.CONF_ID]) + await cg.register_component(var, config) + await lock.register_lock(var, config) + + request_state_button = await button.new_button(config[CONF_REQUEST_STATE]) + await cg.register_parented(request_state_button, config[c.CONF_ID]) + cg.add(var.set_request_state_button(request_state_button)) + + unpair_button = await button.new_button(config[CONF_UNPAIR]) + await cg.register_parented(unpair_button, config[c.CONF_ID]) + cg.add(var.set_request_state_button(unpair_button)) + + cg.add(var.set_paired_binary_sensor(await binary_sensor.new_binary_sensor(config[CONF_PAIRED]))) + cg.add(var.set_error_text_sensor(await text_sensor.new_text_sensor(config[CONF_ERROR]))) + cg.add(var.set_battery_critical_binary_sensor(await binary_sensor.new_binary_sensor(config[CONF_BATTERY_CRITICAL]))) + cg.add(var.set_battery_level_sensor(await sensor.new_sensor(config[c.CONF_BATTERY_LEVEL]))) + cg.add(var.set_door_contact_binary_sensor(await binary_sensor.new_binary_sensor(config[CONF_DOOR_CONTACT]))) + cg.add(var.set_door_contact_sensor_text_sensor(await text_sensor.new_text_sensor(config[CONF_DOOR_CONTACT_SENSOR]))) + cg.add(var.set_trigger_text_sensor(await text_sensor.new_text_sensor(config[CONF_TRIGGER]))) + cg.add(var.set_night_mode_active_binary_sensor(await binary_sensor.new_binary_sensor(config[CONF_NIGHT_MODE_ACTIVE]))) + + if CONF_BEACON_RSSI in config: + cg.add(var.set_beacon_rssi_sensor( await sensor.new_sensor(config[CONF_BEACON_RSSI]))) + + if CONF_BEACON_LATENCY in config: + cg.add(var.set_beacon_latency_sensor(await sensor.new_sensor(config[CONF_BEACON_LATENCY]))) + + if CONF_HEARTBEAT_LATENCY in config: + cg.add(var.set_heartbeat_latency_sensor(await sensor.new_sensor(config[CONF_HEARTBEAT_LATENCY]))) + + if CONF_BEACON_BLE_ADDRESS in config: + cg.add(var.set_beacon_ble_address_text_sensor(await text_sensor.new_text_sensor(config[CONF_BEACON_BLE_ADDRESS]))) + + if CONF_LOCK_CURRENT_DATETIME in config: + sens = await text_sensor.new_text_sensor(config[CONF_LOCK_CURRENT_DATETIME]) + cg.add(var.set_lock_current_datetime_text_sensor(sens)) + + if CONF_CONFIG_UPDATE_COUNT in config: + cg.add(var.set_config_update_count_sensor(await sensor.new_sensor(config[CONF_CONFIG_UPDATE_COUNT]))) + + if CONF_LAST_ACTION in config: + cg.add(var.set_last_action_text_sensor(await text_sensor.new_text_sensor(config[CONF_LAST_ACTION]))) + + if CONF_LAST_ACTION_TRIGGER in config: + cg.add(var.set_last_action_trigger_text_sensor(await text_sensor.new_text_sensor(config[CONF_LAST_ACTION_TRIGGER]))) + + if CONF_LAST_ACTION_COMPLETION_STATUS in config: + cg.add(var.set_last_action_completion_status_text_sensor(await text_sensor.new_text_sensor(config[CONF_LAST_ACTION_COMPLETION_STATUS]))) + + if config.get(CONF_REQUEST_BATTERY_REPORTS): + cg.add(var.set_request_battery_reports(True)) + cg.add(var.set_battery_voltage_sensor(await sensor.new_sensor(config[c.CONF_BATTERY_VOLTAGE]))) + cg.add(var.set_battery_resistance_sensor(await sensor.new_sensor(config[CONF_BATTERY_RESISTANCE]))) + cg.add(var.set_battery_lowest_voltage_sensor(await sensor.new_sensor(config[CONF_BATTERY_LOWEST_VOLTAGE]))) + cg.add(var.set_last_action_start_voltage_sensor(await sensor.new_sensor(config[CONF_LAST_ACTION_START_VOLTAGE]))) + cg.add(var.set_last_action_lock_distance_sensor(await sensor.new_sensor(config[CONF_LAST_ACTION_LOCK_DISTANCE]))) + cg.add(var.set_last_action_start_temperature_sensor(await sensor.new_sensor(config[CONF_LAST_ACTION_START_TEMPERATURE]))) + cg.add(var.set_last_action_battery_drain_sensor(await sensor.new_sensor(config[CONF_LAST_ACTION_BATTERY_DRAIN]))) + cg.add(var.set_last_action_max_turn_current_sensor(await sensor.new_sensor(config[CONF_LAST_ACTION_MAX_TURN_CURRENT]))) + + if CONF_RESTART_AFTER_BEACON_LATENCY in config: + cg.add(var.set_restart_after_beacon_latency(config[CONF_RESTART_AFTER_BEACON_LATENCY])) diff --git a/components/nuki_lock/nuki_lock.cpp b/components/nuki_lock/nuki_lock.cpp new file mode 100644 index 0000000..8949ac0 --- /dev/null +++ b/components/nuki_lock/nuki_lock.cpp @@ -0,0 +1,483 @@ +#include "nuki_lock.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace { + +using namespace esphome; + +const char *TAG = "nuki_lock"; + +class Adapt { + public: + static esphome::lock::LockState lock_state_to_esphome( + NukiLock::LockState state) { + switch (state) { + case NukiLock::LockState::Locked: + return esphome::lock::LOCK_STATE_LOCKED; + case NukiLock::LockState::Unlocked: + return esphome::lock::LOCK_STATE_UNLOCKED; + case NukiLock::LockState::MotorBlocked: + return esphome::lock::LOCK_STATE_JAMMED; + case NukiLock::LockState::Locking: + return esphome::lock::LOCK_STATE_LOCKING; + case NukiLock::LockState::Unlocking: + return esphome::lock::LOCK_STATE_UNLOCKING; + default: + return esphome::lock::LOCK_STATE_NONE; + } + } + + static std::string lock_action_to_string(NukiLock::LockAction action) { + char result[256]; + NukiLock::lockactionToString(action, result); + return result; + } + + static bool door_sensor_state_to_is_door_open(Nuki::DoorSensorState state) { + switch (state) { + case Nuki::DoorSensorState::DoorClosed: + return false; + default: + return true; + } + } + + static std::string door_sensor_state_to_string(Nuki::DoorSensorState state) { + char result[256]; + NukiLock::doorSensorStateToString(state, result); + return result; + } + + static std::string key_turner_datetime_iso( + const NukiLock::KeyTurnerState &state) { + uint8_t tzOffsetHours = abs(state.timeZoneOffset / 60); + uint8_t tzOffsetMinutes = abs(state.timeZoneOffset % 60); + + char ts[256]; + sprintf(ts, "%d-%.2d-%.2dT%.2d:%.2d:%.2d%c%.2d:%.2d", state.currentTimeYear, + state.currentTimeMonth, state.currentTimeDay, state.currentTimeHour, + state.currentTimeMinute, state.currentTimeSecond, + state.timeZoneOffset >= 0 ? '+' : '-', tzOffsetHours, + tzOffsetMinutes); + return ts; + } + + static std::string trigger_to_string(NukiLock::Trigger trigger) { + char result[256]; + NukiLock::triggerToString(trigger, result); + return result; + } + + static std::string completion_status_to_string( + NukiLock::CompletionStatus status) { + char result[256]; + NukiLock::completionStatusToString(status, result); + return result; + } + + static std::string cmd_result_to_string(Nuki::CmdResult result) { + char resultStr[256]; + NukiLock::cmdResultToString(result, resultStr); + return resultStr; + } + + static std::string error_to_string(NukiLock::ErrorCode error) { + switch (error) { + case NukiLock::ErrorCode::ERROR_BAD_CRC: + return "ERROR_BAD_CRC"; + case NukiLock::ErrorCode::ERROR_BAD_LENGTH: + return "ERROR_BAD_LENGTH"; + case NukiLock::ErrorCode::ERROR_UNKNOWN: + return "ERROR_UNKNOWN"; + case NukiLock::ErrorCode::P_ERROR_NOT_PAIRING: + return "P_ERROR_NOT_PAIRING"; + case NukiLock::ErrorCode::P_ERROR_BAD_AUTHENTICATOR: + return "P_ERROR_BAD_AUTHENTICATOR"; + case NukiLock::ErrorCode::P_ERROR_BAD_PARAMETER: + return "P_ERROR_BAD_PARAMETER"; + case NukiLock::ErrorCode::P_ERROR_MAX_USER: + return "P_ERROR_MAX_USER"; + case NukiLock::ErrorCode::K_ERROR_NOT_AUTHORIZED: + return "K_ERROR_NOT_AUTHORIZED"; + case NukiLock::ErrorCode::K_ERROR_BAD_PIN: + return "K_ERROR_BAD_PIN"; + case NukiLock::ErrorCode::K_ERROR_BAD_NONCE: + return "K_ERROR_BAD_NONCE"; + case NukiLock::ErrorCode::K_ERROR_BAD_PARAMETER: + return "K_ERROR_BAD_PARAMETER"; + case NukiLock::ErrorCode::K_ERROR_INVALID_AUTH_ID: + return "K_ERROR_INVALID_AUTH_ID"; + case NukiLock::ErrorCode::K_ERROR_DISABLED: + return "K_ERROR_DISABLED"; + case NukiLock::ErrorCode::K_ERROR_REMOTE_NOT_ALLOWED: + return "K_ERROR_REMOTE_NOT_ALLOWED"; + case NukiLock::ErrorCode::K_ERROR_TIME_NOT_ALLOWED: + return "K_ERROR_TIME_NOT_ALLOWED"; + case NukiLock::ErrorCode::K_ERROR_TOO_MANY_PIN_ATTEMPTS: + return "K_ERROR_TOO_MANY_PIN_ATTEMPTS"; + case NukiLock::ErrorCode::K_ERROR_TOO_MANY_ENTRIES: + return "K_ERROR_TOO_MANY_ENTRIES"; + case NukiLock::ErrorCode::K_ERROR_CODE_ALREADY_EXISTS: + return "K_ERROR_CODE_ALREADY_EXISTS"; + case NukiLock::ErrorCode::K_ERROR_CODE_INVALID: + return "K_ERROR_CODE_INVALID"; + case NukiLock::ErrorCode::K_ERROR_CODE_INVALID_TIMEOUT_1: + return "K_ERROR_CODE_INVALID_TIMEOUT_1"; + case NukiLock::ErrorCode::K_ERROR_CODE_INVALID_TIMEOUT_2: + return "K_ERROR_CODE_INVALID_TIMEOUT_2"; + case NukiLock::ErrorCode::K_ERROR_CODE_INVALID_TIMEOUT_3: + return "K_ERROR_CODE_INVALID_TIMEOUT_3"; + case NukiLock::ErrorCode::K_ERROR_AUTO_UNLOCK_TOO_RECENT: + return "K_ERROR_AUTO_UNLOCK_TOO_RECENT"; + case NukiLock::ErrorCode::K_ERROR_POSITION_UNKNOWN: + return "K_ERROR_POSITION_UNKNOWN"; + case NukiLock::ErrorCode::K_ERROR_MOTOR_BLOCKED: + return "K_ERROR_MOTOR_BLOCKED"; + case NukiLock::ErrorCode::K_ERROR_CLUTCH_FAILURE: + return "K_ERROR_CLUTCH_FAILURE"; + case NukiLock::ErrorCode::K_ERROR_MOTOR_TIMEOUT: + return "K_ERROR_MOTOR_TIMEOUT"; + case NukiLock::ErrorCode::K_ERROR_BUSY: + return "K_ERROR_BUSY"; + case NukiLock::ErrorCode::K_ERROR_CANCELED: + return "K_ERROR_CANCELED"; + case NukiLock::ErrorCode::K_ERROR_NOT_CALIBRATED: + return "K_ERROR_NOT_CALIBRATED"; + case NukiLock::ErrorCode::K_ERROR_MOTOR_POSITION_LIMIT: + return "K_ERROR_MOTOR_POSITION_LIMIT"; + case NukiLock::ErrorCode::K_ERROR_MOTOR_LOW_VOLTAGE: + return "K_ERROR_MOTOR_LOW_VOLTAGE"; + case NukiLock::ErrorCode::K_ERROR_MOTOR_POWER_FAILURE: + return "K_ERROR_MOTOR_POWER_FAILURE"; + case NukiLock::ErrorCode::K_ERROR_CLUTCH_POWER_FAILURE: + return "K_ERROR_CLUTCH_POWER_FAILURE"; + case NukiLock::ErrorCode::K_ERROR_VOLTAGE_TOO_LOW: + return "K_ERROR_VOLTAGE_TOO_LOW"; + case NukiLock::ErrorCode::K_ERROR_FIRMWARE_UPDATE_NEEDED: + return "K_ERROR_FIRMWARE_UPDATE_NEEDED"; + default: + return std::string("Unknown #") + + std::to_string(static_cast(error)); + } + } +}; +} // namespace + +namespace esphome { +namespace nuki_lock { + +bool NukiLockComponent::update_key_turner_state_() { + NukiLock::KeyTurnerState retrievedKeyTurnerState; + Nuki::CmdResult result = + nuki_lock_->requestKeyTurnerState(&retrievedKeyTurnerState); + + if (result != Nuki::CmdResult::Success) { + ESP_LOGE(TAG, "request for key turner state failed: %s", + Adapt::cmd_result_to_string(result).c_str()); + + publish_state(lock::LOCK_STATE_NONE); + error_text_sensor_->publish_state( + Adapt::error_to_string(nuki_lock_->getLastError())); + + // TODO(https://github.com/esphome/feature-requests/issues/1568): publish + // unavailable for all related sensors. + + schedule_update(); + + return false; + } + + publish_state( + Adapt::lock_state_to_esphome(retrievedKeyTurnerState.lockState)); + + door_contact_binary_sensor_->publish_state( + Adapt::door_sensor_state_to_is_door_open( + retrievedKeyTurnerState.doorSensorState)); + door_contact_sensor_text_sensor_->publish_state( + Adapt::door_sensor_state_to_string( + retrievedKeyTurnerState.doorSensorState)); + battery_level_sensor_->publish_state(nuki_lock_->getBatteryPerc()); + battery_critical_binary_sensor_->publish_state( + nuki_lock_->isBatteryCritical()); + trigger_text_sensor_->publish_state( + Adapt::trigger_to_string(retrievedKeyTurnerState.trigger)); + night_mode_active_binary_sensor_->publish_state( + retrievedKeyTurnerState.nightModeActive > 0); + + if (lock_current_datetime_text_sensor_ != nullptr) { + lock_current_datetime_text_sensor_->publish_state( + Adapt::key_turner_datetime_iso(retrievedKeyTurnerState)); + } + if (config_update_count_sensor_ != nullptr) { + config_update_count_sensor_->publish_state( + retrievedKeyTurnerState.configUpdateCount); + } + if (last_action_text_sensor_ != nullptr) { + last_action_text_sensor_->publish_state( + Adapt::lock_action_to_string(retrievedKeyTurnerState.lastLockAction)); + } + if (last_action_trigger_text_sensor_ != nullptr) { + last_action_trigger_text_sensor_->publish_state(Adapt::trigger_to_string( + retrievedKeyTurnerState.lastLockActionTrigger)); + } + if (last_action_completion_status_text_sensor_ != nullptr) { + last_action_completion_status_text_sensor_->publish_state( + Adapt::completion_status_to_string( + retrievedKeyTurnerState.lastLockActionCompletionStatus)); + } + + return true; +} + +bool NukiLockComponent::update_battery_report_() { + NukiLock::BatteryReport batteryReport; + Nuki::CmdResult result = + this->nuki_lock_->requestBatteryReport(&batteryReport); + + if (result != Nuki::CmdResult::Success) { + ESP_LOGE(TAG, "request for battery report failed: %s", + Adapt::cmd_result_to_string(result).c_str()); + // TODO(https://github.com/esphome/feature-requests/issues/1568): publish + // unavailable for all related sensors. + error_text_sensor_->publish_state( + Adapt::error_to_string(nuki_lock_->getLastError())); + + return false; + } + + battery_voltage_sensor_->publish_state(batteryReport.batteryVoltage / + 1000.0f); + battery_resistance_sensor_->publish_state(batteryReport.batteryResistance / + 1000.0f); + battery_lowest_voltage_sensor_->publish_state(batteryReport.lowestVoltage / + 1000.0f); + last_action_start_voltage_sensor_->publish_state(batteryReport.startVoltage / + 1000.0f); + last_action_lock_distance_sensor_->publish_state(batteryReport.lockDistance); + last_action_start_temperature_sensor_->publish_state( + batteryReport.startTemperature); + last_action_battery_drain_sensor_->publish_state(batteryReport.batteryDrain / + 1000.0f); + last_action_max_turn_current_sensor_->publish_state( + batteryReport.maxTurnCurrent / 1000.0f); + + return true; +} + +void NukiLockComponent::notify(Nuki::EventType event) { + ESP_LOGI(TAG, "received Nuki event - updating state"); + // Have to execute later as this runs in BLE scanner context and we can't do + // any BLE in this context. + schedule_update(); +} + +void NukiLockComponent::schedule_update() { + if (!update_scheduled_) { + update_scheduled_ = true; + defer([this]() { + update(); + update_scheduled_ = false; + }); + } +} + +void NukiLockComponent::setup() { + traits.set_supports_open(true); + traits.set_supported_states(std::set{ + lock::LOCK_STATE_NONE, lock::LOCK_STATE_LOCKED, lock::LOCK_STATE_UNLOCKED, + lock::LOCK_STATE_JAMMED, lock::LOCK_STATE_LOCKING, + lock::LOCK_STATE_UNLOCKING}); + publish_state(lock::LOCK_STATE_NONE); + + uint8_t mac[6]; + get_mac_address_raw(mac); + our_device_id_ = mac[0] + (mac[1] << 8) + (mac[2] << 16) + (mac[3] << 24); + // name will be used as part of the Preferences key to save credentials. + // There's a limit of 15 characters on key length, keep it predictable. + our_device_name_ = "nuki" + std::to_string(get_object_id_hash()); + + nuki_lock_ = new NukiLock::NukiLock(our_device_name_, our_device_id_); + scanner_.initialize(); + nuki_lock_->registerBleScanner(&scanner_); + nuki_lock_->initialize(); + nuki_lock_->setEventHandler(this); + + bool paired = nuki_lock_->isPairedWithLock(); + paired_binary_sensor_->publish_initial_state(paired); + error_text_sensor_->publish_state("No error"); + + set_interval("ble_loop", 320 /* ms*/, [this]() { ble_loop_(); }); + schedule_update(); +} + +void NukiLockComponent::unpair() { + ESP_LOGI(TAG, "unpairing Nuki lock"); + nuki_lock_->unPairNuki(); + // Force BLE update on the next loop. + last_passive_update_millis_ = 0; + // And a regular fetch. + schedule_update(); +} + +void NukiLockComponent::ble_loop_() { + scanner_.update(); + + unsigned long ts = millis(); + bool should_publish_update = last_passive_update_millis_ == 0 || + last_passive_update_millis_ + 60000 < ts; + + bool paired = nuki_lock_->isPairedWithLock(); + if (should_publish_update) { + last_passive_update_millis_ = ts; + paired_binary_sensor_->publish_state(paired); + } + + if (!paired) { + if (nuki_lock_->pairNuki() == Nuki::PairingResult::Success) { + ESP_LOGI(TAG, "Nuki lock successfully paired!"); + // Force BLE update on the next loop. + last_passive_update_millis_ = 0; + // And a regular fetch. + schedule_update(); + } else if (should_publish_update) { + ESP_LOGW(TAG, + "Nuki lock is not yet paired. Make sure Bluetooth pairing " + "is enabled in lock settings, then press and hold the button " + "on the lock for a few seconds until LED glows constantly"); + } + + return; + } + + nuki_lock_->updateConnectionState(); + // Update timestamp as we might have gotten a refresh packet on BLE. + ts = millis(); + unsigned long beaconLatency = ts - nuki_lock_->getLastReceivedBeaconTs(), + heartbeatLatency = ts - nuki_lock_->getLastHeartbeat(); + if (should_publish_update) { + if (beacon_rssi_sensor_ != nullptr) { + int rssi = nuki_lock_->getRssi(); + if (rssi != 0) { + beacon_rssi_sensor_->publish_state(rssi); + } + } + if (beacon_latency_sensor_ != nullptr) { + beacon_latency_sensor_->publish_state(beaconLatency); + } + if (heartbeat_latency_sensor_ != nullptr) { + heartbeat_latency_sensor_->publish_state(heartbeatLatency); + } + if (beacon_ble_address_text_sensor_ != nullptr) { + beacon_ble_address_text_sensor_->publish_state( + nuki_lock_->getBleAddress().toString()); + } + } + + if (restart_after_beacon_latency_ != 0 && + beaconLatency > restart_after_beacon_latency_) { + ESP_LOGE(TAG, + "Beacon latency %d higher than configured maximum %d. Restarting " + "the device"); + App.safe_reboot(); + } +} + +void NukiLockComponent::update() { + bool paired = nuki_lock_->isPairedWithLock(); + if (paired) { + update_key_turner_state_(); + if (request_battery_reports_) { + update_battery_report_(); + } + } +} + +void NukiLockComponent::send_action_(NukiLock::LockAction action) { + if (!this->nuki_lock_->isPairedWithLock()) { + ESP_LOGE(TAG, "lock or unlock action %d called before a lock is paired", + action); + return; + } + Nuki::CmdResult result = this->nuki_lock_->lockAction(action); + if (result != Nuki::CmdResult::Success) { + ESP_LOGE(TAG, "setting state %d failed: %s", action, + Adapt::cmd_result_to_string(result).c_str()); + publish_state(lock::LOCK_STATE_NONE); + error_text_sensor_->publish_state( + Adapt::error_to_string(nuki_lock_->getLastError())); + schedule_update(); + } +} + +void NukiLockComponent::control(const lock::LockCall &call) { + auto action = *call.get_state(); + switch (action) { + case lock::LOCK_STATE_LOCKED: + publish_state(lock::LOCK_STATE_LOCKING); + send_action_(NukiLock::LockAction::Lock); + break; + + case lock::LOCK_STATE_UNLOCKED: + publish_state(lock::LOCK_STATE_UNLOCKING); + send_action_(NukiLock::LockAction::Unlock); + break; + + default: + ESP_LOGE(TAG, "unsupported lock state requested: %d", action); + return; + } + + // Final lock state will be published once Nuki informs us of an update. +} + +void NukiLockComponent::open_latch() { + publish_state(lock::LOCK_STATE_UNLOCKING); + send_action_(NukiLock::LockAction::Unlatch); +} + +void NukiLockComponent::dump_config() { + char device_id[256]; + sprintf(device_id, "%s (0x%.8x)", our_device_name_.c_str(), our_device_id_); + LOG_LOCK("", device_id, this); + + // Required. + LOG_BINARY_SENSOR(" ", "", paired_binary_sensor_); + LOG_TEXT_SENSOR(" ", "", error_text_sensor_); + LOG_BINARY_SENSOR(" ", "", battery_critical_binary_sensor_); + LOG_SENSOR(" ", "", battery_level_sensor_); + LOG_BINARY_SENSOR(" ", "", door_contact_binary_sensor_); + LOG_TEXT_SENSOR(" ", "", door_contact_sensor_text_sensor_); + LOG_TEXT_SENSOR(" ", "", trigger_text_sensor_); + LOG_BINARY_SENSOR(" ", "", night_mode_active_binary_sensor_); + + // Optional. + LOG_SENSOR(" ", "", beacon_rssi_sensor_); + LOG_SENSOR(" ", "", beacon_latency_sensor_); + LOG_SENSOR(" ", "", heartbeat_latency_sensor_); + LOG_TEXT_SENSOR(" ", "", beacon_ble_address_text_sensor_); + LOG_TEXT_SENSOR(" ", "", lock_current_datetime_text_sensor_); + LOG_SENSOR(" ", "", config_update_count_sensor_); + LOG_TEXT_SENSOR(" ", "", last_action_text_sensor_); + LOG_TEXT_SENSOR(" ", "", last_action_trigger_text_sensor_); + LOG_TEXT_SENSOR(" ", "", last_action_completion_status_text_sensor_); + + if (request_battery_reports_) { + // Optional sensors - battery report. + LOG_SENSOR(" ", "", battery_voltage_sensor_); + LOG_SENSOR(" ", "", battery_resistance_sensor_); + LOG_SENSOR(" ", "", battery_lowest_voltage_sensor_); + LOG_SENSOR(" ", "", last_action_start_voltage_sensor_); + LOG_SENSOR(" ", "", last_action_lock_distance_sensor_); + LOG_SENSOR(" ", "", last_action_start_temperature_sensor_); + LOG_SENSOR(" ", "", last_action_battery_drain_sensor_); + LOG_SENSOR(" ", "", last_action_max_turn_current_sensor_); + } + + ESP_LOGCONFIG(TAG, " Restart after beacon latency: %dms", + restart_after_beacon_latency_); +} + +} // namespace nuki_lock +} // namespace esphome diff --git a/components/nuki_lock/nuki_lock.h b/components/nuki_lock/nuki_lock.h new file mode 100644 index 0000000..4d70bd2 --- /dev/null +++ b/components/nuki_lock/nuki_lock.h @@ -0,0 +1,131 @@ +#pragma once + +#include "BleScanner.h" +#include "NukiConstants.h" +#include "NukiLock.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/button/button.h" +#include "esphome/components/lock/lock.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace nuki_lock { + +class NukiLockComponent : public lock::Lock, + public PollingComponent, + public Nuki::SmartlockEventHandler { + public: + explicit NukiLockComponent() + : Lock(), + last_passive_update_millis_(0), + restart_after_beacon_latency_(0), + update_scheduled_(false), + request_battery_reports_(false){}; + + // Component. + void setup() override; + float get_setup_priority() const override { + return setup_priority::HARDWARE - 1.0f; + } + void dump_config() override; + + // PollingComponent. + void update() override; + + // Nuki::SmartlockEventHandler. + void notify(Nuki::EventType event); + + // Passive updates (predefined update frequency). + SUB_BINARY_SENSOR(paired) + SUB_SENSOR(beacon_rssi) + SUB_SENSOR(beacon_latency) + SUB_SENSOR(heartbeat_latency) + SUB_TEXT_SENSOR(beacon_ble_address) + + // Active updates (configured update_interval frequency). + // Key turner state. + SUB_BINARY_SENSOR(battery_critical) + SUB_SENSOR(battery_level) + SUB_BINARY_SENSOR(door_contact) + SUB_TEXT_SENSOR(door_contact_sensor) + SUB_TEXT_SENSOR(lock_current_datetime) + SUB_TEXT_SENSOR(error) + SUB_TEXT_SENSOR(trigger) + SUB_SENSOR(config_update_count) + SUB_TEXT_SENSOR(last_action) + SUB_TEXT_SENSOR(last_action_trigger) + SUB_TEXT_SENSOR(last_action_completion_status) + SUB_BINARY_SENSOR(night_mode_active) + // Battery report. + SUB_SENSOR(battery_voltage) + SUB_SENSOR(battery_resistance) + SUB_SENSOR(battery_lowest_voltage) + SUB_SENSOR(last_action_start_voltage) + SUB_SENSOR(last_action_lock_distance) + SUB_SENSOR(last_action_start_temperature) + SUB_SENSOR(last_action_battery_drain) + SUB_SENSOR(last_action_max_turn_current) + + // Actions. + SUB_BUTTON(lock_n_go) + SUB_BUTTON(unpair) + SUB_BUTTON(request_state) + + void set_request_battery_reports(bool value) { + request_battery_reports_ = value; + } + + void set_restart_after_beacon_latency(unsigned long value) { + restart_after_beacon_latency_ = value; + } + + // RequestStateButton. + void schedule_update(); + + // UnpairButton. + void unpair(); + + protected: + // lock::Lock. + void control(const lock::LockCall &call) override; + void open_latch() override; + + private: + bool update_key_turner_state_(); + bool update_battery_report_(); + void ble_loop_(); + void send_action_(NukiLock::LockAction action); + + NukiLock::NukiLock *nuki_lock_; + BleScanner::Scanner scanner_; + + unsigned long last_passive_update_millis_; + unsigned long restart_after_beacon_latency_; + bool update_scheduled_; + bool request_battery_reports_; + + uint32_t our_device_id_; + std::string our_device_name_; +}; + +class RequestStateButton : public button::Button, + public Parented { + public: + RequestStateButton() = default; + + protected: + void press_action() override { get_parent()->schedule_update(); } +}; + +class UnpairButton : public button::Button, public Parented { + public: + UnpairButton() = default; + + protected: + void press_action() override { get_parent()->unpair(); } +}; + +} // namespace nuki_lock +} // namespace esphome diff --git a/frontdoorlock.yaml b/frontdoorlock.yaml new file mode 100644 index 0000000..677245a --- /dev/null +++ b/frontdoorlock.yaml @@ -0,0 +1,46 @@ +esphome: + name: frontdoorlock + friendly_name: "Front door lock" + area: "Entryway" + + project: + name: dasfoo.frontdoorlock + version: "1.0" + + libraries: + - Preferences + - https://github.com/vinmenn/Crc16.git + - https://github.com/I-Connect/NukiBleEsp32 + +external_components: + - source: components + +packages: + device_base: !include templates/esp32-poe.yaml + +button: + - platform: factory_reset + name: Restart with Factory Default Settings + +lock: + - platform: nuki_lock + name: None + request_battery_reports: true + beacon_rssi: + name: "Beacon RSSI" + heartbeat_latency: + name: "Heartbeat latency" + beacon_latency: + name: "Beacon latency" + beacon_ble_address: + name: "Beacon BLE address" + lock_current_datetime: + name: "Lock current datetime" + config_update_count: + name: "Config update count" + last_action: + name: "Last action" + last_action_trigger: + name: "Last action trigger" + last_action_completion_status: + name: "Last action completion status"