feat: bootstrap typhon exporter with jenkins quality gate
This commit is contained in:
commit
c3a92a88e1
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
npm-debug.log*
|
||||||
|
AGENTS.md
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY tsconfig.json jest.config.cjs ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY tests ./tests
|
||||||
|
|
||||||
|
RUN npm run lint
|
||||||
|
RUN npm run test:ci
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
|
EXPOSE 9108
|
||||||
|
USER node
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
305
Jenkinsfile
vendored
Normal file
305
Jenkinsfile
vendored
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
pipeline {
|
||||||
|
agent {
|
||||||
|
kubernetes {
|
||||||
|
defaultContainer 'node'
|
||||||
|
yaml """
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
spec:
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/arch: arm64
|
||||||
|
node-role.kubernetes.io/worker: "true"
|
||||||
|
containers:
|
||||||
|
- name: dind
|
||||||
|
image: docker:27-dind
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
env:
|
||||||
|
- name: DOCKER_TLS_CERTDIR
|
||||||
|
value: ""
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
args:
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
exec dockerd-entrypoint.sh --mtu=1400 --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375
|
||||||
|
volumeMounts:
|
||||||
|
- name: dind-storage
|
||||||
|
mountPath: /var/lib/docker
|
||||||
|
- name: workspace-volume
|
||||||
|
mountPath: /home/jenkins/agent
|
||||||
|
- name: docker
|
||||||
|
image: docker:27
|
||||||
|
command: ['cat']
|
||||||
|
tty: true
|
||||||
|
env:
|
||||||
|
- name: DOCKER_HOST
|
||||||
|
value: tcp://localhost:2375
|
||||||
|
- name: DOCKER_TLS_CERTDIR
|
||||||
|
value: ""
|
||||||
|
volumeMounts:
|
||||||
|
- name: docker-config-writable
|
||||||
|
mountPath: /root/.docker
|
||||||
|
- name: docker-config-secret
|
||||||
|
mountPath: /docker-config
|
||||||
|
- name: workspace-volume
|
||||||
|
mountPath: /home/jenkins/agent
|
||||||
|
- name: node
|
||||||
|
image: node:22-bookworm
|
||||||
|
command: ['cat']
|
||||||
|
tty: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: workspace-volume
|
||||||
|
mountPath: /home/jenkins/agent
|
||||||
|
volumes:
|
||||||
|
- name: docker-config-secret
|
||||||
|
secret:
|
||||||
|
secretName: harbor-robot-pipeline
|
||||||
|
items:
|
||||||
|
- key: .dockerconfigjson
|
||||||
|
path: config.json
|
||||||
|
- name: docker-config-writable
|
||||||
|
emptyDir: {}
|
||||||
|
- name: dind-storage
|
||||||
|
emptyDir: {}
|
||||||
|
- name: workspace-volume
|
||||||
|
emptyDir: {}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
timestamps()
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
booleanParam(
|
||||||
|
name: 'PUBLISH_IMAGE',
|
||||||
|
defaultValue: true,
|
||||||
|
description: 'Build and push typhon image to Harbor on successful quality gate.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
SUITE_NAME = 'typhon'
|
||||||
|
PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091'
|
||||||
|
IMAGE_REPO = 'registry.bstein.dev/bstein/typhon'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Prep Docker Auth') {
|
||||||
|
steps {
|
||||||
|
container('docker') {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /root/.docker
|
||||||
|
cp /docker-config/config.json /root/.docker/config.json
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Quality Gate') {
|
||||||
|
steps {
|
||||||
|
container('node') {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
npm ci
|
||||||
|
npm run lint
|
||||||
|
npm run test:ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
mkdir -p build
|
||||||
|
APP_VERSION="$(node -p \"require('./package.json').version\")"
|
||||||
|
echo "APP_VERSION=${APP_VERSION}" > build/version.env
|
||||||
|
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const junitPath = 'build/junit-typhon.xml';
|
||||||
|
const coveragePath = 'coverage/coverage-summary.json';
|
||||||
|
|
||||||
|
if (!fs.existsSync(junitPath)) {
|
||||||
|
console.error('missing junit report:', junitPath);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(coveragePath)) {
|
||||||
|
console.error('missing coverage summary:', coveragePath);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const junit = fs.readFileSync(junitPath, 'utf8');
|
||||||
|
const tests = Number((junit.match(/tests="(\d+)"/) || [])[1] || 0);
|
||||||
|
const failures = Number((junit.match(/failures="(\d+)"/) || [])[1] || 0);
|
||||||
|
const errors = Number((junit.match(/errors="(\d+)"/) || [])[1] || 0);
|
||||||
|
const skipped = Number((junit.match(/skipped="(\d+)"/) || [])[1] || 0);
|
||||||
|
|
||||||
|
const cov = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
|
||||||
|
const total = cov.total || {};
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
suite: 'typhon',
|
||||||
|
tests: { tests, failures, errors, skipped },
|
||||||
|
coverage: {
|
||||||
|
lines: total.lines?.pct ?? 0,
|
||||||
|
statements: total.statements?.pct ?? 0,
|
||||||
|
functions: total.functions?.pct ?? 0,
|
||||||
|
branches: total.branches?.pct ?? 0
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
functions: 85,
|
||||||
|
branches: 75
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2));
|
||||||
|
NODE
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Publish Test Metrics') {
|
||||||
|
steps {
|
||||||
|
container('node') {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const gateway = process.env.PUSHGATEWAY_URL;
|
||||||
|
const suite = process.env.SUITE_NAME || 'typhon';
|
||||||
|
const qualityPath = 'build/quality-gate.json';
|
||||||
|
|
||||||
|
if (!fs.existsSync(qualityPath)) {
|
||||||
|
throw new Error('quality report missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8'));
|
||||||
|
const status = quality.tests.failures > 0 || quality.tests.errors > 0 ? 'failed' : 'ok';
|
||||||
|
|
||||||
|
function fetchCounter(targetStatus) {
|
||||||
|
try {
|
||||||
|
const metrics = execSync(`curl -fsS ${gateway}/metrics`, { encoding: 'utf8' });
|
||||||
|
const re = new RegExp(`platform_quality_gate_runs_total\\{[^}]*suite=\\"${suite}\\"[^}]*status=\\"${targetStatus}\\"[^}]*\\}\\s+(\\d+(?:\\.\\d+)?)`);
|
||||||
|
const match = metrics.match(re);
|
||||||
|
if (!match) return 0;
|
||||||
|
return Number(match[1]);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = fetchCounter('ok');
|
||||||
|
let failed = fetchCounter('failed');
|
||||||
|
if (status === 'ok') ok += 1;
|
||||||
|
if (status === 'failed') failed += 1;
|
||||||
|
|
||||||
|
const payload = [
|
||||||
|
'# TYPE platform_quality_gate_runs_total counter',
|
||||||
|
`platform_quality_gate_runs_total{suite="${suite}",status="ok"} ${ok}`,
|
||||||
|
`platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed}`,
|
||||||
|
'# TYPE typhon_quality_gate_tests_total gauge',
|
||||||
|
`typhon_quality_gate_tests_total{suite="${suite}",result="total"} ${quality.tests.tests}`,
|
||||||
|
`typhon_quality_gate_tests_total{suite="${suite}",result="failures"} ${quality.tests.failures}`,
|
||||||
|
`typhon_quality_gate_tests_total{suite="${suite}",result="errors"} ${quality.tests.errors}`,
|
||||||
|
`typhon_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`,
|
||||||
|
'# TYPE typhon_quality_gate_coverage_percent gauge',
|
||||||
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="lines"} ${quality.coverage.lines}`,
|
||||||
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`,
|
||||||
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="functions"} ${quality.coverage.functions}`,
|
||||||
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`,
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, {
|
||||||
|
input: payload,
|
||||||
|
stdio: ['pipe', 'inherit', 'inherit']
|
||||||
|
});
|
||||||
|
NODE
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Buildx Setup') {
|
||||||
|
when {
|
||||||
|
expression { return params.PUBLISH_IMAGE }
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
container('docker') {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
seq 1 20 | while read _; do
|
||||||
|
docker info >/dev/null 2>&1 && break || sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
BUILDER_NAME="typhon-${BUILD_NUMBER}"
|
||||||
|
docker buildx rm "${BUILDER_NAME}" >/dev/null 2>&1 || true
|
||||||
|
docker buildx create --name "${BUILDER_NAME}" --driver docker-container --bootstrap --use
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build & Push Image') {
|
||||||
|
when {
|
||||||
|
expression { return params.PUBLISH_IMAGE }
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
container('docker') {
|
||||||
|
withCredentials([
|
||||||
|
usernamePassword(
|
||||||
|
credentialsId: 'harbor-robot',
|
||||||
|
usernameVariable: 'HARBOR_USERNAME',
|
||||||
|
passwordVariable: 'HARBOR_PASSWORD'
|
||||||
|
)
|
||||||
|
]) {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
. build/version.env
|
||||||
|
SHORT_SHA="$(git rev-parse --short HEAD)"
|
||||||
|
IMAGE_TAG="${APP_VERSION}-${BUILD_NUMBER}-${SHORT_SHA}"
|
||||||
|
|
||||||
|
printf '%s' "${HARBOR_PASSWORD}" | docker login registry.bstein.dev -u "${HARBOR_USERNAME}" --password-stdin
|
||||||
|
|
||||||
|
docker buildx build --platform linux/arm64 \
|
||||||
|
--provenance=false \
|
||||||
|
--tag "${IMAGE_REPO}:${IMAGE_TAG}" \
|
||||||
|
--tag "${IMAGE_REPO}:main" \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "IMAGE_REPO=${IMAGE_REPO}"
|
||||||
|
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||||
|
echo "IMAGE_CHANNEL_TAG=main"
|
||||||
|
} > build/image.env
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
script {
|
||||||
|
if (fileExists('build/junit-typhon.xml')) {
|
||||||
|
junit allowEmptyResults: true, testResults: 'build/junit-typhon.xml'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
archiveArtifacts artifacts: 'build/**,dist/**,coverage/**', allowEmptyArchive: true, fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
README.md
Normal file
57
README.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# typhon
|
||||||
|
|
||||||
|
`typhon` is an AC Infinity telemetry exporter that polls the AC Infinity cloud API and exposes Prometheus metrics for Grafana dashboards and alerting.
|
||||||
|
|
||||||
|
Stage 1 scope:
|
||||||
|
- ingest controller climate telemetry (temperature, humidity, VPD)
|
||||||
|
- ingest fan/port telemetry (online state, power state, speed, mode)
|
||||||
|
- expose `/metrics` + `/healthz`
|
||||||
|
|
||||||
|
Stage 2 scope:
|
||||||
|
- add authenticated control APIs and UI for `climate.bstein.dev`
|
||||||
|
|
||||||
|
## API behavior (based on homebridge-acinfinity)
|
||||||
|
|
||||||
|
This implementation follows the same API conventions used by `keithah/homebridge-acinfinity`:
|
||||||
|
- host: `http://www.acinfinityserver.com`
|
||||||
|
- login endpoint: `/api/user/appUserLogin`
|
||||||
|
- list endpoint: `/api/user/devInfoListAll`
|
||||||
|
- mode endpoint: `/api/dev/getdevModeSettingList`
|
||||||
|
- auth header: `token: <appId>`
|
||||||
|
- request headers include `phoneType=1`, `appVersion=1.9.7`, and `User-Agent` matching the official app style
|
||||||
|
- password field key is intentionally `appPasswordl`
|
||||||
|
- API only honors the first 25 chars of password
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- `ACI_EMAIL` (required)
|
||||||
|
- `ACI_PASSWORD` (required, use <= 25 chars for reliability)
|
||||||
|
- `ACI_HOST` (optional, default `http://www.acinfinityserver.com`)
|
||||||
|
- `POLL_INTERVAL_SECONDS` (optional, default `30`)
|
||||||
|
- `REQUEST_TIMEOUT_MS` (optional, default `10000`)
|
||||||
|
- `LISTEN_PORT` (optional, default `9108`)
|
||||||
|
- `LOG_LEVEL` (optional, default `info`)
|
||||||
|
|
||||||
|
Kubernetes runtime is expected to source `ACI_EMAIL`/`ACI_PASSWORD` from Vault.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ACI_EMAIL='you@example.com' \
|
||||||
|
ACI_PASSWORD='your-password' \
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Metrics endpoint:
|
||||||
|
- `http://localhost:9108/metrics`
|
||||||
|
- `http://localhost:9108/healthz`
|
||||||
32
jest.config.cjs
Normal file
32
jest.config.cjs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
roots: ["<rootDir>/tests"],
|
||||||
|
testMatch: ["**/*.test.ts"],
|
||||||
|
testTimeout: 15000,
|
||||||
|
collectCoverageFrom: ["src/**/*.ts"],
|
||||||
|
coveragePathIgnorePatterns: ["/node_modules/", "/dist/"],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 85,
|
||||||
|
branches: 75,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reporters: [
|
||||||
|
"default",
|
||||||
|
[
|
||||||
|
"jest-junit",
|
||||||
|
{
|
||||||
|
outputDirectory: "build",
|
||||||
|
outputName: "junit-typhon.xml",
|
||||||
|
suiteName: "typhon",
|
||||||
|
classNameTemplate: "{classname}",
|
||||||
|
titleTemplate: "{title}",
|
||||||
|
ancestorSeparator: " › "
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
||||||
4485
package-lock.json
generated
Normal file
4485
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "typhon",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "AC Infinity climate ingestion exporter for Prometheus",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"lint": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "jest --runInBand",
|
||||||
|
"test:ci": "mkdir -p build && jest --ci --runInBand --coverage --coverageReporters=text-summary --coverageReporters=cobertura --coverageReporters=json-summary"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
|
"undici": "^7.16.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.15.3",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-junit": "^16.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
scripts/manual_phase1_smoke.sh
Executable file
77
scripts/manual_phase1_smoke.sh
Executable file
@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
echo "usage: $0 /tmp/typhon-aci.env" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENV_FILE="$1"
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "missing env file: $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${ACI_EMAIL:?ACI_EMAIL is required}"
|
||||||
|
: "${ACI_PASSWORD:?ACI_PASSWORD is required}"
|
||||||
|
|
||||||
|
export POLL_INTERVAL_SECONDS="${POLL_INTERVAL_SECONDS:-10}"
|
||||||
|
export REQUEST_TIMEOUT_MS="${REQUEST_TIMEOUT_MS:-10000}"
|
||||||
|
export LISTEN_PORT="${LISTEN_PORT:-9108}"
|
||||||
|
export LOG_LEVEL="${LOG_LEVEL:-info}"
|
||||||
|
|
||||||
|
LOG_FILE="/tmp/typhon-manual-smoke.log"
|
||||||
|
METRICS_FILE="/tmp/typhon-manual-smoke.metrics"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "${APP_PID:-}" ]]; then
|
||||||
|
kill "$APP_PID" 2>/dev/null || true
|
||||||
|
wait "$APP_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
npm run dev >"$LOG_FILE" 2>&1 &
|
||||||
|
APP_PID=$!
|
||||||
|
|
||||||
|
for _ in {1..60}; do
|
||||||
|
if curl -fsS "http://127.0.0.1:${LISTEN_PORT}/healthz" >/dev/null 2>&1; then
|
||||||
|
curl -fsS "http://127.0.0.1:${LISTEN_PORT}/metrics" >"$METRICS_FILE"
|
||||||
|
if rg -q '^typhon_up 1$' "$METRICS_FILE"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -fsS "http://127.0.0.1:${LISTEN_PORT}/metrics" >"$METRICS_FILE"
|
||||||
|
|
||||||
|
echo "=== Required metrics ==="
|
||||||
|
rg -n "^typhon_up|^typhon_build_info|^typhon_poll_errors_total|^typhon_temperature_celsius|^typhon_relative_humidity_percent|^typhon_vpd_kpa|^typhon_fan_speed_level" "$METRICS_FILE" -S || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
if rg -q '^typhon_up 1$' "$METRICS_FILE"; then
|
||||||
|
TEMP_LINE="$(rg '^typhon_temperature_celsius' "$METRICS_FILE" -n -S | head -n 1 || true)"
|
||||||
|
HUMID_LINE="$(rg '^typhon_relative_humidity_percent' "$METRICS_FILE" -n -S | head -n 1 || true)"
|
||||||
|
VPD_LINE="$(rg '^typhon_vpd_kpa' "$METRICS_FILE" -n -S | head -n 1 || true)"
|
||||||
|
echo "=== Current climate ==="
|
||||||
|
[[ -n "$TEMP_LINE" ]] && echo "$TEMP_LINE"
|
||||||
|
[[ -n "$HUMID_LINE" ]] && echo "$HUMID_LINE"
|
||||||
|
[[ -n "$VPD_LINE" ]] && echo "$VPD_LINE"
|
||||||
|
else
|
||||||
|
echo "warning: typhon_up did not reach 1 during smoke window"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Recent app log lines ==="
|
||||||
|
tail -n 60 "$LOG_FILE"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "manual smoke complete"
|
||||||
|
echo "metrics: $METRICS_FILE"
|
||||||
|
echo "logs: $LOG_FILE"
|
||||||
82
src/app/TyphonApplication.ts
Normal file
82
src/app/TyphonApplication.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { AppConfig } from "../config/AppConfig";
|
||||||
|
import { AcInfinityApiClient } from "../http/AcInfinityApiClient";
|
||||||
|
import { TyphonMetrics } from "../metrics/TyphonMetrics";
|
||||||
|
import { Logger } from "../observability/Logger";
|
||||||
|
import { ClimatePollingService } from "../services/ClimatePollingService";
|
||||||
|
import { MetricsServer } from "../transport/MetricsServer";
|
||||||
|
|
||||||
|
export class TyphonApplication {
|
||||||
|
private readonly logger: Logger;
|
||||||
|
private readonly apiClient: AcInfinityApiClient;
|
||||||
|
private readonly metrics: TyphonMetrics;
|
||||||
|
private readonly pollingService: ClimatePollingService;
|
||||||
|
private readonly metricsServer: MetricsServer;
|
||||||
|
private shuttingDown = false;
|
||||||
|
|
||||||
|
public constructor(private readonly config: AppConfig, version: string) {
|
||||||
|
this.logger = new Logger(config.logLevel, "typhon");
|
||||||
|
|
||||||
|
this.apiClient = new AcInfinityApiClient(
|
||||||
|
config.aciHost,
|
||||||
|
config.aciEmail,
|
||||||
|
config.aciPassword,
|
||||||
|
config.requestTimeoutMs
|
||||||
|
);
|
||||||
|
|
||||||
|
this.metrics = new TyphonMetrics(version);
|
||||||
|
this.pollingService = new ClimatePollingService(
|
||||||
|
this.apiClient,
|
||||||
|
this.metrics,
|
||||||
|
this.logger,
|
||||||
|
config.pollIntervalSeconds
|
||||||
|
);
|
||||||
|
this.metricsServer = new MetricsServer(config.listenPort, this.metrics, this.logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
this.installSignalHandlers();
|
||||||
|
await this.metricsServer.start();
|
||||||
|
this.pollingService.start();
|
||||||
|
this.logger.info("typhon started", {
|
||||||
|
poll_interval_seconds: this.config.pollIntervalSeconds,
|
||||||
|
listen_port: this.config.listenPort
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.shuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shuttingDown = true;
|
||||||
|
this.logger.info("typhon shutting down");
|
||||||
|
|
||||||
|
this.pollingService.stop();
|
||||||
|
await this.metricsServer.stop();
|
||||||
|
await this.apiClient.close();
|
||||||
|
|
||||||
|
this.logger.info("typhon shutdown complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
private installSignalHandlers(): void {
|
||||||
|
const onSignal = async (signal: string): Promise<void> => {
|
||||||
|
this.logger.warn("received shutdown signal", { signal });
|
||||||
|
try {
|
||||||
|
await this.stop();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("failed to shut down cleanly", {
|
||||||
|
error_message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
void onSignal("SIGTERM");
|
||||||
|
});
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
void onSignal("SIGINT");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/config/AppConfig.ts
Normal file
77
src/config/AppConfig.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { DEFAULT_AC_INFINITY_HOST } from "../http/ApiConstants";
|
||||||
|
|
||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
export class AppConfig {
|
||||||
|
public constructor(
|
||||||
|
public readonly aciEmail: string,
|
||||||
|
public readonly aciPassword: string,
|
||||||
|
public readonly aciHost: string,
|
||||||
|
public readonly pollIntervalSeconds: number,
|
||||||
|
public readonly listenPort: number,
|
||||||
|
public readonly requestTimeoutMs: number,
|
||||||
|
public readonly logLevel: LogLevel
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static fromEnv(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
|
const aciEmail = this.getRequired(env, "ACI_EMAIL");
|
||||||
|
const aciPassword = this.getRequired(env, "ACI_PASSWORD");
|
||||||
|
const aciHost = this.getOptional(env, "ACI_HOST", DEFAULT_AC_INFINITY_HOST);
|
||||||
|
const pollIntervalSeconds = this.parseNumber(env, "POLL_INTERVAL_SECONDS", 30, 5, 600);
|
||||||
|
const listenPort = this.parseNumber(env, "LISTEN_PORT", 9108, 1, 65535);
|
||||||
|
const requestTimeoutMs = this.parseNumber(env, "REQUEST_TIMEOUT_MS", 10000, 1000, 120000);
|
||||||
|
const logLevel = this.parseLogLevel(this.getOptional(env, "LOG_LEVEL", "info"));
|
||||||
|
|
||||||
|
return new AppConfig(
|
||||||
|
aciEmail,
|
||||||
|
aciPassword,
|
||||||
|
aciHost,
|
||||||
|
pollIntervalSeconds,
|
||||||
|
listenPort,
|
||||||
|
requestTimeoutMs,
|
||||||
|
logLevel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getRequired(env: NodeJS.ProcessEnv, key: string): string {
|
||||||
|
const value = env[key];
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new Error(`${key} is required`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getOptional(env: NodeJS.ProcessEnv, key: string, fallback: string): string {
|
||||||
|
const value = env[key];
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseNumber(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number
|
||||||
|
): number {
|
||||||
|
const raw = env[key];
|
||||||
|
const parsed = raw && raw.trim().length > 0 ? Number(raw) : fallback;
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
||||||
|
throw new Error(`${key} must be an integer`);
|
||||||
|
}
|
||||||
|
if (parsed < min || parsed > max) {
|
||||||
|
throw new Error(`${key} must be between ${min} and ${max}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseLogLevel(value: string): LogLevel {
|
||||||
|
if (value === "debug" || value === "info" || value === "warn" || value === "error") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new Error(`LOG_LEVEL must be one of: debug, info, warn, error`);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/domain/ClimateSnapshot.ts
Normal file
81
src/domain/ClimateSnapshot.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
export enum AcInfinityMode {
|
||||||
|
Unknown = "unknown",
|
||||||
|
Off = "off",
|
||||||
|
On = "on",
|
||||||
|
Auto = "auto",
|
||||||
|
TimerToOn = "timer_to_on",
|
||||||
|
TimerToOff = "timer_to_off",
|
||||||
|
Cycle = "cycle",
|
||||||
|
Schedule = "schedule",
|
||||||
|
Vpd = "vpd"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortClimate {
|
||||||
|
public constructor(
|
||||||
|
public readonly port: number,
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly fanGroup: string,
|
||||||
|
public readonly online: boolean,
|
||||||
|
public readonly powerState: boolean,
|
||||||
|
public readonly currentSpeedLevel: number,
|
||||||
|
public readonly onSpeedLevel: number,
|
||||||
|
public readonly offSpeedLevel: number,
|
||||||
|
public readonly connectedDevice: boolean,
|
||||||
|
public readonly resistanceOhms: number | null,
|
||||||
|
public readonly mode: AcInfinityMode
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ControllerClimate {
|
||||||
|
public constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly online: boolean,
|
||||||
|
public readonly temperatureCelsius: number,
|
||||||
|
public readonly relativeHumidityRatio: number,
|
||||||
|
public readonly vpdKpa: number,
|
||||||
|
public readonly deviceType: number | null,
|
||||||
|
public readonly newFrameworkDevice: boolean | null,
|
||||||
|
public readonly ports: PortClimate[]
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClimateSnapshot {
|
||||||
|
public constructor(
|
||||||
|
public readonly collectedAtEpochSeconds: number,
|
||||||
|
public readonly controllers: ControllerClimate[]
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modeFromNumeric(rawMode: number | null | undefined): AcInfinityMode {
|
||||||
|
switch (rawMode) {
|
||||||
|
case 1:
|
||||||
|
return AcInfinityMode.Off;
|
||||||
|
case 2:
|
||||||
|
return AcInfinityMode.On;
|
||||||
|
case 3:
|
||||||
|
return AcInfinityMode.Auto;
|
||||||
|
case 4:
|
||||||
|
return AcInfinityMode.TimerToOn;
|
||||||
|
case 5:
|
||||||
|
return AcInfinityMode.TimerToOff;
|
||||||
|
case 6:
|
||||||
|
return AcInfinityMode.Cycle;
|
||||||
|
case 7:
|
||||||
|
return AcInfinityMode.Schedule;
|
||||||
|
case 8:
|
||||||
|
return AcInfinityMode.Vpd;
|
||||||
|
default:
|
||||||
|
return AcInfinityMode.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMode(
|
||||||
|
runtimeMode: AcInfinityMode,
|
||||||
|
configuredMode: AcInfinityMode
|
||||||
|
): AcInfinityMode {
|
||||||
|
if (runtimeMode !== AcInfinityMode.Unknown) {
|
||||||
|
return runtimeMode;
|
||||||
|
}
|
||||||
|
return configuredMode;
|
||||||
|
}
|
||||||
351
src/http/AcInfinityApiClient.ts
Normal file
351
src/http/AcInfinityApiClient.ts
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
import { Agent, fetch as undiciFetch } from "undici";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AcInfinityMode,
|
||||||
|
ClimateSnapshot,
|
||||||
|
ControllerClimate,
|
||||||
|
PortClimate,
|
||||||
|
modeFromNumeric,
|
||||||
|
resolveMode
|
||||||
|
} from "../domain/ClimateSnapshot";
|
||||||
|
import type {
|
||||||
|
AcInfinityEnvelope,
|
||||||
|
LoginResponse,
|
||||||
|
RawController,
|
||||||
|
RawPortModeSettings
|
||||||
|
} from "./ApiContracts";
|
||||||
|
import {
|
||||||
|
API_APP_VERSION,
|
||||||
|
API_AUTH_ERROR_CODES,
|
||||||
|
API_MIN_VERSION,
|
||||||
|
API_PASSWORD_MAX_LENGTH,
|
||||||
|
API_PHONE_TYPE,
|
||||||
|
API_URL_DEVICE_LIST_ALL,
|
||||||
|
API_URL_DEVICE_MODE_SETTINGS,
|
||||||
|
API_URL_LOGIN,
|
||||||
|
API_USER_AGENT,
|
||||||
|
PORT_RESISTANCE_CONNECTED_THRESHOLD
|
||||||
|
} from "./ApiConstants";
|
||||||
|
|
||||||
|
export interface HttpResponseLike {
|
||||||
|
status: number;
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchInitLike {
|
||||||
|
method: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: URLSearchParams;
|
||||||
|
signal: AbortSignal;
|
||||||
|
dispatcher?: Agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchLike = (input: string, init: FetchInitLike) => Promise<HttpResponseLike>;
|
||||||
|
|
||||||
|
export class AcInfinityApiError extends Error {
|
||||||
|
public constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly apiCode: string,
|
||||||
|
public readonly httpStatus: number
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AcInfinityApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AcInfinityApiClient {
|
||||||
|
private token: string | null = null;
|
||||||
|
private lastRequestAtMs = 0;
|
||||||
|
private readonly minRequestSpacingMs = 250;
|
||||||
|
private readonly dispatcher: Agent;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly host: string,
|
||||||
|
private readonly email: string,
|
||||||
|
private readonly password: string,
|
||||||
|
private readonly requestTimeoutMs: number,
|
||||||
|
private readonly fetchImpl: FetchLike = undiciFetch as unknown as FetchLike
|
||||||
|
) {
|
||||||
|
this.dispatcher = new Agent({
|
||||||
|
connect: { timeout: requestTimeoutMs },
|
||||||
|
keepAliveTimeout: 60_000,
|
||||||
|
keepAliveMaxTimeout: 60_000,
|
||||||
|
pipelining: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
await this.dispatcher.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchSnapshot(): Promise<ClimateSnapshot> {
|
||||||
|
try {
|
||||||
|
return await this.fetchSnapshotInternal();
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isAuthError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.token = null;
|
||||||
|
return this.fetchSnapshotInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchSnapshotInternal(): Promise<ClimateSnapshot> {
|
||||||
|
const token = await this.ensureToken();
|
||||||
|
const rawControllers = await this.postForm<RawController[]>(
|
||||||
|
API_URL_DEVICE_LIST_ALL,
|
||||||
|
{ userId: token },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
const controllers: ControllerClimate[] = [];
|
||||||
|
|
||||||
|
for (const rawController of rawControllers) {
|
||||||
|
const controllerId = String(rawController.devId);
|
||||||
|
const controllerName = rawController.devName ?? `controller-${controllerId}`;
|
||||||
|
const rawPorts = rawController.deviceInfo?.ports ?? [];
|
||||||
|
const ports: PortClimate[] = [];
|
||||||
|
|
||||||
|
for (const rawPort of rawPorts) {
|
||||||
|
const portNumber = this.toNumber(rawPort.port);
|
||||||
|
if (portNumber <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portOnline = this.toBool(rawPort.online);
|
||||||
|
const portResistance = this.optionalFinite(rawPort.portResistance);
|
||||||
|
const connectedDevice = this.isConnectedDevice(portResistance);
|
||||||
|
|
||||||
|
// Mirrors homebridge behavior: skip known-empty ports.
|
||||||
|
if (portResistance !== null && !connectedDevice && !portOnline) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portName = rawPort.portName ?? `port-${portNumber}`;
|
||||||
|
const modeSettings = await this.fetchPortModeSettings(token, controllerId, portNumber);
|
||||||
|
const runtimeMode = modeFromNumeric(rawPort.curMode);
|
||||||
|
const configuredMode = modeFromNumeric(modeSettings.atType ?? modeSettings.modeType);
|
||||||
|
const selectedMode = resolveMode(runtimeMode, configuredMode);
|
||||||
|
|
||||||
|
ports.push(
|
||||||
|
new PortClimate(
|
||||||
|
portNumber,
|
||||||
|
portName,
|
||||||
|
this.detectFanGroup(portName),
|
||||||
|
portOnline,
|
||||||
|
this.toBool(rawPort.loadState),
|
||||||
|
this.toNumber(rawPort.speak),
|
||||||
|
this.toNumber(modeSettings.onSpead),
|
||||||
|
this.toNumber(modeSettings.offSpead),
|
||||||
|
connectedDevice,
|
||||||
|
portResistance,
|
||||||
|
selectedMode
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
controllers.push(
|
||||||
|
new ControllerClimate(
|
||||||
|
controllerId,
|
||||||
|
controllerName,
|
||||||
|
this.toBool(rawController.online),
|
||||||
|
this.scaledHundred(this.firstDefined(rawController.temperature, rawController.deviceInfo?.temperature)),
|
||||||
|
this.scaledHundred(this.firstDefined(rawController.humidity, rawController.deviceInfo?.humidity)) / 100,
|
||||||
|
this.scaledHundred(this.firstDefined(rawController.vpdnums, rawController.deviceInfo?.vpdnums)),
|
||||||
|
this.optionalFinite(rawController.devType),
|
||||||
|
this.toOptionalBool(rawController.newFrameworkDevice),
|
||||||
|
ports
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ClimateSnapshot(Math.floor(Date.now() / 1000), controllers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPortModeSettings(
|
||||||
|
token: string,
|
||||||
|
controllerId: string,
|
||||||
|
port: number
|
||||||
|
): Promise<RawPortModeSettings> {
|
||||||
|
return this.postForm<RawPortModeSettings>(
|
||||||
|
API_URL_DEVICE_MODE_SETTINGS,
|
||||||
|
{
|
||||||
|
devId: controllerId,
|
||||||
|
port
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
{ minversion: API_MIN_VERSION }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureToken(): Promise<string> {
|
||||||
|
if (this.token) {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = await this.postForm<LoginResponse>(
|
||||||
|
API_URL_LOGIN,
|
||||||
|
{
|
||||||
|
appEmail: this.email,
|
||||||
|
appPasswordl: this.password.slice(0, API_PASSWORD_MAX_LENGTH)
|
||||||
|
},
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!loginData.appId) {
|
||||||
|
throw new AcInfinityApiError("Login response missing appId", "missing_appid", 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.token = String(loginData.appId);
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postForm<T>(
|
||||||
|
path: string,
|
||||||
|
data: Record<string, string | number>,
|
||||||
|
token: string | null,
|
||||||
|
extraHeaders: Record<string, string> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
await this.waitForRequestWindow();
|
||||||
|
|
||||||
|
const url = `${this.host}${path}`;
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
body.set(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||||
|
"User-Agent": API_USER_AGENT,
|
||||||
|
phoneType: API_PHONE_TYPE,
|
||||||
|
appVersion: API_APP_VERSION,
|
||||||
|
...extraHeaders
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchImpl(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
dispatcher: this.dispatcher
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new AcInfinityApiError(
|
||||||
|
`HTTP ${response.status} from AC Infinity API`,
|
||||||
|
"http_error",
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = (await response.json()) as AcInfinityEnvelope<T>;
|
||||||
|
if (parsed.code !== 200) {
|
||||||
|
const code = String(parsed.code ?? "unknown");
|
||||||
|
throw new AcInfinityApiError(parsed.msg ?? "AC Infinity API error", code, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForRequestWindow(): Promise<void> {
|
||||||
|
const elapsedMs = Date.now() - this.lastRequestAtMs;
|
||||||
|
const waitMs = this.minRequestSpacingMs - elapsedMs;
|
||||||
|
if (waitMs > 0) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, waitMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.lastRequestAtMs = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isConnectedDevice(portResistance: number | null): boolean {
|
||||||
|
if (portResistance === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return portResistance < PORT_RESISTANCE_CONNECTED_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectFanGroup(portName: string): string {
|
||||||
|
const label = portName.toLowerCase();
|
||||||
|
if (label.includes("outlet") || label.includes("exhaust")) {
|
||||||
|
return "outlet";
|
||||||
|
}
|
||||||
|
if ((label.includes("inside") || label.includes("internal")) && (label.includes("inlet") || label.includes("intake"))) {
|
||||||
|
return "inside_inlet";
|
||||||
|
}
|
||||||
|
if (label.includes("outside") && (label.includes("inlet") || label.includes("intake"))) {
|
||||||
|
return "outside_inlet";
|
||||||
|
}
|
||||||
|
if (label === "fans" || label === "fan") {
|
||||||
|
return "interior";
|
||||||
|
}
|
||||||
|
if (label.includes("interior") || label.includes("circulation") || label.includes("clip")) {
|
||||||
|
return "interior";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAuthError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof AcInfinityApiError &&
|
||||||
|
API_AUTH_ERROR_CODES.has(error.apiCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scaledHundred(value: number | null | undefined): number {
|
||||||
|
return this.toNumber(value) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toBool(value: number | boolean | null | undefined): boolean {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOptionalBool(value: unknown): boolean | null {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toNumber(value: number | null | undefined): number {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalFinite(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstDefined(
|
||||||
|
primary: number | null | undefined,
|
||||||
|
secondary: number | null | undefined
|
||||||
|
): number | null | undefined {
|
||||||
|
if (primary !== null && primary !== undefined) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
return secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/http/ApiConstants.ts
Normal file
17
src/http/ApiConstants.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export const DEFAULT_AC_INFINITY_HOST = "http://www.acinfinityserver.com";
|
||||||
|
|
||||||
|
export const API_URL_LOGIN = "/api/user/appUserLogin";
|
||||||
|
export const API_URL_DEVICE_LIST_ALL = "/api/user/devInfoListAll";
|
||||||
|
export const API_URL_DEVICE_MODE_SETTINGS = "/api/dev/getdevModeSettingList";
|
||||||
|
|
||||||
|
export const API_USER_AGENT =
|
||||||
|
"ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2";
|
||||||
|
|
||||||
|
export const API_PHONE_TYPE = "1";
|
||||||
|
export const API_APP_VERSION = "1.9.7";
|
||||||
|
export const API_MIN_VERSION = "3.5";
|
||||||
|
|
||||||
|
export const API_PASSWORD_MAX_LENGTH = 25;
|
||||||
|
export const PORT_RESISTANCE_CONNECTED_THRESHOLD = 65535;
|
||||||
|
|
||||||
|
export const API_AUTH_ERROR_CODES = new Set(["10001", "invalid_auth"]);
|
||||||
45
src/http/ApiContracts.ts
Normal file
45
src/http/ApiContracts.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export interface AcInfinityEnvelope<T> {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
appId: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawPort {
|
||||||
|
port: number;
|
||||||
|
portName?: string;
|
||||||
|
online?: number | boolean;
|
||||||
|
loadState?: number | boolean;
|
||||||
|
speak?: number;
|
||||||
|
curMode?: number;
|
||||||
|
portResistance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawDeviceInfo {
|
||||||
|
temperature?: number | null;
|
||||||
|
humidity?: number | null;
|
||||||
|
vpdnums?: number | null;
|
||||||
|
ports?: RawPort[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawController {
|
||||||
|
devId: string | number;
|
||||||
|
devName?: string;
|
||||||
|
online?: number | boolean;
|
||||||
|
devType?: number;
|
||||||
|
newFrameworkDevice?: boolean;
|
||||||
|
temperature?: number | null;
|
||||||
|
humidity?: number | null;
|
||||||
|
vpdnums?: number | null;
|
||||||
|
deviceInfo?: RawDeviceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawPortModeSettings {
|
||||||
|
atType?: number;
|
||||||
|
onSpead?: number;
|
||||||
|
offSpead?: number;
|
||||||
|
modeType?: number;
|
||||||
|
}
|
||||||
29
src/index.ts
Normal file
29
src/index.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { TyphonApplication } from "./app/TyphonApplication";
|
||||||
|
import { AppConfig } from "./config/AppConfig";
|
||||||
|
|
||||||
|
function readVersion(): string {
|
||||||
|
try {
|
||||||
|
const packagePath = join(process.cwd(), "package.json");
|
||||||
|
const parsed = JSON.parse(readFileSync(packagePath, "utf-8")) as { version?: string };
|
||||||
|
return parsed.version ?? "0.0.0";
|
||||||
|
} catch {
|
||||||
|
return "0.0.0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = AppConfig.fromEnv();
|
||||||
|
const version = readVersion();
|
||||||
|
|
||||||
|
const app = new TyphonApplication(config, version);
|
||||||
|
await app.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(JSON.stringify({ level: "error", component: "typhon", message }));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
277
src/metrics/TyphonMetrics.ts
Normal file
277
src/metrics/TyphonMetrics.ts
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import {
|
||||||
|
Counter,
|
||||||
|
Gauge,
|
||||||
|
Registry,
|
||||||
|
collectDefaultMetrics
|
||||||
|
} from "prom-client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AcInfinityMode,
|
||||||
|
ClimateSnapshot
|
||||||
|
} from "../domain/ClimateSnapshot";
|
||||||
|
|
||||||
|
const MODE_LABELS: readonly AcInfinityMode[] = [
|
||||||
|
AcInfinityMode.Off,
|
||||||
|
AcInfinityMode.On,
|
||||||
|
AcInfinityMode.Auto,
|
||||||
|
AcInfinityMode.TimerToOn,
|
||||||
|
AcInfinityMode.TimerToOff,
|
||||||
|
AcInfinityMode.Cycle,
|
||||||
|
AcInfinityMode.Schedule,
|
||||||
|
AcInfinityMode.Vpd,
|
||||||
|
AcInfinityMode.Unknown
|
||||||
|
];
|
||||||
|
|
||||||
|
export class TyphonMetrics {
|
||||||
|
private readonly registry: Registry;
|
||||||
|
|
||||||
|
private readonly exporterUp: Gauge;
|
||||||
|
private readonly lastSuccessTimestampSeconds: Gauge;
|
||||||
|
private readonly dataAgeSeconds: Gauge;
|
||||||
|
|
||||||
|
private readonly pollErrorsTotal: Counter<"reason" | "code">;
|
||||||
|
private readonly controllerOnline: Gauge<"controller_id" | "controller_name">;
|
||||||
|
private readonly controllerInfo: Gauge<
|
||||||
|
"controller_id" | "controller_name" | "device_type" | "new_framework_device"
|
||||||
|
>;
|
||||||
|
private readonly temperatureCelsius: Gauge<"controller_id" | "controller_name">;
|
||||||
|
private readonly relativeHumidityRatio: Gauge<"controller_id" | "controller_name">;
|
||||||
|
private readonly relativeHumidityPercent: Gauge<"controller_id" | "controller_name">;
|
||||||
|
private readonly vpdKpa: Gauge<"controller_id" | "controller_name">;
|
||||||
|
|
||||||
|
private readonly portOnline: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group">;
|
||||||
|
private readonly portPowerState: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group">;
|
||||||
|
private readonly portConnectedDevice: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group">;
|
||||||
|
private readonly portResistanceOhms: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group">;
|
||||||
|
private readonly fanSpeedLevel: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group">;
|
||||||
|
private readonly fanOnSpeedLevel: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group">;
|
||||||
|
private readonly fanOffSpeedLevel: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group">;
|
||||||
|
private readonly modeGauge: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group" | "mode">;
|
||||||
|
|
||||||
|
private readonly buildInfo: Gauge<"version">;
|
||||||
|
|
||||||
|
private readonly resettable: Array<
|
||||||
|
Gauge<"controller_id" | "controller_name"> |
|
||||||
|
Gauge<"controller_id" | "controller_name" | "device_type" | "new_framework_device"> |
|
||||||
|
Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group"> |
|
||||||
|
Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group" | "mode">
|
||||||
|
>;
|
||||||
|
|
||||||
|
private lastSuccessEpoch = 0;
|
||||||
|
|
||||||
|
public constructor(version: string, enableDefaultMetrics = true, registry?: Registry) {
|
||||||
|
this.registry = registry ?? new Registry();
|
||||||
|
if (enableDefaultMetrics) {
|
||||||
|
collectDefaultMetrics({ register: this.registry });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.exporterUp = new Gauge({
|
||||||
|
name: "typhon_up",
|
||||||
|
help: "1 when typhon has a successful recent poll; 0 when it is in failure state",
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.lastSuccessTimestampSeconds = new Gauge({
|
||||||
|
name: "typhon_last_successful_poll_timestamp_seconds",
|
||||||
|
help: "Unix timestamp of the last successful AC Infinity poll",
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.dataAgeSeconds = new Gauge({
|
||||||
|
name: "typhon_data_age_seconds",
|
||||||
|
help: "Age in seconds of the latest successful typhon poll data",
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pollErrorsTotal = new Counter({
|
||||||
|
name: "typhon_poll_errors_total",
|
||||||
|
help: "Count of typhon polling errors by reason and API code",
|
||||||
|
labelNames: ["reason", "code"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.controllerOnline = new Gauge({
|
||||||
|
name: "typhon_controller_online",
|
||||||
|
help: "Controller online status (1 online, 0 offline)",
|
||||||
|
labelNames: ["controller_id", "controller_name"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.controllerInfo = new Gauge({
|
||||||
|
name: "typhon_controller_info",
|
||||||
|
help: "Controller metadata labels set to 1",
|
||||||
|
labelNames: ["controller_id", "controller_name", "device_type", "new_framework_device"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.temperatureCelsius = new Gauge({
|
||||||
|
name: "typhon_temperature_celsius",
|
||||||
|
help: "Controller ambient temperature in celsius",
|
||||||
|
labelNames: ["controller_id", "controller_name"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.relativeHumidityRatio = new Gauge({
|
||||||
|
name: "typhon_relative_humidity_ratio",
|
||||||
|
help: "Controller relative humidity ratio (0..1)",
|
||||||
|
labelNames: ["controller_id", "controller_name"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.relativeHumidityPercent = new Gauge({
|
||||||
|
name: "typhon_relative_humidity_percent",
|
||||||
|
help: "Controller relative humidity percentage (0..100)",
|
||||||
|
labelNames: ["controller_id", "controller_name"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.vpdKpa = new Gauge({
|
||||||
|
name: "typhon_vpd_kpa",
|
||||||
|
help: "Controller vapor pressure deficit in kPa",
|
||||||
|
labelNames: ["controller_id", "controller_name"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.portOnline = new Gauge({
|
||||||
|
name: "typhon_port_online",
|
||||||
|
help: "Controller port online status (1 online, 0 offline)",
|
||||||
|
labelNames: ["controller_id", "controller_name", "port", "port_name", "fan_group"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.portPowerState = new Gauge({
|
||||||
|
name: "typhon_port_power_state",
|
||||||
|
help: "Controller port power state (1 powered, 0 unpowered)",
|
||||||
|
labelNames: ["controller_id", "controller_name", "port", "port_name", "fan_group"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.portConnectedDevice = new Gauge({
|
||||||
|
name: "typhon_port_connected_device",
|
||||||
|
help: "Controller port has a connected device (1 yes, 0 no)",
|
||||||
|
labelNames: ["controller_id", "controller_name", "port", "port_name", "fan_group"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.portResistanceOhms = new Gauge({
|
||||||
|
name: "typhon_port_resistance_ohms",
|
||||||
|
help: "Controller port resistance in ohms when available",
|
||||||
|
labelNames: ["controller_id", "controller_name", "port", "port_name", "fan_group"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.fanSpeedLevel = new Gauge({
|
||||||
|
name: "typhon_fan_speed_level",
|
||||||
|
help: "Current fan speed level (0-10)",
|
||||||
|
labelNames: ["controller_id", "controller_name", "port", "port_name", "fan_group"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.fanOnSpeedLevel = new Gauge({
|
||||||
|
name: "typhon_fan_on_speed_level",
|
||||||
|
help: "Configured fan on speed level (0-10)",
|
||||||
|
labelNames: ["controller_id", "controller_name", "port", "port_name", "fan_group"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.fanOffSpeedLevel = new Gauge({
|
||||||
|
name: "typhon_fan_off_speed_level",
|
||||||
|
help: "Configured fan off speed level (0-10)",
|
||||||
|
labelNames: ["controller_id", "controller_name", "port", "port_name", "fan_group"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
this.modeGauge = new Gauge({
|
||||||
|
name: "typhon_mode",
|
||||||
|
help: "One-hot mode gauge by controller port",
|
||||||
|
labelNames: ["controller_id", "controller_name", "port", "port_name", "fan_group", "mode"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.buildInfo = new Gauge({
|
||||||
|
name: "typhon_build_info",
|
||||||
|
help: "Typhon build information",
|
||||||
|
labelNames: ["version"],
|
||||||
|
registers: [this.registry]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.resettable = [
|
||||||
|
this.controllerOnline,
|
||||||
|
this.controllerInfo,
|
||||||
|
this.temperatureCelsius,
|
||||||
|
this.relativeHumidityRatio,
|
||||||
|
this.relativeHumidityPercent,
|
||||||
|
this.vpdKpa,
|
||||||
|
this.portOnline,
|
||||||
|
this.portPowerState,
|
||||||
|
this.portConnectedDevice,
|
||||||
|
this.portResistanceOhms,
|
||||||
|
this.fanSpeedLevel,
|
||||||
|
this.fanOnSpeedLevel,
|
||||||
|
this.fanOffSpeedLevel,
|
||||||
|
this.modeGauge
|
||||||
|
];
|
||||||
|
|
||||||
|
this.exporterUp.set(0);
|
||||||
|
this.dataAgeSeconds.set(0);
|
||||||
|
this.buildInfo.labels(version).set(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRegistry(): Registry {
|
||||||
|
return this.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public markPollFailure(reason: string, code = "unknown"): void {
|
||||||
|
this.exporterUp.set(0);
|
||||||
|
this.pollErrorsTotal.labels(reason, code).inc();
|
||||||
|
this.refreshDataAgeGauge();
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateFromSnapshot(snapshot: ClimateSnapshot): void {
|
||||||
|
for (const gauge of this.resettable) {
|
||||||
|
gauge.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const controller of snapshot.controllers) {
|
||||||
|
const controllerLabels = {
|
||||||
|
controller_id: controller.id,
|
||||||
|
controller_name: controller.name
|
||||||
|
};
|
||||||
|
|
||||||
|
this.controllerOnline.labels(controllerLabels).set(controller.online ? 1 : 0);
|
||||||
|
this.controllerInfo.labels({
|
||||||
|
...controllerLabels,
|
||||||
|
device_type: controller.deviceType === null ? "unknown" : String(controller.deviceType),
|
||||||
|
new_framework_device:
|
||||||
|
controller.newFrameworkDevice === null ? "unknown" : String(controller.newFrameworkDevice)
|
||||||
|
}).set(1);
|
||||||
|
this.temperatureCelsius.labels(controllerLabels).set(controller.temperatureCelsius);
|
||||||
|
this.relativeHumidityRatio.labels(controllerLabels).set(controller.relativeHumidityRatio);
|
||||||
|
this.relativeHumidityPercent.labels(controllerLabels).set(controller.relativeHumidityRatio * 100);
|
||||||
|
this.vpdKpa.labels(controllerLabels).set(controller.vpdKpa);
|
||||||
|
|
||||||
|
for (const port of controller.ports) {
|
||||||
|
const portLabels = {
|
||||||
|
controller_id: controller.id,
|
||||||
|
controller_name: controller.name,
|
||||||
|
port: String(port.port),
|
||||||
|
port_name: port.name,
|
||||||
|
fan_group: port.fanGroup
|
||||||
|
};
|
||||||
|
|
||||||
|
this.portOnline.labels(portLabels).set(port.online ? 1 : 0);
|
||||||
|
this.portPowerState.labels(portLabels).set(port.powerState ? 1 : 0);
|
||||||
|
this.portConnectedDevice.labels(portLabels).set(port.connectedDevice ? 1 : 0);
|
||||||
|
if (port.resistanceOhms !== null) {
|
||||||
|
this.portResistanceOhms.labels(portLabels).set(port.resistanceOhms);
|
||||||
|
}
|
||||||
|
this.fanSpeedLevel.labels(portLabels).set(port.currentSpeedLevel);
|
||||||
|
this.fanOnSpeedLevel.labels(portLabels).set(port.onSpeedLevel);
|
||||||
|
this.fanOffSpeedLevel.labels(portLabels).set(port.offSpeedLevel);
|
||||||
|
|
||||||
|
for (const mode of MODE_LABELS) {
|
||||||
|
this.modeGauge.labels({ ...portLabels, mode }).set(mode === port.mode ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSuccessEpoch = snapshot.collectedAtEpochSeconds;
|
||||||
|
this.lastSuccessTimestampSeconds.set(this.lastSuccessEpoch);
|
||||||
|
this.exporterUp.set(1);
|
||||||
|
this.refreshDataAgeGauge();
|
||||||
|
}
|
||||||
|
|
||||||
|
public refreshDataAgeGauge(nowEpochSeconds = Math.floor(Date.now() / 1000)): void {
|
||||||
|
if (this.lastSuccessEpoch <= 0) {
|
||||||
|
this.dataAgeSeconds.set(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dataAgeSeconds.set(Math.max(0, nowEpochSeconds - this.lastSuccessEpoch));
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/observability/Logger.ts
Normal file
56
src/observability/Logger.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import type { LogLevel } from "../config/AppConfig";
|
||||||
|
|
||||||
|
const LOG_PRIORITY: Record<LogLevel, number> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
public constructor(
|
||||||
|
private readonly level: LogLevel,
|
||||||
|
private readonly component: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public debug(message: string, context?: Record<string, unknown>): void {
|
||||||
|
this.log("debug", message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public info(message: string, context?: Record<string, unknown>): void {
|
||||||
|
this.log("info", message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public warn(message: string, context?: Record<string, unknown>): void {
|
||||||
|
this.log("warn", message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public error(message: string, context?: Record<string, unknown>): void {
|
||||||
|
this.log("error", message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
|
||||||
|
if (LOG_PRIORITY[level] < LOG_PRIORITY[this.level]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
component: this.component,
|
||||||
|
message,
|
||||||
|
...(context ?? {})
|
||||||
|
};
|
||||||
|
const line = JSON.stringify(entry);
|
||||||
|
|
||||||
|
if (level === "error") {
|
||||||
|
console.error(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (level === "warn") {
|
||||||
|
console.warn(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/services/ClimatePollingService.ts
Normal file
71
src/services/ClimatePollingService.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { AcInfinityApiClient, AcInfinityApiError } from "../http/AcInfinityApiClient";
|
||||||
|
import { TyphonMetrics } from "../metrics/TyphonMetrics";
|
||||||
|
import { Logger } from "../observability/Logger";
|
||||||
|
|
||||||
|
export class ClimatePollingService {
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private inFlight = false;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly client: AcInfinityApiClient,
|
||||||
|
private readonly metrics: TyphonMetrics,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
private readonly pollIntervalSeconds: number
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
void this.pollOnce();
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.pollOnce();
|
||||||
|
}, this.pollIntervalSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollOnce(): Promise<void> {
|
||||||
|
if (this.inFlight) {
|
||||||
|
this.logger.warn("skipping poll because previous poll is still running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inFlight = true;
|
||||||
|
try {
|
||||||
|
const snapshot = await this.client.fetchSnapshot();
|
||||||
|
this.metrics.updateFromSnapshot(snapshot);
|
||||||
|
this.logger.info("poll succeeded", {
|
||||||
|
controller_count: snapshot.controllers.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const { reason, code } = this.classifyError(error);
|
||||||
|
this.metrics.markPollFailure(reason, code);
|
||||||
|
this.logger.error("poll failed", {
|
||||||
|
reason,
|
||||||
|
code,
|
||||||
|
error_message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.metrics.refreshDataAgeGauge();
|
||||||
|
this.inFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private classifyError(error: unknown): { reason: string; code: string } {
|
||||||
|
if (error instanceof AcInfinityApiError) {
|
||||||
|
if (error.apiCode === "10001" || error.apiCode === "invalid_auth") {
|
||||||
|
return { reason: "auth", code: error.apiCode };
|
||||||
|
}
|
||||||
|
return { reason: "api", code: error.apiCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return { reason: "timeout", code: "timeout" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reason: "unknown", code: "unknown" };
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/transport/MetricsServer.ts
Normal file
74
src/transport/MetricsServer.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
import { TyphonMetrics } from "../metrics/TyphonMetrics";
|
||||||
|
import { Logger } from "../observability/Logger";
|
||||||
|
|
||||||
|
export class MetricsServer {
|
||||||
|
private server: http.Server | null = null;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly port: number,
|
||||||
|
private readonly metrics: TyphonMetrics,
|
||||||
|
private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
this.server = http.createServer(async (req, res) => {
|
||||||
|
if (!req.url) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end("missing url");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === "/healthz") {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end('{"ok":true}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url !== "/metrics") {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.metrics.getRegistry().metrics();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", this.metrics.getRegistry().contentType);
|
||||||
|
res.end(payload);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("failed to render metrics", {
|
||||||
|
error_message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end("metrics rendering failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.server?.listen(this.port, () => {
|
||||||
|
this.logger.info("metrics server started", { port: this.port });
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.server?.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
318
tests/AcInfinityApiClient.test.ts
Normal file
318
tests/AcInfinityApiClient.test.ts
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import {
|
||||||
|
AcInfinityApiClient,
|
||||||
|
AcInfinityApiError,
|
||||||
|
type FetchLike,
|
||||||
|
type HttpResponseLike
|
||||||
|
} from "../src/http/AcInfinityApiClient";
|
||||||
|
|
||||||
|
interface CapturedCall {
|
||||||
|
input: string;
|
||||||
|
init: {
|
||||||
|
method: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: URLSearchParams;
|
||||||
|
signal: AbortSignal;
|
||||||
|
dispatcher?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResponse(body: unknown, status = 200): HttpResponseLike {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
async json(): Promise<unknown> {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AcInfinityApiClient", () => {
|
||||||
|
it("logs in, fetches controllers and mode settings, and maps values", async () => {
|
||||||
|
const calls: CapturedCall[] = [];
|
||||||
|
const responses: HttpResponseLike[] = [
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { appId: "token-1" } }),
|
||||||
|
makeResponse({
|
||||||
|
code: 200,
|
||||||
|
msg: "success",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
devId: "controller-a",
|
||||||
|
devName: "Main Tent",
|
||||||
|
online: 1,
|
||||||
|
devType: 20,
|
||||||
|
newFrameworkDevice: true,
|
||||||
|
temperature: 2434,
|
||||||
|
humidity: 5510,
|
||||||
|
vpdnums: 123,
|
||||||
|
deviceInfo: {
|
||||||
|
ports: [
|
||||||
|
{
|
||||||
|
port: 1,
|
||||||
|
portName: "Inline Fan",
|
||||||
|
online: 1,
|
||||||
|
loadState: 1,
|
||||||
|
speak: 6,
|
||||||
|
curMode: 2,
|
||||||
|
portResistance: 1200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { atType: 2, onSpead: 7, offSpead: 1 } })
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock: FetchLike = async (input, init) => {
|
||||||
|
calls.push({ input, init });
|
||||||
|
const response = responses.shift();
|
||||||
|
if (!response) {
|
||||||
|
throw new Error("no response queued");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new AcInfinityApiClient(
|
||||||
|
"http://example.test",
|
||||||
|
"grower@example.com",
|
||||||
|
"abcdefghijklmnopqrstuvwxyz123",
|
||||||
|
1000,
|
||||||
|
fetchMock
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await client.fetchSnapshot();
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(3);
|
||||||
|
expect(calls[0]?.input).toContain("/api/user/appUserLogin");
|
||||||
|
expect(calls[1]?.input).toContain("/api/user/devInfoListAll");
|
||||||
|
expect(calls[2]?.input).toContain("/api/dev/getdevModeSettingList");
|
||||||
|
|
||||||
|
const loginBody = calls[0]?.init.body.toString() ?? "";
|
||||||
|
expect(loginBody).toContain("appPasswordl=abcdefghijklmnopqrstuvwxy");
|
||||||
|
|
||||||
|
expect(snapshot.controllers).toHaveLength(1);
|
||||||
|
const controller = snapshot.controllers[0];
|
||||||
|
expect(controller?.name).toBe("Main Tent");
|
||||||
|
expect(controller?.temperatureCelsius).toBe(24.34);
|
||||||
|
expect(controller?.relativeHumidityRatio).toBe(0.551);
|
||||||
|
expect(controller?.vpdKpa).toBe(1.23);
|
||||||
|
expect(controller?.deviceType).toBe(20);
|
||||||
|
expect(controller?.newFrameworkDevice).toBe(true);
|
||||||
|
|
||||||
|
expect(controller?.ports).toHaveLength(1);
|
||||||
|
const port = controller?.ports[0];
|
||||||
|
expect(port?.currentSpeedLevel).toBe(6);
|
||||||
|
expect(port?.onSpeedLevel).toBe(7);
|
||||||
|
expect(port?.offSpeedLevel).toBe(1);
|
||||||
|
expect(port?.mode).toBe("on");
|
||||||
|
expect(port?.fanGroup).toBe("unknown");
|
||||||
|
expect(port?.connectedDevice).toBe(true);
|
||||||
|
expect(port?.resistanceOhms).toBe(1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses deviceInfo climate values when top-level values are null", async () => {
|
||||||
|
const responses: HttpResponseLike[] = [
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { appId: "token-1" } }),
|
||||||
|
makeResponse({
|
||||||
|
code: 200,
|
||||||
|
msg: "success",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
devId: "controller-a",
|
||||||
|
devName: "Main Tent",
|
||||||
|
online: 1,
|
||||||
|
temperature: null,
|
||||||
|
humidity: null,
|
||||||
|
vpdnums: null,
|
||||||
|
deviceInfo: {
|
||||||
|
temperature: 2406,
|
||||||
|
humidity: 4106,
|
||||||
|
vpdnums: 176,
|
||||||
|
ports: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock: FetchLike = async () => {
|
||||||
|
const response = responses.shift();
|
||||||
|
if (!response) {
|
||||||
|
throw new Error("no response queued");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new AcInfinityApiClient(
|
||||||
|
"http://example.test",
|
||||||
|
"grower@example.com",
|
||||||
|
"secret",
|
||||||
|
1000,
|
||||||
|
fetchMock
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await client.fetchSnapshot();
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
const controller = snapshot.controllers[0];
|
||||||
|
expect(controller?.temperatureCelsius).toBe(24.06);
|
||||||
|
expect(controller?.relativeHumidityRatio).toBe(0.4106);
|
||||||
|
expect(controller?.vpdKpa).toBe(1.76);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies fan groups and skips empty/disconnected ports", async () => {
|
||||||
|
const responses: HttpResponseLike[] = [
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { appId: "token-1" } }),
|
||||||
|
makeResponse({
|
||||||
|
code: 200,
|
||||||
|
msg: "success",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
devId: "controller-b",
|
||||||
|
devName: "Tent B",
|
||||||
|
online: 1,
|
||||||
|
temperature: 2300,
|
||||||
|
humidity: 5000,
|
||||||
|
vpdnums: 140,
|
||||||
|
deviceInfo: {
|
||||||
|
ports: [
|
||||||
|
{ port: 0, portName: "ignored" },
|
||||||
|
{
|
||||||
|
port: 1,
|
||||||
|
portName: "Outlet - Exhaust",
|
||||||
|
online: 1,
|
||||||
|
loadState: 1,
|
||||||
|
speak: 5,
|
||||||
|
curMode: 1,
|
||||||
|
portResistance: 1500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
port: 2,
|
||||||
|
portName: "Inside Inlet",
|
||||||
|
online: 1,
|
||||||
|
loadState: 1,
|
||||||
|
speak: 6,
|
||||||
|
curMode: 0,
|
||||||
|
portResistance: 1600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
port: 3,
|
||||||
|
portName: "Outside Inlet",
|
||||||
|
online: 1,
|
||||||
|
loadState: 1,
|
||||||
|
speak: 4,
|
||||||
|
curMode: 0,
|
||||||
|
portResistance: 1700
|
||||||
|
},
|
||||||
|
{
|
||||||
|
port: 4,
|
||||||
|
portName: "Fans",
|
||||||
|
online: 1,
|
||||||
|
loadState: 1,
|
||||||
|
speak: 3,
|
||||||
|
curMode: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
port: 5,
|
||||||
|
portName: "Disconnected",
|
||||||
|
online: 0,
|
||||||
|
loadState: 0,
|
||||||
|
speak: 0,
|
||||||
|
curMode: 0,
|
||||||
|
portResistance: 70000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { atType: 2, onSpead: 5, offSpead: 1 } }),
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { atType: 7, onSpead: 6, offSpead: 2 } }),
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { atType: 8, onSpead: 4, offSpead: 2 } }),
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { atType: 3, onSpead: 3, offSpead: 1 } })
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock: FetchLike = async () => {
|
||||||
|
const response = responses.shift();
|
||||||
|
if (!response) {
|
||||||
|
throw new Error("no response queued");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new AcInfinityApiClient(
|
||||||
|
"http://example.test",
|
||||||
|
"grower@example.com",
|
||||||
|
"secret",
|
||||||
|
1000,
|
||||||
|
fetchMock
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await client.fetchSnapshot();
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
const controller = snapshot.controllers[0];
|
||||||
|
expect(controller?.ports).toHaveLength(4);
|
||||||
|
const fanGroups = controller?.ports.map((port) => port.fanGroup);
|
||||||
|
expect(fanGroups).toEqual(["outlet", "inside_inlet", "outside_inlet", "interior"]);
|
||||||
|
|
||||||
|
expect(controller?.ports[0]?.mode).toBe("off");
|
||||||
|
expect(controller?.ports[1]?.mode).toBe("schedule");
|
||||||
|
expect(controller?.ports[2]?.mode).toBe("vpd");
|
||||||
|
expect(controller?.ports[3]?.mode).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes token and retries once on auth error", async () => {
|
||||||
|
const calls: CapturedCall[] = [];
|
||||||
|
const responses: HttpResponseLike[] = [
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { appId: "token-1" } }),
|
||||||
|
makeResponse({ code: 10001, msg: "invalid auth", data: null }),
|
||||||
|
makeResponse({ code: 200, msg: "success", data: { appId: "token-2" } }),
|
||||||
|
makeResponse({ code: 200, msg: "success", data: [] })
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock: FetchLike = async (input, init) => {
|
||||||
|
calls.push({ input, init });
|
||||||
|
const response = responses.shift();
|
||||||
|
if (!response) {
|
||||||
|
throw new Error("no response queued");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new AcInfinityApiClient(
|
||||||
|
"http://example.test",
|
||||||
|
"grower@example.com",
|
||||||
|
"secret",
|
||||||
|
1000,
|
||||||
|
fetchMock
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await client.fetchSnapshot();
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
expect(snapshot.controllers).toHaveLength(0);
|
||||||
|
expect(calls).toHaveLength(4);
|
||||||
|
expect(calls[0]?.input).toContain("/api/user/appUserLogin");
|
||||||
|
expect(calls[1]?.input).toContain("/api/user/devInfoListAll");
|
||||||
|
expect(calls[2]?.input).toContain("/api/user/appUserLogin");
|
||||||
|
expect(calls[3]?.input).toContain("/api/user/devInfoListAll");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws typed API error on non-200 HTTP response", async () => {
|
||||||
|
const fetchMock: FetchLike = async () => {
|
||||||
|
return makeResponse({ code: 500, msg: "error", data: null }, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new AcInfinityApiClient(
|
||||||
|
"http://example.test",
|
||||||
|
"grower@example.com",
|
||||||
|
"secret",
|
||||||
|
1000,
|
||||||
|
fetchMock
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.fetchSnapshot()).rejects.toBeInstanceOf(AcInfinityApiError);
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
tests/AppConfig.test.ts
Normal file
37
tests/AppConfig.test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { AppConfig } from "../src/config/AppConfig";
|
||||||
|
|
||||||
|
describe("AppConfig", () => {
|
||||||
|
it("loads required fields with defaults", () => {
|
||||||
|
const config = AppConfig.fromEnv({
|
||||||
|
ACI_EMAIL: "grower@example.com",
|
||||||
|
ACI_PASSWORD: "super-secret"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.aciHost).toBe("http://www.acinfinityserver.com");
|
||||||
|
expect(config.pollIntervalSeconds).toBe(30);
|
||||||
|
expect(config.listenPort).toBe(9108);
|
||||||
|
expect(config.requestTimeoutMs).toBe(10000);
|
||||||
|
expect(config.logLevel).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when required values are missing", () => {
|
||||||
|
expect(() => AppConfig.fromEnv({})).toThrow("ACI_EMAIL is required");
|
||||||
|
expect(() =>
|
||||||
|
AppConfig.fromEnv({
|
||||||
|
ACI_EMAIL: "x",
|
||||||
|
ACI_PASSWORD: "",
|
||||||
|
POLL_INTERVAL_SECONDS: "30"
|
||||||
|
})
|
||||||
|
).toThrow("ACI_PASSWORD is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates numeric ranges", () => {
|
||||||
|
expect(() =>
|
||||||
|
AppConfig.fromEnv({
|
||||||
|
ACI_EMAIL: "x",
|
||||||
|
ACI_PASSWORD: "y",
|
||||||
|
POLL_INTERVAL_SECONDS: "4"
|
||||||
|
})
|
||||||
|
).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600");
|
||||||
|
});
|
||||||
|
});
|
||||||
22
tests/ClimateSnapshot.test.ts
Normal file
22
tests/ClimateSnapshot.test.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { AcInfinityMode, modeFromNumeric, resolveMode } from "../src/domain/ClimateSnapshot";
|
||||||
|
|
||||||
|
describe("ClimateSnapshot mode helpers", () => {
|
||||||
|
it("maps known numeric modes and falls back to unknown", () => {
|
||||||
|
expect(modeFromNumeric(1)).toBe(AcInfinityMode.Off);
|
||||||
|
expect(modeFromNumeric(2)).toBe(AcInfinityMode.On);
|
||||||
|
expect(modeFromNumeric(3)).toBe(AcInfinityMode.Auto);
|
||||||
|
expect(modeFromNumeric(4)).toBe(AcInfinityMode.TimerToOn);
|
||||||
|
expect(modeFromNumeric(5)).toBe(AcInfinityMode.TimerToOff);
|
||||||
|
expect(modeFromNumeric(6)).toBe(AcInfinityMode.Cycle);
|
||||||
|
expect(modeFromNumeric(7)).toBe(AcInfinityMode.Schedule);
|
||||||
|
expect(modeFromNumeric(8)).toBe(AcInfinityMode.Vpd);
|
||||||
|
expect(modeFromNumeric(0)).toBe(AcInfinityMode.Unknown);
|
||||||
|
expect(modeFromNumeric(undefined)).toBe(AcInfinityMode.Unknown);
|
||||||
|
expect(modeFromNumeric(null)).toBe(AcInfinityMode.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers runtime mode unless runtime is unknown", () => {
|
||||||
|
expect(resolveMode(AcInfinityMode.On, AcInfinityMode.Auto)).toBe(AcInfinityMode.On);
|
||||||
|
expect(resolveMode(AcInfinityMode.Unknown, AcInfinityMode.Vpd)).toBe(AcInfinityMode.Vpd);
|
||||||
|
});
|
||||||
|
});
|
||||||
135
tests/TyphonMetrics.test.ts
Normal file
135
tests/TyphonMetrics.test.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { Registry } from "prom-client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AcInfinityMode,
|
||||||
|
ClimateSnapshot,
|
||||||
|
ControllerClimate,
|
||||||
|
PortClimate
|
||||||
|
} from "../src/domain/ClimateSnapshot";
|
||||||
|
import { TyphonMetrics } from "../src/metrics/TyphonMetrics";
|
||||||
|
|
||||||
|
function valueForMetric(
|
||||||
|
registryJson: Array<{
|
||||||
|
name: string;
|
||||||
|
values?: Array<{ labels: Record<string, string | number | undefined>; value: number | string }>;
|
||||||
|
}>,
|
||||||
|
name: string,
|
||||||
|
labels: Record<string, string>
|
||||||
|
): number | null {
|
||||||
|
const metric = registryJson.find((item) => item.name === name);
|
||||||
|
const found = metric?.values?.find((entry) =>
|
||||||
|
Object.entries(labels).every(([key, expected]) => String(entry.labels[key]) === expected)
|
||||||
|
);
|
||||||
|
return found ? Number(found.value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TyphonMetrics", () => {
|
||||||
|
it("updates all climate gauges from a snapshot", async () => {
|
||||||
|
const registry = new Registry();
|
||||||
|
const metrics = new TyphonMetrics("0.1.0", false, registry);
|
||||||
|
|
||||||
|
const snapshot = new ClimateSnapshot(1_700_000_000, [
|
||||||
|
new ControllerClimate("c1", "Tent A", true, 24.5, 0.55, 1.2, 20, true, [
|
||||||
|
new PortClimate(1, "Fan A", "interior", true, true, 6, 7, 1, true, 1200, AcInfinityMode.On)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
|
metrics.updateFromSnapshot(snapshot);
|
||||||
|
|
||||||
|
const json = await registry.getMetricsAsJSON();
|
||||||
|
expect(valueForMetric(json, "typhon_up", {})).toBe(1);
|
||||||
|
expect(valueForMetric(json, "typhon_temperature_celsius", { controller_id: "c1", controller_name: "Tent A" })).toBe(24.5);
|
||||||
|
expect(valueForMetric(json, "typhon_relative_humidity_ratio", { controller_id: "c1", controller_name: "Tent A" })).toBe(0.55);
|
||||||
|
expect(valueForMetric(json, "typhon_relative_humidity_percent", { controller_id: "c1", controller_name: "Tent A" })).toBeCloseTo(55, 6);
|
||||||
|
expect(valueForMetric(json, "typhon_controller_info", {
|
||||||
|
controller_id: "c1",
|
||||||
|
controller_name: "Tent A",
|
||||||
|
device_type: "20",
|
||||||
|
new_framework_device: "true"
|
||||||
|
})).toBe(1);
|
||||||
|
expect(valueForMetric(json, "typhon_fan_speed_level", {
|
||||||
|
controller_id: "c1",
|
||||||
|
controller_name: "Tent A",
|
||||||
|
port: "1",
|
||||||
|
port_name: "Fan A",
|
||||||
|
fan_group: "interior"
|
||||||
|
})).toBe(6);
|
||||||
|
expect(valueForMetric(json, "typhon_port_connected_device", {
|
||||||
|
controller_id: "c1",
|
||||||
|
controller_name: "Tent A",
|
||||||
|
port: "1",
|
||||||
|
port_name: "Fan A",
|
||||||
|
fan_group: "interior"
|
||||||
|
})).toBe(1);
|
||||||
|
expect(valueForMetric(json, "typhon_mode", {
|
||||||
|
controller_id: "c1",
|
||||||
|
controller_name: "Tent A",
|
||||||
|
port: "1",
|
||||||
|
port_name: "Fan A",
|
||||||
|
fan_group: "interior",
|
||||||
|
mode: "on"
|
||||||
|
})).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports unknown controller metadata and nullable resistance", async () => {
|
||||||
|
const registry = new Registry();
|
||||||
|
const metrics = new TyphonMetrics("0.1.0", false, registry);
|
||||||
|
|
||||||
|
const snapshot = new ClimateSnapshot(1_700_000_050, [
|
||||||
|
new ControllerClimate("c2", "Tent B", false, 22.2, 0.40, 0.8, null, null, [
|
||||||
|
new PortClimate(2, "Fan B", "outlet", false, false, 0, 3, 0, true, null, AcInfinityMode.Unknown)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
|
metrics.updateFromSnapshot(snapshot);
|
||||||
|
|
||||||
|
const json = await registry.getMetricsAsJSON();
|
||||||
|
expect(valueForMetric(json, "typhon_controller_info", {
|
||||||
|
controller_id: "c2",
|
||||||
|
controller_name: "Tent B",
|
||||||
|
device_type: "unknown",
|
||||||
|
new_framework_device: "unknown"
|
||||||
|
})).toBe(1);
|
||||||
|
|
||||||
|
const resistance = valueForMetric(json, "typhon_port_resistance_ohms", {
|
||||||
|
controller_id: "c2",
|
||||||
|
controller_name: "Tent B",
|
||||||
|
port: "2",
|
||||||
|
port_name: "Fan B",
|
||||||
|
fan_group: "outlet"
|
||||||
|
});
|
||||||
|
expect(resistance).toBeNull();
|
||||||
|
|
||||||
|
expect(valueForMetric(json, "typhon_mode", {
|
||||||
|
controller_id: "c2",
|
||||||
|
controller_name: "Tent B",
|
||||||
|
port: "2",
|
||||||
|
port_name: "Fan B",
|
||||||
|
fan_group: "outlet",
|
||||||
|
mode: "unknown"
|
||||||
|
})).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks polling failures", async () => {
|
||||||
|
const registry = new Registry();
|
||||||
|
const metrics = new TyphonMetrics("0.1.0", false, registry);
|
||||||
|
|
||||||
|
metrics.markPollFailure("api", "100001");
|
||||||
|
|
||||||
|
const json = await registry.getMetricsAsJSON();
|
||||||
|
expect(valueForMetric(json, "typhon_up", {})).toBe(0);
|
||||||
|
expect(valueForMetric(json, "typhon_poll_errors_total", { reason: "api", code: "100001" })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports data age when there is a successful snapshot", async () => {
|
||||||
|
const registry = new Registry();
|
||||||
|
const metrics = new TyphonMetrics("0.1.0", false, registry);
|
||||||
|
|
||||||
|
metrics.updateFromSnapshot(new ClimateSnapshot(1_700_000_000, []));
|
||||||
|
metrics.refreshDataAgeGauge(1_700_000_090);
|
||||||
|
|
||||||
|
const json = await registry.getMetricsAsJSON();
|
||||||
|
expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(90);
|
||||||
|
expect(valueForMetric(json, "typhon_last_successful_poll_timestamp_seconds", {})).toBe(1_700_000_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node",
|
||||||
|
"jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user