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; value: number | string }>; }>, name: string, labels: Record ): 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", true, 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("suppresses stale climate and fan gauges when controller is offline", async () => { const registry = new Registry(); const metrics = new TyphonMetrics("0.1.0", false, registry); const snapshot = new ClimateSnapshot(1_700_000_060, [ new ControllerClimate("c3", "Tent C", false, 23.1, 0.42, 1.1, 11, false, [ new PortClimate(3, "Fan C", "interior", true, true, 7, 8, 2, true, 1500, AcInfinityMode.Auto) ]) ]); metrics.updateFromSnapshot(snapshot); const json = await registry.getMetricsAsJSON(); expect(valueForMetric(json, "typhon_controller_online", { controller_id: "c3", controller_name: "Tent C" })).toBe(0); expect(valueForMetric(json, "typhon_controller_info", { controller_id: "c3", controller_name: "Tent C", device_type: "11", new_framework_device: "false" })).toBe(1); expect(valueForMetric(json, "typhon_temperature_celsius", { controller_id: "c3", controller_name: "Tent C" })).toBeNull(); expect(valueForMetric(json, "typhon_relative_humidity_percent", { controller_id: "c3", controller_name: "Tent C" })).toBeNull(); expect(valueForMetric(json, "typhon_vpd_kpa", { controller_id: "c3", controller_name: "Tent C" })).toBeNull(); expect(valueForMetric(json, "typhon_fan_speed_level", { controller_id: "c3", controller_name: "Tent C", port: "3", port_name: "Fan C", fan_group: "interior" })).toBeNull(); }); 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); }); });