Leave Min/Max empty for unbounded setpoints. With both set, the value
- clamps at the endstops and the button flashes the alert icon when
- already at the limit (unless suppressed below).
+ clamps at the endstops (unless Cycle is checked) and the button flashes
+ the alert icon when already at the limit (unless suppressed below).
+
+
+
+
+
+ Requires Min and Max. Example: Min 0, Max 30, Delta 10 → 0→10→20→30→0.
-
-
-
-
+
+
+
+
+
+ DataRef step (optional — instead of Command on press)
+
+ When Delta is set, press writes DataRef Path by ±Delta (Direction sets the
+ sign) instead of firing Command Path. Use with Min/Max/Cycle like Rotary DataRef.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ;
+ parsed: Parsed;
+ handle?: SubscriptionHandle;
+ lastValue?: DataRefValue;
+ pressed: boolean;
+ shifted: boolean;
+ holdId?: number;
+}
+
+@action({ UUID: "com.robertw.xplane.encoder" })
+export class XPlaneEncoder extends SingletonAction {
+ private readonly states = new Map();
+
+ constructor(private readonly xplane: XPlaneClient) {
+ super();
+ this.xplane.on("offline", () => this.onOffline());
+ this.xplane.on("online", () => this.onOnline());
+ selectors.watch((changed) => this.onSelectorsChanged(changed));
+ }
+
+ override async onWillAppear(ev: WillAppearEvent): Promise {
+ if (!ev.action.isDial()) return;
+ const state: State = {
+ action: ev.action,
+ parsed: parse(ev.payload.settings ?? {}),
+ pressed: false,
+ shifted: false,
+ };
+ this.states.set(ev.action.id, state);
+ if (this.xplane.isOffline()) {
+ await setDialFeedback(ev.action, state.parsed.label, "OFFLINE");
+ return;
+ }
+ await this.subscribe(state);
+ }
+
+ override onWillDisappear(ev: WillDisappearEvent): Promise {
+ const state = this.states.get(ev.action.id);
+ if (!state) return Promise.resolve();
+ this.unsubscribe(state);
+ void this.endHold(state);
+ this.states.delete(ev.action.id);
+ return Promise.resolve();
+ }
+
+ override async onDidReceiveSettings(
+ ev: DidReceiveSettingsEvent,
+ ): Promise {
+ const state = this.states.get(ev.action.id);
+ if (!state) return;
+ const next = parse(ev.payload.settings ?? {});
+ const pathChanged = next.datarefPath !== state.parsed.datarefPath;
+ state.parsed = next;
+ if (pathChanged) {
+ this.unsubscribe(state);
+ state.lastValue = undefined;
+ await this.subscribe(state);
+ return;
+ }
+ this.render(state);
+ }
+
+ override async onDialRotate(ev: DialRotateEvent): Promise {
+ const state = this.states.get(ev.action.id);
+ if (!state) return;
+ const parsed = parse(ev.payload.settings ?? {});
+ const ticks = ev.payload.ticks;
+ if (ticks === 0) return;
+
+ const shift = ev.payload.pressed || state.pressed;
+ if (shift) {
+ state.shifted = true;
+ if (state.holdId !== undefined && parsed.clickHoldMode) await this.endHold(state);
+ }
+
+ if (parsed.driveMode === "command") {
+ await this.fireCommand(
+ state,
+ ticks > 0
+ ? shift && parsed.shiftCommandPath
+ ? parsed.shiftCommandPath
+ : parsed.commandPath
+ : shift && parsed.shiftCommandPathReverse
+ ? parsed.shiftCommandPathReverse
+ : parsed.commandPathReverse,
+ Math.abs(ticks),
+ );
+ return;
+ }
+ await this.stepDataRef(state, parsed, shift, ticks);
+ }
+
+ override async onDialDown(ev: DialDownEvent): Promise {
+ const state = this.states.get(ev.action.id);
+ if (!state) return;
+ const parsed = parse(ev.payload.settings ?? {});
+ state.pressed = true;
+ state.shifted = false;
+ if (parsed.clickHoldMode && parsed.clickCommand) {
+ await this.beginHold(state, parsed.clickCommand);
+ }
+ }
+
+ override async onDialUp(ev: DialUpEvent): Promise {
+ const state = this.states.get(ev.action.id);
+ if (!state) return;
+ const parsed = parse(ev.payload.settings ?? {});
+ const shifted = state.shifted;
+ state.pressed = false;
+ if (state.holdId !== undefined) {
+ await this.endHold(state);
+ state.shifted = false;
+ return;
+ }
+ if (!shifted && parsed.clickCommand) {
+ await this.fireCommand(state, parsed.clickCommand, 1);
+ }
+ state.shifted = false;
+ }
+
+ private async beginHold(state: State, raw: string): Promise {
+ const path = substitutePlaceholders(raw, selectors.snapshot());
+ try {
+ const id = await this.xplane.getCommandId(path);
+ await this.xplane.beginCommand(id);
+ state.holdId = id;
+ } catch (err) {
+ streamDeck.logger.error(`encoder: hold begin failed: ${path}`, err);
+ await state.action.showAlert();
+ }
+ }
+
+ private async endHold(state: State): Promise {
+ if (state.holdId === undefined) return;
+ const id = state.holdId;
+ state.holdId = undefined;
+ try {
+ await this.xplane.endCommand(id);
+ } catch (err) {
+ streamDeck.logger.error(`encoder: hold end failed (id=${id})`, err);
+ await state.action.showAlert();
+ }
+ }
+
+ private async fireCommand(state: State, raw: string, times: number): Promise {
+ if (!raw) {
+ await state.action.showAlert();
+ return;
+ }
+ const path = substitutePlaceholders(raw, selectors.snapshot());
+ try {
+ const id = await this.xplane.getCommandId(path);
+ for (let i = 0; i < times; i++) await this.xplane.activateCommand(id);
+ } catch (err) {
+ streamDeck.logger.error(`encoder: command failed: ${path}`, err);
+ await state.action.showAlert();
+ }
+ }
+
+ private async stepDataRef(
+ state: State,
+ parsed: Parsed,
+ shift: boolean,
+ ticks: number,
+ ): Promise {
+ if (!parsed.datarefPath) {
+ await state.action.showAlert();
+ return;
+ }
+ const base = shift && parsed.coarseDelta !== undefined ? parsed.coarseDelta : parsed.delta;
+ if (!(base > 0)) {
+ await state.action.showAlert();
+ return;
+ }
+ const step = base * Math.max(1, Math.abs(Math.trunc(ticks))) * (ticks < 0 ? -1 : 1);
+
+ try {
+ const resolved = substitutePlaceholders(parsed.datarefPath, selectors.snapshot());
+ const { basePath, index } = parseDataRefPath(resolved);
+ const drId = await this.xplane.getDataRefId(basePath);
+ const currentRaw =
+ state.lastValue !== undefined
+ ? state.lastValue
+ : applyIndex(await this.xplane.readDataRef(drId), index);
+ const current = coerceNumber(currentRaw) ?? 0;
+
+ let target: number;
+ let blocked: boolean;
+ if (parsed.stepMode === "octal") {
+ target = stepOctalCode(current, step, parsed.minValue, parsed.maxValue);
+ blocked = Math.abs(target - current) < TOLERANCE_FLOAT;
+ } else {
+ ({ value: target, blocked } = applyStep(current, step, {
+ min: parsed.minValue,
+ max: parsed.maxValue,
+ cycle: parsed.cycle,
+ }));
+ }
+
+ if (blocked) {
+ if (!parsed.hideEndstopAlert) await state.action.showAlert();
+ return;
+ }
+
+ await this.xplane.writeDataRef(drId, target, index);
+ state.lastValue = target;
+ this.render(state);
+ } catch (err) {
+ streamDeck.logger.error("encoder: dataref step failed", err);
+ await state.action.showAlert();
+ }
+ }
+
+ private async subscribe(state: State): Promise {
+ if (!state.parsed.datarefPath) {
+ await setDialFeedback(state.action, state.parsed.label, "");
+ return;
+ }
+ if (this.xplane.isOffline()) {
+ await setDialFeedback(state.action, state.parsed.label, "OFFLINE");
+ return;
+ }
+ const resolved = substitutePlaceholders(state.parsed.datarefPath, selectors.snapshot());
+ const { basePath, index } = parseDataRefPath(resolved);
+ try {
+ state.handle = await this.xplane.subscribe(basePath, (raw) => {
+ try {
+ state.lastValue = applyIndex(raw, index);
+ this.render(state);
+ } catch {
+ void setDialFeedback(state.action, state.parsed.label, NOT_FOUND_SUFFIX);
+ }
+ });
+ } catch (err) {
+ streamDeck.logger.warn(
+ `encoder: subscribe failed for ${state.parsed.datarefPath}`,
+ err,
+ );
+ await setDialFeedback(state.action, state.parsed.label, NOT_FOUND_SUFFIX);
+ await state.action.showAlert();
+ }
+ }
+
+ private unsubscribe(state: State): void {
+ if (!state.handle) return;
+ this.xplane.unsubscribe(state.handle);
+ state.handle = undefined;
+ }
+
+ private render(state: State): void {
+ if (state.lastValue === undefined) return;
+ const { parsed } = state;
+ const formatted = formatDataRefValue(state.lastValue, {
+ format: parsed.format,
+ unitScale: parsed.unitScale,
+ precision: parsed.precision,
+ zeroSnap: parsed.zeroSnap,
+ });
+ const value = parsed.unit ? `${formatted} ${parsed.unit}` : formatted;
+ void setDialFeedback(state.action, parsed.label, value);
+ }
+
+ private onOffline(): void {
+ for (const state of this.states.values()) {
+ void this.endHold(state);
+ state.pressed = false;
+ state.shifted = false;
+ this.unsubscribe(state);
+ state.lastValue = undefined;
+ void setDialFeedback(state.action, state.parsed.label, "OFFLINE");
+ // Key-only offline bitmap is a no-op for dials.
+ void setOffline(state.action);
+ }
+ }
+
+ private onOnline(): void {
+ for (const state of this.states.values()) {
+ clearOffline(state.action)
+ .then(() => this.subscribe(state))
+ .catch((err) => streamDeck.logger.warn("encoder: re-subscribe failed", err));
+ }
+ }
+
+ private onSelectorsChanged(changed: ReadonlySet): void {
+ for (const state of this.states.values()) {
+ if (!state.parsed.datarefPath) continue;
+ const keys = extractPlaceholderKeys(state.parsed.datarefPath);
+ if (!keys.some((k) => changed.has(k))) continue;
+ this.unsubscribe(state);
+ state.lastValue = undefined;
+ this.subscribe(state).catch((err) =>
+ streamDeck.logger.warn("encoder: selector re-subscribe failed", err),
+ );
+ }
+ }
+}
+
+function parse(s: EncoderSettings): Parsed {
+ const deltaRaw = toFiniteNumber(s.delta);
+ const coarseRaw = toFiniteNumber(s.coarseDelta);
+ return {
+ driveMode: s.driveMode === "command" ? "command" : "dataref",
+ stepMode: s.stepMode === "octal" ? "octal" : "linear",
+ datarefPath: trimString(s.datarefPath),
+ delta: deltaRaw !== undefined && deltaRaw > 0 ? deltaRaw : 1,
+ coarseDelta: coarseRaw !== undefined && coarseRaw > 0 ? coarseRaw : undefined,
+ minValue: toFiniteNumber(s.minValue),
+ maxValue: toFiniteNumber(s.maxValue),
+ cycle: s.cycle === true,
+ hideEndstopAlert: s.hideEndstopAlert === true,
+ commandPath: trimString(s.commandPath),
+ commandPathReverse: trimString(s.commandPathReverse),
+ shiftCommandPath: trimString(s.shiftCommandPath),
+ shiftCommandPathReverse: trimString(s.shiftCommandPathReverse),
+ clickCommand: trimString(s.clickCommand),
+ clickHoldMode: s.clickHoldMode === true,
+ label: trimString(s.label),
+ format: normalizeFormat(s.format),
+ unit: trimString(s.unit),
+ unitScale: toFiniteNumber(s.unitScale),
+ precision: toFiniteNumber(s.precision),
+ zeroSnap: resolveZeroSnap(s.snapZero, s.zeroThreshold),
+ };
+}
diff --git a/src/actions/rotary-dataref.ts b/src/actions/rotary-dataref.ts
index 725b702..00a20d5 100644
--- a/src/actions/rotary-dataref.ts
+++ b/src/actions/rotary-dataref.ts
@@ -18,7 +18,7 @@ import streamDeck, {
} from "@elgato/streamdeck";
import type { JsonObject } from "@elgato/utils";
-import { TIMINGS, TOLERANCE_FLOAT } from "../const";
+import { TIMINGS } from "../const";
import { selectors } from "../selectors/registry";
import { coerceNumber, toFiniteNumber } from "../util/coerce";
import { applyIndex, parseDataRefPath } from "../util/dataref-path";
@@ -27,6 +27,7 @@ import { clearOffline, combineTitle, NOT_FOUND_SUFFIX, setOffline } from "../uti
import { formatDataRefValue } from "../util/format";
import { extractPlaceholderKeys, substitutePlaceholders } from "../util/placeholders";
import { normalizeFormat, resolveZeroSnap, trimString } from "../util/settings";
+import { applyStep } from "../util/step";
import type { DataRefValue, SubscriptionHandle, XPlaneClient } from "../xplane";
type RotaryDirection = "left" | "right" | "up" | "down";
@@ -39,6 +40,7 @@ type RotaryDataRefSettings = JsonObject & {
direction?: RotaryDirection;
minValue?: string | number;
maxValue?: string | number;
+ cycle?: boolean;
hideConfirmation?: boolean;
hideEndstopAlert?: boolean;
label?: string;
@@ -59,6 +61,7 @@ interface ParsedSettings {
sign: 1 | -1;
minValue?: number;
maxValue?: number;
+ cycle: boolean;
hideConfirmation: boolean;
hideEndstopAlert: boolean;
label: string;
@@ -208,11 +211,13 @@ export class XPlaneRotaryDataRef extends SingletonAction
: applyIndex(await this.xplane.readDataRef(drId), index);
const current = coerceNumber(currentRaw) ?? 0;
- let target = current + parsed.sign * step;
- if (parsed.minValue !== undefined && target < parsed.minValue) target = parsed.minValue;
- if (parsed.maxValue !== undefined && target > parsed.maxValue) target = parsed.maxValue;
+ const { value: target, blocked } = applyStep(current, parsed.sign * step, {
+ min: parsed.minValue,
+ max: parsed.maxValue,
+ cycle: parsed.cycle,
+ });
- if (Math.abs(target - current) < TOLERANCE_FLOAT) {
+ if (blocked) {
streamDeck.logger.info(
`rotary-dataref: ${kind} press at endstop for ${resolvedPath} (value=${current})`,
);
@@ -369,6 +374,7 @@ function parseSettings(s: RotaryDataRefSettings): ParsedSettings {
sign,
minValue: toFiniteNumber(s.minValue),
maxValue: toFiniteNumber(s.maxValue),
+ cycle: s.cycle === true,
hideConfirmation: s.hideConfirmation === true,
hideEndstopAlert: s.hideEndstopAlert === true,
label: trimString(s.label),
diff --git a/src/actions/rotary.ts b/src/actions/rotary.ts
index 98e3f0f..72d50e6 100644
--- a/src/actions/rotary.ts
+++ b/src/actions/rotary.ts
@@ -25,6 +25,7 @@ import { clearOffline, combineTitle, NOT_FOUND_SUFFIX, setOffline } from "../uti
import { formatDataRefValue } from "../util/format";
import { extractPlaceholderKeys, substitutePlaceholders } from "../util/placeholders";
import { normalizeFormat, resolveZeroSnap, trimString } from "../util/settings";
+import { applyStep } from "../util/step";
import type { DataRefValue, SubscriptionHandle, XPlaneClient } from "../xplane";
type RotaryDirection = "left" | "right" | "up" | "down";
@@ -33,8 +34,13 @@ type RotaryFormatMode = "numeric" | "enum";
type RotarySettings = JsonObject & {
commandPath?: string;
hideConfirmation?: boolean;
+ hideEndstopAlert?: boolean;
direction?: RotaryDirection;
datarefPath?: string;
+ delta?: string | number;
+ minValue?: string | number;
+ maxValue?: string | number;
+ cycle?: boolean;
label?: string;
formatMode?: RotaryFormatMode;
format?: string;
@@ -51,6 +57,12 @@ type RotarySettings = JsonObject & {
interface ParsedSettings {
commandPath: string;
datarefPath: string;
+ delta?: number;
+ sign: 1 | -1;
+ minValue?: number;
+ maxValue?: number;
+ cycle: boolean;
+ hideEndstopAlert: boolean;
label: string;
formatMode: RotaryFormatMode;
format: string;
@@ -149,17 +161,17 @@ export class XPlaneRotary extends SingletonAction {
const parsed = parseSettings(ev.payload.settings ?? {});
const hideConfirmation = ev.payload.settings?.hideConfirmation === true;
- if (!parsed.commandPath && !parsed.holdCommand) {
- streamDeck.logger.warn("rotary: commandPath and holdCommand are both empty");
+ const canStep = parsed.delta !== undefined && !!parsed.datarefPath;
+ if (!parsed.commandPath && !parsed.holdCommand && !canStep) {
+ streamDeck.logger.warn(
+ "rotary: commandPath / holdCommand / delta+datarefPath are all empty",
+ );
await ev.action.showAlert();
return;
}
const snap = selectors.snapshot();
- // HOLD branch: enum mode + checkbox on + current value is second-to-last
- // (so the next step would land on the last position). Uses begin/end on
- // `holdCommand` instead of activate on `commandPath`.
if (shouldHoldOnLast(state, parsed)) {
const holdCommand = substitutePlaceholders(parsed.holdCommand, snap);
try {
@@ -177,6 +189,12 @@ export class XPlaneRotary extends SingletonAction {
return;
}
+ // Optional DataRef step (cycle/clamp) — takes precedence over command activate.
+ if (canStep && state) {
+ await this.stepDataRef(state, parsed, hideConfirmation);
+ return;
+ }
+
if (parsed.formatMode === "enum" && !parsed.enumValid) {
streamDeck.logger.warn("rotary: enumMap parse error — refusing to fire command");
await ev.action.showAlert();
@@ -184,7 +202,7 @@ export class XPlaneRotary extends SingletonAction {
}
if (!parsed.commandPath) {
- streamDeck.logger.warn("rotary: commandPath empty (HOLD branch did not apply)");
+ streamDeck.logger.warn("rotary: commandPath empty (HOLD / step did not apply)");
await ev.action.showAlert();
return;
}
@@ -203,6 +221,48 @@ export class XPlaneRotary extends SingletonAction {
}
}
+ private async stepDataRef(
+ state: ActionState,
+ parsed: ParsedSettings,
+ hideConfirmation: boolean,
+ ): Promise {
+ const delta = parsed.delta;
+ if (delta === undefined || !(delta > 0) || !parsed.datarefPath) {
+ await state.action.showAlert();
+ return;
+ }
+ try {
+ const resolvedPath = substitutePlaceholders(parsed.datarefPath, selectors.snapshot());
+ const { basePath, index } = parseDataRefPath(resolvedPath);
+ const drId = await this.xplane.getDataRefId(basePath);
+ const currentRaw =
+ state.lastValue !== undefined
+ ? state.lastValue
+ : applyIndex(await this.xplane.readDataRef(drId), index);
+ const current = coerceNumber(currentRaw) ?? 0;
+ const { value: target, blocked } = applyStep(current, parsed.sign * delta, {
+ min: parsed.minValue,
+ max: parsed.maxValue,
+ cycle: parsed.cycle,
+ });
+ if (blocked) {
+ streamDeck.logger.info(
+ `rotary: step at endstop for ${resolvedPath} (value=${current})`,
+ );
+ if (!parsed.hideEndstopAlert) await state.action.showAlert();
+ return;
+ }
+ await this.xplane.writeDataRef(drId, target, index);
+ streamDeck.logger.info(`rotary: step ${resolvedPath} ${current} → ${target}`);
+ state.lastValue = target;
+ this.render(state);
+ if (!hideConfirmation && state.action.isKey()) await state.action.showOk();
+ } catch (err) {
+ streamDeck.logger.error("rotary: dataref step failed", err);
+ await state.action.showAlert();
+ }
+ }
+
override async onKeyUp(ev: KeyUpEvent): Promise {
const state = this.states.get(ev.action.id);
if (!state?.holdInProgress || state.activeHoldId === undefined) return;
@@ -363,9 +423,18 @@ function shouldHoldOnLast(state: ActionState | undefined, parsed: ParsedSettings
function parseSettings(s: RotarySettings): ParsedSettings {
const formatMode: RotaryFormatMode = s.formatMode === "enum" ? "enum" : "numeric";
const { enumLut, enumMaxIndex, enumValid } = parseEnumMap(s.enumMap ?? "");
+ const deltaRaw = toFiniteNumber(s.delta);
+ const delta = deltaRaw !== undefined && deltaRaw > 0 ? deltaRaw : undefined;
+ const sign: 1 | -1 = s.direction === "left" || s.direction === "down" ? -1 : 1;
return {
commandPath: trimString(s.commandPath),
datarefPath: trimString(s.datarefPath),
+ delta,
+ sign,
+ minValue: toFiniteNumber(s.minValue),
+ maxValue: toFiniteNumber(s.maxValue),
+ cycle: s.cycle === true,
+ hideEndstopAlert: s.hideEndstopAlert === true,
label: trimString(s.label),
formatMode,
format: normalizeFormat(s.format),
diff --git a/src/plugin.ts b/src/plugin.ts
index 4841a02..c71d4c9 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -17,6 +17,7 @@ import { XPlaneDataRefSwitch } from "./actions/dataref-switch";
import { XPlaneDataRefToggle } from "./actions/dataref-toggle";
import { XPlaneDataRefWrite } from "./actions/dataref-write";
import { XPlaneDisplaySelector } from "./actions/display-selector";
+import { XPlaneEncoder } from "./actions/encoder";
import { XPlaneGuardedCommand } from "./actions/guarded-command";
import { XPlaneGuardedDataRef } from "./actions/guarded-dataref";
import { XPlaneMacro } from "./actions/macro";
@@ -43,6 +44,7 @@ streamDeck.actions.registerAction(new XPlaneGuardedCommand(xplane));
streamDeck.actions.registerAction(new XPlaneGuardedDataRef(xplane));
streamDeck.actions.registerAction(new XPlaneRotary(xplane));
streamDeck.actions.registerAction(new XPlaneRotaryDataRef(xplane));
+streamDeck.actions.registerAction(new XPlaneEncoder(xplane));
streamDeck.actions.registerAction(new XPlaneDataRefDisplay(xplane));
streamDeck.actions.registerAction(new XPlaneMultiDataRefDisplay(xplane));
streamDeck.actions.registerAction(new XPlaneDataRefWrite(xplane));
diff --git a/src/util/encoder-feedback.ts b/src/util/encoder-feedback.ts
new file mode 100644
index 0000000..d413143
--- /dev/null
+++ b/src/util/encoder-feedback.ts
@@ -0,0 +1,23 @@
+/*
+ * xp_streamdeck - Stream Deck plugin for X-Plane 12
+ * Copyright (c) 2026 thWelly
+ *
+ * Licensed under the MIT License.
+ * See the LICENSE file in the project root for full license text.
+ */
+
+import streamDeck, { type DialAction } from "@elgato/streamdeck";
+import type { JsonObject } from "@elgato/utils";
+
+/** Encoder strip feedback: label above value (custom layout, no icon). */
+export async function setDialFeedback(
+ action: DialAction,
+ label: string,
+ value: string,
+): Promise {
+ try {
+ await action.setFeedback({ label: label || " ", value: value || " " });
+ } catch (err) {
+ streamDeck.logger.warn("encoder-feedback: setFeedback failed", err);
+ }
+}
diff --git a/src/util/octal.ts b/src/util/octal.ts
new file mode 100644
index 0000000..7b3e84d
--- /dev/null
+++ b/src/util/octal.ts
@@ -0,0 +1,44 @@
+/*
+ * xp_streamdeck - Stream Deck plugin for X-Plane 12
+ * Copyright (c) 2026 thWelly
+ *
+ * Licensed under the MIT License.
+ * See the LICENSE file in the project root for full license text.
+ */
+
+/** Squawk-style codes stored as decimal-looking ints with base-8 digits (0000–7777). */
+
+const PLACES = 4;
+const MOD = 8 ** PLACES;
+
+function toOrdinal(code: number): number {
+ let rest = Math.abs(Math.trunc(code));
+ let ordinal = 0;
+ const digits: number[] = [];
+ for (let i = 0; i < PLACES; i++) {
+ digits.push(Math.min(7, rest % 10));
+ rest = Math.floor(rest / 10);
+ }
+ for (let i = PLACES - 1; i >= 0; i--) ordinal = ordinal * 8 + digits[i];
+ return ordinal;
+}
+
+function fromOrdinal(ordinal: number): number {
+ let n = ((Math.trunc(ordinal) % MOD) + MOD) % MOD;
+ let code = 0;
+ let place = 1;
+ for (let i = 0; i < PLACES; i++) {
+ code += (n % 8) * place;
+ n = Math.floor(n / 8);
+ place *= 10;
+ }
+ return code;
+}
+
+/** Step a squawk-like code in octal space (1207+1 → 1210). Optional XP-style min/max. */
+export function stepOctalCode(code: number, steps: number, min?: number, max?: number): number {
+ let ordinal = toOrdinal(code) + Math.trunc(steps);
+ if (min !== undefined) ordinal = Math.max(ordinal, toOrdinal(min));
+ if (max !== undefined) ordinal = Math.min(ordinal, toOrdinal(max));
+ return fromOrdinal(ordinal);
+}
diff --git a/src/util/step.ts b/src/util/step.ts
new file mode 100644
index 0000000..22f61ae
--- /dev/null
+++ b/src/util/step.ts
@@ -0,0 +1,59 @@
+/*
+ * xp_streamdeck - Stream Deck plugin for X-Plane 12
+ * Copyright (c) 2026 thWelly
+ *
+ * Licensed under the MIT License.
+ * See the LICENSE file in the project root for full license text.
+ */
+
+import { TOLERANCE_FLOAT } from "../const";
+
+export type StepBounds = {
+ min?: number;
+ max?: number;
+ /** When true and both min/max are set, wrap through the min..max grid by |step|. */
+ cycle?: boolean;
+};
+
+/**
+ * Apply a signed step to `current`.
+ * - Default: clamp to min/max (blocked=true when already at the endstop).
+ * - Cycle: walk the discrete grid min, min+|step|, … ≤ max and wrap.
+ */
+export function applyStep(
+ current: number,
+ signedStep: number,
+ opts: StepBounds = {},
+): { value: number; blocked: boolean } {
+ const abs = Math.abs(signedStep);
+ if (!(abs > 0) || !Number.isFinite(signedStep) || !Number.isFinite(current)) {
+ return { value: current, blocked: true };
+ }
+
+ const { min, max, cycle } = opts;
+ if (
+ cycle === true &&
+ min !== undefined &&
+ max !== undefined &&
+ Number.isFinite(min) &&
+ Number.isFinite(max) &&
+ max + TOLERANCE_FLOAT >= min
+ ) {
+ const n = Math.max(0, Math.floor((max - min + TOLERANCE_FLOAT) / abs));
+ let idx = Math.round((current - min) / abs);
+ if (idx < 0) idx = 0;
+ if (idx > n) idx = n;
+ const dir = signedStep < 0 ? -1 : 1;
+ const mod = n + 1;
+ const next = (((idx + dir) % mod) + mod) % mod;
+ return { value: min + next * abs, blocked: false };
+ }
+
+ let target = current + signedStep;
+ if (min !== undefined && target < min) target = min;
+ if (max !== undefined && target > max) target = max;
+ return {
+ value: target,
+ blocked: Math.abs(target - current) < TOLERANCE_FLOAT,
+ };
+}
diff --git a/streamcontroller/com_robertw_xplane/.gitignore b/streamcontroller/com_robertw_xplane/.gitignore
new file mode 100644
index 0000000..503404e
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/.gitignore
@@ -0,0 +1,162 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+VERSION
\ No newline at end of file
diff --git a/streamcontroller/com_robertw_xplane/Icons.json b/streamcontroller/com_robertw_xplane/Icons.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/Icons.json
@@ -0,0 +1 @@
+[]
diff --git a/streamcontroller/com_robertw_xplane/LICENSE b/streamcontroller/com_robertw_xplane/LICENSE
new file mode 100644
index 0000000..c24f21d
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Core447
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/streamcontroller/com_robertw_xplane/Plugins.json b/streamcontroller/com_robertw_xplane/Plugins.json
new file mode 100644
index 0000000..366f0d5
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/Plugins.json
@@ -0,0 +1,8 @@
+[
+ {
+ "url": "https://github.com/4SLSL/xp-sdcontroller",
+ "commits": {
+ "1.5.0-beta": "fb741ec15bbe55bba1f0ad33eb519e544e7cbce3"
+ }
+ }
+]
diff --git a/streamcontroller/com_robertw_xplane/README.md b/streamcontroller/com_robertw_xplane/README.md
new file mode 100644
index 0000000..0cc77f3
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/README.md
@@ -0,0 +1,46 @@
+# X-Plane for StreamController
+
+Linux StreamController plugin mirroring the core of [`xp_streamdeck`](https://github.com/4SLSL/xp_streamdeck): talk to **X-Plane 12** via the native Web API on `localhost:8086`.
+
+Ship / install catalog: this repository doubles as a personal StreamController store (`Plugins.json`).
+
+## Actions (v0.2)
+
+| Action | Inputs | Role |
+| --- | --- | --- |
+| **Command** | Key, Dial | Activate a CommandRef |
+| **DataRef Display** | Key, Dial | Live DataRef readout |
+| **Encoder** | Dial (Key for CW step) | Rotate / click / shift — DataRef step (linear or octal) or CW/CCW commands |
+| **Rotary** | Key, Dial | Directional step or Command; optional Min/Max/**Cycle** DataRef grid |
+
+## Install
+
+### From this store (recommended)
+
+1. In StreamController, add a custom plugin URL:
+ `https://github.com/4SLSL/xp-sdcontroller`
+2. Or point a private store at this repo’s `Plugins.json` (same format as the [official Store](https://github.com/StreamController/StreamController-Store)).
+
+### Local symlink (dev)
+
+```bash
+ln -s /path/to/xp-sdcontroller \
+ /path/to/StreamController/data/plugins/com_robertw_xplane
+```
+
+Enable a FakeDeck (Settings → Developer) if you have no hardware. Restart StreamController; pick actions under **X-Plane**.
+
+Requires X-Plane 12.1.1+ with Web API enabled.
+
+## Rotary + Cycle
+
+Set **Delta** > 0 and a **DataRef Path**. **Direction** chooses the sign (`right`/`up` = +, `left`/`down` = −). With **Min**, **Max**, and **Cycle** enabled, values wrap on the discrete grid (`min…max` by `|delta|`). Leave Delta at 0 to fire **Command Path** instead.
+
+## Parity roadmap
+
+Still to port from the Elgato plugin: Toggle, Switch, Guarded, Lamp, Macro, Wind, Multi-Display, selectors, WebSocket subscriptions (~10 Hz) instead of `on_tick` polling.
+
+## Docs
+
+- StreamController plugins:
+- X-Plane Web API:
diff --git a/streamcontroller/com_robertw_xplane/actions/Command/Command.py b/streamcontroller/com_robertw_xplane/actions/Command/Command.py
new file mode 100644
index 0000000..6990c5b
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/Command/Command.py
@@ -0,0 +1,69 @@
+"""X-Plane Command — fire a CommandRef on key / dial press."""
+
+from __future__ import annotations
+
+from GtkHelper.GenerativeUI.EntryRow import EntryRow
+from GtkHelper.GenerativeUI.SwitchRow import SwitchRow
+from loguru import logger as log
+from src.backend.DeckManagement.InputIdentifier import Input
+from src.backend.PluginManager.ActionCore import ActionCore
+from src.backend.PluginManager.EventAssigner import EventAssigner
+
+
+class Command(ActionCore):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.has_configuration = True
+ self.add_event_assigner(
+ EventAssigner(
+ id="xplane_command_pressed",
+ ui_label="Activate",
+ default_events=[Input.Key.Events.DOWN, Input.Dial.Events.DOWN],
+ callback=self.on_activate,
+ )
+ )
+
+ def on_ready(self) -> None:
+ self.set_media(media_path=self.get_asset_path("info.png"), size=0.75)
+ path = (self.get_settings().get("command_path") or "").strip()
+ label = (self.get_settings().get("label") or "").strip()
+ self.set_top_label(label)
+ self.set_bottom_label(path.split("/")[-1] if path else "Command")
+
+ def get_config_rows(self):
+ EntryRow(
+ action_core=self,
+ var_name="command_path",
+ default_value="sim/operation/pause_toggle",
+ title="Command Path",
+ on_change=lambda *_: self.on_ready(),
+ )
+ EntryRow(
+ action_core=self,
+ var_name="label",
+ default_value="",
+ title="Label",
+ on_change=lambda *_: self.on_ready(),
+ )
+ SwitchRow(
+ action_core=self,
+ var_name="hide_error",
+ default_value=False,
+ title="Hide error overlay",
+ )
+ return self.get_generative_ui_widgets()
+
+ def on_activate(self, _data=None) -> None:
+ path = (self.get_settings().get("command_path") or "").strip()
+ if not path:
+ self.show_error(duration=2)
+ return
+ try:
+ xplane = self.plugin_base.xplane
+ cmd_id = xplane.get_command_id(path)
+ xplane.activate_command(cmd_id)
+ log.info(f"xplane command: {path} (id={cmd_id})")
+ except Exception as err:
+ log.error(f"xplane command failed: {path}: {err}")
+ if not self.get_settings().get("hide_error", False):
+ self.show_error(duration=2)
diff --git a/streamcontroller/com_robertw_xplane/actions/Command/__init__.py b/streamcontroller/com_robertw_xplane/actions/Command/__init__.py
new file mode 100644
index 0000000..de4dbf4
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/Command/__init__.py
@@ -0,0 +1 @@
+# Command action package
diff --git a/streamcontroller/com_robertw_xplane/actions/DataRefDisplay/DataRefDisplay.py b/streamcontroller/com_robertw_xplane/actions/DataRefDisplay/DataRefDisplay.py
new file mode 100644
index 0000000..f2b46e0
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/DataRefDisplay/DataRefDisplay.py
@@ -0,0 +1,97 @@
+"""X-Plane DataRef Display — live value on a key."""
+
+from __future__ import annotations
+
+from GtkHelper.GenerativeUI.EntryRow import EntryRow
+from GtkHelper.GenerativeUI.SpinRow import SpinRow
+from loguru import logger as log
+from src.backend.PluginManager.ActionCore import ActionCore
+
+from ...xplane import apply_index, format_value, parse_dataref_path
+
+
+class DataRefDisplay(ActionCore):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.has_configuration = True
+ self._last_path = ""
+
+ def on_ready(self) -> None:
+ self.set_media(media_path=self.get_asset_path("info.png"), size=0.75)
+ s = self.get_settings()
+ self.set_top_label((s.get("label") or "").strip())
+ self._refresh()
+
+ def on_tick(self) -> None:
+ self._refresh()
+
+ def get_config_rows(self):
+ EntryRow(
+ action_core=self,
+ var_name="dataref_path",
+ default_value="sim/cockpit2/gauges/indicators/airspeed_kts_pilot",
+ title="DataRef Path",
+ on_change=lambda *_: self.on_ready(),
+ )
+ EntryRow(
+ action_core=self,
+ var_name="label",
+ default_value="IAS",
+ title="Label",
+ on_change=lambda *_: self.on_ready(),
+ )
+ EntryRow(
+ action_core=self,
+ var_name="format",
+ default_value="%.0f",
+ title="Format",
+ on_change=lambda *_: self.on_ready(),
+ )
+ EntryRow(
+ action_core=self,
+ var_name="unit",
+ default_value="kt",
+ title="Unit",
+ on_change=lambda *_: self.on_ready(),
+ )
+ SpinRow(
+ action_core=self,
+ var_name="unit_scale",
+ default_value=1.0,
+ title="Unit Scale",
+ min=0.0,
+ max=1_000_000.0,
+ step=0.001,
+ digits=6,
+ on_change=lambda *_: self.on_ready(),
+ )
+ return self.get_generative_ui_widgets()
+
+ def _refresh(self) -> None:
+ s = self.get_settings()
+ path = (s.get("dataref_path") or "").strip()
+ if not path:
+ self.set_center_label("")
+ self.set_bottom_label("—")
+ return
+ try:
+ xplane = self.plugin_base.xplane
+ base, index = parse_dataref_path(path)
+ dr_id = xplane.get_dataref_id(base)
+ raw = apply_index(xplane.read_dataref(dr_id), index)
+ scale_raw = s.get("unit_scale", 1.0)
+ try:
+ scale_f = float(scale_raw)
+ except (TypeError, ValueError):
+ scale_f = 1.0
+ text = format_value(
+ raw,
+ fmt=(s.get("format") or "%s").strip() or "%s",
+ unit=(s.get("unit") or "").strip(),
+ unit_scale=None if scale_f == 1.0 else scale_f,
+ )
+ self.set_center_label(text)
+ self.set_bottom_label("")
+ except Exception as err:
+ log.warning(f"dataref-display: {err}")
+ self.set_center_label("OFFLINE" if not self.plugin_base.xplane.is_online() else "?")
diff --git a/streamcontroller/com_robertw_xplane/actions/DataRefDisplay/__init__.py b/streamcontroller/com_robertw_xplane/actions/DataRefDisplay/__init__.py
new file mode 100644
index 0000000..15bc7b7
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/DataRefDisplay/__init__.py
@@ -0,0 +1 @@
+# DataRefDisplay action package
diff --git a/streamcontroller/com_robertw_xplane/actions/Encoder/Encoder.py b/streamcontroller/com_robertw_xplane/actions/Encoder/Encoder.py
new file mode 100644
index 0000000..51e059f
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/Encoder/Encoder.py
@@ -0,0 +1,320 @@
+"""
+X-Plane Encoder — dial / key stepper for DataRefs (linear or octal) or command pairs.
+
+Gestures (dial):
+ rotate CW/CCW → fine step / command
+ press+rotate → shift (coarse delta or shift commands)
+ press+release → click command
+"""
+
+from __future__ import annotations
+
+from GtkHelper.GenerativeUI.ComboRow import ComboRow
+from GtkHelper.GenerativeUI.EntryRow import EntryRow
+from GtkHelper.GenerativeUI.SpinRow import SpinRow
+from GtkHelper.GenerativeUI.SwitchRow import SwitchRow
+from loguru import logger as log
+from src.backend.DeckManagement.InputIdentifier import Input
+from src.backend.PluginManager.ActionCore import ActionCore
+from src.backend.PluginManager.EventAssigner import EventAssigner
+
+from ...xplane import (
+ apply_index,
+ apply_step,
+ coerce_number,
+ format_value,
+ parse_dataref_path,
+ step_octal_code,
+)
+
+
+class Encoder(ActionCore):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.has_configuration = True
+ self._pressed = False
+ self._shifted = False
+ self._last_value: float | None = None
+
+ self.add_event_assigner(
+ EventAssigner(
+ id="xplane_encoder_cw",
+ ui_label="Rotate CW",
+ default_events=[Input.Dial.Events.TURN_CW, Input.Key.Events.DOWN],
+ callback=lambda data: self.on_rotate(+1, data),
+ )
+ )
+ self.add_event_assigner(
+ EventAssigner(
+ id="xplane_encoder_ccw",
+ ui_label="Rotate CCW",
+ default_events=[Input.Dial.Events.TURN_CCW],
+ callback=lambda data: self.on_rotate(-1, data),
+ )
+ )
+ self.add_event_assigner(
+ EventAssigner(
+ id="xplane_encoder_down",
+ ui_label="Press",
+ default_events=[Input.Dial.Events.DOWN],
+ callback=self.on_down,
+ )
+ )
+ self.add_event_assigner(
+ EventAssigner(
+ id="xplane_encoder_up",
+ ui_label="Release",
+ default_events=[Input.Dial.Events.UP],
+ callback=self.on_up,
+ )
+ )
+
+ def on_ready(self) -> None:
+ self.set_media(media_path=self.get_asset_path("info.png"), size=0.75)
+ s = self.get_settings()
+ self.set_top_label((s.get("label") or "").strip())
+ self._refresh_label()
+
+ def on_tick(self) -> None:
+ self._refresh_label()
+
+ def get_config_rows(self):
+ ComboRow(
+ action_core=self,
+ var_name="drive_mode",
+ default_value="dataref",
+ title="Drive Mode",
+ items=["dataref", "command"],
+ on_change=lambda *_: self.on_ready(),
+ )
+ ComboRow(
+ action_core=self,
+ var_name="step_mode",
+ default_value="linear",
+ title="Step Mode",
+ items=["linear", "octal"],
+ )
+ EntryRow(
+ action_core=self,
+ var_name="dataref_path",
+ default_value="sim/cockpit2/autopilot/heading_dial_deg_mag_pilot",
+ title="DataRef Path",
+ on_change=lambda *_: self.on_ready(),
+ )
+ SpinRow(
+ action_core=self,
+ var_name="delta",
+ default_value=1.0,
+ title="Delta",
+ min=0.0,
+ max=1_000_000.0,
+ step=0.01,
+ digits=4,
+ )
+ SpinRow(
+ action_core=self,
+ var_name="coarse_delta",
+ default_value=0.0,
+ title="Shift Delta (0 = same as Delta)",
+ min=0.0,
+ max=1_000_000.0,
+ step=0.01,
+ digits=4,
+ )
+ EntryRow(
+ action_core=self,
+ var_name="min_value",
+ default_value="",
+ title="Min Value",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="max_value",
+ default_value="",
+ title="Max Value",
+ )
+ SwitchRow(
+ action_core=self,
+ var_name="cycle",
+ default_value=False,
+ title="Cycle at Min/Max",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="command_cw",
+ default_value="",
+ title="CW Command",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="command_ccw",
+ default_value="",
+ title="CCW Command",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="shift_command_cw",
+ default_value="",
+ title="Shift CW Command",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="shift_command_ccw",
+ default_value="",
+ title="Shift CCW Command",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="click_command",
+ default_value="",
+ title="Click Command",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="label",
+ default_value="HDG",
+ title="Label",
+ on_change=lambda *_: self.on_ready(),
+ )
+ EntryRow(
+ action_core=self,
+ var_name="format",
+ default_value="%.0f",
+ title="Format",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="unit",
+ default_value="°",
+ title="Unit",
+ )
+ return self.get_generative_ui_widgets()
+
+ def on_down(self, _data=None) -> None:
+ self._pressed = True
+ self._shifted = False
+
+ def on_up(self, _data=None) -> None:
+ shifted = self._shifted
+ self._pressed = False
+ if not shifted:
+ click = (self.get_settings().get("click_command") or "").strip()
+ if click:
+ self._fire_command(click)
+ self._shifted = False
+
+ def on_rotate(self, direction: int, _data=None) -> None:
+ shift = self._pressed
+ if shift:
+ self._shifted = True
+ s = self.get_settings()
+ ticks = direction # ±1 per event; multiply if event payload has ticks later
+ if (s.get("drive_mode") or "dataref") == "command":
+ path = self._command_for(ticks > 0, shift)
+ if path:
+ self._fire_command(path)
+ else:
+ self.show_error(duration=1)
+ return
+ self._step_dataref(ticks, shift)
+
+ def _command_for(self, cw: bool, shift: bool) -> str:
+ s = self.get_settings()
+ if shift:
+ primary = "shift_command_cw" if cw else "shift_command_ccw"
+ fallback = "command_cw" if cw else "command_ccw"
+ return (s.get(primary) or s.get(fallback) or "").strip()
+ return (s.get("command_cw" if cw else "command_ccw") or "").strip()
+
+ def _fire_command(self, path: str) -> None:
+ try:
+ xplane = self.plugin_base.xplane
+ cmd_id = xplane.get_command_id(path)
+ xplane.activate_command(cmd_id)
+ log.info(f"encoder command: {path}")
+ except Exception as err:
+ log.error(f"encoder command failed: {path}: {err}")
+ self.show_error(duration=2)
+
+ def _opt_float(self, key: str) -> float | None:
+ raw = self.get_settings().get(key, "")
+ if raw in (None, ""):
+ return None
+ try:
+ return float(raw)
+ except (TypeError, ValueError):
+ return None
+
+ def _step_dataref(self, ticks: int, shift: bool) -> None:
+ s = self.get_settings()
+ path = (s.get("dataref_path") or "").strip()
+ if not path:
+ self.show_error(duration=1)
+ return
+ delta = float(s.get("delta") or 1)
+ coarse = float(s.get("coarse_delta") or 0)
+ base = coarse if shift and coarse > 0 else delta
+ if not (base > 0):
+ self.show_error(duration=1)
+ return
+ step = base * abs(ticks) * (1 if ticks > 0 else -1)
+ min_v = self._opt_float("min_value")
+ max_v = self._opt_float("max_value")
+ cycle = bool(s.get("cycle", False))
+ octal = (s.get("step_mode") or "linear") == "octal"
+
+ try:
+ xplane = self.plugin_base.xplane
+ base_path, index = parse_dataref_path(path)
+ dr_id = xplane.get_dataref_id(base_path)
+ current_raw = (
+ self._last_value
+ if self._last_value is not None
+ else apply_index(xplane.read_dataref(dr_id), index)
+ )
+ current = coerce_number(current_raw) or 0.0
+ if octal:
+ target = step_octal_code(current, int(step), min_v, max_v)
+ blocked = abs(target - current) < 1e-6
+ else:
+ target, blocked = apply_step(
+ current, step, min_v=min_v, max_v=max_v, cycle=cycle
+ )
+ if blocked:
+ self.show_error(duration=1)
+ return
+ xplane.write_dataref(dr_id, target, index)
+ self._last_value = target
+ self._paint_value(target)
+ log.info(f"encoder step {path}: {current} → {target}")
+ except Exception as err:
+ log.error(f"encoder step failed: {err}")
+ self.show_error(duration=2)
+
+ def _refresh_label(self) -> None:
+ s = self.get_settings()
+ path = (s.get("dataref_path") or "").strip()
+ if not path:
+ self.set_center_label("")
+ return
+ try:
+ xplane = self.plugin_base.xplane
+ base_path, index = parse_dataref_path(path)
+ dr_id = xplane.get_dataref_id(base_path)
+ raw = apply_index(xplane.read_dataref(dr_id), index)
+ num = coerce_number(raw)
+ if num is not None:
+ self._last_value = num
+ self._paint_value(raw)
+ except Exception:
+ offline = not self.plugin_base.xplane.ping()
+ self.set_center_label("OFFLINE" if offline else "?")
+
+ def _paint_value(self, raw) -> None:
+ s = self.get_settings()
+ text = format_value(
+ raw,
+ fmt=(s.get("format") or "%s").strip() or "%s",
+ unit=(s.get("unit") or "").strip(),
+ )
+ self.set_center_label(text)
diff --git a/streamcontroller/com_robertw_xplane/actions/Encoder/__init__.py b/streamcontroller/com_robertw_xplane/actions/Encoder/__init__.py
new file mode 100644
index 0000000..fa010e6
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/Encoder/__init__.py
@@ -0,0 +1 @@
+# Encoder action package
diff --git a/streamcontroller/com_robertw_xplane/actions/Rotary/Rotary.py b/streamcontroller/com_robertw_xplane/actions/Rotary/Rotary.py
new file mode 100644
index 0000000..202e9d8
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/Rotary/Rotary.py
@@ -0,0 +1,322 @@
+"""
+X-Plane Rotary — key/dial step for multi-position switches.
+
+Press either:
+ - steps a DataRef by ±Delta (Direction sets the sign), with optional Min/Max/Cycle, or
+ - activates a CommandPath when Delta is unset / empty.
+"""
+
+from __future__ import annotations
+
+from GtkHelper.GenerativeUI.ComboRow import ComboRow
+from GtkHelper.GenerativeUI.EntryRow import EntryRow
+from GtkHelper.GenerativeUI.SpinRow import SpinRow
+from GtkHelper.GenerativeUI.SwitchRow import SwitchRow
+from loguru import logger as log
+from src.backend.DeckManagement.InputIdentifier import Input
+from src.backend.PluginManager.ActionCore import ActionCore
+from src.backend.PluginManager.EventAssigner import EventAssigner
+
+from ...xplane import (
+ apply_index,
+ apply_step,
+ coerce_number,
+ format_value,
+ parse_dataref_path,
+)
+
+_DIR_SIGN = {
+ "right": 1,
+ "up": 1,
+ "left": -1,
+ "down": -1,
+}
+
+
+class Rotary(ActionCore):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.has_configuration = True
+ self._last_value: float | None = None
+ self._hold_id: int | None = None
+
+ self.add_event_assigner(
+ EventAssigner(
+ id="xplane_rotary_down",
+ ui_label="Press",
+ default_events=[Input.Key.Events.DOWN, Input.Dial.Events.DOWN],
+ callback=self.on_press,
+ )
+ )
+ self.add_event_assigner(
+ EventAssigner(
+ id="xplane_rotary_up",
+ ui_label="Release",
+ default_events=[Input.Key.Events.UP, Input.Dial.Events.UP],
+ callback=self.on_release,
+ )
+ )
+
+ def on_ready(self) -> None:
+ self.set_media(media_path=self.get_asset_path("info.png"), size=0.75)
+ s = self.get_settings()
+ self.set_top_label((s.get("label") or "").strip())
+ self._refresh_label()
+
+ def on_tick(self) -> None:
+ self._refresh_label()
+
+ def get_config_rows(self):
+ EntryRow(
+ action_core=self,
+ var_name="command_path",
+ default_value="",
+ title="Command Path",
+ on_change=lambda *_: self.on_ready(),
+ )
+ ComboRow(
+ action_core=self,
+ var_name="direction",
+ default_value="right",
+ title="Direction",
+ items=["right", "left", "up", "down"],
+ )
+ EntryRow(
+ action_core=self,
+ var_name="dataref_path",
+ default_value="sim/cockpit2/engine/actuators/ignition_key",
+ title="DataRef Path",
+ on_change=lambda *_: self.on_ready(),
+ )
+ SpinRow(
+ action_core=self,
+ var_name="delta",
+ default_value=0.0,
+ title="Delta (0 = use Command Path)",
+ min=0.0,
+ max=1_000_000.0,
+ step=0.01,
+ digits=4,
+ )
+ EntryRow(
+ action_core=self,
+ var_name="min_value",
+ default_value="",
+ title="Min Value",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="max_value",
+ default_value="",
+ title="Max Value",
+ )
+ SwitchRow(
+ action_core=self,
+ var_name="cycle",
+ default_value=False,
+ title="Cycle at Min/Max",
+ )
+ SwitchRow(
+ action_core=self,
+ var_name="hide_endstop_alert",
+ default_value=False,
+ title="Hide endstop alert",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="hold_command",
+ default_value="",
+ title="Hold Command (at last position)",
+ )
+ SwitchRow(
+ action_core=self,
+ var_name="hold_on_last",
+ default_value=False,
+ title="Hold when already at last position",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="label",
+ default_value="",
+ title="Label",
+ on_change=lambda *_: self.on_ready(),
+ )
+ EntryRow(
+ action_core=self,
+ var_name="format",
+ default_value="%s",
+ title="Format",
+ )
+ EntryRow(
+ action_core=self,
+ var_name="unit",
+ default_value="",
+ title="Unit",
+ )
+ return self.get_generative_ui_widgets()
+
+ def on_press(self, _data=None) -> None:
+ s = self.get_settings()
+ delta = float(s.get("delta") or 0)
+ dataref = (s.get("dataref_path") or "").strip()
+ command = (s.get("command_path") or "").strip()
+ hold_cmd = (s.get("hold_command") or "").strip()
+ can_step = delta > 0 and bool(dataref)
+
+ if not can_step and not command and not hold_cmd:
+ self.show_error(duration=1)
+ return
+
+ if can_step and bool(s.get("hold_on_last", False)) and hold_cmd:
+ if self._at_last_position(delta):
+ self._begin_hold(hold_cmd)
+ return
+
+ if can_step:
+ self._step_dataref(delta)
+ return
+
+ if command:
+ self._fire_command(command)
+ else:
+ self.show_error(duration=1)
+
+ def on_release(self, _data=None) -> None:
+ if self._hold_id is None:
+ return
+ try:
+ self.plugin_base.xplane.end_command(self._hold_id)
+ log.info(f"rotary hold end id={self._hold_id}")
+ except Exception as err:
+ log.error(f"rotary hold end failed: {err}")
+ self.show_error(duration=2)
+ finally:
+ self._hold_id = None
+
+ def _sign(self) -> int:
+ direction = (self.get_settings().get("direction") or "right").strip().lower()
+ return _DIR_SIGN.get(direction, 1)
+
+ def _opt_float(self, key: str) -> float | None:
+ raw = self.get_settings().get(key, "")
+ if raw in (None, ""):
+ return None
+ try:
+ return float(raw)
+ except (TypeError, ValueError):
+ return None
+
+ def _at_last_position(self, delta: float) -> bool:
+ """True when the next step would be blocked (endstop) without cycle."""
+ s = self.get_settings()
+ if bool(s.get("cycle", False)):
+ return False
+ min_v = self._opt_float("min_value")
+ max_v = self._opt_float("max_value")
+ path = (s.get("dataref_path") or "").strip()
+ if not path:
+ return False
+ try:
+ xplane = self.plugin_base.xplane
+ base_path, index = parse_dataref_path(path)
+ dr_id = xplane.get_dataref_id(base_path)
+ current_raw = (
+ self._last_value
+ if self._last_value is not None
+ else apply_index(xplane.read_dataref(dr_id), index)
+ )
+ current = coerce_number(current_raw) or 0.0
+ _, blocked = apply_step(
+ current,
+ self._sign() * delta,
+ min_v=min_v,
+ max_v=max_v,
+ cycle=False,
+ )
+ return blocked
+ except Exception:
+ return False
+
+ def _begin_hold(self, path: str) -> None:
+ try:
+ xplane = self.plugin_base.xplane
+ cmd_id = xplane.get_command_id(path)
+ xplane.begin_command(cmd_id)
+ self._hold_id = cmd_id
+ log.info(f"rotary hold begin: {path} (id={cmd_id})")
+ except Exception as err:
+ log.error(f"rotary hold begin failed: {path}: {err}")
+ self.show_error(duration=2)
+
+ def _fire_command(self, path: str) -> None:
+ try:
+ xplane = self.plugin_base.xplane
+ cmd_id = xplane.get_command_id(path)
+ xplane.activate_command(cmd_id)
+ log.info(f"rotary command: {path}")
+ except Exception as err:
+ log.error(f"rotary command failed: {path}: {err}")
+ self.show_error(duration=2)
+
+ def _step_dataref(self, delta: float) -> None:
+ s = self.get_settings()
+ path = (s.get("dataref_path") or "").strip()
+ min_v = self._opt_float("min_value")
+ max_v = self._opt_float("max_value")
+ cycle = bool(s.get("cycle", False))
+ hide_endstop = bool(s.get("hide_endstop_alert", False))
+ step = self._sign() * delta
+
+ try:
+ xplane = self.plugin_base.xplane
+ base_path, index = parse_dataref_path(path)
+ dr_id = xplane.get_dataref_id(base_path)
+ current_raw = (
+ self._last_value
+ if self._last_value is not None
+ else apply_index(xplane.read_dataref(dr_id), index)
+ )
+ current = coerce_number(current_raw) or 0.0
+ target, blocked = apply_step(
+ current, step, min_v=min_v, max_v=max_v, cycle=cycle
+ )
+ if blocked:
+ log.info(f"rotary endstop {path} value={current}")
+ if not hide_endstop:
+ self.show_error(duration=1)
+ return
+ xplane.write_dataref(dr_id, target, index)
+ self._last_value = target
+ self._paint_value(target)
+ log.info(f"rotary step {path}: {current} → {target}")
+ except Exception as err:
+ log.error(f"rotary step failed: {err}")
+ self.show_error(duration=2)
+
+ def _refresh_label(self) -> None:
+ s = self.get_settings()
+ path = (s.get("dataref_path") or "").strip()
+ if not path:
+ self.set_center_label("")
+ return
+ try:
+ xplane = self.plugin_base.xplane
+ base_path, index = parse_dataref_path(path)
+ dr_id = xplane.get_dataref_id(base_path)
+ raw = apply_index(xplane.read_dataref(dr_id), index)
+ num = coerce_number(raw)
+ if num is not None:
+ self._last_value = num
+ self._paint_value(raw)
+ except Exception:
+ offline = not self.plugin_base.xplane.ping()
+ self.set_center_label("OFFLINE" if offline else "?")
+
+ def _paint_value(self, raw) -> None:
+ s = self.get_settings()
+ text = format_value(
+ raw,
+ fmt=(s.get("format") or "%s").strip() or "%s",
+ unit=(s.get("unit") or "").strip(),
+ )
+ self.set_center_label(text)
diff --git a/streamcontroller/com_robertw_xplane/actions/Rotary/__init__.py b/streamcontroller/com_robertw_xplane/actions/Rotary/__init__.py
new file mode 100644
index 0000000..4931cae
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/Rotary/__init__.py
@@ -0,0 +1 @@
+# Rotary action package
diff --git a/streamcontroller/com_robertw_xplane/actions/__init__.py b/streamcontroller/com_robertw_xplane/actions/__init__.py
new file mode 100644
index 0000000..df89dba
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/actions/__init__.py
@@ -0,0 +1 @@
+# Package marker for StreamController plugin imports.
diff --git a/streamcontroller/com_robertw_xplane/assets/Attribution.txt b/streamcontroller/com_robertw_xplane/assets/Attribution.txt
new file mode 100644
index 0000000..c9dd644
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/assets/Attribution.txt
@@ -0,0 +1,2 @@
+All images in this folder (and subfolders) are from fonts.google.com and licensed under the Apache License, Version 2.0.
+You can retrieve a copy of the license at https://www.apache.org/licenses/LICENSE-2.0.
\ No newline at end of file
diff --git a/streamcontroller/com_robertw_xplane/assets/info.png b/streamcontroller/com_robertw_xplane/assets/info.png
new file mode 100644
index 0000000..e1e4e2b
Binary files /dev/null and b/streamcontroller/com_robertw_xplane/assets/info.png differ
diff --git a/streamcontroller/com_robertw_xplane/attribution.json b/streamcontroller/com_robertw_xplane/attribution.json
new file mode 100644
index 0000000..1646277
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/attribution.json
@@ -0,0 +1,10 @@
+{
+ "github-sponsors": "4SLSL",
+ "generic": {
+ "copyright": "Copyright (c) 2026 thWelly / 4LSL",
+ "license": "MIT",
+ "license-url": "https://opensource.org/licenses/MIT",
+ "description": "X-Plane 12 plugin for StreamController",
+ "url": "https://github.com/4SLSL/xp-sdcontroller"
+ }
+}
diff --git a/streamcontroller/com_robertw_xplane/main.py b/streamcontroller/com_robertw_xplane/main.py
new file mode 100644
index 0000000..ca746a9
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/main.py
@@ -0,0 +1,79 @@
+# Import StreamController modules
+from src.backend.DeckManagement.InputIdentifier import Input
+from src.backend.PluginManager.ActionHolder import ActionHolder
+from src.backend.PluginManager.ActionInputSupport import ActionInputSupport
+from src.backend.PluginManager.PluginBase import PluginBase
+
+from .actions.Command.Command import Command
+from .actions.DataRefDisplay.DataRefDisplay import DataRefDisplay
+from .actions.Encoder.Encoder import Encoder
+from .actions.Rotary.Rotary import Rotary
+from .xplane import XPlaneClient
+
+
+class XPlanePlugin(PluginBase):
+ def __init__(self):
+ super().__init__()
+
+ self.xplane = XPlaneClient()
+ self.xplane.ping()
+
+ self.add_action_holder(
+ ActionHolder(
+ plugin_base=self,
+ action_core=Command,
+ action_id_suffix="Command",
+ action_name="Command",
+ action_support={
+ Input.Key: ActionInputSupport.SUPPORTED,
+ Input.Dial: ActionInputSupport.SUPPORTED,
+ Input.Touchscreen: ActionInputSupport.UNSUPPORTED,
+ },
+ )
+ )
+ self.add_action_holder(
+ ActionHolder(
+ plugin_base=self,
+ action_core=DataRefDisplay,
+ action_id_suffix="DataRefDisplay",
+ action_name="DataRef Display",
+ action_support={
+ Input.Key: ActionInputSupport.SUPPORTED,
+ Input.Dial: ActionInputSupport.SUPPORTED,
+ Input.Touchscreen: ActionInputSupport.UNSUPPORTED,
+ },
+ )
+ )
+ self.add_action_holder(
+ ActionHolder(
+ plugin_base=self,
+ action_core=Encoder,
+ action_id_suffix="Encoder",
+ action_name="Encoder",
+ action_support={
+ Input.Key: ActionInputSupport.SUPPORTED,
+ Input.Dial: ActionInputSupport.SUPPORTED,
+ Input.Touchscreen: ActionInputSupport.UNSUPPORTED,
+ },
+ )
+ )
+ self.add_action_holder(
+ ActionHolder(
+ plugin_base=self,
+ action_core=Rotary,
+ action_id_suffix="Rotary",
+ action_name="Rotary",
+ action_support={
+ Input.Key: ActionInputSupport.SUPPORTED,
+ Input.Dial: ActionInputSupport.SUPPORTED,
+ Input.Touchscreen: ActionInputSupport.UNSUPPORTED,
+ },
+ )
+ )
+
+ self.register(
+ plugin_name="X-Plane",
+ github_repo="https://github.com/4SLSL/xp-sdcontroller",
+ plugin_version="0.2.0",
+ app_version="1.5.0-beta.14",
+ )
diff --git a/streamcontroller/com_robertw_xplane/manifest.json b/streamcontroller/com_robertw_xplane/manifest.json
new file mode 100644
index 0000000..1775418
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/manifest.json
@@ -0,0 +1,9 @@
+{
+ "version": "0.2.0",
+ "thumbnail": "assets/info.png",
+ "id": "com_robertw_xplane",
+ "name": "X-Plane",
+ "descriptions": {
+ "en_US": "Control X-Plane 12 from StreamController via the native Web API (Command, DataRef Display, Encoder, Rotary with cycle)."
+ }
+}
diff --git a/streamcontroller/com_robertw_xplane/requirements.txt b/streamcontroller/com_robertw_xplane/requirements.txt
new file mode 100644
index 0000000..da8d799
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/requirements.txt
@@ -0,0 +1,2 @@
+# Frontend / normal requirements for StreamController install.
+# (stdlib urllib only for now — keep empty unless you add pip deps.)
diff --git a/streamcontroller/com_robertw_xplane/xplane/__init__.py b/streamcontroller/com_robertw_xplane/xplane/__init__.py
new file mode 100644
index 0000000..2a4d780
--- /dev/null
+++ b/streamcontroller/com_robertw_xplane/xplane/__init__.py
@@ -0,0 +1,284 @@
+"""
+com_robertw_xplane — StreamController plugin for X-Plane 12
+Copyright (c) 2026 thWelly — MIT License
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import urllib.error
+import urllib.parse
+import urllib.request
+from typing import Any, Callable
+
+DEFAULT_HOST = "localhost"
+DEFAULT_PORT = 8086
+API_VERSION = "v3"
+TIMEOUT_S = 2.0
+
+DataRefValue = float | int | str | bool | list[float] | None
+DataRefCallback = Callable[[DataRefValue], None]
+
+
+class XPlaneClient:
+ """Minimal X-Plane Web API v3 client (REST). Shared across all actions."""
+
+ def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
+ self.host = host
+ self.port = port
+ self._dataref_ids: dict[str, int] = {}
+ self._command_ids: dict[str, int] = {}
+ self._lock = threading.Lock()
+ self._online = False
+
+ @property
+ def base(self) -> str:
+ return f"http://{self.host}:{self.port}/api/{API_VERSION}"
+
+ def is_online(self) -> bool:
+ return self._online
+
+ def ping(self) -> bool:
+ try:
+ self._get(f"{self.base}/datarefs?filter[name]=sim/time/zulu_time_sec")
+ self._online = True
+ return True
+ except Exception:
+ self._online = False
+ return False
+
+ def get_command_id(self, name: str) -> int:
+ with self._lock:
+ cached = self._command_ids.get(name)
+ if cached is not None:
+ return cached
+ url = f"{self.base}/commands?filter[name]={urllib.parse.quote(name)}"
+ body = self._get(url)
+ match = next((c for c in (body.get("data") or []) if c.get("name") == name), None)
+ if not match:
+ match = (body.get("data") or [None])[0]
+ if not match or not isinstance(match.get("id"), int):
+ raise LookupError(f"Command not found: {name}")
+ with self._lock:
+ self._command_ids[name] = match["id"]
+ return match["id"]
+
+ def get_dataref_id(self, name: str) -> int:
+ with self._lock:
+ cached = self._dataref_ids.get(name)
+ if cached is not None:
+ return cached
+ url = f"{self.base}/datarefs?filter[name]={urllib.parse.quote(name)}"
+ body = self._get(url)
+ match = next((c for c in (body.get("data") or []) if c.get("name") == name), None)
+ if not match:
+ match = (body.get("data") or [None])[0]
+ if not match or not isinstance(match.get("id"), int):
+ raise LookupError(f"DataRef not found: {name}")
+ with self._lock:
+ self._dataref_ids[name] = match["id"]
+ return match["id"]
+
+ def activate_command(self, command_id: int, duration: float = 0) -> None:
+ url = f"{self.base}/command/{command_id}/activate"
+ self._post(url, {"duration": duration})
+
+ def begin_command(self, command_id: int) -> None:
+ url = f"{self.base}/command/{command_id}/activate"
+ self._post(url, {"duration": -1})
+
+ def end_command(self, command_id: int) -> None:
+ # Web API: POST with is_active false via set — use duration 0 end by re-activate docs
+ # X-Plane v3 uses PATCH on command active state via websocket typically;
+ # REST fallback: activate with duration 0 is a click. For hold end we POST deactivate.
+ url = f"{self.base}/command/{command_id}/deactivate"
+ try:
+ self._post(url, {})
+ except Exception:
+ # Some builds only support WS begin/end; ignore REST deactivate failures.
+ pass
+
+ def read_dataref(self, dataref_id: int) -> DataRefValue:
+ url = f"{self.base}/datarefs/{dataref_id}/value"
+ body = self._get(url)
+ return body.get("data")
+
+ def write_dataref(self, dataref_id: int, value: DataRefValue, index: int | None = None) -> None:
+ url = f"{self.base}/datarefs/{dataref_id}/value"
+ if index is not None:
+ current = self.read_dataref(dataref_id)
+ if not isinstance(current, list):
+ raise TypeError(f"DataRef {dataref_id} is not an array")
+ if index < 0 or index >= len(current):
+ raise IndexError(f"index {index} out of bounds")
+ next_arr = list(current)
+ next_arr[index] = value
+ self._patch(url, {"data": next_arr})
+ return
+ self._patch(url, {"data": value})
+
+ def _get(self, url: str) -> Any:
+ return self._request("GET", url)
+
+ def _post(self, url: str, payload: dict) -> Any:
+ return self._request("POST", url, payload)
+
+ def _patch(self, url: str, payload: dict) -> Any:
+ return self._request("PATCH", url, payload)
+
+ def _request(self, method: str, url: str, payload: dict | None = None) -> Any:
+ data = None
+ headers = {"Accept": "application/json"}
+ if payload is not None:
+ data = json.dumps(payload).encode("utf-8")
+ headers["Content-Type"] = "application/json"
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
+ raw = resp.read().decode("utf-8")
+ self._online = True
+ return json.loads(raw) if raw else {}
+ except urllib.error.HTTPError as err:
+ self._online = False
+ body = err.read().decode("utf-8", errors="replace")
+ raise RuntimeError(f"HTTP {err.code} {method} {url}: {body}") from err
+ except Exception:
+ self._online = False
+ raise
+
+
+def parse_dataref_path(path: str) -> tuple[str, int | None]:
+ """Split `name[index]` → (name, index)."""
+ path = path.strip()
+ if path.endswith("]") and "[" in path:
+ base, _, rest = path.rpartition("[")
+ idx_s = rest[:-1]
+ if idx_s.isdigit():
+ return base, int(idx_s)
+ return path, None
+
+
+def apply_index(value: DataRefValue, index: int | None) -> DataRefValue:
+ if index is None or not isinstance(value, list):
+ return value
+ if index < 0 or index >= len(value):
+ raise IndexError(f"index {index} out of bounds")
+ return value[index]
+
+
+def coerce_number(value: DataRefValue) -> float | None:
+ if isinstance(value, bool):
+ return float(value)
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value)
+ except ValueError:
+ return None
+ if isinstance(value, list) and value:
+ return coerce_number(value[0])
+ return None
+
+
+TOLERANCE = 1e-6
+
+
+def apply_step(
+ current: float,
+ signed_step: float,
+ *,
+ min_v: float | None = None,
+ max_v: float | None = None,
+ cycle: bool = False,
+) -> tuple[float, bool]:
+ """Return (target, blocked). Cycle walks the min..max grid by |step|."""
+ abs_step = abs(signed_step)
+ if not (abs_step > 0):
+ return current, True
+
+ if (
+ cycle
+ and min_v is not None
+ and max_v is not None
+ and max_v + TOLERANCE >= min_v
+ ):
+ n = max(0, int((max_v - min_v + TOLERANCE) // abs_step))
+ idx = int(round((current - min_v) / abs_step))
+ idx = max(0, min(n, idx))
+ direction = -1 if signed_step < 0 else 1
+ mod = n + 1
+ nxt = ((idx + direction) % mod + mod) % mod
+ return min_v + nxt * abs_step, False
+
+ target = current + signed_step
+ if min_v is not None and target < min_v:
+ target = min_v
+ if max_v is not None and target > max_v:
+ target = max_v
+ return target, abs(target - current) < TOLERANCE
+
+
+def step_octal_code(
+ code: float,
+ steps: int,
+ min_v: float | None = None,
+ max_v: float | None = None,
+) -> float:
+ """XPDR-style 0000–7777 codes stored as decimal-looking ints."""
+ places = 4
+ mod = 8**places
+
+ def to_ordinal(c: int) -> int:
+ rest = abs(c)
+ digits: list[int] = []
+ for _ in range(places):
+ digits.append(min(7, rest % 10))
+ rest //= 10
+ ordinal = 0
+ for d in reversed(digits):
+ ordinal = ordinal * 8 + d
+ return ordinal
+
+ def from_ordinal(ordinal: int) -> int:
+ n = ((ordinal % mod) + mod) % mod
+ out = 0
+ place = 1
+ for _ in range(places):
+ out += (n % 8) * place
+ n //= 8
+ place *= 10
+ return out
+
+ ordinal = to_ordinal(int(code)) + int(steps)
+ if min_v is not None:
+ ordinal = max(ordinal, to_ordinal(int(min_v)))
+ if max_v is not None:
+ ordinal = min(ordinal, to_ordinal(int(max_v)))
+ return float(from_ordinal(ordinal))
+
+
+def format_value(
+ value: DataRefValue,
+ *,
+ fmt: str = "%s",
+ unit: str = "",
+ unit_scale: float | None = None,
+ precision: int | None = None,
+) -> str:
+ num = coerce_number(value)
+ if num is None:
+ text = str(value) if value is not None else "?"
+ else:
+ scaled = num if unit_scale is None else num * unit_scale
+ try:
+ if precision is not None and "%f" not in fmt and "%.f" not in fmt and "%." not in fmt:
+ text = f"{scaled:.{precision}f}"
+ elif "%" in fmt and fmt != "%s":
+ text = fmt % scaled
+ else:
+ text = str(scaled)
+ except (TypeError, ValueError):
+ text = str(scaled)
+ return f"{text} {unit}".strip() if unit else text