feat: bootstrap typhon exporter with jenkins quality gate

This commit is contained in:
Brad Stein 2026-04-13 01:48:32 -03:00
commit c3a92a88e1
24 changed files with 6716 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
dist/
build/
coverage/
.env
npm-debug.log*
AGENTS.md

26
Dockerfile Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View 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
View 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"

View 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
View 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`);
}
}

View 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;
}

View 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
View 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
View 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
View 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);
});

View 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));
}
}

View 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);
}
}

View 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" };
}
}

View 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;
}
}

View 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
View 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");
});
});

View 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
View 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
View 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"
]
}