UNPKG

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
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;