Skip to content

API guide

How to do the common jobs with the driver: identify, read, command, tare, set up a bus and handle errors.

Every snippet on this page is compiled by the host CI from tests/docs/api_tasks.cpp, with Driver standing for alicat_basis2::Driver<MyUart> and al for alicat_basis2. The API reference lists every member.

The shape of the API

One Driver<UartT> object per instrument. Its calls fall into a few groups:

Group Calls Instruments
Live data ReadInstantaneous() All
Identity ReadIdentity(); the free function GetDriverVersion() for the driver All
Setpoint SetSetpoint(), SetSetpointSource(), SetCommWatchdogMs(), SetMaxSetpointRamp() Controllers
Tare Tare(), SetAutotareEnabled() (controllers) All
Gas and flow processing SetGas(), SetFlowAveragingMs(), SetReferenceTemperatureC() All
Totalizer ResetTotalizer(), SetTotalizerLimitMode(), SetTotalizerBatch() (controllers) All
Measurement window ConfigureMeasurementTrigger() (controllers), StartMeasurementSamples(), ReadMeasurement() All
Bus configuration SetModbusAddress(), SetAsciiUnitId(), SetBaudRate(), FactoryRestore(), DiscoverPresentAddresses() All
Raw access ReadHoldingRegisters(), WriteSingleRegister(), WriteMultipleRegisters() All
Driver settings SetAddress(), GetAddress(), SetTimeoutMs(), GetTimeoutMs(), SetBusBaudRate(), GetBusBaudRate()

Every call is noexcept and returns DriverResult<T> (.ok(), .value, .error) or, for the raw read, a bare DriverError. Check it: a failed call leaves value default-constructed.

Talk to several instruments

Driver flow_in(uart, 1);             // one Driver per instrument, all on the same port
Driver flow_out(uart, 2, 100);       // a 100 ms response timeout for this one

flow_out.SetTimeoutMs(250);          // change it later

The constructor sends nothing. The port must outlive every driver that uses it. Drivers that share a port must not run at the same time (Porting).

Identify the instrument

const auto r = meter.ReadIdentity();  // registers 25, 26..31 and 45..49: three requests
if (!r.ok()) {
  return false;
}
id = r.value;
const double full_scale = id.full_scale_flow_raw / 1000.0;  // registers 47-48: value / 1000
std::printf("FW %u.%u.%u  SN %s  full scale %.3f %s\n", id.fw_major, id.fw_minor, id.fw_patch,
            id.serial_number, full_scale, al::ToString(id.flow_units).data());

ReadIdentity() costs three requests. Read it once at start-up and keep the result: the flow units and full scale tell you how to interpret every flow value.

Read live data

const auto r = meter.ReadInstantaneous(3);  // 3: the instrument's factory-set flow decimals
if (r.ok()) {
  const al::InstantaneousData& d = r.value;
  std::printf("%s %.2f C flow %.3f total %.3f setpoint %.3f valve %.1f %%\n",
              al::GasShortName(d.gas).data(), static_cast<double>(d.temperature_c),
              static_cast<double>(d.flow), static_cast<double>(d.total_volume),
              static_cast<double>(d.setpoint), static_cast<double>(d.valve_drive_pct));
  if (d.mass_flow_overrange || d.temperature_overrange || d.totalizer_overrange) {
    std::printf("over range\n");
  }
}

One request reads registers 2100 to 2109. Temperature and valve drive are always value / 100. Flow, total, setpoint and batch are divided by 10decimals, where decimals (default 3) is the factory-set number of decimal places of the instrument. The manual names no register that reports it: check one reading against the ASCII data frame or the calibration sheet and fix the value in your code. The raw register values are in the raw_* fields for logging.

Command a setpoint

// Controllers only. Order: source, watchdog, setpoint.
if (!controller.SetSetpointSource(al::SetpointSource::DigitalUnsaved).ok()) {
  return false;                      // a meter answers ModbusException
}
if (!controller.SetCommWatchdogMs(1000).ok()) {  // zero setpoint after 1 s without traffic
  return false;
}
return controller.SetSetpoint(2.5f).ok();        // 2.5 in the instrument's flow units
Step Why
SetSetpointSource(DigitalUnsaved) With Analog the instrument ignores digital setpoints. DigitalSaved stores every setpoint in flash; the manual warns that changing it more often than every few minutes can wear the memory out
SetCommWatchdogMs(ms) 0 to 5000 ms. The controller forces a zero setpoint and closes the valve when no serial traffic arrives in time. It works only with DigitalUnsaved
SetSetpoint(value) Writes value × 1000 as a signed 32-bit integer to 2053-2054. The instrument rounds to its own resolution

ReadInstantaneous().value.setpoint reads back the setpoint in use, which lags the command while a ramp (SetMaxSetpointRamp()) is active.

Tare and the totalizer

// With the process gas in the line and no flow:
if (const auto r = meter.Tare(); !r.ok()) {
  std::printf("tare: %s\n", al::ToString(r.error).data());
}
meter.ResetTotalizer();                                  // total volume back to 0
meter.SetTotalizerLimitMode(al::TotalizerLimitMode::SaturateWithOvr);

Tare with the process gas in the line and no flow: the manual says BASIS instruments reach their accuracy only when tared on pure process gas. A tare interrupts a running measurement window. The totalizer is lost when power is lost. SetTotalizerBatch() takes the volume already scaled to the total-volume decimals.

Measure over a window

controller.ConfigureMeasurementTrigger(al::MeasurementTrigger::OnDigitalSetpointChange);
controller.StartMeasurementSamples(400);   // 400 samples at about 2.5 ms: about 1 s
// ... wait at least as long as the window ...
const auto m = controller.ReadMeasurement(3); // 3: the same flow decimals as live data
if (m.ok()) {
  std::printf("%u samples: min %d max %d (raw flow), avg flow %.3f, avg %.2f C\n",
              m.value.num_samples_collected, m.value.min_flow_raw, m.value.max_flow_raw,
              static_cast<double>(m.value.avg_flow),
              static_cast<double>(m.value.avg_temperature_c));
  std::printf("previous window: avg flow %ld (raw)\n",
              static_cast<long>(m.value.prev_avg_flow_raw));
}

The instrument samples about every 2.5 ms. Writing the sample count starts a new window and moves the running one into the "previous" registers. ReadMeasurement(decimals) reads registers 4201 to 4215 in one request and scales avg_flow by the same decimals as live flow; scale the raw minimum, maximum and previous average flow (prev_avg_flow_raw, 32-bit at 4214-4215) the same way.

Find and readdress instruments

std::array<uint8_t, 32> present{};   // bit N set: an instrument answered at address N
const auto r = probe.DiscoverPresentAddresses(present.data(), present.size());
if (r.ok()) {
  for (unsigned a = 1; a <= al::kMaxModbusAddress; ++a) {
    if (present[a / 8] & (1U << (a % 8))) {
      std::printf("instrument at %u\n", a);
    }
  }
}

The sweep reads register 25 at each address with a 30 ms timeout (by default): a full sweep of 247 empty addresses takes about 7.5 s. It restores the driver's address and timeout afterwards. It finds instruments at the host's current baud rate only.

// Only this instrument on the bus, or only one at the old address.
if (!meter.SetModbusAddress(12).ok()) {
  return false;
}
return meter.GetAddress() == 12;     // the driver now talks to address 12

Give each instrument a unique address before you connect the next one: two instruments at one address answer on top of each other.

Change the baud rate

// The instrument switches as soon as it receives the write, so its reply may be garbled.
meter.SetBaudRate(al::BaudRate::Bps_115200);
set_host_baud(al::BaudRateToBps(al::BaudRate::Bps_115200));
return meter.ReadIdentity().ok();    // confirm at the new rate

The instrument switches before it answers

The manual warns that the host must change its own rate in time to receive the confirmation, and may not. Treat the result of SetBaudRate() as unreliable, switch the host UART, then confirm with a read at the new rate. If that fails, sweep the six rates to find the instrument again. SetBaudRate() also switches the driver's inter-frame gap to the new rate; call SetBusBaudRate() on the other drivers that share the port.

Use any register directly

uint16_t full_scale_sccm[2]{};       // registers 35-36: full scale in SCCM, unsigned 32-bit
if (meter.ReadHoldingRegisters(al::reg::FullScaleFlowSccm, full_scale_sccm, 2) == al::DriverError::None) {
  const uint32_t sccm = (static_cast<uint32_t>(full_scale_sccm[0]) << 16) | full_scale_sccm[1];
  std::printf("full scale %lu SCCM\n", static_cast<unsigned long>(sccm));
}
meter.WriteSingleRegister(al::reg::TareSampleCount, 400);  // register 51: samples per tare

The register constants are in alicat_basis2::reg; the Modbus-RTU protocol page lists them with their scaling. ReadHoldingRegisters() reads up to 32 registers; WriteMultipleRegisters() writes up to 32.

Handle errors

const auto r = meter.ReadInstantaneous();
if (!r.ok()) {
  switch (r.error) {
  case al::DriverError::Timeout:         // no reply: wiring, baud rate, address, timeout
  case al::DriverError::BadCrc:          // noise, termination, a second slave answering
    break;
  case al::DriverError::ModbusException: // the instrument refused: register, value, meter vs controller
    std::printf("exception %s\n", al::ToString(meter.GetLastException()).data());
    break;
  default:
    std::printf("%s\n", al::ToString(r.error).data());
    break;
  }
}
DriverError Meaning Where to look
Timeout No byte arrived within the timeout Wiring, baud rate, address, DE/RE, the timeout
InvalidFrame Too short, wrong byte count Line noise, a read() that returns early
BadCrc CRC-16 mismatch Ground, termination, two instruments at one address
ModbusException The instrument returned an exception reply; GetLastException() has the code Register not in this model or firmware (IllegalDataAddress), a meter asked for a controller setting
ResponseMismatch Reply from another address, or an echo that differs from the request Address clash, a late reply from an earlier request
InvalidParameter, InvalidAddress The driver refused the argument; nothing was sent Ranges on the Configuration page
BufferTooSmall A request longer than the driver's 32-register buffers Split the access

Exception replies come back at once

An exception reply is 5 bytes: the function code with bit 7 set, the exception code and the CRC. The driver reads the first 3 bytes of every reply, sees bit 7 and reads only the CRC, so a rejected read or write (for example SetSetpointSource() on a meter) returns ModbusException as soon as the reply has arrived, not after the timeout. GetLastException() returns the code (ModbusExceptionCode, ToString() names it) until the next request.

The status bits in InstantaneousData (mass_flow_overrange, temperature_overrange, totalizer_overrange, valve_held, valve_thermal_management) report instrument conditions, not transport errors. Troubleshooting maps symptoms to causes.