a502322188
손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋. .claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
257 lines
12 KiB
C#
257 lines
12 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|