nodalis-compiler 1.0.27 → 1.0.28

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.28] 2026-03-23
4
+ - Improved support for Arduino, fully tested Arduino compile -> program stack.
5
+ - Added a command line interface for nodalis on Arduino to use in setting IP, read/write of bits, and map info.
6
+
3
7
  ## [1.0.27] 2026-03-20
4
8
  - Corrected issues with SSH Programmer.
5
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodalis-compiler",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "Compiles IEC-61131-3/10 languages into code that can be used as a PLC on multiple platforms.",
5
5
  "icon": "nodalis.png",
6
6
  "main": "src/nodalis.js",
@@ -219,20 +219,62 @@ export class ArduinoCompiler extends Compiler {
219
219
 
220
220
  const inoCode = `#include "nodalis.h"
221
221
  #include <stdint.h>
222
+ #include <Ethernet.h>
222
223
  #include "modbus.h"
224
+ #include "network_config.h"
223
225
 
224
226
  NodalisModbusTcpServer modbusServer;
227
+ IPAddress localIp;
228
+
229
+ #if defined(LEDR)
230
+ const int NODALIS_HEARTBEAT_LED_PIN = LEDR;
231
+ #elif defined(LED_BUILTIN)
232
+ const int NODALIS_HEARTBEAT_LED_PIN = LED_BUILTIN;
233
+ #else
234
+ const int NODALIS_HEARTBEAT_LED_PIN = -1;
235
+ #endif
236
+
237
+ void nodalisHeartbeatTask() {
238
+ if (NODALIS_HEARTBEAT_LED_PIN < 0) {
239
+ return;
240
+ }
241
+
242
+ static uint64_t lastToggle = 0;
243
+ static bool ledState = false;
244
+ const uint64_t now = elapsed();
245
+ if (now - lastToggle < 500) {
246
+ return;
247
+ }
248
+
249
+ lastToggle = now;
250
+ ledState = !ledState;
251
+ digitalWrite(NODALIS_HEARTBEAT_LED_PIN, ledState ? HIGH : LOW);
252
+ }
225
253
  ${transpiledCode}
226
254
 
227
255
  void setup() {
256
+ Serial.begin(115200);
257
+ nodalisLogInfo("Setup starting");
258
+ if (NODALIS_HEARTBEAT_LED_PIN >= 0) {
259
+ pinMode(NODALIS_HEARTBEAT_LED_PIN, OUTPUT);
260
+ digitalWrite(NODALIS_HEARTBEAT_LED_PIN, LOW);
261
+ }
262
+ localIp = nodalisLoadIpAddress();
263
+ nodalisBeginEthernet(localIp);
264
+ nodalisLogInfo("Ethernet initialized");
228
265
  ${globals.join('\n')}
229
- modbusServer.start();
266
+ if (!modbusServer.start()) {
267
+ nodalisLogError("Modbus server start failed");
268
+ }
230
269
  ${mapCode}
270
+ nodalisLogInfo("Setup complete");
231
271
  }
232
272
 
233
273
  void loop() {
274
+ nodalisPollSerialIpConfig(localIp);
234
275
  modbusServer.poll();
235
276
  superviseIO();
277
+ nodalisHeartbeatTask();
236
278
  ${taskCode}
237
279
  delay(1);
238
280
  PROGRAM_COUNT++;
@@ -254,6 +296,8 @@ void loop() {
254
296
  fs.cpSync(path.join(supportDir, 'modbus.cpp'), path.join(outputPath, 'modbus.cpp'), { force: true });
255
297
  fs.cpSync(path.join(supportDir, 'gpio.h'), path.join(outputPath, 'gpio.h'), { force: true });
256
298
  fs.cpSync(path.join(supportDir, 'gpio.cpp'), path.join(outputPath, 'gpio.cpp'), { force: true });
299
+ fs.cpSync(path.join(supportDir, 'network_config.h'), path.join(outputPath, 'network_config.h'), { force: true });
300
+ fs.cpSync(path.join(supportDir, 'network_config.cpp'), path.join(outputPath, 'network_config.cpp'), { force: true });
257
301
  fs.cpSync(path.join(supportDir, 'json.hpp'), path.join(outputPath, 'json.hpp'), { force: true });
258
302
 
259
303
  if (this.isExecutableOutput()) {
@@ -19,6 +19,7 @@ void NodalisGPIOClient::onMappingAdded(const IOMap &map)
19
19
  uint8_t startPin = 0;
20
20
  if (!parsePin(map.remoteAddress, startPin))
21
21
  {
22
+ logErrorThrottled("Invalid GPIO pin mapping " + describeMapping(map));
22
23
  return;
23
24
  }
24
25
 
@@ -55,7 +55,7 @@ ModbusResponse NodalisModbusServer::handleRequest(const ModbusRequest &request)
55
55
  NodalisModbusClient::NodalisModbusClient(const std::string &ip, uint16_t port, uint8_t unitId)
56
56
  : IOClient("MODBUS-TCP"),
57
57
  ip(ip),
58
- port(port == 0 ? 502 : port),
58
+ port(port),
59
59
  modbusTcpClient(tcpClient),
60
60
  deviceAddress(unitId == 0 ? 1 : unitId),
61
61
  serverResolved(false)
@@ -63,6 +63,10 @@ NodalisModbusClient::NodalisModbusClient(const std::string &ip, uint16_t port, u
63
63
  if (!ip.empty())
64
64
  {
65
65
  serverResolved = parseIp(ip, serverIp);
66
+ if (!serverResolved)
67
+ {
68
+ logErrorThrottled("Invalid remote IP " + ip);
69
+ }
66
70
  }
67
71
  }
68
72
 
@@ -109,7 +113,7 @@ void NodalisModbusClient::onMappingAdded(const IOMap &map)
109
113
  {
110
114
  ip = map.moduleID;
111
115
  }
112
- if (port == 0 && !map.modulePort.empty())
116
+ if (!map.modulePort.empty())
113
117
  {
114
118
  port = static_cast<uint16_t>(std::atoi(map.modulePort.c_str()));
115
119
  }
@@ -126,6 +130,10 @@ void NodalisModbusClient::connect()
126
130
  if (!ip.empty() && !serverResolved)
127
131
  {
128
132
  serverResolved = parseIp(ip, serverIp);
133
+ if (!serverResolved)
134
+ {
135
+ logErrorThrottled("Could not parse target IP " + ip);
136
+ }
129
137
  }
130
138
  connected = ensureConnected();
131
139
  }
@@ -133,7 +141,7 @@ void NodalisModbusClient::connect()
133
141
  bool NodalisModbusClient::connectTCP(const std::string &newIp, uint16_t newPort)
134
142
  {
135
143
  ip = newIp;
136
- port = (newPort == 0) ? 502 : newPort;
144
+ port = newPort;
137
145
  serverResolved = parseIp(ip, serverIp);
138
146
  connected = ensureConnected();
139
147
  return connected;
@@ -143,13 +151,20 @@ bool NodalisModbusClient::ensureConnected()
143
151
  {
144
152
  if (!serverResolved)
145
153
  {
154
+ logErrorThrottled("Server IP is unresolved");
146
155
  return false;
147
156
  }
148
157
  if (modbusTcpClient.connected())
149
158
  {
150
159
  return true;
151
160
  }
152
- return modbusTcpClient.begin(serverIp, port) == 1;
161
+ const uint16_t effectivePort = (port == 0) ? 502 : port;
162
+ const bool started = modbusTcpClient.begin(serverIp, effectivePort) == 1;
163
+ if (!started)
164
+ {
165
+ logErrorThrottled("TCP connect failed to " + ip + ":" + std::to_string(effectivePort));
166
+ }
167
+ return started;
153
168
  }
154
169
 
155
170
  void NodalisModbusClient::disconnect()
@@ -183,15 +198,16 @@ bool NodalisModbusClient::sendRequest(const ModbusRequest &request, ModbusRespon
183
198
  return false;
184
199
  }
185
200
 
186
- uint16_t NodalisModbusClient::parseRemoteAddress(const std::string &remote) const
201
+ bool NodalisModbusClient::parseRemoteAddress(const std::string &remote, uint16_t &address) const
187
202
  {
188
203
  char *endPtr = nullptr;
189
204
  const long parsed = std::strtol(remote.c_str(), &endPtr, 10);
190
- if (endPtr == remote.c_str() || parsed < 0)
205
+ if (endPtr == remote.c_str() || *endPtr != '\0' || parsed < 0 || parsed > 65535)
191
206
  {
192
- return 0;
207
+ return false;
193
208
  }
194
- return static_cast<uint16_t>(parsed & 0xFFFF);
209
+ address = static_cast<uint16_t>(parsed & 0xFFFF);
210
+ return true;
195
211
  }
196
212
 
197
213
  bool NodalisModbusClient::readBit(const std::string &remote, int &result)
@@ -200,10 +216,17 @@ bool NodalisModbusClient::readBit(const std::string &remote, int &result)
200
216
  {
201
217
  return false;
202
218
  }
203
- const uint16_t addr = parseRemoteAddress(remote);
204
- const long val = modbusTcpClient.coilRead(deviceAddress, addr);
219
+ uint16_t addr = 0;
220
+ if (!parseRemoteAddress(remote, addr))
221
+ {
222
+ logErrorThrottled("Invalid remote bit address \"" + remote + "\"");
223
+ return false;
224
+ }
225
+ const long val = modbusTcpClient.discreteInputRead(deviceAddress, addr);
205
226
  if (val < 0)
206
227
  {
228
+ logErrorThrottled("Discrete input read failed remote=\"" + remote + "\" parsed=" + std::to_string(addr) +
229
+ " unit=" + std::to_string(deviceAddress) + ": " + modbusTcpClient.lastError());
207
230
  return false;
208
231
  }
209
232
  result = (val == 0) ? 0 : 1;
@@ -216,8 +239,20 @@ bool NodalisModbusClient::writeBit(const std::string &remote, int value)
216
239
  {
217
240
  return false;
218
241
  }
219
- const uint16_t addr = parseRemoteAddress(remote);
220
- return modbusTcpClient.coilWrite(deviceAddress, addr, value != 0) == 1;
242
+ uint16_t addr = 0;
243
+ if (!parseRemoteAddress(remote, addr))
244
+ {
245
+ logErrorThrottled("Invalid remote bit address \"" + remote + "\"");
246
+ return false;
247
+ }
248
+ const int rc = modbusTcpClient.coilWrite(deviceAddress, addr, value != 0);
249
+ if (rc != 1)
250
+ {
251
+ logErrorThrottled("Coil write failed remote=\"" + remote + "\" parsed=" + std::to_string(addr) +
252
+ " unit=" + std::to_string(deviceAddress) + ": " + modbusTcpClient.lastError());
253
+ return false;
254
+ }
255
+ return true;
221
256
  }
222
257
 
223
258
  bool NodalisModbusClient::readByte(const std::string &remote, uint8_t &result)
@@ -242,10 +277,17 @@ bool NodalisModbusClient::readWord(const std::string &remote, uint16_t &result)
242
277
  {
243
278
  return false;
244
279
  }
245
- const uint16_t addr = parseRemoteAddress(remote);
280
+ uint16_t addr = 0;
281
+ if (!parseRemoteAddress(remote, addr))
282
+ {
283
+ logErrorThrottled("Invalid remote register address \"" + remote + "\"");
284
+ return false;
285
+ }
246
286
  const long val = modbusTcpClient.holdingRegisterRead(deviceAddress, addr);
247
287
  if (val < 0)
248
288
  {
289
+ logErrorThrottled("Holding register read failed remote=\"" + remote + "\" parsed=" + std::to_string(addr) +
290
+ " unit=" + std::to_string(deviceAddress) + ": " + modbusTcpClient.lastError());
249
291
  return false;
250
292
  }
251
293
  result = static_cast<uint16_t>(val & 0xFFFF);
@@ -258,15 +300,32 @@ bool NodalisModbusClient::writeWord(const std::string &remote, uint16_t value)
258
300
  {
259
301
  return false;
260
302
  }
261
- const uint16_t addr = parseRemoteAddress(remote);
262
- return modbusTcpClient.holdingRegisterWrite(deviceAddress, addr, value) == 1;
303
+ uint16_t addr = 0;
304
+ if (!parseRemoteAddress(remote, addr))
305
+ {
306
+ logErrorThrottled("Invalid remote register address \"" + remote + "\"");
307
+ return false;
308
+ }
309
+ const int rc = modbusTcpClient.holdingRegisterWrite(deviceAddress, addr, value);
310
+ if (rc != 1)
311
+ {
312
+ logErrorThrottled("Holding register write failed remote=\"" + remote + "\" parsed=" + std::to_string(addr) +
313
+ " unit=" + std::to_string(deviceAddress) + ": " + modbusTcpClient.lastError());
314
+ return false;
315
+ }
316
+ return true;
263
317
  }
264
318
 
265
319
  bool NodalisModbusClient::readDWord(const std::string &remote, uint32_t &result)
266
320
  {
267
321
  uint16_t hi = 0;
268
322
  uint16_t lo = 0;
269
- const uint16_t addr = parseRemoteAddress(remote);
323
+ uint16_t addr = 0;
324
+ if (!parseRemoteAddress(remote, addr))
325
+ {
326
+ logErrorThrottled("Invalid remote register address \"" + remote + "\"");
327
+ return false;
328
+ }
270
329
 
271
330
  if (!readWord(std::to_string(addr), hi))
272
331
  {
@@ -283,7 +342,12 @@ bool NodalisModbusClient::readDWord(const std::string &remote, uint32_t &result)
283
342
 
284
343
  bool NodalisModbusClient::writeDWord(const std::string &remote, uint32_t value)
285
344
  {
286
- const uint16_t addr = parseRemoteAddress(remote);
345
+ uint16_t addr = 0;
346
+ if (!parseRemoteAddress(remote, addr))
347
+ {
348
+ logErrorThrottled("Invalid remote register address \"" + remote + "\"");
349
+ return false;
350
+ }
287
351
  const uint16_t hi = static_cast<uint16_t>((value >> 16) & 0xFFFF);
288
352
  const uint16_t lo = static_cast<uint16_t>(value & 0xFFFF);
289
353
 
@@ -296,7 +360,12 @@ bool NodalisModbusClient::writeDWord(const std::string &remote, uint32_t value)
296
360
 
297
361
  bool NodalisModbusClient::readLWord(const std::string &remote, uint64_t &result)
298
362
  {
299
- const uint16_t addr = parseRemoteAddress(remote);
363
+ uint16_t addr = 0;
364
+ if (!parseRemoteAddress(remote, addr))
365
+ {
366
+ logErrorThrottled("Invalid remote register address \"" + remote + "\"");
367
+ return false;
368
+ }
300
369
  uint16_t regs[4] = {0, 0, 0, 0};
301
370
 
302
371
  for (uint16_t i = 0; i < 4; ++i)
@@ -316,7 +385,12 @@ bool NodalisModbusClient::readLWord(const std::string &remote, uint64_t &result)
316
385
 
317
386
  bool NodalisModbusClient::writeLWord(const std::string &remote, uint64_t value)
318
387
  {
319
- const uint16_t addr = parseRemoteAddress(remote);
388
+ uint16_t addr = 0;
389
+ if (!parseRemoteAddress(remote, addr))
390
+ {
391
+ logErrorThrottled("Invalid remote register address \"" + remote + "\"");
392
+ return false;
393
+ }
320
394
  const uint16_t regs[4] = {
321
395
  static_cast<uint16_t>((value >> 48) & 0xFFFF),
322
396
  static_cast<uint16_t>((value >> 32) & 0xFFFF),
@@ -404,6 +478,7 @@ bool NodalisModbusTcpServer::start(uint8_t serverId)
404
478
 
405
479
  if (modbusServer.begin(serverId) != 1)
406
480
  {
481
+ nodalisLogError("Failed to start Modbus TCP server");
407
482
  return false;
408
483
  }
409
484
 
@@ -418,6 +493,7 @@ bool NodalisModbusTcpServer::start(uint8_t serverId)
418
493
 
419
494
  tcpServer.begin();
420
495
  started = true;
496
+ nodalisLogInfo("Modbus TCP server started");
421
497
 
422
498
  for (size_t i = 0; i < globals.size(); ++i)
423
499
  {
@@ -441,6 +517,7 @@ void NodalisModbusTcpServer::poll()
441
517
  EthernetClient client = tcpServer.available();
442
518
  if (client)
443
519
  {
520
+ nodalisLogInfo("Accepted Modbus TCP client");
444
521
  modbusServer.accept(client);
445
522
  }
446
523
  modbusServer.poll();
@@ -108,7 +108,7 @@ private:
108
108
  uint8_t deviceAddress;
109
109
  bool serverResolved;
110
110
 
111
- uint16_t parseRemoteAddress(const std::string &remote) const;
111
+ bool parseRemoteAddress(const std::string &remote, uint16_t &address) const;
112
112
  uint8_t parseUnitId(const IOMap &map) const;
113
113
  bool ensureConnected();
114
114
  bool parseIp(const std::string &value, IPAddress &out) const;
@@ -0,0 +1,502 @@
1
+ #include "network_config.h"
2
+ #include "nodalis.h"
3
+
4
+ #include <ctype.h>
5
+ #include <stdio.h>
6
+ #include <string.h>
7
+
8
+ #if defined(ARDUINO_ARCH_MBED) && __has_include(<BlockDevice.h>) && __has_include(<MBRBlockDevice.h>) && __has_include(<LittleFileSystem.h>) && __has_include(<FATFileSystem.h>)
9
+ #include <BlockDevice.h>
10
+ #include <MBRBlockDevice.h>
11
+ #include <LittleFileSystem.h>
12
+ #include <FATFileSystem.h>
13
+ #define NODALIS_HAS_OPTA_FS 1
14
+ #else
15
+ #define NODALIS_HAS_OPTA_FS 0
16
+ #endif
17
+
18
+ #if defined(ARDUINO_ARCH_MBED) && __has_include(<kvstore_global_api.h>)
19
+ #include <kvstore_global_api.h>
20
+ #define NODALIS_HAS_KVSTORE 1
21
+ #else
22
+ #define NODALIS_HAS_KVSTORE 0
23
+ #endif
24
+
25
+ #if !NODALIS_HAS_KVSTORE && __has_include(<EEPROM.h>)
26
+ #include <EEPROM.h>
27
+ #define NODALIS_HAS_EEPROM 1
28
+ #else
29
+ #define NODALIS_HAS_EEPROM 0
30
+ #endif
31
+
32
+ namespace
33
+ {
34
+ const uint8_t DEFAULT_MAC[6] = {0x02, 0x4E, 0x4F, 0x44, 0x41, 0x4C};
35
+ const int EEPROM_BASE = 0;
36
+ const uint8_t EEPROM_MAGIC_0 = 0x4E;
37
+ const uint8_t EEPROM_MAGIC_1 = 0x49;
38
+ const uint8_t EEPROM_VERSION = 0x01;
39
+ const size_t SERIAL_BUFFER_LIMIT = 48;
40
+ const char *KVSTORE_IP_KEY = "/kv/nodalis_ip";
41
+ const char *USERDATA_IP_PATH = "/user/nodalis_ip.bin";
42
+
43
+ String serialBuffer;
44
+
45
+ bool nodalisIsBitAddress(const String &address)
46
+ {
47
+ const std::vector<int> parts = parseAddress(std::string(address.c_str()));
48
+ return parts.size() == 4 && parts[0] >= 0 && parts[2] >= 0 && parts[3] >= 0;
49
+ }
50
+
51
+ void nodalisPrintBitValue(const String &address)
52
+ {
53
+ Serial.print(address);
54
+ Serial.print(" = ");
55
+ Serial.println(readBit(std::string(address.c_str())) ? 1 : 0);
56
+ }
57
+
58
+ bool nodalisHandleBitCommand(const String &command, const String &upper)
59
+ {
60
+ if (upper == "MAPS")
61
+ {
62
+ nodalisDumpMappings();
63
+ return true;
64
+ }
65
+
66
+ if (upper.startsWith("READBIT "))
67
+ {
68
+ String address = command.substring(8);
69
+ address.trim();
70
+ if (!nodalisIsBitAddress(address))
71
+ {
72
+ Serial.println("Invalid bit address.");
73
+ return true;
74
+ }
75
+ nodalisPrintBitValue(address);
76
+ return true;
77
+ }
78
+
79
+ if (upper.startsWith("WRITEBIT "))
80
+ {
81
+ const int splitIndex = command.lastIndexOf(' ');
82
+ if (splitIndex <= 8)
83
+ {
84
+ Serial.println("Usage: WRITEBIT %IX0.0 1");
85
+ return true;
86
+ }
87
+
88
+ String address = command.substring(9, splitIndex);
89
+ String valueText = command.substring(splitIndex + 1);
90
+ address.trim();
91
+ valueText.trim();
92
+
93
+ if (!nodalisIsBitAddress(address) || (valueText != "0" && valueText != "1"))
94
+ {
95
+ Serial.println("Usage: WRITEBIT %IX0.0 1");
96
+ return true;
97
+ }
98
+
99
+ writeBit(std::string(address.c_str()), valueText == "1");
100
+ nodalisPrintBitValue(address);
101
+ return true;
102
+ }
103
+
104
+ return false;
105
+ }
106
+
107
+ bool nodalisParseIpString(const String &input, IPAddress &ip)
108
+ {
109
+ unsigned int octets[4] = {0, 0, 0, 0};
110
+ int octetIndex = 0;
111
+ unsigned int current = 0;
112
+ bool hasDigit = false;
113
+
114
+ for (size_t i = 0; i < input.length(); ++i)
115
+ {
116
+ const char ch = input.charAt(i);
117
+ if (ch >= '0' && ch <= '9')
118
+ {
119
+ hasDigit = true;
120
+ current = (current * 10U) + static_cast<unsigned int>(ch - '0');
121
+ if (current > 255U)
122
+ {
123
+ return false;
124
+ }
125
+ continue;
126
+ }
127
+
128
+ if (ch != '.' || !hasDigit || octetIndex >= 3)
129
+ {
130
+ return false;
131
+ }
132
+
133
+ octets[octetIndex++] = current;
134
+ current = 0;
135
+ hasDigit = false;
136
+ }
137
+
138
+ if (!hasDigit || octetIndex != 3)
139
+ {
140
+ return false;
141
+ }
142
+
143
+ octets[octetIndex] = current;
144
+ ip = IPAddress(octets[0], octets[1], octets[2], octets[3]);
145
+ return true;
146
+ }
147
+
148
+ String nodalisNormalizeCommand(String value)
149
+ {
150
+ value.trim();
151
+ while (value.startsWith(" "))
152
+ {
153
+ value.remove(0, 1);
154
+ }
155
+ return value;
156
+ }
157
+
158
+ uint8_t nodalisChecksum(const IPAddress &ip)
159
+ {
160
+ return static_cast<uint8_t>(ip[0] ^ ip[1] ^ ip[2] ^ ip[3] ^ EEPROM_VERSION);
161
+ }
162
+
163
+ #if NODALIS_HAS_OPTA_FS || NODALIS_HAS_KVSTORE
164
+ struct PersistentIpRecord
165
+ {
166
+ uint8_t magic0;
167
+ uint8_t magic1;
168
+ uint8_t version;
169
+ uint8_t ip[4];
170
+ uint8_t checksum;
171
+ };
172
+ #endif
173
+
174
+ #if NODALIS_HAS_OPTA_FS
175
+ template <typename FileSystemType>
176
+ bool nodalisReadRecordFromFileSystem(mbed::MBRBlockDevice &userData, FileSystemType &fs, PersistentIpRecord &record)
177
+ {
178
+ if (fs.mount(&userData) != 0)
179
+ {
180
+ return false;
181
+ }
182
+
183
+ bool success = false;
184
+ FILE *fp = fopen(USERDATA_IP_PATH, "rb");
185
+ if (fp)
186
+ {
187
+ const size_t readCount = fread(&record, sizeof(record), 1, fp);
188
+ fclose(fp);
189
+ success = (readCount == 1);
190
+ }
191
+ fs.unmount();
192
+ return success;
193
+ }
194
+
195
+ template <typename FileSystemType>
196
+ bool nodalisWriteRecordToFileSystem(mbed::MBRBlockDevice &userData, FileSystemType &fs, const PersistentIpRecord &record)
197
+ {
198
+ if (fs.mount(&userData) != 0)
199
+ {
200
+ return false;
201
+ }
202
+
203
+ bool success = false;
204
+ FILE *fp = fopen(USERDATA_IP_PATH, "wb");
205
+ if (fp)
206
+ {
207
+ const size_t writeCount = fwrite(&record, sizeof(record), 1, fp);
208
+ fflush(fp);
209
+ fclose(fp);
210
+ success = (writeCount == 1);
211
+ }
212
+ fs.unmount();
213
+ return success;
214
+ }
215
+
216
+ bool nodalisReadStoredIpFromUserData(IPAddress &ip)
217
+ {
218
+ mbed::BlockDevice *root = mbed::BlockDevice::get_default_instance();
219
+ if (!root || root->init() != 0)
220
+ {
221
+ return false;
222
+ }
223
+
224
+ mbed::MBRBlockDevice userData(root, 4);
225
+ mbed::LittleFileSystem littleFs("user");
226
+ mbed::FATFileSystem fatFs("user");
227
+ PersistentIpRecord record = {};
228
+
229
+ const bool readOk = nodalisReadRecordFromFileSystem(userData, littleFs, record) ||
230
+ nodalisReadRecordFromFileSystem(userData, fatFs, record);
231
+ root->deinit();
232
+
233
+ if (!readOk)
234
+ {
235
+ return false;
236
+ }
237
+
238
+ if (record.magic0 != EEPROM_MAGIC_0 || record.magic1 != EEPROM_MAGIC_1 || record.version != EEPROM_VERSION)
239
+ {
240
+ return false;
241
+ }
242
+
243
+ IPAddress stored(record.ip[0], record.ip[1], record.ip[2], record.ip[3]);
244
+ if (record.checksum != nodalisChecksum(stored))
245
+ {
246
+ return false;
247
+ }
248
+
249
+ ip = stored;
250
+ return true;
251
+ }
252
+
253
+ bool nodalisWriteStoredIpToUserData(const IPAddress &ip)
254
+ {
255
+ mbed::BlockDevice *root = mbed::BlockDevice::get_default_instance();
256
+ if (!root || root->init() != 0)
257
+ {
258
+ return false;
259
+ }
260
+
261
+ mbed::MBRBlockDevice userData(root, 4);
262
+ mbed::LittleFileSystem littleFs("user");
263
+ mbed::FATFileSystem fatFs("user");
264
+ const PersistentIpRecord record = {
265
+ EEPROM_MAGIC_0,
266
+ EEPROM_MAGIC_1,
267
+ EEPROM_VERSION,
268
+ {ip[0], ip[1], ip[2], ip[3]},
269
+ nodalisChecksum(ip)
270
+ };
271
+
272
+ const bool writeOk = nodalisWriteRecordToFileSystem(userData, littleFs, record) ||
273
+ nodalisWriteRecordToFileSystem(userData, fatFs, record);
274
+ root->deinit();
275
+ return writeOk;
276
+ }
277
+ #endif
278
+
279
+ #if NODALIS_HAS_KVSTORE
280
+
281
+ bool nodalisReadStoredIp(IPAddress &ip)
282
+ {
283
+ #if NODALIS_HAS_OPTA_FS
284
+ if (nodalisReadStoredIpFromUserData(ip))
285
+ {
286
+ return true;
287
+ }
288
+ #endif
289
+ PersistentIpRecord record = {};
290
+ size_t actualSize = 0;
291
+ const int readResult = kv_get(KVSTORE_IP_KEY, &record, sizeof(record), &actualSize);
292
+ if (readResult != 0 || actualSize != sizeof(record))
293
+ {
294
+ return false;
295
+ }
296
+
297
+ if (record.magic0 != EEPROM_MAGIC_0 || record.magic1 != EEPROM_MAGIC_1 || record.version != EEPROM_VERSION)
298
+ {
299
+ return false;
300
+ }
301
+
302
+ IPAddress stored(record.ip[0], record.ip[1], record.ip[2], record.ip[3]);
303
+ if (record.checksum != nodalisChecksum(stored))
304
+ {
305
+ return false;
306
+ }
307
+
308
+ ip = stored;
309
+ return true;
310
+ }
311
+
312
+ bool nodalisWriteStoredIp(const IPAddress &ip)
313
+ {
314
+ #if NODALIS_HAS_OPTA_FS
315
+ if (nodalisWriteStoredIpToUserData(ip))
316
+ {
317
+ return true;
318
+ }
319
+ #endif
320
+ PersistentIpRecord record = {
321
+ EEPROM_MAGIC_0,
322
+ EEPROM_MAGIC_1,
323
+ EEPROM_VERSION,
324
+ {ip[0], ip[1], ip[2], ip[3]},
325
+ nodalisChecksum(ip)
326
+ };
327
+ return kv_set(KVSTORE_IP_KEY, &record, sizeof(record), 0) == 0;
328
+ }
329
+
330
+ #elif NODALIS_HAS_EEPROM
331
+ bool nodalisReadStoredIp(IPAddress &ip)
332
+ {
333
+ if (EEPROM.read(EEPROM_BASE) != EEPROM_MAGIC_0 || EEPROM.read(EEPROM_BASE + 1) != EEPROM_MAGIC_1 || EEPROM.read(EEPROM_BASE + 2) != EEPROM_VERSION)
334
+ {
335
+ return false;
336
+ }
337
+
338
+ IPAddress stored(
339
+ EEPROM.read(EEPROM_BASE + 3),
340
+ EEPROM.read(EEPROM_BASE + 4),
341
+ EEPROM.read(EEPROM_BASE + 5),
342
+ EEPROM.read(EEPROM_BASE + 6));
343
+
344
+ if (EEPROM.read(EEPROM_BASE + 7) != nodalisChecksum(stored))
345
+ {
346
+ return false;
347
+ }
348
+
349
+ ip = stored;
350
+ return true;
351
+ }
352
+
353
+ bool nodalisWriteStoredIp(const IPAddress &ip)
354
+ {
355
+ EEPROM.update(EEPROM_BASE, EEPROM_MAGIC_0);
356
+ EEPROM.update(EEPROM_BASE + 1, EEPROM_MAGIC_1);
357
+ EEPROM.update(EEPROM_BASE + 2, EEPROM_VERSION);
358
+ EEPROM.update(EEPROM_BASE + 3, ip[0]);
359
+ EEPROM.update(EEPROM_BASE + 4, ip[1]);
360
+ EEPROM.update(EEPROM_BASE + 5, ip[2]);
361
+ EEPROM.update(EEPROM_BASE + 6, ip[3]);
362
+ EEPROM.update(EEPROM_BASE + 7, nodalisChecksum(ip));
363
+ return true;
364
+ }
365
+ #endif
366
+
367
+ void nodalisProcessSerialCommand(String command, IPAddress &currentIp)
368
+ {
369
+ command = nodalisNormalizeCommand(command);
370
+ if (command.length() == 0)
371
+ {
372
+ return;
373
+ }
374
+
375
+ String upper = command;
376
+ upper.toUpperCase();
377
+
378
+ if (nodalisHandleBitCommand(command, upper))
379
+ {
380
+ return;
381
+ }
382
+
383
+ if (upper == "HELP" || upper == "IP HELP")
384
+ {
385
+ nodalisPrintNetworkConfigHelp();
386
+ return;
387
+ }
388
+
389
+ if (upper == "IP?" || upper == "SHOW IP")
390
+ {
391
+ nodalisPrintCurrentIp(currentIp);
392
+ return;
393
+ }
394
+
395
+ String ipText = command;
396
+ if (upper.startsWith("SETIP "))
397
+ {
398
+ ipText = command.substring(6);
399
+ ipText.trim();
400
+ }
401
+ else if (upper.startsWith("IP "))
402
+ {
403
+ ipText = command.substring(3);
404
+ ipText.trim();
405
+ }
406
+
407
+ IPAddress newIp;
408
+ if (!nodalisParseIpString(ipText, newIp))
409
+ {
410
+ Serial.println("Invalid IP. Use SETIP a.b.c.d or IP?");
411
+ return;
412
+ }
413
+
414
+ const bool ipChanged = !(newIp == currentIp);
415
+ currentIp = newIp;
416
+ #if NODALIS_HAS_KVSTORE || NODALIS_HAS_EEPROM
417
+ if (nodalisWriteStoredIp(currentIp))
418
+ {
419
+ Serial.println(ipChanged ? "Stored new IP address." : "Stored current IP address.");
420
+ }
421
+ else
422
+ {
423
+ Serial.println("Failed to store IP address.");
424
+ }
425
+ #else
426
+ Serial.println(ipChanged ? "Persistent storage not available. IP will reset on reboot." : "IP address unchanged and persistent storage not available.");
427
+ #endif
428
+ Serial.println(ipChanged ? "Applying IP address..." : "IP address unchanged.");
429
+ nodalisBeginEthernet(currentIp);
430
+ nodalisPrintCurrentIp(currentIp);
431
+ }
432
+ } // namespace
433
+
434
+ IPAddress nodalisDefaultIpAddress()
435
+ {
436
+ return IPAddress(192, 168, 1, 15);
437
+ }
438
+
439
+ IPAddress nodalisLoadIpAddress()
440
+ {
441
+ IPAddress ip = nodalisDefaultIpAddress();
442
+ #if NODALIS_HAS_KVSTORE || NODALIS_HAS_EEPROM
443
+ IPAddress stored;
444
+ if (nodalisReadStoredIp(stored))
445
+ {
446
+ ip = stored;
447
+ }
448
+ #endif
449
+ return ip;
450
+ }
451
+
452
+ void nodalisBeginEthernet(const IPAddress &ip)
453
+ {
454
+ #if defined(ARDUINO_ARCH_MBED)
455
+ Ethernet.begin(ip);
456
+ #else
457
+ Ethernet.begin(const_cast<uint8_t *>(DEFAULT_MAC), ip);
458
+ #endif
459
+ }
460
+
461
+ void nodalisPrintCurrentIp(const IPAddress &ip)
462
+ {
463
+ Serial.print("Ethernet IP: ");
464
+ Serial.println(ip);
465
+ }
466
+
467
+ void nodalisPrintNetworkConfigHelp()
468
+ {
469
+ Serial.println("Serial commands: HELP, IP?, SETIP a.b.c.d, MAPS, READBIT %IX0.0, WRITEBIT %IX0.0 1");
470
+ }
471
+
472
+ void nodalisPollSerialIpConfig(IPAddress &currentIp)
473
+ {
474
+ while (Serial.available() > 0)
475
+ {
476
+ const char ch = static_cast<char>(Serial.read());
477
+ if (ch == '\r')
478
+ {
479
+ continue;
480
+ }
481
+
482
+ if (ch == '\n')
483
+ {
484
+ nodalisProcessSerialCommand(serialBuffer, currentIp);
485
+ serialBuffer = "";
486
+ continue;
487
+ }
488
+
489
+ if (isPrintable(static_cast<unsigned char>(ch)))
490
+ {
491
+ if (serialBuffer.length() < SERIAL_BUFFER_LIMIT)
492
+ {
493
+ serialBuffer += ch;
494
+ }
495
+ else
496
+ {
497
+ serialBuffer = "";
498
+ Serial.println("Command too long.");
499
+ }
500
+ }
501
+ }
502
+ }
@@ -0,0 +1,20 @@
1
+ // Copyright [2025] Nathan Skipper
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+
6
+ #pragma once
7
+ #ifndef NODALIS_NETWORK_CONFIG_H
8
+ #define NODALIS_NETWORK_CONFIG_H
9
+
10
+ #include <Arduino.h>
11
+ #include <Ethernet.h>
12
+
13
+ IPAddress nodalisDefaultIpAddress();
14
+ IPAddress nodalisLoadIpAddress();
15
+ void nodalisBeginEthernet(const IPAddress &ip);
16
+ void nodalisPrintCurrentIp(const IPAddress &ip);
17
+ void nodalisPrintNetworkConfigHelp();
18
+ void nodalisPollSerialIpConfig(IPAddress &currentIp);
19
+
20
+ #endif
@@ -11,6 +11,31 @@ uint64_t elapsed()
11
11
  return static_cast<uint64_t>(millis());
12
12
  }
13
13
 
14
+ bool nodalisSerialReady()
15
+ {
16
+ return static_cast<bool>(Serial);
17
+ }
18
+
19
+ static void nodalisLog(const char *level, const String &message)
20
+ {
21
+ if (!nodalisSerialReady())
22
+ return;
23
+ Serial.print("[Nodalis][");
24
+ Serial.print(level);
25
+ Serial.print("] ");
26
+ Serial.println(message);
27
+ }
28
+
29
+ void nodalisLogInfo(const String &message)
30
+ {
31
+ nodalisLog("INFO", message);
32
+ }
33
+
34
+ void nodalisLogError(const String &message)
35
+ {
36
+ nodalisLog("ERROR", message);
37
+ }
38
+
14
39
  static bool validAddressParts(const std::vector<int> &parts, int expectedWidth, bool allowBit)
15
40
  {
16
41
  if (parts.size() != 4)
@@ -196,6 +221,11 @@ void IOClient::addMapping(const IOMap &map)
196
221
  moduleID = map.moduleID;
197
222
  mappings.push_back(map);
198
223
  onMappingAdded(mappings.back());
224
+ logInfo("Added map " + describeMapping(mappings.back()));
225
+ }
226
+ else
227
+ {
228
+ logInfo("Skipping duplicate map " + describeMapping(map));
199
229
  }
200
230
  }
201
231
 
@@ -209,9 +239,43 @@ bool IOClient::hasMapping(std::string localAddress)
209
239
  return false;
210
240
  }
211
241
 
242
+ void IOClient::dumpMappings() const
243
+ {
244
+ nodalisLogInfo(String(protocol.c_str()) + ": mapping dump begin");
245
+ for (size_t i = 0; i < mappings.size(); ++i)
246
+ {
247
+ nodalisLogInfo(String(protocol.c_str()) + ": " + describeMapping(mappings[i]).c_str());
248
+ }
249
+ nodalisLogInfo(String(protocol.c_str()) + ": mapping dump end");
250
+ }
251
+
212
252
  const std::string &IOClient::getProtocol() const { return protocol; }
213
253
  const std::string &IOClient::getModuleID() const { return moduleID; }
214
254
 
255
+ void IOClient::logInfo(const std::string &message) const
256
+ {
257
+ nodalisLogInfo(String(protocol.c_str()) + ": " + message.c_str());
258
+ }
259
+
260
+ void IOClient::logErrorThrottled(const std::string &message)
261
+ {
262
+ const uint64_t now = elapsed();
263
+ if (now - lastErrorReport < 2000)
264
+ return;
265
+ lastErrorReport = now;
266
+ nodalisLogError(String(protocol.c_str()) + ": " + message.c_str());
267
+ }
268
+
269
+ std::string IOClient::describeMapping(const IOMap &map) const
270
+ {
271
+ std::string text = map.localAddress + " <= " + map.remoteAddress + " [" + map.protocol + "]";
272
+ if (!map.moduleID.empty())
273
+ text += " module=" + map.moduleID;
274
+ if (!map.modulePort.empty())
275
+ text += ":" + map.modulePort;
276
+ return text;
277
+ }
278
+
215
279
  void IOClient::poll()
216
280
  {
217
281
  if (!connected)
@@ -219,7 +283,12 @@ void IOClient::poll()
219
283
  if (elapsed() - lastAttempt >= 15000)
220
284
  {
221
285
  lastAttempt = elapsed();
286
+ logInfo("Attempting connection");
222
287
  connect();
288
+ if (connected)
289
+ logInfo("Connection ready");
290
+ else
291
+ logErrorThrottled("Connection attempt failed");
223
292
  }
224
293
  return;
225
294
  }
@@ -232,71 +301,84 @@ void IOClient::poll()
232
301
 
233
302
  if (map.direction == IOType::Output)
234
303
  {
304
+ bool success = true;
235
305
  switch (map.width)
236
306
  {
237
307
  case 1:
238
- writeBit(map.remoteAddress, ::readBit(map.localAddress) ? 1 : 0);
308
+ success = writeBit(map.remoteAddress, ::readBit(map.localAddress) ? 1 : 0);
239
309
  break;
240
310
  case 8:
241
- writeByte(map.remoteAddress, ::readByte(map.localAddress));
311
+ success = writeByte(map.remoteAddress, ::readByte(map.localAddress));
242
312
  break;
243
313
  case 16:
244
- writeWord(map.remoteAddress, ::readWord(map.localAddress));
314
+ success = writeWord(map.remoteAddress, ::readWord(map.localAddress));
245
315
  break;
246
316
  case 32:
247
- writeDWord(map.remoteAddress, ::readDWord(map.localAddress));
317
+ success = writeDWord(map.remoteAddress, ::readDWord(map.localAddress));
248
318
  break;
249
319
  case 64:
250
- writeLWord(map.remoteAddress, ::readLWord(map.localAddress));
320
+ success = writeLWord(map.remoteAddress, ::readLWord(map.localAddress));
251
321
  break;
252
322
  default:
323
+ success = false;
253
324
  break;
254
325
  }
326
+ if (!success)
327
+ logErrorThrottled("Write failed for " + describeMapping(map));
255
328
  continue;
256
329
  }
257
330
 
258
331
  if (map.direction == IOType::Input)
259
332
  {
333
+ bool success = true;
260
334
  switch (map.width)
261
335
  {
262
336
  case 1:
263
337
  {
264
338
  int val = 0;
265
- if (readBit(map.remoteAddress, val))
266
- writeBit(map.localAddress, val != 0);
339
+ success = readBit(map.remoteAddress, val);
340
+ if (success)
341
+ ::writeBit(map.localAddress, val != 0);
267
342
  break;
268
343
  }
269
344
  case 8:
270
345
  {
271
346
  uint8_t val = 0;
272
- if (readByte(map.remoteAddress, val))
273
- writeByte(map.localAddress, val);
347
+ success = readByte(map.remoteAddress, val);
348
+ if (success)
349
+ ::writeByte(map.localAddress, val);
274
350
  break;
275
351
  }
276
352
  case 16:
277
353
  {
278
354
  uint16_t val = 0;
279
- if (readWord(map.remoteAddress, val))
280
- writeWord(map.localAddress, val);
355
+ success = readWord(map.remoteAddress, val);
356
+ if (success)
357
+ ::writeWord(map.localAddress, val);
281
358
  break;
282
359
  }
283
360
  case 32:
284
361
  {
285
362
  uint32_t val = 0;
286
- if (readDWord(map.remoteAddress, val))
287
- writeDWord(map.localAddress, val);
363
+ success = readDWord(map.remoteAddress, val);
364
+ if (success)
365
+ ::writeDWord(map.localAddress, val);
288
366
  break;
289
367
  }
290
368
  case 64:
291
369
  {
292
370
  uint64_t val = 0;
293
- if (readLWord(map.remoteAddress, val))
294
- writeLWord(map.localAddress, val);
371
+ success = readLWord(map.remoteAddress, val);
372
+ if (success)
373
+ ::writeLWord(map.localAddress, val);
295
374
  break;
296
375
  }
297
376
  default:
377
+ success = false;
298
378
  break;
299
379
  }
380
+ if (!success)
381
+ logErrorThrottled("Read failed for " + describeMapping(map));
300
382
  }
301
383
  }
302
384
  }
@@ -337,12 +419,20 @@ std::unique_ptr<IOClient> createClient(IOMap &map)
337
419
  void mapIO(std::string map)
338
420
  {
339
421
  IOMap newMap(map);
422
+ nodalisLogInfo(String("Loading map: ") + newMap.localAddress.c_str() + " <= " + newMap.remoteAddress.c_str() + " [" + newMap.protocol.c_str() + "]");
340
423
  IOClient *existing = findClient(newMap);
341
424
  if (!existing)
342
425
  {
343
426
  std::unique_ptr<IOClient> client = createClient(newMap);
344
427
  if (client)
428
+ {
429
+ nodalisLogInfo(String("Created IO client for protocol ") + newMap.protocol.c_str());
345
430
  Clients.push_back(std::move(client));
431
+ }
432
+ else
433
+ {
434
+ nodalisLogError(String("Unsupported IO protocol: ") + newMap.protocol.c_str());
435
+ }
346
436
  }
347
437
  }
348
438
 
@@ -353,3 +443,17 @@ void superviseIO()
353
443
  Clients[i]->poll();
354
444
  }
355
445
  }
446
+
447
+ void nodalisDumpMappings()
448
+ {
449
+ if (Clients.empty())
450
+ {
451
+ nodalisLogInfo("No IO clients loaded");
452
+ return;
453
+ }
454
+
455
+ for (size_t i = 0; i < Clients.size(); ++i)
456
+ {
457
+ Clients[i]->dumpMappings();
458
+ }
459
+ }
@@ -46,6 +46,9 @@ extern uint64_t PROGRAM_COUNT;
46
46
  extern uint64_t MEMORY[64][16];
47
47
 
48
48
  uint64_t elapsed();
49
+ bool nodalisSerialReady();
50
+ void nodalisLogInfo(const String &message);
51
+ void nodalisLogError(const String &message);
49
52
 
50
53
  inline std::string toLowerCase(const std::string &input)
51
54
  {
@@ -264,6 +267,7 @@ void setBit(RefVar<T> &var, int bit, bool value)
264
267
  }
265
268
 
266
269
  void superviseIO();
270
+ void nodalisDumpMappings();
267
271
 
268
272
  enum class IOType
269
273
  {
@@ -300,6 +304,7 @@ public:
300
304
  void addMapping(const IOMap &map);
301
305
  bool hasMapping(std::string localAddress);
302
306
  void poll();
307
+ void dumpMappings() const;
303
308
 
304
309
  const std::string &getProtocol() const;
305
310
  const std::string &getModuleID() const;
@@ -309,6 +314,7 @@ protected:
309
314
  std::string moduleID;
310
315
  std::vector<IOMap> mappings;
311
316
  uint64_t lastAttempt = 0;
317
+ uint64_t lastErrorReport = 0;
312
318
 
313
319
  virtual bool readBit(const std::string &remote, int &result) = 0;
314
320
  virtual bool writeBit(const std::string &remote, int value) = 0;
@@ -325,6 +331,10 @@ protected:
325
331
  {
326
332
  (void)map;
327
333
  }
334
+
335
+ void logInfo(const std::string &message) const;
336
+ void logErrorThrottled(const std::string &message);
337
+ std::string describeMapping(const IOMap &map) const;
328
338
  };
329
339
 
330
340
  extern std::vector<std::unique_ptr<IOClient>> Clients;
@@ -40,6 +40,10 @@ void GPIOClient::onMappingAdded(const IOMap &map)
40
40
  {
41
41
  return;
42
42
  }
43
+ if (!ensureActiveLow(pin))
44
+ {
45
+ return;
46
+ }
43
47
  if (!ensureDirection(pin, direction))
44
48
  {
45
49
  return;
@@ -364,6 +368,11 @@ bool GPIOClient::ensureDirection(int globalPin, const std::string &direction) co
364
368
  return writeTextFile("/sys/class/gpio/gpio" + std::to_string(globalPin) + "/direction", direction);
365
369
  }
366
370
 
371
+ bool GPIOClient::ensureActiveLow(int globalPin) const
372
+ {
373
+ return writeTextFile("/sys/class/gpio/gpio" + std::to_string(globalPin) + "/active_low", "1");
374
+ }
375
+
367
376
  bool GPIOClient::resolveGlobalPin(const IOMap &map, int &globalPin) const
368
377
  {
369
378
  const std::string chipName = normalizeChipName(map.moduleID);
@@ -40,6 +40,7 @@ private:
40
40
  bool readIntFile(const std::string &path, int &value) const;
41
41
  bool writeTextFile(const std::string &path, const std::string &value) const;
42
42
  bool ensureExported(int globalPin) const;
43
+ bool ensureActiveLow(int globalPin) const;
43
44
  bool ensureDirection(int globalPin, const std::string &direction) const;
44
45
  bool resolveGlobalPin(const IOMap &map, int &globalPin) const;
45
46
  bool resolveRemotePin(const std::string &remote, int &globalPin) const;
@@ -74,6 +74,9 @@ export class GPIOClient extends IOClient {
74
74
  if (!this.ensureExported(pin)) {
75
75
  return;
76
76
  }
77
+ if (!writeTextFile(`/sys/class/gpio/gpio${pin}/active_low`, "1")) {
78
+ return;
79
+ }
77
80
  if (!writeTextFile(`/sys/class/gpio/gpio${pin}/direction`, direction)) {
78
81
  return;
79
82
  }
@@ -18,6 +18,36 @@ import { Programmer } from './Programmer.js';
18
18
  import { runCommand } from './utils.js';
19
19
  import { getManagedArduinoCliExecOptions, getManagedArduinoCliPath } from '../toolchains.js';
20
20
 
21
+ const ARDUINO_UPLOAD_NON_FATAL_STDERR_PATTERNS = [
22
+ /dfu-util:\s*Warning:\s*Invalid DFU suffix signature/i,
23
+ /dfu-util:\s*A valid DFU suffix will be required in a future dfu-util release/i
24
+ ];
25
+
26
+ function classifyUploadStderr(stderrText) {
27
+ const lines = String(stderrText || '')
28
+ .split(/\r?\n/)
29
+ .map(line => line.trim())
30
+ .filter(Boolean);
31
+
32
+ if (lines.length === 0) {
33
+ return { fatal: [], nonFatal: [] };
34
+ }
35
+
36
+ const fatal = [];
37
+ const nonFatal = [];
38
+
39
+ for (const line of lines) {
40
+ const isKnownNonFatal = ARDUINO_UPLOAD_NON_FATAL_STDERR_PATTERNS.some(pattern => pattern.test(line));
41
+ if (isKnownNonFatal) {
42
+ nonFatal.push(line);
43
+ continue;
44
+ }
45
+ fatal.push(line);
46
+ }
47
+
48
+ return { fatal, nonFatal };
49
+ }
50
+
21
51
  export class ArduinoProgrammer extends Programmer {
22
52
  constructor(options) {
23
53
  super(options);
@@ -69,7 +99,13 @@ export class ArduinoProgrammer extends Programmer {
69
99
  console.log(stdout.trim());
70
100
  }
71
101
  if (stderr) {
72
- console.error(stderr.trim());
102
+ const { fatal, nonFatal } = classifyUploadStderr(stderr);
103
+ if (nonFatal.length > 0) {
104
+ console.log(nonFatal.join('\n'));
105
+ }
106
+ if (fatal.length > 0) {
107
+ console.error(fatal.join('\n'));
108
+ }
73
109
  }
74
110
 
75
111
  return true;
@@ -130,6 +130,13 @@ export class SSHProgrammer extends Programmer {
130
130
  ? `cp -a ${quoteForPosixSingle(`${remoteStagingPath}/.`)} ${quoteForPosixSingle(`${remotePath}/`)}`
131
131
  : `cp -a ${quoteForPosixSingle(path.posix.join(remoteStagingPath, path.basename(sourcePath)))} ${quoteForPosixSingle(`${remotePath}/`)}`;
132
132
 
133
+ await this.#runSsh(credentials.password, [
134
+ '-p',
135
+ sshPort,
136
+ `${credentials.username}@${host}`,
137
+ this.#buildSudoCommand(credentials.password, this.#buildStopProgramCommand(remoteProgramPath, runtime))
138
+ ]);
139
+
133
140
  await this.#runSsh(credentials.password, [
134
141
  '-p',
135
142
  sshPort,
@@ -256,6 +263,48 @@ export class SSHProgrammer extends Programmer {
256
263
  return `sudo sh -lc ${quoteForPosixSingle(command)}`;
257
264
  }
258
265
 
266
+ #buildStopProgramCommand(remoteProgramPath, runtime) {
267
+ const quotedPath = quoteForPosixSingle(remoteProgramPath);
268
+ const quotedProgramName = quoteForPosixSingle(path.posix.basename(remoteProgramPath));
269
+ return `
270
+ if command -v pgrep >/dev/null 2>&1; then
271
+ if [ ${quoteForPosixSingle(runtime)} = 'node' ]; then
272
+ pids=$(ps -eo pid=,args= | awk '
273
+ index($0, ${quotedPath}) > 0 && $1 != "'$$'" && $1 != "'"$PPID"'" { print $1 }
274
+ ' 2>/dev/null || true)
275
+ else
276
+ pids=$(pgrep -x -- ${quotedProgramName} 2>/dev/null || true)
277
+ fi
278
+
279
+ target_pids=""
280
+ for pid in $pids; do
281
+ if [ "$pid" = "$$" ] || [ "$pid" = "$PPID" ]; then
282
+ continue
283
+ fi
284
+ target_pids="$target_pids $pid"
285
+ done
286
+
287
+ if [ -n "$target_pids" ]; then
288
+ kill $target_pids >/dev/null 2>&1 || true
289
+ for _ in 1 2 3 4 5; do
290
+ sleep 1
291
+ remaining=""
292
+ for pid in $target_pids; do
293
+ if kill -0 "$pid" >/dev/null 2>&1; then
294
+ remaining="$remaining $pid"
295
+ fi
296
+ done
297
+ if [ -z "$remaining" ]; then
298
+ exit 0
299
+ fi
300
+ target_pids="$remaining"
301
+ done
302
+ kill -9 $target_pids >/dev/null 2>&1 || true
303
+ fi
304
+ fi
305
+ `.trim();
306
+ }
307
+
259
308
  async #cleanupRemoteStaging(credentials, host, sshPort, remoteStagingPath) {
260
309
  if (!remoteStagingPath) {
261
310
  return;
@@ -286,8 +335,7 @@ export class SSHProgrammer extends Programmer {
286
335
  SSH_ASKPASS: askPassPath,
287
336
  SSH_ASKPASS_REQUIRE: 'force',
288
337
  DISPLAY: process.env.DISPLAY || 'nodalis:0'
289
- },
290
- stdio: ['ignore', 'inherit', 'inherit']
338
+ }
291
339
  });
292
340
  }
293
341
  finally {
@@ -65,18 +65,43 @@ export async function runCommand(command, args = [], options = {}) {
65
65
 
66
66
  export async function runCommandInteractive(command, args = [], options = {}) {
67
67
  return new Promise((resolve, reject) => {
68
+ const childStdio = options.stdio ?? ['inherit', 'pipe', 'pipe'];
68
69
  const child = spawn(command, args, {
69
- stdio: 'inherit',
70
+ stdio: childStdio,
70
71
  ...options
71
72
  });
72
73
 
74
+ let stdout = '';
75
+ let stderr = '';
76
+
77
+ if (child.stdout) {
78
+ child.stdout.on('data', data => {
79
+ const text = data.toString();
80
+ stdout += text;
81
+ process.stdout.write(text);
82
+ });
83
+ }
84
+
85
+ if (child.stderr) {
86
+ child.stderr.on('data', data => {
87
+ const text = data.toString();
88
+ stderr += text;
89
+ process.stderr.write(text);
90
+ });
91
+ }
92
+
73
93
  child.on('error', reject);
74
94
  child.on('close', code => {
75
95
  if (code === 0) {
76
96
  resolve({ code });
77
97
  return;
78
98
  }
79
- reject(new Error(`Command failed (${command} ${args.join(' ')}) with exit code ${code}`));
99
+ const detail = (stderr || stdout).trim();
100
+ reject(new Error(
101
+ detail
102
+ ? `Command failed (${command} ${args.join(' ')}) with exit code ${code}: ${detail}`
103
+ : `Command failed (${command} ${args.join(' ')}) with exit code ${code}`
104
+ ));
80
105
  });
81
106
  });
82
107
  }