easyeda-monkey 2026.5.26__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.
- easyeda_monkey/__init__.py +3 -0
- easyeda_monkey/cli.py +50 -0
- easyeda_monkey/cli_command_types.py +14 -0
- easyeda_monkey/cli_commands/__init__.py +19 -0
- easyeda_monkey/cli_commands/fetch_part.py +171 -0
- easyeda_monkey/easyeda_3d_model.py +213 -0
- easyeda_monkey/easyeda_api.py +118 -0
- easyeda_monkey/easyeda_footprint.py +300 -0
- easyeda_monkey/easyeda_pad.py +79 -0
- easyeda_monkey/easyeda_pin.py +168 -0
- easyeda_monkey/easyeda_shapes.py +274 -0
- easyeda_monkey/easyeda_svg_path.py +176 -0
- easyeda_monkey/easyeda_symbol.py +217 -0
- easyeda_monkey/easyeda_types.py +71 -0
- easyeda_monkey-2026.5.26.dist-info/METADATA +161 -0
- easyeda_monkey-2026.5.26.dist-info/RECORD +19 -0
- easyeda_monkey-2026.5.26.dist-info/WHEEL +4 -0
- easyeda_monkey-2026.5.26.dist-info/entry_points.txt +2 -0
- easyeda_monkey-2026.5.26.dist-info/licenses/LICENSE +21 -0
easyeda_monkey/cli.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Top-level command-line orchestrator for EasyEDA Monkey."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .cli_commands import COMMANDS as CLI_COMMANDS
|
|
12
|
+
from .cli_commands import register_commands
|
|
13
|
+
from .cli_commands.fetch_part import normalize_lcsc_id
|
|
14
|
+
|
|
15
|
+
__all__ = ["CLI_COMMANDS", "build_parser", "main", "normalize_lcsc_id"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
"""Build the EasyEDA Monkey argument parser."""
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="easyeda-monkey",
|
|
22
|
+
description="EasyEDA / LCSC parsing utilities.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--version",
|
|
26
|
+
action="version",
|
|
27
|
+
version=f"%(prog)s {__version__}",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
register_commands(subparsers)
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
36
|
+
"""Run the EasyEDA Monkey CLI."""
|
|
37
|
+
parser = build_parser()
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
handler = getattr(args, "handler", None)
|
|
40
|
+
if handler is None:
|
|
41
|
+
parser.print_help(sys.stderr)
|
|
42
|
+
return 2
|
|
43
|
+
return cast("CommandHandler", handler)(args)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
CommandHandler = Callable[[argparse.Namespace], int]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Shared CLI command metadata types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class CliCommandSpec:
|
|
10
|
+
"""Public CLI command metadata used by docs and signoff tests."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
design_doc: str
|
|
14
|
+
help: str
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""CLI subcommand registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..cli_command_types import CliCommandSpec
|
|
8
|
+
from . import fetch_part
|
|
9
|
+
|
|
10
|
+
COMMANDS: tuple[CliCommandSpec, ...] = (
|
|
11
|
+
fetch_part.COMMAND,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_commands(
|
|
16
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Register all public CLI subcommands with an argparse parser."""
|
|
19
|
+
fetch_part.register(subparsers)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Implementation of the fetch-part CLI subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from ..cli_command_types import CliCommandSpec
|
|
13
|
+
from ..easyeda_api import EasyEdaApiClient
|
|
14
|
+
|
|
15
|
+
JsonScalar = str | int | float | bool | None
|
|
16
|
+
JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
|
17
|
+
JsonObject = dict[str, JsonValue]
|
|
18
|
+
|
|
19
|
+
COMMAND = CliCommandSpec(
|
|
20
|
+
name="fetch-part",
|
|
21
|
+
design_doc="cli/fetch-part.html",
|
|
22
|
+
help="Fetch an EasyEDA / LCSC component by C-number.",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register(
|
|
27
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Register the fetch-part parser."""
|
|
30
|
+
parser = subparsers.add_parser(
|
|
31
|
+
COMMAND.name,
|
|
32
|
+
help=COMMAND.help,
|
|
33
|
+
description="Fetch an EasyEDA / LCSC component by C-number.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("lcsc_id", help="LCSC part number, such as C21190 or 21190.")
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--cache-dir",
|
|
38
|
+
type=Path,
|
|
39
|
+
default=None,
|
|
40
|
+
help="Optional directory for cached API JSON responses.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--output",
|
|
44
|
+
type=Path,
|
|
45
|
+
default=None,
|
|
46
|
+
help="Optional output JSON file. Defaults to stdout.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--raw",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Emit the raw EasyEDA API response instead of the compact summary.",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--timeout",
|
|
55
|
+
type=int,
|
|
56
|
+
default=15,
|
|
57
|
+
help="HTTP timeout in seconds when a network fetch is needed.",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--rate-limit-seconds",
|
|
61
|
+
type=float,
|
|
62
|
+
default=0.5,
|
|
63
|
+
help="Minimum delay between live API requests for this process.",
|
|
64
|
+
)
|
|
65
|
+
parser.set_defaults(handler=run)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_lcsc_id(value: str) -> str:
|
|
69
|
+
"""Normalize a user supplied LCSC C-number."""
|
|
70
|
+
lcsc_id = value.strip().upper()
|
|
71
|
+
if not lcsc_id:
|
|
72
|
+
raise ValueError("LCSC id cannot be empty")
|
|
73
|
+
if not lcsc_id.startswith("C"):
|
|
74
|
+
lcsc_id = f"C{lcsc_id}"
|
|
75
|
+
return lcsc_id
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def summarize_component(component_data: Mapping[str, JsonValue], lcsc_id: str) -> JsonObject:
|
|
79
|
+
"""Build a compact summary for an EasyEDA component API response."""
|
|
80
|
+
result = component_data.get("result")
|
|
81
|
+
if not isinstance(result, dict):
|
|
82
|
+
return {
|
|
83
|
+
"lcsc_id": lcsc_id,
|
|
84
|
+
"found": False,
|
|
85
|
+
"title": "",
|
|
86
|
+
"symbol": {"shape_count": 0},
|
|
87
|
+
"footprint": {"shape_count": 0},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
data_str = _dict_or_empty(result.get("dataStr"))
|
|
91
|
+
symbol_shapes = _list_or_empty(data_str.get("shape"))
|
|
92
|
+
|
|
93
|
+
package_detail = _dict_or_empty(result.get("packageDetail"))
|
|
94
|
+
footprint_data = _dict_or_empty(package_detail.get("dataStr"))
|
|
95
|
+
footprint_shapes = _list_or_empty(footprint_data.get("shape"))
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"lcsc_id": _lcsc_id_from_result(result, default=lcsc_id),
|
|
99
|
+
"found": True,
|
|
100
|
+
"title": _str_or_empty(result.get("title")),
|
|
101
|
+
"uuid": _str_or_empty(result.get("uuid")),
|
|
102
|
+
"symbol": {
|
|
103
|
+
"shape_count": len(symbol_shapes),
|
|
104
|
+
"has_data": bool(symbol_shapes),
|
|
105
|
+
},
|
|
106
|
+
"footprint": {
|
|
107
|
+
"shape_count": len(footprint_shapes),
|
|
108
|
+
"has_data": bool(footprint_shapes),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run(args: argparse.Namespace) -> int:
|
|
114
|
+
"""Run the fetch-part subcommand."""
|
|
115
|
+
try:
|
|
116
|
+
lcsc_id = normalize_lcsc_id(args.lcsc_id)
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
119
|
+
return 2
|
|
120
|
+
|
|
121
|
+
client = EasyEdaApiClient(
|
|
122
|
+
cache_dir=args.cache_dir,
|
|
123
|
+
timeout=args.timeout,
|
|
124
|
+
rate_limit_seconds=args.rate_limit_seconds,
|
|
125
|
+
)
|
|
126
|
+
component_data = cast(JsonObject, client.fetch_component(lcsc_id))
|
|
127
|
+
if not component_data:
|
|
128
|
+
sys.stderr.write(f"error: no EasyEDA component data found for {lcsc_id}\n")
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
payload = component_data if args.raw else summarize_component(component_data, lcsc_id)
|
|
132
|
+
_emit_json(payload, args.output)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _dict_or_empty(value: JsonValue | object) -> Mapping[str, JsonValue]:
|
|
137
|
+
"""Return a JSON object view when value is a dictionary."""
|
|
138
|
+
if isinstance(value, dict):
|
|
139
|
+
return cast(Mapping[str, JsonValue], value)
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _list_or_empty(value: JsonValue | object) -> list[JsonValue]:
|
|
144
|
+
"""Return a JSON list when value is a list."""
|
|
145
|
+
if isinstance(value, list):
|
|
146
|
+
return cast(list[JsonValue], value)
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _str_or_empty(value: JsonValue | object) -> str:
|
|
151
|
+
"""Return a string value or an empty string."""
|
|
152
|
+
return value if isinstance(value, str) else ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _lcsc_id_from_result(result: Mapping[str, JsonValue], *, default: str) -> str:
|
|
156
|
+
"""Extract the LCSC C-number from a result object."""
|
|
157
|
+
lcsc = _dict_or_empty(result.get("lcsc"))
|
|
158
|
+
szlcsc = _dict_or_empty(result.get("szlcsc"))
|
|
159
|
+
return _str_or_empty(lcsc.get("number")) or _str_or_empty(szlcsc.get("number")) or default
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _emit_json(payload: JsonObject, output: Path | None) -> None:
|
|
163
|
+
"""Write JSON to stdout or an output file."""
|
|
164
|
+
text = json.dumps(payload, indent=2, sort_keys=True)
|
|
165
|
+
if output is None:
|
|
166
|
+
sys.stdout.write(text)
|
|
167
|
+
sys.stdout.write("\n")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
output.write_text(text + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EasyEDA 3D model reference and download support.
|
|
3
|
+
|
|
4
|
+
3D models are referenced by UUID in the footprint data and downloaded
|
|
5
|
+
from the EasyEDA modules API as STEP or OBJ files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_STEP_URL_BASE = "https://modules.easyeda.com/qAxj6KHrDKw4blvCG8QJPs7Y"
|
|
19
|
+
_OBJ_URL_BASE = "https://modules.easyeda.com/3dmodel"
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .easyeda_footprint import EasyEdaFootprint
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class EasyEda3DModel:
|
|
27
|
+
"""3D model reference extracted from an EasyEDA footprint."""
|
|
28
|
+
|
|
29
|
+
uuid: str = ""
|
|
30
|
+
title: str = ""
|
|
31
|
+
translation_x: float = 0.0
|
|
32
|
+
translation_y: float = 0.0
|
|
33
|
+
translation_z: float = 0.0
|
|
34
|
+
rotation_x: float = 0.0
|
|
35
|
+
rotation_y: float = 0.0
|
|
36
|
+
rotation_z: float = 0.0
|
|
37
|
+
raw_attrs: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def step_url(self) -> str:
|
|
41
|
+
"""URL to download STEP file."""
|
|
42
|
+
if not self.uuid:
|
|
43
|
+
return ""
|
|
44
|
+
return f"{_STEP_URL_BASE}/{self.uuid}"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def obj_url(self) -> str:
|
|
48
|
+
"""URL to download OBJ file."""
|
|
49
|
+
if not self.uuid:
|
|
50
|
+
return ""
|
|
51
|
+
return f"{_OBJ_URL_BASE}/{self.uuid}"
|
|
52
|
+
|
|
53
|
+
def to_json(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"uuid": self.uuid,
|
|
56
|
+
"title": self.title,
|
|
57
|
+
"translation_x": self.translation_x,
|
|
58
|
+
"translation_y": self.translation_y,
|
|
59
|
+
"translation_z": self.translation_z,
|
|
60
|
+
"rotation_x": self.rotation_x,
|
|
61
|
+
"rotation_y": self.rotation_y,
|
|
62
|
+
"rotation_z": self.rotation_z,
|
|
63
|
+
"step_url": self.step_url,
|
|
64
|
+
"obj_url": self.obj_url,
|
|
65
|
+
"raw_attrs": self.raw_attrs,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_json(cls, data: dict[str, Any]) -> EasyEda3DModel:
|
|
70
|
+
return cls(
|
|
71
|
+
uuid=data.get("uuid", ""),
|
|
72
|
+
title=data.get("title", ""),
|
|
73
|
+
translation_x=float(data.get("translation_x", 0)),
|
|
74
|
+
translation_y=float(data.get("translation_y", 0)),
|
|
75
|
+
translation_z=float(data.get("translation_z", 0)),
|
|
76
|
+
rotation_x=float(data.get("rotation_x", 0)),
|
|
77
|
+
rotation_y=float(data.get("rotation_y", 0)),
|
|
78
|
+
rotation_z=float(data.get("rotation_z", 0)),
|
|
79
|
+
raw_attrs=data.get("raw_attrs", {}),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def download_step(self, output_path: str | Path, timeout: int = 30) -> bool:
|
|
83
|
+
"""Download STEP file to the given path. Returns True on success."""
|
|
84
|
+
if not self.uuid:
|
|
85
|
+
log.warning("No 3D model UUID — cannot download")
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
import requests
|
|
89
|
+
|
|
90
|
+
url = self.step_url
|
|
91
|
+
log.info("Downloading STEP from %s", url)
|
|
92
|
+
try:
|
|
93
|
+
resp = requests.get(url, timeout=timeout)
|
|
94
|
+
resp.raise_for_status()
|
|
95
|
+
Path(output_path).write_bytes(resp.content)
|
|
96
|
+
log.info("Saved STEP (%d bytes) to %s", len(resp.content), output_path)
|
|
97
|
+
return True
|
|
98
|
+
except Exception as e:
|
|
99
|
+
log.error("Failed to download STEP: %s", e)
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def download_obj(self, output_path: str | Path, timeout: int = 30) -> bool:
|
|
103
|
+
"""Download OBJ file to the given path. Returns True on success."""
|
|
104
|
+
if not self.uuid:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
import requests
|
|
108
|
+
|
|
109
|
+
url = self.obj_url
|
|
110
|
+
log.info("Downloading OBJ from %s", url)
|
|
111
|
+
try:
|
|
112
|
+
resp = requests.get(url, timeout=timeout)
|
|
113
|
+
resp.raise_for_status()
|
|
114
|
+
Path(output_path).write_text(resp.text, encoding="utf-8")
|
|
115
|
+
log.info("Saved OBJ (%d bytes) to %s", len(resp.content), output_path)
|
|
116
|
+
return True
|
|
117
|
+
except Exception as e:
|
|
118
|
+
log.error("Failed to download OBJ: %s", e)
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def extract_3d_model_from_footprint(ee_fp: EasyEdaFootprint) -> EasyEda3DModel | None:
|
|
123
|
+
"""
|
|
124
|
+
Extract 3D model reference from an EasyEdaFootprint's raw data.
|
|
125
|
+
|
|
126
|
+
EasyEDA stores 3D model info in SVGNODE elements within the footprint
|
|
127
|
+
shape list, containing JSON-encoded attributes with UUID, translation,
|
|
128
|
+
and rotation.
|
|
129
|
+
"""
|
|
130
|
+
# Look for the 3D model UUID in the footprint info
|
|
131
|
+
model_uuid = ee_fp.info.model_3d_uuid
|
|
132
|
+
if model_uuid:
|
|
133
|
+
return EasyEda3DModel(uuid=model_uuid, title=ee_fp.info.name)
|
|
134
|
+
|
|
135
|
+
# Fallback: search passthrough for SVGNODE-derived data
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def extract_3d_models_from_api_response(api_data: dict[str, Any]) -> list[EasyEda3DModel]:
|
|
140
|
+
"""
|
|
141
|
+
Extract all 3D model references from a raw API response.
|
|
142
|
+
|
|
143
|
+
Searches packageDetail.dataStr.shape for SVGNODE entries containing
|
|
144
|
+
3D model metadata.
|
|
145
|
+
"""
|
|
146
|
+
models: list[EasyEda3DModel] = []
|
|
147
|
+
result = api_data.get("result", {})
|
|
148
|
+
pkg_detail = result.get("packageDetail", {})
|
|
149
|
+
data_str = pkg_detail.get("dataStr", {})
|
|
150
|
+
shapes = data_str.get("shape", [])
|
|
151
|
+
|
|
152
|
+
for shape_str in shapes:
|
|
153
|
+
if not isinstance(shape_str, str):
|
|
154
|
+
continue
|
|
155
|
+
if "SVGNODE" not in shape_str and "3dmodel" not in shape_str.lower():
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
# Try to extract JSON from the SVGNODE
|
|
159
|
+
model = _parse_svgnode_3d(shape_str)
|
|
160
|
+
if model:
|
|
161
|
+
models.append(model)
|
|
162
|
+
|
|
163
|
+
# Also check head attributes for 3D model references
|
|
164
|
+
head = data_str.get("head", "")
|
|
165
|
+
if isinstance(head, str) and "3d" in head.lower():
|
|
166
|
+
# Some footprints embed the UUID in head attributes
|
|
167
|
+
uuid_match = re.search(r"([0-9a-f]{32}|[0-9a-f-]{36})", head)
|
|
168
|
+
if uuid_match and not models:
|
|
169
|
+
models.append(EasyEda3DModel(
|
|
170
|
+
uuid=uuid_match.group(1),
|
|
171
|
+
title=result.get("title", ""),
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
return models
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_svgnode_3d(shape_str: str) -> EasyEda3DModel | None:
|
|
178
|
+
"""Try to extract a 3D model reference from an SVGNODE shape string."""
|
|
179
|
+
# SVGNODE shapes contain JSON-like attribute strings
|
|
180
|
+
# Look for c_origin (translation) and c_rotation patterns
|
|
181
|
+
try:
|
|
182
|
+
# Extract UUID — look for a 32-char hex string or standard UUID
|
|
183
|
+
uuid_match = re.search(r'"uuid"\s*:\s*"([^"]+)"', shape_str)
|
|
184
|
+
if not uuid_match:
|
|
185
|
+
uuid_match = re.search(r"([0-9a-f]{32})", shape_str)
|
|
186
|
+
if not uuid_match:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
uuid = uuid_match.group(1)
|
|
190
|
+
|
|
191
|
+
# Extract translation
|
|
192
|
+
tx = ty = tz = 0.0
|
|
193
|
+
origin_match = re.search(r'"c_origin"\s*:\s*"([^"]*)"', shape_str)
|
|
194
|
+
if origin_match:
|
|
195
|
+
parts = origin_match.group(1).split(",")
|
|
196
|
+
if len(parts) >= 3:
|
|
197
|
+
tx, ty, tz = float(parts[0]), float(parts[1]), float(parts[2])
|
|
198
|
+
|
|
199
|
+
# Extract rotation
|
|
200
|
+
rx = ry = rz = 0.0
|
|
201
|
+
rot_match = re.search(r'"c_rotation"\s*:\s*"([^"]*)"', shape_str)
|
|
202
|
+
if rot_match:
|
|
203
|
+
parts = rot_match.group(1).split(",")
|
|
204
|
+
if len(parts) >= 3:
|
|
205
|
+
rx, ry, rz = float(parts[0]), float(parts[1]), float(parts[2])
|
|
206
|
+
|
|
207
|
+
return EasyEda3DModel(
|
|
208
|
+
uuid=uuid,
|
|
209
|
+
translation_x=tx, translation_y=ty, translation_z=tz,
|
|
210
|
+
rotation_x=rx, rotation_y=ry, rotation_z=rz,
|
|
211
|
+
)
|
|
212
|
+
except (ValueError, IndexError, AttributeError):
|
|
213
|
+
return None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LCSC / EasyEDA component API client.
|
|
3
|
+
|
|
4
|
+
Fetches symbol, footprint, and 3D model data by LCSC part number.
|
|
5
|
+
Uses ``requests`` for HTTP — this is the only module with an external dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .easyeda_footprint import EasyEdaFootprint
|
|
17
|
+
from .easyeda_symbol import EasyEdaSymbol
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_API_BASE = "https://easyeda.com/api/products"
|
|
22
|
+
_API_VERSION = "6.4.19.5"
|
|
23
|
+
_USER_AGENT = "wn_pcb_tools/1.0"
|
|
24
|
+
_DEFAULT_TIMEOUT = 15
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EasyEdaApiClient:
|
|
28
|
+
"""
|
|
29
|
+
Client for the LCSC / EasyEDA component API.
|
|
30
|
+
|
|
31
|
+
Fetches component data by LCSC part number (e.g. ``C21190``).
|
|
32
|
+
Supports optional disk caching for offline use and test fixture generation.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
cache_dir: str | Path | None = None,
|
|
38
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
39
|
+
rate_limit_seconds: float = 0.5,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._cache_dir = Path(cache_dir) if cache_dir else None
|
|
42
|
+
self._timeout = timeout
|
|
43
|
+
self._rate_limit = rate_limit_seconds
|
|
44
|
+
self._last_request_time: float = 0.0
|
|
45
|
+
|
|
46
|
+
if self._cache_dir:
|
|
47
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
def fetch_component(self, lcsc_id: str) -> dict[str, Any]:
|
|
50
|
+
"""
|
|
51
|
+
Fetch raw API response for an LCSC component.
|
|
52
|
+
|
|
53
|
+
Returns the full JSON response dict. Caches to disk if cache_dir set.
|
|
54
|
+
"""
|
|
55
|
+
lcsc_id = lcsc_id.strip().upper()
|
|
56
|
+
if not lcsc_id.startswith("C"):
|
|
57
|
+
lcsc_id = f"C{lcsc_id}"
|
|
58
|
+
|
|
59
|
+
# Check cache first
|
|
60
|
+
if self._cache_dir:
|
|
61
|
+
cache_path = self._cache_dir / f"{lcsc_id}.json"
|
|
62
|
+
if cache_path.exists():
|
|
63
|
+
log.debug("Cache hit for %s", lcsc_id)
|
|
64
|
+
return json.loads(cache_path.read_text(encoding="utf-8"))
|
|
65
|
+
|
|
66
|
+
# Rate limiting
|
|
67
|
+
now = time.monotonic()
|
|
68
|
+
elapsed = now - self._last_request_time
|
|
69
|
+
if elapsed < self._rate_limit:
|
|
70
|
+
time.sleep(self._rate_limit - elapsed)
|
|
71
|
+
|
|
72
|
+
import requests
|
|
73
|
+
|
|
74
|
+
url = f"{_API_BASE}/{lcsc_id}/components?version={_API_VERSION}"
|
|
75
|
+
headers = {
|
|
76
|
+
"User-Agent": _USER_AGENT,
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log.info("Fetching %s from LCSC API", lcsc_id)
|
|
81
|
+
response = requests.get(url, headers=headers, timeout=self._timeout)
|
|
82
|
+
self._last_request_time = time.monotonic()
|
|
83
|
+
response.raise_for_status()
|
|
84
|
+
|
|
85
|
+
data = response.json()
|
|
86
|
+
|
|
87
|
+
if not data.get("success") or not data.get("result"):
|
|
88
|
+
log.warning("API returned empty/failed result for %s", lcsc_id)
|
|
89
|
+
return {}
|
|
90
|
+
|
|
91
|
+
# Cache to disk
|
|
92
|
+
if self._cache_dir:
|
|
93
|
+
cache_path = self._cache_dir / f"{lcsc_id}.json"
|
|
94
|
+
cache_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
95
|
+
log.debug("Cached %s (%d bytes)", lcsc_id, cache_path.stat().st_size)
|
|
96
|
+
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
def fetch_symbol(self, lcsc_id: str) -> EasyEdaSymbol:
|
|
100
|
+
"""Fetch and parse a schematic symbol by LCSC part number."""
|
|
101
|
+
data = self.fetch_component(lcsc_id)
|
|
102
|
+
if not data:
|
|
103
|
+
raise ValueError(f"No component data for {lcsc_id}")
|
|
104
|
+
return EasyEdaSymbol.from_json(data)
|
|
105
|
+
|
|
106
|
+
def fetch_footprint(self, lcsc_id: str) -> EasyEdaFootprint:
|
|
107
|
+
"""Fetch and parse a PCB footprint by LCSC part number."""
|
|
108
|
+
data = self.fetch_component(lcsc_id)
|
|
109
|
+
if not data:
|
|
110
|
+
raise ValueError(f"No component data for {lcsc_id}")
|
|
111
|
+
return EasyEdaFootprint.from_json(data)
|
|
112
|
+
|
|
113
|
+
def fetch_both(self, lcsc_id: str) -> tuple[EasyEdaSymbol, EasyEdaFootprint]:
|
|
114
|
+
"""Fetch and parse both symbol and footprint in a single API call."""
|
|
115
|
+
data = self.fetch_component(lcsc_id)
|
|
116
|
+
if not data:
|
|
117
|
+
raise ValueError(f"No component data for {lcsc_id}")
|
|
118
|
+
return EasyEdaSymbol.from_json(data), EasyEdaFootprint.from_json(data)
|