From ab826c53ac0cc94c6ea14aa013e7936efdd7713a Mon Sep 17 00:00:00 2001 From: 4LSL Date: Fri, 24 Jul 2026 17:22:47 +0200 Subject: [PATCH 1/2] Add Stream Deck+ Encoder action (dial-only). Dedicated Controllers:Encoder action with rotate / click / shift, DataRef or command drive, optional octal XPDR stepping, and a label+value strip layout. Co-authored-by: Cursor --- .../layouts/encoder-value.json | 22 + com.robertw.xplane.sdPlugin/manifest.json | 23 + com.robertw.xplane.sdPlugin/ui/encoder.html | 182 ++++++++ src/actions/encoder.ts | 403 ++++++++++++++++++ src/plugin.ts | 2 + src/util/encoder-feedback.ts | 23 + src/util/octal.ts | 44 ++ 7 files changed, 699 insertions(+) create mode 100644 com.robertw.xplane.sdPlugin/layouts/encoder-value.json create mode 100644 com.robertw.xplane.sdPlugin/ui/encoder.html create mode 100644 src/actions/encoder.ts create mode 100644 src/util/encoder-feedback.ts create mode 100644 src/util/octal.ts diff --git a/com.robertw.xplane.sdPlugin/layouts/encoder-value.json b/com.robertw.xplane.sdPlugin/layouts/encoder-value.json new file mode 100644 index 0000000..5547485 --- /dev/null +++ b/com.robertw.xplane.sdPlugin/layouts/encoder-value.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "encoder-value", + "items": [ + { + "key": "label", + "type": "text", + "rect": [8, 8, 184, 24], + "font": { "size": 14, "weight": 600 }, + "alignment": "center", + "value": "" + }, + { + "key": "value", + "type": "text", + "rect": [8, 36, 184, 52], + "font": { "size": 26, "weight": 600 }, + "alignment": "center", + "value": "" + } + ] +} diff --git a/com.robertw.xplane.sdPlugin/manifest.json b/com.robertw.xplane.sdPlugin/manifest.json index 5415019..fb0f305 100644 --- a/com.robertw.xplane.sdPlugin/manifest.json +++ b/com.robertw.xplane.sdPlugin/manifest.json @@ -113,6 +113,29 @@ } ] }, + { + "Name": "Encoder", + "UUID": "com.robertw.xplane.encoder", + "Icon": "imgs/actions/rotary/icon", + "Tooltip": "Stream Deck+ dial: rotate / click / shift. DataRef step (linear or octal) or CW/CCW commands. Live value on the touch strip.", + "PropertyInspectorPath": "ui/encoder.html", + "Controllers": [ + "Encoder" + ], + "Encoder": { + "layout": "layouts/encoder-value.json", + "TriggerDescription": { + "Rotate": "Fine adjust", + "Push": "Click command" + } + }, + "States": [ + { + "Image": "imgs/actions/rotary/key", + "TitleAlignment": "middle" + } + ] + }, { "Name": "DataRef Display", "UUID": "com.robertw.xplane.dataref-display", diff --git a/com.robertw.xplane.sdPlugin/ui/encoder.html b/com.robertw.xplane.sdPlugin/ui/encoder.html new file mode 100644 index 0000000..bc6a8ec --- /dev/null +++ b/com.robertw.xplane.sdPlugin/ui/encoder.html @@ -0,0 +1,182 @@ + + + + + + X-Plane Encoder + + + + + +
+ Rotate = fine. Press+rotate = shift. Press+release (no turn) = click. +
+ + + + + + + + +
+ DataRef rotate + + + + + + + + + + + + + + + + + + +
+ +
+ Command rotate + + + + + + +
+ +
+ Click + + + + + + +
+ +
+ Shift (press + rotate) + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/actions/encoder.ts b/src/actions/encoder.ts new file mode 100644 index 0000000..b8c7b4f --- /dev/null +++ b/src/actions/encoder.ts @@ -0,0 +1,403 @@ +/* + * 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, { + action, + type DialAction, + type DialDownEvent, + type DialRotateEvent, + type DialUpEvent, + type DidReceiveSettingsEvent, + SingletonAction, + type WillAppearEvent, + type WillDisappearEvent, +} from "@elgato/streamdeck"; +import type { JsonObject } from "@elgato/utils"; + +import { TOLERANCE_FLOAT } from "../const"; +import { selectors } from "../selectors/registry"; +import { coerceNumber, toFiniteNumber } from "../util/coerce"; +import { applyIndex, parseDataRefPath } from "../util/dataref-path"; +import { setDialFeedback } from "../util/encoder-feedback"; +import { clearOffline, NOT_FOUND_SUFFIX, setOffline } from "../util/error-tile"; +import { formatDataRefValue } from "../util/format"; +import { stepOctalCode } from "../util/octal"; +import { extractPlaceholderKeys, substitutePlaceholders } from "../util/placeholders"; +import { normalizeFormat, resolveZeroSnap, trimString } from "../util/settings"; +import type { DataRefValue, SubscriptionHandle, XPlaneClient } from "../xplane"; + +type DriveMode = "dataref" | "command"; +type StepMode = "linear" | "octal"; + +type EncoderSettings = JsonObject & { + driveMode?: DriveMode; + stepMode?: StepMode; + datarefPath?: string; + delta?: string | number; + coarseDelta?: string | number; + minValue?: string | number; + maxValue?: string | number; + hideEndstopAlert?: boolean; + commandPath?: string; + commandPathReverse?: string; + shiftCommandPath?: string; + shiftCommandPathReverse?: string; + clickCommand?: string; + clickHoldMode?: boolean; + label?: string; + format?: string; + unit?: string; + unitScale?: string | number; + precision?: string | number; + snapZero?: boolean; + zeroThreshold?: string | number; +}; + +interface Parsed { + driveMode: DriveMode; + stepMode: StepMode; + datarefPath: string; + delta: number; + coarseDelta?: number; + minValue?: number; + maxValue?: number; + hideEndstopAlert: boolean; + commandPath: string; + commandPathReverse: string; + shiftCommandPath: string; + shiftCommandPathReverse: string; + clickCommand: string; + clickHoldMode: boolean; + label: string; + format: string; + unit: string; + unitScale?: number; + precision?: number; + zeroSnap?: number; +} + +interface State { + action: DialAction; + 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 = + parsed.stepMode === "octal" + ? stepOctalCode(current, step, parsed.minValue, parsed.maxValue) + : current + step; + if (parsed.stepMode !== "octal") { + if (parsed.minValue !== undefined) target = Math.max(target, parsed.minValue); + if (parsed.maxValue !== undefined) target = Math.min(target, parsed.maxValue); + } + + if (Math.abs(target - current) < TOLERANCE_FLOAT) { + 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), + 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/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); +} From 4b91916d37a714bafdd113564e56b9590e12e072 Mon Sep 17 00:00:00 2001 From: 4LSL Date: Fri, 24 Jul 2026 23:07:05 +0200 Subject: [PATCH 2/2] Add cycle stepping and StreamController X-Plane plugin. Rotary/Encoder can wrap DataRef grids at min/max; StreamController port ships Command, Display, Encoder, and Rotary. Co-authored-by: Cursor --- .gitignore | 5 + com.robertw.xplane.sdPlugin/ui/encoder.html | 6 + .../ui/rotary-dataref.html | 13 +- com.robertw.xplane.sdPlugin/ui/rotary.html | 40 ++- src/actions/encoder.ts | 24 +- src/actions/rotary-dataref.ts | 16 +- src/actions/rotary.ts | 81 ++++- src/util/step.ts | 59 ++++ .../com_robertw_xplane/.gitignore | 162 +++++++++ .../com_robertw_xplane/Icons.json | 1 + streamcontroller/com_robertw_xplane/LICENSE | 21 ++ .../com_robertw_xplane/Plugins.json | 8 + streamcontroller/com_robertw_xplane/README.md | 46 +++ .../actions/Command/Command.py | 69 ++++ .../actions/Command/__init__.py | 1 + .../actions/DataRefDisplay/DataRefDisplay.py | 97 ++++++ .../actions/DataRefDisplay/__init__.py | 1 + .../actions/Encoder/Encoder.py | 320 +++++++++++++++++ .../actions/Encoder/__init__.py | 1 + .../actions/Rotary/Rotary.py | 322 ++++++++++++++++++ .../actions/Rotary/__init__.py | 1 + .../com_robertw_xplane/actions/__init__.py | 1 + .../com_robertw_xplane/assets/Attribution.txt | 2 + .../com_robertw_xplane/assets/info.png | Bin 0 -> 1624 bytes .../com_robertw_xplane/attribution.json | 10 + streamcontroller/com_robertw_xplane/main.py | 79 +++++ .../com_robertw_xplane/manifest.json | 9 + .../com_robertw_xplane/requirements.txt | 2 + .../com_robertw_xplane/xplane/__init__.py | 284 +++++++++++++++ 29 files changed, 1656 insertions(+), 25 deletions(-) create mode 100644 src/util/step.ts create mode 100644 streamcontroller/com_robertw_xplane/.gitignore create mode 100644 streamcontroller/com_robertw_xplane/Icons.json create mode 100644 streamcontroller/com_robertw_xplane/LICENSE create mode 100644 streamcontroller/com_robertw_xplane/Plugins.json create mode 100644 streamcontroller/com_robertw_xplane/README.md create mode 100644 streamcontroller/com_robertw_xplane/actions/Command/Command.py create mode 100644 streamcontroller/com_robertw_xplane/actions/Command/__init__.py create mode 100644 streamcontroller/com_robertw_xplane/actions/DataRefDisplay/DataRefDisplay.py create mode 100644 streamcontroller/com_robertw_xplane/actions/DataRefDisplay/__init__.py create mode 100644 streamcontroller/com_robertw_xplane/actions/Encoder/Encoder.py create mode 100644 streamcontroller/com_robertw_xplane/actions/Encoder/__init__.py create mode 100644 streamcontroller/com_robertw_xplane/actions/Rotary/Rotary.py create mode 100644 streamcontroller/com_robertw_xplane/actions/Rotary/__init__.py create mode 100644 streamcontroller/com_robertw_xplane/actions/__init__.py create mode 100644 streamcontroller/com_robertw_xplane/assets/Attribution.txt create mode 100644 streamcontroller/com_robertw_xplane/assets/info.png create mode 100644 streamcontroller/com_robertw_xplane/attribution.json create mode 100644 streamcontroller/com_robertw_xplane/main.py create mode 100644 streamcontroller/com_robertw_xplane/manifest.json create mode 100644 streamcontroller/com_robertw_xplane/requirements.txt create mode 100644 streamcontroller/com_robertw_xplane/xplane/__init__.py diff --git a/.gitignore b/.gitignore index 08da176..6ca0bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ Thumbs.db # Local Claude Code settings (per-machine) .claude/settings.local.json + +# Local StreamController shop checkout (lives at ../xp-sdcontroller) +_xp-sdcontroller/ +gh +*.AppImage diff --git a/com.robertw.xplane.sdPlugin/ui/encoder.html b/com.robertw.xplane.sdPlugin/ui/encoder.html index bc6a8ec..9edc749 100644 --- a/com.robertw.xplane.sdPlugin/ui/encoder.html +++ b/com.robertw.xplane.sdPlugin/ui/encoder.html @@ -59,6 +59,12 @@ + + + diff --git a/com.robertw.xplane.sdPlugin/ui/rotary-dataref.html b/com.robertw.xplane.sdPlugin/ui/rotary-dataref.html index df37f6f..d0e4c41 100644 --- a/com.robertw.xplane.sdPlugin/ui/rotary-dataref.html +++ b/com.robertw.xplane.sdPlugin/ui/rotary-dataref.html @@ -76,8 +76,17 @@
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. +
+ + + + + + + + + + + + + + + +
{ : applyIndex(await this.xplane.readDataRef(drId), index); const current = coerceNumber(currentRaw) ?? 0; - let target = - parsed.stepMode === "octal" - ? stepOctalCode(current, step, parsed.minValue, parsed.maxValue) - : current + step; - if (parsed.stepMode !== "octal") { - if (parsed.minValue !== undefined) target = Math.max(target, parsed.minValue); - if (parsed.maxValue !== undefined) target = Math.min(target, parsed.maxValue); + 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 (Math.abs(target - current) < TOLERANCE_FLOAT) { + if (blocked) { if (!parsed.hideEndstopAlert) await state.action.showAlert(); return; } @@ -386,6 +393,7 @@ function parse(s: EncoderSettings): Parsed { 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), 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/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 0000000000000000000000000000000000000000..e1e4e2b82a2441bd9a8a9be7de7bd80cb178344c GIT binary patch literal 1624 zcmV-e2B-OnP) zUx-&l6vsb5G5>(&+L~Dl`=F$dZe{dXN*47Xq=7*MwJJ(I1a)4=MiJRGRr?|_3@`5gE&P`#yTiU6yCmw^|6 zyDDz$ciXEqZJERrUu$UYKchmt zMa_?=x&>GR>;e|m*{BDnf!Bdg>T0mApP_@mC~$!01|QV61E{Tobu?H<4_FJl3-ndp zP+%s24Z!{?8>v!&JJ9Fq){^yH01gAkfgjM{_P?Re>T}sW3(@bEyV0@2gTOLiB{08a z`#aI^t7|3exg}KzFc0_`c)Z}}zX3i3J_WunY0E>vv%rhMqXp~u3V05v-m4Fk3=8H6Z@M-y#2A|SpjQ-z1Wjhmw-pmi6C#v zybIci9ee>c(|f@OwRtn@Lmu?MM%1S~7NN26!nX&^M5plv za_$k}NvfM~jnM-@=dHgq>OYH4uU>Vn)VBw0AV2s3@6&+E?g}`B`j*E48Y5?XdO!zV zYm+yDei>K_ocA%plUO|f;9lSaFgNF(Q|KBdr@;F3fG4n5FYKb);K$}X>SG>@(O5a* zBfu-h^(&P5J={C&M19S}iq#EV1(=PFX7kuhgO>mX89_!JVnYaq@6#36R(3z|{ z=oqCAEMMoMF*9)$V6}1mDAi3PHj!hf&v{re1HuXLfnz;w!tyzo0P}$%m1nz?eflZDS1ey&Kce*yZ9Pz*6J-VH^h?EkjV>^RQwUM1bQCb+i@B z_gVxv;ZR3gv3xfLaM>fa(71lmp^lKmd>3>xmhV>VoO-~W#`Oyhb+i@BcPn;I0$6J( z|8S_ItysRB>rX@UfFwY7g7knyg}4Y%TICi~`sv^zfc5S>3D6xYc0qbT*)2f5jOBZf z9&paMzQm!9wqp5i#m-59(xbf=Q~E7I2myTeg0%_D_gVy4Nxj4WrqDrs&m)KcM}euF z>-S(!*tC%$)c0;oVNY8+32*^>!sQuXRkzc07U)#S%89ZK}7Xwr#4DXlh4`b-+l0`Yr0-A25O)e9)M9 z1s^DttYg$+_xP0bR+5gJ%dog5BN;8)MoQMvoyI5YID-z!Ed{5ejtrM*t8RvI3%;N+ zC=`6Hp?o*p;m}1zQo^QNmJkM~lx2|xKvL4a=mH=qZDn);kd(O9yQI{`?o^VxI3@3P zqooi~%ekRHQ0000 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