From 28d2c5c208c4687be8785b09fd6ee01181801d0e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 1 Oct 2023 20:43:29 +0200 Subject: [PATCH 01/33] Move old documents to archive --- {packages => archive}/arduino/README.md | 0 {packages => archive}/arduino/flexispot_e8.ino | 0 {packages => archive}/arduino/flexispot_ek5.ino | 0 {packages => archive}/arduino/height_demo | 0 {packages => archive}/esphome/Flexispot_E7_MQTT_control.yaml | 0 {packages => archive}/esphome/README.md | 0 {packages => archive}/esphome/desk_height_sensor.h | 0 {packages => archive}/esphome/desk_keypad.h | 0 {packages => archive}/esphome/flexispot_e5b.yaml | 0 {packages => archive}/esphome/flexispot_e5b_esp32.yaml | 0 {packages => archive}/esphome/flexispot_e6.yaml | 0 {packages => archive}/esphome/flexispot_ek5.yaml | 0 {packages => archive}/esphome/secrets.yaml | 0 {packages => archive}/raspberry-pi/README.md | 0 {packages => archive}/raspberry-pi/flexispot.py | 0 15 files changed, 0 insertions(+), 0 deletions(-) rename {packages => archive}/arduino/README.md (100%) rename {packages => archive}/arduino/flexispot_e8.ino (100%) rename {packages => archive}/arduino/flexispot_ek5.ino (100%) rename {packages => archive}/arduino/height_demo (100%) rename {packages => archive}/esphome/Flexispot_E7_MQTT_control.yaml (100%) rename {packages => archive}/esphome/README.md (100%) rename {packages => archive}/esphome/desk_height_sensor.h (100%) rename {packages => archive}/esphome/desk_keypad.h (100%) rename {packages => archive}/esphome/flexispot_e5b.yaml (100%) rename {packages => archive}/esphome/flexispot_e5b_esp32.yaml (100%) rename {packages => archive}/esphome/flexispot_e6.yaml (100%) rename {packages => archive}/esphome/flexispot_ek5.yaml (100%) rename {packages => archive}/esphome/secrets.yaml (100%) rename {packages => archive}/raspberry-pi/README.md (100%) rename {packages => archive}/raspberry-pi/flexispot.py (100%) diff --git a/packages/arduino/README.md b/archive/arduino/README.md similarity index 100% rename from packages/arduino/README.md rename to archive/arduino/README.md diff --git a/packages/arduino/flexispot_e8.ino b/archive/arduino/flexispot_e8.ino similarity index 100% rename from packages/arduino/flexispot_e8.ino rename to archive/arduino/flexispot_e8.ino diff --git a/packages/arduino/flexispot_ek5.ino b/archive/arduino/flexispot_ek5.ino similarity index 100% rename from packages/arduino/flexispot_ek5.ino rename to archive/arduino/flexispot_ek5.ino diff --git a/packages/arduino/height_demo b/archive/arduino/height_demo similarity index 100% rename from packages/arduino/height_demo rename to archive/arduino/height_demo diff --git a/packages/esphome/Flexispot_E7_MQTT_control.yaml b/archive/esphome/Flexispot_E7_MQTT_control.yaml similarity index 100% rename from packages/esphome/Flexispot_E7_MQTT_control.yaml rename to archive/esphome/Flexispot_E7_MQTT_control.yaml diff --git a/packages/esphome/README.md b/archive/esphome/README.md similarity index 100% rename from packages/esphome/README.md rename to archive/esphome/README.md diff --git a/packages/esphome/desk_height_sensor.h b/archive/esphome/desk_height_sensor.h similarity index 100% rename from packages/esphome/desk_height_sensor.h rename to archive/esphome/desk_height_sensor.h diff --git a/packages/esphome/desk_keypad.h b/archive/esphome/desk_keypad.h similarity index 100% rename from packages/esphome/desk_keypad.h rename to archive/esphome/desk_keypad.h diff --git a/packages/esphome/flexispot_e5b.yaml b/archive/esphome/flexispot_e5b.yaml similarity index 100% rename from packages/esphome/flexispot_e5b.yaml rename to archive/esphome/flexispot_e5b.yaml diff --git a/packages/esphome/flexispot_e5b_esp32.yaml b/archive/esphome/flexispot_e5b_esp32.yaml similarity index 100% rename from packages/esphome/flexispot_e5b_esp32.yaml rename to archive/esphome/flexispot_e5b_esp32.yaml diff --git a/packages/esphome/flexispot_e6.yaml b/archive/esphome/flexispot_e6.yaml similarity index 100% rename from packages/esphome/flexispot_e6.yaml rename to archive/esphome/flexispot_e6.yaml diff --git a/packages/esphome/flexispot_ek5.yaml b/archive/esphome/flexispot_ek5.yaml similarity index 100% rename from packages/esphome/flexispot_ek5.yaml rename to archive/esphome/flexispot_ek5.yaml diff --git a/packages/esphome/secrets.yaml b/archive/esphome/secrets.yaml similarity index 100% rename from packages/esphome/secrets.yaml rename to archive/esphome/secrets.yaml diff --git a/packages/raspberry-pi/README.md b/archive/raspberry-pi/README.md similarity index 100% rename from packages/raspberry-pi/README.md rename to archive/raspberry-pi/README.md diff --git a/packages/raspberry-pi/flexispot.py b/archive/raspberry-pi/flexispot.py similarity index 100% rename from packages/raspberry-pi/flexispot.py rename to archive/raspberry-pi/flexispot.py From 6303fa7d10fc1cc102740a4a41e9dff448cd5c42 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 1 Oct 2023 21:04:27 +0200 Subject: [PATCH 02/33] Add new files --- packages/desk_height_sensor.h | 183 ++++++++++++++++++++++ packages/office-desk.yaml | 280 ++++++++++++++++++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 packages/desk_height_sensor.h create mode 100644 packages/office-desk.yaml diff --git a/packages/desk_height_sensor.h b/packages/desk_height_sensor.h new file mode 100644 index 0000000..73f02de --- /dev/null +++ b/packages/desk_height_sensor.h @@ -0,0 +1,183 @@ +#include "esphome.h" +#include + +class DeskHeightSensor : public Component, public UARTDevice, public Sensor +{ +public: + DeskHeightSensor(UARTComponent *parent) : UARTDevice(parent) {} + + float value = 0; + float lastPublished = -1; + unsigned long history[5]; + + int msg_len = 0; + unsigned long msg_type; + bool valid = false; + + float get_setup_priority() const override { return esphome::setup_priority::DATA; } + + int hex_to_int(byte s) + { + std::bitset<8> b(s); + + if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) + { + return 0; + } + if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) + { + return 1; + } + if (b[0] && b[1] && !b[2] && b[3] && b[4] && !b[5] && b[6]) + { + return 2; + } + if (b[0] && b[1] && b[2] && b[3] && !b[4] && !b[5] && b[6]) + { + return 3; + } + if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && b[5] && b[6]) + { + return 4; + } + if (b[0] && !b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) + { + return 5; + } + if (b[0] && !b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) + { + return 6; + } + if (b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) + { + return 7; + } + if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) + { + return 8; + } + if (b[0] && b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) + { + return 9; + } + if (!b[0] && !b[1] && !b[2] && !b[3] && !b[4] && !b[5] && b[6]) + { + return 10; + } + return 0; + } + + bool is_decimal(byte b) + { + return (b & 0x80) == 0x80; + } + + void setup() override + { + // nothing to do here + } + + void loop() override + { + while (available() > 0) + { + byte incomingByte = read(); + // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); + + // First byte, start of a packet + if (incomingByte == 0x9b) + { + // Reset message length + msg_len = 0; + valid = false; + } + + // Second byte defines the message length + if (history[0] == 0x9b) + { + msg_len = (int)incomingByte; + } + + // Third byte is message type + if (history[1] == 0x9b) + { + msg_type = incomingByte; + } + + // Fourth byte is first height digit, if msg type 0x12 & msg len 7 + if (history[2] == 0x9b) + { + + if (msg_type == 0x12 && (msg_len == 7 || msg_len == 10)) + { + // Empty height + if (incomingByte == 0) + { + // ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } + else if (hex_to_int(incomingByte) == 0) + { + // ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } + else + { + valid = true; + // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); + } + } + } + + // Fifth byte is second height digit + if (history[3] == 0x9b) + { + if (valid == true) + { + // ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); + } + } + + // Sixth byte is third height digit + if (history[4] == 0x9b) + { + if (valid == true) + { + int height1 = hex_to_int(history[1]) * 100; + int height2 = hex_to_int(history[0]) * 10; + int height3 = hex_to_int(incomingByte); + if (height2 == 100) // check if 'number' is a hyphen, return value 10 multiplied by 10 + { + } + else + { + float finalHeight = height1 + height2 + height3; + if (is_decimal(history[0])) + { + finalHeight = finalHeight / 10; + } + value = finalHeight; + // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); + } + } + } + + // Save byte buffer to history arrary + history[4] = history[3]; + history[3] = history[2]; + history[2] = history[1]; + history[1] = history[0]; + history[0] = incomingByte; + + // End byte + if (incomingByte == 0x9d) + { + if (value && value != lastPublished) + { + publish_state(value); + lastPublished = value; + } + } + } + } +}; \ No newline at end of file diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml new file mode 100644 index 0000000..41c2bd3 --- /dev/null +++ b/packages/office-desk.yaml @@ -0,0 +1,280 @@ +substitutions: + device_name: Flexispot EK5 + name: office-desk-flexispot-ek5 + min_height: "73.5" # cm + max_height: "123" # cm + +esphome: + name: ${name} + friendly_name: ${device_name} + comment: Used to control your ${device_name} standing desk via Home Assistant. + includes: + - desk_height_sensor.h + + # Wake Desk by sending the "M" command + # This will pull the current height after boot + on_boot: + priority: -10 + then: + - button.press: button_m + +esp32: + board: esp32dev + framework: + type: arduino + +# Enable logging +logger: + +# Enable Home Assistant API +api: + encryption: + key: !secret encryption_key + +ota: + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + + # Enable fallback hotspot (captive portal) in case wifi connection fails + ap: + ssid: ${device_name} Fallback Hotspot + password: "MnANX95MWad1" + +captive_portal: + +uart: + id: desk_uart + baud_rate: 9600 + tx_pin: GPIO17 # TXD 2 + rx_pin: GPIO16 # RXD 2 + +sensor: + - platform: wifi_signal + name: "WiFi Signal" + update_interval: 60s + + - platform: uptime + name: Uptime + + - platform: custom + lambda: |- + auto desk_height_sensor = new DeskHeightSensor(id(desk_uart)); + App.register_component(desk_height_sensor); + return {desk_height_sensor}; + sensors: + id: "desk_height" + name: Desk Height + unit_of_measurement: cm + accuracy_decimals: 1 + icon: "mdi:counter" + state_class: "measurement" + on_value_range: + - below: ${min_height} + then: + - switch.turn_off: switch_down + - above: ${max_height} + then: + - switch.turn_off: switch_up + on_value: + then: + - cover.template.publish: + id: desk_cover + position: !lambda |- + // The sensor outputs values from min_height (cm) to max_height (cm) + // We need to translate this to 0 - 1 scale. + float position = (float(x) - float(${min_height})) / (float(${max_height}) - float(${min_height})); + return position; + +switch: + - platform: gpio + name: "Virtual Screen" # PIN20 + pin: + number: GPIO23 + mode: OUTPUT + restore_mode: ALWAYS_ON + entity_category: "config" + internal: true + + - platform: uart + name: "Up" + id: switch_up + icon: mdi:arrow-up-bold + data: [0x9b, 0x06, 0x02, 0x01, 0x00, 0xfc, 0xa0, 0x9d] + uart_id: desk_uart + send_every: 108ms + + - platform: uart + name: "Down" + id: switch_down + icon: mdi:arrow-down-bold + data: [0x9b, 0x06, 0x02, 0x02, 0x00, 0x0c, 0xa0, 0x9d] + uart_id: desk_uart + send_every: 108ms + + - platform: uart + name: "Alarm off" + id: switch_alarm + icon: mdi:alarm + data: [0x9b, 0x06, 0x02, 0x40, 0x00, 0xAC, 0x90, 0x9d] + uart_id: desk_uart + send_every: 108ms + on_turn_on: + - delay: 3000ms + - switch.turn_off: switch_alarm + entity_category: "config" + + - platform: uart + name: "Child Lock" + id: switch_child_lock + icon: mdi:account-lock + data: [0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d] + uart_id: desk_uart + send_every: 108ms + on_turn_on: + - delay: 5000ms + - switch.turn_off: switch_child_lock + entity_category: "config" + +button: + - platform: template + name: "Preset 1" + icon: mdi:numeric-1-box + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x04, 0x00, 0xac, 0xa3, 0x9d] + + - platform: template + name: "Preset 2" + icon: mdi:numeric-2-box + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x08, 0x00, 0xac, 0xa6, 0x9d] + + - platform: template + name: "Sit" # Preset 3 on some control panels + icon: mdi:chair-rolling + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x00, 0x01, 0xac, 0x60, 0x9d] + + - platform: template + name: "Stand" # Preset 4 on some control panels + icon: mdi:human-handsup + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x10, 0x00, 0xac, 0xac, 0x9d] + + - platform: template + name: "Memory" + id: button_m + icon: mdi:alpha-m-box + entity_category: "config" + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d] + + - platform: template + name: "Wake Screen" + id: button_wake_screen + icon: mdi:gesture-tap-button + entity_category: "config" + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x00, 0x00, 0x6c, 0xa1, 0x9d] + + - platform: template + name: "Alarm" + id: button_alarm + icon: mdi:alarm + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x40, 0x00, 0xAC, 0x90, 0x9d] + + - platform: restart + name: "Restart" + entity_category: "config" + +cover: + - platform: template + id: "desk_cover" + icon: mdi:desk # or mdi:human-male-height-variant + name: "Desk" + device_class: blind # makes it easier to integrate with Google/Alexa + has_position: true + position_action: + - logger.log: + format: "Requesting position change to: %f " + args: [ 'pos' ] + - if: + condition: + - lambda: !lambda |- + return pos > id(desk_cover).position; + then: + - logger.log: "Position should move up" + - cover.open: desk_cover + - wait_until: + lambda: |- + return id(desk_cover).position >= pos; + - cover.stop: desk_cover + else: + - logger.log: "Position should move down" + - cover.close: desk_cover + - wait_until: + lambda: |- + return id(desk_cover).position <= pos; + - cover.stop: desk_cover + stop_action: + - switch.turn_off: switch_up + - switch.turn_off: switch_down + open_action: + - switch.turn_off: switch_down + - switch.turn_on: switch_up + close_action: + - switch.turn_off: switch_up + - switch.turn_on: switch_down + optimistic: false + +number: + - platform: template + name: "Desk Height" + id: set_desk_height + min_value: ${min_height} + max_value: ${max_height} + icon: "mdi:counter" + unit_of_measurement: "cm" + device_class: "distance" + step: 0.1 + lambda: !lambda |- + return id(desk_height).state; + update_interval: 0ms # update in every loop() iteration + set_action: + - logger.log: + format: "Requesting position change to: %x " + args: [ 'x' ] + - if: + condition: + - lambda: !lambda |- + return x > id(desk_height).state; + then: + - logger.log: "Position should move up" + - cover.open: desk_cover + - wait_until: + lambda: |- + return id(desk_height).state >= x; + - cover.stop: desk_cover + else: + - logger.log: "Position should move down" + - cover.close: desk_cover + - wait_until: + lambda: |- + return id(desk_height).state <= x; + - cover.stop: desk_cover \ No newline at end of file From 2747935da29713a01ff5705137e8037b92ca9555 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Nov 2023 17:20:02 +0100 Subject: [PATCH 03/33] Dont spam the log with updates --- packages/office-desk.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml index 41c2bd3..501fe07 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk.yaml @@ -86,6 +86,7 @@ sensor: // We need to translate this to 0 - 1 scale. float position = (float(x) - float(${min_height})) / (float(${max_height}) - float(${min_height})); return position; + - component.update: set_desk_height switch: - platform: gpio @@ -255,7 +256,6 @@ number: step: 0.1 lambda: !lambda |- return id(desk_height).state; - update_interval: 0ms # update in every loop() iteration set_action: - logger.log: format: "Requesting position change to: %x " @@ -277,4 +277,4 @@ number: - wait_until: lambda: |- return id(desk_height).state <= x; - - cover.stop: desk_cover \ No newline at end of file + - cover.stop: desk_cover From 6a1da5dc22688cd4eb8ca8e90e5c4b123c4354d9 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Nov 2023 17:30:53 +0100 Subject: [PATCH 04/33] Simplify desk YAML --- packages/office-desk.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml index 501fe07..15d417e 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk.yaml @@ -105,6 +105,7 @@ switch: data: [0x9b, 0x06, 0x02, 0x01, 0x00, 0xfc, 0xa0, 0x9d] uart_id: desk_uart send_every: 108ms + internal: true - platform: uart name: "Down" @@ -113,6 +114,7 @@ switch: data: [0x9b, 0x06, 0x02, 0x02, 0x00, 0x0c, 0xa0, 0x9d] uart_id: desk_uart send_every: 108ms + internal: true - platform: uart name: "Alarm off" @@ -257,9 +259,6 @@ number: lambda: !lambda |- return id(desk_height).state; set_action: - - logger.log: - format: "Requesting position change to: %x " - args: [ 'x' ] - if: condition: - lambda: !lambda |- From 400116ca8997bb01389c32fec97806306657c3dc Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Nov 2023 18:14:42 +0100 Subject: [PATCH 05/33] Update with components --- README.md | 26 ++++++++++------ .../desk_height_sensor.h | 0 packages/office-desk.yaml | 31 ++++++++++--------- 3 files changed, 32 insertions(+), 25 deletions(-) rename {packages => components/loctekmotion_desk_height_sensor}/desk_height_sensor.h (100%) diff --git a/README.md b/README.md index 050acbb..cc2c672 100644 --- a/README.md +++ b/README.md @@ -95,16 +95,16 @@ Note that RX and TX is defined like this on receiver (control panel) side. So RX - **Tested with control box**: CB38M2A-1 - **Source**: [nv1t/standing-desk-interceptor](https://github.com/nv1t/standing-desk-interceptor) -| RJ45 pin | Name | Original Cable Color | Ethernet cable color (T568B) | -| -------- | --------- | --------------------- | ---------------------------- | -| 8 | +5V (VDD) | Yellow | Brown | -| 7 | GND | Blue | White-Brown | -| 6 | TX | Black | Green | -| 5 | RX | Green | White-Blue | -| 4 | PIN 20 | Red | Blue | -| 3 | (unknown) | Purple | White-Green | -| 2 | SWIM | White | Orange | -| 1 | RES | Brown | White-Orange | +| RJ45 pin | Name | +| -------- | --------- | +| 8 | +5V (VDD) | +| 7 | GND | +| 6 | TX | +| 5 | RX | +| 4 | PIN 20 | +| 3 | (unknown) | +| 2 | SWIM | +| 1 | RES | Note that RX and TX is defined like this on receiver (control panel) side. So RX can be used to receive data, TX to send data. @@ -122,6 +122,12 @@ Make sure you set the baud rate to 9600. For most LoctekMotion desks, the contro source: [alselectro](https://alselectro.wordpress.com/2015/03/03/8051-tutorials-3-interfacing-7-segment-display/) + +### Known issues +- Number entity may overshoot. For more accurate positioning, use the provided presets. +- Some + + ### Execute a command The control box only accepts commands when the 'screen is active'. To activate the screen, `PIN 20` needs to be set to HIGH for about 1 second. The screen gets disabled automatically again after some amount of time receiving no commands. diff --git a/packages/desk_height_sensor.h b/components/loctekmotion_desk_height_sensor/desk_height_sensor.h similarity index 100% rename from packages/desk_height_sensor.h rename to components/loctekmotion_desk_height_sensor/desk_height_sensor.h diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml index 15d417e..68613dd 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk.yaml @@ -3,13 +3,17 @@ substitutions: name: office-desk-flexispot-ek5 min_height: "73.5" # cm max_height: "123" # cm + ssid: !secret wifi_ssid + wifi_password: !secret wifi_password + ap_fallback_password: "MnANX95MWad1" + tx_pin: GPIO17 + rx_pin: GPIO16 + encryption_key: !secret encryption_key esphome: name: ${name} friendly_name: ${device_name} comment: Used to control your ${device_name} standing desk via Home Assistant. - includes: - - desk_height_sensor.h # Wake Desk by sending the "M" command # This will pull the current height after boot @@ -18,6 +22,10 @@ esphome: then: - button.press: button_m +external_components: + source: github://iMicknl/LoctekMotion_IoT@v2 + components: [ loctekmotion_desk_height_sensor ] + esp32: board: esp32dev framework: @@ -29,26 +37,26 @@ logger: # Enable Home Assistant API api: encryption: - key: !secret encryption_key + key: ${encryption_key} ota: wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password + ssid: ${ssid) + password: ${wifi_password} # Enable fallback hotspot (captive portal) in case wifi connection fails ap: ssid: ${device_name} Fallback Hotspot - password: "MnANX95MWad1" + password: ${ap_fallback_password} captive_portal: uart: id: desk_uart baud_rate: 9600 - tx_pin: GPIO17 # TXD 2 - rx_pin: GPIO16 # RXD 2 + tx_pin: ${tx_pin} # TXD 2 + rx_pin: ${rx_pin} # RXD 2 sensor: - platform: wifi_signal @@ -214,22 +222,17 @@ cover: device_class: blind # makes it easier to integrate with Google/Alexa has_position: true position_action: - - logger.log: - format: "Requesting position change to: %f " - args: [ 'pos' ] - if: condition: - lambda: !lambda |- return pos > id(desk_cover).position; then: - - logger.log: "Position should move up" - cover.open: desk_cover - wait_until: lambda: |- return id(desk_cover).position >= pos; - cover.stop: desk_cover else: - - logger.log: "Position should move down" - cover.close: desk_cover - wait_until: lambda: |- @@ -264,14 +267,12 @@ number: - lambda: !lambda |- return x > id(desk_height).state; then: - - logger.log: "Position should move up" - cover.open: desk_cover - wait_until: lambda: |- return id(desk_height).state >= x; - cover.stop: desk_cover else: - - logger.log: "Position should move down" - cover.close: desk_cover - wait_until: lambda: |- From cd899c439b776c2ce2a3408f12347110223ccae8 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Nov 2023 18:27:04 +0100 Subject: [PATCH 06/33] v2.1 try --- components/loctekmotion_desk_height_sensor/__init__.py | 0 components/loctekmotion_desk_height_sensor/sensor.py | 0 packages/office-desk.yaml | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 components/loctekmotion_desk_height_sensor/__init__.py create mode 100644 components/loctekmotion_desk_height_sensor/sensor.py diff --git a/components/loctekmotion_desk_height_sensor/__init__.py b/components/loctekmotion_desk_height_sensor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/loctekmotion_desk_height_sensor/sensor.py b/components/loctekmotion_desk_height_sensor/sensor.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml index 68613dd..936a19e 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk.yaml @@ -23,7 +23,7 @@ esphome: - button.press: button_m external_components: - source: github://iMicknl/LoctekMotion_IoT@v2 + source: github://iMicknl/LoctekMotion_IoT@v2.1 components: [ loctekmotion_desk_height_sensor ] esp32: From 1e2891f8c5e8b6608bda0831466901325faffef7 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Wed, 22 May 2024 23:04:27 +0100 Subject: [PATCH 07/33] Tidy config file --- packages/office-desk.yaml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml index 936a19e..ff14261 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk.yaml @@ -6,10 +6,11 @@ substitutions: ssid: !secret wifi_ssid wifi_password: !secret wifi_password ap_fallback_password: "MnANX95MWad1" - tx_pin: GPIO17 - rx_pin: GPIO16 + tx_pin: GPIO17 # TXD 2 + rx_pin: GPIO16 # RXD 2 + screen_pin: GPIO23 encryption_key: !secret encryption_key - + esphome: name: ${name} friendly_name: ${device_name} @@ -53,10 +54,10 @@ wifi: captive_portal: uart: - id: desk_uart - baud_rate: 9600 - tx_pin: ${tx_pin} # TXD 2 - rx_pin: ${rx_pin} # RXD 2 + - id: desk_uart + baud_rate: 9600 + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} sensor: - platform: wifi_signal @@ -65,7 +66,7 @@ sensor: - platform: uptime name: Uptime - + - platform: custom lambda: |- auto desk_height_sensor = new DeskHeightSensor(id(desk_uart)); @@ -100,7 +101,7 @@ switch: - platform: gpio name: "Virtual Screen" # PIN20 pin: - number: GPIO23 + number: ${screen_pin} mode: OUTPUT restore_mode: ALWAYS_ON entity_category: "config" From 248fbb82cc5f0bcb792fafd3c3af0ce245737f38 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Wed, 22 May 2024 23:10:20 +0100 Subject: [PATCH 08/33] Improve README --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cc2c672..162b2a9 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ This repository will help you to connect your desk to the internet via the seria > Use the information in this repository at your own risk and with caution. Tinkering with electronics always has risks. -| Name | Description | -| ------------------------------------- | -------------------------------------------------------------------------- | -| [Arduino](packages/arduino) | Custom code to control your desk via an ESP32/ESP8266 module via MQTT. | -| [ESPHome](packages/esphome) | Control your desk via an ESP32/ESP8266 module connected to Home Assistant. | -| [Raspberry Pi](packages/raspberry-pi) | Custom code to control your desk via a Raspberry Pi via Python. | +| Name | Description | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| [ESPHome](packages/office-desk.yaml) | Control your desk via an ESP32 module connected to Home Assistant. Can be adapted to ESP8266 or other ESP32 variant. | + +The V1 packages, including the Arduino and Raspberry Pi ones, can be found in the `archive` directory. For more packaged solutions, see [similar projects](#similar-projects--research). Pull requests are welcome. From 9931ca69e116da98923244dfe7eb748149220151 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Wed, 22 May 2024 23:05:13 +0100 Subject: [PATCH 09/33] First try at height sensor component --- .../__init__.py | 0 .../desk_height_sensor.cpp | 176 ++++++++++++++++++ .../desk_height_sensor.h | 30 +++ components/loctekmotion_desk_height/sensor.py | 41 ++++ .../loctekmotion_desk_height_sensor/sensor.py | 0 .../desk_height_sensor.h | 0 packages/office-desk.yaml | 55 +++--- 7 files changed, 270 insertions(+), 32 deletions(-) rename components/{loctekmotion_desk_height_sensor => loctekmotion_desk_height}/__init__.py (100%) create mode 100644 components/loctekmotion_desk_height/desk_height_sensor.cpp create mode 100644 components/loctekmotion_desk_height/desk_height_sensor.h create mode 100644 components/loctekmotion_desk_height/sensor.py delete mode 100644 components/loctekmotion_desk_height_sensor/sensor.py rename {components/loctekmotion_desk_height_sensor => packages}/desk_height_sensor.h (100%) diff --git a/components/loctekmotion_desk_height_sensor/__init__.py b/components/loctekmotion_desk_height/__init__.py similarity index 100% rename from components/loctekmotion_desk_height_sensor/__init__.py rename to components/loctekmotion_desk_height/__init__.py diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp new file mode 100644 index 0000000..f0684e3 --- /dev/null +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -0,0 +1,176 @@ +#include "desk_height_sensor.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include + +namespace esphome { +namespace loctekmotion_desk_height { + +static const char *const TAG = "loctekmotion_desk_height.sensor"; + +// ========== UTILITY METHODS ========== +int hex_to_int(byte s) +{ + std::bitset<8> b(s); + + if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) + { + return 0; + } + if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) + { + return 1; + } + if (b[0] && b[1] && !b[2] && b[3] && b[4] && !b[5] && b[6]) + { + return 2; + } + if (b[0] && b[1] && b[2] && b[3] && !b[4] && !b[5] && b[6]) + { + return 3; + } + if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && b[5] && b[6]) + { + return 4; + } + if (b[0] && !b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) + { + return 5; + } + if (b[0] && !b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) + { + return 6; + } + if (b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) + { + return 7; + } + if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) + { + return 8; + } + if (b[0] && b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) + { + return 9; + } + if (!b[0] && !b[1] && !b[2] && !b[3] && !b[4] && !b[5] && b[6]) + { + return 10; + } + return 0; +} + +bool is_decimal(byte b) +{ + return (b & 0x80) == 0x80; +} + +// ========== INTERNAL METHODS ========== +void DeskHeightSensor::loop() +{ + while (this->available() > 0) + { + byte incomingByte = this->read(); + // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); + + // First byte, start of a packet + if (incomingByte == 0x9b) + { + // Reset message length + this->msg_len = 0; + this->valid = false; + } + + // Second byte defines the message length + if (this->history[0] == 0x9b) + { + this->msg_len = (int)incomingByte; + } + + // Third byte is message type + if (this->history[1] == 0x9b) + { + this->msg_type = incomingByte; + } + + // Fourth byte is first height digit, if msg type 0x12 & msg len 7 + if (this->history[2] == 0x9b) + { + + if (this->msg_type == 0x12 && (this->msg_len == 7 || this->msg_len == 10)) + { + // Empty height + if (incomingByte == 0) + { + // ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } + else if (hex_to_int(incomingByte) == 0) + { + // ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } + else + { + this->valid = true; + // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); + } + } + } + + // Fifth byte is second height digit + if (this->history[3] == 0x9b) + { + if (this->valid == true) + { + // ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); + } + } + + // Sixth byte is third height digit + if (this->history[4] == 0x9b) + { + if (this->valid == true) + { + int height1 = hex_to_int(this->history[1]) * 100; + int height2 = hex_to_int(this->history[0]) * 10; + int height3 = hex_to_int(incomingByte); + if (height2 == 100) // check if 'number' is a hyphen, return value 10 multiplied by 10 + { + } + else + { + float finalHeight = height1 + height2 + height3; + if (is_decimal(this->history[0])) + { + finalHeight = finalHeight / 10; + } + this->value = finalHeight; + // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); + } + } + } + + // Save byte buffer to history arrary + this->history[4] = this->history[3]; + this->history[3] = this->history[2]; + this->history[2] = this->history[1]; + this->history[1] = this->history[0]; + this->history[0] = incomingByte; + + // End byte + if (incomingByte == 0x9d) + { + if (this->value && this->value != this->lastPublished) + { + this->publish_state(this->value); + this->lastPublished = this->value; + } + } + } +} + +void DeskHeightSensor::dump_config() { LOG_SENSOR("", "LoctekMotion Desk Height Sensor", this); } + +} // namespace loctekmotion_desk_height +} // namespace esphome diff --git a/components/loctekmotion_desk_height/desk_height_sensor.h b/components/loctekmotion_desk_height/desk_height_sensor.h new file mode 100644 index 0000000..6da4a83 --- /dev/null +++ b/components/loctekmotion_desk_height/desk_height_sensor.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + + +namespace esphome { +namespace loctekmotion_desk_height { + +class DeskHeightSensor : public sensor::Sensor, public Component, public uart::UARTDevice { +public: + float get_setup_priority() const override { return esphome::setup_priority::DATA; } + + // ========== INTERNAL METHODS ========== + void loop() override; + void dump_config() override; + +protected: + float value = 0; + float lastPublished = -1; + unsigned long history[5]; + + int msg_len = 0; + unsigned long msg_type; + bool valid = false; +}; + +} // namespace loctekmotion_desk_height +} // namespace esphome diff --git a/components/loctekmotion_desk_height/sensor.py b/components/loctekmotion_desk_height/sensor.py new file mode 100644 index 0000000..f7695f0 --- /dev/null +++ b/components/loctekmotion_desk_height/sensor.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor, uart +from esphome.const import ( + STATE_CLASS_MEASUREMENT, + UNIT_CENTIMETER, + ICON_ARROW_EXPAND_VERTICAL, + DEVICE_CLASS_DISTANCE, +) + +CODEOWNERS = ["@iMicknl"] +DEPENDENCIES = ["uart"] + +loctekmotion_ns = cg.esphome_ns.namespace("loctekmotion_desk_height") +DeskHeightSensor = loctekmotion_ns.class_( + "DeskHeightSensor", sensor.Sensor, cg.Component, uart.UARTDevice +) + +CONFIG_SCHEMA = sensor.sensor_schema( + DeskHeightSensor, + unit_of_measurement=UNIT_CENTIMETER, + icon=ICON_ARROW_EXPAND_VERTICAL, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_DISTANCE, +).extend(uart.UART_DEVICE_SCHEMA) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "loctekmotion_desk_height", + baud_rate=9600, + require_tx=False, + require_rx=True, + data_bits=8, + parity=None, + stop_bits=1, +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/components/loctekmotion_desk_height_sensor/sensor.py b/components/loctekmotion_desk_height_sensor/sensor.py deleted file mode 100644 index e69de29..0000000 diff --git a/components/loctekmotion_desk_height_sensor/desk_height_sensor.h b/packages/desk_height_sensor.h similarity index 100% rename from components/loctekmotion_desk_height_sensor/desk_height_sensor.h rename to packages/desk_height_sensor.h diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml index ff14261..4589f3a 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk.yaml @@ -15,7 +15,7 @@ esphome: name: ${name} friendly_name: ${device_name} comment: Used to control your ${device_name} standing desk via Home Assistant. - + # Wake Desk by sending the "M" command # This will pull the current height after boot on_boot: @@ -24,8 +24,8 @@ esphome: - button.press: button_m external_components: - source: github://iMicknl/LoctekMotion_IoT@v2.1 - components: [ loctekmotion_desk_height_sensor ] + source: github://iMicknl/LoctekMotion_IoT@v2.2 + components: [ loctekmotion_desk_height ] esp32: board: esp32dev @@ -67,35 +67,26 @@ sensor: - platform: uptime name: Uptime - - platform: custom - lambda: |- - auto desk_height_sensor = new DeskHeightSensor(id(desk_uart)); - App.register_component(desk_height_sensor); - return {desk_height_sensor}; - sensors: - id: "desk_height" - name: Desk Height - unit_of_measurement: cm - accuracy_decimals: 1 - icon: "mdi:counter" - state_class: "measurement" - on_value_range: - - below: ${min_height} - then: - - switch.turn_off: switch_down - - above: ${max_height} - then: - - switch.turn_off: switch_up - on_value: - then: - - cover.template.publish: - id: desk_cover - position: !lambda |- - // The sensor outputs values from min_height (cm) to max_height (cm) - // We need to translate this to 0 - 1 scale. - float position = (float(x) - float(${min_height})) / (float(${max_height}) - float(${min_height})); - return position; - - component.update: set_desk_height + - platform: loctekmotion_desk_height + id: "desk_height" + name: Desk Height + on_value_range: + - below: ${min_height} + then: + - switch.turn_off: switch_down + - above: ${max_height} + then: + - switch.turn_off: switch_up + on_value: + then: + - cover.template.publish: + id: desk_cover + position: !lambda |- + // The sensor outputs values from min_height (cm) to max_height (cm) + // We need to translate this to 0 - 1 scale. + float position = (float(x) - float(${min_height})) / (float(${max_height}) - float(${min_height})); + return position; + - component.update: set_desk_height switch: - platform: gpio From 4e96fd5d2ef107db1875c0565c9655787d00dc53 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Thu, 23 May 2024 01:19:15 +0100 Subject: [PATCH 10/33] Remove extra file --- packages/desk_height_sensor.h | 183 ---------------------------------- 1 file changed, 183 deletions(-) delete mode 100644 packages/desk_height_sensor.h diff --git a/packages/desk_height_sensor.h b/packages/desk_height_sensor.h deleted file mode 100644 index 73f02de..0000000 --- a/packages/desk_height_sensor.h +++ /dev/null @@ -1,183 +0,0 @@ -#include "esphome.h" -#include - -class DeskHeightSensor : public Component, public UARTDevice, public Sensor -{ -public: - DeskHeightSensor(UARTComponent *parent) : UARTDevice(parent) {} - - float value = 0; - float lastPublished = -1; - unsigned long history[5]; - - int msg_len = 0; - unsigned long msg_type; - bool valid = false; - - float get_setup_priority() const override { return esphome::setup_priority::DATA; } - - int hex_to_int(byte s) - { - std::bitset<8> b(s); - - if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) - { - return 0; - } - if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) - { - return 1; - } - if (b[0] && b[1] && !b[2] && b[3] && b[4] && !b[5] && b[6]) - { - return 2; - } - if (b[0] && b[1] && b[2] && b[3] && !b[4] && !b[5] && b[6]) - { - return 3; - } - if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && b[5] && b[6]) - { - return 4; - } - if (b[0] && !b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) - { - return 5; - } - if (b[0] && !b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) - { - return 6; - } - if (b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) - { - return 7; - } - if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) - { - return 8; - } - if (b[0] && b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) - { - return 9; - } - if (!b[0] && !b[1] && !b[2] && !b[3] && !b[4] && !b[5] && b[6]) - { - return 10; - } - return 0; - } - - bool is_decimal(byte b) - { - return (b & 0x80) == 0x80; - } - - void setup() override - { - // nothing to do here - } - - void loop() override - { - while (available() > 0) - { - byte incomingByte = read(); - // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); - - // First byte, start of a packet - if (incomingByte == 0x9b) - { - // Reset message length - msg_len = 0; - valid = false; - } - - // Second byte defines the message length - if (history[0] == 0x9b) - { - msg_len = (int)incomingByte; - } - - // Third byte is message type - if (history[1] == 0x9b) - { - msg_type = incomingByte; - } - - // Fourth byte is first height digit, if msg type 0x12 & msg len 7 - if (history[2] == 0x9b) - { - - if (msg_type == 0x12 && (msg_len == 7 || msg_len == 10)) - { - // Empty height - if (incomingByte == 0) - { - // ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); - // deskSerial.write(command_wakeup, sizeof(command_wakeup)); - } - else if (hex_to_int(incomingByte) == 0) - { - // ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); - // deskSerial.write(command_wakeup, sizeof(command_wakeup)); - } - else - { - valid = true; - // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); - } - } - } - - // Fifth byte is second height digit - if (history[3] == 0x9b) - { - if (valid == true) - { - // ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); - } - } - - // Sixth byte is third height digit - if (history[4] == 0x9b) - { - if (valid == true) - { - int height1 = hex_to_int(history[1]) * 100; - int height2 = hex_to_int(history[0]) * 10; - int height3 = hex_to_int(incomingByte); - if (height2 == 100) // check if 'number' is a hyphen, return value 10 multiplied by 10 - { - } - else - { - float finalHeight = height1 + height2 + height3; - if (is_decimal(history[0])) - { - finalHeight = finalHeight / 10; - } - value = finalHeight; - // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); - } - } - } - - // Save byte buffer to history arrary - history[4] = history[3]; - history[3] = history[2]; - history[2] = history[1]; - history[1] = history[0]; - history[0] = incomingByte; - - // End byte - if (incomingByte == 0x9d) - { - if (value && value != lastPublished) - { - publish_state(value); - lastPublished = value; - } - } - } - } -}; \ No newline at end of file From c369e399b51303cf63ba6c8e5b26cdcd581102d1 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 7 Jun 2024 18:25:46 +0200 Subject: [PATCH 11/33] Apply suggestions from code review --- README.md | 1 - packages/office-desk.yaml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 162b2a9..8d52908 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,6 @@ source: [alselectro](https://alselectro.wordpress.com/2015/03/03/8051-tutorials- ### Known issues - Number entity may overshoot. For more accurate positioning, use the provided presets. -- Some ### Execute a command diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml index 4589f3a..e3b7e73 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk.yaml @@ -24,7 +24,7 @@ esphome: - button.press: button_m external_components: - source: github://iMicknl/LoctekMotion_IoT@v2.2 + source: github://iMicknl/LoctekMotion_IoT@v2 components: [ loctekmotion_desk_height ] esp32: From f66701cc684abf072fc9c6d2aff8c2d2b26e8127 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 11:32:50 +0000 Subject: [PATCH 12/33] Add devcontainer and initial CI/CD + pre-commit configuration --- .devcontainer/devcontainer.json | 30 ++++++++++++++++++++ .github/CODEOWNERS | 1 + .github/dependabot.yml | 13 +++++++++ .github/workflows/build-and-publish.yaml | 3 ++ .github/workflows/lint.yaml | 28 ++++++++++++++++++ .github/workflows/test.yaml | 36 ++++++++++++++++++++++++ .gitignore | 5 +++- .pre-commit-config.yaml | 21 ++++++++++++++ requirements.txt | 2 ++ 9 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build-and-publish.yaml create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 requirements.txt diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8bb6d17 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip3 install --user -r requirements.txt && pre-commit install", + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.cpptools-extension-pack", + "GitHub.copilot-chat" + ] + } + }, + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9033f3e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @iMicknl diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a760482 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: daily + time: "08:00" + + - package-ecosystem: pip + directory: "/" + schedule: + interval: "daily" + time: "08:00" diff --git a/.github/workflows/build-and-publish.yaml b/.github/workflows/build-and-publish.yaml new file mode 100644 index 0000000..6ee8977 --- /dev/null +++ b/.github/workflows/build-and-publish.yaml @@ -0,0 +1,3 @@ +# uses: esphome/build-action@v1 +# with: +# yaml_file: my_configuration.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..c819694 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,28 @@ +name: Linting tools via pre-commit + +on: + pull_request: + push: + branches: [main, test-me-*] + +jobs: + main: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: "Install Python dependencies" + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - uses: pre-commit/action@v3.0.1 + + - uses: pre-commit-ci/lite-action@v1.0.2 + if: always() diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..1eb62e6 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,36 @@ +name: Compile and validate YAML configuration + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + tests: + name: "Validate ${{ matrix.version }} YAML configuration" + runs-on: "ubuntu-latest" + + strategy: + fail-fast: false + matrix: + version: ["esp32", "esp8266", "esp32-passthrough", "esp8266-passthrough"] + + steps: + - uses: "actions/checkout@v4" + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: "Install Python dependencies" + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: "Compile ${{ matrix.version }}" + run: esphome compile tests/office-desk-${{ matrix.version }}.yaml diff --git a/.gitignore b/.gitignore index 1e59088..8475e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ venv -.esphome \ No newline at end of file +.esphome + +*.pyc +__pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8c732e9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-yaml + args: ['--unsafe'] # required for !secret + - id: trailing-whitespace + - id: end-of-file-fixer + + # - repo: https://github.com/pocc/pre-commit-hooks + # rev: v1.3.5 + # hooks: + # - id: clang-format + # - id: clang-tidy + # - id: cppcheck + # - id: cpplint + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v18.1.6 + hooks: + - id: clang-format + types_or: [c++, c, cuda] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..11038dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +esphome==2024.5.5 +pre-commit From 5bad540a5eaf577a65aed749e1aa8dc73f0cde32 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 11:34:51 +0000 Subject: [PATCH 13/33] Update GH Actions triggers --- .github/workflows/lint.yaml | 2 +- .github/workflows/test.yaml | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c819694..e3088c4 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -3,7 +3,7 @@ name: Linting tools via pre-commit on: pull_request: push: - branches: [main, test-me-*] + branches: [main] jobs: main: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1eb62e6..4a20d62 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,12 +1,9 @@ name: Compile and validate YAML configuration on: - push: - branches: - - main pull_request: - branches: - - main + push: + branches: [main] jobs: tests: From 6ed833f2ce8ed257545963185e283415792d0fd9 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 11:47:24 +0000 Subject: [PATCH 14/33] Fix CI/CD --- .github/workflows/lint.yaml | 2 +- .github/workflows/test.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index e3088c4..8fb5d75 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -6,7 +6,7 @@ on: branches: [main] jobs: - main: + pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4a20d62..94958a6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,7 +7,7 @@ on: jobs: tests: - name: "Validate ${{ matrix.version }} YAML configuration" + name: "${{ matrix.version }}" runs-on: "ubuntu-latest" strategy: From 8aea3bc55d645eef9a8d78c9f409169dfc9900a5 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 11:49:05 +0000 Subject: [PATCH 15/33] Add tests --- .github/workflows/lint.yaml | 7 +- packages/office-desk.yaml | 23 +- tests/office-desk-esp32-passthrough.yaml | 0 tests/office-desk-esp32.yaml | 274 +++++++++++++++++++++ tests/office-desk-esp8266-passthrough.yaml | 0 tests/office-desk-esp8266.yaml | 0 6 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 tests/office-desk-esp32-passthrough.yaml create mode 100644 tests/office-desk-esp32.yaml create mode 100644 tests/office-desk-esp8266-passthrough.yaml create mode 100644 tests/office-desk-esp8266.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 8fb5d75..e9519cb 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,4 +1,4 @@ -name: Linting tools via pre-commit +name: CI on: pull_request: @@ -7,6 +7,7 @@ on: jobs: pre-commit: + name: "Linting and formatting" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -24,5 +25,5 @@ jobs: - uses: pre-commit/action@v3.0.1 - - uses: pre-commit-ci/lite-action@v1.0.2 - if: always() + # - uses: pre-commit-ci/lite-action@v1.0.2 + # if: always() diff --git a/packages/office-desk.yaml b/packages/office-desk.yaml index e3b7e73..2f7a772 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk.yaml @@ -11,6 +11,15 @@ substitutions: screen_pin: GPIO23 encryption_key: !secret encryption_key +external_components: + source: github://iMicknl/LoctekMotion_IoT@v2 + components: [ loctekmotion_desk_height ] + +esp32: + board: esp32dev + framework: + type: arduino + esphome: name: ${name} friendly_name: ${device_name} @@ -23,14 +32,6 @@ esphome: then: - button.press: button_m -external_components: - source: github://iMicknl/LoctekMotion_IoT@v2 - components: [ loctekmotion_desk_height ] - -esp32: - board: esp32dev - framework: - type: arduino # Enable logging logger: @@ -147,7 +148,7 @@ button: on_press: - uart.write: id: desk_uart - data: [0x9b, 0x06, 0x02, 0x04, 0x00, 0xac, 0xa3, 0x9d] + data: [0x9b, 0x06, 0x02, 0x04, 0x00, 0xac, 0xa3, 0x9d] - platform: template name: "Preset 2" @@ -208,7 +209,7 @@ button: cover: - platform: template - id: "desk_cover" + id: "desk_cover" icon: mdi:desk # or mdi:human-male-height-variant name: "Desk" device_class: blind # makes it easier to integrate with Google/Alexa @@ -235,7 +236,7 @@ cover: - switch.turn_off: switch_down open_action: - switch.turn_off: switch_down - - switch.turn_on: switch_up + - switch.turn_on: switch_up close_action: - switch.turn_off: switch_up - switch.turn_on: switch_down diff --git a/tests/office-desk-esp32-passthrough.yaml b/tests/office-desk-esp32-passthrough.yaml new file mode 100644 index 0000000..e69de29 diff --git a/tests/office-desk-esp32.yaml b/tests/office-desk-esp32.yaml new file mode 100644 index 0000000..f05989e --- /dev/null +++ b/tests/office-desk-esp32.yaml @@ -0,0 +1,274 @@ +substitutions: + device_name: Flexispot EK5 + name: office-desk-flexispot-ek5 + min_height: "73.5" # cm + max_height: "123" # cm + ssid: !secret wifi_ssid + wifi_password: "your_wifi_password" + ap_fallback_password: "MnANX95MWad1" + tx_pin: GPIO17 # TXD 2 + rx_pin: GPIO16 # RXD 2 + screen_pin: GPIO23 + encryption_key: "your_encryption_key" + +external_components: + - source: + type: local + path: ../components + +esp32: + board: esp32dev + framework: + type: arduino + +esphome: + name: ${name} + friendly_name: ${device_name} + comment: Used to control your ${device_name} standing desk via Home Assistant. + + # Wake Desk by sending the "M" command + # This will pull the current height after boot + on_boot: + priority: -10 + then: + - button.press: button_m + + +# Enable logging +logger: + +# Enable Home Assistant API +api: + encryption: + key: ${encryption_key} + +ota: + +wifi: + ssid: ${ssid) + password: ${wifi_password} + + # Enable fallback hotspot (captive portal) in case wifi connection fails + ap: + ssid: ${device_name} Fallback Hotspot + password: ${ap_fallback_password} + +captive_portal: + +uart: + - id: desk_uart + baud_rate: 9600 + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + +sensor: + - platform: wifi_signal + name: "WiFi Signal" + update_interval: 60s + + - platform: uptime + name: Uptime + + - platform: loctekmotion_desk_height + id: "desk_height" + name: Desk Height + on_value_range: + - below: ${min_height} + then: + - switch.turn_off: switch_down + - above: ${max_height} + then: + - switch.turn_off: switch_up + on_value: + then: + - cover.template.publish: + id: desk_cover + position: !lambda |- + // The sensor outputs values from min_height (cm) to max_height (cm) + // We need to translate this to 0 - 1 scale. + float position = (float(x) - float(${min_height})) / (float(${max_height}) - float(${min_height})); + return position; + - component.update: set_desk_height + +switch: + - platform: gpio + name: "Virtual Screen" # PIN20 + pin: + number: ${screen_pin} + mode: OUTPUT + restore_mode: ALWAYS_ON + entity_category: "config" + internal: true + + - platform: uart + name: "Up" + id: switch_up + icon: mdi:arrow-up-bold + data: [0x9b, 0x06, 0x02, 0x01, 0x00, 0xfc, 0xa0, 0x9d] + uart_id: desk_uart + send_every: 108ms + internal: true + + - platform: uart + name: "Down" + id: switch_down + icon: mdi:arrow-down-bold + data: [0x9b, 0x06, 0x02, 0x02, 0x00, 0x0c, 0xa0, 0x9d] + uart_id: desk_uart + send_every: 108ms + internal: true + + - platform: uart + name: "Alarm off" + id: switch_alarm + icon: mdi:alarm + data: [0x9b, 0x06, 0x02, 0x40, 0x00, 0xAC, 0x90, 0x9d] + uart_id: desk_uart + send_every: 108ms + on_turn_on: + - delay: 3000ms + - switch.turn_off: switch_alarm + entity_category: "config" + + - platform: uart + name: "Child Lock" + id: switch_child_lock + icon: mdi:account-lock + data: [0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d] + uart_id: desk_uart + send_every: 108ms + on_turn_on: + - delay: 5000ms + - switch.turn_off: switch_child_lock + entity_category: "config" + +button: + - platform: template + name: "Preset 1" + icon: mdi:numeric-1-box + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x04, 0x00, 0xac, 0xa3, 0x9d] + + - platform: template + name: "Preset 2" + icon: mdi:numeric-2-box + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x08, 0x00, 0xac, 0xa6, 0x9d] + + - platform: template + name: "Sit" # Preset 3 on some control panels + icon: mdi:chair-rolling + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x00, 0x01, 0xac, 0x60, 0x9d] + + - platform: template + name: "Stand" # Preset 4 on some control panels + icon: mdi:human-handsup + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x10, 0x00, 0xac, 0xac, 0x9d] + + - platform: template + name: "Memory" + id: button_m + icon: mdi:alpha-m-box + entity_category: "config" + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d] + + - platform: template + name: "Wake Screen" + id: button_wake_screen + icon: mdi:gesture-tap-button + entity_category: "config" + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x00, 0x00, 0x6c, 0xa1, 0x9d] + + - platform: template + name: "Alarm" + id: button_alarm + icon: mdi:alarm + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x40, 0x00, 0xAC, 0x90, 0x9d] + + - platform: restart + name: "Restart" + entity_category: "config" + +cover: + - platform: template + id: "desk_cover" + icon: mdi:desk # or mdi:human-male-height-variant + name: "Desk" + device_class: blind # makes it easier to integrate with Google/Alexa + has_position: true + position_action: + - if: + condition: + - lambda: !lambda |- + return pos > id(desk_cover).position; + then: + - cover.open: desk_cover + - wait_until: + lambda: |- + return id(desk_cover).position >= pos; + - cover.stop: desk_cover + else: + - cover.close: desk_cover + - wait_until: + lambda: |- + return id(desk_cover).position <= pos; + - cover.stop: desk_cover + stop_action: + - switch.turn_off: switch_up + - switch.turn_off: switch_down + open_action: + - switch.turn_off: switch_down + - switch.turn_on: switch_up + close_action: + - switch.turn_off: switch_up + - switch.turn_on: switch_down + optimistic: false + +number: + - platform: template + name: "Desk Height" + id: set_desk_height + min_value: ${min_height} + max_value: ${max_height} + icon: "mdi:counter" + unit_of_measurement: "cm" + device_class: "distance" + step: 0.1 + lambda: !lambda |- + return id(desk_height).state; + set_action: + - if: + condition: + - lambda: !lambda |- + return x > id(desk_height).state; + then: + - cover.open: desk_cover + - wait_until: + lambda: |- + return id(desk_height).state >= x; + - cover.stop: desk_cover + else: + - cover.close: desk_cover + - wait_until: + lambda: |- + return id(desk_height).state <= x; + - cover.stop: desk_cover diff --git a/tests/office-desk-esp8266-passthrough.yaml b/tests/office-desk-esp8266-passthrough.yaml new file mode 100644 index 0000000..e69de29 diff --git a/tests/office-desk-esp8266.yaml b/tests/office-desk-esp8266.yaml new file mode 100644 index 0000000..e69de29 From 35e9cf9e033393896ce44ecc1b14826b89f09615 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 11:54:06 +0000 Subject: [PATCH 16/33] Update test files --- tests/office-desk-esp32-passthrough.yaml | 30 +++ tests/office-desk-esp32.yaml | 4 +- tests/office-desk-esp8266-passthrough.yaml | 30 +++ tests/office-desk-esp8266.yaml | 274 +++++++++++++++++++++ 4 files changed, 336 insertions(+), 2 deletions(-) diff --git a/tests/office-desk-esp32-passthrough.yaml b/tests/office-desk-esp32-passthrough.yaml index e69de29..02ac8fb 100644 --- a/tests/office-desk-esp32-passthrough.yaml +++ b/tests/office-desk-esp32-passthrough.yaml @@ -0,0 +1,30 @@ +substitutions: + device_name: Flexispot EK5 + name: office-desk-flexispot-ek5 + min_height: "73.5" # cm + max_height: "123" # cm + ssid: "your_wifi_ssid" + wifi_password: "your_wifi_password" + ap_fallback_password: "your_ap_fallback_password" + tx_pin: GPIO17 # TXD 2 + rx_pin: GPIO16 # RXD 2 + screen_pin: GPIO23 + encryption_key: "your_encryption_key" + +external_components: + - source: + type: local + path: ../components + +esp32: + board: esp32dev + framework: + type: arduino + +esphome: + name: ${name} + friendly_name: ${device_name} + comment: Used to control your ${device_name} standing desk via Home Assistant. + + +# TODO diff --git a/tests/office-desk-esp32.yaml b/tests/office-desk-esp32.yaml index f05989e..311486b 100644 --- a/tests/office-desk-esp32.yaml +++ b/tests/office-desk-esp32.yaml @@ -3,9 +3,9 @@ substitutions: name: office-desk-flexispot-ek5 min_height: "73.5" # cm max_height: "123" # cm - ssid: !secret wifi_ssid + ssid: "your_wifi_ssid" wifi_password: "your_wifi_password" - ap_fallback_password: "MnANX95MWad1" + ap_fallback_password: "your_ap_fallback_password" tx_pin: GPIO17 # TXD 2 rx_pin: GPIO16 # RXD 2 screen_pin: GPIO23 diff --git a/tests/office-desk-esp8266-passthrough.yaml b/tests/office-desk-esp8266-passthrough.yaml index e69de29..dc562a7 100644 --- a/tests/office-desk-esp8266-passthrough.yaml +++ b/tests/office-desk-esp8266-passthrough.yaml @@ -0,0 +1,30 @@ +substitutions: + device_name: Flexispot EK5 + name: office-desk-flexispot-ek5 + min_height: "73.5" # cm + max_height: "123" # cm + ssid: "your_wifi_ssid" + wifi_password: "your_wifi_password" + ap_fallback_password: "your_ap_fallback_password" + tx_pin: D5 # =GPIO14 + rx_pin: D6 # =GPIO12 + screen_pin: D2 # =GPIO4 + encryption_key: "your_encryption_key" + +external_components: + - source: + type: local + path: ../components + +esp8266: + board: d1_mini + framework: + version: recommended + +esphome: + name: ${name} + friendly_name: ${device_name} + comment: Used to control your ${device_name} standing desk via Home Assistant. + + +# TODO diff --git a/tests/office-desk-esp8266.yaml b/tests/office-desk-esp8266.yaml index e69de29..bc9aebc 100644 --- a/tests/office-desk-esp8266.yaml +++ b/tests/office-desk-esp8266.yaml @@ -0,0 +1,274 @@ +substitutions: + device_name: Flexispot EK5 + name: office-desk-flexispot-ek5 + min_height: "73.5" # cm + max_height: "123" # cm + ssid: "your_wifi_ssid" + wifi_password: "your_wifi_password" + ap_fallback_password: "your_ap_fallback_password" + tx_pin: D5 # =GPIO14 + rx_pin: D6 # =GPIO12 + screen_pin: D2 # =GPIO4 + encryption_key: "your_encryption_key" + +external_components: + - source: + type: local + path: ../components + +esp8266: + board: d1_mini + framework: + version: recommended + +esphome: + name: ${name} + friendly_name: ${device_name} + comment: Used to control your ${device_name} standing desk via Home Assistant. + + # Wake Desk by sending the "M" command + # This will pull the current height after boot + on_boot: + priority: -10 + then: + - button.press: button_m + + +# Enable logging +logger: + +# Enable Home Assistant API +api: + encryption: + key: ${encryption_key} + +ota: + +wifi: + ssid: ${ssid) + password: ${wifi_password} + + # Enable fallback hotspot (captive portal) in case wifi connection fails + ap: + ssid: ${device_name} Fallback Hotspot + password: ${ap_fallback_password} + +captive_portal: + +uart: + - id: desk_uart + baud_rate: 9600 + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + +sensor: + - platform: wifi_signal + name: "WiFi Signal" + update_interval: 60s + + - platform: uptime + name: Uptime + + - platform: loctekmotion_desk_height + id: "desk_height" + name: Desk Height + on_value_range: + - below: ${min_height} + then: + - switch.turn_off: switch_down + - above: ${max_height} + then: + - switch.turn_off: switch_up + on_value: + then: + - cover.template.publish: + id: desk_cover + position: !lambda |- + // The sensor outputs values from min_height (cm) to max_height (cm) + // We need to translate this to 0 - 1 scale. + float position = (float(x) - float(${min_height})) / (float(${max_height}) - float(${min_height})); + return position; + - component.update: set_desk_height + +switch: + - platform: gpio + name: "Virtual Screen" # PIN20 + pin: + number: ${screen_pin} + mode: OUTPUT + restore_mode: ALWAYS_ON + entity_category: "config" + internal: true + + - platform: uart + name: "Up" + id: switch_up + icon: mdi:arrow-up-bold + data: [0x9b, 0x06, 0x02, 0x01, 0x00, 0xfc, 0xa0, 0x9d] + uart_id: desk_uart + send_every: 108ms + internal: true + + - platform: uart + name: "Down" + id: switch_down + icon: mdi:arrow-down-bold + data: [0x9b, 0x06, 0x02, 0x02, 0x00, 0x0c, 0xa0, 0x9d] + uart_id: desk_uart + send_every: 108ms + internal: true + + - platform: uart + name: "Alarm off" + id: switch_alarm + icon: mdi:alarm + data: [0x9b, 0x06, 0x02, 0x40, 0x00, 0xAC, 0x90, 0x9d] + uart_id: desk_uart + send_every: 108ms + on_turn_on: + - delay: 3000ms + - switch.turn_off: switch_alarm + entity_category: "config" + + - platform: uart + name: "Child Lock" + id: switch_child_lock + icon: mdi:account-lock + data: [0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d] + uart_id: desk_uart + send_every: 108ms + on_turn_on: + - delay: 5000ms + - switch.turn_off: switch_child_lock + entity_category: "config" + +button: + - platform: template + name: "Preset 1" + icon: mdi:numeric-1-box + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x04, 0x00, 0xac, 0xa3, 0x9d] + + - platform: template + name: "Preset 2" + icon: mdi:numeric-2-box + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x08, 0x00, 0xac, 0xa6, 0x9d] + + - platform: template + name: "Sit" # Preset 3 on some control panels + icon: mdi:chair-rolling + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x00, 0x01, 0xac, 0x60, 0x9d] + + - platform: template + name: "Stand" # Preset 4 on some control panels + icon: mdi:human-handsup + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x10, 0x00, 0xac, 0xac, 0x9d] + + - platform: template + name: "Memory" + id: button_m + icon: mdi:alpha-m-box + entity_category: "config" + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d] + + - platform: template + name: "Wake Screen" + id: button_wake_screen + icon: mdi:gesture-tap-button + entity_category: "config" + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x00, 0x00, 0x6c, 0xa1, 0x9d] + + - platform: template + name: "Alarm" + id: button_alarm + icon: mdi:alarm + on_press: + - uart.write: + id: desk_uart + data: [0x9b, 0x06, 0x02, 0x40, 0x00, 0xAC, 0x90, 0x9d] + + - platform: restart + name: "Restart" + entity_category: "config" + +cover: + - platform: template + id: "desk_cover" + icon: mdi:desk # or mdi:human-male-height-variant + name: "Desk" + device_class: blind # makes it easier to integrate with Google/Alexa + has_position: true + position_action: + - if: + condition: + - lambda: !lambda |- + return pos > id(desk_cover).position; + then: + - cover.open: desk_cover + - wait_until: + lambda: |- + return id(desk_cover).position >= pos; + - cover.stop: desk_cover + else: + - cover.close: desk_cover + - wait_until: + lambda: |- + return id(desk_cover).position <= pos; + - cover.stop: desk_cover + stop_action: + - switch.turn_off: switch_up + - switch.turn_off: switch_down + open_action: + - switch.turn_off: switch_down + - switch.turn_on: switch_up + close_action: + - switch.turn_off: switch_up + - switch.turn_on: switch_down + optimistic: false + +number: + - platform: template + name: "Desk Height" + id: set_desk_height + min_value: ${min_height} + max_value: ${max_height} + icon: "mdi:counter" + unit_of_measurement: "cm" + device_class: "distance" + step: 0.1 + lambda: !lambda |- + return id(desk_height).state; + set_action: + - if: + condition: + - lambda: !lambda |- + return x > id(desk_height).state; + then: + - cover.open: desk_cover + - wait_until: + lambda: |- + return id(desk_height).state >= x; + - cover.stop: desk_cover + else: + - cover.close: desk_cover + - wait_until: + lambda: |- + return id(desk_height).state <= x; + - cover.stop: desk_cover From 4e212b054186f821987dac1019a7eefa3fa7fe4d Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 11:56:07 +0000 Subject: [PATCH 17/33] Apply pre-commit on all files --- README.md | 24 +- archive/arduino/flexispot_e8.ino | 40 ++- archive/arduino/flexispot_ek5.ino | 74 ++--- archive/arduino/height_demo | 10 +- .../esphome/Flexispot_E7_MQTT_control.yaml | 14 +- archive/esphome/desk_height_sensor.h | 140 ++++------ archive/esphome/desk_keypad.h | 99 ++++--- archive/esphome/flexispot_e5b.yaml | 8 +- archive/esphome/flexispot_e5b_esp32.yaml | 14 +- archive/esphome/flexispot_e6.yaml | 8 +- archive/esphome/flexispot_ek5.yaml | 6 +- archive/esphome/secrets.yaml | 2 +- .../desk_height_sensor.cpp | 262 ++++++++---------- .../desk_height_sensor.h | 33 ++- 14 files changed, 343 insertions(+), 391 deletions(-) diff --git a/README.md b/README.md index 8d52908..77911d7 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,14 @@ In order to connect the control box to a Raspberry Pi and ESP32/ESP8266 chip I u -If your control panel is missing, feel free to [create an issue](https://github.com/iMicknl/LoctekMotion_IoT/issues/new) to discuss the possibilities or create a PR to add your research to this overview. +If your control panel is missing, feel free to [create an issue](https://github.com/iMicknl/LoctekMotion_IoT/issues/new) to discuss the possibilities or create a PR to add your research to this overview. #### HS13B-1 - **Desk model**: Flexispot E7 - **Tested with control box**: CB38M2J(IB)-1 - **Source**: Printed on the PCB of the control box. - + | RJ45 pin | Name | Original Cable Color | Ethernet cable color (T568B) | | -------- | ---------- | -------------------- | ---------------------------- | | 8 | RESET | Brown | White-Orange | @@ -75,7 +75,7 @@ Note that RX and TX is defined like this on receiver (control panel) side. So RX - **Desk model**: Flexispot EK5 - **Tested with control box**: CB38M2B(IB)-1 - **Source**: Printed on the PCB of the control box. - + | RJ45 pin | Name | Original Cable Color | Ethernet cable color (T568B) | | -------- | ---------- | -------------------- | ---------------------------- | | 8 | RESET SWIM | Brown | White-Orange | @@ -94,15 +94,15 @@ Note that RX and TX is defined like this on receiver (control panel) side. So RX - **Desk model**: Flexispot E5B - **Tested with control box**: CB38M2A-1 - **Source**: [nv1t/standing-desk-interceptor](https://github.com/nv1t/standing-desk-interceptor) - -| RJ45 pin | Name | -| -------- | --------- | -| 8 | +5V (VDD) | -| 7 | GND | + +| RJ45 pin | Name | +| -------- | --------- | +| 8 | +5V (VDD) | +| 7 | GND | | 6 | TX | -| 5 | RX | +| 5 | RX | | 4 | PIN 20 | -| 3 | (unknown) | +| 3 | (unknown) | | 2 | SWIM | | 1 | RES | @@ -112,7 +112,7 @@ Other control panels / control boxes could be supported in the same way, but you ### Retrieve current height -Based upon the great work of [minifloat](https://www.mikrocontroller.net/topic/493524), it became clear that the control panel utilises a [7-segment display](https://en.wikipedia.org/wiki/Seven-segment_display). Fortunately, this is very common in such devices and thus there is a lot of [documentation](https://lastminuteengineers.com/seven-segment-arduino-tutorial/) on this topic. +Based upon the great work of [minifloat](https://www.mikrocontroller.net/topic/493524), it became clear that the control panel utilises a [7-segment display](https://en.wikipedia.org/wiki/Seven-segment_display). Fortunately, this is very common in such devices and thus there is a lot of [documentation](https://lastminuteengineers.com/seven-segment-arduino-tutorial/) on this topic. The control box sends the height as 4-bit hexadecimal, which is decoded in the control panel to drive the 7-segment display. The second number on the display also supports an optional decimal point ("8 segment"). @@ -151,7 +151,7 @@ All bytes combined will become the command to send to the control box. See the [ While working on this project, I found out that I am not the only one with this idea. There are a few repositories on GitHub with great research which helped me kickstart this project. ❤️ - [grssmnn / ha-flexispot-standing-desk](https://github.com/grssmnn/ha-flexispot-standing-desk) - Home Assistant integration via MQTT (micropython) -- [Dude88 / loctek_IOT_box](https://github.com/Dude88/loctek_IOT_box) - Arduino code to control via Alexa and MQTT +- [Dude88 / loctek_IOT_box](https://github.com/Dude88/loctek_IOT_box) - Arduino code to control via Alexa and MQTT - [nv1t / standing-desk-interceptor](https://github.com/nv1t/standing-desk-interceptor) - Research on intercepting commands from Flexispot desks - [VinzSpring / LoctekReverseengineering](https://github.com/VinzSpring/LoctekReverseengineering#assumptions) - Research and Python samples diff --git a/archive/arduino/flexispot_e8.ino b/archive/arduino/flexispot_e8.ino index 5c37aeb..38c1f9a 100644 --- a/archive/arduino/flexispot_e8.ino +++ b/archive/arduino/flexispot_e8.ino @@ -5,26 +5,29 @@ #include #define displayPin20 4 // D2 GPIO4 -#define rxPin 12 // D5 GPIO12 -#define txPin 14 // D6 GPIO14 +#define rxPin 12 // D5 GPIO12 +#define txPin 14 // D6 GPIO14 SoftwareSerial sSerial(rxPin, txPin); // RX, TX byte history[2]; // Supported Commands -const byte wakeup[] = { 0x9b, 0x06, 0x02, 0x00, 0x00, 0x6c, 0xa1, 0x9d }; -const byte command_up[] = { 0x9b, 0x06, 0x02, 0x01, 0x00, 0xfc, 0xa0, 0x9d }; -const byte command_down[] = { 0x9b, 0x06, 0x02, 0x02, 0x00, 0x0c, 0xa0, 0x9d }; -const byte command_m[] = {0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d }; -const byte command_preset_1[] = { 0x9b, 0x06, 0x02, 0x04, 0x00, 0xac, 0xa3, 0x9d }; -const byte command_preset_2[] = { 0x9b, 0x06, 0x02, 0x08, 0x00, 0xac, 0xa6, 0x9d }; -const byte command_preset_3[] = { 0x9b, 0x06, 0x02, 0x10, 0x00, 0xac, 0xac, 0x9d }; -const byte command_preset_4[] = { 0x9b, 0x06, 0x02, 0x00, 0x01, 0xac, 0x60, 0x9d }; - +const byte wakeup[] = {0x9b, 0x06, 0x02, 0x00, 0x00, 0x6c, 0xa1, 0x9d}; +const byte command_up[] = {0x9b, 0x06, 0x02, 0x01, 0x00, 0xfc, 0xa0, 0x9d}; +const byte command_down[] = {0x9b, 0x06, 0x02, 0x02, 0x00, 0x0c, 0xa0, 0x9d}; +const byte command_m[] = {0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d}; +const byte command_preset_1[] = {0x9b, 0x06, 0x02, 0x04, + 0x00, 0xac, 0xa3, 0x9d}; +const byte command_preset_2[] = {0x9b, 0x06, 0x02, 0x08, + 0x00, 0xac, 0xa6, 0x9d}; +const byte command_preset_3[] = {0x9b, 0x06, 0x02, 0x10, + 0x00, 0xac, 0xac, 0x9d}; +const byte command_preset_4[] = {0x9b, 0x06, 0x02, 0x00, + 0x01, 0xac, 0x60, 0x9d}; void setup() { - Serial.begin(115200); // Debug serial - sSerial.begin(9600); // Flexispot E8 + Serial.begin(115200); // Debug serial + sSerial.begin(9600); // Flexispot E8 pinMode(displayPin20, OUTPUT); digitalWrite(displayPin20, LOW); @@ -33,7 +36,6 @@ void setup() { demo(); } - void demo() { // Calls sit-preset and waits 20 seconds @@ -49,7 +51,6 @@ void demo() { wake(); } - void turnon() { // Turn desk in operating mode by setting controller pin20 to HIGH Serial.println("sending turn on command"); @@ -58,7 +59,6 @@ void turnon() { digitalWrite(displayPin20, LOW); } - void wake() { turnon(); @@ -70,7 +70,6 @@ void wake() { sSerial.enableTx(false); } - void sit() { turnon(); @@ -82,7 +81,6 @@ void sit() { sSerial.enableTx(false); } - void stand() { turnon(); @@ -94,10 +92,8 @@ void stand() { sSerial.enableTx(false); } - void loop() { - while (sSerial.available()) - { + while (sSerial.available()) { unsigned long in = sSerial.read(); // Start of packet @@ -121,4 +117,4 @@ void loop() { Serial.print(in, HEX); Serial.print(" "); } -} \ No newline at end of file +} diff --git a/archive/arduino/flexispot_ek5.ino b/archive/arduino/flexispot_ek5.ino index 6fca971..a59d9d5 100644 --- a/archive/arduino/flexispot_ek5.ino +++ b/archive/arduino/flexispot_ek5.ino @@ -1,27 +1,30 @@ #include #define displayPin20 4 // D2 GPIO4 -#define rxPin 12 // D5 GPIO12 -#define txPin 14 // D6 GPIO14 +#define rxPin 12 // D5 GPIO12 +#define txPin 14 // D6 GPIO14 SoftwareSerial sSerial(rxPin, txPin); // RX, TX byte history[2]; // Supported Commands -unsigned long wakeup[] = { 0x9b, 0x06, 0x02, 0x00, 0x00, 0x6c, 0xa1, 0x9d }; -unsigned long command_up[] = { 0x9b, 0x06, 0x02, 0x01, 0x00, 0xfc, 0xa0, 0x9d }; -unsigned long command_down[] = { 0x9b, 0x06, 0x02, 0x02, 0x00, 0x0c, 0xa0, 0x9d }; -unsigned long command_m[] = {0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d }; -unsigned long command_preset_1[] = { 0x9b, 0x06, 0x02, 0x04, 0x00, 0xac, 0xa3, 0x9d }; -unsigned long command_preset_2[] = { 0x9b, 0x06, 0x02, 0x08, 0x00, 0xac, 0xa6, 0x9d }; -unsigned long command_preset_3[] = { 0x9b, 0x06, 0x02, 0x10, 0x00, 0xac, 0xac, 0x9d }; -unsigned long command_preset_4[] = { 0x9b, 0x06, 0x02, 0x00, 0x01, 0xac, 0x60, 0x9d }; - +unsigned long wakeup[] = {0x9b, 0x06, 0x02, 0x00, 0x00, 0x6c, 0xa1, 0x9d}; +unsigned long command_up[] = {0x9b, 0x06, 0x02, 0x01, 0x00, 0xfc, 0xa0, 0x9d}; +unsigned long command_down[] = {0x9b, 0x06, 0x02, 0x02, 0x00, 0x0c, 0xa0, 0x9d}; +unsigned long command_m[] = {0x9b, 0x06, 0x02, 0x20, 0x00, 0xac, 0xb8, 0x9d}; +unsigned long command_preset_1[] = {0x9b, 0x06, 0x02, 0x04, + 0x00, 0xac, 0xa3, 0x9d}; +unsigned long command_preset_2[] = {0x9b, 0x06, 0x02, 0x08, + 0x00, 0xac, 0xa6, 0x9d}; +unsigned long command_preset_3[] = {0x9b, 0x06, 0x02, 0x10, + 0x00, 0xac, 0xac, 0x9d}; +unsigned long command_preset_4[] = {0x9b, 0x06, 0x02, 0x00, + 0x01, 0xac, 0x60, 0x9d}; void setup() { - Serial.begin(115200); // Debug serial - sSerial.begin(9600); // Flexispot EK5 - + Serial.begin(115200); // Debug serial + sSerial.begin(9600); // Flexispot EK5 + // Turn desk in operating mode by setting controller pin20 to HIGH // This will allow us to send commands and to receive the current height Serial.println("Turn Operation Mode on"); @@ -37,29 +40,28 @@ void setup() { } void loop() { - while (sSerial.available()) - { - unsigned long in = sSerial.read(); - - // Start of packet - if(in == 0x9b) { - Serial.println(); - } - - // Second byte defines the message length - if(history[0] == 0x9b) { - int msg_len = (int)in; - Serial.print("(LENGTH:"); - Serial.print(in); - Serial.print(")"); - } + while (sSerial.available()) { + unsigned long in = sSerial.read(); - // Get package length (second byte) - history[1] = history[0]; - history[0] = in; + // Start of packet + if (in == 0x9b) { + Serial.println(); + } - // Print hex for debug - Serial.print(in, HEX); - Serial.print(" "); + // Second byte defines the message length + if (history[0] == 0x9b) { + int msg_len = (int)in; + Serial.print("(LENGTH:"); + Serial.print(in); + Serial.print(")"); } + + // Get package length (second byte) + history[1] = history[0]; + history[0] = in; + + // Print hex for debug + Serial.print(in, HEX); + Serial.print(" "); + } } diff --git a/archive/arduino/height_demo b/archive/arduino/height_demo index 3c96a6b..e37ec70 100644 --- a/archive/arduino/height_demo +++ b/archive/arduino/height_demo @@ -6,7 +6,7 @@ #define displayPin20 4 // D2 GPIO4 #define rxPin 12 // D5 GPIO12 -#define txPin 14 // D6 GPIO14 +#define txPin 14 // D6 GPIO14 SoftwareSerial sSerial(rxPin, txPin); // RX, TX int packet_pos = 0; //position in packet @@ -107,11 +107,11 @@ void DecodeHeight() if(height != h) { height = h; - + Serial.print("Height: "); Serial.print(height); Serial.println(); - } + } } int DecodeNumber(byte in) { @@ -188,7 +188,7 @@ void loop() { while (sSerial.available()) { - unsigned long in = sSerial.read(); + unsigned long in = sSerial.read(); // Start of packet if(in == 0x9B) //start of packet @@ -211,6 +211,6 @@ void loop() else if(packet_pos == 2) //position 2 = type of packet packet_type = (int)in; else if(packet_type == 18 && packet_pos >= 3 && packet_pos <= 5) //if packet type is height (0x12) and position is 3-5 - message_height[packet_pos - 3] = in; + message_height[packet_pos - 3] = in; } } diff --git a/archive/esphome/Flexispot_E7_MQTT_control.yaml b/archive/esphome/Flexispot_E7_MQTT_control.yaml index de98be3..eb80ce4 100644 --- a/archive/esphome/Flexispot_E7_MQTT_control.yaml +++ b/archive/esphome/Flexispot_E7_MQTT_control.yaml @@ -23,7 +23,7 @@ wifi: ssid: "${device_name} Fallback Hotspot" password: !secret ap_fallback_password -# Enable MQTT communication. ReadOnly Variables are published immediatly +# Enable MQTT communication. ReadOnly Variables are published immediatly mqtt: topic_prefix: Flexispot @@ -32,17 +32,17 @@ mqtt: port: 1883 username: !secret mqtt_username password: !secret mqtt_password - + # OnMessage is used for MQTT control - # Only a short - + # Only a short + on_message: - topic: Flexispot/Sit/command then: - switch.turn_on: switch_sit - topic: Flexispot/Stand/command then: - - switch.turn_on: switch_stand + - switch.turn_on: switch_stand - topic: Flexispot/Preset1/command then: - switch.turn_on: switch_preset1 @@ -183,14 +183,14 @@ cover: - logger.log: "Executing up command" - switch.turn_on: switch_up - delay: 10ms - + # Move desk down close_action: - while: condition: sensor.in_range: id: desk_height - above: ${min_height} + above: ${min_height} then: - logger.log: "Executing down command" - switch.turn_on: switch_down diff --git a/archive/esphome/desk_height_sensor.h b/archive/esphome/desk_height_sensor.h index 7efcdd2..a80bbfc 100644 --- a/archive/esphome/desk_height_sensor.h +++ b/archive/esphome/desk_height_sensor.h @@ -1,8 +1,7 @@ #include "esphome.h" #include -class DeskHeightSensor : public Component, public UARTDevice, public Sensor -{ +class DeskHeightSensor : public Component, public UARTDevice, public Sensor { public: DeskHeightSensor(UARTComponent *parent) : UARTDevice(parent) {} @@ -14,157 +13,124 @@ class DeskHeightSensor : public Component, public UARTDevice, public Sensor unsigned long msg_type; bool valid = false; - float get_setup_priority() const override { return esphome::setup_priority::DATA; } + float get_setup_priority() const override { + return esphome::setup_priority::DATA; + } - int hex_to_int(byte s) - { + int hex_to_int(byte s) { std::bitset<8> b(s); - if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) - { + if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) { return 0; } - if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) - { + if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) { return 1; } - if (b[0] && b[1] && !b[2] && b[3] && b[4] && !b[5] && b[6]) - { + if (b[0] && b[1] && !b[2] && b[3] && b[4] && !b[5] && b[6]) { return 2; } - if (b[0] && b[1] && b[2] && b[3] && !b[4] && !b[5] && b[6]) - { + if (b[0] && b[1] && b[2] && b[3] && !b[4] && !b[5] && b[6]) { return 3; } - if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && b[5] && b[6]) - { + if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && b[5] && b[6]) { return 4; } - if (b[0] && !b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) - { + if (b[0] && !b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) { return 5; } - if (b[0] && !b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) - { + if (b[0] && !b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) { return 6; } - if (b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) - { + if (b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) { return 7; } - if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) - { + if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) { return 8; } - if (b[0] && b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) - { + if (b[0] && b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) { return 9; } - if (!b[0] && !b[1] && !b[2] && !b[3] && !b[4] && !b[5] && b[6]) - { + if (!b[0] && !b[1] && !b[2] && !b[3] && !b[4] && !b[5] && b[6]) { return 10; } return 0; } - bool is_decimal(byte b) - { - return (b & 0x80) == 0x80; - } + bool is_decimal(byte b) { return (b & 0x80) == 0x80; } - void setup() override - { + void setup() override { // nothing to do here } - void loop() override - { - while (available() > 0) - { + void loop() override { + while (available() > 0) { byte incomingByte = read(); // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); - + // First byte, start of a packet - if (incomingByte == 0x9b) - { + if (incomingByte == 0x9b) { // Reset message length msg_len = 0; valid = false; } // Second byte defines the message length - if (history[0] == 0x9b) - { + if (history[0] == 0x9b) { msg_len = (int)incomingByte; } // Third byte is message type - if (history[1] == 0x9b) - { + if (history[1] == 0x9b) { msg_type = incomingByte; } // Fourth byte is first height digit, if msg type 0x12 & msg len 7 - if (history[2] == 0x9b) - { + if (history[2] == 0x9b) { - if (msg_type == 0x12 && (msg_len == 7 || msg_len == 10)) - { + if (msg_type == 0x12 && (msg_len == 7 || msg_len == 10)) { // Empty height - if (incomingByte == 0) - { - //ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); - //deskSerial.write(command_wakeup, sizeof(command_wakeup)); - } - else if (hex_to_int(incomingByte) == 0) - { - //ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); - //deskSerial.write(command_wakeup, sizeof(command_wakeup)); - } - else - { + if (incomingByte == 0) { + // ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } else if (hex_to_int(incomingByte) == 0) { + // ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } else { valid = true; - // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); + // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); } } } // Fifth byte is second height digit - if (history[3] == 0x9b) - { - if (valid == true) - { - //ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); + if (history[3] == 0x9b) { + if (valid == true) { + // ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); } } // Sixth byte is third height digit - if (history[4] == 0x9b) - { - if (valid == true) - { + if (history[4] == 0x9b) { + if (valid == true) { int height1 = hex_to_int(history[1]) * 100; int height2 = hex_to_int(history[0]) * 10; int height3 = hex_to_int(incomingByte); - if (height2 == 100) // check if 'number' is a hyphen, return value 10 multiplied by 10 - { - - } - else + if (height2 == 100) // check if 'number' is a hyphen, return value 10 + // multiplied by 10 { + + } else { float finalHeight = height1 + height2 + height3; - if (is_decimal(history[0])) - { + if (is_decimal(history[0])) { finalHeight = finalHeight / 10; } value = finalHeight; - // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); + // ESP_LOGD("DeskHeightSensor", "Current height is: %f", + // finalHeight); } } } - - // Save byte buffer to history arrary history[4] = history[3]; history[3] = history[2]; @@ -172,15 +138,13 @@ class DeskHeightSensor : public Component, public UARTDevice, public Sensor history[1] = history[0]; history[0] = incomingByte; - // End byte - if (incomingByte == 0x9d) - { - if (value && value != lastPublished) - { + // End byte + if (incomingByte == 0x9d) { + if (value && value != lastPublished) { publish_state(value); - lastPublished = value; - } + lastPublished = value; + } } - } + } } }; diff --git a/archive/esphome/desk_keypad.h b/archive/esphome/desk_keypad.h index a9da991..86302b0 100644 --- a/archive/esphome/desk_keypad.h +++ b/archive/esphome/desk_keypad.h @@ -1,14 +1,21 @@ #include "esphome.h" -#include "esphome.h" #include -class DeskKeypad : public Component, public UARTDevice, public Sensor -{ +class DeskKeypad : public Component, public UARTDevice, public Sensor { public: DeskKeypad(UARTComponent *parent) : UARTDevice(parent) {} - enum Command { Up = 1, Down = 2, Preset1 = 3 , Preset2 = 4 , Preset3 = 5, M = 6, Alarm = 7, Empty = 8}; + enum Command { + Up = 1, + Down = 2, + Preset1 = 3, + Preset2 = 4, + Preset3 = 5, + M = 6, + Alarm = 7, + Empty = 8 + }; Command mReturnCommand; Command lastPublished = Command::Empty; unsigned long history[3]; @@ -17,65 +24,75 @@ class DeskKeypad : public Component, public UARTDevice, public Sensor unsigned long msg_type; bool valid = false; - float get_setup_priority() const override { return esphome::setup_priority::DATA; } + float get_setup_priority() const override { + return esphome::setup_priority::DATA; + } - void setup() override - { + void setup() override { // nothing to do here } - void loop() override - { - while (available() > 0) - { + void loop() override { + while (available() > 0) { byte incomingByte = read(); - //ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); - // First byte, start of a packet - if (incomingByte == 0x9b) - { + // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); + // First byte, start of a packet + if (incomingByte == 0x9b) { // Reset message length msg_len = 0; valid = false; } // Second byte defines the message length - if (history[0] == 0x9b) - { + if (history[0] == 0x9b) { msg_len = (int)incomingByte; } // Third byte is message type - if (history[1] == 0x9b) - { + if (history[1] == 0x9b) { msg_type = incomingByte; } // Fourth byte is first height digit, if msg type 0x12 & msg len 7 - if (history[2] == 0x9b) - { - switch(incomingByte) - { - case 0x00: mReturnCommand = Command::Empty; break; - case 0x01: mReturnCommand = Command::Up; break; - case 0x02: mReturnCommand = Command::Down; break; - case 0x04: mReturnCommand = Command::Preset1; break; - case 0x08: mReturnCommand = Command::Preset2; break; - case 0x10: mReturnCommand = Command::Preset3; break; - case 0x20: mReturnCommand = Command::M; break; - case 0x40: mReturnCommand = Command::Alarm; break; + if (history[2] == 0x9b) { + switch (incomingByte) { + case 0x00: + mReturnCommand = Command::Empty; + break; + case 0x01: + mReturnCommand = Command::Up; + break; + case 0x02: + mReturnCommand = Command::Down; + break; + case 0x04: + mReturnCommand = Command::Preset1; + break; + case 0x08: + mReturnCommand = Command::Preset2; + break; + case 0x10: + mReturnCommand = Command::Preset3; + break; + case 0x20: + mReturnCommand = Command::M; + break; + case 0x40: + mReturnCommand = Command::Alarm; + break; } } - if (incomingByte == 0x9d && msg_type == 0x02 && msg_len == 6 && mReturnCommand && mReturnCommand != lastPublished) - { - publish_state(mReturnCommand); - lastPublished = mReturnCommand; - } + if (incomingByte == 0x9d && msg_type == 0x02 && msg_len == 6 && + mReturnCommand && mReturnCommand != lastPublished) { + publish_state(mReturnCommand); + lastPublished = mReturnCommand; + } - // Save byte buffer to history arrary - history[2] = history[1]; - history[1] = history[0]; - history[0] = incomingByte; + // Save byte buffer to history arrary + history[2] = history[1]; + history[1] = history[0]; + history[0] = incomingByte; } } -}; \ No newline at end of file +}; diff --git a/archive/esphome/flexispot_e5b.yaml b/archive/esphome/flexispot_e5b.yaml index 3e1225f..0b2f865 100644 --- a/archive/esphome/flexispot_e5b.yaml +++ b/archive/esphome/flexispot_e5b.yaml @@ -3,7 +3,7 @@ substitutions: name: flexispot_e5b min_height: "73.6" # Min height + 0.1 max_height: "122.9" # Max height - 0.1 - + esphome: name: ${name} comment: ${device_name} @@ -139,16 +139,16 @@ cover: - logger.log: "Executing up command" - switch.turn_on: switch_up - delay: 10ms - + # Move desk down close_action: - while: condition: sensor.in_range: id: desk_height - above: ${min_height} + above: ${min_height} then: - logger.log: "Executing down command" - switch.turn_on: switch_down - delay: 10ms - optimistic: true \ No newline at end of file + optimistic: true diff --git a/archive/esphome/flexispot_e5b_esp32.yaml b/archive/esphome/flexispot_e5b_esp32.yaml index 8a63c9f..bfde352 100644 --- a/archive/esphome/flexispot_e5b_esp32.yaml +++ b/archive/esphome/flexispot_e5b_esp32.yaml @@ -4,7 +4,7 @@ substitutions: min_height: "71.0" # Min height + 0.1 #min_height: "62.1" # Min height + 0.1 (original one) max_height: "125.1" # Max height - 0.1 - + esphome: name: ${name} comment: ${device_name} @@ -64,7 +64,7 @@ script: - delay: 480ms else: - script.execute: screen_timer - + uart: - id: desk_uart baud_rate: 9600 @@ -291,9 +291,9 @@ cover: # current_operation: OPENING - switch.turn_on: switch_up - delay: 108ms - - - + + + # Move desk down close_action: - script.execute: script_start_command @@ -302,7 +302,7 @@ cover: condition: sensor.in_range: id: desk_height - above: ${min_height} + above: ${min_height} then: - logger.log: "Executing down command" # - cover.template.publish: @@ -311,5 +311,5 @@ cover: - switch.turn_on: switch_down - delay: 108ms - + optimistic: true diff --git a/archive/esphome/flexispot_e6.yaml b/archive/esphome/flexispot_e6.yaml index 5a3eb90..9ce0ba2 100644 --- a/archive/esphome/flexispot_e6.yaml +++ b/archive/esphome/flexispot_e6.yaml @@ -3,7 +3,7 @@ substitutions: name: flexispot_e6 min_height: "60.1" # Min height + 0.1 max_height: "122.9" # Max height - 0.1 - + esphome: name: ${name} comment: ${device_name} @@ -139,16 +139,16 @@ cover: - logger.log: "Executing up command" - switch.turn_on: switch_up - delay: 10ms - + # Move desk down close_action: - while: condition: sensor.in_range: id: desk_height - above: ${min_height} + above: ${min_height} then: - logger.log: "Executing down command" - switch.turn_on: switch_down - delay: 10ms - optimistic: true \ No newline at end of file + optimistic: true diff --git a/archive/esphome/flexispot_ek5.yaml b/archive/esphome/flexispot_ek5.yaml index 7727caa..20aa91e 100644 --- a/archive/esphome/flexispot_ek5.yaml +++ b/archive/esphome/flexispot_ek5.yaml @@ -147,16 +147,16 @@ cover: - logger.log: "Executing up command" - switch.turn_on: switch_up - delay: 10ms - + # Move desk down close_action: - while: condition: sensor.in_range: id: desk_height - above: ${min_height} + above: ${min_height} then: - logger.log: "Executing down command" - switch.turn_on: switch_down - delay: 10ms - optimistic: true \ No newline at end of file + optimistic: true diff --git a/archive/esphome/secrets.yaml b/archive/esphome/secrets.yaml index 8d20754..d17dc1a 100644 --- a/archive/esphome/secrets.yaml +++ b/archive/esphome/secrets.yaml @@ -1,4 +1,4 @@ wifi_ssid: "Abraham Linksys" wifi_password: "PASSWORD42" ap_fallback_password: "VGhp4HGdsorM" -ha_api_password: "1234" \ No newline at end of file +ha_api_password: "1234" diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp index f0684e3..1aebc0f 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.cpp +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -2,6 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include namespace esphome { namespace loctekmotion_desk_height { @@ -9,168 +10,137 @@ namespace loctekmotion_desk_height { static const char *const TAG = "loctekmotion_desk_height.sensor"; // ========== UTILITY METHODS ========== -int hex_to_int(byte s) -{ - std::bitset<8> b(s); - if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) - { - return 0; - } - if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) - { - return 1; - } - if (b[0] && b[1] && !b[2] && b[3] && b[4] && !b[5] && b[6]) - { - return 2; - } - if (b[0] && b[1] && b[2] && b[3] && !b[4] && !b[5] && b[6]) - { - return 3; - } - if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && b[5] && b[6]) - { - return 4; - } - if (b[0] && !b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) - { - return 5; - } - if (b[0] && !b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) - { - return 6; - } - if (b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) - { - return 7; - } - if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) - { - return 8; - } - if (b[0] && b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) - { - return 9; - } - if (!b[0] && !b[1] && !b[2] && !b[3] && !b[4] && !b[5] && b[6]) - { - return 10; - } +int hex_to_int(std::uint8_t s) { + std::bitset<8> b(s); + + if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) { return 0; + } + if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) { + return 1; + } + if (b[0] && b[1] && !b[2] && b[3] && b[4] && !b[5] && b[6]) { + return 2; + } + if (b[0] && b[1] && b[2] && b[3] && !b[4] && !b[5] && b[6]) { + return 3; + } + if (not b[0] && b[1] && b[2] && !b[3] && !b[4] && b[5] && b[6]) { + return 4; + } + if (b[0] && !b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) { + return 5; + } + if (b[0] && !b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) { + return 6; + } + if (b[0] && b[1] && b[2] && !b[3] && !b[4] && !b[5] && !b[6]) { + return 7; + } + if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && b[6]) { + return 8; + } + if (b[0] && b[1] && b[2] && b[3] && !b[4] && b[5] && b[6]) { + return 9; + } + if (!b[0] && !b[1] && !b[2] && !b[3] && !b[4] && !b[5] && b[6]) { + return 10; + } + return 0; } -bool is_decimal(byte b) -{ - return (b & 0x80) == 0x80; -} +bool is_decimal(std::uint8_t b) { return (b & 0x80) == 0x80; } // ========== INTERNAL METHODS ========== -void DeskHeightSensor::loop() -{ - while (this->available() > 0) - { - byte incomingByte = this->read(); - // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); - - // First byte, start of a packet - if (incomingByte == 0x9b) - { - // Reset message length - this->msg_len = 0; - this->valid = false; - } - - // Second byte defines the message length - if (this->history[0] == 0x9b) - { - this->msg_len = (int)incomingByte; - } +void DeskHeightSensor::loop() { + while (this->available() > 0) { + std::uint8_t incomingByte = this->read(); + // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); + + // First byte, start of a packet + if (incomingByte == 0x9b) { + // Reset message length + this->msg_len = 0; + this->valid = false; + } - // Third byte is message type - if (this->history[1] == 0x9b) - { - this->msg_type = incomingByte; - } + // Second byte defines the message length + if (this->history[0] == 0x9b) { + this->msg_len = (int)incomingByte; + } - // Fourth byte is first height digit, if msg type 0x12 & msg len 7 - if (this->history[2] == 0x9b) - { + // Third byte is message type + if (this->history[1] == 0x9b) { + this->msg_type = incomingByte; + } - if (this->msg_type == 0x12 && (this->msg_len == 7 || this->msg_len == 10)) - { - // Empty height - if (incomingByte == 0) - { - // ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); - // deskSerial.write(command_wakeup, sizeof(command_wakeup)); - } - else if (hex_to_int(incomingByte) == 0) - { - // ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); - // deskSerial.write(command_wakeup, sizeof(command_wakeup)); - } - else - { - this->valid = true; - // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); - } - } + // Fourth byte is first height digit, if msg type 0x12 & msg len 7 + if (this->history[2] == 0x9b) { + + if (this->msg_type == 0x12 && + (this->msg_len == 7 || this->msg_len == 10)) { + // Empty height + if (incomingByte == 0) { + // ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } else if (hex_to_int(incomingByte) == 0) { + // ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } else { + this->valid = true; + // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); } + } + } - // Fifth byte is second height digit - if (this->history[3] == 0x9b) - { - if (this->valid == true) - { - // ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); - } - } + // Fifth byte is second height digit + if (this->history[3] == 0x9b) { + if (this->valid == true) { + // ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); + } + } - // Sixth byte is third height digit - if (this->history[4] == 0x9b) + // Sixth byte is third height digit + if (this->history[4] == 0x9b) { + if (this->valid == true) { + int height1 = hex_to_int(this->history[1]) * 100; + int height2 = hex_to_int(this->history[0]) * 10; + int height3 = hex_to_int(incomingByte); + if (height2 == 100) // check if 'number' is a hyphen, return value 10 + // multiplied by 10 { - if (this->valid == true) - { - int height1 = hex_to_int(this->history[1]) * 100; - int height2 = hex_to_int(this->history[0]) * 10; - int height3 = hex_to_int(incomingByte); - if (height2 == 100) // check if 'number' is a hyphen, return value 10 multiplied by 10 - { - } - else - { - float finalHeight = height1 + height2 + height3; - if (is_decimal(this->history[0])) - { - finalHeight = finalHeight / 10; - } - this->value = finalHeight; - // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); - } - } + } else { + float finalHeight = height1 + height2 + height3; + if (is_decimal(this->history[0])) { + finalHeight = finalHeight / 10; + } + this->value = finalHeight; + // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); } + } + } - // Save byte buffer to history arrary - this->history[4] = this->history[3]; - this->history[3] = this->history[2]; - this->history[2] = this->history[1]; - this->history[1] = this->history[0]; - this->history[0] = incomingByte; - - // End byte - if (incomingByte == 0x9d) - { - if (this->value && this->value != this->lastPublished) - { - this->publish_state(this->value); - this->lastPublished = this->value; - } - } + // Save byte buffer to history arrary + this->history[4] = this->history[3]; + this->history[3] = this->history[2]; + this->history[2] = this->history[1]; + this->history[1] = this->history[0]; + this->history[0] = incomingByte; + + // End byte + if (incomingByte == 0x9d) { + if (this->value && this->value != this->lastPublished) { + this->publish_state(this->value); + this->lastPublished = this->value; + } } + } } -void DeskHeightSensor::dump_config() { LOG_SENSOR("", "LoctekMotion Desk Height Sensor", this); } +void DeskHeightSensor::dump_config() { + LOG_SENSOR("", "LoctekMotion Desk Height Sensor", this); +} -} // namespace loctekmotion_desk_height -} // namespace esphome +} // namespace loctekmotion_desk_height +} // namespace esphome diff --git a/components/loctekmotion_desk_height/desk_height_sensor.h b/components/loctekmotion_desk_height/desk_height_sensor.h index 6da4a83..1182630 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.h +++ b/components/loctekmotion_desk_height/desk_height_sensor.h @@ -1,30 +1,33 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" - +#include "esphome/core/component.h" namespace esphome { namespace loctekmotion_desk_height { -class DeskHeightSensor : public sensor::Sensor, public Component, public uart::UARTDevice { +class DeskHeightSensor : public sensor::Sensor, + public Component, + public uart::UARTDevice { public: - float get_setup_priority() const override { return esphome::setup_priority::DATA; } + float get_setup_priority() const override { + return esphome::setup_priority::DATA; + } - // ========== INTERNAL METHODS ========== - void loop() override; - void dump_config() override; + // ========== INTERNAL METHODS ========== + void loop() override; + void dump_config() override; protected: - float value = 0; - float lastPublished = -1; - unsigned long history[5]; + float value = 0; + float lastPublished = -1; + unsigned long history[5]; - int msg_len = 0; - unsigned long msg_type; - bool valid = false; + int msg_len = 0; + unsigned long msg_type; + bool valid = false; }; -} // namespace loctekmotion_desk_height -} // namespace esphome +} // namespace loctekmotion_desk_height +} // namespace esphome From 3fff038b6a787cd6be5b22be807d95dd3845a23e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 11:57:37 +0000 Subject: [PATCH 18/33] Change encryption key to base64 --- tests/office-desk-esp32-passthrough.yaml | 2 +- tests/office-desk-esp32.yaml | 2 +- tests/office-desk-esp8266-passthrough.yaml | 2 +- tests/office-desk-esp8266.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/office-desk-esp32-passthrough.yaml b/tests/office-desk-esp32-passthrough.yaml index 02ac8fb..862f70f 100644 --- a/tests/office-desk-esp32-passthrough.yaml +++ b/tests/office-desk-esp32-passthrough.yaml @@ -9,7 +9,7 @@ substitutions: tx_pin: GPIO17 # TXD 2 rx_pin: GPIO16 # RXD 2 screen_pin: GPIO23 - encryption_key: "your_encryption_key" + encryption_key: "eW91cl9lbmNyeXB0aW9uX2tleQ==" external_components: - source: diff --git a/tests/office-desk-esp32.yaml b/tests/office-desk-esp32.yaml index 311486b..3f56d22 100644 --- a/tests/office-desk-esp32.yaml +++ b/tests/office-desk-esp32.yaml @@ -9,7 +9,7 @@ substitutions: tx_pin: GPIO17 # TXD 2 rx_pin: GPIO16 # RXD 2 screen_pin: GPIO23 - encryption_key: "your_encryption_key" + encryption_key: "eW91cl9lbmNyeXB0aW9uX2tleQ==" external_components: - source: diff --git a/tests/office-desk-esp8266-passthrough.yaml b/tests/office-desk-esp8266-passthrough.yaml index dc562a7..3fc6798 100644 --- a/tests/office-desk-esp8266-passthrough.yaml +++ b/tests/office-desk-esp8266-passthrough.yaml @@ -9,7 +9,7 @@ substitutions: tx_pin: D5 # =GPIO14 rx_pin: D6 # =GPIO12 screen_pin: D2 # =GPIO4 - encryption_key: "your_encryption_key" + encryption_key: "eW91cl9lbmNyeXB0aW9uX2tleQ==" external_components: - source: diff --git a/tests/office-desk-esp8266.yaml b/tests/office-desk-esp8266.yaml index bc9aebc..9e6b5af 100644 --- a/tests/office-desk-esp8266.yaml +++ b/tests/office-desk-esp8266.yaml @@ -9,7 +9,7 @@ substitutions: tx_pin: D5 # =GPIO14 rx_pin: D6 # =GPIO12 screen_pin: D2 # =GPIO4 - encryption_key: "your_encryption_key" + encryption_key: "eW91cl9lbmNyeXB0aW9uX2tleQ==" external_components: - source: From f17a71ab1100705b757c9cc9c6e82bd8f4edf58d Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 11:59:29 +0000 Subject: [PATCH 19/33] Update encryption key base64 to 32 bits --- tests/office-desk-esp32-passthrough.yaml | 2 +- tests/office-desk-esp32.yaml | 2 +- tests/office-desk-esp8266-passthrough.yaml | 2 +- tests/office-desk-esp8266.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/office-desk-esp32-passthrough.yaml b/tests/office-desk-esp32-passthrough.yaml index 862f70f..19cff0f 100644 --- a/tests/office-desk-esp32-passthrough.yaml +++ b/tests/office-desk-esp32-passthrough.yaml @@ -9,7 +9,7 @@ substitutions: tx_pin: GPIO17 # TXD 2 rx_pin: GPIO16 # RXD 2 screen_pin: GPIO23 - encryption_key: "eW91cl9lbmNyeXB0aW9uX2tleQ==" + encryption_key: "iOZqtvw31Yy6sasRl5h2DElG2VDlqW2WjJEKObVN8bg=" external_components: - source: diff --git a/tests/office-desk-esp32.yaml b/tests/office-desk-esp32.yaml index 3f56d22..7593a21 100644 --- a/tests/office-desk-esp32.yaml +++ b/tests/office-desk-esp32.yaml @@ -9,7 +9,7 @@ substitutions: tx_pin: GPIO17 # TXD 2 rx_pin: GPIO16 # RXD 2 screen_pin: GPIO23 - encryption_key: "eW91cl9lbmNyeXB0aW9uX2tleQ==" + encryption_key: "iOZqtvw31Yy6sasRl5h2DElG2VDlqW2WjJEKObVN8bg=" external_components: - source: diff --git a/tests/office-desk-esp8266-passthrough.yaml b/tests/office-desk-esp8266-passthrough.yaml index 3fc6798..db87b28 100644 --- a/tests/office-desk-esp8266-passthrough.yaml +++ b/tests/office-desk-esp8266-passthrough.yaml @@ -9,7 +9,7 @@ substitutions: tx_pin: D5 # =GPIO14 rx_pin: D6 # =GPIO12 screen_pin: D2 # =GPIO4 - encryption_key: "eW91cl9lbmNyeXB0aW9uX2tleQ==" + encryption_key: "iOZqtvw31Yy6sasRl5h2DElG2VDlqW2WjJEKObVN8bg=" external_components: - source: diff --git a/tests/office-desk-esp8266.yaml b/tests/office-desk-esp8266.yaml index 9e6b5af..5e3567c 100644 --- a/tests/office-desk-esp8266.yaml +++ b/tests/office-desk-esp8266.yaml @@ -9,7 +9,7 @@ substitutions: tx_pin: D5 # =GPIO14 rx_pin: D6 # =GPIO12 screen_pin: D2 # =GPIO4 - encryption_key: "eW91cl9lbmNyeXB0aW9uX2tleQ==" + encryption_key: "iOZqtvw31Yy6sasRl5h2DElG2VDlqW2WjJEKObVN8bg=" external_components: - source: From e0c3667eaf35fe37c9f0482b71994550182701fe Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 12:15:35 +0000 Subject: [PATCH 20/33] Add validation step --- .github/workflows/test.yaml | 3 +++ packages/{office-desk.yaml => office-desk-esp32.yaml} | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) rename packages/{office-desk.yaml => office-desk-esp32.yaml} (98%) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 94958a6..8d7a3d6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -29,5 +29,8 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements.txt + - name: "Validate ${{ matrix.version }}" + run: esphome config tests/office-desk-${{ matrix.version }}.yaml + - name: "Compile ${{ matrix.version }}" run: esphome compile tests/office-desk-${{ matrix.version }}.yaml diff --git a/packages/office-desk.yaml b/packages/office-desk-esp32.yaml similarity index 98% rename from packages/office-desk.yaml rename to packages/office-desk-esp32.yaml index 2f7a772..4b45841 100644 --- a/packages/office-desk.yaml +++ b/packages/office-desk-esp32.yaml @@ -3,13 +3,13 @@ substitutions: name: office-desk-flexispot-ek5 min_height: "73.5" # cm max_height: "123" # cm - ssid: !secret wifi_ssid - wifi_password: !secret wifi_password + ssid: "your_wifi_ssid" + wifi_password: "your_wifi_password" ap_fallback_password: "MnANX95MWad1" tx_pin: GPIO17 # TXD 2 rx_pin: GPIO16 # RXD 2 screen_pin: GPIO23 - encryption_key: !secret encryption_key + encryption_key: "iOZqtvw31Yy6sasRl5h2DElG2VDlqW2WjJEKObVN8bg=" external_components: source: github://iMicknl/LoctekMotion_IoT@v2 From 4f829205ebb51e0239353a6d855aaa3fba927be2 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 12:17:23 +0000 Subject: [PATCH 21/33] Improve linting and validating strategy --- .github/workflows/test.yaml | 1 + .github/workflows/validate-packages.yaml | 37 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 .github/workflows/validate-packages.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8d7a3d6..e5e5cf8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,3 +1,4 @@ +# Test ESPHome configuration files using local components name: Compile and validate YAML configuration on: diff --git a/.github/workflows/validate-packages.yaml b/.github/workflows/validate-packages.yaml new file mode 100644 index 0000000..552e9b6 --- /dev/null +++ b/.github/workflows/validate-packages.yaml @@ -0,0 +1,37 @@ +# Validate ESPHome packages using latest published components +name: Validate ESPHome packages + +on: + pull_request: + push: + branches: [main] + +jobs: + tests: + name: "${{ matrix.version }}" + runs-on: "ubuntu-latest" + + strategy: + fail-fast: false + matrix: + version: ["esp32"] + + steps: + - uses: "actions/checkout@v4" + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: "Install Python dependencies" + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: "Validate ${{ matrix.version }}" + run: esphome config tests/office-desk-${{ matrix.version }}.yaml + + - name: "Compile ${{ matrix.version }}" + run: esphome compile tests/office-desk-${{ matrix.version }}.yaml From f3da2591c6b47f345e3f50a6e02e33eee96fb10e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 12:21:42 +0000 Subject: [PATCH 22/33] Revert desk_height_sensor.cpp changes --- .../loctekmotion_desk_height/desk_height_sensor.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp index 1aebc0f..858ea76 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.cpp +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -2,7 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include -#include namespace esphome { namespace loctekmotion_desk_height { @@ -10,8 +9,7 @@ namespace loctekmotion_desk_height { static const char *const TAG = "loctekmotion_desk_height.sensor"; // ========== UTILITY METHODS ========== - -int hex_to_int(std::uint8_t s) { +int hex_to_int(byte s) { std::bitset<8> b(s); if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) { @@ -50,12 +48,12 @@ int hex_to_int(std::uint8_t s) { return 0; } -bool is_decimal(std::uint8_t b) { return (b & 0x80) == 0x80; } +bool is_decimal(byte b) { return (b & 0x80) == 0x80; } // ========== INTERNAL METHODS ========== void DeskHeightSensor::loop() { while (this->available() > 0) { - std::uint8_t incomingByte = this->read(); + byte incomingByte = this->read(); // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); // First byte, start of a packet From 1f5729750c3a953da3b825a96f65e135e63b2862 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 8 Jun 2024 12:25:27 +0000 Subject: [PATCH 23/33] Revert "Revert desk_height_sensor.cpp changes" This reverts commit f3da2591c6b47f345e3f50a6e02e33eee96fb10e. --- .../loctekmotion_desk_height/desk_height_sensor.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp index 858ea76..1aebc0f 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.cpp +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -2,6 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include namespace esphome { namespace loctekmotion_desk_height { @@ -9,7 +10,8 @@ namespace loctekmotion_desk_height { static const char *const TAG = "loctekmotion_desk_height.sensor"; // ========== UTILITY METHODS ========== -int hex_to_int(byte s) { + +int hex_to_int(std::uint8_t s) { std::bitset<8> b(s); if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) { @@ -48,12 +50,12 @@ int hex_to_int(byte s) { return 0; } -bool is_decimal(byte b) { return (b & 0x80) == 0x80; } +bool is_decimal(std::uint8_t b) { return (b & 0x80) == 0x80; } // ========== INTERNAL METHODS ========== void DeskHeightSensor::loop() { while (this->available() > 0) { - byte incomingByte = this->read(); + std::uint8_t incomingByte = this->read(); // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); // First byte, start of a packet From c9bca29e9d90dda663295c5896120d41d3130e20 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 10 Jun 2024 19:37:03 +0000 Subject: [PATCH 24/33] Change byte to unsigned char --- .../loctekmotion_desk_height/desk_height_sensor.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp index 1aebc0f..847c7d7 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.cpp +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -2,7 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include -#include namespace esphome { namespace loctekmotion_desk_height { @@ -10,8 +9,7 @@ namespace loctekmotion_desk_height { static const char *const TAG = "loctekmotion_desk_height.sensor"; // ========== UTILITY METHODS ========== - -int hex_to_int(std::uint8_t s) { +int hex_to_int(unsigned char s) { std::bitset<8> b(s); if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) { @@ -50,12 +48,12 @@ int hex_to_int(std::uint8_t s) { return 0; } -bool is_decimal(std::uint8_t b) { return (b & 0x80) == 0x80; } +bool is_decimal(unsigned char b) { return (b & 0x80) == 0x80; } // ========== INTERNAL METHODS ========== void DeskHeightSensor::loop() { while (this->available() > 0) { - std::uint8_t incomingByte = this->read(); + unsigned char incomingByte = this->read(); // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); // First byte, start of a packet From 79cd18899514b4492e0d645edf21aed4cfa25a9f Mon Sep 17 00:00:00 2001 From: James Myatt Date: Sat, 15 Jun 2024 19:38:23 +0100 Subject: [PATCH 25/33] Fix config --- packages/office-desk-esp32.yaml | 2 +- tests/office-desk-esp32.yaml | 2 +- tests/office-desk-esp8266.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/office-desk-esp32.yaml b/packages/office-desk-esp32.yaml index 4b45841..afda2a1 100644 --- a/packages/office-desk-esp32.yaml +++ b/packages/office-desk-esp32.yaml @@ -44,7 +44,7 @@ api: ota: wifi: - ssid: ${ssid) + ssid: ${ssid} password: ${wifi_password} # Enable fallback hotspot (captive portal) in case wifi connection fails diff --git a/tests/office-desk-esp32.yaml b/tests/office-desk-esp32.yaml index 7593a21..55c3b62 100644 --- a/tests/office-desk-esp32.yaml +++ b/tests/office-desk-esp32.yaml @@ -45,7 +45,7 @@ api: ota: wifi: - ssid: ${ssid) + ssid: ${ssid} password: ${wifi_password} # Enable fallback hotspot (captive portal) in case wifi connection fails diff --git a/tests/office-desk-esp8266.yaml b/tests/office-desk-esp8266.yaml index 5e3567c..361babd 100644 --- a/tests/office-desk-esp8266.yaml +++ b/tests/office-desk-esp8266.yaml @@ -45,7 +45,7 @@ api: ota: wifi: - ssid: ${ssid) + ssid: ${ssid} password: ${wifi_password} # Enable fallback hotspot (captive portal) in case wifi connection fails From 0bf15206a91399ad352d5b4ae0c37187bab891fc Mon Sep 17 00:00:00 2001 From: James Myatt Date: Sat, 15 Jun 2024 19:58:44 +0100 Subject: [PATCH 26/33] Use uint8_t and read_byte --- .../desk_height_sensor.cpp | 138 +++++++++--------- .../desk_height_sensor.h | 4 +- 2 files changed, 72 insertions(+), 70 deletions(-) diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp index 847c7d7..af3412f 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.cpp +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -9,7 +9,7 @@ namespace loctekmotion_desk_height { static const char *const TAG = "loctekmotion_desk_height.sensor"; // ========== UTILITY METHODS ========== -int hex_to_int(unsigned char s) { +int hex_to_int(uint8_t s) { std::bitset<8> b(s); if (b[0] && b[1] && b[2] && b[3] && b[4] && b[5] && !b[6]) { @@ -48,89 +48,91 @@ int hex_to_int(unsigned char s) { return 0; } -bool is_decimal(unsigned char b) { return (b & 0x80) == 0x80; } +bool is_decimal(uint8_t b) { return (b & 0x80) == 0x80; } // ========== INTERNAL METHODS ========== void DeskHeightSensor::loop() { + uint8_t incomingByte; while (this->available() > 0) { - unsigned char incomingByte = this->read(); - // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); - - // First byte, start of a packet - if (incomingByte == 0x9b) { - // Reset message length - this->msg_len = 0; - this->valid = false; - } + if (this->read_byte(incomingByte)) { + // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); + + // First byte, start of a packet + if (incomingByte == 0x9b) { + // Reset message length + this->msg_len = 0; + this->valid = false; + } - // Second byte defines the message length - if (this->history[0] == 0x9b) { - this->msg_len = (int)incomingByte; - } + // Second byte defines the message length + if (this->history[0] == 0x9b) { + this->msg_len = (int)incomingByte; + } - // Third byte is message type - if (this->history[1] == 0x9b) { - this->msg_type = incomingByte; - } + // Third byte is message type + if (this->history[1] == 0x9b) { + this->msg_type = incomingByte; + } - // Fourth byte is first height digit, if msg type 0x12 & msg len 7 - if (this->history[2] == 0x9b) { - - if (this->msg_type == 0x12 && - (this->msg_len == 7 || this->msg_len == 10)) { - // Empty height - if (incomingByte == 0) { - // ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); - // deskSerial.write(command_wakeup, sizeof(command_wakeup)); - } else if (hex_to_int(incomingByte) == 0) { - // ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); - // deskSerial.write(command_wakeup, sizeof(command_wakeup)); - } else { - this->valid = true; - // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); + // Fourth byte is first height digit, if msg type 0x12 & msg len 7 + if (this->history[2] == 0x9b) { + + if (this->msg_type == 0x12 && + (this->msg_len == 7 || this->msg_len == 10)) { + // Empty height + if (incomingByte == 0) { + // ESP_LOGD("DEBUG", "Height 1 is EMPTY -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } else if (hex_to_int(incomingByte) == 0) { + // ESP_LOGD("DEBUG", "Invalid height 1 -> 0x%02x", incomingByte); + // deskSerial.write(command_wakeup, sizeof(command_wakeup)); + } else { + this->valid = true; + // ESP_LOGD("DEBUG", "Height 1 is: 0x%02x", incomingByte); + } } } - } - // Fifth byte is second height digit - if (this->history[3] == 0x9b) { - if (this->valid == true) { - // ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); + // Fifth byte is second height digit + if (this->history[3] == 0x9b) { + if (this->valid == true) { + // ESP_LOGD("DEBUG", "Height 2 is: 0x%02x", incomingByte); + } } - } - // Sixth byte is third height digit - if (this->history[4] == 0x9b) { - if (this->valid == true) { - int height1 = hex_to_int(this->history[1]) * 100; - int height2 = hex_to_int(this->history[0]) * 10; - int height3 = hex_to_int(incomingByte); - if (height2 == 100) // check if 'number' is a hyphen, return value 10 - // multiplied by 10 - { - } else { - float finalHeight = height1 + height2 + height3; - if (is_decimal(this->history[0])) { - finalHeight = finalHeight / 10; + // Sixth byte is third height digit + if (this->history[4] == 0x9b) { + if (this->valid == true) { + int height1 = hex_to_int(this->history[1]) * 100; + int height2 = hex_to_int(this->history[0]) * 10; + int height3 = hex_to_int(incomingByte); + if (height2 == 100) // check if 'number' is a hyphen, return value 10 + // multiplied by 10 + { + } else { + float finalHeight = height1 + height2 + height3; + if (is_decimal(this->history[0])) { + finalHeight = finalHeight / 10; + } + this->value = finalHeight; + // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); } - this->value = finalHeight; - // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); } } - } - // Save byte buffer to history arrary - this->history[4] = this->history[3]; - this->history[3] = this->history[2]; - this->history[2] = this->history[1]; - this->history[1] = this->history[0]; - this->history[0] = incomingByte; - - // End byte - if (incomingByte == 0x9d) { - if (this->value && this->value != this->lastPublished) { - this->publish_state(this->value); - this->lastPublished = this->value; + // Save byte buffer to history arrary + this->history[4] = this->history[3]; + this->history[3] = this->history[2]; + this->history[2] = this->history[1]; + this->history[1] = this->history[0]; + this->history[0] = incomingByte; + + // End byte + if (incomingByte == 0x9d) { + if (this->value && this->value != this->lastPublished) { + this->publish_state(this->value); + this->lastPublished = this->value; + } } } } diff --git a/components/loctekmotion_desk_height/desk_height_sensor.h b/components/loctekmotion_desk_height/desk_height_sensor.h index 1182630..a19df52 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.h +++ b/components/loctekmotion_desk_height/desk_height_sensor.h @@ -22,10 +22,10 @@ class DeskHeightSensor : public sensor::Sensor, protected: float value = 0; float lastPublished = -1; - unsigned long history[5]; + uint8_t history[5]; int msg_len = 0; - unsigned long msg_type; + uint8_t msg_type; bool valid = false; }; From d21b9fa83c6c35459235fb52741cfdfcc5383ee5 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Sat, 15 Jun 2024 20:00:05 +0100 Subject: [PATCH 27/33] Fix --- components/loctekmotion_desk_height/desk_height_sensor.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp index af3412f..a43676c 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.cpp +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -54,7 +54,7 @@ bool is_decimal(uint8_t b) { return (b & 0x80) == 0x80; } void DeskHeightSensor::loop() { uint8_t incomingByte; while (this->available() > 0) { - if (this->read_byte(incomingByte)) { + if (this->read_byte(&incomingByte)) { // ESP_LOGD("DEBUG", "Incoming byte is: %08x", incomingByte); // First byte, start of a packet From 3971a7bb3fb5ce6506ae34f9cfa32f6f15c583a2 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Sat, 15 Jun 2024 20:01:44 +0100 Subject: [PATCH 28/33] More uint8_t --- components/loctekmotion_desk_height/desk_height_sensor.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp index a43676c..0d6e7f1 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.cpp +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -66,7 +66,7 @@ void DeskHeightSensor::loop() { // Second byte defines the message length if (this->history[0] == 0x9b) { - this->msg_len = (int)incomingByte; + this->msg_len = incomingByte; } // Third byte is message type From b87162a35946d78f7ae61fb860fa8fa89b41ec61 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Sat, 15 Jun 2024 20:07:35 +0100 Subject: [PATCH 29/33] linting fixes --- components/loctekmotion_desk_height/desk_height_sensor.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/loctekmotion_desk_height/desk_height_sensor.cpp b/components/loctekmotion_desk_height/desk_height_sensor.cpp index 0d6e7f1..2d1caf5 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.cpp +++ b/components/loctekmotion_desk_height/desk_height_sensor.cpp @@ -115,7 +115,8 @@ void DeskHeightSensor::loop() { finalHeight = finalHeight / 10; } this->value = finalHeight; - // ESP_LOGD("DeskHeightSensor", "Current height is: %f", finalHeight); + // ESP_LOGD("DeskHeightSensor", "Current height is: %f", + // finalHeight); } } } From 772e7d52f9f7fc03fec12215e4927c0343d0db08 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Sat, 15 Jun 2024 20:10:55 +0100 Subject: [PATCH 30/33] More uint8_t --- components/loctekmotion_desk_height/desk_height_sensor.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/loctekmotion_desk_height/desk_height_sensor.h b/components/loctekmotion_desk_height/desk_height_sensor.h index a19df52..90bc20f 100644 --- a/components/loctekmotion_desk_height/desk_height_sensor.h +++ b/components/loctekmotion_desk_height/desk_height_sensor.h @@ -24,7 +24,7 @@ class DeskHeightSensor : public sensor::Sensor, float lastPublished = -1; uint8_t history[5]; - int msg_len = 0; + uint8_t msg_len = 0; uint8_t msg_type; bool valid = false; }; From 568df697bebb88a0daf0cf1cc3bcde93a7f5e5dd Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 15 Jun 2024 19:07:22 +0000 Subject: [PATCH 31/33] Update devcontainer --- .devcontainer/devcontainer.json | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8bb6d17..5790054 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,6 @@ // README at: https://github.com/devcontainers/templates/tree/main/src/python { "name": "Python 3", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", // Features to add to the dev container. More info: https://containers.dev/features. @@ -13,17 +12,20 @@ // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "pip3 install --user -r requirements.txt && pre-commit install", + + // Configure tool-specific properties. "customizations": { "vscode": { "extensions": [ - "ms-vscode.cpptools-extension-pack", - "GitHub.copilot-chat" + "GitHub.copilot-chat", + "ESPHome.esphome-vscode", + "redhat.vscode-yaml", + "ms-python.python", + "ms-vscode.cpptools", + "ms-vscode.cpptools-extension-pack" ] } - }, - - // Configure tool-specific properties. - // "customizations": {}, + } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" From 137edb0f71761f7a864107763d8d6091c9cbd65c Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 15 Jun 2024 19:11:13 +0000 Subject: [PATCH 32/33] Change CI+devcontainer to Python 3.11 --- .devcontainer/devcontainer.json | 2 +- .github/workflows/lint.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- .github/workflows/validate-packages.yaml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5790054..c83521e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ // README at: https://github.com/devcontainers/templates/tree/main/src/python { "name": "Python 3", - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index e9519cb..854b748 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -12,10 +12,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.11" cache: "pip" - name: "Install Python dependencies" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e5e5cf8..da446e7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,10 +19,10 @@ jobs: steps: - uses: "actions/checkout@v4" - - name: Set up Python 3.12 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.11" cache: "pip" - name: "Install Python dependencies" diff --git a/.github/workflows/validate-packages.yaml b/.github/workflows/validate-packages.yaml index 552e9b6..b9db4f0 100644 --- a/.github/workflows/validate-packages.yaml +++ b/.github/workflows/validate-packages.yaml @@ -19,10 +19,10 @@ jobs: steps: - uses: "actions/checkout@v4" - - name: Set up Python 3.12 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.11" cache: "pip" - name: "Install Python dependencies" From 2a9751926c8d1e26900a7c89025f1f0947e291f3 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Sun, 14 Jul 2024 14:55:50 +0000 Subject: [PATCH 33/33] Fix/clarify pin-out --- README.md | 52 ++++++++++++++++++++--------------- images/RJ45-Pinout-T568B.jpg | Bin 0 -> 104683 bytes 2 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 images/RJ45-Pinout-T568B.jpg diff --git a/README.md b/README.md index 77911d7..ed0b79f 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,15 @@ If you are interested in the internals of the LoctecMotion desk system, have a l At the time of writing, LoctekMotion sells [11 different control panels](https://www.loctekmotion.com/product/control-panel/). The features can differ per model, but it looks like the serial interface is pretty similar for the more advanced models. -The tables below will show a mapping of the RJ45 pinout to the pinout used by the control panel. Please note that all RJ45 pins are described in the following way; +The tables below will show a mapping of the RJ45 pinout to the pinout used by the control panel. Please note that all RJ45 pins are described in the following way: ![RJ-45 connector layout](images/RJ-45_connector.jpg) +The most common [color convention](https://www.showmecables.com/blog/post/rj45-pinout) +for wiring RJ45 for network cables is: + +![RJ45 T568B colors](images/RJ45-Pinout-T568B.jpg) + In order to connect the control box to a Raspberry Pi and ESP32/ESP8266 chip I used a [RJ45 to RS232 adapter](https://www.allekabels.nl/rs232-kabel/4568/1041186/rj45-naar-rs232.html) with DuPont cables (jump wires), but you simply can cut and split an ethernet cable as well. #### Supported Control Panels @@ -59,16 +64,17 @@ If your control panel is missing, feel free to [create an issue](https://github. | RJ45 pin | Name | Original Cable Color | Ethernet cable color (T568B) | | -------- | ---------- | -------------------- | ---------------------------- | -| 8 | RESET | Brown | White-Orange | -| 7 | SWIM | White | Orange | -| 6 | EMPTY | Purple | White-Green | -| 5 | PIN 20 | Red | Blue | -| 4 | RX | Green | White-Blue | -| 3 | TX | Black | Green | -| 2 | GND | Blue | White-Brown | -| 1 | +5V (VDD) | Yellow | Brown | - -Note that RX and TX is defined like this on receiver (control panel) side. So RX can be used to receive data, TX to send data. +| 1 | RESET | Brown | White-Orange | +| 2 | SWIM | White | Orange | +| 3 | EMPTY | Purple | White-Green | +| 4 | PIN 20 | Red | Blue | +| 5 | RX | Green | White-Blue | +| 6 | TX | Black | Green | +| 7 | GND | Blue | White-Brown | +| 8 | +5V (VDD) | Yellow | Brown | + +Note that RX and TX is defined like this on receiver (control panel) side. +So the custom controller also uses RX to receive data and TX to send data. #### HS13A-1 @@ -78,16 +84,17 @@ Note that RX and TX is defined like this on receiver (control panel) side. So RX | RJ45 pin | Name | Original Cable Color | Ethernet cable color (T568B) | | -------- | ---------- | -------------------- | ---------------------------- | -| 8 | RESET SWIM | Brown | White-Orange | -| 7 | PIN 20 | White | Orange | -| 6 | RX | Purple | White-Green | -| 5 | TX | Red | Blue | -| 4 | GND1 | Green | White-Blue | -| 3 | +5V (VDD) | Black | Green | -| 2 | 29V+ | Blue | White-Brown | -| 1 | 29V- | Yellow | Brown | - -Note that RX and TX is defined like this on receiver (control panel) side. So RX can be used to receive data, TX to send data. +| 1 | RESET SWIM | Brown | White-Orange | +| 2 | PIN 20 | White | Orange | +| 3 | RX | Purple | White-Green | +| 4 | TX | Red | Blue | +| 5 | GND1 | Green | White-Blue | +| 6 | +5V (VDD) | Black | Green | +| 7 | 29V+ | Blue | White-Brown | +| 8 | 29V- | Yellow | Brown | + +Note that RX and TX is defined like this on receiver (control panel) side. +So the custom controller also uses RX to receive data and TX to send data. #### HS01B-1 @@ -106,7 +113,8 @@ Note that RX and TX is defined like this on receiver (control panel) side. So RX | 2 | SWIM | | 1 | RES | -Note that RX and TX is defined like this on receiver (control panel) side. So RX can be used to receive data, TX to send data. +Note that RX and TX is defined like this on receiver (control panel) side. +So the custom controller also uses RX to receive data and TX to send data. Other control panels / control boxes could be supported in the same way, but you would need to figure the RJ45 pinout mapping. Most control boxes have an extra RJ45 port for serial communication, but otherwise you would need to place your device in between the control panel and the control box. diff --git a/images/RJ45-Pinout-T568B.jpg b/images/RJ45-Pinout-T568B.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bf606ecc50f669d57b7a6db3a045356bc547dbfc GIT binary patch literal 104683 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 ZBW=ux=llPiUPAwRQT^}#zVmDRKLF$&j8*^u literal 0 HcmV?d00001