Skip to content

Commit 174c4f9

Browse files
Merge release v1.3.1
2 parents 6866912 + a3e7771 commit 174c4f9

File tree

8 files changed

+648
-371
lines changed

8 files changed

+648
-371
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# Changelog
2+
## [1.3.1] - 2025-10-26
3+
- Fixed forwarding direction for HMB devices. (#96)
4+
- Added retry logic with exponential backoff for Hame API calls to handle temporary server errors (#97)
25

36
## [1.3.0] - 2025-10-03
47
- **BREAKING**: Username and password are now required for Home Assistant addon

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:18.19.1-alpine AS builder
1+
FROM node:23.11-alpine AS builder
22

33
WORKDIR /build
44

@@ -19,7 +19,7 @@ RUN npm run build
1919
RUN npm ci --only=production
2020

2121
# Runtime stage
22-
FROM node:20-alpine
22+
FROM node:23.11-alpine
2323

2424
WORKDIR /app
2525

hassio-addon/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Build stage
2-
FROM node:18.19.1-alpine AS builder
2+
FROM node:23.11-alpine AS builder
33

44
WORKDIR /build
55

package-lock.json

Lines changed: 472 additions & 343 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,13 @@
1717
"dependencies": {
1818
"mqtt": "^5.3.5",
1919
"node-fetch": "^3.3.2",
20-
"pino": "^9.7.0",
20+
"pino": "^10.1.0",
2121
"pino-pretty": "^13.0.0"
2222
},
2323
"devDependencies": {
24-
"@types/mqtt": "^2.5.0",
25-
"@types/node": "^20.11.0",
24+
"@types/node": "^24.9.1",
2625
"prettier": "^3.6.2",
27-
"rimraf": "^5.0.5",
26+
"rimraf": "^6.0.1",
2827
"tsx": "^4.20.3",
2928
"typescript": "^5.3.3"
3029
}

src/hame_api.ts

Lines changed: 165 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,119 @@ import { createHash } from "crypto";
22
import fetch from "node-fetch";
33
import { logger } from "./logger.js";
44

5+
/**
6+
* Custom error class that includes HTTP status code information
7+
*/
8+
class HttpError extends Error {
9+
constructor(
10+
message: string,
11+
public readonly statusCode: number,
12+
) {
13+
super(message);
14+
this.name = "HttpError";
15+
}
16+
}
17+
18+
interface RetryOptions {
19+
maxRetries: number;
20+
baseDelayMs: number;
21+
maxDelayMs: number;
22+
backoffMultiplier: number;
23+
}
24+
25+
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
26+
maxRetries: 3,
27+
baseDelayMs: 1000,
28+
maxDelayMs: 10000,
29+
backoffMultiplier: 2,
30+
};
31+
32+
/**
33+
* Determines if an error should be retried based on HTTP status codes
34+
* Uses a simple, reliable approach: only retry on known server errors and timeouts
35+
*/
36+
function shouldRetryError(error: Error, statusCode?: number): boolean {
37+
// If we have an HTTP status code, use standard HTTP semantics
38+
if (statusCode !== undefined) {
39+
// Only retry server errors (5xx)
40+
return statusCode >= 500;
41+
}
42+
43+
// For network errors without status codes, only retry specific known transient issues
44+
// Check Node.js system error codes (most reliable)
45+
if ("code" in error && typeof (error as any).code === "string") {
46+
const code = (error as any).code;
47+
return code === "ETIMEDOUT" || code === "ECONNRESET";
48+
}
49+
50+
// Don't retry anything else - be conservative
51+
return false;
52+
}
53+
54+
async function withRetry<T>(
55+
operation: () => Promise<T>,
56+
operationName: string,
57+
options: Partial<RetryOptions> = {},
58+
): Promise<T> {
59+
const { maxRetries, baseDelayMs, maxDelayMs, backoffMultiplier } = {
60+
...DEFAULT_RETRY_OPTIONS,
61+
...options,
62+
};
63+
64+
let lastError: Error;
65+
let lastStatusCode: number | undefined;
66+
67+
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
68+
try {
69+
const result = await operation();
70+
if (attempt > 1) {
71+
logger.info(`${operationName} succeeded on attempt ${attempt}`);
72+
}
73+
return result;
74+
} catch (error) {
75+
lastError = error as Error;
76+
77+
// Extract status code if it's an HttpError
78+
if (error instanceof HttpError) {
79+
lastStatusCode = error.statusCode;
80+
} else {
81+
lastStatusCode = undefined;
82+
}
83+
84+
if (
85+
attempt <= maxRetries &&
86+
shouldRetryError(lastError, lastStatusCode)
87+
) {
88+
const delay = Math.min(
89+
baseDelayMs * Math.pow(backoffMultiplier, attempt - 1),
90+
maxDelayMs,
91+
);
92+
93+
const statusInfo = lastStatusCode ? ` (HTTP ${lastStatusCode})` : "";
94+
logger.warn(
95+
`${operationName} failed on attempt ${attempt}/${maxRetries + 1}: ${lastError.message}${statusInfo}. Retrying in ${delay}ms...`,
96+
);
97+
98+
await new Promise((resolve) => setTimeout(resolve, delay));
99+
} else {
100+
if (attempt <= maxRetries) {
101+
const statusInfo = lastStatusCode ? ` (HTTP ${lastStatusCode})` : "";
102+
logger.info(
103+
`${operationName} failed with non-retryable error: ${lastError.message}${statusInfo}. Not retrying.`,
104+
);
105+
} else {
106+
logger.error(
107+
`${operationName} failed after ${maxRetries + 1} attempts. Final error: ${lastError.message}`,
108+
);
109+
}
110+
break;
111+
}
112+
}
113+
}
114+
115+
throw lastError!;
116+
}
117+
5118
export interface HameApiResponse {
6119
code: string;
7120
msg: string;
@@ -60,16 +173,28 @@ export class HameApi {
60173
url.searchParams.append("pwd", hashedPassword);
61174

62175
logger.info(`Fetching device token for ${mailbox}...`);
63-
const resp = await fetch(url.toString(), { headers: this.headers });
64-
const data = (await resp.json()) as HameApiResponse;
65176

66-
if (data.code !== "2" || !data.token) {
67-
throw new Error(
68-
`Unexpected API response code: ${data.code} - ${data.msg}`,
69-
);
70-
}
177+
return withRetry(async () => {
178+
const resp = await fetch(url.toString(), { headers: this.headers });
71179

72-
return data;
180+
// Check HTTP status first - we have the response object here
181+
if (!resp.ok) {
182+
throw new HttpError(
183+
`HTTP ${resp.status}: ${resp.statusText}`,
184+
resp.status,
185+
);
186+
}
187+
188+
const data = (await resp.json()) as HameApiResponse;
189+
190+
if (data.code !== "2" || !data.token) {
191+
throw new Error(
192+
`Unexpected API response code: ${data.code} - ${data.msg}`,
193+
);
194+
}
195+
196+
return data;
197+
}, `Fetch device token for ${mailbox}`);
73198
}
74199

75200
async fetchDeviceList(
@@ -84,21 +209,42 @@ export class HameApi {
84209
url.searchParams.append("token", token);
85210

86211
logger.info("Fetching device list...");
87-
const resp = await fetch(url.toString(), { headers: this.headers });
88-
const data = (await resp.json()) as HameDeviceListResponse;
89212

90-
if (data.code !== 1) {
91-
throw new Error(
92-
`Unexpected API response from device list: ${data.code} - ${data.msg}`,
93-
);
94-
}
213+
return withRetry(async () => {
214+
const resp = await fetch(url.toString(), { headers: this.headers });
95215

96-
return data;
216+
// Check HTTP status first - we have the response object here
217+
if (!resp.ok) {
218+
throw new HttpError(
219+
`HTTP ${resp.status}: ${resp.statusText}`,
220+
resp.status,
221+
);
222+
}
223+
224+
const data = (await resp.json()) as HameDeviceListResponse;
225+
226+
if (data.code !== 1) {
227+
throw new Error(
228+
`Unexpected API response from device list: ${data.code} - ${data.msg}`,
229+
);
230+
}
231+
232+
return data;
233+
}, "Fetch device list");
97234
}
98235

99236
async fetchDevices(mailbox: string, password: string): Promise<DeviceInfo[]> {
100-
const tokenResp = await this.fetchDeviceToken(mailbox, password);
101-
const list = await this.fetchDeviceList(mailbox, tokenResp.token!);
102-
return list.data;
237+
return withRetry(
238+
async () => {
239+
const tokenResp = await this.fetchDeviceToken(mailbox, password);
240+
const list = await this.fetchDeviceList(mailbox, tokenResp.token!);
241+
logger.info(
242+
`Successfully fetched ${list.data.length} devices from Hame API`,
243+
);
244+
return list.data;
245+
},
246+
"Fetch devices from Hame API",
247+
{ maxRetries: 2 }, // Fewer retries for the overall operation since individual calls already retry
248+
);
103249
}
104250
}

src/logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const logger = pino({
88
target: "pino-pretty",
99
options: {
1010
colorize: false,
11-
translateTime: "HH:MM:ss",
11+
translateTime: "yyyy-mm-dd HH:MM:ss",
1212
ignore: "pid,hostname",
1313
},
1414
},

src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ async function start() {
245245
// Set inverse forwarding based on device type and configuration
246246
if (device.inverse_forwarding === undefined) {
247247
const deviceType = device.type.toUpperCase();
248-
const selectableTypes = ["HMA", "HMF", "HMK", "HMJ"];
248+
const selectableTypes = ["HMA", "HMF", "HMK", "HMJ", "HMB"];
249249

250250
if (selectableTypes.some((type) => deviceType.startsWith(type))) {
251251
// For selectable device types, check if device ID is in the list

0 commit comments

Comments
 (0)