Files
HECO2/TestProgram/WebDashBoard/ErvCollector/Storage/HistoryDb.cs
T
jeon a502322188 chore: HERV 통합 저장소 재초기화 커밋
손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋.
.claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:32:17 +09:00

200 lines
10 KiB
C#

using System.Globalization;
using System.Text;
using Microsoft.Data.Sqlite;
using ErvProtocol;
namespace ErvCollector.Storage
{
// 현장별 시계열 로그 (그래프용). InfluxDB 와 별개로 자체 SQLite 단일파일에 누적.
// - sample : 현장 1샘플 (시간·전원·운전/자동/풍량/시나리오/프리셋)
// - room_sample : 실별 댐퍼/센서/LED/부하
// 그래프는 /api/history?site=&date= 로 하루치를 읽어 표시, /api/dates 로 날짜 목록.
public sealed class HistoryDb : IDisposable
{
readonly SqliteConnection _conn;
readonly object _lock = new();
public HistoryDb(string path)
{
_conn = new SqliteConnection($"Data Source={path}");
_conn.Open();
Exec("PRAGMA journal_mode=WAL;");
Exec("PRAGMA synchronous=NORMAL;");
Exec(@"CREATE TABLE IF NOT EXISTS sample(
id INTEGER PRIMARY KEY AUTOINCREMENT,
site TEXT NOT NULL, time INTEGER NOT NULL,
power INTEGER, runmode INTEGER, automode INTEGER, fanmode INTEGER,
sleep INTEGER, cook INTEGER, recover INTEGER, hystpreset INTEGER);");
Exec("CREATE INDEX IF NOT EXISTS ix_sample_site_time ON sample(site, time);");
Exec(@"CREATE TABLE IF NOT EXISTS room_sample(
sample_id INTEGER NOT NULL, room_idx INTEGER NOT NULL,
damper_sa INTEGER, damper_ra INTEGER,
co2 INTEGER, pm25 INTEGER, pm10 INTEGER, voc INTEGER,
temp INTEGER, humi INTEGER, led INTEGER, level INTEGER,
PRIMARY KEY(sample_id, room_idx));");
}
void Exec(string sql)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
// STATUS 1건 저장. time 은 로컬(날짜 버킷팅 일치).
public void Insert(string site, StatusRecord s)
{
// 자동운전 세부 : 0 비자동 / 1 자동-집중 / 2 자동-분산
int automode = s.RunMode == 2 ? (s.AutoState == 1 ? 1 : 2) : 0;
int sleep = (s.SubMode & 0x01) != 0 ? 1 : 0;
int cook = (s.SubMode & 0x02) != 0 ? 1 : 0;
int recov = (s.SubMode & 0x04) != 0 ? 1 : 0;
lock (_lock)
{
using var tx = _conn.BeginTransaction();
long id;
using (var cmd = _conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText =
"INSERT INTO sample(site,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset) " +
"VALUES($st,$t,$p,$rm,$am,$fm,$sl,$ck,$rc,$hp); SELECT last_insert_rowid();";
cmd.Parameters.AddWithValue("$st", site);
cmd.Parameters.AddWithValue("$t", DateTime.Now.Ticks);
cmd.Parameters.AddWithValue("$p", s.Power);
cmd.Parameters.AddWithValue("$rm", s.RunMode);
cmd.Parameters.AddWithValue("$am", automode);
cmd.Parameters.AddWithValue("$fm", s.FanMode);
cmd.Parameters.AddWithValue("$sl", sleep);
cmd.Parameters.AddWithValue("$ck", cook);
cmd.Parameters.AddWithValue("$rc", recov);
cmd.Parameters.AddWithValue("$hp", s.HystPreset);
id = (long)(cmd.ExecuteScalar() ?? 0L);
}
using (var cmd = _conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText =
"INSERT INTO room_sample(sample_id,room_idx,damper_sa,damper_ra,co2,pm25,pm10,voc,temp,humi,led,level) " +
"VALUES($id,$ri,$dsa,$dra,$co2,$pm25,$pm10,$voc,$tp,$hm,$led,$lv);";
var pId=cmd.Parameters.Add("$id",SqliteType.Integer); var pRi=cmd.Parameters.Add("$ri",SqliteType.Integer);
var pDsa=cmd.Parameters.Add("$dsa",SqliteType.Integer); var pDra=cmd.Parameters.Add("$dra",SqliteType.Integer);
var pCo2=cmd.Parameters.Add("$co2",SqliteType.Integer); var pP25=cmd.Parameters.Add("$pm25",SqliteType.Integer);
var pP10=cmd.Parameters.Add("$pm10",SqliteType.Integer); var pVoc=cmd.Parameters.Add("$voc",SqliteType.Integer);
var pTp=cmd.Parameters.Add("$tp",SqliteType.Integer); var pHm=cmd.Parameters.Add("$hm",SqliteType.Integer);
var pLed=cmd.Parameters.Add("$led",SqliteType.Integer); var pLv=cmd.Parameters.Add("$lv",SqliteType.Integer);
pId.Value = id;
for (int i = 0; i < 4 && i < s.Rooms.Length; i++)
{
var rm = s.Rooms[i];
pRi.Value=i; pDsa.Value=rm.DamperSa?1:0; pDra.Value=rm.DamperEa?1:0;
pCo2.Value=rm.Co2; pP25.Value=rm.Pm25; pP10.Value=rm.Pm10; pVoc.Value=rm.Voc;
pTp.Value=rm.Temp; pHm.Value=rm.Humi; pLed.Value=rm.LedDim; pLv.Value=rm.LoadScore;
cmd.ExecuteNonQuery();
}
}
tx.Commit();
}
}
// 지정 현장·날짜(로컬 00:00~24:00) 하루치를 JSON 배열로 반환.
public string LoadDayJson(string site, DateTime day)
{
long start = day.Date.Ticks, end = day.Date.AddDays(1).Ticks;
lock (_lock)
{
// sample 먼저 (id 순)
var ids = new List<long>();
var rows = new List<string>(); // 각 sample 의 글로벌 필드 JSON 조각(rooms 채우기 전)
var idToIdx = new Dictionary<long, int>();
using (var cmd = _conn.CreateCommand())
{
cmd.CommandText =
"SELECT id,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset " +
"FROM sample WHERE site=$s AND time>=$a AND time<$b ORDER BY id ASC;";
cmd.Parameters.AddWithValue("$s", site);
cmd.Parameters.AddWithValue("$a", start);
cmd.Parameters.AddWithValue("$b", end);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
long id = rd.GetInt64(0);
var t = new DateTime(rd.GetInt64(1));
var sb = new StringBuilder();
sb.Append("{\"t\":\"").Append(t.ToString("HH:mm:ss", CultureInfo.InvariantCulture)).Append('"')
.Append(",\"date\":\"").Append(t.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append('"')
.Append(",\"power\":").Append(rd.GetInt32(2))
.Append(",\"run_mode\":").Append(rd.GetInt32(3))
.Append(",\"auto_mode\":").Append(rd.GetInt32(4))
.Append(",\"fan_mode\":").Append(rd.GetInt32(5))
.Append(",\"sleep\":").Append(rd.GetInt32(6))
.Append(",\"cook\":").Append(rd.GetInt32(7))
.Append(",\"recover\":").Append(rd.GetInt32(8))
.Append(",\"hyst_preset\":").Append(rd.GetInt32(9))
.Append(",\"rooms\":[");
idToIdx[id] = rows.Count;
rows.Add(sb.ToString()); // 아직 rooms 미완 — 아래서 이어붙임
ids.Add(id);
}
}
if (ids.Count == 0) return "[]";
// room_sample 을 sample 별로 모음
var roomsById = new Dictionary<long, List<string>>();
using (var cmd = _conn.CreateCommand())
{
cmd.CommandText =
"SELECT sample_id,damper_sa,damper_ra,co2,pm25,pm10,voc,temp,humi,led,level " +
"FROM room_sample WHERE sample_id BETWEEN $min AND $max ORDER BY sample_id, room_idx;";
cmd.Parameters.AddWithValue("$min", ids[0]);
cmd.Parameters.AddWithValue("$max", ids[ids.Count - 1]);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
long sid = rd.GetInt64(0);
if (!idToIdx.ContainsKey(sid)) continue;
if (!roomsById.TryGetValue(sid, out var lst)) { lst = new List<string>(); roomsById[sid] = lst; }
lst.Add("{\"damper_sa\":" + rd.GetInt32(1) + ",\"damper_ra\":" + rd.GetInt32(2) +
",\"co2\":" + rd.GetInt32(3) + ",\"pm25\":" + rd.GetInt32(4) + ",\"pm10\":" + rd.GetInt32(5) +
",\"voc\":" + rd.GetInt32(6) + ",\"temp\":" + rd.GetInt32(7) + ",\"humi\":" + rd.GetInt32(8) +
",\"led\":" + rd.GetInt32(9) + ",\"level\":" + rd.GetInt32(10) + "}");
}
}
var outSb = new StringBuilder("[");
for (int i = 0; i < ids.Count; i++)
{
if (i > 0) outSb.Append(',');
outSb.Append(rows[i]);
if (roomsById.TryGetValue(ids[i], out var lst)) outSb.Append(string.Join(",", lst));
outSb.Append("]}");
}
outSb.Append(']');
return outSb.ToString();
}
}
// 데이터가 있는 날짜 목록(JSON 배열, 오름차순).
public string LoadDatesJson(string site)
{
lock (_lock)
{
var list = new List<string>();
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT DISTINCT time/864000000000 FROM sample WHERE site=$s ORDER BY 1;";
cmd.Parameters.AddWithValue("$s", site);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
var d = new DateTime(rd.GetInt64(0) * TimeSpan.TicksPerDay);
list.Add("\"" + d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "\"");
}
return "[" + string.Join(",", list) + "]";
}
}
public void Dispose() => _conn.Dispose();
}
}