chore: HERV 통합 저장소 재초기화 커밋
손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋. .claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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; } = "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user