Tasmota/tasmota/tasmota_xsns_sensor/xsns_81_seesaw_encoder.ino
Allen Schober 312ba73d6d
Add support for Adafruit I2C QT Rotary Encoder which uses Seesaw and refactor existing Adafruit Seesaw Soil sensor (#24270)
- Refactored `xsns_81_seesaw_soil.ino` to utilize a base class `xsns_81_seesaw.ino` for Adafruit Seesaw devices
- Add `xsns_81_seesaw_encoder.ino` for handling Adafruit I2C QT Rotary Encoder with NeoPixel and button functionalities.
- Implemented option to have Adafruit I2C rotary encoder behave like a GPIO rotary encoders
- Update Adafruit Seesaw library files to v1.7.9
2025-12-29 17:23:50 +01:00

565 lines
19 KiB
C++

/*
xsns_81_seesaw_encoder - Adafruit I2C QT Rotary Encoder support for Tasmota
Copyright (C) 2025 Allen Schober
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifdef USE_I2C
#ifdef USE_SEESAW_ENCODER
/*********************************************************************************************\
* SEESAW_ENCODER - Adafruit I2C QT Rotary Encoder with NeoPixel and Button
*
* I2C Address: 0x36, 0x37, 0x38, 0x39 (though Adafruit hw is configurable from 0x36 to 0x3D)
*
* The Adafruit I2C QT Rotary Encoder features:
* - Quadrature rotary encoder with detents
* - Push button (seesaw pin 24)
* - RGB NeoPixel LED (seesaw pin 6)
* - Controlled via seesaw firmware over I2C
*
* Implementation Note:
* Add #define SEESAW_ENCODER_LIKE_ROTARY to have driver follow the same patterns
* as tasmota_support/support_rotary.ino (ROTARY_V1) and to have consistent behavior
* across GPIO rotary encoders and this I2C rotary encoder. See support_rotary.ino
* for rotary encoder operation.
*
* Device Numbering:
* - Encoders are numbered based on detection order by default (SeeEnc-1, SeeEnc-2, etc.)
* - Detection scans addresses 0x36-0x39 in order
* - A single encoder at any address will always be named SeeEnc (no number)
* - #define SEESAW_ENCODER_PERSISTENT_NAMING to use I2C address-based naming
* (e.g., SeeEnc-36 instead of SeeEnc-1) for consistent naming across restarts
*
* Commands:
* - SeeEncSet<x> <position> - Set encoder position (e.g., SeeEncSet1 0)
* - SeeEncColor<x> <rrggbb> - Set NeoPixel color in hex (e.g., SeeEncColor1 FF0000 for red)
* - SeeEncColor<x> <r>,<g>,<b> - Set NeoPixel color as RGB values (e.g., SeeEncColor1 255,0,0)
*
* Light Control (when USE_LIGHT enabled):
* - First two encoders detected control lights similar to GPIO rotary encoders
* - SeeEnc1 (first encoder detected):
* * Button released: Dimmer control (RGB or all channels)
* * Button pressed: Color (RGB) or Color Temperature control
* - SeeEnc2 (second encoder detected):
* * Button released: Dimmer CW control
* * Button pressed: Color Temperature control
* - Configuration via existing SetOptions:
* * SetOption43 (steps): Change Rotary Max Steps (default 10)
* * SetOption98 (0/1): Direct light control (0, default) or rules mode (1)
* * SetOption113 (0/1): Power on with low dimmer when rotated while off
*
* Button Behavior:
* - Click alone (no rotation): Toggle relay (1st encoder -> relay 1, 2nd encoder -> relay 2)
* - Press during rotation: Modify rotation behavior (color/CT instead of dimmer). Clicks
* during rotation are ignored to prevent unwanted toggles.
\*********************************************************************************************/
// Have Seesaw I2C Encoder behave like a GPIO Rotary Encoder for light control and button handling
// #define SEESAW_ENCODER_LIKE_ROTARY
// Encoder Pin configuration
#define SEESAW_ENCODER_BUTTON_PIN 24
#define SEESAW_ENCODER_NEOPIXEL_PIN 6
#define SEESAW_ENCODER_TIMEOUT 2 // 2 * Handler() call which is usually 2 * 0.05 seconds
struct SeesawEncoder : public SeesawDevice {
SeesawEncoder(uint8_t addr) : SeesawDevice(addr),
position(0), previous_position(0), delta(0),
button(0), button_previous(0), pixel_color(0),
timeout(0), rel_position(0), changed(false),
last_change_time(0), rotation_occurred(false) {
type = SEESAW_TYPE_ENCODER;
abs_position[0] = 0;
abs_position[1] = 0;
}
virtual void Init() override {
// Enable encoder interrupt
Seesaw::Write8(address, SEESAW_ENCODER_BASE, SEESAW_ENCODER_INTENSET, 0x01);
// Set initial encoder position to 0
SetEncoderPosition(0);
// Configure button pin (24) as input with pullup
uint32_t pin_mask = (uint32_t)1 << SEESAW_ENCODER_BUTTON_PIN;
// Convert 32-bit pin mask to 4-byte buffer (big-endian)
uint8_t pin_buf[4] = {
(uint8_t)(pin_mask >> 24),
(uint8_t)(pin_mask >> 16),
(uint8_t)(pin_mask >> 8),
(uint8_t)(pin_mask & 0xFF)
};
// Set as input (clear direction bit)
Seesaw::Write(address, SEESAW_GPIO_BASE, SEESAW_GPIO_DIRCLR_BULK, pin_buf, 4);
// Enable pullup
Seesaw::Write(address, SEESAW_GPIO_BASE, SEESAW_GPIO_PULLENSET, pin_buf, 4);
// Set pin high (for pullup)
Seesaw::Write(address, SEESAW_GPIO_BASE, SEESAW_GPIO_BULK_SET, pin_buf, 4);
// Check if NeoPixel module is available (bit 14 = 0x4000)
// if (options & (1UL << SEESAW_NEOPIXEL_BASE)) {
// AddLog(LOG_LEVEL_INFO, PSTR("SEE: NeoPixel module IS available"));
// } else {
// AddLog(LOG_LEVEL_ERROR, PSTR("SEE: NeoPixel module NOT available in firmware!"));
// }
// }
// NeoPixel Init Step 1: updateType - Set speed to 800KHz (NEO_KHZ800 = 0x0000, so is800KHz = true = 1)
bool speed_ok = Seesaw::Write8(address, SEESAW_NEOPIXEL_BASE, SEESAW_NEOPIXEL_SPEED, 1);
// NeoPixel Init Step 2: updateLength - Set buffer length to numBytes
uint16_t num_bytes = 1 * 3; // 1 pixel * 3 bytes = 3 bytes
uint8_t len_buf[2] = {
(uint8_t)(num_bytes >> 8), // high byte
(uint8_t)(num_bytes & 0xFF) // low byte
};
bool len_ok = Seesaw::Write(address, SEESAW_NEOPIXEL_BASE, SEESAW_NEOPIXEL_BUF_LENGTH, len_buf, 2);
// NeoPixel Init Step 3: setPin - Set the NeoPixel output pin to 6
bool pin_ok = Seesaw::Write8(address, SEESAW_NEOPIXEL_BASE, SEESAW_NEOPIXEL_PIN, SEESAW_ENCODER_NEOPIXEL_PIN);
// NeoPixel Init Step 4: Initialize NeoPixel to off
SetPixelColor(0x000000);
// Read initial encoder state
position = GetEncoderPosition();
previous_position = position;
button = GetButton() ? 1 : 0;
button_previous = button;
last_change_time = millis();
valid = true;
#ifdef DEBUG_SEESAW_ENCODER
AddLog(LOG_LEVEL_DEBUG, PSTR("SEE: Init Encoder ADDR=%02X POS=%d BTN=%d PXL_SPD_OK=%d PXL_LEN_OK=%d PXL_PIN_OK=%d"),
address, position, button, speed_ok, len_ok, pin_ok);
#endif
#if defined(SEESAW_ENCODER_LIKE_ROTARY) && defined(USE_LIGHT)
// Initialize rotary settings if needed
InitRotarySettings();
#endif // SEESAW_ENCODER_LIKE_ROTARY && USE_LIGHT
}
virtual void Read() override {
// Read encoder delta (change since last read)
int32_t new_delta = GetEncoderDelta();
// Read encoder position
int32_t new_position = GetEncoderPosition();
// Update delta
delta = new_delta;
// Update position
previous_position = position;
position = new_position;
// Read button state
button_previous = button;
button = GetButton() ? 1 : 0;
// Update timestamp if changed
if (delta != 0 || button != button_previous) {
last_change_time = millis();
}
#ifdef DEBUG_SEESAW_ENCODER
if (delta != 0 || button != button_previous) {
AddLog(LOG_LEVEL_DEBUG, PSTR("SEE: READ ADDR=%02X POS=%d DELTA=%d BTN=%d"),
address, position, delta, button);
}
#endif
}
virtual void Handler() override {
// Handler logic mirrors support_rotary.ino RotaryHandler() (lines 195-278)
// to provide consistent behavior between GPIO and I2C rotary encoders
if (timeout) {
timeout--;
if (!timeout) {
#ifdef USE_LIGHT
if (!Settings->flag4.rotary_uses_rules) { // SetOption98 - Use rules instead of light control
ResponseLightState(0);
MqttPublishPrefixTopicRulesProcess_P(RESULT_OR_STAT, PSTR(D_CMND_STATE));
}
#endif // USE_LIGHT
}
}
// Reset changed flag when button released
if (button_previous && !button) {
if (changed) {
changed = false;
}
}
// Check for rotation or button change
if (delta == 0 && button == button_previous) { return; }
timeout = SEESAW_ENCODER_TIMEOUT; // Prevent fast direction changes within 100ms
int32_t current_delta = delta;
delta = 0; // Clear delta after reading
// Postpone flash writes during rapid rotation
// Mirrors support_rotary.ino line 218-220
if (Settings->save_data && (TasmotaGlobal.save_data_counter < 2)) {
TasmotaGlobal.save_data_counter = 3;
}
bool button_pressed = button; // Button is pressed: set color temperature
if (button_pressed) { changed = true; }
abs_position[button_pressed] += current_delta;
if (abs_position[button_pressed] < 0) {
abs_position[button_pressed] = 0;
}
if (abs_position[button_pressed] > Settings->param[P_ROTARY_MAX_STEP]) {
abs_position[button_pressed] = Settings->param[P_ROTARY_MAX_STEP];
}
rel_position += current_delta;
if (rel_position > Settings->param[P_ROTARY_MAX_STEP]) {
rel_position = Settings->param[P_ROTARY_MAX_STEP];
}
if (rel_position < -(Settings->param[P_ROTARY_MAX_STEP])) {
rel_position = -(Settings->param[P_ROTARY_MAX_STEP]);
}
#ifdef DEBUG_SEESAW_ENCODER
AddLog(LOG_LEVEL_DEBUG, PSTR("SEE: %s btn=%d delta=%d abs_position[0]=%d abs_position[1]=%d, rel_position=%d"),
device_name, button_pressed, current_delta,
abs_position[0], abs_position[1], rel_position);
#endif
#ifdef SEESAW_ENCODER_LIKE_ROTARY
// Button click handling - toggle relay on a click without rotation
// On button click - track if rotation occurs
if (current_delta != 0 && button) {
rotation_occurred = true;
}
// On button release - check if it was just a click (no rotation)
if (button_previous && !button) {
if (!changed && !rotation_occurred) {
// Button released without being used for rotation - toggle relay
uint8_t relay_index = device_index + 1; // device_index is 0-based
ExecuteCommandPower(relay_index, POWER_TOGGLE, SRC_BUTTON);
// Early return to prevent light control code from interfering with toggle
return;
}
// Reset rotation flag for next button press
rotation_occurred = false;
}
#ifdef USE_LIGHT
// Light control (only first 2 encoders detected)
// Mirrors support_rotary.ino lines 227-254 (inline logic matching GPIO rotary behavior)
if (device_index < 2 && !Settings->flag4.rotary_uses_rules) { // SetOption98 - Use rules instead of light control
// Check if second encoder exists
// Matches support_rotary.ino line 228: bool second_rotary = (Encoder[1].pinb >= 0);
bool second_encoder = (SeesawMgr.GetTypeCount(SEESAW_TYPE_ENCODER) > 1);
if (device_index == 0) { // First encoder (lines 229-247 in support_rotary.ino)
if (button_pressed) {
// Color or CT control
if (second_encoder) {
// With second encoder: control color only
LightColorOffset(current_delta * Rotary.color_increment);
} else {
// Without second encoder: try CT, fallback to color
if (!LightColorTempOffset(current_delta * Rotary.ct_increment)) {
LightColorOffset(current_delta * Rotary.color_increment);
}
}
} else {
// Dimmer RGBCW or RGB only if second rotary
uint32_t dimmer_index = second_encoder ? 1 : 0;
if (!Settings->flag4.rotary_poweron_dimlow || TasmotaGlobal.power) { // SetOption113
LightDimmerOffset(dimmer_index, current_delta * Rotary.dimmer_increment);
} else {
if (current_delta > 0) { // Only power on if rotary increase
LightDimmerOffset(dimmer_index, -LightGetDimmer(dimmer_index) + ROTARY_START_DIM);
}
}
}
} else { // Second encoder (lines 248-254 in support_rotary.ino)
if (button_pressed) {
// Color Temperature
LightColorTempOffset(current_delta * Rotary.ct_increment);
} else {
// Dimmer CW
LightDimmerOffset(2, current_delta * Rotary.dimmer_increment);
}
}
return; // Skip rules processing for light control mode
}
#endif // USE_LIGHT
#endif // SEESAW_ENCODER_LIKE_ROTARY
// Trigger rules (when not in direct light control mode)
// Mirrors support_rotary.ino lines 257-273
Response_P(PSTR("{\"%s\":{\"Pos1\":%d,\"Pos2\":%d,\"Button\":%d}}"),
device_name,
abs_position[0],
abs_position[1],
button);
XdrvRulesProcess(0);
}
virtual void Show(bool json, const char *name) override {
// Store name for use in Handler() and debug logging
strlcpy(device_name, name, sizeof(device_name));
if (json) {
ResponseAppend_P(PSTR(",\"%s\":{\"Pos1\":%d,\"Pos2\":%d,\"Button\":%d,\"Color\":\"%06X\"}"),
name, abs_position[0], abs_position[1],
button, pixel_color);
#ifdef USE_WEBSERVER
} else {
WSContentSend_PD(PSTR("{s}%s Pos1{m}%d{e}"), name, abs_position[0]);
WSContentSend_PD(PSTR("{s}%s Pos2{m}%d{e}"), name, abs_position[1]);
WSContentSend_PD(PSTR("{s}%s Button{m}%d{e}"), name, button);
WSContentSend_PD(PSTR("{s}%s Color{m}#%06X{e}"), name, pixel_color);
#endif // USE_WEBSERVER
}
}
virtual bool HandleCommand(const char* cmd, uint32_t len) override {
// Commands: Set<x> <position>, Color<x> <rrggbb> or <r>,<g>,<b>
// This is called from the manager with cmd already pointing to the command
return false; // Commands handled via Tasmota command interface
}
bool SetEncoderPosition(int32_t pos) {
uint8_t buf[4] = {
(uint8_t)(pos >> 24),
(uint8_t)(pos >> 16),
(uint8_t)(pos >> 8),
(uint8_t)(pos & 0xFF)
};
bool success = Seesaw::Write(address, SEESAW_ENCODER_BASE, SEESAW_ENCODER_POSITION, buf, 4);
if (success) {
position = pos;
previous_position = pos;
}
#ifdef DEBUG_SEESAW_ENCODER
AddLog(LOG_LEVEL_DEBUG, PSTR("SEE: WRITE ADDR=%02X val=%d success=%d"),
address, pos, success);
#endif
return success;
}
bool SetPixelColor(uint32_t color) {
// Set NeoPixel buffer: pixel index (2 bytes) + GRB color data (3 bytes)
uint8_t buf[5] = {
0, // index high byte
0, // index low byte
(uint8_t)(color >> 8), // G
(uint8_t)(color >> 16), // R
(uint8_t)(color & 0xFF) // B
};
if (!Seesaw::Write(address, SEESAW_NEOPIXEL_BASE, SEESAW_NEOPIXEL_BUF, buf, 5)) {
return false;
}
// Show the pixel
bool success = Seesaw::Write(address, SEESAW_NEOPIXEL_BASE, SEESAW_NEOPIXEL_SHOW, nullptr, 0);
if (success) {
pixel_color = color;
}
#ifdef DEBUG_SEESAW_ENCODER
AddLog(LOG_LEVEL_DEBUG, PSTR("SEE: COLOR ADDR=%02X color=%06X success=%d"),
address, color, success);
#endif
return success;
}
static const char id[] PROGMEM;
private:
int32_t GetEncoderPosition() {
uint8_t buf[4];
if (!Seesaw::Read(address, SEESAW_ENCODER_BASE, SEESAW_ENCODER_POSITION, buf, 4)) {
return 0;
}
return ((int32_t)buf[0] << 24) | ((int32_t)buf[1] << 16) | ((int32_t)buf[2] << 8) | (int32_t)buf[3];
}
int32_t GetEncoderDelta() {
uint8_t buf[4];
if (!Seesaw::Read(address, SEESAW_ENCODER_BASE, SEESAW_ENCODER_DELTA, buf, 4)) {
return 0;
}
return ((int32_t)buf[0] << 24) | ((int32_t)buf[1] << 16) | ((int32_t)buf[2] << 8) | (int32_t)buf[3];
}
bool GetButton() {
uint8_t buf[4];
if (!Seesaw::Read(address, SEESAW_GPIO_BASE, SEESAW_GPIO_BULK, buf, 4)) {
return false;
}
uint32_t gpio_value = ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) |
((uint32_t)buf[2] << 8) | (uint32_t)buf[3];
// Button is on pin 24, active low with pullup
return !(gpio_value & ((uint32_t)1 << SEESAW_ENCODER_BUTTON_PIN));
}
#if defined(SEESAW_ENCODER_LIKE_ROTARY) && defined(USE_LIGHT)
void InitRotarySettings() {
#ifdef ROTARY_V1
if (Rotary.present) { return; } // GPIO rotaries already initialized their settings
#endif
// No GPIO Rotary present, initialize for Seesaw Encoders
RotaryInitMaxSteps();
}
#endif // SEESAW_ENCODER_LIKE_ROTARY && USE_LIGHT
int32_t position;
int32_t previous_position;
int32_t delta;
uint8_t button;
uint8_t button_previous;
uint32_t pixel_color;
uint8_t timeout;
int8_t abs_position[2];
int8_t rel_position;
bool changed;
uint32_t last_change_time;
bool rotation_occurred;
};
const char SeesawEncoder::id[] PROGMEM = "ENCODER";
// Factory function implementation
SeesawDevice* SeesawManager::CreateEncoderDevice(uint8_t addr) {
return new SeesawEncoder(addr);
}
// Helper function to find encoder by command index
// Returns nullptr if index is out of range
// Sets total_count to the total number of encoders found
SeesawEncoder* GetEncoderByIndex(uint8_t cmd_index, uint8_t* total_count = nullptr) {
uint8_t encoder_count = 0;
// Count encoder devices
for (uint8_t i = 0; i < SeesawMgr.GetCount(); i++) {
SeesawDevice* dev = SeesawMgr.GetDevice(i);
if (dev && dev->GetType() == SEESAW_TYPE_ENCODER) {
encoder_count++;
}
}
if (total_count) {
*total_count = encoder_count;
}
if (cmd_index < 1 || cmd_index > encoder_count) {
return nullptr;
}
// Find the Nth encoder device
uint8_t encoder_index = 0;
for (uint8_t i = 0; i < SeesawMgr.GetCount(); i++) {
SeesawDevice* dev = SeesawMgr.GetDevice(i);
if (dev && dev->GetType() == SEESAW_TYPE_ENCODER) {
encoder_index++;
if (encoder_index == cmd_index) {
return static_cast<SeesawEncoder*>(dev); // Safe: dev validated above
}
}
}
return nullptr;
}
// Command handlers
#define D_PRFX_SEEENC "SeeEnc"
#define D_CMND_SEEENC_SET "Set"
#define D_CMND_SEEENC_COLOR "Color"
const char kSeeEncCommands[] PROGMEM = D_PRFX_SEEENC "|"
D_CMND_SEEENC_SET "|" D_CMND_SEEENC_COLOR;
void CmndSeeEncSet(void) {
// Command format: SeeEncSet<x> <position>
uint8_t encoder_count = 0;
SeesawEncoder* encoder = GetEncoderByIndex(XdrvMailbox.index, &encoder_count);
if (!encoder) {
ResponseCmndIdxNumber(encoder_count);
return;
}
if (encoder->SetEncoderPosition(XdrvMailbox.payload)) {
ResponseCmndNumber(XdrvMailbox.payload);
} else {
ResponseCmndFailed();
}
}
void CmndSeeEncColor(void) {
// Command format: SeeEncColor<x> <rrggbb> or <r>,<g>,<b>
uint8_t encoder_count = 0;
SeesawEncoder* encoder = GetEncoderByIndex(XdrvMailbox.index, &encoder_count);
if (!encoder) {
ResponseCmndIdxNumber(encoder_count);
return;
}
uint32_t color = 0;
// Check if input is hex format (RRGGBB) or comma-separated (R,G,B)
if (strchr(XdrvMailbox.data, ',')) {
// Parse R,G,B format
uint8_t r, g, b;
if (sscanf(XdrvMailbox.data, "%hhu,%hhu,%hhu", &r, &g, &b) == 3) {
color = ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b;
} else {
ResponseCmndError();
return;
}
} else {
// Parse hex format
color = strtoul(XdrvMailbox.data, nullptr, 16);
}
if (encoder->SetPixelColor(color)) {
ResponseCmndIdxChar(XdrvMailbox.data);
} else {
ResponseCmndFailed();
}
}
void (* const SeeEncCommand[])(void) PROGMEM = {
&CmndSeeEncSet,
&CmndSeeEncColor };
#endif // USE_SEESAW_ENCODER
#endif // USE_I2C