From 12444cb5376df9156c9f3acd9b36bba195c78673 Mon Sep 17 00:00:00 2001 From: Rocka84 Date: Mon, 8 Jan 2024 22:30:27 +0100 Subject: [PATCH] initial commit --- .gitignore | 1 + .vimrc | 4 + components/jiecang_desk_controller/README.md | 120 +++++++++++ .../jiecang_desk_controller/__init__.py | 128 +++++++++++ .../jiecang_desk_controller.cpp | 203 ++++++++++++++++++ .../jiecang_desk_controller.h | 76 +++++++ example_jiecang_desk_controller.yaml | 108 ++++++++++ 7 files changed, 640 insertions(+) create mode 100644 .gitignore create mode 100644 .vimrc create mode 100644 components/jiecang_desk_controller/README.md create mode 100644 components/jiecang_desk_controller/__init__.py create mode 100644 components/jiecang_desk_controller/jiecang_desk_controller.cpp create mode 100644 components/jiecang_desk_controller/jiecang_desk_controller.h create mode 100644 example_jiecang_desk_controller.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59ee0a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/__pycache__/** diff --git a/.vimrc b/.vimrc new file mode 100644 index 0000000..00f4114 --- /dev/null +++ b/.vimrc @@ -0,0 +1,4 @@ +if SpmSetProject("esphome_components") == 0 + nmap h yy'fpwd2f:f)lC;==mf^w + nmap s 02w"aye2b02w"byej/::a Vf{%jd/::b f{%jpzz^wj +endif diff --git a/components/jiecang_desk_controller/README.md b/components/jiecang_desk_controller/README.md new file mode 100644 index 0000000..c5a2ccb --- /dev/null +++ b/components/jiecang_desk_controller/README.md @@ -0,0 +1,120 @@ +# ESPHome Jiecang Desk Controller + +[ESPHome](https://esphome.io/) component for controlling Jiecang desk controllers via their serial protocol. + +Attention: I only have one [RJ12 model](https://www.jiecang.com/product/jcb35m11c.html) to test this +but it's likely that other Jiecang controllers are supported as their serial protocol should be compatible. + +## Usage + +### What you need + +* ESPHome compatible microcontroller +* depending on your model of desk controller + * cable with RJ12 connector (phone cable, RJ11 may also work) + * cable with RJ45 connector (network cable) + +### Wiring + +#### RJ12 + +Please double check this for your specific model! + +pin | function +----|--------- + 1 | NC (pulled up) + 2 | GND + 3 | TX + 4 | VCC + 5 | GND + 6 | NC (pulled up) + +#### RJ45 + +Untested and only for reference! + +pin | function +----|--------- + 1 | HS3 [^1] + 2 | TX + 3 | GND + 4 | RX + 5 | VCC + 6 | HS2 [^1] + 7 | HS1 [^1] + 8 | HS0 [^1] + +[^1]: not used here + +#### microcontroller + +ESP | desk +----|----- +GND | GND +5V | VCC +RX | TX +TX | RX + +## Usage + +```yaml +uart: + id: uart_bus + tx_pin: TX + rx_pin: RX + baud_rate: 9600 + +external_components: + - source: + type: git + url: https://github.com/Rocka84/esphome_components/ + components: [ jiecang_desk_controller ] + +jiecang_desk_controller: + id: my_desk + sensors: + height: + name: "Height" + buttons: + raise: + name: "Raise" + position1: + name: "Position 1" + +button: + - platform: template + name: "Lower" + on_press: + lambda: "id(my_desk).lower();" +``` + +See also [example_jiecang_desk_controller.yaml](../../example_jiecang_desk_controller.yaml). + +### Available sensors + +sensor | description +-----------|---------------------------- +height | current height of the desk +height_pct | height in percent +height_min | minimal height +height_max | maximal height +position1 | height of 1st stored height +position2 | height of 2nd stored height +position3 | height of 3rd stored height +position4 | height of 4th stored height + +### Available buttons and methods + +button | lambda method | description +-----------|--------------------------------|--------------------------- +raise | `id(my_desk).raise()` | raise desk by one step (~14mm) +lower | `id(my_desk).raise()` | lower desk by one step (~14mm) +position1 | `id(my_desk).goto_position(1)` | move to 1st stored height +position2 | `id(my_desk).goto_position(2)` | move to 2nd stored height +position3 | `id(my_desk).goto_position(3)` | move to 3rd stored height +position4 | `id(my_desk).goto_position(4)` | move to 4th stored height + +## Sources + +Thanks to [phord/Jarvis](https://github.com/phord/Jarvis) for reverse engineering the UART interface and most control messages + diff --git a/components/jiecang_desk_controller/__init__.py b/components/jiecang_desk_controller/__init__.py new file mode 100644 index 0000000..0cbef8e --- /dev/null +++ b/components/jiecang_desk_controller/__init__.py @@ -0,0 +1,128 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import pins +from esphome.components import uart, sensor, button +from esphome.const import CONF_ID, CONF_HEIGHT, UNIT_CENTIMETER, UNIT_PERCENT + +DEPENDENCIES = ['uart'] +AUTO_LOAD = ['sensor', 'button'] + +jiecang_desk_controller_ns = cg.esphome_ns.namespace('jiecang_desk_controller') + +JiecangDeskController = jiecang_desk_controller_ns.class_('JiecangDeskController', cg.Component, uart.UARTDevice) +JiecangDeskButton = jiecang_desk_controller_ns.class_('JiecangDeskButton', button.Button, cg.Component) + + +CONF_SENSORS = "sensors" +CONF_BUTTONS = "buttons" +CONF_UNIT = "unit" +CONF_HEIGHT_MIN = "height_min" +CONF_HEIGHT_MAX = "height_max" +CONF_HEIGHT_PCT = "height_pct" +CONF_POSITION1 = "position1" +CONF_POSITION2 = "position2" +CONF_POSITION3 = "position3" +CONF_POSITION4 = "position4" +CONF_RAISE = "raise" +CONF_LOWER = "lower" + + +button_constants = {} +button_constants[CONF_RAISE] = 0 +button_constants[CONF_LOWER] = 1 +button_constants[CONF_POSITION1] = 2 +button_constants[CONF_POSITION2] = 3 +button_constants[CONF_POSITION3] = 4 +button_constants[CONF_POSITION4] = 5 + +CONFIG_SCHEMA = cv.COMPONENT_SCHEMA.extend({ + cv.GenerateID(): cv.declare_id(JiecangDeskController), + cv.Optional(CONF_SENSORS): cv.Schema({ + cv.Optional(CONF_HEIGHT): sensor.sensor_schema( + accuracy_decimals = 1, + unit_of_measurement = UNIT_CENTIMETER + ), + cv.Optional(CONF_UNIT): sensor.sensor_schema( + accuracy_decimals = 0 + ), + cv.Optional(CONF_HEIGHT_PCT): sensor.sensor_schema( + accuracy_decimals = 1, + unit_of_measurement = UNIT_PERCENT + ), + cv.Optional(CONF_HEIGHT_MIN): sensor.sensor_schema( + accuracy_decimals = 1, + unit_of_measurement = UNIT_CENTIMETER + ), + cv.Optional(CONF_HEIGHT_MAX): sensor.sensor_schema( + accuracy_decimals = 1, + unit_of_measurement = UNIT_CENTIMETER + ), + cv.Optional(CONF_POSITION1): sensor.sensor_schema( + accuracy_decimals = 1, + unit_of_measurement = UNIT_CENTIMETER + ), + cv.Optional(CONF_POSITION2): sensor.sensor_schema( + accuracy_decimals = 1, + unit_of_measurement = UNIT_CENTIMETER + ), + cv.Optional(CONF_POSITION3): sensor.sensor_schema( + accuracy_decimals = 1, + unit_of_measurement = UNIT_CENTIMETER + ), + cv.Optional(CONF_POSITION4): sensor.sensor_schema( + accuracy_decimals = 1, + unit_of_measurement = UNIT_CENTIMETER + ), + }), + cv.Optional(CONF_BUTTONS): cv.Schema({ + cv.Optional(CONF_RAISE): button.BUTTON_SCHEMA.extend({cv.GenerateID(): cv.declare_id(JiecangDeskButton)}), + cv.Optional(CONF_LOWER): button.BUTTON_SCHEMA.extend({cv.GenerateID(): cv.declare_id(JiecangDeskButton)}), + cv.Optional(CONF_POSITION1): button.BUTTON_SCHEMA.extend({cv.GenerateID(): cv.declare_id(JiecangDeskButton)}), + cv.Optional(CONF_POSITION2): button.BUTTON_SCHEMA.extend({cv.GenerateID(): cv.declare_id(JiecangDeskButton)}), + cv.Optional(CONF_POSITION3): button.BUTTON_SCHEMA.extend({cv.GenerateID(): cv.declare_id(JiecangDeskButton)}), + cv.Optional(CONF_POSITION4): button.BUTTON_SCHEMA.extend({cv.GenerateID(): cv.declare_id(JiecangDeskButton)}), + }), +}).extend(uart.UART_DEVICE_SCHEMA) + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if CONF_SENSORS in config: + sensors = config[CONF_SENSORS] + + if CONF_HEIGHT in sensors: + sens = await sensor.new_sensor(sensors[CONF_HEIGHT]) + cg.add(var.set_sensor_height(sens)) + if CONF_UNIT in sensors: + sens = await sensor.new_sensor(sensors[CONF_UNIT]) + cg.add(var.set_sensor_unit(sens)) + if CONF_HEIGHT_MIN in sensors: + sens = await sensor.new_sensor(sensors[CONF_HEIGHT_MIN]) + cg.add(var.set_sensor_height_min(sens)) + if CONF_HEIGHT_MAX in sensors: + sens = await sensor.new_sensor(sensors[CONF_HEIGHT_MAX]) + cg.add(var.set_sensor_height_max(sens)) + if CONF_HEIGHT_PCT in sensors: + sens = await sensor.new_sensor(sensors[CONF_HEIGHT_PCT]) + cg.add(var.set_sensor_height_pct(sens)) + if CONF_POSITION1 in sensors: + sens = await sensor.new_sensor(sensors[CONF_POSITION1]) + cg.add(var.set_sensor_position1(sens)) + if CONF_POSITION2 in sensors: + sens = await sensor.new_sensor(sensors[CONF_POSITION2]) + cg.add(var.set_sensor_position2(sens)) + if CONF_POSITION3 in sensors: + sens = await sensor.new_sensor(sensors[CONF_POSITION3]) + cg.add(var.set_sensor_position3(sens)) + if CONF_POSITION4 in sensors: + sens = await sensor.new_sensor(sensors[CONF_POSITION4]) + cg.add(var.set_sensor_position4(sens)) + + if CONF_BUTTONS in config: + buttons = config[CONF_BUTTONS] + for button_type in buttons.keys(): + btn = await button.new_button(buttons[button_type]) + cg.add(var.add_button(btn, button_constants[button_type])) + diff --git a/components/jiecang_desk_controller/jiecang_desk_controller.cpp b/components/jiecang_desk_controller/jiecang_desk_controller.cpp new file mode 100644 index 0000000..5bcf60a --- /dev/null +++ b/components/jiecang_desk_controller/jiecang_desk_controller.cpp @@ -0,0 +1,203 @@ +#include "jiecang_desk_controller.h" +#include "esphome/core/log.h" + +namespace esphome { + namespace jiecang_desk_controller { + + static const char *const TAG = "jiecang_desk_controller"; + + float JiecangDeskController::byte2float(int high, int low) { + return static_cast((high<<8) + low)/10; + } + + bool JiecangDeskController::bufferMessage(int data, unsigned int *buffer, int len) { + // This is a very simple method of receiving messages from the desk. + // It will fail on messages that contain the value 0x7E in their payload. + // But it is super simple and works for the messages we care about. + + // format: 0xF2 0xF2 [command] [param_count] [[param] ...] [checksum] 0x7E + // checksum: sum of [command], [param_count] and all [param]s + + static int cmd_incoming = 0; // 0: wait for F2, 1: wait for 2nd F2, 2: buffer data + static int pos = 0; + + if (cmd_incoming < 2 && data == 0xF2) { // start of message, must appear twice + cmd_incoming++; + pos = 0; + + } else if (cmd_incoming == 1) { // no second F2 received + cmd_incoming = 0; + + } else if (cmd_incoming == 2) { + if (data == 0x7E) { // end of message + cmd_incoming = 0; + for (;pos= len) { // message too long, drop it + cmd_incoming = 0; + + } else { + buffer[pos++] = data; // buffer data + } + + } // else: received garbage + + return false; + } + + void JiecangDeskController::handleMessage(unsigned int *message) { + // ESP_LOGV("jiecang_desk_controller", "message %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", message[0], message[1], message[2], message[3], message[4], message[5], message[6], message[7], message[8], message[9]); + + switch (message[0]) { + case 0x01: + ESP_LOGV("jiecang_desk_controller", "height 0x%0X%0X", message[2], message[3]); + if (height != nullptr) height->publish_state(byte2float(message[2], message[3])); + + if (height_pct != nullptr) + height_pct->publish_state((height->state - height_min->state) / (height_max->state - height_min->state) * 100); + break; + + case 0x0e: + ESP_LOGV("jiecang_desk_controller", "unit 0x%0X", message[2]); + if (unit != nullptr) unit->publish_state(message[2]); + break; + + case 0x20: + ESP_LOGV("jiecang_desk_controller", "limits 0x%0X max %i min %i", message[2], (message[2] & 1), (message[2]>>4)); + + if (height_min != nullptr && (message[2] & 1) == 0) { // low nibble 0 -> no max limit, use physical_max + height_max->publish_state(physical_max); + } + if (height_max != nullptr && (message[2]>>4) == 0) { // high nibble 0 -> no min limit, use physical_min + height_min->publish_state(physical_min); + } + break; + + case 0x07: + ESP_LOGV("jiecang_desk_controller", "physical limits 0x%02X%02X 0x%02X%02X", message[2], message[3], message[4], message[5]); + physical_max = byte2float(message[2], message[3]); + physical_min = byte2float(message[4], message[5]); + break; + + case 0x21: + ESP_LOGV("jiecang_desk_controller", "height_max 0x%02X%02X", message[2], message[3]); + if (height_max != nullptr) height_max->publish_state(byte2float(message[2], message[3])); + break; + + case 0x22: + ESP_LOGV("jiecang_desk_controller", "height_min 0x%02X%02X", message[2], message[3]); + if (height_min != nullptr) height_min->publish_state(byte2float(message[2], message[3])); + break; + + case 0x25: + ESP_LOGV("jiecang_desk_controller", "position1 0x%02X%02X", message[2], message[3]); + if (position1 != nullptr) position1->publish_state(byte2float(message[2], message[3])); + break; + + case 0x26: + ESP_LOGV("jiecang_desk_controller", "position2 0x%02X%02X", message[2], message[3]); + if (position2 != nullptr) position2->publish_state(byte2float(message[2], message[3])); + break; + + case 0x27: + ESP_LOGV("jiecang_desk_controller", "position3 0x%02X%02X", message[2], message[3]); + if (position3 != nullptr) position3->publish_state(byte2float(message[2], message[3])); + break; + + case 0x28: + ESP_LOGV("jiecang_desk_controller", "position4 0x%02X%02X", message[2], message[3]); + if (position4 != nullptr) position4->publish_state(byte2float(message[2], message[3])); + break; + + default: + ESP_LOGV("jiecang_desk_controller", "unknown message %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", message[0], message[1], message[2], message[3], message[4], message[5], message[6], message[7], message[8], message[9]); + } + } + + void JiecangDeskController::update() { + const int max_length = 10; + static unsigned int buffer[max_length]; + while (available()) { + if(bufferMessage(read(), buffer, max_length)) { + handleMessage(buffer); + } + } + } + + void JiecangDeskController::send_simple_command(unsigned char cmd) { + write_array({ 0xF1, 0xF1, cmd, 0x00, cmd, 0x7E }); + } + + void JiecangDeskController::add_button(button::Button *btn, int action) { + btn->add_on_press_callback([this, action]() { this->button_press_action(action); }); + } + + void JiecangDeskController::raise() { + send_simple_command(0x01); + } + + void JiecangDeskController::lower() { + send_simple_command(0x02); + } + + void JiecangDeskController::goto_position(int pos) { + switch (pos) { + case 1: + send_simple_command(0x05); + break; + case 2: + send_simple_command(0x06); + break; + case 3: + send_simple_command(0x27); + break; + case 4: + send_simple_command(0x28); + break; + } + } + + void JiecangDeskController::request_physical_limits() { + send_simple_command(0x0C); + } + + void JiecangDeskController::request_limits() { + send_simple_command(0x20); + } + + void JiecangDeskController::request_settings() { + send_simple_command(0x07); + } + + void JiecangDeskController::button_press_action(int action) { + ESP_LOGV("JiecangDeskController", "button_press_action %i", action); + switch (action) { + case BUTTON_RAISE: + raise(); + break; + case BUTTON_LOWER: + lower(); + break; + case BUTTON_POSITION1: + goto_position(1); + break; + case BUTTON_POSITION2: + goto_position(2); + break; + case BUTTON_POSITION3: + goto_position(3); + break; + case BUTTON_POSITION4: + goto_position(4); + break; + } + } + + void JiecangDeskButton::press_action() {} + + } //namespace jiecang_desk_controller +} //namespace esphome + diff --git a/components/jiecang_desk_controller/jiecang_desk_controller.h b/components/jiecang_desk_controller/jiecang_desk_controller.h new file mode 100644 index 0000000..f16197e --- /dev/null +++ b/components/jiecang_desk_controller/jiecang_desk_controller.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/button/button.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#define BUTTON_RAISE 0 +#define BUTTON_LOWER 1 +#define BUTTON_POSITION1 2 +#define BUTTON_POSITION2 3 +#define BUTTON_POSITION3 4 +#define BUTTON_POSITION4 5 + +namespace esphome { + namespace jiecang_desk_controller { + + class JiecangDeskController : public PollingComponent, public sensor::Sensor, public uart::UARTDevice { + private: + float physical_min = 0; + float physical_max = 0; + + float byte2float(int high, int low); + bool bufferMessage(int data, unsigned int *buffer, int len); + void handleMessage(unsigned int *message); + + public: + void update() override; + + void set_sensor_height(sensor::Sensor *sensor) { this->height = sensor; } + void set_sensor_unit(sensor::Sensor *sensor) { this->unit = sensor; } + void set_sensor_height_min(sensor::Sensor *sensor) { this->height_min = sensor; } + void set_sensor_height_max(sensor::Sensor *sensor) { this->height_max = sensor; } + void set_sensor_height_pct(sensor::Sensor *sensor) { this->height_pct = sensor; } + + void set_sensor_position1(sensor::Sensor *sensor) { this->position1 = sensor; } + void set_sensor_position2(sensor::Sensor *sensor) { this->position2 = sensor; } + void set_sensor_position3(sensor::Sensor *sensor) { this->position3 = sensor; } + void set_sensor_position4(sensor::Sensor *sensor) { this->position4 = sensor; } + + void send_simple_command(unsigned char cmd); + void add_button(button::Button *btn, int action); + + void raise(); + void lower(); + void goto_position(int pos); + + void request_physical_limits(); + void request_limits(); + void request_settings(); + + protected: + Sensor *height{nullptr}; + Sensor *unit{nullptr}; + Sensor *height_min{nullptr}; + Sensor *height_max{nullptr}; + Sensor *height_pct{nullptr}; + + Sensor *position1{nullptr}; + Sensor *position2{nullptr}; + Sensor *position3{nullptr}; + Sensor *position4{nullptr}; + + void button_press_action(int type); + }; + + class JiecangDeskButton : public Component, public button::Button { + protected: + void press_action() override; + }; + + } //namespace jiecang_desk_controller +} //namespace esphome diff --git a/example_jiecang_desk_controller.yaml b/example_jiecang_desk_controller.yaml new file mode 100644 index 0000000..30e264b --- /dev/null +++ b/example_jiecang_desk_controller.yaml @@ -0,0 +1,108 @@ +esphome: + name: jiecang-desk-controller + friendly_name: Jiecang Desk Controller + on_boot: + priority: 0 # when mostly everything else is done + then: + - lambda: "id(my_desk).request_physical_limits();" + - delay: 0.1s + - lambda: "id(my_desk).request_limits();" + - delay: 0.1s + - lambda: "id(my_desk).request_settings();" + +esp8266: + board: esp01_1m + +logger: + level: WARN + baud_rate: 0 # disable logging over uart, required when using the RX/TX pins for the controller + +api: + +ota: + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + + # Enable fallback hotspot (captive portal) in case wifi connection fails + ap: + ssid: "esphome-desk" + password: "9ebc6eac0b4e0e26b8d3b955ec660557" + +captive_portal: + + + +uart: + id: uart_bus + tx_pin: TX + rx_pin: RX + baud_rate: 9600 + +external_components: + - source: + type: git + url: https://github.com/Rocka84/esphome_components/ + components: [ jiecang_desk_controller ] + +jiecang_desk_controller: + id: my_desk + sensors: + height: + name: "Height" + height_min: + name: "Height Min" + height_max: + name: "Height Max" + height_pct: + name: "Height Percent" + position1: + name: "Position 1" + position2: + name: "Position 2" + position3: + name: "Position 3" + position4: + name: "Position 4" + buttons: + raise: + name: "Raise" + lower: + name: "Lower" + position1: + name: "Position 1" + position2: + name: "Position 2" + position3: + name: "Position 3" + position4: + name: "Position 4" + +## lambda usage +# button: +# - platform: template +# name: "Raise" +# on_press: +# lambda: "id(my_desk).raise();" +# - platform: template +# name: "Lower" +# on_press: +# lambda: "id(my_desk).lower();" +# - platform: template +# name: "Position 1" +# on_press: +# lambda: "id(my_desk).goto_position(1);" +# - platform: template +# name: "Position 2" +# on_press: +# lambda: "id(my_desk).goto_position(2);" +# - platform: template +# name: "Position 3" +# on_press: +# lambda: "id(my_desk).goto_position(3);" +# - platform: template +# name: "Position 4" +# on_press: +# lambda: "id(my_desk).goto_position(4);" +