Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gallery.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/drum_cast.py" = "/examples/cast/drum_cast.py"
"https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/headless_cast.py" = "/examples/cast/headless_cast.py"
"https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/laptop_input.py" = "/examples/cast/laptop_input.py"
"https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/medialink_tv.py" = "/examples/cast/medialink_tv.py"
"https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/melody.py" = "/examples/cast/melody.py"
"https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/micecast.py" = "/examples/cast/micecast.py"
"https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/pc/rtp_capture.py" = "/examples/cast/pc/rtp_capture.py"
Expand Down
1 change: 1 addition & 0 deletions lib/examples/cast/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ address at the top; set it first.
| `import cast.tv_cast` | The panel on the TV for 60 s with a melody from the P4's speaker and the TV together. The TV is turned on first. |
| `import cast.laptop_input` | The panel in the laptop's Wireless Display app. Move the mouse, click, type: the P4 draws the cursor, a dot per click and the keys. |
| `import cast.drum_cast` | The drum machine, cast with its sound. Press PLAY on the panel. |
| `import cast.medialink_tv` | A medialink camera's pictures and sound on the TV or the laptop: the board finds the camera by name and casts what it sends. Run pydevices' surveillance camera example on the camera side. |

A Roku needs **Screen mirroring** on in its settings. A Windows laptop needs
the Wireless Display app open (Settings > System > Projecting to this PC).
Expand Down
26 changes: 19 additions & 7 deletions lib/examples/cast/cast_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ def fill_rect(self, x, y, w, h, c):
return (x, y, w, h)

def blit_rect(self, buf, x, y, w, h):
if x == 0 and w == self.width and 0 <= y and y + h <= self.height and len(buf) == w * h * 2:
# whole rows are one run of the buffer: a copy, where framebuf's
# blit goes pixel by pixel (60 ms for 640x480 on the P4, 11 ms this way)
memoryview(self._buf)[y * w * 2:(y + h) * w * 2] = buf
return (x, y, w, h)
try:
src = framebuf.FrameBuffer(buf, w, h, framebuf.RGB565)
except (TypeError, ValueError): # a read-only buffer: FBDisplay's copy
Expand All @@ -145,13 +150,20 @@ def stats(self):
out[k] = st[k]
return out

def pcm_out(self, format=None, **kwargs):
"""A desktop's speaker on the cast: an audiodev PCMOutput (any 16-bit
rate, mono or stereo) whose sound goes out with the picture. Needs
audio=True. On the P4 the board's own speaker is already on the cast."""
if pycast is None or self.audio is None:
raise RuntimeError("pcm_out needs CastDisplay(..., audio=True) on a desktop")
return pycast.CastAudioOut(self.audio, format)
def pcm_out(self, format=None, speaker_volume=85, **kwargs):
"""A speaker on the cast: an audiodev PCMOutput whose sound goes out
with the picture. Needs audio=True. On a desktop it takes any 16-bit
rate, mono or stereo. On the P4 it is the board's own speaker through
the audio pump (at ``speaker_volume``, 0 to 100; the cast's level is
the TV's), at the rate ``audio_format`` names, mono or stereo:
boarddev.pcm_out() there is plain I2S, which the cast never hears."""
if self.audio is None:
raise RuntimeError("pcm_out needs CastDisplay(..., audio=True)")
if pycast is not None:
return pycast.CastAudioOut(self.audio, format)
if format is None or format.rate != self.audio.rate:
raise ValueError("the P4's cast speaker runs at audio_format's rate, %d Hz" % self.audio.rate)
return castfast.pump_out(format, volume=speaker_volume, log=self.log)

# touch_read / keypad_read for a board_config: the laptop's mouse (left
# button held), fingers on its touchscreen, and its keys and remote
Expand Down
39 changes: 39 additions & 0 deletions lib/examples/cast/castfast.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,45 @@ def pump_stream(fmt=None, volume=85, capacity=32, log=print):
return stream


def pump_out(fmt, volume=85, log=print):
"""An audiodev PCMOutput on the board's speaker through the audio pump,
so what it plays is on the cast too (TapFeed reads the pump's output).
boarddev.pcm_out() is machine.I2S, which the tap never sees."""
from audiodev import PCMOutput

class PumpOut(PCMOutput):
def __init__(self):
super().__init__(fmt)
self.stream = pump_stream(fmt, volume=volume, log=log)
self.capacity = self.stream.space() # empty: the whole ring
self.frame = fmt.channels * 2

def _write(self, buf):
# A sound card's pace: wait (briefly) for room, take what fits.
for _ in range(50):
room = self.stream.space()
if room >= self.frame:
break
time.sleep_ms(2)
n = min(len(buf), room)
n -= n % self.frame
if n:
self.stream.write(buf[:n])
return n

def queued_size(self):
return max(0, self.capacity - self.stream.space())

def is_active(self):
return self.queued_size() > 0

def deinit(self):
self.close()
self.stream.deinit()

return PumpOut()


