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 = {
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
extends: ['eslint:recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'prettier'],
|
||||
root: true,
|
||||
rules: {
|
||||
'prettier/prettier': 'warn',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||
'prefer-arrow-callback': 'error',
|
||||
'sort-imports': [
|
||||
'error',
|
||||
|
@ -15,6 +14,19 @@ module.exports = {
|
|||
],
|
||||
},
|
||||
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'],
|
||||
env: {
|
||||
|
|
|
@ -4,11 +4,13 @@
|
|||
"singular": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["pin"],
|
||||
"properties": {
|
||||
"pin": {
|
||||
"title": "Pin",
|
||||
"description": "Homebridge PIN for service authentication",
|
||||
"type": "string",
|
||||
"pattern": "^\\d{3}-\\d{2}-\\d{3}$",
|
||||
"required": true
|
||||
},
|
||||
"debug": {
|
||||
|
|
|
@ -16,6 +16,6 @@
|
|||
|
||||
in with pkgs; rec {
|
||||
|
||||
devShell = pkgs.mkShell rec { buildInputs = with pkgs; [ nodejs ]; };
|
||||
devShell = pkgs.mkShell rec { buildInputs = with pkgs; [ nodejs jq ]; };
|
||||
});
|
||||
}
|
||||
|
|
3224
package-lock.json
generated
3224
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -15,7 +15,7 @@
|
|||
"node": ">=14.18.1",
|
||||
"homebridge": ">=1.3.5"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"main": "dist/src/index.js",
|
||||
"scripts": {
|
||||
"lint": "npm run format && eslint --ignore-path=.gitignore '**/**.{ts,js,json}'",
|
||||
"watch": "npm run build && npm run link && nodemon",
|
||||
|
@ -47,6 +47,10 @@
|
|||
"dependencies": {
|
||||
"fastify": "^4.9.2",
|
||||
"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'
|
||||
|
||||
type Pin = string
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { HapDiscover } from './api'
|
||||
import { HAPNodeJSClient } from 'hap-node-client'
|
||||
import { Device, DeviceBoundary } from '../../boundaries'
|
||||
import { Array, Unknown } from 'runtypes'
|
||||
import { Device, DeviceBoundary } from '../../boundaries/hap'
|
||||
import { Logger } from 'homebridge'
|
||||
import z from 'zod'
|
||||
|
||||
const MaybeDevices = Array(Unknown)
|
||||
const MaybeDevices = z.array(z.unknown())
|
||||
|
||||
interface HapConfig {
|
||||
debug: boolean
|
||||
|
@ -13,11 +13,10 @@ interface HapConfig {
|
|||
reqTimeout: number
|
||||
pin: string
|
||||
}
|
||||
type HapClient = typeof HAPNodeJSClient
|
||||
type ResolveFunc = (devices: Device[]) => void
|
||||
type RejectFunc = (error: unknown) => void
|
||||
|
||||
const clientMap: Record<string, HapClient> = {}
|
||||
const clientMap: Record<string, HAPNodeJSClient> = {}
|
||||
const promiseMap: Record<string, [ResolveFunc, RejectFunc]> = {}
|
||||
|
||||
function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc, reject: RejectFunc) {
|
||||
|
@ -30,9 +29,9 @@ function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc,
|
|||
try {
|
||||
const devices: Device[] = []
|
||||
|
||||
for (const device of MaybeDevices.check(deviceData)) {
|
||||
for (const device of MaybeDevices.parse(deviceData)) {
|
||||
try {
|
||||
devices.push(DeviceBoundary.check(device))
|
||||
devices.push(DeviceBoundary.parse(device))
|
||||
} catch (e) {
|
||||
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 { 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 { HttpServerController } from './adapters/http/api'
|
||||
import { PrometheusServer } from './prometheus'
|
||||
import { Config, ConfigBoundary } from './boundaries'
|
||||
|
||||
export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
|
||||
private readonly httpServer: PrometheusServer
|
||||
private httpServerController: HttpServerController | undefined = undefined
|
||||
private readonly config: Config
|
||||
|
||||
constructor(public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API) {
|
||||
this.log.debug('Initializing platform %s', this.config.platform)
|
||||
constructor(public readonly log: Logger, config: PlatformConfig, public readonly api: API) {
|
||||
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.log.debug('Shutting down %s', this.config.platform)
|
||||
this.log.debug('Shutting down %s', config.platform)
|
||||
if (this.httpServerController) {
|
||||
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 {
|
||||
this.log.debug('Starting HAP discovery')
|
||||
discover({
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 dysonData from './fixtures/dyson.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')
|
||||
|
||||
test('Aggregates homebridge-dyson fan metrics', () => {
|
||||
const dyson = DeviceBoundary.check(dysonData)
|
||||
const dyson = DeviceBoundary.parse(dysonData)
|
||||
|
||||
const expectedLabels = {
|
||||
bridge: 'Dyson bridge',
|
||||
|
@ -38,13 +38,13 @@ describe('Metrics aggregator', () => {
|
|||
})
|
||||
|
||||
test('Aggregates empty accessory metrics to empty metrics', () => {
|
||||
const empty = DeviceBoundary.check(emptyData)
|
||||
const empty = DeviceBoundary.parse(emptyData)
|
||||
|
||||
expect(aggregate([empty], timestamp)).toEqual([])
|
||||
})
|
||||
|
||||
test('Aggregates TP-Link plugs metrics', () => {
|
||||
const tpLink = DeviceBoundary.check(tpLinkData)
|
||||
const tpLink = DeviceBoundary.parse(tpLinkData)
|
||||
|
||||
const expectedLabelsAccessory1 = {
|
||||
bridge: 'TP-Link bridge',
|
||||
|
@ -88,7 +88,7 @@ describe('Metrics aggregator', () => {
|
|||
})
|
||||
|
||||
test('Aggregates homebridge-harmony remote metrics', () => {
|
||||
const harmony = DeviceBoundary.check(harmonyData)
|
||||
const harmony = DeviceBoundary.parse(harmonyData)
|
||||
|
||||
const expectedLabels1 = {
|
||||
bridge: 'Harmony bridge',
|
||||
|
|
|
@ -7,12 +7,12 @@
|
|||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/"],
|
||||
"include": ["src/", "tests/"],
|
||||
"exclude": []
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue