Skip to content

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.

This step: expose the detector's reports, then read them from a stream in another project.

In motion-watcher/stream.py, give the camera and the detector names, and expose two outputs:

stream.py
"""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
@stream
def 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 address motion_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.

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:

Terminal window
mkdir motion-log
cd motion-log
uv init --bare --python 3.12
uv python pin 3.12
uv add tatolab-stream
mkdir nodes
touch nodes/__init__.py

That’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.

Create nodes/motion_event_log.py:

nodes/motion_event_log.py
"""Writes each start and stop of motion to a JSON Lines file."""
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from tatolab.stream import RuntimeContextFullAccess, RuntimeContextLimitedAccess, node
@dataclass
class MotionEventLogConfig:
# The file to append to, relative to this project's folder.
path: str = "motion-events.jsonl"
@node
class 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, newest could miss both. (ordered still drops bags if a reader falls far behind, and counts them, but a reader this light won’t.)
  • setup and teardown run 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 to pyproject.toml.
  • The time written is the wall clock, for people reading the file. The reports’ own timestamp_ns is the machine’s monotonic clock, which is better for comparing two events than for reading.

Create stream.py in motion-log:

stream.py
"""Keeps a log of the motion watcher's motion events."""
from tatolab.stream import StreamBuilder, stream
from nodes.motion_event_log import MotionEventLog
@stream
def 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:

Terminal window
tatolab dev

Wave at the camera, then stop, and look at the file from a third terminal, or after stopping tatolab dev:

Terminal window
cat motion-events.jsonl
Output
{"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.

Terminal window
tatolab streams
Output
NAME STATE NODES PROJECT
motion_watcher attached 3 /home/you/motion-watcher
motion_log attached 1 /home/you/motion-log

Two streams, from two projects, in one runtime:

  • The reader owns the link. motion_log pulls from motion_watcher; no stream can push into another’s input.
  • Each runs on its own. Stop motion_log with Ctrl-C and the motion watcher doesn’t notice. Edit and save motion_log’s node and only motion_log reloads.
  • Order doesn’t matter. Start motion_log first and its link waits, shown as awaiting_remote in tatolab graph, until motion_watcher starts.
  • 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.

  • The link stays awaiting_remote. Check that motion_watcher is running (tatolab streams), that the address in remote_output matches the stream, node and port exactly, and that motion_watcher exposes detector.output("motion").
  • … has no .venv/bin/python. Run uv sync in motion-log.
  • The file stays empty. It only gets a line when motion starts or stops. Check that motion_watcher logs motion started when you move.

Two streams are running. Next you’ll look inside them while they run: Look inside.