From 098f9a1b196ccd02b68a6938b776f7e53f827ac2 Mon Sep 17 00:00:00 2001 From: igorufox Date: Sat, 24 Oct 2026 20:14:32 +0300 Subject: [PATCH] feat(button): AC frequency detection switch support (Moes MS-104B) AcSwitch is a BasePin polled by EventEmitter with an IRAM ISR via attachInterruptArg; adds btnMinDuty duty filter against capacitive pickup, btnAcConfirm, btnAcFreq period filter, btnMinPulses, btnAcDbg stats, boot-state sync, settings applied on reload and web UI fields; board config in hardware.h. --- code/espurna/DebounceEvent.cpp | 6 + code/espurna/ac_switch.cpp | 360 ++++++++++++++++++++++++++++++ code/espurna/ac_switch.h | 146 ++++++++++++ code/espurna/button.cpp | 189 +++++++++++++++- code/espurna/config/general.h | 18 ++ code/espurna/config/hardware.h | 43 ++++ code/espurna/config/types.h | 1 + code/espurna/libs/DebounceEvent.h | 4 + code/html/spec/button.spec.mjs | 130 +++++++++++ code/html/src/button.mjs | 86 +++++++ code/html/src/index.mjs | 2 + code/html/src/panel-relay.html | 30 +++ code/html/src/template-relay.html | 50 +++++ code/platformio.ini | 4 + 14 files changed, 1060 insertions(+), 9 deletions(-) create mode 100644 code/espurna/ac_switch.cpp create mode 100644 code/espurna/ac_switch.h create mode 100644 code/html/spec/button.spec.mjs create mode 100644 code/html/src/button.mjs diff --git a/code/espurna/DebounceEvent.cpp b/code/espurna/DebounceEvent.cpp index 774c1b9614..1511887898 100644 --- a/code/espurna/DebounceEvent.cpp +++ b/code/espurna/DebounceEvent.cpp @@ -80,6 +80,12 @@ bool EventEmitter::isPressed() { return (_value != _default_value); } +void EventEmitter::sync() { + if (_pin) { + _value = (_pin->digitalRead() == (HIGH)); + } +} + const BasePinPtr& EventEmitter::pin() const { return _pin; } diff --git a/code/espurna/ac_switch.cpp b/code/espurna/ac_switch.cpp new file mode 100644 index 0000000000..589b9f01e7 --- /dev/null +++ b/code/espurna/ac_switch.cpp @@ -0,0 +1,360 @@ +/* + +AC FREQUENCY DETECTION SWITCH MODULE + +*/ + +#include "espurna.h" + +#if BUTTON_SUPPORT + +#include "ac_switch.h" + +#include +#include + +// ISR always runs on the same (only) core. Masking interrupts while the loop() side +// reads and resets the shared counters is enough, ISR itself needs no extra protection. + +namespace espurna { +namespace { + +constexpr size_t AcSwitchPins { EXTERNAL_NUM_INTERRUPTS }; + +// Which object currently owns the pin interrupt. Prevents a second object +// attaching to the same pin and the first one detaching it from under the second. +std::array owners{}; + +// Without the frequency filter, edges closer than this to the last counted one are noise. +// (100Hz full-wave pulses are 10ms apart, 50Hz half-wave edges are ~10ms apart) +constexpr unsigned long NoiseFloor { 5000ul }; + +// Initial state is sampled in the constructor, do not block for too long +constexpr unsigned long InitialWindowMax { 500ul }; + +constexpr uint16_t tolerance_min(unsigned long period) { + return static_cast((period * 95ul) / 100ul); +} + +constexpr uint16_t tolerance_max(unsigned long period) { + return static_cast((period * 105ul) / 100ul); +} + +} // namespace + +bool AcSwitch::available(unsigned char pin) { + return (pin < AcSwitchPins) + && (digitalPinToInterrupt(pin) != NOT_AN_INTERRUPT) + && (owners[pin] == nullptr); +} + +AcSwitch::AcSwitch(BasePinPtr&& pin, Config config) : + _base(std::move(pin)), + _pin(_base ? _base->pin() : GPIO_NONE), + _timeout_ms(config.timeout_ms), + _min_pulses(config.min_pulses), + _min_duty(config.min_duty), + _confirm(std::max(2ul, std::min(config.confirm, 10ul))), + _debug(config.debug) +{ + _frequency(config.frequency); + + // Optocoupler output is usually open-collector + // (EventEmitter re-applies the configured pin mode right after this, see pinMode()) + pinMode(INPUT_PULLUP); + _window_start = micros(); + _segment_start = _window_start; + _tracked_level = _level(); + _last_debug = millis(); + _attach(); + + // Initial state is the baseline. Sample it right away, so the button does not report + // the switch position at boot as an event + if (_attached) { + delay(std::min(_timeout_ms, InitialWindowMax)); + + const auto window = _take_window(); + _state = _on(window); + _candidate_state = _state; + _latch = 1; + _last_check = millis(); + + if (_debug) { + DEBUG_MSG_P(PSTR("[AC] Pin %u: initial state %s (count: %lu, min: %lu, low: %lu%%, min: %lu%%)\n"), + _pin, _state ? "ON" : "OFF", window.pulses, _min_pulses, window.duty, _min_duty); + } + } +} + +AcSwitch::~AcSwitch() { + _detach(); +} + +void AcSwitch::_attach() { + if (_attached || !available(_pin)) { + return; + } + + owners[_pin] = this; + attachInterruptArg(_pin, _isr_static, this, CHANGE); + _attached = true; +} + +void AcSwitch::_detach() { + if (!_attached) { + return; + } + + detachInterrupt(_pin); + + if ((_pin < AcSwitchPins) && (owners[_pin] == this)) { + owners[_pin] = nullptr; + } + + _attached = false; +} + +const char* AcSwitch::id() const { + return "AcSwitch"; +} + +unsigned char AcSwitch::pin() const { + return _pin; +} + +// Keep the internal pull-up unless something else was explicitly requested +void AcSwitch::pinMode(int8_t mode) { + if (_base) { + _base->pinMode((mode == INPUT) ? INPUT_PULLUP : mode); + } +} + +void AcSwitch::digitalWrite(int8_t val) { + if (_base) { + _base->digitalWrite(val); + } +} + +// CHANGE interrupt sees both edges, but their spacing depends on the circuit: +// - full-wave (bridge / anti-parallel LEDs): short pulse at every zero crossing, +// same-direction edges are 1/(2f) apart (10ms @ 50Hz, 8.33ms @ 60Hz) +// - half-wave (single LED): line is active for (roughly) a half of every cycle, and the +// duty cycle is rarely 50%, e.g. 8ms / 12ms. Same-direction edges are 1/f apart (20ms @ 50Hz, 16.67ms @ 60Hz) +// Measure the interval between the current edge and the one before the previous (i.e. same direction), +// and accept either of the periods with a 5% tolerance +void AcSwitch::_frequency(int frequency) { + _full_min = 0; + _full_max = 0; + _half_min = 0; + _half_max = 0; + + if ((frequency == 50) || (frequency == 60)) { + const unsigned long half = 1000000ul / static_cast(frequency); + const unsigned long full = half / 2ul; + + _full_min = tolerance_min(full); + _full_max = tolerance_max(full); + _half_min = tolerance_min(half); + _half_max = tolerance_max(half); + } +} + +// Settings reload, the current state stays as is and counting starts over with the new parameters +void AcSwitch::configure(Config config) { + noInterrupts(); + _timeout_ms = config.timeout_ms; + _min_pulses = config.min_pulses; + _min_duty = config.min_duty; + _confirm = std::max(2ul, std::min(config.confirm, 10ul)); + _frequency(config.frequency); + _pulses = 0; + _low_us = 0; + _window_start = micros(); + interrupts(); + + _debug = config.debug; + _candidate_state = _state; + _latch = 0; + _last_check = millis(); +} + +// Direct register read, ISR must not call anything in flash +bool IRAM_ATTR AcSwitch::_level() const { + return GPIP(_pin); +} + +AcSwitch::Window AcSwitch::_take_window() { + const unsigned long now = micros(); + + noInterrupts(); + unsigned long count = _pulses; + _pulses = 0; + + unsigned long low = _low_us; + _low_us = 0; + + // LOW segment that is still going on belongs to this window as well, + // the ISR only adds the part after the new window start + if (!_tracked_level) { + const unsigned long since = + (static_cast(_segment_start - _window_start) > 0) ? _segment_start : _window_start; + low += now - since; + } + + const unsigned long length = now - _window_start; + _window_start = now; + interrupts(); + + return Window{ + .pulses = count, + .duty = length ? static_cast((static_cast(low) * 100) / length) : 0, + }; +} + +bool AcSwitch::_on(const Window& window) const { + return (window.pulses >= _min_pulses) + && (!_min_duty || (window.duty >= _min_duty)); +} + +void AcSwitch::_debug_stats(unsigned long now) { + noInterrupts(); + unsigned long d_count = _debug_pulse_count; + unsigned long d_valid = _debug_valid_pulse_count; + unsigned long d_min = _min_period; + unsigned long d_max = _max_period; + unsigned long d_sum = _sum_period; + unsigned long d_p_count = _period_count; + unsigned long d_low = _debug_low_us; + unsigned long d_low_min = _min_low; + unsigned long d_low_max = _max_low; + + // Reset stats + _debug_pulse_count = 0; + _debug_valid_pulse_count = 0; + _min_period = -1UL; + _max_period = 0; + _sum_period = 0; + _period_count = 0; + _debug_low_us = 0; + _min_low = -1UL; + _max_low = 0; + interrupts(); + + // LOW share of the whole period, plus the shortest and the longest LOW pulse + const unsigned long elapsed = now - _last_debug; + const unsigned long low = elapsed ? (d_low / 10ul) / elapsed : 0; + + unsigned long avg = d_p_count ? (d_sum / d_p_count) : 0; + DEBUG_MSG_P(PSTR("[AC] Pin %u: edges=%lu valid=%lu period(us) min=%lu max=%lu avg=%lu low=%lu%% width(us) min=%lu max=%lu\n"), + _pin, d_count, d_valid, (d_min == -1UL ? 0 : d_min), d_max, avg, + low, (d_low_min == -1UL ? 0 : d_low_min), d_low_max); + + _last_debug = now; +} + +int AcSwitch::digitalRead() { + unsigned long now = millis(); + + if (now - _last_check >= _timeout_ms) { + const auto window = _take_window(); + + // Debug output every 5 seconds + if (_debug && (now - _last_debug > 5000)) { + _debug_stats(now); + } + + bool new_state = _on(window); + + if (new_state == _candidate_state) { + if (_latch < 10) _latch++; + } else { + _candidate_state = new_state; + _latch = 0; + } + + // latch counts agreeing windows after the first one + if (((_latch + 1) >= _confirm) && (_candidate_state != _state)) { + if (_debug) { + DEBUG_MSG_P(PSTR("[AC] Pin %u: State changed %s -> %s (count: %lu, min: %lu, low: %lu%%, min: %lu%%)\n"), + _pin, _state ? "ON" : "OFF", _candidate_state ? "ON" : "OFF", + window.pulses, _min_pulses, window.duty, _min_duty); + } + _state = _candidate_state; + } + + _last_check = now; + } + + // Return LOW when AC is detected (active) and HIGH when it is not. + // This matches the behavior of a pushbutton pulling to GND. + return _state ? LOW : HIGH; +} + +void IRAM_ATTR AcSwitch::_isr_static(void* arg) { + static_cast(arg)->_isr(); +} + +void IRAM_ATTR AcSwitch::_isr() { + const unsigned long now = micros(); + + ++_debug_pulse_count; + + // Same-direction period, current edge vs. the one before the previous edge + const bool have_period = (_edges >= 2); + const unsigned long period = now - _prev_edge; + + // Only a real level change ends a segment. When the pin already went back by the time it is read + // (glitch shorter than the interrupt latency), the segment simply goes on + const bool level = _level(); + if (level != _tracked_level) { + // Pin is HIGH again, the segment that just ended was LOW (active) + if (level) { + const unsigned long width = now - _segment_start; + const unsigned long since = + (static_cast(_segment_start - _window_start) > 0) ? _segment_start : _window_start; + _low_us += now - since; + _debug_low_us += width; + if (width < _min_low) _min_low = width; + if (width > _max_low) _max_low = width; + } + + _tracked_level = level; + _segment_start = now; + } + + _prev_edge = _last_edge; + _last_edge = now; + if (_edges < 2) { + ++_edges; + } + + if (have_period) { + if (period < _min_period) _min_period = period; + if (period > _max_period) _max_period = period; + _sum_period += period; + _period_count++; + } + + bool valid { false }; + + // Without the frequency filter, count every edge that is not noise + if (_full_min == 0) { + valid = (_last_counted == 0) || ((now - _last_counted) >= NoiseFloor); + if (valid) { + _last_counted = now; + } + // Otherwise, only edges matching either expected period + } else if (have_period) { + valid = ((period >= _full_min) && (period <= _full_max)) + || ((period >= _half_min) && (period <= _half_max)); + } + + if (valid) { + ++_pulses; + ++_debug_valid_pulse_count; + } +} + +} // namespace espurna + +#endif // BUTTON_SUPPORT diff --git a/code/espurna/ac_switch.h b/code/espurna/ac_switch.h new file mode 100644 index 0000000000..d0a41bb911 --- /dev/null +++ b/code/espurna/ac_switch.h @@ -0,0 +1,146 @@ +/* + +AC FREQUENCY DETECTION SWITCH MODULE + +Mains wall-switch input, usually wired through an optocoupler. While the switch +is ON the GPIO sees a train of mains-frequency edges, while it is OFF the line stays +idle. Edges are counted in the CHANGE interrupt and evaluated in fixed windows. + +digitalRead() returns LOW while AC is present and HIGH while it is not, so the +input is expected to be used as a switch with HIGH default (idle) value. + +*/ + +#pragma once + +#include +#include "libs/BasePin.h" + +namespace espurna { + +class AcSwitch final : public BasePin { +public: + struct Config { + // time window for pulse counting (ms) + unsigned long timeout_ms; + // minimum number of pulses within the window for the ON state + unsigned long min_pulses; + // minimum percentage of the window spent in the active (LOW) level for the ON state, 0 to disable. + // Long switch wires pick up mains capacitively, the optocoupler then sees narrow pulses + // at the same frequency even while the switch is OFF. A closed switch keeps it on for most of the cycle + unsigned long min_duty; + // consecutive windows that must agree before the state changes (2..10). Coupling from other + // wires (e.g. another lamp driver starting up) can look like a closed switch for a couple of windows + unsigned long confirm; + // target AC frequency (50 or 60), 0 to disable frequency filter + int frequency; + // periodic pulse statistics and state changes in the debug log + bool debug; + }; + + // Only on-chip GPIO capable of CHANGE interrupts, which are not already used by another AcSwitch + static bool available(unsigned char pin); + + // Takes ownership of the (already locked) GPIO pin, which is still used for pinMode / digitalWrite. + // Caller is expected to check available(pin->pin()) first. + // Blocks for one counting window, so that the initial state is already known + AcSwitch(BasePinPtr&& pin, Config config); + ~AcSwitch() override; + + // BasePin implementation + const char* id() const override; + unsigned char pin() const override; + int digitalRead() override; + void pinMode(int8_t mode) override; + void digitalWrite(int8_t val) override; + + bool state() const { + return _state; + } + + bool attached() const { + return _attached; + } + + void debug(bool value) { + _debug = value; + } + + // Apply changed settings without re-creating the pin + void configure(Config config); + +private: + static void IRAM_ATTR _isr_static(void* arg); + void IRAM_ATTR _isr(); + bool IRAM_ATTR _level() const; + + void _attach(); + void _detach(); + void _frequency(int frequency); + + struct Window { + unsigned long pulses; + // percentage of the window spent in the active (LOW) level + unsigned long duty; + }; + + // Pulses and active level since the previous call, starts the next window + Window _take_window(); + bool _on(const Window& window) const; + void _debug_stats(unsigned long now); + + BasePinPtr _base; + unsigned char _pin; + bool _attached { false }; + + unsigned long _timeout_ms; + unsigned long _min_pulses; + unsigned long _min_duty; + unsigned long _confirm; + + // Accepted same-direction edge period ranges (us), 0 when filter is disabled + // - full-wave (pulse every half-cycle): 1 / (2 * f) + // - half-wave (pulse every cycle): 1 / f + uint16_t _full_min { 0 }; + uint16_t _full_max { 0 }; + uint16_t _half_min { 0 }; + uint16_t _half_max { 0 }; + + bool _debug { false }; + + volatile unsigned long _pulses { 0 }; + unsigned long _last_check { 0 }; + bool _state { false }; + bool _candidate_state { false }; + unsigned int _latch { 0 }; + + // ISR state. Last two edges, plus the last counted one for the unfiltered mode + volatile unsigned long _last_edge { 0 }; + volatile unsigned long _prev_edge { 0 }; + volatile unsigned char _edges { 0 }; + volatile unsigned long _last_counted { 0 }; + + // Time spent in the active (LOW) level since the window started (us). ISR adds every LOW segment + // that ended within the window, the one still going on at the end of the window is added by digitalRead() + volatile unsigned long _window_start { 0 }; + volatile unsigned long _low_us { 0 }; + + // Last level the ISR has seen and since when. Duty only follows real level changes, a glitch + // of a few us fires both interrupts before the first one reads the pin + volatile bool _tracked_level { true }; + volatile unsigned long _segment_start { 0 }; + + // Statistics for debugging + unsigned long _last_debug { 0 }; + volatile unsigned long _min_period { -1UL }; + volatile unsigned long _max_period { 0 }; + volatile unsigned long _sum_period { 0 }; + volatile unsigned long _period_count { 0 }; + volatile unsigned long _debug_pulse_count { 0 }; + volatile unsigned long _debug_valid_pulse_count { 0 }; + volatile unsigned long _debug_low_us { 0 }; + volatile unsigned long _min_low { -1UL }; + volatile unsigned long _max_low { 0 }; +}; + +} // namespace espurna diff --git a/code/espurna/button.cpp b/code/espurna/button.cpp index 59625e9368..b6f4174166 100644 --- a/code/espurna/button.cpp +++ b/code/espurna/button.cpp @@ -12,6 +12,7 @@ Copyright (C) 2019-2021 by Maxim Prokhorov configure(_buttonAcConfig(id)); + } + } + for (decltype(buttons) id = 0; id < buttons; ++id) { #if RELAY_SUPPORT espurna::button::relay::add_or_remove(id, espurna::button::settings::relay(id)); @@ -1520,7 +1625,8 @@ ButtonEventDelays _buttonDelays(size_t index) { .debounce = espurna::button::settings::debounceDelay(index), .repeat = espurna::button::settings::repeatDelay(index), .lngclick = espurna::button::settings::longClickDelay(index), - .lnglngclick = espurna::button::settings::longLongClickDelay(index)}; + .lnglngclick = espurna::button::settings::longLongClickDelay(index), + .min_pulses = espurna::button::settings::minPulses(index)}; } template @@ -1528,12 +1634,24 @@ void _buttonAdd(T&& button) { espurna::button::internal::buttons.emplace_back(std::forward(button)); } -espurna::button::Button _buttonWithPin(size_t index, BasePinPtr&& pin) { - return espurna::button::Button( - std::move(pin), - espurna::button::runtime_config(index), - _buttonActions(index), - _buttonDelays(index)); +// AC mains switch input. Wraps on-chip GPIO pin, which is still used for the pin mode setup. +// When pin cannot generate interrupts (e.g. GPIO16, MCP23S08, analog) or it is +// already used by another AcSwitch, nullptr is returned and the button stays without a pin. +espurna::AcSwitch* _buttonMakeAcSwitch(size_t index, BasePinPtr& pin) { + const auto gpio = pin->pin(); + if ((espurna::button::settings::provider(index) != ButtonProvider::Gpio) + || (espurna::button::settings::pinType(index) != GpioType::Hardware) + || !espurna::AcSwitch::available(gpio)) + { + DEBUG_MSG_P(PSTR("[BUTTON] Button #%u GPIO%hhu cannot be used for AC detection, button disabled\n"), + index, gpio); + return nullptr; + } + + auto* out = new espurna::AcSwitch(std::move(pin), _buttonAcConfig(index)); + pin.reset(out); + + return out; } espurna::button::Button _buttonWithoutPin(size_t index) { @@ -1542,6 +1660,54 @@ espurna::button::Button _buttonWithoutPin(size_t index) { _buttonDelays(index)); } +espurna::button::Button _buttonWithPin(size_t index, BasePinPtr&& pin) { + auto delays = _buttonDelays(index); + + auto config = espurna::button::runtime_config(index); + auto actions = _buttonActions(index); + + espurna::AcSwitch* ac_switch { nullptr }; + if (pin && espurna::button::settings::isFrequency(index)) { + ac_switch = _buttonMakeAcSwitch(index, pin); + + // Plain pin would read mains pulses as a stream of presses and releases + // and toggle the relay every few hundred ms. Keep the button, without a pin + if (!ac_switch) { + return _buttonWithoutPin(index); + } + + // Maintained wall switch, only position changes (press / release) are meaningful. + // AcSwitch reads LOW while AC is present, idle (no AC) is HIGH. + config.mode = debounce_event::types::Mode::Switch; + config.default_value = debounce_event::types::PinValue::High; + + // Switch never generates clicks. Still, make sure nothing like reset or + // factory reset could ever be triggered by a wall switch + actions.click = ButtonAction::None; + actions.dblclick = ButtonAction::None; + actions.trplclick = ButtonAction::None; + actions.lngclick = ButtonAction::None; + actions.lnglngclick = ButtonAction::None; + + // AcSwitch already uses debounce delay as its counting window, + // do not block the loop() in the EventEmitter for another one + delays.debounce = 0; + } + + auto out = espurna::button::Button( + std::move(pin), config, + std::move(actions), + std::move(delays)); + out.ac_switch = ac_switch; + + // Switch position at boot is the starting point, not an event + if (ac_switch && out.event_emitter) { + out.event_emitter->sync(); + } + + return out; +} + bool _buttonSetupProvider(size_t index, ButtonProvider provider) { bool result { false }; @@ -1628,6 +1794,11 @@ void buttonSetupUnstable() { #endif for (auto& button : espurna::button::internal::buttons) { + // dummy buttons have no pin. AC wall switches must never trigger reset or AP + if (!button.event_emitter || button.ac_switch) { + continue; + } + const auto mode = button.event_emitter->config().mode; switch (mode) { case debounce_event::types::Mode::Pushbutton: diff --git a/code/espurna/config/general.h b/code/espurna/config/general.h index 5087e52b8c..a891d2eee8 100644 --- a/code/espurna/config/general.h +++ b/code/espurna/config/general.h @@ -498,6 +498,24 @@ #define BUTTON_LNGLNGCLICK_DELAY 10000 // Time in ms holding the button down to get a long-long click #endif +// AC mains switch inputs (BUTTON_FREQUENCY, btnFreq#), see ac_switch.h +#ifndef BUTTON_AC_FREQ +#define BUTTON_AC_FREQ 0 // Expected mains frequency, 50 or 60 (Hz). 0 counts every edge +#endif + +#ifndef BUTTON_AC_MIN_PULSES +#define BUTTON_AC_MIN_PULSES 5 // Minimum number of pulses within the counting window (debounce delay) for the ON state +#endif + +#ifndef BUTTON_AC_MIN_DUTY +#define BUTTON_AC_MIN_DUTY 0 // Minimum % of the counting window spent in the active (LOW) level + // for the ON state. 0 disables the check +#endif + +#ifndef BUTTON_AC_CONFIRM +#define BUTTON_AC_CONFIRM 2 // Consecutive counting windows that must agree before the state changes (2..10) +#endif + #ifndef BUTTON_MQTT_SEND_ALL_EVENTS #define BUTTON_MQTT_SEND_ALL_EVENTS 0 // 0 - to send only events the are bound to actions // 1 - to send all button events to MQTT diff --git a/code/espurna/config/hardware.h b/code/espurna/config/hardware.h index 7c210f3513..3a041747a4 100644 --- a/code/espurna/config/hardware.h +++ b/code/espurna/config/hardware.h @@ -2509,6 +2509,49 @@ #define HLW8012_POWER_RATIO 3509285 #define HLW8012_INTERRUPT_ON FALLING +// ----------------------------------------------------------------------------- +// Moes MS-104B 2-Gang Switch Module +// ----------------------------------------------------------------------------- + +#elif defined(MOES_MS_104B) + + // Info + #define MANUFACTURER "MOES" + #define DEVICE "MS_104B" + + // Period filter off (any frequency, 5 ms noise hold-off). + // Set btnAcFreq to 50 or 60 once btnAcDbg period stats are checked + #define BUTTON_AC_FREQ 0 + + // Switch 1 (GPIO 13) controls Relay 1 (GPIO 14) + #define BUTTON1_PIN 13 + #define BUTTON1_CONFIG BUTTON_FREQUENCY + #define BUTTON1_RELAY 1 + #define BUTTON1_PRESS BUTTON_ACTION_TOGGLE + #define BUTTON1_RELEASE BUTTON_ACTION_TOGGLE + #define BUTTON1_DEBOUNCE_DELAY 100 + + // Switch 2 (GPIO 12) controls Relay 2 (GPIO 15) + #define BUTTON2_PIN 12 + #define BUTTON2_CONFIG BUTTON_FREQUENCY + #define BUTTON2_RELAY 2 + #define BUTTON2_PRESS BUTTON_ACTION_TOGGLE + #define BUTTON2_RELEASE BUTTON_ACTION_TOGGLE + #define BUTTON2_DEBOUNCE_DELAY 100 + + // Onboard Button (GPIO 2) + #define BUTTON3_PIN 2 + #define BUTTON3_CONFIG BUTTON_PUSHBUTTON | BUTTON_DEFAULT_HIGH + #define BUTTON3_RELAY 1 + + // Relay 1 + #define RELAY1_PIN 14 + #define RELAY1_TYPE RELAY_TYPE_NORMAL + + // Relay 2 + #define RELAY2_PIN 15 + #define RELAY2_TYPE RELAY_TYPE_NORMAL + // ----------------------------------------------------------------------------- // Oukitel P1 Smart Plug // https://www.amazon.com/Docooler-OUKITEL-Control-Wireless-Adaptor/dp/B07J3BYFJX/ref=sr_1_fkmrnull_2?keywords=oukitel+p1+smart+switch&qid=1550424399&s=gateway&sr=8-2-fkmrnull diff --git a/code/espurna/config/types.h b/code/espurna/config/types.h index 8e6edb1a38..86c353227f 100644 --- a/code/espurna/config/types.h +++ b/code/espurna/config/types.h @@ -59,6 +59,7 @@ #define BUTTON_DEFAULT_BOOT ButtonMask::DefaultBoot #define BUTTON_SET_PULLUP ButtonMask::SetPullup #define BUTTON_SET_PULLDOWN ButtonMask::SetPulldown +#define BUTTON_FREQUENCY ButtonMask::Frequency // configure where do we get the button events #define BUTTON_PROVIDER_NONE ButtonProvider::None diff --git a/code/espurna/libs/DebounceEvent.h b/code/espurna/libs/DebounceEvent.h index 7caf01945e..bd3ec19ec1 100644 --- a/code/espurna/libs/DebounceEvent.h +++ b/code/espurna/libs/DebounceEvent.h @@ -88,6 +88,10 @@ class EventEmitter { types::Event loop(); bool isPressed(); + // Take the current pin value as the starting point, without generating any events. + // (only makes sense for the switch mode, e.g. when pin value is known only after a while) + void sync(); + const BasePinPtr& pin() const; const types::Config& config() const; diff --git a/code/html/spec/button.spec.mjs b/code/html/spec/button.spec.mjs new file mode 100644 index 0000000000..48f134049b --- /dev/null +++ b/code/html/spec/button.spec.mjs @@ -0,0 +1,130 @@ +import { beforeAll, expect, test } from 'vitest'; +import { init } from '../src/button.mjs'; +import { updateKeyValue } from '../src/settings.mjs'; +import { getElementName } from '../src/settings/name.mjs'; + +import panelRelay from '../src/panel-relay.html?raw'; +import templates from '../src/template-relay.html?raw'; + +// Real markup, so that the spec also covers which fields require a reboot +beforeAll(() => { + document.body.innerHTML += templates; + + const panel = document.createElement('div'); + panel.innerHTML = panelRelay; + + const fieldset = panel.querySelector('#buttonConfig')?.closest('fieldset'); + expect(fieldset).toBeTruthy(); + fieldset?.setAttribute('id', 'button-fieldset'); + + const form = document.createElement('form'); + form.id = 'form-button'; + form.appendChild(/** @type {HTMLElement} */(fieldset)); + document.body.appendChild(form); + + init(); +}); + +/** + * @param {string} name + * @returns {HTMLInputElement[]} + */ +function inputs(name) { + return Array.from(document.querySelectorAll(`input[name="${name}"]`)); +} + +/** @returns {HTMLElement[]} */ +function filters() { + return Array.from(document.querySelectorAll('#buttonConfig .button-ac-filter')); +} + +/** @returns {HTMLElement} */ +function global() { + return /** @type {HTMLElement} */(document.getElementById('buttonAcGlobal')); +} + +test('button panel is created from btnConfig', () => { + updateKeyValue('btnAcFreq', 50); + updateKeyValue('btnAcDbg', false); + updateKeyValue('btnConfig', { + schema: ['btnGpio', 'btnFreq', 'btnMinPulses', 'btnMinDuty', 'btnAcConfirm', 'btnDebDel'], + values: [ + ['13', 'true', '5', '30', '3', '100'], + ['12', 'false', '7', '0', '2', '50'], + ], + }); + + const fieldset = document.getElementById('button-fieldset'); + expect(fieldset?.hasAttribute('hidden')).toBe(false); + + const freq = inputs('btnFreq'); + expect(freq).toHaveLength(2); + expect(freq[0].checked).toBe(true); + expect(freq[1].checked).toBe(false); + expect(getElementName(freq[0])).toEqual('btnFreq0'); + expect(getElementName(freq[1])).toEqual('btnFreq1'); + + const pulses = inputs('btnMinPulses'); + expect(pulses[1].valueAsNumber).toEqual(7); + expect(getElementName(pulses[1])).toEqual('btnMinPulses1'); + + const duty = inputs('btnMinDuty'); + expect(duty[0].valueAsNumber).toEqual(30); + expect(getElementName(duty[0])).toEqual('btnMinDuty0'); + + const confirm = inputs('btnAcConfirm'); + expect(confirm[0].valueAsNumber).toEqual(3); + expect(confirm[1].valueAsNumber).toEqual(2); + expect(getElementName(confirm[0])).toEqual('btnAcConfirm0'); + + const window = inputs('btnDebDel'); + expect(window[1].valueAsNumber).toEqual(50); + expect(getElementName(window[1])).toEqual('btnDebDel1'); + + const select = /** @type {HTMLSelectElement} */ + (document.querySelector('select[name="btnAcFreq"]')); + expect(select.value).toEqual('50'); + + const legends = Array.from(document.querySelectorAll('#buttonConfig legend')) + .map((x) => x.textContent); + expect(legends).toEqual(['Button #0 (GPIO13)', 'Button #1 (GPIO12)']); +}); + +test('only btnFreq requires reboot', () => { + for (const name of ['btnMinPulses', 'btnMinDuty', 'btnAcConfirm', 'btnDebDel']) { + for (const input of inputs(name)) { + expect(input.dataset['action']).toBeUndefined(); + } + } + + for (const input of inputs('btnFreq')) { + expect(input.dataset['action']).toEqual('reboot'); + } + + for (const name of ['btnAcFreq', 'btnAcDbg']) { + const elem = /** @type {HTMLElement} */ + (document.querySelector(`[name="${name}"]`)); + expect(elem.dataset['action']).toBeUndefined(); + } +}); + +test('AC filter is only shown for AC switches', () => { + const [first, second] = filters(); + expect(first.hidden).toBe(false); + expect(second.hidden).toBe(true); + expect(global().hidden).toBe(false); + + const freq = inputs('btnFreq'); + freq[1].checked = true; + freq[1].dispatchEvent(new Event('change', {bubbles: true})); + expect(second.hidden).toBe(false); + expect(global().hidden).toBe(false); + + freq[0].checked = false; + freq[0].dispatchEvent(new Event('change', {bubbles: true})); + freq[1].checked = false; + freq[1].dispatchEvent(new Event('change', {bubbles: true})); + expect(first.hidden).toBe(true); + expect(second.hidden).toBe(true); + expect(global().hidden).toBe(true); +}); diff --git a/code/html/src/button.mjs b/code/html/src/button.mjs new file mode 100644 index 0000000000..a7e4a96e69 --- /dev/null +++ b/code/html/src/button.mjs @@ -0,0 +1,86 @@ +import { addOriginalsFromTemplateWithPreparedSchema, prepareFromSchema } from './template.mjs'; +import { variableListeners } from './settings.mjs'; + +/** + * Button inputs, currently only AC mains switch (frequency detection) options. + * Values are reported by the device on connection as `btnConfig`, + * `btnFreq#`, `btnMinPulses#`, `btnMinDuty#`, `btnAcConfirm#` and `btnDebDel#` are saved with the button index suffix. + * Filter options are only shown for the inputs marked as AC mains switch, + * global AC options (`btnAcFreq`, `btnAcDbg`) only when there is at least one such input. + */ + +/** + * @param {Element} node + * @returns {boolean} + */ +function isAcSwitch(node) { + const input = node.querySelector("input[name='btnFreq']"); + return (input instanceof HTMLInputElement) && input.checked; +} + +/** @param {Element} node */ +function updateFilterVisibility(node) { + const filter = node.querySelector(".button-ac-filter"); + if (filter instanceof HTMLElement) { + filter.hidden = !isAcSwitch(node); + } +} + +/** @param {HTMLElement} container */ +function updateGlobalVisibility(container) { + const global = document.getElementById("buttonAcGlobal"); + if (global) { + global.hidden = !Array.from(container.children).some(isAcSwitch); + } +} + +/** @param {HTMLElement} container */ +function updateVisibility(container) { + for (const node of container.children) { + updateFilterVisibility(node); + } + + updateGlobalVisibility(container); +} + +/** + * @param {any[]} values + * @param {string[]} schema + */ +function updateFromConfig(values, schema) { + const container = document.getElementById("buttonConfig"); + if (!container || container.childElementCount > 0) { + return; + } + + const prepared = prepareFromSchema(values, schema); + if (!prepared.length) { + return; + } + + addOriginalsFromTemplateWithPreparedSchema( + container, "button-config", prepared); + + container.addEventListener("change", (event) => { + const target = event.target; + if ((target instanceof HTMLInputElement) && (target.name === "btnFreq")) { + updateVisibility(container); + } + }); + + updateVisibility(container); + container.closest("fieldset")?.removeAttribute("hidden"); +} + +/** @returns {import('./settings.mjs').KeyValueListeners} */ +function listeners() { + return { + "btnConfig": (_, value) => { + updateFromConfig(value.values, value.schema); + }, + }; +} + +export function init() { + variableListeners(listeners()); +} diff --git a/code/html/src/index.mjs b/code/html/src/index.mjs index d6125eabf8..893db8112e 100644 --- a/code/html/src/index.mjs +++ b/code/html/src/index.mjs @@ -64,6 +64,7 @@ import { init as initLight } from './light.mjs'; import { init as initLightfox } from './lightfox.mjs'; import { init as initNtp } from './ntp.mjs'; import { init as initOta } from './ota.mjs'; +import { init as initButton } from './button.mjs'; import { init as initRelay } from './relay.mjs'; import { init as initRfbridge } from './rfbridge.mjs'; import { init as initRfm69 } from './rfm69.mjs'; @@ -417,6 +418,7 @@ async function init() { if (MODULE_RELAY) { initRelay(); + initButton(); } if (MODULE_RFM69) { diff --git a/code/html/src/panel-relay.html b/code/html/src/panel-relay.html index aa6bb8011c..1913012d36 100644 --- a/code/html/src/panel-relay.html +++ b/code/html/src/panel-relay.html @@ -46,6 +46,36 @@

Relay configuration

+ + diff --git a/code/html/src/template-relay.html b/code/html/src/template-relay.html index d4ea763e7d..e598bd7f2f 100644 --- a/code/html/src/template-relay.html +++ b/code/html/src/template-relay.html @@ -5,6 +5,56 @@ + +