Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL. 기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체. - industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버) - src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer) - mcp-server: Python FastMCP (RAG/NL2SQL/P&ID) - 다중 컨트롤러(N-Controller) 지원 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1356 lines
52 KiB
Markdown
1356 lines
52 KiB
Markdown
# HC900 다중 컨트롤러 지원 — 구현 작업 지시서
|
|
|
|
## 목표
|
|
현재 단일 HC900 컨트롤러 구조를 N대 확장 가능한 구조로 변경한다.
|
|
현재 4대 운영 중. 추가 증설 고려.
|
|
|
|
---
|
|
|
|
## 현재 구조 (변경 전)
|
|
|
|
```
|
|
config/gateway-config.json → { gateway: { controllerIp, grpcPort, ... } }
|
|
Hc900GatewayProcessService → hc900_gateway 프로세스 1개 관리
|
|
Hc900GatewayClient → gRPC 채널 1개 (단일 Options 기반)
|
|
Hc900RealtimeService → 단일 gRPC 클라이언트로 폴링
|
|
realtime_table → PK: id (UNIQUE tagname)
|
|
history_table → tagname 기준
|
|
event_history_table → tagname 기준
|
|
hc900_map_master → tagname + hc900_tag
|
|
```
|
|
|
|
---
|
|
|
|
## 목표 구조 (변경 후)
|
|
|
|
```
|
|
config/gateway-config.json → { shared: {...}, controllers: [...] }
|
|
ControllerRegistry → ControllerConfig[] 관리 싱글턴
|
|
ControllerProcessManager → 컨트롤러별 GatewayProcess 관리 BackgroundService
|
|
ControllerGrpcClientPool → 컨트롤러별 GrpcChannel 풀
|
|
Hc900RealtimeService → 컨트롤러 목록 루프로 각각 폴링
|
|
realtime_table → PK: (controller_id, tagname)
|
|
history_table → controller_id 컬럼 추가
|
|
event_history_table → controller_id 컬럼 추가
|
|
hc900_map_master → controller_id 컬럼 추가
|
|
```
|
|
|
|
---
|
|
|
|
## 1단계 — Config 구조 변경
|
|
|
|
### 파일: `src/Infrastructure/Hc900/Hc900GatewayProcessService.cs`
|
|
|
|
#### 1-1. 기존 `GatewayConfig` 클래스 대체
|
|
|
|
기존:
|
|
```csharp
|
|
public class GatewayConfig
|
|
{
|
|
public string BinaryPath { get; set; } = "";
|
|
public string ControllerIp { get; set; } = "";
|
|
public int ControllerPort { get; set; } = 502;
|
|
public int PollIntervalMs { get; set; } = 1000;
|
|
public string RegisterMapPath { get; set; } = "";
|
|
public int GrpcListenPort { get; set; } = 50051;
|
|
public string LdLibraryPath { get; set; } = "";
|
|
public string LogPath { get; set; } = "/tmp/hc900_gateway.log";
|
|
}
|
|
```
|
|
|
|
변경 후 — 파일 맨 위에 새 클래스 추가:
|
|
```csharp
|
|
/// <summary>공유 설정 — 모든 컨트롤러가 공통으로 사용</summary>
|
|
public class GatewaySharedConfig
|
|
{
|
|
public string BinaryPath { get; set; } = "";
|
|
public string LdLibraryPath { get; set; } = "";
|
|
public string LogDir { get; set; } = "/tmp";
|
|
}
|
|
|
|
/// <summary>컨트롤러 1대 설정</summary>
|
|
public class ControllerConfig
|
|
{
|
|
public string Id { get; set; } = ""; // 고유 식별자 e.g. "HC1"
|
|
public string Name { get; set; } = ""; // 표시명 e.g. "반응기 1"
|
|
public string ControllerIp { get; set; } = "";
|
|
public int ControllerPort { get; set; } = 502;
|
|
public int GrpcPort { get; set; } = 50051;
|
|
public int PollIntervalMs { get; set; } = 1000;
|
|
public string RegisterMapPath { get; set; } = "";
|
|
public bool Enabled { get; set; } = true;
|
|
}
|
|
|
|
/// <summary>전체 Config 루트 (기존 GatewayConfig 대체)</summary>
|
|
public class MultiControllerConfig
|
|
{
|
|
public GatewaySharedConfig Shared { get; set; } = new();
|
|
public List<ControllerConfig> Controllers { get; set; } = new();
|
|
}
|
|
|
|
// 하위 호환: 기존 GatewayConfig는 삭제해도 됨
|
|
// SetupController 등에서 GatewayConfig를 직접 참조하는 곳은
|
|
// ControllerConfig + GatewaySharedConfig 로 교체
|
|
```
|
|
|
|
#### 1-2. `gateway-config.json` 형식 변경 예시
|
|
|
|
```json
|
|
{
|
|
"shared": {
|
|
"binaryPath": "/home/windpacer/projects/hc900_ax/industrial-comm/cpp/build/hc900_gateway",
|
|
"ldLibraryPath": "/tmp/grpc_local/lib:/tmp/absl_local/lib",
|
|
"logDir": "/tmp"
|
|
},
|
|
"controllers": [
|
|
{
|
|
"id": "HC1",
|
|
"name": "반응기 1",
|
|
"controllerIp": "192.168.0.240",
|
|
"controllerPort": 502,
|
|
"grpcPort": 50051,
|
|
"pollIntervalMs": 1000,
|
|
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map.json",
|
|
"enabled": true
|
|
},
|
|
{
|
|
"id": "HC2",
|
|
"name": "반응기 2",
|
|
"controllerIp": "192.168.0.241",
|
|
"controllerPort": 502,
|
|
"grpcPort": 50052,
|
|
"pollIntervalMs": 1000,
|
|
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map-hc2.json",
|
|
"enabled": true
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### 1-3. `Hc900GatewayProcessService` → `ControllerProcessManager` 로 재작성
|
|
|
|
기존 파일에서 `Hc900GatewayProcessService` 클래스를 다음으로 교체.
|
|
`GatewayStatus` 클래스도 수정:
|
|
|
|
```csharp
|
|
public class ControllerStatus
|
|
{
|
|
public string ControllerId { get; set; } = "";
|
|
public string Name { get; set; } = "";
|
|
public bool Running { get; set; }
|
|
public int? Pid { get; set; }
|
|
public string Message { get; set; } = "";
|
|
public string ControllerIp { get; set; } = "";
|
|
public int GrpcPort { get; set; }
|
|
public DateTime? StartedAt { get; set; }
|
|
public List<string> RecentLog { get; set; } = new();
|
|
}
|
|
|
|
public class ControllerProcessManager : BackgroundService
|
|
{
|
|
private readonly ILogger<ControllerProcessManager> _logger;
|
|
|
|
// D1: Dictionary → ConcurrentDictionary (HTTP 스레드 + 감시 루프 동시 접근 안전)
|
|
private readonly ConcurrentDictionary<string, Process?> _processes = new();
|
|
private readonly ConcurrentDictionary<string, DateTime?> _startedAt = new();
|
|
// D1: StartAsync 의 "확인 후 시작" 복합연산 TOCTOU 방지 — 컨트롤러별 1개 세마포어
|
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> _startLocks = new();
|
|
|
|
private MultiControllerConfig _config = new();
|
|
private readonly object _configLock = new(); // D1: _config 읽기/쓰기 보호
|
|
private static readonly string ConfigPath = FindConfigPath();
|
|
|
|
private static string FindConfigPath()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
for (int i = 0; i < 8 && dir != null; i++, dir = dir.Parent)
|
|
{
|
|
var c = Path.Combine(dir.FullName, "config", "gateway-config.json");
|
|
if (File.Exists(c)) return c;
|
|
}
|
|
return Path.Combine(AppContext.BaseDirectory,
|
|
"..", "..", "..", "..", "..", "..", "config", "gateway-config.json");
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions _jsonOpts = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
};
|
|
|
|
public ControllerProcessManager(ILogger<ControllerProcessManager> logger)
|
|
{
|
|
_logger = logger;
|
|
ReloadConfig();
|
|
}
|
|
|
|
// D1: _config 읽기는 lock 안에서
|
|
public MultiControllerConfig Config { get { lock (_configLock) return _config; } }
|
|
|
|
public void ReloadConfig()
|
|
{
|
|
try
|
|
{
|
|
var path = Path.GetFullPath(ConfigPath);
|
|
if (!File.Exists(path)) { _logger.LogWarning("[ProcMgr] config 없음: {P}", path); return; }
|
|
var json = File.ReadAllText(path);
|
|
var loaded = JsonSerializer.Deserialize<MultiControllerConfig>(json, _jsonOpts)
|
|
?? new MultiControllerConfig();
|
|
lock (_configLock) { _config = loaded; }
|
|
_logger.LogInformation("[ProcMgr] 컨트롤러 {Count}대 로드", loaded.Controllers.Count);
|
|
}
|
|
catch (Exception ex) { _logger.LogWarning(ex, "[ProcMgr] config 로드 실패"); }
|
|
}
|
|
|
|
public static void SaveConfig(MultiControllerConfig cfg)
|
|
{
|
|
var path = Path.GetFullPath(ConfigPath);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
File.WriteAllText(path, JsonSerializer.Serialize(cfg, _jsonOpts));
|
|
}
|
|
|
|
public List<ControllerStatus> GetAllStatus()
|
|
{
|
|
List<ControllerConfig> ctrls;
|
|
lock (_configLock) { ctrls = _config.Controllers.ToList(); }
|
|
return ctrls.Select(c => GetStatus(c.Id)).ToList();
|
|
}
|
|
|
|
public ControllerStatus GetStatus(string controllerId)
|
|
{
|
|
ControllerConfig? cfg;
|
|
MultiControllerConfig snapshot;
|
|
lock (_configLock) { snapshot = _config; cfg = snapshot.Controllers.FirstOrDefault(c => c.Id == controllerId); }
|
|
if (cfg == null) return new ControllerStatus { ControllerId = controllerId, Message = "설정 없음" };
|
|
|
|
// ConcurrentDictionary — 스레드 안전 읽기
|
|
_processes.TryGetValue(controllerId, out var p);
|
|
var running = p != null && !p.HasExited;
|
|
|
|
var logPath = Path.Combine(snapshot.Shared.LogDir, $"hc900_gateway_{controllerId}.log");
|
|
var log = new List<string>();
|
|
try { if (File.Exists(logPath)) log = File.ReadLines(logPath).TakeLast(20).ToList(); } catch { }
|
|
|
|
_startedAt.TryGetValue(controllerId, out var startedAt);
|
|
return new ControllerStatus
|
|
{
|
|
ControllerId = controllerId,
|
|
Name = cfg.Name,
|
|
Running = running,
|
|
Pid = running ? p!.Id : null,
|
|
Message = running ? $"실행 중 (PID {p!.Id})" : "중지됨",
|
|
ControllerIp = cfg.ControllerIp,
|
|
GrpcPort = cfg.GrpcPort,
|
|
StartedAt = startedAt,
|
|
RecentLog = log,
|
|
};
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("[ProcMgr] 시작");
|
|
await Task.Delay(2000, stoppingToken);
|
|
|
|
foreach (var ctrl in _config.Controllers.Where(c => c.Enabled))
|
|
{
|
|
var (ok, msg) = await StartAsync(ctrl.Id);
|
|
_logger.LogInformation("[ProcMgr] {Id} 초기 시작: {Msg}", ctrl.Id, msg);
|
|
}
|
|
|
|
// 감시 루프
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try { await Task.Delay(5000, stoppingToken); } catch (OperationCanceledException) { break; }
|
|
|
|
List<ControllerConfig> enabledCtrls;
|
|
lock (_configLock) { enabledCtrls = _config.Controllers.Where(c => c.Enabled).ToList(); }
|
|
foreach (var ctrl in enabledCtrls)
|
|
{
|
|
if (_processes.TryGetValue(ctrl.Id, out var p) && p != null && p.HasExited)
|
|
{
|
|
_logger.LogWarning("[ProcMgr] {Id} 종료됨 (exit={Code}) — 재시작", ctrl.Id, p.ExitCode);
|
|
_processes.TryRemove(ctrl.Id, out _); // D1: TryRemove 사용
|
|
_startedAt.TryRemove(ctrl.Id, out _);
|
|
try { await Task.Delay(5000, stoppingToken); } catch (OperationCanceledException) { break; }
|
|
var (ok, msg) = await StartAsync(ctrl.Id);
|
|
_logger.LogInformation("[ProcMgr] {Id} 재시작: {Msg}", ctrl.Id, msg);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<(bool Ok, string Msg)> StartAsync(string controllerId)
|
|
{
|
|
// D1: 컨트롤러별 세마포어로 동시 중복 시작 방지 (TOCTOU 방지)
|
|
var sem = _startLocks.GetOrAdd(controllerId, _ => new SemaphoreSlim(1, 1));
|
|
if (!await sem.WaitAsync(0))
|
|
return (false, "이미 시작 처리 중");
|
|
try
|
|
{
|
|
MultiControllerConfig snapshot;
|
|
lock (_configLock) { snapshot = _config; }
|
|
var cfg = snapshot.Controllers.FirstOrDefault(c => c.Id == controllerId);
|
|
if (cfg == null) return (false, $"컨트롤러 {controllerId} 설정 없음");
|
|
|
|
if (_processes.TryGetValue(controllerId, out var existing) && existing != null && !existing.HasExited)
|
|
return (false, "이미 실행 중");
|
|
|
|
if (!File.Exists(snapshot.Shared.BinaryPath))
|
|
return (false, $"바이너리 없음: {snapshot.Shared.BinaryPath}");
|
|
if (string.IsNullOrEmpty(cfg.ControllerIp))
|
|
return (false, "Controller IP 미설정");
|
|
|
|
var logPath = Path.Combine(snapshot.Shared.LogDir, $"hc900_gateway_{controllerId}.log");
|
|
try { File.WriteAllText(logPath, ""); } catch { }
|
|
|
|
// D7: 인수 순서 — 기존 호환 유지하며 끝에 추가
|
|
// argv[1]=host argv[2]=map_path argv[3]=poll_ms argv[4]=grpc_port argv[5]=modbus_port
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = snapshot.Shared.BinaryPath,
|
|
Arguments = $"{cfg.ControllerIp} {cfg.RegisterMapPath} {cfg.PollIntervalMs} {cfg.GrpcPort} {cfg.ControllerPort}",
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
};
|
|
if (!string.IsNullOrEmpty(snapshot.Shared.LdLibraryPath))
|
|
psi.EnvironmentVariables["LD_LIBRARY_PATH"] = snapshot.Shared.LdLibraryPath;
|
|
|
|
var proc = new Process { StartInfo = psi };
|
|
proc.OutputDataReceived += (_, e) => { if (e.Data != null) AppendLog(logPath, e.Data); };
|
|
proc.ErrorDataReceived += (_, e) => { if (e.Data != null) AppendLog(logPath, e.Data); };
|
|
proc.Start();
|
|
proc.BeginOutputReadLine();
|
|
proc.BeginErrorReadLine();
|
|
|
|
_processes[controllerId] = proc;
|
|
_startedAt[controllerId] = DateTime.UtcNow;
|
|
|
|
// D6: 고정 2초 대기 → gRPC 헬스체크 폴링 (최대 10초)
|
|
// StartAsync 내에서 임시 채널 사용 (아직 풀에 등록 전이므로 불가피); 사용 후 즉시 dispose
|
|
await Task.Delay(500);
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
if (proc.HasExited) return (false, "즉시 종료됨 — 로그 확인");
|
|
try
|
|
{
|
|
using var tempChannel = GrpcChannel.ForAddress($"http://localhost:{cfg.GrpcPort}");
|
|
var tempClient = new ModbusGateway.ModbusGatewayClient(tempChannel);
|
|
var health = await tempClient.HealthCheckAsync(
|
|
new HealthCheckRequest(), deadline: DateTime.UtcNow.AddSeconds(1));
|
|
if (health.Status == HealthCheckResponse.Types.ServingStatus.Serving)
|
|
return (true, $"시작 PID={proc.Id} (헬스체크 통과, {i + 1}회)");
|
|
}
|
|
catch { /* 아직 준비 안 됨 */ }
|
|
await Task.Delay(1000);
|
|
}
|
|
return proc.HasExited
|
|
? (false, "즉시 종료됨 — 로그 확인")
|
|
: (true, $"시작 PID={proc.Id} (헬스체크 미응답)");
|
|
}
|
|
finally { sem.Release(); }
|
|
}
|
|
|
|
public Task<(bool Ok, string Msg)> StopAsync(string controllerId)
|
|
{
|
|
if (!_processes.TryGetValue(controllerId, out var p) || p == null || p.HasExited)
|
|
{
|
|
_processes.TryRemove(controllerId, out _); // D1: TryRemove
|
|
return Task.FromResult((false, "실행 중인 프로세스 없음"));
|
|
}
|
|
try { p.Kill(entireProcessTree: true); } catch { }
|
|
p.Dispose();
|
|
_processes.TryRemove(controllerId, out _); // D1: TryRemove
|
|
_startedAt.TryRemove(controllerId, out _);
|
|
return Task.FromResult((true, $"{controllerId} 중지됨"));
|
|
}
|
|
|
|
private void AppendLog(string logPath, string line)
|
|
{
|
|
try { File.AppendAllText(logPath, line + "\n"); } catch { }
|
|
}
|
|
|
|
public override void Dispose()
|
|
{
|
|
foreach (var (_, p) in _processes)
|
|
{
|
|
try { if (p != null && !p.HasExited) p.Kill(entireProcessTree: true); p?.Dispose(); } catch { }
|
|
}
|
|
foreach (var (_, sem) in _startLocks)
|
|
try { sem.Dispose(); } catch { }
|
|
base.Dispose();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2단계 — C++ 게이트웨이 수정 (gRPC 포트 + Modbus 포트 인수 추가)
|
|
|
|
> **D5 주의**: `src/main.cpp`는 엔트리포인트가 아님 (내용: `auto controller = init_system();` 3줄짜리 미사용 파일).
|
|
> 실제 `main()` 함수는 **`src/gateway.cpp:390`** 에 있음. 모든 변경은 `gateway.cpp`와 `include/gateway.h`에 적용.
|
|
|
|
### 파일 1: `industrial-comm/cpp/include/gateway.h`
|
|
|
|
현재 생성자 (`gateway.h:36`):
|
|
```cpp
|
|
Hc900Gateway(const std::string& host, uint16_t port,
|
|
const std::string& map_path,
|
|
int poll_interval_ms = 1000);
|
|
// 멤버 변수: std::string grpc_listen_{"0.0.0.0:50051"}; ← 하드코딩
|
|
```
|
|
|
|
변경 후 — D4: 생성자에 grpc_port 파라미터 추가, 멤버 변수 초기화 제거:
|
|
```cpp
|
|
// 생성자 선언 변경
|
|
Hc900Gateway(const std::string& host, uint16_t port,
|
|
const std::string& map_path,
|
|
int poll_interval_ms = 1000,
|
|
int grpc_port = 50051); // ← 추가
|
|
|
|
// 멤버 변수: 하드코딩 제거 → 생성자에서 초기화
|
|
std::string grpc_listen_; // "0.0.0.0:50051" 제거
|
|
```
|
|
|
|
### 파일 2: `industrial-comm/cpp/src/gateway.cpp`
|
|
|
|
**생성자 구현 변경** (현재 `gateway.cpp:19-26` 근방):
|
|
```cpp
|
|
// 변경 전
|
|
Hc900Gateway::Hc900Gateway(const std::string& host, uint16_t port,
|
|
const std::string& map_path,
|
|
int poll_interval_ms)
|
|
: host_(host), port_(port), poll_interval_ms_(poll_interval_ms)
|
|
|
|
// 변경 후
|
|
Hc900Gateway::Hc900Gateway(const std::string& host, uint16_t port,
|
|
const std::string& map_path,
|
|
int poll_interval_ms,
|
|
int grpc_port)
|
|
: host_(host), port_(port), poll_interval_ms_(poll_interval_ms),
|
|
grpc_listen_("0.0.0.0:" + std::to_string(grpc_port))
|
|
```
|
|
|
|
**`main()` 변경** (`gateway.cpp:390`):
|
|
|
|
현재:
|
|
```cpp
|
|
// argv[1]=host, argv[2]=map_path, argv[3]=poll_ms (port는 502 하드코딩)
|
|
if (argc > 1) host = argv[1];
|
|
if (argc > 2) map_path = argv[2];
|
|
if (argc > 3) poll_ms = std::atoi(argv[3]);
|
|
Hc900Gateway gateway(host, port, map_path, poll_ms);
|
|
```
|
|
|
|
변경 후 — D7: 기존 인수 순서 유지, 끝에 추가 (하위 호환):
|
|
```cpp
|
|
// argv[1]=host argv[2]=map_path argv[3]=poll_ms
|
|
// argv[4]=grpc_port (신규, 기본 50051)
|
|
// argv[5]=modbus_port (신규, 기본 502)
|
|
std::string host = "192.168.0.240";
|
|
std::string map_path = "docs/register-map.json";
|
|
int poll_ms = 1000;
|
|
int grpc_port = 50051;
|
|
uint16_t modbus_port = 502;
|
|
|
|
if (argc > 1) host = argv[1];
|
|
if (argc > 2) map_path = argv[2];
|
|
if (argc > 3) poll_ms = std::atoi(argv[3]);
|
|
if (argc > 4) grpc_port = std::atoi(argv[4]);
|
|
if (argc > 5) modbus_port = static_cast<uint16_t>(std::atoi(argv[5]));
|
|
|
|
Hc900Gateway gateway(host, modbus_port, map_path, poll_ms, grpc_port);
|
|
```
|
|
|
|
빌드 후 `build/hc900_gateway` 재배포.
|
|
|
|
---
|
|
|
|
## 3단계 — DB 마이그레이션
|
|
|
|
### `Hc900DbContext.cs` > `InitializeAsync()` 에 추가
|
|
|
|
기존 테이블 생성 코드 **아래에** 다음 멱등 ALTER 문 추가:
|
|
|
|
```csharp
|
|
// ── Multi-controller 지원: controller_id 컬럼 추가 ────────────────
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
ALTER TABLE realtime_table
|
|
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
|
|
""");
|
|
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
ALTER TABLE history_table
|
|
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
|
|
""");
|
|
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
ALTER TABLE event_history_table
|
|
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
|
|
""");
|
|
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
ALTER TABLE hc900_map_master
|
|
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
|
|
""");
|
|
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
ALTER TABLE tag_metadata
|
|
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
|
|
""");
|
|
|
|
// UNIQUE 제약 변경: tagname 단독 → (controller_id, tagname)
|
|
// UNIQUE INDEX 이름이 다를 수 있으므로 기존 것 먼저 삭제
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
DO $$
|
|
BEGIN
|
|
-- realtime_table unique 재설정
|
|
IF EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conrelid = 'realtime_table'::regclass AND contype = 'u'
|
|
) THEN
|
|
ALTER TABLE realtime_table DROP CONSTRAINT IF EXISTS realtime_table_tagname_key;
|
|
ALTER TABLE realtime_table DROP CONSTRAINT IF EXISTS uq_realtime_tagname;
|
|
END IF;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conrelid = 'realtime_table'::regclass
|
|
AND conname = 'uq_realtime_controller_tagname'
|
|
) THEN
|
|
ALTER TABLE realtime_table
|
|
ADD CONSTRAINT uq_realtime_controller_tagname UNIQUE (controller_id, tagname);
|
|
END IF;
|
|
END $$
|
|
""");
|
|
|
|
// 인덱스
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
CREATE INDEX IF NOT EXISTS idx_realtime_controller
|
|
ON realtime_table(controller_id)
|
|
""");
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
CREATE INDEX IF NOT EXISTS idx_history_controller
|
|
ON history_table(controller_id)
|
|
""");
|
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
CREATE INDEX IF NOT EXISTS idx_event_history_controller
|
|
ON event_history_table(controller_id)
|
|
""");
|
|
```
|
|
|
|
### 엔티티 클래스 수정: `src/Core/Domain/Entities/Hc900Entities.cs`
|
|
|
|
다음 4개 클래스에 `controller_id` 컬럼 추가:
|
|
|
|
```csharp
|
|
// RealtimePoint 에 추가
|
|
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
|
|
|
|
// HistoryRecord 에 추가
|
|
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
|
|
|
|
// EventHistoryRecord 에 추가
|
|
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
|
|
|
|
// Hc900MapEntry 에 추가
|
|
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
|
|
|
|
// TagMetadata 에 추가
|
|
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
|
|
```
|
|
|
|
---
|
|
|
|
## 4단계 — gRPC 클라이언트 풀
|
|
|
|
### 신규 파일: `src/Infrastructure/Hc900/ControllerGrpcClientPool.cs`
|
|
|
|
```csharp
|
|
using Grpc.Net.Client;
|
|
using Hc900.Gateway;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Hc900Crawler.Infrastructure.Hc900;
|
|
|
|
/// <summary>컨트롤러별 gRPC 채널/클라이언트 풀</summary>
|
|
public class ControllerGrpcClientPool : IDisposable
|
|
{
|
|
private readonly ControllerProcessManager _procMgr;
|
|
private readonly ILogger<ControllerGrpcClientPool> _logger;
|
|
private readonly Dictionary<string, (GrpcChannel Channel, ModbusGateway.ModbusGatewayClient Client)> _clients = new();
|
|
private readonly object _lock = new();
|
|
|
|
public ControllerGrpcClientPool(ControllerProcessManager procMgr, ILogger<ControllerGrpcClientPool> logger)
|
|
{
|
|
_procMgr = procMgr;
|
|
_logger = logger;
|
|
}
|
|
|
|
public ModbusGateway.ModbusGatewayClient GetClient(string controllerId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_clients.TryGetValue(controllerId, out var existing))
|
|
return existing.Client;
|
|
|
|
var ctrl = _procMgr.Config.Controllers.FirstOrDefault(c => c.Id == controllerId)
|
|
?? throw new InvalidOperationException($"컨트롤러 {controllerId} 설정 없음");
|
|
|
|
var address = $"http://localhost:{ctrl.GrpcPort}";
|
|
var channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions
|
|
{
|
|
MaxReceiveMessageSize = 64 * 1024 * 1024,
|
|
});
|
|
var client = new ModbusGateway.ModbusGatewayClient(channel);
|
|
_clients[controllerId] = (channel, client);
|
|
_logger.LogInformation("[GrpcPool] 채널 생성: {Id} → {Addr}", controllerId, address);
|
|
return client;
|
|
}
|
|
}
|
|
|
|
/// <summary>모든 활성 컨트롤러 ID 목록</summary>
|
|
public IEnumerable<string> EnabledControllerIds
|
|
=> _procMgr.Config.Controllers.Where(c => c.Enabled).Select(c => c.Id);
|
|
|
|
public void RemoveClient(string controllerId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_clients.TryGetValue(controllerId, out var pair))
|
|
{
|
|
pair.Channel.ShutdownAsync().GetAwaiter().GetResult();
|
|
pair.Channel.Dispose();
|
|
_clients.Remove(controllerId);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
foreach (var (_, pair) in _clients)
|
|
{
|
|
try { pair.Channel.ShutdownAsync().GetAwaiter().GetResult(); pair.Channel.Dispose(); } catch { }
|
|
}
|
|
_clients.Clear();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5단계 — Hc900RealtimeService 수정
|
|
|
|
### 파일: `src/Infrastructure/Hc900/Hc900RealtimeService.cs`
|
|
|
|
`Hc900GatewayClient` 의존성을 `ControllerGrpcClientPool` 로 교체.
|
|
컨트롤러별로 폴링 루프 실행.
|
|
|
|
```csharp
|
|
// 생성자 변경: Hc900GatewayClient → ControllerGrpcClientPool
|
|
public Hc900RealtimeService(
|
|
ControllerGrpcClientPool clientPool,
|
|
IOptions<Hc900Options> options, // pollIntervalMs 등 공통 옵션 여전히 사용
|
|
IConfiguration config,
|
|
ILogger<Hc900RealtimeService> logger)
|
|
|
|
// ExecuteAsync: 컨트롤러별 Task 동시 실행
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
var tasks = _clientPool.EnabledControllerIds
|
|
.Select(id => RunControllerLoopAsync(id, stoppingToken))
|
|
.ToList();
|
|
await Task.WhenAll(tasks);
|
|
}
|
|
|
|
private async Task RunControllerLoopAsync(string controllerId, CancellationToken ct)
|
|
{
|
|
// 기존 단일 루프와 동일한 로직, client는 _clientPool.GetClient(controllerId)
|
|
// DB upsert 시 controller_id = controllerId 포함
|
|
// IsConnected / PollCount 는 컨트롤러별로 관리 (Dictionary 사용)
|
|
}
|
|
```
|
|
|
|
**변경 핵심:**
|
|
- `BatchUpdateRealtimeTableAsync` 의 INSERT 쿼리:
|
|
```sql
|
|
INSERT INTO realtime_table (controller_id, tagname, node_id, livevalue, timestamp)
|
|
VALUES ($1, $2, '', $3, $4)
|
|
ON CONFLICT (controller_id, tagname) DO UPDATE
|
|
SET livevalue = EXCLUDED.livevalue, timestamp = EXCLUDED.timestamp
|
|
```
|
|
- `LoadMappingAsync` 쿼리:
|
|
```sql
|
|
SELECT tagname, hc900_tag FROM hc900_map_master
|
|
WHERE is_active = TRUE AND controller_id = $1
|
|
```
|
|
- `LoadStateLabelsAsync` 쿼리:
|
|
```sql
|
|
SELECT base_tag, attribute, value FROM tag_metadata
|
|
WHERE attribute LIKE 'state%' AND controller_id = $1
|
|
```
|
|
|
|
**IsConnected / PollCount 노출:**
|
|
```csharp
|
|
// 기존 단일 값 → 컨트롤러별 딕셔너리
|
|
private readonly ConcurrentDictionary<string, bool> _connected = new();
|
|
private readonly ConcurrentDictionary<string, long> _pollCounts = new();
|
|
private readonly ConcurrentDictionary<string, DateTime?> _lastPollAt = new();
|
|
|
|
public bool IsConnected => _connected.Values.Any(v => v); // 하나라도 연결되면 true
|
|
public long PollCount => _pollCounts.Values.Sum();
|
|
public DateTime? LastPollAt => _lastPollAt.Values.Where(v => v.HasValue).Max();
|
|
|
|
// 컨트롤러별 상태 노출 (Setup UI용)
|
|
public IReadOnlyDictionary<string, bool> ControllerConnected => _connected;
|
|
```
|
|
|
|
---
|
|
|
|
## 6단계 — Hc900HistoryService 수정
|
|
|
|
### 파일: `src/Infrastructure/Hc900/Hc900HistoryService.cs`
|
|
|
|
`SnapshotToHistoryAsync` 가 `controller_id` 를 전달하도록 수정.
|
|
|
|
**`IExperionDbService` 인터페이스 / `Hc900DbService` 구현 변경:**
|
|
```csharp
|
|
// 기존 (IExperionServices.cs:31)
|
|
Task<int> SnapshotToHistoryAsync(bool includeDigital = false);
|
|
|
|
// 변경 후 — D2: includeDigital 유지, controllerId 추가
|
|
Task<int> SnapshotToHistoryAsync(string? controllerId = null, bool includeDigital = false);
|
|
// controllerId=null → 전체 컨트롤러 스냅샷
|
|
```
|
|
|
|
`Hc900HistoryService` 호출:
|
|
```csharp
|
|
var count = await db.SnapshotToHistoryAsync(controllerId: null, includeDigital: false);
|
|
```
|
|
|
|
**DB 쿼리:**
|
|
```sql
|
|
INSERT INTO history_table (controller_id, tagname, node_id, value, recorded_at)
|
|
SELECT controller_id, tagname, node_id, livevalue, NOW()
|
|
FROM realtime_table
|
|
WHERE ($1::TEXT IS NULL OR controller_id = $1)
|
|
AND ($2 = TRUE OR livevalue IS NOT NULL) -- includeDigital=false면 NULL 제외
|
|
ON CONFLICT DO NOTHING
|
|
```
|
|
|
|
---
|
|
|
|
## 6-b단계 — Hc900DigitalEventDetectorService 수정 (D3 — 지시서 원안 누락)
|
|
|
|
> **원안 누락 항목.** 이 서비스가 `event_history_table`에 기록할 때 `controller_id`를 전파하지 않으면
|
|
> HC1 이외 컨트롤러의 디지털 이벤트가 모두 `DEFAULT 'HC1'`로 저장됨.
|
|
|
|
### 파일 1: `src/Core/Application/Interfaces/IExperionServices.cs` — DigitalEventRecord
|
|
|
|
```csharp
|
|
public class DigitalEventRecord
|
|
{
|
|
public string ControllerId { get; set; } = "HC1"; // ← 추가
|
|
public string TagName { get; set; } = "";
|
|
public string NodeId { get; set; } = "";
|
|
public string? PrevValue { get; set; }
|
|
public string CurrValue { get; set; } = "";
|
|
public string EventType { get; set; } = "";
|
|
public DateTime EventTime { get; set; } = DateTime.UtcNow;
|
|
public int? DurationSeconds { get; set; }
|
|
public string? Area { get; set; }
|
|
public string? SubArea { get; set; }
|
|
public string? Metadata { get; set; }
|
|
}
|
|
```
|
|
|
|
### 파일 2: `src/Infrastructure/Hc900/Hc900DigitalEventDetectorService.cs`
|
|
|
|
`DetectAndRecordChangesAsync` 내부 — `RealtimePoint.ControllerId` 전파:
|
|
```csharp
|
|
// RealtimePoint 엔티티에 ControllerId가 추가된 후 (3단계 완료 전제)
|
|
events.Add(new DigitalEventRecord
|
|
{
|
|
ControllerId = point.ControllerId, // ← 추가 (기본값 "HC1" 대신 실제 값)
|
|
TagName = tagName,
|
|
NodeId = point.NodeId,
|
|
PrevValue = prevState.Value,
|
|
CurrValue = currValue,
|
|
EventType = eventType,
|
|
EventTime = now,
|
|
DurationSeconds = duration,
|
|
Area = area,
|
|
SubArea = subArea,
|
|
Metadata = BuildMetadata(tagName, eventType, currValue)
|
|
});
|
|
```
|
|
|
|
### 파일 3: `src/Infrastructure/Database/Hc900DbContext.cs` — BatchRecordDigitalEventsAsync
|
|
|
|
DB INSERT 쿼리에 `controller_id` 컬럼 추가:
|
|
```sql
|
|
INSERT INTO event_history_table
|
|
(controller_id, tagname, node_id, prev_value, curr_value,
|
|
event_type, event_time, duration_seconds, area, sub_area, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
```
|
|
파라미터 $1 = `record.ControllerId`
|
|
|
|
---
|
|
|
|
## 7단계 — SetupController 수정
|
|
|
|
### 파일: `src/Hc900Crawler/Controllers/SetupController.cs`
|
|
|
|
`Hc900GatewayProcessService` → `ControllerProcessManager` 로 교체.
|
|
|
|
```csharp
|
|
[ApiController]
|
|
[Route("api/setup")]
|
|
public class SetupController : ControllerBase
|
|
{
|
|
private readonly ControllerProcessManager _procMgr;
|
|
private readonly Hc900RealtimeService _realtime;
|
|
|
|
public SetupController(ControllerProcessManager procMgr, Hc900RealtimeService realtime)
|
|
{ _procMgr = procMgr; _realtime = realtime; }
|
|
|
|
/// <summary>전체 컨트롤러 상태 조회</summary>
|
|
[HttpGet("controllers")]
|
|
public IActionResult GetControllers()
|
|
{
|
|
var statuses = _procMgr.GetAllStatus();
|
|
var connected = _realtime.ControllerConnected;
|
|
return Ok(statuses.Select(s => new
|
|
{
|
|
s.ControllerId, s.Name, s.Running, s.Pid,
|
|
s.Message, s.ControllerIp, s.GrpcPort, s.StartedAt,
|
|
crawlerConnected = connected.GetValueOrDefault(s.ControllerId),
|
|
pollCount = _realtime.PollCount, // 전체 합산 또는 컨트롤러별 구현
|
|
}));
|
|
}
|
|
|
|
/// <summary>단일 컨트롤러 상태 (기존 /gateway/status 대체)</summary>
|
|
[HttpGet("gateway/status")]
|
|
public IActionResult GetStatus()
|
|
{
|
|
// 하위 호환: 첫 번째 컨트롤러 상태 반환 + 전체 합산
|
|
var statuses = _procMgr.GetAllStatus();
|
|
var first = statuses.FirstOrDefault() ?? new ControllerStatus();
|
|
return Ok(new
|
|
{
|
|
running = statuses.Any(s => s.Running),
|
|
pid = first.Pid,
|
|
message = string.Join(", ", statuses.Select(s => $"{s.ControllerId}:{s.Message}")),
|
|
controllerIp = string.Join(", ", statuses.Select(s => s.ControllerIp)),
|
|
startedAt = first.StartedAt,
|
|
recentLog = first.RecentLog,
|
|
crawlerConnected = _realtime.IsConnected,
|
|
crawlerPollCount = _realtime.PollCount,
|
|
crawlerLastPoll = _realtime.LastPollAt,
|
|
controllers = statuses, // 상세 목록 추가
|
|
});
|
|
}
|
|
|
|
/// <summary>전체 설정 조회</summary>
|
|
[HttpGet("config")]
|
|
public IActionResult GetConfig()
|
|
{
|
|
_procMgr.ReloadConfig();
|
|
return Ok(_procMgr.Config);
|
|
}
|
|
|
|
/// <summary>전체 설정 저장</summary>
|
|
[HttpPost("config")]
|
|
public IActionResult SaveConfig([FromBody] MultiControllerConfig cfg)
|
|
{
|
|
ControllerProcessManager.SaveConfig(cfg);
|
|
_procMgr.ReloadConfig();
|
|
return Ok(new { success = true });
|
|
}
|
|
|
|
/// <summary>특정 컨트롤러 시작</summary>
|
|
[HttpPost("gateway/start")]
|
|
public async Task<IActionResult> Start([FromQuery] string? id = null)
|
|
{
|
|
if (id != null)
|
|
{
|
|
var (ok, msg) = await _procMgr.StartAsync(id);
|
|
return Ok(new { success = ok, message = msg });
|
|
}
|
|
// id 없으면 전체 시작
|
|
var results = new List<object>();
|
|
foreach (var ctrl in _procMgr.Config.Controllers.Where(c => c.Enabled))
|
|
{
|
|
var (ok, msg) = await _procMgr.StartAsync(ctrl.Id);
|
|
results.Add(new { ctrl.Id, ok, msg });
|
|
}
|
|
return Ok(results);
|
|
}
|
|
|
|
/// <summary>특정 컨트롤러 중지</summary>
|
|
[HttpPost("gateway/stop")]
|
|
public async Task<IActionResult> Stop([FromQuery] string? id = null)
|
|
{
|
|
if (id != null)
|
|
{
|
|
var (ok, msg) = await _procMgr.StopAsync(id);
|
|
return Ok(new { success = ok, message = msg });
|
|
}
|
|
var results = new List<object>();
|
|
foreach (var ctrl in _procMgr.Config.Controllers)
|
|
{
|
|
var (ok, msg) = await _procMgr.StopAsync(ctrl.Id);
|
|
results.Add(new { ctrl.Id, ok, msg });
|
|
}
|
|
return Ok(results);
|
|
}
|
|
|
|
[HttpPost("gateway/restart")]
|
|
public async Task<IActionResult> Restart([FromBody] MultiControllerConfig? cfg, [FromQuery] string? id = null)
|
|
{
|
|
if (cfg != null) { ControllerProcessManager.SaveConfig(cfg); _procMgr.ReloadConfig(); }
|
|
if (id != null)
|
|
{
|
|
await _procMgr.StopAsync(id);
|
|
await Task.Delay(500);
|
|
var (ok, msg) = await _procMgr.StartAsync(id);
|
|
return Ok(new { success = ok, message = msg });
|
|
}
|
|
// 전체 재시작
|
|
foreach (var ctrl in _procMgr.Config.Controllers)
|
|
await _procMgr.StopAsync(ctrl.Id);
|
|
await Task.Delay(500);
|
|
var results = new List<object>();
|
|
foreach (var ctrl in _procMgr.Config.Controllers.Where(c => c.Enabled))
|
|
{
|
|
var (ok, msg) = await _procMgr.StartAsync(ctrl.Id);
|
|
results.Add(new { ctrl.Id, ok, msg });
|
|
}
|
|
return Ok(results);
|
|
}
|
|
|
|
[HttpGet("gateway/log")]
|
|
public IActionResult GetLog([FromQuery] string? id = null, [FromQuery] int lines = 50)
|
|
{
|
|
var target = id ?? _procMgr.Config.Controllers.FirstOrDefault()?.Id;
|
|
if (target == null) return Ok(new { log = Array.Empty<string>() });
|
|
var status = _procMgr.GetStatus(target);
|
|
return Ok(new { log = status.RecentLog.TakeLast(lines) });
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8단계 — Program.cs 수정
|
|
|
|
### 파일: `src/Hc900Crawler/Program.cs`
|
|
|
|
기존 등록 코드 교체:
|
|
|
|
```csharp
|
|
// 제거:
|
|
// builder.Services.AddSingleton<Hc900GatewayProcessService>();
|
|
// builder.Services.AddHostedService(sp => sp.GetRequiredService<Hc900GatewayProcessService>());
|
|
// builder.Services.AddSingleton<Hc900GatewayClient>();
|
|
// builder.Services.AddSingleton<IHc900GatewayService>(...);
|
|
|
|
// 추가:
|
|
builder.Services.AddSingleton<ControllerProcessManager>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<ControllerProcessManager>());
|
|
builder.Services.AddSingleton<ControllerGrpcClientPool>();
|
|
|
|
// Hc900RealtimeService 생성자 변경에 따라 DI는 자동 해결됨
|
|
builder.Services.AddSingleton<Hc900RealtimeService>();
|
|
builder.Services.AddSingleton<IHc900RealtimeService>(sp => sp.GetRequiredService<Hc900RealtimeService>());
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<Hc900RealtimeService>());
|
|
|
|
// Hc900WriteService 도 ControllerGrpcClientPool 사용하도록 수정 필요
|
|
builder.Services.AddSingleton<Hc900WriteService>();
|
|
```
|
|
|
|
---
|
|
|
|
## 9단계 — Hc900WriteService 수정
|
|
|
|
### 파일: `src/Infrastructure/Hc900/Hc900WriteService.cs`
|
|
|
|
```csharp
|
|
// 생성자: Hc900GatewayClient → ControllerGrpcClientPool
|
|
// WriteTagAsync 에 controllerId 파라미터 추가
|
|
public async Task<(bool Success, string? Error)> WriteTagAsync(
|
|
string controllerId, string tagName, double value)
|
|
{
|
|
var client = _clientPool.GetClient(controllerId);
|
|
var resp = await client.WriteTagAsync(new WriteTagRequest { TagName = tagName, Value = value });
|
|
return (resp.Success, resp.Error);
|
|
}
|
|
```
|
|
|
|
### GatewayController write endpoint 수정:
|
|
```csharp
|
|
// WriteTagDto 에 ControllerId 추가
|
|
public class WriteTagDto
|
|
{
|
|
public string ControllerId { get; set; } = "HC1"; // 기본값 하위 호환
|
|
public string TagName { get; set; } = "";
|
|
public double Value { get; set; }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 10단계 — Setup UI 수정
|
|
|
|
### 파일: `wwwroot/js/setup.js`
|
|
|
|
기존 단일 컨트롤러 상태 표시를 컨트롤러 목록 카드 형식으로 변경.
|
|
|
|
**refreshStatus 변경:**
|
|
```javascript
|
|
async function refreshStatus() {
|
|
try {
|
|
const d = await _setupApi('GET', '/gateway/status');
|
|
// d.controllers = [{controllerId, name, running, pid, controllerIp, grpcPort, crawlerConnected}, ...]
|
|
|
|
const container = document.getElementById('controllers-list');
|
|
if (!container) return;
|
|
container.innerHTML = (d.controllers || []).map(c => `
|
|
<div class="ctrl-card ${c.running ? 'running' : 'stopped'}">
|
|
<div class="ctrl-header">
|
|
<span class="ctrl-id">${esc(c.controllerId)}</span>
|
|
<span class="ctrl-name">${esc(c.name)}</span>
|
|
<span class="ctrl-badge ${c.running ? 'green' : 'red'}">${c.running ? '실행 중' : '중지됨'}</span>
|
|
</div>
|
|
<div class="ctrl-info">
|
|
IP: ${esc(c.controllerIp)} | gRPC: :${c.grpcPort} | PID: ${c.pid ?? '—'}
|
|
| Crawler: ${c.crawlerConnected ? '연결됨' : '미연결'}
|
|
</div>
|
|
<div class="ctrl-btns">
|
|
<button class="setup-btn success" onclick="startGateway('${esc(c.controllerId)}')">▶</button>
|
|
<button class="setup-btn danger" onclick="stopGateway('${esc(c.controllerId)}')">■</button>
|
|
<button class="setup-btn warning" onclick="restartGateway('${esc(c.controllerId)}')">↺</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch(e) { console.error('refreshStatus:', e); }
|
|
}
|
|
```
|
|
|
|
**startGateway/stopGateway/restartGateway 에 id 파라미터 추가:**
|
|
```javascript
|
|
async function startGateway(id) {
|
|
const url = id ? `/gateway/start?id=${encodeURIComponent(id)}` : '/gateway/start';
|
|
const r = await _setupApi('POST', url);
|
|
_setupMsg('action-msg', r.success ?? (r[0]?.ok), r.message ?? '완료');
|
|
setTimeout(refreshStatus, 2500);
|
|
}
|
|
```
|
|
|
|
### 파일: `wwwroot/panes/setup.html`
|
|
|
|
Gateway Status 카드 내부:
|
|
```html
|
|
<!-- 기존 단일 status 행들 → 아래로 교체 -->
|
|
<div id="controllers-list"></div>
|
|
```
|
|
|
|
Config 카드도 `multiControllerConfig` 형식으로 편집할 수 있게 변경.
|
|
(controllers 배열 추가/편집/삭제 UI 구현)
|
|
|
|
---
|
|
|
|
## 11단계 — API 쿼리 파라미터 전파
|
|
|
|
아래 API들에 `?controllerId=HC1` 쿼리 파라미터 추가 및 DB 쿼리에 반영:
|
|
|
|
| API | 변경 |
|
|
|-----|------|
|
|
| `GET /api/realtime/points` | `?controllerId=` 필터 추가 |
|
|
| `POST /api/history/query` | `HistoryQueryDto.ControllerId` 추가 |
|
|
| `POST /api/events/query` | `EventQueryDto.ControllerId` 추가 |
|
|
| `GET /api/hc900/tags` | `?controllerId=` 필터 추가 |
|
|
|
|
UI의 이력/이벤트/태그 탭에 컨트롤러 선택 드롭다운 추가.
|
|
|
|
---
|
|
|
|
## 12단계 — IHc900RealtimeService 인터페이스 수정
|
|
|
|
### 파일: `src/Core/Application/Interfaces/IExperionServices.cs` (또는 유사)
|
|
|
|
```csharp
|
|
public interface IHc900RealtimeService
|
|
{
|
|
bool IsConnected { get; }
|
|
long PollCount { get; }
|
|
DateTime? LastPollAt { get; }
|
|
IReadOnlyDictionary<string, bool> ControllerConnected { get; } // 추가
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 체크리스트
|
|
|
|
```
|
|
[ ] 1. C++ gateway.h: 생성자에 grpc_port 파라미터 추가, grpc_listen_ 초기화 이동 (D4)
|
|
[ ] 2. C++ gateway.cpp main(): argv[4]=grpc_port, argv[5]=modbus_port 추가 (기존 순서 유지) (D7)
|
|
[ ] 3. C++ 재빌드 → build/hc900_gateway 배포
|
|
[ ] 4. gateway-config.json: shared + controllers 형식으로 변환
|
|
[ ] 5. GatewayConfig → MultiControllerConfig / ControllerConfig 교체
|
|
[ ] 6. Hc900GatewayProcessService → ControllerProcessManager 교체
|
|
- Dictionary → ConcurrentDictionary (D1)
|
|
- SemaphoreSlim per-controller StartAsync 보호 (D1)
|
|
- _config 읽기/쓰기 lock(_configLock) 추가 (D1)
|
|
- Task.Delay(2000) → gRPC 헬스체크 폴링 with using tempChannel (D6)
|
|
- Arguments에 grpc_port + modbus_port 포함 (D7)
|
|
[ ] 7. ControllerGrpcClientPool 신규 작성
|
|
[ ] 8. DB InitializeAsync: controller_id 컬럼 ALTER + UNIQUE 재설정
|
|
[ ] 9. 엔티티 클래스 5곳 ControllerId 추가
|
|
(RealtimePoint, HistoryRecord, EventHistoryRecord, Hc900MapEntry, TagMetadata)
|
|
[ ] 10. Hc900RealtimeService: clientPool 기반 멀티 루프로 변경
|
|
[ ] 11. Hc900HistoryService: SnapshotToHistoryAsync(controllerId, includeDigital) — includeDigital 유지 (D2)
|
|
[ ] 12. Hc900DigitalEventDetectorService: DigitalEventRecord.ControllerId 추가 + 전파 (D3)
|
|
[ ] 13. Hc900WriteService: controllerId 파라미터 추가
|
|
[ ] 14. SetupController: ControllerProcessManager 사용으로 교체
|
|
[ ] 15. GatewayController: WriteTagDto.ControllerId 추가
|
|
[ ] 16. Program.cs: DI 등록 교체
|
|
[ ] 17. Setup UI: 컨트롤러 목록 카드 표시 + 개별 제어
|
|
[ ] 18. 이력/이벤트/태그 API + UI: controllerId 필터 추가
|
|
[ ] 19. dotnet build 확인
|
|
[ ] 20. 단일 컨트롤러(HC1)로 회귀 테스트
|
|
```
|
|
|
|
---
|
|
|
|
## 주의사항
|
|
|
|
- **기존 DB 데이터**: `controller_id DEFAULT 'HC1'` 로 마이그레이션 자동 처리됨
|
|
- **하위 호환**: `GET /api/setup/gateway/status` 는 기존 형식 + `controllers` 배열 추가로 호환 유지
|
|
- **gRPC 포트**: 50051~50054 방화벽 개방 필요 (서버 내부 통신이므로 loopback만 사용 시 불필요)
|
|
- **레지스터맵**: 컨트롤러마다 다를 수 있으므로 `registerMapPath` 를 개별 지정
|
|
- **Hc900Options (appsettings.json)**: 단일 `GatewayAddress` 는 더 이상 사용 안 함. config/gateway-config.json 으로 통합
|
|
|
|
---
|
|
|
|
## 진단 결과 및 반박 검수
|
|
|
|
| 항목 | 판정 | 반영 위치 |
|
|
|------|------|-----------|
|
|
| D1 동시성 | **채택 + 보완** — ConcurrentDictionary 외 SemaphoreSlim 추가 | 1-3단계 코드 |
|
|
| D2 includeDigital | **채택** — 기존 시그니처 확인 후 양쪽 파라미터 유지 | 6단계 |
|
|
| D3 DigitalEventDetector | **채택** — 원안 누락 인정, 6-b단계 신규 추가 | 6-b단계 |
|
|
| D4 gateway.h 생성자 | **채택** — 헤더 파일 변경 명시 | 2단계 |
|
|
| D5 main.cpp 혼란 | **채택 + 상향 조정** — 수정 대상이 main.cpp가 아닌 gateway.cpp임을 명시 | 2단계 |
|
|
| D6 Task.Delay | **부분 채택** — 방향 동의. 임시 채널은 `using` 으로 즉시 dispose (풀 우회 주의) | 1-3단계 코드 |
|
|
| D7 인수 순서 | **부분 채택** — 포트 추가 동의. 단 중간 삽입은 Breaking Change이므로 **끝에 추가** | 2단계 |
|
|
|
|
### D1. `ControllerProcessManager` — Dictionary 동시성 미보호 (MED)
|
|
|
|
**문제**: `_processes`(Dictionary)와 `_config`가 `ExecuteAsync`(감시루프), `StartAsync`, `StopAsync`, `GetStatus`에서 **lock 없이** 동시 접근된다.
|
|
**근거**: `MULTI_CONTROLLER_WORK_ORDER.md:154` — `private readonly Dictionary<string, Process?> _processes = new();`
|
|
**영향**: 동시 HTTP 요청 또는 감시 루프와 충돌 시 `Collection was modified` 예외, 프로세스 중복 실행, 또는 `NullReferenceException`.
|
|
**수정**: `_processes` → `ConcurrentDictionary<string, Process?>` 로 변경하고 `_config` 접근 시 `lock (_lock)` 보호:
|
|
|
|
```csharp
|
|
// 필드 선언 교체
|
|
private readonly ConcurrentDictionary<string, Process?> _processes = new();
|
|
private readonly ConcurrentDictionary<string, DateTime?> _startedAt = new();
|
|
|
|
// GetStatus 의 _processes 접근
|
|
_processes.TryGetValue(controllerId, out var p);
|
|
|
|
// ExecuteAsync 의 _processes 접근
|
|
if (_processes.TryGetValue(ctrl.Id, out var p) && p != null && p.HasExited)
|
|
{
|
|
_processes.TryRemove(ctrl.Id, out _);
|
|
// ...
|
|
}
|
|
|
|
// StartAsync 의 _processes 접근
|
|
if (_processes.TryGetValue(controllerId, out var existing) && existing != null && !existing.HasExited)
|
|
return (false, "이미 실행 중");
|
|
// ...
|
|
_processes[controllerId] = proc;
|
|
|
|
// StopAsync 의 _processes 접근
|
|
if (!_processes.TryGetValue(controllerId, out var p) || p == null || p.HasExited)
|
|
{
|
|
_processes.TryRemove(controllerId, out _);
|
|
// ...
|
|
}
|
|
|
|
// _config 도 ReloadConfig() 전역 lock 필요
|
|
private readonly object _configLock = new();
|
|
public void ReloadConfig() { lock (_configLock) { /* 기존 로직 */ } }
|
|
public MultiControllerConfig Config { get { lock (_configLock) return _config; } }
|
|
```
|
|
|
|
---
|
|
|
|
### D2. `IExperionDbService.SnapshotToHistoryAsync` — `includeDigital` 파라미터 유실 (MED)
|
|
|
|
**문제**: STEP 6이 현재 시그니처를 `SnapshotToHistoryAsync()` (파라미터 없음)으로 잘못 기술했고, 변경 후에도 `includeDigital`이 사라진다.
|
|
**근거**: `IExperionServices.cs:31` — 실제 현재 시그니처는 `Task<int> SnapshotToHistoryAsync(bool includeDigital = false)`.
|
|
**영향**: 구현 시 기존 `includeDigital` 기능이 손실됨. 디지털 태그 필터링 없이 전체 스냅샷 저장.
|
|
**수정**: STEP 6 변경 후 시그니처를 아래로 통일:
|
|
|
|
```csharp
|
|
// 변경 후: controllerId + includeDigital 동시 지원
|
|
Task<int> SnapshotToHistoryAsync(string? controllerId = null, bool includeDigital = false);
|
|
```
|
|
|
|
`Hc900HistoryService`의 호출 코드:
|
|
```csharp
|
|
// 실시간 서비스가 하나라도 연결되었는지는 컨트롤러레벨로 확인
|
|
// 각 컨트롤러별로 스냅샷 호출하거나, null로 전체 스냅샷
|
|
var count = await db.SnapshotToHistoryAsync(controllerId: null, includeDigital: false);
|
|
```
|
|
|
|
---
|
|
|
|
### D3. `Hc900DigitalEventDetectorService` — `controller_id` 미전파 (MED)
|
|
|
|
**문제**: 작업 지시서가 `Hc900DigitalEventDetectorService`의 변경을 전혀 언급하지 않는다. 이 서비스가 기록하는 `event_history_table`의 `controller_id`가 항상 `'HC1'` (DEFAULT)이 된다.
|
|
**근거**: `MULTI_CONTROLLER_WORK_ORDER.md:624` — `Hc900HistoryService`만 언급, `Hc900DigitalEventDetectorService` 누락. `Hc900DigitalEventDetectorService.cs:140-152` — `DigitalEventRecord`에 `ControllerId` 필드가 없음.
|
|
**영향**: HC1 이외 컨트롤러의 디지털 이벤트가 모두 HC1로 기록되어 이벤트 조회 시 혼란.
|
|
**수정**: (A) `DigitalEventRecord`에 `ControllerId` 추가, (B) `DetectAndRecordChangesAsync`에서 realtime_table 조회 시 controller_id를 함께 읽어 event record에 포함, (C) DB 저장 쿼리에 controller_id 컬럼 추가:
|
|
|
|
```csharp
|
|
// ── 3a. Core.Application.Interfaces/IExperionServices.cs - DigitalEventRecord ──
|
|
public class DigitalEventRecord
|
|
{
|
|
// ... 기존 필드들 ...
|
|
public string ControllerId { get; set; } = "HC1";
|
|
}
|
|
|
|
// ── 3b. DetectAndRecordChangesAsync 내부 ──
|
|
// RealtimePoint에 ControllerId가 추가되면(STEP 7) 아래와 같이 전파:
|
|
events.Add(new DigitalEventRecord
|
|
{
|
|
TagName = tagName,
|
|
ControllerId = point.ControllerId, // ← 추가
|
|
// ...
|
|
});
|
|
|
|
// ── 3c. DB batch insert 쿼리 수정 (Hc900DbService) ──
|
|
// controller_id 컬럼 포함 INSERT
|
|
```
|
|
|
|
---
|
|
|
|
### D4. C++ `grpc_listen_` 생성자 파라미터화 명시 필요 (LOW)
|
|
|
|
**문제**: STEP 2가 `argv[4]` 파싱만 언급하고 `gateway.h:99`의 `grpc_listen_` 하드코딩을 생성자 파라미터로 변경하는 코드를 명시하지 않았다. 문맥상 유추 가능하지만 실수 방지를 위해 명시 필요.
|
|
**근거**: `gateway.h:99` — `std::string grpc_listen_{"0.0.0.0:50051"};`, `gateway.cpp:19-26` — 생성자에 `grpc_port` 파라미터 없음.
|
|
**수정**: `gateway.h` 생성자 선언과 `gateway.cpp` 생성자 구현에 `grpc_port` 파라미터 추가:
|
|
|
|
```cpp
|
|
// gateway.h:36-38
|
|
Hc900Gateway(const std::string& host, uint16_t port,
|
|
const std::string& map_path, int poll_interval_ms = 1000,
|
|
int grpc_port = 50051);
|
|
|
|
// 멤버 변수:
|
|
// std::string grpc_listen_{"0.0.0.0:50051"}; ← 제거
|
|
std::string grpc_listen_; // 생성자에서 초기화
|
|
|
|
// gateway.cpp 생성자:
|
|
Hc900Gateway::Hc900Gateway(const std::string& host, uint16_t port,
|
|
const std::string& map_path, int poll_interval_ms,
|
|
int grpc_port)
|
|
: host_(host), port_(port), poll_interval_ms_(poll_interval_ms),
|
|
grpc_listen_("0.0.0.0:" + std::to_string(grpc_port))
|
|
{
|
|
LoadRegisterMap(map_path);
|
|
auto transport = std::make_unique<ModbusTCP>();
|
|
controller_ = std::make_unique<Controller>(std::move(transport));
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### D5. `main.cpp` / `app_init.cpp` 가 CMakeLists에서 제외됨 (LOW)
|
|
|
|
**문제**: `main.cpp`(`main()` 함수 없음, 3줄)와 `app_init.cpp`가 CMakeLists의 어떤 target에도 포함되지 않았다. 실제 게이트웨이 `main()`은 `gateway.cpp:390`에 있다. `main.cpp` 파일 내부에 `// app_init.cpp` 코멘트가 있어 파일명과 내용이 불일치.
|
|
**근거**: `CMakeLists.txt:6-10,30-35` — `main.cpp`와 `app_init.cpp`가 source 목록에 없음. `main.cpp:1` — `// app_init.cpp`.
|
|
**영향**: 유지보수 시 혼란. 누군가 `main.cpp`가 엔트리포인트라고 오해할 수 있음.
|
|
**수정**: `main.cpp`의 내용을 제거하거나, 진정한 엔트리포인트로 전환. `app_init.cpp`는 `comm_core` 라이브러리에 등록:
|
|
|
|
```cmake
|
|
# CMakeLists.txt: target_sources(comm_core PRIVATE ... src/app_init.cpp ... 추가)
|
|
target_sources(comm_core
|
|
PRIVATE
|
|
src/controller.cpp
|
|
src/codec.cpp
|
|
src/app_init.cpp # ← 추가
|
|
)
|
|
```
|
|
|
|
또는 `main.cpp`를 실제 엔트리포인트로 만들어 `gateway.cpp`의 `main()`을 분리.
|
|
|
|
---
|
|
|
|
### D6. `Task.Delay(2000)` 고정 대기 — gRPC 헬스체크로 대체 권장 (LOW)
|
|
|
|
**문제**: `ControllerProcessManager.StartAsync()`에서 2초 고정 `Task.Delay`로 게이트웨이 준비를 확인한다.
|
|
**근거**: `MULTI_CONTROLLER_WORK_ORDER.md:321` — `await Task.Delay(2000);`.
|
|
**영향**: 게이트웨이 기동이 2초보다 오래 걸리면 즉시 종료됨으로 오판. 2초 이내면 불필요한 대기.
|
|
**수정**: gRPC 헬스체크 폴링 루프로 대체 (기존 `Hc900RealtimeService.WaitForGatewayAsync` 패턴 재사용):
|
|
|
|
```csharp
|
|
// StartAsync 끝부분:
|
|
await Task.Delay(1000);
|
|
// gRPC 헬스체크 폴링 (최대 10초)
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
try
|
|
{
|
|
var client = new ModbusGateway.ModbusGatewayClient(
|
|
GrpcChannel.ForAddress($"http://localhost:{cfg.GrpcPort}"));
|
|
var health = await client.HealthCheckAsync(new HealthCheckRequest(),
|
|
deadline: DateTime.UtcNow.AddSeconds(2));
|
|
if (health.Status == HealthCheckResponse.Types.ServingStatus.Serving)
|
|
return (true, $"시작 PID={proc.Id} (헬스체크 통과)");
|
|
}
|
|
catch { /* 아직 준비 안 됨 */ }
|
|
try { await Task.Delay(1000, cts.Token); } catch { break; }
|
|
}
|
|
return _processes.TryGetValue(controllerId, out var p) && p != null && !p.HasExited
|
|
? (true, $"시작 PID={proc.Id} (헬스체크 미응답)")
|
|
: (false, "즉시 종료됨 — 로그 확인");
|
|
```
|
|
|
|
---
|
|
|
|
### D7. C++ 게이트웨이 포트 처리 누락 (LOW)
|
|
|
|
**문제**: `Hc900GatewayProcessService.StartAsync()`는 현재 `ControllerPort`를 ProcessStartInfo.Arguments에 포함시키지 않고, `Hc900Gateway` 생성자의 두 번째 인자가 포트이므로 전달되어야 한다. 그러나 `gateway.cpp`의 `main()`에서는 `argv[1]=host`, `argv[2]=map_path`, `argv[3]=poll_ms`만 처리하고 포트는 항상 `uint16_t port = 502`로 고정이다.
|
|
**근거**: `gateway.cpp:392-399` — 포트는 고정 502, `Hc900GatewayProcessService.cs:203` — Arguments에 `ControllerPort` 누락.
|
|
**영향**: `config/gateway-config.json`에서 `controllerPort`를 변경해도 무시되고 항상 502로 연결.
|
|
**수정**: C++ `main()`에 `argv[4]=port` 추가 및 .NET 측 Arguments에 `${_config.ControllerPort}` 포함:
|
|
|
|
```cpp
|
|
// gateway.cpp main()
|
|
if (argc > 1) host = argv[1];
|
|
uint16_t port = 502;
|
|
if (argc > 2) port = static_cast<uint16_t>(std::atoi(argv[2]));
|
|
if (argc > 3) map_path = argv[3];
|
|
if (argc > 4) poll_ms = std::atoi(argv[4]);
|
|
if (argc > 5) grpc_port = std::atoi(argv[5]);
|
|
Hc900Gateway gateway(host, port, map_path, poll_ms, grpc_port);
|
|
```
|
|
|
|
```csharp
|
|
// .NET StartAsync()
|
|
Arguments = $"{cfg.ControllerIp} {cfg.ControllerPort} {cfg.RegisterMapPath} {cfg.PollIntervalMs} {cfg.GrpcPort}";
|
|
```
|
|
|