# ExperionCrawler — OPC UA Write 기능 추가 구현 계획

> **⚠️ 이 문서를 실행할 LLM에게**
>
> 이 문서는 ExperionCrawler 프로젝트에 OPC UA Write 기능(SP/OP/MD/MODE 쓰기)을 추가하는 구현 계획이다.
> 아래 규칙을 반드시 준수하여 구현할 것:
>
> 1. **diagnosis-checklist.md 규칙 준수**: STEP 1~8을 순서대로 실행. 코드를 읽지 않고 구현하지 말 것.
> 2. **JSON camelCase 규칙**: 모든 Controller `Ok(...)` 응답은 명시적 camelCase 키 사용.
>    예: `return Ok(new { success = r.Success, nodeId = r.NodeId, status = r.Status, error = r.Error });`
> 3. **네임스페이스**: `ExperionCrawler.Infrastructure.OpcUa` (Infrastructure), `ExperionCrawler.Core.Application.Interfaces` (인터페이스), `ExperionCrawler.Core.Application.DTOs` (DTO)
> 4. **단위별 구현**: 각 TODO 항목을 하나씩 완료하고, 검증 단계를 실행한 후 다음으로 진행.
> 5. **빌드 검증**: 모든 코드 변경 후 `dotnet build src/Web/ExperionCrawler.csproj`로 빌드 확인.
> 6. **기존 코드 수정 금지**: `ExperionOpcClient.cs`(읽기 전용)는 수정하지 않음. 신규 파일로 추가.
> 7. **DI 등록**: Program.cs에 신규 서비스를 Scoped로 등록.
> 8. **완료 표시**: 각 TODO 항목 완료 시 `[ ]` → `[x]`로 변경하고, 검증 결과 기록.
> 9. **프론트엔드**: `wwwroot/js/app.js`에 Write 탭 UI 추가. 기존 패턴(tab navigation, `api()` helper, `log()` helper) 따름.
> 10. **보안**: 쓰기 이력 로깅은 DB가 아닌 Application Log로만 기록 (단순화).

---

## 테스트 결과 요약 (TEST_RESULTS.md 기반)

| 태그 | 노드 ID | DataType | 방법 | 결과 |
|------|---------|----------|------|------|
| SP | `ns=1;s=sinamserver:ficq-6101.sp` | float | `WriteAsync()` 직접 | ✅ 성공 |
| OP | `ns=1;s=sinamserver:ficq-6101.op` | Double | `WriteAsync()` (MD=MAN 필요) | ✅ 성공 |
| MD | `ns=1;s=sinamserver:ficq-6101.md` | EnumValueType | `EnumValueType` Write | ✅ 성공 |
| Mode | `ns=1;s=sinamserver:ficq-6101.mode` | EnumValueType | `EnumValueType` Write | ✅ 성공 |

### 핵심 발견
- **MD/Mode는 Method가 아님**: `EnumValueType(Value, DisplayName, Description)`을 `WriteAsync()`로 직접 쓰기
- **OP 쓰기 조건**: MD를 MAN(0)으로 변경해야 OP 쓰기 가능 (AUTO면 `BadNoData`)
- **SDK v1.5.378**: `WriteValue`(WriteValueId 아님), `DefaultSessionFactory.CreateAsync()` 사용

---

## 구현 TODO 리스트

### Phase 1: Backend Core (Infrastructure + Interface + DTO)

#### TODO 1-1: `IExperionOpcWriteClient` 인터페이스 정의
- [ ] **파일**: `src/Core/Application/Interfaces/IExperionServices.cs`
- [ ] **작업**: 기존 파일 하단에 인터페이스 추가
- [ ] **내용**:
  ```csharp
  public interface IExperionOpcWriteClient
  {
      Task<ExperionWriteResultDto> WriteTagAsync(ExperionServerConfig cfg, string nodeId, double value, CancellationToken ct = default);
      Task<ExperionModeResultDto> SetModeAsync(ExperionServerConfig cfg, string nodeId, string mode, CancellationToken ct = default);
  }
  ```
- [ ] **검증**: `dotnet build` 성공

#### TODO 1-2: Write Result DTOs 정의
- [ ] **파일**: `src/Core/Application/DTOs/ExperionDtos.cs`
- [ ] **작업**: 파일 하단에 DTO 클래스 추가
- [ ] **내용**:
  ```csharp
  public class ExperionWriteResultDto
  {
      public bool Success { get; set; }
      public string NodeId { get; set; } = "";
      public string Status { get; set; } = "";
      public string? Error { get; set; }
  }
  public class ExperionModeResultDto
  {
      public bool Success { get; set; }
      public string NodeId { get; set; } = "";
      public string Mode { get; set; } = "";
      public int EnumValue { get; set; }
      public string Status { get; set; } = "";
      public string? Error { get; set; }
  }
  ```
