Node.js / TypeScript client for the RealTime Control Systems FDP3-Modbus interface.
The FDP3-Modbus bridges a Toshiba TCC-NET VRF/Split air-conditioning remote-controller bus to Modbus RTU over RS-485. Each FDP3 taps into one indoor unit's wired-remote-controller line (its "TCC-NET" A/B terminals) — in VRF systems where several indoor units share one remote controller (a "twin"/"triple" group), a single FDP3 can see up to 8 linked units. This library gives you a typed, promise-based API to read status and send control commands from Node.js.
Note: despite the similar acronym, TCC-NET (the AB remote-controller bus) is a different, physically separate bus from TCC-Link (the U1/U2 outdoor-to-indoor line) — this library has no visibility into the outdoor unit or refrigerant-circuit communication at all.
- Node.js 18+
- RS-485 USB adapter wired to the FDP3's DB(+)/DA(-)/GND terminals
- FDP3 Modbus address set via DIP switches SW1.3–SW1.8 (range 0–63)
npm install fdp3-modbusimport { FdpNetwork, RunMode, FanSpeed } from 'fdp3-modbus';
const network = new FdpNetwork({ port: '/dev/ttyUSB0' }); // or 'COM3' on Windows
await network.connect();
// Get the device at Modbus address 1 (matches this FDP3's DIP switch setting)
const device = network.getDevice(1);
// Common case: one remote controller + one indoor unit behind this FDP3
const unit = await device.getPrimaryUnitStatus();
console.log('Unit present:', unit.exists);
console.log('Return air temp:', unit.returnAirTemperatureC, '°C');
console.log('Fault:', unit.faultCode.code ?? 'none');
// Control this device's remote-controller group
await device.control({
onOff: true,
mode: RunMode.Cool,
fanSpeed: FanSpeed.High,
setpointC: 22,
});
await network.disconnect();Fixed per the FDP3-Modbus manual — defaults require no configuration:
| Parameter | Value |
|---|---|
| Network | 3-wire RS485 |
| Mode | Modbus RTU Slave |
| Baud rate | 9600 |
| Parity | None |
| Stop bits | 1 |
| Register base | 0 |
| Max devices per bus | 62 (addresses 0–63) |
The manual notes baud rate and parity can be reconfigured on the device if required —
override via ModbusConfig if your setup differs:
const network = new FdpNetwork({
port: '/dev/ttyUSB0',
baudRate: 9600, // default
parity: 'none', // default
interFrameDelayMs: 0, // default; not required by this manual, raise if your adapter needs it
timeoutMs: 2000,
});Note there is no slaveAddress here — one FdpNetwork manages a shared serial connection,
and each FDP3 device's address is supplied separately via network.getDevice(address).
Unlike single-module interface cards, up to 62 FDP3 devices can share one physical RS-485
bus. FdpNetwork owns the shared connection; network.getDevice(address) returns an
FdpDevice for one physical card. Each FdpDevice represents one TCC-NET
remote-controller group, which can contain 1–8 linked indoor units:
const network = new FdpNetwork({ port: '/dev/ttyUSB0' });
await network.connect();
const deviceA = network.getDevice(0); // FDP3 wired to indoor unit A's remote controller
const deviceB = network.getDevice(1); // a second, independent FDP3 on the same bus
const statusA = await deviceA.getPrimaryUnitStatus();
const statusB = await deviceB.getPrimaryUnitStatus();Control (H0001–H0005) is always group-wide — it affects every linked unit behind one
FDP3's remote controller together, since they share a single remote controller. Readback of
individual linked units (getUnitStatus(1..8)) is per-unit diagnostics only.
new FdpNetwork(config: ModbusConfig)
new FdpNetwork(config: ModbusConfig, transport: IModbusTransport) // inject for testing
await network.connect()
await network.disconnect()
network.isConnected // boolean
network.getDevice(address: number): FdpDevice // address 0–63device.address // the Modbus slave address this device answers to
// Control — group-wide, affects every linked unit
await device.control({
onOff: true,
mode: RunMode.Heat, // Auto | Heat | Fan | Cool | Dry
fanSpeed: FanSpeed.Medium, // Auto | Low | Medium | High
louvre: Louvre.Deg45, // Swing | Deg0 | Deg20 | Deg45 | Deg70 | Deg90
setpointC: 21, // plain integer, 10–40 °C
})
// Control limiting (restrict what the remote controller can set)
await device.setControlLimits({
setpointMinC: 18,
setpointMaxC: 28,
fanSpeedInhibit: [FanSpeed.Auto],
modeInhibit: [RunMode.Dry],
louvreInhibit: [],
})
// Update mode (keypad lock + write-propagation behaviour)
await device.setUpdateMode({ global: UpdateMode.OnChange })
await device.setUpdateMode({ onOff: UpdateMode.Central }) // override just one field
// Read back current control / limits / update-mode state — H0001-H0024 are
// Holding Registers (Read/Write per the manual), so they reflect whatever is
// currently set, whether it got there via this library, the remote
// controller, or another Modbus master on the bus.
const control: DeviceControlState = await device.getControl()
const limits: ControlLimitsState = await device.getControlLimits()
const modes: UpdateModeState = await device.getUpdateMode() // H0011-H0015 only, H0010 is write-only
// Group summary of all linked units
const group: GroupStatus = await device.getGroupStatus()
// Wired remote controller readback
const rc: RemoteControllerStatus = await device.getRemoteControllerStatus()
// Individual linked unit (1–8)
const unit: UnitStatus = await device.getUnitStatus(3)
// Convenience for the common single-unit wiring case — same as getUnitStatus(1)
const primary: UnitStatus = await device.getPrimaryUnitStatus()
// All 8 slots — units with `exists: false` simply aren't wired
const all: UnitStatus[] = await device.getAllUnitStatuses()| Field | Type | Notes |
|---|---|---|
unitIndex |
number |
1–8 |
exists |
boolean |
false if no unit is wired at this index |
isFault / faultCode |
boolean / FaultCode |
faultCode.code is e.g. 'E04', or null if no fault |
returnAirTemperatureC |
number |
°C |
filterAlarm |
boolean |
|
thermoState |
ThermoState |
IdleFan / Heating / Cooling |
coilTcTemperatureC / coilTcjTemperatureC |
number |
°C |
duty / dutyPercent |
number |
0–15 raw, 0–100 converted |
defrostActive |
boolean |
|
lineAddress / unitAddress |
number |
1–32, TCC-NET addressing |
Replace the serial port with any object implementing IModbusTransport — useful for
testing or wrapping a TCP-to-serial bridge. Note every method takes an explicit
slaveAddress, since one transport can serve many devices on a shared bus:
import { FdpNetwork, IModbusTransport } from 'fdp3-modbus';
class MockTransport implements IModbusTransport {
isConnected = false;
async connect() { this.isConnected = true; }
async disconnect() { this.isConnected = false; }
async readHoldingRegisters(_slave: number, _addr: number, count: number) { return Array(count).fill(0); }
async readInputRegisters(_slave: number, _addr: number, count: number) { return Array(count).fill(0); }
async writeSingleRegister(_slave: number, _addr: number, _val: number) {}
async writeMultipleRegisters(_slave: number, _addr: number, _vals: number[]) {}
}
const network = new FdpNetwork({ port: '' }, new MockTransport());Two runnable examples live in examples/:
examples/cli— terminal dashboard (tsx, no build step) that polls one or more FDP3 devices on a shared bus and prints group summary, remote controller, and linked-unit diagnostics. Read-only.examples/ui— Express + Vue 3 + PrimeVue web dashboard for a single FDP3 device, including a--mockdemo transport (no hardware needed) that seeds a two-linked-unit "twin" group. Includes device-wide control (power/mode/fan/louvre/setpoint).
npm run build # tsdown → dist/index.mjs + dist/index.js + .d.ts
npm run build:watch # rebuild on source changes
npm run typecheck # tsc --noEmit (no bundling, fast)modbus-serial is kept external and must be installed separately by consumers.
npm test # jest + ts-jest, no hardware needed
npm run test:watch
npm run test:coverageAll tests use an in-memory MockTransport — no RS-485 adapter required. There's also a
standalone runner that works without npm install: npx tsx test/run.ts.
src/
index.ts Public entry point
types/ Config, enums, status shapes, control shapes
registers/ Modbus PDU address constants and unitAddress() formula
codec/ Temperature (x100), fault code, duty, and inhibit-bitmask helpers
transport/ IModbusTransport interface + RS-485 SerialTransport
client/ FdpNetwork, FdpDevice
test/ Jest tests (MockTransport, no hardware)
tsdown.config.ts Build config (ESM + CJS, external: modbus-serial)
tsconfig.json Type-check config (module: Preserve, moduleResolution: Bundler)
tsconfig.test.json Jest config override (module: CommonJS)
AGENTS.md Guidance for AI coding agents
CHANGELOG.md Release history (Keep a Changelog format)
Releases are published manually to the public npm registry — there is no CI automation.
- Make sure
mainis clean andnpm test/npm run typecheckpass. - Update
CHANGELOG.md— move[Unreleased]items under a new version heading. - Bump the version and tag it:
npm version patch # or minor / major - Publish.
prepublishOnlyautomatically re-runstypecheck,test, andbuildfirst, so a broken build can't ship:Usenpm publish
npm publish --dry-runbeforehand to preview exactly what will be included (per thefilesfield:dist/+src/) without actually publishing. - Push the version commit and tag:
git push && git push --tags
BSD-2-Clause — see LICENSE.