From 3ae793eaf76bf5e74b911e64758f5aea452b032f Mon Sep 17 00:00:00 2001 From: Alexander Thoukydides Date: Sun, 31 Dec 2023 16:39:47 +0000 Subject: [PATCH] Tidying --- src/aeg-account.ts | 12 +++---- src/aeg-robot-ctrl.ts | 10 +++--- src/aeg-robot-log.ts | 22 ++++++------ src/aeg-robot.ts | 10 +++--- src/aegapi-error.ts | 6 ++-- src/aegapi-test.ts | 6 ++-- src/aegapi-ua-auth.ts | 12 ++++--- src/aegapi-ua.ts | 7 ++-- src/heartbeat.ts | 9 +++-- src/platform.ts | 6 ++-- src/utils.ts | 78 +++++++++++++++++++++++++++++++++---------- 11 files changed, 114 insertions(+), 64 deletions(-) diff --git a/src/aeg-account.ts b/src/aeg-account.ts index dba4011..01bcd54 100644 --- a/src/aeg-account.ts +++ b/src/aeg-account.ts @@ -6,7 +6,7 @@ import { Logger, LogLevel } from 'homebridge'; import { AEGAPI } from './aegapi'; import { AEGRobot } from './aeg-robot'; import { HealthCheck } from './aegapi-types'; -import { columns } from './utils'; +import { columns, formatList, MS, plural } from './utils'; import { Config } from './config-types'; import { AEGApplianceAPI } from './aegapi-appliance'; import { Heartbeat } from './heartbeat'; @@ -63,8 +63,8 @@ export class AEGAccount { } }); if (incompatible.length) { - this.log.info(`Ignoring ${incompatible.length} incompatible appliances: ` - + incompatible.join(', ')); + this.log.info(`Ignoring ${plural(incompatible.length, 'incompatible appliance')}: ` + + formatList(incompatible)); } // Update any robots with the domain details @@ -82,7 +82,7 @@ export class AEGAccount { ['Feed', intervals.feedSeconds, this.pollFeed] ]; this.heartbeats = poll.map(action => - new Heartbeat(this.log, action[0], action[1] * 1000, action[2].bind(this), + new Heartbeat(this.log, action[0], action[1] * MS, action[2].bind(this), (err) => this.heartbeat(err))); } @@ -123,13 +123,13 @@ export class AEGAccount { // Summary status if (!failed) this.log.debug('All AEG API servers appear healthy:'); - else this.log.error(`${failed} of ${servers.length} AEG API servers have problems:`); + else this.log.error(`${failed} of ${plural(servers.length, 'AEG API server')} have problems:`); // Detailed status const rows: string[][] = servers.map(server => ([ server.app, server.release, - server.version || '', + server.version ?? '', server.environment, `${server.statusCode}`, server.message diff --git a/src/aeg-robot-ctrl.ts b/src/aeg-robot-ctrl.ts index a7602f5..3cd47ec 100644 --- a/src/aeg-robot-ctrl.ts +++ b/src/aeg-robot-ctrl.ts @@ -3,17 +3,19 @@ import { Logger } from 'homebridge'; +import { setTimeout } from 'node:timers/promises'; + import { AEGRobot, SimpleActivity } from './aeg-robot'; import { AEGApplianceAPI } from './aegapi-appliance'; import { Activity, Appliance, CleaningCommand, PowerMode } from './aegapi-types'; -import { logError, sleep } from './utils'; +import { MS, logError } from './utils'; import { Config } from './config-types'; // Timezone to use when changing name if unable to determine const DEFAULT_TIMEZONE = 'London/Europe'; // Timeout waiting for status to reflect a requested change -const TIMEOUT_MIN_MS = 20 * 1000; +const TIMEOUT_MIN_MS = 20 * MS; const TIMEOUT_POLL_MULTIPLE = 3; // An abstract AEG RX 9 / Electrolux Pure i9 robot controller @@ -47,7 +49,7 @@ abstract class AEGRobotCtrl { this.api = robot.api; this.timeout = Math.max(TIMEOUT_MIN_MS, this.config.pollIntervals.statusSeconds - * 1000 * TIMEOUT_POLL_MULTIPLE); + * MS * TIMEOUT_POLL_MULTIPLE); robot.on('preUpdate', () => { if (this.target !== undefined) this.overrideStatus(this.target); }); @@ -113,7 +115,7 @@ abstract class AEGRobotCtrl { await this.setTarget(target); // Timeout waiting for status to reflect the requested change - const timeout = sleep(this.timeout); + const timeout = setTimeout(this.timeout); // Wait for status update, change of target state, or timeout let done: boolean | null, reason; diff --git a/src/aeg-robot-log.ts b/src/aeg-robot-log.ts index 7e59c0b..556b662 100644 --- a/src/aeg-robot-log.ts +++ b/src/aeg-robot-log.ts @@ -6,7 +6,7 @@ import { Logger, LogLevel } from 'homebridge'; import { AEGRobot, CleanedAreaWithMap } from './aeg-robot'; import { Activity, Battery, Capability, Completion, Dustbin, FeedItem, Message, PowerMode } from './aegapi-types'; -import { columns, formatDuration } from './utils'; +import { columns, formatList, formatMilliseconds, formatSeconds, MS, plural } from './utils'; import { AEGRobotMap } from './aeg-map'; // Descriptions of the robot activity @@ -63,7 +63,7 @@ const completionNames: Record = { }; // Robot tick duration -const TICK_MS = 1e-4; +const TICK_MS = 10 * MS; // Logging of information about a robot export class AEGRobotLog { @@ -99,7 +99,7 @@ export class AEGRobotLog { this.robot.on('rawName', (name: string) => { this.log.info(`My name is "${name}"`); }).on('capabilities', (capabilities: Capability[]) => { - this.log.info(`Supported capabilities: ${capabilities.join(', ')}`); + this.log.info(`Supported ${plural(capabilities.length, 'capability')}: ${formatList(capabilities)}`); }).on('firmware', (firmware: string) => { this.log.info(`Firmware version ${firmware} installed`); }).on('battery', (battery?: Battery) => { @@ -140,24 +140,24 @@ export class AEGRobotLog { // Log messages from the robot async logMessages(): Promise { this.robot.on('message', (message: Message) => { - const age = `${formatDuration(Date.now() - message.timestamp * 1000)} ago`; + const age = `${formatMilliseconds(Date.now() - message.timestamp * MS)} ago`; const bits = [`type=${message.type}`]; if (message.userErrorID) bits.push(`user-error=${message.userErrorID}`); if (message.internalErrorID) bits.push(`internal-error=${message.internalErrorID}`); this.log.warn(`Message: ${message.text} (${age})`); - this.log.debug(`Message: ${bits.join(', ')}`); + this.log.debug(`Message: ${formatList(bits)}`); }).on('feed', (item: FeedItem) => { - const age = `${formatDuration(Date.now() - Date.parse(item.createdAtUTC))} ago`; + const age = `${formatMilliseconds(Date.now() - Date.parse(item.createdAtUTC))} ago`; switch (item.feedDataType) { case 'RVCLastWeekCleanedArea': this.log.info(`Weekly insight (${age}):`); - this.log.info(` Worked for ${formatDuration(item.data.cleaningDurationTicks * TICK_MS)}`); + this.log.info(` Worked for ${formatMilliseconds(item.data.cleaningDurationTicks * TICK_MS)}`); this.log.info(` ${item.data.cleanedAreaSquareMeter} m² cleaned`); this.log.info(` Cleaned ${item.data.sessionCount} times`); this.log.info(` Recharged ${item.data.pitstopCount} times while cleaning`); break; case 'OsirisBusierWeekJobDone': { - const formatHours = (hours: number) => formatDuration(hours * 60 * 60 * 1000); + const formatHours = (hours: number) => formatSeconds(hours * 60 * 60); this.log.info(`Worked more this week (${age}):`); this.log.info(` Worked ${formatHours(item.data.current)} this week`); this.log.info(` Worked ${formatHours(item.data.previous)} previous week`); @@ -185,7 +185,7 @@ export class AEGRobotLog { break; case 'ApplianceBirthday': this.log.info(`Happy birthday! (${age})`); - this.log.info(` Robot is ${item.data.age} year${item.data.age === 1 ? '' : 's'} old`); + this.log.info(` Robot is ${plural(item.data.age, 'year')} old`); this.log.info(` First activated ${item.data.birthDay}`); break; default: @@ -205,10 +205,10 @@ export class AEGRobotLog { if (cleaningSession) { const formatTime = (time: string) => new Date(time).toLocaleTimeString(); this.log.info(` ${formatTime(cleaningSession.startTime)} - ${formatTime(cleaningSession.eventTime)}`); - this.log.info(` Cleaned for ${formatDuration(cleaningSession.cleaningDuration * TICK_MS)}`); + this.log.info(` Cleaned for ${formatMilliseconds(cleaningSession.cleaningDuration * TICK_MS)}`); if (cleaningSession.pitstopCount) { this.log.info(` Recharged ${cleaningSession.pitstopCount} times while cleaning`); - this.log.info(` Charged for ${formatDuration(cleaningSession.pitstopDuration * TICK_MS)}`); + this.log.info(` Charged for ${formatMilliseconds(cleaningSession.pitstopDuration * TICK_MS)}`); } if (cleaningSession.completion) { this.log.info(` ${completionNames[cleaningSession.completion]}`); diff --git a/src/aeg-robot.ts b/src/aeg-robot.ts index 8810862..5eb177e 100644 --- a/src/aeg-robot.ts +++ b/src/aeg-robot.ts @@ -11,7 +11,7 @@ import { Activity, Appliance, ApplianceNamePatch, Battery, Capability, DomainAppliance, Dustbin, FeedItem, InteractiveMap, InteractiveMapData, Message, PowerMode, Status } from './aegapi-types'; import { PrefixLogger } from './logger'; -import { logError } from './utils'; +import { MS, formatList, logError } from './utils'; import { AEGRobotLog } from './aeg-robot-log'; import { AEGRobotCtrlActivity, AEGRobotCtrlName, AEGRobotCtrlPower } from './aeg-robot-ctrl'; @@ -138,7 +138,7 @@ export class AEGRobot extends EventEmitter { // Initialise static information that is already known this.applianceId = appliance.applianceId; this.model = appliance.applianceData.modelName; - this.hardware = appliance.properties.reported.platform || ''; + this.hardware = appliance.properties.reported.platform ?? ''; // Allow the robot to be controlled this.setName = new AEGRobotCtrlName (this).makeSetter(); @@ -181,7 +181,7 @@ export class AEGRobot extends EventEmitter { ['Cleaned areas', intervals.cleanedAreasSeconds, this.pollCleanedAreas] ]; this.heartbeats = poll.map(action => - new Heartbeat(this.log, action[0], action[1] * 1000, action[2].bind(this), + new Heartbeat(this.log, action[0], action[1] * MS, action[2].bind(this), (err) => this.heartbeat(err))); } @@ -208,7 +208,7 @@ export class AEGRobot extends EventEmitter { // Other details may be absent if the robot is not reachable capabilities: Object.keys(reported.capabilities ?? {}), - firmware: reported.firmwareVersion || '', + firmware: reported.firmwareVersion ?? '', battery: reported.batteryStatus, activity: reported.robotStatus, dustbin: reported.dustbinStatus, @@ -340,7 +340,7 @@ export class AEGRobot extends EventEmitter { }; const summary = changed.map(key => `${key}: ${toText(this.emittedStatus[key])}->${toText(this.status[key])}`); - this.log.debug(summary.join(', ')); + this.log.debug(formatList(summary)); // Emit events for each change changed.forEach(key => this.emit(key, this.status[key], this.emittedStatus[key])); diff --git a/src/aegapi-error.ts b/src/aegapi-error.ts index 0958509..2eca48f 100644 --- a/src/aegapi-error.ts +++ b/src/aegapi-error.ts @@ -73,8 +73,8 @@ export class AEGAPIStatusCodeError extends AEGAPIError { const statusCode = response.statusCode; const statusCodeName = STATUS_CODES[statusCode]; const description = AEGAPIStatusCodeError.getBodyDescription(text) - || AEGAPIStatusCodeError.getHeaderDescription(response) - || 'No error message returned'; + ?? AEGAPIStatusCodeError.getHeaderDescription(response) + ?? 'No error message returned'; return `[${statusCode} ${statusCodeName}] ${description}`; } @@ -105,7 +105,7 @@ export class AEGAPIStatusCodeError extends AEGAPIError { // Attempt to extract a useful description from the response headers static getHeaderDescription(response: Response): string | null { const header = response.headers['www-authenticate'] - || response.headers['x-amzn-remapped-www-authenticate']; + ?? response.headers['x-amzn-remapped-www-authenticate']; return typeof header === 'string' && header.length ? header : null; } } diff --git a/src/aegapi-test.ts b/src/aegapi-test.ts index a5eb9bf..e8ebb65 100644 --- a/src/aegapi-test.ts +++ b/src/aegapi-test.ts @@ -7,7 +7,7 @@ import { AEGAPI } from './aegapi'; import { AEGApplianceAPI } from './aegapi-appliance'; import { Appliance, Appliances, CleaningCommand, DomainAppliance, Domains, NewTask, PowerMode, SettableProperties, User } from './aegapi-types'; -import { logError } from './utils'; +import { logError, plural } from './utils'; // A test failure interface Failure { @@ -75,7 +75,7 @@ export class AEGAPITest { const user = await test(this.api.getCurrentUser); const appliances = await test(this.api.getAppliances); const domains = await test(this.api.getDomains); - const applianceIds = (appliances || []).map(a => a.applianceId); + const applianceIds = (appliances ?? []).map(a => a.applianceId); await test(this.api.getWebShopURLs, applianceIds); // Return results required for other tests @@ -205,7 +205,7 @@ export class AEGAPITest { // Log a summary of the results summariseResults(): void { if (this.failures.length) { - this.log.error(`${this.failures.length} of ${this.tests} API tests failed`); + this.log.error(`${this.failures.length} of ${plural(this.tests, 'API test')} failed`); this.failures.forEach(failure => { this.log.error(`${failure.logPrefix}: ${failure.testName}`); this.log.error(` ${failure.error}`); diff --git a/src/aegapi-ua-auth.ts b/src/aegapi-ua-auth.ts index 84e7179..461b564 100644 --- a/src/aegapi-ua-auth.ts +++ b/src/aegapi-ua-auth.ts @@ -2,14 +2,16 @@ // Copyright © 2022-2023 Alexander Thoukydides import { Logger } from 'homebridge'; + import { getItem, setItem } from 'node-persist'; import { CheckerT, createCheckers } from 'ts-interface-checker'; +import { setTimeout } from 'node:timers/promises'; import { AuthToken, AuthUser, PostAuthTokenClient, PostAuthTokenExchange, PostAuthTokenRefresh, PostAuthTokenRevoke, PostAuthToken, PostAuthUser, AbsoluteAuthToken } from './aegapi-auth-types'; import { AEGUserAgent, Headers, Method, Request, UAOptions } from './aegapi-ua'; -import { logError, sleep } from './utils'; +import { MS, logError, sleep } from './utils'; import { AEG_CLIENT_ID, AEG_CLIENT_SECRET } from './settings'; import { AEGAPIAuthorisationError, AEGAPIError, AEGAPIStatusCodeError } from './aegapi-error'; @@ -27,10 +29,10 @@ const checkers = createCheckers(aegapiTI) as { export class AEGAuthoriseUserAgent extends AEGUserAgent { // Time before token expiry to request a refresh - private readonly refreshWindow = 60 * 60 * 1000; // (milliseconds) + private readonly refreshWindow = 60 * 60 * MS; // Delay between retrying failed authorisation operations - private readonly authRetryDelay = 60 * 1000; // (milliseconds) + private readonly authRetryDelay = 60 * MS; // Promise that is resolved by successful (re)authorisation private authorised: Promise; @@ -121,7 +123,7 @@ export class AEGAuthoriseUserAgent extends AEGUserAgent { // Try to reauthorise after a short delay this.token = undefined; - await sleep(this.authRetryDelay); + await setTimeout(this.authRetryDelay); } } } @@ -208,7 +210,7 @@ export class AEGAuthoriseUserAgent extends AEGUserAgent { this.token = { authorizationHeader: `${token.tokenType} ${token.accessToken}`, refreshToken: token.refreshToken, - expiresAt: Date.now() + token.expiresIn * 1000 + expiresAt: Date.now() + token.expiresIn * MS }; } diff --git a/src/aegapi-ua.ts b/src/aegapi-ua.ts index dcbaf54..b522226 100644 --- a/src/aegapi-ua.ts +++ b/src/aegapi-ua.ts @@ -8,12 +8,13 @@ import { Client, Dispatcher } from 'undici'; import { buffer } from 'stream/consumers'; import { gunzipSync } from 'zlib'; import { Checker, IErrorDetail } from 'ts-interface-checker'; +import { setTimeout } from 'node:timers/promises'; import { AEG_API_KEY, AEG_API_URL, PLUGIN_NAME, PLUGIN_VERSION } from './settings'; import { AEGAPIError, AEGAPIStatusCodeError, AEGAPIValidationError } from './aegapi-error'; -import { columns, getValidationTree, sleep } from './utils'; +import { columns, getValidationTree, MS } from './utils'; import { Config } from './config-types'; import { IncomingHttpHeaders } from 'undici/types/header'; @@ -67,7 +68,7 @@ export class AEGUserAgent { // Delays between retries readonly retryDelay = { min: 500, - max: 60 * 1000, + max: 60 * MS, factor: 2.0 }; @@ -187,7 +188,7 @@ export class AEGUserAgent { ++retryCount; // Delay before trying again - await sleep(retryDelay); + await setTimeout(retryDelay); retryDelay = Math.min(retryDelay * this.retryDelay.factor, this.retryDelay.max); } } diff --git a/src/heartbeat.ts b/src/heartbeat.ts index 5587097..983e55a 100644 --- a/src/heartbeat.ts +++ b/src/heartbeat.ts @@ -2,11 +2,14 @@ // Copyright © 2022-2023 Alexander Thoukydides import { Logger } from 'homebridge'; -import { logError, sleep } from './utils'; + +import { setTimeout as setTimeoutP } from 'node:timers/promises'; + +import { MS, logError, sleep } from './utils'; // Multiple of interval to treat as a failure const TIMEOUT_MULTIPLE = 3; -const TIMEOUT_OFFSET = 10 * 1000; +const TIMEOUT_OFFSET = 10 * MS; // Perform an action periodically with error reporting and timeout export class Heartbeat { @@ -39,7 +42,7 @@ export class Heartbeat { logError(this.log, this.name, err); this.lastError = err; } - await sleep(this.interval); + await setTimeoutP(this.interval); } } diff --git a/src/platform.ts b/src/platform.ts index 69db71e..f2e2900 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -15,7 +15,7 @@ import { checkDependencyVersions } from './check-versions'; import { AEGAccount } from './aeg-account'; import { AEGRobot } from './aeg-robot'; import { Config } from './config-types'; -import { deepMerge, getValidationTree, logError } from './utils'; +import { deepMerge, getValidationTree, logError, plural } from './utils'; import { PrefixLogger } from './logger'; import configTI from './ti/config-types-ti'; @@ -129,7 +129,7 @@ export class AEGPlatform implements DynamicPlatformPlugin { // Add accessories for any robots associated with the AEG account const account = new AEGAccount(this.log, this.config); const robotPromises = await account.getRobots(); - this.log.info(`Found ${robotPromises.length} robot vacuum(s)`); + this.log.info(`Found ${plural(robotPromises.length, 'robot vacuum')}`); await Promise.all(robotPromises.map(this.addRobotAccessory.bind(this))); } @@ -165,7 +165,7 @@ export class AEGPlatform implements DynamicPlatformPlugin { if (!rmAccessories.length) return; // Remove the identified accessories - this.log.warn(`Removing ${rmAccessories.length} cached accessories that are no longer required`); + this.log.warn(`Removing ${plural(rmAccessories.length, 'cached accessory')} that are no longer required`); this.hb.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, rmAccessories); rmAccessories.forEach(accessory => delete this.accessories[accessory.UUID]); } diff --git a/src/utils.ts b/src/utils.ts index f686558..f40069a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,26 +1,32 @@ // Homebridge plugin for AEG RX 9 / Electrolux Pure i9 robot vacuum // Copyright © 2022-2023 Alexander Thoukydides +import { Logger } from 'homebridge'; + import { IErrorDetail } from 'ts-interface-checker'; import assert from 'assert'; -import { Logger } from 'homebridge'; import { AEGAPIError } from './aegapi-error'; +// Milliseconds in a second +export const MS = 1000; + // Type assertions +export function assertIsDefined(value: Type): asserts value is NonNullable { + assert.notStrictEqual(value, undefined); + assert.notStrictEqual(value, null); +} +export function assertIsUndefined(value: unknown): asserts value is undefined { + assert.strictEqual(value, undefined); +} export function assertIsString(value: unknown): asserts value is string { - assert(typeof value === 'string'); + assert.strictEqual(typeof value, 'string'); } export function assertIsNumber(value: unknown): asserts value is number { - assert(typeof value === 'number'); + assert.strictEqual(typeof value, 'number'); } export function assertIsBoolean(value: unknown): asserts value is boolean { - assert(typeof value === 'boolean'); -} - -// Wait for the next iteration of the event loop -export function immediate(): Promise { - return new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(typeof value, 'boolean'); } // Sleep for a specified period @@ -51,17 +57,17 @@ export function logError(log: Logger, when: string, err: unknown): void { } catch { /*empty */ } } -// Format a millisecond duration -export function formatDuration(ms: number, maxParts = 2): string { +// Format a milliseconds duration +export function formatMilliseconds(ms: number, maxParts = 2): string { if (ms < 1) return 'n/a'; // Split the duration into components const duration: Record = { - day: Math.floor(ms / (24 * 60 * 60 * 1000)), - hour: Math.floor(ms / (60 * 60 * 1000)) % 24, - minute: Math.floor(ms / (60 * 1000)) % 60, - second: Math.floor(ms / 1000 ) % 60, - millisecond: Math.floor(ms ) % 1000 + day: Math.floor(ms / (24 * 60 * 60 * MS)), + hour: Math.floor(ms / (60 * 60 * MS)) % 24, + minute: Math.floor(ms / (60 * MS)) % 60, + second: Math.floor(ms / MS ) % 60, + millisecond: Math.floor(ms ) % MS }; // Remove any leading zero components @@ -71,17 +77,53 @@ export function formatDuration(ms: number, maxParts = 2): string { // Combine the required number of remaining components return keys.slice(0, maxParts) .filter(key => duration[key] !== 0) - .map(key => `${duration[key]} ${key}${duration[key] === 1 ? '' : 's'}`) + .map(key => plural(duration[key], key)) .join(' '); } +// Format a seconds duration +export function formatSeconds(seconds: number, maxParts = 2): string { + return formatMilliseconds(seconds * 1000, maxParts); +} + +// Format a list (with Oxford comma) +export function formatList(items: string[]): string { + switch (items.length) { + case 0: return 'n/a'; + case 1: return items[0]; + case 2: return `${items[0]} and ${items[1]}`; + default: return [...items.slice(0, -1), `and ${items[items.length - 1]}`].join(', '); + } +} + +// Format a counted noun (handling most regular cases automatically) +export function plural(count: number, noun: string | [string, string], showCount: boolean = true): string { + const [singular, plural] = Array.isArray(noun) ? noun : [noun, '']; + noun = count === 1 ? singular : plural; + if (!noun) { + // Apply regular rules + const rules: [string, string, number][] = [ + ['on$', 'a', 2], // phenomenon/phenomena criterion/criteria + ['us$', 'i', 1], // cactus/cacti focus/foci + ['[^aeiou]y$', 'ies', 1], // cty/cites puppy/puppies + ['(ch|is|o|s|sh|x|z)$', 'es', 0], // iris/irises truss/trusses + ['', 's', 0] // cat/cats house/houses + ]; + const rule = rules.find(([ending]) => new RegExp(ending, 'i').test(singular)); + assertIsDefined(rule); + const matchCase = (s: string) => singular === singular.toUpperCase() ? s.toUpperCase() : s; + noun = singular.substring(0, singular.length - rule[2]).concat(matchCase(rule[1])); + } + return showCount ? `${count} ${noun}` : noun; +} + // Format strings in columns export function columns(rows: string[][], separator = ' '): string[] { // Determine the required column widths const width: number[] = []; rows.forEach(row => { row.forEach((value, index) => { - width[index] = Math.max(width[index] || 0, value.length); + width[index] = Math.max(width[index] ?? 0, value.length); }); }); width.splice(-1, 1, 0);