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(
`
// 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 },
)

View file

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

View file

@ -1,5 +1,5 @@
// 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',
'000000DA-0000-1000-8000-0026BB765291': 'AccessControl',
'0000003E-0000-1000-8000-0026BB765291': 'AccessoryInformation',
@ -78,7 +78,7 @@ export const Uuids: Record<string, string> = {
'0000008C-0000-1000-8000-0026BB765291': 'WindowCovering',
} as const
export const Services: Record<string, string> = {
export const Services = {
AccessCode: '00000260-0000-1000-8000-0026BB765291',
AccessControl: '000000DA-0000-1000-8000-0026BB765291',
AccessoryInformation: '0000003E-0000-1000-8000-0026BB765291',

View file

@ -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<string, string>
export class Metric {
constructor(
public readonly name: string,
public readonly value: number,
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()
}
function extractMetrics(service: Service, timestamp: Date, labels: Record<string, string>) {
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<string
case 'uint64':
{
const name = formatName(
Uuids[service.type] || 'custom',
isKeyOfConstObject(service.type, Uuids) ? Uuids[service.type] : 'custom',
characteristic.description,
characteristic.unit,
)
@ -79,45 +81,35 @@ function extractMetrics(service: Service, timestamp: Date, labels: Record<string
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 (
[serviceName, description, typeof unit === 'string' ? unit.toLowerCase() : undefined]
.filter(isType('string'))
.map((v) => 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<string, string> {
function getDeviceLabels(device: Device): Labels {
return {
bridge: device.instance.name,
device_id: device.instance.deviceID,
}
}
function getAccessoryLabels(accessory: Accessory): Record<string, string> {
const labels: Record<string, string> = {}
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<string, string> {
const labels: Record<string, string> = {}
function getServiceLabels(service: Service): Labels {
const labels: Labels = {}
for (const characteristic of service.characteristics) {
if (
@ -134,7 +126,7 @@ function getServiceLabels(service: Service): Record<string, string> {
'Hardware Revision',
].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 {
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) {

View file

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

View file

@ -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<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
}
// 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 {
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}]+`), ''))
}