Initial Code commit
This commit is contained in:
		
							parent
							
								
									5068041f80
								
							
						
					
					
						commit
						435d973c53
					
				
							
								
								
									
										16
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.editorconfig
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										94
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										94
									
								
								README.md
									
									
									
									
									
								
							@ -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
									
								
							
							
						
						
									
										63
									
								
								config.schema.json
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										3891
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										47
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								package.json
									
									
									
									
									
										Normal 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"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										38
									
								
								src/accessories/Plug/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/accessories/Plug/index.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										38
									
								
								src/accessories/Socket/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/accessories/Socket/index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										6
									
								
								src/accessories/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
export { Accessory } from "../data/types"
 | 
			
		||||
 | 
			
		||||
import Socket from "./Socket"
 | 
			
		||||
import Plug from "./Plug"
 | 
			
		||||
 | 
			
		||||
export default [ Socket, Plug ]
 | 
			
		||||
							
								
								
									
										38
									
								
								src/characteristics/on-off/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/characteristics/on-off/index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										143
									
								
								src/controller/index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										2
									
								
								src/data/constants.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,2 @@
 | 
			
		||||
export const PLUGIN_NAME = "WizSmartHome"
 | 
			
		||||
export const PLATFORM_NAME = "WizSmartHome"
 | 
			
		||||
							
								
								
									
										27
									
								
								src/data/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/data/types.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										5
									
								
								src/index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										109
									
								
								src/pilotes/on-off/index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										217
									
								
								src/utilities/color.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										11
									
								
								src/utilities/logger.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										249
									
								
								src/utilities/network.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
				)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user