3. Share the motion reports
Expose the detector's reports, then write a second stream, in a project of its own, that reads them and keeps a log of motion events.
Right now the motion reports go nowhere: nothing outside motion_watcher can read them. In this step you’ll expose them, then build a second stream in a separate project, motion_log, that reads them and appends each start and stop of motion to a file.
The second stream stands in for anything else that wants the reports, such as a teammate’s dashboard, an alerting script or a model. It gets them without the motion watcher changing, and without opening the camera a second time.
Expose the reports
Section titled “Expose the reports”In motion-watcher/stream.py, give the camera and the detector names, and expose two outputs:
"""A motion watcher: the camera in a window, and motion reports other streams can read."""
from tatolab.stream import CameraSource, DisplayWindow, StreamBuilder, stream
from nodes.motion_detector import MotionDetector
@streamdef motion_watcher(stream_builder: StreamBuilder) -> None: """Watches the camera for motion, and shares the camera and the motion reports.""" camera = stream_builder.add(CameraSource, name="camera") detector = stream_builder.add(MotionDetector, name="detector", config={"sensitivity": 0.02}) window = stream_builder.add(DisplayWindow, config={"title": "Motion watcher"})
stream_builder.connect(camera.output("video"), window.input("video")) stream_builder.connect(camera.output("video"), detector.input("video"))
stream_builder.expose(camera.output("video")) stream_builder.expose(detector.output("motion"))Save. tatolab dev loads the new version.
name="detector"names the node. Without it, a node is named after its class in lowercase,motiondetector. The name becomes part of each of its outputs’ addresses, so pick one that reads well.stream_builder.expose(...)lets other streams on this machine read an output. The motion reports now have the addressmotion_watcher/detector/motion: the stream, the node, and the port.- Exposing the camera’s video too means another stream can use the picture without opening the camera again. One process owns the device; everything else reads the exposed output.
Exposing changes nothing inside motion_watcher. Its nodes run exactly as before, and nothing is sent anywhere until some other stream reads.
Create a second project
Section titled “Create a second project”motion_log lives in a project of its own, with its own dependencies, as it would if another team wrote it. Open a second terminal, go to the folder that holds motion-watcher (not into it), and create the project:
mkdir motion-logcd motion-loguv init --bare --python 3.12uv python pin 3.12uv add tatolab-streammkdir nodestouch nodes/__init__.pyThat’s an ordinary uv project: a pyproject.toml that depends on tatolab-stream, a .python-version pinning Python 3.12, and a .venv. tatolab new would have written a camera stream you don’t need here.
Write the node
Section titled “Write the node”Create nodes/motion_event_log.py:
"""Writes each start and stop of motion to a JSON Lines file."""
import jsonfrom dataclasses import dataclassfrom datetime import datetime, timezone
from tatolab.stream import RuntimeContextFullAccess, RuntimeContextLimitedAccess, node
@dataclassclass MotionEventLogConfig: # The file to append to, relative to this project's folder. path: str = "motion-events.jsonl"
@nodeclass MotionEventLog: """Appends a line to a JSON Lines file each time motion starts or stops."""
@node.input(delivery_profile="ordered") def motion(self) -> dict: ...
def __init__(self, config: MotionEventLogConfig) -> None: self.path = config.path self.moving = False
def setup(self, ctx: RuntimeContextFullAccess) -> None: self.events_file = open(self.path, "a", encoding="utf-8")
def process(self, ctx: RuntimeContextLimitedAccess) -> None: report = ctx.inputs.read("motion") if report is None or report["moving"] == self.moving: return self.moving = report["moving"] event = { "time": datetime.now(timezone.utc).isoformat(timespec="seconds"), "event": "motion started" if self.moving else "motion stopped", "changed": report["changed"], } self.events_file.write(json.dumps(event) + "\n") self.events_file.flush()
def teardown(self, ctx: RuntimeContextFullAccess) -> None: self.events_file.close()It reads the detector’s reports, the same dicts MotionDetector writes, and writes a line only when moving changes:
delivery_profile="ordered", because a log mustn’t skip a report: if motion starts and stops between two reads,newestcould miss both. (orderedstill drops bags if a reader falls far behind, and counts them, but a reader this light won’t.)setupandteardownrun once each, at the start and the end, which makes them the place to open and close the file. The node runs in its project’s folder, so the relative path lands next topyproject.toml.- The time written is the wall clock, for people reading the file. The reports’ own
timestamp_nsis the machine’s monotonic clock, which is better for comparing two events than for reading.
Read from the other stream
Section titled “Read from the other stream”Create stream.py in motion-log:
"""Keeps a log of the motion watcher's motion events."""
from tatolab.stream import StreamBuilder, stream
from nodes.motion_event_log import MotionEventLog
@streamdef motion_log(stream_builder: StreamBuilder) -> None: """Writes each start and stop of motion to motion-events.jsonl.""" event_log = stream_builder.add(MotionEventLog) stream_builder.connect( stream_builder.remote_output("motion_watcher/detector/motion"), event_log.input("motion"), )stream_builder.remote_output(...) names another stream’s exposed output by its address, and you connect it like one of your own.
Run it, in the second terminal:
tatolab devWave at the camera, then stop, and look at the file from a third terminal, or after stopping tatolab dev:
cat motion-events.jsonl{"time": "2026-10-11T14:03:22+00:00", "event": "motion started", "changed": 0.1391}{"time": "2026-10-11T14:03:25+00:00", "event": "motion stopped", "changed": 0.0018}Your times and numbers will differ.
What you have now
Section titled “What you have now”tatolab streamsNAME STATE NODES PROJECTmotion_watcher attached 3 /home/you/motion-watchermotion_log attached 1 /home/you/motion-logTwo streams, from two projects, in one runtime:
- The reader owns the link.
motion_logpulls frommotion_watcher; no stream can push into another’s input. - Each runs on its own. Stop
motion_logwith Ctrl-C and the motion watcher doesn’t notice. Edit and savemotion_log’s node and onlymotion_logreloads. - Order doesn’t matter. Start
motion_logfirst and its link waits, shown asawaiting_remoteintatolab graph, untilmotion_watcherstarts. - Only exposed outputs can be read this way. A link to an output that isn’t exposed waits, saying which port it needs and that its level is internal.
To read motion_watcher from your other machines, expose its output as public: the runtime’s MoQ relay gives each exposed output a URL, and a public one is readable from your other machines. See Across machines.
If something goes wrong
Section titled “If something goes wrong”- The link stays
awaiting_remote. Check thatmotion_watcheris running (tatolab streams), that the address inremote_outputmatches the stream, node and port exactly, and thatmotion_watcherexposesdetector.output("motion"). … has no .venv/bin/python. Runuv syncinmotion-log.- The file stays empty. It only gets a line when motion starts or stops. Check that
motion_watcherlogsmotion startedwhen you move.
Two streams are running. Next you’ll look inside them while they run: Look inside.