新增米家环境监控模块 (采集/聚合/历史/前端 EnvView) + CI 挂载米家凭证目录并自动应用迁移
Docker Build and Push / build-image (push) Successful in 6m54s
Docker Build and Push / build-image (push) Successful in 6m54s
This commit is contained in:
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user