Skip to content

Commit 7800000

Browse files
committed
Add Encoder Support
Start with support for 1 device: Tuya Smart Knob (https://www.zigbee2mqtt.io/devices/ERS-10TZBVK-AA.html)
1 parent 44f74ed commit 7800000

23 files changed

Lines changed: 769 additions & 21 deletions

.devcontainer/devcontainer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"name": "tuya-zigbee-switch",
33
"image": "mcr.microsoft.com/devcontainers/cpp",
4+
"runArgs": ["--platform=linux/amd64" ],
45
"postCreateCommand": "sudo apt update && sudo apt install -y default-jdk python3-venv uncrustify && ./make_scripts/make_install.sh",
56
"remoteEnv": {
67
"VIRTUAL_ENV": "/workspaces/tuya-zigbee-switch/.venv",

device_db.yaml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6138,4 +6138,31 @@ SWITCH_ZEMISMART_TS0601_6GANG:
61386138
status: in_progress
61396139
info: Needs pinout. Secondary MCU. 6-gang!
61406140
threads: https://github.com/romasku/tuya-zigbee-switch/issues/209
6141-
store: https://www.zemismart.com/products/tb26-6
6141+
store: https://www.zemismart.com/products/tb26-6
6142+
REMOTE_TUYA_SMART_KNOB:
6143+
human_name: Tuya Smart Knob
6144+
category: remote
6145+
power: CR2032
6146+
neutral: without
6147+
output: nothing
6148+
device_type: end_device
6149+
stock_model_name: lumi.remote.b286acn01
6150+
stock_manufacturer_name: LUMI
6151+
stock_converter_manufacturer: Aqara
6152+
stock_converter_model: WXKG02LM_rev2
6153+
override_z2m_device: null
6154+
tuya_module: none
6155+
mcu_family: Silabs
6156+
mcu: EFR32MG21A020F768IM32
6157+
config_str: qja6nq5z;TS004F;LD0i;EB0uA0uA4u;
6158+
alt_config_str: null
6159+
old_manufacturer_names: null
6160+
old_zb_models: null
6161+
stock_manufacturer_id: null
6162+
stock_image_type: null
6163+
firmware_image_type: 54179
6164+
build: yes
6165+
status: in_progress
6166+
info: Encoder support in development
6167+
threads: null
6168+
store: https://www.aliexpress.com/item/1005006690028688.html

docs/supported_devices.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,8 @@ Support new devices: [contribute/porting.md](/docs/contribute/porting.md)
152152
| 🟧 | ✔️ | 🇴 | 🔌 | 🛜 | **TL** | `_TZ3000_ss98ec5d` <br> `TS011F` | [Moes PM wall socket](https://www.zigbee2mqtt.io/devices/ZK-EU.html) | | [`#420`](https://github.com/romasku/tuya-zigbee-switch/pull/420) | Power monitoring not implemented |
153153
| 🟧 | ✔️ | 🇴 | 🔌 | 🛜 | **TL** | `_TZ3210_vddauzhn` <br> `TS011F` | [Tuya PM wall socket](https://www.zigbee2mqtt.io/devices/TS011F_plug_1.html) | [`AlEx`](https://www.aliexpress.com/item/1005006374575890.html) | [`#426`](https://github.com/romasku/tuya-zigbee-switch/issues/426) | Power monitoring not implemented |
154154
| 🟧 | ✔️ | 🇵 | 🔌 | 🛜 | **TL** | `_TZ3000_gvn91tmx` <br> `TS011F` | [Aubess PM plug](https://www.zigbee2mqtt.io/devices/TS011F_plug_1.html) | [`AlEx`](https://aliexpress.com/item/1005005958920309.html) | [`#334`](https://github.com/romasku/tuya-zigbee-switch/issues/334) | Power monitoring not implemented |
155-
| 🟩 | ✔️ | 🇵 | 🔌 | 🛜 | **TL** | `LELLKI` <br> `JZ-ZB-001` | [Lellki plug](https://www.zigbee2mqtt.io/devices/TS011F_plug_1.html) | | [`#334`](https://github.com/romasku/tuya-zigbee-switch/issues/334) | Supported |
156-
| 🟧 | ✔️ | 🇵 | 🔌 | 🛜 | **TL** | `_TZ3000_ynmowqk2` <br> `TS011F` | [LIDL PM plug HG08673](https://www.zigbee2mqtt.io/devices/HG08673.html) | | | Power monitoring not implemented |
157155
| 🟥 | ❌️ | 🇷 | 🔋 | 🛜 | **NXP** | `LUMI` <br> `lumi.remote.b286acn01` | [Aqara WXKG02LM](https://www.zigbee2mqtt.io/devices/WXKG02LM_rev2.html) | | | NXP chip not supported, needs pinout |
158156
| 🟩 | ✔️ | 🇷 | 🔋 | 🛜 | **TL** | `_TZ3000_bgtzm4ny` <br> `TS0044` | [HOBEIAN ZG-101ZS](https://www.zigbee2mqtt.io/devices/ZG-101ZS.html) | | [`#237`](https://github.com/romasku/tuya-zigbee-switch/issues/237) | Supported |
159-
| 🟧 | ✔️ | 🇷 | 🔋 | 🛜 | **TL** | `HOBEIAN` <br> `ZG-101ZL` | [HOBEIAN ZG-101ZL button](https://www.zigbee2mqtt.io/devices/ZG-101ZL.html) | | [`#399`](https://github.com/romasku/tuya-zigbee-switch/issues/399) | 1MB variant working, 512KB not |
160-
| 🟩 | ✔️ | 🇷 | 🔋 | 🛜 | **TL** | `_TZ3000_fa9mlvja` <br> `TS0041` | [iHseno IH-K663 button](https://www.zigbee2mqtt.io/devices/IH-K663.html) | | [`#409`](https://github.com/romasku/tuya-zigbee-switch/issues/409) | Supported |
161157
| 🟩 | ✔️ | 🇷 | 🔋 | 🛜 | **TL** | `_TZ3000_mh9px7cq` <br> `TS0044` | [iHseno 4-button remote](https://www.zigbee2mqtt.io/devices/_TZ3000_mh9px7cq.html) | [`AlEx`](https://www.aliexpress.com/item/1005005792534172.html) [`Store`](https://allegro.pl/oferta/pilot-atlo-rc4-tuya-zigbee-tuya-smart-da-16513965398) | [`#171`](https://github.com/romasku/tuya-zigbee-switch/issues/171) [`#395`](https://github.com/romasku/tuya-zigbee-switch/pull/395) | Supported |
162158
| 🟧 | ✔️ | 🇷 | 🔋 | 🛜 | **TL** | `_TZ3000_nrfkrgf4` <br> `TS0046` | [iHseno 6-button remote](https://www.zigbee2mqtt.io/devices/TS0046.html) | | [`#416`](https://github.com/romasku/tuya-zigbee-switch/issues/416) | Max 4 buttons supported |
163159
| 🟨 | ✔️ | 🇷 | 🔋 || **SL** | `_TZ3000_rco1yzb1` <br> `TS004F` | [LIDL button](https://www.zigbee2mqtt.io/devices/HG08164.html) | | [`#388`](https://github.com/romasku/tuya-zigbee-switch/issues/388) | Lower battery life |
@@ -258,5 +254,7 @@ Support new devices: [contribute/porting.md](/docs/contribute/porting.md)
258254
| 🟩 | ✔️ | 🇸 | 🔌 || **SL** | `_TZ3000_ilauzyjm` <br> `TS0011` | [Zemismart 1-gang switch 🅱](https://www.zigbee2mqtt.io/devices/TS0011.html) | [`Store`](https://www.zemismart.com/products/kes-606us-z?VariantsId=14159) | [`#022`](https://github.com/romasku/tuya-zigbee-switch/issues/22) | Supported |
259255
| 🟩 | ✔️ | 🇸 | 🔌 || **SL** | `_TZ3000_rbl8c85w` <br> `TS0012` | [Zemismart 2-gang switch 🅱](https://www.zigbee2mqtt.io/devices/TS0012.html) | [`Store`](https://www.zemismart.com/products/kes-606us-z?VariantsId=14160) | [`#022`](https://github.com/romasku/tuya-zigbee-switch/issues/22) | Supported |
260256
| 🟧 | ❌️ | 🇸 | 🔌 | 🛜 | **TL** | `_TZE200_r731zlxk` <br> `TS0601` | [Zemismart TB26-6](https://www.zigbee2mqtt.io/devices/TB26-6.html) | [`Store`](https://www.zemismart.com/products/tb26-6) | [`#209`](https://github.com/romasku/tuya-zigbee-switch/issues/209) | Needs pinout. Secondary MCU. 6-gang! |
257+
| 🟩 | ✔️ | 🇲 | 🔌 | 🛜 | **TL** | `_TZ3000_knoj8lpk` <br> `TS0004` | [Zabby Encoder](https://www.zigbee2mqtt.io/devices/E1.html) | | [`#000`](https://github.com/romasku/tuya-zigbee-switch/issues/TBD) | Supported |
258+
| 🟧 | ✔️ | 🇷 | 🔋 || **SL** | `LUMI` <br> `lumi.remote.b286acn01` | [Tuya Smart Knob](https://www.zigbee2mqtt.io/devices/WXKG02LM_rev2.html) | [`AlEx`](https://www.aliexpress.com/item/1005006690028688.html) | | Encoder support in development |
261259

262260
Data from [`device_db.yaml`](/device_db.yaml)

helper_scripts/make_z2m_custom_converters.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
cover_switch_cnt = 0
4444
cover_cnt = 0
4545
indicators_cnt = 0
46+
encoder_cnt = 0
4647
has_dedicated_net_led = False
4748
has_battery_cluster = False
4849
for peripheral in peripherals:
@@ -58,6 +59,8 @@
5859
cover_cnt += 1
5960
if peripheral[0] == "I":
6061
indicators_cnt += 1
62+
if peripheral[0] == "E":
63+
encoder_cnt += 1
6164
if peripheral[0] == "L":
6265
has_dedicated_net_led = True
6366
if peripheral[:2] == "BT":
@@ -105,6 +108,8 @@
105108
else:
106109
cover_names = [f"cover_{index}" for index in range(cover_cnt)]
107110

111+
encoders = [f"encoder_{index}" for index in range(encoder_cnt)]
112+
108113
devices.append(
109114
{
110115
"zb_models": [zb_model] + (device.get("old_zb_models") or []),
@@ -117,6 +122,7 @@
117122
"coverNames": cover_names,
118123
"has_dedicated_net_led": has_dedicated_net_led,
119124
"has_battery_cluster": has_battery_cluster,
125+
"encoders": encoders,
120126
}
121127
)
122128

helper_scripts/templates/switch_custom.js.jinja

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ const {
77
binary,
88
windowCovering,
99
deviceAddCustomCluster,
10+
setupConfigureForBinding,
1011
} = require("zigbee-herdsman-converters/lib/modernExtend");
12+
const {presets: e, access: ea} = require("zigbee-herdsman-converters/lib/exposes");
1113
const {assertString} = require("zigbee-herdsman-converters/lib/utils");
1214
const reporting = require("zigbee-herdsman-converters/lib/reporting");
1315
const constants = require("zigbee-herdsman-converters/lib/constants");
@@ -27,6 +29,11 @@ const ota = require("zigbee-herdsman-converters/lib/ota");
2729
Generate with: `make tools/update_converters`
2830
********************************************************************/
2931

32+
const genEncoderPayload = (endpoint, action, stepSize) => ({
33+
[`${endpoint}_action`]: action,
34+
[`${endpoint}_step_size`]: stepSize,
35+
})
36+
3037
const romasku = {
3138
switchAction: (name, endpointName) =>
3239
enumLookup({
@@ -241,8 +248,10 @@ const romasku = {
241248
;
242249
} else if(part[0] == 'i') {
243250
; // TODO: write validation
251+
} else if(part[0] == 'E') {
252+
; // TODO: write validation
244253
} else {
245-
throw new Error(`Invalid entry ${part}. Should start with one of B, BT, C, D, I, L, M, R, S, SLP, X, i`);
254+
throw new Error(`Invalid entry ${part}. Should start with one of B, BT, C, D, I, L, M, R, S, SLP, X, i, E`);
246255
}
247256
}
248257
},
@@ -358,6 +367,73 @@ const romasku = {
358367
description: "Reverse motor direction (swap OPEN/CLOSE relays)",
359368
entityCategory: "config",
360369
}),
370+
encoderAction: (endpointName) =>{
371+
const exposes = [];
372+
exposes.push(e.enum(`${endpointName}_action`, ea.STATE, [])
373+
.withDescription("Last triggered action")
374+
.withCategory("diagnostic"))
375+
exposes.push(e.numeric(`${endpointName}_step_size`, ea.STATE, [])
376+
.withValueMin(0)
377+
.withValueMax(5000)
378+
.withDescription("Step size for last triggered action")
379+
.withCategory("diagnostic"))
380+
381+
const fromZigbee = [
382+
{
383+
cluster: "genLevelCtrl",
384+
type: ["commandStep"],
385+
convert:
386+
((model, msg, publish, options, meta) => {
387+
publish(genEncoderPayload(endpointName, `brightness_${msg.data.stepmode === 0 ? 'up' : 'down'}`, msg.data.stepsize));
388+
389+
setTimeout(() => {
390+
publish(genEncoderPayload(endpointName, '', 0));
391+
}, 20);
392+
393+
return;
394+
}),
395+
// biome-ignore lint/suspicious/noExplicitAny: generic
396+
},
397+
{
398+
cluster: "lightingColorCtrl",
399+
type: ["commandStepColorTemp"],
400+
convert:
401+
((model, msg, publish, options, meta) => {
402+
publish(genEncoderPayload(endpointName, `color_temp_${msg.data.stepmode === 1 ? 'up' : 'down'}`, msg.data.stepsize));
403+
404+
setTimeout(() => {
405+
publish(genEncoderPayload(endpointName, '', 0));
406+
}, 20);
407+
408+
return;
409+
}),
410+
// biome-ignore lint/suspicious/noExplicitAny: generic
411+
},
412+
{
413+
cluster: "genOnOff",
414+
type: ["commandToggle"],
415+
convert:
416+
((model, msg, publish, options, meta) => {
417+
publish(genEncoderPayload(endpointName, 'toggle_on_off', 0));
418+
419+
setTimeout(() => {
420+
publish(genEncoderPayload(endpointName, '', 0));
421+
}, 20);
422+
423+
return;
424+
}),
425+
// biome-ignore lint/suspicious/noExplicitAny: generic
426+
}
427+
];
428+
429+
const configure = [
430+
setupConfigureForBinding("genLevelCtrl", "output", [endpointName]),
431+
setupConfigureForBinding("lightingColorCtrl", "output", [endpointName]),
432+
setupConfigureForBinding("genOnOff", "output", [endpointName]),
433+
]
434+
435+
return {exposes, fromZigbee, configure, isModernExtend: true};
436+
},
361437
};
362438

363439
const definitions = [
@@ -414,6 +490,9 @@ const definitions = [
414490
{%- for coverName in device.coverNames -%}
415491
"{{coverName}}": {{loop.index + (device.switchNames | length) + (device.relayNames | length) + (device.coverSwitchNames | length)}},{{" "}}
416492
{%- endfor -%}
493+
{%- for encoder in device.encoders -%}
494+
"{{encoder}}": {{loop.index + (device.switchNames | length) + (device.relayNames | length) + (device.coverSwitchNames | length) + (device.coverNames | length)}},{{" "}}
495+
{%- endfor -%}
417496
} }),
418497
{% set first_endpoint = device.switchNames[0] if device.switchNames else (device.relayNames[0] if device.relayNames else (device.coverSwitchNames[0] if device.coverSwitchNames else device.coverNames[0])) %}
419498
romasku.deviceConfig("device_config", "{{first_endpoint}}"),
@@ -461,6 +540,9 @@ const definitions = [
461540
romasku.coverSwitchBindedMode("{{coverSwitchName}}_binded_mode", "{{coverSwitchName}}"),
462541
romasku.coverSwitchLongPressDuration("{{coverSwitchName}}_long_press_duration", "{{coverSwitchName}}"),
463542
{% endfor %}
543+
{% for encoder in device.encoders %}
544+
romasku.encoderAction("{{encoder}}"),
545+
{% endfor %}
464546
],
465547
meta: { multiEndpoint: true },
466548
configure: async (device, coordinatorEndpoint, logger) => {

src/base_components/encoder.c

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#include "encoder.h"
2+
#include "hal/printf_selector.h"
3+
#include "hal/timer.h"
4+
#include <stdbool.h>
5+
#include <stddef.h>
6+
7+
static void sw_callback(hal_gpio_pin_t pin, encoder_t *encoder);
8+
static void rotate_callback(hal_gpio_pin_t pin, encoder_t *encoder);
9+
10+
void encoder_init(encoder_t *encoder) {
11+
printf("Encoder Init, with PinA: %d, PinB: %d, PinSW: %d\r\n", encoder->pin_a, encoder->pin_b,
12+
encoder->pin_sw);
13+
14+
encoder->pin_sw_state = hal_gpio_read(encoder->pin_sw);
15+
encoder->pin_sw_last_change = hal_millis();
16+
17+
encoder->old_AB = 0b00000011; // TODO, we are assuming pin a & b start high. We should read their actual values.
18+
encoder->encval = 0;
19+
20+
hal_gpio_callback(encoder->pin_a, (gpio_callback_t)rotate_callback, encoder);
21+
hal_gpio_callback(encoder->pin_b, (gpio_callback_t)rotate_callback, encoder);
22+
hal_gpio_callback(encoder->pin_sw, (gpio_callback_t)sw_callback, encoder);
23+
24+
encoder->rotate_since_pressed = false;
25+
}
26+
27+
// Based on https://youtube.com/watch?v=fgOfSHTYeio
28+
// NOTE: Using a half step encoder (detent on high and low), so step size (encval) is reduced from 4 to 2 per step
29+
static void rotate_callback(hal_gpio_pin_t pin, encoder_t *encoder) {
30+
static const int8_t enc_states[] = { 0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0 };
31+
32+
encoder->old_AB <<= 2; // Push old pin values into into high positions IE 0b00000011 -> 0b00001100
33+
34+
if (hal_gpio_read(encoder->pin_a)) encoder->old_AB |= 0b00000010; // Read pin a state into position 2
35+
if (hal_gpio_read(encoder->pin_b)) encoder->old_AB |= 0b00000001; // Read pin b state into position 1
36+
37+
encoder->encval += enc_states[(encoder->old_AB & 0b00001111)]; // Use last four bits to lookup change in enc_state and apply to encval
38+
39+
if (encoder->encval > 1) {
40+
encoder->encval = 0;
41+
encoder->rotate_since_pressed = true;
42+
43+
if (encoder->pin_sw_state == 0) {
44+
printf("Encoder Rotating CW while Pressed\r\n");
45+
if (encoder->on_rotate_cw_while_pressed != NULL) {
46+
encoder->on_rotate_cw_while_pressed(encoder->callback_param);
47+
}
48+
}else{
49+
printf("Encoder Rotating CW\r\n");
50+
51+
if (encoder->on_rotate_cw != NULL) {
52+
encoder->on_rotate_cw(encoder->callback_param);
53+
}
54+
}
55+
}else if (encoder->encval < -1) {
56+
encoder->encval = 0;
57+
encoder->rotate_since_pressed = true;
58+
59+
if (encoder->pin_sw_state == 0) {
60+
printf("Encoder Rotating CCW while Pressed\r\n");
61+
if (encoder->on_rotate_ccw_while_pressed != NULL) {
62+
encoder->on_rotate_ccw_while_pressed(encoder->callback_param);
63+
}
64+
}else{
65+
printf("Encoder Rotating CCW\r\n");
66+
if (encoder->on_rotate_ccw != NULL) {
67+
encoder->on_rotate_ccw(encoder->callback_param);
68+
}
69+
}
70+
}
71+
}
72+
73+
static void sw_callback(hal_gpio_pin_t pin, encoder_t *encoder) {
74+
uint8_t new_state = hal_gpio_read(encoder->pin_sw);
75+
uint32_t now = hal_millis();
76+
77+
if (new_state != encoder->pin_sw_state &&
78+
(now - encoder->pin_sw_last_change) > 10) {
79+
encoder->pin_sw_state = new_state;
80+
encoder->pin_sw_last_change = now;
81+
82+
if (new_state == 0) {
83+
printf("Encoder Pressed\r\n");
84+
85+
encoder->rotate_since_pressed = false;
86+
87+
if (now - encoder->released_at_ms < encoder->multi_press_duration_ms) {
88+
encoder->multi_press_cnt += 1;
89+
printf("Multi press detected: %d\r\n", encoder->multi_press_cnt);
90+
if (encoder->on_multi_press != NULL) {
91+
encoder->on_multi_press(encoder->callback_param, encoder->multi_press_cnt);
92+
}
93+
} else {
94+
encoder->multi_press_cnt = 1;
95+
}
96+
97+
} else {
98+
printf("Encoder Released\r\n");
99+
encoder->released_at_ms = now;
100+
101+
if (!encoder->rotate_since_pressed && encoder->on_press != NULL)
102+
encoder->on_press(encoder->callback_param);
103+
}
104+
}
105+
}

src/base_components/encoder.h

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#ifndef _ENCODER_H_
2+
#define _ENCODER_H_
3+
4+
#include "hal/gpio.h"
5+
#include "hal/tasks.h"
6+
#include <stdint.h>
7+
#include <stdbool.h>
8+
9+
typedef void (*ev_encoder_callback_t)(void *);
10+
typedef void (*ev_encoder_multi_press_callback_t)(void *, uint8_t);
11+
12+
typedef struct {
13+
hal_gpio_pin_t pin_a; // Also known as CLK
14+
hal_gpio_pin_t pin_b; // Also known as DT
15+
uint8_t old_AB;
16+
int8_t encval;
17+
18+
hal_gpio_pin_t pin_sw;
19+
uint8_t pin_sw_state;
20+
uint32_t pin_sw_last_change;
21+
22+
bool rotate_since_pressed;
23+
24+
ev_encoder_callback_t on_press;
25+
ev_encoder_callback_t on_rotate_ccw;
26+
ev_encoder_callback_t on_rotate_cw;
27+
ev_encoder_callback_t on_rotate_ccw_while_pressed;
28+
ev_encoder_callback_t on_rotate_cw_while_pressed;
29+
void * callback_param;
30+
31+
// Needed to detect Multi presses for reset logic
32+
uint32_t released_at_ms;
33+
uint32_t multi_press_duration_ms;
34+
uint8_t multi_press_cnt;
35+
ev_encoder_multi_press_callback_t on_multi_press;
36+
} encoder_t;
37+
38+
void encoder_init(encoder_t *encoder);
39+
40+
#endif

0 commit comments

Comments
 (0)