|
|
||
|---|---|---|
| .godot | ||
| addons/godot_ai | ||
| Assets | ||
| python_bridge | ||
| Scenes | ||
| Scripts | ||
| Themes | ||
| .editorconfig | ||
| .gitignore | ||
| export_presets.cfg | ||
| icon.svg | ||
| icon.svg.import | ||
| project.godot | ||
| README.md | ||
Comfac Robot Arm Controller
Version 0.7.5 — pre-release.
Everything described below works and is verified in simulation. The physical-arm path (the Python bridge and the two hardware modes) is fully implemented and has been tested end-to-end against a mock arm, but has not yet been validated on real hardware. That validation is what 1.0 waits on.
A Godot test bench for a LeRobot-style (SO-100 / SO-101) robot arm.
It runs three jobs in one scene: simulate a pair of arms, drive a physical arm from the on-screen controls, or mirror a physical arm back into Godot. The same UI and the same joint state serve all three, so you can develop against the simulation and then swap in real hardware without changing how you work.
Requirements
| Engine | Godot 4.7.2-stable (project features 4.7, GL Compatibility) |
| Renderer | gl_compatibility — chosen for broad/mobile support |
| Physics | Jolt Physics |
| Display driver | D3D12 on Windows |
| Optional | Python 3 for the hardware bridge (see Hardware bridge) |
Open the folder as a project in Godot and press F5. The main scene is
res://Scenes/main_environment.tscn.
The two arms
| Arm | Role |
|---|---|
| Leader | The controller. On-screen sliders command it, and in Godot -> Robot its values are forwarded to the real arm. |
| Follower | The mirror. It never reads sliders or hardware itself — it just reflects whatever ArmState says, which is why the same script works in both directions. |
Both arms share the LeRobot joint naming so they line up 1:1 with the physical arm's motors:
base · shoulder_pan · shoulder_lift · elbow_flex · wrist_flex · wrist_roll · gripper
baseis view-only. It spins the whole model for inspection and has no physical motor behind it, so it is never forwarded to hardware.
Resting pose
Both arms start in — and reset to — a single resting pose, defined once as
ArmState.REST_POSE:
| Joint | Resting value |
|---|---|
| base | 0° |
| shoulder_pan | 0° |
| shoulder_lift | −25° |
| elbow_flex | 40° |
| wrist_flex | 80° |
| wrist_roll | 0° |
| gripper | 0° |
This mirrors how a physical SO-100 / SO-101 sits when idle: the elbow folds and the wrist curls, rather than the arm standing straight out.
The values are not arbitrary. The base is a large block and the leader arm carries a protruding grip handle, and between them they rule out most folded poses: folding the shoulder down drives the wrist assembly straight through the base, and letting the wrist unwind drops the handle through the desk. The pose above was tuned per-mesh against the actual models and is verified to keep every part of both arms clear of the base and above the desk surface.
Because REST_POSE is the only definition, the startup pose, the full-control
Reset and the shift-stick Reset All Joints agree by construction.
Operating modes
The active mode (AppMode) decides both what is visible and what the camera
shows.
| Mode | Control flow | Arms shown | Camera views |
|---|---|---|---|
| Simulation Only | Sliders drive the Leader; the Follower mirrors it in Godot only | Both | Two side-by-side views — Leader (left), Follower (right) |
| Godot -> Robot | Sliders drive the Leader and are forwarded to the physical arm | Leader only | One full-width Leader view |
| Robot -> Godot | Physical servo feedback drives both Godot arms | Follower only | One full-width Follower view |
Switch modes from Menu → Control Mode.
Camera system
Each view is its own SubViewport with its own Camera3D, sharing the main
World3D — so the two arms really are rendered by two independent cameras at
once, rather than one camera trying to frame both.
- Per-arm isolation. The arms sit only 10 units apart, so both cameras
would otherwise see both. Each arm's meshes and its desk are assigned their
own visual layer at runtime and each camera gets a matching
cull_mask, so a view shows its own arm and desk plus the shared room — never its neighbour. The desk half of that matters as much as the arm half: with the arm culled but its desk left on the shared layer, the neighbouring view contains a bare, empty table. - Auto-framing. On load each rig measures its arm's bounding box and backs the camera off far enough to frame it, fitting the box's two axes against the view's two half-angles. Swapping or rescaling a model still lands in shot.
- Labels and borders. Each view is labelled (
LEADER ARM/FOLLOWER ARM) and bordered, so the split is unambiguous. - Cost when hidden. A hidden view's SubViewport is set to
UPDATE_DISABLED, so the single-view modes pay for one world render, not two.
Looking around
Orbiting is linked — one gesture drives every visible view together, so the two arms always stay at the same relative angle and remain comparable.
| Input | Action |
|---|---|
| Right-drag (mouse) | Orbit / tilt |
| Mouse wheel | Zoom |
| One-finger drag (touch) | Orbit / tilt |
| Two-finger pinch (touch) | Zoom |
Dragging over the control panels does not orbit — those consume the event first. Yaw is unlimited (full 360°); tilt is clamped to keep the arm framed.
HUD
Status bar
A strip across the top of the screen summarises what the app is doing, so the operator never has to open a menu to find out:
| Chip | Shows |
|---|---|
| Mode | Simulation Only / Godot -> Robot / Robot -> Godot |
| Kill switch | Armed or STOPPED |
| Hardware bridge | HW Online or HW Offline |
Each chip carries an icon drawn in code (status_icon.gd: rings, arrows, a
stop sign, an LED) rather than shipped as art, so an icon recolours the instant
its state changes and adds nothing to the export size. The bar itself is built
at runtime by ui_status_bar.gd, which subscribes to AppMode, ArmState and
HardwareBridge and re-reads their state on every signal. The bar ignores mouse
input throughout, so it never steals clicks from the controls underneath.
Motion
The HUD is not static:
- Intro. On load the screen is covered by an opaque black veil and every panel starts transparent. The veil fades away while each panel slides in from its nearest edge, staggered over roughly 0.7 s.
- Panels. The settings window slides down into place on open and back up on close, tab pages cross-fade, and the two control schemes slide past each other when you switch between them.
- Exit. Quitting fades the screen back to black before the app closes, so exiting reads as deliberate rather than abrupt.
All of it runs through ui_animator.gd, a small set of static tween helpers
(enter / exit / fade_in / fade_out / fade_from_black). Two properties
of those helpers are deliberate:
- Each one binds its tween to the node it animates, so a tween can never outlive its node.
- Each is self-resetting — it restores the node's rest position and modulate when it finishes — so repeated open/close cycles cannot accumulate drift.
The intro veil is created by main_ui.gd, which also reuses it for the exit
fade. That script registers itself in the fade_overlay group, which is how the
exit page reaches it without holding a hard reference.
Environment
The bench is dressed as a small office rather than a bare plane, so the arms
read as a real desk-top installation. It all lives in
Scenes/office_environment.tscn, instanced by the main scene:
- Room — a single box rendered with
cull_mode = CULL_FRONT, so you see its inward faces. From inside it is a closed room; if the orbit camera swings outside, the near wall is culled and you still see the arms rather than the outside of a black box. - Desks — one laminate-topped desk per arm, each centred under its own arm
(at
x = 0andx = 10). A single shared bench cannot centre both arms at once: each would sit near one end of it, which reads as lopsided in a view that shows only that arm. Each top's surface sits exactly aty = 0, which is where the arms' bases already are, so neither arm had to be moved to stand on it. - Window — an unshaded bright quad on the back wall. It motivates the key light and stops the backdrop reading as a flat wall.
- Lighting — deliberately soft and diffuse rather than dramatic. A weak,
steeply-angled key light comes through the window with partial
(
shadow_opacity), widened (light_angular_distance) shadows; a warm ceiling fitting carries most of the illumination; and a coloured ambient fill lifts the shaded sides. The shadows are intentionally light: a strong low directional light threw long hard-edged shadows across the desk, which read as harsh rather than as office lighting. There is deliberately no sky: the background is a flat interior colour, since daylight outdoors would be wrong for an office.
The room shell has shadow casting switched off so it does not block the light.
Controls
Two interchangeable control schemes drive the exact same joint state, so you can switch mid-operation without anything jumping.
Slider handles are deliberately oversized (48px, against the ~44-48px touch target guidelines) because a fingertip is far less precise than a cursor — they were easy to miss on the mobile build at their original size.
Full control
One slider per joint, each with −/+ nudge buttons (5° per press) and a Reset that returns every joint to the resting pose.
| Joint | Slider range |
|---|---|
| Gripper | −90° … 90° |
| Wrist Roll | −360° … 360° |
| Wrist Flex / Elbow Flex / Shoulder Lift / Shoulder Pan | −95° … 95° |
| Base | −180° … 180° |
Shift-stick
A joint selector picks which joint is active, a second slider rotates it, and the current angle is shown numerically. Reset All Joints returns every joint to the resting pose.
This is the faster scheme for driving one joint at a time on a touchscreen.
Note: both Reset buttons now do the same thing — they call
ArmState.reset_all_joints(), which returns every joint to the resting pose. Resetting still works while the kill switch is tripped, becauseArmStateaccepts"system"sourced writes in that state.
Safety
An emergency-stop strip is always on screen (top-left), independent of the settings menu — stopping the arm never requires opening a menu first. The same control also appears on the Safety tab.
Tripping the kill switch:
- blocks every joint write in
ArmState(except"system"sourced writes, so resets still work), and - tells the physical arm to stop over the hardware bridge.
The button reads EMERGENCY STOP when armed and RESUME when tripped, with a
colour-coded status label. While tripped, joint sliders and nudge buttons are
disabled; reset stays available by design.
Sliders are also locked in Robot -> Godot, since dragging them there would
fight the incoming hardware feedback.
Settings menu
A single tabbed overlay keeps the main screen clear. Open it with the Menu button (top-right) or close it with Close / Esc.
The overlay runs at a larger text scale than the HUD (22px body text, 30px
section titles, against 18px in the controls panel) so it is comfortable to read
on a phone. That comes from its own Themes/settings_theme.tres, which defines
only font sizes and lets everything else — panel and button styleboxes
included — fall through to the shared HUD theme.
| Tab | Contents |
|---|---|
| Safety | Emergency stop and its status |
| Control Mode | Operating mode, hardware-bridge status, connect/disconnect |
| Calibration | Per-joint travel limits and zeroing |
| Exit | Quit the app, behind a confirmation prompt |
Calibration
Per joint you can view and edit:
- Min ° / Max ° — the travel limits enforced by
ArmStatewhen clamping - Offset ° — the zero reference
Actions: Apply Limits (also echoed to the arm if connected) and Set Current As Zero (whatever the joint reads now becomes the new offset).
Calibration is the source of truth for clamping and is persisted to disk at
user://arm_calibration.cfg, so it survives restarts. Defaults are
−90° … 90° with a 0° offset for every joint.
Hardware bridge
Godot has no native serial support, so a small companion Python process owns the servo bus and exposes it over a local TCP socket using newline-delimited JSON.
- Default endpoint:
127.0.0.1:9999 - Connect from Menu → Control Mode → Connect to Hardware
Outbound (Godot → Python → servos)
{"cmd":"set_joint","joint":"shoulder_pan","degrees":45.0}
{"cmd":"kill_switch","active":true}
{"cmd":"calibrate","joint":"shoulder_pan","min":-90.0,"max":90.0,"offset":0.0}
{"cmd":"mode","mode":"robot_to_godot"}
Inbound (servos → Python → Godot)
{"event":"joint_feedback","joint":"shoulder_pan","degrees":47.2}
{"event":"connected"}
{"event":"error","message":"..."}
Joint names must match ArmState.JOINT_NAMES exactly, or that joint is silently
ignored. The bridge auto-reconnects every 2 s while connecting.
Bridged by
python_bridge/arm_bridge.py, which ships in this repo — seepython_bridge/README.mdfor setup. It can run with--mockto exercise this whole path with no hardware attached. Simulation modes need nothing at all; onlyGodot -> RobotandRobot -> Godotneed the bridge running.
Architecture
ArmState is the single source of truth for every joint angle. The arms never
talk to each other; they all read and write through it. That is what makes
bidirectional sync possible without the two arms fighting over ownership.
sliders ─┐
├─► ArmState ─┬─► Leader holders (arm_controller.gd)
hardware ─┘ (clamped, │
persisted) ├─► Follower holders (robort_arm_follower.gd)
│
└─► (Godot -> Robot) ─► hardware bridge
Every change carries a source:
| Source | Meaning |
|---|---|
ui |
A slider or nudge button. The only source forwarded to hardware. |
hardware |
Feedback from the physical arm |
system |
Reset / calibration / init — the only source allowed while the kill switch is tripped |
Project structure
arm-test/
├── Assets/ Source .glb models (see note below)
├── Scenes/
│ ├── main_environment.tscn Main scene: both arms, environment, UI
│ ├── main_ui.tscn HUD, camera views and settings overlay
│ ├── office_environment.tscn Room, desk, window and office lighting
│ ├── lerobot_leader.tscn Leader arm rig
│ └── lerobot_follower.tscn Follower arm rig
├── Scripts/
│ ├── arm_state.gd AUTOLOAD — joint state, clamping, calibration
│ ├── app_mode.gd AUTOLOAD — operating mode
│ ├── hardware_bridge.gd AUTOLOAD — TCP client for the Python bridge
│ ├── arm_controller.gd Leader: UI → ArmState → holders
│ ├── robort_arm_follower.gd Follower: mirrors ArmState
│ ├── camera_views.gd Mode-driven camera views, linked orbit
│ ├── camera_view_rig.gd One view's orbit rig + auto-framing
│ ├── main_ui.gd HUD intro / exit fade, attached to MainUI
│ ├── ui_animator.gd Static tween helpers for UI motion
│ ├── ui_status_bar.gd Top status bar (mode / kill switch / bridge)
│ ├── status_icon.gd Code-drawn icons for the status bar
│ ├── settings_menu.gd Tabbed settings overlay
│ ├── kill_switch_panel.gd Emergency stop (HUD strip + Safety tab)
│ ├── mode_panel.gd Control Mode tab
│ ├── calibration_panel.gd Calibration tab
│ ├── shift_stick_panel.gd Shift-stick control scheme
│ ├── resetSliders.gd Full-control Reset
│ └── exit.gd Exit tab
├── Themes/
│ ├── panel_theme.tres Shared UI theme (panels, text sizes, buttons)
│ └── settings_theme.tres Larger-text variant for the settings overlay only
├── python_bridge/
│ ├── arm_bridge.py TCP server owning the LeRobot servo bus
│ └── README.md Bridge setup, protocol and troubleshooting
├── addons/godot_ai/ Editor tooling — not part of the app
├── export_presets.cfg
└── project.godot
About
Assets/: the.glbfiles are the original models. The arm scenes embed their meshes directly, so nothing references the.glbat runtime — they are kept as source art for regenerating the rigs.
About
addons/godot_ai/: this is editor tooling for an AI-assistant bridge, not part of the app. It is also whyproject.godotregisters a fourth autoload,_mcp_game_helper— the three listed above are the application's own. The plugin and that autoload can be removed for a clean release build.
Export
The project version is 0.7.5, set in project.godot
(application/config/version) and mirrored into both export presets, so builds
are self-describing.
Two presets ship in export_presets.cfg:
| Preset | Platform | Output | Notable settings |
|---|---|---|---|
Android |
Android | Build/ComfacRobotArmController-0.7.5.apk |
arm64-v8a only, immersive mode, landscape |
Windows Desktop |
Windows | Build/ComfacRobotArmController-0.7.5.exe |
x86_64, no embedded PCK |
Android identity is net.comfac_it.robot_arm_controller (reverse-DNS of the project
remote), version name 0.7.5, version code 2 — increment the version code for
every upload, and note the package name is permanent once published.
Bumping the version for a release touches three places (shown here for the current 0.7.5):
project.godot application/config/version "0.7.5"
export_presets.cfg version/name (Android) "0.7.5"
version/code (Android) increment
application/file_version (Win) "0.7.5.0"
application/product_version "0.7.5"
export_path (both) Build/ComfacRobotArmController-0.7.5.*
The two Windows version fields are deliberately not the same string:
file_versionis four-part (0.7.5.0) because Windows file versions require four components, whileproduct_versionis three-part (0.7.5).
Both presets still use the stock Godot logo as the app icon.
icon.svgis the default engine icon, and the Android launcher and Windows icon fields are left empty so Godot falls back to it. A real icon is worth doing before any public release.
The app is laid out for landscape, and the UI is anchored/container-driven so it adapts to other aspect ratios — the control column docks right and the camera views take the remaining width.
Both presets use
export_filter="all_resources", which bundles every resource in the project — including the unreferenced.glbfiles (~5 MB). Switching to dependency-based filtering, or removing those assets, will shrink the mobile build.
Known limitations
- Camera framing is computed once from the arm's resting pose, so tilt is clamped to a gentle range; a hard top-down swing would crop the arm.
baseis a viewing joint only and has no hardware counterpart.- The full-control panel is sized for 1080p-class landscape; on a much shorter window its content would need a scroll container.