Replace runtypes with zod, validate config, stricter eslint config

This commit is contained in:
Lars Strojny 2022-11-08 19:13:34 +01:00
parent f87b9af7c9
commit 84761dfa93
16 changed files with 1886 additions and 1571 deletions

View file

@ -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: {

View file

@ -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": {

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
} }
} }

View file

@ -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

View file

@ -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
View file

@ -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
}
}

View file

@ -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
View 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
View 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
View 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>

View file

@ -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'

View file

@ -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({

View file

@ -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',

View file

@ -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": []
} }