Compare commits

..

No commits in common. "develop" and "v0.0.11" have entirely different histories.

32 changed files with 4515 additions and 6315 deletions

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -19,7 +19,6 @@ module.exports = {
'import/no-default-export': 'error', 'import/no-default-export': 'error',
'import/no-namespace': 'error', 'import/no-namespace': 'error',
'import/no-useless-path-segments': 'error', 'import/no-useless-path-segments': 'error',
'import/no-named-as-default': 0,
'no-duplicate-imports': 'error', 'no-duplicate-imports': 'error',
}, },
overrides: [ overrides: [

1
.github/FUNDING.yml vendored
View file

@ -1,2 +1 @@
github: lstrojny github: lstrojny
custom: ["https://paypal.me/larsstrojny"]

View file

@ -3,11 +3,4 @@ updates:
- package-ecosystem: npm - package-ecosystem: npm
directory: / directory: /
schedule: schedule:
interval: daily interval: weekly
open-pull-requests-limit: 30
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
open-pull-requests-limit: 30

View file

@ -4,12 +4,16 @@ on: [push, pull_request]
jobs: jobs:
build: build:
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- { node-version: 10.x, lint: false, tests: false }
- { node-version: 11.x, lint: false, tests: false }
- { node-version: 12.x, lint: false, tests: false }
- { node-version: 13.x, lint: false, tests: false }
- { node-version: 14.x, lint: true, tests: true } - { node-version: 14.x, lint: true, tests: true }
- { node-version: 15.x, lint: false, tests: true } - { node-version: 15.x, lint: false, tests: true }
- { node-version: 16.x, lint: true, tests: true } - { node-version: 16.x, lint: true, tests: true }
@ -17,19 +21,19 @@ jobs:
- { node-version: 18.x, lint: true, tests: true } - { node-version: 18.x, lint: true, tests: true }
- { node-version: 19.x, lint: true, tests: true } - { node-version: 19.x, lint: true, tests: true }
name: nodejs ${{ matrix.node-version }} (${{ matrix.lint && 'lint → ' || '' }}${{ matrix.tests && 'test → ' || '' }}build) name: Node.js ${{ matrix.node-version }}${{ matrix.lint && ', lint' || '' }}${{ matrix.tests && ', test' || '' }}, build
steps: steps:
- uses: actions/checkout@v4.1.0 - uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- name: Cache node modules - name: Cache node modules
id: cache-npm id: cache-npm
uses: actions/cache@v3.3.2 uses: actions/cache@v3
env: env:
cache-name: cache-node-modules cache-name: cache-node-modules
with: with:
@ -38,7 +42,7 @@ jobs:
- name: Cache eslint - name: Cache eslint
id: cache-eslint id: cache-eslint
uses: actions/cache@v3.3.2 uses: actions/cache@v3
env: env:
cache-name: cache-eslint cache-name: cache-eslint
with: with:
@ -47,7 +51,7 @@ jobs:
- name: Cache TypeScript - name: Cache TypeScript
id: cache-typescript id: cache-typescript
uses: actions/cache@v3.3.2 uses: actions/cache@v3
env: env:
cache-name: cache-typescript cache-name: cache-typescript
with: with:
@ -56,7 +60,7 @@ jobs:
- name: Cache prettier - name: Cache prettier
id: cache-prettier id: cache-prettier
uses: actions/cache@v3.3.2 uses: actions/cache@v3
env: env:
cache-name: cache-prettier cache-name: cache-prettier
with: with:
@ -75,12 +79,5 @@ jobs:
run: npm test run: npm test
if: ${{ matrix.tests }} if: ${{ matrix.tests }}
- name: Upload code coverage
uses: actions/upload-artifact@v3.1.3
with:
name: code-coverage
path: coverage/lcov.info
if: ${{ matrix.node-version == '18.x' }}
- name: Build the project - name: Build the project
run: npm run build run: npm run build

View file

@ -1,28 +0,0 @@
name: Dependabot auto merge
on:
workflow_run:
workflows: [CI]
types:
- completed
jobs:
automerge:
name: Auto merge "${{ github.event.workflow_run.head_branch }}"
runs-on: ubuntu-22.04
if: >
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'
&& github.actor == 'dependabot[bot]'
&& startsWith(github.event.workflow_run.head_branch, 'dependabot/')
steps:
- name: Checkout source
uses: actions/checkout@v4.1.0
with:
ref: ${{ github.event.workflow_run.head_commit.id }}
- name: Instruct @dependabot to merge
run: "gh issue comment $ISSUE_ID --body \"(This is an automated comment from workflow $WORKFLOW_URL)\n\n@dependabot squash and merge\""
env:
GITHUB_TOKEN: ${{ secrets.DEPENDABOT_COMMENT_TOKEN }}
ISSUE_ID: ${{ github.event.workflow_run.pull_requests[0].number }}
WORKFLOW_URL: ${{ github.event.repository.html_url }}/actions/runs/${{ github.run_id }}

View file

@ -1,55 +0,0 @@
name: Sonar scan
on:
workflow_run:
workflows: [CI]
types: [completed]
jobs:
sonar:
name: Sonar scan on "${{ github.event.workflow_run.head_branch }}"
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v4.1.0
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }}
fetch-depth: 0
- name: 'Download code coverage'
uses: actions/github-script@v6
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "code-coverage"
})[0];
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/code-coverage.zip`, Buffer.from(download.data));
- name: 'Unzip code coverage'
run: unzip code-coverage.zip -d coverage
- name: SonarCloud scan
uses: sonarsource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.scm.revision=${{ github.event.workflow_run.head_sha }}
-Dsonar.pullrequest.key=${{ github.event.workflow_run.pull_requests[0].number }}
-Dsonar.pullrequest.branch=${{ github.event.workflow_run.pull_requests[0].head.ref }}
-Dsonar.pullrequest.base=${{ github.event.workflow_run.pull_requests[0].base.ref }}

View file

@ -143,4 +143,3 @@ prettier.config.js
jest.config.js jest.config.js
nodemon.json nodemon.json
/code-generation/ /code-generation/
/release.sh

102
README.md
View file

@ -11,8 +11,7 @@
width="10%"/> width="10%"/>
</div> </div>
# Homebridge Prometheus Exporter # Homebridge Prometheus Exporter [![CI](https://github.com/lstrojny/homebridge-prometheus-exporter/actions/workflows/build.yml/badge.svg)](https://github.com/lstrojny/homebridge-prometheus-exporter/actions/workflows/build.yml)
[![CI](https://github.com/lstrojny/homebridge-prometheus-exporter/actions/workflows/build.yml/badge.svg)](https://github.com/lstrojny/homebridge-prometheus-exporter/actions/workflows/build.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=lstrojny_homebridge-prometheus-exporter&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=lstrojny_homebridge-prometheus-exporter) [![npm version](https://badge.fury.io/js/homebridge-prometheus-exporter.svg)](https://badge.fury.io/js/homebridge-prometheus-exporter) ![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/lstrojny/homebridge-prometheus-exporter) ![npm](https://img.shields.io/npm/dw/homebridge-prometheus-exporter)
> What if we could store homebridge metrics in Prometheus > What if we could store homebridge metrics in Prometheus
@ -117,95 +116,46 @@ Once *Prometheus* is restarted, metrics with the `homebridge_` prefix should sta
*homebridge-prometheus-exporter* offers a few advanced settings to customize its behavior. *homebridge-prometheus-exporter* offers a few advanced settings to customize its behavior.
<!-- AUTOGENERATED CONFIG DOCS BEGIN --> ```json lines
```json5
{ {
// ... //
"platforms": [ "platforms": [
{ {
"platform": "PrometheusExporter", "platform": "PrometheusExporter",
// Homebridge PIN for service authentication. String of digits, format XXX-XX-XXX. Required
"pin": string,
// Toggle debug mode. Run homebridge with -D if you want to see the debug output. Default: false
"debug": boolean,
// Pin // Prefix for all metrics. Default: "homebridge"
// "prefix": string,
// Homebridge PIN for service authentication
"pin": "<string>",
// TCP port where the Prometheus metrics server listens. Default: 36123
"port": number,
// Debug // How frequently the services should be rediscovered (in seconds). Default: 60
// "refresh_interval": number,
// Default: false
"debug": "<boolean>",
// Timeout for the HTTP request that retrieves the homekit devices (in seconds). Default: 10
"request_timeout": number,
// Metrics prefix // Timeout for the service discovery (in seconds). Default: 20
// "discovery_timeout": number,
// Default: "homebridge"
"prefix": "<string>",
// Metrics server port
//
// TCP port where the Prometheus metrics server listens
//
// Default: 36123
"port": "<integer>",
// Metrics server interface
//
// Interface where the Prometheus metrics server listens. Can be an IP, a
// hostname, "0.0.0.0" for all IPv4 interfaces, "::1" for all IPv6 interfaces.
// Default is "::" which means "any interface"
//
// Default: "::"
"interface": "<string>",
// Service refresh interval
//
// Discover new services every <interval> seconds
//
// Default: 60
"refresh_interval": "<integer>",
// Request timeout
//
// Request timeout when interacting with homebridge instances
//
// Default: 10
"request_timeout": "<integer>",
// Service discovery timeout
//
// Discovery timeout after which the current discovery is considered failed
//
// Default: 20
"discovery_timeout": "<integer>",
// TLS cert file
//
// Path to TLS certificate file (in PEM format) // Path to TLS certificate file (in PEM format)
"tls_cert_file": "<string>", "tls_cert_file": string,
// TLS key file
//
// Path to TLS key file // Path to TLS key file
"tls_key_file": "<string>", "tls_key_file": string,
// Usernames and passwords for basic auth. Key is the username, value is the password.
// Basic auth username/password pairs // Password must be encoded with bcrypt
// "basic_auth": {
// Usernames and passwords for basic auth. Object key is the username, object "username": "<password encoded with bcrypt>"
// value is the password. Password must be encoded with bcrypt. Example: }
// {"joanna": "$2a$12$5/mmmRB28wg9yzaXhee5Iupq3UrFr/qMgAe9LvAxGoY5jLcfVGTUq"} },
"basic_auth": "<object>" // …
}
] ]
} }
``` ```
<!-- AUTOGENERATED CONFIG DOCS END -->

View file

@ -1,17 +1,17 @@
#!/usr/bin/env node #!/usr/bin/env node
const { parseSchema } = require('json-schema-to-zod') const { parseSchema } = require('json-schema-to-zod-with-defaults')
const { schema } = require('../config.schema.json') const { schema } = require('../config.schema.json')
const { format } = require('prettier') const { format } = require('prettier')
const { join, basename } = require('path') const { join, basename } = require('path')
const prettierConfig = require('../prettier.config') const prettierConfig = require('../prettier.config')
const { writeFileSync, readFileSync } = require('fs') const { writeFileSync } = require('fs')
const file = join(__dirname, '../src/generated/config_boundary.ts') const file = join(__dirname, '../src/generated/config_boundary.ts')
console.log(`Starting code generation for ${file}`) console.log(`Starting code generation for ${file}`)
const zodSchema = parseSchema(schema, false) const zodSchema = parseSchema(schema, true)
const code = format( const code = format(
` `
@ -26,77 +26,4 @@ export const ConfigBoundary = ${zodSchema}
writeFileSync(file, code) writeFileSync(file, code)
const note = 'AUTOGENERATED CONFIG DOCS'
const comment = (...strings) => `<!-- ${strings.join(' ')} -->`
const readmePath = join(__dirname, '../README.md')
const readme = readFileSync(readmePath).toString()
const regex = new RegExp(`${comment(note, 'BEGIN')}.*${comment(note, 'END')}`, 'mgs')
if (!readme.match(regex)) {
console.log('Could not update README.md')
process.exit(1)
}
writeFileSync(
readmePath,
readme.replace(
regex,
`${comment(note, 'BEGIN')}\n\`\`\`json5\n${generateDocs(schema)}\n\`\`\`\n${comment(note, 'END')}`,
),
)
function generateDocs(schema) {
const doc = indent(
Object.entries(schema.properties)
.map(([property, definition]) => {
const lines = []
if (definition.title) {
lines.push(`// ${definition.title}`)
}
if (definition.description) {
lines.push(`//\n// ${wordwrap(definition.description, 80, '\n// ')}`)
}
if (definition.default !== undefined) {
lines.push(`//\n// Default: ${JSON.stringify(definition.default)}`)
}
lines.push(`${JSON.stringify(property)}: ${JSON.stringify('<' + definition.type + '>')}`)
return lines.join('\n')
})
.join(',\n\n\n'),
6,
)
return `{
// ...
"platforms": [
{
"platform": "PrometheusExporter",
${doc}
}
]
}`
}
function wordwrap(word, length, wrap = '\n') {
const wrapped = []
while (word.length > length) {
const cut = word.substring(0, length)
const pos = cut.lastIndexOf(' ')
wrapped.push(cut.substring(0, pos))
word = word.substring(pos + 1)
}
wrapped.push(word)
return wrapped.join(wrap)
}
function indent(string, indent) {
return string.replace(/^(.+)$/gm, `${' '.repeat(indent)}$1`)
}
console.log(`Finished code generation for ${file}`) console.log(`Finished code generation for ${file}`)

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 = ${JSON.stringify(uuidToServiceMap)} as const export const Uuids: Record<string,string> = ${JSON.stringify(uuidToServiceMap)} as const
export const Services = ${JSON.stringify(serviceToUuidMap)} as const export const Services: Record<string,string> = ${JSON.stringify(serviceToUuidMap)} as const
`, `,
{ filepath: 'codegen.ts', ...prettierConfig }, { filepath: 'codegen.ts', ...prettierConfig },
) )

