tactus-mcp 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ import { ButtplugClient, ButtplugNodeWebsocketClientConnector, DeviceOutput, InputType, OutputType, } from "buttplug";
2
+ import { TactusError, } from "./types.js";
3
+ /** Buttplug output types we expose as actuators, in display order. */
4
+ const OUTPUT_TO_ACTUATOR = [
5
+ [OutputType.Vibrate, "vibrate"],
6
+ [OutputType.Oscillate, "oscillate"],
7
+ [OutputType.Rotate, "rotate"],
8
+ [OutputType.HwPositionWithDuration, "linear"],
9
+ [OutputType.Position, "linear"],
10
+ [OutputType.Constrict, "constrict"],
11
+ ];
12
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
+ /**
14
+ * DeviceController backed by Buttplug/Intiface over a local WebSocket.
15
+ * Our code never touches BLE — Intiface owns that, so this is OS-agnostic.
16
+ */
17
+ export class ButtplugController {
18
+ config;
19
+ client;
20
+ serverName;
21
+ disconnectCbs = [];
22
+ deviceRemovedCbs = [];
23
+ deviceAddedCbs = [];
24
+ constructor(config) {
25
+ this.config = config;
26
+ this.client = new ButtplugClient("Tactus");
27
+ this.client.on("disconnect", () => {
28
+ for (const cb of this.disconnectCbs)
29
+ cb();
30
+ });
31
+ this.client.on("deviceadded", (d) => {
32
+ for (const cb of this.deviceAddedCbs)
33
+ cb(d.index, d.name);
34
+ });
35
+ this.client.on("deviceremoved", (d) => {
36
+ for (const cb of this.deviceRemovedCbs)
37
+ cb(d.index, d.name);
38
+ });
39
+ }
40
+ async connect() {
41
+ const connector = new ButtplugNodeWebsocketClientConnector(this.config.intifaceUrl);
42
+ try {
43
+ await this.client.connect(connector);
44
+ }
45
+ catch {
46
+ throw new TactusError(`Intiface is not reachable at ${this.config.intifaceUrl}. ` +
47
+ `Start Intiface Central and click "Start Server", then retry.`, "NOT_CONNECTED");
48
+ }
49
+ this.serverName = this.client.serverInfo?.serverName;
50
+ }
51
+ async disconnect() {
52
+ if (this.client.connected)
53
+ await this.client.disconnect();
54
+ }
55
+ isConnected() {
56
+ return this.client.connected;
57
+ }
58
+ status() {
59
+ const connected = this.client.connected;
60
+ return {
61
+ connected,
62
+ intiface_url: this.config.intifaceUrl,
63
+ server_name: this.serverName,
64
+ // `client.devices` throws when the connector is down, so only read it while
65
+ // connected. status() must be safe to call in ANY state — especially during
66
+ // a reconnect, which is exactly when a caller most wants to know the state.
67
+ device_count: connected ? this.client.devices.size : 0,
68
+ };
69
+ }
70
+ listDevices() {
71
+ return [...this.client.devices.values()].map((d) => this.describe(d));
72
+ }
73
+ async scan(durationMs) {
74
+ this.requireConnected();
75
+ const before = new Set(this.client.devices.keys());
76
+ await this.client.startScanning();
77
+ await sleep(durationMs);
78
+ await this.client.stopScanning();
79
+ return [...this.client.devices.values()]
80
+ .filter((d) => !before.has(d.index))
81
+ .map((d) => this.describe(d));
82
+ }
83
+ async getBattery(deviceId) {
84
+ const device = this.requireDevice(deviceId);
85
+ try {
86
+ return await device.battery();
87
+ }
88
+ catch {
89
+ throw new TactusError(`Device ${deviceId} does not report battery.`, "DEVICE_ERROR");
90
+ }
91
+ }
92
+ async output(deviceId, type, value, target) {
93
+ const device = this.requireDevice(deviceId);
94
+ const outputType = type === "vibrate" ? OutputType.Vibrate : OutputType.Oscillate;
95
+ const ctor = type === "vibrate" ? DeviceOutput.Vibrate : DeviceOutput.Oscillate;
96
+ await this.runOnFeatures(device, outputType, type, () => ctor.percent(value), target);
97
+ }
98
+ async rotate(deviceId, speed, clockwise, target) {
99
+ const device = this.requireDevice(deviceId);
100
+ await this.runOnFeatures(device, OutputType.Rotate, "rotate", (f) => {
101
+ const range = f.output(OutputType.Rotate).valueRange;
102
+ return DeviceOutput.Rotate.value(rotateValue(range, speed, clockwise));
103
+ }, target);
104
+ }
105
+ async linear(deviceId, position, durationMs, target) {
106
+ const device = this.requireDevice(deviceId);
107
+ const outputType = device.hasOutput(OutputType.HwPositionWithDuration)
108
+ ? OutputType.HwPositionWithDuration
109
+ : OutputType.Position;
110
+ const cmd = DeviceOutput.PositionWithDuration.percent(position, durationMs);
111
+ await this.runOnFeatures(device, outputType, "linear", () => cmd, target);
112
+ }
113
+ async stopDevice(deviceId) {
114
+ await this.requireDevice(deviceId).stop();
115
+ }
116
+ async stopAll() {
117
+ await Promise.all([...this.client.devices.values()].map((d) => d.stop()));
118
+ }
119
+ onDisconnect(cb) {
120
+ this.disconnectCbs.push(cb);
121
+ }
122
+ onDeviceAdded(cb) {
123
+ this.deviceAddedCbs.push(cb);
124
+ }
125
+ onDeviceRemoved(cb) {
126
+ this.deviceRemovedCbs.push(cb);
127
+ }
128
+ // --- internals ---
129
+ /** Resolve the target features, build a per-feature command, and send. */
130
+ async runOnFeatures(device, outputType, label, build, target) {
131
+ const features = this.featuresFor(device, outputType, label, target);
132
+ try {
133
+ await Promise.all(features.map((f) => f.runOutput(build(f))));
134
+ }
135
+ catch (e) {
136
+ if (e instanceof TactusError)
137
+ throw e;
138
+ throw new TactusError(`Device ${device.index} rejected the ${label} command: ${buttplugErrorText(e)}`, "DEVICE_ERROR");
139
+ }
140
+ }
141
+ featuresFor(device, outputType, label, target) {
142
+ let features = [...device.features.values()].filter((f) => f.hasOutput(outputType));
143
+ if (target?.actuatorIndex !== undefined) {
144
+ features = features.filter((f) => f.index === target.actuatorIndex);
145
+ if (features.length === 0) {
146
+ throw new TactusError(`Device ${device.index} has no ${label} actuator at index ${target.actuatorIndex}.`, "NO_ACTUATOR");
147
+ }
148
+ }
149
+ if (features.length === 0) {
150
+ throw new TactusError(`Device ${device.index} has no ${label} actuator.`, "NO_ACTUATOR");
151
+ }
152
+ return features;
153
+ }
154
+ describe(device) {
155
+ const actuators = [];
156
+ let hasBattery = false;
157
+ for (const f of device.features.values()) {
158
+ // A feature can expose several output types that collapse to the same
159
+ // actuator type (e.g. Position + HwPositionWithDuration → "linear");
160
+ // emit at most one actuator per type per feature.
161
+ const seen = new Set();
162
+ for (const [outputType, actuatorType] of OUTPUT_TO_ACTUATOR) {
163
+ if (seen.has(actuatorType))
164
+ continue;
165
+ const out = f.output(outputType);
166
+ if (out) {
167
+ seen.add(actuatorType);
168
+ actuators.push({ index: f.index, type: actuatorType, step_count: out.valueRange[1] });
169
+ }
170
+ }
171
+ if (f.hasInput(InputType.Battery))
172
+ hasBattery = true;
173
+ }
174
+ return { id: device.index, name: device.name, actuators, has_battery: hasBattery };
175
+ }
176
+ requireConnected() {
177
+ if (!this.client.connected) {
178
+ throw new TactusError(`Not connected to Intiface at ${this.config.intifaceUrl}. ` +
179
+ `Start Intiface Central and click "Start Server".`, "NOT_CONNECTED");
180
+ }
181
+ }
182
+ requireDevice(deviceId) {
183
+ this.requireConnected();
184
+ const device = this.client.devices.get(deviceId);
185
+ if (!device) {
186
+ const valid = [...this.client.devices.keys()].join(", ") || "none";
187
+ throw new TactusError(`Unknown device id ${deviceId}. Valid ids: [${valid}].`, "UNKNOWN_DEVICE");
188
+ }
189
+ return device;
190
+ }
191
+ }
192
+ /** Turn a Buttplug error (often a JSON string like
193
+ * `{"ButtplugDeviceError":{"MessageNotSupported":"OutputCmd"}}`) into a short
194
+ * human-readable phrase. */
195
+ function buttplugErrorText(e) {
196
+ const raw = e instanceof Error ? e.message : String(e);
197
+ try {
198
+ const parsed = JSON.parse(raw);
199
+ const inner = (parsed.ButtplugDeviceError ?? parsed.ButtplugError ?? parsed);
200
+ const [kind, detail] = Object.entries(inner)[0] ?? [];
201
+ if (kind)
202
+ return `${kind}: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`;
203
+ }
204
+ catch {
205
+ /* not JSON — fall through */
206
+ }
207
+ return raw;
208
+ }
209
+ /** Map a 0..1 speed + direction onto a feature's (possibly signed) value range. */
210
+ function rotateValue(range, speed, clockwise) {
211
+ const [min, max] = range;
212
+ if (clockwise || min >= 0)
213
+ return Math.round(max * speed);
214
+ return Math.round(min * speed); // counter-clockwise via the negative side of the range
215
+ }
216
+ //# sourceMappingURL=buttplug.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buttplug.js","sourceRoot":"","sources":["../src/buttplug.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import { TactusConfig } from "./types.js";
2
+ /**
3
+ * Build runtime config from environment variables and CLI flags.
4
+ * Safety defaults (clamp on, 10-min watchdog) are intentional — see spec §7.4.
5
+ */
6
+ export declare function loadConfig(argv?: readonly string[]): TactusConfig;
package/dist/config.js ADDED
@@ -0,0 +1,38 @@
1
+ const DEFAULT_INTIFACE_URL = "ws://127.0.0.1:12345";
2
+ const DEFAULT_MAX_INTENSITY = 1.0;
3
+ const DEFAULT_MAX_CONTINUOUS_MS = 600_000; // 10 minutes
4
+ const DEFAULT_SCAN_MS = 5_000;
5
+ const MAX_COMMANDS_PER_SEC = 20;
6
+ const MAX_PATTERN_STEPS = 200;
7
+ function numEnv(name, fallback) {
8
+ const raw = process.env[name];
9
+ if (raw === undefined || raw.trim() === "")
10
+ return fallback;
11
+ const n = Number(raw);
12
+ if (!Number.isFinite(n)) {
13
+ throw new Error(`Invalid ${name}="${raw}": expected a number`);
14
+ }
15
+ return n;
16
+ }
17
+ const clamp01 = (n) => Math.max(0, Math.min(1, n));
18
+ /**
19
+ * Build runtime config from environment variables and CLI flags.
20
+ * Safety defaults (clamp on, 10-min watchdog) are intentional — see spec §7.4.
21
+ */
22
+ export function loadConfig(argv = process.argv.slice(2)) {
23
+ const allowUnsafe = argv.includes("--allow-unsafe") || process.env.TACTUS_ALLOW_UNSAFE === "1";
24
+ // The unsafe override opens the clamp ceiling to the full range.
25
+ const maxIntensity = allowUnsafe
26
+ ? 1.0
27
+ : clamp01(numEnv("MAX_INTENSITY", DEFAULT_MAX_INTENSITY));
28
+ return {
29
+ intifaceUrl: process.env.INTIFACE_URL?.trim() || DEFAULT_INTIFACE_URL,
30
+ maxIntensity,
31
+ maxContinuousMs: numEnv("MAX_CONTINUOUS_MS", DEFAULT_MAX_CONTINUOUS_MS),
32
+ scanDefaultMs: numEnv("SCAN_DEFAULT_MS", DEFAULT_SCAN_MS),
33
+ maxCommandsPerSec: MAX_COMMANDS_PER_SEC,
34
+ maxPatternSteps: MAX_PATTERN_STEPS,
35
+ allowUnsafe,
36
+ };
37
+ }
38
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,MAAM,oBAAoB,GAAG,sBAAsB,CAAC;AACpD,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAClC,MAAM,yBAAyB,GAAG,OAAO,CAAC,CAAC,aAAa;AACxD,MAAM,eAAe,GAAG,KAAK,CAAC;AAC9B,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAChC,MAAM,iBAAiB,GAAG,GAAG,CAAC;AAE9B,SAAS,MAAM,CAAC,IAAY,EAAE,QAAgB;IAC5C,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC9B,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,QAAQ,CAAC;IAC5D,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IACtB,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CAAC,WAAW,IAAI,KAAK,GAAG,sBAAsB,CAAC,CAAC;IACjE,CAAC;IACD,OAAO,CAAC,CAAC;AACX,CAAC;AAED,MAAM,OAAO,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAEnE;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,OAA0B,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IACxE,MAAM,WAAW,GACf,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,mBAAmB,KAAK,GAAG,CAAC;IAE7E,iEAAiE;IACjE,MAAM,YAAY,GAAG,WAAW;QAC9B,CAAC,CAAC,GAAG;QACL,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,qBAAqB,CAAC,CAAC,CAAC;IAE5D,OAAO;QACL,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,IAAI,EAAE,IAAI,oBAAoB;QACrE,YAAY;QACZ,eAAe,EAAE,MAAM,CAAC,mBAAmB,EAAE,yBAAyB,CAAC;QACvE,aAAa,EAAE,MAAM,CAAC,iBAAiB,EAAE,eAAe,CAAC;QACzD,iBAAiB,EAAE,oBAAoB;QACvC,eAAe,EAAE,iBAAiB;QAClC,WAAW;KACZ,CAAC;AACJ,CAAC"}
@@ -0,0 +1,35 @@
1
+ import { DeviceInfo, ServerStatus } from "./types.js";
2
+ /** Scalar outputs driven by a single 0..1 intensity. */
3
+ export type ScalarOutput = "vibrate" | "oscillate";
4
+ export interface DriveTarget {
5
+ /** Restrict to one actuator (Buttplug feature index); omit to drive all matching. */
6
+ actuatorIndex?: number;
7
+ }
8
+ /**
9
+ * Protocol-agnostic control core. MCP is the flagship adapter over this
10
+ * interface; REST/SDK adapters can be added later without touching callers
11
+ * (spec §1/§6). Keeping Buttplug behind this seam is what makes that possible.
12
+ *
13
+ * These are RAW ops — clamping, watchdog, rate limiting and fail-safe stop
14
+ * live in the safety layer that wraps this (safety.ts).
15
+ */
16
+ export interface DeviceController {
17
+ connect(): Promise<void>;
18
+ disconnect(): Promise<void>;
19
+ isConnected(): boolean;
20
+ status(): ServerStatus;
21
+ listDevices(): DeviceInfo[];
22
+ scan(durationMs: number): Promise<DeviceInfo[]>;
23
+ getBattery(deviceId: number): Promise<number>;
24
+ output(deviceId: number, type: ScalarOutput, value: number, target?: DriveTarget): Promise<void>;
25
+ rotate(deviceId: number, speed: number, clockwise: boolean, target?: DriveTarget): Promise<void>;
26
+ linear(deviceId: number, position: number, durationMs: number, target?: DriveTarget): Promise<void>;
27
+ stopDevice(deviceId: number): Promise<void>;
28
+ stopAll(): Promise<void>;
29
+ /** Register a callback fired when the transport to Intiface drops. */
30
+ onDisconnect(cb: () => void): void;
31
+ /** Fired when Intiface reports a device connected (e.g. found by a scan). */
32
+ onDeviceAdded(cb: (id: number, name: string) => void): void;
33
+ /** Fired when Intiface reports a device dropped (e.g. BLE link lost / out of range). */
34
+ onDeviceRemoved(cb: (id: number, name: string) => void): void;
35
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"controller.js","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { ButtplugController } from "./buttplug.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { SafetyLayer } from "./safety.js";
7
+ import { makeServer, registerTools } from "./server.js";
8
+ // Single source of truth for the version: package.json (sits one level above
9
+ // dist/ at runtime). Avoids hard-coding the version in more than one place.
10
+ const require = createRequire(import.meta.url);
11
+ const { version } = require("../package.json");
12
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
+ // All logging goes to stderr — stdout is the JSON-RPC channel for stdio transport.
14
+ const log = (msg) => console.error(`[tactus] ${msg}`);
15
+ async function main() {
16
+ const config = loadConfig();
17
+ const controller = new ButtplugController(config);
18
+ const safety = new SafetyLayer(controller, config);
19
+ const server = makeServer(version);
20
+ registerTools(server, safety, controller, config);
21
+ let shuttingDown = false;
22
+ // Keep a live connection to Intiface with exponential backoff. Runs in the
23
+ // background so the MCP server still starts (and returns clear errors) when
24
+ // Intiface is down (spec §7.5).
25
+ const connectLoop = async () => {
26
+ let delay = 1_000;
27
+ while (!shuttingDown) {
28
+ try {
29
+ await controller.connect();
30
+ log(`connected to Intiface at ${config.intifaceUrl}`);
31
+ return;
32
+ }
33
+ catch (e) {
34
+ log(`${e instanceof Error ? e.message : String(e)} — retrying in ${delay}ms`);
35
+ await sleep(delay);
36
+ delay = Math.min(delay * 2, 30_000);
37
+ }
38
+ }
39
+ };
40
+ controller.onDisconnect(() => {
41
+ if (shuttingDown)
42
+ return;
43
+ log("Intiface connection dropped — reconnecting");
44
+ void connectLoop();
45
+ });
46
+ controller.onDeviceAdded((id, name) => log(`device connected: [${id}] ${name}`));
47
+ controller.onDeviceRemoved((id, name) => {
48
+ log(`device disconnected: [${id}] ${name} — clearing its safety timers`);
49
+ safety.forgetDevice(id);
50
+ });
51
+ // Stop everything if the AI client disconnects (transport close).
52
+ server.server.onclose = () => {
53
+ void safety.shutdown();
54
+ };
55
+ const shutdown = async (signal) => {
56
+ if (shuttingDown)
57
+ return;
58
+ shuttingDown = true;
59
+ log(`${signal} — stopping all devices`);
60
+ await safety.shutdown();
61
+ await controller.disconnect().catch(() => { });
62
+ process.exit(0);
63
+ };
64
+ process.on("SIGINT", () => void shutdown("SIGINT"));
65
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
66
+ void connectLoop();
67
+ const transport = new StdioServerTransport();
68
+ await server.connect(transport);
69
+ log("MCP server ready on stdio");
70
+ }
71
+ main().catch((e) => {
72
+ log(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
73
+ process.exit(1);
74
+ });
75
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ import { DeviceController, DriveTarget } from "./controller.js";
2
+ import { TactusConfig } from "./types.js";
3
+ export interface PatternStep {
4
+ intensity: number;
5
+ durationMs: number;
6
+ }
7
+ /**
8
+ * Wraps a DeviceController with the safety guarantees that are Tactus's headline
9
+ * feature (spec §7.4, playbook §3): intensity clamp, an INDEPENDENT watchdog
10
+ * timer (not check-on-next-call), per-device rate limiting, stop-preemptible
11
+ * patterns, and fail-safe stops that retry rather than assume success.
12
+ */
13
+ export declare class SafetyLayer {
14
+ private readonly controller;
15
+ private readonly config;
16
+ private readonly watchdogs;
17
+ private readonly limiters;
18
+ private readonly patterns;
19
+ constructor(controller: DeviceController, config: TactusConfig);
20
+ /** Drive methods return the EFFECTIVE value actually applied (post-clamp), so
21
+ * callers can see when a request was clamped — honesty over optimism. */
22
+ vibrate(deviceId: number, intensity: number, target?: DriveTarget): Promise<number>;
23
+ oscillate(deviceId: number, intensity: number, target?: DriveTarget): Promise<number>;
24
+ rotate(deviceId: number, speed: number, clockwise: boolean, target?: DriveTarget): Promise<number>;
25
+ linear(deviceId: number, position: number, durationMs: number, target?: DriveTarget): Promise<number>;
26
+ /**
27
+ * Kick off a server-side timed pattern and return immediately. Playing is
28
+ * driven by chained timers (NOT an awaited sleep), so a single tool call
29
+ * never occupies the serial stdio handler — stop_* can always preempt it
30
+ * (playbook §3.4).
31
+ */
32
+ startVibratePattern(deviceId: number, steps: readonly PatternStep[], repeat: number, target?: DriveTarget): void;
33
+ /** Fail-safe stop: if we cannot confirm the device stopped, retry — never
34
+ * optimistically report success (playbook §3.1). */
35
+ stopDevice(deviceId: number): Promise<void>;
36
+ stopAll(): Promise<void>;
37
+ /** Drop all per-device state for a device that has disconnected. No point
38
+ * keeping a watchdog/pattern/limiter alive for a device that is gone. */
39
+ forgetDevice(deviceId: number): void;
40
+ /** Best-effort emergency stop for shutdown paths; never throws. */
41
+ shutdown(): Promise<void>;
42
+ private driveScalar;
43
+ private clampIntensity;
44
+ private submit;
45
+ /** (Re)arm the independent auto-stop timer for a device. */
46
+ private armWatchdog;
47
+ private clearWatchdog;
48
+ private cancelPattern;
49
+ private retry;
50
+ }