using System.Net; using System.Net.Sockets; using System.Text.Json; using ErvCollector.Http; using ErvCollector.Storage; using ErvProtocol; namespace ErvCollector { // 미니PC 수집/제어 서버: // 3개 현장 EW11(TCP Client) → 포트별(6001/6002/6003) 수신 → 0xAA STATUS 파싱 // → 샘플러(10초 + 변화시) → InfluxDB 적재 // 동시에 HTTP(대시보드 + /api/latest + /api/control) 로 원격 모니터링/제어 제공. // 제어는 현장 EW11 이 열어둔 동일 TCP 소켓으로 CTRL_* 프레임을 역방향 송신. internal static class Program { static Config _cfg = null!; static InfluxLineWriter _influx = null!; static SiteHub _hub = null!; static HistoryDb _history = null!; static async Task Main() { _cfg = Config.Load("appsettings.json"); _influx = new InfluxLineWriter(_cfg.Influx.Url, _cfg.Influx.Org, _cfg.Influx.Bucket, _cfg.Influx.Token); _influx.OnError += m => LogErr(m); _hub = new SiteHub(); _hub.Log += LogErr; // 그래프 이력 — exe 옆 SQLite (현장별 5초 누적) _history = new HistoryDb(Path.Combine(AppContext.BaseDirectory, "erv_history.db")); Log($"ErvCollector 시작. Influx={_cfg.Influx.Url} bucket={_cfg.Influx.Bucket} 샘플주기={_cfg.SampleIntervalSeconds}s"); // HTTP 대시보드/제어 서버 var http = new ControlServer(_cfg.Http.Prefix, _cfg.Http.Token, _cfg.Sites.Select(s => s.Name), _hub, _history); http.Log += Log; try { http.Start(); Log($" HTTP 대시보드/제어 ← {_cfg.Http.Prefix}"); } catch (Exception ex) { LogErr($"HTTP 서버 시작 실패: {ex.Message}"); } var tasks = new List(); foreach (var site in _cfg.Sites) { Log($" 현장 '{site.Name}' ← TCP 포트 {site.Port} 대기"); tasks.Add(ListenSiteAsync(site)); } await Task.WhenAll(tasks); } static async Task ListenSiteAsync(SiteConfig site) { var listener = new TcpListener(IPAddress.Any, site.Port); listener.Start(); while (true) { TcpClient client; try { client = await listener.AcceptTcpClientAsync(); } catch (Exception ex) { LogErr($"[{site.Name}] accept 오류: {ex.Message}"); await Task.Delay(500); continue; } _ = HandleClientAsync(site, client); } } static async Task HandleClientAsync(SiteConfig site, TcpClient client) { var remote = client.Client.RemoteEndPoint?.ToString() ?? "?"; Log($"[{site.Name}] 연결됨 ({remote})"); var parser = new FrameParser(); var sampler = new Sampler(_cfg.SampleIntervalSeconds); var lastHist = DateTime.MinValue; // 그래프 이력 5초 throttle (현장별) parser.OnError += m => LogErr($"[{site.Name}] {m}"); parser.OnFrame += (cmd, payload) => { if (cmd != StatusDecoder.STATUS) return; // 저장 대상은 STATUS만 var rec = StatusDecoder.Decode(payload); if (rec == null) { LogErr($"[{site.Name}] STATUS 길이부족 ({payload.Length})"); return; } _hub.SetStatus(site.Name, rec); // 최신 상태(원격 조회/대시보드용) var now = DateTime.UtcNow; // 그래프 이력 : 5초마다 SQLite 기록 if ((now - lastHist).TotalSeconds >= 5) { lastHist = now; try { _history.Insert(site.Name, rec); } catch (Exception ex) { LogErr($"[{site.Name}] 이력저장: {ex.Message}"); } } if (sampler.ShouldWrite(rec, now, out var reason)) _ = _influx.WriteAsync(site.Name, rec, now); // reason=="skip" 이면 적재 생략 (주기/무변화) if (reason == "change") Log($"[{site.Name}] 상태변화 기록 (mode={rec.RunMode} fan={rec.FanMode} err=0x{rec.ErrorCode:X4})"); }; NetworkStream? stream = null; try { using (client) using (stream = client.GetStream()) { _hub.SetSocket(site.Name, stream); // 제어 송신용 소켓 등록 var buf = new byte[1024]; int n; while ((n = await stream.ReadAsync(buf)) > 0) parser.Feed(buf.AsSpan(0, n)); } } catch (Exception ex) { LogErr($"[{site.Name}] 수신 오류: {ex.Message}"); } finally { if (stream != null) _hub.RemoveSocket(site.Name, stream); Log($"[{site.Name}] 연결 종료 ({remote})"); } } static void Log(string m) => Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {m}"); static void LogErr(string m) => Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] ERR {m}"); } // ---- 설정 ---- sealed class Config { public InfluxConfig Influx { get; set; } = new(); public HttpConfig Http { get; set; } = new(); public int SampleIntervalSeconds { get; set; } = 10; public List Sites { get; set; } = new(); public static Config Load(string path) { var json = File.ReadAllText(path); var opt = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; return JsonSerializer.Deserialize(json, opt) ?? throw new InvalidOperationException("appsettings.json 파싱 실패"); } } sealed class InfluxConfig { public string Url { get; set; } = "http://127.0.0.1:8086"; public string Org { get; set; } = "herv"; public string Bucket { get; set; } = "erv"; public string Token { get; set; } = ""; } sealed class HttpConfig { public string Prefix { get; set; } = "http://+:8080/"; // Linux: http://*:8080/, Windows 비관리자: http://localhost:8080/ public string Token { get; set; } = ""; // 제어 인증 토큰(비우면 인증 없음, 개발용) } sealed class SiteConfig { public int Port { get; set; } public string Name { get; set; } = ""; } }