diff --git a/astro.config.mjs b/astro.config.mjs
index 607fc48..9f5ca58 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -60,7 +60,20 @@ export default defineConfig({
label: "Robot Module",
link: "/robot/",
icon: "puzzle",
- items: [{ autogenerate: { directory: "robot" } }],
+ items: [
+ { slug: "robot" },
+ { slug: "robot/commands" },
+ { slug: "robot/mechanisms" },
+ { slug: "robot/nextrobot" },
+ { slug: "robot/nextopmode" },
+ { slug: "robot/triggers" },
+ { slug: "robot/drive-commands" },
+ { slug: "robot/project-structure" },
+ {
+ label: "Advanced",
+ items: [{ autogenerate: { directory: "robot/advanced" } }],
+ },
+ ],
},
{
label: "Hardware Module",
diff --git a/src/content/docs/robot/advanced/coroutine-commands.mdx b/src/content/docs/robot/advanced/coroutine-commands.mdx
new file mode 100644
index 0000000..d530287
--- /dev/null
+++ b/src/content/docs/robot/advanced/coroutine-commands.mdx
@@ -0,0 +1,174 @@
+---
+title: Coroutine Commands
+description: Writing a command as sequential Kotlin instead of a start/execute/done state machine.
+sidebar:
+ order: 1
+---
+
+import { Aside } from "@astrojs/starlight/components";
+
+A normal [command](/robot/commands/) is a state machine: `start()`, then `execute()` once per loop,
+then `done()` decides when it's over.
+Anything sequential has to be split across those calls, or built out of nested groups.
+
+A coroutine command lets you write the same thing top to bottom:
+
+```kotlin
+val scorePiece = command {
+ await(arm.toHigh())
+ wait(0.5)
+ await(claw.open())
+ fork(lights.flash())
+ await(arm.toRest())
+}
+```
+
+The body runs a slice per loop and suspends at each `await`, `wait`, or `yield`,
+so it never blocks the OpMode loop.
+
+
+
+## Creating one
+
+`command { }` is a top-level function, and `Mechanism.coroutine { }` is the same thing with
+`requiring(this)` already applied:
+
+```kotlin
+class Arm : Mechanism {
+ val motor = NextMotor("armMotor")
+
+ fun scorePiece() = coroutine {
+ await(toHigh())
+ wait(0.5)
+ await(toRest())
+ }
+}
+```
+
+Both return a `CoroutineCommandBuilder`, which is itself a `Command`.
+You can schedule it, bind it to a [trigger](/robot/triggers/), or drop it into an Ivy group without
+calling `build()`:
+
+```kotlin
+command {
+ await(arm.toHigh())
+ await(claw.open())
+}.requiring(arm, claw).setPriority(1).schedule()
+```
+
+`requiring`, `setPriority`, `setInterruptedBehavior`, `setConflictBehavior`, and
+`setBlockedBehavior` work the same as on Ivy's `CommandBuilder`.
+A builder makes a fresh coroutine every time it starts, so one builder can be scheduled repeatedly.
+
+## What the body can do
+
+Inside the braces you're in a `CommandScope`:
+
+| Function | Suspends until... |
+| --------------------------- | ------------------------------------------------------- |
+| `yield()` | the next loop iteration |
+| `wait(seconds)` | at least that many seconds have passed |
+| `waitUntil { condition }` | the condition is true, checked once per loop |
+| `await(command)` | that command is done |
+| `awaitAll(vararg commands)` | every one of those commands is done |
+| `awaitAny(vararg commands)` | any one of them is done; returns the winner |
+| `fork(command)` | nothing — it starts the command and returns immediately |
+
+`awaitAll` and `awaitAny` also accept a `Collection`.
+`awaitAny` ends the winner naturally and interrupts the rest, and throws if you pass it no commands.
+
+### Doing work every loop
+
+The other functions all wait for something. `yield()` is the one you use when the body itself has
+work to do on each iteration: write a loop, do one iteration's worth of work, and `yield()` at the
+bottom.
+
+```kotlin
+fun ramp() = coroutine {
+ var power = 0.0
+ while (power < 1.0) {
+ power += 0.02
+ motor.throttle = power
+ yield()
+ }
+ motor.throttle = 1.0
+}
+```
+
+Each `yield()` gives the loop back to the OpMode, and the next `execute()` picks up on the line
+after it, so this ramp takes fifty loop iterations rather than fifty iterations of a `while` loop
+in one.
+
+The same shape works for anything that has to keep running while it waits.
+Here the arm holds its target with a controller until it's close enough, then stops:
+
+```kotlin
+fun goTo(target: Double) = coroutine {
+ controller.target = target
+ while (abs(motor.currentPosition - target) > 10.0) {
+ motor.throttle = controller.calculate(motor.currentPosition)
+ yield()
+ }
+ motor.throttle = 0.0
+}
+```
+
+## Inline commands and requirements
+
+Commands you `await` or `fork` are driven by the coroutine, not by the `Scheduler`.
+They never reach the scheduler, so **their requirements are not checked for conflicts**.
+List every mechanism the body touches on the enclosing command:
+
+```kotlin
+command {
+ await(arm.toHigh())
+ await(claw.open())
+}.requiring(arm, claw)
+```
+
+To hand a command to the scheduler instead, so it competes for requirements normally,
+call `schedule()` on it rather than `await`ing it.
+
+Forked commands keep running after the line that started them.
+The coroutine command isn't done until the body has returned _and_ every forked command has finished.
+
+## Cancellation
+
+Interrupting a coroutine command resumes the body with a `CancellationException`.
+That means `finally` blocks run, and any command you were `await`ing or had `fork`ed is ended with
+`INTERRUPTED`:
+
+```kotlin
+fun intakeUntilLoaded() = coroutine {
+ try {
+ fork(run())
+ waitUntil { sensor.isWithinDistance(2.0) }
+ } finally {
+ motor.throttle = 0.0
+ }
+}
+```
+
+If the command's `InterruptedBehavior` is `SUSPEND`, the body isn't unwound.
+The continuation is kept, and the coroutine picks up where it left off when the scheduler resumes it.
+
+## Rules of the body
+
+Everything between two suspension points runs in a single loop iteration, so the usual rule applies:
+don't write anything blocking.
+A loop with no suspension point in it hangs the robot.
+
+```kotlin
+// hangs: nothing yields
+while (!sensor.isWithinDistance(2.0)) { }
+
+// correct
+waitUntil { sensor.isWithinDistance(2.0) }
+```
+
+`Thread.sleep` has the same problem.
+Use `wait(seconds)`.
diff --git a/src/content/docs/robot/commands.mdx b/src/content/docs/robot/commands.mdx
index 4feaa0f..9f529f5 100644
--- a/src/content/docs/robot/commands.mdx
+++ b/src/content/docs/robot/commands.mdx
@@ -5,7 +5,7 @@ sidebar:
order: 1
---
-import { Tabs, TabItem } from "@astrojs/starlight/components";
+import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
NextFTC v2 has no command framework of its own.
The `robot` module uses [Ivy](https://pedropathing.com/docs/ivy),
@@ -57,6 +57,14 @@ so something has to cancel it.
That makes `infinite` the right choice for anything fed by gamepad input,
where the value changes every loop.
+
+
## Starting, checking, and cancelling
Three `Command` methods from Ivy come up outside of triggers:
diff --git a/src/content/docs/robot/mechanisms.mdx b/src/content/docs/robot/mechanisms.mdx
index 57f609d..e25abbf 100644
--- a/src/content/docs/robot/mechanisms.mdx
+++ b/src/content/docs/robot/mechanisms.mdx
@@ -20,6 +20,7 @@ interface Mechanism {
val defaultCommand: Command get() = infinite {}
fun instant(action: Runnable): CommandBuilder
fun infinite(action: Runnable): CommandBuilder
+ fun coroutine(body: suspend CommandScope.() -> Unit): CoroutineCommandBuilder
}
```
@@ -52,6 +53,10 @@ Two commands can never drive the same hardware at the same time.
`instant` runs its action once.
`infinite` re-runs its action every loop until something cancels it.
+Kotlin has a third: `coroutine { }` builds a
+[coroutine command](/robot/advanced/coroutine-commands/) requiring this mechanism,
+for anything with steps in it.
+
## `defaultCommand`
The command that runs whenever nothing else has claimed the mechanism.