node-red-contrib-fanuc-focas 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # node-red-contrib-fanuc-focas
2
+
3
+ A Node-RED node for collecting telemetry from **FANUC CNC controllers** via the **FOCAS2 TCP protocol**.
4
+
5
+ Pure Node.js — no Python, no native libraries, no FANUC SDK required. Works on any platform including **Raspberry Pi (aarch64/arm64)**.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - Connects directly to FANUC controllers over Ethernet using the FOCAS2 wire protocol
12
+ - Selectable **Function** — poll only the data you need per node instance
13
+ - Sub-type selector for **Axes Data** (position, servo/spindle load, feedrate)
14
+ - Configurable **Parameter** and **Macro** variable reads by number
15
+ - Sub-second timer resolution (millisecond companion parameters)
16
+ - Correct run-state decoding for Series 16/18/21/0i/30i controllers
17
+ - Series 15/15i support via config selector
18
+ - Status indicator on each node (blue = polling, green = ok, red = error)
19
+
20
+ ---
21
+
22
+ ## Supported Controllers
23
+
24
+ | Series | Examples |
25
+ |--------|---------|
26
+ | Series 0i-D / 0i-F | 0i-D T (lathe), 0i-D M (mill) |
27
+ | Series 16i / 18i / 21i | 16i-T, 18i-M |
28
+ | Series 30i / 31i / 32i | 30i-B |
29
+ | Series 15 / 15i | (select Series 15 in config) |
30
+
31
+ Requires the **FOCAS Ethernet option** to be enabled on the controller (option code `A02B-0207-J732` or equivalent). Default TCP port is **8193**.
32
+
33
+ ---
34
+
35
+ ## Installation
36
+
37
+ ### From Node-RED Palette Manager
38
+
39
+ Search for `fanuc-focas` in **Menu → Manage Palette → Install**.
40
+
41
+ ### From command line
42
+
43
+ ```bash
44
+ cd ~/.node-red
45
+ npm install node-red-contrib-fanuc-focas
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Usage
51
+
52
+ 1. Drag a **fanuc focas** node onto your flow (found under the *input* category).
53
+ 2. Double-click it and create a new **FANUC Controller** config:
54
+ - **IP Address** — controller Ethernet IP (e.g. `192.168.0.100`)
55
+ - **FOCAS Port** — default `8193`
56
+ - **CNC Series** — `16/18/21/0i/30i` for most modern controllers
57
+ 3. Select a **Function** from the dropdown.
58
+ 4. Wire an **Inject** node (e.g. repeat every 2 seconds) to trigger polling.
59
+ 5. `msg.payload` contains the result for the selected function.
60
+
61
+ ---
62
+
63
+ ## Functions
64
+
65
+ | Function | Description | `msg.payload` fields |
66
+ |----------|-------------|----------------------|
67
+ | **All Data** | Full combined snapshot | `controller`, `machine_state`, `active_program`, `timers`, `part_count`, `feedrate_spindle`, `active_alarms` |
68
+ | **Status Info** | Machine run state | `mode`, `run_state`, `motion`, `mstb`, `emergency`, `alarm`, `edit` |
69
+ | **System Info** | Controller identity | `cnc_type`, `mt_type`, `series`, `version`, `axes` |
70
+ | **Timers** | Accumulated time counters | `power_on_time`, `auto_operation_time`, `cutting_time`, `cycle_time` |
71
+ | **Axes Data** | Position / load / feed | See sub-types below |
72
+ | **Parameters** | Raw CNC parameters | `{ [param_number]: value, … }` |
73
+ | **Program Number** | Active program | `running_program`, `main_program`, `running_comment`, `main_comment` |
74
+ | **Part Count** | Parts produced | `required_parts`, `lifetime_total` |
75
+ | **Alarm Messages** | Active alarms | Array of `{ type, code, axis, text }` |
76
+ | **Macro** | Custom macro variables | `{ [macro_number]: value, … }` |
77
+
78
+ ### Axes Data sub-types
79
+
80
+ | Sub-type | Description |
81
+ |----------|-------------|
82
+ | Absolute position | Axis positions in absolute coordinates |
83
+ | Machine position | Axis positions in machine coordinates |
84
+ | Relative position | Axis positions relative to last reset |
85
+ | Distance to go | Remaining distance in current block |
86
+ | Servo load meter | Per-axis servo load (%) |
87
+ | Spindle load meter | Spindle load (%) |
88
+ | Spindle motor speed | Actual spindle RPM |
89
+ | Actual feedrate | Feedrate in mm/min |
90
+
91
+ ### Timer format
92
+
93
+ Each timer returns both a machine-readable value and a formatted string:
94
+
95
+ ```json
96
+ "cutting_time": {
97
+ "total_seconds": 53594.237,
98
+ "formatted": "14h 53m 14.237s"
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Example Payload — All Data
105
+
106
+ ```json
107
+ {
108
+ "controller": {
109
+ "cnc_type": "0",
110
+ "mt_type": "T",
111
+ "series": "D6G3",
112
+ "version": "29.0",
113
+ "axes": 32
114
+ },
115
+ "machine_state": {
116
+ "mode": "MEMory",
117
+ "run_state": "STaRt",
118
+ "motion": "Moving",
119
+ "mstb": "Inactive",
120
+ "emergency": null,
121
+ "alarm": null,
122
+ "edit": "Inactive"
123
+ },
124
+ "active_program": {
125
+ "running_program": "O8888",
126
+ "main_program": "O8888",
127
+ "running_comment": "DRIVING BAND TURNING",
128
+ "main_comment": "DRIVING BAND TURNING"
129
+ },
130
+ "timers": {
131
+ "power_on_time": { "total_seconds": 810844, "formatted": "225h 14m" },
132
+ "auto_operation_time": { "total_seconds": 183989.237,"formatted": "51h 06m 29.237s" },
133
+ "cutting_time": { "total_seconds": 53594.237, "formatted": "14h 53m 14.237s" },
134
+ "cycle_time": { "total_seconds": 47.123, "formatted": "0h 00m 47.123s" }
135
+ },
136
+ "part_count": {
137
+ "required_parts": 500,
138
+ "lifetime_total": 488584
139
+ },
140
+ "feedrate_spindle": {
141
+ "actual_feedrate_mm_min": 24000,
142
+ "actual_spindle_rpm": 101
143
+ },
144
+ "active_alarms": [],
145
+ "timestamp": "2026-05-28T07:35:23.213Z"
146
+ }
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Dynamic Override
152
+
153
+ You can override the configured function at runtime by setting properties on the incoming message:
154
+
155
+ | Property | Description | Example |
156
+ |----------|-------------|---------|
157
+ | `msg.function` | Override function | `"status_info"` |
158
+ | `msg.subtype` | Override axes sub-type | `"abs_pos"` |
159
+ | `msg.params` | Override parameter/macro numbers | `"6711,6712"` |
160
+
161
+ ---
162
+
163
+ ## run_state Values
164
+
165
+ | Value | Series 16/18/21/0i/30i | Series 15/15i |
166
+ |-------|------------------------|---------------|
167
+ | `****` | Reset / not in auto | — |
168
+ | `STOP` | Stopped in auto | Stopped |
169
+ | `HOLD` | Feed hold | Feed hold |
170
+ | `STaRt` | Auto running ✓ | Auto running |
171
+ | `MSTR` | Tool retract / MDI exec | M/S/T executing |
172
+
173
+ > **Note:** Series 16/18/21/0i/30i `run=0` means reset (not running), not STOP. Using the wrong table is a common source of misclassified machine states.
174
+
175
+ ---
176
+
177
+ ## Multiple Machines
178
+
179
+ Create one **FANUC Controller** config node per machine, each with its own IP address. Wire separate polling chains independently:
180
+
181
+ ```
182
+ [Inject 2s] → [fanuc-focas · Lathe 1] → [OPC UA out]
183
+ [Inject 2s] → [fanuc-focas · Lathe 2] → [OPC UA out]
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Requirements
189
+
190
+ - Node.js ≥ 14.0.0
191
+ - Node-RED ≥ 2.0.0
192
+ - FOCAS Ethernet option enabled on the controller
193
+ - Network access to the controller on its FOCAS port (default 8193)
194
+
195
+ No additional npm dependencies — uses only Node.js built-ins (`net`, `Buffer`).
196
+
197
+ ---
198
+
199
+ ## Technical Notes
200
+
201
+ - **FOCAS is strictly sequential.** Each request must complete before the next is sent on the same TCP connection. This node correctly awaits each response before proceeding.
202
+ - **Connection per poll.** A new TCP connection is opened and cleanly closed for each poll cycle, matching the FOCAS session model.
203
+ - The FOCAS wire protocol is reverse-engineered from [`diohpix/pyfanuc`](https://github.com/diohpix/pyfanuc) with several bug fixes applied (valtype-2 unpack, readparam3 fallback guard, statinfo cnctype matching).
204
+
205
+ ---
206
+
207
+ ## License
208
+
209
+ MIT
@@ -0,0 +1,211 @@
1
+ <!-- ── FANUC Config Node ─────────────────────────────────────────────────── -->
2
+ <script type="text/javascript">
3
+ RED.nodes.registerType('fanuc-config', {
4
+ category: 'config',
5
+ defaults: {
6
+ name: { value: '' },
7
+ host: { value: '192.168.0.100', required: true },
8
+ port: { value: '8193', required: true },
9
+ cnc_series: { value: '16', required: true },
10
+ },
11
+ label: function() {
12
+ return this.name || `${this.host}:${this.port}`;
13
+ }
14
+ });
15
+ </script>
16
+
17
+ <script type="text/html" data-template-name="fanuc-config">
18
+ <div class="form-row">
19
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
20
+ <input type="text" id="node-config-input-name" placeholder="e.g. Lathe 1">
21
+ </div>
22
+ <div class="form-row">
23
+ <label for="node-config-input-host"><i class="fa fa-globe"></i> IP Address</label>
24
+ <input type="text" id="node-config-input-host" placeholder="192.168.0.100">
25
+ </div>
26
+ <div class="form-row">
27
+ <label for="node-config-input-port"><i class="fa fa-plug"></i> FOCAS Port</label>
28
+ <input type="text" id="node-config-input-port" placeholder="8193">
29
+ </div>
30
+ <div class="form-row">
31
+ <label for="node-config-input-cnc_series"><i class="fa fa-microchip"></i> CNC Series</label>
32
+ <select id="node-config-input-cnc_series">
33
+ <option value="16">Series 16 / 18 / 21 / 0i / 30i (most machines)</option>
34
+ <option value="15">Series 15 / 15i</option>
35
+ </select>
36
+ </div>
37
+ </script>
38
+
39
+
40
+ <!-- ── FANUC Poll Node ───────────────────────────────────────────────────── -->
41
+ <script type="text/javascript">
42
+
43
+ // Axes Data sub-types (mirrors the screenshot)
44
+ const FANUC_AXIS_TYPES = [
45
+ { group: 'Position', options: [
46
+ { v: 'abs_pos', l: 'Absolute position' },
47
+ { v: 'machine_pos',l: 'Machine position' },
48
+ { v: 'rel_pos', l: 'Relative position' },
49
+ { v: 'dist_to_go', l: 'Distance to go' },
50
+ ]},
51
+ { group: 'Servo', options: [
52
+ { v: 'servo_load', l: 'Servo load meter (percentage)' },
53
+ ]},
54
+ { group: 'Spindle', options: [
55
+ { v: 'spindle_load', l: 'Spindle load meter (percentage)' },
56
+ { v: 'spindle_speed', l: 'Spindle motor speed (rpm)' },
57
+ ]},
58
+ { group: 'Feed', options: [
59
+ { v: 'feedrate', l: 'Actual feedrate (mm/min)' },
60
+ ]},
61
+ ];
62
+
63
+ // Functions that need no extra config
64
+ const FANUC_SIMPLE_FNS = new Set([
65
+ 'status_info','system_info','timers','program_number',
66
+ 'part_count','alarm_messages','all'
67
+ ]);
68
+
69
+ RED.nodes.registerType('fanuc-focas', {
70
+ category: 'input',
71
+ color: '#f5d128',
72
+ defaults: {
73
+ name: { value: '' },
74
+ server: { value: '', type: 'fanuc-config', required: true },
75
+ fn: { value: 'all' },
76
+ subtype: { value: 'feedrate' },
77
+ params: { value: '' },
78
+ },
79
+ inputs: 1,
80
+ outputs: 1,
81
+ icon: 'inject.png',
82
+ label: function() {
83
+ const cfg = RED.nodes.node(this.server);
84
+ const host = cfg ? cfg.label() : '';
85
+ const fnLabel = {
86
+ all: 'All Data',
87
+ status_info: 'Status Info',
88
+ system_info: 'System Info',
89
+ timers: 'Timers',
90
+ axes_data: 'Axes Data',
91
+ parameters: 'Parameters',
92
+ program_number: 'Program Number',
93
+ part_count: 'Part Count',
94
+ alarm_messages: 'Alarm Messages',
95
+ macro: 'Macro',
96
+ }[this.fn] || this.fn;
97
+ return this.name || (host ? `${host} · ${fnLabel}` : fnLabel);
98
+ },
99
+ paletteLabel: 'fanuc focas',
100
+
101
+ oneditprepare: function() {
102
+ const node = this;
103
+
104
+ // Build axes sub-type select with optgroups
105
+ const $axisType = $('#node-input-subtype');
106
+ $axisType.empty();
107
+ FANUC_AXIS_TYPES.forEach(group => {
108
+ const $g = $('<optgroup>').attr('label', group.group);
109
+ group.options.forEach(o => {
110
+ $g.append($('<option>').val(o.v).text(o.l));
111
+ });
112
+ $axisType.append($g);
113
+ });
114
+
115
+ function updateVisibility() {
116
+ const fn = $('#node-input-fn').val();
117
+ $('#fanuc-row-subtype').toggle(fn === 'axes_data');
118
+ $('#fanuc-row-params').toggle(fn === 'parameters' || fn === 'macro');
119
+ // Update params label
120
+ if (fn === 'macro') {
121
+ $('#fanuc-params-label').text('Macro variables');
122
+ $('#fanuc-params-hint').text('Comma-separated macro numbers e.g. 3901,3902');
123
+ } else {
124
+ $('#fanuc-params-label').text('Parameter numbers');
125
+ $('#fanuc-params-hint').text('Comma-separated param numbers e.g. 6711,6712');
126
+ }
127
+ }
128
+
129
+ $('#node-input-fn').on('change', updateVisibility);
130
+
131
+ // Restore saved values
132
+ $('#node-input-fn').val(node.fn || 'all');
133
+ $axisType.val(node.subtype || 'feedrate');
134
+ $('#node-input-params').val(node.params || '');
135
+
136
+ updateVisibility();
137
+ },
138
+ });
139
+ </script>
140
+
141
+ <script type="text/html" data-template-name="fanuc-focas">
142
+ <div class="form-row">
143
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
144
+ <input type="text" id="node-input-name" placeholder="fanuc-focas">
145
+ </div>
146
+
147
+ <div class="form-row">
148
+ <label for="node-input-server"><i class="fa fa-server"></i> Endpoint</label>
149
+ <input type="text" id="node-input-server">
150
+ </div>
151
+
152
+ <div class="form-row">
153
+ <label for="node-input-fn"><i class="fa fa-cog"></i> Function</label>
154
+ <select id="node-input-fn" style="width:70%">
155
+ <option value="all">All Data (combined payload)</option>
156
+ <optgroup label="────────────────">
157
+ <option value="status_info">Status Info</option>
158
+ <option value="system_info">System Info</option>
159
+ <option value="timers">Timers</option>
160
+ <option value="axes_data">Axes Data</option>
161
+ <option value="parameters">Parameters</option>
162
+ <option value="program_number">Program Number</option>
163
+ <option value="part_count">Part Count</option>
164
+ <option value="alarm_messages">Alarm Messages</option>
165
+ <option value="macro">Macro</option>
166
+ </optgroup>
167
+ </select>
168
+ </div>
169
+
170
+ <!-- Axes sub-type — shown only when fn=axes_data -->
171
+ <div class="form-row" id="fanuc-row-subtype" style="display:none">
172
+ <label for="node-input-subtype"><i class="fa fa-list"></i> Type</label>
173
+ <select id="node-input-subtype" style="width:70%"></select>
174
+ </div>
175
+
176
+ <!-- Param / macro numbers — shown only when fn=parameters or fn=macro -->
177
+ <div class="form-row" id="fanuc-row-params" style="display:none">
178
+ <label for="node-input-params">
179
+ <i class="fa fa-hashtag"></i>
180
+ <span id="fanuc-params-label">Parameter numbers</span>
181
+ </label>
182
+ <input type="text" id="node-input-params" placeholder="6711,6712" style="width:70%">
183
+ <div class="form-tips" id="fanuc-params-hint" style="margin-top:4px">
184
+ Comma-separated param numbers e.g. 6711,6712
185
+ </div>
186
+ </div>
187
+ </script>
188
+
189
+ <script type="text/html" data-help-name="fanuc-focas">
190
+ <p>Polls a FANUC CNC controller via FOCAS2 TCP and outputs <code>msg.payload</code>.</p>
191
+
192
+ <h3>Function</h3>
193
+ <dl class="message-properties">
194
+ <dt>All Data</dt><dd>Full combined payload (controller, state, timers, program, alarms)</dd>
195
+ <dt>Status Info</dt><dd>mode, run_state, motion, alarm, emergency, edit</dd>
196
+ <dt>System Info</dt><dd>cnc_type, mt_type, series, version, axes</dd>
197
+ <dt>Timers</dt><dd>power_on, auto_operation, cutting, cycle — with total_seconds and formatted</dd>
198
+ <dt>Axes Data</dt><dd>Position, servo/spindle load, feedrate — select sub-type</dd>
199
+ <dt>Parameters</dt><dd>Raw parameter values by number</dd>
200
+ <dt>Program Number</dt><dd>Running and main program numbers with comments</dd>
201
+ <dt>Part Count</dt><dd>Required and lifetime total parts</dd>
202
+ <dt>Alarm Messages</dt><dd>Active alarms with type, code, axis, text</dd>
203
+ <dt>Macro</dt><dd>Custom macro variable values by number</dd>
204
+ </dl>
205
+
206
+ <h3>Dynamic override</h3>
207
+ <p>Set <code>msg.function</code>, <code>msg.subtype</code>, or <code>msg.params</code> to override the configured values at runtime.</p>
208
+
209
+ <h3>Notes</h3>
210
+ <p>FOCAS is strictly sequential. Each node maintains its own TCP connection per poll cycle.</p>
211
+ </script>
package/fanuc-focas.js ADDED
@@ -0,0 +1,279 @@
1
+ 'use strict';
2
+ let Focas, ALLAXIS;
3
+ try {
4
+ const m = require('./focas');
5
+ Focas = m.Focas;
6
+ ALLAXIS = m.ALLAXIS;
7
+ if (typeof Focas !== 'function') {
8
+ throw new Error(
9
+ `focas.js exported Focas as '${typeof Focas}' (expected a class). ` +
10
+ `Ensure focas.js is in the same directory as fanuc-focas.js.`
11
+ );
12
+ }
13
+ } catch (e) {
14
+ throw new Error(`node-red-contrib-fanuc-focas: failed to load focas.js — ${e.message}`);
15
+ }
16
+
17
+
18
+ // ── Lookup tables ─────────────────────────────────────────────────────────────
19
+ const AUT_MODES = {
20
+ 0:'MDI', 1:'MEMory', 2:'****', 3:'EDIT', 4:'HaNDle',
21
+ 5:'JOG', 6:'Teach in JOG', 7:'Teach in HaNDle',
22
+ 8:'INC feed', 9:'REFerence', 10:'ReMoTe',
23
+ };
24
+ const RUN_MODES_16 = { 0:'****', 1:'STOP', 2:'HOLD', 3:'STaRt', 4:'MSTR' };
25
+ const RUN_MODES_15 = {
26
+ 0:'STOP', 1:'HOLD', 2:'STaRt', 3:'MSTR', 4:'ReSTaRt',
27
+ 5:'PRSR', 6:'NSRC', 7:'ReSTaRt*', 8:'ReSET', 13:'HPCC',
28
+ };
29
+ const EMG_STATES = { 0:null, 1:'EMERGENCY', 2:'RESET', 3:'WAIT' };
30
+ const ALM_STATES = {
31
+ 0:null, 1:'ALARM', 2:'BATTERY LOW', 3:'FAN',
32
+ 4:'PS WARNING', 5:'FSSB WARNING', 6:'INSULATE WARNING',
33
+ 7:'ENCODER WARNING', 8:'PMC ALARM',
34
+ };
35
+ const ALARM_TYPES = {
36
+ 0:'BG', 1:'TH', 2:'RS', 4:'SV', 8:'SW',
37
+ 16:'IO', 32:'PS', 64:'OT', 128:'OH',
38
+ };
39
+
40
+ // ── Helpers ───────────────────────────────────────────────────────────────────
41
+ function readParamVal(paramMap, key) {
42
+ return (paramMap && paramMap[key]) ? paramMap[key].data[0] : null;
43
+ }
44
+
45
+ function buildTimer(minVal, msVal = null) {
46
+ if (minVal === null || minVal === undefined)
47
+ return { total_seconds: null, formatted: null };
48
+ const ms = (msVal !== null && msVal !== undefined) ? msVal : 0;
49
+ const totalSec = minVal * 60 + ms / 1000;
50
+ const h = Math.floor(totalSec / 3600);
51
+ const rem = Math.floor(totalSec % 3600);
52
+ const m = Math.floor(rem / 60);
53
+ const s = Math.floor(rem % 60);
54
+ const frac = Math.round(totalSec * 1000) % 1000;
55
+ const formatted = msVal !== null
56
+ ? `${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}.${String(frac).padStart(3,'0')}s`
57
+ : `${h}h ${String(m).padStart(2,'0')}m`;
58
+ return { total_seconds: Math.round(totalSec * 1000) / 1000, formatted };
59
+ }
60
+
61
+ // ── Function implementations ──────────────────────────────────────────────────
62
+
63
+ async function fnStatusInfo(focas, runModes) {
64
+ const state = await focas.statinfo();
65
+ if (!state) return null;
66
+ return {
67
+ mode: state.aut in AUT_MODES ? AUT_MODES[state.aut] : String(state.aut),
68
+ run_state: state.run in runModes ? runModes[state.run] : String(state.run),
69
+ motion: state.motion ? 'Moving' : 'Stopped',
70
+ mstb: state.mstb ? 'Active' : 'Inactive',
71
+ emergency: (state.emegency in EMG_STATES) ? EMG_STATES[state.emegency] : `UNKNOWN(${state.emegency})`,
72
+ alarm: (state.alarm in ALM_STATES) ? ALM_STATES[state.alarm] : `ALARM(${state.alarm})`,
73
+ edit: state.edit ? 'Active' : 'Inactive',
74
+ };
75
+ }
76
+
77
+ async function fnSystemInfo(focas) {
78
+ const si = focas.sysinfo;
79
+ return {
80
+ cnc_type: si.cnctype.trim(),
81
+ mt_type: si.mttype.trim(),
82
+ series: si.series.trim(),
83
+ version: si.version.trim(),
84
+ axes: si.maxaxis,
85
+ };
86
+ }
87
+
88
+ async function fnTimers(focas) {
89
+ // FOCAS is strictly sequential — no Promise.all on a single socket
90
+ const p6750 = await focas.readparam3(ALLAXIS, 6750);
91
+ const p6751 = await focas.readparam3(ALLAXIS, 6751);
92
+ const p6752 = await focas.readparam3(ALLAXIS, 6752);
93
+ const p6753 = await focas.readparam3(ALLAXIS, 6753);
94
+ const p6754 = await focas.readparam3(ALLAXIS, 6754);
95
+ const p6757 = await focas.readparam3(ALLAXIS, 6757);
96
+ const p6758 = await focas.readparam3(ALLAXIS, 6758);
97
+ return {
98
+ power_on_time: buildTimer(readParamVal(p6750, 6750)),
99
+ auto_operation_time: buildTimer(readParamVal(p6752, 6752), readParamVal(p6751, 6751)),
100
+ cutting_time: buildTimer(readParamVal(p6754, 6754), readParamVal(p6753, 6753)),
101
+ cycle_time: buildTimer(readParamVal(p6758, 6758), readParamVal(p6757, 6757)),
102
+ };
103
+ }
104
+
105
+ async function fnProgramNumber(focas) {
106
+ const prognum = await focas.readprognum();
107
+ if (!prognum) return null;
108
+ const runNum = prognum.run;
109
+ const mainNum = prognum.main;
110
+ const progList = await focas.listprog(Math.min(runNum, mainNum));
111
+ const cmt = (n) => (progList && progList[n]) ? (progList[n].comment.trim() || null) : null;
112
+ return {
113
+ running_program: `O${runNum}`,
114
+ main_program: `O${mainNum}`,
115
+ running_comment: cmt(runNum),
116
+ main_comment: cmt(mainNum),
117
+ };
118
+ }
119
+
120
+ async function fnPartCount(focas) {
121
+ const p6711 = await focas.readparam3(ALLAXIS, 6711);
122
+ const p6712 = await focas.readparam3(ALLAXIS, 6712);
123
+ return {
124
+ required_parts: readParamVal(p6711, 6711),
125
+ lifetime_total: readParamVal(p6712, 6712),
126
+ };
127
+ }
128
+
129
+ async function fnAlarmMessages(focas) {
130
+ const alarms = await focas.readalarmcode(1, 1, 10, 32);
131
+ return (alarms || []).map(a => ({
132
+ type: a.alarmtype in ALARM_TYPES ? ALARM_TYPES[a.alarmtype] : String(a.alarmtype),
133
+ code: a.alarmcode,
134
+ axis: a.axis,
135
+ text: a.text,
136
+ }));
137
+ }
138
+
139
+ async function fnAxesData(focas, axisType) {
140
+ // axisType maps to the what bitmask used in readaxes()
141
+ // Implemented directly via _reqSingle for the data types we need
142
+ switch (axisType) {
143
+ case 'feedrate':
144
+ return { actual_feedrate_mm_min: await focas.readactfeed() };
145
+ case 'spindle_speed':
146
+ return { actual_spindle_rpm: await focas.readactspindlespeed() };
147
+ case 'spindle_load':
148
+ return { spindle_load_percent: await focas.readspindleload() };
149
+ case 'servo_load':
150
+ return { servo_load_percent: await focas.readservoload() };
151
+ case 'abs_pos':
152
+ return { absolute_position: await focas.readaxes(1) };
153
+ case 'rel_pos':
154
+ return { relative_position: await focas.readaxes(2) };
155
+ case 'dist_to_go':
156
+ return { distance_to_go: await focas.readaxes(16) };
157
+ case 'machine_pos':
158
+ return { machine_position: await focas.readaxes(4) };
159
+ default:
160
+ return null;
161
+ }
162
+ }
163
+
164
+ async function fnParameters(focas, paramNums) {
165
+ // paramNums is a comma-separated string of parameter numbers
166
+ const nums = String(paramNums).split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n));
167
+ const result = {};
168
+ for (const n of nums) {
169
+ const r = await focas.readparam3(ALLAXIS, n);
170
+ if (r && r[n]) result[n] = r[n].data[0];
171
+ }
172
+ return result;
173
+ }
174
+
175
+ async function fnMacro(focas, macroNums) {
176
+ const nums = String(macroNums).split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n));
177
+ const result = {};
178
+ for (const n of nums) {
179
+ const r = await focas.readmacro(n);
180
+ if (r && r[n] !== undefined) result[n] = r[n];
181
+ }
182
+ return result;
183
+ }
184
+
185
+ // ── All-in-one (legacy behaviour) ────────────────────────────────────────────
186
+ async function fnAll(focas, runModes) {
187
+ const [status, sysinfo, timers, program, parts, alarms] = [
188
+ await fnStatusInfo(focas, runModes),
189
+ await fnSystemInfo(focas),
190
+ await fnTimers(focas),
191
+ await fnProgramNumber(focas),
192
+ await fnPartCount(focas),
193
+ await fnAlarmMessages(focas),
194
+ ];
195
+ const feed = await focas.readactfeed();
196
+ const spindle = await focas.readactspindlespeed();
197
+ return {
198
+ controller: sysinfo,
199
+ machine_state: status,
200
+ active_program: program,
201
+ timers,
202
+ part_count: parts,
203
+ feedrate_spindle: { actual_feedrate_mm_min: feed, actual_spindle_rpm: spindle },
204
+ active_alarms: alarms,
205
+ };
206
+ }
207
+
208
+ // ── Main dispatcher ───────────────────────────────────────────────────────────
209
+ async function collect(ip, port, cnc_series, fn, subtype, params) {
210
+ const focas = new Focas(ip, port);
211
+ const runModes = cnc_series === '15' ? RUN_MODES_15 : RUN_MODES_16;
212
+
213
+ await focas.connect();
214
+ let result;
215
+ try {
216
+ switch (fn) {
217
+ case 'status_info': result = await fnStatusInfo(focas, runModes); break;
218
+ case 'system_info': result = await fnSystemInfo(focas); break;
219
+ case 'timers': result = await fnTimers(focas); break;
220
+ case 'program_number': result = await fnProgramNumber(focas); break;
221
+ case 'part_count': result = await fnPartCount(focas); break;
222
+ case 'alarm_messages': result = await fnAlarmMessages(focas); break;
223
+ case 'axes_data': result = await fnAxesData(focas, subtype); break;
224
+ case 'parameters': result = await fnParameters(focas, params); break;
225
+ case 'macro': result = await fnMacro(focas, params); break;
226
+ case 'all':
227
+ default: result = await fnAll(focas, runModes); break;
228
+ }
229
+ result = { ...result, timestamp: new Date().toISOString() };
230
+ } finally {
231
+ await focas.disconnect();
232
+ }
233
+ return result;
234
+ }
235
+
236
+ // ── Node-RED registration ─────────────────────────────────────────────────────
237
+ module.exports = function(RED) {
238
+
239
+ function FanucConfigNode(config) {
240
+ RED.nodes.createNode(this, config);
241
+ this.host = config.host || '192.168.0.100';
242
+ this.port = parseInt(config.port) || 8193;
243
+ this.cnc_series = config.cnc_series || '16';
244
+ }
245
+ RED.nodes.registerType('fanuc-config', FanucConfigNode);
246
+
247
+ function FanucFocasNode(config) {
248
+ RED.nodes.createNode(this, config);
249
+ const node = this;
250
+ const server = RED.nodes.getNode(config.server);
251
+
252
+ if (!server) {
253
+ node.error('No FANUC config node selected');
254
+ return;
255
+ }
256
+
257
+ node.on('input', async function(msg, send, done) {
258
+ // Allow overriding function/subtype/params via msg
259
+ const fn = msg.function || config.fn || 'all';
260
+ const subtype = msg.subtype || config.subtype || 'feedrate';
261
+ const params = msg.params || config.params || '';
262
+
263
+ node.status({ fill:'blue', shape:'dot', text: fn });
264
+ try {
265
+ msg.payload = await collect(server.host, server.port, server.cnc_series, fn, subtype, params);
266
+ node.status({ fill:'green', shape:'dot', text:'ok' });
267
+ send(msg);
268
+ done();
269
+ } catch (err) {
270
+ node.status({ fill:'red', shape:'ring', text: err.message });
271
+ node.error(err.message, msg);
272
+ done(err);
273
+ }
274
+ });
275
+
276
+ node.on('close', () => node.status({}));
277
+ }
278
+ RED.nodes.registerType('fanuc-focas', FanucFocasNode);
279
+ };
package/focas.js ADDED
@@ -0,0 +1,416 @@
1
+ 'use strict';
2
+ /**
3
+ * focas.js — Pure Node.js FOCAS2 TCP client
4
+ * Port of diohpix/pyfanuc with all known bug-fixes applied.
5
+ *
6
+ * Protocol constants match pyfanuc.py exactly.
7
+ * All methods return Promises.
8
+ */
9
+ const net = require('net');
10
+
11
+ // ── Frame type constants ──────────────────────────────────────────────────────
12
+ const FTYPE_OPN_REQU = 0x0101;
13
+ const FTYPE_OPN_RESP = 0x0102;
14
+ const FTYPE_VAR_REQU = 0x2101;
15
+ const FTYPE_VAR_RESP = 0x2102;
16
+ const FTYPE_CLS_REQU = 0x0201;
17
+ const FTYPE_CLS_RESP = 0x0202;
18
+
19
+ const FRAMEHEAD = Buffer.from([0xa0, 0xa0, 0xa0, 0xa0]);
20
+ const FRAME_DST = Buffer.from([0x00, 0x02]);
21
+ const ALLAXIS = -1;
22
+
23
+ // ── Low-level framing ─────────────────────────────────────────────────────────
24
+ function encap(ftype, payload, fvers = 1) {
25
+ if (ftype === FTYPE_VAR_REQU) {
26
+ if (Array.isArray(payload)) {
27
+ const parts = payload.map(p => {
28
+ const lenBuf = Buffer.alloc(2);
29
+ lenBuf.writeUInt16BE(p.length + 2);
30
+ return Buffer.concat([lenBuf, p]);
31
+ });
32
+ const countBuf = Buffer.alloc(2);
33
+ countBuf.writeUInt16BE(parts.length);
34
+ payload = Buffer.concat([countBuf, ...parts]);
35
+ } else {
36
+ const hdr = Buffer.alloc(4);
37
+ hdr.writeUInt16BE(1, 0);
38
+ hdr.writeUInt16BE(payload.length + 2, 2);
39
+ payload = Buffer.concat([hdr, payload]);
40
+ }
41
+ }
42
+ const hdr = Buffer.alloc(6);
43
+ hdr.writeUInt16BE(fvers, 0);
44
+ hdr.writeUInt16BE(ftype, 2);
45
+ hdr.writeUInt16BE(payload.length, 4);
46
+ return Buffer.concat([FRAMEHEAD, hdr, payload]);
47
+ }
48
+
49
+ function decap(data) {
50
+ if (data.length < 10) return { len: -1 };
51
+ if (data[0] !== 0xa0 || data[1] !== 0xa0 || data[2] !== 0xa0 || data[3] !== 0xa0)
52
+ return { len: -1 };
53
+ const fvers = data.readUInt16BE(4);
54
+ const ftype = data.readUInt16BE(6);
55
+ const len1 = data.readUInt16BE(8);
56
+ if (len1 + 10 !== data.length) return { len: -1 };
57
+ if (len1 === 0) return { len: 0, ftype, fvers, data: Buffer.from([0x30]) };
58
+
59
+ const body = data.slice(10);
60
+ if (ftype === FTYPE_VAR_RESP) {
61
+ const qu = body.readUInt16BE(0);
62
+ let n = 2, re = [];
63
+ for (let t = 0; t < qu; t++) {
64
+ const le = body.readUInt16BE(n);
65
+ re.push(body.slice(n + 2, n + le));
66
+ n += le;
67
+ }
68
+ return { len: len1, ftype, fvers, data: re };
69
+ }
70
+ return { len: len1, ftype, fvers, data: body };
71
+ }
72
+
73
+ // ── Build sub-command buffer (for _req_rdmulti) ───────────────────────────────
74
+ function reqSub(c1, c2, c3, v1=0, v2=0, v3=0, v4=0, v5=0) {
75
+ const b = Buffer.alloc(6 + 5*4);
76
+ b.writeUInt16BE(c1, 0);
77
+ b.writeUInt16BE(c2, 2);
78
+ b.writeUInt16BE(c3, 4);
79
+ b.writeInt32BE(v1, 6);
80
+ b.writeInt32BE(v2, 10);
81
+ b.writeInt32BE(v3, 14);
82
+ b.writeInt32BE(v4, 18);
83
+ b.writeInt32BE(v5, 22);
84
+ return b;
85
+ }
86
+
87
+ // ── Decode 8-byte value (feedrate/spindle) ────────────────────────────────────
88
+ function decode8(val) {
89
+ const flag = val[5];
90
+ if (flag === 2 || flag === 10) {
91
+ if (val[6] === 0xff && val[7] === 0xff) return null;
92
+ const raw = val.readInt32BE(0);
93
+ return raw / Math.pow(flag, val[7]);
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // ── Parse param/diag response body ───────────────────────────────────────────
99
+ function parseParamBody(data, maxaxis, mode = 'param3') {
100
+ const stride = maxaxis * 4 + 8;
101
+ const r = {};
102
+ for (let pos = 0; pos + 8 <= data.length; pos += stride) {
103
+ const varname = data.readUInt32BE(pos);
104
+ const axiscount = data.readInt16BE(pos + 4);
105
+ const valtype = data.readUInt16BE(pos + 6);
106
+ const values = { type: valtype, axis: axiscount, data: [] };
107
+
108
+ for (let n = pos + 8; n < pos + stride; n += 4) {
109
+ const chunk = data.slice(n, n + 4);
110
+ let value;
111
+ if (mode === 'param3') {
112
+ if (valtype === 0) value = chunk[3];
113
+ else if (valtype === 1) { const b = chunk[3]; value = [7,6,5,4,3,2,1,0].map(i => (b>>i)&1); }
114
+ else if (valtype === 2) value = chunk.readInt16BE(2); // fix: last 2 bytes
115
+ else if (valtype === 3) value = chunk.readInt32BE(0);
116
+ } else { // diag
117
+ if (valtype === 4 || valtype === 0) value = chunk[3];
118
+ else if (valtype === 1) value = chunk.readInt16BE(2);
119
+ else if (valtype === 2) value = chunk.readInt32BE(0);
120
+ else if (valtype === 3) { const b = chunk[3]; value = [7,6,5,4,3,2,1,0].map(i => (b>>i)&1); }
121
+ }
122
+ if (axiscount !== -1) { values.data.push(value); break; }
123
+ else values.data.push(value);
124
+ }
125
+ r[varname] = values;
126
+ }
127
+ return r;
128
+ }
129
+
130
+ // ── Main client class ─────────────────────────────────────────────────────────
131
+ class Focas {
132
+ constructor(ip, port = 8193, timeout = 5000) {
133
+ this.ip = ip;
134
+ this.port = port;
135
+ this.timeout = timeout;
136
+ this.socket = null;
137
+ this.sysinfo = null;
138
+ }
139
+
140
+ // ── Socket helpers ────────────────────────────────────────────────────────
141
+ _send(buf) {
142
+ return new Promise((resolve, reject) => {
143
+ this.socket.write(buf, err => err ? reject(err) : resolve());
144
+ });
145
+ }
146
+
147
+ _recv() {
148
+ return new Promise((resolve, reject) => {
149
+ const timer = setTimeout(() => reject(new Error('FOCAS recv timeout')), this.timeout);
150
+ let buf = Buffer.alloc(0);
151
+
152
+ const onData = chunk => {
153
+ buf = Buffer.concat([buf, chunk]);
154
+ if (buf.length < 10) return;
155
+ const expected = buf.readUInt16BE(8) + 10;
156
+ if (buf.length >= expected) {
157
+ clearTimeout(timer);
158
+ this.socket.removeListener('data', onData);
159
+ this.socket.removeListener('error', onErr);
160
+ resolve(buf.slice(0, expected));
161
+ }
162
+ };
163
+ const onErr = err => { clearTimeout(timer); reject(err); };
164
+ this.socket.on('data', onData);
165
+ this.socket.on('error', onErr);
166
+ });
167
+ }
168
+
169
+ // ── Connect / disconnect ──────────────────────────────────────────────────
170
+ connect() {
171
+ return new Promise((resolve, reject) => {
172
+ this.socket = new net.Socket();
173
+ this.socket.setTimeout(this.timeout);
174
+ this.socket.connect(this.port, this.ip, async () => {
175
+ try {
176
+ await this._send(encap(FTYPE_OPN_REQU, FRAME_DST));
177
+ const raw = await this._recv();
178
+ const res = decap(raw);
179
+ if (res.ftype !== FTYPE_OPN_RESP) return reject(new Error('Open handshake failed'));
180
+ await this._getsysinfo();
181
+ resolve();
182
+ } catch (e) { reject(e); }
183
+ });
184
+ this.socket.on('error', reject);
185
+ this.socket.on('timeout', () => reject(new Error('Connection timeout')));
186
+ });
187
+ }
188
+
189
+ async disconnect() {
190
+ if (!this.socket) return;
191
+ try {
192
+ await this._send(encap(FTYPE_CLS_REQU, Buffer.alloc(0)));
193
+ await this._recv();
194
+ } catch (_) {}
195
+ this.socket.destroy();
196
+ this.socket = null;
197
+ }
198
+
199
+ // ── Request primitives ────────────────────────────────────────────────────
200
+ async _reqSingle(c1, c2, c3, v1=0, v2=0, v3=0, v4=0, v5=0, pl=Buffer.alloc(0)) {
201
+ const cmd = Buffer.alloc(6);
202
+ cmd.writeUInt16BE(c1, 0); cmd.writeUInt16BE(c2, 2); cmd.writeUInt16BE(c3, 4);
203
+ const args = Buffer.alloc(20);
204
+ args.writeInt32BE(v1, 0); args.writeInt32BE(v2, 4);
205
+ args.writeInt32BE(v3, 8); args.writeInt32BE(v4, 12); args.writeInt32BE(v5, 16);
206
+ await this._send(encap(FTYPE_VAR_REQU, Buffer.concat([cmd, args, pl])));
207
+ const raw = await this._recv();
208
+ const t = decap(raw);
209
+ if (t.len === 0) return { len: -1 };
210
+ if (t.ftype !== FTYPE_VAR_RESP) return { len: -1 };
211
+ const d = t.data[0];
212
+ if (d.slice(0, 6).equals(cmd) && d[6] === 0 && d[7] === 0 && d[8] === 0 && d[9] === 0 && d[10] === 0 && d[11] === 0) {
213
+ return { len: d.readUInt16BE(12), data: d.slice(14) };
214
+ }
215
+ if (d.slice(0, 6).equals(cmd)) {
216
+ return { len: 0, data: d.slice(6), error: d.readInt16BE(6) };
217
+ }
218
+ return { len: -1 };
219
+ }
220
+
221
+ async _reqMulti(list) {
222
+ await this._send(encap(FTYPE_VAR_REQU, list));
223
+ const raw = await this._recv();
224
+ const t = decap(raw);
225
+ if (t.len === 0 || t.ftype !== FTYPE_VAR_RESP) return { len: -1 };
226
+ if (list.length !== t.data.length) return { len: -1 };
227
+ for (let x = 0; x < t.data.length; x++) {
228
+ if (t.data[x].slice(0, 6).equals(list[x].slice(0, 6))) {
229
+ const zeros = t.data[x].slice(6, 12).every(b => b === 0);
230
+ t.data[x] = zeros
231
+ ? [0, t.data[x].slice(12)]
232
+ : [t.data[x].readInt16BE(0), t.data[x].slice(12)];
233
+ } else {
234
+ return { len: -1 };
235
+ }
236
+ }
237
+ return t;
238
+ }
239
+
240
+ // ── Public API ────────────────────────────────────────────────────────────
241
+
242
+ async _getsysinfo() {
243
+ const st = await this._reqSingle(1, 1, 0x18);
244
+ if (st.len !== 0x12) throw new Error('getsysinfo: unexpected response length');
245
+ this.sysinfo = {
246
+ addinfo: st.data.readUInt16BE(0),
247
+ maxaxis: st.data.readUInt16BE(2),
248
+ cnctype: st.data.slice(4, 6).toString('ascii'),
249
+ mttype: st.data.slice(6, 8).toString('ascii'),
250
+ series: st.data.slice(8, 12).toString('ascii'),
251
+ version: st.data.slice(12, 16).toString('ascii'),
252
+ axes: st.data.slice(16, 18).toString('ascii'),
253
+ };
254
+ }
255
+
256
+ async statinfo() {
257
+ const st = await this._reqSingle(1, 1, 0x19, 0);
258
+ const t = this.sysinfo.cnctype.trim();
259
+ const is16 = ['16','31','18','0i','30',' 0','0 '].includes(t) ||
260
+ t === '0'; // 0i-D returns bare '0'
261
+ if (is16 && st.len === 0x0e) {
262
+ const d = st.data;
263
+ return {
264
+ hdck: d.readUInt16BE(0),
265
+ tmmode: d.readUInt16BE(2), // actually: aut at offset 0 per ODBST layout...
266
+ aut: d.readUInt16BE(0),
267
+ run: d.readUInt16BE(2),
268
+ motion: d.readUInt16BE(4),
269
+ mstb: d.readUInt16BE(6),
270
+ emegency: d.readUInt16BE(8),
271
+ alarm: d.readUInt16BE(10),
272
+ edit: d.readUInt16BE(12),
273
+ };
274
+ }
275
+ return null;
276
+ }
277
+
278
+ async readprognum() {
279
+ const st = await this._reqSingle(1, 1, 0x1c);
280
+ if (st.len < 8) return null;
281
+ return {
282
+ run: st.data.readInt32BE(0),
283
+ main: st.data.readInt32BE(4),
284
+ };
285
+ }
286
+
287
+ async listprog(start = 1) {
288
+ const ret = {};
289
+ while (true) {
290
+ const st = await this._reqSingle(1, 1, 0x06, start, 0x13, 2);
291
+ if (st.len < -1) return null;
292
+ if (st.len === 0) return ret;
293
+ for (let t = 0; t + 72 <= st.len; t += 72) {
294
+ const number = st.data.readUInt32BE(t);
295
+ // size = st.data.readUInt32BE(t+4);
296
+ let comment = st.data.slice(t + 8, t + 72);
297
+ const nullIdx = comment.indexOf(0);
298
+ if (nullIdx !== -1) comment = comment.slice(0, nullIdx);
299
+ ret[number] = { comment: comment.toString('ascii') };
300
+ start = number + 1;
301
+ }
302
+ }
303
+ }
304
+
305
+ async readparam3(axis, first, last = 0) {
306
+ if (last === 0) last = first;
307
+ // Try 0x8d (0i/30i), fall back to 0x0e (16/18/21i) if response empty
308
+ let st = await this._reqSingle(1, 1, 0x8d, first, last, axis);
309
+ if (st.len <= 0) {
310
+ st = await this._reqSingle(1, 1, 0x0e, first, last, axis);
311
+ }
312
+ if (st.len <= 0) return null;
313
+ return parseParamBody(st.data, this.sysinfo.maxaxis, 'param3');
314
+ }
315
+
316
+ async readactfeed() {
317
+ const st = await this._reqSingle(1, 1, 0x24);
318
+ return (st.len === 8) ? decode8(st.data) : null;
319
+ }
320
+
321
+ async readactspindlespeed() {
322
+ const st = await this._reqSingle(1, 1, 0x25);
323
+ return (st.len === 8) ? decode8(st.data) : null;
324
+ }
325
+
326
+ async readalarmcode(type, withtext = 1, maxmsgs = -1, textlength = 32) {
327
+ if (maxmsgs <= 0) maxmsgs = parseInt(this.sysinfo.axes) || 32;
328
+ const st = await this._reqSingle(1, 1, 0x23, type, maxmsgs, withtext, textlength);
329
+ if (st.len <= 0) return [];
330
+ const stride = 4 * 4 + textlength;
331
+ const ret = [];
332
+ for (let pos = 0; pos + stride <= st.len; pos += stride) {
333
+ const entry = {
334
+ alarmcode: st.data.readInt32BE(pos),
335
+ alarmtype: st.data.readInt32BE(pos + 4),
336
+ axis: st.data.readInt32BE(pos + 8),
337
+ };
338
+ const txlen = st.data.readInt32BE(pos + 12);
339
+ if (txlen > 0 && withtext > 0) {
340
+ let text = st.data.slice(pos + 16, pos + 16 + textlength);
341
+ const nullIdx = text.indexOf(0);
342
+ if (nullIdx !== -1) text = text.slice(0, nullIdx);
343
+ entry.text = text.toString('ascii').trim();
344
+ } else {
345
+ entry.text = '';
346
+ }
347
+ ret.push(entry);
348
+ }
349
+ return ret;
350
+ }
351
+
352
+ // ── Axes position data ────────────────────────────────────────────────────
353
+ // what bitmask: ABS=1, REL=2, REF=4 (machine pos), DIST=16
354
+ async readaxes(what = 1, axis = ALLAXIS) {
355
+ const axvalues = [
356
+ { name:'ABS', flag:1, sub:4 },
357
+ { name:'REL', flag:2, sub:6 },
358
+ { name:'REF', flag:4, sub:1 },
359
+ { name:'SKIP', flag:8, sub:8 },
360
+ { name:'DIST', flag:16, sub:7 },
361
+ ];
362
+ const cmds = axvalues
363
+ .filter(a => what & a.flag)
364
+ .map(a => {
365
+ const b = Buffer.alloc(26);
366
+ b.writeUInt16BE(1, 0); b.writeUInt16BE(1, 2); b.writeUInt16BE(0x26, 4);
367
+ b.writeInt32BE(a.sub, 6); b.writeInt32BE(axis, 10);
368
+ return b;
369
+ });
370
+ if (cmds.length === 0) return null;
371
+ const st = await this._reqMulti(cmds);
372
+ if (!st || st.len < 0) return null;
373
+ const result = {};
374
+ let idx = 0;
375
+ for (const a of axvalues) {
376
+ if (!(what & a.flag)) continue;
377
+ const d = st.data[idx++];
378
+ if (!d || d[0] !== 0) { result[a.name] = null; continue; }
379
+ const body = d[1];
380
+ const count = body.readUInt16BE(0);
381
+ const vals = [];
382
+ for (let p = 2; p < count * 8 + 2; p += 8) vals.push(decode8(body.slice(p, p + 8)));
383
+ result[a.name] = vals;
384
+ }
385
+ return result;
386
+ }
387
+
388
+ // ── Macro variables ───────────────────────────────────────────────────────
389
+ async readmacro(first, last = 0) {
390
+ if (last === 0) last = first;
391
+ const st = await this._reqSingle(1, 1, 0x15, first, last);
392
+ if (st.len <= 0) return null;
393
+ const r = {};
394
+ let n = first;
395
+ for (let pos = 0; pos + 8 <= st.len; pos += 8) {
396
+ r[n++] = decode8(st.data.slice(pos, pos + 8));
397
+ }
398
+ return r;
399
+ }
400
+
401
+ // ── Servo load (diag 400, per-axis percentage) ────────────────────────────
402
+ async readservoload() {
403
+ const st = await this._reqSingle(1, 1, 0x30, 400, 400, ALLAXIS);
404
+ if (st.len <= 0) return null;
405
+ const r400 = parseParamBody(st.data, this.sysinfo.maxaxis, 'diag')[400]; return r400 ? r400.data : null;
406
+ }
407
+
408
+ // ── Spindle load (diag 300, percentage) ──────────────────────────────────
409
+ async readspindleload() {
410
+ const st = await this._reqSingle(1, 1, 0x30, 300, 300, ALLAXIS);
411
+ if (st.len <= 0) return null;
412
+ const r300 = parseParamBody(st.data, this.sysinfo.maxaxis, 'diag')[300]; return r300 ? r300.data : null;
413
+ }
414
+ }
415
+
416
+ module.exports = { Focas, ALLAXIS };
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "node-red-contrib-fanuc-focas",
3
+ "version": "0.0.1",
4
+ "description": "Node-RED node for FANUC CNC data collection via FOCAS2 protocol (pure Node.js, no native libraries, adapted from pyfanuc)",
5
+ "keywords": ["node-red", "fanuc", "focas", "cnc", "iot", "focas2"],
6
+ "author": "zhide loh",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/zhideloh/node-red-contrib-fanuc-focas"
11
+ },
12
+ "node-red": {
13
+ "version": ">=2.0.0",
14
+ "nodes": {
15
+ "fanuc-focas": "fanuc-focas.js"
16
+ }
17
+ },
18
+ "engines": { "node": ">=14.0.0" }
19
+ }