feat: 处理整数溢出并持久化无法处理的数据

- 更新 heartbeatProcessor.js 以处理所有数字溢出类型(int16、int32、int64)并使用二进制补码。
- 防止仅与数据相关的 PostgreSQL 失败抛出个别回退错误。
- 在 databaseManager.js 中添加 insertHeartbeatEventsErrors 方法以存储被拒绝的记录。
- 更新 heartbeatProcessor.js 中的 _emitRejectedRecord 方法,直接将所有无法处理的心跳数据写入 heartbeat_events_errors 数据库。
- 更新 openspec 规范以支持新的溢出和验证回退状态。
- 添加测试文件以验证大整数处理。
This commit is contained in:
2026-03-02 10:49:02 +08:00
parent 58c3880732
commit d0c4940e01
7 changed files with 281 additions and 103 deletions

View File

@@ -0,0 +1,37 @@
/*
Navicat Premium Dump SQL
Source Server : FnOS 109
Source Server Type : PostgreSQL
Source Server Version : 150004 (150004)
Source Host : 10.8.8.109:5433
Source Catalog : log_platform
Source Schema : heartbeat
Target Server Type : PostgreSQL
Target Server Version : 150004 (150004)
File Encoding : 65001
Date: 02/03/2026 09:36:55
*/
-- ----------------------------
-- Table structure for heartbeat_events_errors
-- ----------------------------
DROP TABLE IF EXISTS "heartbeat"."heartbeat_events_errors";
CREATE TABLE "heartbeat"."heartbeat_events_errors" (
"error_id" int4 NOT NULL DEFAULT nextval('"heartbeat".heartbeat_events_errors_error_id_seq'::regclass),
"error_time" timestamp(6) DEFAULT now(),
"hotel_id" int4,
"room_id" int4,
"original_data" jsonb,
"error_code" text COLLATE "pg_catalog"."default",
"error_message" text COLLATE "pg_catalog"."default"
)
;
-- ----------------------------
-- Primary Key structure for table heartbeat_events_errors
-- ----------------------------
ALTER TABLE "heartbeat"."heartbeat_events_errors" ADD CONSTRAINT "heartbeat_events_errors_pkey" PRIMARY KEY ("error_id");

View File

@@ -0,0 +1,15 @@
# Change: Handle integer overflows and persist unprocessable data
## Why
Hardware devices occasionally report `service_mask` or other bitmasks and counters (like `power_state`) where their unsigned values exceed PostgreSQL's signed boundaries (e.g. `uint64`, `uint32`, `uint16`). This triggers out of range database insertion errors which causes batch failure and falls back to individual row insertions. Previously, rows that failed due to data range constraints directly crashed out or were only logged to Redis, meaning fully invalid data or boundary constraint violations were fundamentally lost from DB history.
## What Changes
- Safely map completely oversized integers to signed `int64`, `int32`, `int16` 2's complement equivalents natively in Javascript (e.g. `(v << 16) >> 16` for `int2`).
- Refine the loop mechanism in `databaseManager.js` to avoid throwing errors exclusively built from data-level constraint mismatches when doing individual row fallback.
- Extend `_emitRejectedRecord` to persist any unprocessable, validation-failing, or insert-failing raw records directly into a dedicated error database table: `heartbeat_events_errors`.
## Impact
- Affected specs: `processor`
- Affected code:
- `src/processor/heartbeatProcessor.js`
- `src/db/databaseManager.js`

View File

@@ -0,0 +1,17 @@
## MODIFIED Requirements
### Requirement: 心跳数据转换
系统 MUST 能够将解包后的心跳数据转换为数据库存储格式。
#### Scenario: 转换为 v2 明细表字段
- **WHEN** 心跳数据验证通过时
- **THEN** 系统应输出与 v2 明细表字段一致的数据结构
- **AND** 添加必要的元数据
#### Scenario: 缺失必填字段
- **WHEN** 心跳数据缺失必填字段时
- **THEN** 系统应判定为无效数据并丢弃
#### Scenario: 兼容超限的 uint64 字段转换为有符号 bigint
- **WHEN** `service_mask` 等字段由于无符号位满转为十进制后超出 PostgreSQL 的 signed bigint 上限
- **THEN** 系统 MUST 将该长数字利用补码映射转换其等价二进制结构对应的有符号负数
- **AND** 保证原始 Bit 掩码结构完整并确保能顺利存入数据库

View File

@@ -0,0 +1,6 @@
## 1. Implementation
- [x] 1.1 Update `heartbeatProcessor.js` to handle all numeric overflow types (`int16`, `int32`, `int64`) with two's complement.
- [x] 1.2 Prevent purely data-related Postgres failures from throwing away individual fallbacks within `databaseManager.js`.
- [x] 1.3 Add `insertHeartbeatEventsErrors` to `databaseManager.js` to sink rejected records.
- [x] 1.4 Wire `_emitRejectedRecord` in `heartbeatProcessor.js` to directly write all completely unprocessable heartbeats into the `heartbeat_events_errors` DB.
- [x] 1.5 Update the `openspec` specs with these newly supported overflow & validation fallback states.

View File

@@ -22,18 +22,18 @@ class DatabaseManager {
idleTimeoutMillis: this.config.idleTimeoutMillis, idleTimeoutMillis: this.config.idleTimeoutMillis,
connectionTimeoutMillis: 5000, // 5秒连接超时防止断网时无限等待 connectionTimeoutMillis: 5000, // 5秒连接超时防止断网时无限等待
}); });
// 监听连接池错误,防止后端断开导致进程崩溃 // 监听连接池错误,防止后端断开导致进程崩溃
this.pool.on('error', (err, client) => { this.pool.on('error', (err, client) => {
console.error('[db] 发生未捕获的连接池错误:', err); console.error('[db] 发生未捕获的连接池错误:', err);
// 不抛出,让应用层通过心跳检测发现问题 // 不抛出,让应用层通过心跳检测发现问题
}); });
// 测试连接 // 测试连接
const client = await this.pool.connect(); const client = await this.pool.connect();
client.release(); client.release();
console.log('数据库连接池创建成功'); console.log('数据库连接池创建成功');
// 初始化表结构 // 初始化表结构
await this.initTables(); await this.initTables();
@@ -570,15 +570,55 @@ class DatabaseManager {
await this.pool.query(singleSql, toRowValues(event)); await this.pool.query(singleSql, toRowValues(event));
insertedCount += 1; insertedCount += 1;
} catch (error) { } catch (error) {
const connCodes = ['57P03', '08006', '08001', '08003', '08004', '08007'];
if (connCodes.includes(error?.code) || (error?.message && /ECONNREFUSED|ETIMEDOUT|connection/i.test(error.message))) {
throw error;
}
failedRecords.push({ error, record: event }); failedRecords.push({ error, record: event });
} }
} }
if (insertedCount === 0 && failedRecords.length === events.length) { if (insertedCount === 0 && failedRecords.length === events.length) {
throw lastError; // 只有在确定是纯网络断开时(所有单独重试都触发同一级别的连接错误),才向外抛出网络错误以重试批次。
const connCodes = ['57P03', '08006', '08001', '08003', '08004', '08007'];
const isConnError = connCodes.includes(lastError?.code) ||
(lastError?.message && /ECONNREFUSED|ETIMEDOUT|connection/i.test(lastError.message));
if (isConnError) {
throw lastError;
}
} }
return { insertedCount, failedRecords, batchError: lastError }; return { insertedCount, failedRecords, batchError: (insertedCount === 0 && failedRecords.length === events.length) ? lastError : null };
}
async insertHeartbeatEventsErrors(errorsList) {
if (!errorsList || errorsList.length === 0) return;
try {
const placeholders = [];
const values = [];
let i = 1;
for (const err of errorsList) {
let hotel_id = Number(err.hotel_id);
if (!Number.isFinite(hotel_id)) hotel_id = null;
let room_id = Number(err.room_id);
if (!Number.isFinite(room_id)) room_id = null;
let original_data = null;
try { original_data = JSON.stringify(err.original_data); } catch { /* ignore */ }
const error_code = String(err.error_code || '');
const error_message = String(err.error_message || '');
placeholders.push(`($${i}, $${i + 1}, $${i + 2}, $${i + 3}, $${i + 4})`);
values.push(hotel_id, room_id, original_data, error_code, error_message);
i += 5;
}
const sql = `INSERT INTO heartbeat.heartbeat_events_errors (hotel_id, room_id, original_data, error_code, error_message) VALUES ${placeholders.join(', ')}`;
await this.pool.query(sql, values);
} catch (e) {
console.warn('Failed to insert into heartbeat_events_errors:', e);
}
} }
async insertHeartbeatData(data) { async insertHeartbeatData(data) {
@@ -586,11 +626,11 @@ class DatabaseManager {
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
data = [data]; data = [data];
} }
if (data.length === 0) { if (data.length === 0) {
return; return;
} }
// 构建批量插入语句 // 构建批量插入语句
const values = data.map(item => [ const values = data.map(item => [
item.component_id, item.component_id,
@@ -598,7 +638,7 @@ class DatabaseManager {
item.timestamp, item.timestamp,
item.data item.data
]); ]);
const query = { const query = {
text: ` text: `
INSERT INTO heartbeat (component_id, status, timestamp, data) INSERT INTO heartbeat (component_id, status, timestamp, data)
@@ -606,7 +646,7 @@ class DatabaseManager {
`, `,
values: values.flat() values: values.flat()
}; };
const res = await this.pool.query(query); const res = await this.pool.query(query);
console.log(`成功插入 ${data.length} 条心跳数据`); console.log(`成功插入 ${data.length} 条心跳数据`);
return { insertedCount: Number(res?.rowCount ?? data.length) }; return { insertedCount: Number(res?.rowCount ?? data.length) };
@@ -706,11 +746,11 @@ class DatabaseManager {
// 使用 IS DISTINCT FROM 避免无意义更新 // 使用 IS DISTINCT FROM 避免无意义更新
const updateColumns = columns.filter(c => !['hotel_id', 'room_id', 'device_id'].includes(c)); const updateColumns = columns.filter(c => !['hotel_id', 'room_id', 'device_id'].includes(c));
const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', '); const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', ');
// 构建 WHERE 子句:仅当至少一个字段发生变化,且时间戳未回退时才更新 // 构建 WHERE 子句:仅当至少一个字段发生变化,且时间戳未回退时才更新
// 注意room_status.room_status_moment.ts_ms 是 bigintEXCLUDED.ts_ms 也是 bigint // 注意room_status.room_status_moment.ts_ms 是 bigintEXCLUDED.ts_ms 也是 bigint
const whereConditions = updateColumns.map(col => `room_status.room_status_moment.${col} IS DISTINCT FROM EXCLUDED.${col}`).join(' OR '); const whereConditions = updateColumns.map(col => `room_status.room_status_moment.${col} IS DISTINCT FROM EXCLUDED.${col}`).join(' OR ');
// 生成批量插入 SQL // 生成批量插入 SQL
// 注意ON CONFLICT (hotel_id, room_id, device_id) 依赖于唯一索引 idx_room_status_unique_device // 注意ON CONFLICT (hotel_id, room_id, device_id) 依赖于唯一索引 idx_room_status_unique_device
const values = []; const values = [];
@@ -753,7 +793,7 @@ class DatabaseManager {
} }
} }
} }
// 不抛出错误只记录日志避免影响主流程Heartbeat History 写入已成功) // 不抛出错误只记录日志避免影响主流程Heartbeat History 写入已成功)
console.warn('[db] upsertRoomStatus failed:', error.message); console.warn('[db] upsertRoomStatus failed:', error.message);
return { error }; return { error };
@@ -804,7 +844,7 @@ class DatabaseManager {
`, `,
values: [componentId] values: [componentId]
}; };
const result = await this.pool.query(query); const result = await this.pool.query(query);
return result.rows[0] || null; return result.rows[0] || null;
} catch (error) { } catch (error) {
@@ -824,7 +864,7 @@ class DatabaseManager {
`, `,
values: [componentId, startTime, endTime] values: [componentId, startTime, endTime]
}; };
const result = await this.pool.query(query); const result = await this.pool.query(query);
return result.rows; return result.rows;
} catch (error) { } catch (error) {

View File

@@ -202,11 +202,16 @@ class HeartbeatProcessor {
this._batchInFlight = true; this._batchInFlight = true;
let hasMore = false; let hasMore = false;
let batchData = null; let batchData = null;
let batchEventCount = 0;
let batchMessageCount = 0;
let batchMessages = [];
try { try {
const { batchEventCount, batchMessageCount } = this.computeNextBatchWindow(); const window = this.computeNextBatchWindow();
batchEventCount = window.batchEventCount;
batchMessageCount = window.batchMessageCount;
batchData = this.batchQueue.slice(0, batchEventCount); batchData = this.batchQueue.slice(0, batchEventCount);
const batchMessages = this.batchMessageQueue.slice(0, batchMessageCount); batchMessages = this.batchMessageQueue.slice(0, batchMessageCount);
let insertedCount = 0; let insertedCount = 0;
let failedRecords = []; let failedRecords = [];
@@ -219,10 +224,10 @@ class HeartbeatProcessor {
// 只有当历史表写入成功insertedCount > 0才尝试同步 // 只有当历史表写入成功insertedCount > 0才尝试同步
// 过滤掉写入失败的记录(如果有) // 过滤掉写入失败的记录(如果有)
if (insertedCount > 0) { if (insertedCount > 0) {
const successData = failedRecords.length > 0 const successData = failedRecords.length > 0
? batchData.filter(d => !failedRecords.some(f => f.record === d)) ? batchData.filter(d => !failedRecords.some(f => f.record === d))
: batchData; : batchData;
if (successData.length > 0) { if (successData.length > 0) {
this.databaseManager.upsertRoomStatus(successData).catch(err => { this.databaseManager.upsertRoomStatus(successData).catch(err => {
console.warn('异步同步 room_status 失败 (忽略):', err); console.warn('异步同步 room_status 失败 (忽略):', err);
@@ -243,13 +248,12 @@ class HeartbeatProcessor {
} }
if (failedRecords.length > 0) { if (failedRecords.length > 0) {
for (const item of failedRecords) { const rejects = failedRecords.map(item => ({
this._emitRejectedRecord({ errorId: 'db_write_failed',
errorId: 'db_write_failed', error: item?.error,
error: item?.error, rawData: item?.record,
rawData: item?.record, }));
}); this._emitRejectedRecords(rejects);
}
this.stats?.incDbWriteFailed?.(failedRecords.length); this.stats?.incDbWriteFailed?.(failedRecords.length);
} }
this.stats?.incDbWritten?.(insertedCount); this.stats?.incDbWritten?.(insertedCount);
@@ -353,28 +357,60 @@ class HeartbeatProcessor {
} }
_emitRejectedRecord({ errorId, error, rawData }) { _emitRejectedRecord({ errorId, error, rawData }) {
if (!this.redis?.isEnabled?.()) return; this._emitRejectedRecords([{ errorId, error, rawData }]);
const ts = formatTimestamp(new Date()); }
const errMsg = error ? String(error?.stack ?? error?.message ?? error) : undefined;
const payload = this._safeStringify({ _emitRejectedRecords(records) {
errorId, if (!records || records.length === 0) return;
error: errMsg,
rawData, if (typeof this.databaseManager?.insertHeartbeatEventsErrors === 'function') {
}); const dbPayload = records.map(r => {
const base = `[ERROR] ${ts} ${errorId}: `; let hotel_id = null;
const maxChunkChars = 50_000; let room_id = null;
if (payload.length <= maxChunkChars) { const effective = r.rawData?.effective || r.rawData?.record || r.rawData;
this.redis.pushConsoleLog?.({ level: 'warn', message: `${base}${payload}`, metadata: { module: 'processor' } }); if (effective && typeof effective === 'object') {
return; hotel_id = effective.hotel_id ?? effective.hotelId;
} room_id = effective.room_id ?? effective.roomId;
const parts = Math.ceil(payload.length / maxChunkChars); if (typeof room_id === 'string' && /^-?\d+$/.test(room_id)) room_id = Number(room_id);
for (let i = 0; i < parts; i += 1) { }
const chunk = payload.slice(i * maxChunkChars, (i + 1) * maxChunkChars); return {
this.redis.pushConsoleLog?.({ hotel_id,
level: 'warn', room_id,
message: `${base}(part ${i + 1}/${parts}) ${chunk}`, original_data: r.rawData,
metadata: { module: 'processor' }, error_code: String(r.errorId),
error_message: r.error ? String(r.error?.message ?? r.error) : 'Rejected record'
};
}); });
this.databaseManager.insertHeartbeatEventsErrors(dbPayload).catch(() => { });
}
if (!this.redis?.isEnabled?.()) return;
// Batch log to redis (at most first 10 to avoid noise)
const logs = records.slice(0, 10);
for (const { errorId, error, rawData } of logs) {
const ts = formatTimestamp(new Date());
const errMsg = error ? String(error?.stack ?? error?.message ?? error) : undefined;
const payload = this._safeStringify({
errorId,
error: errMsg,
rawData,
});
const base = `[ERROR] ${ts} ${errorId}: `;
const maxChunkChars = 50_000;
if (payload.length <= maxChunkChars) {
this.redis.pushConsoleLog?.({ level: 'warn', message: `${base}${payload}`, metadata: { module: 'processor' } });
continue;
}
const parts = Math.ceil(payload.length / maxChunkChars);
for (let i = 0; i < parts; i += 1) {
const chunk = payload.slice(i * maxChunkChars, (i + 1) * maxChunkChars);
this.redis.pushConsoleLog?.({
level: 'warn',
message: `${base}(part ${i + 1}/${parts}) ${chunk}`,
metadata: { module: 'processor' },
});
}
} }
} }
@@ -607,50 +643,68 @@ class HeartbeatProcessor {
return s.length === 0 ? undefined : s; return s.length === 0 ? undefined : s;
}; };
const toIntOrUndefined = (v) => { const toInt32OrUndefined = (v) => {
if (v === undefined || v === null) return v; if (v === undefined || v === null) return v;
if (typeof v === 'number') { let val = null;
if (!Number.isFinite(v)) return undefined; if (typeof v === 'number' && Number.isFinite(v)) val = Math.trunc(v);
return Math.trunc(v); else {
const s = String(v).trim();
if (s.length === 0 || !/^-?\d+$/.test(s)) return undefined;
val = Number(s);
} }
const s = String(v).trim(); if (val === null || !Number.isFinite(val)) return undefined;
if (s.length === 0) return undefined; return val | 0; // 强制补码映射到 32位有符号并去小数
if (!/^-?\d+$/.test(s)) return undefined; };
const n = Number(s);
if (!Number.isFinite(n)) return undefined; const toInt16OrUndefined = (v) => {
return Math.trunc(n); const val = toInt32OrUndefined(v);
if (val === undefined) return undefined;
return (val << 16) >> 16; // 强制补码映射到 16位有符号
}; };
const toBigintParamOrUndefined = (v) => { const toBigintParamOrUndefined = (v) => {
if (v === undefined || v === null) return v; if (v === undefined || v === null) return v;
let s;
if (typeof v === 'number') { if (typeof v === 'number') {
if (!Number.isFinite(v)) return undefined; if (!Number.isFinite(v)) return undefined;
const n = Math.trunc(v); const n = Math.trunc(v);
return Number.isSafeInteger(n) ? n : String(n); // 如果是安全整数范围内,原样返回(作为 number 进库没问题)
if (Number.isSafeInteger(n)) return n;
// 如果超出了上限(比如 uint64 最大值附近的数因精度变为 float转成字符串走下面转换
s = BigInt(n).toString();
} else {
s = String(v).trim();
if (s.length === 0) return undefined;
if (!/^-?\d+$/.test(s)) return undefined;
}
try {
// 把可能超过有符号 64 位整数上限的无符号数(高位置 1 时),
// 转换为对应的有符号负数,以完美适应 PostgreSQL bigint 并保留完整的 bit 掩码。
return BigInt.asIntN(64, BigInt(s)).toString();
} catch (err) {
return s;
} }
const s = String(v).trim();
if (s.length === 0) return undefined;
if (!/^-?\d+$/.test(s)) return undefined;
return s;
}; };
normalized.ts_ms = toBigintParamOrUndefined(normalized.ts_ms); normalized.ts_ms = toBigintParamOrUndefined(normalized.ts_ms);
normalized.hotel_id = toIntOrUndefined(normalized.hotel_id); normalized.hotel_id = toInt16OrUndefined(normalized.hotel_id);
normalized.room_id = toTrimmedStringOrUndefined(normalized.room_id); normalized.room_id = toTrimmedStringOrUndefined(normalized.room_id);
normalized.device_id = toTrimmedStringOrUndefined(normalized.device_id); normalized.device_id = toTrimmedStringOrUndefined(normalized.device_id);
normalized.ip = toTrimmedStringOrUndefined(normalized.ip); normalized.ip = toTrimmedStringOrUndefined(normalized.ip);
normalized.power_state = toIntOrUndefined(normalized.power_state); normalized.power_state = toInt16OrUndefined(normalized.power_state);
normalized.guest_type = toIntOrUndefined(normalized.guest_type); normalized.guest_type = toInt16OrUndefined(normalized.guest_type);
normalized.cardless_state = toIntOrUndefined(normalized.cardless_state); normalized.cardless_state = toInt16OrUndefined(normalized.cardless_state);
normalized.service_mask = toBigintParamOrUndefined(normalized.service_mask); normalized.service_mask = toBigintParamOrUndefined(normalized.service_mask);
normalized.pms_state = toIntOrUndefined(normalized.pms_state); normalized.pms_state = toInt16OrUndefined(normalized.pms_state);
normalized.carbon_state = toIntOrUndefined(normalized.carbon_state); normalized.carbon_state = toInt16OrUndefined(normalized.carbon_state);
normalized.device_count = toIntOrUndefined(normalized.device_count); normalized.device_count = toInt16OrUndefined(normalized.device_count);
normalized.comm_seq = toIntOrUndefined(normalized.comm_seq); normalized.comm_seq = toInt32OrUndefined(normalized.comm_seq); // int4
normalized.insert_card = toIntOrUndefined(normalized.insert_card); normalized.insert_card = toInt16OrUndefined(normalized.insert_card);
const bg = toIntOrUndefined(normalized.bright_g); const bg = toInt16OrUndefined(normalized.bright_g);
normalized.bright_g = bg === -1 ? undefined : bg; normalized.bright_g = bg === -1 ? undefined : bg;
normalized.version = toIntOrUndefined(normalized.version); normalized.version = toInt32OrUndefined(normalized.version);
// 其余未知字段塞进 extra避免丢信息但不覆盖显式 extra // 其余未知字段塞进 extra避免丢信息但不覆盖显式 extra
if (!normalized.extra || typeof normalized.extra !== 'object') { if (!normalized.extra || typeof normalized.extra !== 'object') {
@@ -660,25 +714,25 @@ class HeartbeatProcessor {
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
if ( if (
[ [
'ts_ms','tsMs','TsMs','timestampMs','TimestampMs','timestamp','Timestamp','ts','Ts', 'ts_ms', 'tsMs', 'TsMs', 'timestampMs', 'TimestampMs', 'timestamp', 'Timestamp', 'ts', 'Ts',
'hotel_id','hotelId','HotelId', 'hotel_id', 'hotelId', 'HotelId',
'room_id','roomId','RoomId', 'room_id', 'roomId', 'RoomId',
'device_id','deviceId','DeviceId','device','Device', 'device_id', 'deviceId', 'DeviceId', 'device', 'Device',
'ip','Ip','IP', 'ip', 'Ip', 'IP',
'power_state','powerState','PowerState', 'power_state', 'powerState', 'PowerState',
'guest_type','guestType','GuestType', 'guest_type', 'guestType', 'GuestType',
'cardless_state','cardlessState','CardlessState', 'cardless_state', 'cardlessState', 'CardlessState',
'service_mask','serviceMask','ServiceMask', 'service_mask', 'serviceMask', 'ServiceMask',
'pms_state','pmsState','PmsState', 'pms_state', 'pmsState', 'PmsState',
'carbon_state','carbonState','CarbonState', 'carbon_state', 'carbonState', 'CarbonState',
'device_count','deviceCount','DeviceCount', 'device_count', 'deviceCount', 'DeviceCount',
'comm_seq','commSeq','CommSeq', 'comm_seq', 'commSeq', 'CommSeq',
'insert_card','insertCard','InsertCard', 'insert_card', 'insertCard', 'InsertCard',
'bright_g','brightG','BrightG', 'bright_g', 'brightG', 'BrightG',
'version','Version','ver','Ver', 'version', 'Version', 'ver', 'Ver',
'extra','Extra', 'extra', 'Extra',
'electricity','Electricity', 'electricity', 'Electricity',
'air_conditioner','airConditioner','AirConditioner' 'air_conditioner', 'airConditioner', 'AirConditioner'
].includes(k) ].includes(k)
) { ) {
continue; continue;
@@ -703,13 +757,20 @@ class HeartbeatProcessor {
const num = Number(s); const num = Number(s);
return Number.isFinite(num) ? num : null; return Number.isFinite(num) ? num : null;
}; };
const toIntOrNull = (v) => { const toInt16OrNull = (v) => {
if (v === undefined || v === null) return null; if (v === undefined || v === null) return null;
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null; let val = null;
const s = String(v).trim(); if (typeof v === 'number') {
if (!/^-?\d+$/.test(s)) return null; if (Number.isFinite(v)) val = Math.trunc(v);
const num = Number(s); } else {
return Number.isFinite(num) ? Math.trunc(num) : null; const s = String(v).trim();
if (/^-?\d+$/.test(s)) {
const num = Number(s);
if (Number.isFinite(num)) val = Math.trunc(num);
}
}
if (val === null) return null;
return (val << 16) >> 16;
}; };
const out = {}; const out = {};
const elec = Array.isArray(n.electricity) ? n.electricity : null; const elec = Array.isArray(n.electricity) ? n.electricity : null;
@@ -725,12 +786,12 @@ class HeartbeatProcessor {
const ac = Array.isArray(n.air_conditioner) ? n.air_conditioner : null; const ac = Array.isArray(n.air_conditioner) ? n.air_conditioner : null;
if (ac && ac.length) { if (ac && ac.length) {
out.air_address = ac.map((x) => toStrOrNull(x?.address)); out.air_address = ac.map((x) => toStrOrNull(x?.address));
out.state = ac.map((x) => toIntOrNull(x?.state)); out.state = ac.map((x) => toInt16OrNull(x?.state));
out.model = ac.map((x) => toIntOrNull(x?.model)); out.model = ac.map((x) => toInt16OrNull(x?.model));
out.speed = ac.map((x) => toIntOrNull(x?.speed)); out.speed = ac.map((x) => toInt16OrNull(x?.speed));
out.set_temp = ac.map((x) => toIntOrNull(x?.set_temp)); out.set_temp = ac.map((x) => toInt16OrNull(x?.set_temp));
out.now_temp = ac.map((x) => toIntOrNull(x?.now_temp)); out.now_temp = ac.map((x) => toInt16OrNull(x?.now_temp));
out.solenoid_valve = ac.map((x) => toIntOrNull(x?.solenoid_valve)); out.solenoid_valve = ac.map((x) => toInt16OrNull(x?.solenoid_valve));
} }
return out; return out;
} }

2
test.js Normal file
View File

@@ -0,0 +1,2 @@
const n = JSON.parse('{\
mask\: 9223372054034653000 }').mask; console.log(n, typeof n, String(Math.trunc(n)), BigInt.asIntN(64, BigInt(Math.trunc(n))));