Compare commits

..

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

51 changed files with 3019 additions and 11128 deletions

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -19,8 +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',
}, },
overrides: [ overrides: [
{ {
@ -35,7 +33,6 @@ module.exports = {
}, },
rules: { rules: {
'@typescript-eslint/explicit-module-boundary-types': 'error', '@typescript-eslint/explicit-module-boundary-types': 'error',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
}, },
settings: { settings: {
'import/resolver': { 'import/resolver': {
@ -46,7 +43,7 @@ module.exports = {
}, },
}, },
{ {
files: ['**/*.js'], files: ['.eslintrc.js', 'jest.config.js', 'prettier.config.js'],
env: { env: {
node: true, node: true,
browser: false, browser: false,

2
.github/FUNDING.yml vendored
View file

@ -1,2 +0,0 @@
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,83 +4,40 @@ 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:
# the Node.js versions to build on
node-version: [14.x, 16.x, 17.x, 18.x]
allow-lint-failure: [false]
allow-test-failure: [false]
include: include:
- { node-version: 14.x, lint: true, tests: true } - { node-version: 10.x, allow-lint-failure: true, allow-test-failure: true }
- { node-version: 15.x, lint: false, tests: true } - { node-version: 11.x, allow-lint-failure: true, allow-test-failure: true }
- { node-version: 16.x, lint: true, tests: true } - { node-version: 12.x, allow-lint-failure: true, allow-test-failure: true }
- { node-version: 17.x, lint: true, tests: true } - { node-version: 13.x, allow-lint-failure: true, allow-test-failure: true }
- { node-version: 18.x, lint: true, tests: true } - { node-version: 15.x, allow-lint-failure: true, allow-test-failure: false }
- { node-version: 19.x, lint: true, tests: true }
name: nodejs ${{ 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
id: cache-npm
uses: actions/cache@v3.3.2
env:
cache-name: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
- name: Cache eslint
id: cache-eslint
uses: actions/cache@v3.3.2
env:
cache-name: cache-eslint
with:
path: .eslintcache
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
- name: Cache TypeScript
id: cache-typescript
uses: actions/cache@v3.3.2
env:
cache-name: cache-typescript
with:
path: .tsbuildinfo
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
- name: Cache prettier
id: cache-prettier
uses: actions/cache@v3.3.2
env:
cache-name: cache-prettier
with:
path: node_modules/.cache/prettier/.prettier-cache
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}
- name: Lint the project - name: Lint the project
run: npm run lint run: npm run lint
if: ${{ matrix.lint }} continue-on-error: ${{ matrix.allow-lint-failure }}
- name: Run tests - name: Run tests
run: npm test run: npm test
if: ${{ matrix.tests }} continue-on-error: ${{ matrix.allow-test-failure }}
- 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 }}

6
.gitignore vendored
View file

@ -123,7 +123,5 @@ dist
# IntelliJ # IntelliJ
/.idea/ /.idea/
/.homebridge/accessories/ /dot-homebridge/accessories/
/.homebridge/persist/ /dot-homebridge/persist/
release.sh

View file

@ -134,7 +134,7 @@ web_modules/
.pnp.* .pnp.*
# Dev setup # Dev setup
.homebridge/ dot-homebridge/
tests/ tests/
.idea/ .idea/
flake.* flake.*
@ -142,5 +142,3 @@ prettier.config.js
.eslintrc.js .eslintrc.js
jest.config.js jest.config.js
nodemon.json nodemon.json
/code-generation/
/release.sh

View file

@ -1,9 +0,0 @@
{
"github": {
"release": true
},
"hooks": {
"before:init": ["test `git rev-parse --abbrev-ref HEAD` == 'develop'", "npm run lint", "CI=1 npm test"],
"after:bump": "npm run build"
}
}

View file

118
README.md
View file

@ -1,18 +1,11 @@
<div align="center" style="background:red"> <p align="center">
<img src="https://github.com/homebridge/branding/raw/master/logos/homebridge-wordmark-logo-vertical.png"
alt="Homebridge logo"
width="10%"/>
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Plus_symbol.svg/500px-Plus_symbol.svg.png"
alt="Plus sign"
width="8%"/>
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/3/38/Prometheus_software_logo.svg/115px-Prometheus_software_logo.svg.png"
alt="Prometheus logo"
width="10%"/>
</div>
# Homebridge Prometheus Exporter <img src="https://github.com/homebridge/branding/raw/master/logos/homebridge-wordmark-logo-vertical.png" width="150">
[![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)
</p>
# 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)
> What if we could store homebridge metrics in Prometheus > What if we could store homebridge metrics in Prometheus
@ -64,7 +57,7 @@ Create `/etc/systemd/system/homebridge.service.d` folder:
```shell ```shell
mkdir /etc/systemd/system/homebridge.service.d mkdir /etc/systemd/system/homebridge.service.d
``` ```
Write this drop-in configuration file to `/etc/systemd/system/homebridge.service.d/insecure.conf`: Write this drop-in configuration file to /etc/systemd/system/homebridge.service.d/insecure.conf:
```ini ```ini
[Service] [Service]
ExecStart= ExecStart=
@ -117,95 +110,34 @@ 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)
"tls_cert_file": "<string>",
// TLS key file
//
// Path to TLS key file
"tls_key_file": "<string>",
// Basic auth username/password pairs
//
// 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"}
"basic_auth": "<object>"
}
] ]
} }
``` ```
<!-- AUTOGENERATED CONFIG DOCS END -->

View file

@ -1,102 +0,0 @@
#!/usr/bin/env node
const { parseSchema } = require('json-schema-to-zod')
const { schema } = require('../config.schema.json')
const { format } = require('prettier')
const { join, basename } = require('path')
const prettierConfig = require('../prettier.config')
const { writeFileSync, readFileSync } = require('fs')
const file = join(__dirname, '../src/generated/config_boundary.ts')
console.log(`Starting code generation for ${file}`)
const zodSchema = parseSchema(schema, false)
const code = format(
`
// Auto-generated by "${join(basename(__dirname), basename(__filename))}", dont manually edit
import { z } from 'zod'
export const ConfigBoundary = ${zodSchema}
`,
{ filepath: 'codegen.ts', ...prettierConfig },
)
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}`)

View file

@ -1,36 +0,0 @@
#!/usr/bin/env node
const hap = require('hap-nodejs')
const { format } = require('prettier')
const prettierConfig = require('../prettier.config')
const { writeFileSync } = require('fs')
const { join, basename } = require('path')
const uuidToServiceMap = {}
const serviceToUuidMap = {}
const file = join(__dirname, '../src/generated/services.ts')
console.log(`Starting code generation for ${file}`)
for (const [name, service] of Object.entries(hap.Service)) {
if (typeof service !== 'function' || typeof service.UUID !== 'string') {
console.log(`Skipping ${typeof service} ${name}`)
continue
}
uuidToServiceMap[service.UUID] = name
serviceToUuidMap[name] = service.UUID
}
const code = format(
`
// Auto-generated by "${join(basename(__dirname), basename(__filename))}", dont manually edit
export const Uuids = ${JSON.stringify(uuidToServiceMap)} as const
export const Services = ${JSON.stringify(serviceToUuidMap)} as const
`,
{ filepath: 'codegen.ts', ...prettierConfig },
)
writeFileSync(file, code)
console.log(`Finished code generation for ${file}`)

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",
@ -58,25 +52,6 @@
"type": "integer", "type": "integer",
"required": false, "required": false,
"default": 20 "default": 20
},
"tls_cert_file": {
"title": "TLS cert file",
"description": "Path to TLS certificate file (in PEM format)",
"type": "string",
"required": false
},
"tls_key_file": {
"title": "TLS key file",
"description": "Path to TLS key file",
"type": "string",
"required": false
},
"basic_auth": {
"title": "Basic auth username/password pairs",
"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",
"additionalProperties": { "type": "string" },
"required": false
} }
} }
} }

View file

@ -6,7 +6,7 @@
}, },
"platforms": [ "platforms": [
{ {
"pin": "121-11-121", "pin": "808-00-808",
"platform": "PrometheusExporter", "platform": "PrometheusExporter",
"debug": true "debug": true
}, },

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'],
} }

View file

@ -1,7 +1,7 @@
{ {
"watch": ["./"], "watch": ["./"],
"ignore": ["dist/*", "tests/*", ".homebridge/persist", ".homebridge/accessories"], "ignore": ["dist/*", "tests/*"],
"exec": "tsc && homebridge -I -D -U .homebridge", "exec": "tsc && homebridge -D -U dot-homebridge",
"signal": "SIGTERM", "signal": "SIGTERM",
"env": { "env": {
"NODE_OPTIONS": "--trace-warnings" "NODE_OPTIONS": "--trace-warnings"

12192
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.2",
"description": "Prometheus exporter for homebridge accessories.", "description": "Prometheus exporter for homebridge accessories.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@ -16,15 +16,12 @@
}, },
"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`/prettier --ignore-path=.gitignore `ifNotCi --write --check` '**/**.{ts,js,json}' && `npm bin`/eslint `ifNotCi --fix` --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 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 && tsc",
"code-generation": "./code-generation/hap-gen.js && ./code-generation/config-scheme-gen.js", "prepublishOnly": "npm run lint && npm run build"
"prepublishOnly": "npm run code-generation && npm run lint && npm run build",
"release": "release-it --only-version"
}, },
"keywords": [ "keywords": [
"homebridge-plugin", "homebridge-plugin",
@ -34,37 +31,30 @@
], ],
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.3.0", "@jest/globals": "^29.3.0",
"@types/bcrypt": "^5.0.0", "@types/node": "^18.11.9",
"@types/node": "^20.2.3",
"@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",
"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": "^0.2.0",
"nodemon": "^3.0.1", "nodemon": "^2.0.13",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"release-it": "^16.0.0", "rimraf": "^3.0.2",
"rimraf": "^5.0.0",
"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/basic-auth": "^5.0.0",
"array.prototype.group": "^1.1.2",
"bcrypt": "^5.1.0",
"fastify": "^4.9.2", "fastify": "^4.9.2",
"hap-node-client": "^0.2.1", "hap-node-client": "git+https://github.com/NorthernMan54/Hap-Node-Client.git#fe200ba",
"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

