Flic Home

    Community

    • Login
    • Search
    • Popular
    • Users
    1. Home
    2. rickard
    • Profile
    • Following 0
    • Followers 0
    • Topics 1
    • Posts 5
    • Best 0
    • Controversial 0
    • Groups 0

    rickard

    @rickard

    0
    Reputation
    1
    Profile views
    5
    Posts
    0
    Followers
    0
    Following
    Joined Last Online

    rickard Unfollow Follow

    Latest posts made by rickard

    • RE: Flic duo gesture and twist sensitivity

      @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.");
      
      
      posted in Developers
      rickard
      rickard
    • RE: Flic duo gesture and twist sensitivity

      @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.

      posted in Developers
      rickard
      rickard
    • Flic duo gesture and twist sensitivity

      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.

      posted in Developers sdk
      rickard
      rickard
    • RE: Flic Hub LR and Home Assistant

      @Emil Now the duo is released!

      Any news on this implementation?

      Also, Gott nytt år!

      posted in Flic Hub
      rickard
      rickard
    • RE: Flic Hub LR and Home Assistant

      @Emil I have just ordered a bunch of flic duo with hub.

      I am new to both home assistant and flic platform but I do want home assistant in between to have better support for everything I want to do.

      What I want is a simple mqtt implementation that can send over which controller is triggering, which gesture and which amount.

      Is all of this possible?
      Can you post instructions on how to make this happen?

      I am guessing I want a message similar to this

      publishMqtt("flic/button", {
      buttonId: "button1",
      gesture: "twist",
      value: 47
      });

      posted in Flic Hub
      rickard
      rickard