diff --git a/docs/manual/src/applets/index.rst b/docs/manual/src/applets/index.rst index d303b33f6..9b82361fa 100644 --- a/docs/manual/src/applets/index.rst +++ b/docs/manual/src/applets/index.rst @@ -16,4 +16,5 @@ Applet index sensor/index bridge/index audio/index + video/index internal/index diff --git a/docs/manual/src/applets/video/index.rst b/docs/manual/src/applets/video/index.rst new file mode 100644 index 000000000..57f4e0631 --- /dev/null +++ b/docs/manual/src/applets/video/index.rst @@ -0,0 +1,11 @@ +.. _applet.video: + +Video capture and output +======================== + +.. automodule:: glasgow.applet.video + +.. toctree:: + :maxdepth: 3 + + ws2812_output diff --git a/docs/manual/src/applets/video/ws2812_output.rst b/docs/manual/src/applets/video/ws2812_output.rst new file mode 100644 index 000000000..5e54b5e77 --- /dev/null +++ b/docs/manual/src/applets/video/ws2812_output.rst @@ -0,0 +1,18 @@ +``video-ws2812-output`` +======================= + +CLI reference +------------- + +.. _applet.video.ws2812_output: + +.. autoprogram:: glasgow.applet.video.ws2812_output:VideoWS2812OutputApplet._get_argparser_for_sphinx("video-ws2812-output") + :prog: glasgow run video-ws2812-output + + +API reference +------------- + +.. module:: glasgow.applet.video.ws2812_output + +.. autoclass:: VideoWS2812OutputInterface diff --git a/software/glasgow/applet/video/ws2812_output/__init__.py b/software/glasgow/applet/video/ws2812_output/__init__.py index 5a5d3d989..25b6aff76 100644 --- a/software/glasgow/applet/video/ws2812_output/__init__.py +++ b/software/glasgow/applet/video/ws2812_output/__init__.py @@ -1,15 +1,50 @@ +from dataclasses import dataclass import logging -import asyncio -from amaranth import * -from amaranth.lib import io +import typing as t -from ....support.endpoint import * -from ....gateware.pll import * -from ... import * +from amaranth import * +from amaranth.lib import io, wiring, stream +from amaranth.lib.wiring import In + +from glasgow.abstract import AbstractAssembly, ClockDivisor, GlasgowPin, PortGroup +from glasgow.applet import GlasgowAppletV2 +from glasgow.support.endpoint import * +from glasgow.gateware.pll import * + + +__all__ = [ + "VideoWS2812PixelFormat", + "VIDEO_WS2812_PIXEL_FORMATS", + "VideoWS2812OutputComponent", + "VideoWS2812OutputInterface", +] + + +@dataclass(frozen=True) +class VideoWS2812PixelFormat: + in_size: int + out_size: int + format_func: t.Callable + + +VIDEO_WS2812_PIXEL_FORMATS = { + "RGB-BRG": VideoWS2812PixelFormat( + in_size=3, out_size=3, format_func=lambda r, g, b: Cat(b, r, g) + ), + "RGB-BGR": VideoWS2812PixelFormat( + in_size=3, out_size=3, format_func=lambda r, g, b: Cat(b, g, r) + ), + "RGB-xBRG": VideoWS2812PixelFormat( + in_size=3, out_size=4, format_func=lambda r, g, b: Cat(Const(0, unsigned(8)), b, r, g) + ), + "RGBW-WBRG": VideoWS2812PixelFormat( + in_size=4, out_size=4, format_func=lambda r, g, b, w: Cat(w, b, r, g) + ), +} class VideoWS2812Output(Elaboratable): - def __init__(self, ports): + def __init__(self, ports: PortGroup): self.ports = ports self.out = Signal(len(ports.out)) @@ -22,14 +57,16 @@ def elaborate(self, platform): return m -class VideoWS2812OutputSubtarget(Elaboratable): - def __init__(self, ports, count, pix_in_size, pix_out_size, pix_format_func, out_fifo): - self.ports = ports - self.count = count - self.pix_in_size = pix_in_size - self.pix_out_size = pix_out_size - self.pix_format_func = pix_format_func - self.out_fifo = out_fifo +class VideoWS2812OutputComponent(wiring.Component): + i_stream: In(stream.Signature(8)) + framerate_divisor: In(24) + + def __init__(self, ports: PortGroup, count: int, pixel_format: VideoWS2812PixelFormat): + self.ports = ports + self.count = count + self.pixel_format = pixel_format + + super().__init__() def elaborate(self, platform): # Safe timings: @@ -50,33 +87,37 @@ def elaborate(self, platform): m.submodules.output = output = VideoWS2812Output(self.ports) - pix_in_size = self.pix_in_size - pix_out_size = self.pix_out_size + pix_in_size = self.pixel_format.in_size + pix_out_size = self.pixel_format.out_size pix_out_bpp = pix_out_size * 8 - cyc_ctr = Signal(range(t_reset+1)) - bit_ctr = Signal(range(pix_out_bpp+1)) - byt_ctr = Signal(range((pix_in_size)+1)) - pix_ctr = Signal(range(self.count+1)) + cyc_ctr = Signal(range(t_reset + 1)) + bit_ctr = Signal(range(pix_out_bpp + 1)) + byt_ctr = Signal(range((pix_in_size) + 1)) + pix_ctr = Signal(range(self.count + 1)) word_ctr = Signal(range(max(2, len(self.ports.out)))) + framerate_ctr = Signal(self.framerate_divisor.shape()) - pix = Array([ Signal(8) for i in range((pix_in_size) - 1) ]) + pix = Array([Signal(8) for i in range((pix_in_size) - 1)]) word = Signal(pix_out_bpp * len(self.ports.out)) + with m.If(framerate_ctr + 1 != 0): + m.d.sync += framerate_ctr.eq(framerate_ctr + 1) + with m.FSM(): with m.State("LOAD"): m.d.comb += [ - self.out_fifo.r_en.eq(1), + self.i_stream.ready.eq(1), output.out.eq(0), ] - with m.If(self.out_fifo.r_rdy): + with m.If(self.i_stream.valid): with m.If(byt_ctr < ((pix_in_size) - 1)): m.d.sync += [ - pix[byt_ctr].eq(self.out_fifo.r_data), + pix[byt_ctr].eq(self.i_stream.payload), byt_ctr.eq(byt_ctr + 1), ] with m.Else(): - p = self.pix_format_func(*pix, self.out_fifo.r_data) + p = self.pixel_format.format_func(*pix, self.i_stream.payload) m.d.sync += word.eq(Cat(word[pix_out_bpp:], p)) with m.If(word_ctr < (len(self.ports.out) - 1)): m.d.sync += [ @@ -91,8 +132,10 @@ def elaborate(self, platform): m.d.comb += output.out.eq((1 << len(self.ports.out)) - 1) m.d.sync += cyc_ctr.eq(cyc_ctr + 1) with m.Elif(cyc_ctr < t_one): - m.d.comb += (o.eq(word[(pix_out_bpp - 1) + (pix_out_bpp * i)]) - for i,o in enumerate(output.out)) + m.d.comb += ( + o.eq(word[(pix_out_bpp - 1) + (pix_out_bpp * i)]) + for i, o in enumerate(output.out) + ) m.d.sync += cyc_ctr.eq(cyc_ctr + 1) with m.Elif(cyc_ctr < t_period): m.d.comb += output.out.eq(0) @@ -121,96 +164,128 @@ def elaborate(self, platform): with m.State("RESET"): m.d.comb += output.out.eq(0) - m.d.sync += cyc_ctr.eq(cyc_ctr + 1) - with m.If(cyc_ctr == t_reset): + with m.If(cyc_ctr + 1 != 0): + m.d.sync += cyc_ctr.eq(cyc_ctr + 1) + with m.If((cyc_ctr >= t_reset) & (framerate_ctr >= self.framerate_divisor)): m.d.sync += [ cyc_ctr.eq(0), pix_ctr.eq(0), bit_ctr.eq(0), byt_ctr.eq(0), word_ctr.eq(0), + framerate_ctr.eq(0), ] m.next = "LOAD" return m -class VideoWS2812OutputApplet(GlasgowApplet): +class VideoWS2812OutputInterface: + def __init__( + self, + logger: logging.Logger, + assembly: AbstractAssembly, + *, + out: tuple[GlasgowPin], + count: int, + pixel_format: VideoWS2812PixelFormat, + buffer: int, + ): + self._logger = logger + self._frame_size = len(out) * pixel_format.in_size * count + ports = assembly.add_port_group(out=out) + component = assembly.add_submodule(VideoWS2812OutputComponent(ports, count, pixel_format)) + self._pipe = assembly.add_out_pipe( + component.i_stream, buffer_size=self._frame_size * buffer + ) + self._framerate = assembly.add_clock_divisor( + component.framerate_divisor, ref_period=assembly.sys_clk_period, name="framerate" + ) + + async def write_frame(self, data): + """Send one or more frame's worth of pixel data to the LED string.""" + assert len(data) % self._frame_size == 0 + await self._pipe.send(data) + await self._pipe.flush(_wait=False) + + @property + def frame_size(self) -> int: + """Size of each frame in bytes.""" + return self._frame_size + + @property + def framerate_limiter(self) -> ClockDivisor: + """Framerate limiter.""" + return self._framerate + + +class VideoWS2812OutputApplet(GlasgowAppletV2): logger = logging.getLogger(__name__) help = "display video via WS2812 LEDs" description = """ Output RGB(W) frames from a socket to one or more WS2812(B) LED strings. """ - pixel_formats = { - # in-out in size out size format_func - "RGB-BRG": ( 3, 3, lambda r,g,b: Cat(b,r,g) ), - "RGB-xBRG": ( 3, 4, lambda r,g,b: Cat(Const(0, unsigned(8)),b,r,g) ), - "RGBW-WBRG": ( 4, 4, lambda r,g,b,w: Cat(w,b,r,g) ), - } - @classmethod def add_build_arguments(cls, parser, access): - super().add_build_arguments(parser, access) - + access.add_voltage_argument(parser) access.add_pins_argument(parser, "out", width=range(1, 17), required=True) parser.add_argument( "-c", "--count", metavar="N", type=int, required=True, help="set the number of LEDs per string") parser.add_argument( - "-f", "--pix-fmt", metavar="F", choices=cls.pixel_formats.keys(), default="RGB-BRG", - help="set the pixel format (one of: %(choices)s, default: %(default)s)") - - def build(self, target, args): - self.pix_in_size, pix_out_size, pix_format_func = self.pixel_formats[args.pix_fmt] - - self.mux_interface = iface = target.multiplexer.claim_interface(self, args) - subtarget = iface.add_subtarget(VideoWS2812OutputSubtarget( - ports=iface.get_port_group(out=args.out), - count=args.count, - pix_in_size=self.pix_in_size, - pix_out_size=pix_out_size, - pix_format_func=pix_format_func, - out_fifo=iface.get_out_fifo(), - )) + "-f", "--pix-fmt", metavar="F", choices=VIDEO_WS2812_PIXEL_FORMATS.keys(), + default="RGB-BRG", help="set the pixel format (default: %(default)s)") + parser.add_argument( + "-b", "--buffer", metavar="N", type=int, default=16, + help="set the number of frames to buffer internally (buffered twice)") - return subtarget + def build(self, args): + with self.assembly.add_applet(self): + self.assembly.use_voltage(args.voltage) + self.ws2812_iface = VideoWS2812OutputInterface( + self.logger, + self.assembly, + out=args.out, + count=args.count, + pixel_format=VIDEO_WS2812_PIXEL_FORMATS[args.pix_fmt], + buffer=args.buffer, + ) @classmethod - def add_run_arguments(cls, parser, access): - super().add_run_arguments(parser, access) - + def add_setup_arguments(cls, parser): parser.add_argument( - "-b", "--buffer", metavar="N", type=int, default=16, - help="set the number of frames to buffer internally (buffered twice)") + "-r", "--framerate", type=float, + help="configure a framerate limiter in Hz") - async def run(self, device, args): - buffer_size = len(args.out) * args.count * self.pix_in_size * args.buffer - return await device.demultiplexer.claim_interface(self, self.mux_interface, args, - write_buffer_size=buffer_size) + async def setup(self, args): + if args.framerate is not None: + await self.ws2812_iface.framerate_limiter.set_frequency(args.framerate) @classmethod - def add_interact_arguments(cls, parser): + def add_run_arguments(cls, parser): ServerEndpoint.add_argument(parser, "endpoint") - async def interact(self, device, args, leds): - frame_size = len(args.out) * args.count * self.pix_in_size - buffer_size = frame_size * args.buffer - endpoint = await ServerEndpoint("socket", self.logger, args.endpoint, - queue_size=buffer_size, deprecated_cancel_on_eof=True) + async def run(self, args): + # This buffer is for the socket only, and is independet from the one + # configured in VideoWS2812OutputInterface + buffer_size = self.ws2812_iface.frame_size * args.buffer + endpoint = await ServerEndpoint( + "socket", + self.logger, + args.endpoint, + queue_size=buffer_size, + ) while True: try: - data = await asyncio.shield(endpoint.recv(buffer_size)) - partial = len(data) % frame_size - while partial: - data += await asyncio.shield(endpoint.recv(frame_size - partial)) - partial = len(data) % frame_size - await leds.write(data) - await leds.flush(wait=False) - except asyncio.CancelledError: + await self.ws2812_iface.write_frame( + await endpoint.recv(self.ws2812_iface.frame_size) + ) + except EOFError: pass @classmethod def tests(cls): from . import test + return test.VideoWS2812OutputAppletTestCase diff --git a/software/glasgow/applet/video/ws2812_output/test.py b/software/glasgow/applet/video/ws2812_output/test.py index c80b79d93..f2b2d9c82 100644 --- a/software/glasgow/applet/video/ws2812_output/test.py +++ b/software/glasgow/applet/video/ws2812_output/test.py @@ -1,8 +1,8 @@ -from ... import * +from glasgow.applet import GlasgowAppletV2TestCase, synthesis_test from . import VideoWS2812OutputApplet -class VideoWS2812OutputAppletTestCase(GlasgowAppletTestCase, applet=VideoWS2812OutputApplet): +class VideoWS2812OutputAppletTestCase(GlasgowAppletV2TestCase, applet=VideoWS2812OutputApplet): @synthesis_test def test_build(self): self.assertBuilds(args=["--out", "A0:3", "-c", "1024"])