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