Replace runtypes with zod, validate config, stricter eslint config
This commit is contained in:
parent
f87b9af7c9
commit
84761dfa93
16 changed files with 1886 additions and 1571 deletions
16
.eslintrc.js
16
.eslintrc.js
|
@ -1,11 +1,10 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
extends: ['eslint:recommended'],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
plugins: ['@typescript-eslint', 'prettier'],
|
plugins: ['@typescript-eslint', 'prettier'],
|
||||||
root: true,
|
root: true,
|
||||||
rules: {
|
rules: {
|
||||||
'prettier/prettier': 'warn',
|
'prettier/prettier': 'warn',
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
|
||||||
'prefer-arrow-callback': 'error',
|
'prefer-arrow-callback': 'error',
|
||||||
'sort-imports': [
|
'sort-imports': [
|
||||||
'error',
|
'error',
|
||||||
|
@ -15,6 +14,19 @@ module.exports = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['.eslintrc.js', 'jest.config.js', 'prettier.config.js'],
|
files: ['.eslintrc.js', 'jest.config.js', 'prettier.config.js'],
|
||||||
env: {
|
env: {
|
||||||
|
|
|
@ -4,11 +4,13 @@
|
||||||
"singular": true,
|
"singular": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"required": ["pin"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"pin": {
|
"pin": {
|
||||||
"title": "Pin",
|
"title": "Pin",
|
||||||
"description": "Homebridge PIN for service authentication",
|
"description": "Homebridge PIN for service authentication",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"pattern": "^\\d{3}-\\d{2}-\\d{3}$",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
|
|
|
@ -16,6 +16,6 @@
|
||||||
|
|
||||||
in with pkgs; rec {
|
in with pkgs; rec {
|
||||||
|
|
||||||
devShell = pkgs.mkShell rec { buildInputs = with pkgs; [ nodejs ]; };
|
devShell = pkgs.mkShell rec { buildInputs = with pkgs; [ nodejs jq ]; };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
3226
package-lock.json
generated
3226
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -15,7 +15,7 @@
|
||||||
"node": ">=14.18.1",
|
"node": ">=14.18.1",
|
||||||
"homebridge": ">=1.3.5"
|
"homebridge": ">=1.3.5"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run format && eslint --ignore-path=.gitignore '**/**.{ts,js,json}'",
|
"lint": "npm run format && eslint --ignore-path=.gitignore '**/**.{ts,js,json}'",
|
||||||
"watch": "npm run build && npm run link && nodemon",
|
"watch": "npm run build && npm run link && nodemon",
|
||||||
|
@ -47,6 +47,10 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^4.9.2",
|
"fastify": "^4.9.2",
|
||||||
"hap-node-client": "git+https://github.com/NorthernMan54/Hap-Node-Client.git#fe200ba",
|
"hap-node-client": "git+https://github.com/NorthernMan54/Hap-Node-Client.git#fe200ba",
|
||||||
"runtypes": "^6.6.0"
|
"json-schema-to-zod": "^0.2.0",
|
||||||
|
"zod": "^3.19.1"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"jest-mock": "29.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Device } from '../../boundaries'
|
import type { Device } from '../../boundaries/hap'
|
||||||
import { Logger } from 'homebridge'
|
import { Logger } from 'homebridge'
|
||||||
|
|
||||||
type Pin = string
|
type Pin = string
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import type { HapDiscover } from './api'
|
import type { HapDiscover } from './api'
|
||||||
import { HAPNodeJSClient } from 'hap-node-client'
|
import { HAPNodeJSClient } from 'hap-node-client'
|
||||||
import { Device, DeviceBoundary } from '../../boundaries'
|
import { Device, DeviceBoundary } from '../../boundaries/hap'
|
||||||
import { Array, Unknown } from 'runtypes'
|
|
||||||
import { Logger } from 'homebridge'
|
import { Logger } from 'homebridge'
|
||||||
|
import z from 'zod'
|
||||||
|
|
||||||
const MaybeDevices = Array(Unknown)
|
const MaybeDevices = z.array(z.unknown())
|
||||||
|
|
||||||
interface HapConfig {
|
interface HapConfig {
|
||||||
debug: boolean
|
debug: boolean
|
||||||
|
@ -13,11 +13,10 @@ interface HapConfig {
|
||||||
reqTimeout: number
|
reqTimeout: number
|
||||||
pin: string
|
pin: string
|
||||||
}
|
}
|
||||||
type HapClient = typeof HAPNodeJSClient
|
|
||||||
type ResolveFunc = (devices: Device[]) => void
|
type ResolveFunc = (devices: Device[]) => void
|
||||||
type RejectFunc = (error: unknown) => void
|
type RejectFunc = (error: unknown) => void
|
||||||
|
|
||||||
const clientMap: Record<string, HapClient> = {}
|
const clientMap: Record<string, HAPNodeJSClient> = {}
|
||||||
const promiseMap: Record<string, [ResolveFunc, RejectFunc]> = {}
|
const promiseMap: Record<string, [ResolveFunc, RejectFunc]> = {}
|
||||||
|
|
||||||
function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc, reject: RejectFunc) {
|
function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc, reject: RejectFunc) {
|
||||||
|
@ -30,9 +29,9 @@ function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc,
|
||||||
try {
|
try {
|
||||||
const devices: Device[] = []
|
const devices: Device[] = []
|
||||||
|
|
||||||
for (const device of MaybeDevices.check(deviceData)) {
|
for (const device of MaybeDevices.parse(deviceData)) {
|
||||||
try {
|
try {
|
||||||
devices.push(DeviceBoundary.check(device))
|
devices.push(DeviceBoundary.parse(device))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4))
|
logger.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4))
|
||||||
}
|
}
|
||||||
|
|
16
src/ambient.d.ts
vendored
16
src/ambient.d.ts
vendored
|
@ -1 +1,15 @@
|
||||||
declare module 'hap-node-client'
|
declare module 'hap-node-client' {
|
||||||
|
type HAPNodeJSClientConfig = {
|
||||||
|
debug?: boolean
|
||||||
|
refresh?: number
|
||||||
|
timeout?: number
|
||||||
|
reqTimeout?: number
|
||||||
|
pin?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class HAPNodeJSClient {
|
||||||
|
constructor(config: HAPNodeJSClientConfig)
|
||||||
|
|
||||||
|
on(event: 'Ready', callback: (v: unknown) => void): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
import { Array, Intersect, Literal, Number, Optional, Record, Static, String, Union } from 'runtypes'
|
|
||||||
|
|
||||||
export const NUMBER_TYPES = [] as const
|
|
||||||
|
|
||||||
const NumberAlikeTypesTypesBoundary = Union(
|
|
||||||
Literal('bool'),
|
|
||||||
Literal('float'),
|
|
||||||
Literal('int'),
|
|
||||||
Literal('uint8'),
|
|
||||||
Literal('uint16'),
|
|
||||||
Literal('uint32'),
|
|
||||||
Literal('uint64'),
|
|
||||||
)
|
|
||||||
export type NumberAlikeTypes = Static<typeof NumberAlikeTypesTypesBoundary>
|
|
||||||
|
|
||||||
export const CharacteristicBoundary = Intersect(
|
|
||||||
Record({ type: String, description: String }),
|
|
||||||
Union(
|
|
||||||
Record({
|
|
||||||
format: NumberAlikeTypesTypesBoundary,
|
|
||||||
value: Optional(Number),
|
|
||||||
unit: Optional(String),
|
|
||||||
}),
|
|
||||||
Record({ format: Literal('string'), value: String }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
export type Characteristic = Static<typeof CharacteristicBoundary>
|
|
||||||
|
|
||||||
export const ServiceBoundary = Record({
|
|
||||||
type: String,
|
|
||||||
characteristics: Array(CharacteristicBoundary),
|
|
||||||
})
|
|
||||||
export type Service = Static<typeof ServiceBoundary>
|
|
||||||
|
|
||||||
export const AccessoryBoundary = Record({
|
|
||||||
services: Array(ServiceBoundary),
|
|
||||||
})
|
|
||||||
export type Accessory = Static<typeof AccessoryBoundary>
|
|
||||||
|
|
||||||
export const InstanceBoundary = Record({
|
|
||||||
deviceID: String,
|
|
||||||
name: String,
|
|
||||||
url: String,
|
|
||||||
})
|
|
||||||
export type Instance = Static<typeof InstanceBoundary>
|
|
||||||
|
|
||||||
export const DeviceBoundary = Record({
|
|
||||||
instance: InstanceBoundary,
|
|
||||||
accessories: Record({
|
|
||||||
accessories: Array(AccessoryBoundary),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
export type Device = Static<typeof DeviceBoundary>
|
|
19
src/boundaries/config.ts
Normal file
19
src/boundaries/config.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const ConfigBoundary = z.object({
|
||||||
|
pin: z.string().regex(new RegExp('^\\d{3}-\\d{2}-\\d{3}$')).describe('Homebridge PIN for service authentication'),
|
||||||
|
debug: z.boolean().default(false),
|
||||||
|
prefix: z.string().default('homebridge'),
|
||||||
|
port: z.number().int().describe('TCP port for the prometheus probe server to listen to').default(36123),
|
||||||
|
refresh_interval: z.number().int().describe('Discover new services every <interval> seconds').default(60),
|
||||||
|
request_timeout: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.describe('Request timeout when interacting with homebridge instances')
|
||||||
|
.default(10),
|
||||||
|
discovery_timeout: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.describe('Discovery timeout after which the current discovery is considered failed')
|
||||||
|
.default(20),
|
||||||
|
})
|
51
src/boundaries/hap.ts
Normal file
51
src/boundaries/hap.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import z from 'zod'
|
||||||
|
|
||||||
|
const NumberAlikeTypesTypesBoundary = z.union([
|
||||||
|
z.literal('bool'),
|
||||||
|
z.literal('float'),
|
||||||
|
z.literal('int'),
|
||||||
|
z.literal('uint8'),
|
||||||
|
z.literal('uint16'),
|
||||||
|
z.literal('uint32'),
|
||||||
|
z.literal('uint64'),
|
||||||
|
])
|
||||||
|
export type NumberAlikeTypes = z.infer<typeof NumberAlikeTypesTypesBoundary>
|
||||||
|
|
||||||
|
export const CharacteristicBoundary = z.intersection(
|
||||||
|
z.object({ type: z.string(), description: z.string() }),
|
||||||
|
z.union([
|
||||||
|
z.object({
|
||||||
|
format: NumberAlikeTypesTypesBoundary,
|
||||||
|
value: z.optional(z.number()),
|
||||||
|
unit: z.optional(z.string()),
|
||||||
|
}),
|
||||||
|
z.object({ format: z.literal('string'), value: z.string() }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
export type Characteristic = z.infer<typeof CharacteristicBoundary>
|
||||||
|
|
||||||
|
export const ServiceBoundary = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
characteristics: z.array(CharacteristicBoundary),
|
||||||
|
})
|
||||||
|
export type Service = z.infer<typeof ServiceBoundary>
|
||||||
|
|
||||||
|
export const AccessoryBoundary = z.object({
|
||||||
|
services: z.array(ServiceBoundary),
|
||||||
|
})
|
||||||
|
export type Accessory = z.infer<typeof AccessoryBoundary>
|
||||||
|
|
||||||
|
export const InstanceBoundary = z.object({
|
||||||
|
deviceID: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
})
|
||||||
|
export type Instance = z.infer<typeof InstanceBoundary>
|
||||||
|
|
||||||
|
export const DeviceBoundary = z.object({
|
||||||
|
instance: InstanceBoundary,
|
||||||
|
accessories: z.object({
|
||||||
|
accessories: z.array(AccessoryBoundary),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
export type Device = z.infer<typeof DeviceBoundary>
|
6
src/boundaries/index.ts
Normal file
6
src/boundaries/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import z from 'zod'
|
||||||
|
import { ConfigBoundary as BaseConfigBoundary } from './config'
|
||||||
|
|
||||||
|
export * from './hap'
|
||||||
|
export const ConfigBoundary = z.intersection(BaseConfigBoundary, z.object({ platform: z.string() }))
|
||||||
|
export type Config = z.infer<typeof ConfigBoundary>
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Accessory, Device, Service } from './boundaries'
|
import type { Accessory, Device, Service } from './boundaries/hap'
|
||||||
import { assertTypeExhausted, isType } from './std'
|
import { assertTypeExhausted, isType } from './std'
|
||||||
import { Service as HapService } from 'hap-nodejs'
|
import { Service as HapService } from 'hap-nodejs'
|
||||||
|
|
||||||
|
|
|
@ -5,18 +5,22 @@ import { discover } from './adapters/discovery/hap_node_js_client'
|
||||||
import { serve } from './adapters/http/fastify'
|
import { serve } from './adapters/http/fastify'
|
||||||
import { HttpServerController } from './adapters/http/api'
|
import { HttpServerController } from './adapters/http/api'
|
||||||
import { PrometheusServer } from './prometheus'
|
import { PrometheusServer } from './prometheus'
|
||||||
|
import { Config, ConfigBoundary } from './boundaries'
|
||||||
|
|
||||||
export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
|
export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
|
||||||
private readonly httpServer: PrometheusServer
|
private readonly httpServer: PrometheusServer
|
||||||
private httpServerController: HttpServerController | undefined = undefined
|
private httpServerController: HttpServerController | undefined = undefined
|
||||||
|
private readonly config: Config
|
||||||
|
|
||||||
constructor(public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API) {
|
constructor(public readonly log: Logger, config: PlatformConfig, public readonly api: API) {
|
||||||
this.log.debug('Initializing platform %s', this.config.platform)
|
this.log.debug('Initializing platform %s', config.platform)
|
||||||
|
|
||||||
this.configure()
|
this.config = ConfigBoundary.parse(config)
|
||||||
|
|
||||||
|
this.log.debug('Configuration parsed', this.config)
|
||||||
|
|
||||||
this.api.on('shutdown', () => {
|
this.api.on('shutdown', () => {
|
||||||
this.log.debug('Shutting down %s', this.config.platform)
|
this.log.debug('Shutting down %s', config.platform)
|
||||||
if (this.httpServerController) {
|
if (this.httpServerController) {
|
||||||
this.httpServerController.shutdown()
|
this.httpServerController.shutdown()
|
||||||
}
|
}
|
||||||
|
@ -40,21 +44,6 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private configure(): void {
|
|
||||||
if (this.config.pin !== 'string' || !this.config.pin.match(/^\d{3}-\d{2}-\d{3}$/)) {
|
|
||||||
this.log.error('"pin" must be defined in config and match format 000-00-000')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.config.debug = this.config.debug ?? false
|
|
||||||
this.config.port = this.config.port ?? 36123
|
|
||||||
this.config.prefix = this.config.prefix ?? 'homebridge'
|
|
||||||
this.config.refresh_interval = this.config.refresh_interval || 60
|
|
||||||
this.config.request_timeout = this.config.request_timeout || 10
|
|
||||||
this.config.discovery_timeout = this.config.discovery_timeout || 20
|
|
||||||
|
|
||||||
this.log.debug('Configuration materialized: %o', this.config)
|
|
||||||
}
|
|
||||||
|
|
||||||
private startHapDiscovery(): void {
|
private startHapDiscovery(): void {
|
||||||
this.log.debug('Starting HAP discovery')
|
this.log.debug('Starting HAP discovery')
|
||||||
discover({
|
discover({
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from '@jest/globals'
|
import { describe, expect, test } from '@jest/globals'
|
||||||
import { DeviceBoundary } from '../src/boundaries'
|
import { DeviceBoundary } from '../src/boundaries/hap'
|
||||||
import { Metric, aggregate } from '../src/metrics'
|
import { Metric, aggregate } from '../src/metrics'
|
||||||
import dysonData from './fixtures/dyson.json'
|
import dysonData from './fixtures/dyson.json'
|
||||||
import emptyData from './fixtures/empty.json'
|
import emptyData from './fixtures/empty.json'
|
||||||
|
@ -10,7 +10,7 @@ describe('Metrics aggregator', () => {
|
||||||
const timestamp = new Date('2000-01-01 00:00:00 UTC')
|
const timestamp = new Date('2000-01-01 00:00:00 UTC')
|
||||||
|
|
||||||
test('Aggregates homebridge-dyson fan metrics', () => {
|
test('Aggregates homebridge-dyson fan metrics', () => {
|
||||||
const dyson = DeviceBoundary.check(dysonData)
|
const dyson = DeviceBoundary.parse(dysonData)
|
||||||
|
|
||||||
const expectedLabels = {
|
const expectedLabels = {
|
||||||
bridge: 'Dyson bridge',
|
bridge: 'Dyson bridge',
|
||||||
|
@ -38,13 +38,13 @@ describe('Metrics aggregator', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Aggregates empty accessory metrics to empty metrics', () => {
|
test('Aggregates empty accessory metrics to empty metrics', () => {
|
||||||
const empty = DeviceBoundary.check(emptyData)
|
const empty = DeviceBoundary.parse(emptyData)
|
||||||
|
|
||||||
expect(aggregate([empty], timestamp)).toEqual([])
|
expect(aggregate([empty], timestamp)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Aggregates TP-Link plugs metrics', () => {
|
test('Aggregates TP-Link plugs metrics', () => {
|
||||||
const tpLink = DeviceBoundary.check(tpLinkData)
|
const tpLink = DeviceBoundary.parse(tpLinkData)
|
||||||
|
|
||||||
const expectedLabelsAccessory1 = {
|
const expectedLabelsAccessory1 = {
|
||||||
bridge: 'TP-Link bridge',
|
bridge: 'TP-Link bridge',
|
||||||
|
@ -88,7 +88,7 @@ describe('Metrics aggregator', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Aggregates homebridge-harmony remote metrics', () => {
|
test('Aggregates homebridge-harmony remote metrics', () => {
|
||||||
const harmony = DeviceBoundary.check(harmonyData)
|
const harmony = DeviceBoundary.parse(harmonyData)
|
||||||
|
|
||||||
const expectedLabels1 = {
|
const expectedLabels1 = {
|
||||||
bridge: 'Harmony bridge',
|
bridge: 'Harmony bridge',
|
||||||
|
|
|
@ -7,12 +7,12 @@
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/"],
|
"include": ["src/", "tests/"],
|
||||||
"exclude": []
|
"exclude": []
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue