Files
HECO2/TestProgram/WebDashBoard/ErvCollector/Http/ControlServer.cs
T
jeon 5a96a696b1 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>
2026-06-15 21:44:23 +09:00

257 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>? Log;
public ControlServer(string prefix, string token, IEnumerable<string> 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();
}
}
}