Skip to content

Repository files navigation

fdp3-modbus

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.


Requirements

  • 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)

Installation

npm install fdp3-modbus

Quick start

import { 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();

Connection settings

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).


Architecture: one network, many devices, linked units

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 (H0001H0005) 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.


API reference

FdpNetwork

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–63

FdpDevice

device.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()

UnitStatus — key fields

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

Custom transport

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());

Examples

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 --mock demo transport (no hardware needed) that seeds a two-linked-unit "twin" group. Includes device-wide control (power/mode/fan/louvre/setpoint).

Building

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.


Testing

npm test             # jest + ts-jest, no hardware needed
npm run test:watch
npm run test:coverage

All 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.


Project layout

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)

Publishing / releasing

Releases are published manually to the public npm registry — there is no CI automation.

  1. Make sure main is clean and npm test / npm run typecheck pass.
  2. Update CHANGELOG.md — move [Unreleased] items under a new version heading.
  3. Bump the version and tag it:
    npm version patch   # or minor / major
  4. Publish. prepublishOnly automatically re-runs typecheck, test, and build first, so a broken build can't ship:
    npm publish
    Use npm publish --dry-run beforehand to preview exactly what will be included (per the files field: dist/ + src/) without actually publishing.
  5. Push the version commit and tag:
    git push && git push --tags

License

BSD-2-Clause — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages