chore: HERV 통합 저장소 재초기화 커밋
손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋. .claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ErvCollector</RootNamespace>
|
||||
<AssemblyName>ErvCollector</AssemblyName>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 그래프 이력(로그) 저장 — InfluxDB 없이 자체 SQLite -->
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 공용 프로토콜 라이브러리 (단일 진실원본) -->
|
||||
<ProjectReference Include="..\..\ErvProtocol\ErvProtocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,256 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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<Task>();
|
||||
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<SiteConfig> Sites { get; set; } = new();
|
||||
|
||||
public static Config Load(string path)
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var opt = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
return JsonSerializer.Deserialize<Config>(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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
# ErvCollector — ERV 24시간 수집/저장 + 원격 모니터링·제어 서버 (미니PC)
|
||||
|
||||
3개 현장의 EW11(TCP Client)이 보내는 ERV `0xAA STATUS` 프레임을 수신·파싱하여
|
||||
**InfluxDB** 에 적재(장기 보관)하고, **내장 웹 대시보드 + HTTP API** 로 실시간 모니터링과 **원격 제어**를 제공한다.
|
||||
|
||||
```
|
||||
[현장1 ERV]─RS485─[EW11]─WiFi/인터넷─┐ TCP 6001
|
||||
[현장2 ERV]─RS485─[EW11]─WiFi/인터넷─┤ TCP 6002 → [미니PC] ErvCollector ┬ InfluxDB(보관) → Grafana(장기분석)
|
||||
[현장3 ERV]─RS485─[EW11]─WiFi/인터넷─┘ TCP 6003 └ HTTP 8080 (웹 대시보드 + 제어 API)
|
||||
```
|
||||
|
||||
**원격 제어 원리**: EW11 은 양방향 투명 브리지이므로, 현장 EW11 이 서버로 열어둔 **동일 TCP 소켓**으로
|
||||
서버가 `CTRL_*` 프레임을 역방향 전송하면 EW11 → RS-485 → ERV 로 전달된다(추가 회선 불필요).
|
||||
→ ERV 펌웨어(UART1)가 `CTRL_*` 수신 처리(PC_ERV_Protocol 2.1)를 구현해야 실제 동작한다.
|
||||
|
||||
- 프레임 규격: `../PC_ERV_Protocol.md`
|
||||
- 현장 구분: **포트 분리**(6001/6002/6003 = site01/02/03), 펌웨어/프로토콜 변경 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 1. 저장 정책 (결정 사항)
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 저장 해상도 | **10초 주기 + 이산 상태 변화 시 즉시** |
|
||||
| 보관 기간 | **1년** (InfluxDB 버킷 retention) |
|
||||
| 스택 | **InfluxDB OSS 2.x + Grafana** |
|
||||
|
||||
- 연속값(PM2.5/PM10/VOC/CO2 등)은 10초 주기 샘플.
|
||||
- 이산값(전원/운전모드/풍량/부가모드/후드/프리셋/에러코드 + 각실 댐퍼·공기질등급·LED)이 바뀌면 **즉시 1건** 추가 기록 → 변화 시점 누락 없음.
|
||||
|
||||
---
|
||||
|
||||
## 1.5 원격 모니터링·제어 (HTTP API)
|
||||
|
||||
서버는 `Http.Prefix`(기본 8080)에서 웹 대시보드와 API 를 제공한다.
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|---|---|---|
|
||||
| GET | `/` | 내장 웹 대시보드(`wwwroot/index.html`) — 3현장 모니터링 + 제어 |
|
||||
| GET | `/api/latest` | 현장별 최신 상태 JSON(온라인 여부 + 글로벌 + 각실) |
|
||||
| POST | `/api/control` | 제어. 헤더 `X-Auth-Token`(토큰 설정 시), 본문 JSON |
|
||||
|
||||
**제어 본문 예시**
|
||||
```json
|
||||
{ "site":"site01", "action":"power", "value":1 }
|
||||
{ "site":"site01", "action":"runmode", "value":2 } // 1환기 2자동 3공청 4바이패스
|
||||
{ "site":"site01", "action":"fan", "value":3 } // 0~4 (자동모드 무시)
|
||||
{ "site":"site01", "action":"submode", "type":2, "value":1 }// type 1수면 2조리 3회복
|
||||
{ "site":"site01", "action":"hood", "value":1 }
|
||||
{ "site":"site01", "action":"preset", "value":2 } // 0 ECO 1 NORMAL 2 TURBO
|
||||
{ "site":"site01", "action":"hyst", "pm25":30,"pm10":50,"voc":300,"co2":700 }
|
||||
{ "site":"site01", "action":"damper", "room":2, "value":1 }// room 1거실 2~4침실
|
||||
{ "site":"site01", "action":"led", "room":1, "value":7 }// 0~9
|
||||
```
|
||||
→ 서버가 해당 현장 EW11 소켓으로 `CTRL_*` 프레임 송신. 응답 `{"ok":true}` (연결 없으면 503).
|
||||
|
||||
> **보안**: 제어는 민감하므로 `Http.Token` 을 반드시 설정(대시보드 토큰칸/`X-Auth-Token`).
|
||||
> 운영 시 HTTP 포트는 사내망/VPN 으로 제한하고 외부 직노출 금지 권장.
|
||||
|
||||
> **바인딩**: `Http.Prefix` — Linux 미니PC 는 `http://*:8080/`(전체 인터페이스),
|
||||
> Windows 비관리자 테스트는 `http://localhost:8080/` 만 가능(`+`/`*` 는 관리자 또는 `netsh http add urlacl` 필요).
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 스키마 (InfluxDB measurement)
|
||||
|
||||
**erv_global** (tag: `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** (tag: `site`, `room`=1~4)
|
||||
`damper, pm25, pm10, voc, co2, air_quality, led_dim, load_score, final_volume` (모두 정수)
|
||||
|
||||
> 코드값 의미(run_mode 1환기/2자동/…, air_quality 1매우나쁨~4좋음 등)는 Grafana **Value mappings** 로 라벨 표시.
|
||||
|
||||
---
|
||||
|
||||
## 3. 미니PC 설치 (Ubuntu 기준)
|
||||
|
||||
### 3.1 .NET 런타임
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y dotnet-runtime-10.0 # 또는 dotnet-sdk-10.0
|
||||
```
|
||||
|
||||
### 3.2 InfluxDB OSS 2.x
|
||||
```bash
|
||||
# 설치 (공식 저장소)
|
||||
curl -s https://repos.influxdata.com/influxdata-archive_compat.key | sudo gpg --dearmor -o /usr/share/keyrings/influxdata.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/influxdata.gpg] https://repos.influxdata.com/debian stable main" | sudo tee /etc/apt/sources.list.d/influxdata.list
|
||||
sudo apt-get update && sudo apt-get install -y influxdb2
|
||||
sudo systemctl enable --now influxdb
|
||||
|
||||
# 초기 설정 (org=herv, bucket=erv, 보관 1년=8760h)
|
||||
influx setup --org herv --bucket erv --retention 8760h --username admin --password '<PW>' --force
|
||||
# 쓰기용 토큰 확인
|
||||
influx auth list
|
||||
```
|
||||
→ 출력된 토큰을 `appsettings.json` 의 `Influx.Token` 에 기입.
|
||||
|
||||
### 3.3 Collector 빌드/실행
|
||||
```bash
|
||||
cd ErvCollector
|
||||
dotnet publish -c Release -o /opt/erv-collector
|
||||
# appsettings.json 의 Influx.Token / Sites 포트 확인 후
|
||||
/opt/erv-collector/ErvCollector
|
||||
```
|
||||
|
||||
### 3.4 systemd 등록 (24시간 자동 실행/재시작)
|
||||
`/etc/systemd/system/erv-collector.service`
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ERV Collector
|
||||
After=network.target influxdb.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/erv-collector
|
||||
ExecStart=/opt/erv-collector/ErvCollector
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
```bash
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now erv-collector
|
||||
journalctl -u erv-collector -f # 로그 확인
|
||||
```
|
||||
|
||||
### 3.5 Grafana
|
||||
```bash
|
||||
sudo apt-get install -y grafana && sudo systemctl enable --now grafana-server
|
||||
# 브라우저 http://<미니PC>:3000 (admin/admin)
|
||||
# Data source: InfluxDB → Query language Flux → URL http://127.0.0.1:8086 → org=herv, token, default bucket=erv
|
||||
```
|
||||
대시보드: site별 변수(`site`)로 현장 전환, room별 패널로 PM/CO2/VOC 추이 + 운전모드/에러 상태 표시.
|
||||
|
||||
---
|
||||
|
||||
## 4. EW11 설정 (현장 3대, IOTService 또는 웹)
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| Serial | 115200 / 8 / None / 1 |
|
||||
| Protocol | **TCP Client** |
|
||||
| Server Addr | 회사 서버 **공인 IP**(또는 DDNS 도메인) |
|
||||
| Server Port | 현장1→6001, 현장2→6002, 현장3→6003 |
|
||||
| Security | **AES**(16자 키, 서버와 동일) ※ EW11 TLS는 인증서 미검증이라 AES 병행 권장 |
|
||||
| Keep Alive / Reconnect | 활성 |
|
||||
| Buffer Size | ≥ 256 (STATUS 78B 여유) |
|
||||
|
||||
> 회사측: 라우터에서 6001~6003 → 미니PC로 **포트포워딩**. 가능하면 inbound 를 3현장 공인 IP로 제한.
|
||||
|
||||
---
|
||||
|
||||
## 5. 용량 메모
|
||||
|
||||
- 10초 주기 × 3현장 ≈ 연 ~950만 레코드(이벤트 추가분 포함). InfluxDB 압축 시 연 수백 MB~2GB 수준 → 미니PC SSD로 충분.
|
||||
- 1년 retention 이면 자동으로 오래된 데이터 만료.
|
||||
|
||||
---
|
||||
|
||||
## 6.5 WSL2 로 임시 구축 (미니PC 구입 전 검증)
|
||||
|
||||
WSL2 는 실제 Linux VM 이라 위 **3장 Ubuntu 설치 절차(.NET / InfluxDB / Grafana)가 그대로** 동작한다.
|
||||
검증/개발용으로 충분하다. 단 아래 2가지를 챙겨야 한다.
|
||||
|
||||
### (1) WSL 안에서 서비스 자동 실행 — systemd 활성화
|
||||
`/etc/wsl.conf` (WSL 내부)
|
||||
```ini
|
||||
[boot]
|
||||
systemd=true
|
||||
```
|
||||
→ Windows PowerShell 에서 `wsl --shutdown` 후 재시작하면 `systemctl` 로 influxdb/grafana/erv-collector 를 서비스로 띄울 수 있다.
|
||||
|
||||
### (2) 외부 EW11 이 WSL 서비스에 접속 — 네트워크 노출
|
||||
WSL2 는 기본적으로 NAT 라 **LAN 의 EW11 이 WSL 내부 포트에 직접 못 붙는다**. 둘 중 하나로 해결:
|
||||
|
||||
**방법 A — Mirrored networking (Windows 11 22H2+, 권장)**
|
||||
`%UserProfile%\.wslconfig` (Windows 측)
|
||||
```ini
|
||||
[wsl2]
|
||||
networkingMode=mirrored
|
||||
```
|
||||
→ `wsl --shutdown` 후 재시작. WSL 이 호스트 IP 를 공유하므로 EW11 은 **Windows PC 의 LAN IP : 6001~6003** 으로 바로 접속.
|
||||
|
||||
**방법 B — portproxy (Windows 10 등)**
|
||||
관리자 PowerShell:
|
||||
```powershell
|
||||
$wsl = (wsl hostname -I).Trim().Split(" ")[0] # WSL 내부 IP (재부팅 시 변동)
|
||||
foreach ($p in 6001,6002,6003) {
|
||||
netsh interface portproxy add v4tov4 listenport=$p listenaddress=0.0.0.0 connectport=$p connectaddress=$wsl
|
||||
}
|
||||
```
|
||||
|
||||
### (3) Windows 방화벽 인바운드 허용
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "ERV Collector" -Direction Inbound -Protocol TCP -LocalPort 6001-6003 -Action Allow
|
||||
New-NetFirewallRule -DisplayName "Grafana" -Direction Inbound -Protocol TCP -LocalPort 3000 -Action Allow
|
||||
```
|
||||
|
||||
> ⚠️ WSL/PC 는 **검증용**으로 권장. 24시간 양산 운영은 절전/재부팅/업데이트로 끊길 수 있어 전용 미니PC 가 안전하다.
|
||||
> 또한 방법 B 의 WSL 내부 IP 는 재부팅마다 바뀌므로 방법 A(mirrored)가 편하다.
|
||||
|
||||
### (4) 순수 로컬 테스트라면
|
||||
EW11 없이 같은 PC 에서 데모 프레임만 흘려볼 거면 네트워크 설정 불필요 — `127.0.0.1:6001` 로 바로 송신해 검증 가능(앞선 스모크 테스트 방식).
|
||||
|
||||
---
|
||||
|
||||
## 7. 구성 파일 (`appsettings.json`)
|
||||
```json
|
||||
{
|
||||
"Influx": { "Url": "http://127.0.0.1:8086", "Org": "herv", "Bucket": "erv", "Token": "<TOKEN>" },
|
||||
"SampleIntervalSeconds": 10,
|
||||
"Sites": [
|
||||
{ "Port": 6001, "Name": "site01" },
|
||||
{ "Port": 6002, "Name": "site02" },
|
||||
{ "Port": 6003, "Name": "site03" }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"Influx": {
|
||||
"Url": "http://127.0.0.1:8086",
|
||||
"Org": "herv",
|
||||
"Bucket": "erv",
|
||||
"Token": "PUT-YOUR-INFLUXDB-TOKEN-HERE"
|
||||
},
|
||||
"Http": {
|
||||
"Prefix": "http://localhost:8080/",
|
||||
"Token": ""
|
||||
},
|
||||
"SampleIntervalSeconds": 10,
|
||||
"Sites": [
|
||||
{
|
||||
"Port": 6001,
|
||||
"Name": "site01"
|
||||
},
|
||||
{
|
||||
"Port": 6002,
|
||||
"Name": "site02"
|
||||
},
|
||||
{
|
||||
"Port": 6003,
|
||||
"Name": "site03"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HuevenEco DL 각실제어 모니터링·제어</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#F4F6FB; --card:#FFFFFF; --border:#E3E7EF; --text:#1F2733; --text2:#8A93A6;
|
||||
--accent:#3B82F6; --accent-soft:#E7F0FF; --good:#22C55E; --warn:#F59E0B; --bad:#EF4444; --track:#EDEFF4;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Malgun Gothic",sans-serif;font-size:14px;}
|
||||
.wrap{max-width:1360px;margin:0 auto;padding:16px;}
|
||||
.topbar{display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:12px;}
|
||||
.title{font-size:20px;font-weight:700;}
|
||||
.subtitle{font-size:12px;color:var(--text2);margin-top:2px;}
|
||||
.meta{font-size:12px;color:var(--text2);text-align:right;line-height:1.5;}
|
||||
.modechip{display:inline-block;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700;color:#fff;margin-left:8px;}
|
||||
.tabs{display:flex;gap:8px;margin-bottom:12px;align-items:center;}
|
||||
.tab{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:9px 18px;cursor:pointer;font-weight:600;color:var(--text);}
|
||||
.tab:hover{background:var(--accent-soft);}
|
||||
.tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||
.tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;background:var(--good);vertical-align:middle;}
|
||||
.tab .dot.off{background:var(--bad);}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;}
|
||||
.card-title{font-size:13px;font-weight:600;color:var(--text2);margin-bottom:12px;}
|
||||
.tiles{display:grid;grid-template-columns:repeat(7,1fr);gap:10px;}
|
||||
.tile{background:var(--track);border-radius:10px;padding:12px;text-align:center;}
|
||||
.tile .label{font-size:12px;color:var(--text2);}
|
||||
.tile .value{font-size:20px;font-weight:700;margin-top:4px;}
|
||||
.ctrl-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;}
|
||||
.ctrl-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;}
|
||||
.ctrl-row .lbl{width:84px;color:var(--text2);font-size:13px;}
|
||||
.btn{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:7px 13px;cursor:pointer;font-size:13px;color:var(--text);}
|
||||
.btn:hover{background:var(--accent-soft);}
|
||||
.btn.active{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||
.btn.on{background:var(--good);color:#fff;border-color:var(--good);}
|
||||
.btn.off{background:var(--card);color:var(--text2);}
|
||||
.btn:disabled{opacity:.4;cursor:not-allowed;}
|
||||
.autocard{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;}
|
||||
.autobox{background:var(--track);border-radius:10px;padding:10px;text-align:center;}
|
||||
.autobox .rn{font-size:12px;color:var(--text2);}
|
||||
.autobox .sc{font-size:22px;font-weight:700;}
|
||||
.autobox .vol{font-size:12px;color:var(--text2);margin-top:2px;}
|
||||
.rooms{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;}
|
||||
.room{border:1px solid var(--border);border-radius:12px;padding:14px;}
|
||||
.room-head{display:flex;align-items:center;justify-content:space-between;}
|
||||
.room-name{font-size:16px;font-weight:700;}
|
||||
.aq{display:flex;align-items:center;gap:6px;font-weight:600;font-size:13px;}
|
||||
.aq .led{width:14px;height:14px;border-radius:50%;}
|
||||
.sensors{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;margin-top:12px;}
|
||||
.sensor{display:flex;justify-content:space-between;}
|
||||
.sensor .k{color:var(--text2);}
|
||||
.sensor .v{font-weight:700;}
|
||||
.ledrow{display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-top:10px;}
|
||||
input[type=range]{width:100%;}
|
||||
.vsp-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
|
||||
.vsp-row{display:flex;align-items:center;gap:6px;background:var(--track);border-radius:8px;padding:8px 10px;}
|
||||
.vsp-row .vl{width:60px;font-weight:600;font-size:13px;}
|
||||
.vsp-row .u{color:var(--text2);font-size:12px;}
|
||||
.vsp-row input{width:56px;border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:13px;text-align:right;}
|
||||
.hyst-grid{display:grid;grid-template-columns:90px repeat(4,1fr);gap:6px 10px;align-items:center;max-width:620px;}
|
||||
.hyst-grid .hh{font-size:12px;color:var(--text2);font-weight:600;text-align:center;}
|
||||
.hyst-grid .pl{font-weight:700;font-size:13px;}
|
||||
.hyst-grid input{width:100%;border:1px solid var(--border);border-radius:6px;padding:5px 6px;font-size:13px;text-align:right;}
|
||||
.thr-grid{display:grid;grid-template-columns:70px repeat(4,1fr);gap:5px 8px;align-items:center;margin-top:8px;}
|
||||
.thr-grid .hh{font-size:11px;color:var(--text2);font-weight:600;text-align:center;}
|
||||
.thr-grid .pl{font-weight:700;font-size:12px;}
|
||||
.thr-grid input{width:100%;border:1px solid var(--border);border-radius:6px;padding:4px 5px;font-size:12px;text-align:right;}
|
||||
.modal-bg{position:fixed;inset:0;background:rgba(15,23,42,.4);display:none;align-items:flex-start;justify-content:center;z-index:100;}
|
||||
.modal-bg.show{display:flex;}
|
||||
.modal{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px;margin-top:48px;width:92%;max-width:760px;max-height:86vh;overflow:auto;box-shadow:0 12px 40px rgba(0,0,0,.22);}
|
||||
.modal.modal-wide{max-width:1180px;}
|
||||
.modal-h{display:flex;justify-content:space-between;align-items:center;font-size:15px;font-weight:700;margin-bottom:14px;}
|
||||
.footer{font-size:12px;color:var(--text2);text-align:center;margin-top:8px;}
|
||||
.tokenbox{font-size:12px;border:1px solid var(--border);border-radius:8px;padding:6px 8px;width:130px;}
|
||||
/* 그래프 */
|
||||
.graph-wrap{display:grid;grid-template-columns:200px 1fr;gap:12px;}
|
||||
.graph-side{border:1px solid var(--border);border-radius:8px;padding:8px;max-height:420px;overflow:auto;}
|
||||
.graph-side .grp{font-weight:700;font-size:12px;margin:8px 0 3px;}
|
||||
.graph-side label{display:block;font-size:12px;padding:2px 0;cursor:pointer;}
|
||||
.graph-side label input{margin-right:5px;}
|
||||
.graph-main canvas{width:100%;height:380px;display:block;border:1px solid var(--border);border-radius:8px;}
|
||||
.glegend{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--text2);margin-top:6px;}
|
||||
.glegend span{display:inline-flex;align-items:center;gap:4px;}
|
||||
.glegend i{width:11px;height:3px;border-radius:2px;display:inline-block;}
|
||||
.gtoolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<div class="title">HuevenEco DL 각실제어 모니터링·제어 <span class="modechip" id="modeChip"></span></div>
|
||||
<div class="subtitle">EW11(RS-485↔WiFi) ↔ 미니PC 수집/제어 서버 · PC 대시보드 기능 통합</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<input class="tokenbox" id="tokenBox" placeholder="제어 토큰(선택)">
|
||||
<button class="btn" id="demoBtn" style="display:none">데모 일시정지</button>
|
||||
<div class="meta">만든이 : 전경선<br>만든날 : 2026.06.3</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="tabs"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">현장 개요</div>
|
||||
<div class="tiles" id="tiles"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 제어 ===== -->
|
||||
<div class="card">
|
||||
<div class="card-title">ERV 제어 (원격)</div>
|
||||
<div class="ctrl-grid">
|
||||
<div>
|
||||
<div class="ctrl-row"><span class="lbl">전원</span><button class="btn" id="cPower" onclick="togglePower()">OFF</button>
|
||||
<span class="lbl" style="width:auto;margin-left:8px;">ERV 리셋</span><button class="btn" id="cReset" onclick="toggleReset()">OFF</button></div>
|
||||
<div class="ctrl-row"><span class="lbl">운전모드</span><span id="cModes"></span></div>
|
||||
<div class="ctrl-row"><span class="lbl">풍량</span><span id="cFans"></span>
|
||||
<span class="lbl" style="width:auto;margin-left:8px;">자동 프리셋</span><span id="cPresetsMain"></span></div>
|
||||
<div class="ctrl-row"><span class="lbl">(꺼짐)예약</span>
|
||||
<select id="cReserve" class="btn" onchange="setReserve(this.value)">
|
||||
<option value="0">해제</option><option value="1">1시간</option><option value="2">2시간</option>
|
||||
<option value="3">3시간</option><option value="4">4시간</option><option value="5">5시간</option>
|
||||
<option value="6">6시간</option><option value="7">7시간</option><option value="8">8시간</option>
|
||||
</select>
|
||||
<span id="cReserveTxt" style="color:var(--text2);margin-left:6px;font-size:12px;"></span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="ctrl-row"><span class="lbl">부가모드</span><span id="cSubs"></span></div>
|
||||
<div class="ctrl-row"><span class="lbl">연동후드</span><button class="btn" id="cHood" onclick="toggleHood()">OFF</button>
|
||||
<span id="cHoodConn" style="font-size:12px;margin-left:8px;"></span></div>
|
||||
<div class="ctrl-row"><span class="lbl">스마트수면<br>시간설정</span>
|
||||
<input type="time" id="slStart" class="tokenbox" style="width:110px" value="23:00">~
|
||||
<input type="time" id="slEnd" class="tokenbox" style="width:110px" value="07:00">
|
||||
<label style="font-size:12px;display:flex;align-items:center;gap:4px;"><input type="checkbox" id="slEnable" onchange="renderSleep()">예약</label>
|
||||
<span id="slTxt" style="font-size:12px;color:var(--text2);"></span></div>
|
||||
<div class="ctrl-row"><span class="lbl">설정</span>
|
||||
<button class="btn" onclick="openModal('hyst')">공기질 히스테리시스 ▸</button>
|
||||
<button class="btn" onclick="openModal('vsp')">풍량 VSP ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 자동운전 상태 ===== -->
|
||||
<div class="card">
|
||||
<div class="card-title">자동운전 상태 (표시 전용) <span id="autoSummary" style="font-weight:400;margin-left:8px;"></span></div>
|
||||
<div class="autocard" id="autoCard"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">각실 모니터링 · 제어</div>
|
||||
<div class="rooms" id="rooms"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 로그 그래프 (별도 창) ===== -->
|
||||
<div class="card">
|
||||
<div class="card-title">로그 그래프</div>
|
||||
<button class="btn" onclick="openModal('graph')">📂 로그 그래프 — 날짜별 불러오기 ▸</button>
|
||||
<span style="color:var(--text2);font-size:12px;margin-left:8px;">날짜 선택 → 불러오기 → 좌측 시리즈 선택/해제 → (필요 시) 엑셀 저장</span>
|
||||
</div>
|
||||
|
||||
<div class="footer" id="footNote"></div>
|
||||
|
||||
<!-- ===== 팝업: 공기질 히스테리시스 / 풍량 VSP ===== -->
|
||||
<div class="modal-bg" id="modalBg" onclick="closeModalBg(event)">
|
||||
<div class="modal" id="modalHyst" style="display:none">
|
||||
<div class="modal-h"><span>공기질 히스테리시스 (ECO / NORMAL / TURBO)</span>
|
||||
<button class="btn" onclick="closeModal()">닫기</button></div>
|
||||
<div class="ctrl-row"><span class="lbl">프리셋</span><span id="cPresets"></span></div>
|
||||
<div style="font-weight:700;font-size:13px;margin:6px 0 2px">데드밴드(하강)</div>
|
||||
<div id="hystGrid"></div>
|
||||
<button class="btn" style="margin-top:10px" onclick="applyHyst()">데드밴드 변경</button>
|
||||
<div style="font-weight:700;font-size:13px;margin:16px 0 2px">오염단계 임계 (L1~L4 상한)</div>
|
||||
<div id="thrGrid"></div>
|
||||
<button class="btn" style="margin-top:10px" onclick="applyThr()">임계 변경</button>
|
||||
</div>
|
||||
<div class="modal" id="modalVsp" style="display:none">
|
||||
<div class="modal-h"><span>풍량 VSP 제어 · 상태 (SA 급기 / EA 배기)</span>
|
||||
<button class="btn" onclick="closeModal()">닫기</button></div>
|
||||
<div class="vsp-grid" id="vspGrid"></div>
|
||||
</div>
|
||||
<div class="modal modal-wide" id="modalGraph" style="display:none">
|
||||
<div class="modal-h"><span>로그 그래프 (가로 시간 · 세로 댐퍼/센서/모드)</span>
|
||||
<button class="btn" onclick="closeModal()">닫기</button></div>
|
||||
<div class="gtoolbar">
|
||||
<span style="color:var(--text2)">날짜</span>
|
||||
<input type="date" id="gDate" class="tokenbox" style="width:150px">
|
||||
<button class="btn" onclick="loadGraphClick()">📂 불러오기</button>
|
||||
<button class="btn" onclick="gSelectAll(true)">전체선택</button>
|
||||
<button class="btn" onclick="gSelectAll(false)">전체해제</button>
|
||||
<button class="btn" onclick="exportCsv()">📊 엑셀(CSV) 저장</button>
|
||||
<span id="gCount" style="color:var(--text2);font-size:12px"></span>
|
||||
</div>
|
||||
<div class="graph-wrap">
|
||||
<div class="graph-side" id="gSide"></div>
|
||||
<div class="graph-main">
|
||||
<canvas id="gChart" width="1000" height="380"></canvas>
|
||||
<div class="glegend" id="gLegend"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const MODE = location.protocol.startsWith("http") ? "server" : "demo";
|
||||
|
||||
const SITES = ["site01","site02","site03"];
|
||||
const SITE_LABEL = {site01:"현장1",site02:"현장2",site03:"현장3"};
|
||||
const ROOMS = ["거실","침실1","침실2","침실3"];
|
||||
const ROOM_COLOR = ["#3B82F6","#22C55E","#F59E0B","#8B5CF6"];
|
||||
const RUNMODE = {0:"OFF",1:"환기",2:"자동",3:"공청",4:"바이패스"};
|
||||
const MODE_BTNS = [[1,"환기"],[2,"자동"],[3,"공청"],[4,"바이패스"]];
|
||||
const SUB_BTNS = [[1,"수면",1],[2,"조리",2],[3,"회복",4]];
|
||||
const PRESET_BTNS = [[0,"ECO"],[1,"NORMAL"],[2,"TURBO"]];
|
||||
const AUTOSTATE = {0:"분산",1:"집중"};
|
||||
const PRESET = {0:"ECO",1:"NORMAL",2:"TURBO"};
|
||||
const AQ = {1:{t:"매우나쁨",c:"#EF4444"},2:{t:"나쁨",c:"#F59E0B"},3:{t:"보통",c:"#22C55E"},4:{t:"좋음",c:"#3B82F6"}};
|
||||
const VSP_LABELS = ["환기1","환기2","환기3","환기4","바이패스","공청1","공청2","공청3","공청4"];
|
||||
const VSP_GROUP = [0,0,0,0,1,2,2,2,2];
|
||||
const VSP_INDEX = [1,2,3,4,1,1,2,3,4];
|
||||
const VSP_DEMO = [[56,57],[63,63],[70,70],[86,85],[67,75],[65,0],[72,0],[78,0],[80,0]];
|
||||
const HYST_PRESETS = ["ECO","NORMAL","TURBO"];
|
||||
const HYST_FIELDS = ["pm25","pm10","voc","co2"];
|
||||
const POLL = ["co2","pm25","pm10","voc"]; // 임계 오염원 순서(서버 thr 키)
|
||||
const HYST_DEMO = [{pm25:20,pm10:40,voc:250,co2:600},{pm25:30,pm10:50,voc:300,co2:700},{pm25:40,pm10:70,voc:400,co2:900}];
|
||||
const THR_DEMO = [
|
||||
{co2:[1000,1300,1600,2000],pm25:[20,38,60,86],pm10:[40,86,126,173],voc:[171,195,308,438]},
|
||||
{co2:[800,1100,1400,1700],pm25:[14,29,49,69],pm10:[28,66,102,138],voc:[120,150,250,350]},
|
||||
{co2:[700,1000,1300,1600],pm25:[12,23,38,52],pm10:[24,53,78,104],voc:[103,120,192,263]}];
|
||||
const HIST=60;
|
||||
|
||||
let current="site01", demoOn=true, tick=0;
|
||||
const state={};
|
||||
SITES.forEach((s,si)=>{ state[s]={
|
||||
online:false,
|
||||
g:{power:1,run_mode:2,auto_state:0,fan_mode:2,sub_mode:0,hood:0,hyst_preset:1,error_code:0,reset:0,reserve_remain:0},
|
||||
rooms:Array.from({length:4},()=>({damper_sa:0,damper_ea:0,pm25:0,pm10:0,voc:0,co2:0,air_quality:3,led_dim:0,load_score:0,final_volume:0,temp:0,humi:0})),
|
||||
vsp:VSP_DEMO.map(([sa,ea])=>({sa,ea})),
|
||||
hyst:HYST_DEMO.map(h=>({...h})),
|
||||
thr:THR_DEMO.map(t=>({co2:[...t.co2],pm25:[...t.pm25],pm10:[...t.pm10],voc:[...t.voc]})),
|
||||
seed:si*100 }; });
|
||||
|
||||
// ===== 데모 생성기 =====
|
||||
function genSensors(s,t){
|
||||
const st=state[s]; st.online=true;
|
||||
st.g.auto_state=Math.floor(t/8)%2;
|
||||
for(let r=0;r<4;r++){
|
||||
const seed=t+r*13+st.seed, rm=st.rooms[r];
|
||||
rm.pm25=10+(seed*3+Math.round(8*Math.sin(t/6+r)))%60;
|
||||
rm.pm10=15+(seed*5)%90; rm.voc=100+(seed*7)%400;
|
||||
rm.co2=450+(seed*11)%700+Math.round(60*Math.sin(t/5+r));
|
||||
rm.air_quality=1+(seed%4); rm.load_score=(seed*4)%5; rm.final_volume=seed%5;
|
||||
rm.temp=22+(seed)%6; rm.humi=40+(seed*7)%30;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 제어 전송 =====
|
||||
function applyLocal(site,action,a){
|
||||
const st=state[site],g=st.g;
|
||||
if(action==="power")g.power=a.value;
|
||||
else if(action==="runmode")g.run_mode=a.value;
|
||||
else if(action==="fan")g.fan_mode=a.value;
|
||||
else if(action==="hood")g.hood=a.value?(g.hood|1):(g.hood&~1);
|
||||
else if(action==="preset")g.hyst_preset=a.value;
|
||||
else if(action==="submode"){const bit=a.type===1?1:a.type===2?2:4;g.sub_mode=a.value?(g.sub_mode|bit):(g.sub_mode&~bit);}
|
||||
else if(action==="damper")st.rooms[a.room-1][a.type===1?"damper_ea":"damper_sa"]=a.value;
|
||||
else if(action==="led")st.rooms[a.room-1].led_dim=a.value;
|
||||
else if(action==="reset")g.reset=a.value;
|
||||
else if(action==="reserve")g.reserve_remain=a.value*3600;
|
||||
else if(action==="vsp")st.vsp[a._idx]={sa:a.sa,ea:a.ea};
|
||||
else if(action==="hyst")st.hyst[a.preset]={pm25:a.pm25,pm10:a.pm10,voc:a.voc,co2:a.co2};
|
||||
else if(action==="hystthr")st.thr[a.preset][a._poll]=[a.l1,a.l2,a.l3,a.l4];
|
||||
}
|
||||
async function ctl(action,a={}){
|
||||
applyLocal(current,action,a);
|
||||
renderTiles(); renderControls(); renderAuto(); renderHyst(); renderThr(); renderVsp(); renderRooms();
|
||||
if(MODE==="server"){
|
||||
try{
|
||||
const r=await fetch("/api/control",{method:"POST",
|
||||
headers:{"Content-Type":"application/json","X-Auth-Token":document.getElementById("tokenBox").value||""},
|
||||
body:JSON.stringify(Object.assign({site:current,action},a))});
|
||||
if(!r.ok) flash(`제어 실패 (${r.status})`);
|
||||
}catch(e){ flash("제어 전송 오류: "+e.message); }
|
||||
}
|
||||
}
|
||||
function flash(msg){ const f=document.getElementById("footNote"); f.textContent=msg; f.style.color="var(--bad)"; setTimeout(setFoot,2500); }
|
||||
|
||||
// ===== 제어 핸들러 =====
|
||||
function togglePower(){ ctl("power",{value:state[current].g.power?0:1}); }
|
||||
function toggleHood(){ ctl("hood",{value:(state[current].g.hood&1)?0:1}); }
|
||||
function setMode(m){ ctl("runmode",{value:m}); }
|
||||
function setFan(s){ const g=state[current].g; if(g.run_mode===2)return; if(g.run_mode===4&&s>1)return; ctl("fan",{value:s}); }
|
||||
function toggleSub(type,bit){
|
||||
const g=state[current].g, orig=g.sub_mode, on=(orig&bit)?0:1;
|
||||
if(on){
|
||||
if(type!==1 && (orig&1)) ctl("submode",{type:1,value:0});
|
||||
if(type!==2 && (orig&2)) ctl("submode",{type:2,value:0});
|
||||
if(type!==3 && (orig&4)) ctl("submode",{type:3,value:0});
|
||||
}
|
||||
ctl("submode",{type,value:on});
|
||||
}
|
||||
function setPreset(p){ ctl("preset",{value:p}); }
|
||||
function setDamperSa(room){ if(state[current].g.run_mode===2)return; const cur=state[current].rooms[room-1].damper_sa; ctl("damper",{room,type:0,value:cur?0:1}); }
|
||||
function setDamperEa(room){ if(state[current].g.run_mode===2)return; const cur=state[current].rooms[room-1].damper_ea; ctl("damper",{room,type:1,value:cur?0:1}); }
|
||||
function setLed(room,val){ ctl("led",{room,value:parseInt(val)}); }
|
||||
function setReserve(h){ ctl("reserve",{value:parseInt(h)}); }
|
||||
function toggleReset(){ ctl("reset",{value:state[current].g.reset?0:1}); }
|
||||
function setHyst(pi,field,val){ state[current].hyst[pi][field]=parseInt(val)||0; }
|
||||
function applyHyst(){ const st=state[current];
|
||||
for(let pi=0;pi<3;pi++){ const v=st.hyst[pi]; ctl("hyst",{preset:pi,pm25:v.pm25,pm10:v.pm10,voc:v.voc,co2:v.co2}); } }
|
||||
function setThr(pi,poll,li,val){ state[current].thr[pi][poll][li]=parseInt(val)||0; }
|
||||
function applyThr(){ const st=state[current];
|
||||
for(let pi=0;pi<3;pi++) POLL.forEach((poll,pp)=>{ const v=st.thr[pi][poll];
|
||||
ctl("hystthr",{preset:pi,pollutant:pp,l1:v[0],l2:v[1],l3:v[2],l4:v[3],_poll:poll}); }); }
|
||||
|
||||
// ===== 팝업 =====
|
||||
function openModal(which){
|
||||
document.getElementById("modalHyst").style.display = which==="hyst"?"block":"none";
|
||||
document.getElementById("modalVsp").style.display = which==="vsp"?"block":"none";
|
||||
document.getElementById("modalGraph").style.display = which==="graph"?"block":"none";
|
||||
document.getElementById("modalBg").classList.add("show");
|
||||
renderControls(); renderHyst(); renderThr(); renderVsp();
|
||||
if(which==="graph"){ renderSide(); loadGraph(); } // 열 때 현재 로드날짜로 갱신
|
||||
}
|
||||
function graphOpen(){ return document.getElementById("modalBg").classList.contains("show")
|
||||
&& document.getElementById("modalGraph").style.display==="block"; }
|
||||
function closeModal(){ document.getElementById("modalBg").classList.remove("show"); }
|
||||
function closeModalBg(e){ if(e.target.id==="modalBg") closeModal(); }
|
||||
function setVsp(i,field,val){
|
||||
const v=state[current].vsp[i];
|
||||
const sa=field==="sa"?(parseInt(val)||0):v.sa, ea=field==="ea"?(parseInt(val)||0):v.ea;
|
||||
ctl("vsp",{group:VSP_GROUP[i],index:VSP_INDEX[i],sa,ea,_idx:i});
|
||||
}
|
||||
|
||||
// ===== 스마트수면 시간설정 (브라우저 스케줄, 현재 현장에 적용) =====
|
||||
let _slLast=-1;
|
||||
function hm2min(v){ const [h,m]=(v||"0:0").split(":").map(Number); return (h*60+m)||0; }
|
||||
function renderSleep(){
|
||||
const en=document.getElementById("slEnable").checked;
|
||||
const s=document.getElementById("slStart").value, e=document.getElementById("slEnd").value;
|
||||
document.getElementById("slTxt").textContent = en?`예약 ON (${s}~${e}, 현재 현장)`:"예약 OFF";
|
||||
}
|
||||
function sleepTick(){
|
||||
if(!document.getElementById("slEnable").checked) return;
|
||||
const now=new Date(), cur=now.getHours()*60+now.getMinutes();
|
||||
const s=hm2min(document.getElementById("slStart").value), e=hm2min(document.getElementById("slEnd").value);
|
||||
const inWin = s<e ? (cur>=s&&cur<e) : (cur>=s||cur<e); // 자정 넘김 지원
|
||||
const want = inWin?1:0;
|
||||
if(want!==_slLast){ _slLast=want; toggleSubForce(1, want); }
|
||||
}
|
||||
function toggleSubForce(type,on){ // 스케줄용: 상태와 무관하게 지정값 전송
|
||||
if(on){ const g=state[current].g; if(g.sub_mode&2) ctl("submode",{type:2,value:0}); if(g.sub_mode&4) ctl("submode",{type:3,value:0}); }
|
||||
ctl("submode",{type,value:on});
|
||||
}
|
||||
|
||||
// ===== 렌더 =====
|
||||
function renderTabs(){
|
||||
const el=document.getElementById("tabs"); el.innerHTML="";
|
||||
SITES.forEach(s=>{ const d=document.createElement("div");
|
||||
d.className="tab"+(s===current?" active":"");
|
||||
d.innerHTML=`<span class="dot ${state[s].online?"":"off"}"></span>${SITE_LABEL[s]}`;
|
||||
d.onclick=()=>{current=s;renderAll();if(graphOpen())loadGraph();}; el.appendChild(d); });
|
||||
}
|
||||
function tile(label,value,color){return `<div class="tile"><div class="label">${label}</div><div class="value" ${color?`style="color:${color}"`:""}>${value}</div></div>`;}
|
||||
function renderTiles(){
|
||||
const g=state[current].g;
|
||||
const err=g.error_code===0?"정상":("0x"+g.error_code.toString(16).toUpperCase().padStart(4,"0"));
|
||||
document.getElementById("tiles").innerHTML=
|
||||
tile("전원",g.power?"ON":"OFF",g.power?"var(--good)":"var(--text2)")+
|
||||
tile("운전모드",RUNMODE[g.run_mode])+
|
||||
tile("풍량",g.run_mode===2?`자동(${g.fan_mode})`:g.fan_mode)+
|
||||
tile("자동상태",AUTOSTATE[g.auto_state])+
|
||||
tile("연동후드",(g.hood&1)?"ON":"OFF",(g.hood&1)?"var(--good)":"var(--text2)")+
|
||||
tile("히스테리시스",PRESET[g.hyst_preset])+
|
||||
tile("에러",err,g.error_code?"var(--bad)":"var(--text2)");
|
||||
}
|
||||
function renderControls(){
|
||||
const g=state[current].g;
|
||||
const p=document.getElementById("cPower"); p.textContent=g.power?"ON":"OFF"; p.className="btn "+(g.power?"on":"off");
|
||||
const rs=document.getElementById("cReset"); rs.textContent=g.reset?"ON":"OFF"; rs.className="btn "+(g.reset?"on":"off");
|
||||
const h=document.getElementById("cHood"); h.textContent=(g.hood&1)?"ON":"OFF"; h.className="btn "+((g.hood&1)?"on":"off");
|
||||
const hc=document.getElementById("cHoodConn");
|
||||
if(g.hood&1){ const conn=(g.hood&4); hc.textContent=conn?"후드 연결":"후드 연결 안됨"; hc.style.color=conn?"var(--good)":"var(--bad)"; }
|
||||
else hc.textContent="";
|
||||
document.getElementById("cModes").innerHTML=MODE_BTNS.map(([m,l])=>
|
||||
`<button class="btn ${g.run_mode===m?"active":""}" onclick="setMode(${m})">${l}</button>`).join("");
|
||||
const fanMax = g.run_mode===4?1:4;
|
||||
document.getElementById("cFans").innerHTML=[0,1,2,3,4].map(s=>
|
||||
`<button class="btn ${g.fan_mode===s?"active":""}" ${(g.run_mode===2||s>fanMax)?"disabled":""} onclick="setFan(${s})">${s}</button>`).join("");
|
||||
const presetMain=document.getElementById("cPresetsMain");
|
||||
if(presetMain) presetMain.innerHTML=PRESET_BTNS.map(([v,l])=>
|
||||
`<button class="btn ${g.hyst_preset===v?"active":""}" ${g.run_mode!==2?"disabled":""} onclick="setPreset(${v})">${l}</button>`).join("");
|
||||
const sec=g.reserve_remain||0, rsv=document.getElementById("cReserve"), rt=document.getElementById("cReserveTxt");
|
||||
if(rt) rt.textContent = sec>0 ? `꺼짐까지 ${Math.floor(sec/3600)}:${String(Math.floor(sec%3600/60)).padStart(2,"0")}:${String(sec%60).padStart(2,"0")}` : "예약 없음";
|
||||
if(rsv && sec===0 && rsv.value!=="0") rsv.value="0";
|
||||
document.getElementById("cSubs").innerHTML=SUB_BTNS.map(([t,l,bit])=>
|
||||
`<button class="btn ${(g.sub_mode&bit)?"active":""}" onclick="toggleSub(${t},${bit})">${l}</button>`).join("");
|
||||
const cp=document.getElementById("cPresets");
|
||||
if(cp) cp.innerHTML=PRESET_BTNS.map(([v,l])=>
|
||||
`<button class="btn ${g.hyst_preset===v?"active":""}" onclick="setPreset(${v})">${l}</button>`).join("");
|
||||
}
|
||||
function renderAuto(){
|
||||
const st=state[current], g=st.g;
|
||||
const total=st.rooms.reduce((a,rm)=>a+(rm.load_score||0),0);
|
||||
document.getElementById("autoSummary").innerHTML = g.run_mode===2
|
||||
? `동작: <b>${AUTOSTATE[g.auto_state]}</b> · 합산부하 ${total} · ${g.fan_mode}단`
|
||||
: "(자동모드 아님)";
|
||||
document.getElementById("autoCard").innerHTML = st.rooms.map((rm,i)=>
|
||||
`<div class="autobox"><div class="rn">${ROOMS[i]}</div><div class="sc">${rm.load_score||0}</div><div class="vol">풍량 ${rm.final_volume||0}</div></div>`).join("");
|
||||
}
|
||||
function renderRooms(){
|
||||
const st=state[current], el=document.getElementById("rooms"); el.innerHTML="";
|
||||
const isAuto = st.g.run_mode===2;
|
||||
st.rooms.forEach((rm,i)=>{ const aq=AQ[rm.air_quality]||AQ[3]; const room=i+1;
|
||||
el.insertAdjacentHTML("beforeend",`
|
||||
<div class="room">
|
||||
<div class="room-head"><div class="room-name">${ROOMS[i]}</div>
|
||||
<div class="aq"><span class="led" style="background:${aq.c}"></span>${aq.t}</div></div>
|
||||
<div class="ctrl-row" style="margin-top:10px;">
|
||||
<span class="lbl" style="width:auto">급기댐퍼</span>
|
||||
<button class="btn ${rm.damper_sa?"on":"off"}" ${isAuto?"disabled":""} onclick="setDamperSa(${room})">${rm.damper_sa?"열림":"닫힘"}</button>
|
||||
<span class="lbl" style="width:auto;margin-left:10px;">배기댐퍼</span>
|
||||
<button class="btn ${rm.damper_ea?"on":"off"}" ${isAuto?"disabled":""} onclick="setDamperEa(${room})">${rm.damper_ea?"열림":"닫힘"}</button>
|
||||
</div>
|
||||
<div class="sensors">
|
||||
<div class="sensor"><span class="k">PM2.5</span><span class="v">${rm.pm25}</span></div>
|
||||
<div class="sensor"><span class="k">PM10</span><span class="v">${rm.pm10}</span></div>
|
||||
<div class="sensor"><span class="k">VOC</span><span class="v">${rm.voc}</span></div>
|
||||
<div class="sensor"><span class="k">CO2</span><span class="v">${rm.co2}</span></div>
|
||||
<div class="sensor"><span class="k">TEMP</span><span class="v">${rm.temp}℃</span></div>
|
||||
<div class="sensor"><span class="k">HUMI.</span><span class="v">${rm.humi}%</span></div>
|
||||
</div>
|
||||
<div class="ledrow"><span>LED 디밍 (모든 모드)</span><span>${rm.led_dim} / 9 · 풍량 ${rm.final_volume} · 부하 ${rm.load_score}</span></div>
|
||||
<input type="range" min="0" max="9" value="${rm.led_dim}" oninput="setLed(${room},this.value)">
|
||||
</div>`);
|
||||
});
|
||||
}
|
||||
function renderHyst(){
|
||||
const grid=document.getElementById("hystGrid");
|
||||
if(document.activeElement && grid.contains(document.activeElement)) return;
|
||||
const st=state[current];
|
||||
let h='<div class="hyst-grid"><span class="hh"></span><span class="hh">PM2.5</span><span class="hh">PM10</span><span class="hh">VOC</span><span class="hh">CO2</span>';
|
||||
HYST_PRESETS.forEach((name,pi)=>{ const v=(st.hyst&&st.hyst[pi])||{pm25:0,pm10:0,voc:0,co2:0};
|
||||
h+=`<span class="pl">${name}</span>`+HYST_FIELDS.map(f=>`<input type="number" min="0" value="${v[f]}" onchange="setHyst(${pi},'${f}',this.value)">`).join(""); });
|
||||
grid.innerHTML=h+"</div>";
|
||||
}
|
||||
function renderThr(){
|
||||
const grid=document.getElementById("thrGrid");
|
||||
if(document.activeElement && grid.contains(document.activeElement)) return;
|
||||
const st=state[current];
|
||||
let h='<div class="thr-grid"><span class="hh"></span><span class="hh">L1</span><span class="hh">L2</span><span class="hh">L3</span><span class="hh">L4</span>';
|
||||
HYST_PRESETS.forEach((name,pi)=>{
|
||||
POLL.forEach(poll=>{ const v=(st.thr&&st.thr[pi]&&st.thr[pi][poll])||[0,0,0,0];
|
||||
h+=`<span class="pl">${name}·${poll.toUpperCase()}</span>`+
|
||||
[0,1,2,3].map(li=>`<input type="number" min="0" value="${v[li]}" onchange="setThr(${pi},'${poll}',${li},this.value)">`).join("");
|
||||
});
|
||||
});
|
||||
grid.innerHTML=h+"</div>";
|
||||
}
|
||||
function renderVsp(){
|
||||
const grid=document.getElementById("vspGrid");
|
||||
if(document.activeElement && grid.contains(document.activeElement)) return;
|
||||
const st=state[current];
|
||||
grid.innerHTML = VSP_LABELS.map((lab,i)=>{ const v=(st.vsp&&st.vsp[i])||{sa:0,ea:0};
|
||||
return `<div class="vsp-row"><span class="vl">${lab}</span>`+
|
||||
`<span class="u">SA</span><input type="number" min="0" value="${v.sa}" onchange="setVsp(${i},'sa',this.value)">`+
|
||||
`<span class="u">EA</span><input type="number" min="0" value="${v.ea}" onchange="setVsp(${i},'ea',this.value)"></div>`;
|
||||
}).join("");
|
||||
}
|
||||
function renderAll(){renderTabs();renderTiles();renderControls();renderAuto();renderHyst();renderThr();renderVsp();renderRooms();renderSleep();}
|
||||
|
||||
// ===== 로그 그래프 =====
|
||||
let GDEF=[], GON=[], GDATA=[], _loadedDate=todayStr();
|
||||
function buildDefs(){
|
||||
const c=["#3B82F6","#EF4444","#22C55E","#8B5CF6","#F59E0B","#06B6D4","#EC4899","#64748B","#84CC16"];
|
||||
const defs=[
|
||||
{g:"운전",l:"운전모드",f:s=>s.run_mode},
|
||||
{g:"운전",l:"자동-집중",f:s=>s.auto_mode===1?1:0},
|
||||
{g:"운전",l:"자동-분산",f:s=>s.auto_mode===2?1:0},
|
||||
{g:"운전",l:"프리셋-ECO",f:s=>s.hyst_preset===0?1:0},
|
||||
{g:"운전",l:"프리셋-NORMAL",f:s=>s.hyst_preset===1?1:0},
|
||||
{g:"운전",l:"프리셋-TURBO",f:s=>s.hyst_preset===2?1:0},
|
||||
{g:"운전",l:"풍량",f:s=>s.fan_mode},
|
||||
{g:"운전",l:"전원",f:s=>s.power},
|
||||
{g:"시나리오",l:"스마트수면",f:s=>s.sleep},
|
||||
{g:"시나리오",l:"쾌적조리",f:s=>s.cook},
|
||||
{g:"시나리오",l:"안심회복",f:s=>s.recover},
|
||||
];
|
||||
ROOMS.forEach((nm,r)=>{
|
||||
[["CO2","co2"],["PM2.5","pm25"],["PM10","pm10"],["VOC","voc"],["온도","temp"],["습도","humi"],["LED","led"],["부하","level"],
|
||||
["급기댐퍼","damper_sa"],["배기댐퍼","damper_ra"]].forEach(([lab,key])=>{
|
||||
defs.push({g:nm,l:`${nm} ${lab}`,f:s=>(s.rooms[r]?s.rooms[r][key]:0)});
|
||||
});
|
||||
});
|
||||
defs.forEach((d,i)=>d.color=c[i%c.length]);
|
||||
GDEF=defs; GON=defs.map(_=>true);
|
||||
}
|
||||
function renderSide(){
|
||||
const el=document.getElementById("gSide"); let h=""; let grp=null;
|
||||
GDEF.forEach((d,i)=>{ if(d.g!==grp){grp=d.g; h+=`<div class="grp">${grp}</div>`;}
|
||||
const lab=d.l.startsWith(d.g+" ")?d.l.slice(d.g.length+1):d.l;
|
||||
h+=`<label><input type="checkbox" ${GON[i]?"checked":""} onchange="GON[${i}]=this.checked;drawGraph()">${lab}</label>`; });
|
||||
el.innerHTML=h;
|
||||
}
|
||||
function gSelectAll(v){ GON=GON.map(_=>v); renderSide(); drawGraph(); }
|
||||
// "불러오기" 클릭 : 선택한 날짜를 확정 로드. 오늘이면 이후 실시간 갱신, 과거면 정적 유지.
|
||||
function loadGraphClick(){ _loadedDate = document.getElementById("gDate").value || todayStr(); loadGraph(); }
|
||||
async function loadGraph(){
|
||||
const dt=_loadedDate;
|
||||
if(MODE==="server"){
|
||||
try{ GDATA=await (await fetch(`/api/history?site=${current}&date=${dt}`)).json(); }
|
||||
catch(e){ GDATA=[]; }
|
||||
}else{ GDATA=demoHistory(dt); }
|
||||
const live = dt===todayStr();
|
||||
document.getElementById("gCount").textContent=`${dt} · ${GDATA.length}개 (5초)${live?" · 실시간":" · 과거(정적)"}`;
|
||||
drawGraph();
|
||||
}
|
||||
function drawGraph(){
|
||||
const cv=document.getElementById("gChart"),ctx=cv.getContext("2d"),W=cv.width,H=cv.height,pad=34;
|
||||
ctx.clearRect(0,0,W,H);
|
||||
const on=GDEF.map((d,i)=>GON[i]?i:-1).filter(i=>i>=0);
|
||||
let max=-Infinity,min=Infinity;
|
||||
GDATA.forEach(s=>on.forEach(i=>{const v=GDEF[i].f(s); if(v>max)max=v; if(v<min)min=v;}));
|
||||
if(!isFinite(max)){max=1;min=0;} if(max===min)max=min+1; const range=max-min;
|
||||
ctx.strokeStyle="#EDEFF4";ctx.fillStyle="#8A93A6";ctx.font="10px sans-serif";ctx.lineWidth=1;
|
||||
for(let g=0;g<=4;g++){const y=pad+(H-2*pad)*g/4;ctx.beginPath();ctx.moveTo(pad,y);ctx.lineTo(W-6,y);ctx.stroke();
|
||||
ctx.fillText(Math.round(max-range*g/4),2,y+3);}
|
||||
const n=GDATA.length;
|
||||
if(n>1) for(let g=0;g<=4;g++){const idx=Math.round((n-1)*g/4);const x=pad+(W-pad-6)*(idx/(n-1));
|
||||
ctx.fillText(GDATA[idx].t,x-18,H-8);}
|
||||
on.forEach(i=>{ const d=GDEF[i]; ctx.strokeStyle=d.color;ctx.lineWidth=1.6;ctx.beginPath();
|
||||
GDATA.forEach((s,k)=>{const x=pad+(W-pad-6)*(n>1?k/(n-1):0),y=pad+(H-2*pad)*(1-(d.f(s)-min)/range);k?ctx.lineTo(x,y):ctx.moveTo(x,y);});ctx.stroke(); });
|
||||
document.getElementById("gLegend").innerHTML=on.map(i=>`<span><i style="background:${GDEF[i].color}"></i>${GDEF[i].l}</span>`).join("");
|
||||
}
|
||||
function exportCsv(){
|
||||
if(!GDATA.length){ flash("저장할 데이터가 없습니다."); return; }
|
||||
const head=["날짜","시간","전원","운전모드","자동상태","풍량","스마트수면","쾌적조리","안심회복","프리셋"];
|
||||
ROOMS.forEach(nm=>head.push(`${nm}_급기댐퍼`,`${nm}_배기댐퍼`,`${nm}_CO2`,`${nm}_PM2.5`,`${nm}_PM10`,`${nm}_VOC`,`${nm}_온도`,`${nm}_습도`,`${nm}_LED`,`${nm}_부하`));
|
||||
const mn=["OFF","환기","자동","공청","바이패스"], an=["","집중","분산"], pn=["ECO","NORMAL","TURBO"];
|
||||
const lines=[head.join(",")];
|
||||
GDATA.forEach(s=>{ const row=[s.date,s.t,s.power,mn[s.run_mode]||s.run_mode,an[s.auto_mode]||"",s.fan_mode,s.sleep,s.cook,s.recover,pn[s.hyst_preset]||s.hyst_preset];
|
||||
s.rooms.forEach(rm=>row.push(rm.damper_sa,rm.damper_ra,rm.co2,rm.pm25,rm.pm10,rm.voc,rm.temp,rm.humi,rm.led,rm.level));
|
||||
lines.push(row.join(",")); });
|
||||
const blob=new Blob([""+lines.join("\r\n")],{type:"text/csv;charset=utf-8"});
|
||||
const a=document.createElement("a"); a.href=URL.createObjectURL(blob);
|
||||
a.download=`HERV_${current}_${_loadedDate}.csv`; a.click();
|
||||
}
|
||||
function todayStr(){ const d=new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }
|
||||
function demoHistory(){ const arr=[]; for(let k=0;k<120;k++){ const t=k*5; const hh=String(Math.floor(t/3600)).padStart(2,"0"),mm=String(Math.floor(t%3600/60)).padStart(2,"0"),ss=String(t%60).padStart(2,"0");
|
||||
arr.push({t:`${hh}:${mm}:${ss}`,date:todayStr(),power:1,run_mode:2,auto_mode:(k%16<8?2:1),fan_mode:k%5,sleep:0,cook:0,recover:0,hyst_preset:1,
|
||||
rooms:Array.from({length:4},(_,r)=>({damper_sa:k%2,damper_ra:(k+1)%2,co2:500+(k*7+r*50)%900,pm25:10+(k*3+r*5)%60,pm10:15+(k*5)%90,voc:100+(k*7)%400,temp:23+r,humi:45+r*3,led:9,level:(k+r)%5}))}); }
|
||||
return arr; }
|
||||
|
||||
// ===== 서버 폴링 =====
|
||||
async function poll(){
|
||||
try{
|
||||
const j=await (await fetch("/api/latest")).json();
|
||||
for(const s of SITES){ const d=j[s]; if(!d)continue; state[s].online=d.online;
|
||||
if(d.g){ state[s].g=d.g; state[s].rooms=d.rooms; if(d.vsp)state[s].vsp=d.vsp; if(d.hyst)state[s].hyst=d.hyst; if(d.thr)state[s].thr=d.thr; } }
|
||||
renderAll();
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function setFoot(){
|
||||
const f=document.getElementById("footNote"); f.style.color="var(--text2)";
|
||||
f.textContent = MODE==="server"
|
||||
? "서버 연동 모드: /api/latest 폴링 · 제어는 /api/control · 그래프는 /api/history(SQLite 누적) 로 동작합니다."
|
||||
: "데모 모드(파일 직접 열기): 제어/그래프는 화면에만 반영됩니다. 실제는 미니PC 서버(http) 접속 시 동작.";
|
||||
}
|
||||
|
||||
// ===== 시작 =====
|
||||
buildDefs(); renderSide(); setFoot();
|
||||
document.getElementById("gDate").value=todayStr();
|
||||
document.getElementById("slStart").onchange=renderSleep; document.getElementById("slEnd").onchange=renderSleep;
|
||||
const chip=document.getElementById("modeChip");
|
||||
if(MODE==="server"){
|
||||
chip.textContent="서버연동"; chip.style.background="var(--good)";
|
||||
poll(); setInterval(poll,1000);
|
||||
// 그래프 창이 열려있고 오늘을 보고 있을 때만 실시간 갱신
|
||||
setInterval(()=>{ if(graphOpen() && _loadedDate===todayStr()) loadGraph(); },10000);
|
||||
setInterval(sleepTick,20000);
|
||||
}else{
|
||||
chip.textContent="데모"; chip.style.background="var(--warn)";
|
||||
document.getElementById("demoBtn").style.display="";
|
||||
document.getElementById("demoBtn").onclick=function(){demoOn=!demoOn;this.textContent=demoOn?"데모 일시정지":"데모 시작";};
|
||||
for(let i=0;i<HIST;i++){tick++;SITES.forEach(s=>genSensors(s,tick));}
|
||||
renderAll();
|
||||
setInterval(()=>{ if(demoOn){tick++;SITES.forEach(s=>genSensors(s,tick));renderTiles();renderAuto();renderRooms();} },1000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user