diff --git a/code-generation/hap-gen.js b/code-generation/hap-gen.js index c8cccfb..4b23084 100755 --- a/code-generation/hap-gen.js +++ b/code-generation/hap-gen.js @@ -24,9 +24,9 @@ for (const [name, service] of Object.entries(hap.Service)) { const code = format( ` // Auto-generated by "${join(basename(__dirname), basename(__filename))}", don’t manually edit -export const Uuids: Record = ${JSON.stringify(uuidToServiceMap)} as const +export const Uuids = ${JSON.stringify(uuidToServiceMap)} as const -export const Services: Record = ${JSON.stringify(serviceToUuidMap)} as const +export const Services = ${JSON.stringify(serviceToUuidMap)} as const `, { filepath: 'codegen.ts', ...prettierConfig }, ) diff --git a/src/adapters/http/api.ts b/src/adapters/http/api.ts index 1424b93..e3a2131 100644 --- a/src/adapters/http/api.ts +++ b/src/adapters/http/api.ts @@ -21,10 +21,10 @@ export type HttpConfig = Pick< > export interface HttpServer { - log?: Logger + log: Logger | null config: HttpConfig serverFactory?: (requestListener: RequestListener) => Server - onRequest(): HttpResponse | undefined + onRequest(): HttpResponse | null onMetrics(): HttpResponse onNotFound(): HttpResponse onError(error: Error): HttpResponse diff --git a/src/generated/services.ts b/src/generated/services.ts index 92a4e80..039d557 100644 --- a/src/generated/services.ts +++ b/src/generated/services.ts @@ -1,5 +1,5 @@ // Auto-generated by "code-generation/hap-gen.js", don’t manually edit -export const Uuids: Record = { +export const Uuids = { '00000260-0000-1000-8000-0026BB765291': 'AccessCode', '000000DA-0000-1000-8000-0026BB765291': 'AccessControl', '0000003E-0000-1000-8000-0026BB765291': 'AccessoryInformation', @@ -78,7 +78,7 @@ export const Uuids: Record = { '0000008C-0000-1000-8000-0026BB765291': 'WindowCovering', } as const -export const Services: Record = { +export const Services = { AccessCode: '00000260-0000-1000-8000-0026BB765291', AccessControl: '000000DA-0000-1000-8000-0026BB765291', AccessoryInformation: '0000003E-0000-1000-8000-0026BB765291', diff --git a/src/metrics.ts b/src/metrics.ts index 732fb87..3b793e0 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,13 +1,15 @@ import type { Accessory, Device, Service } from './boundaries' -import { Uuids } from './generated/services' -import { assertTypeExhausted, isType } from './std' +import { Services, Uuids } from './generated/services' +import { assertTypeExhausted, isKeyOfConstObject, isType, strCamelCaseToSnakeCase } from './std' + +type Labels = Record export class Metric { constructor( public readonly name: string, public readonly value: number, public readonly timestamp: Date | null = null, - public readonly labels: Record = {}, + public readonly labels: Labels = {}, ) {} } @@ -35,7 +37,7 @@ export function aggregate(devices: Device[], timestamp: Date): Metric[] { return metrics.flat() } -function extractMetrics(service: Service, timestamp: Date, labels: Record) { +function extractMetrics(service: Service, timestamp: Date, labels: Labels): Metric[] { const metrics: Metric[] = [] for (const characteristic of service.characteristics) { @@ -63,7 +65,7 @@ function extractMetrics(service: Service, timestamp: Date, labels: Record camelCaseToSnakeCase(v)) + .map((val) => strCamelCaseToSnakeCase(val)) // Remove duplicate prefix - .reduce((carry, value) => (value.startsWith(carry) ? value : `${carry}_${value}`)) + .reduce((carry, val) => (val.startsWith(carry) ? val : `${carry}_${val}`)) ) } -function camelCaseToSnakeCase(str: string): string { - return str - .replace(/\B([A-Z][a-z])/g, ' $1') - .toLowerCase() - .trim() - .replace(/\s+/g, '_') -} - -function getDeviceLabels(device: Device): Record { +function getDeviceLabels(device: Device): Labels { return { bridge: device.instance.name, device_id: device.instance.deviceID, } } -function getAccessoryLabels(accessory: Accessory): Record { - const labels: Record = {} - +function getAccessoryLabels(accessory: Accessory): Labels { for (const service of accessory.services) { - if (service.type === '0000003E-0000-1000-8000-0026BB765291') { + if (service.type === Services.AccessoryInformation) { return getServiceLabels(service) } } - return labels + return {} } -function getServiceLabels(service: Service): Record { - const labels: Record = {} +function getServiceLabels(service: Service): Labels { + const labels: Labels = {} for (const characteristic of service.characteristics) { if ( @@ -134,7 +126,7 @@ function getServiceLabels(service: Service): Record { 'Hardware Revision', ].includes(characteristic.description) ) { - labels[camelCaseToSnakeCase(characteristic.description)] = characteristic.value + labels[strCamelCaseToSnakeCase(characteristic.description)] = characteristic.value } } diff --git a/src/platform.ts b/src/platform.ts index 4bc6b06..c052d6b 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -8,7 +8,7 @@ import { type Config, ConfigBoundary, checkBoundary } from './boundaries' export class PrometheusExporterPlatform implements IndependentPlatformPlugin { private readonly httpServer: PrometheusServer - private httpServerController: HttpServerController | undefined = undefined + private httpServerController: HttpServerController | null = null private readonly config: Config constructor(public readonly log: Logger, config: PlatformConfig, public readonly api: API) { diff --git a/src/prometheus.ts b/src/prometheus.ts index df1da81..68f15df 100644 --- a/src/prometheus.ts +++ b/src/prometheus.ts @@ -1,12 +1,13 @@ -import type { Metric } from './metrics' import type { Logger } from 'homebridge' import type { HttpConfig, HttpResponse, HttpServer } from './adapters/http' +import type { Metric } from './metrics' +import { strTrimRight } from './std' export class MetricsRenderer { private readonly prefix: string constructor(prefix: string) { - this.prefix = trimRight(prefix, '_') + this.prefix = strTrimRight(prefix, '_') } render(metric: Metric): string { @@ -21,7 +22,7 @@ export class MetricsRenderer { private renderLabels(labels: Metric['labels']): string { const rendered = Object.entries(labels) - .map(([key, value]) => `${sanitizePrometheusMetricName(key)}="${escapeAttributeValue(value)}"`) + .map(([label, val]) => `${sanitizePrometheusMetricName(label)}="${escapeAttributeValue(val)}"`) .join(',') return rendered !== '' ? '{' + rendered + '}' : '' @@ -34,14 +35,6 @@ export class MetricsRenderer { } } -function trimRight(str: string, char: string): string { - return stringReverse(stringReverse(str).replace(new RegExp(`^[${char}]+`), '')) -} - -function stringReverse(str: string): string { - return str.split('').reverse().join('') -} - const retryAfterWhileDiscovery = 15 const textContentType = 'text/plain; charset=utf-8' const prometheusSpecVersion = '0.0.4' @@ -57,17 +50,19 @@ export class PrometheusServer implements HttpServer { constructor( public readonly config: HttpConfig, - public readonly log: Logger | undefined = undefined, + public readonly log: Logger | null = null, private readonly renderer: MetricsRenderer = new MetricsRenderer(config.prefix), ) {} - onRequest(): HttpResponse | undefined { - if (!this.metricsDiscovered) { - return { - statusCode: 503, - headers: withHeaders(textContentType, { 'Retry-After': String(retryAfterWhileDiscovery) }), - body: 'Metrics discovery pending', - } + onRequest(): HttpResponse | null { + if (this.metricsDiscovered) { + return null + } + + return { + statusCode: 503, + headers: withHeaders(textContentType, { 'Retry-After': String(retryAfterWhileDiscovery) }), + body: 'Metrics discovery pending', } } @@ -113,7 +108,7 @@ function escapeString(str: string) { * * `undefined` is converted to an empty string. */ -function escapeAttributeValue(str: string) { +function escapeAttributeValue(str: Metric['labels'][keyof Metric['labels']]) { if (typeof str !== 'string') { str = JSON.stringify(str) } diff --git a/src/std.ts b/src/std.ts index 626362e..c1badb5 100644 --- a/src/std.ts +++ b/src/std.ts @@ -1,15 +1,41 @@ -type Types = 'string' | 'number' | 'boolean' | 'object' interface TypeMap { string: string number: number + bigint: bigint boolean: boolean object: object + symbol: symbol + undefined: undefined } -export function isType(type: T): (v: unknown) => v is TypeMap[T] { +// Type predicate higher order function for use with e.g. filter or map +export function isType(type: T): (v: unknown) => v is TypeMap[T] { return (v: unknown): v is TypeMap[T] => typeof v === type } +// Type predicate for object keys +// Only safe for const objects, as other objects might carry additional, undeclared properties +export function isKeyOfConstObject(key: string | number | symbol, obj: T): key is keyof T { + return key in obj +} + +// Use for exhaustiveness checks in switch/case export function assertTypeExhausted(v: never): never { throw new Error(`Type should be exhausted but is not. Value "${JSON.stringify(v)}`) } + +export function strCamelCaseToSnakeCase(str: string): string { + return str + .replace(/\B([A-Z][a-z])/g, ' $1') + .toLowerCase() + .trim() + .replace(/\s+/g, '_') +} + +export function strReverse(str: string): string { + return str.split('').reverse().join('') +} + +export function strTrimRight(str: string, char: string): string { + return strReverse(strReverse(str).replace(new RegExp(`^[${char}]+`), '')) +}