179 lines
6.0 KiB
JavaScript
179 lines
6.0 KiB
JavaScript
// /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
|
|
} |