---
module: agent.nh-tcr-events
title: NH TCR Events — cassette + firmware monitoring for Nautilus Hyosung / Glory BRM30 recyclers
tab: Architect
order: 30
audience: dev
---

> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-nh-tcr-events` source. Verify against code before relying on specifics.

## Module shape

Package `com.hiveops.nhtcr`, three classes, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]):

- **`NhTcrEventsModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.nhtcr.NhTcrEventsModule`). `getName()` = `nh-tcr-events`, `getVersion()` = `1.0.0`, `getLogPackage()` = `com.hiveops.nhtcr`. Owns lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`) and the single shared `ScheduledExecutorService`.
- **`ApLogMonitor`** (package-private) — parses the MoniPlus2S APLog and syncs firmware inventory.
- **`FwEventLogMonitor`** (package-private) — parses the Glory BRM30 FWLogSave circular buffer.

Both monitors are plain constructed objects (`new ApLogMonitor(dir, atmName, client)`), not their own `AgentModule`s — `NhTcrEventsModule` drives both from one poll loop. Each monitor is created only if its directory property is set **and** resolves to an existing directory; `ready = apLogMonitor != null || fwEventLogMonitor != null` gates `isEnabled()`.

## Lifecycle & gating (`initialize()`)

1. Reads `device.type`; if not `NH_TCR` (uppercased/trimmed) → logs and returns (module stays disabled, `ready=false`).
2. Reads `nh.tcr.ap.log.dir` and `nh.tcr.fw.log.dir`; if **both** null → disabled.
3. Resolves the endpoint: `agent.endpoint` if present, else `incident.endpoint`; if neither → disabled. `prefix` is `"agent"` or `"incident"` accordingly.
4. Builds one `IncidentEventClient` from `HttpClientHelper.loadSettingsFromProperties(props, prefix)` + `context.getAtmName()` + `context.getCountry()`, shared by both monitors.
5. Constructs each monitor only if its dir exists (else a per-monitor warning; the other monitor can still run).
6. Reads `nh.tcr.events.poll.interval.sec` (default `DEFAULT_POLL_SEC=30`) into `pollSec`.

`start()` spins one daemon thread `"nh-tcr-events"` and `scheduleAtFixedRate(this::poll, 0, pollSec, SECONDS)` — **`pollSec` is honored here** (contrast the `atec-tcr-events` sibling, whose poll interval is a no-op bug). `poll()` calls `apLogMonitor.poll()` then `fwEventLogMonitor.poll()`, wrapping both in a try/catch so one monitor's exception can't kill the scheduler.

## Flow: poll → parse → detect → emit

```
NhTcrEventsModule.start()
  → ScheduledExecutorService (daemon "nh-tcr-events", fixed-rate, every pollSec sec)
    → poll()
      → apLogMonitor.poll()      // cassette status + firmware inventory
      → fwEventLogMonitor.poll() // EP restarts
```

### `ApLogMonitor` — date-subfolder tail + header scrape

