Skip to content

Add ESP-NOW pendant/peripheral support with pairing mode#1744

Open
figamore wants to merge 15 commits into
bdring:mainfrom
figamore:feature/esp-now
Open

Add ESP-NOW pendant/peripheral support with pairing mode#1744
figamore wants to merge 15 commits into
bdring:mainfrom
figamore:feature/esp-now

Conversation

@figamore

@figamore figamore commented Jun 8, 2026

Copy link
Copy Markdown

This PR adds ESP-NOW as a native FluidNC channel for direct communication with FluidDial pendants and peripherals.

Features

  • Adds an ESP-NOW channel for FluidDial communication.
  • Supports operation in both FluidNC STA and SoftAP modes.
  • Supports up to 8 saved pendant pairings.
  • Persists pairings securely in NVS.
  • Restores saved peers automatically after restart.
  • Supports multiple connected pendants with active-control arbitration.
  • Streams normal FluidNC channel data and realtime commands.
  • Fragments and reassembles messages exceeding one ESP-NOW packet.
  • Uses keepalives and session synchronization for connection recovery.
  • Keeps ESP-NOW callback work bounded and processes packets from FluidNC's normal polling context.

Pairing and Security

Pairing is allowed only during a user-initiated 60-second pairing window.

The protocol uses:

  • Fresh P-256 ECDH keypairs during pairing.
  • A unique Local Master Key derived for each pendant/controller pair.
  • HMAC-SHA256 authentication tags for the pairing handshake.
  • ESP-NOW encrypted-peer mode for normal traffic.
  • Per-session nonce and counter validation to reject replayed packets.
  • A persistent FluidNC whitelist of paired pendant MAC addresses.

Commands

  • $espnow/pair opens the pairing window.
  • $espnow/cancel closes the pairing window.
  • $espnow/list lists paired pendants.
  • $espnow/unpair=<index> removes one pendant.
  • $espnow/unpair=0 removes all paired pendants.

Additional Stability Work

This branch also makes Telnet output non-blocking and cleans up stalled clients to reduce the risk of task-watchdog resets.

Note

OLED support has been sacrificed from the ESP32 WiFi build to make room for the ESP-NOW channel, pairing, crypto, and peer-management code within the existing flash partition constraints. The OLED code is not deleted from the tree; it is excluded from this build variant and can be re-enabled later if the partition layout or feature budget changes.

Comment thread platformio.ini Outdated
TMCStepper@>=0.7.0,<1.0.0
thingpulse/ESP8266 and ESP32 OLED driver for SSD1306 displays@4.4.1
; Enable this with OLED*.cpp/SSD1306_I2C.cpp below when building OLED display support - disabled to save ~2% flash capcity
; thingpulse/ESP8266 and ESP32 OLED driver for SSD1306 displays@4.4.1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like something that should be discussed instead of just breaking existing functionality.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ESP-NOW implementation plus existing OLED support pushes the standard wifi build past the 4 MB ESP32 upload limit. I tried to make both fit together, but it does not seem realistically feasible with the current partition size. The original non-ESP-NOW build with OLED is already around 98.3% flash usage. ESP-NOW without OLED is around 98.0%, but enabling both exceeds the available flash space.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chose OLED as the thing to strip because the FluidNC wiki states that it would likely be removed at some point. That made it feel like the least bad tradeoff. If other ideas come to mind to shave off some flash space, please let me know.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the sort of global-impact thing that should be discussed in the developers-forum channel in Discord.

Comment thread FluidNC/src/RealtimeCmd.cpp Outdated
// A realtime jog cancel is a stream barrier. Discard any
// partially assembled or already-buffered commands from the
// issuing channel so stale jog input cannot execute afterward.
channel.flushRx();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What can this break?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this so the global realtime jog cancel path no longer flushes every channel. Instead, ESP-NOW overrides flushRx() and only clears its own receive/reassembly buffers when an ESP-NOW jog cancel is received. That avoids affecting Telnet/WebSocket/serial behavior while still preventing stale fragmented ESP-NOW jog data from executing after cancel.

Please see 4bde9f1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this is a discussion topic. It might be the case that flushing is appropriate on all channels.

size_t queueFree() const;
bool queueLine(const uint8_t* data, size_t len, size_t reserve);
void flushQueue(int sockfd);
void queueCompletedLine();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test case?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Telnet task watchdog issue occurs when a Telnet client with an auto-report interval disconnects abruptly DURING a job that is running. For example, it happens if I use a Telnet g-code sender and suddenly disconnect from WiFi or otherwise ungracefully close the connection. I put together this simple python script to test this out.

The Telnet task watchdog issue occurs when a Telnet connection I added a simple Python test that subscribes to an auto-report interval via Telnet.

Steps to induce machine stall + task watchdog trigger:

  1. Ensure current version is FluidNC v4.0.3
  2. Run the following Python script BEFORE running a job.
  3. Start a job or long-homing run.
  4. Disconnect the WiFi entirely while the job is in progress.
  5. Observe a stall after 15-30 seconds, with eventual task watchdog trigger about a minute or more later
  6. The same procedure does not induce a failure after the proposed fix.

Here is the backtrace:

E (109355) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (109355) task_wdt:  - async_tcp (CPU 0)
E (109355) task_wdt: Tasks currently running:
E (109355) task_wdt: CPU 0: IDLE0
E (109355) task_wdt: CPU 1: IDLE1
E (109355) task_wdt: Aborting.

abort() was called at PC 0x4014921c on core 0
  #0  0x4014921c in task_wdt_isr at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_system/task_wdt.c:176 (discriminator 3)



Backtrace: 0x400853ad:0x3ffbf0bc |<-CORRUPTED
  #0  0x400853ad in panic_abort at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_system/panic.c:408
  #1  0x3ffbf0bc in port_IntStack at ??:?





