1

Initial Code commit

This commit is contained in:
nisch.codes 2025-03-15 20:40:21 +01:00
parent 5068041f80
commit 435d973c53
17 changed files with 4993 additions and 1 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
root = true
[*]
indent_style = tab
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yaml]
indent_style = space
indent_size = 2
[*.yml]
indent_style = space
indent_size = 2

View File

@ -1,3 +1,95 @@
# homebridge-wiz-net
Control Wiz products over network.
Control european Wiz products over network.
## Based of
- [kpsuperplane/homebridge-wiz-lan](https://github.com/kpsuperplane/homebridge-wiz-lan#readme)
## Currently supports
- Wiz Plugs/Outlets (ESP10_SOCKET_06, ESP25_SOCKET_01)
# Installation
Make sure your bulbs are already set up via the Wiz app and you have "Allow Local Communication" set to ON in your settings.
1. Install homebridge using: `npm install -g homebridge`
2. Install this plugin using: `npm install -g homebridge-wiz-net`
3. Update your configuration file. See the sample below.
# Configuration
Simple Configuration:
```javascript
{
"platform": "WizSmartHome",
"name": "WizSmartHome",
}
```
Full configuration options:
```javascript
{
"platform": "WizSmartHome",
"name": "Wiz",
// [Optional] Port for bulbs to connect to your server
// Default: 38900
"port": 38900,
// [Optional] Enable scenes support for your bulbs
// Default: false
"enableScenes": false,
// [Optional] UDP Broadcast address for bulb discovery
// Default: 255.255.255.255
"broadcast": "255.255.255.255",
// [Optional] Your server's IP address
// Default: Autodiscovered
"address": "192.168.0.1",
// [Optional] Manual list of IP addresses of bulbs
// Useful if UDP broadcast doesn't work for some reason
// Default: None
"devices": [
{ "host": "192.168.0.2" },
{ "host": "192.168.0.3" },
{ "host": "192.168.0.4" },
// ...
]
}
```
## Some Notes
### Color
The Wiz bulbs strongly distinguish between RGB color modes and Kelvin color modes, **the latter being significantly brighter**. Unfortunately, HomeKit is not very good at handling both at the same time, [yielding weird errors if you try to add both characteristics](https://github.com/home-assistant/home-assistant/pull/30756).
Luckily, even if we only enable the color mode, we still get a nice temperature picker. Problem is, the color temperature is given in standard HSV. As such, this app will try to guess which one to best use given a color, and you will notice some significant brightness variance switching between a "temp" hue and a "color" hue.
**In particular, since the Wiz bulbs only support up to 6500K, this means that only the top-ish half of the temperature picker is actually bright**
# Development
Ideas from http://blog.dammitly.net/2019/10/cheap-hackable-wifi-light-bulbs-or-iot.html?m=1
## Contributing
Mostly built for my own personal use - so no active development. Feel free to fork and contribute.
## How bulbs are discovered
Make a UDP broadcast to port 38899 with the following content:
```
{"method":"registration","params":{"phoneMac":"<my_mac_address>","register":false,"phoneIp":"<my_ip_address>"}}
```
You will get a response on port 38900 with the following content:
```
{"method":"registration","env":"pro","result":{"mac":"<light_address>","success":true}}
```
# License
See LICENSE file

63
config.schema.json Normal file
View File

@ -0,0 +1,63 @@
{
"pluginAlias": "WizSmartHome",
"pluginType": "platform",
"singular": true,
"schema": {
"type": "object",
"properties": {
"name": {
"title": "Name",
"type": "string",
"default": "WizSmartHome"
},
"port": {
"title": "Port",
"type": "integer",
"description": "[Optional] Port for bulbs to connect to your server.",
"placeholder": 38900,
"minimum": 0
},
"enableScenes": {
"title": "Enable Scenes",
"type": "boolean",
"description": "[Optional] Turn on support for scenes with your lightbulbs. THIS WILL MAKE IT IMPOSSIBLE TO GROUP LIGHTS",
"default": false
},
"broadcast": {
"title": "Broadcast Address",
"type": "string",
"format": "ipv4",
"description": "[Optional] UDP Broadcast address for bulb discovery."
},
"address": {
"title": "Server Address",
"type": "string",
"format": "ipv4",
"description": "[Optional] Your server's IP address."
},
"devices": {
"title": "Devices",
"type": "array",
"description": "[Optional] Manual list of IP addresses of bulbs",
"items": {
"type": "object",
"properties": {
"host": {
"title": "Device IP",
"type": "string",
"format": "ipv4"
},
"name": {
"title": "Device Name",
"type": "string"
},
"mac": {
"title": "Device MAC",
"type": "string"
}
}
}
}
}
}
}

3891
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "homebridge-wiz-net",
"version": "1.0.1",
"type": "module",
"description": "Control Wiz products over network.",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/nikolas-schwarz/homebridge-wiz-net.git"
},
"keywords": [
"homebridge-plugin"
],
"author": "Nikolas Schwarz <6736204+nikolas-schwarz@users.noreply.github.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/nikolas-schwarz/homebridge-wiz-net/issues"
},
"homepage": "https://github.com/nikolas-schwarz/homebridge-wiz-net#readme",
"engines": {
"node": ">=0.12.0",
"homebridge": ">=1.3.0"
},
"scripts": {
"clean": "rimraf ./dist",
"build": "rimraf ./dist && npm run build:slim",
"build:full": "esbuild src/index.ts --outfile=dist/index.js --bundle --platform=node --target=es2018,node12",
"build:slim": "esbuild src/index.ts --outfile=dist/index.js --bundle --platform=node --target=es2018,node12 --external:./node_modules/* --format=esm",
"build:slim:min": "esbuild src/index.ts --outfile=dist/index.js --minify --bundle --platform=node --target=es2018,node12 --external:./node_modules/*",
"build:dev": "esbuild src/index.ts --outfile=debug/index.js --sourcemap --bundle --platform=node --target=es2018,node12",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"esbuild": "^0.15.13",
"@types/jwt-decode": "^2.2.1",
"@types/node": "^14.14.6",
"homebridge": "^1.3.1",
"nodemon": "^2.0.6",
"rimraf": "^3.0.2",
"ts-node": "^9.0.0",
"typescript": "^4.0.5"
},
"dependencies": {
"getmac": "^5.17.0",
"internal-ip": "^6.2.0"
}
}

View File

@ -0,0 +1,38 @@
import { PlatformAccessory } from "homebridge"
import Controller from "../../controller"
import { initOnOff } from "../../characteristics/on-off"
import { Accessory, Device } from "../../data/types"
import {
getPilot as _getPilot,
setPilot as _setPilot,
} from "../../utilities/network"
const Plug: Accessory = {
is: (device: Device) => ["ESP25_SOCKET_01"].some((id) => device.model.includes(id)),
getName: (_: Device) => { return "Wiz Plug" },
init: (
accessory: PlatformAccessory,
device: Device,
controller: Controller
) => {
const { Service } = controller
// Setup the outlet service
let service = accessory.getService(Service.Outlet)
if (typeof service === "undefined") {
service = new Service.Outlet(accessory.displayName)
accessory.addService(service)
}
// All plugs support on/off
initOnOff(accessory, device, controller)
},
}
export default Plug

View File

@ -0,0 +1,38 @@
import { PlatformAccessory } from "homebridge"
import Controller from "../../controller"
import { initOnOff } from "../../characteristics/on-off"
import { Accessory, Device } from "../../data/types"
import {
getPilot as _getPilot,
setPilot as _setPilot,
} from "../../utilities/network"
const Socket: Accessory = {
is: (device: Device) => ["ESP10_SOCKET_06"].some((id) => device.model.includes(id)),
getName: (_: Device) => { return "Wiz Socket" },
init: (
accessory: PlatformAccessory,
device: Device,
controller: Controller
) => {
const { Service } = controller
// Setup the outlet service
let service = accessory.getService(Service.Outlet)
if (typeof service === "undefined") {
service = new Service.Outlet(accessory.displayName)
accessory.addService(service)
}
// All sockets support on/off
initOnOff(accessory, device, controller)
},
}
export default Socket

6
src/accessories/index.ts Normal file
View File

@ -0,0 +1,6 @@
export { Accessory } from "../data/types"
import Socket from "./Socket"
import Plug from "./Plug"
export default [ Socket, Plug ]

View File

@ -0,0 +1,38 @@
import { CharacteristicSetCallback, CharacteristicValue, PlatformAccessory } from "homebridge"
import Controller from "../../controller"
import { Device } from "../../data/types"
import { Pilot, getPilot, setPilot } from "../../pilotes/on-off"
import {
getPilot as _getPilot,
setPilot as _setPilot,
} from "../../utilities/network"
function transformOnOff(pilot: Pilot) {
return Number(pilot.state)
}
export function initOnOff( accessory: PlatformAccessory, device: Device, controller: Controller ) {
const { Characteristic, Service } = controller
const service = accessory.getService(Service.Outlet)!
service
.getCharacteristic(Characteristic.On)
.on("get", callback =>
getPilot(
controller,
accessory,
device,
pilot => callback(null, transformOnOff(pilot)),
callback
)
)
.on(
"set",
(newValue: CharacteristicValue, next: CharacteristicSetCallback) => {
setPilot(controller, accessory, device, { state: Boolean(newValue) }, next)
}
)
}

143
src/controller/index.ts Normal file
View File

