An ESP32-S3-based controller for a swim machine, featuring a web-based user interface, programmable workouts, and WiFi/Ethernet connectivity. This project allows you to manage, run, and monitor swim workouts from your phone or computer.
Note: I am not affiliated with Endless Pools.
Tested hardware: Waveshare ESP32-S3-ETH (W5500 over SPI). See board details and pinouts on the Waveshare wiki:
- Introduction
- Swim Machine Protocol
- Deploying to ESP32-S3 (Arduino IDE/CLI)
- Uploading Data Files (Web UI, Workouts, etc.)
- Over-the-Air (OTA) Updates
- User Manual
- Tips: Preventing Phone Screen Lock (Guided Access)
- Viewer and UDP Monitor
- UDP Message Formats
- License
This project is a smart controller for a swim machine, built on the ESP32-S3 microcontroller. It provides:
- A web-based interface for configuring and running workouts.
- WiFi and Ethernet connectivity for easy access from any device.
- Persistent storage of workouts and preferences.
- Real-time monitoring and control of the swim machine.
The swim machine is controlled via a state machine and communicates over UDP. The protocol was reverse engineered and rewritten.
- Segment: Each workout consists of one or more segments.
pace100s: Pace in seconds per 100 meters (0 = rest).durSec: Duration of the segment in seconds.
- Initialization:
SwimMachine::begin()sets up the protocol and network event handling. - Workout Control:
loadWorkout(segments): Load a new workout (list of segments).start(): Begin the workout.pause(): Pause or resume the workout.stop(): Abort the workout.tick(): Call regularly in the main loop to advance the state machine.
- Status:
getStatus()returns the current state (active, paused, current segment, elapsed time). - Networking:
setPeerIP(ip)sets the peer for UDP communication.
Hardware (tested)
- Waveshare ESP32-S3-ETH (ESP32-S3R8 with 16MB flash, 8MB PSRAM, W5500 SPI Ethernet)
- SPI Ethernet (W5500) pins (per Waveshare docs):
- CS=GPIO14, RST=GPIO9, INT=GPIO10, SCK=GPIO13, MISO=GPIO12, MOSI=GPIO11
- Camera and other interfaces are available but not required for this project
- SPI Ethernet (W5500) pins (per Waveshare docs):
- Swim machine hardware (relay/motor control, sensors as needed).
- USB Type‑C cable.
Software (common)
- Filesystem: LittleFS for web UI and workout data storage.
- Web server: ESPAsyncWebServer + AsyncTCP (ESP32).
- JSON: ArduinoJson.
- mDNS: ESPmDNS (included with ESP32 Arduino core).
Note on Ethernet: This firmware uses the Arduino Ethernet support for SPI W5500 (ETH.h). No RMII/PHY configuration is needed for ESP32-S3-ETH since Ethernet is via W5500 on SPI. The default W5500 pin definitions in ConnectionManager.h/NetworkSetup.h match the Waveshare board:
- ETH_TYPE=ETH_PHY_W5500
- ETH_CS=14, ETH_IRQ=10, ETH_RST=9, ETH_SPI_SCK=13, ETH_SPI_MISO=12, ETH_SPI_MOSI=11
Reference: https://www.waveshare.com/wiki/ESP32-S3-ETH
Requirements
- Arduino IDE 2.x
- ESP32 boards support by Espressif Systems (Boards Manager). Install “ESP32 by Espressif Systems” (2.0.12 or newer recommended per Waveshare docs).
- Libraries (Library Manager or GitHub):
- ArduinoJson
- ESPAsyncWebServer (ESP32-compatible)
- AsyncTCP (ESP32)
- LittleFS (ESP32) support is included in the ESP32 core
Board selection
- Tools > Board > esp32 > ESP32S3 Dev Module
Recommended board options for Waveshare ESP32-S3-ETH:
- USB Mode: Hardware CDC and JTAG
- USB CDC On Boot: Enabled (needed if board exposes only USB CDC)
- Flash Size: 8MB
- Partition Scheme: Custom (uses partitions.csv)
- PSRAM: OPI PSRAM (8MB)
- CPU Frequency, Upload Speed: defaults are fine unless you need changes
Build and upload (USB)
- Open
endless-pools-controller.ino - Select board and port
- Click Upload
Helpers:
- scripts/ota-upload.bat: Windows helper to compile with Arduino CLI and upload via OTA. If you provide a sketch.yaml, the script will read default_fqbn/default_port/ota_password from it; otherwise specify target and options explicitly.
- scripts/ota-upload.ps1 and scripts/ota_upload.py: alternative helpers for PowerShell/Python.
- scripts/ota-upload.sh and scripts/serial-upload.sh: Bash helpers for Linux/macOS/WSL that wrap the Python scripts. Make them executable once with:
chmod +x scripts/*.sh.
ESP32-S3 Dev Module FQBN (example)
- The exact option keys can vary by ESP32 core version. A typical FQBN with the requested settings looks like:
esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=8M,PartitionScheme=custom,PSRAM=opi
If Arduino CLI reports an error about options, run:
arduino-cli board details esp32:esp32:esp32s3
to list the supported option names for your installed core version, then adjust sketch.yaml accordingly (edit default_fqbn or the esp32s3-eth profile).
Using the helper script (Windows)
- Prerequisite: Install Arduino CLI and the ESP32 core (e.g.
arduino-cli core install esp32:esp32). - Optional: Configure your WiFi/Ethernet so the device is reachable by hostname or IP (default OTA service port: 3232).
- To compile and OTA upload in one step:
scripts\ota-upload.bat 192.168.1.50
- If your device advertises mDNS and your network supports it, you can use:
scripts\ota-upload.bat swimmachine.local
- Optionally select the explicit profile defined in
sketch.yaml:
scripts\ota-upload.bat 192.168.1.50 esp32s3-eth
- If
sketch.yamlis present, the script reads its settings; otherwise pass target/profile explicitly or build first. It builds tobuild\arduino, then uploads over the network using Arduino CLI’s OTA.
Using the helper script (Bash: Linux/macOS/WSL)
- Prerequisite: Install Arduino CLI and the ESP32 core (e.g.
arduino-cli core install esp32:esp32). - Optional: Configure your WiFi/Ethernet so the device is reachable by hostname or IP (default OTA service port: 3232).
- To compile and OTA upload in one step:
./scripts/ota-upload.sh 192.168.1.50
- If your device advertises mDNS and your network supports it, you can use:
./scripts/ota-upload.sh swimmachine.local
- Optionally select the explicit profile defined in
sketch.yaml:
./scripts/ota-upload.sh 192.168.1.50 esp32s3-eth
Running the Python scripts directly (Windows/macOS/Linux)
- Prerequisites:
- Install Python 3.10+.
- Install Arduino CLI and the ESP32 core (e.g.
arduino-cli core install esp32:esp32) if you want the scripts to build/upload.
- Verify Python:
- Windows (Command Prompt):
py -3 --version - PowerShell:
py -3 --version - macOS/Linux:
python3 --version
- Windows (Command Prompt):
Windows (Command Prompt)
- Serial (first flash, flashes custom partitions):
py -3 scripts\serial_upload.py --port COM5 --build
- OTA (build if needed, then upload):
py -3 scripts\ota_upload.py --target 192.168.1.50 --build
Notes for Windows:
- If
pythonopens Microsoft Store or shows “Python was not found”, prefer using the Python Launcher:py -3 .... - Alternatively, install Python from https://www.python.org/downloads/ and check “Add python.exe to PATH”.
- Or disable App execution aliases: Settings > Apps > Advanced app settings > App execution aliases (turn off Python entries).
PowerShell
- Serial:
py -3 .\scripts\serial_upload.py --port COM5 --build
- OTA:
py -3 .\scripts\ota_upload.py --target swimmachine.local --build
macOS / Linux
- Serial:
python3 scripts/serial_upload.py --port /dev/ttyUSB0 --build
- OTA:
python3 scripts/ota_upload.py --target swimmachine.local --build
WSL / Git Bash
- You can use
python3(if installed in your environment). - The serial script accepts
/dev/ttyS{n}and will map it toCOM{n+1}automatically on Windows.
Notes
- The Python scripts only use the standard library.
- For OTA, ensure the Arduino ESP32 core is installed; the script auto-detects
espota.pyon Windows, Linux (~/.arduino15), and macOS (~/Library/Arduino15).
Initial flash over serial from Bash (sets custom partitions)
- Linux (example):
./scripts/serial-upload.sh --port /dev/ttyUSB0 --build
- macOS (example):
./scripts/serial-upload.sh --port /dev/cu.usbserial-0001 --build
Notes
- If you get a permission error running the .sh files, mark them executable:
chmod +x scripts/*.sh
- On Windows Git Bash, you can still use the Bash helpers; the serial script will map /dev/ttyS{n} to COM{n+1} automatically.
Why a first serial flash?
- OTA (espota.py/Arduino OTA) only replaces the application image; it does not change the flash partition table.
- This project uses a custom partitions.csv for 8MB flash with 3MB APP (dual slots) and 1.5MB SPIFFS.
- Therefore, you must perform one serial/USB flash to install the custom partition scheme. After that, use OTA for future updates.
First flash (serial) options
Option A – Arduino IDE (USB)
- Tools > Board: esp32 > ESP32S3 Dev Module
- Tools options (recommended for Waveshare ESP32-S3-ETH):
- USB Mode: Hardware CDC and JTAG
- USB CDC On Boot: Enabled
- Flash Size: 8MB
- Partition Scheme: Custom (uses partitions.csv)
- PSRAM: OPI PSRAM (8MB)
- Connect the board via USB, select the COM port under Tools > Port.
- Sketch > Upload.
- Optional: Upload the contents of the
data/folder to LittleFS using the LittleFS upload tool (see “Uploading Data Files” below).
Option B – Windows helper scripts (USB serial)
- Batch:
- scripts\ota-upload.bat COM5
- PowerShell:
- .\scripts\ota-upload.ps1 COM5 Notes:
- Replace COM5 with your actual serial port.
- These helpers pass the required FQBN options (FlashSize=8M, PartitionScheme=custom) so the custom partition table is flashed.
Option C – Arduino CLI (USB serial)
- Compile with custom FQBN and export binaries:
- arduino-cli compile -b "esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=8M,PartitionScheme=custom,PSRAM=opi" --build-path build\arduino --export-binaries .
- Upload over serial (replace COM5 with your port):
- arduino-cli upload -p COM5 -b "esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashSize=8M,PartitionScheme=custom,PSRAM=opi" --input-dir build\arduino
Subsequent OTA updates (after first serial flash)
Option A – Windows batch helper
- scripts\ota-upload.bat 192.168.1.50
- scripts\ota-upload.bat swimmachine.local
- Optional profile (if using sketch.yaml profiles):
- scripts\ota-upload.bat 192.168.1.50 esp32s3-eth
Option B – PowerShell helper
- .\scripts\ota-upload.ps1 192.168.1.50
- .\scripts\ota-upload.ps1 swimmachine.local
Option C – Python helper (builds if needed, then OTA uploads)
- python scripts\ota_upload.py --target 192.168.1.50 --build
- python scripts\ota_upload.py --target swimmachine.local --build
- If a binary already exists (build\arduino*.ino.bin), you can omit --build.
OTA upload and data refesh python scripts\ota_upload.py --target swimmachine.local --build python scripts\upload_http_data.py
Passwords and defaults
- OTA service port: 3232 (default).
- OTA password:
- Batch/PowerShell helpers read ota_password from sketch.yaml (if present) and also inject it at compile time.
- Python helper accepts --password or reads from sketch.yaml if present; when building it injects -DOTA_PASSWORD="...".
- Target host:
- If not provided, helpers try to use default_port from sketch.yaml.
Troubleshooting
- If OTA fails immediately: verify the device is reachable (IP/mDNS), the OTA password matches, and the service port (3232) is open on your network.
- If the device was previously flashed with a different partition table: perform one serial upload again to reapply the custom table, then resume OTA.
The ESP32-S3 uses LittleFS to store web UI files and workout data.
Where are the files?
- Web UI files (
index.html,run.html,status.html), static assets (/static), and workout JSON files (/workouts) are all placed in thedata/directory at the repository root.
How to upload to the device?
- Arduino IDE:
- Arduino LittleFS Upload tool (Arduino IDE 2.x, recommended): https://github.com/earlephilhower/arduino-littlefs-upload?tab=readme-ov-file
- Use the LittleFS Upload menu under Tools to upload the contents of
data/to the device.
- Use the LittleFS Upload menu under Tools to upload the contents of
- ESP32 LittleFS Uploader plugin (lorol): https://github.com/lorol/arduino-esp32fs-plugin
- Use Tools > ESP32 Sketch Data Upload to upload
data/to LittleFS.
- Use Tools > ESP32 Sketch Data Upload to upload
- Arduino LittleFS Upload tool (Arduino IDE 2.x, recommended): https://github.com/earlephilhower/arduino-littlefs-upload?tab=readme-ov-file
Notes
- You can also upload via the device’s HTTP API using the provided Python helper (see below).
- Authentication: uploads require a PSK equal to the first 10 characters of OTA_PASSWORD (defined in local otapassword.h). The Python helper auto-derives this PSK by reading otapassword.h unless you override it.
A lightweight uploader is provided at tools/upload_http_data.py. It walks a local directory (default: data/) and uploads each file, preserving relative paths, to the device’s LittleFS via the /api/upload endpoint.
Prerequisites
- Python 3.10+ installed
- Device reachable by hostname (mDNS) or IP (e.g., http://swimmachine.local or http://192.168.1.50)
- The device must be running this firmware (which exposes
/api/upload)
Basic usage (auto-derives PSK from otapassword.h)
python tools/upload_http_data.py --base http://swimmachine.local --dir data
Using an IP instead of mDNS:
python tools/upload_http_data.py --base http://192.168.1.50 --dir data
Override PSK (optional)
python tools/upload_http_data.py --base http://swimmachine.local --dir data -k YOUR_PSK
Dry-run (list what would be uploaded, but don’t send)
python tools/upload_http_data.py --base http://swimmachine.local --dir data --dry-run
What it does
- For each file under
--dir, computes a relative path and uploads to/api/upload?path=RELATIVE/PATH- Example:
data/static/app.js→ remote pathstatic/app.js - Example:
data/index.html→ remote pathindex.html
- Example:
- Sends raw file bytes with headers:
X-PSK: <pre-shared-key>(first 10 chars of OTA_PASSWORD)Content-Type: application/octet-stream
- Creates subfolders automatically on the device as needed
Single-file upload via curl (alternative)
curl -X POST --data-binary @data/index.html \
-H "X-PSK: $(python -c "import re;print(re.search(r'OTA_PASSWORD\\s+\\\"(.*?)\\\"',open('otapassword.h').read()).group(1)[:10])")" \
-H "Content-Type: application/octet-stream" \
"http://swimmachine.local/api/upload?path=index.html"
Troubleshooting
- 401 Unauthorized: PSK mismatch. Ensure the PSK equals the first 10 characters of
OTA_PASSWORDinotapassword.h, or pass-kexplicitly. - Connection errors: verify the device is on your network and the URL/hostname is reachable.
- After uploading, hard-refresh your browser to avoid cached old assets (Ctrl+F5/Shift+Reload).
Arduino OTA is built into the firmware and enabled at boot.
Defaults
- Hostname: swimmachine
- Port: 3232
- Password: defined in local
otapassword.h(not committed)
Requirements
- The device must have an IP on your LAN (Wi‑Fi STA or Ethernet).
- Your computer must be on the same network.
Local secret setup
- Set
ota_passwordinsketch.yaml. This value is passed to Arduino CLI for OTA and injected into the firmware at build time.- WARNING: This stores a secret in plain text in your repo. Consider rotating it regularly or moving secrets to a private repo/CI environment.
- Optional fallback for local builds: the code still contains
otapassword.h. If no build-timeOTA_PASSWORDis injected, the firmware falls back to the value from this header:
#pragma once
#ifndef OTA_PASSWORD
#define OTA_PASSWORD "REPLACE_WITH_A_LONG_RANDOM_SECRET"
#endifArduino IDE
- Power the device and wait for it to connect to the network.
- In Arduino IDE: Tools > Port, select the Network Port for
swimmachine(or the device’s IP). - Click Upload. When prompted for a password, enter the value of
OTA_PASSWORDfrom your localotapassword.h. - The device will report progress over the network and reboot when done.
Arduino CLI (Windows helper)
- With
sketch.yamlconfigured, compile and OTA upload in one step (usesdefault_portandota_passwordfromsketch.yaml):
scripts\ota-upload.bat
- You can also specify the target and/or profile explicitly:
scripts\ota-upload.bat swimmachine.local
scripts\ota-upload.bat 192.168.1.50 esp32s3-eth
- The script reads
default_fqbn/ profile,default_port, andota_passwordfromsketch.yaml, injectsOTA_PASSWORDat build time, builds tobuild\arduino, and uploads via Arduino CLI OTA.
Command-line (espota.py, alternative)
- You can also use Espressif’s
espota.pyscript directly to upload a compiled binary:- Export/compile a .bin (Arduino IDE: Sketch > Export Compiled Binary or use Arduino CLI build output).
- Then run:
python espota.py -i DEVICE_IP -p 3232 --auth=YOUR_OTA_PASSWORD -f PATH_TO_BINARY.bin espota.pyis bundled with the Arduino ESP32 core. Adjust the path if needed.
Notes
- OTA also prints progress to the serial log (115200). You’ll see “OTA: Start”, periodic progress, then “OTA: End”.
- If mDNS is available on your network, you can reach the device by
swimmachine.local; otherwise use its IP address.
First Boot or WiFi Change
- On boot, the device tries to connect using stored WiFi credentials (saved in LittleFS).
- If connection fails within ~15 seconds, it starts AP (Access Point) mode for configuration.
- Connect your phone or computer to the WiFi network named
swimmachine(password12345678). - Open a browser and go to http://192.168.4.1
- Enter your WiFi SSID and password, then save. The device will reboot and connect to your network.
Normal Operation
- The device attempts to connect to the last used WiFi network on boot.
- If successful, it will be accessible on your local network.
Once connected to WiFi or Ethernet, access the web UI:
- By mDNS hostname: http://swimmachine.local (on networks that support mDNS).
- Or by device IP address (check serial output or your router’s device list).
The web UI allows you to:
- View and manage workouts.
- Start, pause, or stop workouts.
- Monitor swim machine status in real time.
- Workouts are stored as JSON files in
/data/workouts/. - Example workout files are provided in this folder. You can use them as templates for your own workouts.
- You can add, edit, or delete workouts via the web UI.
- Workouts can also be uploaded directly to the device using the filesystem upload process above.
iOS (iPhone/iPad)
- Guided Access:
- Settings > Accessibility > Guided Access (enable)
- Set a passcode
- Open the swim machine web UI in Safari
- Triple-click side or home button to start Guided Access
- Auto-Lock: Settings > Display & Brightness > Auto-Lock > Never (remember to restore after workout)
Android
- Screen Pinning:
- Settings > Security > Screen pinning (enable)
- Open the swim machine web UI in Chrome
- Use Overview > app icon > Pin
- Keep Screen On: Settings > Display > Sleep > longer duration or Never (if available)
- Install Node.js 18+ and dependencies:
- cd viewer
- npm install
- Run the viewer:
- node server.js
- Open http://localhost:3000
- It listens for UDP on:
- 9750 (44-byte control messages)
- 45654 (111-byte status messages) These are parsed and streamed to the browser via Socket.IO.
The data/status.html file provides a lightweight, browser-based UDP message monitor, designed to be served from the device’s web server.
Features
- Displays the last 10,000 UDP messages in a table.
- Auto-scrolls as new messages arrive.
- Shows details such as port, message ID, command, parameters, timestamps, speeds, pace, remaining time, and runtime.
- Includes a Copy feature for message data.
Usage
- Access the status page via the device’s web server (e.g., http://swimmachine.local/status.html or the device’s IP address).
- The page updates in real time as UDP messages are received by the device.
Communication between the client (controller/web UI) and the swim machine hardware is performed using fixed-size UDP messages:
| Byte(s) | Name | Description |
|---|---|---|
| 0 | Header | Always 0x0A |
| 1 | Fixed | Always 0xF0 |
| 2 | msgId | Message index/counter (idx2) must be between 0x64 and 0xC6 |
| 3 | cmd | Command code (see commands.csv) |
| 4-5 | param | Command parameter (uint16, little-endian) |
| 6-11 | param | Additional parameters (not used in this project) |
| 12-31 | Reserved | Zero-filled |
| 32-35 | timestamp | Monotonic tick (uint32, little-endian, ms since boot) |
| 36 | Footer | Always 0x97 |
| 37 | Footer2 | Always 0x01 |
| 38-39 | Reserved | Zero-filled |
| 40-43 | CRC32 | CRC-32 of bytes 0-39 (uint32, little-endian) |
All other bytes are zeroed. The message is sent to the swim machine to control its operation.
| Byte(s) | Name | Description |
|---|---|---|
| 0 | Fixed | Always 0x0A |
| 1 | Fixed | Always 0xF0 |
| 2 | msgId | Message index/counter echoed back (confirms receipt) |
| 3 | cmd | Status code (see commands.csv) |
| 4 | curSpeed | Current speed |
| 5 | tgtSpeed | Target speed |
| 6 | not known | |
| 7-8 | pace | Pace (seconds per 100m) |
| 9-10 | not known | |
| 11-12 | remaining | Remaining time in current segment |
| 13-22 | not known | |
| 23-26 | runtimeSec | Runtime in seconds (float, little-endian) |
| 27-30 | totalRuntimeSec | Total runtime in seconds (float, little-endian) |
| 31-70 | not known | |
| 71-74 | timestamp | Timestamp (uint32, little-endian, seconds since epoch) |
| 75-110 | Not known | |
| 107-110 | CRC32 | CRC-32 of bytes 0-106 (uint32, little-endian) |
paceandremainingare encoded as two bytes: total seconds = byte0 + 256 * byte1, then formatted as mm:ss.- Other fields may be present but are not used by the standard viewer.
Both message types use binary encoding for efficiency and are exchanged over the local network. The key distinction is:
- 44 bytes: Sent by the client to control the machine.
- 111 bytes: Sent by the machine to report status to the client.
The last 4 bytes of the 111-byte message contain a CRC32 checksum, calculated over bytes 0 to 106, ensuring data integrity.
This project is licensed under the terms of the LICENSE file provided in this repository.