ELF file SHA256: fd8828ae6dd09da9

Rebooting...

Test:

#!/usr/bin/env python3
"""
Reproduce a FluidNC Telnet auto-report WiFi-disconnect crash on FluidNC v4.0.3

Instructions: 
    1. Ensure current version is FluidNC v4.0.3
    2. Run this script before running a job.
    3. Start a job or long-homing run.
    4. Disconnect the WiFi entirely while the job is in progress.
    5. Observe a stall after 15-30 seconds, with eventual task watchdog trigger about a minute or more later.


Usage:
    python3 test.py 192.168.1.201
"""

import argparse
import socket
import sys
import time


def connect(host, port):
    s = socket.socket()
    s.connect((host, port))
    s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)  # like FluidDial
    s.settimeout(0.2)
    return s


def drain(s, seconds=0.5):
    """Read whatever arrives for up to `seconds`; return it as one line."""
    s.settimeout(seconds)
    out = b""
    try:
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            out += chunk
    except (socket.timeout, OSError):
        pass
    return " ".join(out.decode(errors="replace").split())


def send_line(s, line):
    s.sendall(line.encode("ascii") + b"\n")


def main():
    ap = argparse.ArgumentParser(description="FluidNC Telnet auto-report WiFi-disconnect repro")
    ap.add_argument("host", help="FluidNC IP or hostname")
    ap.add_argument("--port", type=int, default=23)
    ap.add_argument("--interval", type=int, default=200, help="auto-report interval in ms")
    args = ap.parse_args()

    sock = connect(args.host, args.port)
    greeting = drain(sock, 1.0)
    if greeting:
        print(greeting[:160])

    send_line(sock, "$I")
    print(drain(sock, 1.0)[:160])

    send_line(sock, "$G")
    print(drain(sock, 1.0)[:160])

    send_line(sock, f"$RI={args.interval}")
    response = drain(sock, 1.0)
    print(response[:160])
    if "auto report interval set" not in response:
        print("WARNING: did not see auto-report confirmation")

    print()
    print("1. Start a new job AFTER this script is already running")
    print("2. Disconnect this computer from WiFi.")
    print("Leave this script running until the network disconnects. A stall should occur within 16-30 seconds, followed by a task watchdog trigger and reboot")

    try:
        while True:
            data = drain(sock, 1.0)
            if data:
                print(data[:160])
            time.sleep(0.1)
    except OSError as exc:
        print(f"Socket ended after WiFi/network disconnect: {exc}")
        return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        sys.exit(130)

figamore added 2 commits June 24, 2026 12:17
…og cancel path no longer flushes every channel
…ormant extender/listener features + re-enable OLED support
Comment thread platformio.ini Outdated
-<Extenders>
-<Pins/ExtPinDetail.cpp>
-<Listeners>
-<Status_outputs.cpp>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omitting Status_outputs is probably not a good idea because it is pretty common for people to use it with Rodent, and in fact the config snippet on the Rodent wiki page includes status_outputs: .

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status_outputs is back in the build. I hadn't realized the Rodent's dependence on it.

Comment thread FluidNC/src/Machine/MachineConfig.h Outdated
#include "Configuration/Configurable.h"
#include "CoolantControl.h"
#include "Kinematics/Kinematics.h"
#ifndef DISABLE_PIN_EXTENDERS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to merge this with the Portability branch for testing but it conflicts with the defines I used. I have

#if SUPPORT_LISTENERS

and

#if SUPPORT_PIN_EXTENDERS

with this in platformio.ini

    ; Reinstate the following to support pin extenders and listeners
    ; and define SUPPORT_PIN_EXTENDERS=1 and SUPPORT_LISTENERS=1
    -<Extenders/*>
    -<Pins/ExtPinDetail.cpp>
    -<Listeners/*>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched the extender/listener gating from my DISABLE_* defines over to your SUPPORT_PIN_EXTENDERS / SUPPORT_LISTENERS so it matches the Portability branch. I also ran a test merge against Portability to be sure - MachineConfig, Main, and Protocol merge cleanly now, so the define clash should be gone. There's still some overlap in platformio.ini from the build system changes on the Portability branch (plus a one-line include in Pin.cpp). Let me know if you'd like me to address those, or if you'll be resolving the conflicts during the merge.

@MitchBradley

Copy link
Copy Markdown
Collaborator

I have a clean merge with my local copy of Portability and wifi builds cleanly. The macos build fails due to it trying to compile ESPNow/ . I can fix that with an exclusion rule but it brings up the fact that a core axiom of the Portability work was to remove all direct dependencies on ESP from src. To that end, I propose to move ESPNow underneath esp32/

@MitchBradley

Copy link
Copy Markdown
Collaborator

The move underneath esp32/ succeeded. Now I have to deal with a problem whereby the include of lwip/sockets.h in Telnet breaks the posix build.

@MitchBradley

Copy link
Copy Markdown
Collaborator

I am having trouble making TelnetClient work in the rp2040 build. I am questioning the wisdom of mixing Arduino API and direct socket calls. I am also questioning whether the Telnet changes are within the scope of the ESPNow work.

@MitchBradley

Copy link
Copy Markdown
Collaborator

I managed to do the non-blocking TelnetClient using Arduino functions, more or less. I pushed the merged version of Portability upstream. I compile-tested all of the ESP and RP targets, plus the macos target, but I haven't tested anything yet.

@figamore

Copy link
Copy Markdown
Author

Great to hear that! 👏 Please let me know if there's anything you'd like me to revise.

@MitchBradley

Copy link
Copy Markdown
Collaborator

Testing would be appreciated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants