Skip to content

synapticon/motion-master

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

497 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Motion Master

Next-generation motion control software for SOMANET servo drives. Browser-based control interface, real-time process data exchange, and a secure HTTP API and WebSocket interface — control from any language, any tool, any AI agent.

Architecture

Design documents with Mermaid diagrams (rendered natively on GitHub):

  • Threading model — the five threads, RT cycle, and control-plane vs PDO-path locking.
  • Class diagram — class structure, ownership, and inheritance.

Prerequisites

  • CMake 4.0+
  • Ninja
  • GCC / Clang with C++23 support (or MSVC on Windows)
  • Git
  • Windows only: Npcap in WinPcap-compatible mode — required at runtime for raw EtherCAT packet capture (install with "Install Npcap in WinPcap API-compatible Mode" checked)

Getting Started

git clone <repo-url>
cd motion-master
git submodule update --init --recursive

./tools/configure.sh
./tools/build.sh

The motion-master binary lands in build/x64-linux-debug/apps/motion_master/.

Usage

motion-master [OPTIONS]

  -h, --help                    Print this help message and exit
      --version                 Display program version and exit
  -c, --config TEXT:FILE        Path to JSONC config file (JSON with // and /* */ comments)
  -p, --port UINT [61447]       HTTP API port
      --ws-port UINT [62281]    WebSocket port (separate loop from the HTTP API)
      --cert TEXT               TLS certificate file
      --key TEXT                TLS private key file
      --update-cert             Download a fresh TLS cert/key, install them, and exit
      --no-cert-update          Do not auto-fetch a fresh cert on startup when it is missing/expired
      --cert-url TEXT           Source URL for the TLS certificate (default: rolling release)
      --key-url TEXT            Source URL for the TLS private key (default: rolling release)
      --cors-origin TEXT [https://motion-master.synapticon.com]
                                Value sent in Access-Control-Allow-Origin (use '*' to allow any)
  -d, --driver TEXT             Fieldbus driver: soem (omit to defer initialisation to the HTTP API)
  -l, --log-level TEXT [info]   Log level: trace, debug, info, warn, error
  -a, --adapter TEXT            Network adapter for EtherCAT: interface name or MAC address
      --list-adapters           Print available network adapters and exit
      --open                    Open https://motion-master.synapticon.com/apps/console/ in the default browser after startup

On Linux, motion-master requires raw socket and RT scheduling capabilities. ./tools/build.sh runs sudo setcap cap_sys_nice,cap_net_admin,cap_net_raw=eip on the binary after linking — you will be prompted for your password. Without these capabilities the binary still runs but EtherCAT initialisation will fail.

Installation

Release packages are available on the Releases page. Every release ships three artefacts:

Artefact Format Install on
motion-master-<version>-linux-x64.tar.gz Tarball Any Linux x86-64
motion-master-<version>-amd64.deb Debian package Ubuntu / Debian
motion-master-<version>-x86_64.rpm RPM package Fedora / RHEL / openSUSE

All packages install to /opt/motion-master/ with a /usr/local/bin/motion-master symlink.

Debian / Ubuntu

sudo apt install ./motion-master-<version>-amd64.deb    # install or upgrade
sudo apt remove motion-master                            # remove (leaves cert.pem / key.pem)
sudo apt purge motion-master                             # full removal including certs

The postinst script automatically sets the required capabilities (cap_sys_nice, cap_net_admin, cap_net_raw) on the binary. On upgrade the capabilities are re-applied to the new binary automatically.

Note: apt remove leaves cert.pem and key.pem behind as conffiles. Use apt purge for a complete uninstall.

Fedora / RHEL / openSUSE

sudo dnf install ./motion-master-<version>-x86_64.rpm   # Fedora / RHEL (install or upgrade)
sudo zypper install ./motion-master-<version>-x86_64.rpm # openSUSE (install or upgrade)
sudo dnf remove motion-master                            # full removal

On uninstall, unmodified cert.pem and key.pem are removed automatically. If you replaced them with your own, they are saved as cert.pem.rpmsave / key.pem.rpmsave.

Tarball

tar -xzf motion-master-<version>-linux-x64.tar.gz
cd motion-master-<version>-linux-x64
sudo ./setup.sh    # sets capabilities once; re-run after any OS update that resets them
./motion-master --help

Docker

# Build
git submodule update --init --recursive
docker build -t motion-master .

--network host is required on all docker run commands — the server binds to 127.0.0.1 and Docker's port forwarding never reaches the loopback interface.

TLS certificates

Release images have cert.pem/key.pem baked in (CI places them at the repo root before docker build). Developer images built from source fall back to the acme.sh cert or a self-signed cert. The discovery order is the same as tools/run.sh:

# Release image — bundled cert used automatically
docker run --rm --network host motion-master

# Developer image — mount acme.sh cert from host (no browser warning)
docker run --rm --network host \
  -v "$HOME/.acme.sh/local.motion-master.synapticon.com_ecc:/root/.acme.sh/local.motion-master.synapticon.com_ecc:ro" \
  motion-master

# Developer image — self-signed fallback (browser security exception required)
docker run --rm --network host motion-master

Updating an expired cert on an older image

The bundled cert is renewed monthly, but an older image keeps its original cert. By default the container self-heals: if the baked-in cert is expired at startup it fetches a fresh one from the rolling release before serving (pass --no-cert-update to disable, e.g. for air-gapped hosts). To pin your own cert instead, override it at runtime — the volume mount shadows the baked-in file:

docker run --rm --network host \
  -v /path/to/cert.pem:/opt/motion-master/cert.pem:ro \
  -v /path/to/key.pem:/opt/motion-master/key.pem:ro \
  motion-master

Or point to an arbitrary path with env vars:

docker run --rm --network host \
  -e CERT=/certs/cert.pem -e KEY=/certs/key.pem \
  -v /path/to/cert.pem:/certs/cert.pem:ro \
  -v /path/to/key.pem:/certs/key.pem:ro \
  motion-master

Capabilities

Docker drops most Linux capabilities by default. On a bare-metal install postinst/setup.sh stamps the binary with setcap so any user can run it and it receives the required capabilities automatically. Inside a container, file capabilities are ignored — you grant the equivalent capabilities to the container process with --cap-add at docker run time instead.

Capability What it unlocks Required for
CAP_NET_RAW Open raw/packet sockets SOEM sending/receiving raw EtherCAT frames
CAP_NET_ADMIN Configure network interfaces SOEM putting the NIC into promiscuous mode
CAP_SYS_NICE Set SCHED_FIFO scheduling policy and RT priority Real-time game loop
CAP_IPC_LOCK Call mlockall() to pin process memory Preventing page faults during RT cycles

--ulimit memlock=-1 is also required alongside CAP_IPC_LOCK — without it the kernel rejects mlockall() even when the capability is present.

The binary degrades gracefully: missing RT caps produce a warning and the loop runs without RT guarantees; missing EtherCAT caps cause POST /api/init to fail when a SOEM driver is requested.

# EtherCAT only (no RT requirement on the host kernel)
docker run --rm --network host \
  --cap-add NET_ADMIN --cap-add NET_RAW \
  motion-master --driver soem --adapter eth0

# RT scheduling only (PREEMPT_RT host kernel required)
docker run --rm --network host \
  --cap-add SYS_NICE --cap-add IPC_LOCK --ulimit memlock=-1 \
  motion-master

# Full EtherCAT + RT
docker run --rm --network host \
  --cap-add NET_ADMIN --cap-add NET_RAW \
  --cap-add SYS_NICE --cap-add IPC_LOCK --ulimit memlock=-1 \
  motion-master --driver soem --adapter eth0

Docker Hub

Publish the image to a personal Docker Hub account (markosankovic/motion-master in the example) so it can be pulled on other machines.

# 1. Authenticate — use a Docker Hub access token, not your password.
#    Create one at Docker Hub → Account Settings → Personal access tokens.
echo "$DOCKERHUB_TOKEN" | docker login -u markosankovic --password-stdin
#    (or interactively, pasting the token at the password prompt)
docker login -u markosankovic

# 2. Build and tag in one step (VERSION = the repo's current version).
DOCKER_BUILDKIT=1 docker build \
  -t markosankovic/motion-master:6.0.0-alpha.31 \
  -t markosankovic/motion-master:latest .

# 3. Push both tags. The repo is auto-created (public) on first push.
docker push markosankovic/motion-master:6.0.0-alpha.31
docker push markosankovic/motion-master:latest

Tag with the version (matching the repo VERSION) so a specific build is identifiable — don't rely on latest alone. Already-built motion-master image? Re-tag it instead of rebuilding: docker tag motion-master markosankovic/motion-master:latest.

Run from Docker Hub

Pull and run the published image, mounting cert.pem/key.pem from the current directory. Source paths must be absolute — a relative -v cert.pem:... is interpreted as a named volume, not your local file, and the container silently falls back to a self-signed cert. Use $(pwd)/ to force an absolute path:

docker run --rm --network host \
  --cap-add NET_RAW --cap-add NET_ADMIN \
  --cap-add SYS_NICE --cap-add IPC_LOCK --ulimit memlock=-1 \
  -v "$(pwd)/cert.pem:/opt/motion-master/cert.pem:ro" \
  -v "$(pwd)/key.pem:/opt/motion-master/key.pem:ro" \
  markosankovic/motion-master:latest

docker run pulls the image automatically if it isn't present locally. Drop the two -v flags to let the container self-heal its cert instead (fetches a fresh Let's Encrypt cert at startup; see Updating an expired cert above).

Local Development

Production releases bundle a real Let's Encrypt TLS certificate for local.motion-master.synapticon.com, so the PWA at https://motion-master.synapticon.com connects without any browser warning.

For development, the run script picks up a cert automatically:

./tools/run.sh

It looks for a certificate in this order:

  1. cert.pem / key.pem next to the binary (present in release builds)
  2. ~/.acme.sh/local.motion-master.synapticon.com_ecc/ — if you have acme.sh installed locally with the Let's Encrypt cert (no browser warning)
  3. Self-signed fallback — generated on the fly; requires accepting a browser security exception once per server restart

If the cert is missing or already expired at startup, the binary fetches a fresh one from the rolling release and installs it before serving (disable with --no-cert-update). You can also refresh on demand — motion-master --update-cert (terminal) or the Refresh certificate button on the PWA's Connection page (POST /api/cert/refresh). The cert is rotated monthly and published at a stable URL, decoupled from app releases:

https://github.com/synapticon/motion-master/releases/download/tls-cert/{cert,key}.pem

Test the API (add -k only when using the self-signed fallback):

curl -k https://localhost:61447/api/version

CORS

The server sends Access-Control-Allow-Origin: https://motion-master.synapticon.com by default so the production PWA can reach a locally running backend. When developing the UI against a different origin (e.g. Vite dev server on http://localhost:5173), override it via the CORS_ORIGIN env var picked up by tools/run.sh, or by passing --cors-origin to the binary directly:

# Vite dev server
CORS_ORIGIN=http://localhost:5173 ./tools/run.sh

# Allow any origin (development only — do not use in production)
CORS_ORIGIN='*' ./tools/run.sh

# Equivalent, calling the binary directly
./build/x64-linux-debug/apps/motion_master/motion-master --cors-origin http://localhost:5173

Fieldbus lifecycle via API

--driver and --adapter are optional at startup. When omitted, the fieldbus is uninitialised and GET /api/devices returns an empty array. Use the lifecycle endpoints to initialise at runtime:

# 1. Discover available adapters
curl -k https://localhost:61447/api/adapters

# 2. Initialise the fieldbus driver
curl -k -X POST https://localhost:61447/api/init \
     -H 'Content-Type: application/json' \
     -d '{"driver":"soem","adapter":"eth0"}'

# 3. Scan for slaves and populate the device list. The first scan of a given drive enumerates its
#    full CoE object dictionary (hundreds of mailbox round-trips, a few seconds); the definitions
#    are cached on disk per device identity, so later scans of the same hardware skip it. Enabled
#    for Synapticon drives by default — see the parameterCache config block. Inspect or clear the
#    cache via GET/DELETE /api/parameter-caches (or the Parameter Caches page in the UI).
curl -k -X POST https://localhost:61447/api/scan

# 4. List discovered devices
curl -k https://localhost:61447/api/devices

# 5. Climb to Op state (state values: 1=Init, 2=PreOp, 3=Boot, 4=SafeOp, 8=Op).
#    EtherCAT only allows single-step climbs, so go up one level at a time. After a
#    scan devices sit in Init; jumping straight to a higher state is rejected.
curl -k -X POST https://localhost:61447/api/devices/state -H 'Content-Type: application/json' -d '{"state":2}'  # PreOp
curl -k -X POST https://localhost:61447/api/devices/state -H 'Content-Type: application/json' -d '{"state":4}'  # SafeOp
curl -k -X POST https://localhost:61447/api/devices/state -H 'Content-Type: application/json' -d '{"state":8}'  # Op

# 6. Transition specific devices, with a custom timeout
curl -k -X POST https://localhost:61447/api/devices/state \
     -H 'Content-Type: application/json' \
     -d '{"state":2,"positions":[1,2],"timeout":3000}'

# 7. Tear down (stops driver, clears device list; init + scan can be called again)
curl -k -X POST https://localhost:61447/api/reset

Connect a WebSocket client to wss://localhost:62281 (the WebSocket runs on its own port and event loop, separate from the HTTP API on 61447, so a slow HTTP request never stalls the stream; the whole port is the WebSocket, so the URL needs no path). The server sends two message types:

{"type": "monitoring", "topic": "left-leg", "data": [[1735821000123456, 39, 0, 12345], ...]}
{"type": "notification", "data": {"event": "slaves_changed"}}

data is an array of cycle rows — the stream is lossless, one row per recorded process-data cycle since the last flush. Each row is [timestampUs, v0, v1, ...]: epoch microseconds followed by one value per parameter in the monitoring's order (a value is null while its device is not exchanging). interval is the flush cadence (5–2000 ms), not a sample rate. Fetch the parameter order (and how each value is sourced) to interpret the array:

curl -k https://localhost:61447/api/monitorings/left-leg

Bus inspection

Read-only endpoints expose the configured bus state and live health — none of them reconfigure the bus:

# Per-slave ESC configuration captured at scan (Sync Managers, FMMUs, mailbox, distributed clock)
curl -k https://localhost:61447/api/bus-config

# Process-image layout: every PDO-mapped object resolved to a bit offset, plus working-counter health
curl -k https://localhost:61447/api/process-image

# Dump the lossless recorder to a binary .mmpd file (full raw inputs+outputs for every cycle in the
# ring, with the process image embedded as a header so it decodes offline). Captures the ring from
# oldest to newest at the moment of the call — works while exchanging too. The file is written on the
# server's machine under recorder.dumpDir (default: <temp>/motion-master); the response is its path.
curl -k -X POST https://localhost:61447/api/process-data/dump

# Current AL state of every device (1=Init, 2=PreOp, 3=Boot, 4=SafeOp, 8=Op)
curl -k https://localhost:61447/api/devices/state

# Live link diagnostics from each slave's EtherCAT Slave Controller: per-port error counters
# (invalid frame, RX / forwarded errors, lost link), link state, and watchdog expirations. The
# counters are cumulative — watch for a rising delta to localise a degrading cable or connector.
curl -k https://localhost:61447/api/devices/diagnostics

# The device queries accept a positions filter
curl -k 'https://localhost:61447/api/devices/diagnostics?positions=1,2'

In the web UI these are the Fieldbus group's pages — Control (init / scan / state), Configuration, Process Image, and Diagnostics.

Extending the API (C++ routes)

You can add your own HTTP endpoints in C++ without touching the server core. libs/example is a copy-me starter (the server-side counterpart of the web/apps/example PWA) that registers GET /api/example/devices:

curl -k https://localhost:61447/api/example/devices

To add your own:

  1. Copy libs/example, then rename the directory, the mm::example namespace, and the /api/example/... route prefix.

  2. Put real logic in *_logic.{h,cc} — plain, HTTP-agnostic functions that take a DeviceManager& (testable with no server). Format responses in *_routes.cc using the mm::api::sendJson / sendError / sendStatus helpers so the content type and CORS headers match the built-in routes.

  3. Add the subdirectory to the root CMakeLists.txt and link your lib into apps/motion_master.

  4. Wire it in main.cc before the server starts:

    httpServer.addRoutes(mm::yourapp::registerRoutes);  // before httpServer.start()

Your registerRoutes(uWS::SSLApp&, const mm::api::RouteContext&) runs once on the HTTP event-loop thread, after the built-in routes and before the catch-all 404. Register only your own paths (e.g. /api/yourapp/...) — never the /api/* or /* wildcards. The transport glue lives in libs/api (mm::api); the domain layer (mm::node) stays free of any HTTP/uWebSockets dependency. These routes are intentionally not part of swagger.yml — that spec documents the stable built-in API only.

Developer Scripts

All scripts default to the x64-linux-debug preset. Pass a preset name as the first argument to override (e.g. ./tools/build.sh x64-linux-release).

Script Description
./tools/configure.sh Run CMake configure
./tools/build.sh Build all targets
./tools/run.sh Run the binary with the best available TLS cert (real cert if acme.sh is set up, self-signed otherwise)
./tools/test.sh Run tests
./tools/format.sh Auto-format all sources with clang-format
./tools/lint.sh Run cpplint (pip install cpplint if missing)
./tools/cppcheck.sh Run cppcheck static analysis
./tools/check.sh Run format, cppcheck, and lint in sequence
./tools/clean.sh Remove the build directory
./tools/bump-version.sh <version> Bump the project semver everywhere (see Versioning)
./tools/package.sh [preset] Build .deb and .rpm packages (requires cert.pem/key.pem in the build dir)

Code Quality Tools

  • format — runs clang-format over all .cc/.h sources and rewrites them in-place. Enforces Google style with a 100-column limit as defined in .clang-format. CI fails if any file is not already formatted.
  • lint — runs cpplint to check for include order, deprecated constructs, and header guards. Configured via CPPLINT.cfg. Naming conventions are enforced in code review, not by this tool.
  • cppcheck — static analysis that catches bugs the compiler doesn't warn about: null pointer dereferences, out-of-bounds access, uninitialized variables, resource leaks, etc. Runs with warning,style,performance,portability checks at --std=c++23 and exits non-zero on any finding.

Versioning

All components — C++ backend, React UI, OpenAPI spec, and npm packages — share a single semver. VERSION (repo root) is the canonical source: a one-line plain-text file (e.g. 6.0.0-alpha.31). There is no auto-increment — a human picks the next version. Everything else is either derived from VERSION at build time or kept in sync with it by the bump script.

Bumping

Never edit VERSION by hand. Run the bump script with the new version:

./tools/bump-version.sh 6.1.0
./tools/bump-version.sh 6.1.0-alpha.0

It writes VERSION, then propagates the value to every location that isn't auto-derived: vcpkg.json, the package.json manifests (root workspace + motion-master, motion-master-client, hil/api), swagger.yml (info.version), the version_test.cc assertion, and the UI sidebar badge in RootLayout.tsx.

How it reaches the C++ binary

CMake does the propagation into native code at configure time — version.h is generated, never edited by hand:

  1. CMakeLists.txt reads the file into a variable: file(STRINGS "${CMAKE_SOURCE_DIR}/VERSION" MM_VERSION).

  2. libs/core/CMakeLists.txt runs configure_file(version.h.in …), substituting @MM_VERSION@ in the template to produce the build-dir version.h:

    constexpr std::string_view kVersion = "6.0.0-alpha.31";
    static_assert(semver::valid(kVersion));   // build fails on a malformed version

The static_assert is a compile-time guard: a malformed version in VERSION breaks the build rather than shipping a bad string. The Doxyfile version is propagated the same way.

Releasing

After bumping, commit the changed files, then push a v<version> tag to trigger the release workflow:

git add -A
git commit -m "chore: bump version to 6.1.0"
git tag v6.1.0
git push && git push --tags

The v* tag builds the platform binaries and publishes @synapticon/motion-master-client@<version> to npm (prereleases under the next dist-tag). Two drift nets back the sync: version_test.cc fails if its hard-coded string falls out of step, and the api-client-drift CI job fails if the committed API client is stale against swagger.yml.

CI

Workflow Trigger Purpose
build-linux-x64.yml push / PR to main Build & test (Linux x64); vcpkg packages cached
build-linux-arm64.yml push / PR to main Build & test (Linux ARM64)
build-macos-arm64.yml push / PR to main Build & test (macOS Apple Silicon)
build-windows-x64.yml push / PR to main Build & test (Windows x64)
lint.yml push / PR to main clang-format + cpplint checks
cert-renewal.yml 1st of every month Renew Let's Encrypt cert via acme-dns; update TLS_CERT / TLS_KEY secrets
release.yml v* tag push Build all platforms, bundle cert + key from secrets, publish GitHub Release with .tar.gz, .deb, .rpm (Linux), .zip (Windows), and .tar.gz (macOS arm64)

The vcpkg cache key is OS + vcpkg.json hash. The first run after a dependency change rebuilds from source; subsequent runs restore from cache.

Dependencies

Managed via vcpkg. No manual installation needed — vcpkg downloads and builds everything on first configure.

Package Purpose
CLI11 Command-line argument parsing
spdlog Structured logging
nlohmann-json JSONC config file parsing (comments enabled) and HTTP response serialization
neargye-semver Semantic versioning
uwebsockets HTTP and WebSocket server (TLS via OpenSSL)
GTest Unit testing

Platform Support

Platform Status
Linux x86-64 Primary target
Linux ARM Planned
Windows x64 Planned

Real-Time Linux

Motion Master targets CONFIG_PREEMPT_RT kernels for hard real-time operation. The GameLoop sets SCHED_FIFO priority 80 and calls mlockall before entering the cycle loop. The cycle timer uses clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME) so scheduling jitter in one cycle never accumulates into drift.

Hardware-in-the-Loop Tests

The hil/ directory contains standalone binaries for validating RT behaviour on a pre-configured Linux machine. They are built automatically with the rest of the project but require root (or CAP_SYS_NICE + CAP_IPC_LOCK) to produce valid results.

jitter_bench

Measures the cycle-to-cycle scheduling jitter of the GameLoop timer loop — how much each actual cycle interval deviates from the target period.

# Build
./tools/build.sh

# Run 30 s at 1 ms period, write jitter.csv
sudo ./build/x64-linux-debug/hil/jitter_bench/jitter_bench

# Simulate 300 µs of task load per cycle
sudo ./build/x64-linux-debug/hil/jitter_bench/jitter_bench --workload 300

# Plot (requires matplotlib)
python3 hil/jitter_bench/plot_jitter.py jitter.csv
python3 hil/jitter_bench/plot_jitter.py jitter.csv -o report.png

# Full option list
./build/x64-linux-debug/hil/jitter_bench/jitter_bench --help

The plot shows a time-series with P99/P99.9 reference lines and a jitter histogram. The terminal output prints min/max/mean/stddev/P50/P95/P99/P99.9 and an overrun count. Compare a standard kernel against PREEMPT_RT by running with --workload 300 (a realistic 1 ms cycle budget) on each.

About

Next-generation motion control software for SOMANET servo drives.

Resources

License

Stars

Watchers

Forks

Contributors