devlab-fpga 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devlab/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Python helpers for the devlab FPGA command line tools."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
6
+
devlab/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
devlab/cli.py ADDED
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import platform
5
+ from pathlib import Path
6
+
7
+ from . import __version__
8
+ from .errors import DevlabError
9
+ from .platforms import current_platform
10
+ from .project import build_project, create_project, detect_flash, doctor, flash_project
11
+ from .toolchain import (
12
+ OSS_CAD_SUITE_RELEASE_URL,
13
+ archive_path,
14
+ devlab_home,
15
+ install_oss_cad_suite,
16
+ install_path,
17
+ select_asset,
18
+ )
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(
23
+ prog="devlab",
24
+ description="Install FPGA toolchains and run local FPGA build flows.",
25
+ )
26
+ parser.add_argument("--version", action="version", version=f"devlab {__version__}")
27
+
28
+ commands = parser.add_subparsers(dest="command", required=True)
29
+
30
+ doctor_parser = commands.add_parser("doctor", help="Check local FPGA tools.")
31
+ doctor_parser.add_argument(
32
+ "--strict",
33
+ action="store_true",
34
+ help="Exit 1 when a tool is missing.",
35
+ )
36
+ doctor_parser.set_defaults(func=_doctor)
37
+
38
+ install_parser = commands.add_parser("install", help="Install OSS CAD Suite.")
39
+ install_parser.add_argument("--force", action="store_true", help="Re-download and reinstall.")
40
+ install_parser.add_argument(
41
+ "--run-installer",
42
+ action="store_true",
43
+ help="On Windows, execute the downloaded OSS CAD Suite installer.",
44
+ )
45
+ install_parser.set_defaults(func=_install)
46
+
47
+ new_parser = commands.add_parser("new", help="Create a new FPGA project.")
48
+ new_parser.add_argument("name", help="Project directory name.")
49
+ new_parser.add_argument("--dir", type=Path, help="Target directory. Defaults to NAME.")
50
+ new_parser.add_argument("--force", action="store_true", help="Overwrite template files.")
51
+ new_parser.add_argument(
52
+ "--hdl",
53
+ choices=("verilog", "vhdl"),
54
+ default="verilog",
55
+ help="Source template language. Defaults to verilog.",
56
+ )
57
+ new_parser.set_defaults(func=_new)
58
+
59
+ build = commands.add_parser("build", help="Build the current FPGA project.")
60
+ build.add_argument("-c", "--config", type=Path, help="Path to devlab.toml.")
61
+ build.add_argument(
62
+ "--dry-run",
63
+ action="store_true",
64
+ help="Print commands without running them.",
65
+ )
66
+ build.set_defaults(func=_build)
67
+
68
+ flash = commands.add_parser("flash", help="Flash the built FPGA bitstream.")
69
+ flash.add_argument("-c", "--config", type=Path, help="Path to devlab.toml.")
70
+ flash.add_argument("--board", help="openFPGALoader board name.")
71
+ flash.add_argument("--artifact", type=Path, help="Bitstream path.")
72
+ flash.add_argument(
73
+ "--mode",
74
+ choices=("sram", "flash"),
75
+ help="Write target. sram is temporary; flash is persistent after reboot.",
76
+ )
77
+ flash.add_argument(
78
+ "--detect",
79
+ action="store_true",
80
+ help="Detect FPGA and flash support without writing a bitstream.",
81
+ )
82
+ flash.add_argument(
83
+ "--verify",
84
+ action="store_true",
85
+ help="Verify flash write when supported by the board/programming path.",
86
+ )
87
+ flash.add_argument(
88
+ "--no-verify",
89
+ action="store_true",
90
+ help="Skip flash verification when using --mode flash.",
91
+ )
92
+ flash.add_argument(
93
+ "--external-flash",
94
+ action="store_true",
95
+ help="Use external flash when the device has internal and external storage.",
96
+ )
97
+ flash.add_argument("--offset", help="Flash offset in bytes.")
98
+ flash.add_argument("--dry-run", action="store_true", help="Print command without running it.")
99
+ flash.set_defaults(func=_flash)
100
+
101
+ return parser
102
+
103
+
104
+ def main(argv: list[str] | None = None) -> int:
105
+ parser = build_parser()
106
+ args = parser.parse_args(argv)
107
+ try:
108
+ return args.func(args)
109
+ except DevlabError as exc:
110
+ parser.exit(2, f"devlab: error: {exc}\n")
111
+
112
+
113
+ def _doctor(args: argparse.Namespace) -> int:
114
+ platform_id = current_platform()
115
+ asset = select_asset(platform_id)
116
+ print(f"devlab: {__version__}")
117
+ print(f"python: {platform.python_version()} ({platform.python_implementation()})")
118
+ print(f"platform: {platform_id.key}")
119
+ print(f"home: {devlab_home()}")
120
+ print(f"release: {OSS_CAD_SUITE_RELEASE_URL}")
121
+ print(f"asset: {asset.name}")
122
+ print(f"archive: {archive_path(asset)}")
123
+ print(f"toolchain: {install_path(asset)}")
124
+ return doctor(strict=args.strict)
125
+
126
+
127
+ def _install(args: argparse.Namespace) -> int:
128
+ destination = install_oss_cad_suite(
129
+ force=args.force,
130
+ run_windows_installer=args.run_installer,
131
+ )
132
+ print(f"OSS CAD Suite installed at {destination}")
133
+ return 0
134
+
135
+
136
+ def _new(args: argparse.Namespace) -> int:
137
+ root = create_project(args.name, directory=args.dir, force=args.force, hdl=args.hdl)
138
+ print(f"Created FPGA project at {root}")
139
+ return 0
140
+
141
+
142
+ def _build(args: argparse.Namespace) -> int:
143
+ artifact = build_project(config_path=args.config, dry_run=args.dry_run)
144
+ print(f"Build artifact: {artifact}")
145
+ return 0
146
+
147
+
148
+ def _flash(args: argparse.Namespace) -> int:
149
+ if args.verify and args.no_verify:
150
+ raise DevlabError("--verify and --no-verify cannot be used together.")
151
+
152
+ if args.detect:
153
+ detect_flash(
154
+ config_path=args.config,
155
+ board=args.board,
156
+ dry_run=args.dry_run,
157
+ )
158
+ return 0
159
+
160
+ flash_project(
161
+ config_path=args.config,
162
+ board=args.board,
163
+ artifact=args.artifact,
164
+ mode=args.mode,
165
+ verify=True if args.verify else False if args.no_verify else None,
166
+ external_flash=True if args.external_flash else None,
167
+ offset=args.offset,
168
+ dry_run=args.dry_run,
169
+ )
170
+ return 0
devlab/errors.py ADDED
@@ -0,0 +1,3 @@
1
+ class DevlabError(RuntimeError):
2
+ """User-facing error raised by devlab commands."""
3
+
devlab/platforms.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ from dataclasses import dataclass
5
+
6
+ from .errors import DevlabError
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class PlatformId:
11
+ os_name: str
12
+ arch: str
13
+
14
+ @property
15
+ def key(self) -> str:
16
+ return f"{self.os_name}-{self.arch}"
17
+
18
+
19
+ def current_platform() -> PlatformId:
20
+ system = platform.system().lower()
21
+ machine = platform.machine().lower()
22
+
23
+ if system == "linux":
24
+ os_name = "linux"
25
+ elif system == "darwin":
26
+ os_name = "darwin"
27
+ elif system in {"windows", "msys", "cygwin"}:
28
+ os_name = "windows"
29
+ else:
30
+ raise DevlabError(f"Unsupported operating system: {platform.system()}")
31
+
32
+ if machine in {"x86_64", "amd64"}:
33
+ arch = "x64"
34
+ elif machine in {"aarch64", "arm64"}:
35
+ arch = "arm64"
36
+ else:
37
+ raise DevlabError(f"Unsupported CPU architecture: {platform.machine()}")
38
+
39
+ return PlatformId(os_name=os_name, arch=arch)
40
+
devlab/project.py ADDED
@@ -0,0 +1,430 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ import subprocess
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .errors import DevlabError
10
+ from .toolchain import env_with_toolchain, find_executable, install_path
11
+
12
+ try:
13
+ import tomllib
14
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
15
+ import tomli as tomllib # type: ignore
16
+
17
+
18
+ DEFAULT_CONFIG = "devlab.toml"
19
+
20
+
21
+ @dataclass
22
+ class BuildConfig:
23
+ family: str = "GW1N-9C"
24
+ device: str = "GW1NR-LV9QN88PC6/I5"
25
+ package: str = "sg48"
26
+ top: str = "top"
27
+ sources: list[str] = field(default_factory=lambda: ["src/top.v"])
28
+ constraints: str = "pins.cst"
29
+ build_dir: str = "build"
30
+ board: str | None = None
31
+ flash_mode: str = "sram"
32
+ flash_verify: bool = False
33
+ external_flash: bool = False
34
+ flash_offset: str | None = None
35
+ series: str | None = None
36
+
37
+ @property
38
+ def artifact(self) -> str:
39
+ if self.flow == "ecp5":
40
+ return f"{self.build_dir}/{self.top}.bit"
41
+ if self.flow == "gowin":
42
+ return f"{self.build_dir}/{self.top}.fs"
43
+ return f"{self.build_dir}/{self.top}.bin"
44
+
45
+ @property
46
+ def flow(self) -> str:
47
+ family = self.family.lower()
48
+ if family in {"gowin", "apicula"} or family.startswith("gw"):
49
+ return "gowin"
50
+ return family
51
+
52
+ @property
53
+ def gowin_family(self) -> str:
54
+ if self.series:
55
+ return self.series
56
+ if self.family.lower() in {"gowin", "apicula"}:
57
+ raise DevlabError(
58
+ "Gowin builds need [fpga] family = \"GW1N-9C\" "
59
+ "or [fpga] series = \"GW1N-9C\"."
60
+ )
61
+ return self.family
62
+
63
+
64
+ def load_config(path: Path | None = None) -> BuildConfig:
65
+ config_path = path or Path(DEFAULT_CONFIG)
66
+ if not config_path.exists():
67
+ raise DevlabError(f"Project config not found: {config_path}")
68
+
69
+ with config_path.open("rb") as handle:
70
+ data = tomllib.load(handle)
71
+
72
+ fpga = _section(data, "fpga")
73
+ build = _section(data, "build")
74
+ flash = _section(data, "flash")
75
+
76
+ return BuildConfig(
77
+ family=str(fpga.get("family", "GW1N-9C")),
78
+ device=str(fpga.get("device", "GW1NR-LV9QN88PC6/I5")),
79
+ package=str(fpga.get("package", "sg48")),
80
+ top=str(build.get("top", "top")),
81
+ sources=[str(item) for item in build.get("sources", ["src/top.v"])],
82
+ constraints=str(build.get("constraints", fpga.get("cst", "pins.cst"))),
83
+ build_dir=str(build.get("build_dir", "build")),
84
+ board=flash.get("board"),
85
+ flash_mode=str(flash.get("mode", "sram")).lower(),
86
+ flash_verify=bool(flash.get("verify", False)),
87
+ external_flash=bool(flash.get("external_flash", False)),
88
+ flash_offset=str(flash["offset"]) if "offset" in flash else None,
89
+ series=fpga.get("series"),
90
+ )
91
+
92
+
93
+ def create_project(
94
+ name: str,
95
+ directory: Path | None = None,
96
+ force: bool = False,
97
+ hdl: str = "verilog",
98
+ ) -> Path:
99
+ hdl = hdl.lower()
100
+ if hdl not in {"verilog", "vhdl"}:
101
+ raise DevlabError(f"Unsupported HDL: {hdl}")
102
+
103
+ root = directory or Path(name)
104
+ if root.exists() and any(root.iterdir()) and not force:
105
+ raise DevlabError(f"Directory is not empty: {root}")
106
+
107
+ source_name = "top.vhd" if hdl == "vhdl" else "top.v"
108
+ (root / "src").mkdir(parents=True, exist_ok=True)
109
+ _write(root / "devlab.toml", _template_config(source_name), force)
110
+ _write(root / "src" / source_name, _template_top(hdl), force)
111
+ _write(root / "pins.cst", _template_constraints(), force)
112
+ _write(root / ".gitignore", "build/\n", force)
113
+ return root
114
+
115
+
116
+ def build_project(config_path: Path | None = None, dry_run: bool = False) -> Path:
117
+ config = load_config(config_path)
118
+ build_dir = Path(config.build_dir)
119
+ build_dir.mkdir(parents=True, exist_ok=True)
120
+
121
+ commands = build_commands(config)
122
+ for command in commands:
123
+ _run(command, dry_run=dry_run)
124
+
125
+ return Path(config.artifact)
126
+
127
+
128
+ def flash_project(
129
+ config_path: Path | None = None,
130
+ board: str | None = None,
131
+ artifact: Path | None = None,
132
+ mode: str | None = None,
133
+ verify: bool | None = None,
134
+ external_flash: bool | None = None,
135
+ offset: str | None = None,
136
+ dry_run: bool = False,
137
+ ) -> None:
138
+ config = load_config(config_path)
139
+ selected_board = board or config.board
140
+ if not selected_board:
141
+ raise DevlabError(
142
+ "No flash board configured. Add [flash] board = \"...\" "
143
+ "to devlab.toml or pass --board."
144
+ )
145
+
146
+ bitstream = artifact or Path(config.artifact)
147
+ if not dry_run and not bitstream.exists():
148
+ raise DevlabError(f"Bitstream not found: {bitstream}")
149
+
150
+ _run(
151
+ flash_command(
152
+ config,
153
+ selected_board,
154
+ bitstream,
155
+ mode=mode,
156
+ verify=verify,
157
+ external_flash=external_flash,
158
+ offset=offset,
159
+ ),
160
+ dry_run=dry_run,
161
+ )
162
+
163
+
164
+ def detect_flash(
165
+ config_path: Path | None = None,
166
+ board: str | None = None,
167
+ dry_run: bool = False,
168
+ ) -> None:
169
+ config = load_config(config_path)
170
+ selected_board = board or config.board
171
+ if not selected_board:
172
+ raise DevlabError(
173
+ "No flash board configured. Add [flash] board = \"...\" "
174
+ "to devlab.toml or pass --board."
175
+ )
176
+
177
+ _run(["openFPGALoader", "-b", selected_board, "--detect", "-f"], dry_run=dry_run)
178
+
179
+
180
+ def flash_command(
181
+ config: BuildConfig,
182
+ board: str,
183
+ bitstream: Path,
184
+ mode: str | None = None,
185
+ verify: bool | None = None,
186
+ external_flash: bool | None = None,
187
+ offset: str | None = None,
188
+ ) -> list[str]:
189
+ selected_mode = (mode or config.flash_mode).lower()
190
+ if selected_mode not in {"sram", "flash"}:
191
+ raise DevlabError(f"Unsupported flash mode: {selected_mode}")
192
+
193
+ command = ["openFPGALoader", "-b", board]
194
+ if selected_mode == "flash":
195
+ command.append("-f")
196
+ selected_verify = verify if verify is not None else config.flash_verify
197
+ if selected_verify:
198
+ command.append("--verify")
199
+ selected_external = (
200
+ external_flash if external_flash is not None else config.external_flash
201
+ )
202
+ if selected_external:
203
+ command.append("--external-flash")
204
+ selected_offset = offset if offset is not None else config.flash_offset
205
+ if selected_offset:
206
+ command.extend(["--offset", selected_offset])
207
+ else:
208
+ command.append("-m")
209
+
210
+ command.append(str(bitstream))
211
+ return command
212
+
213
+
214
+ def build_commands(config: BuildConfig) -> list[list[str]]:
215
+ if config.flow == "gowin":
216
+ json_file = f"{config.build_dir}/{config.top}.json"
217
+ pnr_json_file = f"{config.build_dir}/{config.top}_pnr.json"
218
+ return [
219
+ _yosys_command(config, f"synth_gowin -top {config.top} -json {json_file}"),
220
+ [
221
+ "nextpnr-himbaechel",
222
+ "--json",
223
+ json_file,
224
+ "--write",
225
+ pnr_json_file,
226
+ "--device",
227
+ config.device,
228
+ "--vopt",
229
+ f"family={config.gowin_family}",
230
+ "--vopt",
231
+ f"cst={config.constraints}",
232
+ ],
233
+ [
234
+ "gowin_pack",
235
+ "-d",
236
+ config.gowin_family,
237
+ "-o",
238
+ config.artifact,
239
+ pnr_json_file,
240
+ ],
241
+ ]
242
+
243
+ if config.flow == "ice40":
244
+ json_file = f"{config.build_dir}/{config.top}.json"
245
+ asc_file = f"{config.build_dir}/{config.top}.asc"
246
+ return [
247
+ _yosys_command(config, f"synth_ice40 -top {config.top} -json {json_file}"),
248
+ [
249
+ "nextpnr-ice40",
250
+ f"--{config.device}",
251
+ "--package",
252
+ config.package,
253
+ "--json",
254
+ json_file,
255
+ "--pcf",
256
+ config.constraints,
257
+ "--asc",
258
+ asc_file,
259
+ ],
260
+ ["icepack", asc_file, config.artifact],
261
+ ]
262
+
263
+ if config.flow == "ecp5":
264
+ json_file = f"{config.build_dir}/{config.top}.json"
265
+ cfg_file = f"{config.build_dir}/{config.top}.config"
266
+ return [
267
+ _yosys_command(config, f"synth_ecp5 -top {config.top} -json {json_file}"),
268
+ [
269
+ "nextpnr-ecp5",
270
+ "--json",
271
+ json_file,
272
+ "--lpf",
273
+ config.constraints,
274
+ "--textcfg",
275
+ cfg_file,
276
+ "--um5g-85k" if config.device == "um5g-85k" else f"--{config.device}",
277
+ "--package",
278
+ config.package,
279
+ ],
280
+ ["ecppack", cfg_file, config.artifact],
281
+ ]
282
+
283
+ raise DevlabError(f"Unsupported FPGA family: {config.family}")
284
+
285
+
286
+ def doctor(strict: bool = False) -> int:
287
+ checks = {
288
+ "yosys": find_executable("yosys", install_path()),
289
+ "ghdl": find_executable("ghdl", install_path()),
290
+ "nextpnr-himbaechel": find_executable("nextpnr-himbaechel", install_path()),
291
+ "gowin_pack": find_executable("gowin_pack", install_path()),
292
+ "nextpnr-ice40": find_executable("nextpnr-ice40", install_path()),
293
+ "nextpnr-ecp5": find_executable("nextpnr-ecp5", install_path()),
294
+ "icepack": find_executable("icepack", install_path()),
295
+ "ecppack": find_executable("ecppack", install_path()),
296
+ "openFPGALoader": find_executable("openFPGALoader", install_path()),
297
+ }
298
+ failed = False
299
+ for name, executable in checks.items():
300
+ if executable:
301
+ print(f"ok {name}: {executable}")
302
+ else:
303
+ failed = True
304
+ print(f"missing {name}")
305
+ if failed:
306
+ print("Run `devlab install` to install OSS CAD Suite.")
307
+ return 1 if strict and failed else 0
308
+
309
+
310
+ def _yosys_command(config: BuildConfig, synth_script: str) -> list[str]:
311
+ hdl = _source_hdl(config.sources)
312
+ if hdl == "vhdl":
313
+ sources = " ".join(shlex.quote(source) for source in config.sources)
314
+ return [
315
+ "yosys",
316
+ "-m",
317
+ "ghdl",
318
+ "-p",
319
+ f"ghdl --std=08 {sources} -e {config.top}; {synth_script}",
320
+ ]
321
+ return ["yosys", "-p", synth_script, *config.sources]
322
+
323
+
324
+ def _source_hdl(sources: list[str]) -> str:
325
+ has_vhdl = any(Path(source).suffix.lower() in {".vhd", ".vhdl"} for source in sources)
326
+ has_verilog = any(
327
+ Path(source).suffix.lower() in {".v", ".vh", ".sv", ".svh"} for source in sources
328
+ )
329
+ if has_vhdl and has_verilog:
330
+ raise DevlabError("Mixed VHDL and Verilog sources are not supported yet.")
331
+ if has_vhdl:
332
+ return "vhdl"
333
+ return "verilog"
334
+
335
+
336
+ def _section(data: dict[str, Any], name: str) -> dict[str, Any]:
337
+ value = data.get(name, {})
338
+ if not isinstance(value, dict):
339
+ raise DevlabError(f"[{name}] must be a table in devlab.toml")
340
+ return value
341
+
342
+
343
+ def _run(command: list[str], dry_run: bool) -> None:
344
+ resolved = find_executable(command[0], install_path())
345
+ if not resolved:
346
+ if dry_run:
347
+ print("+ " + " ".join(command))
348
+ return
349
+ raise DevlabError(
350
+ f"Required command not found: {command[0]}. Run `devlab install` first."
351
+ )
352
+
353
+ executable_command = [resolved, *command[1:]]
354
+ print("+ " + " ".join(executable_command))
355
+ if dry_run:
356
+ return
357
+ subprocess.check_call(executable_command, env=env_with_toolchain(install_path()))
358
+
359
+
360
+ def _write(path: Path, content: str, force: bool) -> None:
361
+ if path.exists() and not force:
362
+ raise DevlabError(f"File already exists: {path}")
363
+ path.write_text(content, encoding="utf-8")
364
+
365
+
366
+ def _template_config(source_name: str = "top.v") -> str:
367
+ return """[fpga]
368
+ family = "GW1N-9C"
369
+ device = "GW1NR-LV9QN88PC6/I5"
370
+ cst = "pins.cst"
371
+
372
+ [build]
373
+ top = "top"
374
+ sources = ["src/%s"]
375
+ constraints = "pins.cst"
376
+ build_dir = "build"
377
+
378
+ [flash]
379
+ board = "tangnano9k"
380
+ mode = "sram"
381
+ verify = false
382
+ """ % source_name
383
+
384
+
385
+ def _template_top(hdl: str = "verilog") -> str:
386
+ if hdl == "vhdl":
387
+ return """library ieee;
388
+ use ieee.std_logic_1164.all;
389
+ use ieee.numeric_std.all;
390
+
391
+ entity top is
392
+ port (
393
+ clk : in std_logic;
394
+ led : out std_logic
395
+ );
396
+ end entity top;
397
+
398
+ architecture rtl of top is
399
+ signal counter : unsigned(23 downto 0) := (others => '0');
400
+ begin
401
+ process (clk)
402
+ begin
403
+ if rising_edge(clk) then
404
+ counter <= counter + 1;
405
+ led <= counter(23);
406
+ end if;
407
+ end process;
408
+ end architecture rtl;
409
+ """
410
+
411
+ return """module top (
412
+ input wire clk,
413
+ output reg led
414
+ );
415
+ reg [23:0] counter = 0;
416
+
417
+ always @(posedge clk) begin
418
+ counter <= counter + 1;
419
+ led <= counter[23];
420
+ end
421
+ endmodule
422
+ """
423
+
424
+
425
+ def _template_constraints() -> str:
426
+ return """// Update these pins for your GW1NR-LV9QN88PC6/I5 board.
427
+ // Example Gowin CST syntax:
428
+ // IO_LOC "clk" 52;
429
+ // IO_LOC "led" 10;
430
+ """
devlab/toolchain.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import shutil
6
+ import stat
7
+ import subprocess
8
+ import sys
9
+ import tarfile
10
+ import tempfile
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from .errors import DevlabError
16
+ from .platforms import PlatformId, current_platform
17
+
18
+
19
+ OSS_CAD_SUITE_TAG = "2026-07-06"
20
+ OSS_CAD_SUITE_RELEASE_URL = (
21
+ "https://github.com/YosysHQ/oss-cad-suite-build/releases/tag/2026-07-06"
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ToolchainAsset:
27
+ platform: str
28
+ name: str
29
+ url: str
30
+ sha256: str
31
+
32
+ @property
33
+ def suffix(self) -> str:
34
+ return Path(self.name).suffix
35
+
36
+
37
+ OSS_CAD_SUITE_ASSETS: dict[str, ToolchainAsset] = {
38
+ "darwin-arm64": ToolchainAsset(
39
+ platform="darwin-arm64",
40
+ name="oss-cad-suite-darwin-arm64-20260706.tgz",
41
+ url="https://github.com/YosysHQ/oss-cad-suite-build/releases/download/2026-07-06/oss-cad-suite-darwin-arm64-20260706.tgz",
42
+ sha256="8e7677c6876000c19f49582ffcefbbe6ee49ddb802170a9f86eca95dcba7ed67",
43
+ ),
44
+ "darwin-x64": ToolchainAsset(
45
+ platform="darwin-x64",
46
+ name="oss-cad-suite-darwin-x64-20260706.tgz",
47
+ url="https://github.com/YosysHQ/oss-cad-suite-build/releases/download/2026-07-06/oss-cad-suite-darwin-x64-20260706.tgz",
48
+ sha256="5adceaa550b1ce9961ce81f0ea0ef2245fcb665863fb970e068c17aab83779ad",
49
+ ),
50
+ "linux-arm64": ToolchainAsset(
51
+ platform="linux-arm64",
52
+ name="oss-cad-suite-linux-arm64-20260706.tgz",
53
+ url="https://github.com/YosysHQ/oss-cad-suite-build/releases/download/2026-07-06/oss-cad-suite-linux-arm64-20260706.tgz",
54
+ sha256="0943e9eda87ee49b5c96aacc543bbe4301332467f0b2e7a374028608bcf4c961",
55
+ ),
56
+ "linux-x64": ToolchainAsset(
57
+ platform="linux-x64",
58
+ name="oss-cad-suite-linux-x64-20260706.tgz",
59
+ url="https://github.com/YosysHQ/oss-cad-suite-build/releases/download/2026-07-06/oss-cad-suite-linux-x64-20260706.tgz",
60
+ sha256="213a50813e809637f37dd86bb03cad7a58e726f841e7685d3a3062ef2492c315",
61
+ ),
62
+ "windows-x64": ToolchainAsset(
63
+ platform="windows-x64",
64
+ name="oss-cad-suite-windows-x64-20260706.exe",
65
+ url="https://github.com/YosysHQ/oss-cad-suite-build/releases/download/2026-07-06/oss-cad-suite-windows-x64-20260706.exe",
66
+ sha256="e8ab814d490d89163e418dc634842cf086ea305dde0c32f832528194a5b93ac9",
67
+ ),
68
+ }
69
+
70
+
71
+ def devlab_home() -> Path:
72
+ return Path(os.environ.get("DEVLAB_HOME", Path.home() / ".devlab")).expanduser()
73
+
74
+
75
+ def toolchains_dir(home: Path | None = None) -> Path:
76
+ return (home or devlab_home()) / "toolchains"
77
+
78
+
79
+ def cache_dir(home: Path | None = None) -> Path:
80
+ return (home or devlab_home()) / "cache"
81
+
82
+
83
+ def select_asset(platform_id: PlatformId | None = None) -> ToolchainAsset:
84
+ platform_id = platform_id or current_platform()
85
+ try:
86
+ return OSS_CAD_SUITE_ASSETS[platform_id.key]
87
+ except KeyError as exc:
88
+ supported = ", ".join(sorted(OSS_CAD_SUITE_ASSETS))
89
+ raise DevlabError(
90
+ f"Unsupported platform for OSS CAD Suite: {platform_id.key}. "
91
+ f"Supported: {supported}"
92
+ ) from exc
93
+
94
+
95
+ def install_path(asset: ToolchainAsset | None = None, home: Path | None = None) -> Path:
96
+ asset = asset or select_asset()
97
+ return toolchains_dir(home) / f"oss-cad-suite-{OSS_CAD_SUITE_TAG}-{asset.platform}"
98
+
99
+
100
+ def archive_path(asset: ToolchainAsset | None = None, home: Path | None = None) -> Path:
101
+ asset = asset or select_asset()
102
+ return cache_dir(home) / asset.name
103
+
104
+
105
+ def bin_dir(path: Path | None = None) -> Path:
106
+ path = path or install_path()
107
+ return path / "bin"
108
+
109
+
110
+ def env_with_toolchain(path: Path | None = None) -> dict[str, str]:
111
+ env = os.environ.copy()
112
+ binary_dir = str(bin_dir(path))
113
+ env["PATH"] = binary_dir + os.pathsep + env.get("PATH", "")
114
+ return env
115
+
116
+
117
+ def find_executable(name: str, path: Path | None = None) -> str | None:
118
+ suffixes = [""]
119
+ if sys.platform.startswith("win"):
120
+ suffixes = [".exe", ".bat", ".cmd", ""]
121
+
122
+ candidates: list[str] = []
123
+ binary_dir = bin_dir(path)
124
+ for suffix in suffixes:
125
+ candidates.append(str(binary_dir / f"{name}{suffix}"))
126
+
127
+ for candidate in candidates:
128
+ if Path(candidate).exists():
129
+ return candidate
130
+ return shutil.which(name, path=env_with_toolchain(path).get("PATH"))
131
+
132
+
133
+ def download_asset(asset: ToolchainAsset, destination: Path, force: bool = False) -> Path:
134
+ destination.parent.mkdir(parents=True, exist_ok=True)
135
+ if destination.exists() and not force:
136
+ verify_sha256(destination, asset.sha256)
137
+ return destination
138
+
139
+ tmp_destination = destination.with_suffix(destination.suffix + ".part")
140
+ if tmp_destination.exists():
141
+ tmp_destination.unlink()
142
+
143
+ def report(blocks: int, block_size: int, total_size: int) -> None:
144
+ if total_size <= 0:
145
+ return
146
+ downloaded = min(blocks * block_size, total_size)
147
+ percent = downloaded * 100 / total_size
148
+ print(f"\rDownloading {asset.name}: {percent:5.1f}%", end="", flush=True)
149
+
150
+ try:
151
+ urllib.request.urlretrieve(asset.url, tmp_destination, reporthook=report)
152
+ print()
153
+ except OSError as exc:
154
+ raise DevlabError(f"Could not download {asset.url}: {exc}") from exc
155
+
156
+ verify_sha256(tmp_destination, asset.sha256)
157
+ tmp_destination.replace(destination)
158
+ return destination
159
+
160
+
161
+ def verify_sha256(path: Path, expected: str) -> None:
162
+ digest = hashlib.sha256()
163
+ with path.open("rb") as handle:
164
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
165
+ digest.update(chunk)
166
+ actual = digest.hexdigest()
167
+ if actual != expected:
168
+ raise DevlabError(
169
+ f"SHA-256 mismatch for {path.name}: expected {expected}, got {actual}"
170
+ )
171
+
172
+
173
+ def install_oss_cad_suite(
174
+ home: Path | None = None,
175
+ force: bool = False,
176
+ run_windows_installer: bool = False,
177
+ ) -> Path:
178
+ home = home or devlab_home()
179
+ asset = select_asset()
180
+ destination = install_path(asset, home)
181
+
182
+ if destination.exists() and not force:
183
+ return destination
184
+
185
+ archive = download_asset(asset, archive_path(asset, home), force=force)
186
+
187
+ if asset.name.endswith(".exe"):
188
+ if not run_windows_installer:
189
+ raise DevlabError(
190
+ "Windows uses the OSS CAD Suite .exe installer. "
191
+ f"The file was downloaded to {archive}. Re-run with "
192
+ "--run-installer to execute it."
193
+ )
194
+ subprocess.check_call([str(archive)])
195
+ return destination
196
+
197
+ _extract_tarball(archive, destination, force=force)
198
+ _mark_executables(bin_dir(destination))
199
+ return destination
200
+
201
+
202
+ def _extract_tarball(archive: Path, destination: Path, force: bool) -> None:
203
+ destination.parent.mkdir(parents=True, exist_ok=True)
204
+ if destination.exists():
205
+ if not force:
206
+ raise DevlabError(f"Install destination already exists: {destination}")
207
+ shutil.rmtree(destination)
208
+
209
+ with tempfile.TemporaryDirectory(prefix="devlab-oss-cad-suite-") as tmp:
210
+ tmp_path = Path(tmp)
211
+ with tarfile.open(archive, "r:gz") as tar:
212
+ _safe_extract(tar, tmp_path)
213
+
214
+ children = [child for child in tmp_path.iterdir()]
215
+ if len(children) == 1 and children[0].is_dir():
216
+ shutil.move(str(children[0]), destination)
217
+ else:
218
+ destination.mkdir(parents=True)
219
+ for child in children:
220
+ shutil.move(str(child), destination / child.name)
221
+
222
+
223
+ def _safe_extract(tar: tarfile.TarFile, destination: Path) -> None:
224
+ destination = destination.resolve()
225
+ for member in tar.getmembers():
226
+ target = (destination / member.name).resolve()
227
+ if destination != target and destination not in target.parents:
228
+ raise DevlabError(f"Unsafe path in archive: {member.name}")
229
+ try:
230
+ tar.extractall(destination, filter="data")
231
+ except TypeError: # pragma: no cover - Python < 3.12
232
+ tar.extractall(destination)
233
+
234
+
235
+ def _mark_executables(path: Path) -> None:
236
+ if not path.exists():
237
+ return
238
+ for file_path in path.iterdir():
239
+ if not file_path.is_file():
240
+ continue
241
+ mode = file_path.stat().st_mode
242
+ file_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: devlab-fpga
3
+ Version: 0.1.0
4
+ Summary: FPGA development helper for installing OSS CAD Suite and running build/flash flows.
5
+ Author-email: Mrju10 <name@email.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/unit-electronics/unit_devlab_lib
8
+ Project-URL: Source, https://github.com/unit-electronics/unit_devlab_lib
9
+ Keywords: fpga,yosys,oss-cad-suite,openfpgaloader,devlab,devlab-fpga
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Software Development :: Build Tools
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: tomli>=2.0.1; python_version < "3.11"
23
+ Dynamic: license-file
24
+
25
+ # devlab
26
+
27
+ `devlab` is a Python CLI package for FPGA development. It installs the
28
+ matching OSS CAD Suite build for the current operating system, creates a small
29
+ FPGA project, and runs build/flash commands from `devlab.toml`.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install devlab-fpga
35
+ ```
36
+
37
+ For local development from this repository:
38
+
39
+ ```bash
40
+ pip install -e .
41
+ ```
42
+
43
+ ## Commands
44
+
45
+ ```bash
46
+ devlab doctor
47
+ devlab install
48
+ devlab new blink
49
+ devlab new blink-vhdl --hdl vhdl
50
+ cd blink
51
+ devlab build
52
+ devlab flash
53
+ ```
54
+
55
+ `devlab install` downloads OSS CAD Suite release `2026-07-06` from
56
+ YosysHQ. The installer selects the correct asset for:
57
+
58
+ - Linux x64
59
+ - Linux arm64
60
+ - macOS x64
61
+ - macOS arm64
62
+ - Windows x64
63
+
64
+ The default install location is `~/.devlab`. Set `DEVLAB_HOME` to use a
65
+ different directory.
66
+
67
+ ## OSS CAD Suite Source
68
+
69
+ Default release:
70
+
71
+ https://github.com/YosysHQ/oss-cad-suite-build/releases/tag/2026-07-06
72
+
73
+ Example assets:
74
+
75
+ ```text
76
+ https://github.com/YosysHQ/oss-cad-suite-build/releases/download/2026-07-06/oss-cad-suite-linux-arm64-20260706.tgz
77
+ https://github.com/YosysHQ/oss-cad-suite-build/releases/download/2026-07-06/oss-cad-suite-windows-x64-20260706.exe
78
+ ```
79
+
80
+ Downloaded archives are verified with the SHA-256 digest published by the
81
+ GitHub release API.
82
+
83
+ ## Project Format
84
+
85
+ `devlab new blink` creates:
86
+
87
+ ```text
88
+ blink/
89
+ devlab.toml
90
+ pins.cst
91
+ src/top.v
92
+ ```
93
+
94
+ Use `--hdl vhdl` to create `src/top.vhd` instead:
95
+
96
+ ```bash
97
+ devlab new blink-vhdl --hdl vhdl
98
+ ```
99
+
100
+ Default `devlab.toml`:
101
+
102
+ ```toml
103
+ [fpga]
104
+ family = "GW1N-9C"
105
+ device = "GW1NR-LV9QN88PC6/I5"
106
+ cst = "pins.cst"
107
+
108
+ [build]
109
+ top = "top"
110
+ sources = ["src/top.v"]
111
+ constraints = "pins.cst"
112
+ build_dir = "build"
113
+
114
+ [flash]
115
+ board = "tangnano9k"
116
+ mode = "sram"
117
+ verify = false
118
+ ```
119
+
120
+ For Gowin, `family` is the FPGA series used by the packer and place-and-route
121
+ flow, for example `GW1N-9C`. `device` is the complete part number, for example
122
+ `GW1NR-LV9QN88PC6/I5`. `pins.cst` is the Gowin constraints file.
123
+
124
+ Update `pins.cst` and `[flash].board` for the real FPGA board before building
125
+ and flashing hardware.
126
+
127
+ ## Build Flows
128
+
129
+ For Gowin `GW1N-9C` / `GW1NR-LV9QN88PC6/I5`, `devlab build` runs:
130
+
131
+ ```bash
132
+ yosys
133
+ nextpnr-himbaechel
134
+ gowin_pack
135
+ ```
136
+
137
+ The iCE40 and ECP5 flows are still available by setting `family = "ice40"` or
138
+ `family = "ecp5"` in `devlab.toml`.
139
+
140
+ For iCE40, it runs:
141
+
142
+ ```bash
143
+ yosys
144
+ nextpnr-ice40
145
+ icepack
146
+ ```
147
+
148
+ `devlab flash` uses `openFPGALoader`. By default it writes SRAM, so the FPGA
149
+ loses the design after power cycling. Use flash mode to write the bitstream to
150
+ non-volatile memory:
151
+
152
+ ```bash
153
+ devlab flash --mode flash
154
+ ```
155
+
156
+ Before writing flash on board variants where flash may or may not be populated,
157
+ run detection:
158
+
159
+ ```bash
160
+ devlab flash --detect
161
+ ```
162
+
163
+ Persistent flash can also be configured in `devlab.toml`:
164
+
165
+ ```toml
166
+ [flash]
167
+ board = "tangnano9k"
168
+ mode = "flash"
169
+ verify = false
170
+ # external_flash = true
171
+ # offset = "0"
172
+ ```
173
+
174
+ For Tang Nano 9K/Gowin, flash write verification may print
175
+ `writing verification not supported` and fail the CRC check even when the flash
176
+ write completed. Keep `verify = false` unless the selected board/programming
177
+ path explicitly supports flash verification.
178
+
179
+ When sources end in `.vhd` or `.vhdl`, `devlab build` runs Yosys with the
180
+ GHDL plugin before synthesis. OSS CAD Suite is expected to provide that plugin.
181
+
182
+ Use `--dry-run` to print the commands without executing them:
183
+
184
+ ```bash
185
+ devlab build --dry-run
186
+ devlab flash --dry-run --board tangnano9k
187
+ devlab flash --dry-run --mode flash
188
+ ```
@@ -0,0 +1,13 @@
1
+ devlab/__init__.py,sha256=8YbSz2Aj8SQbG_L7_RgQ-ygB4R-qq6II-Tvv4fiAcD4,112
2
+ devlab/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
3
+ devlab/cli.py,sha256=X5TyrC72x4XefcI3QgJnbBcCcrutdRKGd0z5kQwPwGM,5726
4
+ devlab/errors.py,sha256=RFT2YqJthU2N7wdYZZ5YJXkmrFr34kYyyT2JJL1MXXo,89
5
+ devlab/platforms.py,sha256=xOHG2f1u7G3dB4tXM-PlJgMQ_qrkjTIlPgBnhxLaXNI,946
6
+ devlab/project.py,sha256=dZ2YuGOMV3cqMhgXin-Ny4wa7LB8IpbYGFeJHK1YiBI,13050
7
+ devlab/toolchain.py,sha256=b-GEDbdVcNtGPNHk4zf3ZY279n5KOLGoslEQWMTkLWw,8336
8
+ devlab_fpga-0.1.0.dist-info/licenses/LICENSE,sha256=nrbPcnyVbAcWTBTG_nJt3M2yHrL-zucDeHC02dJ09Fo,1063
9
+ devlab_fpga-0.1.0.dist-info/METADATA,sha256=Mwqi0BP_I673k4lXJ1m9mwNUC13zqNhQp4fA_O3xZKc,4448
10
+ devlab_fpga-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ devlab_fpga-0.1.0.dist-info/entry_points.txt,sha256=5kjH9FbXX8jasuwPZH1hTpVrGc4-jbOje92tLitLYvg,43
12
+ devlab_fpga-0.1.0.dist-info/top_level.txt,sha256=dVqsvPXb6Uy0rMo_xDjEyvHLc-v_B28HCh0bKP3-saA,7
13
+ devlab_fpga-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devlab = devlab.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mrju10
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ devlab