diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ab71df..c07c229 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v2 with: - node-version: '18.x' + node-version: '22.x' - name: Install dependencies run: npm ci - name: Check formatting @@ -30,7 +30,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v2 with: - node-version: '18.x' + node-version: '22.x' - name: Install dependencies run: npm ci - name: Run eslint @@ -43,7 +43,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v2 with: - node-version: '18.x' + node-version: '22.x' - name: Install dependencies run: npm ci - name: Run tests @@ -59,7 +59,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v2 with: - node-version: '18.x' + node-version: '22.x' - name: Install dependencies run: npm ci - name: Check formatting @@ -75,7 +75,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v2 with: - node-version: '18.x' + node-version: '22.x' - name: Install dependencies run: npm ci - name: Run eslint diff --git a/package-lock.json b/package-lock.json index 6ff5329..cef3bed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "license": "GPL-3.0-or-later", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", - "axios": "^1.6.0", "modbus-serial": "^8.0.16", "mqtt": "^5.1.2", "slugify": "^1.6.6", @@ -23,7 +22,7 @@ "devDependencies": { "@tsconfig/node18": "^18.2.2", "@types/jest": "^29.5.6", - "@types/node": "^20.5.9", + "@types/node": "^22.1.0", "@types/ws": "^8.5.5", "@types/yargs": "^17.0.24", "@typescript-eslint/eslint-plugin": "^6.7.2", @@ -35,7 +34,7 @@ "typescript": "^5.2.2" }, "engines": { - "node": ">= 18" + "node": ">= 22" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1665,9 +1664,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.5.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.9.tgz", - "integrity": "sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ==" + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", + "integrity": "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.13.0" + } }, "node_modules/@types/readable-stream": { "version": "4.0.4", @@ -2056,21 +2059,6 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", - "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2506,17 +2494,6 @@ "text-hex": "1.0.x" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commist": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", @@ -2649,14 +2626,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3151,38 +3120,6 @@ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4448,25 +4385,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -4948,11 +4866,6 @@ "node": ">= 6" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -5663,6 +5576,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", + "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", diff --git a/package.json b/package.json index 9cee0e0..c498af6 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,12 @@ "test": "jest" }, "engines": { - "node": ">= 18" + "node": ">= 22" }, "devDependencies": { "@tsconfig/node18": "^18.2.2", "@types/jest": "^29.5.6", - "@types/node": "^20.5.9", + "@types/node": "^22.1.0", "@types/ws": "^8.5.5", "@types/yargs": "^17.0.24", "@typescript-eslint/eslint-plugin": "^6.7.2", @@ -32,7 +32,6 @@ }, "dependencies": { "@influxdata/influxdb-client": "^1.33.2", - "axios": "^1.6.0", "modbus-serial": "^8.0.16", "mqtt": "^5.1.2", "slugify": "^1.6.6", diff --git a/src/eachwatt.ts b/src/eachwatt.ts index cd080b0..02d1610 100644 --- a/src/eachwatt.ts +++ b/src/eachwatt.ts @@ -8,7 +8,7 @@ import { httpRequestHandler } from './http/server' import { WebSocketPublisherImpl } from './publisher/websocket' import { PublisherType } from './publisher' import { pollCharacteristicsSensors } from './characteristics' -import { createLogger } from './logger' +import { createLogger, LogLevel, setLogLevel } from './logger' import { setRequestTimeout as setHttpRequestTimeout } from './http/client' import { setRequestTimeout as setModbusRequestTimeout } from './modbus/client' import { applyFilters } from './filter/filter' @@ -26,10 +26,18 @@ const argv = yargs(process.argv.slice(2)) demandOption: true, alias: 'c', }, + 'verbose': { + description: 'Enable verbose logging', + alias: 'v', + }, }) .parseSync() const logger = createLogger('main') +if (argv.verbose) { + logger.info('Setting log level to DEBUG') + setLogLevel(LogLevel.DEBUG) +} const mainPollerFunc = async (config: Config) => { const now = Date.now() diff --git a/src/http/client.ts b/src/http/client.ts index bbfc65d..3bf00da 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -1,23 +1,26 @@ -import axios, { AxiosResponse } from 'axios' -import http from 'http' import { createLogger } from '../logger' const logger = createLogger('http') -const httpClient = axios.create({ - // We keep polling the same hosts over and over so keep-alive is essential - httpAgent: new http.Agent({ keepAlive: true }), -}) - +let requestTimeout = 0 let lastTimestamp = 0 const promiseCache = new Map() +const createRequestParams = (): RequestInit => { + return { + // We keep polling the same hosts over and over so keep-alive is essential + keepalive: true, + // Use the configured timeout + signal: AbortSignal.timeout(requestTimeout), + } +} + export const setRequestTimeout = (timeoutMs: number) => { - httpClient.defaults.timeout = timeoutMs + requestTimeout = timeoutMs logger.info(`Using ${timeoutMs} millisecond timeout for HTTP requests`) } -export const getDedupedResponse = async (timestamp: number, url: string): Promise => { +export const getDedupedResponse = async (timestamp: number, url: string): Promise => { // Clear the cache whenever the timestamp changes if (timestamp !== lastTimestamp) { lastTimestamp = timestamp @@ -30,7 +33,9 @@ export const getDedupedResponse = async (timestamp: number, url: string): Promis return promiseCache.get(key) } - const promise = httpClient.get(url) + const request = new Request(url, createRequestParams()) + logger.debug(`GET ${url}`) + const promise = fetch(request) promiseCache.set(key, promise) return promise diff --git a/src/logger.ts b/src/logger.ts index 7c6e757..4979004 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,9 @@ import winston, { Logger } from 'winston' -const DEFAULT_LOG_LEVEL = 'info' +export enum LogLevel { + INFO = 'info', + DEBUG = 'debug', +} // Define log transports here, so we can change the log level later const transports = [new winston.transports.Console()] @@ -9,14 +12,13 @@ const logFormat = winston.format.printf(({ level, message, label, timestamp }) = return `${timestamp} [${label}] ${level}: ${message}` }) -// export const setLogLevel = (logger: Logger, level: string) => { -// logger.info(`Setting log level to ${level}`) -// transports[0].level = level -// } +export const setLogLevel = (level: LogLevel) => { + transports[0].level = level +} export const createLogger = (module: string): Logger => { return winston.createLogger({ - 'level': DEFAULT_LOG_LEVEL, + 'level': LogLevel.INFO, 'format': winston.format.combine(winston.format.label({ label: module }), winston.format.timestamp(), logFormat), 'transports': transports, }) diff --git a/src/sensor/iotawatt.ts b/src/sensor/iotawatt.ts index 74dd04a..3d011af 100644 --- a/src/sensor/iotawatt.ts +++ b/src/sensor/iotawatt.ts @@ -114,10 +114,10 @@ export const getSensorData: PowerSensorPollFunction = async ( const sensor = circuit.sensor as IotawattSensor try { - const configurationResult = await getDedupedResponse(timestamp, getConfigurationUrl(sensor)) - const configuration = configurationResult.data as IotawattConfiguration - const statusResult = await getDedupedResponse(timestamp, getStatusUrl(sensor)) - const status = statusResult.data as IotawattStatus + const configurationResult = (await getDedupedResponse(timestamp, getConfigurationUrl(sensor))).clone() + const configuration = (await configurationResult.json()) as IotawattConfiguration + const statusResult = (await getDedupedResponse(timestamp, getStatusUrl(sensor))).clone() + const status = (await statusResult.json()) as IotawattStatus return { timestamp: timestamp, @@ -142,8 +142,8 @@ export const getCharacteristicsSensorData: CharacteristicsSensorPollFunction = a const sensor = characteristics.sensor as IotawattCharacteristicsSensor try { - const queryResult = await getDedupedResponse(timestamp, getQueryUrl(sensor)) - const query = queryResult.data as IotawattCharacteristicsQuery + const queryResult = (await getDedupedResponse(timestamp, getQueryUrl(sensor))).clone() + const query = (await queryResult.json()) as IotawattCharacteristicsQuery return { timestamp: timestamp, diff --git a/src/sensor/modbus.ts b/src/sensor/modbus.ts index b10bf41..809dc94 100644 --- a/src/sensor/modbus.ts +++ b/src/sensor/modbus.ts @@ -36,6 +36,7 @@ export const getSensorData: PowerSensorPollFunction = async ( } // Read the register and parse it accordingly + logger.debug(`Reading holding register ${sensorSettings.register}`) const readRegisterResult = await client.readHoldingRegisters(sensorSettings.register, 1) return { diff --git a/src/sensor/shelly.ts b/src/sensor/shelly.ts index 872d5b5..d76b55f 100644 --- a/src/sensor/shelly.ts +++ b/src/sensor/shelly.ts @@ -11,7 +11,6 @@ import { } from '../sensor' import { Circuit } from '../circuit' import { getDedupedResponse } from '../http/client' -import { AxiosResponse } from 'axios' import { Characteristics } from '../characteristics' import { createLogger } from '../logger' @@ -66,9 +65,13 @@ const getSensorDataUrl = (sensor: ShellySensor | ShellyCharacteristicsSensor): s } } -const parseGen1Response = (timestamp: number, circuit: Circuit, httpResponse: AxiosResponse): PowerSensorData => { +const parseGen1Response = async ( + timestamp: number, + circuit: Circuit, + httpResponse: Response, +): Promise => { const sensor = circuit.sensor as ShellySensor - const data = httpResponse.data as Gen1StatusResult + const data = (await httpResponse.json()) as Gen1StatusResult return { timestamp: timestamp, @@ -77,8 +80,12 @@ const parseGen1Response = (timestamp: number, circuit: Circuit, httpResponse: Ax } } -const parseGen2PMResponse = (timestamp: number, circuit: Circuit, httpResponse: AxiosResponse): PowerSensorData => { - const data = httpResponse.data as Gen2SwitchGetStatusResult +const parseGen2PMResponse = async ( + timestamp: number, + circuit: Circuit, + httpResponse: Response, +): Promise => { + const data = (await httpResponse.json()) as Gen2SwitchGetStatusResult return { timestamp: timestamp, @@ -87,9 +94,13 @@ const parseGen2PMResponse = (timestamp: number, circuit: Circuit, httpResponse: } } -const parseGen2EMResponse = (timestamp: number, circuit: Circuit, httpResponse: AxiosResponse): PowerSensorData => { +const parseGen2EMResponse = async ( + timestamp: number, + circuit: Circuit, + httpResponse: Response, +): Promise => { const sensor = circuit.sensor as ShellySensor - const data = httpResponse.data as Gen2EMGetStatusResult + const data = (await httpResponse.json()) as Gen2EMGetStatusResult let power = 0 let apparentPower = 0 @@ -129,7 +140,7 @@ export const getSensorData: PowerSensorPollFunction = async ( const url = getSensorDataUrl(sensor) try { - const httpResponse = await getDedupedResponse(timestamp, url) + const httpResponse = (await getDedupedResponse(timestamp, url)).clone() // Parse the response differently depending on what type of Shelly we're dealing with switch (sensor.shelly.type as ShellyType) { @@ -159,8 +170,8 @@ export const getCharacteristicsSensorData: CharacteristicsSensorPollFunction = a } try { - const httpResponse = await getDedupedResponse(timestamp, url) - const data = httpResponse.data as Gen2EMGetStatusResult + const httpResponse = (await getDedupedResponse(timestamp, url)).clone() + const data = (await httpResponse.json()) as Gen2EMGetStatusResult let voltage = 0 let frequency = 0 diff --git a/tests/sensor/shelly.test.ts b/tests/sensor/shelly.test.ts index 34ce4fd..9735c18 100644 --- a/tests/sensor/shelly.test.ts +++ b/tests/sensor/shelly.test.ts @@ -10,24 +10,22 @@ const gen2pmResponse = fs.readFileSync('./tests/sensor/shelly-plus-1pm.Switch.Ge // Mock getDedupedResponse calls to return real-world data jest.mock('../../src/http/client', () => ({ - getDedupedResponse: (timestamp: number, url: string) => { - let contents + getDedupedResponse: async (timestamp: number, url: string) => { + let contents: string | null = null switch (url) { case 'http://127.0.0.1/status': - contents = gen1Response + contents = String(gen1Response) break case 'http://127.0.0.1/rpc/EM.GetStatus?id=0': - contents = gen2emResponse + contents = String(gen2emResponse) break case 'http://127.0.0.1/rpc/Switch.GetStatus?id=0': - contents = gen2pmResponse + contents = String(gen2pmResponse) break } - return { - data: JSON.parse(String(contents)), - } + return Promise.resolve(new Response(contents)) }, }))