class PumpFeed:
"""The one producer for both listeners: the P4's speaker (the pump) and the cast.

Expand Down
124 changes: 124 additions & 0 deletions lib/examples/cast/medialink_tv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""Watch a medialink camera on a TV, with its sound.

This finds a medialink camera on the network by name and casts what it
sees and hears to a TV over Miracast: a Roku TV with Screen mirroring on,
or a Windows PC's Wireless Display app. The TV is the screen and the
speaker, so the board running this needs no display of its own. Every few
seconds it prints how many pictures it showed and how the sound is going.

The camera side is pydevices' medialink surveillance camera example, run
with CAMERA = True on a board with a camera, or on a computer with a
folder of JPEG files. Set its RATE to 24000 to match this one: on a board
the cast carries what the board's speaker plays, and reads it at 24 or
48 kHz. Set its FPS to 5 as well. Sound comes first: an ESP32-P4 decodes
and shows a 640x480 picture in about 80 ms, so it keeps up with 5 a second
and still has time for the sound, where at 10 a second it shows about 6
and drops packets. The cast itself runs at FPS, 10 frames a second, since
it sends a frame only when the picture changes; capping it at 30, 15 or
10 made no difference to the sound.

Needs: on a board, an ESP32-P4 with firmware that carries castif, h264enc
and the audio pump, and Wi-Fi; on a computer, CPython with ffmpeg on the
path. Both need pydevices' medialink and audiodev, and jpegio (built into
the firmware, or Pillow on CPython). The Roku TV needs Screen mirroring
on; Windows needs its Wireless Display app open, on another computer than
the one running this.

Run it on the board, beside the cast example's files:

import cast.medialink_tv

or on a computer:

python lib/examples/cast/medialink_tv.py

Tested on: CPython 3.12 on Linux and Windows to a Roku TV, and a Waveshare
ESP32-P4 panel on MicroPython 1.30 preview to a Roku TV and to Windows
Wireless Display, with a computer's folder of JPEG files as the camera.
"""

import sys

import medialink
import multimer
from audiodev import AudioFormat
from cast_display import CastDisplay

# Change these to match your setup.
TV = "roku:192.168.1.129" # "roku:ADDRESS" for a Roku TV, "windows:ADDRESS" for Wireless Display
CAMERA_NAME = "porch-camera" # the camera side's name on the network
CAMERA_ADDRESS = None # or ("192.168.1.20", PORT) to skip the search
PORT = 47800
WIDTH, HEIGHT = 640, 480 # the cast's picture; the TV scales it to fit
FPS = 10 # the cast's frames a second, at most; it sends only when a picture changes
RATE = 24000 # sound samples a second: 24000 or 48000, the same as the camera's
SPEAKER_VOLUME = 0 # a board's own speaker, 0 to 100; the TV has its own volume

# Start the cast. It joins the TV in the background and rejoins by itself
# if the TV goes away.
kind, address = TV.split(":", 1)
if sys.implementation.name != "cpython":
from _common import wifi_up

wifi_up()
display = CastDisplay(address, WIDTH, HEIGHT, kind=kind, name="Porch camera", fps=FPS,
audio=True, audio_format=(RATE, 2))

# The speaker whose sound goes out with the picture. On a board it is the
# board's own speaker too, at SPEAKER_VOLUME.
audio = AudioFormat(RATE, 1, 16)
speaker = display.pcm_out(audio, speaker_volume=SPEAKER_VOLUME)

# Open the link, with pictures and sound coming in.
link = medialink.Link(medialink.UDPTransport(PORT), retries=2)
video = medialink.Video(link)
screen = medialink.Screen(display)
intercom = medialink.Intercom(link, None, speaker)
finder = medialink.MDNS()
print("Casting to %s, looking for %r" % (TV, CAMERA_NAME))


def look_for_camera():
"""Find the camera by name and say hello to it."""
found = [(CAMERA_NAME, CAMERA_ADDRESS)] if CAMERA_ADDRESS else finder.find(CAMERA_NAME, timeout=1)
for _name, where in found:
try:
link.connect(where)
print("Watching %r at %s" % (CAMERA_NAME, where))
except medialink.PeerUnreachable:
pass


# Show each picture on the TV and play the sound, until stopped.
looked = reported = multimer.ticks_ms() - 3000
counted = 0
try:
while True:
now = multimer.ticks_ms()
if not link.alive and multimer.ticks_diff(now, looked) > 3000:
look_for_camera()
looked = multimer.ticks_ms()
frame = video.receive()
if frame is not None:
screen.show(frame.payload)
display.show()
intercom.service()

# Every five seconds, say how it is going.
if multimer.ticks_diff(now, reported) >= 5000:
total = video.stats["received"]
rate = (total - counted) * 1000 / multimer.ticks_diff(now, reported)
cast = display.stats()
print("%d pictures shown, %.1f a second; sound %s ms late, %d underruns; cast %.0f fps, %d sessions"
% (total, rate, intercom.stats["latency_ms"], intercom.stats["underruns"], cast["fps"],
cast["sessions"]))
counted, reported = total, now
multimer.sleep_ms(1)
finally:
intercom.close()
link.close()
finder.deinit()
display.close()
4 changes: 4 additions & 0 deletions packages/cast.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
"cast/laptop_input.py",
"../lib/examples/cast/laptop_input.py"
],
[
"cast/medialink_tv.py",
"../lib/examples/cast/medialink_tv.py"
],
[
"cast/melody.py",
"../lib/examples/cast/melody.py"
Expand Down
4 changes: 4 additions & 0 deletions packages/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@
"cast/laptop_input.py",
"github:PyDevices/gallery/lib/examples/cast/laptop_input.py"
],
[
"cast/medialink_tv.py",
"github:PyDevices/gallery/lib/examples/cast/medialink_tv.py"
],
[
"cast/melody.py",
"github:PyDevices/gallery/lib/examples/cast/melody.py"
Expand Down
Loading