View file

@ -26,18 +26,12 @@
"default": "homebridge" "default": "homebridge"
}, },
"port": { "port": {
"title": "Metrics server port", "title": "Probe server port",
"description": "TCP port where the Prometheus metrics server listens", "description": "TCP port for the prometheus probe server to listen to",
"type": "integer", "type": "integer",
"required": false, "required": false,
"default": 36123 "default": 36123
}, },
"interface": {
"title": "Metrics server interface",
"description": "Interface where the Prometheus metrics server listens. Can be an IP, a hostname, \"0.0.0.0\" for all IPv4 interfaces, \"::1\" for all IPv6 interfaces. Default is \"::\" which means \"any interface\"",
"type": "string",
"default": "::"
},
"refresh_interval": { "refresh_interval": {
"title": "Service refresh interval", "title": "Service refresh interval",
"description": "Discover new services every <interval> seconds", "description": "Discover new services every <interval> seconds",
@ -60,20 +54,17 @@
"default": 20 "default": 20
}, },
"tls_cert_file": { "tls_cert_file": {
"title": "TLS cert file",
"description": "Path to TLS certificate file (in PEM format)", "description": "Path to TLS certificate file (in PEM format)",
"type": "string", "type": "string",
"required": false "required": false
}, },
"tls_key_file": { "tls_key_file": {
"title": "TLS key file",
"description": "Path to TLS key file", "description": "Path to TLS key file",
"type": "string", "type": "string",
"required": false "required": false
}, },
"basic_auth": { "basic_auth": {
"title": "Basic auth username/password pairs", "description": "Usernames and passwords for basic auth. Key is the username, value is the password. Password must be encoded with bcrypt",
"description": "Usernames and passwords for basic auth. Object key is the username, object value is the password. Password must be encoded with bcrypt. Example: {\"joanna\": \"$2a$12$5/mmmRB28wg9yzaXhee5Iupq3UrFr/qMgAe9LvAxGoY5jLcfVGTUq\"}",
"type": "object", "type": "object",
"additionalProperties": { "type": "string" }, "additionalProperties": { "type": "string" },
"required": false "required": false

View file

@ -1,5 +1,5 @@
{ {
description = "homebridge-prometheus-exporter"; description = "Ansible environment";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@ -16,6 +16,6 @@
in with pkgs; rec { in with pkgs; rec {
devShell = pkgs.mkShell rec { buildInputs = with pkgs; [ nodejs ]; }; devShell = pkgs.mkShell rec { buildInputs = with pkgs; [ nodejs jq ]; };
}); });
} }

