Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/src/components/TopologyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,30 @@ export function TopologyEditor({ open, onOpenChange, install }: Props) {
/>
</Field>
</div>
<Field label={t('ajustes.topology.backupPowerId')}>
<Input
value={inv.backupPowerId || ''}
placeholder="sensor.inverter_cloud_power"
className="font-mono text-xs"
onChange={(e) => kv.inv(i, { backupPowerId: e.target.value })}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t('ajustes.topology.backupPowerUnit')}>
<Select
value={inv.backupPowerUnit || 'W'}
onValueChange={(v) => kv.inv(i, { backupPowerUnit: v as 'kW' | 'W' })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kW">kW</SelectItem>
<SelectItem value="W">W</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Field label={t('ajustes.topology.energyId')}>
<Input
value={inv.energyId}
Expand Down Expand Up @@ -243,6 +267,8 @@ export function TopologyEditor({ open, onOpenChange, install }: Props) {
batteryKwh: 0,
powerId: '',
powerUnit: 'kW',
backupPowerId: '',
backupPowerUnit: 'W',
energyId: '',
energyAcc: 'state',
energyCap: 100,
Expand Down
3 changes: 3 additions & 0 deletions app/src/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ export interface TopologyInverter {
batteryKwh: number;
powerId: string;
powerUnit: 'kW' | 'W';
/** Fuente secundaria de potencia para rellenar huecos de la curva (issue 124). */
backupPowerId?: string;
backupPowerUnit?: 'kW' | 'W';
energyId: string;
energyAcc: 'sum' | 'state';
energyCap: number;
Expand Down
4 changes: 3 additions & 1 deletion app/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@
"panels": "Panels",
"powerId": "Power sensor",
"powerUnit": "Unit",
"backupPowerId": "Backup power sensor",
"energyCap": "Daily cap (kWh)",
"energyId": "Today's energy sensor",
"energyAcc": "Accumulator",
Expand Down Expand Up @@ -559,7 +560,8 @@
"environment": "Sun and weather",
"sun": "Sun",
"weather": "Weather",
"weatherTemp": "Outdoor temperature"
"weatherTemp": "Outdoor temperature",
"backupPowerUnit": "Backup unit"
},
"extensions": {
"enable": "Enable extensions",
Expand Down
4 changes: 3 additions & 1 deletion app/src/i18n/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@
"panels": "Placas",
"powerId": "Sensor de potencia",
"powerUnit": "Unidad",
"backupPowerId": "Sensor de potencia de respaldo",
"energyCap": "Límite diario (kWh)",
"energyId": "Sensor de energía de hoy",
"energyAcc": "Acumulador",
Expand Down Expand Up @@ -559,7 +560,8 @@
"environment": "Sol y tiempo",
"sun": "Sol",
"weather": "Tiempo",
"weatherTemp": "Temperatura exterior"
"weatherTemp": "Temperatura exterior",
"backupPowerUnit": "Unidad del respaldo"
},
"extensions": {
"enable": "Activar extensiones",
Expand Down
4 changes: 3 additions & 1 deletion app/src/i18n/locales/zh-CN/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@
"panels": "面板",
"powerId": "功率传感器",
"powerUnit": "单位",
"backupPowerId": "备用功率传感器",
"energyCap": "每日上限 (kWh)",
"energyId": "今日能量传感器",
"energyAcc": "累加器",
Expand Down Expand Up @@ -556,7 +557,8 @@
"environment": "太阳与天气",
"sun": "太阳",
"weather": "天气",
"weatherTemp": "室外温度"
"weatherTemp": "室外温度",
"backupPowerUnit": "备用传感器单位"
},
"extensions": {
"enable": "启用扩展",
Expand Down
26 changes: 13 additions & 13 deletions server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ ha.start()

const app = new Hono()

// Cache HTTP: los bundles llevan hash en el nombre (inmutables); el shell
// (index.html, sw.js, manifest) y TODA la API SIEMPRE se revalidan para que un
// deploy o un recálculo de datos se vea al refrescar (sin esto el navegador
// puede servir la respuesta vieja de /api/* por caché heurística).
app.use('*', async (c, next) => {
await next()
const path = c.req.path
if (path.startsWith('/assets/')) c.header('Cache-Control', 'public, max-age=31536000, immutable')
else if (path.startsWith('/api/') || path === '/' || path === '/index.html' || path === '/sw.js' || path === '/manifest.webmanifest') {
c.header('Cache-Control', 'no-cache')
}
})

app.get('/health', (c) => {
let dbOk = true
try {
Expand Down Expand Up @@ -796,19 +809,6 @@ app.onError((err, c) => {
return c.json({ error: 'error interno' }, 500)
})

// Cache HTTP: los bundles llevan hash en el nombre (inmutables); el shell
// (index.html, sw.js, manifest) SIEMPRE se revalida para que un deploy se vea
// al refrescar (sin esto el navegador puede servir HTML viejo por caché
// heurística y el usuario ver una app antigua tras un deploy).
app.use('*', async (c, next) => {
await next()
const path = c.req.path
if (path.startsWith('/assets/')) c.header('Cache-Control', 'public, max-age=31536000, immutable')
else if (path === '/' || path === '/index.html' || path === '/sw.js' || path === '/manifest.webmanifest') {
c.header('Cache-Control', 'no-cache')
}
})

app.use('/*', serveStatic({ root: config.staticDir }))

app.get('*', (c) => {
Expand Down
6 changes: 6 additions & 0 deletions server/src/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export const LEGACY_TOPOLOGY = {
hasBattery: false,
powerId: 'sensor.almacen_pinza_power_b',
powerUnit: 'W',
// Respaldo de potencia: la nube del fabricante (mismo shape, ~4-5% más
// baja). Rellena los huecos de la curva cuando la pinza local cae.
backupPowerId: 'sensor.foxess_solar_power',
backupPowerUnit: 'kW',
energyId: 'sensor.energia_fox_diaria',
energyAcc: 'state',
energyCap: 20,
Expand Down Expand Up @@ -229,6 +233,8 @@ export function normalizeTopology(cfg, base = GENERIC_TOPOLOGY) {
name: inv.name || `Inverter ${i + 1}`,
powerId: inv.powerId || '',
powerUnit: inv.powerUnit || 'kW',
backupPowerId: inv.backupPowerId || '',
backupPowerUnit: inv.backupPowerUnit || inv.powerUnit || 'kW',
energyId: inv.energyId || '',
energyAcc: inv.energyAcc || 'state',
energyCap: typeof inv.energyCap === 'number' ? inv.energyCap : 100,
Expand Down
166 changes: 156 additions & 10 deletions server/src/solar.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ async function getDaySeriesUncached(ha, dateStr, db) {
const end = new Date(start)
end.setDate(end.getDate() + 1)

const invIds = t.inverters.map((inv) => inv.powerId).filter(Boolean)
const invIds = t.inverters.flatMap((inv) => [inv.powerId, inv.backupPowerId].filter(Boolean))
const ids = [...invIds, ...t.consumption.powerIds]
if (t.battery.enabled) {
if (t.battery.powerId) ids.push(t.battery.powerId)
Expand All @@ -274,16 +274,40 @@ async function getDaySeriesUncached(ha, dateStr, db) {
types: ['mean'],
})

return buildDaySeries(stats, dateStr, db)
// HOY aún no tiene fila `daily`: el total de referencia para escalar el
// tramo rellenado por respaldo es el KPI en vivo del energyId (la UM del
// día). Para días pasados buildDaySeries lee la fila daily directamente.
const liveTargets = {}
const isToday = !dateStr || dateStr === todayStr()
if (isToday) {
for (const inv of t.inverters) {
if (inv.backupPowerId && inv.energyId) liveTargets[inv.key] = entityNum(ha, inv.energyId)
}
// NOTA: el consumo de HOY NO se escala contra los contadores en vivo: los
// contadores de energía actualizan lento y se quedan congelados mientras
// la potencia medida sigue creciendo, lo que encoge el relleno del hueco
// en cada recálculo. Para HOY se usa el patrón del día anterior tal cual
// (estimated); la consolidación nocturna regenera "ayer" y lo re-escala al
// total `daily` consolidado (issue 125).
}

return buildDaySeries(stats, dateStr, db, liveTargets)
}

// Construye los puntos de la curva de un día a partir de las estadísticas
// 5-minutos ({sensorId: [{start, mean}]}). Reutilizable para la curva en vivo
// (HAOS) y para el backfill desde una fuente externa (backup de HAOS). El
// heurístico de curva estimada (Solis sin state_class) también aplica aquí.
function buildDaySeries(stats, dateStr, db) {
//
// Respaldo por inversor (issue 124): si en un bucket no hay fila del sensor
// de potencia principal (unavailable), se usa `backupPowerId` del mismo
// inversor. El tramo rellenado se ESCALA al total diario real del inversor
// (fila `daily` para días pasados; `liveTargets` para HOY, que aún no tiene
// fila), para que la curva integre el mismo total que la fuente profunda.
// Si se usó respaldo en algún bucket, la curva se marca `estimated`.
function buildDaySeries(stats, dateStr, db, liveTargets) {
const t = getInstall()
const invIds = t.inverters.map((inv) => inv.powerId).filter(Boolean)
const invIds = t.inverters.flatMap((inv) => [inv.powerId, inv.backupPowerId].filter(Boolean))
const ids = [...invIds, ...t.consumption.powerIds]
if (t.battery.enabled) {
if (t.battery.powerId) ids.push(t.battery.powerId)
Expand All @@ -305,17 +329,34 @@ function buildDaySeries(stats, dateStr, db) {
let prevSoc = null
const minutes = [...buckets.keys()].sort((a, b) => a - b)
const consDiv = t.consumption.powerUnit === 'W' ? 1000 : 1
for (const min of minutes) {
const backupIdx = {} // inv.key -> índices de points que usaron respaldo
const consGapIdx = [] // índices de points sin NINGÚN medidor de consumo
const consMeterIds = t.consumption.powerIds || []
minutes.forEach((min, pointIdx) => {
const b = buckets.get(min)
let production = 0
const perInv = {}
for (const inv of t.inverters) {
let kw = num(b[inv.powerId]) / (inv.powerUnit === 'W' ? 1000 : 1)
let kw = 0
const raw = b[inv.powerId]
if (raw !== undefined && raw !== null) {
kw = num(raw) / (inv.powerUnit === 'W' ? 1000 : 1)
} else if (inv.backupPowerId) {
const backRaw = b[inv.backupPowerId]
if (backRaw !== undefined && backRaw !== null) {
kw = num(backRaw) / ((inv.backupPowerUnit || 'kW') === 'W' ? 1000 : 1)
;(backupIdx[inv.key] ||= []).push(pointIdx)
}
}
if (kw < 0.02) kw = 0
perInv[inv.key] = kw
production += kw
}
const consumption = t.consumption.powerIds.reduce((acc, id) => acc + num(b[id]) / consDiv, 0)
// Hueco de consumo: el bucket existe (otros sensores reportan) pero
// NINGÚN medidor de consumo tiene fila (cayeron todos a la vez).
const consGap = consMeterIds.length > 0 && consMeterIds.every((id) => b[id] === undefined || b[id] === null)
if (consGap) consGapIdx.push(pointIdx)
const consumption = consMeterIds.reduce((acc, id) => acc + num(b[id]) / consDiv, 0)

let batteryPower = 0
if (t.battery.enabled) {
Expand Down Expand Up @@ -349,11 +390,112 @@ function buildDaySeries(stats, dateStr, db) {
soc: round1(soc),
grid: round3(grid),
})
}
})

const dtH = 5 / 60
const sumInv = (key) => points.reduce((acc, p) => acc + (p.inverters[key] || 0) * dtH, 0)
const dayKey = dateStr || todayStr()
let estimated = false
// Escalado de tramos rellenados por respaldo al total diario real. El total
// de referencia es la fila daily del día si existe; para HOY (sin fila aún)
// lo aporta `liveTargets` (KPI en vivo del energyId). Solo se escala el
// tramo con respaldo: la parte medida por el sensor principal se respeta.
const backupKeys = Object.keys(backupIdx)
if (backupKeys.length && points.length) {
estimated = true
let row = null
if (db) {
row = db.prepare('SELECT solis_kwh, fox_kwh, inverters_kwh FROM daily WHERE date = ?').get(dayKey)
}
for (const inv of t.inverters) {
const idxs = backupIdx[inv.key] || []
if (!idxs.length) continue
let target = liveTargets && liveTargets[inv.key] > 0 ? liveTargets[inv.key] : 0
if (!(target > 0) && row) {
if (row.inverters_kwh) {
try {
const ik = JSON.parse(row.inverters_kwh)
target = ik[inv.key] || 0
} catch {}
}
if (!(target > 0)) {
const invIdx = t.inverters.findIndex((x) => x.key === inv.key)
if (invIdx === 0) target = row.solis_kwh || 0
else if (invIdx === 1) target = row.fox_kwh || 0
}
}
if (!(target > 0)) continue
const idxSet = new Set(idxs)
let sumReal = 0
let sumBackup = 0
points.forEach((p, i) => {
const kw = p.inverters[inv.key] || 0
if (idxSet.has(i)) sumBackup += kw
else sumReal += kw
})
sumReal *= dtH
sumBackup *= dtH
if (sumBackup <= 0) continue
const gap = target - (sumReal + sumBackup)
if (gap < 0.05) continue // ya cuadra o la parte real supera el total
const factor = (target - sumReal) / sumBackup
const compat = inv.key === t.inverters[0]?.key ? 'solis' : inv.key === t.inverters[1]?.key ? 'fox' : null
for (const i of idxs) {
const p = points[i]
const v = (p.inverters[inv.key] || 0) * factor
p.inverters[inv.key] = round3(v)
if (compat === 'solis') p.solis = p.inverters[inv.key]
if (compat === 'fox') p.fox = p.inverters[inv.key]
p.production = round3(t.inverters.reduce((acc, x) => acc + (p.inverters[x.key] || 0), 0))
let grid = p.consumption - p.production + p.batteryPower
if (Math.abs(grid) < 0.03) grid = 0
p.grid = round3(grid)
}
}
}
// Hueco de consumo (issue 125): si todos los medidores cayeron a la vez y no
// hay sensor redundante que mida el consumo, se rellena el tramo con la FORMA
// del día anterior (mismos buckets 5-min). El consumo exacto del hueco es
// incognoscible: para días pasados se escala a la fila daily consolidada
// (total real del día); para HOY (sin fila aún) se usa el patrón tal cual y
// la consolidación nocturna lo re-escala al regenerar ayer. La curva se marca
// estimated.
if (db && consGapIdx.length && points.length) {
let consTarget = 0
const rowC = db.prepare('SELECT consumption_kwh FROM daily WHERE date = ?').get(dayKey)
if (rowC && rowC.consumption_kwh > 0) consTarget = rowC.consumption_kwh
const prevKey = dateKey(addDays(new Date(dayKey + 'T00:00:00'), -1))
const prevRow = getDaySeriesRow(db, prevKey)
if (prevRow) {
const prevPts = JSON.parse(prevRow.points_json)
const prevByT = new Map(prevPts.map((p) => [p.t, p.consumption || 0]))
const gapSet = new Set(consGapIdx)
let factor = null
if (consTarget > 0) {
let realSum = 0 // consumo integrado FUERA del hueco (medidores reales)
let patSum = 0 // patrón del día anterior integrado DENTRO del hueco
points.forEach((p, i) => {
if (gapSet.has(i)) patSum += prevByT.get(p.t) || 0
else realSum += p.consumption || 0
})
realSum *= dtH
patSum *= dtH
const deficit = consTarget - realSum
if (patSum > 0.01 && deficit > 0.05) factor = deficit / patSum
}
if (factor === null || factor <= 0) factor = 1 // HOY: patrón sin escalar
for (const i of consGapIdx) {
const p = points[i]
const pat = prevByT.get(p.t)
if (!pat) continue
p.consumption = round3(pat * factor)
let grid = p.consumption - p.production + p.batteryPower
if (Math.abs(grid) < 0.03) grid = 0
p.grid = round3(grid)
}
estimated = true
}
}
// Estimación de curva: si el inversor 0 no tiene statistics pero el total
// diario sí, se escala la curva del resto (fallback típico de sensores sin
// state_class). Se mantiene el heurístico original Solis→Fox.
Expand All @@ -363,7 +505,7 @@ function buildDaySeries(stats, dateStr, db) {
const sum0 = sumInv(inv0Key)
const sum1 = inv1Key ? sumInv(inv1Key) : 0
if (sum0 < 0.5 && sum1 > 1 && inv0Key && inv1Key) {
const row = db.prepare('SELECT solis_kwh, fox_kwh FROM daily WHERE date = ?').get(dateStr || todayStr())
const row = db.prepare('SELECT solis_kwh, fox_kwh FROM daily WHERE date = ?').get(dayKey)
if (row && row.solis_kwh > 1 && sum1 > 1) {
const ratio = row.solis_kwh / sum1
for (const p of points) {
Expand Down Expand Up @@ -436,9 +578,13 @@ export async function backfillDaySeries(ha, db, backupFilePath) {
const cursor = new Date(firstDaily + 'T00:00:00')
const end = new Date()
end.setDate(end.getDate() + 1)
// Ayer se regenera SIEMPRE aunque tenga fila: la consolidación nocturna
// acaba de escribir su fila `daily` real y la curva persistida en vivo (con
// huecos por respaldo sin consolidar) debe re-escalarse a ese total.
const yesterdayKey = dateKey(addDays(new Date(), -1))

for (const key of eachDayKey(cursor, end)) {
if (done.has(key)) continue
if (done.has(key) && key !== yesterdayKey) continue
const keyDate = new Date(key + 'T00:00:00')
const fromBackup = backup && Object.keys(sliceStatsByDay(backup, key)).length > 0
if (!fromBackup && keyDate < haosFloor) continue
Expand Down
Loading
Loading