Add episode recording functionality and enhance serial communication handling
- Introduced record_episode.py for capturing ESP32 IMU data and RealSense images. - Added SwitchChangeDetector and SwitchLevelController for managing recording triggers. - Enhanced ESP32Bridge with new methods for reading samples and latest packets. - Updated verify.py and visualise.py to improve serial communication stability. - Modified .gitignore to include dataset, csv, and png directories.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@ -4,3 +4,9 @@ __pycache__/
|
|||||||
|
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
dataset/
|
||||||
|
|
||||||
|
csv/
|
||||||
|
|
||||||
|
png/
|
||||||
100
esp_bridge.py
100
esp_bridge.py
@ -3,6 +3,7 @@ import time
|
|||||||
import serial
|
import serial
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
SYNC = b"\xA5\x5A"
|
SYNC = b"\xA5\x5A"
|
||||||
PACKET_FORMAT = "<2sBBBI10hB"
|
PACKET_FORMAT = "<2sBBBI10hB"
|
||||||
PACKET_SIZE = struct.calcsize(PACKET_FORMAT)
|
PACKET_SIZE = struct.calcsize(PACKET_FORMAT)
|
||||||
@ -35,8 +36,8 @@ class ESP32Bridge:
|
|||||||
port,
|
port,
|
||||||
baud=115200,
|
baud=115200,
|
||||||
timeout=0.05,
|
timeout=0.05,
|
||||||
startup_wait=1.0,
|
startup_wait=4.0,
|
||||||
safe_stop_on_close=True,
|
safe_stop_on_close=False,
|
||||||
reset_input_on_open=True,
|
reset_input_on_open=True,
|
||||||
reset_output_on_open=True,
|
reset_output_on_open=True,
|
||||||
):
|
):
|
||||||
@ -207,3 +208,98 @@ class ESP32Bridge:
|
|||||||
yield self.read_packet()
|
yield self.read_packet()
|
||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
def read_samples(self, seconds=None, max_samples=None, max_gap_ms=1000):
|
||||||
|
"""
|
||||||
|
Read multiple packets and return flattened samples.
|
||||||
|
|
||||||
|
Suitable for visualise.py / verify.py.
|
||||||
|
"""
|
||||||
|
self._require_open()
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
bad_delta_count = 0
|
||||||
|
elapsed_s = 0.0
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if seconds is not None and time.monotonic() - start >= seconds:
|
||||||
|
break
|
||||||
|
|
||||||
|
if max_samples is not None and len(samples) >= max_samples:
|
||||||
|
break
|
||||||
|
|
||||||
|
data = self.read_packet()
|
||||||
|
|
||||||
|
if self.last_t_ms is None:
|
||||||
|
freq_hz = 0.0
|
||||||
|
else:
|
||||||
|
delta = (data["t_ms"] - self.last_t_ms) & 0xFFFFFFFF
|
||||||
|
if 0 < delta <= max_gap_ms:
|
||||||
|
elapsed_s += delta / 1000.0
|
||||||
|
freq_hz = 1000.0 / delta
|
||||||
|
else:
|
||||||
|
bad_delta_count += 1
|
||||||
|
freq_hz = 0.0
|
||||||
|
|
||||||
|
self.last_t_ms = data["t_ms"]
|
||||||
|
|
||||||
|
sample = flatten_packet(data, elapsed_s, freq_hz)
|
||||||
|
samples.append(sample)
|
||||||
|
|
||||||
|
return samples, bad_delta_count
|
||||||
|
|
||||||
|
def read_latest_packet(self, max_drain=200):
|
||||||
|
"""
|
||||||
|
Return the newest decoded packet currently available.
|
||||||
|
|
||||||
|
It drains old buffered packets so the PC side follows the latest DIN/DOUT state.
|
||||||
|
Useful when camera/image saving is slower than ESP32 50Hz output.
|
||||||
|
"""
|
||||||
|
self._require_open()
|
||||||
|
|
||||||
|
latest = self.read_packet()
|
||||||
|
drained = 0
|
||||||
|
|
||||||
|
while drained < max_drain:
|
||||||
|
# Pull all currently available bytes from OS serial buffer.
|
||||||
|
waiting = self.ser.in_waiting
|
||||||
|
if waiting <= 0 and len(self.buffer) < PACKET_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
if waiting > 0:
|
||||||
|
chunk = self.ser.read(waiting)
|
||||||
|
if chunk:
|
||||||
|
self.buffer.extend(chunk)
|
||||||
|
|
||||||
|
packet = self._try_pop_packet()
|
||||||
|
|
||||||
|
if packet is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
latest = decode_packet(packet)
|
||||||
|
drained += 1
|
||||||
|
|
||||||
|
return latest, drained
|
||||||
|
|
||||||
|
def flatten_packet(data, t_s, freq_hz):
|
||||||
|
return {
|
||||||
|
"t_s": t_s,
|
||||||
|
"acc_x_g": data["acc_g"][0],
|
||||||
|
"acc_y_g": data["acc_g"][1],
|
||||||
|
"acc_z_g": data["acc_g"][2],
|
||||||
|
"gyro_x_dps": data["gyro_dps"][0],
|
||||||
|
"gyro_y_dps": data["gyro_dps"][1],
|
||||||
|
"gyro_z_dps": data["gyro_dps"][2],
|
||||||
|
"angle_x_deg": data["angle_deg"][0],
|
||||||
|
"angle_y_deg": data["angle_deg"][1],
|
||||||
|
"angle_z_deg": data["angle_deg"][2],
|
||||||
|
"temp_c": data["temp_c"],
|
||||||
|
"freq_hz": freq_hz,
|
||||||
|
"din0": data["din"][0],
|
||||||
|
"din1": data["din"][1],
|
||||||
|
"din2": data["din"][2],
|
||||||
|
"din3": data["din"][3],
|
||||||
|
"dout0": data["dout"][0],
|
||||||
|
"dout1": data["dout"][1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
938
record_episode.py
Normal file
938
record_episode.py
Normal file
@ -0,0 +1,938 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
record_episode.py
|
||||||
|
|
||||||
|
PC-side episode recorder.
|
||||||
|
|
||||||
|
Records:
|
||||||
|
- ESP32 IMU data: acc / gyro / angle / temperature
|
||||||
|
- ESP32 DIN state: GPIO25 / GPIO26 / GPIO27 / GPIO32
|
||||||
|
- ESP32 DOUT logical state: GPIO18 / GPIO19 relay command state
|
||||||
|
- inferred scissor state: open / close / stop
|
||||||
|
- RealSense D405 RGB image
|
||||||
|
- RealSense D405 depth image
|
||||||
|
- Filtered IMU integrated velocity
|
||||||
|
|
||||||
|
Default DIN mapping from ESP32 main.py:
|
||||||
|
DIN_PINS = (25, 26, 27, 32)
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
din[0] = GPIO25
|
||||||
|
din[1] = GPIO26
|
||||||
|
din[2] = GPIO27 # already used as physical scissor switch
|
||||||
|
din[3] = GPIO32
|
||||||
|
|
||||||
|
Default recording trigger:
|
||||||
|
din[0] / GPIO25
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
Test ESP32 switch + CSV only:
|
||||||
|
python3 record_episode.py --port /dev/ttyUSB0 --no-camera
|
||||||
|
|
||||||
|
Record with D405 RGB + depth:
|
||||||
|
python3 record_episode.py --port /dev/ttyUSB0
|
||||||
|
|
||||||
|
Use GPIO26 as record trigger:
|
||||||
|
python3 record_episode.py --port /dev/ttyUSB0 --record-din 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyrealsense2 as rs
|
||||||
|
except ImportError:
|
||||||
|
rs = None
|
||||||
|
|
||||||
|
from esp_bridge import ESP32Bridge
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Switch edge detector
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class SwitchChangeDetector:
|
||||||
|
"""
|
||||||
|
Detect any valid state change from a toggle switch.
|
||||||
|
|
||||||
|
For a latching/toggle switch:
|
||||||
|
1 -> 0 triggers once
|
||||||
|
0 -> 1 triggers once
|
||||||
|
|
||||||
|
This is different from a push button, where only "pressed" edge is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, debounce_s=0.25):
|
||||||
|
self.debounce_s = debounce_s
|
||||||
|
self.last_value = None
|
||||||
|
self.last_edge_time = 0.0
|
||||||
|
|
||||||
|
def update(self, raw_value):
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# First sample only initializes state.
|
||||||
|
# Do not trigger recording immediately when program starts.
|
||||||
|
if self.last_value is None:
|
||||||
|
self.last_value = raw_value
|
||||||
|
return False
|
||||||
|
|
||||||
|
if raw_value == self.last_value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if now - self.last_edge_time < self.debounce_s:
|
||||||
|
return False
|
||||||
|
|
||||||
|
old_value = self.last_value
|
||||||
|
self.last_value = raw_value
|
||||||
|
self.last_edge_time = now
|
||||||
|
|
||||||
|
print(f"[SWITCH CHANGE] {old_value} -> {raw_value}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Switch level controller
|
||||||
|
# ============================================================
|
||||||
|
class SwitchLevelController:
|
||||||
|
"""
|
||||||
|
Level-control recording switch.
|
||||||
|
|
||||||
|
raw_value == active_value -> should record
|
||||||
|
raw_value != active_value -> should stop
|
||||||
|
|
||||||
|
For your current requirement:
|
||||||
|
DIN0 = 1 -> REC
|
||||||
|
DIN0 = 0 -> IDLE
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, active_value=1, debounce_s=0.25):
|
||||||
|
self.active_value = active_value
|
||||||
|
self.debounce_s = debounce_s
|
||||||
|
|
||||||
|
self.last_raw_value = None
|
||||||
|
self.stable_value = None
|
||||||
|
self.last_change_time = 0.0
|
||||||
|
|
||||||
|
def update(self, raw_value):
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# First sample initializes stable state.
|
||||||
|
# If program starts while switch is already 1, it will start recording.
|
||||||
|
if self.last_raw_value is None:
|
||||||
|
self.last_raw_value = raw_value
|
||||||
|
self.stable_value = raw_value
|
||||||
|
return raw_value == self.active_value
|
||||||
|
|
||||||
|
# Raw value changed. Start debounce timer.
|
||||||
|
if raw_value != self.last_raw_value:
|
||||||
|
self.last_raw_value = raw_value
|
||||||
|
self.last_change_time = now
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Raw value has remained the same. Accept it after debounce time.
|
||||||
|
if raw_value != self.stable_value:
|
||||||
|
if now - self.last_change_time >= self.debounce_s:
|
||||||
|
old_value = self.stable_value
|
||||||
|
self.stable_value = raw_value
|
||||||
|
print(f"[SWITCH LEVEL] {old_value} -> {self.stable_value}")
|
||||||
|
return self.stable_value == self.active_value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# RealSense D405 camera
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class D405Camera:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
width=640,
|
||||||
|
height=480,
|
||||||
|
fps=30,
|
||||||
|
enable_depth=True,
|
||||||
|
warmup_frames=15,
|
||||||
|
):
|
||||||
|
if rs is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"pyrealsense2 is not installed. "
|
||||||
|
"Install it or run with --no-camera."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.fps = fps
|
||||||
|
self.enable_depth = enable_depth
|
||||||
|
self.warmup_frames = warmup_frames
|
||||||
|
|
||||||
|
self.pipeline = rs.pipeline()
|
||||||
|
self.config = rs.config()
|
||||||
|
self.align = None
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
self.config.enable_stream(
|
||||||
|
rs.stream.color,
|
||||||
|
self.width,
|
||||||
|
self.height,
|
||||||
|
rs.format.bgr8,
|
||||||
|
self.fps,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.enable_depth:
|
||||||
|
self.config.enable_stream(
|
||||||
|
rs.stream.depth,
|
||||||
|
self.width,
|
||||||
|
self.height,
|
||||||
|
rs.format.z16,
|
||||||
|
self.fps,
|
||||||
|
)
|
||||||
|
self.align = rs.align(rs.stream.color)
|
||||||
|
|
||||||
|
self.pipeline.start(self.config)
|
||||||
|
|
||||||
|
# Warm up auto exposure.
|
||||||
|
for _ in range(self.warmup_frames):
|
||||||
|
self.pipeline.wait_for_frames()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
frames = self.pipeline.wait_for_frames()
|
||||||
|
|
||||||
|
if self.enable_depth and self.align is not None:
|
||||||
|
frames = self.align.process(frames)
|
||||||
|
|
||||||
|
color_frame = frames.get_color_frame()
|
||||||
|
if not color_frame:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
color = np.asanyarray(color_frame.get_data())
|
||||||
|
|
||||||
|
depth = None
|
||||||
|
if self.enable_depth:
|
||||||
|
depth_frame = frames.get_depth_frame()
|
||||||
|
if depth_frame:
|
||||||
|
depth = np.asanyarray(depth_frame.get_data())
|
||||||
|
|
||||||
|
return color, depth
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.pipeline.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.open()
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Simple IMU velocity estimator
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class ProviderIMUVelocityEstimator:
|
||||||
|
"""
|
||||||
|
Provider-style roll/pitch gravity compensation + velocity integration.
|
||||||
|
|
||||||
|
This follows verify.py:
|
||||||
|
gravity_body_g = [
|
||||||
|
-sin(pitch),
|
||||||
|
cos(pitch) * sin(roll),
|
||||||
|
cos(pitch) * cos(roll),
|
||||||
|
] * gravity_sign
|
||||||
|
|
||||||
|
Then:
|
||||||
|
linear_acc_body = raw_acc_body - gravity_body
|
||||||
|
|
||||||
|
Important:
|
||||||
|
This is still an estimated IMU velocity, not ground truth.
|
||||||
|
It removes the static gravity/support acceleration approximately,
|
||||||
|
but velocity integration will still drift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
gravity_sign=1.0,
|
||||||
|
bias_seconds=1.0,
|
||||||
|
acc_deadband=0.10,
|
||||||
|
vel_decay=0.995,
|
||||||
|
):
|
||||||
|
self.gravity_sign = gravity_sign
|
||||||
|
self.bias_seconds = bias_seconds
|
||||||
|
self.acc_deadband = acc_deadband
|
||||||
|
self.vel_decay = vel_decay
|
||||||
|
|
||||||
|
self.start_t_ms = None
|
||||||
|
self.last_t_ms = None
|
||||||
|
|
||||||
|
self.bias_ready = False
|
||||||
|
self.bias_samples = []
|
||||||
|
self.acc_bias = np.zeros(3, dtype=float)
|
||||||
|
|
||||||
|
self.v = np.zeros(3, dtype=float)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.start_t_ms = None
|
||||||
|
self.last_t_ms = None
|
||||||
|
|
||||||
|
self.bias_ready = False
|
||||||
|
self.bias_samples = []
|
||||||
|
self.acc_bias[:] = 0.0
|
||||||
|
|
||||||
|
self.v[:] = 0.0
|
||||||
|
|
||||||
|
def gravity_body_g(self, roll_deg, pitch_deg):
|
||||||
|
roll = math.radians(roll_deg)
|
||||||
|
pitch = math.radians(pitch_deg)
|
||||||
|
|
||||||
|
g_x = -math.sin(pitch)
|
||||||
|
g_y = math.cos(pitch) * math.sin(roll)
|
||||||
|
g_z = math.cos(pitch) * math.cos(roll)
|
||||||
|
|
||||||
|
return np.array([g_x, g_y, g_z], dtype=float) * self.gravity_sign
|
||||||
|
|
||||||
|
def update(self, packet):
|
||||||
|
t_ms = packet["t_ms"]
|
||||||
|
|
||||||
|
if self.start_t_ms is None:
|
||||||
|
self.start_t_ms = t_ms
|
||||||
|
|
||||||
|
acc_body_g = np.array(packet["acc_g"], dtype=float)
|
||||||
|
|
||||||
|
roll_deg = packet["angle_deg"][0]
|
||||||
|
pitch_deg = packet["angle_deg"][1]
|
||||||
|
|
||||||
|
gravity_g = self.gravity_body_g(
|
||||||
|
roll_deg=roll_deg,
|
||||||
|
pitch_deg=pitch_deg,
|
||||||
|
)
|
||||||
|
|
||||||
|
linear_acc_g_raw = acc_body_g - gravity_g
|
||||||
|
linear_acc_ms2_raw = linear_acc_g_raw * 9.81
|
||||||
|
|
||||||
|
elapsed_s = ((t_ms - self.start_t_ms) & 0xFFFFFFFF) / 1000.0
|
||||||
|
|
||||||
|
# First N seconds are used as residual bias estimation.
|
||||||
|
# During this period velocity stays zero.
|
||||||
|
if not self.bias_ready:
|
||||||
|
self.bias_samples.append(linear_acc_ms2_raw)
|
||||||
|
|
||||||
|
if elapsed_s >= self.bias_seconds:
|
||||||
|
if self.bias_samples:
|
||||||
|
self.acc_bias = np.mean(
|
||||||
|
np.array(self.bias_samples, dtype=float),
|
||||||
|
axis=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.bias_ready = True
|
||||||
|
print(f"[IMU] Provider acc bias estimated: {self.acc_bias}")
|
||||||
|
|
||||||
|
self.last_t_ms = t_ms
|
||||||
|
|
||||||
|
linear_acc_ms2 = linear_acc_ms2_raw - self.acc_bias
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gravity_g": gravity_g.copy(),
|
||||||
|
"linear_acc_ms2": linear_acc_ms2.copy(),
|
||||||
|
"velocity_ms": self.v.copy(),
|
||||||
|
"acc_bias_ms2": self.acc_bias.copy(),
|
||||||
|
"bias_ready": self.bias_ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
delta_ms = (t_ms - self.last_t_ms) & 0xFFFFFFFF
|
||||||
|
self.last_t_ms = t_ms
|
||||||
|
|
||||||
|
linear_acc_ms2 = linear_acc_ms2_raw - self.acc_bias
|
||||||
|
|
||||||
|
if np.linalg.norm(linear_acc_ms2) < self.acc_deadband:
|
||||||
|
linear_acc_ms2[:] = 0.0
|
||||||
|
|
||||||
|
if 0 < delta_ms <= 200:
|
||||||
|
dt = delta_ms / 1000.0
|
||||||
|
self.v = self.v * self.vel_decay + linear_acc_ms2 * dt
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gravity_g": gravity_g.copy(),
|
||||||
|
"linear_acc_ms2": linear_acc_ms2.copy(),
|
||||||
|
"velocity_ms": self.v.copy(),
|
||||||
|
"acc_bias_ms2": self.acc_bias.copy(),
|
||||||
|
"bias_ready": self.bias_ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Scissor state
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def infer_scissor_state(packet):
|
||||||
|
"""
|
||||||
|
DOUT logical mapping:
|
||||||
|
|
||||||
|
dout[0] = GPIO18 = relay IN1
|
||||||
|
dout[1] = GPIO19 = relay IN2
|
||||||
|
|
||||||
|
Hardware behavior:
|
||||||
|
IN1 active -> CLOSE
|
||||||
|
IN2 active -> OPEN
|
||||||
|
|
||||||
|
Important:
|
||||||
|
packet["dout"] comes from ESP32 dout_shadow.
|
||||||
|
It is a logical active state, not the raw GPIO voltage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dout0 = packet["dout"][0]
|
||||||
|
dout1 = packet["dout"][1]
|
||||||
|
|
||||||
|
if dout0 == 1 and dout1 == 0:
|
||||||
|
return "close"
|
||||||
|
|
||||||
|
if dout0 == 0 and dout1 == 1:
|
||||||
|
return "open"
|
||||||
|
|
||||||
|
if dout0 == 0 and dout1 == 0:
|
||||||
|
return "stop"
|
||||||
|
|
||||||
|
return "invalid_both_active"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Episode writer
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class EpisodeWriter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root_dir="dataset",
|
||||||
|
save_rgb=True,
|
||||||
|
save_depth=True,
|
||||||
|
jpeg_quality=95,
|
||||||
|
flush_every=10,
|
||||||
|
):
|
||||||
|
self.root_dir = Path(root_dir)
|
||||||
|
self.save_rgb = save_rgb
|
||||||
|
self.save_depth = save_depth
|
||||||
|
self.jpeg_quality = jpeg_quality
|
||||||
|
self.flush_every = flush_every
|
||||||
|
|
||||||
|
self.root_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.recording = False
|
||||||
|
|
||||||
|
self.episode_dir = None
|
||||||
|
self.rgb_dir = None
|
||||||
|
self.depth_dir = None
|
||||||
|
|
||||||
|
self.csv_file = None
|
||||||
|
self.writer = None
|
||||||
|
|
||||||
|
self.frame_id = 0
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
episode_name = "episode_" + datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
self.episode_dir = self.root_dir / episode_name
|
||||||
|
|
||||||
|
self.rgb_dir = self.episode_dir / "rgb"
|
||||||
|
self.depth_dir = self.episode_dir / "depth"
|
||||||
|
|
||||||
|
self.episode_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if self.save_rgb:
|
||||||
|
self.rgb_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if self.save_depth:
|
||||||
|
self.depth_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
csv_path = self.episode_dir / "data.csv"
|
||||||
|
self.csv_file = open(csv_path, "w", newline="", encoding="utf-8")
|
||||||
|
|
||||||
|
fieldnames = [
|
||||||
|
"frame_id",
|
||||||
|
|
||||||
|
"t_wall",
|
||||||
|
"t_monotonic",
|
||||||
|
"t_esp_ms",
|
||||||
|
|
||||||
|
"din0_gpio25_record_switch",
|
||||||
|
"din1_gpio26",
|
||||||
|
"din2_gpio27_scissor_switch",
|
||||||
|
"din3_gpio32",
|
||||||
|
|
||||||
|
"dout0_gpio18_in1",
|
||||||
|
"dout1_gpio19_in2",
|
||||||
|
"scissor_state",
|
||||||
|
|
||||||
|
"acc_x_g",
|
||||||
|
"acc_y_g",
|
||||||
|
"acc_z_g",
|
||||||
|
|
||||||
|
"gyro_x_dps",
|
||||||
|
"gyro_y_dps",
|
||||||
|
"gyro_z_dps",
|
||||||
|
|
||||||
|
"angle_x_deg",
|
||||||
|
"angle_y_deg",
|
||||||
|
"angle_z_deg",
|
||||||
|
|
||||||
|
"temp_c",
|
||||||
|
|
||||||
|
"vel_x_ms",
|
||||||
|
"vel_y_ms",
|
||||||
|
"vel_z_ms",
|
||||||
|
|
||||||
|
"lin_acc_x_ms2",
|
||||||
|
"lin_acc_y_ms2",
|
||||||
|
"lin_acc_z_ms2",
|
||||||
|
|
||||||
|
"acc_bias_x_ms2",
|
||||||
|
"acc_bias_y_ms2",
|
||||||
|
"acc_bias_z_ms2",
|
||||||
|
|
||||||
|
"bias_ready",
|
||||||
|
|
||||||
|
"rgb_path",
|
||||||
|
"depth_path",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.writer = csv.DictWriter(self.csv_file, fieldnames=fieldnames)
|
||||||
|
self.writer.writeheader()
|
||||||
|
|
||||||
|
self.frame_id = 0
|
||||||
|
self.recording = True
|
||||||
|
|
||||||
|
print(f"[REC START] {self.episode_dir}")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if not self.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.recording = False
|
||||||
|
|
||||||
|
if self.csv_file is not None:
|
||||||
|
self.csv_file.flush()
|
||||||
|
self.csv_file.close()
|
||||||
|
|
||||||
|
print(f"[REC STOP] {self.episode_dir}")
|
||||||
|
|
||||||
|
self.csv_file = None
|
||||||
|
self.writer = None
|
||||||
|
|
||||||
|
self.episode_dir = None
|
||||||
|
self.rgb_dir = None
|
||||||
|
self.depth_dir = None
|
||||||
|
|
||||||
|
self.frame_id = 0
|
||||||
|
|
||||||
|
def toggle(self):
|
||||||
|
if self.recording:
|
||||||
|
self.stop()
|
||||||
|
else:
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def write(self, packet, imu_state, color_image=None, depth_image=None):
|
||||||
|
if not self.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
rgb_rel = ""
|
||||||
|
depth_rel = ""
|
||||||
|
|
||||||
|
if self.save_rgb and color_image is not None:
|
||||||
|
rgb_name = f"{self.frame_id:06d}.jpg"
|
||||||
|
rgb_path = self.rgb_dir / rgb_name
|
||||||
|
|
||||||
|
ok = cv2.imwrite(
|
||||||
|
str(rgb_path),
|
||||||
|
color_image,
|
||||||
|
[int(cv2.IMWRITE_JPEG_QUALITY), int(self.jpeg_quality)],
|
||||||
|
)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
rgb_rel = "rgb/" + rgb_name
|
||||||
|
|
||||||
|
if self.save_depth and depth_image is not None:
|
||||||
|
depth_name = f"{self.frame_id:06d}.png"
|
||||||
|
depth_path = self.depth_dir / depth_name
|
||||||
|
|
||||||
|
ok = cv2.imwrite(str(depth_path), depth_image)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
depth_rel = "depth/" + depth_name
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"frame_id": self.frame_id,
|
||||||
|
|
||||||
|
"t_wall": time.time(),
|
||||||
|
"t_monotonic": time.monotonic(),
|
||||||
|
"t_esp_ms": packet["t_ms"],
|
||||||
|
|
||||||
|
"din0_gpio25_record_switch": packet["din"][0],
|
||||||
|
"din1_gpio26": packet["din"][1],
|
||||||
|
"din2_gpio27_scissor_switch": packet["din"][2],
|
||||||
|
"din3_gpio32": packet["din"][3],
|
||||||
|
|
||||||
|
"dout0_gpio18_in1": packet["dout"][0],
|
||||||
|
"dout1_gpio19_in2": packet["dout"][1],
|
||||||
|
"scissor_state": infer_scissor_state(packet),
|
||||||
|
|
||||||
|
"acc_x_g": packet["acc_g"][0],
|
||||||
|
"acc_y_g": packet["acc_g"][1],
|
||||||
|
"acc_z_g": packet["acc_g"][2],
|
||||||
|
|
||||||
|
"gyro_x_dps": packet["gyro_dps"][0],
|
||||||
|
"gyro_y_dps": packet["gyro_dps"][1],
|
||||||
|
"gyro_z_dps": packet["gyro_dps"][2],
|
||||||
|
|
||||||
|
"angle_x_deg": packet["angle_deg"][0],
|
||||||
|
"angle_y_deg": packet["angle_deg"][1],
|
||||||
|
"angle_z_deg": packet["angle_deg"][2],
|
||||||
|
|
||||||
|
"temp_c": packet["temp_c"],
|
||||||
|
|
||||||
|
"vel_x_ms": float(imu_state["velocity_ms"][0]),
|
||||||
|
"vel_y_ms": float(imu_state["velocity_ms"][1]),
|
||||||
|
"vel_z_ms": float(imu_state["velocity_ms"][2]),
|
||||||
|
|
||||||
|
"lin_acc_x_ms2": float(imu_state["linear_acc_ms2"][0]),
|
||||||
|
"lin_acc_y_ms2": float(imu_state["linear_acc_ms2"][1]),
|
||||||
|
"lin_acc_z_ms2": float(imu_state["linear_acc_ms2"][2]),
|
||||||
|
|
||||||
|
"acc_bias_x_ms2": float(imu_state["acc_bias_ms2"][0]),
|
||||||
|
"acc_bias_y_ms2": float(imu_state["acc_bias_ms2"][1]),
|
||||||
|
"acc_bias_z_ms2": float(imu_state["acc_bias_ms2"][2]),
|
||||||
|
|
||||||
|
"bias_ready": int(imu_state["bias_ready"]),
|
||||||
|
|
||||||
|
"rgb_path": rgb_rel,
|
||||||
|
"depth_path": depth_rel,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.writer.writerow(row)
|
||||||
|
|
||||||
|
self.frame_id += 1
|
||||||
|
|
||||||
|
if self.flush_every > 0 and self.frame_id % self.flush_every == 0:
|
||||||
|
self.csv_file.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Main recorder
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
if args.record_din == 2:
|
||||||
|
print(
|
||||||
|
"[WARNING] You selected din[2] / GPIO27 as recording trigger. "
|
||||||
|
"But GPIO27 is already used as physical scissor switch."
|
||||||
|
)
|
||||||
|
|
||||||
|
# record_switch = SwitchChangeDetector(
|
||||||
|
# debounce_s=args.debounce,
|
||||||
|
# )
|
||||||
|
#in case we need this former switch change detector, we can keep it here for reference. But the level controller is more suitable for this use case.
|
||||||
|
record_switch = SwitchLevelController(
|
||||||
|
active_value=args.record_active_value,
|
||||||
|
debounce_s=args.debounce,
|
||||||
|
)
|
||||||
|
velocity_estimator = ProviderIMUVelocityEstimator(
|
||||||
|
gravity_sign=args.gravity_sign,
|
||||||
|
bias_seconds=args.bias_seconds,
|
||||||
|
acc_deadband=args.acc_deadband,
|
||||||
|
vel_decay=args.velocity_decay,
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = EpisodeWriter(
|
||||||
|
root_dir=args.dataset,
|
||||||
|
save_rgb=not args.no_rgb,
|
||||||
|
save_depth=not args.no_depth,
|
||||||
|
jpeg_quality=args.jpeg_quality,
|
||||||
|
flush_every=args.flush_every,
|
||||||
|
)
|
||||||
|
|
||||||
|
camera = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.no_camera:
|
||||||
|
print("[CAMERA] disabled by --no-camera.")
|
||||||
|
else:
|
||||||
|
camera = D405Camera(
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
fps=args.fps,
|
||||||
|
enable_depth=not args.no_depth,
|
||||||
|
warmup_frames=args.camera_warmup,
|
||||||
|
)
|
||||||
|
camera.open()
|
||||||
|
print("[CAMERA] D405 opened.")
|
||||||
|
|
||||||
|
with ESP32Bridge(
|
||||||
|
port=args.port,
|
||||||
|
baud=args.baud,
|
||||||
|
startup_wait=args.startup_wait,
|
||||||
|
safe_stop_on_close=False,
|
||||||
|
) as bridge:
|
||||||
|
|
||||||
|
print(f"[ESP32] Reading {args.port} at {args.baud} baud.")
|
||||||
|
print(
|
||||||
|
"[SWITCH] record trigger: "
|
||||||
|
f"din[{args.record_din}], "
|
||||||
|
f"mode=level-control, "
|
||||||
|
f"active_value={args.record_active_value}, "
|
||||||
|
f"debounce={args.debounce}s"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[INFO] DIN[{args.record_din}]={args.record_active_value} -> REC, "
|
||||||
|
f"DIN[{args.record_din}]!={args.record_active_value} -> IDLE."
|
||||||
|
)
|
||||||
|
print("[INFO] Ctrl+C to exit.")
|
||||||
|
|
||||||
|
packet_count = 0
|
||||||
|
last_status_t = time.monotonic()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
packet, drained = bridge.read_latest_packet()
|
||||||
|
packet_count += 1 + drained
|
||||||
|
serial_waiting = bridge.ser.in_waiting if bridge.ser is not None else -1
|
||||||
|
raw_record_value = packet["din"][args.record_din]
|
||||||
|
|
||||||
|
desired_recording = record_switch.update(raw_record_value)
|
||||||
|
|
||||||
|
if desired_recording is not None:
|
||||||
|
if desired_recording and not writer.recording:
|
||||||
|
writer.start()
|
||||||
|
velocity_estimator.reset()
|
||||||
|
|
||||||
|
elif not desired_recording and writer.recording:
|
||||||
|
writer.stop()
|
||||||
|
|
||||||
|
imu_state = velocity_estimator.update(packet)
|
||||||
|
color_image = None
|
||||||
|
depth_image = None
|
||||||
|
|
||||||
|
if writer.recording and camera is not None:
|
||||||
|
color_image, depth_image = camera.read()
|
||||||
|
|
||||||
|
writer.write(
|
||||||
|
packet=packet,
|
||||||
|
imu_state=imu_state,
|
||||||
|
color_image=color_image,
|
||||||
|
depth_image=depth_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_status_t >= args.status_period:
|
||||||
|
last_status_t = now
|
||||||
|
|
||||||
|
rec_state = "REC" if writer.recording else "IDLE"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[{rec_state}] "
|
||||||
|
f"packets={packet_count} "
|
||||||
|
f"DIN={packet['din']} "
|
||||||
|
f"DOUT={packet['dout']} "
|
||||||
|
f"scissor={infer_scissor_state(packet)} "
|
||||||
|
f"frames={writer.frame_id} "
|
||||||
|
f"drained={drained} "
|
||||||
|
f"serial_waiting={serial_waiting}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[EXIT] Interrupted by user.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
writer.stop()
|
||||||
|
|
||||||
|
if camera is not None:
|
||||||
|
camera.close()
|
||||||
|
print("[CAMERA] closed.")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Record ESP32 IMU/DIN/DOUT + D405 RGB/depth into episodes."
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
required=True,
|
||||||
|
help="ESP32 serial port, for example /dev/ttyUSB0",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--baud",
|
||||||
|
type=int,
|
||||||
|
default=115200,
|
||||||
|
help="ESP32 USB serial baudrate.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--dataset",
|
||||||
|
default="dataset",
|
||||||
|
help="Output dataset root directory.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--record-din",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
choices=[0, 1, 2, 3],
|
||||||
|
help=(
|
||||||
|
"DIN index used as record start/stop switch. "
|
||||||
|
"DIN_PINS=(25,26,27,32). "
|
||||||
|
"0=GPIO25, 1=GPIO26, 2=GPIO27, 3=GPIO32. "
|
||||||
|
"GPIO27/din2 is already used as scissor physical switch, "
|
||||||
|
"so default is din0/GPIO25."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--record-active-value",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
choices=[0, 1],
|
||||||
|
help="Record when selected DIN equals this value. Default: 1, meaning DIN=1 records and DIN=0 stops.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--active-low",
|
||||||
|
dest="active_low",
|
||||||
|
action="store_true",
|
||||||
|
default=True,
|
||||||
|
help="Record switch is low-level active. Default: True.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--active-high",
|
||||||
|
dest="active_low",
|
||||||
|
action="store_false",
|
||||||
|
help="Use high-level active record switch.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--debounce",
|
||||||
|
type=float,
|
||||||
|
default=0.25,
|
||||||
|
help="Record switch debounce time in seconds.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--startup-wait",
|
||||||
|
type=float,
|
||||||
|
default=4.0,
|
||||||
|
help="Wait time after opening ESP32 serial port.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-camera",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable RealSense camera. Useful for testing DIN toggle and CSV only.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--width",
|
||||||
|
type=int,
|
||||||
|
default=640,
|
||||||
|
help="RealSense color/depth width.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--height",
|
||||||
|
type=int,
|
||||||
|
default=480,
|
||||||
|
help="RealSense color/depth height.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--fps",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="RealSense FPS.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--camera-warmup",
|
||||||
|
type=int,
|
||||||
|
default=15,
|
||||||
|
help="Frames to discard after starting camera.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-rgb",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not save RGB images.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-depth",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not save depth images.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--jpeg-quality",
|
||||||
|
type=int,
|
||||||
|
default=95,
|
||||||
|
help="JPEG quality for RGB image saving.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--velocity-decay",
|
||||||
|
type=float,
|
||||||
|
default=0.995,
|
||||||
|
help="Decay factor for simple raw IMU velocity integration.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--flush-every",
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help="Flush CSV every N frames. Use 1 for safer but slower writing.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--status-period",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help="Print status every N seconds.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--gravity-sign",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
choices=[1.0, -1.0],
|
||||||
|
help="Use +1 if horizontal static acc_z is about +1g; use -1 if about -1g.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--bias-seconds",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help="Use the first N seconds after episode start to estimate residual acceleration bias.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--acc-deadband",
|
||||||
|
type=float,
|
||||||
|
default=0.10,
|
||||||
|
help="Set linear acceleration to zero if norm is below this value, unit m/s^2.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run(parse_args())
|
||||||
|
# python3 record_episode.py --port /dev/ttyUSB0 --baud 115200 --record-din 0 --dataset dataset --no-camera
|
||||||
30
verify.py
30
verify.py
@ -57,10 +57,33 @@ def read_serial_samples(port, baud, seconds=None, max_samples=None, max_gap_ms=1
|
|||||||
last_t_ms = None
|
last_t_ms = None
|
||||||
elapsed_s = 0.0
|
elapsed_s = 0.0
|
||||||
bad_delta_count = 0
|
bad_delta_count = 0
|
||||||
start = time.monotonic()
|
|
||||||
|
|
||||||
with serial.Serial(port, baud, timeout=1) as ser:
|
ser = serial.Serial()
|
||||||
|
ser.port = port
|
||||||
|
ser.baudrate = baud
|
||||||
|
ser.timeout = 0.05
|
||||||
|
ser.write_timeout = 0.05
|
||||||
|
|
||||||
|
# Avoid unwanted ESP32 auto-reset / boot-mode toggling.
|
||||||
|
ser.dtr = False
|
||||||
|
ser.rts = False
|
||||||
|
|
||||||
|
ser.open()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Some USB-UART adapters may still change line states during open().
|
||||||
|
ser.dtr = False
|
||||||
|
ser.rts = False
|
||||||
|
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
ser.reset_output_buffer()
|
||||||
|
|
||||||
print(f"Reading {port} at {baud} baud...", file=sys.stderr)
|
print(f"Reading {port} at {baud} baud...", file=sys.stderr)
|
||||||
|
print("Waiting for ESP32 main.py startup...", file=sys.stderr)
|
||||||
|
time.sleep(4.0)
|
||||||
|
|
||||||
|
# Start timing after ESP32 startup wait.
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if seconds is not None and time.monotonic() - start >= seconds:
|
if seconds is not None and time.monotonic() - start >= seconds:
|
||||||
@ -110,6 +133,9 @@ def read_serial_samples(port, baud, seconds=None, max_samples=None, max_gap_ms=1
|
|||||||
if max_samples is not None and len(samples) >= max_samples:
|
if max_samples is not None and len(samples) >= max_samples:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
ser.close()
|
||||||
|
|
||||||
if bad_delta_count:
|
if bad_delta_count:
|
||||||
print(f"Ignored {bad_delta_count} abnormal packet time delta(s).", file=sys.stderr)
|
print(f"Ignored {bad_delta_count} abnormal packet time delta(s).", file=sys.stderr)
|
||||||
|
|
||||||
|
|||||||
27
visualise.py
27
visualise.py
@ -47,10 +47,30 @@ def read_serial_samples(port, baud, seconds=None, max_samples=None, max_gap_ms=1
|
|||||||
last_t_ms = None
|
last_t_ms = None
|
||||||
elapsed_s = 0.0
|
elapsed_s = 0.0
|
||||||
bad_delta_count = 0
|
bad_delta_count = 0
|
||||||
start = time.monotonic()
|
|
||||||
|
|
||||||
with serial.Serial(port, baud, timeout=1) as ser:
|
ser = serial.Serial()
|
||||||
|
ser.port = port
|
||||||
|
ser.baudrate = baud
|
||||||
|
ser.timeout = 0.05
|
||||||
|
ser.write_timeout = 0.05
|
||||||
|
|
||||||
|
# 关键:避免 ESP32 因 DTR/RTS 被 pyserial 拉动而自动 reset / 进入 boot mode
|
||||||
|
ser.dtr = False
|
||||||
|
ser.rts = False
|
||||||
|
|
||||||
|
ser.open()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ser.dtr = False
|
||||||
|
ser.rts = False
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
ser.reset_output_buffer()
|
||||||
|
|
||||||
print("Reading {} at {} baud...".format(port, baud), file=sys.stderr)
|
print("Reading {} at {} baud...".format(port, baud), file=sys.stderr)
|
||||||
|
print("Waiting for ESP32 main.py startup...", file=sys.stderr)
|
||||||
|
time.sleep(4.0)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if seconds is not None and time.monotonic() - start >= seconds:
|
if seconds is not None and time.monotonic() - start >= seconds:
|
||||||
@ -97,6 +117,9 @@ def read_serial_samples(port, baud, seconds=None, max_samples=None, max_gap_ms=1
|
|||||||
if max_samples is not None and len(samples) >= max_samples:
|
if max_samples is not None and len(samples) >= max_samples:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
ser.close()
|
||||||
|
|
||||||
if bad_delta_count:
|
if bad_delta_count:
|
||||||
print(
|
print(
|
||||||
"Ignored {} abnormal packet time delta(s).".format(bad_delta_count),
|
"Ignored {} abnormal packet time delta(s).".format(bad_delta_count),
|
||||||
|
|||||||
Reference in New Issue
Block a user