- **File selection:** `rotateDailyFileIfNeeded()` compares `LocalDate.now()` to `currentDate`; on a new day it recomputes `currentFile = baseLogDir/{YYYYMMDD}/APLog{YYYYMMDD}.log` (`DateTimeFormatter.ofPattern("yyyyMMdd")`), resets `fileOffset=0`, `headerParsed=false`, and clears the `slotStatus` map.
- **Header sync (once per daily file):** on first poll of a file, `parseAndSyncHeader()` opens a **separate** `RandomAccessFile` from offset 0 (so it doesn't disturb `fileOffset`), reads the block between the two `={20,}` delimiter lines, and extracts versions. Two line shapes: `- <label> = SP[..], EP[..]` (→ `key.sp` / `key.ep`) and simple `- <label> = <value>`. `labelToKey()` strips a trailing ` Version`, lowercases, dots spaces, and skips `Machine Number` / `Model` / `IP Address` / `Port Number` / `Time Zone`. If any versions found → `HardwareInventoryRequest{atmName, peripheralVersions}` → `client.syncHardware(req)`. `headerParsed=true` is set even on partial failure so it won't loop.
- **Tail-by-offset:** opens `currentFile` with `RandomAccessFile`, guards truncation (`raf.length() < fileOffset` → reset offset + clear `slotStatus`), seeks `fileOffset`, reads new lines to EOF, advances `fileOffset = raf.getFilePointer()`.
- **Per-line detection (`processLine`):** `STACK_BLOCK` regex captures `denom | count | status | type | slot` from `** IStackedBillsBlock = $|...`. Diffs `status` against `slotStatus.put(slot, status)` — **no emit if unchanged** (this in-agent map is the only storm guard; there's no server-side dedup). On a real change:
  - Always emit **`CASSETTE_INVENTORY`** first (keeps the cassette-details tab current on any transition, including recoveries).
  - Then by status: `LOW`→`CASSETTE_LOW`, `EMPTY`→`CASSETTE_EMPTY`, `MISSING`→`TCR_CASSETTE_MISSING`, `MANIP`→`TCR_CASSETTE_MANIP`, `OK`→log-only recovery notice (no event).
  - `slotStatus` is per-instance/per-agent-process lifetime, cleared on truncation or day rollover — so a slot still `EMPTY` at the first block of a new day re-emits (state doesn't carry across daily rotation).

> Note: `NhTcrEventsModule`'s class-level Javadoc lists a stale subset (`CASSETTE_LOW`, `CASSETTE_EMPTY`, `TCR_CASSETTE_MISSING`, `TCR_EP_RESTART`) — the authoritative emit set is what `ApLogMonitor.processLine` / `emit` and `FwEventLogMonitor` actually send.

### `FwEventLogMonitor` — circular-buffer tail

- 10 fixed files `00FWEvent00.log`…`00FWEvent09.log` (`FILE_PREFIX="00FWEvent"`), one `long[] offsets` per index.
- **First poll:** seeks every existing file to its current size (`offsets[i]=Files.size`) and marks `seenOnInit`, so only entries written **after** agent start are processed; then returns.
- **Subsequent polls:** for each index, `scanFile()` opens the file, resets offset to 0 if `fileLen < offsets[index]` (file recycled), seeks and reads new lines. `processLine` matches `LogManager_StartUp\(\)` → emit **`TCR_EP_RESTART`** (with `FWEventNN` label in details). No de-dup beyond offset tracking.

## How events reach the platform

Both monitors emit through the same shared `IncidentEventClient` (constructed once in `initialize()`), the same client class used by `journal-events` and other event-reporting modules — see [technical.agent] for the full outbound surface.

```
ApLogMonitor/FwEventLogMonitor.emit(...)
  → CreateJournalEventRequest.builder()
      .agentAtmId(atmId)            // ATM name string; resolved to incident's numeric atmId server-side
      .eventType(eventType)         // free-form String — not validated client-side
      .eventDetails(details)
      // cassette fields (ApLogMonitor only):
      .cassettePosition(slot)       // e.g. CST_A / CST_C
      .cassetteDenomination(int) .cassetteBillCount(int)
      .cassetteType(type)           // RECYCLING / REJECT / REPCONTAINER
      .cassetteCurrency("USD")      // hardcoded
      .eventSource("NH_TCR_APLOG" | "NH_TCR_FWEVENT")
      .build()
  → IncidentEventClient.sendEvent(req)   → POST {endpoint}/api/journal-events
```

Firmware inventory takes a different path in the same client: `client.syncHardware(req)` → `POST {endpoint}/api/atms/hardware/sync`.

`eventType` is a plain `String` on `CreateJournalEventRequest` (the `hiveops-agent-journal` DTO). Nothing client-side blocks an unregistered type; recognition/labeling happens server-side against hiveops-incident's DB-backed `event_type` table. Send/sync failures throw `IOException`, which `emit()` / `parseAndSyncHeader()` catch and log — no retry or queueing.

## Auth / endpoint selection

`HttpClientHelper.loadSettingsFromProperties(props, prefix)` resolves institution key with fallback `{prefix}.institution.key` → `agent.institution.key` → `incident.institution.key`, and reads the bearer token from `agent.auth.token`. No bespoke auth in this module — 100% the shared agent HTTP settings pattern.

## Platform abstraction

None used directly. The module reads files via `java.nio.file` / `RandomAccessFile` — no `PlatformService` / `PathResolver` dependency, even though NH TCR software is Windows-only in practice. The Windows-only constraint is enforced by deployment convention (`device.type=NH_TCR`), not a runtime OS check in this module's code.

## Cross-links
- [technical.agent] — module system, lifecycle, shared HTTP client settings, full outbound API surface
- [platform.module-map]
