parent
64d6dfe960
commit
19131ee9c3
7 changed files with 67 additions and 54 deletions
|
@ -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))}", don’t manually edit
|
// Auto-generated by "${join(basename(__dirname), basename(__filename))}", don’t 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 },
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// Auto-generated by "code-generation/hap-gen.js", don’t manually edit
|
// Auto-generated by "code-generation/hap-gen.js", don’t 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',
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
30
src/std.ts
30
src/std.ts
|
@ -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}]+`), ''))
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue