@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.");