5a96a696b1
- 펌웨어(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>
156 lines
6.5 KiB
C#
156 lines
6.5 KiB
C#
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; } = "";
|
|
}
|
|
}
|