@ -0,0 +1,143 @@
import { Socket } from "dgram"
import { API, Logger, PlatformAccessory, Service, Characteristic } from "homebridge"
import Accessories from '../accessories'
import { PLATFORM_NAME, PLUGIN_NAME } from "../data/constants"
import { Config, Device } from "../data/types"
import { bindSocket, createSocket, registerDiscoveryHandler, sendDiscoveryBroadcast } from "../utilities/network"
export default class Controller {
public readonly Service: typeof Service = this.api.hap.Service
public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic
// this is used to track restored cached accessories
public readonly accessories: PlatformAccessory[] = []
public readonly initializedAccessories = new Set<string>()
public readonly socket: Socket
constructor(
public readonly log: Logger,
public readonly config: Config,
public readonly api: API
) {
this.socket = createSocket(this)
// When this event is fired it means Homebridge has restored all cached accessories from disk.
// Dynamic Platform plugins should only register new accessories after this event was fired,
// in order to ensure they weren't added to homebridge already. This event can also be used
// to start discovery of new accessories.
this.api.on("didFinishLaunching", () => {
log.debug("Executed didFinishLaunching callback")
// run the method to discover / register your devices as accessories
bindSocket(this, () => {
registerDiscoveryHandler(this, this.tryAddDevice.bind(this))
sendDiscoveryBroadcast(this)
})
})
}
initAccessory(platformAccessory: PlatformAccessory) {
// Already initialized!!
if (this.initializedAccessories.has(platformAccessory.UUID)) {
return
}
const device = platformAccessory.context as Device
// Skip if it doesn't have the new context schema
if (typeof device?.model !== "string") {
return
}
platformAccessory
.getService(this.Service.AccessoryInformation)!!
.setCharacteristic(this.Characteristic.Manufacturer, "Wiz")
.setCharacteristic(this.Characteristic.Model, device.model)
.setCharacteristic(this.Characteristic.SerialNumber, device.mac)
const accessory = Accessories.find(accessory => accessory.is(device))
if (typeof accessory === 'undefined') {
this.log.warn(`Unknown device ${device.toString()}, skipping...`)
return
}
accessory.init(platformAccessory, device, this)
this.initializedAccessories.add(platformAccessory.UUID)
}
/**
* This function is invoked when homebridge restores cached accessories from disk at startup.
* It should be used to setup event handlers for characteristics and update respective values.
*/
configureAccessory(accessory: PlatformAccessory) {
this.log.info("Loading accessory from cache:", accessory.displayName)
this.initAccessory(accessory)
// add the restored accessory to the accessories cache so we can track if it has already been registered
this.accessories.push(accessory)
}
tryAddDevice(device: Device) {
const accessory = Accessories.find(accessory => accessory.is(device))
if (typeof accessory === 'undefined') {
this.log.warn(`Unknown device ${device.model.toString()}, skipping...`)
return
}
const uuid = this.api.hap.uuid.generate(device.mac)
const defaultName = `Wiz ${accessory.getName(device)} ${device.mac}`
let name = defaultName
const existingAccessory = this.accessories.find(
(accessory) => accessory.UUID === uuid
)
this.log.debug(`Considering alternative names in ${JSON.stringify(this.config.devices)} from ${JSON.stringify(this.config)}...`)
if (Array.isArray(this.config.devices)) {
this.log.debug(`Found some configs...`)
for (const configDevice of this.config.devices) {
this.log.debug(`Pondering ${JSON.stringify(configDevice)} versus ${JSON.stringify(device)}...`)
if ((configDevice.mac && device.mac == configDevice.mac) ||
(configDevice.host && device.ip == configDevice.host)) {
this.log.debug(`Found a match...`)
if (configDevice.name) {
this.log.debug(`Changing name to ${configDevice.name}...`)
name = configDevice.name
}
}
}
}
// check the accessory was not restored from cache
if (!existingAccessory) {
// create a new accessory
const accessory = new this.api.platformAccessory(name, uuid)
accessory.context = device
this.log.info("Adding new accessory:", name)
this.initAccessory(accessory)
this.accessories.push(accessory)
// register the accessory
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
accessory,
])
} else {
existingAccessory.context = device
this.log.info(`Updating accessory: ${name}${name == existingAccessory.displayName ? "" : ` [formerly ${existingAccessory.displayName}]`}`)
existingAccessory.displayName = name
this.api.updatePlatformAccessories([existingAccessory])
// try initializing again in case it didn't the last time
// (e.g. platform upgrade)
this.initAccessory(existingAccessory)
}
}
}

2
src/data/constants.ts Normal file
View File

@ -0,0 +1,2 @@
export const PLUGIN_NAME = "WizSmartHome"
export const PLATFORM_NAME = "WizSmartHome"

27
src/data/types.ts Normal file
View File

@ -0,0 +1,27 @@
import { PlatformConfig, PlatformAccessory } from "homebridge"
import Controller from "../controller"
export interface Accessory {
is: (device: Device) => boolean
getName: (device: Device) => string
init: (accessory: PlatformAccessory, device: Device, controller: Controller) => void
}
export interface Config extends PlatformConfig {
port?: number
enableScenes?: boolean
broadcast?: string
address?: string
devices?: {
host?: string
mac?: string
name?: string
}[]
}
export interface Device {
model: string
ip: string
mac: string
lastSelectedSceneId?: number
}

5
src/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { API } from "homebridge";
import { PLUGIN_NAME } from "./data/constants";
import Controller from "./controller";
export default (api: API) => api.registerPlatform(PLUGIN_NAME, Controller as any)

109
src/pilotes/on-off/index.ts Normal file
View File

