CNC Machine Spindle Load and Cycle Time Tracker
This reference application is here to give you ideas and help you get started quickly. It's built on specific hardware that may not match yours, so focus on the sections that fit your use case. If you'd like to talk through your project and whether it's a good fit for Blues, reach out.
Building with an AI assistant? Point it at this accelerator's firmware in firmware/cnc_spindle_tracker/ so it can learn how the device works and help you adapt it to your hardware.
This project is an industrial equipment monitoring reference design for machine-tool OEMs who want a continuous view of how their installed base is actually being used. The device sits on the customer's CNC machine and reports the signals an OEM cares about: how hard the spindle is working, how many cycles ran each hour, how often the machine sits idle, which operator is logged in, and what alarm codes the controller raises. Everything goes back to the OEM's cloud over cellular and never touches the customer's plant network. Routine telemetry rolls up into hourly summaries, while alarms and operator-ID changes go out right away. The hardware is a Finder OPTA with a Blues Wireless for OPTA cellular expansion (§4 has the full BOM), and the data comes from any CNC controller that exposes telemetry over Modbus TCP.
1. Project Overview
The problem. A CNC (computer numerical control) machining center is a six-figure piece of capital equipment, and once it ships, the OEM that built it usually has no idea what happens to it. The spindle might be running at 80% load twelve hours a day, or it might be sitting idle because the shop scheduled it wrong. Maybe the same alarm code fires every Tuesday morning. With data, that pattern is obviously a tooling-change reminder. Without data, it's an invisible warranty claim. An operator could be overriding the feed rate up to 140% on a finishing pass and burning through inserts, and nobody at the OEM would know.
That blind spot matters because the whole industry is moving toward EaaS (equipment as a service) and usage-based billing. You can't sell spindle-hours when you don't know how many are actually running, and you can't offer a predictive-maintenance contract without seeing the telemetry. Benchmarking your machine's OEE (Overall Equipment Effectiveness, a manufacturing KPI that combines availability, performance, and quality into a single utilization score) takes a continuous stream of cycle-time and run/idle data coming back from the shop floor.
On controls that expose it, the data is already there. A good number of modern CNC controls publish live spindle load, feed-rate override, alarm codes, cycle state, cumulative part counts, and the current operator ID over Modbus TCP, through an Ethernet port on the back of the cabinet. Support depends on the vendor, the model, and which optional feature packages are installed, so check the controller's Modbus/Ethernet option documentation for availability and the exact register layout before you deploy. Some vendors use proprietary protocols or OPC-UA instead (Siemens SINUMERIK, Fanuc FOCAS-over-Ethernet) and need a protocol translation layer that this design doesn't cover. Where Modbus TCP is available, the hard part is getting the data off the machine and back to the OEM without involving the machine shop's network.
Why Notecard. Machine shops run their CNC machines on isolated OT (operational technology) networks. Those networks are air-gapped from the corporate LAN by design, and plant IT is deeply skeptical of anyone who wants to plug an OEM's monitoring device into their subnet. Asking every customer to configure a VLAN, assign a static routable IP, punch a firewall rule, and keep all of that working through staff turnover is a nightmare for customer acquisition and for support. The OEM's monitoring device has to work the same way in a shop in Ohio, Stuttgart, or Osaka, with no help from site IT.
New to Blues?
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
That's why this design uses cellular. Snap a Blues Wireless for OPTA onto the Finder OPTA's expansion port and the device gets its own cellular data channel, a private uplink to the Blues Notehub cloud service. It doesn't need plant LAN credentials, a firewall exception, or an IT ticket. The Modbus TCP connection to the CNC runs over a direct point-to-point Ethernet cable between the OPTA and the machine control, so the two devices sit on a private subnet with no route to the shop floor network at all. Plant IT never sees the monitoring device, because it never touches their infrastructure.
Deployment scenario. One Finder OPTA with Blues Wireless for OPTA mounts on the DIN rail in the machine's electrical panel. A two-meter Cat6 patch cable runs from the OPTA's RJ45 port straight to the CNC controller's Modbus TCP Ethernet port, which makes a closed, private connection with its own subnet. The cellular antenna exits through a cable gland on the panel door, and the panel's existing 24 VDC supply powers the assembly. Getting to first light takes an hour of wiring, and the OEM's service technician never has to deal with the shop's network team.
2. System Architecture
Device-side responsibilities. The OPTA's STM32H747 Cortex-M7 host acts as
the Modbus TCP client. Once a minute it asks the CNC controller (the Modbus
server) for the same block of six holding registers over the OPTA's
built-in Ethernet port. Each poll feeds the rolling hourly statistics the OEM
cares about: mean and peak spindle load, mean feed-rate override, run/idle
minute counts, cycle completions, and average cycle time. It also runs the
two sample-based alert rules (spindle_overload and cnc_alarm). A third,
connection-failure alarm (modbus_unreachable) fires when the Modbus TCP link
still can't be reached after a retry. The same poll catches operator-ID
transitions, so any change in the operator-ID register fires a
cnc_operator.qo Note right away. Everything the host produces goes over I²C,
through the expansion's AUX connector, to the Notecard inside Blues Wireless for
OPTA. The host never deals with the cellular modem or its session state.
Notecard responsibilities. From there, the Notecard queues every
Note on the device and brings up a cellular
session on the hub.set
outbound cadence (60 minutes by default). For anything marked sync:true, it
wakes the radio within a minute of the alert firing. Configuration comes the
other way through the Notecard too. OEM application engineers can push
environment
variables
from Notehub to retune the Modbus register block base address, alert
thresholds, and reporting cadence for each fleet, without touching firmware.
When report_minutes changes, the firmware reissues hub.set so the
Notecard's outbound cadence stays in step with the summary window.
Notehub responsibilities. The Notecard manages its own cellular session on supported carrier networks worldwide through its embedded global SIM, then hands the data to Notehub. Notehub ingests and stores every event and applies project-level routes. For an OEM, the natural way to organize devices is with Fleets and Smart Fleets: one fleet per controller family or model profile, with the register block base address, unit ID, port, and alert thresholds stored as fleet-level environment variables. Group by controller model instead of by customer site, because register maps often differ across CNC models even inside the same facility. IP addressing is still compile-time in this reference design, so machines on different network configurations need a per-build adjustment (see §12 Limitations).
Routing to the cloud (high level). Notehub can route to HTTP, MQTT, AWS IoT Core, Azure IoT Hub, GCP Pub/Sub, Snowflake, and several other targets. The Notehub routing docs cover the setup. This project doesn't ship a specific downstream endpoint, so the OEE dashboard is a project-specific integration you route to from Notehub.
3. Technical Summary
- Flash the OPTA with
firmware/cnc_spindle_tracker/cnc_spindle_tracker.ino. You'll need the Arduino IDE and the Mbed OS Opta Boards core. On a Finder OPTA Codesys, double-tap RESET before the first upload (see the quickstart). - Get ProductUID from notehub.io, paste it into the sketch, and reflash.
- Wire: Run Cat6 from the OPTA's RJ45 port to the CNC's Modbus TCP port
(default
192.168.250.1:502), and bring the cellular antenna out through the panel door. - Power: Apply 24 VDC to the OPTA. The Notecard auto-claims to your Notehub project on its first cellular session (≈5 minutes).
- Validate: Look for
cnc_summary.qoNotes in Notehub within 60 minutes. You should get one summary per hour, plus one alarm per event whenever something triggers.
When you're done: you have a continuous stream of spindle load, cycle counts, alarm codes, operator IDs, and run/idle telemetry reaching Notehub every hour, and overloads or fault transitions raise real-time cellular alarms. Roll the summaries up into an OEE dashboard, and use Notehub routes to send alarms to your CMMS or on-call system.
Here's a sample Note from this device:
{
"file": "cnc_summary.qo",
"body": {
"spindle_pct_mean": 71.4,
"spindle_pct_peak": 88.2,
"feed_override_pct_mean": 97.5,
"run_min": 51,
"idle_min": 9,
"cycle_count": 25,
"avg_cycle_sec": 122,
"operator_id": 7,
"alarm_count": 0,
"valid_samples": 60
}
}4. Hardware Requirements
| Part | Qty | Rationale |
|---|---|---|
| Finder OPTA Codesys (8A.04.9.024.832C) | 1 | Industrial DIN-rail micro-PLC, included in the Wireless for OPTA Bundle. Its built-in RJ45 Ethernet port handles Modbus TCP to the CNC, RS-485 is there as a future bus option, and the Cortex-M7 host has enough RAM for the rolling stats. For this project it runs Arduino firmware. Any Arduino Opta variant also works, since all you need is Ethernet. |
| Blues Wireless for OPTA (NA, SKU 992-00155-C) | 1 | Snaps onto the OPTA's right-hand expansion port and adds a Notecard Cell+WiFi over I²C with a prepaid global SIM. Because it's cellular, nothing depends on the plant network. For European deployments, pick the EMEA variant (SKU 992-00156-C). |
| External cellular antenna(s), SMA, ~3 m lead (e.g. SparkFun CEL-16432) | 1 required, 2 recommended | Metal electrical panels block cellular signals, so route at least the primary antenna through a cable gland to the outside of the panel. Adding the diversity antenna improves LTE Cat-1 performance where signal is weak. |
| Blues Mojo | 1 | Coulomb counter for the bench. During commissioning it sits inline on the Wireless for OPTA power input to check the Notecard subsystem's per-session energy. It isn't deployed to the field. |
| 24 VDC DIN-rail supply, ≥10 W (e.g. MeanWell HDR-15-24) | 1 | Powers the OPTA and the Wireless for OPTA expansion. Most machine panels already have a 24 VDC rail, so only source one locally if you need it. |
| Cat6 patch cable, ~2 m | 1 | Point-to-point Modbus TCP link from the OPTA's Ethernet port to the CNC controller's Modbus TCP port. A straight-through cable is fine, since modern NICs don't need a crossover. |
| DIN rail, ~15 cm | 1 | Mount for the OPTA and expansion. There's probably one inside the panel already. |
The Blues hardware ships with an active SIM that includes 500 MB of data and 10 years of service, with no activation fees and no monthly commitment.
5. Wiring and Assembly
Safety. Machine electrical panels carry hazardous voltages on the main power bus, even when the control wiring is low-voltage. Installation must be done by qualified personnel following site lockout/tagout procedures and applicable electrical codes. This reference design is read-only over Modbus and never sends a command to the CNC controller.
-
Mount and power. Snap Wireless for OPTA onto the OPTA's right-hand expansion port, using the solderless AUX connector that comes with Wireless for OPTA to join the two. That connector carries the I²C bus the Notecard talks over. Mount the assembly on the DIN rail and wire 24 VDC from the panel supply to the OPTA's
+and-terminals. The Wireless for OPTA quickstart calls for 24 VDC in field deployments. USB-C powers the host CPU for programming, but it doesn't power the OPTA's output stage or the expansion. Jumper the OPTA's+24Vterminal to the matching power input on the expansion so both run from the same supply rail. -
Antennas. Drill or gland two SMA bulkhead connectors through the panel door or a side knockout. Run the primary cellular antenna lead to the first SMA port on Wireless for OPTA and the diversity lead to the second. The bundled rubber-duck antennas are only good for bench testing, so don't rely on them inside a metal panel once it's in the field.
-
Modbus TCP link. Run a Cat6 patch cable from the OPTA's RJ45 Ethernet port to the CNC controller's dedicated Modbus TCP port. The controller's Modbus/Ethernet option documentation tells you which physical connector and TCP port number to use. That gives you a private point-to-point link, with the two devices on an isolated
/24subnet that has no route to the shop floor network. Give the OPTA a static IP on the same subnet as the CNC's Modbus TCP interface. The firmware defaults to192.168.250.10for the OPTA and192.168.250.1for the CNC. IP addressing is compile-time only in the current firmware, so before you build, changeLOCAL_IPincnc_spindle_tracker.ino(line 40) and_DEFAULT_CNC_IPincnc_spindle_tracker_helpers.cpp(line 27) to match the real CNC network configuration. This reference design has no way to set IPs at runtime (see Limitations for the production path).Deployment variant: shared CNC main port. Some controllers only expose Modbus TCP on the same port the machine tool LAN uses. In that case the OPTA has to join the customer's machine network, even if all it does there is reach the CNC, and that gives up the private-subnet / no-plant-network-touch property described in §1. You'll need customer network and OT-security approval before deploying that way, and it's outside the primary scope of this design.
-
Bench validation setup. During first-light commissioning, splice the Mojo inline between the 24 VDC supply and the Wireless for OPTA power terminal. It measures the whole expansion subsystem (the Notecard plus the expansion's onboard regulators) during a real cellular session.
6. Notehub Setup
-
Create a project. Sign up at notehub.io and create a new project. Copy the ProductUID into
firmware/cnc_spindle_tracker/cnc_spindle_tracker.ino. On line 19, paste your UID between the quotes in#define PRODUCT_UID "". -
Claim the device. Flash the OPTA and power the panel. The Notecard associates itself with your project on its first cellular session. Within 5–10 minutes, the device should show up under Fleet > Devices in Notehub.
-
Create a fleet per controller family or model profile. Fleets let you push a common configuration to every machine with the same controller type. You set fleet-level environment variables in the Notehub UI under Fleet > Environment, and they hold the shared parameters: Modbus register block base address, unit ID, port, and alert thresholds. Every machine with the same controller model and register map uses that one configuration, with no separate firmware build. IP addressing (
LOCAL_IPand_DEFAULT_CNC_IP) is still compile-time in this reference design, though, so machines whose CNC controllers sit on different network configurations need a per-build adjustment (see Limitations). Register maps often differ across CNC models even at the same customer site. Organizing fleets by controller family or model profile, not just by site, gives you the tightest control over shared settings. Smart Fleets can handle fleet assignment automatically, based on a device tag you set during commissioning. -
Set environment variables. In the Notehub web UI, go to Fleet > Environment to see every available variable. They're all optional, and the table shows the firmware defaults. When you save a change in Notehub, the Notecard downloads the new value on its next inbound sync. The host then applies it on the next scheduled
env.getcall, either at boot or at the end of the current report window.Variable Default Purpose sample_minutes1Minutes between Modbus TCP polls. report_minutes60Minutes between summary Notes ( cnc_summary.qo). Changing it also realigns the Notecard's outbound sync cadence, so the local summary window and the cellular session schedule update together.modbus_port502Modbus TCP port on the CNC controller. modbus_unit_id1Modbus unit (slave) ID. Many CNC controllers default to 1, but check the CNC controller's Modbus/TCP settings. reg_spindle_load256Starting address (0-based, wire-level) of the contiguous six-register block the firmware reads in one transaction. The firmware always reads the six registers in order from this address: spindle load, feed-rate override, alarm code, cycle state, cycle count, operator ID. Set it to the base address where your CNC controller's block starts. spindle_overload_pct90.0Spindle load (%) above which a spindle_overloadalarm fires while the machine is in-cycle.expected_cycle_sec120Reserved for future use. The device stores it in config, but the firmware doesn't evaluate it or send it in any Note. Cadence constraint.
sample_minutesmust not exceedreport_minutes. For accurate run/idle minute totals,report_minutesshould also be a whole-number multiple ofsample_minutes(for example,sample_minutes=5, report_minutes=60). If it isn't (say,sample_minutes=7, report_minutes=60), the run+idle totals in the summary come up short of the window duration. The firmware enforces the first rule by clampingsample_minutesdown toreport_minuteswhenever the env var would break it. It accepts non-divisible pairs but logs a Serial warning. See §12 Limitations.CNC register-map gotchas. The defaults above are only examples. Real CNC controllers vary widely in addressing convention (0-based wire-level vs. 1-based / Fanuc "PLC address" notation), per-register scaling (spindle load may be 0.1 %, 1 %, or % of rated torque), signedness, and whether the cycle count is a 16-bit or 32-bit (two-register) value. The big one is that the current firmware only supports a contiguous six-register layout. All six values have to sit back to back in the controller's Modbus map, starting at
reg_spindle_load. Production deployments need a vendor-specific register map with a matching contiguous block, or individual per-register reads added to the firmware. See Limitations. -
Configure routes. Add routes to send data to your downstream systems:
cnc_alarm.qo→ real-time delivery to your CMMS, on-call paging system, or Slack webhook.cnc_summary.qo→ batched hourly delivery to your OEE analytics or data warehouse.cnc_operator.qo→ real-time delivery to an operator-session or access-logging system. This one is best-effort, since comms outages can drop individual transitions, and a transition that happens and reverts between two polls never gets recorded.
Because the three Notefiles are separate, each one can go to a different destination at a different priority with no filter logic downstream.
7. Firmware Design
The firmware lives in the firmware/ directory. It's split into three files
(orchestration, shared types, and the helpers that talk to the Notecard and
Modbus) so each concern has a clear home:
| File | Role |
|---|---|
cnc_spindle_tracker.ino | Main sketch: setup(), loop(), sample accumulation, alert evaluation, and all global state definitions. |
cnc_spindle_tracker_helpers.h | Shared types (Config, Sample, WindowStats), compile-time defaults, extern declarations for globals, and helper-function prototypes. |
cnc_spindle_tracker_helpers.cpp | Notecard and Modbus helper implementations: notecardConfigure(), defineTemplates(), fetchEnvOverrides(), modbusConnect(), pollCnc(), sendSummary(), sendAlarm(), sendOperatorChange(), resetWindow(). |
Modules
| Responsibility | Function(s) |
|---|---|
Notecard init, hub.set | notecardConfigure() |
| Note template registration | defineTemplates() |
| Environment variable fetch (incremental, time-gated) | fetchEnvOverrides() |
| Ethernet/Modbus TCP connection management | modbusConnect() |
| Modbus register poll (6 registers, 1 transaction) | pollCnc() |
| Rolling hourly stats accumulation | accumulateSample() |
| Alert rule evaluation | evaluateAlerts() |
| Hourly summary Note emission | sendSummary() |
| Immediate alarm Note emission | sendAlarm() |
| Immediate operator-change Note emission | sendOperatorChange() |
| Periodic scheduling (millis-based; no MCU sleep) | loop() |
Sensor reading strategy
The firmware reads six holding registers in a single Modbus TCP transaction
(requestFrom with quantity 6, starting at reg_spindle_load). One TCP round
trip is much cheaper than six separate reads, and it keeps the polling window
short compared to the one-minute sample interval. The six registers must be
contiguous in the CNC controller's holding-register map. This reference
design doesn't support non-contiguous layouts (see
Limitations).
Samples taken while the machine is idle (cycle state ≠ 1) stay out of the spindle load and feed-rate averages, because a 0 % spindle reading while the operator sets up a part doesn't belong in the same bucket as a loaded cut. Run and idle minutes are tracked separately, so the OEE dashboard can compute the Availability component straight from the summary Note.
Event payload design
This design uses three template-backed Notefiles. Templates store Notes on the Notecard as fixed-length binary records instead of free-form JSON, which cuts wire size by 3–5×. That matters for a device that may run for a decade on its included 500 MB.
cnc_summary.qo (queued hourly, templated):
{
"file": "cnc_summary.qo",
"body": {
"spindle_pct_mean": 71.4,
"spindle_pct_peak": 88.2,
"feed_override_pct_mean": 97.5,
"run_min": 51,
"idle_min": 9,
"cycle_count": 25,
"avg_cycle_sec": 122,
"operator_id": 7,
"alarm_count": 0,
"valid_samples": 60
}
}cycle_count is the per-window delta of the controller's own cumulative
cycle-count holding register, meaning the unsigned difference between the
register values observed at the start and end of the reporting window. Since it
comes from the controller's own accumulator, it catches short cycles that finish
entirely inside one poll interval, which edge detection alone can't see.
Unsigned 16-bit arithmetic handles counter wrap (65535 → 0). A controller reset
that drops the counter by more than 32767, though, looks just like a natural
wrap and would inflate one window's delta.
avg_cycle_sec is an edge-timing heuristic built from cycleState register
transitions (1 → non-1) and the host MCU's millis() clock. It is not a
controller-native cycle timer, and it has known gaps, especially on fast-cycle
jobs. §12 Limitations covers them.
valid_samples counts the successful Modbus reads in the window. A value of
0 means every poll in the window failed. Downstream analytics use that
sentinel to tell a total communication outage apart from a machine that really
was powered and idle.
cnc_alarm.qo (immediate, sync:true, templated):
{
"file": "cnc_alarm.qo",
"sync": true,
"body": {
"alert_type": "spindle_overload",
"alarm_code": 0,
"spindle_pct": 93.1,
"operator_id": 7
}
}alert_type is one of spindle_overload (spindle load went over the threshold
during a cut), cnc_alarm (the CNC control raised a fault code), or
modbus_unreachable (the Modbus TCP connection was lost; rate-limited to once
per summary window).
cnc_operator.qo (immediate, sync:true, templated):
{
"file": "cnc_operator.qo",
"sync": true,
"body": {
"operator_id": 9,
"prev_operator_id": 7
}
}This Note fires whenever the value in the controller's operator-ID holding
register changes between consecutive samples. operator_id holds the new
(incoming) value and prev_operator_id the outgoing one. By convention,
operator_id == 0 means nobody is logged in. These events are best-effort
and only sampled at the poll interval. A brief comms outage can drop an
individual transition Note, and the firmware never sees an operator-ID change
that happens and reverts between two consecutive polls. The hourly
cnc_summary.qo records the most recently observed operator ID when the window
closes. That's a snapshot of the state, not a complete record of every
transition during the window.
Power and sync strategy
The OPTA and Wireless for OPTA run on 24 VDC line power, so the design goal is
bandwidth and bus efficiency, not host MCU sleep. The Notecard runs in
hub.set periodic mode.
At startup, outbound is set to DEFAULT_REPORT_MINUTES (60 minutes) and
inbound to DEFAULT_REPORT_MINUTES × 2 (120 minutes). When an env-var update
changes report_minutes, the firmware reissues hub.set with new outbound
and inbound values, which keeps the Notecard's cellular session schedule in
line with the summary window. Summary Notes queue on the device and go out
together in one session per window. Alarm Notes carry sync:true and wake the
radio as soon as a session can be established, usually within 15–60 seconds.
The firmware doesn't push the Modbus bus any faster than the sample interval.
It polls once per sample_minutes, then the host just yields in loop() until
the next scheduled tick.
Retry and error handling
notecardConfigure()callsnotecard.sendRequest(req)in a blocking loop that repeats every 30 seconds untilhub.setsucceeds. The firmware treats Notecard configuration as mandatory. Without a validhub.set, the ProductUID isn't registered on the Notecard, the outbound/inbound cadence is wrong, and later Notes would be un-routable. The device won't enter the main loop until configuration is confirmed.- The firmware detects a lost Modbus TCP connection from the
requestFrom()return code and retries once per sample (reconnect + re-poll). If both attempts fail, it skips the sample and fires amodbus_unreachablealarm. That alarm is rate-limited to once per summary window, so nobody gets buried in alarms when the CNC is just powered off. - Before reading any field from a
notecard.requestAndResponse()response, the firmware checks for both aNULLreturn and anerrfield. - Environment variable fetches pass the
timeargument, so they only request variables modified since the last successful fetch and inbound syncs don't move any data they don't need to. - Alert de-duplication:
spindle_overloadhas a 30-minute cooldown timer, so a slowly climbing load doesn't page the on-call person on every sample. CNC alarm codes fire on any transition to a nonzero value (0 → nonzero, or one nonzero code changing to a different nonzero code), not on every sample where a fault is asserted. The firmware updates its alarm-state tracking (g_lastAlarmCode,g_window.alarmCount) as soon as it observes the transition, whether or not thecnc_alarmNote queues successfully.sendAlarm()makes twonote.addattempts. If both fail, the alarm goes into an in-memory ring buffer (g_alarmFifo, depth 8) and gets retried one slot per poll cycle, so a comm outage can't make the summary undercount alarm transitions or quietly lose a short-lived alarm. On a ring-buffer overflow, the firmware evicts the oldest slot and logs a warning to Serial.
Key code snippet 1: Notecard periodic sync configuration
// Notecard configuration is mandatory: without a successful hub.set the
// ProductUID is not registered on the Notecard, the outbound/inbound cadence
// is wrong, and subsequent Notes are un-routable. The function blocks —
// retrying hub.set every 30 s — until configuration succeeds, so setup()
// never returns with the Notecard in an unconfigured state.
void notecardConfigure(const char *productUid) {
// ...
while (true) {
J *req = notecard.newRequest("hub.set");
if (req == NULL) {
usbSerial.println("[NOTECARD] hub.set allocation failed — retrying in 5 s.");
delay(5000);
continue;
}
JAddStringToObject(req, "product", productUid);
JAddStringToObject(req, "mode", "periodic");
JAddNumberToObject(req, "outbound", DEFAULT_REPORT_MINUTES); // minutes
JAddNumberToObject(req, "inbound", DEFAULT_REPORT_MINUTES * 2);
if (notecard.sendRequest(req)) {
usbSerial.println("[NOTECARD] hub.set OK.");
return;
}
usbSerial.println("[NOTECARD] hub.set failed — check wiring and PRODUCT_UID. Retrying in 30 s.");
delay(30000);
}
}Key code snippet 2: Modbus TCP batch read (6 registers, 1 transaction)
// All 6 demo registers are contiguous starting at regSpindleLoad.
// One requestFrom() is ~6× faster than six individual reads.
const uint16_t regBase = cfg.regSpindleLoad;
const int qty = 6;
if (!_modbusTCPClient.requestFrom(cfg.modbusUnitId, HOLDING_REGISTERS, (int)regBase, qty)) {
// Retry once: reconnect then re-poll.
// ...
}
// ...
// Registers use 0.1-unit scaling for percentages (demo map convention).
int16_t rawSpindle = (int16_t)_modbusTCPClient.read();
// Register N+1 is the feed-rate override (0–150 % of the programmed feed
// rate), NOT actual feed rate in engineering units (mm/min or in/min).
// Engineering-unit feed rate is not routinely available over Modbus TCP on
// most CNC controllers; see §12 Limitations on the dev.blues.io page.
int16_t rawFeedOverride = (int16_t)_modbusTCPClient.read();
s.alarmCode = (uint16_t)_modbusTCPClient.read();
s.cycleState = (uint8_t)(_modbusTCPClient.read() & 0x00FF);
s.cycleCount = (uint16_t)_modbusTCPClient.read();
s.operatorId = (uint16_t)_modbusTCPClient.read();
s.spindleLoadPct = rawSpindle / 10.0f; // e.g. 714 → 71.4 %
s.feedOverridePct = rawFeedOverride / 10.0f; // e.g. 1000 → 100.0 % of programmed rateKey code snippet 3: Immediate alarm Note with sync:true
// sync:true wakes the radio immediately rather than waiting for the outbound
// window. Returns true when the Note was successfully queued.
// A single automatic retry covers transient I²C / allocation errors without
// masking persistent failures — alarms are the highest-value events in the
// design.
bool sendAlarm(const char *alertType, const Sample &s) {
for (int attempt = 0; attempt < 2; attempt++) {
J *req = notecard.newRequest("note.add");
// ...
JAddStringToObject(req, "file", "cnc_alarm.qo");
JAddBoolToObject(req, "sync", true);
J *body = JAddObjectToObject(req, "body");
JAddStringToObject(body, "alert_type", alertType);
JAddNumberToObject(body, "alarm_code", (int)s.alarmCode);
JAddNumberToObject(body, "spindle_pct", s.spindleLoadPct);
JAddNumberToObject(body, "operator_id", (int)s.operatorId);
if (notecard.sendRequest(req)) {
// ...
return true;
}
// ...
}
return false;
}8. Build and Flash
Prerequisites:
- Arduino IDE 2.3+ or
arduino-cli(command-line). If you're using the IDE, install the Arduino Mbed OS Opta Boards board package from Tools > Board Manager by searching for "Opta". - Dependencies (install them from the Library Manager or with
arduino-cli):Blues Wireless Notecard(check releases)ArduinoModbusArduinoRS485Ethernet.h(included with the Mbed OS Opta Boards core)
Build steps (using arduino-cli):
# Install the board core
arduino-cli core install arduino:mbed_opta
# Install dependencies
arduino-cli lib install "Blues Wireless Notecard" "ArduinoModbus" "ArduinoRS485"
# Edit firmware/cnc_spindle_tracker/cnc_spindle_tracker.ino line 19 — paste your ProductUID into PRODUCT_UID
# Edit firmware/cnc_spindle_tracker/cnc_spindle_tracker.ino line 40 — set LOCAL_IP to match your Modbus subnet
# Edit firmware/cnc_spindle_tracker/cnc_spindle_tracker_helpers.cpp line 27 — set _DEFAULT_CNC_IP to your CNC controller IP
# Compile
arduino-cli compile -b arduino:mbed_opta:opta firmware/cnc_spindle_tracker/
# Flash (replace /dev/cu.usbmodem1234567 with your OPTA's USB port)
arduino-cli upload -b arduino:mbed_opta:opta -p /dev/cu.usbmodem1234567 firmware/cnc_spindle_tracker/
# Open the serial monitor to check for errors
arduino-cli monitor -p /dev/cu.usbmodem1234567 -c baudrate=115200Alternatively, using the Arduino IDE: Open
firmware/cnc_spindle_tracker/cnc_spindle_tracker.ino, set your ProductUID and
IPs, select Tools > Board > Arduino Opta, and click Upload.
The Finder OPTA Codesys ships running the CODESYS runtime. The first time you upload Arduino firmware to it, you have to double-tap its RESET button to put it into bootloader mode, as described in the Wireless for OPTA Quickstart. That first upload replaces the CODESYS runtime, so you won't need the double tap again.
9. Data Flow
Collected. Every sample_minutes (1 minute by default), the firmware reads
six registers in one Modbus TCP transaction: spindle load (%), feed-rate
override (%), active alarm code, cycle state, cumulative cycle count, and
current operator ID (whatever value is in the controller's operator-ID register
at that moment).
Accumulated. Each sample updates rolling counters: spindle load sum and peak
(while running), feed-rate sum (while running), run/idle minute tallies, the
per-window cycle-count register delta (the difference between consecutive
cycleCount register reads, and the primary source for cycle_count),
edge-timed cycle durations (used only for avg_cycle_sec), the count of
observed active-alarm transitions (alarms that assert and clear between polls
are never seen), and operator-ID change detection. The edge detector misses
cycles that finish entirely within one poll interval, but the register delta
still counts them. Limitations explains what
the avg_cycle_sec heuristic means in practice.
Transmitted.
cnc_summary.qo: once perreport_minutes(24 Notes per day per machine by default), queued locally and shipped on the Notecard's hourly outbound sync. Fields: spindle mean/peak, feed-rate override mean (feed_override_pct_mean, the override percentage of the programmed rate rather than an engineering-unit feed rate; see §12), run minutes, idle minutes,cycle_count(per-window delta of the controller's cumulative cycle-count register),avg_cycle_sec(edge-timing heuristic; see Limitations), the most recently observed operator ID,alarm_count(the number of observed active-alarm transitions in the window, not a complete fault history; see §12 Limitations), andvalid_samples(where 0 signals a total Modbus communication outage for the window).cnc_alarm.qo: sent right away when an alert triggers, withsync:true. There are three alert types:spindle_overload,cnc_alarm, andmodbus_unreachable.cnc_operator.qo: sent right away when the operator-ID register changes, withsync:true. Fields:operator_id(incoming value) andprev_operator_id(outgoing value). It's best-effort and sampled, so comms outages can drop individual events, and a transition that happens and reverts between consecutive polls is never recorded. The hourly summary holds the last ID seen at window close, not a complete transition history.
Routed. Notehub sends cnc_alarm.qo in real time to the OEM's CMMS or
on-call system and cnc_summary.qo to the OEE analytics store every hour.
cnc_operator.qo goes out in real time to an access-logging or per-operator
OEE attribution system.
Alert triggers.
spindle_overload: spindle load exceedsspindle_overload_pctwhile the machine is in-cycle (state = 1). A 30-minute cooldown keeps a sustained overload from paging someone over and over.cnc_alarm: the active alarm code changes to any nonzero value (0 → nonzero, or one nonzero code changing to a different nonzero code) as observed on the poll. It fires as soon as the poll sees the transition. Alarms that assert and clear between consecutive polls are never seen and produce no Note.modbus_unreachable: the Modbus TCP connection fails on two consecutive attempts. It's rate-limited to once perreport_minuteswindow.
10. Validation and Testing
Expected steady-state behavior. A healthy machine produces one
cnc_summary.qo per hour and no cnc_alarm.qo Notes. During first
commissioning, you might see one modbus_unreachable alarm before the Modbus
TCP link is confirmed.
Check Notehub for events. Sign in to notehub.io, go
to Fleet > Events, and filter for your device. Within 1 hour of power-up
you should see a cnc_summary.qo Note with fields like spindle_pct_mean,
run_min, and cycle_count. The body tab shows the actual JSON.
Modbus first-light (bench test). Before you connect to the real CNC:
- Run a Modbus TCP simulator on your laptop (Modbus Mechanic, ModRSsim2, ModbusMaster, or any open-source server).
- Connect it to the OPTA's Ethernet port, on the same subnet as the OPTA's IP
(for example, both on
192.168.250.0/24). - Set up six contiguous holding registers at address 256 with demo values, in this order: spindle load 650 (65%), feed-rate override 1000 (100%), alarm code 0, cycle state 1 (running), cycle count 10, operator ID 1.
- Flash the firmware pointed at your demo server by setting
_DEFAULT_CNC_IPto the simulator's address. - Wait one sample period (1 minute by default) plus one report window (60
minutes by default) for the first summary to show up in Notehub. Check that
spindle_pct_mean ≈ 65,cycle_count = 0(register delta), andvalid_samples ≈ 60. - Raise the spindle-load register to 950 (95%) and wait 1 minute. A
cnc_alarm.qowithalert_type: "spindle_overload"should reach Notehub within 60 seconds of that poll.
Field alert testing. To test alert delivery on a live machine:
- In Notehub, go to Fleet > Environment.
- Temporarily set
spindle_overload_pct = 0. - Wait for the next Notecard inbound sync (every 120 minutes by default). The host applies the new threshold at the next report window boundary.
- After that, any nonzero spindle reading on the next in-cycle poll
(cycle state 1) triggers a
cnc_alarm.qo. - When you're done testing, set
spindle_overload_pctback to its normal value (for example, 90).
Power validation (optional Mojo). Splice the Mojo coulomb counter between the 24 VDC supply and the Wireless for OPTA power input. Here's the current envelope to expect, from Notecard low-power design:
| Phase | Expected current |
|---|---|
| Notecard idle (radio off, between syncs) | ~8–18 µA @ 5 V |
| Cellular session (LTE Cat-1) | ~250 mA average; ≤2 A burst |
| WiFi fallback session | ~80 mA average |
Confirm that idle current between syncs sits in the µA range (that's what lets the device run for years in the field), that per-session energy for one hourly sync is ~10–30 mAh, and that energy stays consistent across consecutive cycles.
Bench caveat: When the OPTA is powered and programmed over USB-C, the USB rail holds the Notecard in a higher-power idle state, so you won't see µA idle current while USB is connected. For true field idle numbers, disconnect USB, power from 24 VDC only, and check the conditions in the Notecard low-power design docs.
11. Troubleshooting
| Symptom | Probable Cause | Solution |
|---|---|---|
| Device doesn't claim to Notehub after first power-up | Missing or malformed PRODUCT_UID in firmware | Set PRODUCT_UID on line 19 of cnc_spindle_tracker.ino, then reflash. |
No Modbus connection; continuous modbus_unreachable alarms | Wrong CNC IP or port, Ethernet cable unplugged, or CNC controller powered off or Modbus not enabled | Look up the CNC controller's Modbus TCP IP and port in its documentation, and make sure _DEFAULT_CNC_IP (helpers.cpp) matches the CNC. Check that the Cat6 cable is plugged in at both ends. Confirm the modbus_port env var matches the CNC's TCP port (default 502). |
No cnc_summary.qo Notes appear in Notehub | Device claimed, but no summaries visible | Make sure the Notecard's outbound sync interval (60 minutes by default) has passed. The firmware queues a summary even when every Modbus poll fails (with valid_samples = 0), so a missing summary points at the Notecard side, not the CNC link. Watch the Serial output with arduino-cli monitor for a Summary send failed message. |
valid_samples = 0 in all summaries | All Modbus polls failed | The Serial console should show Modbus errors. Make sure the CNC is powered and Modbus TCP is enabled. From your laptop, use a Modbus client tool (QModBus, ModRSsim2) to confirm you can reach the CNC at the configured IP and port. If your laptop can but the OPTA can't, there may be a routing or firewall issue on the Ethernet segment. |
| Notecard not syncing; no Notes leaving the device | I²C communication between OPTA and Notecard failed, or Notecard not powered | Make sure the AUX connector is fully seated, and check that 24 VDC reaches both the OPTA and Wireless for OPTA. The Notecard has a small blue LED near the SMA connectors that should blink during a cellular session. If it doesn't blink, the Notecard may not be powered or may have failed. |
spindle_overload alarms fire too often | Threshold too low, or noisy spindle-load sensor data | Raise spindle_overload_pct in the Fleet environment (for example, from 90 to 95). If that doesn't fix it, the CNC sensor may be noisy. Add averaging on the CNC side (most controllers have digital-filter registers) or increase SPINDLE_ALERT_COOLDOWN_MS in cnc_spindle_tracker_helpers.h. |
avg_cycle_sec is wildly wrong | Cycle time shorter than the sample interval, or cycleState semantics differ from what the firmware expects | avg_cycle_sec is a heuristic limited by the sample period (1 minute by default), so it can't see cycles faster than that. sample_minutes only accepts whole minutes (1 is the minimum), so sampling faster means changing the firmware, and the run_min/idle_min math assumes whole-minute samples too. On fast jobs, rely on cycle_count instead. Check that the target CNC model maps cycleState == 1 to "program running" (this is vendor-specific). |
| Operator-ID transitions not appearing | operator_id register not exposed or always zero | Confirm the CNC controller supports operator ID over Modbus TCP (many don't). Check the controller's Modbus documentation to make sure the sixth register in the contiguous block (starting at reg_spindle_load) maps to operator ID. Then confirm the operator ID actually changes on the machine, since some controllers require a login/logout. |
12. Limitations and Next Steps
This reference design is scoped on purpose to the moment the OEM's service technician cares about most, which is getting a single CNC from shipping crate to usable telemetry in an afternoon without one conversation with the shop's IT department. A few details were kept simple so that path stays clean. Each one is documented below, along with what production hardening looks like.
Simplified for this reference design
Every item below was simplified on purpose. Once the basic single-CNC monitor is proven, a production deployment will come back to each one to validate a vendor register map, add configurability, or harden a comms path.
Active alarm register only; transient alarms between polls are invisible.
The firmware reads the CNC controller's currently active alarm-code holding
register once per sample_minutes poll. It does not read a fault-history
log, alarm queue, or event recorder. Most controllers keep a log like that
internally, but standard Modbus TCP interfaces rarely expose it. If an alarm
asserts and clears entirely within one poll interval, the firmware never sees
it, no cnc_alarm.qo Note goes out, and the hourly summary's alarm_count
doesn't include it. So alarm_count is the number of observed active-alarm
transitions during the window, not a complete record of every fault the
controller hit, and it isn't the same as reading the controller's internal
fault history. For complete fault logging, go to the controller's native alarm
log directly, through the operator panel, vendor software, or (where supported)
a proprietary interface like Fanuc FOCAS or Siemens OPC-UA. In practice, the
odds of missing a brief alarm rise in proportion to sample_minutes, so keep
the poll interval at 1 minute. That shrinks the gap but doesn't close it.
Feed-rate override as a proxy for feed rate. The firmware reads register N+1
of the six-register block as the feed-rate override percentage, which is the
operator-set multiplier on the programmed feed rate, usually 0–150 %. That's
different from the actual feed rate in engineering units (mm/min or in/min).
Typical CNC controllers rarely expose engineering-unit feed rate over Modbus
TCP, because most vendors keep that value for internal NC interpolation. They
either don't map it to a Modbus register or require a proprietary protocol to
read it (Fanuc FOCAS or Siemens OPC-UA, for example). Feed-rate override still
works as a production proxy. It shows the operator behavior described in §1
(overriding the programmed rate on finishing passes) and is enough for the EaaS
monitoring use case, but it can't stand in for engineering-unit feed-rate
telemetry in a rigorous Performance calculation. That's why the field in
cnc_summary.qo is named feed_override_pct_mean, and your downstream
analytics should document the difference.
sample_minutes must not exceed report_minutes, and ideally must divide it
evenly. The firmware computes run_min and idle_min in cnc_summary.qo by
adding sample_minutes to the right accumulator on every successful poll. Two
rules have to hold for those totals to be valid. The first is
sample_minutes ≤ report_minutes. Break it, and a single poll adds more
minutes than the whole report window, so the run/idle counts end up longer than
the window itself. The second is that report_minutes should be a whole-number
multiple of sample_minutes (for example,
sample_minutes=5, report_minutes=60). Otherwise the run+idle total comes in
under the window duration even when the machine runs nonstop, because the
accumulator moves in fixed steps that don't land exactly on the report
boundary. The firmware enforces the first rule by clamping sample_minutes
down to report_minutes in fetchEnvOverrides() and logging a warning to
Serial. The second rule is only advisory. Non-divisible combinations get
through with a Serial warning, but the run/idle totals will be approximate.
Stick to divisible pairs when you set fleet environment variables.
Demo register map only. The firmware reads six contiguous 16-bit holding registers starting at address 256, with fixed 0.1-unit scaling. Real CNC controllers differ in addressing convention (0-based wire-level vs. Fanuc PLC notation vs. Siemens DBx addressing), per-register scaling, signedness, 32-bit cycle counters that span two registers with vendor-specific word order, and which registers are exposed over Modbus at all instead of a proprietary protocol (Fanuc FOCAS, Siemens OPC-UA, Haas NGC). You'll need a validated register map for each vendor before this design goes onto production machines.
IP addressing is hardcoded. The CNC server IP and the OPTA's local IP are
compile-time #defines. A production deployment needs a web-based local
configuration UI, an in-panel DIP-switch scheme, or env-var parsing of
dotted-decimal IP strings. This design doesn't implement any of them.
Operator-change events are best-effort, not guaranteed. The firmware detects
operator-ID transitions by comparing consecutive register reads, and it sends a
cnc_operator.qo Note right away on each change. sendOperatorChange()
doesn't retry on failure, so a brief I²C or cellular comms outage can drop an
individual transition Note. The hourly cnc_summary.qo carries the most
recently observed operator ID as a snapshot at window close. It doesn't capture
every transition in the window, and a transition that happens and reverts
between polls is never recorded. Any per-operator session durations or
cumulative utilization you derive from summary Notes are approximate and can't
replace durable session accounting. Like every register-based read,
operator_id is only as reliable as the controller's implementation. Fanuc
0i-MF exposes operator ID in the PMC area, Siemens SINUMERIK exposes it through
OPC-UA instead of Modbus, and some controllers have no external
operator-identity interface at all. Where it isn't available, operator_id
reads 0 on every sample.
cycle_count is authoritative; avg_cycle_sec is a heuristic.
cycle_count in cnc_summary.qo is the per-window delta of the
controller's own cumulative cycle-count holding register, computed with unsigned
16-bit subtraction of consecutive register reads. It counts every cycle that
increments the controller's register, including cycles that finish inside a
single poll interval, which edge detection alone can't see. Counter wrap
(65535 → 0) is handled correctly. A mid-session controller reset that drops the
counter by more than 32767 looks the same as a natural wrap and would inflate
one window's delta, but resets like that are rare enough that it's an accepted
corner case.
avg_cycle_sec works differently. It's an edge-timing heuristic: the
firmware watches cycleState transitions (1 → non-1) and uses millis() to
measure the wall-clock time between them. That has two systematic limits.
Missed short cycles: the edge detector can't see a cycle that starts and
finishes within one poll interval, so those cycles are left out of
avg_cycle_sec even though cycle_count includes them. On fast-cycle jobs
(cycle time shorter than sample_minutes), avg_cycle_sec skews toward the
longer cycles it can observe. Timing granularity: accuracy is limited by
the sample period (1 minute by default), not the controller's internal timer.
Treat avg_cycle_sec as a first-order estimate, not a certified measurement.
Whether cycleState == 1 means "program running" depends on the vendor, so
verify it on each target CNC model before you rely on avg_cycle_sec.
Quality component of OEE is absent. OEE = Availability × Performance ×
Quality. This design provides the raw data for Availability (run/idle minutes)
and the device-side inputs for Performance (cycle count and average cycle time,
sent in cnc_summary.qo). For now, though, comparing average cycle time
against an expected target has to be set up entirely in the downstream
analytics layer. The device stores expected_cycle_sec in its config for
future use, but the firmware doesn't evaluate it or send it in any Note.
Quality, the ratio of conforming parts to total parts, needs a downstream
measurement (CMM output, vision inspection, or operator scrap entry) that
Modbus can't supply.
Single CNC per OPTA. The firmware polls one Modbus slave ID. A multi-machine cell with a shared Modbus gateway could handle multiple IDs with round-robin polling, but that complicates the per-machine summary bookkeeping, so it's out of scope here.
No Modbus writes. That's intentional, and it isn't negotiable in this reference design. Writing setpoints to a CNC (feed override, spindle speed, program selection, M-codes) brings in machine safety, interlocks, and functional safety certification, all of which are entirely outside the scope of a monitoring-only device.
Modbus TCP reconnection is simple. The firmware reconnects after every failed transaction. Production hardening would add exponential backoff, a connection health watchdog, and a per-session keep-alive that catches stale TCP connections before the next poll.
No host firmware updates wired up. Notecard Outboard Firmware Update works on STM32H7 (the OPTA's MCU family), but it needs AUX wiring that Blues Wireless for OPTA doesn't break out right now. On the current Wireless for OPTA hardware, host firmware updates are local-only over USB-C.
Production Next Steps
Getting this monitor ready for a production fleet comes down to validating vendor-specific register maps, adding configuration you can change in the field, and closing the rest of the OEE loop. The extensions below are listed roughly in the order you'd want them, from most immediately useful to most dependent on outside integrations.
Vendor-specific register-map builds are the first thing most deployments will need: Fanuc 30i/31i/32i (Series 30), Siemens SINUMERIK 840D sl (via an OPC-UA adapter), Mitsubishi M80/M800, and Haas NGC. Each one needs the right addressing and scaling, plus protocol translation wherever Modbus TCP isn't available natively.
IP addressing via Notehub environment variables (parsing dotted-decimal
strings), or a local web configurator the OPTA serves on first boot, would get
rid of the compile-time #define constraint.
32-bit cycle counter support means reading two consecutive registers and putting them together in the vendor's byte order.
Native elapsed-cycle-timer reads would replace the heuristic cycleState
edge detection behind avg_cycle_sec with a read of the controller's own
elapsed-cycle-timer register, where the vendor exposes one. That removes the
timing bias that comes from poll-interval granularity, along with the
dependence on vendor-specific cycleState semantics. cycle_count already
uses the controller's cumulative register, and this step does the same for the
average timing field.
OEE Quality component integration closes the OEE loop on the device. An
inbound Notefile (cnc_quality.qi) would receive scrap/conforming counts from a
CMM or vision system.
Per-machine baseline learning builds up a 30-day
spindle-load-vs-feed-rate operating envelope, then triggers spindle_overload
against the machine's own learned curve instead of a static percentage.
Wiring ODFU to the OPTA's BOOT/RESET pins would allow over-the-air host updates once the AUX path is available.
13. Summary
The OEM whose six-figure machining centers went silent the day the shop floor accepted delivery now gets the picture it never had. Spindle load, cycle counts, alarm codes, and operator IDs come back every hour, and overload and fault transitions arrive in real time, all over a cellular channel that machine-shop IT never sees and never has to approve. CNC controllers that expose telemetry over Modbus TCP always had this data on board. The OT-network policies that protect the plant are what kept it stranded there. A direct point-to-point Ethernet cable from the OPTA to the machine control reads the registers locally, and the Notecard inside Blues Wireless for OPTA carries them to the OEM's cloud on its own independent uplink. That's the raw material for a credible equipment-as-a-service offer. Utilization data is granular enough to bill by the spindle-hour, the alarm telemetry can back a proactive-service contract, and the OEE components are accurate enough to benchmark the fleet. What's left is a validated, vendor-specific Modbus TCP register map for each target CNC model, which is a commissioning task that doesn't touch the architecture.