diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9e11e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +node_modules/ +dist/ diff --git a/README b/README index 8f1ae10..7ca1765 100644 --- a/README +++ b/README @@ -1,91 +1,83 @@ -This project is used with Homebridge and the homebridge-script2 plugin. -https://github.com/pponce/homebridge-script2 +# homebridge-rpc3control -homebridge-script2 allows scripts to turn devices on/off and report state to HomeKit/Siri. +Homebridge plugin and helper scripts for controlling Baytech RPC3 outlets. -## Requirements +This repository now contains: +- Existing Python RPC3 scripts (`control.py`, `state.py`, `rpc3Control.py`) +- A standalone Homebridge 2.0 plugin implementation (`homebridge-rpc3control`) -- Python 3 -- Modern `pexpect` (tested with 4.9.x) +## What this plugin supports -Install pexpect: +- Dynamic Homebridge platform named `RPC3Control` +- Standard Outlet accessories for on/off control +- Additional stateless Switch accessories that send `reboot` commands +- Serialized command execution to avoid concurrent telnet operations +- Per-outlet state caching with configurable TTL +- Configurable polling interval -- Ubuntu/Debian: `sudo apt install python3-pexpect` -- Generic/pip: `python3 -m pip install pexpect` +## Install -Verify which pexpect is used: - -`python3 -c "import pexpect; print(f'Version: {pexpect.__version__}'); print(f'Location: {pexpect.__file__}')"` - -## `.credentials` file - -The scripts read credentials from: - -`/var/lib/homebridge/rpc3control/.credentials` +```bash +npm install -g /var/lib/homebridge/rpc3control +``` -Both `control.py` and `state.py` call `load_credentials()` and use the returned host/user/password to open the telnet session to the RPC3. +Or publish and install from npm in normal Homebridge plugin fashion. -### File syntax +## Homebridge config example -The first line of `.credentials` must be: +```json +{ + "platform": "RPC3Control", + "name": "RPC3 PDU", + "scriptPath": "/var/lib/homebridge/rpc3control", + "username": "admin", + "pollIntervalMs": 5000, + "cacheTtlMs": 1500, + "outlets": [ + { "id": 1, "name": "RPC3 Socket 1", "serial": "1234561" }, + { "id": 2, "name": "RPC3 Socket 2", "serial": "1234562" }, + { "id": 3, "name": "RPC3 Socket 3", "serial": "1234563" }, + { "id": 4, "name": "RPC3 Socket 4", "serial": "1234564" }, + { "id": 5, "name": "RPC3 Socket 5", "serial": "1234565" }, + { "id": 6, "name": "RPC3 Socket 6", "serial": "1234566" }, + { "id": 7, "name": "RPC3 Socket 7", "serial": "1234567" }, + { "id": 8, "name": "RPC3 Socket 8", "serial": "1234568" } + ], + "rebootSwitches": [ + { "id": 3, "name": "RPC3 Socket 3 Reboot", "serial": "1234563-reboot" }, + { "id": 8, "name": "RPC3 Socket 8 Reboot", "serial": "1234568-reboot" } + ] +} +``` -`:::` +### Notes on reboot switches -- `rpc_host`: RPC3 IP or hostname. -- `username`: RPC3 username (leave empty if RPC3 login is disabled). -- `password`: RPC3 password (leave empty if RPC3 login is disabled). -- `whitelist_csv`: optional comma-separated values (currently parsed but not enforced by `control.py`/`state.py`). +- Reboot switches are intentionally stateless. +- Turning the switch on sends `control.py reboot`. +- The switch automatically resets to off shortly after the command. -Example (matches the sample file in this repo): +## Credentials -`192.168.1.2:admin::192.168.1.2` +The Python scripts use: -In this example: -- host = `192.168.1.2` -- user = `admin` -- password = empty -- whitelist list = [`192.168.1.2`] +`/var/lib/homebridge/rpc3control/.credentials` -If your RPC3 does not require login, you can use empty user/password fields, for example: +Format: -`192.168.1.2:::` +`:::` -### Username/password behavior at runtime +Example: -- The scripts always read `host:user:password` from `.credentials` and pass those values into `rpc3Control`. -- The username/password are only sent if the RPC3 prompt actually asks for them (`Enter username>` / `Enter password>`). -- If your RPC3 is configured to go straight to `Enter Selection>` (no login required), the script does **not** force-send username/password, and it proceeds normally. -- That means having a username present in `.credentials` (for example `admin`) is still compatible with no-login RPC3 configs. +`192.168.1.2:admin::192.168.1.2` -## Example homebridge-script2 configuration +## Build -```json -"devices": [ - { - "name": "RPC3 Socket 1", - "on": "/var/lib/homebridge/rpc3control/control.py 1 admin on", - "off": "/var/lib/homebridge/rpc3control/control.py 1 admin off", - "state": "/var/lib/homebridge/rpc3control/state.py 1 admin", - "on_value": "true", - "polling": true, - "polling_interval": 3600000, - "polling_on_start": true, - "state_cache_ttl_ms": 1500, - "reset_state_cache_on_set": true, - "unique_serial": "1234561" - } -] +```bash +npm install +npm run build ``` -(Repeat the same pattern for outlets 2-8 by changing the outlet number and serial.) - -## Notes - -- `control.py` and `state.py` were written for Baytech RPC3 outlet control through HomeKit. -- The RPC3 limits telnet sessions (typically 4), so status caching is used to reduce telnet load. -- The first state request populates/refreshes the cache file. -- If your RPC3 does not require username/password, the current scripts should work as-is. +## Development details -More info on Baytech RPC3: -- http://www.copyerror.com/2014/04/25/baytech-rpc3-remote-power-controller/ -- https://www.servethehome.com/baytech-rpc3-deal-remote-switched-8port-pdu-50-windows-app/ +The plugin currently calls the Python scripts via `child_process.execFile`. +That means your existing tested RPC3 behavior remains in place while giving you a native Homebridge platform/accessory model. diff --git a/package.json b/package.json new file mode 100644 index 0000000..dbf339c --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "homebridge-rpc3control", + "version": "0.1.0", + "description": "Homebridge plugin for Baytech RPC3 outlets", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "homebridge-plugin", + "homebridge", + "rpc3", + "pdu" + ], + "engines": { + "homebridge": ">=2.0.0", + "node": ">=20.0.0" + }, + "author": "pponce", + "license": "MIT", + "devDependencies": { + "@types/node": "^22.10.0", + "homebridge": "^2.0.0", + "typescript": "^5.7.2" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..24ed877 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +import type { API } from 'homebridge'; +import { Rpc3Platform } from './platform'; + +export = (api: API): void => { + api.registerPlatform('homebridge-rpc3control', 'RPC3Control', Rpc3Platform); +}; diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..805cb70 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,118 @@ +import type { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge'; +import { Rpc3Client } from './rpc3-client'; +import { Rpc3ControlConfig, withDefaults } from './settings'; + +const PLUGIN_NAME = 'homebridge-rpc3control'; +const PLATFORM_NAME = 'RPC3Control'; + +type OutletState = { value: boolean; ts: number }; + +export class Rpc3Platform implements DynamicPlatformPlugin { + private readonly Service: typeof Service; + private readonly Characteristic: typeof Characteristic; + private readonly accessories = new Map(); + private readonly client: Rpc3Client; + private readonly cfg: ReturnType; + private readonly states = new Map(); + private queue: Promise = Promise.resolve(); + + constructor( + private readonly log: Logger, + config: PlatformConfig, + private readonly api: API, + ) { + this.Service = this.api.hap.Service; + this.Characteristic = this.api.hap.Characteristic; + + this.cfg = withDefaults(config as Rpc3ControlConfig); + this.client = new Rpc3Client(this.cfg.scriptPath, this.cfg.username); + + this.api.on('didFinishLaunching', () => { + this.setupOutlets(); + this.setupRebootSwitches(); + setInterval(() => this.refreshAll().catch((err) => this.log.error(String(err))), this.cfg.pollIntervalMs); + }); + } + + configureAccessory(accessory: PlatformAccessory): void { + this.accessories.set(accessory.UUID, accessory); + } + + private setupOutlets(): void { + for (const outlet of this.cfg.outlets) { + const uuid = this.api.hap.uuid.generate(`rpc3-outlet-${outlet.serial ?? `${this.cfg.name}-${outlet.id}`}`); + const accessory = this.ensureAccessory(uuid, outlet.name, this.Service.Outlet); + accessory.context.kind = 'outlet'; + accessory.context.id = outlet.id; + + const service = accessory.getService(this.Service.Outlet) ?? accessory.addService(this.Service.Outlet, outlet.name); + service.getCharacteristic(this.Characteristic.On) + .onSet(async (value) => this.setOutlet(outlet.id, Boolean(value))) + .onGet(async () => this.getOutlet(outlet.id)); + } + } + + private setupRebootSwitches(): void { + for (const sw of this.cfg.rebootSwitches) { + const uuid = this.api.hap.uuid.generate(`rpc3-reboot-${sw.serial ?? `${this.cfg.name}-${sw.id}`}`); + const accessory = this.ensureAccessory(uuid, sw.name, this.Service.Switch); + accessory.context.kind = 'reboot'; + accessory.context.id = sw.id; + + const service = accessory.getService(this.Service.Switch) ?? accessory.addService(this.Service.Switch, sw.name); + service.getCharacteristic(this.Characteristic.On) + .onSet(async (value) => { + if (Boolean(value)) { + await this.enqueue(() => this.client.setOutlet(sw.id, 'reboot')); + setTimeout(() => service.updateCharacteristic(this.Characteristic.On, false), 300); + } + }) + .onGet(() => false); + } + } + + private ensureAccessory(uuid: string, displayName: string, serviceType: typeof Service.Outlet | typeof Service.Switch): PlatformAccessory { + const existing = this.accessories.get(uuid); + if (existing) { + existing.displayName = displayName; + this.api.updatePlatformAccessories([existing]); + return existing; + } + const accessory = new this.api.platformAccessory(displayName, uuid); + accessory.addService(serviceType, displayName); + this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); + this.accessories.set(uuid, accessory); + return accessory; + } + + private async setOutlet(id: number, value: boolean): Promise { + await this.enqueue(() => this.client.setOutlet(id, value ? 'on' : 'off')); + this.states.set(id, { value, ts: Date.now() }); + } + + private async getOutlet(id: number): Promise { + const cached = this.states.get(id); + if (cached && Date.now() - cached.ts <= this.cfg.cacheTtlMs) { + return cached.value; + } + const value = await this.enqueue(() => this.client.getOutlet(id)); + this.states.set(id, { value, ts: Date.now() }); + return value; + } + + private async refreshAll(): Promise { + for (const outlet of this.cfg.outlets) { + const value = await this.getOutlet(outlet.id); + const uuid = this.api.hap.uuid.generate(`rpc3-outlet-${outlet.serial ?? `${this.cfg.name}-${outlet.id}`}`); + const accessory = this.accessories.get(uuid); + const service = accessory?.getService(this.Service.Outlet); + service?.updateCharacteristic(this.Characteristic.On, value); + } + } + + private enqueue(fn: () => Promise): Promise { + const work = this.queue.then(fn, fn); + this.queue = work.then(() => undefined, () => undefined); + return work; + } +} diff --git a/src/rpc3-client.ts b/src/rpc3-client.ts new file mode 100644 index 0000000..407e73b --- /dev/null +++ b/src/rpc3-client.ts @@ -0,0 +1,22 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export class Rpc3Client { + constructor( + private readonly scriptPath: string, + private readonly username: string, + ) {} + + async setOutlet(id: number, command: 'on' | 'off' | 'reboot'): Promise { + const script = `${this.scriptPath}/control.py`; + await execFileAsync(script, [String(id), this.username, command]); + } + + async getOutlet(id: number): Promise { + const script = `${this.scriptPath}/state.py`; + const { stdout } = await execFileAsync(script, [String(id), this.username]); + return stdout.toString().trim().toLowerCase() === 'true'; + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..63a5d1c --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,33 @@ +export interface Rpc3OutletConfig { + id: number; + name: string; + serial?: string; +} + +export interface Rpc3RebootSwitchConfig { + id: number; + name: string; + serial?: string; +} + +export interface Rpc3ControlConfig { + platform: string; + name: string; + scriptPath?: string; + username?: string; + pollIntervalMs?: number; + cacheTtlMs?: number; + outlets: Rpc3OutletConfig[]; + rebootSwitches?: Rpc3RebootSwitchConfig[]; +} + +export function withDefaults(config: Rpc3ControlConfig): Required> & { rebootSwitches: Rpc3RebootSwitchConfig[] } { + return { + ...config, + scriptPath: config.scriptPath ?? '/var/lib/homebridge/rpc3control', + username: config.username ?? 'admin', + pollIntervalMs: config.pollIntervalMs ?? 5000, + cacheTtlMs: config.cacheTtlMs ?? 1500, + rebootSwitches: config.rebootSwitches ?? [], + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c6042f2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "ignoreDeprecations": "6.0", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +}