using System.Net; using System.Text; using System.Text.Json; using ErvCollector.Storage; using ErvProtocol; namespace ErvCollector.Http { // 대시보드 서빙 + 모니터링/제어 HTTP API // GET / → wwwroot/index.html // GET /api/latest → 현장별 최신 상태 JSON // POST /api/control → 제어 (헤더 X-Auth-Token 필요, 토큰 설정 시) public sealed class ControlServer { readonly HttpListener _listener = new(); readonly SiteHub _hub; readonly HistoryDb _history; readonly string _token; readonly string[] _sites; readonly string _webRoot; public event Action? Log; public ControlServer(string prefix, string token, IEnumerable sites, SiteHub hub, HistoryDb history) { _hub = hub; _history = history; _token = token ?? ""; _sites = sites.ToArray(); _webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot"); _listener.Prefixes.Add(prefix.EndsWith('/') ? prefix : prefix + "/"); } public void Start() { _listener.Start(); if (string.IsNullOrEmpty(_token)) Log?.Invoke("경고: 제어 토큰이 비어있음 → 인증 없이 제어 허용(개발용). 운영 시 Http.Token 설정 권장"); _ = AcceptLoop(); } async Task AcceptLoop() { while (_listener.IsListening) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } _ = Task.Run(() => Handle(ctx)); } } void Handle(HttpListenerContext ctx) { try { var req = ctx.Request; string path = req.Url?.AbsolutePath ?? "/"; if (req.HttpMethod == "GET" && (path == "/" || path == "/index.html")) ServeFile(ctx, "index.html", "text/html; charset=utf-8"); else if (req.HttpMethod == "GET" && path == "/api/latest") Json(ctx, 200, BuildLatestJson()); else if (req.HttpMethod == "GET" && path == "/api/history") HandleHistory(ctx); else if (req.HttpMethod == "GET" && path == "/api/dates") HandleDates(ctx); else if (req.HttpMethod == "POST" && path == "/api/control") HandleControl(ctx); else if (req.HttpMethod == "GET" && path.StartsWith("/")) ServeFile(ctx, path.TrimStart('/'), GuessMime(path)); else Text(ctx, 404, "not found"); } catch (Exception ex) { try { Text(ctx, 500, "error: " + ex.Message); } catch { } } } void HandleControl(HttpListenerContext ctx) { // 인증 if (!string.IsNullOrEmpty(_token)) { var h = ctx.Request.Headers["X-Auth-Token"]; if (h != _token) { Json(ctx, 401, "{\"ok\":false,\"error\":\"unauthorized\"}"); return; } } string body; using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) body = r.ReadToEnd(); string site, action; byte[]? frame; try { using var doc = JsonDocument.Parse(body); var root = doc.RootElement; site = root.GetProperty("site").GetString() ?? ""; action = root.GetProperty("action").GetString() ?? ""; int V(string n, int def = 0) => root.TryGetProperty(n, out var e) ? e.GetInt32() : def; frame = action switch { "power" => CtrlFrame.Power(V("value")), "runmode" => CtrlFrame.RunModeCmd(V("value")), "fan" => CtrlFrame.Fan(V("value")), "submode" => CtrlFrame.SubMode(V("type"), V("value")), "hood" => CtrlFrame.Hood(V("value")), "preset" => CtrlFrame.Preset(V("value")), "hyst" => CtrlFrame.HystValue(V("preset"), V("pm25"), V("pm10"), V("voc"), V("co2")), "hystthr" => CtrlFrame.HystThr(V("preset"), V("pollutant"), V("l1"), V("l2"), V("l3"), V("l4")), "damper" => CtrlFrame.Damper(V("room"), V("type"), V("value")), // type 0=급기/1=배기 (미지정 시 0) "led" => CtrlFrame.Led(V("room"), V("value")), "reset" => CtrlFrame.Reset(V("value")), "reserve" => CtrlFrame.Reserve(V("value")), // (꺼짐)예약 0~8시간 "vsp" => CtrlFrame.Vsp(V("group"), V("index"), V("sa"), V("ea")), _ => null, }; } catch (Exception ex) { Json(ctx, 400, $"{{\"ok\":false,\"error\":\"bad request: {ex.Message}\"}}"); return; } if (frame == null) { Json(ctx, 400, "{\"ok\":false,\"error\":\"unknown action\"}"); return; } if (!_sites.Contains(site)) { Json(ctx, 400, "{\"ok\":false,\"error\":\"unknown site\"}"); return; } bool ok = _hub.TrySend(site, frame); Log?.Invoke($"[{site}] 제어 {action} → {(ok ? "송신" : "실패(연결없음)")} : {HexFormat.Bytes(frame, frame.Length)}"); Json(ctx, ok ? 200 : 503, $"{{\"ok\":{(ok ? "true" : "false")}}}"); } void HandleHistory(HttpListenerContext ctx) { var q = ctx.Request.QueryString; string site = q["site"] ?? ""; string dateStr = q["date"] ?? ""; if (!_sites.Contains(site)) { Json(ctx, 400, "{\"error\":\"unknown site\"}"); return; } if (!DateTime.TryParse(dateStr, out var day)) day = DateTime.Now.Date; try { Json(ctx, 200, _history.LoadDayJson(site, day)); } catch (Exception ex) { Json(ctx, 500, $"{{\"error\":\"{ex.Message}\"}}"); } } void HandleDates(HttpListenerContext ctx) { string site = ctx.Request.QueryString["site"] ?? ""; if (!_sites.Contains(site)) { Json(ctx, 400, "{\"error\":\"unknown site\"}"); return; } try { Json(ctx, 200, _history.LoadDatesJson(site)); } catch (Exception ex) { Json(ctx, 500, $"{{\"error\":\"{ex.Message}\"}}"); } } string BuildLatestJson() { var sb = new StringBuilder("{"); for (int i = 0; i < _sites.Length; i++) { var site = _sites[i]; if (i > 0) sb.Append(','); sb.Append('"').Append(site).Append("\":"); AppendSite(sb, site); } sb.Append('}'); return sb.ToString(); } void AppendSite(StringBuilder sb, string site) { var s = _hub.GetStatus(site); bool online = _hub.IsOnline(site); if (s == null) { sb.Append($"{{\"online\":{(online ? "true" : "false")},\"g\":null,\"rooms\":[]}}"); return; } sb.Append("{\"online\":").Append(online ? "true" : "false").Append(",\"g\":{") .Append("\"power\":").Append(s.Power).Append(",\"run_mode\":").Append(s.RunMode) .Append(",\"auto_state\":").Append(s.AutoState).Append(",\"fan_mode\":").Append(s.FanMode) .Append(",\"sub_mode\":").Append(s.SubMode).Append(",\"hood\":").Append(s.Hood) .Append(",\"hyst_preset\":").Append(s.HystPreset).Append(",\"hyst_pm25\":").Append(s.HystPm25) .Append(",\"hyst_pm10\":").Append(s.HystPm10).Append(",\"hyst_voc\":").Append(s.HystVoc) .Append(",\"hyst_co2\":").Append(s.HystCo2).Append(",\"error_code\":").Append(s.ErrorCode) .Append(",\"reset\":").Append(s.Reset) .Append(",\"reserve_remain\":").Append(s.ReserveRemainSec).Append("},\"rooms\":["); for (int r = 0; r < 4; r++) { var rm = s.Rooms[r]; if (r > 0) sb.Append(','); sb.Append("{\"damper_sa\":").Append(rm.DamperSa ? 1 : 0).Append(",\"damper_ea\":").Append(rm.DamperEa ? 1 : 0).Append(",\"pm25\":").Append(rm.Pm25) .Append(",\"pm10\":").Append(rm.Pm10).Append(",\"voc\":").Append(rm.Voc) .Append(",\"co2\":").Append(rm.Co2).Append(",\"air_quality\":").Append(rm.AirQuality) .Append(",\"led_dim\":").Append(rm.LedDim).Append(",\"load_score\":").Append(rm.LoadScore) .Append(",\"final_volume\":").Append(rm.FinalVolume) .Append(",\"temp\":").Append(rm.Temp).Append(",\"humi\":").Append(rm.Humi).Append('}'); } sb.Append("],\"vsp\":["); for (int i = 0; i < 9; i++) { var v = s.Vsp[i]; if (i > 0) sb.Append(','); sb.Append("{\"sa\":").Append(v.Sa).Append(",\"ea\":").Append(v.Ea).Append('}'); } sb.Append("],\"hyst\":["); for (int i = 0; i < 3; i++) { var h = s.HystTable[i]; if (i > 0) sb.Append(','); sb.Append("{\"pm25\":").Append(h.Pm25).Append(",\"pm10\":").Append(h.Pm10) .Append(",\"voc\":").Append(h.Voc).Append(",\"co2\":").Append(h.Co2).Append('}'); } // 모드별 오염단계 임계표 (프리셋 ECO/NORMAL/TURBO × 오염원 × L1~L4) sb.Append("],\"thr\":["); for (int i = 0; i < 3; i++) { var th = s.ThrTable[i]; if (i > 0) sb.Append(','); sb.Append("{\"co2\":[").Append(string.Join(",", th.Co2)) .Append("],\"pm25\":[").Append(string.Join(",", th.Pm25)) .Append("],\"pm10\":[").Append(string.Join(",", th.Pm10)) .Append("],\"voc\":[").Append(string.Join(",", th.Voc)).Append("]}"); } sb.Append("]}"); } void ServeFile(HttpListenerContext ctx, string rel, string mime) { var full = Path.GetFullPath(Path.Combine(_webRoot, rel)); if (!full.StartsWith(_webRoot) || !File.Exists(full)) { Text(ctx, 404, "not found"); return; } var bytes = File.ReadAllBytes(full); ctx.Response.ContentType = mime; ctx.Response.StatusCode = 200; ctx.Response.OutputStream.Write(bytes); ctx.Response.Close(); } static string GuessMime(string p) => p.EndsWith(".html") ? "text/html; charset=utf-8" : p.EndsWith(".js") ? "application/javascript" : p.EndsWith(".css") ? "text/css" : "application/octet-stream"; static void Json(HttpListenerContext ctx, int code, string json) => Write(ctx, code, json, "application/json"); static void Text(HttpListenerContext ctx, int code, string txt) => Write(ctx, code, txt, "text/plain; charset=utf-8"); static void Write(HttpListenerContext ctx, int code, string body, string mime) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = code; ctx.Response.ContentType = mime; ctx.Response.OutputStream.Write(bytes); ctx.Response.Close(); } } static class HexFormat { public static string Bytes(byte[] d, int n) { var sb = new StringBuilder(n * 3); for (int i = 0; i < n; i++) { if (i > 0) sb.Append(' '); sb.Append(d[i].ToString("X2")); } return sb.ToString(); } } }