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>
This commit is contained in:
2026-06-15 21:44:23 +09:00
commit 5a96a696b1
265 changed files with 76458 additions and 0 deletions
@@ -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>
@@ -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 = [[20,18],[40,38],[60,58],[80,78],[70,0],[25,0],[45,0],[65,0],[85,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>
@@ -0,0 +1,89 @@
# 통신 방식 검토 : HTTP vs MQTT
- 작성일: 2026-06-03
- 대상: HuevenEco DL 각실제어 원격 모니터링·제어 (EW11 ↔ 미니PC 수집서버 ↔ 웹 대시보드)
- 관련: `ErvCollector/`, `../TestProgram/PC_ERV_Protocol.md`, `../EW11_RS485 TO WIFI/260603_EW11_클라우드전송_검토.md`
---
## 1. 현재 시스템의 통신 계층 (두 가지)
| # | 구간 | 현재 방식 | 비고 |
|---|------|-----------|------|
| ① | ERV ↔ EW11 ↔ 수집서버 | **0xAA 바이너리 프레임 over TCP** | 장치 구간 (HTTP 아님) |
| ② | 브라우저 ↔ 수집서버 | **HTTP** (`/api/latest` 1초 폴링 + `/api/control` POST) | 대시보드 구간 |
> "웹 프로토콜이 HTTP" = ②번. 일반적으로 IoT에서 MQTT가 거론되는 구간은 **①번(장치↔서버)** 이다.
---
## 2. HTTP vs MQTT 핵심 차이
| 항목 | HTTP (현재) | MQTT |
|---|---|---|
| 모델 | 요청-응답 (Pull) | 발행-구독 (Pub/Sub), **브로커** 경유 |
| 연결 | 매 요청 단발 | **지속 TCP 1개 유지** |
| 방향 | 클라이언트가 물어봐야 받음(폴링) | 변화 시 **즉시 푸시** |
| 오버헤드 | 헤더 큼(매 요청 수백 B) | 헤더 2~수 B, **매우 경량** |
| 실시간성 | 폴링 주기만큼 지연 | 낮은 지연 |
| 다수 장치 | 서버가 일일이 응답 | 브로커가 **N:N 중계**, 확장 용이 |
| 신뢰성/오프라인 | 별도 구현 필요 | **QoS 0/1/2, retained, LWT(접속 끊김 자동 통지)** |
| 브라우저 | 기본 지원 | **직접 불가 → MQTT over WebSocket 필요** |
| 추가 인프라 | 없음(수집서버가 이미 제공) | **브로커(예: Mosquitto) 1개 필요** |
---
## 3. 우리 프로젝트에 대입
### (A) EW11 → 서버 (3개 현장, 인터넷) — MQTT의 본 무대
- EW11은 **MQTT 클라이언트 모드 지원** (매뉴얼 검토 완료).
- 토픽 설계(예): 각 EW11이 `erv/site01/status` 에 0xAA STATUS **발행**, 서버 구독.
제어는 서버가 `erv/site01/control`**발행** → EW11 구독.
- 이점:
- 변화 즉시 푸시(저지연)
- **LWT(Last Will & Testament)** 로 접속 끊김 자동 감지 (현재는 30초 추정 방식)
- QoS 로 유실 방지, retained 로 최신값 즉시 수신
- 현장 수가 늘어도(10·50곳) 브로커가 확장 처리
- **중요**: 0xAA 프레임/파서/빌더(공용 `ErvProtocol`)는 **그대로 유지**. "raw TCP" 대신 "MQTT 페이로드"로 운반만 바뀜.
### (B) 브라우저 ↔ 서버 — 굳이 MQTT 아니어도 됨
- 브라우저는 raw MQTT 불가 → **MQTT-over-WebSocket** 필요(복잡도 증가).
- 모니터링 대시보드엔 **현재 1초 HTTP 폴링으로 충분**(트래픽 미미).
- 진짜 실시간 푸시가 필요하면 WebSocket(또는 MQTT-WS)로 전환 가능하나 필수는 아님.
---
## 4. 트레이드오프 / 주의
- MQTT는 **브로커(Mosquitto)** 를 미니PC에 추가로 운영해야 함(구성 요소 증가).
- **AWS IoT Core 직결은 EW11 클라이언트 인증서 미지원으로 곤란** (`260603_EW11_클라우드전송_검토.md` 참조) → MQTT로 간다면 **자체 Mosquitto(user/pass + 서버 TLS)** 가 현실적.
- 현재 raw TCP + HTTP 폴링도 3현장 모니터링·제어엔 충분히 동작 중.
- MQTT 이득이 큰 경우: **현장 수 증가 / 끊김 잦은 망 / 신뢰성·표준 IoT 스택 정렬**.
---
## 5. 권장
| 상황 | 권장 |
|------|------|
| 현장 3곳 고정 + 단순 운영 | **현행 유지** (raw TCP + HTTP 폴링) — 가장 간단 |
| 현장 확장 / 끊김 감지·신뢰성·표준화 중요 | **(A) 장치↔서버만 MQTT(Mosquitto) 전환**, 브라우저는 HTTP 유지 |
---
## 6. MQTT 전환 시 작업 범위 (참고)
전환해도 **변경은 전송 계층에 한정**된다.
- **EW11 설정**: TCP Client → **MQTT** 모드 (브로커 주소, Client ID, user/pass, publish/subscribe 토픽)
- **미니PC**: Mosquitto 브로커 설치(+user/pass, TLS)
- **수집서버(ErvCollector)**: 현장별 TCP 리스너 → **MQTT 구독자**로 교체
- `erv/+/status` 구독 → 기존 `FrameParser`/`StatusDecoder` 그대로 사용
- 제어 시 `erv/{site}/control``CtrlFrame.*` 결과를 **발행**
- 온라인/오프라인은 **LWT** + retained 로 처리(SiteHub 의 30초 추정 대체)
- **변경 없음**: 공용 프로토콜(`ErvProtocol` — 0xAA 프레임/CRC/STATUS/CTRL), 웹 대시보드(HTTP API 유지), WPF 대시보드, 프로토콜 문서(장치 페이로드 규격 동일)
---
> 결론: MQTT는 **장치↔서버 구간(①)** 에 의미가 있고, 0xAA 규격을 유지한 채 전송만 교체하면 된다.
> 3현장 검증을 먼저 마치고, 확장 시점에 (A)만 MQTT로 전환하는 단계적 접근을 권장한다.
@@ -0,0 +1,289 @@
# WSL2 설치 → ErvCollector 실행 가이드 (윈도우 PC)
미니PC 구입 전, **윈도우 PC + WSL2(Ubuntu)** 에서 수집/모니터·제어 서버(`ErvCollector`)와
InfluxDB·Grafana 를 띄워 전체 파이프라인을 검증하기 위한 단계별 가이드.
> 대상: `TestProgram/WebDashBoard/ErvCollector`
> 순서대로 복사·붙여넣기 하면 됩니다. (Ubuntu 24.04 기준)
---
## 0. 사전 요약 (무엇을 깔고 띄우나)
| 구성요소 | 역할 | 포트 |
|---|---|---|
| ErvCollector(.NET) | 현장 EW11 TCP 수신 + 웹 대시보드/제어 API | 6001~6003(수집), 8080(HTTP) |
| InfluxDB OSS 2.x | 시계열 데이터 24시간 저장(1년 보관) | 8086 |
| Grafana | 장기 분석 대시보드(선택) | 3000 |
---
## 1. WSL2 + Ubuntu 설치 (Windows PowerShell, 관리자)
```powershell
wsl --install -d Ubuntu-24.04
# 설치 후 재부팅 → Ubuntu 최초 실행 시 사용자/암호 설정
wsl --status # 버전 2 확인
wsl --update
```
이후 명령은 모두 **Ubuntu(WSL) 터미널** 안에서 실행한다.
---
## 2. WSL systemd 활성화 (서비스 자동실행용)
```bash
sudo tee /etc/wsl.conf >/dev/null <<'EOF'
[boot]
systemd=true
EOF
```
```powershell
# Windows PowerShell 에서 WSL 재시작
wsl --shutdown
```
다시 Ubuntu 터미널을 열고 확인:
```bash
systemctl is-system-running # running 또는 degraded 면 OK
```
---
## 3. .NET 10 SDK 설치
```bash
sudo apt-get update
sudo apt-get install -y dotnet-sdk-10.0 || {
# 패키지가 없으면 공식 스크립트로 설치
curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0
echo 'export PATH="$HOME/.dotnet:$PATH"' >> ~/.bashrc && source ~/.bashrc
}
dotnet --version
```
---
## 4. 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 influxdb2-cli
sudo systemctl enable --now influxdb
# 초기 설정 : org=herv, bucket=erv, 보관 1년(8760h), 토큰을 직접 지정
influx setup \
--org herv --bucket erv --retention 8760h \
--username admin --password 'change-this-pw' \
--token herv-erv-token-0001 \
--force
```
> 위 `--token herv-erv-token-0001` 값을 그대로 `appsettings.json` 의 `Influx.Token` 에 넣으면 된다.
> (토큰을 따로 확인하려면 `influx auth list`)
---
## 5. (선택) Grafana 설치
```bash
sudo apt-get install -y apt-transport-https software-properties-common
curl -s https://apt.grafana.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/grafana.gpg
echo "deb [signed-by=/usr/share/keyrings/grafana.gpg] https://apt.grafana.com stable main" \
| sudo tee /etc/apt/sources.list.d/grafana.list
sudo apt-get update && sudo apt-get install -y grafana
sudo systemctl enable --now grafana-server
```
- 브라우저에서 `http://localhost:3000` (초기 admin / admin)
- Connections → Data sources → **InfluxDB** 추가
- Query language: **Flux**
- URL: `http://localhost:8086`
- Organization: `herv`, Token: `herv-erv-token-0001`, Default bucket: `erv`
- 패널 쿼리 예시(거실 PM2.5 추이):
```flux
from(bucket: "erv")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r._measurement == "erv_room" and r._field == "pm25" and r.site == "site01" and r.room == "1")
```
---
## 6. 프로젝트 가져오기
> ⚠️ `ErvCollector` 는 공용 라이브러리 **`ErvProtocol`**(경로상 `HERV/TestProgram/ErvProtocol`)을 프로젝트 참조한다.
> (csproj: `..\..\ErvProtocol\ErvProtocol.csproj`) → 빌드 시 이 폴더가 **상대경로로 함께 있어야** 한다.
**방법 1 — 원본 위치에서 바로 빌드 (가장 간단, 권장)**
```bash
cd /mnt/d/project/nuvoton/HERV/TestProgram/WebDashBoard/ErvCollector
# ../../ErvProtocol 가 /mnt/d/.../HERV/TestProgram/ErvProtocol 로 자동 해석됨
```
> `/mnt` 는 다소 느리지만 경로 문제 없이 동작.
**방법 2 — 리눅스 홈으로 복사 (빌드 빠름, 상대구조 유지 필수)**
```bash
mkdir -p ~/herv
cp -r /mnt/d/project/nuvoton/HERV/TestProgram/ErvProtocol ~/herv/ErvProtocol
cp -r /mnt/d/project/nuvoton/HERV/TestProgram/WebDashBoard ~/herv/WebDashBoard
cd ~/herv/WebDashBoard/ErvCollector
# ../../ErvProtocol → ~/herv/ErvProtocol (깊이 일치) ✓
```
### appsettings.json 수정
```bash
nano appsettings.json
```
- `Influx.Token` → `herv-erv-token-0001` (4장에서 지정한 값)
- `Http.Prefix` → 로컬 검증은 `http://localhost:8080/`, **LAN 의 EW11 접속까지** 받으려면 `http://*:8080/`
- (권장) `Http.Token` → 임의의 제어 인증 토큰 지정
---
## 7. 빌드 & 실행
```bash
# 6장에서 정한 ErvCollector 폴더에서 (방법1: /mnt/d/... , 방법2: ~/herv/WebDashBoard/ErvCollector)
dotnet run # 참조된 ErvProtocol 라이브러리도 자동으로 함께 빌드됨
```
정상 기동 로그:
```
ErvCollector 시작. Influx=http://127.0.0.1:8086 bucket=erv 샘플주기=10s
HTTP 대시보드/제어 ← http://localhost:8080/
현장 'site01' ← TCP 포트 6001 대기
현장 'site02' ← TCP 포트 6002 대기
현장 'site03' ← TCP 포트 6003 대기
```
→ **Windows 브라우저**에서 `http://localhost:8080/` 접속(WSL localhost 는 Windows 와 공유됨).
---
## 8. 동작 검증 (EW11 없이)
`ErvCollector` 실행 중인 상태에서, **다른 Ubuntu 터미널**을 열고 데모 STATUS 프레임을 주입:
```bash
python3 - <<'PY'
import socket
# STATUS(0x81) 78바이트 예시 프레임 (PC_ERV_Protocol.md)
frame = bytes.fromhex(("AA 81 49 01 02 01 03 01 01 01 00 1E 00 32 01 2C 02 BC 00 00 "
"01 00 16 00 23 00 B4 02 6C 03 05 00 2A 03 00 00 30 00 46 02 08 03 84 02 03 00 58 04 "
"01 00 0C 00 12 00 5A 01 E0 04 00 00 0F 01 00 00 21 00 37 01 2C 02 D0 02 09 00 3C 02 EB 43").replace(" ",""))
s=socket.create_connection(("127.0.0.1",6001)) # 현장1 포트
import time
for _ in range(20):
s.sendall(frame); time.sleep(1) # 1초마다 1프레임
s.close()
PY
```
확인:
```bash
# 최신 상태 JSON (site01 online:true 로 채워짐)
curl -s http://localhost:8080/api/latest | head -c 400; echo
# 제어 테스트 (전원 OFF 프레임을 site01 소켓으로 송신 → 콜렉터 로그에 '송신' 찍힘)
curl -s -X POST http://localhost:8080/api/control \
-H "Content-Type: application/json" \
-H "X-Auth-Token: <설정한 Http.Token, 없으면 생략>" \
-d '{"site":"site01","action":"power","value":0}'
```
- 브라우저 `http://localhost:8080/` 상단 칩이 **서버연동**, 현장1 점이 초록(온라인)으로 표시.
- InfluxDB 적재 확인:
```bash
influx query 'from(bucket:"erv") |> range(start:-10m) |> filter(fn:(r)=>r._measurement=="erv_room") |> limit(n:5)'
```
---
## 9. (선택) 24시간 자동 실행 — systemd 서비스
```bash
# ErvCollector 폴더에서 게시(참조 ErvProtocol 포함 단일 폴더로 묶임)
dotnet publish -c Release -o ~/erv-publish
sudo tee /etc/systemd/system/erv-collector.service >/dev/null <<EOF
[Unit]
Description=ERV Collector
After=network.target influxdb.service
[Service]
WorkingDirectory=$HOME/erv-publish
ExecStart=$HOME/erv-publish/ErvCollector
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now erv-collector
journalctl -u erv-collector -f # 실시간 로그
```
---
## 10. 외부 EW11(LAN) 에서 WSL 서비스로 접속하게 만들기
WSL2 는 기본 NAT 라 LAN 의 EW11 이 WSL 내부 포트에 직접 못 붙는다. 둘 중 하나:
**방법 A — Mirrored networking (Windows 11 22H2+, 권장)**
`%UserProfile%\.wslconfig` (Windows 측, 메모장):
```ini
[wsl2]
networkingMode=mirrored
```
```powershell
wsl --shutdown
```
→ WSL 이 호스트 IP 공유 → EW11 은 **윈도우 PC LAN IP : 6001~6003** 으로 접속. `appsettings.json` 의 `Http.Prefix` 도 `http://*:8080/` 권장.
**방법 B — portproxy (Windows 10)**
관리자 PowerShell:
```powershell
$wsl = (wsl hostname -I).Trim().Split(" ")[0]
foreach ($p in 6001,6002,6003,8080) {
netsh interface portproxy add v4tov4 listenport=$p listenaddress=0.0.0.0 connectport=$p connectaddress=$wsl
}
```
> 방법 B 의 WSL 내부 IP 는 재부팅마다 바뀌므로 재실행 필요(방법 A 가 편함).
**Windows 방화벽 인바운드 허용** (관리자 PowerShell):
```powershell
New-NetFirewallRule -DisplayName "ERV Collector" -Direction Inbound -Protocol TCP -LocalPort 6001-6003 -Action Allow
New-NetFirewallRule -DisplayName "ERV Web" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow
```
EW11(IOTService) 설정: TCP Client / Server=윈도우 PC IP / Port=현장별 6001~6003 / 115200 8N1 / AES / Keepalive.
---
## 11. 종료 · 재시작 · 트러블슈팅
```bash
# 서비스 제어
sudo systemctl restart erv-collector
sudo systemctl status influxdb grafana-server erv-collector
# 포트 점유 확인
ss -ltnp | grep -E '6001|6002|6003|8080|8086'
```
| 증상 | 원인 / 조치 |
|---|---|
| `HTTP 서버 시작 실패` | `Http.Prefix` 가 `+`/`*` 인데 권한 부족 → WSL(Linux)에선 보통 OK. Windows 직접 실행 시 `localhost` 사용 |
| `Influx write FAIL: 연결 거부` | InfluxDB 미기동 → `sudo systemctl start influxdb`, 토큰/org/bucket 확인 |
| 브라우저에서 8080 접속 안됨 | WSL localhost 공유는 보통 자동. 안되면 `wsl --shutdown` 후 재시도 / 방화벽 |
| EW11 이 못 붙음 | 10장(mirrored/portproxy + 방화벽), `Http.Prefix=http://*:8080/`, 같은 서브넷 여부 |
| `dotnet` 없음 | 3장 재확인, `source ~/.bashrc` |
---
## 12. 참고 문서
- 수집서버 상세: `ErvCollector/README.md`
- 프레임 규격: `../TestProgram/PC_ERV_Protocol.md`
- EW11 클라우드 전송 검토: `../EW11_RS485 TO WIFI/260603_EW11_클라우드전송_검토.md`