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.