@ -0,0 +1,109 @@
import { PlatformAccessory } from "homebridge"
import Controller from "../../controller"
import { Device } from "../../data/types"
import {
getPilot as _getPilot,
setPilot as _setPilot,
} from "../../utilities/network"
export interface Pilot {
mac: string
rssi: number
src: string
state: boolean
}
// We need to cache all the state values
// since we need to send them all when
// updating, otherwise the bulb resets
// to default values
export const cachedPilot: { [mac: string]: Pilot } = {}
function updatePilot(
controller: Controller,
accessory: PlatformAccessory,
_: Device,
pilot: Pilot | Error
) {
const { Service } = controller
const service = accessory.getService(Service.Outlet)!
service
.getCharacteristic(controller.Characteristic.On)
.updateValue(pilot instanceof Error ? pilot : Number(pilot.state))
}
// Write a custom getPilot/setPilot that takes this
// caching into account
export function getPilot(
controller: Controller,
accessory: PlatformAccessory,
device: Device,
onSuccess: (pilot: Pilot) => void,
onError: (error: Error) => void
) {
const { Service } = controller
const service = accessory.getService(Service.Outlet)!
let callbacked = false
const onDone = (error: Error | null, pilot: Pilot) => {
const shouldCallback = !callbacked
callbacked = true
if (error !== null) {
if (shouldCallback) {
onError(error)
} else {
service.getCharacteristic(controller.Characteristic.On).updateValue(error)
}
delete cachedPilot[device.mac]
return
}
cachedPilot[device.mac] = pilot
if (shouldCallback) {
onSuccess(pilot)
} else {
updatePilot(controller, accessory, device, pilot)
}
}
const timeout = setTimeout(() => {
if (device.mac in cachedPilot) {
onDone(null, cachedPilot[device.mac])
} else {
onDone(new Error("No response within 1s"), undefined as any)
}
}, 1000)
_getPilot<Pilot>(controller, device, (error, pilot) => {
clearTimeout(timeout)
onDone(error, pilot)
})
}
export function setPilot(
controller: Controller,
_: PlatformAccessory,
device: Device,
pilot: Partial<Pilot>,
callback: (error: Error | null) => void
) {
const oldPilot = cachedPilot[device.mac]
if (typeof oldPilot == "undefined") {
return
}
const newPilot = {
...oldPilot,
state: oldPilot.state ?? false,
...pilot,
sceneId: undefined,
}
cachedPilot[device.mac] = {
...oldPilot,
...newPilot,
} as any
return _setPilot(controller, device, newPilot, (error) => {
if (error !== null) {
cachedPilot[device.mac] = oldPilot
}
callback(error)
})
}

217
src/utilities/color.ts Normal file
View File