@ -1,9 +1,14 @@
import type { Config, Device } from '../../boundaries' import type { Device } from '../../boundaries/hap'
import type { Logger } from 'homebridge' import { Logger } from 'homebridge'
export interface HapDiscoveryConfig { type Pin = string
config: Pick<Config, 'debug' | 'pin' | 'refresh_interval' | 'discovery_timeout' | 'request_timeout'>
log?: Logger export interface HapConfig {
pin: Pin
refreshInterval: number
discoveryTimeout: number
requestTimeout: number
logger: Logger
debug: boolean
} }
export type HapDiscover = (config: HapConfig) => Promise<Device[]>
export type HapDiscover = (config: HapDiscoveryConfig) => Promise<Device[]>

View file

@ -1,46 +1,39 @@
import type { HapDiscover } from './api' import type { HapDiscover } from './api'
import { HAPNodeJSClient, type HAPNodeJSClientConfig } from 'hap-node-client' import { HAPNodeJSClient } from 'hap-node-client'
import { type Device, DeviceBoundary, checkBoundary } from '../../boundaries' import { Device, DeviceBoundary } from '../../boundaries/hap'
import type { Logger } from 'homebridge' import { Logger } from 'homebridge'
import z from 'zod' import z from 'zod'
const MaybeDevices = z.array(z.unknown()) const MaybeDevices = z.array(z.unknown())
interface HapConfig {
debug: boolean
refresh: number
timeout: number
reqTimeout: number
pin: string
}
type ResolveFunc = (devices: Device[]) => void type ResolveFunc = (devices: Device[]) => void
type RejectFunc = (error: unknown) => void 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: HapConfig, 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 {
logger?.debug('Reusing existing HAP client')
}
}
function createDiscoveryHandler(logger: Logger | undefined, key: string): (deviceData: unknown) => void {
return (deviceData: unknown) => {
try { try {
const devices: Device[] = [] const devices: Device[] = []
for (const device of checkBoundary(MaybeDevices, deviceData)) { for (const device of MaybeDevices.parse(deviceData)) {
try { try {
devices.push(checkBoundary(DeviceBoundary, device)) devices.push(DeviceBoundary.parse(device))
} catch (e) { } catch (e) {
logger?.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4)) logger.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4))
} }
} }
@ -48,19 +41,24 @@ function createDiscoveryHandler(logger: Logger | undefined, key: string): (devic
} 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 discover: HapDiscover = ({ pin, refreshInterval, discoveryTimeout, requestTimeout, logger, debug }) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
startDiscovery( startDiscovery(
log, logger,
{ {
debug: config.debug, debug: debug,
refresh: config.refresh_interval, refresh: refreshInterval,
timeout: config.discovery_timeout, timeout: discoveryTimeout,
reqTimeout: config.request_timeout, reqTimeout: requestTimeout,
pin: config.pin, pin,
}, },
resolve, resolve,
reject, reject,

View file

@ -1,2 +0,0 @@
export * from './api'
export * from './hap_node_js_client'

View file

@ -1,7 +1,4 @@
import type { Logger } from 'homebridge' import { HttpServer } from '../../http'
import type { RequestListener, Server } from 'http'
import type { Config } from '../../boundaries'
import type { Metric } from '../../metrics'
export interface HttpResponse { export interface HttpResponse {
statusCode?: number statusCode?: number
@ -14,19 +11,3 @@ export interface HttpServerController {
} }
export type HttpAdapter = (config: HttpServer) => Promise<HttpServerController> export type HttpAdapter = (config: HttpServer) => Promise<HttpServerController>
export type HttpConfig = Pick<
Config,
'debug' | 'port' | 'interface' | 'prefix' | 'basic_auth' | 'tls_cert_file' | 'tls_key_file'
>
export interface HttpServer {
log: Logger | null
config: HttpConfig
serverFactory?: (requestListener: RequestListener) => Server
onRequest(): HttpResponse | null
onMetrics(): HttpResponse
onNotFound(): HttpResponse
onError(error: Error): HttpResponse
onMetricsDiscovery(metrics: Metric[]): void
}

View file

@ -1,79 +1,23 @@
import Fastify, { type FastifyReply, type FastifyRequest, type HookHandlerDoneFunction } from 'fastify' import Fastify, { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify'
import { readFileSync } from 'fs' import { HttpAdapter, HttpResponse } from './api'
import { constants as HttpConstants } from 'http2' import { HttpServer } from '../../http'
import { isAuthenticated } from '../../security'
import type { HttpAdapter, HttpResponse, HttpServer } from './api'
import fastifyAuth from '@fastify/auth'
import fastifyBasicAuth from '@fastify/basic-auth'
function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void { function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void {
if (response.statusCode) { if (response.statusCode) {
void reply.code(response.statusCode) void reply.code(response.statusCode)
} }
if (response.body) {
void reply.send(response.body)
}
if (response.headers) { if (response.headers) {
void reply.headers(response.headers) void reply.headers(response.headers)
} }
if (response.body) {
void reply.send(response.body)
}
} }
function formatCombinedLog(request: FastifyRequest, reply: FastifyReply): string { export const serve: HttpAdapter = async (server: HttpServer) => {
const remoteAddress = [request.socket.remoteAddress, request.socket.remotePort].filter((v) => v != null).join(':') const fastify = Fastify({
const userAgent = request.headers['user-agent'] || '' logger: server.debug,
const contentType = request.headers['content-type'] || ''
return `${remoteAddress} - "${request.method} ${request.url} HTTP/${request.raw.httpVersion}" ${reply.statusCode} "${request.protocol}://${request.hostname}" "${userAgent}" "${contentType}"`
}
type FastifyServer = ReturnType<typeof Fastify>
function createFastify(server: HttpServer): FastifyServer {
const config = { logger: false }
if (server.config.tls_cert_file && server.config.tls_key_file) {
server.log?.debug('Running with TLS enabled')
return Fastify({
...config,
https: {
key: readFileSync(server.config.tls_key_file),
cert: readFileSync(server.config.tls_cert_file),
},
})
}
return Fastify({
...config,
serverFactory: server.serverFactory,
})
}
export const fastifyServe: HttpAdapter = async (server: HttpServer) => {
const fastify = createFastify(server)
if (server.config.basic_auth && Object.keys(server.config.basic_auth).length > 0) {
const users = server.config.basic_auth
const validate = async (username: string, password: string) => {
if (!(await isAuthenticated(username, password, users))) {
throw new Error('Unauthorized')
}
}
await fastify.register(fastifyAuth)
await fastify.register(fastifyBasicAuth, { validate, authenticate: true })
fastify.after(() => {
fastify.addHook('preHandler', fastify.auth([fastify.basicAuth]))
})
}
fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply) => {
if (reply.statusCode >= HttpConstants.HTTP_STATUS_BAD_REQUEST) {
server.log?.error(formatCombinedLog(request, reply))
} else if (server.config.debug) {
server.log?.debug(formatCombinedLog(request, reply))
}
}) })
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, next: HookHandlerDoneFunction) => { fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, next: HookHandlerDoneFunction) => {
@ -98,7 +42,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 fastify.listen({ port: server.port, host: '::' })
return { return {
shutdown() { shutdown() {
@ -106,13 +50,3 @@ export const fastifyServe: HttpAdapter = async (server: HttpServer) => {
}, },
} }
} }
async function listen(fastify: FastifyServer, port: number, host: string): Promise<void> {
try {
await fastify.listen({ port, host })
} catch (e: unknown) {
if (host === '::' && e instanceof Error && (e as Error & { code: string }).code === 'EAFNOSUPPORT') {
await listen(fastify, port, '0.0.0.0')
}
}
}

View file

@ -1,2 +0,0 @@
export * from './api'
export * from './fastify'

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

@ -1,52 +0,0 @@
import type z from 'zod'
type Path = (string | number)[]
function resolvePath(data: unknown, path: Path): { resolvedValue: string; resolvedPath: Path } {
const resolvedPath: Path = []
for (const element of path) {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (data[element] != null) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
data = data[element]
resolvedPath.push(element)
} else {
break
}
} catch (e) {
break
}
}
return { resolvedValue: JSON.stringify(data), resolvedPath }
}
function formatPath(path: Path): string {
return path.map((element) => (typeof element === 'number' ? `[${element}]` : element)).join('.')
}
export function checkBoundary<Output, T extends z.ZodType<Output>>(type: T, data: unknown): z.infer<T> {
const result = type.safeParse(data)
if (result.success) {
return result.data
}
const message =
'Error checking type. Details: ' +
result.error.issues
.map((issue) => ({ ...issue, ...resolvePath(data, issue.path) }))
.map(
(issue) =>
`[${issue.code}] ${issue.message}${
issue.path.length > 0 ? ` at path "${formatPath(issue.path)}"` : ''
} (data${
issue.resolvedPath.length > 0 ? ` at resolved path "${formatPath(issue.resolvedPath)}"` : ''
} is "${issue.resolvedValue}")`,
)
.join(' | ')
throw new Error(message)
}

View file

@ -1,5 +1,19 @@
import { z } from 'zod' import { z } from 'zod'
import { ConfigBoundary as ConfigBoundaryWithoutPlatform } from '../generated/config_boundary'
export const ConfigBoundary = z.intersection(ConfigBoundaryWithoutPlatform, z.object({ platform: z.string() })) export const ConfigBoundary = z.object({
export type Config = z.infer<typeof ConfigBoundary> pin: z.string().regex(new RegExp('^\\d{3}-\\d{2}-\\d{3}$')).describe('Homebridge PIN for service authentication'),
debug: z.boolean().default(false),
prefix: z.string().default('homebridge'),
port: z.number().int().describe('TCP port for the prometheus probe server to listen to').default(36123),
refresh_interval: z.number().int().describe('Discover new services every <interval> seconds').default(60),
request_timeout: z
.number()
.int()
.describe('Request timeout when interacting with homebridge instances')
.default(10),
discovery_timeout: z
.number()
.int()
.describe('Discovery timeout after which the current discovery is considered failed')
.default(20),
})

View file

@ -1,6 +1,6 @@
import z, { type ZodNull, type ZodOptional, type ZodType, type ZodUnion } from 'zod' import z from 'zod'
const NumericTypesBoundary = z.union([ const NumberAlikeTypesTypesBoundary = z.union([
z.literal('bool'), z.literal('bool'),
z.literal('float'), z.literal('float'),
z.literal('int'), z.literal('int'),
@ -9,23 +9,17 @@ const NumericTypesBoundary = z.union([
z.literal('uint32'), z.literal('uint32'),
z.literal('uint64'), z.literal('uint64'),
]) ])
export type NumericTypes = z.infer<typeof NumericTypesBoundary> export type NumberAlikeTypes = z.infer<typeof NumberAlikeTypesTypesBoundary>
function optionalNullable<T extends ZodType>(type: T): ZodOptional<ZodUnion<[ZodNull, T]>> {
return z.optional(z.union([z.null(), type]))
}
export const CharacteristicBoundary = z.intersection( export const CharacteristicBoundary = z.intersection(
z.object({ type: z.string(), description: z.string() }), z.object({ type: z.string(), description: z.string() }),
z.union([ z.union([
z.object({ z.object({
format: NumericTypesBoundary, format: NumberAlikeTypesTypesBoundary,
value: z.optional(z.number()),
unit: z.optional(z.string()), unit: z.optional(z.string()),
value: optionalNullable(z.number()),
}), }),
z.object({ format: z.literal('string'), value: optionalNullable(z.string()) }), z.object({ format: z.literal('string'), value: z.string() }),
z.object({ format: z.literal('data'), value: optionalNullable(z.string()) }),
z.object({ format: z.literal('tlv8'), value: optionalNullable(z.string()) }),
]), ]),
) )
export type Characteristic = z.infer<typeof CharacteristicBoundary> export type Characteristic = z.infer<typeof CharacteristicBoundary>

View file

@ -1,3 +1,6 @@
export * from './checker' import z from 'zod'
import { ConfigBoundary as BaseConfigBoundary } from './config'
export * from './hap' export * from './hap'
export * from './config' export const ConfigBoundary = z.intersection(BaseConfigBoundary, z.object({ platform: z.string() }))
export type Config = z.infer<typeof ConfigBoundary>

View file

@ -1,35 +0,0 @@
// Auto-generated by "code-generation/config-scheme-gen.js", dont manually edit
import { z } from 'zod'
export const ConfigBoundary = z.object({
pin: z.string().regex(new RegExp('^\\d{3}-\\d{2}-\\d{3}$')).describe('Homebridge PIN for service authentication'),
debug: z.boolean().default(false),
prefix: z.string().default('homebridge'),
port: z.number().int().describe('TCP port where the Prometheus metrics server listens').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),
request_timeout: z
.number()
.int()
.describe('Request timeout when interacting with homebridge instances')
.default(10),
discovery_timeout: z
.number()
.int()
.describe('Discovery timeout after which the current discovery is considered failed')
.default(20),
tls_cert_file: z.string().describe('Path to TLS certificate file (in PEM format)').optional(),
tls_key_file: z.string().describe('Path to TLS key file').optional(),
basic_auth: z
.record(z.string())
.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"}',
)
.optional(),
})

View file

@ -1,164 +0,0 @@
// Auto-generated by "code-generation/hap-gen.js", dont manually edit
export const Uuids = {
'00000260-0000-1000-8000-0026BB765291': 'AccessCode',
'000000DA-0000-1000-8000-0026BB765291': 'AccessControl',
'0000003E-0000-1000-8000-0026BB765291': 'AccessoryInformation',
'00000270-0000-1000-8000-0026BB765291': 'AccessoryMetrics',
'00000239-0000-1000-8000-0026BB765291': 'AccessoryRuntimeInformation',
'000000BB-0000-1000-8000-0026BB765291': 'AirPurifier',
'0000008D-0000-1000-8000-0026BB765291': 'AirQualitySensor',
'00000267-0000-1000-8000-0026BB765291': 'AssetUpdate',
'0000026A-0000-1000-8000-0026BB765291': 'Assistant',
'00000127-0000-1000-8000-0026BB765291': 'AudioStreamManagement',
'00000096-0000-1000-8000-0026BB765291': 'Battery',
'000000A1-0000-1000-8000-0026BB765291': 'BridgeConfiguration',
'00000062-0000-1000-8000-0026BB765291': 'BridgingState',
'00000111-0000-1000-8000-0026BB765291': 'CameraControl',
'0000021A-0000-1000-8000-0026BB765291': 'CameraOperatingMode',
'00000204-0000-1000-8000-0026BB765291': 'CameraRecordingManagement',
'00000110-0000-1000-8000-0026BB765291': 'CameraRTPStreamManagement',
'00000097-0000-1000-8000-0026BB765291': 'CarbonDioxideSensor',
'0000007F-0000-1000-8000-0026BB765291': 'CarbonMonoxideSensor',
'0000005A-0000-1000-8000-0026BB765291': 'CloudRelay',
'00000080-0000-1000-8000-0026BB765291': 'ContactSensor',
'00000129-0000-1000-8000-0026BB765291': 'DataStreamTransportManagement',
'00000237-0000-1000-8000-0026BB765291': 'Diagnostics',
'00000081-0000-1000-8000-0026BB765291': 'Door',
'00000121-0000-1000-8000-0026BB765291': 'Doorbell',
'00000040-0000-1000-8000-0026BB765291': 'Fan',
'000000B7-0000-1000-8000-0026BB765291': 'Fanv2',
'000000D7-0000-1000-8000-0026BB765291': 'Faucet',
'000000BA-0000-1000-8000-0026BB765291': 'FilterMaintenance',
'00000236-0000-1000-8000-0026BB765291': 'FirmwareUpdate',
'00000041-0000-1000-8000-0026BB765291': 'GarageDoorOpener',
'000000BC-0000-1000-8000-0026BB765291': 'HeaterCooler',
'000000BD-0000-1000-8000-0026BB765291': 'HumidifierDehumidifier',
'00000082-0000-1000-8000-0026BB765291': 'HumiditySensor',
'000000D9-0000-1000-8000-0026BB765291': 'InputSource',
'000000CF-0000-1000-8000-0026BB765291': 'IrrigationSystem',
'00000083-0000-1000-8000-0026BB765291': 'LeakSensor',
'00000043-0000-1000-8000-0026BB765291': 'Lightbulb',
'00000084-0000-1000-8000-0026BB765291': 'LightSensor',
'00000044-0000-1000-8000-0026BB765291': 'LockManagement',
'00000045-0000-1000-8000-0026BB765291': 'LockMechanism',
'00000112-0000-1000-8000-0026BB765291': 'Microphone',
'00000085-0000-1000-8000-0026BB765291': 'MotionSensor',
'00000266-0000-1000-8000-0026BB765291': 'NFCAccess',
'00000086-0000-1000-8000-0026BB765291': 'OccupancySensor',
'00000047-0000-1000-8000-0026BB765291': 'Outlet',
'00000055-0000-1000-8000-0026BB765291': 'Pairing',
'00000221-0000-1000-8000-0026BB765291': 'PowerManagement',
'000000A2-0000-1000-8000-0026BB765291': 'ProtocolInformation',
'0000007E-0000-1000-8000-0026BB765291': 'SecuritySystem',
'000000CC-0000-1000-8000-0026BB765291': 'ServiceLabel',
'00000133-0000-1000-8000-0026BB765291': 'Siri',
'00000253-0000-1000-8000-0026BB765291': 'SiriEndpoint',
'000000B9-0000-1000-8000-0026BB765291': 'Slats',
'00000228-0000-1000-8000-0026BB765291': 'SmartSpeaker',
'00000087-0000-1000-8000-0026BB765291': 'SmokeSensor',
'00000113-0000-1000-8000-0026BB765291': 'TelevisionSpeaker',
'00000088-0000-1000-8000-0026BB765291': 'StatefulProgrammableSwitch',
'00000089-0000-1000-8000-0026BB765291': 'StatelessProgrammableSwitch',
'00000049-0000-1000-8000-0026BB765291': 'Switch',
'0000022E-0000-1000-8000-0026BB765291': 'TapManagement',
'00000125-0000-1000-8000-0026BB765291': 'TargetControl',
'00000122-0000-1000-8000-0026BB765291': 'TargetControlManagement',
'000000D8-0000-1000-8000-0026BB765291': 'Television',
'0000008A-0000-1000-8000-0026BB765291': 'TemperatureSensor',
'0000004A-0000-1000-8000-0026BB765291': 'Thermostat',
'00000701-0000-1000-8000-0026BB765291': 'ThreadTransport',
'00000099-0000-1000-8000-0026BB765291': 'TimeInformation',
'00000203-0000-1000-8000-0026BB765291': 'TransferTransportManagement',
'00000056-0000-1000-8000-0026BB765291': 'Tunnel',
'000000D0-0000-1000-8000-0026BB765291': 'Valve',
'0000020A-0000-1000-8000-0026BB765291': 'WiFiRouter',
'0000020F-0000-1000-8000-0026BB765291': 'WiFiSatellite',
'0000022A-0000-1000-8000-0026BB765291': 'WiFiTransport',
'0000008B-0000-1000-8000-0026BB765291': 'Window',
'0000008C-0000-1000-8000-0026BB765291': 'WindowCovering',
} as const
export const Services = {
AccessCode: '00000260-0000-1000-8000-0026BB765291',
AccessControl: '000000DA-0000-1000-8000-0026BB765291',
AccessoryInformation: '0000003E-0000-1000-8000-0026BB765291',
AccessoryMetrics: '00000270-0000-1000-8000-0026BB765291',
AccessoryRuntimeInformation: '00000239-0000-1000-8000-0026BB765291',
AirPurifier: '000000BB-0000-1000-8000-0026BB765291',
AirQualitySensor: '0000008D-0000-1000-8000-0026BB765291',
AssetUpdate: '00000267-0000-1000-8000-0026BB765291',
Assistant: '0000026A-0000-1000-8000-0026BB765291',
AudioStreamManagement: '00000127-0000-1000-8000-0026BB765291',
BatteryService: '00000096-0000-1000-8000-0026BB765291',
Battery: '00000096-0000-1000-8000-0026BB765291',
BridgeConfiguration: '000000A1-0000-1000-8000-0026BB765291',
BridgingState: '00000062-0000-1000-8000-0026BB765291',
CameraControl: '00000111-0000-1000-8000-0026BB765291',
CameraOperatingMode: '0000021A-0000-1000-8000-0026BB765291',
CameraEventRecordingManagement: '00000204-0000-1000-8000-0026BB765291',
CameraRecordingManagement: '00000204-0000-1000-8000-0026BB765291',
CameraRTPStreamManagement: '00000110-0000-1000-8000-0026BB765291',
CarbonDioxideSensor: '00000097-0000-1000-8000-0026BB765291',
CarbonMonoxideSensor: '0000007F-0000-1000-8000-0026BB765291',
Relay: '0000005A-0000-1000-8000-0026BB765291',
CloudRelay: '0000005A-0000-1000-8000-0026BB765291',
ContactSensor: '00000080-0000-1000-8000-0026BB765291',
DataStreamTransportManagement: '00000129-0000-1000-8000-0026BB765291',
Diagnostics: '00000237-0000-1000-8000-0026BB765291',
Door: '00000081-0000-1000-8000-0026BB765291',
Doorbell: '00000121-0000-1000-8000-0026BB765291',
Fan: '00000040-0000-1000-8000-0026BB765291',
Fanv2: '000000B7-0000-1000-8000-0026BB765291',
Faucet: '000000D7-0000-1000-8000-0026BB765291',
FilterMaintenance: '000000BA-0000-1000-8000-0026BB765291',
FirmwareUpdate: '00000236-0000-1000-8000-0026BB765291',
GarageDoorOpener: '00000041-0000-1000-8000-0026BB765291',
HeaterCooler: '000000BC-0000-1000-8000-0026BB765291',
HumidifierDehumidifier: '000000BD-0000-1000-8000-0026BB765291',
HumiditySensor: '00000082-0000-1000-8000-0026BB765291',
InputSource: '000000D9-0000-1000-8000-0026BB765291',
IrrigationSystem: '000000CF-0000-1000-8000-0026BB765291',
LeakSensor: '00000083-0000-1000-8000-0026BB765291',
Lightbulb: '00000043-0000-1000-8000-0026BB765291',
LightSensor: '00000084-0000-1000-8000-0026BB765291',
LockManagement: '00000044-0000-1000-8000-0026BB765291',
LockMechanism: '00000045-0000-1000-8000-0026BB765291',
Microphone: '00000112-0000-1000-8000-0026BB765291',
MotionSensor: '00000085-0000-1000-8000-0026BB765291',
NFCAccess: '00000266-0000-1000-8000-0026BB765291',
OccupancySensor: '00000086-0000-1000-8000-0026BB765291',
Outlet: '00000047-0000-1000-8000-0026BB765291',
Pairing: '00000055-0000-1000-8000-0026BB765291',
PowerManagement: '00000221-0000-1000-8000-0026BB765291',
ProtocolInformation: '000000A2-0000-1000-8000-0026BB765291',
SecuritySystem: '0000007E-0000-1000-8000-0026BB765291',
ServiceLabel: '000000CC-0000-1000-8000-0026BB765291',
Siri: '00000133-0000-1000-8000-0026BB765291',
SiriEndpoint: '00000253-0000-1000-8000-0026BB765291',
Slat: '000000B9-0000-1000-8000-0026BB765291',
Slats: '000000B9-0000-1000-8000-0026BB765291',
SmartSpeaker: '00000228-0000-1000-8000-0026BB765291',
SmokeSensor: '00000087-0000-1000-8000-0026BB765291',
Speaker: '00000113-0000-1000-8000-0026BB765291',
StatefulProgrammableSwitch: '00000088-0000-1000-8000-0026BB765291',
StatelessProgrammableSwitch: '00000089-0000-1000-8000-0026BB765291',
Switch: '00000049-0000-1000-8000-0026BB765291',
TapManagement: '0000022E-0000-1000-8000-0026BB765291',
TargetControl: '00000125-0000-1000-8000-0026BB765291',
TargetControlManagement: '00000122-0000-1000-8000-0026BB765291',
Television: '000000D8-0000-1000-8000-0026BB765291',
TelevisionSpeaker: '00000113-0000-1000-8000-0026BB765291',
TemperatureSensor: '0000008A-0000-1000-8000-0026BB765291',
Thermostat: '0000004A-0000-1000-8000-0026BB765291',
ThreadTransport: '00000701-0000-1000-8000-0026BB765291',
TimeInformation: '00000099-0000-1000-8000-0026BB765291',
TransferTransportManagement: '00000203-0000-1000-8000-0026BB765291',
TunneledBTLEAccessoryService: '00000056-0000-1000-8000-0026BB765291',
Tunnel: '00000056-0000-1000-8000-0026BB765291',
Valve: '000000D0-0000-1000-8000-0026BB765291',
WiFiRouter: '0000020A-0000-1000-8000-0026BB765291',
WiFiSatellite: '0000020F-0000-1000-8000-0026BB765291',
WiFiTransport: '0000022A-0000-1000-8000-0026BB765291',
Window: '0000008B-0000-1000-8000-0026BB765291',
WindowCovering: '0000008C-0000-1000-8000-0026BB765291',
} as const

14
src/http.ts Normal file
View file

@ -0,0 +1,14 @@
import { HttpResponse } from './adapters/http/api'
import { Metric } from './metrics'
import { Logger } from 'homebridge'
export interface HttpServer {
port: number
debug: boolean
log: Logger
onRequest(): HttpResponse | undefined
onMetrics(): HttpResponse
onNotFound(): HttpResponse
onError(error: unknown): HttpResponse
updateMetrics(metrics: Metric[]): void
}

View file

@ -1,4 +1,4 @@
import type { API } from 'homebridge' import { API } from 'homebridge'
import { PLATFORM_NAME } from './settings' import { PLATFORM_NAME } from './settings'
import { PrometheusExporterPlatform } from './platform' import { PrometheusExporterPlatform } from './platform'

View file

@ -1,15 +1,14 @@
import type { Accessory, Device, Service } from './boundaries' import type { Accessory, Device, Service } from './boundaries/hap'
import { Services, Uuids } from './generated/services' import { assertTypeExhausted, isType } from './std'
import { assertTypeExhausted, isKeyOfConstObject, isType, strCamelCaseToSnakeCase } from './std' // eslint-disable-next-line import/no-extraneous-dependencies
import { Service as HapService } from 'hap-nodejs'
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 +18,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,31 +28,10 @@ export function aggregate(devices: Device[], timestamp: Date): Metric[] {
...getAccessoryLabels(accessory), ...getAccessoryLabels(accessory),
...getServiceLabels(service), ...getServiceLabels(service),
} }
metrics.push(extractMetrics(service, timestamp, labels))
}
}
}
return metrics.flat()
}
function extractMetrics(service: Service, timestamp: Date, labels: Labels): Metric[] {
const metrics: Metric[] = []
for (const characteristic of service.characteristics) { for (const characteristic of service.characteristics) {
if (METRICS_FILTER.includes(characteristic.description)) {
continue
}
if (characteristic.value == null) {
continue
}
const format = characteristic.format const format = characteristic.format
switch (format) { switch (format) {
case 'string': case 'string':
case 'tlv8':
case 'data':
break break
case 'bool': case 'bool':
@ -63,9 +41,12 @@ function extractMetrics(service: Service, timestamp: Date, labels: Labels): Metr
case 'uint16': case 'uint16':
case 'uint32': case 'uint32':
case 'uint64': case 'uint64':
{ if (typeof characteristic.value !== 'undefined') {
if (METRICS_FILTER.includes(characteristic.description)) {
break
}
const name = formatName( const name = formatName(
isKeyOfConstObject(service.type, Uuids) ? Uuids[service.type] : 'custom', uuidToServerName(service.type),
characteristic.description, characteristic.description,
characteristic.unit, characteristic.unit,
) )
@ -77,43 +58,55 @@ function extractMetrics(service: Service, timestamp: Date, labels: Labels): Metr
assertTypeExhausted(format) 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 (
characteristic.value != null &&
characteristic.format === 'string' && characteristic.format === 'string' &&
[ [
'Name', 'Name',
@ -126,9 +119,21 @@ 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
} }
} }
return labels return labels
} }
function uuidToServerName(uuid: string): string {
for (const name of Object.getOwnPropertyNames(HapService)) {
const maybeService = (HapService as unknown as Record<string, unknown>)[name]
if (typeof maybeService === 'function' && 'UUID' in maybeService) {
if ((maybeService as Record<string, string>)['UUID'] === uuid) {
return name
}
}
}
throw new Error(`Could not resolve UUID ${uuid} to service`)
}

View file

@ -1,20 +1,21 @@
import type { API, IndependentPlatformPlugin, Logger, PlatformConfig } from 'homebridge' import { API, IndependentPlatformPlugin, Logger, PlatformConfig } from 'homebridge'
import { aggregate } from './metrics' import { aggregate } from './metrics'
import { hapNodeJsClientDiscover as discover } from './adapters/discovery' import { discover } from './adapters/discovery/hap_node_js_client'
import { type HttpServerController, fastifyServe as serve } from './adapters/http' import { serve } from './adapters/http/fastify'
import { HttpServerController } from './adapters/http/api'
import { PrometheusServer } from './prometheus' import { PrometheusServer } from './prometheus'
import { type Config, ConfigBoundary, checkBoundary } from './boundaries' import { Config, ConfigBoundary } 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) {
this.log.debug('Initializing platform %s', config.platform) this.log.debug('Initializing platform %s', config.platform)
this.config = checkBoundary(ConfigBoundary, config) this.config = ConfigBoundary.parse(config)
this.log.debug('Configuration parsed', this.config) this.log.debug('Configuration parsed', this.config)
@ -27,7 +28,7 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
this.log.debug('Starting Prometheus HTTP server on port %d', this.config.port) this.log.debug('Starting Prometheus HTTP server on port %d', this.config.port)
this.httpServer = new PrometheusServer(this.config, this.log) this.httpServer = new PrometheusServer(this.config.port, this.log, this.config.debug, this.config.prefix)
serve(this.httpServer) serve(this.httpServer)
.then((httpServerController) => { .then((httpServerController) => {
this.log.debug('HTTP server started on port %d', this.config.port) this.log.debug('HTTP server started on port %d', this.config.port)
@ -45,10 +46,17 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
private startHapDiscovery(): void { private startHapDiscovery(): void {
this.log.debug('Starting HAP discovery') this.log.debug('Starting HAP discovery')
discover({ log: this.log, config: this.config }) discover({
logger: this.log,
refreshInterval: this.config.refresh_interval,
discoveryTimeout: this.config.discovery_timeout,
requestTimeout: this.config.request_timeout,
pin: this.config.pin,
debug: this.config.debug,
})
.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,113 +1,7 @@
import type { Logger } from 'homebridge' import { Metric } from './metrics'
import type { HttpConfig, HttpResponse, HttpServer } from './adapters/http' import { Logger } from 'homebridge'
import type { Metric } from './metrics' import { HttpResponse } from './adapters/http/api'
import { strTrimRight } from './std' import { HttpServer } from './http'
import { shim } from 'array.prototype.group'
shim()
export class MetricsRenderer {
private readonly prefix: string
constructor(prefix: string) {
this.prefix = strTrimRight(prefix, '_')
}
render(metrics: Metric[]): 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)
.map(([label, val]) => `${sanitizePrometheusMetricName(label)}="${escapeAttributeValue(val)}"`)
.join(',')
return rendered !== '' ? '{' + rendered + '}' : ''
}
private metricName(name: string): string {
name = name.replace(/^(.*_)?(total)_(.*)$/, '$1$3_$2')
return sanitizePrometheusMetricName(`${this.prefix}_${name}`)
}
}
const retryAfterWhileDiscovery = 15
const textContentType = 'text/plain; charset=utf-8'
const prometheusSpecVersion = '0.0.4'
const metricsContentType = `${textContentType}; version=${prometheusSpecVersion}`
function withHeaders(contentType: string, headers: Record<string, string> = {}): Record<string, string> {
return { ...headers, 'Content-Type': contentType }
}
export class PrometheusServer implements HttpServer {
private metricsDiscovered = false
private metricsResponse = ''
constructor(
public readonly config: HttpConfig,
public readonly log: Logger | null = null,
private readonly renderer: MetricsRenderer = new MetricsRenderer(config.prefix),
) {}
onRequest(): HttpResponse | null {
if (this.metricsDiscovered) {
return null
}
return {
statusCode: 503,
headers: withHeaders(textContentType, { 'Retry-After': String(retryAfterWhileDiscovery) }),
body: 'Metrics discovery pending',
}
}
onMetrics(): HttpResponse {
return {
statusCode: 200,
headers: withHeaders(metricsContentType),
body: this.metricsResponse,
}
}
onNotFound(): HttpResponse {
return {
statusCode: 404,
headers: withHeaders(textContentType),
body: 'Not found. Try /metrics',
}
}
onError(error: Error): HttpResponse {
this.log?.error('HTTP request error: %o', error)
return {
headers: withHeaders(textContentType),
body: error.message,
}
}
onMetricsDiscovery(metrics: Metric[]): void {
this.metricsResponse = this.renderer.render(metrics)
this.metricsDiscovered = true
}
}
// From https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts
function escapeString(str: string) { function escapeString(str: string) {
return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n') return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n')
@ -119,7 +13,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)
} }
@ -149,3 +43,88 @@ const invalidCharacterRegex = /[^a-z0-9_]/gi
function sanitizePrometheusMetricName(name: string): string { function sanitizePrometheusMetricName(name: string): string {
return name.replace(invalidCharacterRegex, '_') // replace all invalid characters with '_' return name.replace(invalidCharacterRegex, '_') // replace all invalid characters with '_'
} }
export class MetricsRenderer {
constructor(private readonly prefix: string) {}
render(metric: Metric): string {
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')
}
private renderLabels(labels: Metric['labels']): string {
const rendered = Object.entries(labels)
.map(([key, value]) => `${sanitizePrometheusMetricName(key)}="${escapeAttributeValue(value)}"`)
.join(',')
return rendered !== '' ? '{' + rendered + '}' : ''
}
private metricName(name: string): string {
name = name.replace(/^(.*_)?(total)_(.*)$/, '$1$3_$2')
return sanitizePrometheusMetricName(this.prefix.replace(/_+$/, '') + '_' + name)
}
}
const contentTypeHeader = { 'Content-Type': 'text/plain; charset=UTF-8' }
export class PrometheusServer implements HttpServer {
private metricsInitialized = false
private metrics: Metric[] = []
constructor(
public readonly port: number,
public readonly log: Logger,
public readonly debug: boolean,
private readonly prefix: string,
) {}
onRequest(): HttpResponse | undefined {
if (!this.metricsInitialized) {
return {
statusCode: 503,
headers: { ...contentTypeHeader, 'Retry-After': '10' },
body: 'Metrics discovery pending',
}
}
}
onMetrics(): HttpResponse {
const renderer = new MetricsRenderer(this.prefix)
const metrics = this.metrics.map((metric) => renderer.render(metric)).join('\n')
return {
statusCode: 200,
headers: contentTypeHeader,
body: metrics,
}
}
onNotFound(): HttpResponse {
return {
statusCode: 404,
headers: contentTypeHeader,
body: 'Not found. Try /metrics',
}
}
onError(error: unknown): HttpResponse {
this.log.error('HTTP request error: %o', error)
return {
statusCode: 500,
headers: contentTypeHeader,
body: 'Server error',
}
}
updateMetrics(metrics: Metric[]): void {
this.metrics = metrics
this.metricsInitialized = true
}
}

View file

@ -1,9 +0,0 @@
import { compare } from 'bcrypt'
export function isAuthenticated(
username: string,
plainPassword: string,
map: Record<string, string>,
): Promise<boolean> {
return compare(plainPassword, map[username] || '')
}

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

@ -1,129 +0,0 @@
import { describe, test } from '@jest/globals'
import request from 'supertest'
import { PrometheusServer } from '../../../src/prometheus'
import { type HttpServer, fastifyServe } from '../../../src/adapters/http'
import { type Server, createServer } from 'http'
import { Metric } from '../../../src/metrics'
class TestablePrometheusServer extends PrometheusServer {
public serverFactory: HttpServer['serverFactory']
}
function createTestServer(): { http: Server; prometheus: HttpServer } {
return createTestServerWithBasicAuth({})
}
function createTestServerWithBasicAuth(basicAuth: Record<string, string>): { http: Server; prometheus: HttpServer } {
const http = createServer()
const prometheus = new TestablePrometheusServer({
port: 0,
interface: 'localhost',
debug: false,
prefix: 'homebridge',
basic_auth: basicAuth,
})
prometheus.serverFactory = (handler) => http.on('request', handler)
fastifyServe(prometheus).catch((err: Error) => {
if (!('code' in err) || (err as unknown as { code: unknown }).code !== 'ERR_SERVER_ALREADY_LISTEN') {
console.debug(err)
}
})
return { http, prometheus }
}
const secretAsBcrypt = '$2b$12$B8C9hsi2idheYOdSM9au0.6DbD6z44iI5dZo.72AYLsAEiNdnqNPG'
describe('Fastify HTTP adapter', () => {
test('Serves 503 everywhere while metrics are not available', () => {
return request(createTestServer().http)
.get('/any-url')
.expect(503)
.expect('Content-Type', 'text/plain; charset=utf-8')
.expect('Retry-After', '15')
.expect('Metrics discovery pending')
})
test('Serves 404 on / when metrics are available', () => {
const testServer = createTestServer()
testServer.prometheus.onMetricsDiscovery([])
return request(testServer.http)
.get('/')
.expect(404)
.expect('Content-Type', 'text/plain; charset=utf-8')
.expect('Not found. Try /metrics')
})
test('Serves metrics', () => {
const testServer = createTestServer()
const timestamp = new Date('2020-01-01 00:00:00 UTC')
testServer.prometheus.onMetricsDiscovery([
new Metric('metric', 0.1, timestamp, { name: 'metric' }),
new Metric('total_something', 100, timestamp, { name: 'counter' }),
])
return request(testServer.http)
.get('/metrics')
.expect(200)
.expect('Content-Type', 'text/plain; charset=utf-8; version=0.0.4')
.expect(
[
'# TYPE homebridge_metric gauge',
'homebridge_metric{name="metric"} 0.1 1577836800000',
'',
'# TYPE homebridge_something_total counter',
'homebridge_something_total{name="counter"} 100 1577836800000',
'',
].join('\n'),
)
})
test('Basic auth denied without user', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
testServer.prometheus.onMetricsDiscovery([])
return request(testServer.http)
.get('/metrics')
.expect(401)
.expect('Content-Type', 'text/plain; charset=utf-8')
.expect('Missing or bad formatted authorization header')
})
test('Basic auth denied with incorrect user', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
testServer.prometheus.onMetricsDiscovery([])
return request(testServer.http)
.get('/metrics')
.auth('john', 'secret')
.expect(401)
.expect('Content-Type', 'text/plain; charset=utf-8')
.expect('Unauthorized')
})
test('Basic auth grants access', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
const timestamp = new Date('2020-01-01 00:00:00 UTC')
testServer.prometheus.onMetricsDiscovery([
new Metric('metric', 0.1, timestamp, { name: 'metric' }),
new Metric('total_something', 100, timestamp, { name: 'counter' }),
])
return request(testServer.http)
.get('/metrics')
.auth('joanna', 'secret')
.expect(200)
.expect('Content-Type', 'text/plain; charset=utf-8; version=0.0.4')
.expect(
[
'# TYPE homebridge_metric gauge',
'homebridge_metric{name="metric"} 0.1 1577836800000',
'',
'# TYPE homebridge_something_total counter',
'homebridge_something_total{name="counter"} 100 1577836800000',
'',
].join('\n'),
)
})
})

View file

@ -1,12 +1,10 @@
import { describe, expect, test } from '@jest/globals' import { describe, expect, test } from '@jest/globals'
import { DeviceBoundary } from '../src/boundaries' import { DeviceBoundary } from '../src/boundaries/hap'
import { Metric, aggregate } from '../src/metrics' import { Metric, aggregate } from '../src/metrics'
import dysonData from './fixtures/dyson.json' import dysonData from './fixtures/dyson.json'
import emptyData from './fixtures/empty.json' import emptyData from './fixtures/empty.json'
import tpLinkData from './fixtures/tp-link.json' import tpLinkData from './fixtures/tp-link.json'
import harmonyData from './fixtures/harmony.json' import harmonyData from './fixtures/harmony.json'
import unknownUuidData from './fixtures/issues/gh-9-unknown-uuid.json'
import nullableValueData from './fixtures/issues/gh-19-nullable-value.json'
describe('Metrics aggregator', () => { describe('Metrics aggregator', () => {
const timestamp = new Date('2000-01-01 00:00:00 UTC') const timestamp = new Date('2000-01-01 00:00:00 UTC')
@ -150,32 +148,10 @@ describe('Metrics aggregator', () => {
new Metric('input_source_current_visibility_state', 0, timestamp, expectedLabels3), new Metric('input_source_current_visibility_state', 0, timestamp, expectedLabels3),
new Metric('input_source_target_visibility_state', 0, timestamp, expectedLabels3), new Metric('input_source_target_visibility_state', 0, timestamp, expectedLabels3),
new Metric('television_speaker_active', 1, timestamp, expectedLabels4), new Metric('speaker_active', 1, timestamp, expectedLabels4),
new Metric('television_speaker_volume_control_type', 3, timestamp, expectedLabels4), new Metric('speaker_volume_control_type', 3, timestamp, expectedLabels4),
new Metric('television_speaker_mute', 0, timestamp, expectedLabels4), new Metric('speaker_mute', 0, timestamp, expectedLabels4),
new Metric('television_speaker_volume_percentage', 50, timestamp, expectedLabels4), new Metric('speaker_volume_percentage', 50, timestamp, expectedLabels4),
]) ])
}) })
test('Aggregates metrics with unknown service UUID as "custom"', () => {
const expectedLabels = {
bridge: 'Test bridge',
device_id: 'AA:AA:AA:AA:AA:AA',
name: 'Phoscon-GW',
}
const unknowmnUuid = DeviceBoundary.parse(unknownUuidData)
expect(aggregate([unknowmnUuid], timestamp)).toEqual([
new Metric('custom_heartrate_seconds', 5, timestamp, expectedLabels),
new Metric('custom_transition_time_seconds', 0.4, timestamp, expectedLabels),
new Metric('custom_restart', 0, timestamp, expectedLabels),
])
})
test('Aggregates metrics with nullable values', () => {
const unknowmnValue = DeviceBoundary.parse(nullableValueData)
expect(aggregate([unknowmnValue], timestamp)).toEqual([])
})
}) })

6
tests/ambient.d.ts vendored
View file

@ -1,6 +0,0 @@
import type { SuperTest, Test } from 'supertest'
import type { Server } from 'http'
declare module 'supertest' {
function supertest(app: Server): SuperTest<Test>
}

View file

@ -1,45 +0,0 @@
import { describe, expect, test } from '@jest/globals'
import z from 'zod'
import { checkBoundary } from '../../src/boundaries'
const TestBoundary = z.object({
member: z.literal('something'),
anotherMember: z.optional(z.literal('something else')),
yetAnotherMember: z.optional(
z.array(
z.object({
member: z.literal('member'),
}),
),
),
})
describe('Test boundary checker', () => {
test('Returns checked data after successful check', () => {
const result = checkBoundary(TestBoundary, { member: 'something' })
expect(result).toEqual({ member: 'something' })
})
test('Returns error and insightful error message on failing check for simple string', () => {
expect(() => checkBoundary(z.string(), 123)).toThrow(
'[invalid_type] Expected string, received number (data is "123")',
)
})
test('Returns error and insightful error message on failing check for nested object', () => {
expect(() =>
checkBoundary(TestBoundary, {
member: 'something else',
anotherMember: 'unexpected',
yetAnotherMember: [{ foo: 123 }],
}),
).toThrow(
[
'[invalid_literal] Invalid literal value, expected "something" at path "member" (data at resolved path "member" is ""something else"") | ',
'[invalid_literal] Invalid literal value, expected "something else" at path "anotherMember" (data at resolved path "anotherMember" is ""unexpected"") | ',
'[invalid_literal] Invalid literal value, expected "member" at path "yetAnotherMember.[0].member" (data at resolved path "yetAnotherMember.[0]" is "{"foo":123}")',
].join(''),
)
})
})

