From Team #14270
██████╗ ██╗ ██╗ █████╗ ███╗ ██╗████████╗██╗ ██╗███╗ ███╗
██╔═══██╗██║ ██║██╔══██╗████╗ ██║╚══██╔══╝██║ ██║████╗ ████║
██║ ██║██║ ██║███████║██╔██╗ ██║ ██║ ██║ ██║██╔████╔██║
██║▄▄ ██║██║ ██║██╔══██║██║╚██╗██║ ██║ ██║ ██║██║╚██╔╝██║
╚██████╔╝╚██████╔╝██║ ██║██║ ╚████║ ██║ ╚██████╔╝██║ ╚═╝ ██║
╚══▀▀═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
██████╗ ██████╗ ██████╗ ██████╗ ████████╗██╗ ██████╗███████╗
██╔══██╗██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝██║██╔════╝██╔════╝
██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ██║██║ ███████╗
██╔══██╗██║ ██║██╔══██╗██║ ██║ ██║ ██║██║ ╚════██║
██║ ██║╚██████╔╝██████╔╝╚██████╔╝ ██║ ██║╚██████╗███████║
╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝
One command to build an FTC project and deploy it to the robot.
pusherIf a hub is on USB it uses that and leaves your Wi-Fi alone. Otherwise it builds first, joins the robot's Wi-Fi, deploys, and puts you back on the network you started on.
Pusher Extreme reloads your code instead of installing it
2.89 seconds instead of about 39. Only your team code goes to the robot, and nothing is installed. Experimental, and under active development.
How it compares to Sloth
Sloth got here first and is the reason anyone knows this is possible. It is worth being straight about where each one is ahead.
Where Pusher Extreme is better
- Nothing is added to your robot app. No library, no runtime, no annotations,
no gradle plugin. One marked block goes into
TeamCode/build.gradleand that is the entire footprint. Sloth is a dependency that ships a runtime with it. - Stock FTC Dashboard. Sloth includes a drop-in replacement of Dashboard to
make it compatible. Pusher registers your
@Configclasses with the real one from inside the reload, so you keep the Dashboard you already have. Panels is not handled yet. - It refuses when a reload would be a lie. If anything outside team code changed, it installs instead and says which input moved. Running stale code while everything reports success is the worst thing a tool like this can do.
- It is the command you already run.
pusherdecides for itself whether to reload or install; there is no second task to remember.
Where Sloth is better
- Sloth is faster. It advertises under a second, ceiling of two. Pusher Extreme is 2.89s, of which 1.09s reaches the robot and the rest is compiling.
- Sloth is safe to deploy while an OpMode is running, because it applies the change when the OpMode ends. Pusher Extreme reloads immediately, and what that does mid-OpMode has not been established. Stop the OpMode first.
@Pinnedis finer grained. Sloth pins individual classes. Pusher keeps whole packages in the APK, which is a blunter instrument.- Sloth is an extensible runtime. Sinister classpath scanning lets libraries and your own code hook into reloads. Pusher has one internal bridge and no public extension point.
- Sloth is mature. Pusher Extreme has been measured on one project, on one hub, and is explicitly experimental.
brew install PzmuV1517/PzmuV1517/pusherOr from source:
go build -o pusher && sudo mv pusher /usr/local/bin/Requires adb and an FTC project with a Gradle wrapper.
| OS | Wi-Fi switching via | adb |
|---|---|---|
| macOS | networksetup |
brew install android-platform-tools |
| Debian/Ubuntu | nmcli (sudo apt install network-manager) |
sudo apt install adb |
| Windows | netsh + PowerShell |
Android SDK Platform-Tools |
| Command | Description |
|---|---|
pusher |
Build and deploy |
pusher connect |
Join the robot Wi-Fi and connect adb |
pusher exit |
Disconnect adb and return to your Wi-Fi |
pusher dc |
Disconnect adb only |
pusher settings |
Profiles and preferences |
pusher slim |
Shrink the APK (--undo to revert) |
pusher hwconfig |
Pull, edit and push the robot's hardware configs |
pusher doctor |
Diagnose Wi-Fi, adb and project problems |
pusher visualiser <OpMode> |
Draw the path an auto drove, coloured by speed |
pusher visualiser --file <log> |
Draw a blob follower log: asked against got, per loop |
pusher power |
Show what drew the most current on the last run |
pusher profile |
Flame chart of what ate the loop time on the last run |
pusher ip |
Where the robot is, and every port it is serving |
pusher relay setup |
Put the robot on your own Wi-Fi over a USB adapter |
pusher prepare |
Cache Gradle dependencies while online |
pusher help |
Help |
Anything that needs the robot will offer to go and get it. Run pusher power or
pusher hwconfig with nothing attached and it names the network it would join
and asks; say no and you get the error you would have got anyway. Nothing is
asked when pusher is not running in a terminal, so scripts behave as before.
The menus offer the same thing with a key, since a menu has nowhere to print a
question: where a screen needs the robot and has none, c joins and connects,
and the screen goes back for whatever it could not read.
pusher settings opens a menu covering robot profiles, which network to return
to, whether to prefer USB, slimming, delta transfer, and Gradle threads. Changes
save immediately to ~/.config/pusher/config.yaml.
Update pusher checks for a newer release and installs it. A Homebrew install
is handed to brew upgrade so the next one does not undo it; anything else
replaces its own binary, verified against the release checksums.
Pusher also looks for a newer release on its own, once a day, in the background.
When it finds one it says so with a desktop notification rather than a line in
the terminal, because the point is to reach somebody who has stopped reading it:
macOS through osascript, Linux through notify-send, Windows through a toast. A
version is announced once rather than every day until it is installed, and a
check that cannot reach GitHub says nothing at all. Turn it off with
Tell me about updates, or PUSHER_NO_NOTIFY=1 to keep the check and drop
only the notification.
If your project uses the blob library, a deploy says when a newer release of it is out, on whichever branch you follow. Twice: once at the start, where it is still cheap to stop and take it, and once at the end, where it is the last thing on screen rather than something a minute of Gradle output scrolled past. The check runs beside the deploy rather than in front of it, and a project without blob, or a machine without access to the repository, is told nothing.
Release branch in the blob menu chooses which of blob's branches this
project follows. blob publishes branch work as a labelled tag,
v1.8.0-RSTController.1, which GitHub marks as a pre-release, and the label up
to its first dot is the branch. Pusher reads that out of the tag rather than
asking which branch a release targets, because branches get deleted once merged
while the releases made from them stay.
Picking a branch moves the project onto the newest release from it, so it is one action rather than a setting that changes and a project that does not. Version then means the newest on that branch, and so does the line a deploy prints. Everything else is unchanged: same two builds, same asset names, same menu.
Turn Power monitor on in pusher settings, deploy, drive, stop the OpMode,
then look at what it recorded. Every run is its own file, so a practice session
leaves one per run, newest first:
One run each, newest first.
TeleOP 15:31:57
> TeleOP 15:24:57
TeleOP 15:19:57
CloseBlue 15:02:57
Power readings in pusher settings opens that list and shows any of them.
pusher power prints the newest without opening a menu:
TeleOP, 42s
─────────────────────────────────────────
motor peak average peak at
shooter 21.70A 6.50A 30.1s
left_front 8.40A 2.10A 12.3s
right_front 7.90A 2.00A 12.1s
intake 5.20A 1.40A 8.0s
12.00A drawn by the motors together, on average
Control_Hub (hub) 33.50A peak, 14.20A average
battery 13.40V down to 11.20V, a sag of 2.20V
Which turns "the battery keeps dying" into a name and a number.
Power readings in pusher settings lists every run on the robot and opens
any of them as a page, in the same style as the path visualiser. The graph is
meant to be worked: drag across it to zoom in like an oscilloscope, double-click
to zoom out, click a name in the legend to take that line out of the way, and
move across it to read every value at that instant. The battery graph follows
the same window, because the question after seeing a spike is always what the
battery did at that moment.
The table ranks by charge rather than peak. Peak is what trips breakers; charge is what flattens the battery, and a motor sitting at 4 A all match beats a 20 A spike that lasts a tenth of a second. Both numbers are there.
Only two things on an FTC robot report current: motors, and the hub. There is no per-servo or per-sensor current sensing in the hardware, so:
- Motors are measured individually.
- The servo bus and the I2C bus get one number each, from the hub. That is everything the hardware can say about servos and I2C sensors.
- The hub's own reading is everything plugged into it, which is why it is listed apart from the motors rather than ranked beside them.
- A Limelight cannot report current at all. It is not on a rail the hub measures, so what it draws is inside the hub's total and nowhere else. It is reported as how hard it is working, which is the nearest thing there is.
Sample rate chooses how often everything is read, from 20 ms to 500 ms. It is the entire cost of the feature, so pick it deliberately. The value is baked into the generated file, because the robot cannot read this laptop's settings, so changing it rewrites the monitor and needs a deploy.
Do not run this in an official match. A motor's current cannot be read in a bulk transfer: the bulk packet carries an over-current flag but not the value, so every reading is its own round trip over the RS485 bus. Reading eight motors ten times a second is eighty round trips a second that your robot was not making before, and they compete with the ones your OpMode makes.
Sampling runs on its own thread rather than in your loop, and at 10Hz rather than every iteration, so the cost is as small as it can be made. It is not zero. Pusher says so on every deploy while the monitor is installed, because the way this goes wrong is somebody turning it on during practice and forgetting:
[!] The power monitor is installed, so this build reads motor current
while every OpMode runs. That costs loop time: a motor's current
cannot be bulk read, so each reading is its own trip over the bus.
Turn it off in `pusher settings` before an official match.
Switching Wi-Fi to deploy is the thing everybody puts up with: the laptop leaves whatever it was on, the robot's access point has no route anywhere, and getting back is another wait at the other end.
The robot can join your network instead. Plug a USB Wi-Fi adapter into the Control Hub and:
pusher relay setup "YourWiFi" "your password"
The hub joins your network and carries on serving its own access point to the Driver Station, so the robot stays drivable throughout. Deploys, FtcDashboard, Panels and the Limelight all then work over the network you were already on.
Inspired by Dhruv, FTC 32001L, whose ADB relay bridged adb from the robot's access point to the local network with a Raspberry Pi in between. The want is theirs; this reaches it from the other end, by putting the robot on your network rather than putting a machine on the robot's.
The hub's own radio cannot join anything — it is the access point, and the SDK
has no client mode for it: RobotControllerAccessPointAssistant reports
RCWIRELESSAP and has no connect method at all. So this needs a second radio.
A Linux driver is compiled against one exact kernel and the kernel refuses
anything else, so a driver cannot be downloaded and cannot be built on the
laptop that wants it. Pusher carries six, built from
the kernel source REV publishes,
one per Control Hub OS release from 1.0.0 to 1.1.6. The driver is already in
REV's own tree at drivers/net/wireless/rockchip_wlan/rtl8822bu; it is simply
not enabled in the config they ship, so these are their source with one line
turned on, plus the TP-Link device IDs Realtek never listed.
It picks by OS release, and failing that by kernel release, which is what the
hub itself checks — these kernels are built without CONFIG_MODVERSIONS, so
vermagic is the whole test. A hub it has nothing for is told so by name, with a
link to ask.
Which adapters work is decided by that driver: RTL8822BU and RTL8812BU, the
chipset in the TP-Link Archer T4U v3 among others. pusher ip --adapters says
what your hub can see, what its kernel can drive, and what the kernel said about
whatever is plugged in.
A boot hook rejoins on every power cycle and then watches. Unplug the adapter
and plug it back in and the robot is on the network again within half a minute,
with no laptop involved — which also covers the access point restarting and the
lease expiring. pusher relay forget removes all of it and leaves the hub as it
was.
pusher settings -> Reaching the robot over your own network browses rather
than types. Look for networks near the robot scans from the hub itself,
which is the only vantage point that counts: what a laptop hears in the pit is
not what the robot hears on the field. Signal, band, and open networks are all
shown. Networks the robot has joined before are remembered, so a workshop and a
venue are a choice rather than a retype.
When the robot is already somewhere on your network, pusher finds it: where it was last, then the hub's own access point, then a sweep. A sweep only ever walks a subnet small enough to be somebody's own network, never a /8 or a /16, and something answering on the adb port is not enough to be treated as a robot — pusher asks the device what it is and leaves it alone if it turns out to be a phone.
pusher ip asks the robot where it is and then knocks on every door:
Control Hub v1.0 (192.168.1.183:5555)
─────────────────────────────────────────
addresses 192.168.1.183, 192.168.43.1
hostname android-a252101d8e716898
✓ adb 192.168.1.183:5555 what pusher deploys over
✓ Robot Controller http://192.168.1.183:8080 the hub's own manage page
✓ FtcDashboard http://192.168.1.183:8000 tuning and telemetry
✓ Panels http://192.168.1.183:8001 tuning and telemetry
✓ Panels socket 192.168.1.183:8002 what `pusher dash diff` reads
✓ Limelight http://192.168.1.183:5801 through the Panels proxy
Only what actually answered is reported, so an address printed there is one you can paste into a browser rather than one pusher believes ought to work.
The power monitor answers what the battery is going into. This answers where the time is going, which is the other half of the same question and the one you cannot guess your way to.
Turn Loop profiler on in pusher settings, deploy, run an OpMode, stop it,
then pusher profile. It draws a flame chart:
LynxModule.getBulkData PathFollower.step
Blob.update Spindexer.update
FarBlue.loop
EventLoopManager.run
all
Each bar is a method, as wide as the share of the run spent inside it and everything it called, growing upwards from the whole run at the bottom so a bar sits on top of the one that called it. The code that was actually executing ends up along the top. Click a bar to zoom into it. Your own code is drawn in blue so it stands out from the SDK and the libraries around it, and the table under the chart ranks by time spent in a method rather than under it, which is the difference between the code that was running and a list of everything that called it.
Nothing has to be instrumented, and no OpMode has to be edited. A thread wakes up every few milliseconds, asks the thread running the OpMode what it is in the middle of, and files the answer. Time is then counted in samples: a method in half of them was running for half the run.
The thread to sample is found by looking for your OpMode's own class in the
stacks rather than by name, because the SDK runs an iterative OpMode's loop()
on the event loop thread and a LinearOpMode on one of its own, and promises the
name of neither.
Profile rate chooses how often, from 2 ms to 50 ms. Like the power monitor's, the value is baked into the generated file, so changing it needs a deploy.
The page says how much of the run it actually saw. A robot too busy to let the sampler run at its period produces a profile that is correctly shaped but stands for less of the run than it looks like, and that is worth knowing rather than hiding.
Reading another thread's stack stops that thread for as long as the walk takes. At the default rate that is small, and it is not nothing, so pusher says so on every deploy while the profiler is installed:
[!] The loop profiler is installed, so this build samples the OpMode's
thread while every OpMode runs. Reading a thread's stack stops it for
as long as the walk takes, so that costs loop time.
Turn it off in `pusher settings` before an official match.
Turning it on writes one generated file into your project, under a
pusherpower package, and turning it off deletes it. Nothing else in your
project changes: no library, no dependency, and no edits to your OpModes.
It attaches itself. The file carries a @WebHandlerRegistrar method, which the
robot controller calls once at startup, and from there it listens for OpModes
starting and stopping. That is the same hook FtcDashboard uses to attach, which
is the reason to trust it. Your OpModes do not know it exists, so there is
nothing to remember before a practice run and nothing to take out afterwards
except the toggle.
Readings are held in memory and written once when the OpMode stops, to
/sdcard/FIRST/pusher-power, which is where pusher power reads them from.
pusher power --clear deletes them.
With Pusher Extreme set up, the monitor is kept in the APK rather than reloaded. It has to be: the startup hook is called when the robot controller's web server comes up, long before anything is reloaded, so a monitor that arrived in a reload would be found and never called. Pusher adds it to the keep list itself.
The robot's hardware configuration is one XML file in /sdcard/FIRST that the
Driver Station writes. pusher hwconfig brings those into your project so they
can be read, edited and committed next to the code that names the devices.
Run it on its own and it opens a menu covering all of it. Everything below is also a subcommand, for scripting or for when you know exactly what you want:
pusher hwconfig open the menu
pusher hwconfig list what the robot and the project each have
pusher hwconfig pull copy the robot's configs into configs/
pusher hwconfig view comp show what is wired where
pusher hwconfig edit comp open it in $EDITOR, check it, offer to push
pusher hwconfig diff what changed against the robot
pusher hwconfig push comp copy it back
Configurations land in configs/ at your FTC project root. Use --dir to keep
them somewhere else.
The menu's editor works on ports and devices rather than on XML, which is what lets it help:
- Device types autocomplete. Type
pinpointand it findsgoBILDAPinpoint; typegoand it lists the goBILDA parts. The list is every type the SDK ships, read out of the FTC jars. - New devices land on a free port. Pick a type and the port is filled in with the lowest one nothing is using, per bus, for I2C.
- Problems show up as you type, not after a failed push: a name that is already taken, a port that is already used, a port the hub does not have.
- Nothing is written until you save. Backing out of an edit leaves the file untouched, and the whole tree is marked with what is wrong before you push.
Reading, saving and pushing all preserve the file byte for byte apart from what you actually changed, same declaration, same indentation, same attribute order as the Driver Station writes. A rename comes out as a one-line diff.
If you would rather use your own editor, pusher hwconfig edit <name> opens
$EDITOR on the raw XML and checks it when you save.
Files move byte for byte in both directions. Pusher parses them to check and describe them, never to rewrite them.
Before pushing, each file is checked for what the robot controller would
reject: two devices sharing a name, two devices on one port, a port the hub does
not have, an Expansion Hub on the address reserved for the Control Hub. Errors
stop the push (--force overrides); anything pusher is unsure about is a
warning. Device types it does not recognise, your own OnBotJava or external
library drivers, still have their names checked but are left alone otherwise.
Pushes are checked. Every configuration is read back off the robot and compared byte for byte, and the robot's own list is checked for the name. A push that adb reported as fine but that left nothing usable behind now fails at the moment it happens rather than turning up as a configuration that never appears.
Not in the list on the Driver Station? The robot controller reads that
directory afresh whenever it is asked, but the Driver Station shows the list it
was given when it last asked. A configuration that appeared underneath it is not
there yet. Leave the config screen and open it again, or push with --restart,
which restarts the robot controller so the list is rebuilt.
Overwriting is guarded. The robot's copy of anything about to be replaced is
saved into configs/.pusher-backup/ first, because it may have been changed on
the Driver Station since you pulled it. --no-backup skips that.
Pushing does not activate. The robot controller reads a configuration when it is selected, not while it is running one, so overwriting the active file changes nothing until you re-select it on the Driver Station: Configure Robot → pick it → Activate. Pusher says so when the file you pushed is the active one.
Reading which configuration is active needs privileged adb. That works on a Control Hub; on a phone robot controller pusher says it could not tell rather than guessing.
pusher visualiser CloseBlue pulls a path trace off the robot and renders an HTML
page: the whole flow of the auto, every curve coloured by modelled speed, and a
duration estimate next to the measured time.
pusher visualiser CloseBlue # newest trace for that OpMode
pusher visualiser # newest trace on the robot
pusher visualiser --file t.json # a trace you already haveSegments are labelled with the case they came from. The blob library captures a
stack trace on each path commit and pusher maps the line number back into your
source, so it works whatever shape the auto is: state machine, inheritance chain,
poses from a constants class. Nothing to annotate.
Colour is modelled speed, not commanded power. Pusher runs a forward/backward
sweep over each curve capped by maxPower, by acceleration, and by how hard the
curve bends, so a leg that stays cold is usually cornering-limited and lowering
maxPower there costs you nothing. Tune the model to your drivetrain with
--top-speed, --accel, --decel and --lat-accel; the gap between the
estimate and the measured time tells you how far off the defaults are.
Recording requires the blob-dev artifact and BlobParams.recordTrace = true.
Competition builds of blob contain no recording code at all, so a robot you take
to a match cannot log even if the flag is set.
A path trace says where the robot went. The follower log says what the path follower asked for and what it actually got, one row per control loop, and it exists to settle one question: a robot that wobbles is either fighting its mechanics or running code that does not do what anybody believes it does, and those have completely different fixes.
Logs live in /sdcard/FIRST/blob-follower on the hub, one file per OpMode run,
named after the time it started. They are listed under pusher settings → blob
library → Follower logs, newest first, with the number of loops and the size
of each. Picking one pulls it and opens a page.
pusher visualiser --file follower-1737030000000.csv # one you already haveThe page is the run in the order the questions get asked:
- Reference against measurement, per axis. Forward, left and turn, stacked on one time axis. A gap that stays open means the loop cannot keep up, so either the constants are wrong or the closed loop time constant is asking for more than the pack supplies. A gap that oscillates means that constant is too small for the rate the loop actually ran at. Nothing else separates those two.
- The turn command against what was delivered, with the saturated loops shaded. The delivered twist is recovered by running blob's mix backwards from the four logged wheel commands; on unsaturated loops it reproduces the volts exactly, and where it does not, the mix could not deliver what was asked. A heading loop starved of volts and a badly tuned one look identical everywhere else.
- Volts per axis against the pack, with the kS feedforward shaded underneath, so a command that is the model driving is visually separate from one that is the controller correcting.
- The loop period, as a distribution rather than an average, with the fastest
tauClosedLoopthe rate can support. blob's autotune assumes 20 ms when it derives that constant, so a robot actually running at 30 ms has had its pole placed where its loop cannot follow it, and the ringing that produces looks exactly like badly chosen constants. - The four wheel commands with full scale marked, where a persistent asymmetry points at one motor rather than at the controller, and heading error over time with the settled loops marked.
Drag across any plot to zoom every plot to the same window, which is how the ringing gets looked at; double click to come back out.
The heading error is drawn CCW positive, like everything else on the page. The
column in the file is not: blob stores it mirrored, where a positive error asks
for a clockwise turn. Comparing that column against omega without negating it
makes a robot that is turning correctly look like one turning the wrong way.
Recording needs the blob-dev artifact and BlobParams.recordFollower = true.
A competition build's recorder is a stub with no file IO in it, so the directory
will not exist at all, and pusher says which of those two it is rather than
showing an empty list. The file is rewritten whole every couple of seconds while
the run is going and carries no end marker, so a log pulled mid run is complete,
valid and shorter than the run it came from; pull it again for the rest.
Put the Control Hub on 5 GHz. Hold the hub's button through power-on and release when the LED turns magenta (yellow is 2.4 GHz). Needs Control Hub OS 1.1.2+. Biggest win available, and it costs nothing.
Only changed parts are sent. On by default. The hub keeps the APK in pieces
under /data/local/tmp/pusher, which survives reboots, so later pushes transfer
only what differs, measured at 0.6 MB instead of 74 MB for a one-line change.
The rebuilt APK is checksummed on the hub before installing; anything unexpected
falls back to a full transfer.
pusher slim drops the native libraries for the CPU your hub does not have,
which is about 10 MB of a stock FTC APK. It asks the connected hub which
architecture it runs and refuses to guess, so connect the robot first. Files it
edits are backed up next to themselves; pusher slim --undo restores them.
It needs a Groovy build.common.gradle. On a project configured with the Kotlin
DSL it will not work, and is not going to: the lines it patches are written by
your team in whichever of several shapes you wrote them, so it would sit there
matching nothing while every deploy went on packaging everything. Pusher stops
rather than pretend. Pusher Extreme is different and supports both the Kotlin
DSL and Kotlin source files, because it writes one block of its own rather than
editing yours.
A deploy is two halves that behave differently: getting the bytes to the robot,
and the package manager installing them once they arrive. The install is not
just a copy. It writes the APK into /data/app, verifies the signature,
extracts the native libraries if they are compressed, and runs dexopt over
every dex file. On a stock FTC project that is tens of megabytes of writes and
tens of megabytes of dex to compile.
pusher settings -> Deploy speed has a switch for each part of that, because
what wins over USB is not what wins over the robot's 2.4 GHz hotspot:
| setting | what it does | default |
|---|---|---|
| Send only changed parts | sends only the chunks of the APK that changed | on |
| Skip install when unchanged | does nothing at all if the robot already holds this build | on |
| Stream the install | writes the APK straight into an install session instead of pushing it to a temporary file first, halving what gets written on the robot | on |
| Store native libraries uncompressed | stops the install extracting 20 MB+ of libraries, at the cost of a bigger APK. Applied by pusher slim |
off |
| Install only changed splits | when the project builds a base plus a feature module, installs only the module that changed | off |
The last two are not free. Storing the libraries makes the APK bigger, which costs transfer time, so it is a win on USB or 5 GHz and a question on 2.4 GHz. Stored entries also make the delta cache far more effective, because one changed byte in a deflate stream shifts everything after it while stored bytes do not move.
Everything falls back safely. A streaming install that the hub does not like drops to the staged one; a split install with nothing to inherit from installs the whole APK.
Do not guess which of these to turn on. Measure them.
Experimental, and under active development. It works, it is measured, and it is not finished. Read the precautions before a competition.
Pusher Extreme reloads your OpModes onto a running robot instead of installing an APK. Only your team code is sent, so the write-run-test cycle stops being dominated by waiting.
A full deploy on a Control Hub measured around 39 seconds, and every transfer trick pusher has was within noise of that, because the cost is the install and not the transfer. Pusher Extreme does not install:
| stage | best of 5 | spread |
|---|---|---|
| resolve the classpath (gradle) | 307ms | 216ms |
| compile (javac + d8) | 1.47s | 173ms |
| push and trigger | 1.09s | 112ms |
| total | 2.89s | 484ms |
204 classes, 727 KB sent. Timed from the start of the command to the robot being told to reload; the robot picks it up on its next event loop tick.
Roughly thirteen times faster than installing, and the numbers above are the whole command, not the interesting part of it. Two of the three stages never touch the robot, and resolving the classpath is not cached yet.
For honest comparison: Sloth advertises under a second, with a ceiling of two. Pusher Extreme is slower than that today. Its 1.09s is the part that reaches the robot; the rest is compiling, which those figures may not include.
- Changes survive restarts and power cycles. The reloaded code lives on the robot, not in memory.
- It refuses to reload when a reload would be a lie. If anything outside team code changed, or the robot is not running the build this project produces, it installs and says which. Reloading where an install was needed leaves a robot running stale code with everything reporting success, and that is discovered by the robot driving last week's autonomous.
- FtcDashboard keeps working. Dashboard finds
@Configclasses by scanning the APK, which reloaded code is not in. Pusher registers them from inside the reload instead, so live tuning survives. 43 classes in the project measured above. - No changes to the robot controller app. No custom classloader, no bytecode rewriting, nothing installed on the robot that is not your code. Pusher uses a path the SDK already supports.
- Nothing is triggered until it is verified. Both files are checked on the robot before anything is told to reload, because re-registration abandons everything on the first failure rather than skipping one file.
- One marked block is added to
TeamCode/build.gradle. The same menu takes it out.
- Only
org.firstinspires.ftc.teamcodeis reloaded. Everything else lives in the APK. - Hardware device drivers written in team code stay in the APK. Pusher finds them and keeps them there automatically, one file at a time, so the rest of the package still reloads. They cannot be reloaded: every reload builds a new classloader, and a driver loaded from the reload is a different class each time while the device in the hardware map was built under an earlier one. The robot would report that it cannot find its own hardware.
- Your team code is not in the APK while this is set up. That is what makes it work: a class in the APK always wins. It also means a teammate deploying from Android Studio gets a robot with no OpModes until pusher reloads them.
- Do not reload while an OpMode is running. A reload rebuilds the classloader under a live app, and what the SDK does with that mid-OpMode has not been established. Stop the OpMode first.
- Code can compile and still not reload. Installing or changing a library, or changing anything outside team code, needs a full install. Pusher detects that and installs instead, but it cannot detect a library that reaches back into your classes: that fails at runtime, not at compile time.
- Deleting a whole
@Configclass leaves its entry in the dashboard until the robot app restarts. Adding classes, and adding or removing fields, are handled. - OnBotJava cannot be used at the same time. Both use the same mechanism to say where classes live, and whichever ran last wins.
pusher settings -> Pusher Extreme -> Set up this project, then turn on
Use it when deploying. Deploy once to install an APK without team code in it;
every deploy after that reloads.
The same menu undoes it. Deploy once afterwards so the robot gets an APK with your team code back in it.
Turning Use it when deploying off does not put team code back in the APK.
Only undoing the setup does: the exclusion lives in your gradle file, and that
is what the build obeys. Pusher deploys correctly either way, reloading after
the install even when the setting is off, and says so when it does. The menu
shows that state as off (project still set up).
Delivering classes and having them registered are different things, and until now pusher only knew about the first. A reload that pushed every file and registered none of them printed the same success as one that worked.
So after each reload pusher asks the robot what it actually has. Both
FtcDashboard and Panels report the robot's own OpMode list, straight out of
RegisteredOpModes, which is the same list the Driver Station shows. Compared
against the OpModes your source declares, that turns a silent disappearance
into a line of output:
[!] the robot registered 32 of 33 OpModes. Missing: RST TUNING
They compiled and reached the robot, so this is registration rather
than delivery. Usually the app never rescanned: restart the robot app,
since the SDK attaches its reload watch when it starts.
Both dashboards are asked, because a team runs one or the other and asking the wrong one looks exactly like a robot that registered nothing. When neither answers, the reload says so rather than staying quiet:
[!] could not check that the robot registered the 33 OpModes this reload sent.
No dashboard answered, and a reload can deliver every class and still
register none of them. Look at the Driver Station's list before you rely
on this build, or add FtcDashboard or Panels so pusher can look for you.
That used to be silence, on the grounds that a dashboard is not a requirement. The reasoning missed the point of the check: it exists for the deploy that did not work, and the one team it happened to ran Panels, so pusher had nothing to say while their Driver Station listed nothing at all.
Sloth reloads team code onto the robot, which is what Pusher Extreme does, and the two cannot share a robot. Both install a class loader and both leave a copy of team code on the SD card, so the robot ends up holding two and loading whichever it reaches first.
Confirmed from a team's robot log: Sloth's loader picked up the dex pusher had left in the OnBotJava jars directory nine hours earlier, and every one of their fifteen classes failed to define, because the copy pusher ships carries team code alone and the library those classes extend was not reachable from that loader. The Driver Station listed no OpModes at all, and it survived uninstalling pusher, the broken copy being on the robot rather than the laptop.
So setup is refused on a project that has Sloth in it, rather than warned about.
pusher doctor says so too, for a project that was set up before this existed.
Both kinds. A module configured with build.gradle.kts gets its exclusion
written in that dialect, and .kt source files are compiled and reloaded
alongside .java ones.
The compiler is the one your project already builds with. Pusher reads the
Kotlin version off the kotlin-stdlib on your compile classpath and takes the
matching kotlin-compiler-embeddable out of the Gradle cache, where the Kotlin
plugin put it. Nothing is downloaded, and a project that has never been built is
told to build once rather than guessed at. Kotlin is compiled first, with your
Java files passed alongside for their signatures, then javac compiles the Java
against what came out, so the two halves can refer to each other in both
directions. Measured on a real Kotlin project: 7 files, 2.0 seconds to a dex.
The exclusion has a second half for Kotlin, and it is the half worth knowing
about. Excluding from the android source set does nothing to .kt files,
because the Kotlin plugin compiles from a source set of its own. Without the
extra block, team Kotlin stayed in the APK while also being reloaded, and
parent-first delegation means the packaged copy is the one that runs: the reload
would report success and change nothing. Measured before the fix, six of six
Kotlin classes survived an exclusion that reported success.
@Config classes written as Kotlin objects are bridged like any other, named
by their declaration rather than their file, since Kotlin does not require the
two to match.
The FTC SDK already loads classes from outside the APK, and already watches a file to know when to rescan. Pusher compiles your team code on your laptop, puts it where the SDK reads from, and touches that file. The SDK throws away its classloader, builds a new one and rediscovers your OpModes through the same path it uses for everything else.
Checked against the libraries in a real project, reading the full artifacts rather than their API jars: pedro, ftclib, EasyOpenCV and blob do not go looking for classes at all, so they need nothing.
Two do. FtcDashboard scans the APK with getPackageCodePath, and is handled:
pusher registers your @Config classes with it from inside the reload.
Panels scans the same way through its own ClassFinder, and cannot be
handled, so anything Panels discovers by scanning will not see reloaded
classes. Not for want of trying: its only public way in is
PanelsConfigurables.refreshClass(Object), which takes the object's class name
and hands it to Class.forName(String). That resolves against the caller's
loader, which is the APK's, and a reloaded class is not in it. The Class object
that would have worked is discarded on the way. Panels used directly from your
own code is unaffected, and pusher dash diff reads Panels either way.
A library that scans and is not handled can have its package kept in the APK instead.
Pusher needs to know which network to put you back on. How it works that out
differs by platform. pusher doctor shows which backend is in use.
macOS hides the current Wi-Fi name from command-line tools, and your terminal cannot be added to Location Services by hand: macOS only lists apps that have already asked, and command-line tools have not been able to ask since macOS 13. Pusher instead reads the saved-network list, which macOS keeps in most-recently-joined order, so the network you are on is the first entry. No permission needed, recomputed every run.
Debian/Ubuntu is the easiest case. NetworkManager reports the SSID freely and records a real last-connected timestamp per saved network, so returning you to the right network is stored fact rather than inference. Machines managed by ifupdown or systemd-networkd instead of NetworkManager cannot switch networks; connect to the robot yourself and pusher will deploy over that connection.
Windows reports the SSID freely, so a normal push is fine. But it keeps no
record of when each saved network was last used, so a standalone pusher exit
cannot tell where you came from. Set the network to return to in
pusher settings → Home Wi-Fi network. Note also that netsh cannot take a
password inline, so pusher generates a WPA2-PSK profile and imports it before
connecting.
If the network is ever guessed wrong on any platform, pin it in
pusher settings → Home Wi-Fi network, which always wins.
Could not find network 14270-RC does not mean the hub is off. It means the
network was not in the driver's list of what is nearby at the moment the join
asked, which is the normal state for a hub that was switched on a minute ago:
the list is only refreshed when something scans, and nothing had.
So pusher scans. It starts looking for the hub's network in the background before the build begins, which costs nothing because the build was going to take that long anyway, and by the time the join happens the hub is in the list. It stops scanning before the join and stays stopped for the deploy, because a scan hops the radio across every channel and would cost the transfer several seconds of throughput.
The payoff is that a failed join now comes with the half the OS cannot tell you:
[!] 14270-RC is not broadcasting.
Trying anyway, but check the hub is powered on and nearby.
Error: failed to join "14270-RC": Could not find network 14270-RC.
pusher never saw that network while it was looking, so the hub is
probably switched off, still starting up, or out of range
and when the hub was seen, pusher says so and retries the join once, which is usually all a hub that is sitting right there needs.
pusher doctor answers the same question on demand, under Robot Wi-Fi.
On macOS this works despite Location Services, which is the part worth knowing:
the name filter is applied inside the OS, before the redaction that hides
network names from command-line tools. Pusher never learns the names of the
networks around you, only whether the one it asked about is among them. The
pace is set by the radio, which takes about four seconds per scan and refuses
another for ten. Linux asks nmcli for a rescan; Windows reads the list netsh
keeps. Set PUSHER_NO_SCAN=1 to turn it off everywhere.
Once a day, pusher reports that it ran. Three things: a random ID it generated
for itself the first time it started, the pusher version, and your operating
system and CPU (darwin/arm64). That is the whole payload, and there is a test
that fails if anything else is ever added to it.
It does not send your team number, your project, your file paths, your robot configuration, your Wi-Fi names, your username, your hostname, or your IP beyond the fact that a connection was made. The ID is random, not derived from your hardware, so it identifies a copy of pusher and nothing else. The server hashes it with a secret before storing it, so even the database cannot be matched back to the ID sitting in your config file.
It exists to answer one question: how many teams use this, and which versions are they on. That is what makes it safe to change something, rather than guessing about who would notice.
The request runs in the background, never blocks a deploy and never fails one. If it cannot reach the server it gives up in three seconds and says nothing.
To turn it off:
PUSHER_NO_TELEMETRY=1 pusher # once
pusher settings → Count this device # for goodWith it off, nothing is sent and no ID is generated at all. Builds with no
counter configured send nothing regardless of the setting; pusher settings
says "not set up" when that is the case.
The server is a Cloudflare Worker in worker/, about a hundred lines
of it, including what it stores and what it refuses.
Made with love by Andrei "PzmuV1517" Banu
From Team #14270
MIT licensed.