oscura 0.3.0__py3-none-any.whl → 0.4.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/clock.py +9 -1
- oscura/analyzers/digital/edges.py +1 -1
- oscura/analyzers/digital/timing.py +41 -11
- 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/loaders/__init__.py +1 -0
- oscura/loaders/chipwhisperer.py +393 -0
- oscura/loaders/touchstone.py +1 -1
- 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-0.3.0.dist-info → oscura-0.4.0.dist-info}/METADATA +86 -5
- {oscura-0.3.0.dist-info → oscura-0.4.0.dist-info}/RECORD +37 -21
- {oscura-0.3.0.dist-info → oscura-0.4.0.dist-info}/WHEEL +0 -0
- {oscura-0.3.0.dist-info → oscura-0.4.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.3.0.dist-info → oscura-0.4.0.dist-info}/licenses/LICENSE +0 -0
oscura/sessions/base.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Base class for analysis sessions.
|
|
2
|
+
|
|
3
|
+
This module defines AnalysisSession - the abstract base class for all
|
|
4
|
+
interactive analysis sessions in Oscura. It provides a unified pattern
|
|
5
|
+
for domain-specific sessions (CAN, Serial, BlackBox, etc.).
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from oscura.sessions import AnalysisSession
|
|
9
|
+
>>> from oscura.acquisition import FileSource, HardwareSource
|
|
10
|
+
>>>
|
|
11
|
+
>>> # Domain-specific sessions extend AnalysisSession
|
|
12
|
+
>>> class CANSession(AnalysisSession):
|
|
13
|
+
... def analyze(self):
|
|
14
|
+
... # CAN-specific analysis
|
|
15
|
+
... return self.discover_signals()
|
|
16
|
+
...
|
|
17
|
+
... def discover_signals(self):
|
|
18
|
+
... # Extract CAN signals from recordings
|
|
19
|
+
... pass
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Unified interface across all sessions
|
|
22
|
+
>>> session = CANSession()
|
|
23
|
+
>>> session.add_recording("baseline", FileSource("idle.blf"))
|
|
24
|
+
>>> session.add_recording("active", FileSource("running.blf"))
|
|
25
|
+
>>> diff = session.compare("baseline", "active")
|
|
26
|
+
|
|
27
|
+
Pattern:
|
|
28
|
+
All domain-specific sessions (CAN, Serial, BlackBox, etc.) inherit
|
|
29
|
+
from AnalysisSession and provide:
|
|
30
|
+
- Recording management (add_recording, list_recordings)
|
|
31
|
+
- Comparison and differential analysis
|
|
32
|
+
- Result export (reports, specs, dissectors)
|
|
33
|
+
- Domain-specific analysis methods (abstract)
|
|
34
|
+
|
|
35
|
+
Benefits:
|
|
36
|
+
- Consistent API across all analysis domains
|
|
37
|
+
- Polymorphic session handling
|
|
38
|
+
- Shared infrastructure (comparison, export, history)
|
|
39
|
+
- Domain-specific specialization via inheritance
|
|
40
|
+
|
|
41
|
+
References:
|
|
42
|
+
Architecture Plan Phase 0.3: AnalysisSession Base Class
|
|
43
|
+
docs/architecture/api-patterns.md: When to use Sessions
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from abc import ABC, abstractmethod
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from datetime import datetime
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import TYPE_CHECKING, Any
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from oscura.acquisition import Source
|
|
56
|
+
from oscura.core.types import Trace
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ComparisonResult:
|
|
61
|
+
"""Result of comparing two recordings.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
recording1: Name of first recording.
|
|
65
|
+
recording2: Name of second recording.
|
|
66
|
+
changed_bytes: Number of bytes that differ.
|
|
67
|
+
changed_regions: List of (start, end, description) tuples.
|
|
68
|
+
similarity_score: Similarity metric (0.0 to 1.0).
|
|
69
|
+
details: Additional comparison details.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
>>> result = session.compare("baseline", "stimulus")
|
|
73
|
+
>>> print(f"Changed bytes: {result.changed_bytes}")
|
|
74
|
+
>>> print(f"Similarity: {result.similarity_score:.2%}")
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
recording1: str
|
|
78
|
+
recording2: str
|
|
79
|
+
changed_bytes: int
|
|
80
|
+
changed_regions: list[tuple[int, int, str]] = field(default_factory=list)
|
|
81
|
+
similarity_score: float = 0.0
|
|
82
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AnalysisSession(ABC):
|
|
86
|
+
"""Abstract base class for all analysis sessions.
|
|
87
|
+
|
|
88
|
+
Provides unified interface for interactive signal analysis across
|
|
89
|
+
different domains (CAN, Serial, RF, BlackBox, etc.). All domain-specific
|
|
90
|
+
sessions extend this class.
|
|
91
|
+
|
|
92
|
+
Subclasses must implement:
|
|
93
|
+
- analyze(): Domain-specific analysis method
|
|
94
|
+
|
|
95
|
+
Subclasses may override:
|
|
96
|
+
- export_results(): Custom export formats
|
|
97
|
+
- compare(): Domain-specific comparison logic
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
name: Session name.
|
|
101
|
+
recordings: Dictionary mapping names to (source, trace) tuples.
|
|
102
|
+
metadata: Session metadata dictionary.
|
|
103
|
+
created_at: Session creation timestamp.
|
|
104
|
+
modified_at: Last modification timestamp.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> # Subclass for domain-specific analysis
|
|
108
|
+
>>> class SerialSession(AnalysisSession):
|
|
109
|
+
... def analyze(self):
|
|
110
|
+
... # Detect baud rate, decode UART
|
|
111
|
+
... baud = self.detect_baud_rate()
|
|
112
|
+
... frames = self.decode_uart(baud)
|
|
113
|
+
... return {"baud_rate": baud, "frames": frames}
|
|
114
|
+
...
|
|
115
|
+
... def detect_baud_rate(self):
|
|
116
|
+
... # Domain-specific logic
|
|
117
|
+
... pass
|
|
118
|
+
>>>
|
|
119
|
+
>>> session = SerialSession()
|
|
120
|
+
>>> session.add_recording("capture", FileSource("uart.wfm"))
|
|
121
|
+
>>> results = session.analyze()
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, name: str = "Untitled Session") -> None:
|
|
125
|
+
"""Initialize analysis session.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
name: Session name (default: "Untitled Session").
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
>>> session = CANSession(name="Vehicle Debug Session")
|
|
132
|
+
"""
|
|
133
|
+
self.name = name
|
|
134
|
+
self.recordings: dict[str, tuple[Source, Trace | None]] = {}
|
|
135
|
+
self.metadata: dict[str, Any] = {}
|
|
136
|
+
self.created_at = datetime.now()
|
|
137
|
+
self.modified_at = datetime.now()
|
|
138
|
+
|
|
139
|
+
def add_recording(
|
|
140
|
+
self,
|
|
141
|
+
name: str,
|
|
142
|
+
source: Source,
|
|
143
|
+
*,
|
|
144
|
+
load_immediately: bool = True,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Add a recording to the session.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
name: Name for this recording (e.g., "baseline", "stimulus1").
|
|
150
|
+
source: Source to acquire data from (FileSource, HardwareSource, etc.).
|
|
151
|
+
load_immediately: If True, load trace now. If False, defer loading.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ValueError: If name already exists.
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
>>> from oscura.acquisition import FileSource
|
|
158
|
+
>>> session.add_recording("baseline", FileSource("idle.blf"))
|
|
159
|
+
>>> session.add_recording("active", FileSource("running.blf"))
|
|
160
|
+
"""
|
|
161
|
+
if name in self.recordings:
|
|
162
|
+
raise ValueError(f"Recording '{name}' already exists in session")
|
|
163
|
+
|
|
164
|
+
# Load trace if requested
|
|
165
|
+
trace = source.read() if load_immediately else None
|
|
166
|
+
|
|
167
|
+
self.recordings[name] = (source, trace)
|
|
168
|
+
self.modified_at = datetime.now()
|
|
169
|
+
|
|
170
|
+
def get_recording(self, name: str) -> Trace:
|
|
171
|
+
"""Get a recording by name, loading if necessary.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
name: Recording name.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Loaded trace.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
KeyError: If recording not found.
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
>>> trace = session.get_recording("baseline")
|
|
184
|
+
>>> print(f"Loaded {len(trace.data)} samples")
|
|
185
|
+
"""
|
|
186
|
+
if name not in self.recordings:
|
|
187
|
+
available = list(self.recordings.keys())
|
|
188
|
+
raise KeyError(f"Recording '{name}' not found. Available: {available}")
|
|
189
|
+
|
|
190
|
+
source, trace = self.recordings[name]
|
|
191
|
+
|
|
192
|
+
# Load if not already loaded
|
|
193
|
+
if trace is None:
|
|
194
|
+
trace = source.read()
|
|
195
|
+
self.recordings[name] = (source, trace)
|
|
196
|
+
|
|
197
|
+
return trace
|
|
198
|
+
|
|
199
|
+
def list_recordings(self) -> list[str]:
|
|
200
|
+
"""List all recording names in the session.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
List of recording names.
|
|
204
|
+
|
|
205
|
+
Example:
|
|
206
|
+
>>> session.list_recordings()
|
|
207
|
+
['baseline', 'stimulus1', 'stimulus2']
|
|
208
|
+
"""
|
|
209
|
+
return list(self.recordings.keys())
|
|
210
|
+
|
|
211
|
+
def compare(self, name1: str, name2: str) -> ComparisonResult:
|
|
212
|
+
"""Compare two recordings (differential analysis).
|
|
213
|
+
|
|
214
|
+
Default implementation provides basic byte-level comparison.
|
|
215
|
+
Subclasses can override for domain-specific comparison logic.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
name1: First recording name.
|
|
219
|
+
name2: Second recording name.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
ComparisonResult with differences.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
KeyError: If recordings not found.
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
>>> result = session.compare("baseline", "stimulus")
|
|
229
|
+
>>> print(f"Changed: {result.changed_bytes} bytes")
|
|
230
|
+
>>> print(f"Similarity: {result.similarity_score:.2%}")
|
|
231
|
+
"""
|
|
232
|
+
trace1 = self.get_recording(name1)
|
|
233
|
+
trace2 = self.get_recording(name2)
|
|
234
|
+
|
|
235
|
+
# Basic comparison - count differing samples
|
|
236
|
+
import numpy as np
|
|
237
|
+
|
|
238
|
+
from oscura.core.types import IQTrace
|
|
239
|
+
|
|
240
|
+
# Handle IQTrace separately
|
|
241
|
+
if isinstance(trace1, IQTrace) or isinstance(trace2, IQTrace):
|
|
242
|
+
raise TypeError("IQTrace comparison not yet supported in base session")
|
|
243
|
+
|
|
244
|
+
min_len = min(len(trace1.data), len(trace2.data))
|
|
245
|
+
data1 = trace1.data[:min_len]
|
|
246
|
+
data2 = trace2.data[:min_len]
|
|
247
|
+
|
|
248
|
+
# For analog traces, use threshold comparison
|
|
249
|
+
threshold = 0.01 # 1% tolerance
|
|
250
|
+
changed = np.abs(data1 - data2) > threshold
|
|
251
|
+
changed_count = int(np.sum(changed))
|
|
252
|
+
|
|
253
|
+
# Similarity score
|
|
254
|
+
similarity = 1.0 - (changed_count / min_len)
|
|
255
|
+
|
|
256
|
+
return ComparisonResult(
|
|
257
|
+
recording1=name1,
|
|
258
|
+
recording2=name2,
|
|
259
|
+
changed_bytes=changed_count,
|
|
260
|
+
similarity_score=similarity,
|
|
261
|
+
details={
|
|
262
|
+
"trace1_length": len(trace1.data),
|
|
263
|
+
"trace2_length": len(trace2.data),
|
|
264
|
+
"compared_length": min_len,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def export_results(self, format: str, path: str | Path) -> None:
|
|
269
|
+
"""Export analysis results to file.
|
|
270
|
+
|
|
271
|
+
Default implementation provides basic export. Subclasses should
|
|
272
|
+
override to support domain-specific formats (DBC, Wireshark, etc.).
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
format: Export format (e.g., "report", "json", "csv").
|
|
276
|
+
path: Output file path.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
ValueError: If format not supported.
|
|
280
|
+
|
|
281
|
+
Example:
|
|
282
|
+
>>> session.export_results("report", "analysis.txt")
|
|
283
|
+
>>> session.export_results("json", "results.json")
|
|
284
|
+
"""
|
|
285
|
+
path = Path(path)
|
|
286
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
|
|
288
|
+
if format == "report":
|
|
289
|
+
# Basic text report
|
|
290
|
+
with open(path, "w") as f:
|
|
291
|
+
f.write(f"Analysis Session: {self.name}\n")
|
|
292
|
+
f.write(f"Created: {self.created_at}\n")
|
|
293
|
+
f.write(f"Modified: {self.modified_at}\n\n")
|
|
294
|
+
f.write(f"Recordings: {len(self.recordings)}\n")
|
|
295
|
+
for name in self.list_recordings():
|
|
296
|
+
f.write(f" - {name}\n")
|
|
297
|
+
else:
|
|
298
|
+
raise ValueError(f"Unsupported export format: {format}")
|
|
299
|
+
|
|
300
|
+
@abstractmethod
|
|
301
|
+
def analyze(self) -> Any:
|
|
302
|
+
"""Perform domain-specific analysis.
|
|
303
|
+
|
|
304
|
+
Subclasses must implement this method to provide domain-specific
|
|
305
|
+
analysis functionality (CAN signal discovery, UART decoding,
|
|
306
|
+
protocol reverse engineering, etc.).
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Analysis results (format depends on domain).
|
|
310
|
+
|
|
311
|
+
Example:
|
|
312
|
+
>>> class CANSession(AnalysisSession):
|
|
313
|
+
... def analyze(self):
|
|
314
|
+
... signals = self.discover_signals()
|
|
315
|
+
... return {"signals": signals}
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def __repr__(self) -> str:
|
|
319
|
+
"""String representation."""
|
|
320
|
+
return f"{self.__class__.__name__}(name={self.name!r}, recordings={len(self.recordings)})"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
__all__ = ["AnalysisSession", "ComparisonResult"]
|