Winter cleanup (#41)

Various small cleanups, code reorgs, etc.
This commit is contained in:
Lars Strojny 2022-11-24 22:12:40 +01:00 committed by GitHub
parent 64d6dfe960
commit 19131ee9c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 67 additions and 54 deletions

View file

@ -24,9 +24,9 @@ for (const [name, service] of Object.entries(hap.Service)) {
const code = format( const code = format(
` `
// Auto-generated by "${join(basename(__dirname), basename(__filename))}", dont manually edit // Auto-generated by "${join(basename(__dirname), basename(__filename))}", dont manually edit
export const Uuids: Record<string,string> = ${JSON.stringify(uuidToServiceMap)} as const export const Uuids = ${JSON.stringify(uuidToServiceMap)} as const
export const Services: Record<string,string> = ${JSON.stringify(serviceToUuidMap)} as const export const Services = ${JSON.stringify(serviceToUuidMap)} as const
`, `,
{ filepath: 'codegen.ts', ...prettierConfig }, { filepath: 'codegen.ts', ...prettierConfig },
) )

View file

@ -21,10 +21,10 @@ export type HttpConfig = Pick<
> >
export interface HttpServer { export interface HttpServer {
log?: Logger log: Logger | null
config: HttpConfig config: HttpConfig
serverFactory?: (requestListener: RequestListener) => Server serverFactory?: (requestListener: RequestListener) => Server
onRequest(): HttpResponse | undefined onRequest(): HttpResponse | null
onMetrics(): HttpResponse onMetrics(): HttpResponse
onNotFound(): HttpResponse onNotFound(): HttpResponse
onError(error: Error): HttpResponse onError(error: Error): HttpResponse

View file

@ -1,5 +1,5 @@
// Auto-generated by "code-generation/hap-gen.js", dont manually edit // Auto-generated by "code-generation/hap-gen.js", dont manually edit
export const Uuids: Record<string, string> = { export const Uuids = {
'00000260-0000-1000-8000-0026BB765291': 'AccessCode', '00000260-0000-1000-8000-0026BB765291': 'AccessCode',
'000000DA-0000-1000-8000-0026BB765291': 'AccessControl', '000000DA-0000-1000-8000-0026BB765291': 'AccessControl',
'0000003E-0000-1000-8000-0026BB765291': 'AccessoryInformation', '0000003E-0000-1000-8000-0026BB765291': 'AccessoryInformation',
@ -78,7 +78,7 @@ export const Uuids: Record<string, string> = {
'0000008C-0000-1000-8000-0026BB765291': 'WindowCovering', '0000008C-0000-1000-8000-0026BB765291': 'WindowCovering',
} as const } as const
export const Services: Record<string, string> = { export const Services = {
AccessCode: '00000260-0000-1000-8000-0026BB765291', AccessCode: '00000260-0000-1000-8000-0026BB765291',
AccessControl: '000000DA-0000-1000-8000-0026BB765291', AccessControl: '000000DA-0000-1000-8000-0026BB765291',
AccessoryInformation: '0000003E-0000-1000-8000-0026BB765291', AccessoryInformation: '0000003E-0000-1000-8000-0026BB765291',

View file

@ -1,13 +1,15 @@
import type { Accessory, Device, Service } from './boundaries' import type { Accessory, Device, Service } from './boundaries'
import { Uuids } from './generated/services' import { Services, Uuids } from './generated/services'
import { assertTypeExhausted, isType } from './std' import { assertTypeExhausted, isKeyOfConstObject, isType, strCamelCaseToSnakeCase } from './std'
type Labels = Record<string, string>
export class Metric { export class Metric {
constructor( constructor(
public readonly name: string, public readonly name: string,
public readonly value: number, public readonly value: number,
public readonly timestamp: Date | null = null, public readonly timestamp: Date | null = null,
public readonly labels: Record<string, string> = {}, public readonly labels: Labels = {},
) {} ) {}
} }
@ -35,7 +37,7 @@ export function aggregate(devices: Device[], timestamp: Date): Metric[] {
return metrics.flat() return metrics.flat()
} }
function extractMetrics(service: Service, timestamp: Date, labels: Record<string, string>) { function extractMetrics(service: Service, timestamp: Date, labels: Labels): Metric[] {
const metrics: Metric[] = [] const metrics: Metric[] = []
for (const characteristic of service.characteristics) { for (const characteristic of service.characteristics) {
@ -63,7 +65,7 @@ function extractMetrics(service: Service, timestamp: Date, labels: Record<string
case 'uint64': case 'uint64':
{ {
const name = formatName( const name = formatName(
Uuids[service.type] || 'custom', isKeyOfConstObject(service.type, Uuids) ? Uuids[service.type] : 'custom',
characteristic.description, characteristic.description,
characteristic.unit, characteristic.unit,
) )
@ -79,45 +81,35 @@ function extractMetrics(service: Service, timestamp: Date, labels: Record<string
return metrics return metrics
} }
export function formatName(serviceName: string, description: string, unit: string | undefined = undefined): string { export function formatName(serviceName: string, description: string, unit: string | null = null): string {
return ( return (
[serviceName, description, typeof unit === 'string' ? unit.toLowerCase() : undefined] [serviceName, description, typeof unit === 'string' ? unit.toLowerCase() : undefined]
.filter(isType('string')) .filter(isType('string'))
.map((v) => camelCaseToSnakeCase(v)) .map((val) => strCamelCaseToSnakeCase(val))
// Remove duplicate prefix // 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 { function getDeviceLabels(device: Device): Labels {
return str
.replace(/\B([A-Z][a-z])/g, ' $1')
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
}
function getDeviceLabels(device: Device): Record<string, string> {
return { return {
bridge: device.instance.name, bridge: device.instance.name,
device_id: device.instance.deviceID, device_id: device.instance.deviceID,
} }
} }
function getAccessoryLabels(accessory: Accessory): Record<string, string> { function getAccessoryLabels(accessory: Accessory): Labels {
const labels: Record<string, string> = {}
for (const service of accessory.services) { for (const service of accessory.services) {
if (service.type === '0000003E-0000-1000-8000-0026BB765291') { if (service.type === Services.AccessoryInformation) {
return getServiceLabels(service) return getServiceLabels(service)
} }
} }
return labels return {}
} }
function getServiceLabels(service: Service): Record<string, string> { function getServiceLabels(service: Service): Labels {
const labels: Record<string, string> = {} const labels: Labels = {}
for (const characteristic of service.characteristics) { for (const characteristic of service.characteristics) {
if ( if (
@ -134,7 +126,7 @@ function getServiceLabels(service: Service): Record<string, string> {
'Hardware Revision', 'Hardware Revision',
].includes(characteristic.description) ].includes(characteristic.description)
) { ) {
labels[camelCaseToSnakeCase(characteristic.description)] = characteristic.value labels[strCamelCaseToSnakeCase(characteristic.description)] = characteristic.value
} }
} }

View file

@ -8,7 +8,7 @@ import { type Config, ConfigBoundary, checkBoundary } 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 | null = null
private readonly config: Config private readonly config: Config
constructor(public readonly log: Logger, config: PlatformConfig, public readonly api: API) { constructor(public readonly log: Logger, config: PlatformConfig, public readonly api: API) {

View file

@ -1,12 +1,13 @@
import type { Metric } from './metrics'
import type { Logger } from 'homebridge' import type { Logger } from 'homebridge'
import type { HttpConfig, HttpResponse, HttpServer } from './adapters/http' import type { HttpConfig, HttpResponse, HttpServer } from './adapters/http'
import type { Metric } from './metrics'
import { strTrimRight } from './std'
export class MetricsRenderer { export class MetricsRenderer {
private readonly prefix: string private readonly prefix: string
constructor(prefix: string) { constructor(prefix: string) {
this.prefix = trimRight(prefix, '_') this.prefix = strTrimRight(prefix, '_')
} }
render(metric: Metric): string { render(metric: Metric): string {
@ -21,7 +22,7 @@ export class MetricsRenderer {
private renderLabels(labels: Metric['labels']): string { private renderLabels(labels: Metric['labels']): string {
const rendered = Object.entries(labels) const rendered = Object.entries(labels)
.map(([key, value]) => `${sanitizePrometheusMetricName(key)}="${escapeAttributeValue(value)}"`) .map(([label, val]) => `${sanitizePrometheusMetricName(label)}="${escapeAttributeValue(val)}"`)
.join(',') .join(',')
return rendered !== '' ? '{' + rendered + '}' : '' 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 retryAfterWhileDiscovery = 15
const textContentType = 'text/plain; charset=utf-8' const textContentType = 'text/plain; charset=utf-8'
const prometheusSpecVersion = '0.0.4' const prometheusSpecVersion = '0.0.4'
@ -57,19 +50,21 @@ export class PrometheusServer implements HttpServer {
constructor( constructor(
public readonly config: HttpConfig, public readonly config: HttpConfig,
public readonly log: Logger | undefined = undefined, public readonly log: Logger | null = null,
private readonly renderer: MetricsRenderer = new MetricsRenderer(config.prefix), private readonly renderer: MetricsRenderer = new MetricsRenderer(config.prefix),
) {} ) {}
onRequest(): HttpResponse | undefined { onRequest(): HttpResponse | null {
if (!this.metricsDiscovered) { if (this.metricsDiscovered) {
return null
}
return { return {
statusCode: 503, statusCode: 503,
headers: withHeaders(textContentType, { 'Retry-After': String(retryAfterWhileDiscovery) }), headers: withHeaders(textContentType, { 'Retry-After': String(retryAfterWhileDiscovery) }),
body: 'Metrics discovery pending', body: 'Metrics discovery pending',
} }
} }
}
onMetrics(): HttpResponse { onMetrics(): HttpResponse {
return { return {
@ -113,7 +108,7 @@ function escapeString(str: string) {
* *
* `undefined` is converted to an empty string. * `undefined` is converted to an empty string.
*/ */
function escapeAttributeValue(str: string) { function escapeAttributeValue(str: Metric['labels'][keyof Metric['labels']]) {
if (typeof str !== 'string') { if (typeof str !== 'string') {
str = JSON.stringify(str) str = JSON.stringify(str)
} }

View file

@ -1,15 +1,41 @@
type Types = 'string' | 'number' | 'boolean' | 'object'
interface TypeMap { interface TypeMap {
string: string string: string
number: number number: number
bigint: bigint
boolean: boolean boolean: boolean
object: object object: object
symbol: symbol
undefined: undefined
} }
export function isType<T extends Types>(type: T): (v: unknown) => v is TypeMap[T] { // Type predicate higher order function for use with e.g. filter or map
export function isType<T extends keyof TypeMap>(type: T): (v: unknown) => v is TypeMap[T] {
return (v: unknown): v is TypeMap[T] => typeof v === type 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<T extends object>(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 { export function assertTypeExhausted(v: never): never {
throw new Error(`Type should be exhausted but is not. Value "${JSON.stringify(v)}`) 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}]+`), ''))
}