View file

@ -1,48 +0,0 @@
{
"ipAddress": "192.168.0.1",
"instance": {
"host": "192.168.0.1",
"port": 51826,
"url": "http://192.168.0.1:51826",
"deviceID": "AA:AA:AA:AA:AA:AA",
"txt": {
"c#": "5",
"ff": "0",
"id": "AA:AA:AA:AA:AA:AA",
"md": "homebridge",
"pv": "1.1",
"s#": "1",
"sf": "0",
"ci": "2",
"sh": "Hv0v9A=="
},
"name": "Test bridge"
},
"accessories": {
"accessories": [
{
"aid": 3,
"services": [
{
"type": "00000012-0000-1000-8000-656261617577",
"iid": 8,
"characteristics": [
{
"type": "00000073-0000-1000-8000-0026BB765291",
"iid": 11,
"value": null,
"perms": ["ev", "pr"],
"description": "Programmable Switch Event",
"format": "uint8",
"minValue": 0,
"maxValue": 2,
"minStep": 1,
"valid-values": [0]
}
]
}
]
}
]
}
}

View file

@ -1,85 +0,0 @@
{
"ipAddress": "192.168.0.1",
"instance": {
"host": "192.168.0.1",
"port": 51826,
"url": "http://192.168.0.1:51826",
"deviceID": "AA:AA:AA:AA:AA:AA",
"txt": {
"c#": "5",
"ff": "0",
"id": "AA:AA:AA:AA:AA:AA",
"md": "homebridge",
"pv": "1.1",
"s#": "1",
"sf": "0",
"ci": "2",
"sh": "Hv0v9A=="
},
"name": "Test bridge"
},
"accessories": {
"accessories": [
{
"aid": 3,
"services": [
{
"type": "00000012-0000-1000-8000-656261617577",
"iid": 8,
"characteristics": [
{
"type": "23",
"iid": 9,
"value": "Phoscon-GW",
"perms": ["pr"],
"description": "Name",
"format": "string",
"maxLen": 64
},
{
"type": "00000024-0000-1000-8000-656261617577",
"iid": 10,
"value": 5,
"perms": ["pr", "ev", "pw"],
"description": "Heartrate",
"format": "uint16",
"unit": "seconds",
"minValue": 1,
"maxValue": 30,
"minStep": 1
},
{
"type": "00000023-0000-1000-8000-656261617577",
"iid": 11,
"value": "Thu Mar 04 2021 22:57:10",
"perms": ["pr", "ev"],
"description": "Last Updated",
"format": "string"
},
{
"type": "0000002F-0000-1000-8000-656261617577",
"iid": 12,
"value": 0.4,
"perms": ["pr", "ev", "pw"],
"description": "Transition Time",
"format": "float",
"unit": "seconds",
"minValue": 0,
"maxValue": 3600,
"minStep": 0.1
},
{
"type": "00000058-0000-1000-8000-656261617577",
"iid": 13,
"value": 0,
"perms": ["pr", "ev", "pw"],
"description": "Restart",
"format": "bool"
}
]
}
]
}
]
}
}

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,11 +10,8 @@
"rootDir": "./", "rootDir": "./",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": false,
"noImplicitAny": true, "noImplicitAny": true,
"resolveJsonModule": true, "resolveJsonModule": true
"tsBuildInfoFile": ".tsbuildinfo",
"incremental": true
}, },
"include": ["src/", "tests/"], "include": ["src/", "tests/"],
"exclude": [] "exclude": []