using System; using System.Collections.Generic; using Microsoft.Data.Sqlite; using ErvDashboard.Model; namespace ErvDashboard.Storage { // 로그 시계열 실시간 저장 (SQLite 단일파일, 무제한 누적). // - sample : 5초 1행 (시간·전원·운전/풍량·시나리오) // - room_sample : 실별 댐퍼/센서 (sample 당 N행) // 그래프는 LoadRecent() 로 최근 구간을 읽어 표시, 이후 실시간 append. public sealed class LogDb : IDisposable { readonly SqliteConnection _conn; public LogDb(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, time INTEGER NOT NULL, -- DateTime.Ticks power INTEGER, runmode INTEGER, automode INTEGER, fanmode INTEGER, sleep INTEGER, cook INTEGER, recover INTEGER, hystpreset INTEGER);"); // 구버전 DB 마이그레이션 — 이미 있으면 무시 try { Exec("ALTER TABLE sample ADD COLUMN automode INTEGER DEFAULT 0;"); } catch { } try { Exec("ALTER TABLE sample ADD COLUMN hystpreset INTEGER DEFAULT 0;"); } catch { } 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(); } // 1샘플 실시간 저장 (sample + room_sample 트랜잭션 1회). public void Insert(LogSample s) { using var tx = _conn.BeginTransaction(); long id; using (var cmd = _conn.CreateCommand()) { cmd.Transaction = tx; cmd.CommandText = "INSERT INTO sample(time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset) " + "VALUES($t,$p,$rm,$am,$fm,$sl,$ck,$rc,$hp); SELECT last_insert_rowid();"; cmd.Parameters.AddWithValue("$t", s.Time.Ticks); cmd.Parameters.AddWithValue("$p", s.Power ? 1 : 0); cmd.Parameters.AddWithValue("$rm", (int)s.RunMode); cmd.Parameters.AddWithValue("$am", (int)s.AutoMode); cmd.Parameters.AddWithValue("$hp", (int)s.HystPreset); cmd.Parameters.AddWithValue("$fm", (int)s.FanMode); cmd.Parameters.AddWithValue("$sl", s.SmartSleep ? 1 : 0); cmd.Parameters.AddWithValue("$ck", s.ComfortCook ? 1 : 0); cmd.Parameters.AddWithValue("$rc", s.ReliefRecover ? 1 : 0); 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($sid,$ri,$dsa,$dra,$co2,$pm25,$pm10,$voc,$tp,$hm,$led,$lv);"; var pSid = cmd.Parameters.Add("$sid", 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); pSid.Value = id; for (int i = 0; i < s.Rooms.Length; i++) { var rm = s.Rooms[i]; pRi.Value = i; pDsa.Value = rm.DamperSa ? 1 : 0; pDra.Value = rm.DamperRa ? 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.Led; pLv.Value = rm.Level; cmd.ExecuteNonQuery(); } } tx.Commit(); } // 최근 max개 샘플을 시간 오름차순으로 로드 (그래프 표시용). public List LoadRecent(int max) { using var cmd = _conn.CreateCommand(); cmd.CommandText = "SELECT id,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset FROM " + "(SELECT * FROM sample ORDER BY id DESC LIMIT $n) ORDER BY id ASC;"; cmd.Parameters.AddWithValue("$n", max); return Materialize(cmd); } // 지정 날짜(그날 00:00 ~ 다음날 00:00, 로컬)의 샘플을 시간 오름차순으로 로드. public List LoadByDate(DateTime day) { using var cmd = _conn.CreateCommand(); cmd.CommandText = "SELECT id,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset " + "FROM sample WHERE time >= $s AND time < $e ORDER BY id ASC;"; cmd.Parameters.AddWithValue("$s", day.Date.Ticks); cmd.Parameters.AddWithValue("$e", day.Date.AddDays(1).Ticks); return Materialize(cmd); } // 데이터가 존재하는 날짜 목록(오름차순) — 상단 날짜 선택 UI용. public List LoadDates() { var list = new List(); using var cmd = _conn.CreateCommand(); // Ticks/일(864e9)로 버킷팅 → 날짜(자정) 환산 cmd.CommandText = "SELECT DISTINCT time/864000000000 FROM sample ORDER BY 1;"; using var rd = cmd.ExecuteReader(); while (rd.Read()) list.Add(new DateTime(rd.GetInt64(0) * TimeSpan.TicksPerDay)); return list; } // sample 행을 읽고 room_sample 을 붙여 LogSample 리스트로 구성 (시간 오름차순). List Materialize(SqliteCommand sampleCmd) { var byId = new Dictionary(); var order = new List(); using (var rd = sampleCmd.ExecuteReader()) { while (rd.Read()) { long id = rd.GetInt64(0); byId[id] = new LogSample { Time = new DateTime(rd.GetInt64(1)), Power = rd.GetInt32(2) != 0, RunMode = (byte)rd.GetInt32(3), AutoMode = (byte)(rd.IsDBNull(4) ? 0 : rd.GetInt32(4)), FanMode = (byte)rd.GetInt32(5), SmartSleep = rd.GetInt32(6) != 0, ComfortCook = rd.GetInt32(7) != 0, ReliefRecover = rd.GetInt32(8) != 0, HystPreset = (byte)(rd.IsDBNull(9) ? 0 : rd.GetInt32(9)), }; order.Add(id); } } if (order.Count == 0) return new List(); AttachRooms(byId, order); var result = new List(order.Count); foreach (var id in order) result.Add(byId[id]); return result; } // order(오름차순 id) 범위의 room_sample 을 읽어 각 LogSample.Rooms 채움. void AttachRooms(Dictionary byId, List order) { var rooms = new Dictionary>(); using var cmd = _conn.CreateCommand(); cmd.CommandText = "SELECT sample_id,room_idx,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", order[0]); cmd.Parameters.AddWithValue("$max", order[order.Count - 1]); using var rd = cmd.ExecuteReader(); while (rd.Read()) { long sid = rd.GetInt64(0); if (!byId.ContainsKey(sid)) continue; if (!rooms.TryGetValue(sid, out var lst)) { lst = new List(); rooms[sid] = lst; } lst.Add(new RoomSample { DamperSa = rd.GetInt32(2) != 0, DamperRa = rd.GetInt32(3) != 0, Co2 = rd.GetInt32(4), Pm25 = rd.GetInt32(5), Pm10 = rd.GetInt32(6), Voc = rd.GetInt32(7), Temp = rd.GetInt32(8), Humi = rd.GetInt32(9), Led = rd.GetInt32(10), Level = rd.GetInt32(11), }); } foreach (var id in order) if (rooms.TryGetValue(id, out var lst)) byId[id].Rooms = lst.ToArray(); } public void Dispose() => _conn.Dispose(); } }