- [ ] **검증**: `dotnet build` 성공

#### TODO 1-3: `ExperionOpcWriteClient` 구현체 생성
- [ ] **파일**: `src/Infrastructure/OpcUa/ExperionOpcWriteClient.cs` (신규)
- [ ] **작업**: `/tmp/opcua-test/ExperionOpcWriteClient.cs`를 기반으로 구현. 변경 사항:
  - 네임스페이스: `ExperionCrawler.Infrastructure.OpcUa`
  - `IExperionOpcWriteClient` 구현
  - `IOpcUaConfigProvider` 의존성 주입 (기존 `ExperionOpcClient`와 동일 패턴)
  - `BuildConfigAsync`, `SelectEndpointAsync`, `CreateSessionAsync`는 `ExperionOpcClient`와 동일한 로직 사용
  - `ExperionServerConfig` 사용 (별도 파라미터 아님)
  - Result 타입: `ExperionWriteResultDto`, `ExperionModeResultDto` (Core DTO 사용)
- [ ] **검증**: `dotnet build` 성공

#### TODO 1-4: Program.cs에 DI 등록
- [ ] **파일**: `src/Web/Program.cs`
- [ ] **작업**: `IExperionOpcClient` 등록 바로 아래에 `IExperionOpcWriteClient` 등록 추가
  ```csharp
  builder.Services.AddScoped<IExperionOpcWriteClient, ExperionOpcWriteClient>();
  ```
- [ ] **검증**: `dotnet build` 성공

---

### Phase 2: API Controller

#### TODO 2-1: Write Controller 생성
- [ ] **파일**: `src/Web/Controllers/ExperionControllers.cs`
- [ ] **작업**: 파일 하단에 신규 Controller 추가
- [ ] **내용**:
  ```
  [ApiController]
  [Route("api/points")]
  public class ExperionWriteController : ControllerBase
  {
      private readonly IExperionOpcWriteClient _writeClient;
      private readonly IExperionOpcClient _readClient;
      private readonly IConfiguration _config;
      private readonly ILogger<ExperionWriteController> _logger;
      // ...
  }
  ```
- [ ] **엔드포인트**:
  - `POST /api/points/write` — SP/OP 쓰기
  - `POST /api/points/mode` — MD/MODE 변경
  - `POST /api/points/control` — 통합 제어 (MD→MAN → OP 쓰기 → AUTO 복귀)
  - `POST /api/points/read` — 태그 현재값 읽기 (확인용)
- [ ] **Request DTOs** (Controller 내부에 정의):
  ```csharp
  public class WriteTagRequest(string NodeId, double Value);
  public class SetModeRequest(string NodeId, string Mode);
  public class ControlOpRequest(string TagName, double OpValue, bool? RestoreAuto = true);
  ```
- [ ] **ServerConfig**: `appsettings.json`의 `Experion:Default` 섹션에서 읽음 (기존 패턴 동일)
- [ ] **camelCase 응답**: 모든 `Ok(new { success = ..., nodeId = ..., status = ..., error = ... })`
- [ ] **검증**: `dotnet build` 성공

---

### Phase 3: Frontend UI

#### TODO 3-1: Write 탭 HTML 추가
- [ ] **파일**: `src/Web/wwwroot/index.html`
- [ ] **작업**:
  - nav-item 추가: `<a class="nav-item" data-tab="write">Write</a>`
  - pane-write 추가: SP/OP/Mode 입력 폼 + 로그 영역
- [ ] **UI 구성**:
  - 태그명 입력 (예: `ficq-6101`)
  - 속성 선택: SP / OP / MD / Mode
  - 값 입력 (SP/OP: 숫자, MD/Mode: MAN/AUTO 선택)
  - 실행 버튼
  - 통합 제어: "OP 변경 (자동 MAN→AUTO)" 체크박스
  - 로그 출력 영역
- [ ] **검증**: 브라우저에서 탭 전환 확인

#### TODO 3-2: Write 탭 JS 구현
- [ ] **파일**: `src/Web/wwwroot/js/app.js`
- [ ] **작업**: 파일 하단에 Write 관련 함수 추가
- [ ] **함수**:
  - `writeTag()` — `/api/points/write` 호출
  - `setMode()` — `/api/points/mode` 호출
  - `controlOp()` — `/api/points/control` 호출
  - `readTagValue()` — `/api/points/read` 호출
