chore: HERV 통합 저장소 초기 커밋

- 펌웨어(program), C# 대시보드(TestProgram), 시뮬레이터(Simulator),
  프로토콜/문서(Protocol, doc) 전체를 단일 저장소로 통합
- program 폴더의 별도 git 저장소를 제거하고 통합 저장소에 흡수
- 빌드 산출물(program/build, bin/obj, *.o/.elf/.bin/.hex 등) .gitignore 처리
- 사내 Synology NAS Git 원격 연결 예정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:44:23 +09:00
commit 5a96a696b1
265 changed files with 76458 additions and 0 deletions
@@ -0,0 +1,199 @@
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();
}
}
@@ -0,0 +1,114 @@
using System.Net.Http.Headers;
using System.Text;
using ErvProtocol;
namespace ErvCollector.Storage
{
// InfluxDB v2 라인 프로토콜 기록 (외부 라이브러리 없이 HTTP /api/v2/write)
//
// measurement:
// erv_global,site=<site> power,run_mode,auto_state,fan_mode,sub_mode,hood,
// hyst_preset,hyst_pm25,hyst_pm10,hyst_voc,hyst_co2,error_code
// erv_room,site=<site>,room=<1..4> damper,pm25,pm10,voc,co2,air_quality,led_dim,
// load_score,final_volume
public sealed class InfluxLineWriter : IDisposable
{
readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
readonly string _writeUrl;
public event Action<string>? OnError;
public InfluxLineWriter(string url, string org, string bucket, string token)
{
_writeUrl = $"{url.TrimEnd('/')}/api/v2/write" +
$"?org={Uri.EscapeDataString(org)}&bucket={Uri.EscapeDataString(bucket)}&precision=ns";
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", token);
}
public async Task WriteAsync(string site, StatusRecord s, DateTime nowUtc)
{
long ts = ToUnixNanos(nowUtc);
var sb = new StringBuilder(512);
// erv_global
sb.Append("erv_global,site=").Append(site).Append(' ')
.Append("power=").Append(s.Power).Append("i,")
.Append("run_mode=").Append(s.RunMode).Append("i,")
.Append("auto_state=").Append(s.AutoState).Append("i,")
.Append("fan_mode=").Append(s.FanMode).Append("i,")
.Append("sub_mode=").Append(s.SubMode).Append("i,")
.Append("hood=").Append(s.Hood).Append("i,")
.Append("hyst_preset=").Append(s.HystPreset).Append("i,")
.Append("hyst_pm25=").Append(s.HystPm25).Append("i,")
.Append("hyst_pm10=").Append(s.HystPm10).Append("i,")
.Append("hyst_voc=").Append(s.HystVoc).Append("i,")
.Append("hyst_co2=").Append(s.HystCo2).Append("i,")
.Append("error_code=").Append(s.ErrorCode).Append("i,")
.Append("reset=").Append(s.Reset).Append('i')
.Append(' ').Append(ts).Append('\n');
// erv_room (4실)
for (int r = 0; r < 4; r++)
{
var rm = s.Rooms[r];
sb.Append("erv_room,site=").Append(site).Append(",room=").Append(r + 1).Append(' ')
.Append("damper=").Append(rm.Damper).Append("i,")
.Append("pm25=").Append(rm.Pm25).Append("i,")
.Append("pm10=").Append(rm.Pm10).Append("i,")
.Append("voc=").Append(rm.Voc).Append("i,")
.Append("co2=").Append(rm.Co2).Append("i,")
.Append("air_quality=").Append(rm.AirQuality).Append("i,")
.Append("led_dim=").Append(rm.LedDim).Append("i,")
.Append("load_score=").Append(rm.LoadScore).Append("i,")
.Append("final_volume=").Append(rm.FinalVolume).Append('i')
.Append(' ').Append(ts).Append('\n');
}
// erv_vsp (9엔트리: 환기1~4, 바이패스, 공청1~4)
for (int i = 0; i < 9; i++)
{
var v = s.Vsp[i];
sb.Append("erv_vsp,site=").Append(site).Append(",vsp=").Append(VspInfo.Keys[i]).Append(' ')
.Append("sa=").Append(v.Sa).Append("i,")
.Append("ea=").Append(v.Ea).Append('i')
.Append(' ').Append(ts).Append('\n');
}
// erv_hyst (프리셋별 임계값: eco/normal/turbo)
string[] presetKey = { "eco", "normal", "turbo" };
for (int i = 0; i < 3; i++)
{
var h = s.HystTable[i];
sb.Append("erv_hyst,site=").Append(site).Append(",preset=").Append(presetKey[i]).Append(' ')
.Append("pm25=").Append(h.Pm25).Append("i,")
.Append("pm10=").Append(h.Pm10).Append("i,")
.Append("voc=").Append(h.Voc).Append("i,")
.Append("co2=").Append(h.Co2).Append('i')
.Append(' ').Append(ts).Append('\n');
}
try
{
using var content = new StringContent(sb.ToString(), Encoding.UTF8);
using var resp = await _http.PostAsync(_writeUrl, content);
if (!resp.IsSuccessStatusCode)
{
string body = await resp.Content.ReadAsStringAsync();
OnError?.Invoke($"Influx write HTTP {(int)resp.StatusCode}: {body}");
}
}
catch (Exception ex)
{
OnError?.Invoke($"Influx write FAIL: {ex.Message}");
}
}
static long ToUnixNanos(DateTime utc)
{
long ticks = utc.ToUniversalTime().Ticks - DateTime.UnixEpoch.Ticks; // 100ns ticks
return ticks * 100L;
}
public void Dispose() => _http.Dispose();
}
}
@@ -0,0 +1,55 @@
using ErvProtocol;
namespace ErvCollector.Storage
{
// 저장 정책: "N초 주기 + 상태(이산값) 변화 시 즉시"
// - 연속값(센서 PM/VOC/CO2 등)은 주기 샘플
// - 이산값(전원/모드/팬/부가/후드/프리셋/에러 + 각실 댐퍼/공기질등급/LED)이 바뀌면 즉시 기록
public sealed class Sampler
{
readonly TimeSpan _interval;
string? _lastFingerprint;
DateTime _lastWriteUtc = DateTime.MinValue;
public Sampler(int intervalSeconds)
{
_interval = TimeSpan.FromSeconds(intervalSeconds <= 0 ? 10 : intervalSeconds);
}
// 기록해야 하면 true (그리고 내부 상태 갱신)
public bool ShouldWrite(StatusRecord s, DateTime nowUtc, out string reason)
{
string fp = Fingerprint(s);
bool changed = fp != _lastFingerprint;
bool periodic = (nowUtc - _lastWriteUtc) >= _interval;
if (changed || periodic)
{
reason = changed ? (_lastFingerprint == null ? "first" : "change") : "periodic";
_lastFingerprint = fp;
_lastWriteUtc = nowUtc;
return true;
}
reason = "skip";
return false;
}
static string Fingerprint(StatusRecord s)
{
// 이산 상태/제어값만 모아 변화 감지 (연속 센서값 제외)
var sb = new System.Text.StringBuilder(48);
sb.Append(s.Power).Append(s.RunMode).Append('.').Append(s.AutoState).Append('.')
.Append(s.FanMode).Append('.').Append(s.SubMode).Append('.').Append(s.Hood).Append('.')
.Append(s.HystPreset).Append('.').Append(s.ErrorCode).Append('.').Append(s.Reset);
foreach (var r in s.Rooms)
sb.Append('|').Append(r.Damper).Append(r.AirQuality).Append(r.LedDim);
// VSP(설정값) 변경도 즉시 기록
foreach (var v in s.Vsp)
sb.Append('#').Append(v.Sa).Append(',').Append(v.Ea);
// 히스테리시스 프리셋 테이블 변경도 즉시 기록
foreach (var h in s.HystTable)
sb.Append('@').Append(h.Pm25).Append(',').Append(h.Pm10).Append(',').Append(h.Voc).Append(',').Append(h.Co2);
return sb.ToString();
}
}
}
@@ -0,0 +1,55 @@
using System.Collections.Concurrent;
using System.Net.Sockets;
using ErvProtocol;
namespace ErvCollector.Storage
{
// 현장별 활성 소켓 + 최신 상태 보관. 제어 프레임을 해당 현장 EW11 소켓으로 송신.
public sealed class SiteHub
{
readonly ConcurrentDictionary<string, NetworkStream> _sockets = new();
readonly ConcurrentDictionary<string, StatusRecord> _last = new();
readonly ConcurrentDictionary<string, DateTime> _lastSeenUtc = new();
public event Action<string>? Log;
public void SetSocket(string site, NetworkStream s) { _sockets[site] = s; }
public void RemoveSocket(string site, NetworkStream s)
{
// 현재 등록된 것이 이 스트림일 때만 제거(재연결 레이스 방지)
if (_sockets.TryGetValue(site, out var cur) && ReferenceEquals(cur, s))
_sockets.TryRemove(site, out _);
}
public void SetStatus(string site, StatusRecord rec)
{
_last[site] = rec;
_lastSeenUtc[site] = DateTime.UtcNow;
}
public StatusRecord? GetStatus(string site) => _last.TryGetValue(site, out var r) ? r : null;
public DateTime LastSeen(string site) => _lastSeenUtc.TryGetValue(site, out var t) ? t : DateTime.MinValue;
public bool IsOnline(string site) =>
_sockets.ContainsKey(site) && (DateTime.UtcNow - LastSeen(site)) < TimeSpan.FromSeconds(30);
// 제어 프레임 송신. 성공 true.
public bool TrySend(string site, byte[] frame)
{
if (!_sockets.TryGetValue(site, out var stream))
{
Log?.Invoke($"[{site}] 제어 실패: 연결 없음");
return false;
}
try
{
lock (stream) { stream.Write(frame, 0, frame.Length); stream.Flush(); }
return true;
}
catch (Exception ex)
{
Log?.Invoke($"[{site}] 제어 송신 오류: {ex.Message}");
return false;
}
}
}
}