diff --git a/.gitignore b/.gitignore index f5a8aee..6739095 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,16 @@ +# Toolchain (too large for git, ~500MB) +oss-cad-suite/ + +# Build artifacts +build/ + # Extracted firmware binaries (re-extractable from Logic 2 app bundle) *.bin -# Ghidra project files -*.gpr -*.rep - -# Temporary decompilation output -/tmp/decomp* +# Python +__pycache__/ +*.pyc +*.egg-info/ # macOS .DS_Store diff --git a/constraints/icepi-zero.lpf b/constraints/icepi-zero.lpf new file mode 100644 index 0000000..52a8d9f --- /dev/null +++ b/constraints/icepi-zero.lpf @@ -0,0 +1,51 @@ +# picologic - Icepi Zero pin constraints +# Uses USB port 0 for Saleae device emulation +# Based on icepi-zero-v1_3.lpf + +SYSCONFIG CONFIG_IOVOLTAGE=3.3 COMPRESS_CONFIG=ON MCCLK_FREQ=62 MASTER_SPI_PORT=ENABLE SLAVE_SPI_PORT=DISABLE SLAVE_PARALLEL_PORT=DISABLE; + +BLOCK RESETPATHS; +BLOCK ASYNCPATHS; + +# 50 MHz system clock +LOCATE COMP "clk" SITE "M1"; +IOBUF PORT "clk" IO_TYPE=LVCMOS33 PULLMODE=NONE; +FREQUENCY PORT "clk" 50 MHZ; + +# USB port 0 - direct FPGA GPIO (Full-Speed bit-banged via LUNA) +LOCATE COMP "usb_dp" SITE "F15"; +IOBUF PORT "usb_dp" IO_TYPE=LVCMOS33 DRIVE=4 PULLMODE=NONE; +LOCATE COMP "usb_dn" SITE "E16"; +IOBUF PORT "usb_dn" IO_TYPE=LVCMOS33 DRIVE=4 PULLMODE=NONE; +LOCATE COMP "usb_pullup" SITE "G15"; +IOBUF PORT "usb_pullup" IO_TYPE=LVCMOS33 DRIVE=4 PULLMODE=NONE; + +# Status LEDs +LOCATE COMP "led[0]" SITE "E13"; +IOBUF PORT "led[0]" IO_TYPE=LVCMOS33 PULLMODE=DOWN DRIVE=4; +LOCATE COMP "led[1]" SITE "D14"; +IOBUF PORT "led[1]" IO_TYPE=LVCMOS33 PULLMODE=DOWN DRIVE=4; +LOCATE COMP "led[2]" SITE "E12"; +IOBUF PORT "led[2]" IO_TYPE=LVCMOS33 PULLMODE=DOWN DRIVE=4; +LOCATE COMP "led[3]" SITE "C13"; +IOBUF PORT "led[3]" IO_TYPE=LVCMOS33 PULLMODE=DOWN DRIVE=4; +LOCATE COMP "led[4]" SITE "D13"; +IOBUF PORT "led[4]" IO_TYPE=LVCMOS33 PULLMODE=DOWN DRIVE=4; + +# Digital probe inputs (GPIO header, first 8 channels for v1) +LOCATE COMP "probe[0]" SITE "G3"; +IOBUF PORT "probe[0]" IO_TYPE=LVCMOS33 PULLMODE=DOWN; +LOCATE COMP "probe[1]" SITE "K3"; +IOBUF PORT "probe[1]" IO_TYPE=LVCMOS33 PULLMODE=DOWN; +LOCATE COMP "probe[2]" SITE "T2"; +IOBUF PORT "probe[2]" IO_TYPE=LVCMOS33 PULLMODE=DOWN; +LOCATE COMP "probe[3]" SITE "R2"; +IOBUF PORT "probe[3]" IO_TYPE=LVCMOS33 PULLMODE=DOWN; +LOCATE COMP "probe[4]" SITE "R1"; +IOBUF PORT "probe[4]" IO_TYPE=LVCMOS33 PULLMODE=DOWN; +LOCATE COMP "probe[5]" SITE "E1"; +IOBUF PORT "probe[5]" IO_TYPE=LVCMOS33 PULLMODE=DOWN; +LOCATE COMP "probe[6]" SITE "F3"; +IOBUF PORT "probe[6]" IO_TYPE=LVCMOS33 PULLMODE=DOWN; +LOCATE COMP "probe[7]" SITE "G1"; +IOBUF PORT "probe[7]" IO_TYPE=LVCMOS33 PULLMODE=DOWN; diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4709e1f --- /dev/null +++ b/flake.nix @@ -0,0 +1,71 @@ +{ + description = "picologic - Open-source Saleae Logic 8 emulator for ECP5 FPGA"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # LUNA is not in nixpkgs yet — build from source + luna = pkgs.python3Packages.buildPythonPackage { + pname = "luna"; + version = "0.1.0"; + src = pkgs.fetchFromGitHub { + owner = "greatscottgadgets"; + repo = "luna"; + rev = "main"; + hash = pkgs.lib.fakeHash; # Will be filled on first build + }; + pyproject = true; + build-system = [ pkgs.python3Packages.setuptools ]; + dependencies = with pkgs.python3Packages; [ + amaranth + amaranth-lib + pyvcd + pyserial + ]; + doCheck = false; + }; + + pythonEnv = pkgs.python3.withPackages (ps: with ps; [ + amaranth + amaranth-lib + pyvcd + pyserial + pytest + luna + ]); + + in { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + # FPGA toolchain + yosys + nextpnr + trellis + openFPGALoader + + # Python + gateware deps + pythonEnv + + # Utilities + just + git + ]; + + shellHook = '' + echo "picologic dev environment" + echo " yosys: $(yosys --version 2>/dev/null || echo 'not found')" + echo " nextpnr: $(nextpnr-ecp5 --version 2>/dev/null || echo 'not found')" + echo " ecppack: $(which ecppack 2>/dev/null || echo 'not found')" + echo " amaranth: $(python3 -c 'import amaranth; print(amaranth.__version__)' 2>/dev/null || echo 'not found')" + ''; + }; + } + ); +} diff --git a/gateware/__init__.py b/gateware/__init__.py new file mode 100644 index 0000000..f7f56c2 --- /dev/null +++ b/gateware/__init__.py @@ -0,0 +1 @@ +"""picologic gateware package.""" diff --git a/gateware/ep1_handler.py b/gateware/ep1_handler.py new file mode 100644 index 0000000..5f7356f --- /dev/null +++ b/gateware/ep1_handler.py @@ -0,0 +1,289 @@ +"""EP1 command/response handler for Saleae Logic 8 emulation. + +Implements the EP1 bulk command protocol. Receives single-byte opcodes +from the host and returns appropriate responses. + +Reference: saleae_logic_reversed/doc/protocol/ep1-commands.md +Logic 8 does NOT use XOR obfuscation on normal commands (only during FW upload). +""" + +from amaranth import * +from amaranth.lib.fifo import SyncFIFO + + +# EP1 Command Opcodes +CMD_START_READ = 0x01 +CMD_STOP_STREAM = 0x02 +CMD_PING = 0x7D +CMD_RESET_FPGA = 0x7E +CMD_WRITE_CONFIG_DATA = 0x7F +CMD_WRITE_REGISTERS = 0x80 +CMD_READ_REGISTERS = 0x81 +CMD_STOP_DATA_PATH = 0x86 +CMD_GET_DEVICE_INFO = 0x8B + + +class EP1Handler(Elaboratable): + """EP1 command/response state machine. + + Interfaces with LUNA's bulk endpoints to receive commands and + send responses. Maintains a small register file for WriteRegisters/ + ReadRegisters emulation. + + Attributes + ---------- + rx_data : Signal(8), input + Data from EP1 OUT endpoint. + rx_valid : Signal(), input + Strobe when rx_data is valid. + tx_data : Signal(8), output + Data to EP1 IN endpoint. + tx_valid : Signal(), output + Strobe when tx_data should be sent. + tx_ready : Signal(), input + EP1 IN can accept data. + capture_active : Signal(), output + High when START_READ has been received. + fpga_programmed : Signal(), output + High after successful FPGA programming sequence. + """ + + def __init__(self): + self.rx_data = Signal(8) + self.rx_valid = Signal() + self.tx_data = Signal(8) + self.tx_valid = Signal() + self.tx_ready = Signal() + self.capture_active = Signal() + self.fpga_programmed = Signal() + + # Internal register file (256 x 8-bit) + self._regs = Memory(width=8, depth=256, init=[0] * 256) + + def elaborate(self, platform): + m = Module() + + regs_rd = self._regs.read_port() + regs_wr = self._regs.write_port() + m.submodules.regs_rd = regs_rd + m.submodules.regs_wr = regs_wr + + # State machine + opcode = Signal(8) + response_byte = Signal(8) + response_count = Signal(4) + response_idx = Signal(4) + write_config_len_lo = Signal(8) + write_config_len_hi = Signal(8) + write_config_remaining = Signal(16) + reg_count = Signal(8) + reg_addr = Signal(8) + reg_phase = Signal(2) # 0=count, 1=addr, 2=val (write) or addr-only (read) + + with m.FSM(): + with m.State("IDLE"): + m.d.comb += self.tx_valid.eq(0) + with m.If(self.rx_valid): + m.d.sync += opcode.eq(self.rx_data) + with m.Switch(self.rx_data): + with m.Case(CMD_PING): + m.next = "PING" + with m.Case(CMD_RESET_FPGA): + m.next = "RESET_FPGA" + with m.Case(CMD_WRITE_CONFIG_DATA): + m.next = "WRITE_CONFIG_LEN_LO" + with m.Case(CMD_WRITE_REGISTERS): + m.d.sync += [reg_count.eq(0), reg_phase.eq(0)] + m.next = "WRITE_REGS" + with m.Case(CMD_READ_REGISTERS): + m.d.sync += [reg_count.eq(0), reg_phase.eq(0)] + m.next = "READ_REGS" + with m.Case(CMD_START_READ): + m.next = "START_READ" + with m.Case(CMD_STOP_STREAM): + m.next = "STOP_STREAM" + with m.Case(CMD_STOP_DATA_PATH): + m.next = "STOP_DATA_PATH" + with m.Case(CMD_GET_DEVICE_INFO): + m.next = "DEVICE_INFO" + with m.Default(): + # Unknown command — ignore + m.next = "IDLE" + + # --- PING: respond with complement of test byte --- + with m.State("PING"): + # Wait for the test byte (0x55) + with m.If(self.rx_valid): + m.d.sync += response_byte.eq(~self.rx_data) + m.next = "SEND_ONE" + + # --- RESET_FPGA: respond with 0x00 --- + with m.State("RESET_FPGA"): + m.d.sync += [ + response_byte.eq(0x00), + self.fpga_programmed.eq(0), + ] + m.next = "SEND_ONE" + + # --- WRITE_CONFIG_DATA: read length then consume data bytes --- + with m.State("WRITE_CONFIG_LEN_LO"): + with m.If(self.rx_valid): + m.d.sync += write_config_len_lo.eq(self.rx_data) + m.next = "WRITE_CONFIG_LEN_HI" + + with m.State("WRITE_CONFIG_LEN_HI"): + with m.If(self.rx_valid): + m.d.sync += [ + write_config_len_hi.eq(self.rx_data), + write_config_remaining.eq( + Cat(write_config_len_lo, self.rx_data) + ), + ] + m.next = "WRITE_CONFIG_DATA" + + with m.State("WRITE_CONFIG_DATA"): + with m.If(self.rx_valid): + m.d.sync += write_config_remaining.eq( + write_config_remaining - 1 + ) + with m.If(write_config_remaining <= 1): + m.d.sync += [ + response_byte.eq(0x00), # ACK + self.fpga_programmed.eq(1), + ] + m.next = "SEND_ONE" + + # --- WRITE_REGISTERS: count, then addr/val pairs --- + with m.State("WRITE_REGS"): + with m.If(self.rx_valid): + with m.Switch(reg_phase): + with m.Case(0): + # First byte is count + m.d.sync += [ + reg_count.eq(self.rx_data), + reg_phase.eq(1), + ] + with m.Case(1): + # Address byte + m.d.sync += [ + reg_addr.eq(self.rx_data), + regs_rd.addr.eq(self.rx_data), + reg_phase.eq(2), + ] + with m.Case(2): + # Value byte — write to register + m.d.sync += [ + regs_wr.addr.eq(reg_addr), + regs_wr.data.eq(self.rx_data), + regs_wr.en.eq(1), + reg_count.eq(reg_count - 1), + reg_phase.eq(1), + ] + with m.If(reg_count <= 1): + m.next = "IDLE" # Fire-and-forget, no response + + # --- READ_REGISTERS: count, then addresses, then send values --- + with m.State("READ_REGS"): + with m.If(self.rx_valid): + with m.Switch(reg_phase): + with m.Case(0): + m.d.sync += [ + reg_count.eq(self.rx_data), + response_count.eq(self.rx_data), + response_idx.eq(0), + reg_phase.eq(1), + ] + with m.Case(1): + # Store address, read register value + m.d.sync += [ + reg_addr.eq(self.rx_data), + regs_rd.addr.eq(self.rx_data), + reg_count.eq(reg_count - 1), + ] + with m.If(reg_count <= 1): + m.d.sync += [ + response_idx.eq(0), + reg_phase.eq(2), + ] + m.next = "SEND_REGS" + + with m.State("SEND_REGS"): + with m.If(self.tx_ready): + m.d.comb += [ + self.tx_data.eq(regs_rd.data), + self.tx_valid.eq(1), + ] + m.d.sync += response_idx.eq(response_idx + 1) + with m.If(response_idx >= response_count - 1): + m.next = "IDLE" + + # --- START_READ: respond [0x07, 0x00], activate capture --- + with m.State("START_READ"): + m.d.sync += [ + response_byte.eq(0x07), + self.capture_active.eq(1), + ] + m.next = "SEND_TWO_A" + + # --- STOP_STREAM: respond [0x07, 0x00], deactivate capture --- + with m.State("STOP_STREAM"): + m.d.sync += [ + response_byte.eq(0x07), + self.capture_active.eq(0), + ] + m.next = "SEND_TWO_A" + + # --- STOP_DATA_PATH: no response --- + with m.State("STOP_DATA_PATH"): + m.d.sync += self.capture_active.eq(0) + m.next = "IDLE" + + # --- DEVICE_INFO: send "1, Logic 8\0" --- + with m.State("DEVICE_INFO"): + # Simplified: just send a short identifier + m.d.sync += [ + response_byte.eq(ord('L')), + response_count.eq(9), + response_idx.eq(0), + ] + m.next = "SEND_STRING" + + # --- Generic single-byte response --- + with m.State("SEND_ONE"): + with m.If(self.tx_ready): + m.d.comb += [ + self.tx_data.eq(response_byte), + self.tx_valid.eq(1), + ] + m.next = "IDLE" + + # --- Two-byte response (first byte) --- + with m.State("SEND_TWO_A"): + with m.If(self.tx_ready): + m.d.comb += [ + self.tx_data.eq(response_byte), + self.tx_valid.eq(1), + ] + m.next = "SEND_TWO_B" + + # --- Two-byte response (second byte = 0x00) --- + with m.State("SEND_TWO_B"): + with m.If(self.tx_ready): + m.d.comb += [ + self.tx_data.eq(0x00), + self.tx_valid.eq(1), + ] + m.next = "IDLE" + + # --- String response (simplified) --- + with m.State("SEND_STRING"): + with m.If(self.tx_ready): + m.d.comb += [ + self.tx_data.eq(response_byte), + self.tx_valid.eq(1), + ] + m.d.sync += response_idx.eq(response_idx + 1) + with m.If(response_idx >= response_count - 1): + m.next = "IDLE" + + return m diff --git a/gateware/test_pattern.py b/gateware/test_pattern.py new file mode 100644 index 0000000..61d1d11 --- /dev/null +++ b/gateware/test_pattern.py @@ -0,0 +1,84 @@ +"""Test pattern generator for digital capture emulation. + +Generates configurable test patterns to serve as fake sample data +when no real probes are connected. Patterns include: + - Free-running counter (default) + - Square wave at configurable frequency + - Walking ones/zeros +""" + +from amaranth import * + + +class TestPatternGenerator(Elaboratable): + """Generates test pattern samples on each clock cycle. + + Parameters + ---------- + width : int + Number of digital channels (default 8). + + Attributes + ---------- + sample : Signal(width), output + Current sample value. + valid : Signal(), output + High when sample is valid (always high for test patterns). + pattern : Signal(3), input + Pattern select: 0=counter, 1=square, 2=walking1, 3=walking0. + divider : Signal(16), input + Clock divider for square/walking patterns (0 = every cycle). + """ + + def __init__(self, width=8): + self.width = width + self.sample = Signal(width) + self.valid = Signal(reset=1) + self.pattern = Signal(3) + self.divider = Signal(16) + + def elaborate(self, platform): + m = Module() + + counter = Signal(self.width) + div_counter = Signal(16) + tick = Signal() + + # Divider logic + with m.If(div_counter >= self.divider): + m.d.sync += div_counter.eq(0) + m.d.comb += tick.eq(1) + with m.Else(): + m.d.sync += div_counter.eq(div_counter + 1) + + with m.Switch(self.pattern): + with m.Case(0): + # Free-running counter + m.d.sync += counter.eq(counter + 1) + with m.Case(1): + # Square wave + with m.If(tick): + m.d.sync += counter.eq(~counter) + with m.Case(2): + # Walking ones + with m.If(tick): + with m.If(counter == 0): + m.d.sync += counter.eq(1) + with m.Else(): + m.d.sync += counter.eq(counter << 1) + with m.Case(3): + # Walking zeros (inverted walking ones) + with m.If(tick): + with m.If(counter == 0): + m.d.sync += counter.eq(~C(1, self.width)) + with m.Else(): + m.d.sync += counter.eq(counter >> 1) + with m.Default(): + m.d.sync += counter.eq(counter + 1) + + m.d.comb += [ + self.sample.eq(counter), + self.valid.eq(1), + ] + + return m diff --git a/gateware/top.py b/gateware/top.py new file mode 100644 index 0000000..aa879fa --- /dev/null +++ b/gateware/top.py @@ -0,0 +1,111 @@ +"""Top-level picologic gateware for Icepi Zero. + +Ties together LUNA USB stack, EP1 command handler, test pattern generator, +and EP2 sample streamer into a single FPGA design. +""" + +from amaranth import * +from amaranth.build import Resource, Subsignal, Pins, Attrs, Platform + +from .usb_device import create_saleae_device +from .ep1_handler import EP1Handler +from .test_pattern import TestPatternGenerator + + +class IcepiZeroPlatform(Platform): + """Amaranth platform definition for the Icepi Zero.""" + + device = "LFE5U-25F" + package = "CABGA256" + speed = "6" + + resources = [ + Resource("clk", 0, Pins("M1", dir="i"), + Attrs(IO_TYPE="LVCMOS33")), + + # USB port 0 (direct FPGA GPIO, Full-Speed) + Resource("usb", 0, + Subsignal("dp", Pins("F15", dir="io"), Attrs(IO_TYPE="LVCMOS33")), + Subsignal("dn", Pins("E16", dir="io"), Attrs(IO_TYPE="LVCMOS33")), + Subsignal("pullup", Pins("G15", dir="o"), Attrs(IO_TYPE="LVCMOS33"))), + + # Status LEDs + Resource("led", 0, Pins("E13", dir="o"), Attrs(IO_TYPE="LVCMOS33")), + Resource("led", 1, Pins("D14", dir="o"), Attrs(IO_TYPE="LVCMOS33")), + Resource("led", 2, Pins("E12", dir="o"), Attrs(IO_TYPE="LVCMOS33")), + Resource("led", 3, Pins("C13", dir="o"), Attrs(IO_TYPE="LVCMOS33")), + Resource("led", 4, Pins("D13", dir="o"), Attrs(IO_TYPE="LVCMOS33")), + + # Digital probe inputs + Resource("probe", 0, Pins("G3", dir="i"), Attrs(IO_TYPE="LVCMOS33")), + Resource("probe", 1, Pins("K3", dir="i"), Attrs(IO_TYPE="LVCMOS33")), + Resource("probe", 2, Pins("T2", dir="i"), Attrs(IO_TYPE="LVCMOS33")), + Resource("probe", 3, Pins("R2", dir="i"), Attrs(IO_TYPE="LVCMOS33")), + Resource("probe", 4, Pins("R1", dir="i"), Attrs(IO_TYPE="LVCMOS33")), + Resource("probe", 5, Pins("E1", dir="i"), Attrs(IO_TYPE="LVCMOS33")), + Resource("probe", 6, Pins("F3", dir="i"), Attrs(IO_TYPE="LVCMOS33")), + Resource("probe", 7, Pins("G1", dir="i"), Attrs(IO_TYPE="LVCMOS33")), + ] + + connectors = [] + + def get_usb_resource(self, index=0): + return self.request("usb", index) + + +class PicologicTop(Elaboratable): + """Top-level design: Saleae Logic 8 emulator on Icepi Zero.""" + + def elaborate(self, platform): + m = Module() + + # --- USB PHY interface --- + usb_bus = platform.get_usb_resource(0) + + # --- Create USB device (start in running mode for now) --- + usb_device = create_saleae_device(usb_bus, boot_mode=False) + m.submodules.usb = usb_device + + # --- EP1 Command Handler --- + ep1 = EP1Handler() + m.submodules.ep1 = ep1 + + # --- Test Pattern Generator --- + test_gen = TestPatternGenerator(width=8) + m.submodules.test_gen = test_gen + + # --- LED status indicators --- + leds = [platform.request("led", i) for i in range(5)] + + # LED0: USB configured + # LED1: FPGA programmed + # LED2: Capture active + # LED3: EP1 activity + # LED4: heartbeat (1 Hz blink) + heartbeat_counter = Signal(25) + m.d.sync += heartbeat_counter.eq(heartbeat_counter + 1) + + m.d.comb += [ + leds[0].o.eq(usb_device.configured), + leds[1].o.eq(ep1.fpga_programmed), + leds[2].o.eq(ep1.capture_active), + leds[4].o.eq(heartbeat_counter[-1]), + ] + + # TODO: Wire EP1 bulk endpoints to ep1 handler + # TODO: Wire EP2 bulk endpoint to test_gen when capture_active + # These require connecting to LUNA's endpoint data interfaces, + # which depends on the exact LUNA API version. + + return m + + +if __name__ == "__main__": + from amaranth.back import verilog + + top = PicologicTop() + platform = IcepiZeroPlatform() + + # Generate Verilog for inspection + output = verilog.convert(top, platform=platform) + print(output) diff --git a/gateware/usb_device.py b/gateware/usb_device.py new file mode 100644 index 0000000..6e332d4 --- /dev/null +++ b/gateware/usb_device.py @@ -0,0 +1,135 @@ +"""Saleae Logic 8 USB device descriptors and endpoint configuration. + +Emulates a Cypress FX3-based Saleae Logic 8 (VID 21A9, PID 1004). +Presents two USB configurations: + - Bootloader mode (for firmware upload via EP0 vendor requests) + - Running mode (EP1 command/response + EP2 sample streaming) + +Logic 2 expects the device to enumerate as bootloader first, accept a +firmware upload over EP0, then re-enumerate in running mode. +""" + +from amaranth import * +from luna.gateware.usb.usb2.device import USBDevice +from luna.gateware.usb.usb2.control import USBControlEndpoint +from luna.gateware.usb.usb2.endpoints.stream import USBStreamInEndpoint, USBStreamOutEndpoint +from luna.gateware.usb.request.standard import StandardRequestHandler + + +# --- USB Descriptors --- + +SALEAE_VID = 0x21A9 +LOGIC8_PID_BOOT = 0x1004 # Same PID in both modes; mode distinguished by bcdDevice +LOGIC8_PID_RUNNING = 0x1004 + +BCD_DEVICE_BOOT = 0x0001 +BCD_DEVICE_RUNNING = 0x0100 + +USB_SPEED_FULL = 1 # Full-Speed only on Icepi Zero (no ULPI PHY) + + +class SaleaeBootRequestHandler(StandardRequestHandler): + """Handles EP0 vendor requests during bootloader mode. + + Accepts Cypress FX3 firmware upload (bRequest=0xA0) and triggers + re-enumeration when complete. In this emulation, we accept the bytes + but don't actually execute them — just signal readiness. + """ + + def __init__(self): + super().__init__() + self.firmware_upload_complete = Signal() + + def elaborate(self, platform): + m = Module() + + interface = self.interface + setup = interface.setup + + with m.If(setup.type == USBRequestHandler.TYPE_VENDOR): + with m.If(setup.request == 0xA0): + # Cypress FW_UPLOAD / RW_INTERNAL + # Accept all data phases, ACK status phase + m.d.comb += [ + interface.handshakes_out.ack.eq(1), + ] + # After zero-length IN (entry trigger), mark complete + with m.If((setup.length == 0) & (setup.direction == USBRequestHandler.DIRECTION_IN)): + m.d.sync += self.firmware_upload_complete.eq(1) + + return m + + +def create_saleae_device(usb_bus, *, boot_mode=True): + """Create a LUNA USBDevice configured as a Saleae Logic 8. + + Parameters + ---------- + usb_bus : Record + LUNA UTMI/ULPI bus interface. + boot_mode : bool + If True, present as bootloader (accepts firmware upload). + If False, present as running device (EP1 commands + EP2 streaming). + + Returns + ------- + USBDevice + Configured LUNA USB device ready for elaboration. + """ + device = USBDevice(bus=usb_bus) + + # Standard descriptors + device.add_descriptor( + "device", + idVendor=SALEAE_VID, + idProduct=LOGIC8_PID_BOOT, + bcdDevice=BCD_DEVICE_BOOT if boot_mode else BCD_DEVICE_RUNNING, + iManufacturer="Saleae", + iProduct="Logic 8" if not boot_mode else "WestBridge", + bNumConfigurations=1, + ) + + # Configuration descriptor + device.add_descriptor( + "configuration", + bConfigurationValue=1, + iConfiguration="Saleae Logic 8 Configuration", + bmAttributes=0x80, # Bus-powered + bMaxPower=250, # 500 mA + ) + + # Interface descriptor + device.add_descriptor( + "interface", + bInterfaceNumber=0, + bAlternateSetting=0, + bNumEndpoints=3 if not boot_mode else 1, + bInterfaceClass=0xFF, # Vendor-specific + bInterfaceSubClass=0x00, + bInterfaceProtocol=0x00, + iInterface="Saleae Logic 8 Interface", + ) + + if boot_mode: + # Bootloader: only EP0 (control) needed for firmware upload + control_ep = USBControlEndpoint() + control_ep.add_handler(SaleaeBootRequestHandler()) + device.add_endpoint(control_ep) + else: + # Running mode: EP0 control + EP1 bulk IN/OUT + EP2 bulk IN + control_ep = USBControlEndpoint() + device.add_endpoint(control_ep) + + # EP1 OUT — host sends commands + ep1_out = USBStreamOutEndpoint(endpoint_number=1, max_packet_size=64) + device.add_endpoint(ep1_out) + + # EP1 IN — device sends responses + ep1_in = USBStreamInEndpoint(endpoint_number=1, max_packet_size=64) + device.add_endpoint(ep1_in) + + # EP2 IN — sample data streaming + ep2_in = USBStreamInEndpoint(endpoint_number=2, max_packet_size=64) + device.add_endpoint(ep2_in) + + return device diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..4f2ade0 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Build picologic gateware for Icepi Zero +# Usage: ./scripts/build.sh [target] +# target: synth | pnr | pack | all (default: all) + +set -euo pipefail + +TOP="gateware.top" +LPF="constraints/icepi-zero.lpf" +BUILD_DIR="build" +DEVICE="25k" # ECP5-25F + +mkdir -p "$BUILD_DIR" + +TARGET="${1:-all}" + +synth() { + echo "=== Synthesis ===" + python3 -m amaranth.build \ + --name picologic \ + --type json \ + "$TOP" \ + -o "$BUILD_DIR/picologic.json" +} + +pnr() { + echo "=== Place & Route ===" + nextpnr-ecp5 \ + --$DEVICE \ + --json "$BUILD_DIR/picologic.json" \ + --lpf "$LPF" \ + --textcfg "$BUILD_DIR/picologic.config" \ + --freq 50 +} + +pack() { + echo "=== Bitstream Packing ===" + ecppack \ + --compress \ + "$BUILD_DIR/picologic.config" \ + "$BUILD_DIR/picologic.bit" +} + +flash() { + echo "=== Programming ===" + openFPGALoader -b icepi-zero "$BUILD_DIR/picologic.bit" +} + +case "$TARGET" in + synth) synth ;; + pnr) synth; pnr ;; + pack) synth; pnr; pack ;; + flash) synth; pnr; pack; flash ;; + all) synth; pnr; pack ;; + *) echo "Unknown target: $TARGET"; exit 1 ;; +esac + +echo "=== Done ===" +ls -la "$BUILD_DIR"/picologic.* 2>/dev/null || true diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..591ee30 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Set up picologic development environment +# Downloads oss-cad-suite and installs Python dependencies + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OSS_DIR="$SCRIPT_DIR/oss-cad-suite" + +if [ -d "$OSS_DIR" ] && [ -f "$OSS_DIR/environment" ]; then + echo "✓ oss-cad-suite already installed at $OSS_DIR" +else + echo "Downloading oss-cad-suite for macOS ARM64..." + RELEASE=$(curl -s "https://api.github.com/repos/YosysHQ/oss-cad-suite-build/releases/latest" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") + URL="https://github.com/YosysHQ/oss-cad-suite-build/releases/download/${RELEASE}/oss-cad-suite-darwin-arm64-${RELEASE//-/}.tgz" + echo " Release: $RELEASE" + echo " URL: $URL" + curl -L -o /tmp/oss-cad-suite.tgz "$URL" --progress-bar + echo "Extracting..." + tar xzf /tmp/oss-cad-suite.tgz -C "$SCRIPT_DIR" + rm /tmp/oss-cad-suite.tgz + echo "✓ oss-cad-suite installed" +fi + +echo "" +echo "Activating environment..." +cd "$OSS_DIR" && source environment + +echo "" +echo "Installing Python dependencies..." +python3 -m pip install --quiet "amaranth==0.4.5" "luna-usb==0.1.1" pyvcd pyserial pytest 2>&1 | grep -v "DEPRECATION\|notice" + +echo "" +echo "Verifying toolchain..." +echo " yosys: $(yosys --version 2>&1 | head -1)" +echo " nextpnr: $(nextpnr-ecp5 --version 2>&1 | head -1)" +echo " ecppack: $(ecppack --version 2>&1 | head -1)" +echo " amaranth: $(python3 -c 'import amaranth; print(amaranth.__version__)')" +echo " luna: $(python3 -c 'from luna.gateware.usb.usb2.device import USBDevice; print("OK")')" + +echo "" +echo "✓ Toolchain ready!" +echo "" +echo "To activate in new shells:" +echo " cd $OSS_DIR && source environment" +echo "" +echo "To build gateware:" +echo " cd $SCRIPT_DIR && ./scripts/build.sh" diff --git a/tests/test_ep1_handler.py b/tests/test_ep1_handler.py new file mode 100644 index 0000000..de08de3 --- /dev/null +++ b/tests/test_ep1_handler.py @@ -0,0 +1,32 @@ +"""Unit tests for EP1 command handler.""" + +import pytest + + +def test_ep1_handler_import(): + """Verify the module imports cleanly.""" + from gateware.ep1_handler import EP1Handler + handler = EP1Handler() + assert handler.rx_data is not None + assert handler.tx_data is not None + assert handler.capture_active is not None + assert handler.fpga_programmed is not None + + +def test_ep1_opcode_constants(): + """Verify opcode constants match saleae_logic_reversed.""" + from gateware.ep1_handler import ( + CMD_START_READ, CMD_STOP_STREAM, CMD_PING, + CMD_RESET_FPGA, CMD_WRITE_CONFIG_DATA, + CMD_WRITE_REGISTERS, CMD_READ_REGISTERS, + CMD_STOP_DATA_PATH, CMD_GET_DEVICE_INFO, + ) + assert CMD_START_READ == 0x01 + assert CMD_STOP_STREAM == 0x02 + assert CMD_PING == 0x7D + assert CMD_RESET_FPGA == 0x7E + assert CMD_WRITE_CONFIG_DATA == 0x7F + assert CMD_WRITE_REGISTERS == 0x80 + assert CMD_READ_REGISTERS == 0x81 + assert CMD_STOP_DATA_PATH == 0x86 + assert CMD_GET_DEVICE_INFO == 0x8B diff --git a/tests/test_test_pattern.py b/tests/test_test_pattern.py new file mode 100644 index 0000000..5d13965 --- /dev/null +++ b/tests/test_test_pattern.py @@ -0,0 +1,22 @@ +"""Unit tests for test pattern generator.""" + +import pytest + + +def test_pattern_generator_import(): + """Verify the module imports cleanly.""" + from gateware.test_pattern import TestPatternGenerator + gen = TestPatternGenerator(width=8) + assert gen.width == 8 + assert gen.sample.shape().width == 8 + + +def test_pattern_generator_signals(): + """Verify all expected signals exist.""" + from gateware.test_pattern import TestPatternGenerator + gen = TestPatternGenerator(width=16) + assert gen.width == 16 + assert gen.sample.shape().width == 16 + assert gen.valid is not None + assert gen.pattern is not None + assert gen.divider is not None