- [ ] **검증**: 브라우저에서 Write 탭에서 SP 쓰기 테스트

---

### Phase 4: 검증 및 테스트

#### TODO 4-1: 빌드 검증
- [ ] `dotnet build src/Web/ExperionCrawler.csproj` — 오류 없음
- [ ] `dotnet test` — 기존 테스트 통과

#### TODO 4-2: API 테스트 (Postman/curl)
- [ ] `POST /api/points/write` — ficq-6101.sp = 36.0 → 성공
- [ ] `POST /api/points/mode` — ficq-6101.md = MAN → 성공
- [ ] `POST /api/points/control` — ficq-6101 OP = 35.5 → MAN 전환 → OP 쓰기 → AUTO 복귀 → 성공
- [ ] `POST /api/points/read` — ficq-6101.sp → 현재값 반환

#### TODO 4-3: Frontend 테스트
- [ ] Write 탭에서 SP 쓰기 UI 테스트
- [ ] Mode 변경 UI 테스트
- [ ] 통합 제어 UI 테스트

---

## 파일 변경 요약

| 파일 | 액션 | 내용 |
|------|------|------|
| `src/Core/Application/Interfaces/IExperionServices.cs` | 수정 | `IExperionOpcWriteClient` 인터페이스 추가 |
| `src/Core/Application/DTOs/ExperionDtos.cs` | 수정 | `ExperionWriteResultDto`, `ExperionModeResultDto` 추가 |
| `src/Infrastructure/OpcUa/ExperionOpcWriteClient.cs` | **신규** | Write 클라이언트 구현체 |
| `src/Web/Program.cs` | 수정 | DI 등록 1줄 추가 |
| `src/Web/Controllers/ExperionControllers.cs` | 수정 | `ExperionWriteController` + Request DTOs 추가 |
| `src/Web/wwwroot/index.html` | 수정 | Write 탭 HTML 추가 |
| `src/Web/wwwroot/js/app.js` | 수정 | Write 탭 JS 함수 추가 |

---

## 구현 진행 상태

| # | TODO | 상태 | 검증 | 비고 |
|---|------|------|------|------|
| 1-1 | IExperionOpcWriteClient 인터페이스 | ✅ 완료 | 빌드 성공 | `IExperionServices.cs` 하단 추가 |
| 1-2 | Write Result DTOs | ✅ 완료 | 빌드 성공 | `ExperionDtos.cs` 하단 추가 |
| 1-3 | ExperionOpcWriteClient 구현체 | ✅ 완료 | 빌드 성공 | `IOpcUaConfigProvider` DI 사용 |
| 1-4 | Program.cs DI 등록 | ✅ 완료 | 빌드 성공 | Scoped 등록 |
| 2-1 | Write Controller | ✅ 완료 | 빌드 성공 | 4개 엔드포인트 + Request DTOs |
| 3-1 | Write 탭 HTML | ✅ 완료 | 시각 확인 | nav-item 15 + pane-write |
| 3-2 | Write 탭 JS | ✅ 완료 | 시각 확인 | wrWriteTag/wrSetMode/wrControlOp/wrReadTag |
| 4-1 | 빌드 검증 | ✅ 완료 | 0 Warning, 0 Error | `dotnet build` 성공 |
| 4-2 | API 테스트 | ⬜ 미완료 | | 런타임 테스트 필요 |
| 4-3 | Frontend 테스트 | ⬜ 미완료 | | 브라우저 테스트 필요 |

---

## 참고: 기존 아키텍처 패턴

### Service 등록 패턴 (Program.cs)
```csharp
builder.Services.AddScoped<IExperionOpcClient,  ExperionOpcClient>();
// ↑ 이 패턴을 따름
```

### Controller에서 ServerConfig 읽기 패턴
```csharp
var section = _config.GetSection("Experion:Default");
return new ExperionServerConfig
{
    ServerHostName = section["ServerHostName"] ?? "",
    Port = section.GetValue<int?>("Port") ?? 4840,
    ClientHostName = section["ClientHostName"] ?? "dbsvr",
    UserName = section["UserName"] ?? "",
    Password = section["Password"] ?? ""
};
```

### JSON camelCase 응답 패턴
```csharp
return Ok(new { success = r.Success, nodeId = r.NodeId, status = r.Status, error = r.Error });
```

### Frontend api() helper
```javascript
async function api(method, path, body) {
  const opt = { method, headers: { 'Content-Type': 'application/json' } };
  if (body) opt.body = JSON.stringify(body);
  const res = await fetch(path, opt);
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}
```
