Compare commits
No commits in common. "develop" and "v0.0.6" have entirely different histories.
48 changed files with 3100 additions and 10677 deletions
1
.envrc
1
.envrc
|
@ -1 +0,0 @@
|
||||||
use flake
|
|
|
@ -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,
|
||||||
|
|
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
@ -1,2 +1 @@
|
||||||
github: lstrojny
|
github: lstrojny
|
||||||
custom: ["https://paypal.me/larsstrojny"]
|
|
||||||
|
|
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
|
@ -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
|
|
||||||
|
|
71
.github/workflows/build.yml
vendored
71
.github/workflows/build.yml
vendored
|
@ -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
|
||||||
|
|
28
.github/workflows/dependabot-automerge.yml
vendored
28
.github/workflows/dependabot-automerge.yml
vendored
|
@ -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 }}
|
|
55
.github/workflows/sonar.yml
vendored
55
.github/workflows/sonar.yml
vendored
|
@ -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 }}
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -125,5 +125,3 @@ dist
|
||||||
|
|
||||||
/.homebridge/accessories/
|
/.homebridge/accessories/
|
||||||
/.homebridge/persist/
|
/.homebridge/persist/
|
||||||
|
|
||||||
release.sh
|
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
102
README.md
102
README.md
|
@ -11,8 +11,7 @@
|
||||||
width="10%"/>
|
width="10%"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Homebridge Prometheus Exporter
|
# Homebridge Prometheus Exporter [](https://github.com/lstrojny/homebridge-prometheus-exporter/actions/workflows/build.yml)
|
||||||
[](https://github.com/lstrojny/homebridge-prometheus-exporter/actions/workflows/build.yml) [](https://sonarcloud.io/summary/new_code?id=lstrojny_homebridge-prometheus-exporter) [](https://badge.fury.io/js/homebridge-prometheus-exporter)  
|
|
||||||
|
|
||||||
> What if we could store homebridge metrics in Prometheus
|
> What if we could store homebridge metrics in Prometheus
|
||||||
|
|
||||||
|
@ -64,7 +63,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 +116,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 -->
|
|
|
@ -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))}", don’t 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}`)
|
|
|
@ -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))}", don’t 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}`)
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 ]; };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,5 +3,4 @@ module.exports = {
|
||||||
preset: 'ts-jest',
|
preset: 'ts-jest',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
verbose: true,
|
verbose: true,
|
||||||
testPathIgnorePatterns: ['dist'],
|
|
||||||
}
|
}
|
||||||
|
|
12131
package-lock.json
generated
12131
package-lock.json
generated
File diff suppressed because it is too large
Load diff
37
package.json
37
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "homebridge-prometheus-exporter",
|
"name": "homebridge-prometheus-exporter",
|
||||||
"version": "1.0.5",
|
"version": "0.0.6",
|
||||||
"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,13 @@
|
||||||
},
|
},
|
||||||
"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() { test \"$1\" && npm version $1 && npm publish && git push origin develop && git push origin --tags; }; release"
|
||||||
"release": "release-it --only-version"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"homebridge-plugin",
|
"homebridge-plugin",
|
||||||
|
@ -34,37 +32,32 @@
|
||||||
],
|
],
|
||||||
"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",
|
"@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",
|
"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": "^0.1.25",
|
||||||
"zod": "^3.19.1"
|
"zod": "^3.19.1"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"jest-mock": "29.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/**
|
|
|
@ -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[]>
|
|
||||||
|
|
|
@ -1,66 +1,64 @@
|
||||||
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 {
|
try {
|
||||||
logger?.debug('Reusing existing HAP client')
|
const devices: Device[] = []
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDiscoveryHandler(logger: Logger | undefined, key: string): (deviceData: unknown) => void {
|
for (const device of MaybeDevices.parse(deviceData)) {
|
||||||
return (deviceData: unknown) => {
|
try {
|
||||||
try {
|
devices.push(DeviceBoundary.parse(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 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,
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './api'
|
|
||||||
export * from './hap_node_js_client'
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
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) {
|
||||||
|
@ -20,61 +16,11 @@ function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
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) => {
|
||||||
const response = server.onRequest()
|
const response = server.onRequest()
|
||||||
|
@ -98,7 +44,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 +52,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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './api'
|
|
||||||
export * from './fastify'
|
|
9
src/ambient.d.ts
vendored
9
src/ambient.d.ts
vendored
|
@ -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[] }
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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),
|
||||||
|
})
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import z, { type ZodNull, type ZodOptional, type ZodType, type ZodUnion } from 'zod'
|
import z from 'zod'
|
||||||
|
|
||||||
const NumericTypesBoundary = z.union([
|
const NumericTypesBoundary = z.union([
|
||||||
z.literal('bool'),
|
z.literal('bool'),
|
||||||
|
@ -11,21 +11,17 @@ const NumericTypesBoundary = z.union([
|
||||||
])
|
])
|
||||||
export type NumericTypes = z.infer<typeof NumericTypesBoundary>
|
export type NumericTypes = z.infer<typeof NumericTypesBoundary>
|
||||||
|
|
||||||
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: NumericTypesBoundary,
|
||||||
|
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('data'), value: z.optional(z.string()) }),
|
||||||
z.object({ format: z.literal('tlv8'), value: optionalNullable(z.string()) }),
|
z.object({ format: z.literal('tlv8'), value: z.string(z.string()) }),
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
export type Characteristic = z.infer<typeof CharacteristicBoundary>
|
export type Characteristic = z.infer<typeof CharacteristicBoundary>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
// Auto-generated by "code-generation/config-scheme-gen.js", don’t 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(),
|
|
||||||
})
|
|
|
@ -1,164 +0,0 @@
|
||||||
// Auto-generated by "code-generation/hap-gen.js", don’t 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
|
|
16
src/http.ts
Normal file
16
src/http.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { HttpResponse } from './adapters/http/api'
|
||||||
|
import { Metric } from './metrics'
|
||||||
|
import { Logger } from 'homebridge'
|
||||||
|
import { RequestListener, Server } from 'http'
|
||||||
|
|
||||||
|
export interface HttpServer {
|
||||||
|
port: number
|
||||||
|
debug: boolean
|
||||||
|
log?: Logger
|
||||||
|
serverFactory?: (requestListener: RequestListener) => Server
|
||||||
|
onRequest(): HttpResponse | undefined
|
||||||
|
onMetrics(): HttpResponse
|
||||||
|
onNotFound(): HttpResponse
|
||||||
|
onError(error: unknown): HttpResponse
|
||||||
|
updateMetrics(metrics: Metric[]): void
|
||||||
|
}
|
|
@ -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'
|
||||||
|
|
129
src/metrics.ts
129
src/metrics.ts
|
@ -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,91 +28,87 @@ 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 (typeof characteristic.value !== 'undefined') {
|
||||||
|
if (METRICS_FILTER.includes(characteristic.description)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const name = formatName(
|
||||||
|
uuidToServerName(service.type),
|
||||||
|
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 (
|
||||||
characteristic.value != null &&
|
|
||||||
characteristic.format === 'string' &&
|
characteristic.format === 'string' &&
|
||||||
[
|
[
|
||||||
'Name',
|
'Name',
|
||||||
|
@ -126,9 +121,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,39 +1,24 @@
|
||||||
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 {
|
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,68 +27,71 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryAfterWhileDiscovery = 15
|
const retryAfterWhileDiscovery = 15
|
||||||
const textContentType = 'text/plain; charset=utf-8'
|
const textContentType = 'text/plain; charset=utf-8'
|
||||||
const prometheusSpecVersion = '0.0.4'
|
const prometheusSpecVersion = '0.0.4'
|
||||||
const metricsContentType = `${textContentType}; version=${prometheusSpecVersion}`
|
const metricsContentType = `text/plain; 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 port: number,
|
||||||
public readonly log: Logger | null = null,
|
public readonly log: Logger | undefined,
|
||||||
private readonly renderer: MetricsRenderer = new MetricsRenderer(config.prefix),
|
public readonly debug: boolean,
|
||||||
|
private readonly prefix: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
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.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',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onError(error: Error): HttpResponse {
|
onError(error: unknown): HttpResponse {
|
||||||
this.log?.error('HTTP request error: %o', error)
|
this.log?.error('HTTP request error: %o', error)
|
||||||
return {
|
return {
|
||||||
headers: withHeaders(textContentType),
|
statusCode: 500,
|
||||||
body: error.message,
|
headers: headers(textContentType),
|
||||||
|
body: 'Server error',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +107,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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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] || '')
|
|
||||||
}
|
|
30
src/std.ts
30
src/std.ts
|
@ -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}]+`), ''))
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { describe, test } from '@jest/globals'
|
import { describe, test } from '@jest/globals'
|
||||||
import request from 'supertest'
|
import request from 'supertest'
|
||||||
import { PrometheusServer } from '../../../src/prometheus'
|
import { PrometheusServer } from '../../../src/prometheus'
|
||||||
import { type HttpServer, fastifyServe } from '../../../src/adapters/http'
|
import { serve } from '../../../src/adapters/http/fastify'
|
||||||
import { type Server, createServer } from 'http'
|
import { Server, createServer } from 'http'
|
||||||
|
import { HttpServer } from '../../../src/http'
|
||||||
import { Metric } from '../../../src/metrics'
|
import { Metric } from '../../../src/metrics'
|
||||||
|
|
||||||
class TestablePrometheusServer extends PrometheusServer {
|
class TestablePrometheusServer extends PrometheusServer {
|
||||||
|
@ -10,20 +11,10 @@ class TestablePrometheusServer extends PrometheusServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTestServer(): { http: Server; prometheus: HttpServer } {
|
function createTestServer(): { http: Server; prometheus: HttpServer } {
|
||||||
return createTestServerWithBasicAuth({})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestServerWithBasicAuth(basicAuth: Record<string, string>): { http: Server; prometheus: HttpServer } {
|
|
||||||
const http = createServer()
|
const http = createServer()
|
||||||
const prometheus = new TestablePrometheusServer({
|
const prometheus = new TestablePrometheusServer(0, undefined, false, 'homebridge')
|
||||||
port: 0,
|
|
||||||
interface: 'localhost',
|
|
||||||
debug: false,
|
|
||||||
prefix: 'homebridge',
|
|
||||||
basic_auth: basicAuth,
|
|
||||||
})
|
|
||||||
prometheus.serverFactory = (handler) => http.on('request', handler)
|
prometheus.serverFactory = (handler) => http.on('request', handler)
|
||||||
fastifyServe(prometheus).catch((err: Error) => {
|
serve(prometheus).catch((err: Error) => {
|
||||||
if (!('code' in err) || (err as unknown as { code: unknown }).code !== 'ERR_SERVER_ALREADY_LISTEN') {
|
if (!('code' in err) || (err as unknown as { code: unknown }).code !== 'ERR_SERVER_ALREADY_LISTEN') {
|
||||||
console.debug(err)
|
console.debug(err)
|
||||||
}
|
}
|
||||||
|
@ -32,8 +23,6 @@ function createTestServerWithBasicAuth(basicAuth: Record<string, string>): { htt
|
||||||
return { http, prometheus }
|
return { http, prometheus }
|
||||||
}
|
}
|
||||||
|
|
||||||
const secretAsBcrypt = '$2b$12$B8C9hsi2idheYOdSM9au0.6DbD6z44iI5dZo.72AYLsAEiNdnqNPG'
|
|
||||||
|
|
||||||
describe('Fastify HTTP adapter', () => {
|
describe('Fastify HTTP adapter', () => {
|
||||||
test('Serves 503 everywhere while metrics are not available', () => {
|
test('Serves 503 everywhere while metrics are not available', () => {
|
||||||
return request(createTestServer().http)
|
return request(createTestServer().http)
|
||||||
|
@ -46,7 +35,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 +47,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' }),
|
||||||
])
|
])
|
||||||
|
@ -66,63 +55,13 @@ describe('Fastify HTTP adapter', () => {
|
||||||
return request(testServer.http)
|
return request(testServer.http)
|
||||||
.get('/metrics')
|
.get('/metrics')
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect('Content-Type', 'text/plain; charset=utf-8; version=0.0.4')
|
.expect('Content-Type', 'text/plain; version=0.0.4')
|
||||||
.expect(
|
.expect(
|
||||||
[
|
[
|
||||||
'# 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'),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
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'),
|
].join('\n'),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,7 +6,6 @@ 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 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,10 +149,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),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -172,10 +171,4 @@ describe('Metrics aggregator', () => {
|
||||||
new Metric('custom_restart', 0, 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([])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
4
tests/ambient.d.ts
vendored
4
tests/ambient.d.ts
vendored
|
@ -1,5 +1,5 @@
|
||||||
import type { SuperTest, Test } from 'supertest'
|
import { SuperTest, Test } from 'supertest'
|
||||||
import type { Server } from 'http'
|
import { Server } from 'http'
|
||||||
|
|
||||||
declare module 'supertest' {
|
declare module 'supertest' {
|
||||||
function supertest(app: Server): SuperTest<Test>
|
function supertest(app: Server): SuperTest<Test>
|
||||||
|
|
|
@ -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(''),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
48
tests/fixtures/issues/gh-19-nullable-value.json
vendored
48
tests/fixtures/issues/gh-19-nullable-value.json
vendored
|
@ -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]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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`,
|
||||||
`,
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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": []
|
||||||
|
|
Loading…
Add table
Reference in a new issue