Skip to content

Commit 092b5e3

Browse files
committed
Add CI workflow with Docker-based network scanning tests
### Network Scanning Fixes - Add thread lock to serialize JLink connections during parallel scanning - Prevents pylink state conflicts when multiple threads connect simultaneously - Fix incorrect target detection (STM32F765ZG → STM32F103RE) - Add 500ms delay after disconnect to ensure device is fully released ### CI Workflow - Add GitHub Actions workflow with 7 comprehensive tests - Docker-based JLinkRemoteServer containers on custom network (192.168.3.0/24) - Tests cover USB scanning, network scanning, and negative cases - Strict validation: exactly 3 STM32F103RE devices at specified IPs ### Improvements - Simplify validation logic: only check target MCU and IPs - Clean Docker network management to prevent pool overlap errors - Remove excessive debug output for cleaner CI logs - Network scanning: port checks parallel, target detection sequential
1 parent 9a48e91 commit 092b5e3

File tree

5 files changed

+297
-29
lines changed

5 files changed

+297
-29
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
##
2+
# Usage example:
3+
# Build:
4+
# docker build -t my-jlink-image -f jlinkRemoteServerEmu.dockerfile .
5+
# Run (with USB access and custom network):
6+
# docker network create --subnet=192.168.100.0/24 jlink-net
7+
# docker run -d --rm --name jlink1 --network jlink-net --ip 192.168.100.10 \
8+
# --device=/dev/bus/usb:/dev/bus/usb \
9+
# my-jlink-image \
10+
# -select usb=SERIAL1 -device STM32F103RE -endian little -speed 4000 -if swd
11+
# Replace SERIAL1 with your J-Link serial number.
12+
#
13+
FROM ubuntu:22.04
14+
15+
## Install required packages and JLink dependencies
16+
RUN apt-get update && \
17+
apt-get install -y wget libusb-1.0-0 udev && \
18+
rm -rf /var/lib/apt/lists/*
19+
20+
21+
# Copy both JLink packages into the container (they must be present in build context)
22+
# COPY .github/workflows/JLink_Linux_${JLINK_VERSION}_x86_64.deb /tmp/JLink_Linux_${JLINK_VERSION}_x86_64.deb
23+
# COPY .github/workflows/JLink_Linux_${JLINK_VERSION}_arm64.deb /tmp/JLink_Linux_${JLINK_VERSION}_arm64.deb
24+
25+
# Workaround: replace udevadm with a stub to avoid postinst errors in Docker
26+
RUN if [ -f /bin/udevadm ]; then mv /bin/udevadm /bin/udevadm.real; fi && \
27+
echo '#!/bin/bash' > /bin/udevadm && \
28+
echo 'exit 0' >> /bin/udevadm && \
29+
chmod +x /bin/udevadm
30+
31+
# Install the appropriate JLink package depending on architecture (force install, then fix deps)
32+
WORKDIR /tmp
33+
RUN ARCH=$(uname -m) && \
34+
if [ "$ARCH" = "x86_64" ]; then \
35+
wget --post-data "accept_license_agreement=accepted" \
36+
https://www.segger.com/downloads/jlink/JLink_Linux_V794e_x86_64.deb \
37+
-O JLink.deb && \
38+
dpkg --force-depends -i JLink.deb && \
39+
rm JLink.deb; \
40+
elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
41+
wget --post-data "accept_license_agreement=accepted" \
42+
https://www.segger.com/downloads/jlink/JLink_Linux_V794e_arm64.deb \
43+
-O JLink.deb && \
44+
dpkg --force-depends -i JLink.deb && \
45+
rm JLink.deb; \
46+
fi
47+
48+
# Restore real udevadm after J-Link installation
49+
RUN if [ -f /bin/udevadm.real ]; then rm /bin/udevadm && mv /bin/udevadm.real /bin/udevadm; fi
50+
51+
# Add user (optional)
52+
RUN useradd -ms /bin/bash jlink
53+
USER jlink
54+
55+
WORKDIR /home/jlink
56+
57+
ENTRYPOINT ["/opt/SEGGER/JLink/JLinkRemoteServer"]

.github/workflows/test.yml

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
name: Test Builds
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
env:
10+
VNET_BASE: 192.168.3
11+
VNET_MASK: 24
12+
jobs:
13+
test-build:
14+
runs-on: self-hosted
15+
16+
steps:
17+
- name: Cleanup before start
18+
if: always()
19+
run: |
20+
pkill -9 -f JLinkRemoteServer || true
21+
docker rm -f jlink100 jlink101 jlink102 2>/dev/null || true
22+
docker network rm jlink-net 2>/dev/null || true
23+
sleep 2
24+
25+
- uses: actions/checkout@v4
26+
27+
- name: Build release distributions
28+
run: |
29+
python3 -m venv .venv
30+
. .venv/bin/activate
31+
pip install -e ".[dev]"
32+
33+
- name: Test 1 — bmlab-scan (no arguments)
34+
run: |
35+
. .venv/bin/activate
36+
bmlab-scan | tee scan1.txt
37+
count=$(grep -c "Target: *STM32F103RE" scan1.txt)
38+
awk '/JLink Programmer/ {serial=""} /Serial:/ {serial=$2} /Target: *STM32F103RE/ && serial {print serial}' scan1.txt | sort | uniq > serials.txt
39+
40+
if [ "$count" -ne 3 ]; then
41+
echo "Test 1 failed: expected 3 STM32F103RE, found $count"
42+
cat scan1.txt
43+
exit 1
44+
fi
45+
46+
- name: Test 2 — bmlab-scan -p jlink
47+
run: |
48+
. .venv/bin/activate
49+
bmlab-scan -p jlink | tee scan2.txt
50+
diff scan1.txt scan2.txt
51+
52+
- name: Test 3 — Start JLinkRemoteServer and scan via network
53+
run: |
54+
. .venv/bin/activate
55+
serial=$(head -n1 serials.txt)
56+
nohup JLinkRemoteServer -select usb=$serial -device STM32F103RE -endian little -speed 4000 -if swd > /dev/null 2>&1 &
57+
sleep 3
58+
bmlab-scan --network 127.0.0.1/32 | tee scan3.txt
59+
grep -q "Target: *STM32F103RE" scan3.txt
60+
grep -q "IP: *127.0.0.1" scan3.txt
61+
pkill -9 -f JLinkRemoteServer || true
62+
sleep 1
63+
64+
- name: Test 4 — bmlab-scan --network 127.0.0.1/32 -p jlink
65+
run: |
66+
. .venv/bin/activate
67+
serial=$(head -n1 serials.txt)
68+
nohup JLinkRemoteServer -select usb=$serial -device STM32F103RE -endian little -speed 4000 -if swd > /dev/null 2>&1 &
69+
sleep 3
70+
bmlab-scan --network 127.0.0.1/32 -p jlink | tee scan4.txt
71+
diff scan3.txt scan4.txt
72+
pkill -9 -f JLinkRemoteServer || true
73+
sleep 1
74+
75+
- name: Test 5 — bmlab-scan --network 127.0.0.2/32 (negative)
76+
run: |
77+
. .venv/bin/activate
78+
! bmlab-scan --network 127.0.0.2/32 | tee scan5.txt | grep -q "STM32F103RE"
79+
grep -qi "No JLink Remote Servers found on the network." scan5.txt
80+
81+
- name: Build JLinkRemoteServer Docker image
82+
run: |
83+
docker build -t my-jlink-image -f .github/workflows/jlinkRemoteServerEmu.dockerfile .
84+
85+
- name: Cleanup jlink-net Docker network
86+
run: |
87+
docker rm -f jlink100 jlink101 jlink102 2>/dev/null || true
88+
docker network rm jlink-net 2>/dev/null || true
89+
90+
- name: Setup JLinkRemoteServer Docker containers
91+
run: |
92+
docker network rm jlink-net 2>/dev/null || true
93+
docker network create --driver bridge --subnet=$VNET_BASE.0/$VNET_MASK --gateway=$VNET_BASE.1 jlink-net
94+
95+
serial1=$(sed -n '1p' serials.txt)
96+
serial2=$(sed -n '2p' serials.txt)
97+
serial3=$(sed -n '3p' serials.txt)
98+
99+
docker run -d --rm \
100+
--device=/dev/bus/usb:/dev/bus/usb \
101+
--name jlink100 \
102+
--network jlink-net \
103+
--ip $VNET_BASE.100 \
104+
my-jlink-image \
105+
-select usb=$serial1 -device STM32F103RE -endian little -speed 4000 -if swd
106+
sleep 3
107+
108+
docker run -d --rm \
109+
--device=/dev/bus/usb:/dev/bus/usb \
110+
--name jlink101 \
111+
--network jlink-net \
112+
--ip $VNET_BASE.101 \
113+
my-jlink-image \
114+
-select usb=$serial2 -device STM32F103RE -endian little -speed 4000 -if swd
115+
sleep 3
116+
117+
docker run -d --rm \
118+
--device=/dev/bus/usb:/dev/bus/usb \
119+
--name jlink102 \
120+
--network jlink-net \
121+
--ip $VNET_BASE.102 \
122+
my-jlink-image \
123+
-select usb=$serial3 -device STM32F103RE -endian little -speed 4000 -if swd
124+
sleep 3
125+
126+
- name: Test 6 — Multiple RemoteServers network scan
127+
run: |
128+
. .venv/bin/activate
129+
bmlab-scan --network $VNET_BASE.0/$VNET_MASK | tee scan6.txt
130+
131+
f103_count=$(grep -c "Target:.*STM32F103RE" scan6.txt || echo 0)
132+
133+
if [ "$f103_count" -ne 3 ]; then
134+
echo "Expected 3 STM32F103RE devices, found $f103_count"
135+
cat scan6.txt
136+
exit 1
137+
fi
138+
139+
for ip in 100 101 102; do
140+
if ! grep -q "IP:.*$VNET_BASE\.$ip" scan6.txt; then
141+
echo "No device found at $VNET_BASE.$ip"
142+
cat scan6.txt
143+
exit 1
144+
fi
145+
done
146+
147+
- name: Test 7 — Network scan with IP range filter
148+
run: |
149+
. .venv/bin/activate
150+
bmlab-scan --network $VNET_BASE.0/$VNET_MASK --start-ip 100 --end-ip 150 | tee scan7.txt
151+
152+
f103_count=$(grep -c "Target:.*STM32F103RE" scan7.txt || echo 0)
153+
154+
if [ "$f103_count" -ne 3 ]; then
155+
echo "Expected 3 STM32F103RE devices, found $f103_count"
156+
cat scan7.txt
157+
exit 1
158+
fi
159+
160+
for ip in 100 101 102; do
161+
if ! grep -q "IP:.*$VNET_BASE\.$ip" scan7.txt; then
162+
echo "No device found at $VNET_BASE.$ip"
163+
cat scan7.txt
164+
exit 1
165+
fi
166+
done
167+
168+
- name: Cleanup after Docker tests
169+
if: always()
170+
run: |
171+
docker rm -f jlink100 jlink101 jlink102 || true
172+
docker network rm jlink-net || true
173+
pkill -9 -f JLinkRemoteServer || true
174+
sleep 2

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.7] - 2026-01-06
9+
10+
### Fixed
11+
- **Network scanning now correctly detects target MCU for all devices**:
12+
- Added thread lock (`threading.Lock`) to serialize JLink connections during parallel scanning
13+
- Prevents state conflicts in pylink library when multiple threads connect simultaneously
14+
- Previously parallel scans could incorrectly detect STM32F765ZG instead of actual STM32F103RE
15+
- Added 500ms delay after disconnect to ensure device is fully released
16+
- **CI workflow improvements**:
17+
- Removed excessive debug output from test steps
18+
- Simplified Docker container setup and cleanup
19+
- Fixed validation logic: removed redundant device counting, only validate target MCU and IPs
20+
- Added network cleanup before creation to prevent "pool overlaps" errors
21+
- Strict validation restored: exactly 3 STM32F103RE devices required at specified IPs
22+
23+
### Changed
24+
- Network scanning: target detection is now serialized with lock protection
25+
- Port checks remain parallel for speed
26+
- Target MCU detection runs sequentially to prevent pylink state conflicts
27+
828
## [0.1.6] - 2026-01-04
929

1030
### Changed

src/bmlab_toolkit/jlink_programmer.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212
from .programmer import Programmer, DBGMCU_IDCODE_ADDRESSES, DEVICE_ID_MAP, DEFAULT_MCU_MAP
1313

1414
# Configure default logging level for JLinkProgrammer
15-
logging.basicConfig(level=logging.WARNING, format='%(levelname)s - %(message)s')
15+
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
1616

1717
# Suppress pylink logger to avoid communication timeout errors during disconnect
1818
pylink_logger = logging.getLogger('pylink')
19-
pylink_logger.setLevel(logging.WARNING)
19+
pylink_logger.setLevel(logging.INFO)
2020

2121

2222
class JLinkProgrammer(Programmer):
2323
"""JLink programmer implementation."""
2424

25-
def __init__(self, serial: Optional[int] = None, ip_addr: Optional[str] = None, log_level: int = logging.WARNING):
25+
def __init__(self, serial: Optional[int] = None, ip_addr: Optional[str] = None, log_level: int = logging.DEBUG):
2626
"""
2727
Initialize JLink programmer.
2828
@@ -356,20 +356,23 @@ def scan() -> List[Dict[str, Any]]:
356356

357357
# Try to detect target MCU
358358
try:
359-
temp_jlink = pylink.JLink()
360-
temp_jlink.open(serial_no=emu.SerialNumber)
361-
temp_jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
359+
print(f"serial found {emu.SerialNumber}")
360+
# temp_jlink = pylink.JLink()
361+
# jlink.open(serial_no=emu.SerialNumber)
362+
# jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
362363

363364
# Create temporary programmer instance to use detect_target
364-
temp_programmer = JLinkProgrammer.__new__(JLinkProgrammer)
365-
temp_programmer._jlink = temp_jlink
366-
temp_programmer.logger = logging.getLogger(__name__)
365+
temp_programmer = JLinkProgrammer(serial=emu.SerialNumber)
366+
detected = temp_programmer.connect_target()
367+
# temp_programmer._jlink = jlink
368+
# temp_programmer.logger = logging.getLogger(__name__)
367369

368-
detected = temp_programmer.detect_target()
370+
# detected = temp_programmer.detect_target()
369371
if detected:
372+
print(f"Detected target for JLink S/N {emu.SerialNumber}: {detected}")
370373
device_info['target'] = detected
371374

372-
temp_jlink.close()
375+
# jlink.close()
373376
except Exception:
374377
# If detection fails, just skip it
375378
pass

src/bmlab_toolkit/scan_cli.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@
88
import argparse
99
import logging
1010
import ipaddress
11+
import threading
1112
from concurrent.futures import ThreadPoolExecutor, as_completed
1213
from .constants import SUPPORTED_PROGRAMMERS, DEFAULT_PROGRAMMER, PROGRAMMER_JLINK
1314
from .jlink_programmer import JLinkProgrammer
1415
import socket
1516
import time
1617

18+
# Global lock to serialize JLink connections (prevents state conflicts)
19+
_jlink_connection_lock = threading.Lock()
20+
21+
1722
def scan_network_ip(ip_str, log_level):
1823
"""Scan a single IP for JLink Remote Server."""
1924
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -32,32 +37,40 @@ def scan_network_ip(ip_str, log_level):
3237
return None
3338

3439
# Try to connect and detect MCU
40+
# Use lock to prevent parallel connections from interfering with each other
3541
prog = None
3642
try:
37-
prog = JLinkProgrammer(ip_addr=ip_str, log_level=log_level)
38-
mcu = prog.connect_target()
39-
if not mcu:
40-
# Connection failed
41-
prog._jlink.close()
42-
print(f"{ip_str}: No target detected")
43-
return None
44-
45-
# Successfully connected - get device info
46-
device_info = {
47-
'ip': ip_str,
48-
'type': 'jlink-remote',
49-
'status': 'Connected',
50-
'target': mcu if mcu else 'Unknown'
51-
}
52-
53-
return device_info
43+
with _jlink_connection_lock:
44+
prog = JLinkProgrammer(ip_addr=ip_str, log_level=log_level)
45+
mcu = prog.connect_target()
46+
if not mcu:
47+
# Connection failed
48+
prog._jlink.close()
49+
print(f"{ip_str}: No target detected")
50+
return None
51+
52+
# Successfully connected - get device info
53+
device_info = {
54+
'ip': ip_str,
55+
'type': 'jlink-remote',
56+
'status': 'Connected',
57+
'target': mcu if mcu else 'Unknown'
58+
}
59+
60+
# Disconnect immediately after getting info
61+
prog.disconnect_target()
62+
# Delay to ensure device is fully released before next scan
63+
time.sleep(0.5)
64+
65+
return device_info
5466
except Exception as e:
5567
# Connection or detection failed
5668
return None
5769
finally:
5870
# Always close the connection
5971
try:
60-
prog.disconnect_target()
72+
if prog:
73+
prog.disconnect_target()
6174
except:
6275
pass
6376

@@ -198,6 +211,7 @@ def main():
198211

199212
# USB scan mode
200213
else:
214+
print(f"Scanning for USB JLink programmers...\n")
201215
devices = JLinkProgrammer.scan()
202216

203217
if not devices:

0 commit comments

Comments
 (0)