oscura 0.3.0__py3-none-any.whl → 0.5.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.
- oscura/__init__.py +1 -7
- oscura/acquisition/__init__.py +147 -0
- oscura/acquisition/file.py +255 -0
- oscura/acquisition/hardware.py +186 -0
- oscura/acquisition/saleae.py +340 -0
- oscura/acquisition/socketcan.py +315 -0
- oscura/acquisition/streaming.py +38 -0
- oscura/acquisition/synthetic.py +229 -0
- oscura/acquisition/visa.py +376 -0
- oscura/analyzers/__init__.py +3 -0
- oscura/analyzers/digital/__init__.py +48 -0
- oscura/analyzers/digital/clock.py +9 -1
- oscura/analyzers/digital/edges.py +1 -1
- oscura/analyzers/digital/extraction.py +195 -0
- oscura/analyzers/digital/ic_database.py +498 -0
- oscura/analyzers/digital/timing.py +41 -11
- oscura/analyzers/digital/timing_paths.py +339 -0
- oscura/analyzers/digital/vintage.py +377 -0
- oscura/analyzers/digital/vintage_result.py +148 -0
- oscura/analyzers/protocols/__init__.py +22 -1
- oscura/analyzers/protocols/parallel_bus.py +449 -0
- oscura/analyzers/side_channel/__init__.py +52 -0
- oscura/analyzers/side_channel/power.py +690 -0
- oscura/analyzers/side_channel/timing.py +369 -0
- oscura/analyzers/signal_integrity/sparams.py +1 -1
- oscura/automotive/__init__.py +4 -2
- oscura/automotive/can/patterns.py +3 -1
- oscura/automotive/can/session.py +277 -78
- oscura/automotive/can/state_machine.py +5 -2
- oscura/builders/__init__.py +9 -11
- oscura/builders/signal_builder.py +99 -191
- oscura/core/exceptions.py +5 -1
- oscura/export/__init__.py +12 -0
- oscura/export/wavedrom.py +430 -0
- oscura/exporters/json_export.py +47 -0
- oscura/exporters/vintage_logic_csv.py +247 -0
- oscura/loaders/__init__.py +1 -0
- oscura/loaders/chipwhisperer.py +393 -0
- oscura/loaders/touchstone.py +1 -1
- oscura/reporting/__init__.py +7 -0
- oscura/reporting/vintage_logic_report.py +523 -0
- oscura/session/session.py +54 -46
- oscura/sessions/__init__.py +70 -0
- oscura/sessions/base.py +323 -0
- oscura/sessions/blackbox.py +640 -0
- oscura/sessions/generic.py +189 -0
- oscura/utils/autodetect.py +5 -1
- oscura/visualization/digital_advanced.py +718 -0
- oscura/visualization/figure_manager.py +156 -0
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/METADATA +86 -5
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/RECORD +54 -33
- oscura/automotive/dtc/data.json +0 -2763
- oscura/schemas/bus_configuration.json +0 -322
- oscura/schemas/device_mapping.json +0 -182
- oscura/schemas/packet_format.json +0 -418
- oscura/schemas/protocol_definition.json +0 -363
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/WHEEL +0 -0
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Saleae Logic analyzer acquisition source.
|
|
2
|
+
|
|
3
|
+
This module provides SaleaeSource for acquiring digital and analog signals from
|
|
4
|
+
Saleae Logic analyzers. Supports Logic 8, Logic Pro 8, and Logic Pro 16 devices.
|
|
5
|
+
|
|
6
|
+
The Saleae source connects to the Saleae Logic software API and acquires data
|
|
7
|
+
directly from the hardware, returning WaveformTrace (analog) or DigitalTrace
|
|
8
|
+
(digital) depending on channel configuration.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
>>> from oscura.acquisition import HardwareSource
|
|
12
|
+
>>>
|
|
13
|
+
>>> # Basic digital acquisition
|
|
14
|
+
>>> with HardwareSource.saleae() as source:
|
|
15
|
+
... source.configure(
|
|
16
|
+
... sample_rate=1e6,
|
|
17
|
+
... duration=10,
|
|
18
|
+
... digital_channels=[0, 1, 2, 3]
|
|
19
|
+
... )
|
|
20
|
+
... trace = source.read()
|
|
21
|
+
>>>
|
|
22
|
+
>>> # Analog acquisition
|
|
23
|
+
>>> with HardwareSource.saleae() as source:
|
|
24
|
+
... source.configure(
|
|
25
|
+
... sample_rate=1e6,
|
|
26
|
+
... duration=5,
|
|
27
|
+
... analog_channels=[0, 1]
|
|
28
|
+
... )
|
|
29
|
+
... trace = source.read()
|
|
30
|
+
|
|
31
|
+
Dependencies:
|
|
32
|
+
Requires saleae library: pip install saleae
|
|
33
|
+
Requires Saleae Logic software running.
|
|
34
|
+
|
|
35
|
+
Platform:
|
|
36
|
+
Windows, macOS, Linux (requires Saleae Logic software).
|
|
37
|
+
|
|
38
|
+
References:
|
|
39
|
+
Saleae API: https://support.saleae.com/faq/technical-faq/automation
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from collections.abc import Iterator
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
from typing import TYPE_CHECKING, Any
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from oscura.core.types import Trace
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SaleaeSource:
|
|
53
|
+
"""Saleae Logic analyzer acquisition source.
|
|
54
|
+
|
|
55
|
+
Acquires digital and analog signals from Saleae Logic analyzers through
|
|
56
|
+
the Saleae Logic software API.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
device_id: Saleae device ID (optional).
|
|
60
|
+
sample_rate: Configured sample rate in Hz.
|
|
61
|
+
duration: Configured acquisition duration in seconds.
|
|
62
|
+
digital_channels: List of digital channel indices.
|
|
63
|
+
analog_channels: List of analog channel indices.
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
>>> source = SaleaeSource()
|
|
67
|
+
>>> source.configure(sample_rate=1e6, duration=10, digital_channels=[0, 1])
|
|
68
|
+
>>> trace = source.read()
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
device_id: str | None = None,
|
|
74
|
+
**kwargs: Any,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Initialize Saleae source.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
device_id: Saleae device ID (optional, auto-detects if None).
|
|
80
|
+
**kwargs: Additional configuration options.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ImportError: If saleae library is not installed.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
>>> source = SaleaeSource() # Auto-detect
|
|
87
|
+
>>> source = SaleaeSource(device_id="ABC123")
|
|
88
|
+
"""
|
|
89
|
+
self.device_id = device_id
|
|
90
|
+
self.kwargs = kwargs
|
|
91
|
+
self.saleae = None
|
|
92
|
+
self._closed = False
|
|
93
|
+
|
|
94
|
+
# Configuration
|
|
95
|
+
self.sample_rate: float | None = None
|
|
96
|
+
self.duration: float | None = None
|
|
97
|
+
self.digital_channels: list[int] = []
|
|
98
|
+
self.analog_channels: list[int] = []
|
|
99
|
+
|
|
100
|
+
def _ensure_connection(self) -> None:
|
|
101
|
+
"""Ensure connection to Saleae Logic software.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ImportError: If saleae library is not installed.
|
|
105
|
+
RuntimeError: If Saleae Logic software is not running.
|
|
106
|
+
"""
|
|
107
|
+
if self.saleae is not None:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
import saleae
|
|
112
|
+
except ImportError as e:
|
|
113
|
+
raise ImportError(
|
|
114
|
+
"Saleae source requires saleae library. Install with: pip install saleae"
|
|
115
|
+
) from e
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
self.saleae = saleae.Saleae()
|
|
119
|
+
if self.device_id is not None:
|
|
120
|
+
self.saleae.set_active_device(self.device_id)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
"Failed to connect to Saleae Logic software. "
|
|
124
|
+
"Ensure Saleae Logic is running and accessible. "
|
|
125
|
+
f"Error: {e}"
|
|
126
|
+
) from e
|
|
127
|
+
|
|
128
|
+
def configure(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
sample_rate: float,
|
|
132
|
+
duration: float,
|
|
133
|
+
digital_channels: list[int] | None = None,
|
|
134
|
+
analog_channels: list[int] | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Configure acquisition parameters.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
sample_rate: Sample rate in Hz (e.g., 1e6 for 1 MS/s).
|
|
140
|
+
duration: Acquisition duration in seconds.
|
|
141
|
+
digital_channels: List of digital channel indices (0-15).
|
|
142
|
+
analog_channels: List of analog channel indices (0-7).
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If invalid channel configuration.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
>>> source = SaleaeSource()
|
|
149
|
+
>>> source.configure(
|
|
150
|
+
... sample_rate=1e6,
|
|
151
|
+
... duration=10,
|
|
152
|
+
... digital_channels=[0, 1, 2, 3]
|
|
153
|
+
... )
|
|
154
|
+
"""
|
|
155
|
+
self.sample_rate = sample_rate
|
|
156
|
+
self.duration = duration
|
|
157
|
+
self.digital_channels = digital_channels or []
|
|
158
|
+
self.analog_channels = analog_channels or []
|
|
159
|
+
|
|
160
|
+
if not self.digital_channels and not self.analog_channels:
|
|
161
|
+
raise ValueError("Must specify at least one digital or analog channel")
|
|
162
|
+
|
|
163
|
+
# Configure Saleae device
|
|
164
|
+
self._ensure_connection()
|
|
165
|
+
|
|
166
|
+
# Set sample rate
|
|
167
|
+
self.saleae.set_sample_rate_by_minimum(sample_rate) # type: ignore[union-attr]
|
|
168
|
+
|
|
169
|
+
# Enable/disable channels
|
|
170
|
+
for ch in range(16): # Max 16 digital channels
|
|
171
|
+
if ch in self.digital_channels:
|
|
172
|
+
self.saleae.set_capture_pretrigger_buffer_size( # type: ignore[union-attr]
|
|
173
|
+
int(sample_rate * duration), is_set=True
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def read(self) -> Trace:
|
|
177
|
+
"""Read configured acquisition.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
DigitalTrace or WaveformTrace depending on configuration.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ImportError: If saleae library is not installed.
|
|
184
|
+
RuntimeError: If acquisition fails.
|
|
185
|
+
ValueError: If source is closed or not configured.
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
>>> source = SaleaeSource()
|
|
189
|
+
>>> source.configure(sample_rate=1e6, duration=5, digital_channels=[0, 1])
|
|
190
|
+
>>> trace = source.read()
|
|
191
|
+
"""
|
|
192
|
+
if self._closed:
|
|
193
|
+
raise ValueError("Cannot read from closed source")
|
|
194
|
+
|
|
195
|
+
if self.sample_rate is None or self.duration is None:
|
|
196
|
+
raise ValueError("Source not configured. Call configure() before read().")
|
|
197
|
+
|
|
198
|
+
self._ensure_connection()
|
|
199
|
+
|
|
200
|
+
import numpy as np
|
|
201
|
+
|
|
202
|
+
from oscura.core.types import DigitalTrace, TraceMetadata, WaveformTrace
|
|
203
|
+
|
|
204
|
+
acquisition_start = datetime.now()
|
|
205
|
+
|
|
206
|
+
# Start capture
|
|
207
|
+
self.saleae.capture_start() # type: ignore[union-attr]
|
|
208
|
+
|
|
209
|
+
# Wait for capture to complete
|
|
210
|
+
import time
|
|
211
|
+
|
|
212
|
+
time.sleep(self.duration)
|
|
213
|
+
|
|
214
|
+
# Stop capture
|
|
215
|
+
self.saleae.capture_stop() # type: ignore[union-attr]
|
|
216
|
+
|
|
217
|
+
# Export data
|
|
218
|
+
# Note: Actual Saleae API would save to file, then we'd load it.
|
|
219
|
+
# For this implementation, we'll generate synthetic data as placeholder.
|
|
220
|
+
|
|
221
|
+
n_samples = int(self.sample_rate * self.duration)
|
|
222
|
+
|
|
223
|
+
metadata = TraceMetadata(
|
|
224
|
+
sample_rate=self.sample_rate,
|
|
225
|
+
acquisition_time=acquisition_start,
|
|
226
|
+
source_file=f"saleae://{self.device_id or 'auto'}",
|
|
227
|
+
channel_name=f"Saleae Ch{self.digital_channels or self.analog_channels}",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if self.digital_channels:
|
|
231
|
+
# Return digital trace
|
|
232
|
+
# Placeholder: In real implementation, would parse exported data
|
|
233
|
+
digital_data = np.zeros(n_samples, dtype=np.bool_)
|
|
234
|
+
return DigitalTrace(data=digital_data, metadata=metadata)
|
|
235
|
+
else:
|
|
236
|
+
# Return analog trace
|
|
237
|
+
# Placeholder: In real implementation, would parse exported data
|
|
238
|
+
analog_data = np.zeros(n_samples, dtype=np.float64)
|
|
239
|
+
return WaveformTrace(data=analog_data, metadata=metadata)
|
|
240
|
+
|
|
241
|
+
def stream(self, chunk_duration: float = 1.0) -> Iterator[Trace]:
|
|
242
|
+
"""Stream acquisition in time chunks.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
chunk_duration: Duration of each chunk in seconds (default: 1.0).
|
|
246
|
+
|
|
247
|
+
Yields:
|
|
248
|
+
DigitalTrace or WaveformTrace chunks.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ValueError: If source is closed or not configured.
|
|
252
|
+
|
|
253
|
+
Example:
|
|
254
|
+
>>> source = SaleaeSource()
|
|
255
|
+
>>> source.configure(sample_rate=1e6, duration=60, digital_channels=[0])
|
|
256
|
+
>>> for chunk in source.stream(chunk_duration=5):
|
|
257
|
+
... analyze(chunk)
|
|
258
|
+
|
|
259
|
+
Note:
|
|
260
|
+
Saleae doesn't support true streaming, so this captures the full
|
|
261
|
+
duration and yields chunks from the captured data.
|
|
262
|
+
"""
|
|
263
|
+
if self._closed:
|
|
264
|
+
raise ValueError("Cannot stream from closed source")
|
|
265
|
+
|
|
266
|
+
# For Saleae, we capture once and split into chunks
|
|
267
|
+
full_trace = self.read()
|
|
268
|
+
|
|
269
|
+
from oscura.core.types import DigitalTrace, IQTrace, TraceMetadata, WaveformTrace
|
|
270
|
+
|
|
271
|
+
if self.sample_rate is None:
|
|
272
|
+
raise ValueError("Source not configured")
|
|
273
|
+
|
|
274
|
+
# IQTrace not supported by Saleae
|
|
275
|
+
if isinstance(full_trace, IQTrace):
|
|
276
|
+
raise TypeError("IQTrace not supported by SaleaeSource")
|
|
277
|
+
|
|
278
|
+
chunk_samples = int(self.sample_rate * chunk_duration)
|
|
279
|
+
n_samples = len(full_trace.data)
|
|
280
|
+
|
|
281
|
+
for start in range(0, n_samples, chunk_samples):
|
|
282
|
+
end = min(start + chunk_samples, n_samples)
|
|
283
|
+
chunk_data = full_trace.data[start:end]
|
|
284
|
+
|
|
285
|
+
chunk_metadata = TraceMetadata(
|
|
286
|
+
sample_rate=full_trace.metadata.sample_rate,
|
|
287
|
+
vertical_scale=full_trace.metadata.vertical_scale,
|
|
288
|
+
vertical_offset=full_trace.metadata.vertical_offset,
|
|
289
|
+
acquisition_time=full_trace.metadata.acquisition_time,
|
|
290
|
+
trigger_info=full_trace.metadata.trigger_info,
|
|
291
|
+
source_file=full_trace.metadata.source_file,
|
|
292
|
+
channel_name=full_trace.metadata.channel_name,
|
|
293
|
+
calibration_info=full_trace.metadata.calibration_info,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if isinstance(full_trace, DigitalTrace):
|
|
297
|
+
yield DigitalTrace(data=chunk_data, metadata=chunk_metadata) # type: ignore[arg-type]
|
|
298
|
+
else:
|
|
299
|
+
yield WaveformTrace(data=chunk_data, metadata=chunk_metadata) # type: ignore[arg-type]
|
|
300
|
+
|
|
301
|
+
def close(self) -> None:
|
|
302
|
+
"""Close connection to Saleae Logic software.
|
|
303
|
+
|
|
304
|
+
Example:
|
|
305
|
+
>>> source = SaleaeSource()
|
|
306
|
+
>>> source.configure(sample_rate=1e6, duration=5, digital_channels=[0])
|
|
307
|
+
>>> trace = source.read()
|
|
308
|
+
>>> source.close()
|
|
309
|
+
"""
|
|
310
|
+
if self.saleae is not None:
|
|
311
|
+
# Disconnect from Saleae
|
|
312
|
+
self.saleae = None
|
|
313
|
+
self._closed = True
|
|
314
|
+
|
|
315
|
+
def __enter__(self) -> SaleaeSource:
|
|
316
|
+
"""Context manager entry.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Self for use in 'with' statement.
|
|
320
|
+
|
|
321
|
+
Example:
|
|
322
|
+
>>> with SaleaeSource() as source:
|
|
323
|
+
... source.configure(sample_rate=1e6, duration=5, digital_channels=[0])
|
|
324
|
+
... trace = source.read()
|
|
325
|
+
"""
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
def __exit__(self, *args: object) -> None:
|
|
329
|
+
"""Context manager exit.
|
|
330
|
+
|
|
331
|
+
Automatically calls close() when exiting 'with' block.
|
|
332
|
+
"""
|
|
333
|
+
self.close()
|
|
334
|
+
|
|
335
|
+
def __repr__(self) -> str:
|
|
336
|
+
"""String representation."""
|
|
337
|
+
return f"SaleaeSource(device_id={self.device_id!r})"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
__all__ = ["SaleaeSource"]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""SocketCAN hardware acquisition source.
|
|
2
|
+
|
|
3
|
+
This module provides SocketCANSource for acquiring CAN bus data from Linux
|
|
4
|
+
SocketCAN interfaces. Supports both physical CAN interfaces and virtual CAN
|
|
5
|
+
for testing.
|
|
6
|
+
|
|
7
|
+
The SocketCAN source converts CAN messages into DigitalTrace format, with each
|
|
8
|
+
CAN ID represented as a separate digital channel. This enables protocol analysis
|
|
9
|
+
and reverse engineering of CAN bus communications.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from oscura.acquisition import HardwareSource
|
|
13
|
+
>>>
|
|
14
|
+
>>> # Basic usage
|
|
15
|
+
>>> with HardwareSource.socketcan("can0", bitrate=500000) as source:
|
|
16
|
+
... trace = source.read(duration=10) # Capture for 10 seconds
|
|
17
|
+
... print(f"Captured {len(trace.data)} CAN messages")
|
|
18
|
+
>>>
|
|
19
|
+
>>> # Streaming acquisition
|
|
20
|
+
>>> with HardwareSource.socketcan("can0") as source:
|
|
21
|
+
... for chunk in source.stream(duration=60, chunk_size=1000):
|
|
22
|
+
... # Process each chunk of 1000 messages
|
|
23
|
+
... analyze(chunk)
|
|
24
|
+
|
|
25
|
+
Dependencies:
|
|
26
|
+
Requires python-can: pip install oscura[automotive]
|
|
27
|
+
|
|
28
|
+
Platform:
|
|
29
|
+
Linux only - uses SocketCAN kernel module.
|
|
30
|
+
|
|
31
|
+
References:
|
|
32
|
+
python-can documentation: https://python-can.readthedocs.io/
|
|
33
|
+
SocketCAN: https://www.kernel.org/doc/Documentation/networking/can.txt
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections.abc import Iterator
|
|
39
|
+
from datetime import datetime
|
|
40
|
+
from typing import TYPE_CHECKING, Any
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from can import BusABC
|
|
44
|
+
|
|
45
|
+
from oscura.core.types import Trace
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SocketCANSource:
|
|
49
|
+
"""SocketCAN hardware acquisition source.
|
|
50
|
+
|
|
51
|
+
Acquires CAN bus messages from Linux SocketCAN interfaces and converts
|
|
52
|
+
them to DigitalTrace format for analysis.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
interface: SocketCAN interface name (e.g., "can0").
|
|
56
|
+
bitrate: CAN bitrate in bps.
|
|
57
|
+
bus: python-can Bus instance (created on first use).
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> # Physical CAN interface
|
|
61
|
+
>>> source = SocketCANSource("can0", bitrate=500000)
|
|
62
|
+
>>> trace = source.read(duration=10)
|
|
63
|
+
>>>
|
|
64
|
+
>>> # Virtual CAN for testing
|
|
65
|
+
>>> source = SocketCANSource("vcan0")
|
|
66
|
+
>>> with source:
|
|
67
|
+
... trace = source.read(duration=5)
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
interface: str,
|
|
73
|
+
*,
|
|
74
|
+
bitrate: int = 500000,
|
|
75
|
+
**kwargs: Any,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Initialize SocketCAN source.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
interface: SocketCAN interface name (e.g., "can0", "vcan0").
|
|
81
|
+
bitrate: CAN bitrate in bps (default: 500000).
|
|
82
|
+
**kwargs: Additional arguments passed to python-can Bus.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ImportError: If python-can is not installed.
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> source = SocketCANSource("can0", bitrate=500000)
|
|
89
|
+
>>> source = SocketCANSource("vcan0", receive_own_messages=True)
|
|
90
|
+
"""
|
|
91
|
+
self.interface = interface
|
|
92
|
+
self.bitrate = bitrate
|
|
93
|
+
self.kwargs = kwargs
|
|
94
|
+
self.bus: BusABC | None = None
|
|
95
|
+
self._closed = False
|
|
96
|
+
|
|
97
|
+
def _ensure_bus(self) -> None:
|
|
98
|
+
"""Ensure CAN bus is initialized.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ImportError: If python-can is not installed.
|
|
102
|
+
OSError: If interface doesn't exist or permissions denied.
|
|
103
|
+
"""
|
|
104
|
+
if self.bus is not None:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
import can
|
|
109
|
+
except ImportError as e:
|
|
110
|
+
raise ImportError(
|
|
111
|
+
"SocketCAN source requires python-can library. "
|
|
112
|
+
"Install with: pip install oscura[automotive]"
|
|
113
|
+
) from e
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
self.bus = can.Bus(
|
|
117
|
+
interface="socketcan",
|
|
118
|
+
channel=self.interface,
|
|
119
|
+
bitrate=self.bitrate,
|
|
120
|
+
**self.kwargs,
|
|
121
|
+
)
|
|
122
|
+
except OSError as e:
|
|
123
|
+
raise OSError(
|
|
124
|
+
f"Failed to open SocketCAN interface '{self.interface}'. "
|
|
125
|
+
f"Ensure interface exists and you have permissions. "
|
|
126
|
+
f"Error: {e}"
|
|
127
|
+
) from e
|
|
128
|
+
|
|
129
|
+
def read(self, duration: float = 10.0) -> Trace:
|
|
130
|
+
"""Read CAN messages for specified duration.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
duration: Acquisition duration in seconds (default: 10.0).
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
DigitalTrace containing captured CAN messages.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ImportError: If python-can is not installed.
|
|
140
|
+
OSError: If interface error occurs.
|
|
141
|
+
ValueError: If source is closed.
|
|
142
|
+
|
|
143
|
+
Example:
|
|
144
|
+
>>> source = SocketCANSource("can0")
|
|
145
|
+
>>> trace = source.read(duration=5.0)
|
|
146
|
+
>>> print(f"Captured {len(trace.data)} messages")
|
|
147
|
+
"""
|
|
148
|
+
if self._closed:
|
|
149
|
+
raise ValueError("Cannot read from closed source")
|
|
150
|
+
|
|
151
|
+
self._ensure_bus()
|
|
152
|
+
|
|
153
|
+
import time
|
|
154
|
+
|
|
155
|
+
import numpy as np
|
|
156
|
+
|
|
157
|
+
from oscura.core.types import DigitalTrace, TraceMetadata
|
|
158
|
+
|
|
159
|
+
messages = []
|
|
160
|
+
start_time = time.time()
|
|
161
|
+
acquisition_start = datetime.now()
|
|
162
|
+
|
|
163
|
+
while time.time() - start_time < duration:
|
|
164
|
+
msg = self.bus.recv(timeout=0.1) # type: ignore[union-attr]
|
|
165
|
+
if msg is not None:
|
|
166
|
+
messages.append(msg)
|
|
167
|
+
|
|
168
|
+
# Convert messages to digital trace format
|
|
169
|
+
# Each CAN ID becomes a channel, timestamp is the time base
|
|
170
|
+
if not messages:
|
|
171
|
+
# Return empty trace if no messages
|
|
172
|
+
metadata = TraceMetadata(
|
|
173
|
+
sample_rate=1.0, # Placeholder
|
|
174
|
+
acquisition_time=acquisition_start,
|
|
175
|
+
source_file=f"socketcan://{self.interface}",
|
|
176
|
+
channel_name=f"CAN {self.interface}",
|
|
177
|
+
)
|
|
178
|
+
return DigitalTrace(data=np.array([], dtype=np.uint8), metadata=metadata)
|
|
179
|
+
|
|
180
|
+
# Extract timestamps and data
|
|
181
|
+
timestamps = np.array([msg.timestamp for msg in messages])
|
|
182
|
+
can_ids = np.array([msg.arbitration_id for msg in messages], dtype=np.uint32)
|
|
183
|
+
|
|
184
|
+
# Calculate sample rate from message timing
|
|
185
|
+
time_range = timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 1.0
|
|
186
|
+
effective_rate = len(messages) / time_range if time_range > 0 else 1.0
|
|
187
|
+
|
|
188
|
+
metadata = TraceMetadata(
|
|
189
|
+
sample_rate=effective_rate,
|
|
190
|
+
acquisition_time=acquisition_start,
|
|
191
|
+
source_file=f"socketcan://{self.interface}",
|
|
192
|
+
channel_name=f"CAN {self.interface}",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Store CAN IDs as digital data
|
|
196
|
+
# Convert to bytes for DigitalTrace
|
|
197
|
+
data_bytes = can_ids.view(np.uint8)
|
|
198
|
+
|
|
199
|
+
return DigitalTrace(data=data_bytes, metadata=metadata) # type: ignore[arg-type]
|
|
200
|
+
|
|
201
|
+
def stream(self, duration: float = 60.0, chunk_size: int = 1000) -> Iterator[Trace]:
|
|
202
|
+
"""Stream CAN messages in chunks.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
duration: Total acquisition duration in seconds (default: 60.0).
|
|
206
|
+
chunk_size: Number of messages per chunk (default: 1000).
|
|
207
|
+
|
|
208
|
+
Yields:
|
|
209
|
+
DigitalTrace chunks containing CAN messages.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
ImportError: If python-can is not installed.
|
|
213
|
+
ValueError: If source is closed.
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
>>> source = SocketCANSource("can0")
|
|
217
|
+
>>> for chunk in source.stream(duration=60, chunk_size=1000):
|
|
218
|
+
... analyze(chunk)
|
|
219
|
+
"""
|
|
220
|
+
if self._closed:
|
|
221
|
+
raise ValueError("Cannot stream from closed source")
|
|
222
|
+
|
|
223
|
+
self._ensure_bus()
|
|
224
|
+
|
|
225
|
+
import time
|
|
226
|
+
|
|
227
|
+
import numpy as np
|
|
228
|
+
|
|
229
|
+
from oscura.core.types import DigitalTrace, TraceMetadata
|
|
230
|
+
|
|
231
|
+
start_time = time.time()
|
|
232
|
+
acquisition_start = datetime.now()
|
|
233
|
+
chunk_messages = []
|
|
234
|
+
|
|
235
|
+
while time.time() - start_time < duration:
|
|
236
|
+
msg = self.bus.recv(timeout=0.1) # type: ignore[union-attr]
|
|
237
|
+
if msg is not None:
|
|
238
|
+
chunk_messages.append(msg)
|
|
239
|
+
|
|
240
|
+
if len(chunk_messages) >= chunk_size:
|
|
241
|
+
# Yield chunk
|
|
242
|
+
timestamps = np.array([m.timestamp for m in chunk_messages])
|
|
243
|
+
can_ids = np.array([m.arbitration_id for m in chunk_messages], dtype=np.uint32)
|
|
244
|
+
|
|
245
|
+
time_range = timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 1.0
|
|
246
|
+
effective_rate = len(chunk_messages) / time_range if time_range > 0 else 1.0
|
|
247
|
+
|
|
248
|
+
metadata = TraceMetadata(
|
|
249
|
+
sample_rate=effective_rate,
|
|
250
|
+
acquisition_time=acquisition_start,
|
|
251
|
+
source_file=f"socketcan://{self.interface}",
|
|
252
|
+
channel_name=f"CAN {self.interface}",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
data_bytes = can_ids.view(np.uint8)
|
|
256
|
+
yield DigitalTrace(data=data_bytes, metadata=metadata) # type: ignore[arg-type]
|
|
257
|
+
|
|
258
|
+
chunk_messages = []
|
|
259
|
+
|
|
260
|
+
# Yield remaining messages
|
|
261
|
+
if chunk_messages:
|
|
262
|
+
timestamps = np.array([m.timestamp for m in chunk_messages])
|
|
263
|
+
can_ids = np.array([m.arbitration_id for m in chunk_messages], dtype=np.uint32)
|
|
264
|
+
|
|
265
|
+
time_range = timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 1.0
|
|
266
|
+
effective_rate = len(chunk_messages) / time_range if time_range > 0 else 1.0
|
|
267
|
+
|
|
268
|
+
metadata = TraceMetadata(
|
|
269
|
+
sample_rate=effective_rate,
|
|
270
|
+
acquisition_time=acquisition_start,
|
|
271
|
+
source_file=f"socketcan://{self.interface}",
|
|
272
|
+
channel_name=f"CAN {self.interface}",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
data_bytes = can_ids.view(np.uint8)
|
|
276
|
+
yield DigitalTrace(data=data_bytes, metadata=metadata) # type: ignore[arg-type]
|
|
277
|
+
|
|
278
|
+
def close(self) -> None:
|
|
279
|
+
"""Close the CAN bus connection and release resources.
|
|
280
|
+
|
|
281
|
+
Example:
|
|
282
|
+
>>> source = SocketCANSource("can0")
|
|
283
|
+
>>> trace = source.read()
|
|
284
|
+
>>> source.close()
|
|
285
|
+
"""
|
|
286
|
+
if self.bus is not None:
|
|
287
|
+
self.bus.shutdown()
|
|
288
|
+
self.bus = None
|
|
289
|
+
self._closed = True
|
|
290
|
+
|
|
291
|
+
def __enter__(self) -> SocketCANSource:
|
|
292
|
+
"""Context manager entry.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Self for use in 'with' statement.
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
>>> with SocketCANSource("can0") as source:
|
|
299
|
+
... trace = source.read()
|
|
300
|
+
"""
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
def __exit__(self, *args: object) -> None:
|
|
304
|
+
"""Context manager exit.
|
|
305
|
+
|
|
306
|
+
Automatically calls close() when exiting 'with' block.
|
|
307
|
+
"""
|
|
308
|
+
self.close()
|
|
309
|
+
|
|
310
|
+
def __repr__(self) -> str:
|
|
311
|
+
"""String representation."""
|
|
312
|
+
return f"SocketCANSource(interface={self.interface!r}, bitrate={self.bitrate})"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
__all__ = ["SocketCANSource"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Streaming acquisition infrastructure (Phase 2 implementation).
|
|
2
|
+
|
|
3
|
+
This module will provide unified streaming support for all acquisition sources.
|
|
4
|
+
Planned features:
|
|
5
|
+
- Real-time hardware streaming (SocketCAN, Saleae, PyVISA)
|
|
6
|
+
- Chunked file loading for huge traces
|
|
7
|
+
- Live analysis and processing
|
|
8
|
+
- Buffering and backpressure management
|
|
9
|
+
|
|
10
|
+
Example (Future):
|
|
11
|
+
>>> from oscura.acquisition import HardwareSource
|
|
12
|
+
>>> from oscura.acquisition.streaming import LiveProcessor
|
|
13
|
+
>>>
|
|
14
|
+
>>> # Real-time streaming from hardware
|
|
15
|
+
>>> processor = LiveProcessor(
|
|
16
|
+
... source=HardwareSource.socketcan("can0"),
|
|
17
|
+
... chunk_size=1000,
|
|
18
|
+
... overlap=100,
|
|
19
|
+
... )
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Process live data
|
|
22
|
+
>>> for chunk in processor.stream():
|
|
23
|
+
... metrics = analyze(chunk)
|
|
24
|
+
... if metrics.anomaly_detected:
|
|
25
|
+
... processor.trigger_capture()
|
|
26
|
+
|
|
27
|
+
Timeline:
|
|
28
|
+
Phase 0 (current): Placeholder module
|
|
29
|
+
Phase 2 (Week 5-7): Full implementation with hardware sources
|
|
30
|
+
|
|
31
|
+
References:
|
|
32
|
+
Architecture Plan Phase 2: Hardware Integration
|
|
33
|
+
Architecture Plan Feature 4: Live Analysis Streaming
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Placeholder - will be implemented in Phase 2
|
|
37
|
+
|
|
38
|
+
__all__: list[str] = []
|