Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
node_modules/
dist/
128 changes: 60 additions & 68 deletions README
Original file line number Diff line number Diff line change
@@ -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" }
]
}
```

`<rpc_host>:<username>:<password>:<whitelist_csv>`
### 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 <id> <username> 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:::`
`<rpc_host>:<username>:<password>:<whitelist_csv>`

### 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.
28 changes: 28 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { API } from 'homebridge';
import { Rpc3Platform } from './platform';

export = (api: API): void => {
api.registerPlatform('homebridge-rpc3control', 'RPC3Control', Rpc3Platform);
};
118 changes: 118 additions & 0 deletions src/platform.ts
Original file line number Diff line number Diff line change
@@ -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<string, PlatformAccessory>();
private readonly client: Rpc3Client;
private readonly cfg: ReturnType<typeof withDefaults>;
private readonly states = new Map<number, OutletState>();
private queue: Promise<void> = 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<void> {
await this.enqueue(() => this.client.setOutlet(id, value ? 'on' : 'off'));
this.states.set(id, { value, ts: Date.now() });
}

private async getOutlet(id: number): Promise<boolean> {
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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
const work = this.queue.then(fn, fn);
this.queue = work.then(() => undefined, () => undefined);
return work;
}
}
22 changes: 22 additions & 0 deletions src/rpc3-client.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const script = `${this.scriptPath}/control.py`;
await execFileAsync(script, [String(id), this.username, command]);
}

async getOutlet(id: number): Promise<boolean> {
const script = `${this.scriptPath}/state.py`;
const { stdout } = await execFileAsync(script, [String(id), this.username]);
return stdout.toString().trim().toLowerCase() === 'true';
}
}
33 changes: 33 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<Rpc3ControlConfig, 'rebootSwitches'>> & { 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 ?? [],
};
}
15 changes: 15 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}