Flic Home

    Community

    • Login
    • Search
    • Popular
    • Users

    Flic duo gesture and twist sensitivity

    Developers
    sdk
    1
    3
    35
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • rickard
      rickard last edited by

      One quick question and some general feedback.

      I am setting up my flic duo buttons with mqtt to home assistant.

      First my two questions

      1. Is it possible to adjust how virtual volume/lights work with twist motion. It's nice to be able to set max volume, but I am less worried about max and more worried about jumping to a volume I don't want.
        When I experiemtn and log the output it works pretty well but can be a bit too twitchy.
        I would like a max speed increase, or in fact I would like to more see it like a "knob" you hold. Hold it in first angle it increases with 1%/s for small adjustments, angle it further to enter next step where volume is adjusted by x%/s and maybe a third fast one too?
        So basically instead of seeing it as on old stereo knob that you turn to a position for a certain volume I would more like to see it as variable speed volume up/down.
        Bonus would be to set a "high", "low" volume above/below respectively it would always adjust slower no matter what.

      2. Is it possibe to set sensitivity for gesture, I find mine is a bit trigger happy so it says a gesture happened even tho I meant just button press. I would like it to be pretty solid movement before it counts it as gesture.

      Bonus statement: Maybe my hand works poorly, but swipe left is often reported as down, the other 3 directions is more clear. Could be nice to be able to set how sure it needs to be to call it a gesture. I would rather have "unclear" result than a false one.


      Then I am saying how am doing in general and happy about feedback.

      1. I have created a mqtt module as defined in https://github.com/50ButtonsEach/flic-hub-sdk-mqtt-js
        Altho in fairness right now I am just logging to console during testing and setup.

      Then I listen to my virtual volume and lights I set up via Flic Hub Studio from the app.
      And I also listen to buttonUp to detect clicks and gestures.

      However I found that it would happily send rotate events for volume etc when I was just doing clicks and gestures.
      I also tried to setup the other gestures in the app instead of listening on buttonUp but same happened, I got both rotate and gesture events.

      So I settled for that I have to hold the button for 700ms to enter rotate mode, then it disregards everything else and only streams volume etc

      This means, to adjust volume or dimming I need to hold button first but at least I don't mix up commands. Since I find dimming and volume most annoying to do by mistake.

      So the only downsides of this is a slight delay when I want to adjust those and I reasonable should avoid long press actions.

      rickard 2 Replies Last reply Reply Quote 0
      • rickard
        rickard @rickard last edited by

        @rickard If anyone else wants me a chatgpt (98% chatgpt) did this

        This basically uses a twist motion as a variable speed adjuster that keeps going after hitting value from flic (intended). For that reason the virtual volume/lights/blinds should not have a limiter, instead you configure the limiter here in main.js

        All you need to do is set up a virtual adjuster in the flic app and this will log the output. You can always then send this over mqtt or such

        // rateDetentController.js
        //
        // Key fixes in this version:
        // 1) Soft recenter while neutral-latched (prevents UP/DOWN asymmetry)
        // 2) Debounced "ease into fine mode" so jitter does NOT trigger fine mode
        //
        // No behavior changes elsewhere.
        
        function clamp(x, min, max) {
          return x < min ? min : (x > max ? max : x);
        }
        function sign(x) {
          return x > 0 ? 1 : (x < 0 ? -1 : 0);
        }
        
        class RateDetentController {
          constructor(cfg) {
            this.cfg = cfg || {};
        
            this.tickMs = this.cfg.tickMs || 333;
        
            // Neutral hysteresis
            this.deadbandEnter = typeof this.cfg.deadbandEnter === "number" ? this.cfg.deadbandEnter : 5;
            this.deadbandExit  = typeof this.cfg.deadbandExit  === "number" ? this.cfg.deadbandExit  : 9;
        
            // Speed tiers
            this.tier1MaxOff = typeof this.cfg.tier1MaxOff === "number" ? this.cfg.tier1MaxOff : 25;
            this.tier2MaxOff = typeof this.cfg.tier2MaxOff === "number" ? this.cfg.tier2MaxOff : 50;
            this.tierHys     = typeof this.cfg.tierHys === "number"     ? this.cfg.tierHys     : 3;
        
            // Output clamp
            this.minOutPct = typeof this.cfg.minOutPct === "number" ? this.cfg.minOutPct : 0;
            this.maxOutPct = typeof this.cfg.maxOutPct === "number" ? this.cfg.maxOutPct : 100;
        
            // --- NEW: soft recenter tuning ---
            this.neutralRecenterAlpha =
              typeof this.cfg.neutralRecenterAlpha === "number"
                ? this.cfg.neutralRecenterAlpha
                : 0.08; // 8% per update while resting
        
            // --- NEW: debounce for easing into fine mode ---
            this.easeConfirmMs =
              typeof this.cfg.easeConfirmMs === "number"
                ? this.cfg.easeConfirmMs
                : 200;
        
            // State
            this.centerInPct = null;
            this.actualOutPct = typeof this.cfg.initialOutPct === "number" ? this.cfg.initialOutPct : null;
        
            this.fineMode = false;
        
            this.lastDir = 0;
            this.lastSpeed = 0;
        
            this.currentDir = 0;
            this.currentSpeed = 0;
        
            this.neutralLatched = false;
            this.speedLatched = 0;
        
            // For debouncing easing
            this._easeCandidateSince = null;
            this._easeCandidateDir = 0;
        
            this._lastIntentKey = null;
        
            this._timer = setInterval(() => this._tick(), this.tickMs);
          }
        
          _desiredSpeed(absOff) {
            if (absOff <= this.tier1MaxOff) return 1;
            if (absOff <= this.tier2MaxOff) return 2;
            return 3;
          }
        
          _updateLatchedSpeed(desired, absOff) {
            if (this.speedLatched === 0) {
              this.speedLatched = desired;
              return;
            }
        
            if (this.speedLatched === 1) {
              if (desired >= 2 && absOff >= (this.tier1MaxOff + this.tierHys)) this.speedLatched = 2;
              if (desired === 3 && absOff >= (this.tier2MaxOff + this.tierHys)) this.speedLatched = 3;
              return;
            }
        
            if (this.speedLatched === 2) {
              if (absOff <= (this.tier1MaxOff - this.tierHys)) { this.speedLatched = 1; return; }
              if (desired === 3 && absOff >= (this.tier2MaxOff + this.tierHys)) { this.speedLatched = 3; return; }
              return;
            }
        
            if (this.speedLatched === 3) {
              if (absOff <= (this.tier2MaxOff - this.tierHys)) this.speedLatched = 2;
            }
          }
        
          _baseIntent(rawInPct) {
            if (this.centerInPct === null) {
              this.centerInPct = rawInPct;
              this.neutralLatched = true;
              this.speedLatched = 0;
              return { dir: 0, speed: 0, desiredSpeed: 0, reason: "center set" };
            }
        
            const off = rawInPct - this.centerInPct;
            const absOff = Math.abs(off);
        
            // --- Sticky neutral with SOFT RECENTER ---
            if (this.neutralLatched) {
              if (absOff <= this.deadbandExit) {
                // soft recenter while resting
                this.centerInPct =
                  this.centerInPct +
                  this.neutralRecenterAlpha * (rawInPct - this.centerInPct);
        
                this.speedLatched = 0;
                return { dir: 0, speed: 0, desiredSpeed: 0, reason: "deadband (latched)" };
              }
              this.neutralLatched = false;
            } else {
              if (absOff <= this.deadbandEnter) {
                this.neutralLatched = true;
                this.speedLatched = 0;
                return { dir: 0, speed: 0, desiredSpeed: 0, reason: "deadband (enter)" };
              }
            }
        
            const dir = sign(off);
            const desiredSpeed = this._desiredSpeed(absOff);
            this._updateLatchedSpeed(desiredSpeed, absOff);
        
            const speed = (this.speedLatched === 0) ? 1 : this.speedLatched;
            return { dir, speed, desiredSpeed, reason: "detent" };
          }
        
          _applyFineMode(base) {
            const { dir, speed, desiredSpeed } = base;
            const now = Date.now();
        
            const directionChanged =
              (this.lastDir !== 0 && dir !== 0 && dir !== this.lastDir);
        
            const hitNeutralFromIntent =
              (this.lastSpeed > 0 && speed === 0);
        
            // --- Debounced easing detection ---
            let easedConfirmed = false;
        
            const easingCandidate =
              (this.lastSpeed >= 2 &&
               desiredSpeed > 0 &&
               desiredSpeed < this.lastSpeed &&
               dir === this.lastDir);
        
            if (easingCandidate) {
              if (this._easeCandidateSince === null) {
                this._easeCandidateSince = now;
                this._easeCandidateDir = dir;
              } else if (
                this._easeCandidateDir === dir &&
                (now - this._easeCandidateSince) >= this.easeConfirmMs
              ) {
                easedConfirmed = true;
              }
            } else {
              this._easeCandidateSince = null;
              this._easeCandidateDir = 0;
            }
        
            if (!this.fineMode && (directionChanged || hitNeutralFromIntent || easedConfirmed)) {
              this.fineMode = true;
              this._easeCandidateSince = null;
        
              if (speed === 0) return { dir: 0, speed: 0, note: "enter fine (neutral)" };
              if (directionChanged) return { dir, speed: 1, note: "enter fine (turn)" };
              if (easedConfirmed) return { dir, speed: 1, note: "enter fine (ease)" };
              return { dir, speed: 1, note: "enter fine" };
            }
        
            if (this.fineMode) {
              if (speed === 0) return { dir: 0, speed: 0, note: "fine mode" };
              return { dir, speed: 1, note: "fine mode" };
            }
        
            return { dir, speed, note: null };
          }
        
          updateRaw(rawInPct) {
            if (typeof rawInPct !== "number") return null;
        
            if (this.actualOutPct === null) this.actualOutPct = this.minOutPct;
            if (this.centerInPct === null) this.centerInPct = rawInPct;
        
            const base = this._baseIntent(rawInPct);
            const applied = this._applyFineMode(base);
        
            this.currentDir = applied.dir;
            this.currentSpeed = applied.speed;
        
            const note = applied.note || base.reason || null;
        
            const key =
              this.currentDir + "|" +
              this.currentSpeed + "|" +
              (this.fineMode ? "F" : "-") + "|" +
              (this.neutralLatched ? "N" : "-") + "|" +
              (note || "");
        
            const intentChanged = (key !== this._lastIntentKey);
            this._lastIntentKey = key;
        
            this.lastDir = this.currentDir;
            this.lastSpeed = this.currentSpeed;
        
            return {
              intentChanged,
              rawInPct,
              dir: this.currentDir,
              speed: this.currentSpeed,
              fineMode: this.fineMode,
              note
            };
          }
        
          _tick() {
            if (this.actualOutPct === null) return;
            if (this.currentDir === 0 || this.currentSpeed === 0) return;
        
            this.actualOutPct = clamp(
              this.actualOutPct + (this.currentDir * this.currentSpeed),
              this.minOutPct,
              this.maxOutPct
            );
          }
        
          getActualOutPct() {
            return (this.actualOutPct === null) ? null : Math.round(this.actualOutPct);
          }
        
          stop() {
            if (this._timer) {
              clearInterval(this._timer);
              this._timer = null;
            }
            return this.getActualOutPct();
          }
        }
        
        module.exports = RateDetentController;
        
        

        That can be tested with a main.js like this

        var buttons = require("buttons");
        var flicapp = require("flicapp");
        var RateDetentController = require("./rateDetentController");
        
        var ROTATE_ARM_MS = 700;
        var CLICK_SUPPRESS_AFTER_ROTATE_MS = 800;
        
        var speakerVirtualDeviceId = null;
        var lightVirtualDeviceId = null;
        var blindVirtualDeviceId = null;
        
        var stateByKey = {};
        var rotateSessionByBdaddr = {};
        var suppressClicksUntilByBdaddr = {};
        
        var ctrlByKey = {};
        var lastFinalOutByDevKey = {};
        
        function keyOf(obj) { return obj.bdaddr + ":" + obj.buttonNumber; }
        function sizeLabel(buttonNumber) { return buttonNumber === 0 ? "BIG" : "small"; }
        function clamp01(x) { return x < 0 ? 0 : (x > 1 ? 1 : x); }
        function toPct01(x01) { return typeof x01 === "number" ? Math.round(clamp01(x01) * 100) : null; }
        function pctTo01(pct) { return clamp01(pct / 100); }
        function isInRotateMode(bdaddr) { return !!rotateSessionByBdaddr[bdaddr]; }
        function dirText(d) { return d > 0 ? "UP" : (d < 0 ? "DOWN" : "NEUTRAL"); }
        
        function clearArmTimer(st) {
          if (st && st.armTimer) { clearTimeout(st.armTimer); st.armTimer = null; }
        }
        function startRotateMode(st) {
          if (!st || st.rotateArmed) return;
          st.rotateArmed = true;
          rotateSessionByBdaddr[st.bdaddr] = { key: st.key, sizeLabel: st.sizeLabel, startedPrinted: false };
        }
        
        function inToOut(inPct, minOut, maxOut) {
          var range = maxOut - minOut;
          if (range <= 0) return minOut;
          return minOut + (inPct / 100) * range;
        }
        function outToIn(outPct, minOut, maxOut) {
          var range = maxOut - minOut;
          if (range <= 0) return 0;
          var v = ((outPct - minOut) / range) * 100;
          if (v < 0) v = 0;
          if (v > 100) v = 100;
          return Math.round(v);
        }
        function syncVirtualFromOut(type, id, outPct, minOut, maxOut) {
          var inPct = outToIn(outPct, minOut, maxOut);
          var v01 = pctTo01(inPct);
        
          if (type === "Speaker") flicapp.virtualDeviceUpdateState("Speaker", id, { volume: v01 });
          else if (type === "Light") flicapp.virtualDeviceUpdateState("Light", id, { brightness: v01 });
          else if (type === "Blind") flicapp.virtualDeviceUpdateState("Blind", id, { position: v01 });
        }
        
        // ---- buttons ----
        buttons.on("buttonDown", function (obj) {
          if (!obj) return;
        
          var k = keyOf(obj);
          stateByKey[k] = {
            key: k,
            bdaddr: obj.bdaddr,
            buttonNumber: obj.buttonNumber,
            sizeLabel: sizeLabel(obj.buttonNumber),
            rotateArmed: false,
            armTimer: setTimeout(function () { startRotateMode(stateByKey[k]); }, ROTATE_ARM_MS)
          };
        });
        
        buttons.on("buttonUp", function (obj) {
          if (!obj) return;
        
          var k = keyOf(obj);
          var st = stateByKey[k];
        
          clearArmTimer(st);
        
          if (isInRotateMode(obj.bdaddr)) {
            delete rotateSessionByBdaddr[obj.bdaddr];
            suppressClicksUntilByBdaddr[obj.bdaddr] = Date.now() + CLICK_SUPPRESS_AFTER_ROTATE_MS;
        
            var keys = Object.keys(ctrlByKey);
            for (var i = 0; i < keys.length; i++) {
              var entry = ctrlByKey[keys[i]];
              if (!entry || entry.bdaddr !== obj.bdaddr) continue;
        
              var finalOut = entry.ctrl.stop();
              if (finalOut !== null) {
                console.log(entry.label + " - rotate end - " + entry.prettyName + " - final out " + finalOut + "%");
                lastFinalOutByDevKey[entry.devKey] = finalOut;
                syncVirtualFromOut(entry.type, entry.id, finalOut, entry.minOut, entry.maxOut);
              }
        
              delete ctrlByKey[keys[i]];
            }
          }
        
          if (st && !st.rotateArmed) {
            if (obj.gesture === "up" || obj.gesture === "down" || obj.gesture === "left" || obj.gesture === "right") {
              console.log(st.sizeLabel + " - " + obj.gesture.toUpperCase());
            }
          }
        
          delete stateByKey[k];
        });
        
        buttons.on("buttonSingleOrDoubleClick", function (obj) {
          if (!obj) return;
          if (isInRotateMode(obj.bdaddr)) return;
        
          var suppressUntil = suppressClicksUntilByBdaddr[obj.bdaddr] || 0;
          if (Date.now() < suppressUntil) return;
        
          var lbl = sizeLabel(obj.buttonNumber);
          console.log(lbl + (obj.isDoubleClick === true ? " - DOUBLECLICK" : " - CLICK"));
        });
        
        // ---- rotate ----
        flicapp.on("virtualDeviceUpdate", function (metaData, values) {
          if (!metaData || !metaData.dimmableType || !metaData.virtualDeviceId) return;
        
          var bdaddr = metaData.buttonId;
          if (!bdaddr) return;
        
          var session = rotateSessionByBdaddr[bdaddr];
          if (!session) return;
        
          var type = metaData.dimmableType;
          var id = metaData.virtualDeviceId;
        
          if (type === "Speaker" && speakerVirtualDeviceId === null) speakerVirtualDeviceId = id;
          if (type === "Light" && lightVirtualDeviceId === null) lightVirtualDeviceId = id;
          if (type === "Blind" && blindVirtualDeviceId === null) blindVirtualDeviceId = id;
        
          if (type === "Speaker" && id !== speakerVirtualDeviceId) return;
          if (type === "Light" && id !== lightVirtualDeviceId) return;
          if (type === "Blind" && id !== blindVirtualDeviceId) return;
        
          var inPct = null;
          var prettyName = null;
        
          if (type === "Speaker") { inPct = values && typeof values.volume === "number" ? toPct01(values.volume) : null; prettyName = "tv speaker"; }
          else if (type === "Light") { inPct = values && typeof values.brightness === "number" ? toPct01(values.brightness) : null; prettyName = "living room lights"; }
          else if (type === "Blind") { inPct = values && typeof values.position === "number" ? toPct01(values.position) : null; prettyName = "test"; }
        
          if (inPct === null) return;
        
          var devKey = bdaddr + "|" + type + "|" + id;
          var entry = ctrlByKey[devKey];
        
          var minOut = 0, maxOut = 100;
          if (type === "Speaker") { minOut = 0; maxOut = 80; }
          else if (type === "Light") { minOut = 5; maxOut = 100; }
          else if (type === "Blind") { minOut = 0; maxOut = 100; }
        
          if (!entry) {
            var storedOut = lastFinalOutByDevKey[devKey];
            var startOut = (typeof storedOut === "number") ? storedOut : Math.round(inToOut(inPct, minOut, maxOut));
        
            syncVirtualFromOut(type, id, startOut, minOut, maxOut);
        
            var label = session.sizeLabel;
        
            var ctrl = new RateDetentController({
              initialOutPct: startOut,
              tickMs: 333,
        
              // STICKY NEUTRAL (tweak here)
              deadbandEnter: 5,
              deadbandExit: 9,
        
              // SENSITIVITY
              tier1MaxOff: 25,
              tier2MaxOff: 40,
              extremeBandPct: 10,
        
              minOutPct: minOut,
              maxOutPct: maxOut
            });
        
            entry = {
              bdaddr: bdaddr,
              devKey: devKey,
              type: type,
              id: id,
              prettyName: prettyName,
              label: label,
              ctrl: ctrl,
              minOut: minOut,
              maxOut: maxOut,
              lastOut: null
            };
        
            ctrlByKey[devKey] = entry;
        
            if (!session.startedPrinted) {
              session.startedPrinted = true;
              console.log(label + " - rotate mode - start " + prettyName + " - out " + startOut + "% (in " + outToIn(startOut, minOut, maxOut) + "%)");
            }
          }
        
          var u = entry.ctrl.updateRaw(inPct);
          if (!u) return;
        
          var outNow = entry.ctrl.getActualOutPct();
        
          // intent change line
          if (u.intentChanged) {
            console.log(
              entry.label + " - " + entry.prettyName +
              " - out " + (outNow === null ? "?" : outNow) + "% - " +
              dirText(u.dir) + " " + u.speed +
              " - in " + inPct + "%" +
              (u.note ? " - " + u.note : "")
            );
          }
        
          // tick/output line (only if output changed)
          if (outNow !== null && outNow !== entry.lastOut) {
            entry.lastOut = outNow;
            console.log(entry.label + " - " + entry.prettyName + " - out " + outNow + "% - " + dirText(u.dir) + " " + u.speed);
          }
        });
        
        console.log("Ready. Short press: click/double + gesture. Hold >= 700ms: rotate-only until release.");
        
        
        1 Reply Last reply Reply Quote 0
        • rickard
          rickard @rickard last edited by

          @rickard

          Another thought hit me.

          One could simplify my suggestion #1 above by just calling it variable speed control.

          Where you simply configure how many speed settings you want.

          So lets say I set 3 in each direction, small, medium, big.
          Then simply I define how fast they each are in "percent per second" or something like that.

          Then when I do the rotate gesture depending on how wildly I swing it, it starts adjusting volume at a corresponding speed, not stopping until I let go of the button.


          I should mention I have now implemented this solution myself by listening to the raw data with added logic, it takes me 90% of the way.

          1 Reply Last reply Reply Quote 0
          • First post
            Last post