Camera example
A camera is a controller over hardware: every operation takes time, the device
can fail on its own, and it has to be closed on purpose. This page builds that
controller one decision at a time — state rules, queue policies, the cleanup of
the device. It ends on the code of the runnable example/
package: the last fragment of each method below is its code in
example/lib/src/camera_controller.dart, comments aside.
The states:
sealed class CameraState { const CameraState();}
sealed class NotDisposed extends CameraState { const NotDisposed();}
final class Initial extends NotDisposed { const Initial();}
final class Preparing extends NotDisposed { const Preparing();}
final class Ready extends NotDisposed { final double zoom; final Point<double>? focusPoint; final bool paused;
const Ready({this.zoom = 1, this.focusPoint, this.paused = false});
Ready copyWith({double? zoom, Point<double>? focusPoint, bool? paused}) => Ready( zoom: zoom ?? this.zoom, focusPoint: focusPoint ?? this.focusPoint, paused: paused ?? this.paused, );}
final class Broken extends NotDisposed { final Object error;
const Broken(this.error);}
final class Disposed extends CameraState { const Disposed();}NotDisposed groups every state in which the hardware can still be talked to,
and Disposed is the one where it cannot. Broken is where a failure of the
hardware lands; it is not called Failed, which is the name of an outcome. In
the example every state also prints itself, and that text is what the journals
below show; two Ready states with the same fields are equal there as well.
A null passed to copyWith keeps the old value, so copyWith cannot clear
focusPoint, where null means automatic focus. The example’s
resetFocusPoint publishes a fresh Ready that keeps the current zoom.
The hardware is FakeCameraHardware from the example, and the controller keeps
it in its field hw. Every operation takes ten milliseconds and a capture
thirty, and a capture returns a Photo. failures makes a named operation
fail after its delay, fail reports a failure from outside any job by calling
onError, the callback the controller sets, and log records where each
operation begins and ends.
The journals below are what the example’s observer prints for this code. They
show a job started, finished with its outcome or dropped before it
started, an error it reported, a log line, every change of state:, and
closed once the controller has closed. A job goes by its key, a value of the
example’s CameraKey enum, followed by its describe text where it has one:
[setZoom: zoom: 2.0]. A child job’s lines begin with >.
Opening the camera
Section titled “Opening the camera”init publishes Preparing, opens the hardware and publishes Ready. It may
start only from Initial.
The first attempt
Section titled “The first attempt”// The first attempt: the first emit leaves the working type.Job<void> init() => enqueue<Initial, void>( key: CameraKey.init, policy: Policy.droppable, (ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready()); },);[init] startedstate: Preparing()[init] finished Cancelled(rules: is not Initial)The working type is the state the job’s rules require for as long as it runs,
and enqueue<Initial, void> requires Initial. The body’s first emit leaves
it, the join on the next line is a checkpoint that finds the state outside
Initial, and the job ends Cancelled before hw.open is ever called: the
hardware log stays empty. The controller is left in Preparing, and the next
init() is dropped at its start for the same reason — the camera can no longer
be opened at all.
A working type wide enough
Section titled “A working type wide enough”Job<void> init() => enqueue<NotDisposed, void>( key: CameraKey.init, policy: Policy.droppable, canStart: (state) => state is Initial, (ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready()); },);NotDisposed is what the body needs for all its length: Preparing and
Ready both belong to it, and the emits stay inside the rules. Where the job
may start is a separate question, and canStart answers it. Without it, a
second init() on a camera that is already open runs again and opens the
hardware a second time. With it, the call ends before it starts:
[init] dropped Cancelled(rules: canStart)A second init() made while the first one still runs never gets that far:
Policy.droppable hands it the job already in flight, and the hardware is
opened once.
An opening that fails
Section titled “An opening that fails”The hardware can refuse to open while another app holds the camera, and a job can be cancelled while it opens. Either way the controller has to end in a state the camera can be opened from later.
The first attempt
Section titled “The first attempt”// The first attempt: a cancellation leaves the state at Preparing.(ctx) async { ctx.emit(const Preparing()); try { await ctx.join(hw.open); } on Object catch (error) { ctx.emit(Broken(error)); rethrow; } ctx.emit(const Ready());},A failure lands where it should: the catch publishes Broken, and the job ends
Failed. A cancellation passes through the same catch, and there the emit
does not publish. On a job that is already cancelled it is a checkpoint, and it
throws Cancelled instead:
[init] startedstate: Preparing()[init] finished Cancelled(manual)join has waited for the opening, so the hardware is open, and the state says
it is still opening. Neither way of opening the camera starts from Preparing:
init wants Initial, and reopen, the example’s way back, wants Ready or
Broken. It is the trap of
State after failure or cancellation
on the state page, with a device behind the spinner.
The handlers of enqueue
Section titled “The handlers of enqueue”Job<void> init() => enqueue<NotDisposed, void>( key: CameraKey.init, policy: Policy.droppable, canStart: (state) => state is Initial, ifFailed: (state, error, stackTrace) => Broken(error), ifCancelled: (state, cancelled) => Broken(cancelled), (ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready()); },);The handlers compute the state after the body is over, so a cancellation
reaches its own handler instead of a refused emit. A failure and a
cancellation both land in Broken, and reopen starts from there:
[init] startedstate: Preparing()[init] error Bad state: camera in usestate: Broken(Bad state: camera in use)[init] finished Failed(Bad state: camera in use)[init] startedstate: Preparing()state: Broken(Cancelled(manual))[init] finished Cancelled(manual)A job dropped before its start reaches neither handler, so the second init()
of the section above still ends in its one dropped line. reopen has the
same two handlers.
A failure reported while opening
Section titled “A failure reported while opening”The hardware reports failures on its own as well, and a report can come while
the camera opens. The controller turns it into Broken, and the opening has to
leave it there: the camera it was opening is broken.
The first attempt
Section titled “The first attempt”// The first attempt: Ready is published over a reported failure.(ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready());},This is the body of init under
The handlers of enqueue, and the hardware reports a
failure while hw.open runs:
[init] startedstate: Preparing()state: Broken(Bad state: cable pulled)state: Ready(zoom: 1.0, focusPoint: null, paused: false)[init] finished Done(null)Broken is a NotDisposed as well, so no rule of the job refuses it. The wait
returns as usual, the body publishes Ready over the report, and the job ends
Done, which reaches neither handler. The camera says it is ready after its
hardware said it is not. reopen does the same with a failure reported while
it opens the hardware or restores the zoom.
keepWhile
Section titled “keepWhile”Job<void> init() => enqueue<NotDisposed, void>( key: CameraKey.init, policy: Policy.droppable, canStart: (state) => state is Initial, keepWhile: (state) => state is! Broken, ifFailed: (state, error, stackTrace) => Broken(error), ifCancelled: (state, cancelled) => Broken(cancelled), (ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready()); },);keepWhile refuses what the working type lets by: an opening does not go on in
Broken. The report writes it with externalSetState, the rule refuses it,
and the job’s rules cancel the job. join stays with the hardware until it has
opened, then throws Cancelled instead of returning, so the emit is never
reached:
[init] startedstate: Preparing()state: Broken(Bad state: cable pulled)[init] finished Cancelled(rules: keepWhile)A job its rules cancelled leaves the state as it is, and the handlers are
disabled at the report itself. A cancellation or a failure of hw.open that
comes after it reaches neither of them, and the reported failure stays. It is
the rule that
Preserving an incompatible external state
on the state page gives a load whose connection drops.
reopen starts from Broken, and a failure reported while it closes the
hardware is the one it is there to undo. So its rule holds from the Preparing
it writes. The body raises the flag the rule reads right before that emit:
the emit asks none of the job’s rules, and a report made while Preparing is
delivered is refused as well:
Job<void> reopen() { var opening = false; return enqueue<NotDisposed, void>( key: CameraKey.reopen, policy: Policy.droppable, canStart: (state) => state is Ready || state is Broken, keepWhile: (state) => !opening || state is! Broken, ifFailed: (state, error, stackTrace) => Broken(error), ifCancelled: (state, cancelled) => Broken(cancelled), (ctx) async { final zoom = switch (ctx.state) { Ready(:final zoom) => zoom, _ => 1.0, }; await ctx.run(_closeCameraJob()); opening = true; ctx.emit(const Preparing()); await ctx.join(hw.open); await ctx.join(() => hw.setZoom(zoom)); ctx.emit(Ready(zoom: zoom)); }, );}A failure reported while it opens the hardware or restores the zoom ends it the
way it ends init. A failure reported while it closes the hardware lets it go
on. A reopen() called after a report gets a job of its own: the one the
report cancelled is still unwinding, and droppable does not hand it out.
Only the last zoom
Section titled “Only the last zoom”A pinch asks for a zoom on every frame, and only the last request matters.
The first attempt
Section titled “The first attempt”// The first attempt: every zoom reaches the lens.Job<void> setZoom(double zoom) => enqueue<Ready, void>( key: CameraKey.setZoom, describe: () => 'zoom: $zoom', (ctx) async { await ctx.join(() => hw.setZoom(zoom)); ctx.emit(ctx.state.copyWith(zoom: zoom)); },);Three calls in one turn, setZoom(2), setZoom(3) and setZoom(4), end on
Ready(zoom: 4.0, focusPoint: null, paused: false), and so does the version
below: by the state the two cannot be told apart. The hardware can. Every
request waits in the queue and reaches the lens:
zoom 2.0: beginzoom 2.0: endzoom 3.0: beginzoom 3.0: endzoom 4.0: beginzoom 4.0: endPolicy.keepLatest
Section titled “Policy.keepLatest”Job<void> setZoom(double zoom) => enqueue<Ready, void>( key: CameraKey.setZoom, policy: Policy.keepLatest, describe: () => 'zoom: $zoom', canStart: (state) => !state.paused, (ctx) async { await ctx.join(() => hw.setZoom(zoom)); ctx.emit(ctx.state.copyWith(zoom: zoom)); },);keepLatest queues the new job and then drops the queued one with the same
key, so the same three calls reach the hardware once:
[setZoom: zoom: 2.0] dropped Cancelled(replaced)[setZoom: zoom: 3.0] dropped Cancelled(replaced)[setZoom: zoom: 4.0] startedstate: Ready(zoom: 4.0, focusPoint: null, paused: false)[setZoom: zoom: 4.0] finished Done(null)The job that is already running is left to finish. When setZoom(3) and
setZoom(4) arrive while the zoom to 2 is in flight, the lens still goes to 2,
the request for 3 is dropped, and then it goes to 4. canStart keeps a paused
camera from taking the command at all.
Commands that arrive during a shot
Section titled “Commands that arrive during a shot”Job<Photo> takePhoto() => enqueue<Ready, Photo>( key: CameraKey.takePhoto, policy: Policy.droppable, canStart: (state) => !state.paused, (ctx) async { final photo = await ctx.join(hw.capture); queue.clear(); ctx.log('captured $photo'); return photo; },);A capture takes three times as long as the other operations, and the commands
asked for meanwhile wait in the queue. They have nothing to apply to: the frame
stays fixed while the capture runs, and after it the user is shown the photo.
So once the photo is taken, queue.clear() drops them. A zoom requested five
milliseconds into the capture:
[takePhoto] started[setZoom: zoom: 3.0] dropped Cancelled(manual)[takePhoto] log captured Photo#1[takePhoto] finished Done(Photo#1)Without the clear that zoom runs once the shot is over. queue.clear() leaves
the running job alone, and the running job here is the shot itself. It also
leaves the jobs that are not cancellable, and this controller queues only one
kind of those: a disposal.
Disposing of the camera
Section titled “Disposing of the camera”dispose() closes the hardware and publishes Disposed, whatever the camera
was doing when it was called.
The first attempt
Section titled “The first attempt”// The first attempt: the disposal waits at the end of the queue.Job<void> dispose() => enqueue<CameraState, void>( key: CameraKey.dispose, policy: Policy.droppable, cancellable: false, canStart: (state) => state is! Disposed, (ctx) async { if (ctx.state is! Initial) { await ctx.run(_closeCameraJob()); } ctx.emit(const Disposed()); },);
SoloJob<void> _closeCameraJob() => job<NotDisposed, void>( key: CameraKey.closeCamera, cancellable: false, (ctx) async => hw.close(),);Called with a zoom in flight and a shot waiting behind it, this disposal takes its place at the end of the queue:
[setZoom: zoom: 2.0] startedstate: Ready(zoom: 2.0, focusPoint: null, paused: false)[setZoom: zoom: 2.0] finished Done(null)[takePhoto] started[takePhoto] log captured Photo#1[takePhoto] finished Done(Photo#1)[dispose] started> [closeCamera] started> [closeCamera] finished Done(null)state: Disposed()[dispose] finished Done(null)Everything in front of it still runs, and the camera takes a photo after it was
told to shut down. Its cancellable: false is right: the job may not be
cancelled once started, and neither may its child that closes the hardware, so
the device is never left half closed.
The second attempt
Section titled “The second attempt”// The second attempt: a later dispose() reports a cancellation.Job<void> dispose() { queue.clear(); current?.cancel(); return enqueue<CameraState, void>( key: CameraKey.dispose, policy: Policy.droppable, cancellable: false, canStart: (state) => state is! Disposed, (ctx) async { if (ctx.state is! Initial) { await ctx.run(_closeCameraJob()); } ctx.emit(const Disposed()); }, );}[setZoom: zoom: 2.0] started[takePhoto] dropped Cancelled(manual)[setZoom: zoom: 2.0] finished Cancelled(manual)[dispose] started> [closeCamera] started> [closeCamera] finished Done(null)state: Disposed()[dispose] finished Done(null)queue.clear() drops what has not started, and the shot never reaches the
hardware. current?.cancel() asks the running zoom to stop, and the lens moves
to 2 all the same: the zoom waits with join, which lets the job leave only
once the operation it started is over. The table at the top of the page
Cancellation has each waiting method. What the cancellation
saves is the rest of the body: the zoom publishes no state for a camera about
to close, and the disposal starts as soon as the hardware is free.
The fault is in a call made after the disposal is over. It queues a job of its
own, and canStart drops that job before it starts:
[dispose] dropped Cancelled(rules: canStart)The camera is disposed, and its caller is told the disposal was cancelled.
Checking in the body
Section titled “Checking in the body”Job<void> dispose() { queue.clear(); current?.cancel(); return enqueue<CameraState, void>( key: CameraKey.dispose, policy: Policy.droppable, cancellable: false, (ctx) async { if (ctx.state is Disposed) { return; } if (ctx.state is! Initial) { await ctx.run(_closeCameraJob()); } ctx.emit(const Disposed()); }, );}[dispose] started[dispose] finished Done(null)The same call now starts, finds the camera disposed and ends Done; the
hardware log stays empty. canStart is for a job that must not run in some
state, and a disposal of a disposed camera may run: it only has nothing to do.
The clear is not forced. A disposal that is still in the queue survives it,
because it is not cancellable, and Policy.droppable hands it to the second
call. Two dispose() calls in a row get the same job, and both callers see
Done. queue.clear(force: true) would drop that job instead, and its caller
would get Cancelled(manual) for a camera that was disposed after all.
Closing the controller
Section titled “Closing the controller”After the disposal the controller itself is released with close().
The first attempt
Section titled “The first attempt”// The first attempt: close() drops the queued disposal.camera.dispose();await camera.close();[dispose] dropped Cancelled(closed)closedclose() goes by where a job is, not by its flag. It waits for the running
job, and a job that is not cancellable runs to its end. A job still in the
queue never starts. close() ends it with Cancelled(closed), cancellable or
not, and whoever awaits it gets that outcome at once, as
Cancelling and closing a controller
on the cancellation page describes. dispose() queued its job and close()
came in the same turn, so the disposal ended in the queue. The hardware log has
no close in it. The camera stays open, the state still says Ready, and the
controller that could close the camera is closed itself.
The second attempt
Section titled “The second attempt”// The second attempt: a failed disposal goes to the zone.camera.dispose();await camera.close(mode: SoloCloseMode.drain);SoloCloseMode.drain runs the queue before closing, and the disposal with it:
the hardware closes and the state becomes Disposed. The fault shows when the
hardware fails to close:
[dispose] started> [closeCamera] started> [closeCamera] error Bad state: close timed out> [closeCamera] finished Failed(Bad state: close timed out)[dispose] error Bad state: close timed out[dispose] finished Failed(Bad state: close timed out)closedThe disposal failed and the camera is still open, but the controller closed
right after it, and a second dispose() comes back Cancelled(closed). Nobody
reads the outcome of the first one, and no Solo.unansweredHandler is set, so
its failure goes to the zone as an unhandled error, the way
Handled and unhandled failures on
the errors page describes. The failure arrives where nothing can act on it.
Awaiting the disposal
Section titled “Awaiting the disposal”final camera = CameraController(FakeCameraHardware());await camera.init().value;
switch (await camera.dispose().done) { case Done(): print('disposed'); await camera.close(); case Cancelled(:final reason): print('cancelled: $reason'); case Failed(:final error): print('failed: $error');}close() stands in the Done case: the disposal is over before it is called,
and close() finds nothing to cancel.
Failed is the case that needs handling: a close that fails is not a disposal.
ctx.run throws what the child threw, the body never reaches
emit(Disposed()), and the state stays where it was. The code above leaves the
controller open in that case, so a second dispose() can try again.
Cancelled does not come back in this code. A disposal that has started turns
every cancellation down; a queued one is taken out only by close() or by a
forced removal such as queue.clear(force: true), and nothing here calls
either before the outcome arrives. The switch names the case because an outcome
is always one of the three. It does come back when close() gets there first,
as in the first attempt, or when the controller was closed before dispose()
was called.
A failure after the disposal
Section titled “A failure after the disposal”The hardware can report a failure after the disposal too, and the controller
turns every report into Broken. Once the camera is disposed, a report must
change nothing: Disposed is the state the controller ends on.
The first attempt
Section titled “The first attempt”// The first attempt: the listener outlives the disposal.CameraController(this.hw) : super(const Initial()) { hw.onError = (error) => externalSetState(Broken(error));}The listener is set and never taken off. The camera is disposed and its
controller closed as in Awaiting the disposal, and
the hardware reports a failure in between, after Disposed and before
close():
[dispose] started> [closeCamera] started> [closeCamera] finished Done(null)state: Disposed()[dispose] finished Done(null)state: Broken(Bad state: cable pulled)The disposal decided the final state, and the next report of the hardware
replaced it. After close() the same report does not change the state: the
state of a closed controller is final, so externalSetState throws a
StateError, and it throws into the hardware’s own callback.
The second attempt
Section titled “The second attempt”// The second attempt: a report before close() still replaces Disposed.@overridevoid onClose() => hw.onError = null;onClose is where externalSetState on the state
page stops a source: the engine calls it once, when the last job is over and
the controller is about to finish closing. A report that comes after close()
now finds no listener, and nothing throws. The report in the journal above came
before close(), and this version prints the same journal: the camera is done
with its hardware when it is disposed, and the controller closes after that.
Detaching the source first
Section titled “Detaching the source first”Job<void> dispose() { hw.onError = null; queue.clear(); current?.cancel(); return enqueue<CameraState, void>( key: CameraKey.dispose, policy: Policy.droppable, cancellable: false, (ctx) async { if (ctx.state is Disposed) { return; } if (ctx.state is! Initial) { await ctx.run(_closeCameraJob()); } ctx.emit(const Disposed()); }, );}The listener goes before anything else, so the disposal decides the final state
alone, and a failure reported after it has nobody to tell. The camera stays
Disposed. A disposal that fails leaves the listener off as well: until a
second dispose() succeeds, the controller does not hear its hardware.
onClose stays in the example for a controller that is closed without a
disposal.
Running the example
Section titled “Running the example”cd exampledart pub getdart run bin/main.dartdart testbin/main.dart opens the camera, zooms, sets a focus point, starts a shot and
disposes of the camera in the middle of it, printing the journal as it goes.
The example also has reopen, pause, resume and the focus operations, and
its tests exercise each of them.