View file

@ -3,5 +3,4 @@ module.exports = {
preset: 'ts-jest', preset: 'ts-jest',
testEnvironment: 'node', testEnvironment: 'node',
verbose: true, verbose: true,
testPathIgnorePatterns: ['dist'],
} }

9976
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "homebridge-prometheus-exporter", "name": "homebridge-prometheus-exporter",
"version": "1.0.5", "version": "0.0.11",
"description": "Prometheus exporter for homebridge accessories.", "description": "Prometheus exporter for homebridge accessories.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@ -16,12 +16,11 @@
}, },
"main": "dist/src/index.js", "main": "dist/src/index.js",
"scripts": { "scripts": {
"_portable_exec": "npmPortableExec() { `npm root`/.bin/$@; }; npmPortableExec", "lint": "ifNotCi() { test \"$CI\" && echo \"$2\" || echo \"$1\"; }; `npm bin`/tsc --noEmit && `npm bin`/prettier --ignore-path=.gitignore `ifNotCi --write \"--check --cache --cache-strategy content\"` '**/**.{ts,js,json}' && `npm bin`/eslint `ifNotCi --fix \"--cache --cache-strategy content\"` --ignore-path=.gitignore '**/**.{ts,js,json}'",
"lint": "ifNotCi() { test \"$CI\" && echo \"$2\" || echo \"$1\"; }; npm run _portable_exec -- tsc --noEmit && npm run _portable_exec -- prettier --ignore-path=.gitignore `ifNotCi --write \"--check --cache --cache-strategy content\"` '**/**.{ts,js,json}' && npm run _portable_exec -- eslint `ifNotCi --fix \"--cache --cache-strategy content\"` --ignore-path=.gitignore '**/**.{ts,js,json}'",
"start": "npm run build && npm run link && nodemon", "start": "npm run build && npm run link && nodemon",
"test": "ifNotCi() { test \"$CI\" && echo \"$2\" || echo \"$1\"; }; npm run code-generation && npm run _portable_exec -- jest `ifNotCi --watchAll --collect-coverage`", "test": "ifNotCi() { test \"$CI\" && echo \"$2\" || echo \"$1\"; }; npm run code-generation && `npm bin`/jest `ifNotCi --watchAll`",
"link": "npm install --no-save file:///$PWD/", "link": "npm install --no-save file:///$PWD/",
"build": "rimraf ./dist .tsbuildinfo && npm run code-generation && tsc", "build": "rimraf ./dist && npm run code-generation && tsc",
"code-generation": "./code-generation/hap-gen.js && ./code-generation/config-scheme-gen.js", "code-generation": "./code-generation/hap-gen.js && ./code-generation/config-scheme-gen.js",
"prepublishOnly": "npm run code-generation && npm run lint && npm run build", "prepublishOnly": "npm run code-generation && npm run lint && npm run build",
"release": "release-it --only-version" "release": "release-it --only-version"
@ -35,36 +34,37 @@
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.3.0", "@jest/globals": "^29.3.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/node": "^20.2.3", "@types/node": "^18.11.9",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.42.0", "@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0", "@typescript-eslint/parser": "^5.42.0",
"array.prototype.flatmap": "^1.3.1",
"eslint": "^8.0.1", "eslint": "^8.0.1",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"hap-nodejs": "^0.11.0", "hap-nodejs": "^0.10.4",
"homebridge": "^1.3.5", "homebridge": "^1.3.5",
"homebridge-cmdswitch2": "^0.2.10", "homebridge-cmdswitch2": "^0.2.10",
"jest": "^29.3.0", "jest": "^29.3.0",
"json-schema-to-zod": "^0.6.0", "json-schema-to-zod-with-defaults": "^0.2.1",
"nodemon": "^3.0.1", "nodemon": "^2.0.13",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"release-it": "^16.0.0", "release-it": "^15.5.0",
"rimraf": "^5.0.0", "rimraf": "^3.0.2",
"supertest": "^6.3.1", "supertest": "^6.3.1",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"ts-node": "^10.3.0", "ts-node": "^10.3.0",
"typescript": "^5.0.2" "typescript": "^4.4.4"
}, },
"dependencies": { "dependencies": {
"@fastify/auth": "^4.1.0", "@fastify/auth": "^4.1.0",
"@fastify/basic-auth": "^5.0.0", "@fastify/basic-auth": "^4.0.0",
"array.prototype.group": "^1.1.2",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"fastify": "^4.9.2", "fastify": "^4.9.2",
"hap-node-client": "^0.2.1", "hap-node-client": "^0.1.25",
"zod": "^3.19.1" "zod": "^3.19.1"
},
"overrides": {
"jest-mock": "29.2"
} }
} }

View file

@ -1,7 +0,0 @@
sonar.projectKey=lstrojny_homebridge-prometheus-exporter
sonar.organization=lstrojny
sonar.sources=.
sonar.exclusions=tests/**
sonar.tests=tests
sonar.javascript.lcov.reportPaths=./coverage/lcov.info
sonar.coverage.exclusions=code-generation/**,*.config.js,src/generated/**

View file

@ -3,7 +3,7 @@ import type { Logger } from 'homebridge'
export interface HapDiscoveryConfig { export interface HapDiscoveryConfig {
config: Pick<Config, 'debug' | 'pin' | 'refresh_interval' | 'discovery_timeout' | 'request_timeout'> config: Pick<Config, 'debug' | 'pin' | 'refresh_interval' | 'discovery_timeout' | 'request_timeout'>
log?: Logger log: Logger
} }
export type HapDiscover = (config: HapDiscoveryConfig) => Promise<Device[]> export type HapDiscover = (config: HapDiscoveryConfig) => Promise<Device[]>

View file

@ -12,43 +12,34 @@ type RejectFunc = (error: unknown) => void
const clientMap: Record<string, HAPNodeJSClient> = {} const clientMap: Record<string, HAPNodeJSClient> = {}
const promiseMap: Record<string, [ResolveFunc, RejectFunc]> = {} const promiseMap: Record<string, [ResolveFunc, RejectFunc]> = {}
function startDiscovery( function startDiscovery(logger: Logger, config: HAPNodeJSClientConfig, resolve: ResolveFunc, reject: RejectFunc) {
logger: Logger | undefined,
config: HAPNodeJSClientConfig,
resolve: ResolveFunc,
reject: RejectFunc,
) {
const key = JSON.stringify(config) const key = JSON.stringify(config)
promiseMap[key] = [resolve, reject]
if (!clientMap[key]) { if (!clientMap[key]) {
logger?.debug('Creating new HAP client') logger.debug('Creating new HAP client')
clientMap[key] = new HAPNodeJSClient(config) const client = new HAPNodeJSClient(config)
clientMap[key].on('Ready', createDiscoveryHandler(logger, key)) client.on('Ready', (deviceData: unknown) => {
} else { try {
logger?.debug('Reusing existing HAP client') const devices: Device[] = []
}
}
function createDiscoveryHandler(logger: Logger | undefined, key: string): (deviceData: unknown) => void { for (const device of checkBoundary(MaybeDevices, deviceData)) {
return (deviceData: unknown) => { try {
try { devices.push(checkBoundary(DeviceBoundary, device))
const devices: Device[] = [] } catch (e) {
logger.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4))
for (const device of checkBoundary(MaybeDevices, deviceData)) { }
try {
devices.push(checkBoundary(DeviceBoundary, device))
} catch (e) {
logger?.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4))
} }
}
if (promiseMap[key]) promiseMap[key][0](devices) if (promiseMap[key]) promiseMap[key][0](devices)
} catch (e) { } catch (e) {
if (promiseMap[key]) promiseMap[key][1](e) if (promiseMap[key]) promiseMap[key][1](e)
} }
})
clientMap[key] = client
} else {
logger.debug('Reusing existing HAP client')
} }
promiseMap[key] = [resolve, reject]
} }
export const hapNodeJsClientDiscover: HapDiscover = ({ config, log }) => { export const hapNodeJsClientDiscover: HapDiscover = ({ config, log }) => {

View file

@ -15,18 +15,15 @@ export interface HttpServerController {
export type HttpAdapter = (config: HttpServer) => Promise<HttpServerController> export type HttpAdapter = (config: HttpServer) => Promise<HttpServerController>
export type HttpConfig = Pick< export type HttpConfig = Pick<Config, 'debug' | 'port' | 'prefix' | 'basic_auth' | 'tls_cert_file' | 'tls_key_file'>
Config,
'debug' | 'port' | 'interface' | 'prefix' | 'basic_auth' | 'tls_cert_file' | 'tls_key_file'
>
export interface HttpServer { export interface HttpServer {
log: Logger | null log?: Logger
config: HttpConfig config: HttpConfig
serverFactory?: (requestListener: RequestListener) => Server serverFactory?: (requestListener: RequestListener) => Server
onRequest(): HttpResponse | null onRequest(): HttpResponse | undefined
onMetrics(): HttpResponse onMetrics(): HttpResponse
onNotFound(): HttpResponse onNotFound(): HttpResponse
onError(error: Error): HttpResponse onError(error: Error): HttpResponse
onMetricsDiscovery(metrics: Metric[]): void updateMetrics(metrics: Metric[]): void
} }

View file

@ -1,6 +1,5 @@
import Fastify, { type FastifyReply, type FastifyRequest, type HookHandlerDoneFunction } from 'fastify' import Fastify, { type FastifyReply, type FastifyRequest, type HookHandlerDoneFunction } from 'fastify'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { constants as HttpConstants } from 'http2'
import { isAuthenticated } from '../../security' import { isAuthenticated } from '../../security'
import type { HttpAdapter, HttpResponse, HttpServer } from './api' import type { HttpAdapter, HttpResponse, HttpServer } from './api'
import fastifyAuth from '@fastify/auth' import fastifyAuth from '@fastify/auth'
@ -69,8 +68,8 @@ export const fastifyServe: HttpAdapter = async (server: HttpServer) => {
} }
fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply) => { fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply) => {
if (reply.statusCode >= HttpConstants.HTTP_STATUS_BAD_REQUEST) { if (reply.statusCode >= 400) {
server.log?.error(formatCombinedLog(request, reply)) server.log?.warn(formatCombinedLog(request, reply))
} else if (server.config.debug) { } else if (server.config.debug) {
server.log?.debug(formatCombinedLog(request, reply)) server.log?.debug(formatCombinedLog(request, reply))
} }
@ -98,7 +97,7 @@ export const fastifyServe: HttpAdapter = async (server: HttpServer) => {
adaptResponseToReply(server.onMetrics(), reply) adaptResponseToReply(server.onMetrics(), reply)
}) })
await listen(fastify, server.config.port, server.config.interface) await listen(fastify, server.config.port, '::')
return { return {
shutdown() { shutdown() {

9
src/ambient.d.ts vendored
View file

@ -13,12 +13,3 @@ declare module 'hap-node-client' {
on(event: 'Ready', callback: (v: unknown) => void): void on(event: 'Ready', callback: (v: unknown) => void): void
} }
} }
declare module 'array.prototype.group' {
function shim(): void
}
interface Array<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
group<U>(fn: (value: T, index: number, array: T[]) => U, thisArg?: any): { U: T[] }
}

View file

@ -6,13 +6,7 @@ export const ConfigBoundary = z.object({
pin: z.string().regex(new RegExp('^\\d{3}-\\d{2}-\\d{3}$')).describe('Homebridge PIN for service authentication'), pin: z.string().regex(new RegExp('^\\d{3}-\\d{2}-\\d{3}$')).describe('Homebridge PIN for service authentication'),
debug: z.boolean().default(false), debug: z.boolean().default(false),
prefix: z.string().default('homebridge'), prefix: z.string().default('homebridge'),
port: z.number().int().describe('TCP port where the Prometheus metrics server listens').default(36123), port: z.number().int().describe('TCP port for the prometheus probe server to listen to').default(36123),
interface: z
.string()
.describe(
'Interface where the Prometheus metrics server listens. Can be an IP, a hostname, "0.0.0.0" for all IPv4 interfaces, "::1" for all IPv6 interfaces. Default is "::" which means "any interface"',
)
.default('::'),
refresh_interval: z.number().int().describe('Discover new services every <interval> seconds').default(60), refresh_interval: z.number().int().describe('Discover new services every <interval> seconds').default(60),
request_timeout: z request_timeout: z
.number() .number()
@ -29,7 +23,7 @@ export const ConfigBoundary = z.object({
basic_auth: z basic_auth: z
.record(z.string()) .record(z.string())
.describe( .describe(
'Usernames and passwords for basic auth. Object key is the username, object value is the password. Password must be encoded with bcrypt. Example: {"joanna": "$2a$12$5/mmmRB28wg9yzaXhee5Iupq3UrFr/qMgAe9LvAxGoY5jLcfVGTUq"}', 'Usernames and passwords for basic auth. Key is the username, value is the password. Password must be encoded with bcrypt',
) )
.optional(), .optional(),
}) })

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 = { export const Uuids: Record<string, string> = {
'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',
@ -29,7 +29,6 @@ export const Uuids = {
'000000B7-0000-1000-8000-0026BB765291': 'Fanv2', '000000B7-0000-1000-8000-0026BB765291': 'Fanv2',
'000000D7-0000-1000-8000-0026BB765291': 'Faucet', '000000D7-0000-1000-8000-0026BB765291': 'Faucet',
'000000BA-0000-1000-8000-0026BB765291': 'FilterMaintenance', '000000BA-0000-1000-8000-0026BB765291': 'FilterMaintenance',
'00000236-0000-1000-8000-0026BB765291': 'FirmwareUpdate',
'00000041-0000-1000-8000-0026BB765291': 'GarageDoorOpener', '00000041-0000-1000-8000-0026BB765291': 'GarageDoorOpener',
'000000BC-0000-1000-8000-0026BB765291': 'HeaterCooler', '000000BC-0000-1000-8000-0026BB765291': 'HeaterCooler',
'000000BD-0000-1000-8000-0026BB765291': 'HumidifierDehumidifier', '000000BD-0000-1000-8000-0026BB765291': 'HumidifierDehumidifier',
@ -60,7 +59,6 @@ export const Uuids = {
'00000088-0000-1000-8000-0026BB765291': 'StatefulProgrammableSwitch', '00000088-0000-1000-8000-0026BB765291': 'StatefulProgrammableSwitch',
'00000089-0000-1000-8000-0026BB765291': 'StatelessProgrammableSwitch', '00000089-0000-1000-8000-0026BB765291': 'StatelessProgrammableSwitch',
'00000049-0000-1000-8000-0026BB765291': 'Switch', '00000049-0000-1000-8000-0026BB765291': 'Switch',
'0000022E-0000-1000-8000-0026BB765291': 'TapManagement',
'00000125-0000-1000-8000-0026BB765291': 'TargetControl', '00000125-0000-1000-8000-0026BB765291': 'TargetControl',
'00000122-0000-1000-8000-0026BB765291': 'TargetControlManagement', '00000122-0000-1000-8000-0026BB765291': 'TargetControlManagement',
'000000D8-0000-1000-8000-0026BB765291': 'Television', '000000D8-0000-1000-8000-0026BB765291': 'Television',
@ -78,7 +76,7 @@ export const Uuids = {
'0000008C-0000-1000-8000-0026BB765291': 'WindowCovering', '0000008C-0000-1000-8000-0026BB765291': 'WindowCovering',
} as const } as const
export const Services = { export const Services: Record<string, string> = {
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',
@ -111,7 +109,6 @@ export const Services = {
Fanv2: '000000B7-0000-1000-8000-0026BB765291', Fanv2: '000000B7-0000-1000-8000-0026BB765291',
Faucet: '000000D7-0000-1000-8000-0026BB765291', Faucet: '000000D7-0000-1000-8000-0026BB765291',
FilterMaintenance: '000000BA-0000-1000-8000-0026BB765291', FilterMaintenance: '000000BA-0000-1000-8000-0026BB765291',
FirmwareUpdate: '00000236-0000-1000-8000-0026BB765291',
GarageDoorOpener: '00000041-0000-1000-8000-0026BB765291', GarageDoorOpener: '00000041-0000-1000-8000-0026BB765291',
HeaterCooler: '000000BC-0000-1000-8000-0026BB765291', HeaterCooler: '000000BC-0000-1000-8000-0026BB765291',
HumidifierDehumidifier: '000000BD-0000-1000-8000-0026BB765291', HumidifierDehumidifier: '000000BD-0000-1000-8000-0026BB765291',
@ -143,7 +140,6 @@ export const Services = {
StatefulProgrammableSwitch: '00000088-0000-1000-8000-0026BB765291', StatefulProgrammableSwitch: '00000088-0000-1000-8000-0026BB765291',
StatelessProgrammableSwitch: '00000089-0000-1000-8000-0026BB765291', StatelessProgrammableSwitch: '00000089-0000-1000-8000-0026BB765291',
Switch: '00000049-0000-1000-8000-0026BB765291', Switch: '00000049-0000-1000-8000-0026BB765291',
TapManagement: '0000022E-0000-1000-8000-0026BB765291',
TargetControl: '00000125-0000-1000-8000-0026BB765291', TargetControl: '00000125-0000-1000-8000-0026BB765291',
TargetControlManagement: '00000122-0000-1000-8000-0026BB765291', TargetControlManagement: '00000122-0000-1000-8000-0026BB765291',
Television: '000000D8-0000-1000-8000-0026BB765291', Television: '000000D8-0000-1000-8000-0026BB765291',

View file

@ -1,15 +1,13 @@
import type { Accessory, Device, Service } from './boundaries' import type { Accessory, Device, Service } from './boundaries'
import { Services, Uuids } from './generated/services' import { Uuids } from './generated/services'
import { assertTypeExhausted, isKeyOfConstObject, isType, strCamelCaseToSnakeCase } from './std' import { assertTypeExhausted, isType } 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: Labels = {}, public readonly labels: Record<string, string> = {},
) {} ) {}
} }
@ -19,7 +17,7 @@ export class Metric {
const METRICS_FILTER = ['Identifier'] const METRICS_FILTER = ['Identifier']
export function aggregate(devices: Device[], timestamp: Date): Metric[] { export function aggregate(devices: Device[], timestamp: Date): Metric[] {
const metrics: Metric[][] = [] const metrics: Metric[] = []
for (const device of devices) { for (const device of devices) {
for (const accessory of device.accessories.accessories) { for (const accessory of device.accessories.accessories) {
@ -29,87 +27,84 @@ export function aggregate(devices: Device[], timestamp: Date): Metric[] {
...getAccessoryLabels(accessory), ...getAccessoryLabels(accessory),
...getServiceLabels(service), ...getServiceLabels(service),
} }
metrics.push(extractMetrics(service, timestamp, labels)) for (const characteristic of service.characteristics) {
} const format = characteristic.format
} switch (format) {
} case 'string':
case 'tlv8':
case 'data':
break
return metrics.flat() case 'bool':
} case 'float':
case 'int':
case 'uint8':
case 'uint16':
case 'uint32':
case 'uint64':
if (characteristic.value != null) {
if (METRICS_FILTER.includes(characteristic.description)) {
break
}
const name = formatName(
Uuids[service.type] || 'custom',
characteristic.description,
characteristic.unit,
)
metrics.push(new Metric(name, characteristic.value, timestamp, labels))
}
break
function extractMetrics(service: Service, timestamp: Date, labels: Labels): Metric[] { default:
const metrics: Metric[] = [] assertTypeExhausted(format)
}
for (const characteristic of service.characteristics) {
if (METRICS_FILTER.includes(characteristic.description)) {
continue
}
if (characteristic.value == null) {
continue
}
const format = characteristic.format
switch (format) {
case 'string':
case 'tlv8':
case 'data':
break
case 'bool':
case 'float':
case 'int':
case 'uint8':
case 'uint16':
case 'uint32':
case 'uint64':
{
const name = formatName(
isKeyOfConstObject(service.type, Uuids) ? Uuids[service.type] : 'custom',
characteristic.description,
characteristic.unit,
)
metrics.push(new Metric(name, characteristic.value, timestamp, labels))
} }
break }
default:
assertTypeExhausted(format)
} }
} }
return metrics return metrics
} }
export function formatName(serviceName: string, description: string, unit: string | null = null): string { export function formatName(serviceName: string, description: string, unit: string | undefined = undefined): 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((val) => strCamelCaseToSnakeCase(val)) .map((v) => camelCaseToSnakeCase(v))
// Remove duplicate prefix // Remove duplicate prefix
.reduce((carry, val) => (val.startsWith(carry) ? val : `${carry}_${val}`)) .reduce((carry, value) => (value.startsWith(carry) ? value : carry + '_' + value))
) )
} }
function getDeviceLabels(device: Device): Labels { 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> {
return { return {
bridge: device.instance.name, bridge: device.instance.name,
device_id: device.instance.deviceID, device_id: device.instance.deviceID,
} }
} }
function getAccessoryLabels(accessory: Accessory): Labels { function getAccessoryLabels(accessory: Accessory): Record<string, string> {
const labels: Record<string, string> = {}
for (const service of accessory.services) { for (const service of accessory.services) {
if (service.type === Services.AccessoryInformation) { if (service.type === '0000003E-0000-1000-8000-0026BB765291') {
return getServiceLabels(service) return getServiceLabels(service)
} }
} }
return {} return labels
} }
function getServiceLabels(service: Service): Labels { function getServiceLabels(service: Service): Record<string, string> {
const labels: Labels = {} const labels: Record<string, string> = {}
for (const characteristic of service.characteristics) { for (const characteristic of service.characteristics) {
if ( if (
@ -126,7 +121,7 @@ function getServiceLabels(service: Service): Labels {
'Hardware Revision', 'Hardware Revision',
].includes(characteristic.description) ].includes(characteristic.description)
) { ) {
labels[strCamelCaseToSnakeCase(characteristic.description)] = characteristic.value labels[camelCaseToSnakeCase(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 | null = null private httpServerController: HttpServerController | undefined = undefined
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) {
@ -48,7 +48,7 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
discover({ log: this.log, config: this.config }) discover({ log: this.log, config: this.config })
.then((devices) => { .then((devices) => {
const metrics = aggregate(devices, new Date()) const metrics = aggregate(devices, new Date())
this.httpServer.onMetricsDiscovery(metrics) this.httpServer.updateMetrics(metrics)
this.log.debug('HAP discovery completed, %d metrics discovered', metrics.length) this.log.debug('HAP discovery completed, %d metrics discovered', metrics.length)
this.startHapDiscovery() this.startHapDiscovery()
}) })

View file

@ -1,39 +1,23 @@
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'
import { shim } from 'array.prototype.group'
shim()
export class MetricsRenderer { export class MetricsRenderer {
private readonly prefix: string constructor(private readonly prefix: string) {}
constructor(prefix: string) { render(metric: Metric): string {
this.prefix = strTrimRight(prefix, '_') const name = this.metricName(metric.name)
return [
`# TYPE ${name} ${name.endsWith('_total') ? 'counter' : 'gauge'}`,
`${name}${this.renderLabels(metric.labels)} ${metric.value}${
metric.timestamp !== null ? ' ' + String(metric.timestamp.getTime()) : ''
}`,
].join('\n')
} }
render(metrics: Metric[]): string { private renderLabels(labels: Metric['labels']): string {
return (
Object.entries(metrics.sort().group((metric) => this.metricName(metric.name)))
.map(([name, metrics]) => {
return [
`# TYPE ${name} ${name.endsWith('_total') ? 'counter' : 'gauge'}`,
metrics.map((metric) => this.formatMetric(metric)).join('\n'),
].join('\n')
})
.join('\n\n') + '\n'
)
}
private formatMetric(metric: Metric): string {
return `${this.metricName(metric.name)}${MetricsRenderer.renderLabels(metric.labels)} ${metric.value}${
metric.timestamp !== null ? ' ' + String(metric.timestamp.getTime()) : ''
}`
}
private static renderLabels(labels: Metric['labels']): string {
const rendered = Object.entries(labels) const rendered = Object.entries(labels)
.map(([label, val]) => `${sanitizePrometheusMetricName(label)}="${escapeAttributeValue(val)}"`) .map(([key, value]) => `${sanitizePrometheusMetricName(key)}="${escapeAttributeValue(value)}"`)
.join(',') .join(',')
return rendered !== '' ? '{' + rendered + '}' : '' return rendered !== '' ? '{' + rendered + '}' : ''
@ -42,7 +26,7 @@ export class MetricsRenderer {
private metricName(name: string): string { private metricName(name: string): string {
name = name.replace(/^(.*_)?(total)_(.*)$/, '$1$3_$2') name = name.replace(/^(.*_)?(total)_(.*)$/, '$1$3_$2')
return sanitizePrometheusMetricName(`${this.prefix}_${name}`) return sanitizePrometheusMetricName(this.prefix.replace(/_+$/, '') + '_' + name)
} }
} }
@ -51,44 +35,41 @@ const textContentType = 'text/plain; charset=utf-8'
const prometheusSpecVersion = '0.0.4' const prometheusSpecVersion = '0.0.4'
const metricsContentType = `${textContentType}; version=${prometheusSpecVersion}` const metricsContentType = `${textContentType}; version=${prometheusSpecVersion}`
function withHeaders(contentType: string, headers: Record<string, string> = {}): Record<string, string> { function headers(contentType: string, headers: Record<string, string> = {}): Record<string, string> {
return { ...headers, 'Content-Type': contentType } return { ...headers, 'Content-Type': contentType }
} }
export class PrometheusServer implements HttpServer { export class PrometheusServer implements HttpServer {
private metricsDiscovered = false private metricsInitialized = false
private metricsResponse = '' private metrics: Metric[] = []
constructor( constructor(public readonly config: HttpConfig, public readonly log: Logger | undefined = undefined) {}
public readonly config: HttpConfig,
public readonly log: Logger | null = null,
private readonly renderer: MetricsRenderer = new MetricsRenderer(config.prefix),
) {}
onRequest(): HttpResponse | null { onRequest(): HttpResponse | undefined {
if (this.metricsDiscovered) { if (!this.metricsInitialized) {
return null return {
} statusCode: 503,
headers: headers(textContentType, { 'Retry-After': String(retryAfterWhileDiscovery) }),
return { body: 'Metrics discovery pending',
statusCode: 503, }
headers: withHeaders(textContentType, { 'Retry-After': String(retryAfterWhileDiscovery) }),
body: 'Metrics discovery pending',
} }
} }
onMetrics(): HttpResponse { onMetrics(): HttpResponse {
const renderer = new MetricsRenderer(this.config.prefix)
const metrics = this.metrics.map((metric) => renderer.render(metric)).join('\n')
return { return {
statusCode: 200, statusCode: 200,
headers: withHeaders(metricsContentType), headers: headers(metricsContentType),
body: this.metricsResponse, body: metrics,
} }
} }
onNotFound(): HttpResponse { onNotFound(): HttpResponse {
return { return {
statusCode: 404, statusCode: 404,
headers: withHeaders(textContentType), headers: headers(textContentType),
body: 'Not found. Try /metrics', body: 'Not found. Try /metrics',
} }
} }
@ -96,14 +77,14 @@ export class PrometheusServer implements HttpServer {
onError(error: Error): HttpResponse { onError(error: Error): HttpResponse {
this.log?.error('HTTP request error: %o', error) this.log?.error('HTTP request error: %o', error)
return { return {
headers: withHeaders(textContentType), headers: headers(textContentType),
body: error.message, body: error.message,
} }
} }
onMetricsDiscovery(metrics: Metric[]): void { updateMetrics(metrics: Metric[]): void {
this.metricsResponse = this.renderer.render(metrics) this.metrics = metrics
this.metricsDiscovered = true this.metricsInitialized = true
} }
} }
@ -119,7 +100,7 @@ function escapeString(str: string) {
* *
* `undefined` is converted to an empty string. * `undefined` is converted to an empty string.
*/ */
function escapeAttributeValue(str: Metric['labels'][keyof Metric['labels']]) { function escapeAttributeValue(str: string) {
if (typeof str !== 'string') { if (typeof str !== 'string') {
str = JSON.stringify(str) str = JSON.stringify(str)
} }

View file

@ -1,41 +1,15 @@
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
} }
// Type predicate higher order function for use with e.g. filter or map export function isType<T extends Types>(type: T): (v: unknown) => v is TypeMap[T] {
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}]+`), ''))
}

View file

@ -1,74 +0,0 @@
import { afterAll, describe, expect, jest, test } from '@jest/globals'
import { hapNodeJsClientDiscover as discover } from '../../../src/adapters/discovery/hap_node_js_client'
const intervals: NodeJS.Timer[] = []
let deviceData: unknown = null
jest.mock('hap-node-client', () => ({
HAPNodeJSClient: class {
on(event: string, fn: (data: unknown) => void) {
intervals.push(setInterval(() => fn(deviceData), 100))
}
},
}))
const properDeviceData = {
instance: {
deviceID: 'bff926c2-ddbe-4141-b17f-f011e03e669c',
name: 'name',
url: 'http://bridge.local',
},
accessories: {
accessories: [
{
services: [
{
type: 'SERVICE TYPE',
characteristics: [
{
format: 'bool',
value: 1,
description: 'description',
type: 'CHARACTERISTIC TYPE',
},
],
},
],
},
],
},
}
const invalidDeviceData = {}
const config = {
debug: false,
pin: '123-12-123',
refresh_interval: 10,
discovery_timeout: 10,
request_timeout: 10,
}
describe('HAP NodeJS Client', () => {
afterAll(() => {
intervals.map((timer) => clearInterval(timer))
})
test('Simple discovery', async () => {
deviceData = [properDeviceData]
expect(await discover({ config })).toHaveLength(1)
})
test('Connection pooling works', async () => {
deviceData = [properDeviceData]
expect(await discover({ config })).toHaveLength(1)
expect(await discover({ config })).toHaveLength(1)
})
test('Invalid device data is ignored', async () => {
deviceData = [invalidDeviceData, properDeviceData]
expect(await discover({ config })).toHaveLength(1)
})
})

View file

@ -17,7 +17,6 @@ function createTestServerWithBasicAuth(basicAuth: Record<string, string>): { htt
const http = createServer() const http = createServer()
const prometheus = new TestablePrometheusServer({ const prometheus = new TestablePrometheusServer({
port: 0, port: 0,
interface: 'localhost',
debug: false, debug: false,
prefix: 'homebridge', prefix: 'homebridge',
basic_auth: basicAuth, basic_auth: basicAuth,
@ -46,7 +45,7 @@ describe('Fastify HTTP adapter', () => {
test('Serves 404 on / when metrics are available', () => { test('Serves 404 on / when metrics are available', () => {
const testServer = createTestServer() const testServer = createTestServer()
testServer.prometheus.onMetricsDiscovery([]) testServer.prometheus.updateMetrics([])
return request(testServer.http) return request(testServer.http)
.get('/') .get('/')
@ -58,7 +57,7 @@ describe('Fastify HTTP adapter', () => {
test('Serves metrics', () => { test('Serves metrics', () => {
const testServer = createTestServer() const testServer = createTestServer()
const timestamp = new Date('2020-01-01 00:00:00 UTC') const timestamp = new Date('2020-01-01 00:00:00 UTC')
testServer.prometheus.onMetricsDiscovery([ testServer.prometheus.updateMetrics([
new Metric('metric', 0.1, timestamp, { name: 'metric' }), new Metric('metric', 0.1, timestamp, { name: 'metric' }),
new Metric('total_something', 100, timestamp, { name: 'counter' }), new Metric('total_something', 100, timestamp, { name: 'counter' }),
]) ])
@ -71,17 +70,15 @@ describe('Fastify HTTP adapter', () => {
[ [
'# TYPE homebridge_metric gauge', '# TYPE homebridge_metric gauge',
'homebridge_metric{name="metric"} 0.1 1577836800000', 'homebridge_metric{name="metric"} 0.1 1577836800000',
'',
'# TYPE homebridge_something_total counter', '# TYPE homebridge_something_total counter',
'homebridge_something_total{name="counter"} 100 1577836800000', 'homebridge_something_total{name="counter"} 100 1577836800000',
'',
].join('\n'), ].join('\n'),
) )
}) })
test('Basic auth denied without user', () => { test('Basic auth denied without user', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt }) const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
testServer.prometheus.onMetricsDiscovery([]) testServer.prometheus.updateMetrics([])
return request(testServer.http) return request(testServer.http)
.get('/metrics') .get('/metrics')
@ -92,7 +89,7 @@ describe('Fastify HTTP adapter', () => {
test('Basic auth denied with incorrect user', () => { test('Basic auth denied with incorrect user', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt }) const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
testServer.prometheus.onMetricsDiscovery([]) testServer.prometheus.updateMetrics([])
return request(testServer.http) return request(testServer.http)
.get('/metrics') .get('/metrics')
@ -105,7 +102,7 @@ describe('Fastify HTTP adapter', () => {
test('Basic auth grants access', () => { test('Basic auth grants access', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt }) const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
const timestamp = new Date('2020-01-01 00:00:00 UTC') const timestamp = new Date('2020-01-01 00:00:00 UTC')
testServer.prometheus.onMetricsDiscovery([ testServer.prometheus.updateMetrics([
new Metric('metric', 0.1, timestamp, { name: 'metric' }), new Metric('metric', 0.1, timestamp, { name: 'metric' }),
new Metric('total_something', 100, timestamp, { name: 'counter' }), new Metric('total_something', 100, timestamp, { name: 'counter' }),
]) ])
@ -119,10 +116,8 @@ describe('Fastify HTTP adapter', () => {
[ [
'# TYPE homebridge_metric gauge', '# TYPE homebridge_metric gauge',
'homebridge_metric{name="metric"} 0.1 1577836800000', 'homebridge_metric{name="metric"} 0.1 1577836800000',
'',
'# TYPE homebridge_something_total counter', '# TYPE homebridge_something_total counter',
'homebridge_something_total{name="counter"} 100 1577836800000', 'homebridge_something_total{name="counter"} 100 1577836800000',
'',
].join('\n'), ].join('\n'),
) )
}) })

View file

@ -6,94 +6,68 @@ describe('Render prometheus metrics', () => {
const renderer = new MetricsRenderer('prefix') const renderer = new MetricsRenderer('prefix')
test('Renders simple metric', () => { test('Renders simple metric', () => {
expect(renderer.render([new Metric('metric', 0.000001)])).toEqual( expect(renderer.render(new Metric('metric', 0.000001))).toEqual(
`# TYPE prefix_metric gauge `# TYPE prefix_metric gauge
prefix_metric 0.000001 prefix_metric 0.000001`,
`,
) )
}) })
test('Renders simple metric with timestamp', () => { test('Renders simple metric with timestamp', () => {
expect(renderer.render([new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC'))])).toEqual( expect(renderer.render(new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC')))).toEqual(
`# TYPE prefix_metric gauge `# TYPE prefix_metric gauge
prefix_metric 0.000001 946684800000 prefix_metric 0.000001 946684800000`,
`,
) )
}) })
test('Renders simple metric with labels', () => { test('Renders simple metric with labels', () => {
expect( expect(
renderer.render([ renderer.render(
new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }), new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }),
]), ),
).toEqual( ).toEqual(
`# TYPE prefix_metric gauge `# TYPE prefix_metric gauge
prefix_metric{label="Some Label"} 0.000001 946684800000 prefix_metric{label="Some Label"} 0.000001 946684800000`,
`,
) )
}) })
test('Renders total as counter', () => { test('Renders total as counter', () => {
for (const metricName of ['some_total_metric', 'some_metric_total', 'total_some_metric']) { for (const metricName of ['some_total_metric', 'some_metric_total', 'total_some_metric']) {
expect( expect(
renderer.render([ renderer.render(
new Metric(metricName, 42, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }), new Metric(metricName, 42, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }),
]), ),
).toEqual( ).toEqual(
`# TYPE prefix_some_metric_total counter `# TYPE prefix_some_metric_total counter
prefix_some_metric_total{label="Some Label"} 42 946684800000 prefix_some_metric_total{label="Some Label"} 42 946684800000`,
`,
) )
} }
}) })
test('Renders multiple metrics correctly', () => {
expect(
renderer.render([
new Metric('some_gauge', 10, new Date('2000-01-01 00:00:00 UTC')),
new Metric('another_gauge', 30, new Date('2000-01-01 00:00:00 UTC')),
new Metric('some_gauge', 20, new Date('2000-01-01 00:00:00 UTC')),
]),
).toEqual(
`# TYPE prefix_some_gauge gauge
prefix_some_gauge 10 946684800000
prefix_some_gauge 20 946684800000
# TYPE prefix_another_gauge gauge
prefix_another_gauge 30 946684800000
`,
)
})
test('Sanitizes metric names', () => { test('Sanitizes metric names', () => {
expect(renderer.render([new Metric('mätric name', 0)])).toEqual( expect(renderer.render(new Metric('mätric name', 0))).toEqual(
`# TYPE prefix_m_tric_name gauge `# TYPE prefix_m_tric_name gauge
prefix_m_tric_name 0 prefix_m_tric_name 0`,
`,
) )
}) })
test('Sanitizes label names', () => { test('Sanitizes label names', () => {
expect(renderer.render([new Metric('metric', 0, null, { 'yet another label': 'foo' })])).toEqual( expect(renderer.render(new Metric('metric', 0, null, { 'yet another label': 'foo' }))).toEqual(
`# TYPE prefix_metric gauge `# TYPE prefix_metric gauge
prefix_metric{yet_another_label="foo"} 0 prefix_metric{yet_another_label="foo"} 0`,
`,
) )
}) })
test('Escapes newlines in attribute value', () => { test('Escapes newlines in attribute value', () => {
expect(renderer.render([new Metric('metric', 0, null, { label: 'foo\nbar' })])).toEqual( expect(renderer.render(new Metric('metric', 0, null, { label: 'foo\nbar' }))).toEqual(
`# TYPE prefix_metric gauge `# TYPE prefix_metric gauge
prefix_metric{label="foo\\nbar"} 0 prefix_metric{label="foo\\nbar"} 0`,
`,
) )
}) })
test('Escapes quotes in attribute value', () => { test('Escapes quotes in attribute value', () => {
expect(renderer.render([new Metric('metric', 0, null, { label: 'foo"bar' })])).toEqual( expect(renderer.render(new Metric('metric', 0, null, { label: 'foo"bar' }))).toEqual(
`# TYPE prefix_metric gauge `# TYPE prefix_metric gauge
prefix_metric{label="foo\\"bar"} 0 prefix_metric{label="foo\\"bar"} 0`,
`,
) )
}) })
}) })

View file

@ -10,7 +10,7 @@
"rootDir": "./", "rootDir": "./",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": false, "importsNotUsedAsValues": "error",
"noImplicitAny": true, "noImplicitAny": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"tsBuildInfoFile": ".tsbuildinfo", "tsBuildInfoFile": ".tsbuildinfo",