chore: 코어 엔티티/DB컨텍스트/설정/UI 셸 갱신
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"iiot-rag": {
|
"iiot-rag": {
|
||||||
"command": "/home/windpacer/projects/ExperionCrawler/mcp-server/.venv/bin/python",
|
"command": "/home/windpacer/projects/hc900_ax/mcp-server/.venv/bin/python",
|
||||||
"args": ["/home/windpacer/projects/ExperionCrawler/mcp-server/server.py"],
|
"args": ["/home/windpacer/projects/hc900_ax/mcp-server/server.py"],
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
CLAUDE.md
122
CLAUDE.md
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
> **📌 [`docs/베이직아키텍처-태그-디자인-전면재설계.md`](docs/베이직아키텍처-태그-디자인-전면재설계.md)** 를 먼저 읽을 것 — 모든 아키텍처 결정 사항이 정의되어 있음.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -11,6 +11,13 @@ Before: HC900 ──Modbus TCP──▶ Experion R530 ──OPC UA──▶ Expe
|
|||||||
After: HC900 ──Modbus TCP──▶ C++ Gateway ──gRPC──▶ HC900Crawler ──▶ PostgreSQL
|
After: HC900 ──Modbus TCP──▶ C++ Gateway ──gRPC──▶ HC900Crawler ──▶ PostgreSQL
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**핵심 원칙:**
|
||||||
|
- 단일 진실 공급원 = **`docs/Sinam_Tag_all.xlsx`** (태그명·주소·메타데이터 전부 포함)
|
||||||
|
- **모든 태그명은 대문자** (OPC UA 강제 소문자 규칙 폐기)
|
||||||
|
- **컨트롤러별 독립 맵** (`register-map-cN.json`)
|
||||||
|
- Loop 블록은 **통째로 읽기** (개별 파라미터 금지)
|
||||||
|
- `build_register_map.py` 하나로 **맵 생성 + 메타데이터 적재** 모두 처리
|
||||||
|
|
||||||
Four active components:
|
Four active components:
|
||||||
- **`industrial-comm/cpp/`** — C++ gateway: Modbus TCP poller + gRPC server (`hc900_gateway` binary)
|
- **`industrial-comm/cpp/`** — C++ gateway: Modbus TCP poller + gRPC server (`hc900_gateway` binary)
|
||||||
- **`src/Hc900Crawler/`** — C# .NET 8 ASP.NET Core web app: full monitoring platform (gRPC client + web UI + KB/P&ID/FF)
|
- **`src/Hc900Crawler/`** — C# .NET 8 ASP.NET Core web app: full monitoring platform (gRPC client + web UI + KB/P&ID/FF)
|
||||||
@@ -35,7 +42,7 @@ Produces `build/hc900_gateway` and `build/libcomm_core.so`.
|
|||||||
Run the gateway:
|
Run the gateway:
|
||||||
```bash
|
```bash
|
||||||
./build/hc900_gateway [host] [register-map-path] [poll_ms]
|
./build/hc900_gateway [host] [register-map-path] [poll_ms]
|
||||||
# defaults: 192.168.0.240, docs/register-map.json, 1000
|
# defaults: 192.168.0.240, docs/register-map-c3.json, 1000
|
||||||
```
|
```
|
||||||
|
|
||||||
Log file: `/tmp/hc900_gateway.log`. gRPC listens on `0.0.0.0:50051`.
|
Log file: `/tmp/hc900_gateway.log`. gRPC listens on `0.0.0.0:50051`.
|
||||||
@@ -50,22 +57,23 @@ dotnet run
|
|||||||
|
|
||||||
Configuration via `appsettings.json`: `Hc900.GatewayAddress` (default `http://localhost:50051`), `Hc900.PollIntervalMs`, `ConnectionStrings.DefaultConnection` (PostgreSQL, `Search Path=hc900`). Serves web UI at `http://0.0.0.0:5000`.
|
Configuration via `appsettings.json`: `Hc900.GatewayAddress` (default `http://localhost:50051`), `Hc900.PollIntervalMs`, `ConnectionStrings.DefaultConnection` (PostgreSQL, `Search Path=hc900`). Serves web UI at `http://0.0.0.0:5000`.
|
||||||
|
|
||||||
### Register Map Generation
|
### Register Map Generation (단일 스크립트)
|
||||||
|
|
||||||
Converts HC Designer CSV exports → `docs/register-map.json` used by the gateway at startup:
|
`docs/Sinam_Tag_all.xlsx` 하나로 모든 처리:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/build_register_map.py \
|
python3 scripts/build_register_map.py \
|
||||||
--loop-csv docs/SummaryFucntionBlockReport.csv \
|
--xlsx docs/Sinam_Tag_all.xlsx \
|
||||||
--signal-csv docs/SignalTags.csv \
|
-o docs/register-map-c3.json \
|
||||||
--variable-csv docs/Variables.csv \
|
--controller C3 \
|
||||||
-o docs/register-map.json
|
[--db-conn "Host=...;Database=hc900;..."]
|
||||||
```
|
```
|
||||||
|
|
||||||
Load state labels (StatusPoint descriptors from xlsx) into DB:
|
출력:
|
||||||
```bash
|
- `register-map-cN.json` — 게이트웨이용 레지스터 맵
|
||||||
python3 scripts/load_state_labels.py
|
- `tag_metadata` upsert (state labels, units, desc, area)
|
||||||
```
|
|
||||||
|
**더 이상 `load_state_labels.py`를 별도 실행할 필요 없음.**
|
||||||
|
|
||||||
### Test Utilities
|
### Test Utilities
|
||||||
|
|
||||||
@@ -74,99 +82,59 @@ python3 scripts/load_state_labels.py
|
|||||||
python3 test/modbus_sim.py
|
python3 test/modbus_sim.py
|
||||||
|
|
||||||
# Read tags directly via Modbus TCP
|
# Read tags directly via Modbus TCP
|
||||||
python3 test/read_tags.py FICQ3101.PV FICQ3101.SP FICQ3101.MODE
|
python3 test/read_tags.py FICQ-6101.PV FICQ-6101.SP FICQ-6101.MODE
|
||||||
python3 test/read_tags.py --port 5020 FICQ3101.PV # against simulator
|
python3 test/read_tags.py --port 5020 FICQ-6101.PV # against simulator
|
||||||
python3 test/read_tags.py --all --limit 50
|
python3 test/read_tags.py --all --limit 50
|
||||||
|
|
||||||
# pymodbus must be available; it's expected at /tmp/hc900_venv
|
|
||||||
/tmp/hc900_venv/bin/python3 test/read_tags.py FICQ3101.PV
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
**반드시 [`docs/베이직아키텍처-태그-디자인-전면재설계.md`](docs/베이직아키텍처-태그-디자인-전면재설계.md) 를 읽을 것.** 여기서는 요약만 기술.
|
||||||
|
|
||||||
### C++ Gateway (`industrial-comm/cpp/`)
|
### C++ Gateway (`industrial-comm/cpp/`)
|
||||||
|
|
||||||
**`Hc900Gateway`** (`src/gateway.cpp`, `include/gateway.h`) is the core class:
|
**`Hc900Gateway`** (`src/gateway.cpp`, `include/gateway.h`) is the core class:
|
||||||
- Loads `register-map.json` at startup into `registers_` (vector of `RegisterEntry`) and `tag_index_` (name→index map)
|
- Loads `register-map-cN.json` at startup into `registers_` and `tag_index_`
|
||||||
- Spawns a poll thread (`PollLoop`) that runs `ReadAllRegisters()` every `poll_interval_ms`
|
- Spawns poll thread (`PollLoop`) → `ReadAllRegisters()` every `poll_interval_ms`
|
||||||
- `ReadAllRegisters()` groups consecutive registers into batches of ≤120 and issues one `read_raw()` call per batch (~48 batches total for a full HC900 config, ~117 ms round-trip)
|
- Groups consecutive registers into batches of **≤120** (FC03), **Loop 블록은 통째로 192 regs**
|
||||||
- Cache (`cache_`, protected by `cache_mutex_`) stores `CachedValue` per tag; quality=192=good, quality=0=bad/stale
|
- Cache (`cache_`, protected by `cache_mutex_`); quality=192=good, quality=0=bad/stale
|
||||||
- `transport_mutex_` serializes all Modbus transport calls between the poll thread and gRPC `WriteTag` handlers
|
- `transport_mutex_` serializes Modbus calls between poll thread and gRPC `WriteTag`
|
||||||
|
|
||||||
**Key gRPC operations** (all implemented in `gateway.cpp`):
|
**Key gRPC operations:**
|
||||||
- `ReadTags` — reads from cache, sub-millisecond, no Modbus I/O
|
- `ReadTags` — from cache, no Modbus I/O
|
||||||
- `WriteTag` — calls Modbus FC16 directly, then updates cache
|
- `WriteTag` — FC16 directly, then update cache
|
||||||
- `StreamTags` — pushes cache snapshot at requested interval
|
- `StreamTags` — push cache at interval
|
||||||
- `ListTags` — returns metadata from in-memory register list
|
- `ListTags` — metadata from register list
|
||||||
- `HealthCheck` — reports connection state, poll count, last poll duration
|
- `HealthCheck` — connection state, poll count, duration
|
||||||
|
|
||||||
**`Controller`** (`src/controller.cpp`, `include/controller.hpp`) wraps `ITransport` with typed read/write methods. **`ModbusTCP`** (`src/modbus_tcp.cpp`, `include/modbus_tcp.hpp`) implements the transport.
|
**`Controller`** → wraps `ITransport` with typed read/write. **`ModbusTCP`** → FC03/FC16. **Codec** → FP_B format.
|
||||||
|
|
||||||
**Codec** (`src/codec.cpp`, `include/codec.hpp`) handles byte/word order for float32, int32, int64, double. HC900 uses `VendorFormat::HC900_FLOAT` = `{BigEndian, HighFirst, Normal}` (FP B format per manual).
|
### C# Crawler (`src/Hc900Crawler/`)
|
||||||
|
|
||||||
**Proto:** The generated files (`gen/modbus_gateway.pb.{cc,h}` and `gen/modbus_gateway.grpc.pb.{cc,h}`) are pre-built and committed. The source proto is at `proto/modbus_gateway.proto`. The C# copy lives at `src/Hc900Crawler/Proto/modbus_gateway.proto` and is compiled by MSBuild via `Grpc.Tools`.
|
**Project layout:**
|
||||||
|
|
||||||
### C# Crawler (`src/Hc900Crawler/`) — 3-Layer Architecture
|
|
||||||
|
|
||||||
**Project layout** (ExperionCrawler 패턴 적용):
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
Core/ ← Domain entities, interfaces, application services
|
Core/ ← Domain entities, interfaces, application services
|
||||||
Infrastructure/ ← DB (Hc900DbContext), HC900 services, Control, Kb, Mcp, Trend, Docs
|
Infrastructure/ ← DB (Hc900DbContext), HC900 services, Control, Kb, Mcp, Trend, Docs
|
||||||
Hc900Crawler/ ← ASP.NET Core web project (Controllers, wwwroot, Program.cs, csproj)
|
Hc900Crawler/ ← ASP.NET Core web project
|
||||||
mcp-server/ ← Python MCP server (copied from ExperionCrawler)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**BackgroundServices:**
|
**BackgroundServices:**
|
||||||
- **`Hc900RealtimeService`** (`Infrastructure/Hc900/`) — gRPC 폴링 → `hc900.realtime_table` upsert (500 rows/batch). 상태 노출: `IsConnected`, `PollCount`, `LastPollAt`
|
- `Hc900RealtimeService` — gRPC poll → `realtime_table` upsert (500 rows/batch)
|
||||||
- **`Hc900HistoryService`** — 60초 주기 `realtime_table` → `history_table` 스냅샷 (IsConnected 확인 후 실행)
|
- `Hc900HistoryService` — 60s snapshot `realtime_table` → `history_table`
|
||||||
- **`Hc900DigitalEventDetectorService`** — 1초 주기로 realtime_table 변화 감지 → `event_history_table` 기록
|
- `Hc900DigitalEventDetectorService` — 1s digital change detect → `event_history_table`
|
||||||
|
|
||||||
**`Hc900GatewayClient`** (`Infrastructure/Hc900/`) — `IHc900GatewayService` 구현. gRPC 채널 lazy 생성. `GetHealthAsync()`, `ListTagsAsync()`, `WriteTagAsync()`.
|
|
||||||
|
|
||||||
**`Hc900WriteService`** — FeedforwardSupervisor·FeedforwardController에서 SP 쓰기용.
|
|
||||||
|
|
||||||
**Value formatting**: 상태 레이블 있으면 `{N | LABEL | }`, 없으면 float/uint16 string.
|
|
||||||
|
|
||||||
**Web API endpoints** (port 5000):
|
|
||||||
- `GET /api/gateway/health` — gRPC HealthCheck
|
|
||||||
- `GET /api/gateway/tags` — ListTags
|
|
||||||
- `POST /api/gateway/write` — WriteTag
|
|
||||||
- `GET /api/gateway/status` — Hc900RealtimeService 상태
|
|
||||||
- `GET /api/realtime/points` — realtime_table
|
|
||||||
- `POST /api/history/query` — history_table 조회
|
|
||||||
- `POST /api/events/query` — event_history_table 조회
|
|
||||||
- `/api/pid/*`, `/api/kb/*`, `/api/ff/*`, `/api/t2s/*`, `/api/ollama/*` 등 ExperionCrawler 동일
|
|
||||||
|
|
||||||
### Database (PostgreSQL, schema `hc900`)
|
### Database (PostgreSQL, schema `hc900`)
|
||||||
|
|
||||||
`Hc900DbContext` (`Infrastructure/Database/Hc900DbContext.cs`) — `HasDefaultSchema("hc900")` + `Search Path=hc900` 연결문자열. `InitializeAsync()`에서 모든 테이블·뷰·TimeScaleDB 하이퍼테이블 자동 생성.
|
|
||||||
|
|
||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `hc900_map_master` | OPC UA `tagname` ↔ `hc900_tag` 매핑, Modbus addr, 데이터타입 |
|
|
||||||
| `realtime_table` | 실시간 값 (tagname, livevalue, timestamp) — upsert on conflict |
|
| `realtime_table` | 실시간 값 (tagname, livevalue, timestamp) — upsert on conflict |
|
||||||
| `history_table` | 60초 이력 스냅샷 (TimeScaleDB hypertable) |
|
| `history_table` | 60초 이력 스냅샷 (TimeScaleDB hypertable) |
|
||||||
| `event_history_table` | 디지털 태그 상태 변경 이벤트 (TRIP/ALARM/RUN 등) |
|
| `event_history_table` | 디지털 태그 상태 변경 이벤트 (TRIP/ALARM/RUN 등) |
|
||||||
| `tag_metadata` | 태그 메타 (description, area, sub_area, state0-7 레이블) |
|
| `tag_metadata` | 태그 메타 (desc, area, sub_area, state0-7, units) |
|
||||||
| `pid_equipment`, `pid_prefix_rules` | P&ID 추출 데이터 |
|
| `hc900_map_master` | 활성 태그 관리 (향후 단순화 예정) |
|
||||||
| `kb_*` | Knowledge Base (Qdrant RAG) |
|
|
||||||
| `ff_*` | Feedforward 제어 설정/감사 |
|
|
||||||
|
|
||||||
### Register Map (`docs/register-map.json`)
|
|
||||||
|
|
||||||
JSON file with a top-level `registers` array. Each entry: `tag`, `addr` (0-based Modbus holding register address), `count` (1=uint16, 2=float32), `type`, `access` ("R"/"RW"), `description`.
|
|
||||||
|
|
||||||
Sources:
|
|
||||||
- **Loops** (PID): `SummaryFucntionBlockReport.csv` → expanded into per-parameter entries using `LOOP_PARAM_OFFSETS` in `build_register_map.py`. Loop #N base address = `0x40 + (N-1)*0x100` (loops 1–24), `0x7840 + (N-25)*0x100` (loops 25–32).
|
|
||||||
- **Signal Tags** (read-only): `SignalTags.csv`, addresses `0x2000–0x25E4`
|
|
||||||
- **Variables** (R/W): `Variables.csv`, addresses `0x18C0–0x1A10`
|
|
||||||
|
|
||||||
Float format is `FP_B` (IEEE 754 big-endian, wire order bytes 4,3,2,1).
|
|
||||||
|
|
||||||
### HC900 Hardware
|
### HC900 Hardware
|
||||||
|
|
||||||
- Controller: HC900-C70, IP `192.168.0.240`, Modbus TCP port 502
|
- Controller: HC900-C70, IP `192.168.0.240`, Modbus TCP port 502
|
||||||
- Maximum simultaneous connections: 10 (R530 uses 1, leaving 9 available)
|
- Unit ID: 1, Float format: **FP B**
|
||||||
- Unit ID: 0x00 (not used in Modbus TCP mode)
|
|
||||||
- Float format must be set to **FP B** on the controller
|
|
||||||
|
|||||||
@@ -93,6 +93,61 @@ public class PointBuilderApplyDto
|
|||||||
public List<string> SelectedNodeIds { get; set; } = new();
|
public List<string> SelectedNodeIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── HC900 Point Builder DTOs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class Hc900PointBuilderGroupDto
|
||||||
|
{
|
||||||
|
public List<string> TagPatterns { get; set; } = new();
|
||||||
|
public List<string> ParamTypes { get; set; } = new();
|
||||||
|
public string? DataType { get; set; }
|
||||||
|
public int? LoopNo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Hc900PointBuilderBuildDto
|
||||||
|
{
|
||||||
|
public Hc900PointBuilderGroupDto Loop { get; set; } = new();
|
||||||
|
public Hc900PointBuilderGroupDto Signal { get; set; } = new();
|
||||||
|
public Hc900PointBuilderGroupDto Digital { get; set; } = new();
|
||||||
|
public Hc900PointBuilderGroupDto Variable { get; set; } = new();
|
||||||
|
public Hc900PointBuilderGroupDto Custom { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Hc900PointBuilderPreviewItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tagName")] public string TagName { get; set; } = "";
|
||||||
|
[JsonPropertyName("hc900Tag")] public string Hc900Tag { get; set; } = "";
|
||||||
|
[JsonPropertyName("modbusAddr")] public int ModbusAddr { get; set; }
|
||||||
|
[JsonPropertyName("paramType")] public string ParamType { get; set; } = "";
|
||||||
|
[JsonPropertyName("dataType")] public string DataType { get; set; } = "";
|
||||||
|
[JsonPropertyName("loopNo")] public int? LoopNo { get; set; }
|
||||||
|
[JsonPropertyName("access")] public string Access { get; set; } = "R";
|
||||||
|
[JsonPropertyName("controllerId")]public string ControllerId { get; set; } = "HC1";
|
||||||
|
[JsonPropertyName("group")] public string Group { get; set; } = "";
|
||||||
|
[JsonPropertyName("isActive")] public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Hc900PointBuilderPreviewResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("count")] public int Count { get; set; }
|
||||||
|
[JsonPropertyName("items")] public List<Hc900PointBuilderPreviewItem> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Hc900PointBuilderApplyDto
|
||||||
|
{
|
||||||
|
public List<string> SelectedTagNames { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Hc900PointBuilderAddDto
|
||||||
|
{
|
||||||
|
public string TagName { get; set; } = "";
|
||||||
|
public string Hc900Tag { get; set; } = "";
|
||||||
|
public int ModbusAddr { get; set; }
|
||||||
|
public string DataType { get; set; } = "float32";
|
||||||
|
public string? ParamType { get; set; }
|
||||||
|
public string Access { get; set; } = "R";
|
||||||
|
public string ControllerId { get; set; } = "HC1";
|
||||||
|
}
|
||||||
|
|
||||||
// ── TimeScaleDB 하이퍼테이블 ────────────────────────────────────────────────────
|
// ── TimeScaleDB 하이퍼테이블 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class HypertableStatusDto
|
public class HypertableStatusDto
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class TagMetadata
|
|||||||
[Column("value")] public string? Value { get; set; }
|
[Column("value")] public string? Value { get; set; }
|
||||||
[Column("node_id")] public string? NodeId { get; set; }
|
[Column("node_id")] public string? NodeId { get; set; }
|
||||||
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
|
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
|
||||||
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
|
[Column("controller_id")] public string ControllerId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Knowledge Base (RAG) ─────────────────────────────────────────────────────
|
// ── Knowledge Base (RAG) ─────────────────────────────────────────────────────
|
||||||
@@ -216,4 +216,12 @@ public class Hc900MapEntry
|
|||||||
|
|
||||||
[Column("controller_id")]
|
[Column("controller_id")]
|
||||||
public string ControllerId { get; set; } = "HC1";
|
public string ControllerId { get; set; } = "HC1";
|
||||||
|
|
||||||
|
/// <summary>실시간 폴링 대상 여부 (Hc900RealtimeService poll 대상 필터)</summary>
|
||||||
|
[Column("realtime_enabled")]
|
||||||
|
public bool RealtimeEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>history_table 아카이브 대상 여부 (SnapshotToHistoryAsync 필터)</summary>
|
||||||
|
[Column("archive_enabled")]
|
||||||
|
public bool ArchiveEnabled { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ builder.Services.AddScoped<Hc900Crawler.Infrastructure.Control.FeedRampAdvisorSe
|
|||||||
// 측류 추종(ON/OFF) 상태 저장소
|
// 측류 추종(ON/OFF) 상태 저장소
|
||||||
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFfTrackingStore,
|
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFfTrackingStore,
|
||||||
Hc900Crawler.Infrastructure.Control.FfTrackingStore>();
|
Hc900Crawler.Infrastructure.Control.FfTrackingStore>();
|
||||||
|
// 모듈1 shadow 검증용 인메모리 버퍼
|
||||||
|
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFfShadowStore,
|
||||||
|
Hc900Crawler.Infrastructure.Control.FfShadowStore>();
|
||||||
// 작업 B: FEED 램프 실행기 + 작업 저장소
|
// 작업 B: FEED 램프 실행기 + 작업 저장소
|
||||||
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFeedRampJobStore,
|
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFeedRampJobStore,
|
||||||
Hc900Crawler.Infrastructure.Control.FeedRampJobStore>();
|
Hc900Crawler.Infrastructure.Control.FeedRampJobStore>();
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
"McpServer": {
|
"McpServer": {
|
||||||
"WorkingDirectory": "../../mcp-server"
|
"WorkingDirectory": "../../mcp-server"
|
||||||
},
|
},
|
||||||
|
"Sinam": {
|
||||||
|
"ScriptPath": "scripts/build_register_map_from_sinam.py",
|
||||||
|
"WorkingDirectory": "/home/windpacer/projects/hc900_ax",
|
||||||
|
"UploadDir": "docs/uploads",
|
||||||
|
"OutputDir": "docs",
|
||||||
|
"ProcessTimeoutSeconds": 120
|
||||||
|
},
|
||||||
"DocBrowser": {
|
"DocBrowser": {
|
||||||
"Root": "/home/windpacer/projects/hc900_ax",
|
"Root": "/home/windpacer/projects/hc900_ax",
|
||||||
"MaxTextBytes": 2097152,
|
"MaxTextBytes": 2097152,
|
||||||
@@ -65,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SteamAdvisor": {
|
"SteamAdvisor": {
|
||||||
"ModelPath": "/home/windpacer/projects/hc900_ax/scripts/analysis/C-6111_model.json",
|
"ModelPath": "/home/windpacer/projects/hc900_ax/scripts/analysis/C-6PG-Dump-20260605111_model.json",
|
||||||
"PlotDataDir": "/home/windpacer/projects/hc900_ax/scripts/analysis",
|
"PlotDataDir": "/home/windpacer/projects/hc900_ax/scripts/analysis",
|
||||||
"ModelDir": "/home/windpacer/projects/hc900_ax/scripts/analysis",
|
"ModelDir": "/home/windpacer/projects/hc900_ax/scripts/analysis",
|
||||||
"DefaultColumn": "C-6111",
|
"DefaultColumn": "C-6111",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" data-tab="pb">
|
<li class="nav-item" data-tab="pb">
|
||||||
<span class="ni">01</span>
|
<span class="ni">01</span>
|
||||||
<span class="nl">태그 관리</span>
|
<span class="nl">포인트빌더</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" data-tab="hist">
|
<li class="nav-item" data-tab="hist">
|
||||||
<span class="ni">02</span>
|
<span class="ni">02</span>
|
||||||
|
|||||||
@@ -265,6 +265,246 @@ public class Hc900DbContext : DbContext
|
|||||||
e.HasIndex(x => x.ExpiresAt);
|
e.HasIndex(x => x.ExpiresAt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── HC900 Point Builder ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<int> Hc900BuildRealtimeTableAsync(
|
||||||
|
IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)> groups)
|
||||||
|
{
|
||||||
|
using var tx = await Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Database.ExecuteSqlRawAsync(
|
||||||
|
"UPDATE hc900_map_master SET is_active = false");
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
foreach (var (groupKey, group) in groups)
|
||||||
|
{
|
||||||
|
var matched = await BuildGroupQuery(group).ToListAsync();
|
||||||
|
foreach (var entry in matched)
|
||||||
|
{
|
||||||
|
entry.IsActive = true;
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await SaveChangesAsync();
|
||||||
|
|
||||||
|
await Hc900SyncRealtimeTableAsync();
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await tx.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Hc900PointBuilderPreviewResult> Hc900PreviewRealtimeBuildAsync(
|
||||||
|
IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)> groups)
|
||||||
|
{
|
||||||
|
var items = new List<Hc900PointBuilderPreviewItem>();
|
||||||
|
foreach (var (groupKey, group) in groups)
|
||||||
|
{
|
||||||
|
var matched = await BuildGroupQuery(group)
|
||||||
|
.Select(e => new Hc900PointBuilderPreviewItem
|
||||||
|
{
|
||||||
|
TagName = e.TagName,
|
||||||
|
Hc900Tag = e.Hc900Tag,
|
||||||
|
ModbusAddr = e.ModbusAddr,
|
||||||
|
ParamType = e.ParamType ?? "",
|
||||||
|
DataType = e.DataType,
|
||||||
|
LoopNo = e.LoopNo,
|
||||||
|
Access = e.Access,
|
||||||
|
ControllerId = e.ControllerId,
|
||||||
|
Group = groupKey,
|
||||||
|
IsActive = e.IsActive
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
items.AddRange(matched);
|
||||||
|
}
|
||||||
|
return new Hc900PointBuilderPreviewResult { Count = items.Count, Items = items };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> Hc900ApplySelectedPointsAsync(IEnumerable<string> selectedTagNames)
|
||||||
|
{
|
||||||
|
var tagNames = selectedTagNames.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||||
|
if (tagNames.Count == 0) return 0;
|
||||||
|
|
||||||
|
using var tx = await Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Database.ExecuteSqlRawAsync(
|
||||||
|
"UPDATE hc900_map_master SET is_active = false");
|
||||||
|
|
||||||
|
var count = await Hc900MapEntries
|
||||||
|
.Where(x => tagNames.Contains(x.TagName))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true));
|
||||||
|
|
||||||
|
await Hc900SyncRealtimeTableAsync();
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await tx.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> Hc900AppendPointsAsync(IEnumerable<string> tagNames)
|
||||||
|
{
|
||||||
|
var tagNamesList = tagNames.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||||
|
if (tagNamesList.Count == 0) return 0;
|
||||||
|
|
||||||
|
using var tx = await Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var count = await Hc900MapEntries
|
||||||
|
.Where(x => tagNamesList.Contains(x.TagName) && !x.IsActive)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true));
|
||||||
|
|
||||||
|
await Hc900SyncRealtimeTableAsync();
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await tx.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RealtimePoint> Hc900AddRealtimePointAsync(
|
||||||
|
string tagName, string hc900Tag, int modbusAddr,
|
||||||
|
string dataType, string? paramType, string access, string controllerId)
|
||||||
|
{
|
||||||
|
var existing = await Hc900MapEntries
|
||||||
|
.FirstOrDefaultAsync(x => x.TagName == tagName && x.ControllerId == controllerId);
|
||||||
|
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
existing = new Hc900MapEntry
|
||||||
|
{
|
||||||
|
TagName = tagName,
|
||||||
|
Hc900Tag = hc900Tag ?? tagName,
|
||||||
|
ModbusAddr = modbusAddr,
|
||||||
|
DataType = dataType,
|
||||||
|
ParamType = paramType,
|
||||||
|
Access = access,
|
||||||
|
ControllerId = controllerId,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
Hc900MapEntries.Add(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.IsActive = true;
|
||||||
|
}
|
||||||
|
await SaveChangesAsync();
|
||||||
|
|
||||||
|
return await Hc900SyncSinglePointAsync(tagName, controllerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Point Builder Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private IQueryable<Hc900MapEntry> BuildGroupQuery(Hc900PointBuilderGroupDto group)
|
||||||
|
{
|
||||||
|
var q = Hc900MapEntries.AsQueryable();
|
||||||
|
|
||||||
|
if (group.TagPatterns?.Any() == true)
|
||||||
|
{
|
||||||
|
var patterns = group.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
|
||||||
|
if (patterns.Count > 0)
|
||||||
|
{
|
||||||
|
q = q.Where(x => patterns.Any(p => EF.Functions.ILike(x.TagName, $"%{p}%")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.ParamTypes?.Any() == true)
|
||||||
|
{
|
||||||
|
var validTypes = group.ParamTypes.Where(p => !string.IsNullOrEmpty(p)).ToList();
|
||||||
|
if (validTypes.Count > 0)
|
||||||
|
q = q.Where(x => validTypes.Contains(x.ParamType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(group.DataType))
|
||||||
|
q = q.Where(x => x.DataType == group.DataType);
|
||||||
|
|
||||||
|
if (group.LoopNo.HasValue)
|
||||||
|
q = q.Where(x => x.LoopNo == group.LoopNo.Value);
|
||||||
|
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Hc900SyncRealtimeTableAsync()
|
||||||
|
{
|
||||||
|
var activeTags = await Hc900MapEntries
|
||||||
|
.Where(x => x.IsActive)
|
||||||
|
.Select(x => new { x.TagName, x.Hc900Tag, x.ControllerId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var activeTagNames = activeTags.Select(t => t.TagName).ToList();
|
||||||
|
|
||||||
|
var existingTagNames = await RealtimePoints
|
||||||
|
.Select(r => r.TagName)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var toRemove = existingTagNames.Except(activeTagNames).ToList();
|
||||||
|
if (toRemove.Count > 0)
|
||||||
|
{
|
||||||
|
RealtimePoints.RemoveRange(
|
||||||
|
RealtimePoints.Where(r => toRemove.Contains(r.TagName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var toAddTagNames = activeTagNames.Except(existingTagNames).ToList();
|
||||||
|
if (toAddTagNames.Count > 0)
|
||||||
|
{
|
||||||
|
var toAdd = activeTags
|
||||||
|
.Where(t => toAddTagNames.Contains(t.TagName))
|
||||||
|
.Select(t => new RealtimePoint
|
||||||
|
{
|
||||||
|
TagName = t.TagName,
|
||||||
|
NodeId = t.Hc900Tag,
|
||||||
|
LiveValue = null,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
ControllerId = t.ControllerId
|
||||||
|
}).ToList();
|
||||||
|
RealtimePoints.AddRange(toAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RealtimePoint> Hc900SyncSinglePointAsync(string tagName, string controllerId)
|
||||||
|
{
|
||||||
|
var existing = await RealtimePoints
|
||||||
|
.FirstOrDefaultAsync(r => r.TagName == tagName && r.ControllerId == controllerId);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Timestamp = DateTime.UtcNow;
|
||||||
|
await SaveChangesAsync();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var point = new RealtimePoint
|
||||||
|
{
|
||||||
|
TagName = tagName,
|
||||||
|
NodeId = tagName,
|
||||||
|
LiveValue = null,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
ControllerId = controllerId
|
||||||
|
};
|
||||||
|
RealtimePoints.Add(point);
|
||||||
|
await SaveChangesAsync();
|
||||||
|
return point;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service ──────────────────────────────────────────────────────────────────
|
// ── Service ──────────────────────────────────────────────────────────────────
|
||||||
@@ -274,12 +514,21 @@ public class Hc900DbService : IExperionDbService
|
|||||||
private readonly Hc900DbContext _ctx;
|
private readonly Hc900DbContext _ctx;
|
||||||
private readonly ILogger<Hc900DbService> _logger;
|
private readonly ILogger<Hc900DbService> _logger;
|
||||||
|
|
||||||
private static HashSet<string> _digitalTagCache = new();
|
|
||||||
private static DateTime _digitalTagCacheTime = DateTime.MinValue;
|
|
||||||
private static Task? _cacheRefreshTask = null;
|
|
||||||
private static readonly object _digitalCacheLock = new();
|
|
||||||
private const int DigitalTagCacheTtlSeconds = 300;
|
private const int DigitalTagCacheTtlSeconds = 300;
|
||||||
|
|
||||||
|
// archive_enabled = TRUE 인 (controller_id, tagname) 쌍 캐시.
|
||||||
|
// peer 통신으로 동일 tagname이 컨트롤러마다 다른 주소/플래그로 존재할 수 있어 컨트롤러 단위로 매칭한다.
|
||||||
|
private static HashSet<(string ControllerId, string TagName)> _archiveTagPairCache = new();
|
||||||
|
private static DateTime _archiveTagCacheTime = DateTime.MinValue;
|
||||||
|
private static Task? _archiveCacheRefreshTask = null;
|
||||||
|
private static readonly object _archiveCacheLock = new();
|
||||||
|
|
||||||
|
// 디지털 포인트 (controller_id, name) 쌍 캐시 — 위와 동일한 이유로 컨트롤러 단위 매칭.
|
||||||
|
private static HashSet<(string ControllerId, string Name)> _digitalTagPairCache = new();
|
||||||
|
private static DateTime _digitalTagPairCacheTime = DateTime.MinValue;
|
||||||
|
private static Task? _digitalPairCacheRefreshTask = null;
|
||||||
|
private static readonly object _digitalPairCacheLock = new();
|
||||||
|
|
||||||
public Hc900DbService(Hc900DbContext ctx, ILogger<Hc900DbService> logger)
|
public Hc900DbService(Hc900DbContext ctx, ILogger<Hc900DbService> logger)
|
||||||
{
|
{
|
||||||
_ctx = ctx;
|
_ctx = ctx;
|
||||||
@@ -453,7 +702,7 @@ public class Hc900DbService : IExperionDbService
|
|||||||
await _ctx.Database.ExecuteSqlRawAsync(
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
"ALTER TABLE realtime_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
|
"ALTER TABLE realtime_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
|
||||||
await _ctx.Database.ExecuteSqlRawAsync(
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
"ALTER TABLE history_table ADD COLUMN IF NOT EXISTS controller_id TEXT DEFAULT 'HC1'");
|
"ALTER TABLE history_table ADD COLUMN IF NOT EXISTS controller_id TEXT");
|
||||||
await _ctx.Database.ExecuteSqlRawAsync(
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
"ALTER TABLE event_history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
|
"ALTER TABLE event_history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
|
||||||
await _ctx.Database.ExecuteSqlRawAsync(
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
@@ -461,6 +710,12 @@ public class Hc900DbService : IExperionDbService
|
|||||||
await _ctx.Database.ExecuteSqlRawAsync(
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
"ALTER TABLE hc900_map_master ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
|
"ALTER TABLE hc900_map_master ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
|
||||||
|
|
||||||
|
// hc900_map_master: realtime_enabled / archive_enabled columns (register-map generation v2)
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
|
"ALTER TABLE hc900_map_master ADD COLUMN IF NOT EXISTS realtime_enabled BOOLEAN NOT NULL DEFAULT TRUE");
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
|
"ALTER TABLE hc900_map_master ADD COLUMN IF NOT EXISTS archive_enabled BOOLEAN NOT NULL DEFAULT FALSE");
|
||||||
|
|
||||||
// hc900_map_master: tagname is unique PER controller, not globally — the same
|
// hc900_map_master: tagname is unique PER controller, not globally — the same
|
||||||
// SignalTag name can exist on several controllers (peer comms). Replace the old
|
// SignalTag name can exist on several controllers (peer comms). Replace the old
|
||||||
// UNIQUE(tagname) with UNIQUE(controller_id, tagname).
|
// UNIQUE(tagname) with UNIQUE(controller_id, tagname).
|
||||||
@@ -485,6 +740,32 @@ public class Hc900DbService : IExperionDbService
|
|||||||
END $$;
|
END $$;
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
// tag_metadata: same base_tag/attribute can exist per controller (peer comms),
|
||||||
|
// so the unique key must include controller_id. The register-map loader upserts
|
||||||
|
// with ON CONFLICT(base_tag, attribute, controller_id); the old UNIQUE(base_tag,
|
||||||
|
// attribute) made every metadata upsert fail. Replace it.
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'tag_metadata'::regclass
|
||||||
|
AND conname = 'tag_metadata_base_tag_attribute_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tag_metadata DROP CONSTRAINT tag_metadata_base_tag_attribute_key;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'tag_metadata'::regclass
|
||||||
|
AND conname = 'tag_metadata_base_tag_attribute_controller_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tag_metadata
|
||||||
|
ADD CONSTRAINT tag_metadata_base_tag_attribute_controller_key
|
||||||
|
UNIQUE (base_tag, attribute, controller_id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""");
|
||||||
|
|
||||||
// realtime_table: UNIQUE(controller_id, tagname) for ON CONFLICT upsert
|
// realtime_table: UNIQUE(controller_id, tagname) for ON CONFLICT upsert
|
||||||
// Also drop the legacy tagname-only UNIQUE index/constraint that would
|
// Also drop the legacy tagname-only UNIQUE index/constraint that would
|
||||||
// conflict with peer-mirrored tags (same tagname, different controller_id).
|
// conflict with peer-mirrored tags (same tagname, different controller_id).
|
||||||
@@ -1566,16 +1847,24 @@ public class Hc900DbService : IExperionDbService
|
|||||||
if (!string.IsNullOrEmpty(controllerId))
|
if (!string.IsNullOrEmpty(controllerId))
|
||||||
query = query.Where(p => p.ControllerId == controllerId);
|
query = query.Where(p => p.ControllerId == controllerId);
|
||||||
|
|
||||||
|
var points = await query.ToListAsync();
|
||||||
|
if (points.Count == 0) return 0;
|
||||||
|
|
||||||
|
// archive_enabled = TRUE 인 (controller_id, tagname) 쌍만 아카이브.
|
||||||
|
// EF가 튜플 Contains를 SQL로 변환하지 못하므로 머티리얼라이즈 후 인메모리로 필터링한다
|
||||||
|
// (realtime_table은 수천 행 규모라 비용 무시 가능). 쌍이 비면(미설정/마이그레이션 전) 전체 아카이브 — 레거시 폴백.
|
||||||
|
var archivePairs = await GetArchiveEnabledTagPairsCachedAsync();
|
||||||
|
if (archivePairs.Count > 0)
|
||||||
|
points = points.Where(p => archivePairs.Contains((p.ControllerId, p.TagName))).ToList();
|
||||||
|
|
||||||
|
// 디지털 포인트 제외 — 동일하게 컨트롤러 단위 쌍 매칭.
|
||||||
if (!includeDigital)
|
if (!includeDigital)
|
||||||
{
|
{
|
||||||
var digitalTagNames = await GetDigitalTagNamesCachedAsync();
|
var digitalPairs = await GetDigitalTagPairsCachedAsync();
|
||||||
if (digitalTagNames.Count > 0)
|
if (digitalPairs.Count > 0)
|
||||||
{
|
points = points.Where(p => !digitalPairs.Contains((p.ControllerId, p.TagName))).ToList();
|
||||||
query = query.Where(p => !digitalTagNames.Contains(p.TagName));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var points = await query.ToListAsync();
|
|
||||||
if (points.Count == 0) return 0;
|
if (points.Count == 0) return 0;
|
||||||
|
|
||||||
var rows = points.Select(p => new HistoryRecord
|
var rows = points.Select(p => new HistoryRecord
|
||||||
@@ -1593,8 +1882,10 @@ public class Hc900DbService : IExperionDbService
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이력 조회 드롭다운용 — history_table에 실제 존재하는 태그만 반환한다.
|
||||||
|
// (realtime_table을 추종하면 history에 없는 폴링 태그까지 노출됨. idx_hist_tag로 DISTINCT는 수 ms.)
|
||||||
public async Task<IEnumerable<string>> GetTagNamesAsync()
|
public async Task<IEnumerable<string>> GetTagNamesAsync()
|
||||||
=> await _ctx.RealtimePoints.Select(x => x.TagName).OrderBy(x => x).ToListAsync();
|
=> await _ctx.HistoryRecords.Select(x => x.TagName).Distinct().OrderBy(x => x).ToListAsync();
|
||||||
|
|
||||||
public async Task<HistoryQueryResult> QueryHistoryAsync(
|
public async Task<HistoryQueryResult> QueryHistoryAsync(
|
||||||
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit)
|
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit)
|
||||||
@@ -1721,8 +2012,9 @@ public class Hc900DbService : IExperionDbService
|
|||||||
List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit)
|
List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit)
|
||||||
{
|
{
|
||||||
var selectParts = new List<string>();
|
var selectParts = new List<string>();
|
||||||
// time_bucket은 TimescaleDB 전용 함수 — PostgreSQL 내장 date_trunc로 대체
|
// date_trunc 단위를 intervalStr에서 추출 (예: "1 minute" → "minute")
|
||||||
selectParts.Add($"date_trunc('second', recorded_at) AS time_bucket");
|
var truncUnit = ParseIntervalToTruncUnit(intervalStr);
|
||||||
|
selectParts.Add($"date_trunc('{truncUnit}', recorded_at) AS time_bucket");
|
||||||
|
|
||||||
// 태그명별로 동적으로 PIVOT 컬럼 생성 (MAX + CASE)
|
// 태그명별로 동적으로 PIVOT 컬럼 생성 (MAX + CASE)
|
||||||
foreach (var tag in tags)
|
foreach (var tag in tags)
|
||||||
@@ -1754,6 +2046,16 @@ public class Hc900DbService : IExperionDbService
|
|||||||
return sql;
|
return sql;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// intervalStr에서 date_trunc 단위 추출 (예: "1 minute" → "minute", "5 minutes" → "minute")
|
||||||
|
/// </summary>
|
||||||
|
private static string ParseIntervalToTruncUnit(string interval)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(interval)) return "minute";
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(interval.ToLower().Trim(), @"^\d+\s+(second|minute|hour|day|week)");
|
||||||
|
return m.Success ? m.Groups[1].Value : "minute";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 사용자 입력 간격을 PostgreSQL INTERVAL 형식으로 변환
|
/// 사용자 입력 간격을 PostgreSQL INTERVAL 형식으로 변환
|
||||||
/// 예: "1 minute" → "1 minute", "5 minutes" → "5 minutes", "1 hour" → "1 hour"
|
/// 예: "1 minute" → "1 minute", "5 minutes" → "5 minutes", "1 hour" → "1 hour"
|
||||||
@@ -1981,15 +2283,21 @@ public class Hc900DbService : IExperionDbService
|
|||||||
|
|
||||||
public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
|
public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
|
||||||
{
|
{
|
||||||
|
// 디지털/상태 포인트 = tag_metadata에 state 라벨(state0~7)을 가진 base_tag.
|
||||||
|
// 값 N → state{N} 라벨(L-STOP/L-RUN/R-TRIP 등). 구 'i=7594' OPC UA 마커는
|
||||||
|
// 새 register-map 로더가 더 이상 생성하지 않으므로 state 라벨 보유를 기준으로 한다.
|
||||||
var fromMetadata = await _ctx.TagMetadata
|
var fromMetadata = await _ctx.TagMetadata
|
||||||
.Where(m => m.Value == "i=7594")
|
.Where(m => m.Attribute.StartsWith("state") && m.Value != null && m.Value != "")
|
||||||
.Select(m => m.BaseTag)
|
.Select(m => m.BaseTag)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (fromMetadata.Any())
|
if (fromMetadata.Any())
|
||||||
return fromMetadata;
|
// 롤아웃 후 realtime 태그명은 {base}.PV 이므로 .PV 를 붙여 반환한다(메모리에서 —
|
||||||
|
// SQL 번역 불필요). 이러면 호출부는 단순 Contains(p.TagName) 만으로 매칭 가능.
|
||||||
|
return fromMetadata.Select(b => b + ".PV").ToList();
|
||||||
|
|
||||||
|
// 폴백: realtime livevalue가 '{N | label | }' (FormatValue가 만든 디지털 포맷)
|
||||||
return await _ctx.RealtimePoints
|
return await _ctx.RealtimePoints
|
||||||
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
|
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
|
||||||
.Select(p => p.TagName)
|
.Select(p => p.TagName)
|
||||||
@@ -2005,6 +2313,8 @@ public class Hc900DbService : IExperionDbService
|
|||||||
if (tagSet.Count == 0)
|
if (tagSet.Count == 0)
|
||||||
return Enumerable.Empty<RealtimePoint>();
|
return Enumerable.Empty<RealtimePoint>();
|
||||||
|
|
||||||
|
// digitalTagNames 는 이미 .PV 명이므로 단순 Contains — substring/LastIndexOf 불필요
|
||||||
|
// (LastIndexOf 는 Npgsql 이 SQL 로 번역하지 못해 런타임 에러를 일으킴).
|
||||||
return await _ctx.RealtimePoints
|
return await _ctx.RealtimePoints
|
||||||
.Where(p => tagSet.Contains(p.TagName))
|
.Where(p => tagSet.Contains(p.TagName))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -2032,7 +2342,7 @@ public class Hc900DbService : IExperionDbService
|
|||||||
{
|
{
|
||||||
var baseTag = tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName;
|
var baseTag = tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName;
|
||||||
return await _ctx.TagMetadata
|
return await _ctx.TagMetadata
|
||||||
.Where(m => m.BaseTag.Equals(baseTag, StringComparison.OrdinalIgnoreCase) && m.Attribute == "sub_area")
|
.Where(m => m.BaseTag == baseTag && m.Attribute == "sub_area")
|
||||||
.Select(m => m.Value)
|
.Select(m => m.Value)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
@@ -2374,34 +2684,91 @@ public class Hc900DbService : IExperionDbService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<HashSet<string>> GetDigitalTagNamesCachedAsync()
|
// ── archive_enabled (controller_id, tagname) 쌍 캐시 ─────────────────────────
|
||||||
|
private async Task<HashSet<(string, string)>> GetArchiveEnabledTagPairsCachedAsync()
|
||||||
{
|
{
|
||||||
if ((DateTime.UtcNow - _digitalTagCacheTime).TotalSeconds < DigitalTagCacheTtlSeconds)
|
if ((DateTime.UtcNow - _archiveTagCacheTime).TotalSeconds < DigitalTagCacheTtlSeconds)
|
||||||
return _digitalTagCache;
|
return _archiveTagPairCache;
|
||||||
|
|
||||||
Task refreshTask;
|
Task refreshTask;
|
||||||
lock (_digitalCacheLock)
|
lock (_archiveCacheLock)
|
||||||
{
|
{
|
||||||
if (_cacheRefreshTask == null)
|
if (_archiveCacheRefreshTask == null)
|
||||||
_cacheRefreshTask = RefreshDigitalTagCacheAsync();
|
_archiveCacheRefreshTask = RefreshArchiveTagCacheAsync();
|
||||||
refreshTask = _cacheRefreshTask;
|
refreshTask = _archiveCacheRefreshTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshTask;
|
await refreshTask;
|
||||||
|
|
||||||
lock (_digitalCacheLock)
|
lock (_archiveCacheLock)
|
||||||
{
|
{
|
||||||
_cacheRefreshTask = null;
|
_archiveCacheRefreshTask = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _digitalTagCache;
|
return _archiveTagPairCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshDigitalTagCacheAsync()
|
private async Task RefreshArchiveTagCacheAsync()
|
||||||
{
|
{
|
||||||
var tags = await GetDigitalTagNamesAsync();
|
var rows = await _ctx.Hc900MapEntries
|
||||||
_digitalTagCache = new HashSet<string>(tags);
|
.Where(m => m.ArchiveEnabled && m.IsActive)
|
||||||
_digitalTagCacheTime = DateTime.UtcNow;
|
.Select(m => new { m.ControllerId, m.TagName })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
_archiveTagPairCache = rows.Select(r => (r.ControllerId, r.TagName)).ToHashSet();
|
||||||
|
_archiveTagCacheTime = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 디지털 포인트 (controller_id, name) 쌍 캐시 ──────────────────────────────
|
||||||
|
private async Task<HashSet<(string, string)>> GetDigitalTagPairsCachedAsync()
|
||||||
|
{
|
||||||
|
if ((DateTime.UtcNow - _digitalTagPairCacheTime).TotalSeconds < DigitalTagCacheTtlSeconds)
|
||||||
|
return _digitalTagPairCache;
|
||||||
|
|
||||||
|
Task refreshTask;
|
||||||
|
lock (_digitalPairCacheLock)
|
||||||
|
{
|
||||||
|
if (_digitalPairCacheRefreshTask == null)
|
||||||
|
_digitalPairCacheRefreshTask = RefreshDigitalTagPairCacheAsync();
|
||||||
|
refreshTask = _digitalPairCacheRefreshTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshTask;
|
||||||
|
|
||||||
|
lock (_digitalPairCacheLock)
|
||||||
|
{
|
||||||
|
_digitalPairCacheRefreshTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _digitalTagPairCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshDigitalTagPairCacheAsync()
|
||||||
|
{
|
||||||
|
_digitalTagPairCache = await GetDigitalTagPairsAsync();
|
||||||
|
_digitalTagPairCacheTime = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>디지털 태그를 (controller_id, name) 쌍으로 조회.
|
||||||
|
/// GetDigitalTagNamesAsync 와 동일한 소스(메타데이터 state 라벨 보유 base_tag,
|
||||||
|
/// 폴백: realtime live "{")에 controller_id 차원만 추가한 버전.</summary>
|
||||||
|
private async Task<HashSet<(string, string)>> GetDigitalTagPairsAsync()
|
||||||
|
{
|
||||||
|
var fromMetadata = await _ctx.TagMetadata
|
||||||
|
.Where(m => m.Attribute.StartsWith("state") && m.Value != null && m.Value != "")
|
||||||
|
.Select(m => new { m.ControllerId, m.BaseTag })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (fromMetadata.Any())
|
||||||
|
return fromMetadata.Select(m => (m.ControllerId, m.BaseTag + ".PV")).ToHashSet();
|
||||||
|
|
||||||
|
var fromRealtime = await _ctx.RealtimePoints
|
||||||
|
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
|
||||||
|
.Select(p => new { p.ControllerId, p.TagName })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
return fromRealtime.Select(p => (p.ControllerId, p.TagName)).ToHashSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ public class Hc900RealtimeService : BackgroundService, IHc900RealtimeService
|
|||||||
await using var conn = new NpgsqlConnection(_connectionString);
|
await using var conn = new NpgsqlConnection(_connectionString);
|
||||||
await conn.OpenAsync(ct);
|
await conn.OpenAsync(ct);
|
||||||
await using var cmd = new NpgsqlCommand(
|
await using var cmd = new NpgsqlCommand(
|
||||||
"SELECT tagname, hc900_tag FROM hc900_map_master WHERE is_active = TRUE AND controller_id = $1", conn);
|
"SELECT tagname, hc900_tag FROM hc900_map_master WHERE is_active = TRUE AND realtime_enabled = TRUE AND controller_id = $1", conn);
|
||||||
cmd.Parameters.Add(new NpgsqlParameter { Value = controllerId });
|
cmd.Parameters.Add(new NpgsqlParameter { Value = controllerId });
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
while (await reader.ReadAsync(ct))
|
while (await reader.ReadAsync(ct))
|
||||||
|
|||||||
Reference in New Issue
Block a user