@ -0,0 +1,217 @@
import Controller from "../controller"
export function miredToKelvin(mired: number) {
return Math.round(1000000 / mired)
}
export function kelvinToMired(kelvin: number) {
return Math.round(1000000 / kelvin)
}
const KELVIN_RANGE = { min: 2200, max: 6500 }
export type RGB = {
r: number
g: number
b: number
}
// from https://github.com/neilbartlett/color-temperature
export function colorTemperature2rgb(kelvin: number) {
const temperature = kelvin / 100.0
let red, green, blue
if (temperature < 66.0) {
red = 255
} else {
// a + b x + c Log[x] /.
// {a -> 351.97690566805693`,
// b -> 0.114206453784165`,
// c -> -40.25366309332127
//x -> (kelvin/100) - 55}
red = temperature - 55.0
red =
351.97690566805693 +
0.114206453784165 * red -
40.25366309332127 * Math.log(red)
if (red < 0) red = 0
if (red > 255) red = 255
}
/* Calculate green */
if (temperature < 66.0) {
// a + b x + c Log[x] /.
// {a -> -155.25485562709179`,
// b -> -0.44596950469579133`,
// c -> 104.49216199393888`,
// x -> (kelvin/100) - 2}
green = temperature - 2
green =
-155.25485562709179 -
0.44596950469579133 * green +
104.49216199393888 * Math.log(green)
if (green < 0) green = 0
if (green > 255) green = 255
} else {
// a + b x + c Log[x] /.
// {a -> 325.4494125711974`,
// b -> 0.07943456536662342`,
// c -> -28.0852963507957`,
// x -> (kelvin/100) - 50}
green = temperature - 50.0
green =
325.4494125711974 +
0.07943456536662342 * green -
28.0852963507957 * Math.log(green)
if (green < 0) green = 0
if (green > 255) green = 255
}
/* Calculate blue */
if (temperature >= 66.0) {
blue = 255
} else {
if (temperature <= 20.0) {
blue = 0
} else {
// a + b x + c Log[x] /.
// {a -> -254.76935184120902`,
// b -> 0.8274096064007395`,
// c -> 115.67994401066147`,
// x -> kelvin/100 - 10}
blue = temperature - 10
blue =
-254.76935184120902 +
0.8274096064007395 * blue +
115.67994401066147 * Math.log(blue)
if (blue < 0) blue = 0
if (blue > 255) blue = 255
}
}
return { r: Math.round(red), b: Math.round(blue), g: Math.round(green) }
}
// from https://github.com/neilbartlett/color-temperature
export function rgb2colorTemperature(rgb: RGB) {
let temperature = 0,
testRGB
const epsilon = 0.4
let minTemperature = 1000
let maxTemperature = 40000
while (maxTemperature - minTemperature > epsilon) {
temperature = (maxTemperature + minTemperature) / 2
testRGB = colorTemperature2rgb(temperature)
if (testRGB.b / testRGB.r >= rgb.b / rgb.r) {
maxTemperature = temperature
} else {
minTemperature = temperature
}
}
return Math.max(KELVIN_RANGE.min, Math.min(KELVIN_RANGE.max, Math.round(temperature)))
}
// from https://gist.github.com/mjackson/5311256
export function rgbToHsv({ r, g, b }: RGB) {
(r /= 255), (g /= 255), (b /= 255)
const max = Math.max(r, g, b),
min = Math.min(r, g, b)
let h: number = 0,
s: number = max
var d = max - min
s = max == 0 ? 0 : d / max
if (max == min) {
h = 0 // achromatic
} else {
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return { hue: Math.round(h * 360), saturation: Math.round(s * 100) }
}
export function hsvToColor(h: number, s: number, controller: Controller) {
// First, convert to RGB
const v = 1
let r = 0,
g = 0,
b = 0
const i = Math.floor(h * 6)
const f = h * 6 - i
const p = v * (1 - s)
const q = v * (1 - f * s)
const t = v * (1 - (1 - f) * s)
switch (i % 6) {
case 0:
(r = v), (g = t), (b = p)
break
case 1:
(r = q), (g = v), (b = p)
break
case 2:
(r = p), (g = v), (b = t)
break
case 3:
(r = p), (g = q), (b = v)
break
case 4:
(r = t), (g = p), (b = v)
break
case 5:
(r = v), (g = p), (b = q)
break
}
const rgb = {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
}
// See if it might actually be a color temperature
const h360 = h * 360
const s100 = s * 100
if (
(h360 >= 0 && h360 <= 30 && s100 >= 0 && s100 <= 76) ||
(h360 >= 220 && h360 <= 360 && s100 >= 0 && s100 <= 25)
) {
const possibleKelvin = rgb2colorTemperature(rgb)
controller.log.debug(
`Considering possible Kelvin conversion of ${possibleKelvin}`
)
// check if bulb supports it
if (
possibleKelvin >= KELVIN_RANGE.min &&
possibleKelvin <= KELVIN_RANGE.max
) {
return { temp: possibleKelvin }
}
}
return rgb
}
export function clampRgb(rgb: RGB) {
return {
r: Math.max(0, Math.min(255, rgb.r)),
g: Math.max(0, Math.min(255, rgb.g)),
b: Math.max(0, Math.min(255, rgb.b))
}
}

11
src/utilities/logger.ts Normal file
View File

@ -0,0 +1,11 @@
import Controller from "../controller"
export function makeLogger({ log }: Controller, prefix: string) {
const format = (msg: string) => `[${prefix}] ${msg}`
return {
debug: (msg: string) => log.debug(format(msg)),
info: (msg: string) => log.info(format(msg)),
warn: (msg: string) => log.warn(format(msg)),
error: (msg: string) => log.error(format(msg)),
}
}

249
src/utilities/network.ts Normal file
View File

@ -0,0 +1,249 @@
import dgram from "dgram"
import internalIp from "internal-ip"
import getMac from "getmac"
import Controller from "../controller"
import { Device } from "../data/types"
import { makeLogger } from "./logger"
function strMac() {
return getMac().toUpperCase().replace(/:/g, "")
}
function strIp() {
return internalIp.v4.sync() ?? "0.0.0.0"
}
const BROADCAST_PORT = 38899
function getNetworkConfig({ config }: Controller) {
return {
ADDRESS: config.address ?? strIp(),
PORT: config.port ?? 38900,
BROADCAST: config.broadcast ?? "255.255.255.255",
MAC: config.mac ?? strMac(),
}
}
const getPilotQueue: {
[key: string]: ((error: Error | null, pilot: any) => void)[]
} = {}
const getPilotDebounce: {
[key: string]: {
timeout: NodeJS.Timeout
callbacks: ((error: Error | null, pilot: any) => void)[]
}
} = {}
export function getPilot<T>(
controller: Controller,
device: Device,
callback: (error: Error | null, pilot: T) => void
) {
const timeout = setTimeout(() => {
const { callbacks } = getPilotDebounce[device.mac]
getPilotInternal(controller, device, (error, pilot) => {
callbacks.map((cb) => cb(error, pilot))
})
delete getPilotDebounce[device.mac]
}, 50)
if (device.mac in getPilotDebounce) {
clearTimeout(getPilotDebounce[device.mac].timeout)
}
getPilotDebounce[device.mac] = {
timeout,
callbacks: [callback, ...(getPilotDebounce[device.mac]?.callbacks ?? [])],
}
}
function getPilotInternal<T>(
controller: Controller,
device: Device,
callback: (error: Error | null, pilot: T) => void
) {
if (device.mac in getPilotQueue) {
getPilotQueue[device.mac].push(callback)
} else {
getPilotQueue[device.mac] = [callback]
}
controller.log.debug(`[getPilot] Sending getPilot to ${device.mac}`)
controller.socket.send(
`{"method":"getPilot","params":{}}`,
BROADCAST_PORT,
device.ip,
(error: Error | null) => {
if (error !== null && device.mac in getPilotQueue) {
controller.log.debug(
`[Socket] Failed to send getPilot response to ${device.mac
}: ${error.toString()}`
)
const callbacks = getPilotQueue[device.mac]
delete getPilotQueue[device.mac]
callbacks.map((f) => f(error, null))
}
}
)
}
const setPilotQueue: { [key: string]: ((error: Error | null) => void)[] } = {}
export function setPilot(
controller: Controller,
device: Device,
pilot: object,
callback: (error: Error | null) => void
) {
const msg = JSON.stringify({
method: "setPilot",
env: "pro",
params: {
mac: device.mac,
src: "udp",
...pilot,
},
})
if (device.ip in setPilotQueue) {
setPilotQueue[device.ip].push(callback)
} else {
setPilotQueue[device.ip] = [callback]
}
controller.log.debug(`[SetPilot][${device.ip}:${BROADCAST_PORT}] ${msg}`)
controller.socket.send(msg, BROADCAST_PORT, device.ip, (error: Error | null) => {
if (error !== null && device.mac in setPilotQueue) {
controller.log.debug(
`[Socket] Failed to send setPilot response to ${device.mac
}: ${error.toString()}`
)
const callbacks = setPilotQueue[device.mac]
delete setPilotQueue[device.mac]
callbacks.map((f) => f(error))
}
})
}
export function createSocket(controller: Controller) {
const log = makeLogger(controller, "Socket")
const socket = dgram.createSocket("udp4")
socket.on("error", (err) => {
log.error(`UDP Error: ${err}`)
})
socket.on("message", (msg, rinfo) => {
const decryptedMsg = msg.toString("utf8")
log.debug(
`[${rinfo.address}:${rinfo.port}] Received message: ${decryptedMsg}`
)
})
controller.api.on("shutdown", () => {
log.debug("Shutting down socket")
socket.close()
})
return socket
}
export function bindSocket(controller: Controller, onReady: () => void) {
const log = makeLogger(controller, "Socket")
const { PORT, ADDRESS } = getNetworkConfig(controller)
log.info(`Setting up socket on ${ADDRESS ?? "0.0.0.0"}:${PORT}`)
controller.socket.bind(PORT, ADDRESS, () => {
const sockAddress = controller.socket.address()
log.debug(
`Socket Bound: UDP ${sockAddress.family} listening on ${sockAddress.address}:${sockAddress.port}`
)
controller.socket.setBroadcast(true)
onReady()
})
}
export function registerDiscoveryHandler(
controller: Controller,
addDevice: (device: Device) => void
) {
const log = makeLogger(controller, "Discovery")
log.debug("Initiating discovery handlers")
try {
controller.socket.on("message", (msg, rinfo) => {
const decryptedMsg = msg.toString("utf8")
let response: any
const ip = rinfo.address
try {
response = JSON.parse(decryptedMsg)
} catch (err) {
log.debug(
`Error parsing JSON: ${err}\nFrom: ${rinfo.address} ${rinfo.port} Original: [${msg}] Decrypted: [${decryptedMsg}]`
)
return
}
if (response.method === "registration") {
const mac = response.result.mac
log.debug(`[${ip}@${mac}] Sending config request (getSystemConfig)`)
// Send system config request
controller.socket.send(
`{"method":"getSystemConfig","params":{}}`,
BROADCAST_PORT,
ip
)
} else if (response.method === "getSystemConfig") {
const mac = response.result.mac
log.debug(`[${ip}@${mac}] Received config`)
addDevice({
ip,
mac,
model: response.result.moduleName,
})
} else if (response.method === "getPilot") {
const mac = response.result.mac
if (mac in getPilotQueue) {
const callbacks = getPilotQueue[mac]
delete getPilotQueue[mac]
callbacks.map((f) => f(null, response.result))
}
} else if (response.method === "setPilot") {
const ip = rinfo.address
if (ip in setPilotQueue) {
const callbacks = setPilotQueue[ip]
delete setPilotQueue[ip]
callbacks.map((f) =>
f(response.error ? new Error(response.error.toString()) : null)
)
}
}
})
} catch (err) {
log.error(`Error: ${err}`)
}
}
export function sendDiscoveryBroadcast(service: Controller) {
const { ADDRESS, MAC, BROADCAST } = getNetworkConfig(service)
const log = makeLogger(service, "Discovery")
log.info(`Sending discovery UDP broadcast to ${BROADCAST}:${BROADCAST_PORT}`)
// Send generic discovery message
service.socket.send(
`{"method":"registration","params":{"phoneMac":"${MAC}","register":false,"phoneIp":"${ADDRESS}"}}`,
BROADCAST_PORT,
BROADCAST
)
// Send discovery message to listed devices
if (Array.isArray(service.config.devices)) {
for (const device of service.config.devices) {
if (device.host) {
log.info(`Sending discovery UDP broadcast to ${device.host}:${BROADCAST_PORT}`)
service.socket.send(
`{"method":"registration","params":{"phoneMac":"${MAC}","register":false,"phoneIp":"${ADDRESS}"}}`,
BROADCAST_PORT,
device.host
)
}
}
}
}