scrypted-rachio3-plugin
Version:
A Scrypted plugin that controls Rachio 3 Smart Sprinkler Controller via the Rachio Cloud API.
283 lines (249 loc) • 9.05 kB
text/typescript
import sdk, {
ScryptedDeviceBase,
ScryptedDeviceType,
DeviceProvider,
DeviceDiscovery,
Settings,
Setting,
StartStop,
ScryptedInterface,
ScryptedNativeId,
DiscoveredDevice,
AdoptDevice, // up with other imports.
} from "@scrypted/sdk";
const { deviceManager } = sdk;
///////////////////////////////////////////////////////////////////////////
// RachioZoneDevice
///////////////////////////////////////////////////////////////////////////
export class RachioZoneDevice extends ScryptedDeviceBase implements StartStop {
public running: boolean = false;
private plugin: RachioPlugin;
private zoneId: string;
private durationSeconds: number;
constructor(
plugin: RachioPlugin,
nativeId: string,
zoneName: string,
durationSeconds: number
) {
super(nativeId);
this.plugin = plugin;
this.zoneId = nativeId;
this.durationSeconds = durationSeconds;
this.console.log(
`RachioZoneDevice created: ${zoneName} (zoneId=${this.zoneId}), default watering duration=${durationSeconds}s`
);
}
// StartStop interface property from Scrypted is "running"
// We won't redefine get/set 'running' to avoid conflicts.
// Instead, we simply set this.binaryState = true/false as needed.
async start() {
try {
this.console.log(
`Starting zone ${this.zoneId} for ${this.durationSeconds} seconds.`
);
const url = "https://api.rach.io/1/public/zone/start";
const body = {
id: this.zoneId,
duration: this.durationSeconds,
};
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.plugin.rachioAuthToken}`,
},
body: JSON.stringify(body),
});
// Mark the zone as running
this.running = true;
this.console.log(`Zone ${this.zoneId} started.`);
} catch (e) {
this.console.error(`Error starting zone ${this.zoneId}: ${e}`);
}
}
async stop() {
// Rachio does not offer "stop single zone by zoneID";
// Instead "stop_water" stops all running zones on the device
try {
this.console.log(`Stopping watering for all zones (including ${this.zoneId}).`);
const url = "https://api.rach.io/1/public/device/stop_water";
await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.plugin.rachioAuthToken}`,
},
});
// Mark the zone as no longer running
this.running = false;
this.console.log(`Zone ${this.zoneId} stopped.`);
} catch (e) {
this.console.error(`Error stopping zone ${this.zoneId}: ${e}`);
}
}
}
///////////////////////////////////////////////////////////////////////////
// RachioPlugin - main plugin
///////////////////////////////////////////////////////////////////////////
export class RachioPlugin extends ScryptedDeviceBase
implements DeviceProvider, DeviceDiscovery, Settings
{
// Keep a local map of discovered zones
devices = new Map<string, RachioZoneDevice>();
// Rachio Auth Token stored in plugin settings
get rachioAuthToken(): string {
return this.storage.getItem("rachioAuthToken") || "";
}
set rachioAuthToken(token: string) {
this.storage.setItem("rachioAuthToken", token);
}
constructor(nativeId?: ScryptedNativeId) {
super(nativeId);
// If there's already a token, try discovering on startup
if (this.rachioAuthToken) {
// "scan" parameter is optional, so pass 'false' or 'true' or nothing
this.discoverDevices(false);
}
}
async adoptDevice(device: AdoptDevice): Promise<string> {
this.console.log('adoptDevice not implemented.');
// Return a valid nativeId string.
return device.nativeId || '';
}
async releaseDevice(id: ScryptedNativeId): Promise<void> {
this.console.log('releaseDevice not implemented.');
}
/************************************************************************
* Scrypted Settings
************************************************************************/
async getSettings(): Promise<Setting[]> {
return [
{
key: "rachioAuthToken",
title: "Rachio API Token",
description: "Enter your Rachio Personal Access Token here",
value: this.rachioAuthToken || "",
},
];
}
async putSetting(key: string, value: string) {
if (key === "rachioAuthToken") {
this.rachioAuthToken = value.trim();
this.console.log(`Rachio API token updated. Discovering devices...`);
// re-discover after token changes
await this.discoverDevices(false);
}
}
/************************************************************************
* DeviceProvider
************************************************************************/
// Must be async and return a Promise
async getDevice(nativeId: ScryptedNativeId) {
return nativeId ? this.devices.get(nativeId) : undefined;
}
/************************************************************************
* DeviceDiscovery
************************************************************************/
// Must return a Promise of DiscoveredDevice[] and accept an optional boolean
async discoverDevices(scan?: boolean): Promise<DiscoveredDevice[]> {
this.console.log(`Discovering Rachio devices. scan=${scan ?? false}`);
const discoveredDevices: DiscoveredDevice[] = [];
if (!this.rachioAuthToken) {
this.console.warn("No Rachio auth token set, cannot discover devices.");
return discoveredDevices; // empty array
}
try {
// Step 1: Get Person info to retrieve userId
let url = "https://api.rach.io/1/public/person/info";
let resp = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${this.rachioAuthToken}`,
},
});
if (!resp.ok) {
throw new Error(`Failed fetching /person/info: ${resp.statusText}`);
}
const infoData = await resp.json();
const personId = infoData.id;
// Step 2: With personId, fetch /person/:id to get devices
url = `https://api.rach.io/1/public/person/${personId}`;
resp = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${this.rachioAuthToken}`,
},
});
if (!resp.ok) {
throw new Error(`Failed fetching /person/${personId}: ${resp.statusText}`);
}
const personDetail = await resp.json();
if (!personDetail.devices || !Array.isArray(personDetail.devices)) {
this.console.warn("No Rachio devices found in this account.");
return discoveredDevices;
}
// Each device can have multiple "zones"
for (const device of personDetail.devices) {
const deviceName = device.name;
const deviceId = device.id;
this.console.log(`Found Rachio controller: ${deviceName}, id=${deviceId}`);
const zones = device.zones || [];
for (const zone of zones) {
if (!zone.enabled) {
this.console.log(`Skipping disabled zone: ${zone.name} (id=${zone.id})`);
continue;
}
const zoneId = zone.id as string;
const zoneName = zone.name as string;
// Build a discovered device descriptor
const discoveredDevice: DiscoveredDevice = {
name: zoneName,
nativeId: zoneId,
description: `Rachio Zone: ${zoneName}`,
type: ScryptedDeviceType.Valve,
interfaces: [ScryptedInterface.StartStop],
info: {
manufacturer: "Rachio",
model: "Rachio 3",
firmware: device.firmwareVersion || "",
serialNumber: zoneId,
},
};
discoveredDevices.push(discoveredDevice);
}
}
// Inform Scrypted about the discovered devices
deviceManager.onDevicesChanged({
devices: discoveredDevices.map(d => ({
nativeId: d.nativeId!,
name: d.name,
description: d.description,
type: d.type,
interfaces: d.interfaces || [],
info: d.info,
})),
});
// Create or update local device wrappers
for (const d of discoveredDevices) {
if (!d.nativeId) {
this.console.error(`Discovered device has no nativeId, skipping: ${d.name}`);
continue;
}
const sid = d.nativeId; // safe string
let existing = this.devices.get(sid);
if (!existing) {
existing = new RachioZoneDevice(this, sid, d.name ?? 'Unknown Zone', 60);
this.devices.set(sid, existing);
}
}
this.console.log("Rachio device discovery complete.");
} catch (e) {
this.console.error(`Rachio discovery error: ${e}`);
}
// Return the discovered devices
return discoveredDevices;
}
}
// Default export for Scrypted
export default RachioPlugin;