新增米家环境监控模块 (采集/聚合/历史/前端 EnvView) + CI 挂载米家凭证目录并自动应用迁移
Docker Build and Push / build-image (push) Successful in 6m54s

This commit is contained in:
jif2.zhang
2026-09-21 11:42:58 +08:00
parent 53f63e0986
commit 8e2c6ffa35
36 changed files with 11327 additions and 428 deletions
+36
View File
@@ -5,6 +5,7 @@ import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
import { checkAfterRefresh } from './alert-engine.js'
import { insertCronAlert } from '../scripts/lib/cron-alert.js'
import { ApizeroError, oil, history } from './apizero/index.js'
import * as mihome from './mihome/index.js'
function parseUrl(url) {
const u = new URL(url)
@@ -77,3 +78,38 @@ function scheduleOilHistory() {
scheduleNews()
scheduleOilHistory()
// 环境监控: 每小时 5 分小时采样 + 每日 03:05 聚合/电量/vendor 历史补齐 (Asia/Shanghai)
// 整点 +5min 缓冲, 避开米家高峰
function scheduleMihome() {
cron.schedule('5 * * * *', async () => {
console.log('[cron] 米家小时采样触发 @ 上海 xx:05')
try {
const r = await mihome.syncNow()
console.log('[cron] 米家采样完成: 家庭=' + r.homes + ' 设备=' + r.devices + ' 采样=' + r.sampled + ' 聚合=' + r.aggregated + ' (' + r.ms + 'ms)')
} catch (e) {
console.error('[cron] 米家小时采样失败:', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每小时 xx:05 米家温湿度采样')
cron.schedule('5 0,3 * * *', async () => {
console.log('[cron] 米家每日维护触发 (00:05 / 03:05)')
try {
const r = await mihome.dailyMaintenance()
console.log('[cron] 米家聚合完成: 采样=' + r.sampled + ' 聚合=' + r.aggregated
+ ' vendor小时=' + r.vendorHours + ' vendor天=' + r.vendorDays + ' 缺口聚合=' + r.gapAggregated)
} catch (e) {
console.error('[cron] 米家每日维护失败:', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 00:05/03:05 米家日聚合 + vendor 补齐')
// 启动追赶: 服务重启/停机恢复后立即补一次 (不打断 5 分钟内的整点采样)
setTimeout(() => {
mihome.dailyMaintenance().catch((e) => console.error('[cron] 米家启动追赶失败:', e.message))
}, 8000)
console.log('[cron] 已注册: 启动后 8s 执行米家追赶同步')
}
scheduleMihome()
+2
View File
@@ -11,6 +11,7 @@ import articlesRouter from './routes/articles.js'
import otaRouter from './routes/ota.js'
import apizeroRouter from './routes/apizero.js'
import monitorsRouter from './routes/monitors.js'
import mihomeRouter from './routes/mihome.js'
const PORT = Number(process.env.PORT) || 3001
const DIST_DIR = path.join(process.cwd(), 'dist')
@@ -34,6 +35,7 @@ app.use('/api', alertsRouter)
app.use('/api', articlesRouter)
app.use('/api', apizeroRouter)
app.use('/api', monitorsRouter)
app.use('/api/mihome', mihomeRouter)
// 静态资源 (Vite build 产物)
if (HAS_DIST) {
+10
View File
@@ -0,0 +1,10 @@
// 米家凭证缓存目录: 认证 token 只落本地磁盘, 不进库不进 git
// 环境变量:
// MIHOME_CACHE_DIR 覆盖 (默认 <cwd>/server/mihome/.cache)
import path from 'node:path'
export function cacheDir() {
return process.env.MIHOME_CACHE_DIR
? path.resolve(process.env.MIHOME_CACHE_DIR)
: path.resolve(process.cwd(), 'server', 'mihome', '.cache')
}
+112
View File
@@ -0,0 +1,112 @@
// 米家客户端单例 + 认证状态 + 扫码流程状态机
import { MijiaAPI } from '@zythum02/mijia-api'
import { DiskCache } from '@zythum02/mijia-api/cache/disk-cache'
import { prisma } from '../db.js'
import { cacheDir } from './cache.js'
// 未授权/已过期 (模块内部错误, 路由层做 4xx 映射)
export class MihomeAuthError extends Error {
constructor(message = '米家未授权或已过期') {
super(message)
this.name = 'MihomeAuthError'
}
}
let _api = null
// 惰性单例: 首次调用时才构造并加载磁盘凭证
export function getApi() {
if (!_api) _api = new MijiaAPI(new DiskCache(cacheDir()))
return _api
}
// 认证状态内存缓存 (5 分钟 TTL, /status 不每次打米家账号服务)
let authCache = { status: 'unknown', lastError: '', checkedAt: 0 }
function persistAccount(status, lastError) {
return prisma.mihomeAccount
.upsert({
where: { key: 'default' },
create: { key: 'default', status, lastError: lastError || '' },
update: { status, lastError: lastError || '' },
})
.catch((e) => {
console.warn('[mihome] 写入授权状态失败(忽略):', e.message)
})
}
// 校验米家是否可用: 有凭证 + 能刷新 token (刷新会自动处理 30 天过期)
// force=true 忽略 TTL 强查
export async function checkAuth(force = false) {
const now = Date.now()
if (!force && now - authCache.checkedAt < 5 * 60 * 1000) return authCache
const api = getApi()
let status = 'expired'
let lastError = ''
try {
if (!api.available) throw new Error('无授权凭证')
await api.refreshToken()
status = 'ok'
} catch (e) {
status = 'expired'
lastError = e?.message || String(e)
}
authCache = { status, lastError, checkedAt: now }
await persistAccount(status, lastError)
return authCache
}
// ==================== 扫码重授权流程 (模块级单例) ====================
// 状态: idle | waiting(已出二维码等扫码) | failed
// 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB
const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
export function getQrState() {
return { ...qrState }
}
export function getQrImageUrl() {
return qrState.qrImageUrl || ''
}
export async function startQrLogin() {
if (qrState.running) return getQrState()
const api = getApi()
qrState.running = true
qrState.canceled = false
qrState.lastError = ''
qrState.qrImageUrl = ''
qrState.startedAt = Date.now()
;(async () => {
try {
await api.QRlogin(async (info) => {
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
qrState.state = 'waiting'
})
if (qrState.canceled) return
qrState.state = 'idle'
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
await persistAccount('ok', '')
} catch (e) {
if (qrState.canceled) {
qrState.state = 'idle'
} else {
qrState.state = 'failed'
qrState.lastError = e?.message || String(e)
}
} finally {
qrState.running = false
}
})()
return getQrState()
}
export function cancelQrLogin() {
qrState.canceled = true
if (qrState.running) {
// 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果
} else if (qrState.state !== 'failed') {
qrState.state = 'idle'
}
return getQrState()
}
+285
View File
@@ -0,0 +1,285 @@
// 采集器: 家庭/设备发现 + 每小时采样 + 电量 + 日聚合 + vendor 补齐编排
import { prisma } from '../db.js'
import { checkAuth, getApi, MihomeAuthError } from './client.js'
import { getSpec } from './specs.js'
import { dayStart, hourStart, addDays } from './time.js'
import * as vendorStats from './vendorStats.js'
const BACKFILL_DAYS = 45 // 停机/首次接入时, 云端统计补齐窗口
const round2 = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : null)
// 互斥锁: 同一时刻只允许一个同步在跑 (cron 与手动/启动触发并发时合并)
let runLock = null
function withLock(fn) {
if (runLock) return runLock
runLock = Promise.resolve()
.then(fn)
.finally(() => {
runLock = null
})
return runLock
}
// ==================== 设备发现 ====================
async function syncHomes() {
const api = getApi()
const homes = await api.getHomesList()
for (const h of homes) {
const homeId = String(h.id)
await prisma.mihomeHome.upsert({
where: { homeId },
create: { homeId, name: h.name || homeId },
update: { name: h.name || homeId },
})
}
return homes.length
}
async function syncDevices() {
const api = getApi()
const list = await api.getDevicesList()
let kept = 0
for (const d of list) {
const spec = getSpec(d.model)
if (!spec) continue // 非环境设备白名单, 跳过
const homeId = String(d.home_id || '')
if (!homeId) continue
const data = {
homeId,
model: d.model,
name: String(d.name || d.did),
tempSiid: spec.temp[0],
tempPiid: spec.temp[1],
humSiid: spec.hum[0],
humPiid: spec.hum[1],
battSiid: spec.batt[0],
battPiid: spec.batt[1],
}
await prisma.mihomeDevice.upsert({
where: { did: d.did },
create: { did: d.did, ...data },
update: data,
})
kept++
}
return kept
}
async function listEnabledDevices() {
return prisma.mihomeDevice.findMany({ where: { enabled: true } })
}
// ==================== 采样 ====================
// 每小时采样: 批量读温湿度 -> upsert 小时档
async function sampleHour(devices) {
const api = getApi()
const bucket = hourStart()
const props = []
for (const d of devices) {
props.push({ did: d.did, siid: d.tempSiid, piid: d.tempPiid })
props.push({ did: d.did, siid: d.humSiid, piid: d.humPiid })
}
if (props.length === 0) return 0
const results = []
try {
const res = await api.getDevicesProp(props)
results.push(...(Array.isArray(res) ? res : [res]))
} catch (e) {
// 整体失败: 不写入任何小时档, 缺档留给整点补采/vendor 补齐, 本次直接抛给上层记录
throw e
}
// did -> { temp, hum }
const byDevice = new Map(devices.map((d) => [d.did, d]))
const perDevice = new Map()
for (const r of results) {
if (!r || !r.did) continue
const ok = Number(r.code) === 0 && r.value !== undefined && r.value !== null
if (!ok) continue
const d = byDevice.get(r.did)
if (!d) continue
const cur = perDevice.get(r.did) || {}
if (r.siid === d.tempSiid && r.piid === d.tempPiid) {
cur.temperatureAvg = round2(r.value)
} else if (r.siid === d.humSiid && r.piid === d.humPiid) {
cur.humidityAvg = round2(r.value)
}
perDevice.set(r.did, cur)
}
let written = 0
for (const d of devices) {
const v = perDevice.get(d.did)
if (!v || (v.temperatureAvg === undefined && v.humidityAvg === undefined)) continue
await prisma.mihomeEnvHour.upsert({
where: { deviceId_hour: { deviceId: d.id, hour: bucket } },
create: {
deviceId: d.id,
hour: bucket,
temperatureAvg: v.temperatureAvg ?? null,
humidityAvg: v.humidityAvg ?? null,
raw: null, // 自采
},
update: {
temperatureAvg: v.temperatureAvg ?? null,
humidityAvg: v.humidityAvg ?? null,
raw: null,
},
})
await prisma.mihomeDevice.update({
where: { id: d.id },
data: { lastSyncAt: new Date() },
})
written++
}
return written
}
// 电量: 每天读一次 (batteryAt 不在今天就刷新), 标题展示用
async function refreshBattery(devices) {
const api = getApi()
const today = dayStart()
for (const d of devices) {
const isFresh = d.batteryAt && d.batteryAt.getTime() >= today.getTime()
if (isFresh) continue
try {
const res = await api.getDevicesProp({ did: d.did, siid: d.battSiid, piid: d.battPiid })
if (res && Number(res.code) === 0 && typeof res.value === 'number') {
await prisma.mihomeDevice.update({
where: { id: d.id },
data: { battery: Math.round(res.value), batteryAt: new Date() },
})
}
} catch (e) {
// 电量读不到不报错, 下次再试
}
}
}
// ==================== 日聚合 ====================
// 按日档重算: 读该上海日小时内档 -> avg/min/max + 最后一次值
export async function aggregateDay(device, dayDate) {
const start = dayStart(dayDate.getTime())
const end = addDays(start, 1)
const hours = await prisma.mihomeEnvHour.findMany({
where: { deviceId: device.id, hour: { gte: start, lt: end } },
orderBy: { hour: 'asc' },
})
if (hours.length === 0) return false
const temps = hours.filter((h) => h.temperatureAvg != null).map((h) => h.temperatureAvg)
const hums = hours.filter((h) => h.humidityAvg != null).map((h) => h.humidityAvg)
const last = hours[hours.length - 1]
const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null)
const allVendor = hours.every((h) => h.raw && h.raw.vendor === true)
await prisma.mihomeEnvSample.upsert({
where: { deviceId_date: { deviceId: device.id, date: start } },
create: {
deviceId: device.id,
date: start,
temperature: temps.length ? temps[temps.length - 1] : null,
humidity: hums.length ? hums[hums.length - 1] : null,
temperatureAvg: round2(avg(temps)),
humidityAvg: round2(avg(hums)),
temperatureMin: round2(temps.length ? Math.min(...temps) : null),
temperatureMax: round2(temps.length ? Math.max(...temps) : null),
humidityMin: round2(hums.length ? Math.min(...hums) : null),
humidityMax: round2(hums.length ? Math.max(...hums) : null),
origin: allVendor ? 'vendor_history' : 'live',
sampledAt: last.hour,
raw: { bucketCount: hours.length },
},
update: {
temperature: temps.length ? temps[temps.length - 1] : null,
humidity: hums.length ? hums[hums.length - 1] : null,
temperatureAvg: round2(avg(temps)),
humidityAvg: round2(avg(hums)),
temperatureMin: round2(temps.length ? Math.min(...temps) : null),
temperatureMax: round2(temps.length ? Math.max(...temps) : null),
humidityMin: round2(hums.length ? Math.min(...hums) : null),
humidityMax: round2(hums.length ? Math.max(...hums) : null),
origin: allVendor ? 'vendor_history' : 'live',
sampledAt: last.hour,
},
})
return true
}
// 补聚合缺口日: [since, today) 有小时档却没有日档的日子
export async function aggregateGapDays(device, since) {
const today = dayStart()
const existing = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: since, lt: today } },
select: { date: true },
})
const have = new Set(existing.map((s) => s.date.getTime()))
let agg = 0
for (let t = since.getTime(); t < today.getTime(); t += 86400000) {
if (!have.has(t)) {
const ok = await aggregateDay(device, new Date(t))
if (ok) agg++
}
}
return agg
}
// ==================== 对外入口 ====================
// 每次同步: 发现 + 当前小时采样 + 今天/昨天聚合 + 电量 (轻量, 每小时跑)
// 注意: 单独不包 withLock, 由 syncNow/dailyMaintenance 统一加锁, 避免嵌套自锁
async function syncLocked() {
const auth = await checkAuth()
if (auth.status !== 'ok') throw new MihomeAuthError(auth.lastError || '米家未授权或已过期')
const began = Date.now()
const homes = await syncHomes()
const devices = await syncDevices()
const kept = await listEnabledDevices()
const sampled = await sampleHour(kept)
await refreshBattery(kept)
// 今天 + 昨天都聚合 (保证 00 点边界不丢)
const nowMs = Date.now()
const today = dayStart(nowMs)
let agg = 0
for (const d of kept) {
if (await aggregateDay(d, today)) agg++
const y = addDays(today, -1)
if (await aggregateDay(d, y)) agg++
}
return {
ok: true,
homes,
devices,
sampled,
aggregated: agg,
ms: Date.now() - began,
}
}
export function syncNow() {
return withLock(syncLocked)
}
// 每日维护: 轻量同步 + vendor 补齐历史缺口 (45 天窗口) + 缺口日聚合
export function dailyMaintenance() {
return withLock(async () => {
const r = await syncLocked()
const auth = await checkAuth()
if (auth.status !== 'ok') throw new MihomeAuthError(auth.lastError || '米家未授权或已过期')
const since = addDays(dayStart(), -BACKFILL_DAYS)
const devices = await listEnabledDevices()
let hFilled = 0
let dFilled = 0
let agg = 0
for (const d of devices) {
hFilled += await vendorStats.fillHourGaps(d, since)
dFilled += await vendorStats.fillDayGaps(d, since)
agg += await aggregateGapDays(d, since)
}
return { ...r, vendorHours: hFilled, vendorDays: dFilled, gapAggregated: agg }
})
}
+142
View File
@@ -0,0 +1,142 @@
// 历史查询聚合: today(时) / month(天) / year(周)
// 响应统一: [{ t(桶起始 ms, Asia/Shanghai), temperature, temperatureAvg, temperatureMin, temperatureMax,
// humidity, humidityAvg, humidityMin, humidityMax, sampleCount?, missing? }]
import { prisma } from '../db.js'
import { dayStart, hourStart, monthStart, yearStart, weekStart, addDays, addHours, cnDateStr } from './time.js'
// 设备是否存在 (路由层校验)
export async function getDevice(did) {
return prisma.mihomeDevice.findUnique({ where: { did } })
}
// ---------- 当天逐小时 ----------
export async function todayBuckets(device) {
const start = dayStart()
const now = hourStart()
const rows = await prisma.mihomeEnvHour.findMany({
where: { deviceId: device.id, hour: { gte: start, lt: addDays(start, 1) } },
orderBy: { hour: 'asc' },
})
const byHour = new Map(rows.map((r) => [r.hour.getTime(), r]))
const out = []
for (let t = start.getTime(); t <= now.getTime(); t += 3600000) {
const r = byHour.get(t)
out.push({
t,
temperature: r?.temperatureAvg ?? null,
humidity: r?.humidityAvg ?? null,
sampleCount: r ? 1 : 0,
})
}
return out
}
// ---------- 当前月逐日 ----------
export async function monthBuckets(device) {
const start = monthStart()
const today = dayStart()
const end = addDays(today, 1)
const rows = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
orderBy: { date: 'asc' },
})
const byDay = new Map(rows.map((r) => [r.date.getTime(), r]))
const out = []
for (let t = start.getTime(); t < end.getTime(); t += 86400000) {
const r = byDay.get(t)
out.push(r
? {
t,
temperature: r.temperature,
temperatureAvg: r.temperatureAvg,
temperatureMin: r.temperatureMin,
temperatureMax: r.temperatureMax,
humidity: r.humidity,
humidityAvg: r.humidityAvg,
humidityMin: r.humidityMin,
humidityMax: r.humidityMax,
origin: r.origin,
sampleCount: 1,
}
: { t, sampleCount: 0, missing: true })
}
return out
}
// ---------- 当前年逐周 (按日档现场聚合, 不落库) ----------
export async function yearBuckets(device) {
const start = yearStart()
const today = dayStart()
const end = addDays(today, 1)
const rows = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
orderBy: { date: 'asc' },
})
// 日 -> 周 (上海周一为一周起点)
const weekOf = (dayStartMs) => weekStart(new Date(dayStartMs)).getTime()
const weeks = new Map() // weekStartMs -> { tempA: [], tempMin: [], tempMax: [], humA: [], humMin: [], humMax: [], days: [] }
for (const r of rows) {
const k = weekOf(r.date.getTime())
if (!weeks.has(k)) {
weeks.set(k, { tempA: [], tempMin: [], tempMax: [], humA: [], humMin: [], humMax: [], days: [] })
}
const w = weeks.get(k)
if (r.temperatureAvg != null) w.tempA.push(r.temperatureAvg)
if (r.temperatureMin != null) w.tempMin.push(r.temperatureMin)
if (r.temperatureMax != null) w.tempMax.push(r.temperatureMax)
if (r.humidityAvg != null) w.humA.push(r.humidityAvg)
if (r.humidityMin != null) w.humMin.push(r.humidityMin)
if (r.humidityMax != null) w.humMax.push(r.humidityMax)
w.days.push(r.date)
}
const avg = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null)
const out = []
for (const [k, w] of [...weeks.entries()].sort((a, b) => a[0] - b[0])) {
// 补齐周内缺的天数 (统计无数据的日算缺失)
const wkStart = new Date(k)
const missing = addDays(wkStart, 7)
const haveMap = new Set(w.days.map((d) => d.getTime()))
let missingDays = 0
for (let t = k; t < missing.getTime(); t += 86400000) {
// 只统计当年/到今天的日
if (t >= start.getTime() && t < end.getTime() && !haveMap.has(t)) missingDays++
}
out.push({
t: k,
label: cnDateStr(wkStart).slice(5) + '~' + cnDateStr(addDays(wkStart, 6)).slice(5),
temperatureAvg: avg(w.tempA),
temperatureMin: w.tempMin.length ? Math.min(...w.tempMin) : null,
temperatureMax: w.tempMax.length ? Math.max(...w.tempMax) : null,
humidityAvg: avg(w.humA),
humidityMin: w.humMin.length ? Math.min(...w.humMin) : null,
humidityMax: w.humMax.length ? Math.max(...w.humMax) : null,
sampleCount: w.days.length,
missingDays,
})
}
return out
}
// ---------- 设备概览 (状态页用): 最新样本 + 近 30 天缺日列表 ----------
export async function deviceOverview(device) {
const latest = await prisma.mihomeEnvSample.findFirst({
where: { deviceId: device.id },
orderBy: { date: 'desc' },
})
// 近 30 天 (含今天) 缺少样本的上海日
const start = addDays(dayStart(), -29)
const end = addDays(dayStart(), 1)
const have = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
select: { date: true },
})
const haveSet = new Set(have.map((h) => h.date.getTime()))
const missingDays = []
for (let t = start.getTime(); t < end.getTime(); t += 86400000) {
if (!haveSet.has(t)) missingDays.push(cnDateStr(new Date(t)))
}
return {
latest,
missingDays,
}
}
+68
View File
@@ -0,0 +1,68 @@
// server/mihome 模块入口: 汇总导出采集/认证/查询, 并提供 /status 聚合
import { prisma } from '../db.js'
import * as vendorStats from './vendorStats.js'
import * as history from './history.js'
import * as auth from './client.js'
export { checkAuth, startQrLogin, cancelQrLogin, getQrState, getQrImageUrl, MihomeAuthError } from './client.js'
export { syncNow, dailyMaintenance } from './collector.js'
export { getDevice, todayBuckets, monthBuckets, yearBuckets, deviceOverview } from './history.js'
export { getSpec, ENV_MODELS } from './specs.js'
// GET /api/mihome/status 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日)
export async function getStatus() {
const authState = await auth.checkAuth()
const [homes, devices, account] = await Promise.all([
prisma.mihomeHome.findMany({ orderBy: { id: 'asc' } }),
prisma.mihomeDevice.findMany({ orderBy: { id: 'asc' } }),
prisma.mihomeAccount.findUnique({ where: { key: 'default' } }),
])
const overviews = await Promise.all(
devices.map(async (d) => {
const ov = await history.deviceOverview(d)
return {
did: d.did,
latest: ov.latest,
missingDays: ov.missingDays,
}
})
)
const perDevice = new Map(overviews.map((o) => [o.did, o]))
// 统计可用性 (探测过的型号才有值, 未探测 undefined)
const capability = {}
for (const d of devices) {
const v = vendorStats.capabilityOf(d.model)
if (v !== undefined) capability[d.model] = v
}
const homeList = homes.map((h) => ({
homeId: h.homeId,
name: h.name,
devices: devices
.filter((d) => d.homeId === h.homeId)
.map((d) => ({
did: d.did,
model: d.model,
name: d.name,
alias: d.alias,
title: d.alias || d.name,
battery: d.battery,
batteryAt: d.batteryAt,
lastSyncAt: d.lastSyncAt,
latest: perDevice.get(d.did)?.latest ?? null,
missingDays: perDevice.get(d.did)?.missingDays ?? [],
})),
}))
return {
auth: {
status: account?.status || authState.status,
lastError: account?.lastError || authState.lastError,
checkedAt: authState.checkedAt,
},
capability,
homes: homeList,
}
}
+26
View File
@@ -0,0 +1,26 @@
// 环境设备白名单: 型号 -> 属性定义 (siid.piid), 均为调研阶段实测确认
// - temp/hum: 采集用; batt: 电量 (每天读一次, 标题展示)
// - vendorStats: 是否已实测该型号支持 /v2/user/statistics 云端聚合历史
export const ENV_MODELS = {
// 炎米智能温湿度计: 实测 stat_hour_v3/stat_day_v3 可用, 回溯到接入米家日
'ym001.sensor_ht.ymwsdj': {
temp: [2, 1001],
hum: [2, 1002],
batt: [3, 1003],
vendorStats: true,
},
// 青萍蓝牙闹钟: 云端统计返回 -8 invalid params (未开通), 只走实时采样
'cgllc.clock.dove': {
temp: [2, 1],
hum: [2, 2],
batt: [2, 3],
vendorStats: false,
},
}
export function getSpec(model) {
return ENV_MODELS[model] || null
}
export const XIAOMI_APP_UA =
'APP/com.xiaomi.mihome APPV/6.0.89 Channel/MI-COM-BD-00059-00 OSVersion/MIUI-12.0.1 Android/28'
+78
View File
@@ -0,0 +1,78 @@
// Asia/Shanghai 时区工具 (中国无夏令时, 固定 +08:00)
// 桶语义: 小时档/日档桶的 \"边界时刻\" 都按上海时区对齐, 存储统一存 UTC (Date)
const OFFSET_MS = 8 * 3600 * 1000
// 把时刻按上海时区拆成 \"假装 UTC 的本地部件\"
function cnParts(ts) {
const d = new Date(ts + OFFSET_MS)
return {
y: d.getUTCFullYear(),
mo: d.getUTCMonth() + 1,
da: d.getUTCDate(),
h: d.getUTCHours(),
min: d.getUTCMinutes(),
sec: d.getUTCSeconds(),
dow: d.getUTCDay(), // 0=Sun
}
}
// 上海当日 00:00 (返回对应 UTC 时刻的 Date)
export function dayStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, p.da) - OFFSET_MS)
}
// 上海整点 (返回对应 UTC 时刻的 Date)
export function hourStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, p.da, p.h) - OFFSET_MS)
}
// 上海当周起始 (周一 00:00)
export function weekStart(date) {
const dow = cnParts(date.getTime()).dow
const diff = dow === 0 ? 6 : dow - 1 // 距周一的天数
return addDays(dayStart(date.getTime()), -diff)
}
export function addDays(date, n) {
return new Date(date.getTime() + n * 86400000)
}
// 上海当月 1 号 00:00
export function monthStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, 1) - OFFSET_MS)
}
// 上海当年 1 月 1 日 00:00
export function yearStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, 0, 1) - OFFSET_MS)
}
export function addHours(date, n) {
return new Date(date.getTime() + n * 3600000)
}
// 上海本地 \"YYYY-MM-DD\"
export function cnDateStr(date) {
const p = cnParts(date.getTime())
return `${p.y}-${String(p.mo).padStart(2, '0')}-${String(p.da).padStart(2, '0')}`
}
// 上海本地 \"HH:mm\"
export function cnTimeStr(date) {
const p = cnParts(date.getTime())
return `${String(p.h).padStart(2, '0')}:${String(p.min).padStart(2, '0')}`
}
// 上海本地 \"YYYY-MM-DD HH:mm\" (用于日志)
export function cnFullStr(date) {
return `${cnDateStr(date)} ${cnTimeStr(date)}`
}
// 判断两个日期是否同一上海日
export function sameCnDay(a, b) {
return dayStart(a.getTime()).getTime() === dayStart(b.getTime()).getTime()
}
+179
View File
@@ -0,0 +1,179 @@
// /v2/user/statistics 云端聚合历史: 探测可用性 + 小时/日缺口回填
// 实测: 炎米 ym001 可用 (stat_hour_v3/stat_day_v3/stat_month_v3); 青萍/VRF 返回 -8 invalid params
import { prisma } from '../db.js'
import { getApi } from './client.js'
import { dayStart, hourStart, addDays, addHours } from './time.js'
import { getSpec } from './specs.js'
// 型号 -> 是否支持统计 (探测一次后缓存; undefined=未探测)
const capabilityCache = new Map()
export function capabilityOf(model) {
if (capabilityCache.has(model)) return capabilityCache.get(model)
return undefined
}
// 用同型号任一设备探测该型号统计可用性
async function ensureCapability(device) {
if (capabilityCache.has(device.model)) return capabilityCache.get(device.model)
let supported = false
try {
const api = getApi()
const res = await api.getStatistics({
did: device.did,
key: `${device.tempSiid}.${device.tempPiid}`,
data_type: 'stat_day_v3',
limit: 1,
time_start: Math.floor(Date.now() / 1000) - 86400,
time_end: Math.floor(Date.now() / 1000),
})
supported = Array.isArray(res)
} catch (e) {
supported = false
}
capabilityCache.set(device.model, supported)
console.log(`[mihome] 统计探测 ${device.model}: ${supported ? '可用' : '不可用'}`)
return supported
}
// 解析 "[28.8]" / "[28.1,29.200001]" -> [28.8, 29.2]
function parseStatValue(value) {
if (typeof value !== 'string') return []
const m = value.match(/\[([^\]]*)\]/)
if (!m) return []
return m[1]
.split(',')
.map((s) => parseFloat(s.trim()))
.filter((n) => Number.isFinite(n))
}
// 拉某设备某属性的小时均值序列 (stat_hour_v3), 默认带排序
async function fetchStatHours(device, key, fromTs, toTs) {
const api = getApi()
const res = await api.getStatistics({
did: device.did,
key,
data_type: 'stat_hour_v3',
limit: 2000,
time_start: fromTs,
time_end: toTs,
})
if (!Array.isArray(res)) return []
return res
.filter((r) => r && r.time)
.sort((a, b) => a.time - b.time)
.map((r) => ({ time: new Date(r.time * 1000), values: parseStatValue(r.value) }))
}
// 回填小时缺口: 查 [since, now) 内缺失的整点桶, 从云端补齐 (origin 标记 raw.vendor=true)
// 返回新增小时桶数量
export async function fillHourGaps(device, since) {
if (!(await ensureCapability(device))) return 0
const now = hourStart()
if (since >= now) return 0
const existing = await prisma.mihomeEnvHour.findMany({
where: {
deviceId: device.id,
hour: { gte: since, lte: addHours(now, -1) },
},
select: { hour: true },
})
const have = new Set(existing.map((h) => h.hour.getTime()))
// 实际缺失桶数
let missingCount = 0
for (let t = since.getTime(); t <= addHours(now, -1).getTime(); t += 3600000) {
if (!have.has(t)) missingCount++
}
if (missingCount === 0) return 0
const fromTs = Math.floor(since.getTime() / 1000)
const toTs = Math.floor(now.getTime() / 1000)
let filled = 0
for (const [siid, piid, col, num] of [
[device.tempSiid, device.tempPiid, 'temperatureAvg', 0],
[device.humSiid, device.humPiid, 'humidityAvg', 1],
]) {
const rows = await fetchStatHours(device, `${siid}.${piid}`, fromTs, toTs)
for (const r of rows) {
const bucket = r.time.getTime()
if (r.values.length <= num || have.has(bucket) || bucket > now.getTime()) continue
// 只补严格已过去的整点
have.add(bucket)
try {
await prisma.mihomeEnvHour.upsert({
where: { deviceId_hour: { deviceId: device.id, hour: r.time } },
create: { deviceId: device.id, hour: r.time, [col]: r.values[num], raw: { vendor: true } },
update: { [col]: r.values[num], raw: { vendor: true } },
})
filled++
} catch (e) {
if (e?.code !== 'P2002') throw e
}
}
}
if (filled > 0) console.log(`[mihome] vendor 补齐小时档 ${device.did}: +${filled}`)
return filled
}
// 回填日缺口: 对仍无日档的日子, 若云端有日 [min,max] 则补一条 vendor_history
export async function fillDayGaps(device, since) {
if (!(await ensureCapability(device))) return 0
const today = dayStart()
const existingSamples = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: since, lt: today } },
select: { date: true },
})
const haveDay = new Set(existingSamples.map((s) => s.date.getTime()))
const fromTs = Math.floor(since.getTime() / 1000)
const toTs = Math.floor(Date.now() / 1000)
let filled = 0
for (const [siid, piid] of [
[device.tempSiid, device.tempPiid],
[device.humSiid, device.humPiid],
]) {
const api = getApi()
const res = await api.getStatistics({
did: device.did,
key: `${siid}.${piid}`,
data_type: 'stat_day_v3',
limit: 120,
time_start: fromTs,
time_end: toTs,
})
if (!Array.isArray(res)) continue
const isTemp = siid === device.tempSiid && piid === device.tempPiid
for (const r of res) {
if (!r || !r.time) continue
const day = new Date(r.time * 1000)
const vals = parseStatValue(r.value)
const bucket = dayStart(day.getTime()).getTime()
if (haveDay.has(bucket) || bucket >= today.getTime() || vals.length < 2) continue
haveDay.add(bucket)
const data = isTemp
? { temperatureMin: vals[0], temperatureMax: vals[1] }
: { humidityMin: vals[0], humidityMax: vals[1] }
try {
await prisma.mihomeEnvSample.upsert({
where: { deviceId_date: { deviceId: device.id, date: dayStart(day.getTime()) } },
create: {
deviceId: device.id,
date: dayStart(day.getTime()),
origin: 'vendor_history',
...data,
},
update: { origin: 'vendor_history', ...data },
})
filled++
} catch (e) {
if (e?.code !== 'P2002') throw e
}
}
}
if (filled > 0) console.log(`[mihome] vendor 补齐日档 ${device.did}: +${filled}`)
return filled
}
+93
View File
@@ -0,0 +1,93 @@
// 环境监控 REST 路由: /api/mihome/* (authRequired)
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import {
getStatus,
getDevice,
todayBuckets,
monthBuckets,
yearBuckets,
syncNow,
startQrLogin,
cancelQrLogin,
getQrState,
getQrImageUrl,
MihomeAuthError,
} from '../mihome/index.js'
import { XIAOMI_APP_UA } from '../mihome/specs.js'
const router = Router()
router.use(authRequired)
// 统一错误边界: 米家授权错误映射为 401, 其余走 500
function guard(handler) {
return async (req, res, next) => {
try {
return await handler(req, res, next)
} catch (e) {
if (e instanceof MihomeAuthError) return res.status(401).json({ error: e.message })
return next(e)
}
}
}
// 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数)
router.get('/status', guard(async (_req, res) => {
res.json(await getStatus())
}))
// 历史: range = today(默认) | month | year
router.get('/device/:did/history', guard(async (req, res) => {
const device = await getDevice(req.params.did)
if (!device) return res.status(404).json({ error: '设备不存在' })
const range = req.query.range || 'today'
let buckets
if (range === 'month') buckets = await monthBuckets(device)
else if (range === 'year') buckets = await yearBuckets(device)
else buckets = await todayBuckets(device)
res.json({ did: device.did, range: range === 'today' ? 'today' : range, buckets })
}))
// 改别名 (可清空); alias 最长 24 字符
router.put('/device/:did', guard(async (req, res) => {
const device = await getDevice(req.params.did)
if (!device) return res.status(404).json({ error: '设备不存在' })
const alias = typeof req.body?.alias === 'string' ? req.body.alias.trim().slice(0, 24) : ''
const updated = await prisma.mihomeDevice.update({ where: { id: device.id }, data: { alias: alias || null } })
res.json({ did: updated.did, alias: updated.alias || '' })
}))
// 立即同步一次 (小时采样 + 日聚合), 首页「立即同步」
router.post('/sync', guard(async (_req, res) => {
res.json(await syncNow())
}))
// 重授权: 启动扫码 (幂等; 已在跑则返回当前状态)
router.post('/reauthorize', guard(async (_req, res) => {
res.json(await startQrLogin())
}))
// 扫码状态轮询
router.get('/reauthorize/status', guard(async (_req, res) => {
res.json(getQrState())
}))
// 取消扫码
router.post('/reauthorize/cancel', guard(async (_req, res) => {
res.json(cancelQrLogin())
}))
// 代理二维码图片 (带小米 UA, 避免被 CDN 拒)
router.get('/reauthorize/qr-image', guard(async (_req, res) => {
const url = getQrImageUrl()
if (!url) return res.status(404).json({ error: '暂无二维码' })
const r = await fetch(url, { headers: { 'User-Agent': XIAOMI_APP_UA } })
if (!r.ok) throw new Error('二维码拉取失败 ' + r.status)
const buf = Buffer.from(await r.arrayBuffer())
res.set('Content-Type', r.headers.get('content-type') || 'image/png')
res.set('Cache-Control', 'no-store')
res.send(buf)
}))
export default router