FlexiSpot E7 Proのコントローラーの信号を解析してみた

FlexiSpotの電動昇降式デスクにESP32を接続して、WebブラウザやHome Assistant等から操作できるようにするというのは、まあまあメジャーな改造かと思います。
Learn how to connect your Flexispot (LoctekMotion) desk to the internet. This repository contains a collection of scripts to get your started, combined with research and instructions.
コントローラーとの通信についてはこちらのGitHubレポジトリにまとまってるし、HomeAssistantと連携させるためのESPHomeのyamlファイルも配布されているため、簡単に導入することができます。
とはいえ、こちらはあくまで純正コントローラーを残しつつESP32を追加する改造であるため、純正コントローラーを置き換えできる代物ではありません。
今回、自分はFlexiSpotのコントローラーを外して、M5Stack Tab5を設置する計画なので、なるべく純正コントローラーと同等の使い勝手を実現できるように通信仕様を調査してみました。
マイコン・接続

制御するマイコンにはESP32S3(AtomS3-Lite)を使いました。接続は参考元のGitHubレポジトリとそんなに相違ありません。マイコンが違うからGPIOピンが違うくらい
| RJ45 Pin | FlexiSpot | Atom-S3 Lite |
|---|---|---|
| 8 | +5V (VDD) | 5V (VIN_5V) |
| 7 | GND | G (GND) |
| 6 | RX | G5 (UART1 TX) |
| 5 | TX | G6 (UART1 RX) |
| 4 | Detect | G7 (GPIO7) |
| 3 | ||
| 2 | ||
| 1 |
Pin8と7は電源
Pin6と5を使ってUARTで通信します。Pin6がESP32->FlexiSpot方向の通信で、Pin5がFlexiSpot->ESP32の通信になります。
Pin4について、参考元のGitHubではPIN 20と記載されており、その他調べるとWake Upピンと説明されていることがあったりしますが、自分が手元で試した感じだと
- Highにしている間FlexiSpot本体からUARTのパケットが届く
- LowにするとFlexiSpotからの通信が止まる
- コントローラーは常時5Vを出力
- Low -> HighになったタイミングでLCD出力
という感じでコントローラーが接続されているかどうかを検出するために使っていそうな挙動だったので、Detectって表記にしました。
なので、ここはESP32と接続する必要はなく、Pin8とショートさせるだけでいいのですが、Low->Highと切り替えることで任意のタイミングでLCDを点灯させることができるため繋いでおいても損はないと思います。
なお、FlexiSpotが電源Offの場合にPin4を1秒間Highにして電源Onにする必要があるとの記述も見かけましたが、とくにその必要はなさそうでした。
Pin4がHighなら通信可能 == Pin4は常時Highにしておけばいい、それだけです。
パケット
通信は9600bpsのUART上で、パケット単位でやり取りされます。
構造
UART上での通信に使ってるパケット構造です。0x9Bから始まり、0x9Dで終わります。
その他特殊なエンコードとかはありません。
| Size | Note | |
|---|---|---|
| 0x9B | 1 | 開始バイト |
| Length | 1 | パケットの長さ。Type,Payload,Checksum,0x9Dのサイズ合計値 |
| Type | 1 | データタイプ |
| Payload | 0~N | 実際にやり取りするデータ |
| Checksum | 2 | Length,Type,Payload(上図の青い範囲)のCRC-16/MODBUS, Big Endian |
| 0x9D | 1 | 終了バイト |
主なパケット
状態リクエスト
| Direction | Length | Type | Payload |
|---|---|---|---|
| 本体 -> コントローラー | 4 | 0x11 | なし |
本体からコントローラーに対する状態取得リクエストです。Payloadはありません。
約40ms間隔で本体からコントローラーに送られ、コントローラーはこのパケットを受信すると次セクションで説明するボタンの状態を本体に応答します。
ボタンの状態
| Direction | Length | Type | Payload |
|---|---|---|---|
| コントローラー -> 本体 | 6 | 0x02 | ボタンの押下状態(2バイト) |
参考元ではCommandと表現されていますが、これはコントローラーからFlexiSpotを操作するコマンドとかではなく、単に押されているボタンの状態を表現しています。
HID KeyboardのInput Reportみたいなものです。
なので、Payloadが 00 00 のパケットは、Wake Upコマンドではなく、単にボタンが押されていないという状態を送っているだけですね。
| ボタン | Payload Bit |
|---|---|
| Up | 01 00 |
| Down | 02 00 |
| M | 20 00 |
| Preset 1 | 04 00 |
| Preset 2 | 08 00 |
| Preset 3 (stand) | 10 00 |
| Preset 4 (sit) | 00 01 |
本体側から 9b 04 11 7c c3 9d が届いたときに、ボタンが何も押されていなければ 9b 06 02 00 00 6c a1 9d を、Upが押されていれば 9b 06 02 01 00 fc a0 9d を送るようなシーケンスになります。
また、Up/Downボタン同時押しで緊急停止の感度調整ができたりしますが、そういうボタンの同時押しがされているときは、たとえばUpとDown両方同時押しなら 01 00 と 02 00 の OR をとった 03 00 をPayloadに入れて 9b 06 02 03 00 9c a1 9d を送る形になります。
画面表示の状態
| Direction | Length | Type | Payload |
|---|---|---|---|
| 本体 -> コントローラー | 7 | 0x12 | 7セグメントLEDの表示状態(3バイト) |
これに関しては参考元のGitHubレポジトリ記載の通りです。
7セグメントLEDのどの場所を点灯させるか指定する形で表示内容が届きます。Payloadは3桁の表示内容で3バイトで、画面が消えているときはもちろん 00 00 00 が来ます。
これを読み取ってうまく文字列・数値に変換することで高さを取得することができます。
また、数字以外にもたとえばMボタンを押したときに 5- と表示されるハイフンだったり、ロック時の LoC とかリセット時の RST なんかが表示されるため、以下の表くらいの表示は対応しておくといいかと思います。
ちなみに、RSTのSは5と区別がつかないので、そこはうまくR5Tが来たときに置き換えるとかで対処する必要があります。
| Payloadの値 | 表示 | Payloadの値 | 表示 | Payloadの値 | 表示 | ||
|---|---|---|---|---|---|---|---|
| 0x3F | 0 | 0xBF | 0. | 0x40 | - | ||
| 0x06 | 1 | 0x86 | 1. | 0x39 | C | ||
| 0x5B | 2 | 0xDB | 2. | 0x79 | E | ||
| 0x4F | 3 | 0xCF | 3. | 0x38 | L | ||
| 0x66 | 4 | 0xE6 | 4. | 0x77 | R | ||
| 0x6D | 5 | 0xED | 5. | 0x31 | T | ||
| 0x7D | 6 | 0xFD | 6. | 0x5C | o | ||
| 0x07 | 7 | 0x87 | 7. | ||||
| 0x7F | 8 | 0xFF | 8. | ||||
| 0x6F | 9 | 0xEF | 9. |
不明なパケット1
| Direction | Length | Type | Payload |
|---|---|---|---|
| 本体 -> コントローラー | 4 | 0x15 | なし |
本体からコントローラーに対して頻繁に送られているパケットです。
約16msごとに本体から届いていて、状態リクエストよりも頻度が高いです。
Keep-aliveとか、コントローラーのクロックとかで使う用なのかなと推測してて、コントローラーを自作するときは特に読み取る必要なさそうだなと思っています。
不明なパケット2
| Direction | Length | Type | Payload |
|---|---|---|---|
| コントローラー -> 本体 | 5 | 0x02 | 0x82 |
コントローラーが電源On直後、最初の状態リクエストが届くまで100ms間隔で本体に送っています。
おそらくこれがコントローラー側から本体を叩き起こすWake Upコマンドに相当するものじゃないかなと思っているのですが、それがなくてもE7 Proは動いてくれるので、これも特に必要はなさそうです。
ロック
Mボタン長押しでロックする際に、青色のLEDが点滅・点灯したり音が鳴ります。
これに関してはFlexiSpot本体側ではなくコントローラー側で制御してそうな感じがしました。解析結果としては以下のような感じ。
- LED点灯(ロック)・消灯(ロック解除)時で画面表示以外のパケットに変化がない
- 純正コントローラーとESP32両方接続して、ESP32側からMボタン長押し操作をしてもLED点滅・ブザー鳴動しない
- コントローラーから電源とTx/Rx以外の接続を遮断してもLED・ブザーが動作する
なので、ロック時の挙動を再現するなら
- Mボタンが押されている && LED表示がLoC: LED点滅 + ブザー
- 現在or最後のLED表示内容がLoC: LED点灯
- それ以外: LED消灯
のような形でいいかなと思います。
実装
特に語ることはないです。UARTで通信して終わりです。
#pragma once
#include "esp_err.h"
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef void (*flexispot_notify_callback_t)();
typedef enum {
FLEXISPOT_BUTTON_NONE = 0,
FLEXISPOT_BUTTON_UP = 0x0001,
FLEXISPOT_BUTTON_DOWN = 0x0002,
FLEXISPOT_BUTTON_MEMORY = 0x0020,
FLEXISPOT_BUTTON_PRESET1 = 0x0004,
FLEXISPOT_BUTTON_PRESET2 = 0x0008,
FLEXISPOT_BUTTON_PRESET3 = 0x0010,
FLEXISPOT_BUTTON_PRESET4 = 0x0100,
} flexispot_button_t;
extern flexispot_button_t flexispot_button_state;
void flexispot_send_packet(uint8_t type, const uint8_t *payload, size_t size);
void flexispot_set_button_state(flexispot_button_t buttons);
void flexispot_get_lcd_str(char *buffer, size_t size);
void flexispot_register_notify_callback(flexispot_notify_callback_t callback);
esp_err_t flexispot_init(void);
#ifdef __cplusplus
}
#endif#include "flexispot.h"
#include "driver/uart.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include <string.h>
static const char *TAG = "flexispot";
flexispot_button_t flexispot_button_state = FLEXISPOT_BUTTON_NONE;
// UART Configuration for M5AtomS3
#define FLEXISPOT_UART_NUM UART_NUM_1
#define FLEXISPOT_TXD_PIN GPIO_NUM_5
#define FLEXISPOT_RXD_PIN GPIO_NUM_6
#define FLEXISPOT_DETECT_PIN GPIO_NUM_7
#define FLEXISPOT_BUF_SIZE 1024
static uint16_t crc16_modbus(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++) {
if (crc & 1) crc = (crc >> 1) ^ 0xA001;
else crc >>= 1;
}
}
return crc;
}
void flexispot_send_packet(uint8_t type, const uint8_t *payload, size_t size) {
uint8_t packet[32] = { 0x9b, size + 4, type };
if (size) memcpy(&packet[3], payload, size);
uint16_t checksum = crc16_modbus(&packet[1], size + 2);
packet[3 + size] = checksum >> 8; // CRC high byte first (big endian)
packet[4 + size] = checksum & 0xFF; // CRC low byte
packet[5 + size] = 0x9d;
char hex_str[128] = {0};
for (int i = 0; i < 6 + size; i++) {
sprintf(hex_str + i * 3, "%02x ", packet[i]);
}
// ESP_LOGI(TAG, "TX: %s", hex_str);
int len = uart_write_bytes(FLEXISPOT_UART_NUM, packet, 6 + size);
if (len != 6 + size) ESP_LOGE(TAG, "Failed to send packet");
}
static void send_button_state(flexispot_button_t buttons) {
// ESP_LOGI(TAG, "Sending button state [0x%04d]", buttons);
flexispot_send_packet(0x02, (uint8_t[]){ buttons & 0xFF, buttons >> 8 }, 2);
}
/*
* MARK: RX
*/
static int rx_offset = -1, rx_remain = 0;
static uint8_t rx_buf[128];
static SemaphoreHandle_t current_state_mutex;
static struct {
char lcd_str[32];
int height;
} current_state;
static flexispot_notify_callback_t notify_callback;
static char *decode_7seg(char *str, uint8_t byte) {
struct { char c; uint8_t code; } chars[] = {
{ '-', 0x40 },
{ '0', 0x3f },
{ '1', 0x06 },
{ '2', 0x5b },
{ '3', 0x4f },
{ '4', 0x66 },
{ '5', 0x6d },
{ '6', 0x7d },
{ '7', 0x07 },
{ '8', 0x7f },
{ '9', 0x6f },
{ 'C', 0x39 },
{ 'E', 0x79 },
{ 'L', 0x38 },
{ 'R', 0x77 },
{ 'T', 0x31 },
{ 'o', 0x5c },
};
// Remove decimal point bit for comparison
uint8_t segments = byte & 0x7f;
for (int i = 0; i < sizeof(chars) / sizeof(chars[0]); i++) {
if (chars[i].code == segments) {
*str++ = chars[i].c;
break;
}
}
if (byte & 0x80) {
*str++ = '.';
}
return str;
}
static void display_packet_received(uint8_t *data, int size) {
if (size != 7) return;
char buf[8] = {}, *ptr = buf;
for (int i = 1; i <= 3; i++) ptr = decode_7seg(ptr, data[i]);
if (ptr != buf) {
ESP_LOGI(TAG, "Display: %s", buf);
xSemaphoreTake(current_state_mutex, portMAX_DELAY);
strncpy(current_state.lcd_str, buf, sizeof(current_state.lcd_str));
xSemaphoreGive(current_state_mutex);
if (notify_callback) notify_callback();
}
}
void flexispot_get_lcd_str(char *buffer, size_t size) {
xSemaphoreTake(current_state_mutex, portMAX_DELAY);
strncpy(buffer, current_state.lcd_str, size);
xSemaphoreGive(current_state_mutex);
}
void flexispot_register_notify_callback(flexispot_notify_callback_t callback) {
notify_callback = callback;
}
static void rx_consume_byte(uint8_t byte) {
if (rx_offset < 0) {
if (byte == 0x9b) rx_offset = 0;
return;
}
if (rx_remain == 0) {
rx_remain = byte;
return;
}
rx_buf[rx_offset++] = byte;
if (--rx_remain == 0) {
if (rx_buf[rx_offset - 1] == 0x9d) {
char hex_str[64] = {0};
for (int i = 0; i < rx_offset && i < 20; i++) {
sprintf(hex_str + i * 3, "%02x ", rx_buf[i]);
}
// ESP_LOGI(TAG, "RX[%d]: %s", rx_offset, hex_str);
if (rx_buf[0] == 0x11) send_button_state(flexispot_button_state);
if (rx_buf[0] == 0x12) display_packet_received(rx_buf, rx_offset);
}
rx_offset = -1;
}
}
static void rx_task(void *pvParameters) {
uint8_t rx_buf[16];
ESP_LOGI(TAG, "UART Rx task started");
while (1) {
int len = uart_read_bytes(FLEXISPOT_UART_NUM, rx_buf, sizeof(rx_buf), pdMS_TO_TICKS(100));
for (int i = 0; i < len; i++) rx_consume_byte(rx_buf[i]);
}
}
static esp_err_t rx_task_start(void) {
BaseType_t ret = xTaskCreate(rx_task, "flexispot", 4096, NULL, 5, NULL);
if (ret != pdPASS) {
ESP_LOGE(TAG, "Failed to create UART Rx task");
return ESP_FAIL;
}
ESP_LOGI(TAG, "UART Rx task created");
return ESP_OK;
}
esp_err_t flexispot_init(void) {
current_state_mutex = xSemaphoreCreateMutex();
if (current_state_mutex == NULL) {
ESP_LOGE(TAG, "Failed to create current_state_mutex");
return ESP_FAIL;
}
esp_err_t ret;
// Configure UART parameters
uart_config_t uart_config = {
.baud_rate = 9600,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
// Install UART driver
ret = uart_driver_install(FLEXISPOT_UART_NUM, FLEXISPOT_BUF_SIZE * 2, 0, 0, NULL, 0);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to install UART driver: %s", esp_err_to_name(ret));
return ret;
}
// Configure UART parameters
ret = uart_param_config(FLEXISPOT_UART_NUM, &uart_config);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to configure UART parameters: %s", esp_err_to_name(ret));
uart_driver_delete(FLEXISPOT_UART_NUM);
return ret;
}
// Set UART pins
ret = uart_set_pin(FLEXISPOT_UART_NUM,
FLEXISPOT_TXD_PIN,
FLEXISPOT_RXD_PIN,
UART_PIN_NO_CHANGE,
UART_PIN_NO_CHANGE);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to set UART pins: %s", esp_err_to_name(ret));
uart_driver_delete(FLEXISPOT_UART_NUM);
return ret;
}
// Configure Detect PIN
gpio_config_t detect_pin_config = {
.pin_bit_mask = (1ULL << FLEXISPOT_DETECT_PIN),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
ret = gpio_config(&detect_pin_config);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to configure detect pin: %s", esp_err_to_name(ret));
uart_driver_delete(FLEXISPOT_UART_NUM);
return ret;
}
gpio_set_level(FLEXISPOT_DETECT_PIN, 1); // set detect pin always high
// Start UART Rx Task
ret = rx_task_start();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to start UART Rx Task: %s", esp_err_to_name(ret));
uart_driver_delete(FLEXISPOT_UART_NUM);
return ret;
}
ESP_LOGI(TAG, "Flexispot UART initialized successfully");
ESP_LOGI(TAG, " UART: UART%d, TX: GPIO%d, RX: GPIO%d",
FLEXISPOT_UART_NUM, FLEXISPOT_TXD_PIN, FLEXISPOT_RXD_PIN);
return ESP_OK;
}余談
今回僕の目的としてはM5Stack Tab5から操作することなので、とりあえずAtomS3-LiteをGATT ServerにしてBluetooth経由で操作しました。
FlexiSpot用のサービスにボタン用のWrite可能なCharacteristicと、ディスプレイ表示用のRead+Notify可能なCharacteristicを用意する形です。

