Initialized MiniProfiler project
- Contains the host code with a protocol implementation, data analyser and web-based visualiser
This commit is contained in:
6
host/miniprofiler/__init__.py
Normal file
6
host/miniprofiler/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""MiniProfiler Host Application
|
||||
|
||||
A Python-based host application for profiling embedded STM32 applications.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
314
host/miniprofiler/analyzer.py
Normal file
314
host/miniprofiler/analyzer.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Profiling data analysis and visualization data generation.
|
||||
|
||||
This module processes raw profiling records to build call trees,
|
||||
compute statistics, and generate data structures for visualization.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Any
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .protocol import ProfileRecord
|
||||
from .symbolizer import Symbolizer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionStats:
|
||||
"""Statistics for a single function."""
|
||||
name: str
|
||||
address: int
|
||||
call_count: int = 0
|
||||
total_time_us: int = 0
|
||||
min_time_us: int = float('inf')
|
||||
max_time_us: int = 0
|
||||
self_time_us: int = 0 # Time excluding children
|
||||
|
||||
def update(self, duration_us: int):
|
||||
"""Update statistics with a new duration measurement."""
|
||||
self.call_count += 1
|
||||
self.total_time_us += duration_us
|
||||
self.min_time_us = min(self.min_time_us, duration_us)
|
||||
self.max_time_us = max(self.max_time_us, duration_us)
|
||||
|
||||
@property
|
||||
def avg_time_us(self) -> float:
|
||||
"""Average execution time in microseconds."""
|
||||
return self.total_time_us / self.call_count if self.call_count > 0 else 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallTreeNode:
|
||||
"""Node in the call tree."""
|
||||
name: str
|
||||
address: int
|
||||
entry_time: int
|
||||
duration_us: int
|
||||
depth: int
|
||||
children: List['CallTreeNode'] = field(default_factory=list)
|
||||
|
||||
def add_child(self, node: 'CallTreeNode'):
|
||||
"""Add a child node to this node."""
|
||||
self.children.append(node)
|
||||
|
||||
def to_flamegraph_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to d3-flame-graph format.
|
||||
|
||||
Returns:
|
||||
Dictionary in the format:
|
||||
{
|
||||
"name": "function_name",
|
||||
"value": duration_in_microseconds,
|
||||
"children": [child_dicts...]
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"name": self.name,
|
||||
"value": self.duration_us
|
||||
}
|
||||
|
||||
if self.children:
|
||||
result["children"] = [child.to_flamegraph_dict() for child in self.children]
|
||||
|
||||
return result
|
||||
|
||||
def to_timeline_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to timeline/flame chart format.
|
||||
|
||||
Returns:
|
||||
Dictionary with timing information for Plotly timeline
|
||||
"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"start": self.entry_time,
|
||||
"duration": self.duration_us,
|
||||
"depth": self.depth,
|
||||
"children": [child.to_timeline_dict() for child in self.children]
|
||||
}
|
||||
|
||||
|
||||
class ProfileAnalyzer:
|
||||
"""Analyzes profiling data and generates visualization data."""
|
||||
|
||||
def __init__(self, symbolizer: Optional[Symbolizer] = None):
|
||||
"""Initialize the analyzer.
|
||||
|
||||
Args:
|
||||
symbolizer: Symbolizer for resolving addresses to names
|
||||
"""
|
||||
self.symbolizer = symbolizer
|
||||
self.records: List[ProfileRecord] = []
|
||||
self.stats: Dict[int, FunctionStats] = {} # addr -> stats
|
||||
self.call_tree: Optional[CallTreeNode] = None
|
||||
self.timeline_events: List[Dict[str, Any]] = []
|
||||
|
||||
def add_records(self, records: List[ProfileRecord]):
|
||||
"""Add profiling records for analysis.
|
||||
|
||||
Args:
|
||||
records: List of ProfileRecord objects
|
||||
"""
|
||||
self.records.extend(records)
|
||||
logger.debug(f"Added {len(records)} records, total: {len(self.records)}")
|
||||
|
||||
def clear(self):
|
||||
"""Clear all recorded data."""
|
||||
self.records.clear()
|
||||
self.stats.clear()
|
||||
self.call_tree = None
|
||||
self.timeline_events.clear()
|
||||
logger.info("Cleared all profiling data")
|
||||
|
||||
def _resolve_name(self, addr: int) -> str:
|
||||
"""Resolve address to function name."""
|
||||
if self.symbolizer:
|
||||
return self.symbolizer.resolve_name(addr)
|
||||
return f"func_0x{addr:08x}"
|
||||
|
||||
def compute_statistics(self) -> Dict[int, FunctionStats]:
|
||||
"""Compute statistics for all functions.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping addresses to FunctionStats
|
||||
"""
|
||||
self.stats.clear()
|
||||
|
||||
for record in self.records:
|
||||
addr = record.func_addr
|
||||
name = self._resolve_name(addr)
|
||||
|
||||
if addr not in self.stats:
|
||||
self.stats[addr] = FunctionStats(name=name, address=addr)
|
||||
|
||||
self.stats[addr].update(record.duration_us)
|
||||
|
||||
logger.info(f"Computed statistics for {len(self.stats)} functions")
|
||||
return self.stats
|
||||
|
||||
def build_call_tree(self) -> Optional[CallTreeNode]:
|
||||
"""Build call tree from profiling records.
|
||||
|
||||
The call tree is built using the depth field to determine
|
||||
parent-child relationships.
|
||||
|
||||
Returns:
|
||||
Root node of the call tree, or None if no records
|
||||
"""
|
||||
if not self.records:
|
||||
return None
|
||||
|
||||
# Sort records by entry time to process in chronological order
|
||||
sorted_records = sorted(self.records, key=lambda r: r.entry_time)
|
||||
|
||||
# Stack to track current path in the tree
|
||||
# stack[depth] = node at that depth
|
||||
stack: List[CallTreeNode] = []
|
||||
root = None
|
||||
|
||||
for record in sorted_records:
|
||||
name = self._resolve_name(record.func_addr)
|
||||
|
||||
node = CallTreeNode(
|
||||
name=name,
|
||||
address=record.func_addr,
|
||||
entry_time=record.entry_time,
|
||||
duration_us=record.duration_us,
|
||||
depth=record.depth
|
||||
)
|
||||
|
||||
# Adjust stack to current depth
|
||||
while len(stack) > record.depth:
|
||||
stack.pop()
|
||||
|
||||
# Add node to tree
|
||||
if record.depth == 0:
|
||||
# Root level function
|
||||
if root is None:
|
||||
root = node
|
||||
stack = [root]
|
||||
else:
|
||||
# Multiple root-level functions - create synthetic root
|
||||
if not isinstance(root.name, str) or not root.name.startswith("__root__"):
|
||||
synthetic_root = CallTreeNode(
|
||||
name="__root__",
|
||||
address=0,
|
||||
entry_time=0,
|
||||
duration_us=0,
|
||||
depth=-1
|
||||
)
|
||||
synthetic_root.add_child(root)
|
||||
root = synthetic_root
|
||||
stack = [root]
|
||||
|
||||
root.add_child(node)
|
||||
# Update root duration to encompass all children
|
||||
root.duration_us = max(root.duration_us,
|
||||
node.entry_time + node.duration_us)
|
||||
|
||||
else:
|
||||
# Child function
|
||||
if len(stack) >= record.depth:
|
||||
parent = stack[record.depth - 1]
|
||||
parent.add_child(node)
|
||||
else:
|
||||
logger.warning(f"Orphan node at depth {record.depth}: {name}")
|
||||
continue
|
||||
|
||||
# Push to stack if we're going deeper
|
||||
if len(stack) == record.depth:
|
||||
stack.append(node)
|
||||
elif len(stack) == record.depth + 1:
|
||||
stack[record.depth] = node
|
||||
|
||||
self.call_tree = root
|
||||
logger.info(f"Built call tree with {len(sorted_records)} nodes")
|
||||
return root
|
||||
|
||||
def to_flamegraph_json(self) -> Dict[str, Any]:
|
||||
"""Generate flame graph data in d3-flame-graph format.
|
||||
|
||||
Returns:
|
||||
Dictionary suitable for d3-flame-graph
|
||||
"""
|
||||
if self.call_tree is None:
|
||||
self.build_call_tree()
|
||||
|
||||
if self.call_tree is None:
|
||||
return {"name": "root", "value": 0, "children": []}
|
||||
|
||||
return self.call_tree.to_flamegraph_dict()
|
||||
|
||||
def to_timeline_json(self) -> List[Dict[str, Any]]:
|
||||
"""Generate timeline data for flame chart visualization.
|
||||
|
||||
Returns:
|
||||
List of events for timeline/flame chart
|
||||
"""
|
||||
events = []
|
||||
|
||||
for record in sorted(self.records, key=lambda r: r.entry_time):
|
||||
name = self._resolve_name(record.func_addr)
|
||||
|
||||
events.append({
|
||||
"name": name,
|
||||
"start": record.entry_time,
|
||||
"end": record.entry_time + record.duration_us,
|
||||
"duration": record.duration_us,
|
||||
"depth": record.depth
|
||||
})
|
||||
|
||||
return events
|
||||
|
||||
def to_statistics_json(self) -> List[Dict[str, Any]]:
|
||||
"""Generate statistics table data.
|
||||
|
||||
Returns:
|
||||
List of function statistics dictionaries
|
||||
"""
|
||||
if not self.stats:
|
||||
self.compute_statistics()
|
||||
|
||||
stats_list = []
|
||||
for func_stats in self.stats.values():
|
||||
stats_list.append({
|
||||
"name": func_stats.name,
|
||||
"address": f"0x{func_stats.address:08x}",
|
||||
"calls": func_stats.call_count,
|
||||
"total_us": func_stats.total_time_us,
|
||||
"avg_us": func_stats.avg_time_us,
|
||||
"min_us": func_stats.min_time_us,
|
||||
"max_us": func_stats.max_time_us,
|
||||
})
|
||||
|
||||
# Sort by total time (descending)
|
||||
stats_list.sort(key=lambda x: x["total_us"], reverse=True)
|
||||
|
||||
return stats_list
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with summary information
|
||||
"""
|
||||
if not self.stats:
|
||||
self.compute_statistics()
|
||||
|
||||
total_records = len(self.records)
|
||||
total_functions = len(self.stats)
|
||||
total_time = sum(s.total_time_us for s in self.stats.values())
|
||||
|
||||
hottest = None
|
||||
if self.stats:
|
||||
hottest = max(self.stats.values(), key=lambda s: s.total_time_us)
|
||||
|
||||
return {
|
||||
"total_records": total_records,
|
||||
"total_functions": total_functions,
|
||||
"total_time_us": total_time,
|
||||
"hottest_function": hottest.name if hottest else None,
|
||||
"hottest_time_us": hottest.total_time_us if hottest else 0,
|
||||
}
|
||||
90
host/miniprofiler/cli.py
Normal file
90
host/miniprofiler/cli.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Command-line interface for MiniProfiler."""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from .web_server import create_app
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False):
|
||||
"""Configure logging.
|
||||
|
||||
Args:
|
||||
verbose: Enable verbose (DEBUG) logging
|
||||
"""
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='MiniProfiler - Real-time Embedded Profiling Visualization'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--host',
|
||||
type=str,
|
||||
default='0.0.0.0',
|
||||
help='Host address to bind to (default: 0.0.0.0)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--port',
|
||||
type=int,
|
||||
default=5000,
|
||||
help='Port number to listen on (default: 5000)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='Enable debug mode'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='Enable verbose logging'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
setup_logging(args.verbose)
|
||||
|
||||
# Create and run the web server
|
||||
print(f"""
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ MiniProfiler ║
|
||||
║ Real-time Embedded Profiling Tool ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
|
||||
Starting web server...
|
||||
Host: {args.host}
|
||||
Port: {args.port}
|
||||
|
||||
Open your browser and navigate to:
|
||||
http://localhost:{args.port}
|
||||
|
||||
Press Ctrl+C to stop the server.
|
||||
""")
|
||||
|
||||
try:
|
||||
server = create_app(args.host, args.port)
|
||||
server.run(debug=args.debug)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nShutting down gracefully...")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
280
host/miniprofiler/protocol.py
Normal file
280
host/miniprofiler/protocol.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Protocol definitions and packet structures for MiniProfiler.
|
||||
|
||||
This module defines the binary protocol used for communication between
|
||||
the embedded device and the host application.
|
||||
"""
|
||||
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
import crc
|
||||
|
||||
|
||||
class Command(IntEnum):
|
||||
"""Commands sent from host to embedded device."""
|
||||
START_PROFILING = 0x01
|
||||
STOP_PROFILING = 0x02
|
||||
GET_STATUS = 0x03
|
||||
RESET_BUFFERS = 0x04
|
||||
GET_METADATA = 0x05
|
||||
SET_CONFIG = 0x06
|
||||
|
||||
|
||||
class ResponseType(IntEnum):
|
||||
"""Response types from embedded device to host."""
|
||||
ACK = 0x01
|
||||
NACK = 0x02
|
||||
METADATA = 0x03
|
||||
STATUS = 0x04
|
||||
PROFILE_DATA = 0x05
|
||||
|
||||
|
||||
# Protocol constants
|
||||
COMMAND_HEADER = 0x55
|
||||
RESPONSE_HEADER = 0xAA55
|
||||
RESPONSE_END = 0x0A
|
||||
PROTOCOL_VERSION = 0x01
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfileRecord:
|
||||
"""A single profiling record from the embedded device.
|
||||
|
||||
Attributes:
|
||||
func_addr: Function address (from instrumentation)
|
||||
entry_time: Entry timestamp in microseconds
|
||||
duration_us: Function duration in microseconds
|
||||
depth: Call stack depth
|
||||
"""
|
||||
func_addr: int
|
||||
entry_time: int
|
||||
duration_us: int
|
||||
depth: int
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> 'ProfileRecord':
|
||||
"""Parse a ProfileRecord from binary data.
|
||||
|
||||
Format: <I (func_addr) <I (entry_time) <I (duration_us) <H (depth)
|
||||
Total: 14 bytes
|
||||
"""
|
||||
if len(data) < 14:
|
||||
raise ValueError(f"Invalid ProfileRecord size: {len(data)} bytes")
|
||||
|
||||
func_addr, entry_time, duration_us, depth = struct.unpack('<IIIH', data[:14])
|
||||
return cls(func_addr, entry_time, duration_us, depth)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Serialize ProfileRecord to binary format."""
|
||||
return struct.pack('<IIIH', self.func_addr, self.entry_time,
|
||||
self.duration_us, self.depth)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metadata:
|
||||
"""Metadata packet sent by embedded device at startup.
|
||||
|
||||
Attributes:
|
||||
mcu_clock_hz: MCU clock frequency in Hz
|
||||
timer_freq: Timer frequency in Hz
|
||||
elf_build_id: CRC32 of .text section for version matching
|
||||
fw_version: Firmware version string
|
||||
"""
|
||||
mcu_clock_hz: int
|
||||
timer_freq: int
|
||||
elf_build_id: int
|
||||
fw_version: str
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> 'Metadata':
|
||||
"""Parse Metadata from binary data.
|
||||
|
||||
Format: <I (mcu_clock) <I (timer_freq) <I (build_id) 16s (fw_version)
|
||||
Total: 28 bytes
|
||||
"""
|
||||
if len(data) < 28:
|
||||
raise ValueError(f"Invalid Metadata size: {len(data)} bytes")
|
||||
|
||||
mcu_clock_hz, timer_freq, elf_build_id, fw_version_bytes = struct.unpack(
|
||||
'<III16s', data[:28]
|
||||
)
|
||||
fw_version = fw_version_bytes.decode('utf-8').rstrip('\x00')
|
||||
return cls(mcu_clock_hz, timer_freq, elf_build_id, fw_version)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Serialize Metadata to binary format."""
|
||||
fw_version_bytes = self.fw_version.encode('utf-8')[:16].ljust(16, b'\x00')
|
||||
return struct.pack('<III16s', self.mcu_clock_hz, self.timer_freq,
|
||||
self.elf_build_id, fw_version_bytes)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusInfo:
|
||||
"""Status information from embedded device.
|
||||
|
||||
Attributes:
|
||||
is_profiling: Whether profiling is currently active
|
||||
buffer_overflows: Number of buffer overflow events
|
||||
records_captured: Total number of records captured
|
||||
buffer_usage_percent: Current buffer usage percentage
|
||||
"""
|
||||
is_profiling: bool
|
||||
buffer_overflows: int
|
||||
records_captured: int
|
||||
buffer_usage_percent: int
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> 'StatusInfo':
|
||||
"""Parse StatusInfo from binary data.
|
||||
|
||||
Format: <B (is_profiling) <I (overflows) <I (records) <B (buffer_pct)
|
||||
Total: 10 bytes
|
||||
"""
|
||||
if len(data) < 10:
|
||||
raise ValueError(f"Invalid StatusInfo size: {len(data)} bytes")
|
||||
|
||||
is_profiling, buffer_overflows, records_captured, buffer_usage_percent = \
|
||||
struct.unpack('<BIIB', data[:10])
|
||||
return cls(bool(is_profiling), buffer_overflows, records_captured,
|
||||
buffer_usage_percent)
|
||||
|
||||
|
||||
class CommandPacket:
|
||||
"""Command packet sent from host to embedded device."""
|
||||
|
||||
def __init__(self, cmd: Command, payload: bytes = b''):
|
||||
"""Create a command packet.
|
||||
|
||||
Args:
|
||||
cmd: Command type
|
||||
payload: Optional payload (max 8 bytes)
|
||||
"""
|
||||
if len(payload) > 8:
|
||||
raise ValueError("Command payload cannot exceed 8 bytes")
|
||||
self.cmd = cmd
|
||||
self.payload = payload.ljust(8, b'\x00')
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Serialize command packet to binary format.
|
||||
|
||||
Format: <B (header) <B (cmd) <B (payload_len) 8s (payload) <B (checksum)
|
||||
Total: 12 bytes
|
||||
"""
|
||||
payload_len = len(self.payload.rstrip(b'\x00'))
|
||||
data = struct.pack('<BBB8s', COMMAND_HEADER, self.cmd, payload_len, self.payload)
|
||||
checksum = sum(data) & 0xFF
|
||||
return data + struct.pack('<B', checksum)
|
||||
|
||||
|
||||
class ResponsePacket:
|
||||
"""Response packet from embedded device to host."""
|
||||
|
||||
def __init__(self, response_type: ResponseType, payload: bytes):
|
||||
"""Create a response packet.
|
||||
|
||||
Args:
|
||||
response_type: Type of response
|
||||
payload: Response payload
|
||||
"""
|
||||
self.response_type = response_type
|
||||
self.payload = payload
|
||||
|
||||
@staticmethod
|
||||
def calculate_crc16(data: bytes) -> int:
|
||||
"""Calculate CRC16-CCITT for data validation."""
|
||||
calculator = crc.Calculator(crc.Crc16.CCITT)
|
||||
return calculator.checksum(data)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Serialize response packet to binary format.
|
||||
|
||||
Format: <H (header) <B (type) <H (payload_len) payload <H (crc16) <B (end)
|
||||
"""
|
||||
payload_len = len(self.payload)
|
||||
header_data = struct.pack('<HBH', RESPONSE_HEADER, self.response_type,
|
||||
payload_len)
|
||||
data = header_data + self.payload
|
||||
crc16 = self.calculate_crc16(data)
|
||||
return data + struct.pack('<HB', crc16, RESPONSE_END)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Optional['ResponsePacket']:
|
||||
"""Parse response packet from binary data.
|
||||
|
||||
Returns:
|
||||
ResponsePacket if valid, None otherwise
|
||||
"""
|
||||
if len(data) < 8: # Minimum packet size
|
||||
return None
|
||||
|
||||
# Parse header
|
||||
header, response_type, payload_len = struct.unpack('<HBH', data[:5])
|
||||
|
||||
if header != RESPONSE_HEADER:
|
||||
raise ValueError(f"Invalid response header: 0x{header:04X}")
|
||||
|
||||
# Check if we have enough data
|
||||
total_len = 5 + payload_len + 3 # header + payload + crc + end
|
||||
if len(data) < total_len:
|
||||
return None
|
||||
|
||||
# Extract payload
|
||||
payload = data[5:5+payload_len]
|
||||
|
||||
# Verify CRC
|
||||
crc16_expected = struct.unpack('<H', data[5+payload_len:5+payload_len+2])[0]
|
||||
crc16_actual = cls.calculate_crc16(data[:5+payload_len])
|
||||
|
||||
if crc16_expected != crc16_actual:
|
||||
raise ValueError(f"CRC mismatch: expected 0x{crc16_expected:04X}, "
|
||||
f"got 0x{crc16_actual:04X}")
|
||||
|
||||
# Verify end marker
|
||||
end_marker = data[5+payload_len+2]
|
||||
if end_marker != RESPONSE_END:
|
||||
raise ValueError(f"Invalid end marker: 0x{end_marker:02X}")
|
||||
|
||||
return cls(ResponseType(response_type), payload)
|
||||
|
||||
def parse_payload(self) -> object:
|
||||
"""Parse the payload based on response type.
|
||||
|
||||
Returns:
|
||||
Parsed payload object (Metadata, StatusInfo, List[ProfileRecord], etc.)
|
||||
"""
|
||||
if self.response_type == ResponseType.METADATA:
|
||||
return Metadata.from_bytes(self.payload)
|
||||
|
||||
elif self.response_type == ResponseType.STATUS:
|
||||
return StatusInfo.from_bytes(self.payload)
|
||||
|
||||
elif self.response_type == ResponseType.PROFILE_DATA:
|
||||
# First byte is protocol version
|
||||
if len(self.payload) < 3:
|
||||
return []
|
||||
|
||||
version = self.payload[0]
|
||||
if version != PROTOCOL_VERSION:
|
||||
raise ValueError(f"Unsupported protocol version: {version}")
|
||||
|
||||
record_count = struct.unpack('<H', self.payload[1:3])[0]
|
||||
records = []
|
||||
|
||||
offset = 3
|
||||
for _ in range(record_count):
|
||||
if offset + 14 > len(self.payload):
|
||||
break
|
||||
record = ProfileRecord.from_bytes(self.payload[offset:offset+14])
|
||||
records.append(record)
|
||||
offset += 14
|
||||
|
||||
return records
|
||||
|
||||
elif self.response_type == ResponseType.ACK:
|
||||
return True
|
||||
|
||||
elif self.response_type == ResponseType.NACK:
|
||||
return False
|
||||
|
||||
return self.payload
|
||||
267
host/miniprofiler/serial_reader.py
Normal file
267
host/miniprofiler/serial_reader.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Serial communication module for MiniProfiler.
|
||||
|
||||
Handles UART communication with the embedded device, including
|
||||
sending commands and receiving profiling data.
|
||||
"""
|
||||
|
||||
import serial
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from typing import Callable, Optional, List
|
||||
from queue import Queue
|
||||
|
||||
from .protocol import (
|
||||
Command, CommandPacket, ResponsePacket, ResponseType,
|
||||
ProfileRecord, Metadata, StatusInfo
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SerialReader:
|
||||
"""Manages serial communication with the embedded profiling device."""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
||||
"""Initialize serial reader.
|
||||
|
||||
Args:
|
||||
port: Serial port name (e.g., '/dev/ttyUSB0', 'COM3')
|
||||
baudrate: Baud rate (default: 115200)
|
||||
timeout: Read timeout in seconds
|
||||
"""
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.serial: Optional[serial.Serial] = None
|
||||
self.running = False
|
||||
self.read_thread: Optional[threading.Thread] = None
|
||||
self.command_queue = Queue()
|
||||
|
||||
# Callbacks
|
||||
self.on_profile_data: Optional[Callable[[List[ProfileRecord]], None]] = None
|
||||
self.on_metadata: Optional[Callable[[Metadata], None]] = None
|
||||
self.on_status: Optional[Callable[[StatusInfo], None]] = None
|
||||
self.on_error: Optional[Callable[[Exception], None]] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Open the serial port connection.
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
self.serial = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=self.timeout,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE
|
||||
)
|
||||
logger.info(f"Connected to {self.port} at {self.baudrate} baud")
|
||||
return True
|
||||
except serial.SerialException as e:
|
||||
logger.error(f"Failed to connect to {self.port}: {e}")
|
||||
if self.on_error:
|
||||
self.on_error(e)
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Close the serial port connection."""
|
||||
if self.serial and self.serial.is_open:
|
||||
self.serial.close()
|
||||
logger.info(f"Disconnected from {self.port}")
|
||||
|
||||
def send_command(self, cmd: Command, payload: bytes = b'') -> bool:
|
||||
"""Send a command to the embedded device.
|
||||
|
||||
Args:
|
||||
cmd: Command to send
|
||||
payload: Optional command payload
|
||||
|
||||
Returns:
|
||||
True if command sent successfully, False otherwise
|
||||
"""
|
||||
if not self.serial or not self.serial.is_open:
|
||||
logger.error("Serial port not open")
|
||||
return False
|
||||
|
||||
try:
|
||||
packet = CommandPacket(cmd, payload)
|
||||
data = packet.to_bytes()
|
||||
self.serial.write(data)
|
||||
logger.debug(f"Sent command: {cmd.name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send command {cmd.name}: {e}")
|
||||
if self.on_error:
|
||||
self.on_error(e)
|
||||
return False
|
||||
|
||||
def start_profiling(self) -> bool:
|
||||
"""Send START_PROFILING command."""
|
||||
return self.send_command(Command.START_PROFILING)
|
||||
|
||||
def stop_profiling(self) -> bool:
|
||||
"""Send STOP_PROFILING command."""
|
||||
return self.send_command(Command.STOP_PROFILING)
|
||||
|
||||
def get_metadata(self) -> bool:
|
||||
"""Request metadata from the device."""
|
||||
return self.send_command(Command.GET_METADATA)
|
||||
|
||||
def get_status(self) -> bool:
|
||||
"""Request status from the device."""
|
||||
return self.send_command(Command.GET_STATUS)
|
||||
|
||||
def reset_buffers(self) -> bool:
|
||||
"""Send RESET_BUFFERS command."""
|
||||
return self.send_command(Command.RESET_BUFFERS)
|
||||
|
||||
def _read_packet(self) -> Optional[ResponsePacket]:
|
||||
"""Read a response packet from the serial port.
|
||||
|
||||
Returns:
|
||||
ResponsePacket if valid packet received, None otherwise
|
||||
"""
|
||||
if not self.serial or not self.serial.is_open:
|
||||
return None
|
||||
|
||||
buffer = bytearray()
|
||||
|
||||
try:
|
||||
# Search for header (0xAA55)
|
||||
while self.running:
|
||||
byte = self.serial.read(1)
|
||||
if not byte:
|
||||
continue
|
||||
|
||||
buffer.append(byte[0])
|
||||
|
||||
# Check for header
|
||||
if len(buffer) >= 2:
|
||||
header = (buffer[-2] << 8) | buffer[-1]
|
||||
if header == 0xAA55:
|
||||
# Found header, read rest of packet
|
||||
buffer = bytearray([0xAA, 0x55])
|
||||
break
|
||||
# Keep only last byte for next iteration
|
||||
if len(buffer) > 1:
|
||||
buffer = bytearray([buffer[-1]])
|
||||
|
||||
if not self.running:
|
||||
return None
|
||||
|
||||
# Read type and length (3 bytes)
|
||||
header_rest = self.serial.read(3)
|
||||
if len(header_rest) < 3:
|
||||
return None
|
||||
buffer.extend(header_rest)
|
||||
|
||||
# Extract payload length
|
||||
payload_len = (header_rest[2] << 8) | header_rest[1]
|
||||
|
||||
# Read payload + CRC (2 bytes) + end marker (1 byte)
|
||||
remaining = payload_len + 3
|
||||
remaining_data = self.serial.read(remaining)
|
||||
if len(remaining_data) < remaining:
|
||||
logger.warning(f"Incomplete packet: expected {remaining}, got {len(remaining_data)}")
|
||||
return None
|
||||
|
||||
buffer.extend(remaining_data)
|
||||
|
||||
# Parse packet
|
||||
packet = ResponsePacket.from_bytes(bytes(buffer))
|
||||
return packet
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading packet: {e}")
|
||||
if self.on_error:
|
||||
self.on_error(e)
|
||||
return None
|
||||
|
||||
def _reader_thread(self):
|
||||
"""Background thread for reading serial data."""
|
||||
logger.info("Serial reader thread started")
|
||||
|
||||
while self.running:
|
||||
packet = self._read_packet()
|
||||
if not packet:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Parse and dispatch based on response type
|
||||
if packet.response_type == ResponseType.PROFILE_DATA:
|
||||
records = packet.parse_payload()
|
||||
if self.on_profile_data and isinstance(records, list):
|
||||
self.on_profile_data(records)
|
||||
|
||||
elif packet.response_type == ResponseType.METADATA:
|
||||
metadata = packet.parse_payload()
|
||||
if self.on_metadata and isinstance(metadata, Metadata):
|
||||
self.on_metadata(metadata)
|
||||
|
||||
elif packet.response_type == ResponseType.STATUS:
|
||||
status = packet.parse_payload()
|
||||
if self.on_status and isinstance(status, StatusInfo):
|
||||
self.on_status(status)
|
||||
|
||||
elif packet.response_type == ResponseType.ACK:
|
||||
logger.debug("Received ACK")
|
||||
|
||||
elif packet.response_type == ResponseType.NACK:
|
||||
logger.warning("Received NACK")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing packet: {e}")
|
||||
if self.on_error:
|
||||
self.on_error(e)
|
||||
|
||||
logger.info("Serial reader thread stopped")
|
||||
|
||||
def start_reading(self) -> bool:
|
||||
"""Start background thread to read serial data.
|
||||
|
||||
Returns:
|
||||
True if thread started successfully, False otherwise
|
||||
"""
|
||||
if self.running:
|
||||
logger.warning("Reader thread already running")
|
||||
return False
|
||||
|
||||
if not self.serial or not self.serial.is_open:
|
||||
logger.error("Serial port not open")
|
||||
return False
|
||||
|
||||
self.running = True
|
||||
self.read_thread = threading.Thread(target=self._reader_thread, daemon=True)
|
||||
self.read_thread.start()
|
||||
logger.info("Started serial reading thread")
|
||||
return True
|
||||
|
||||
def stop_reading(self):
|
||||
"""Stop the background reading thread."""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("Stopping serial reader thread...")
|
||||
self.running = False
|
||||
|
||||
if self.read_thread:
|
||||
self.read_thread.join(timeout=2.0)
|
||||
if self.read_thread.is_alive():
|
||||
logger.warning("Reader thread did not stop cleanly")
|
||||
else:
|
||||
logger.info("Reader thread stopped")
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
self.stop_reading()
|
||||
self.disconnect()
|
||||
205
host/miniprofiler/symbolizer.py
Normal file
205
host/miniprofiler/symbolizer.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Symbol resolution using ELF/DWARF debug information.
|
||||
|
||||
This module resolves function addresses to human-readable names,
|
||||
file locations, and line numbers using the ELF symbol table and
|
||||
DWARF debugging information.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from elftools.elf.elffile import ELFFile
|
||||
from elftools.dwarf.descriptions import describe_form_class
|
||||
from elftools.dwarf.die import DIE
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SymbolInfo:
|
||||
"""Information about a symbol."""
|
||||
|
||||
def __init__(self, name: str, file: str = "", line: int = 0, size: int = 0):
|
||||
"""Initialize symbol information.
|
||||
|
||||
Args:
|
||||
name: Function or symbol name
|
||||
file: Source file path
|
||||
line: Line number in source file
|
||||
size: Symbol size in bytes
|
||||
"""
|
||||
self.name = name
|
||||
self.file = file
|
||||
self.line = line
|
||||
self.size = size
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self.file and self.line:
|
||||
return f"{self.name} ({self.file}:{self.line})"
|
||||
return self.name
|
||||
|
||||
|
||||
class Symbolizer:
|
||||
"""Resolves addresses to symbol names using ELF/DWARF information."""
|
||||
|
||||
def __init__(self, elf_path: str):
|
||||
"""Initialize symbolizer with an ELF file.
|
||||
|
||||
Args:
|
||||
elf_path: Path to the ELF file with debug symbols
|
||||
"""
|
||||
self.elf_path = Path(elf_path)
|
||||
self.symbols: Dict[int, SymbolInfo] = {}
|
||||
self.loaded = False
|
||||
|
||||
if self.elf_path.exists():
|
||||
self._load_symbols()
|
||||
else:
|
||||
logger.warning(f"ELF file not found: {elf_path}")
|
||||
|
||||
def _load_symbols(self):
|
||||
"""Load symbols from the ELF file."""
|
||||
try:
|
||||
with open(self.elf_path, 'rb') as f:
|
||||
elffile = ELFFile(f)
|
||||
|
||||
# Load symbol table
|
||||
self._load_symbol_table(elffile)
|
||||
|
||||
# Load DWARF debug info for line numbers
|
||||
if elffile.has_dwarf_info():
|
||||
self._load_dwarf_info(elffile)
|
||||
|
||||
self.loaded = True
|
||||
logger.info(f"Loaded {len(self.symbols)} symbols from {self.elf_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load symbols from {self.elf_path}: {e}")
|
||||
self.loaded = False
|
||||
|
||||
def _load_symbol_table(self, elffile: ELFFile):
|
||||
"""Load function symbols from the symbol table.
|
||||
|
||||
Args:
|
||||
elffile: Parsed ELF file object
|
||||
"""
|
||||
symtab = elffile.get_section_by_name('.symtab')
|
||||
if not symtab:
|
||||
logger.warning("No symbol table found in ELF file")
|
||||
return
|
||||
|
||||
for symbol in symtab.iter_symbols():
|
||||
# Only interested in function symbols
|
||||
if symbol['st_info']['type'] == 'STT_FUNC':
|
||||
addr = symbol['st_value']
|
||||
name = symbol.name
|
||||
size = symbol['st_size']
|
||||
|
||||
if addr and name:
|
||||
self.symbols[addr] = SymbolInfo(name, size=size)
|
||||
|
||||
logger.debug(f"Loaded {len(self.symbols)} function symbols from symbol table")
|
||||
|
||||
def _load_dwarf_info(self, elffile: ELFFile):
|
||||
"""Load DWARF debug information for file/line mappings.
|
||||
|
||||
Args:
|
||||
elffile: Parsed ELF file object
|
||||
"""
|
||||
dwarfinfo = elffile.get_dwarf_info()
|
||||
|
||||
# Process line number information
|
||||
for CU in dwarfinfo.iter_CUs():
|
||||
lineprog = dwarfinfo.line_program_for_CU(CU)
|
||||
if not lineprog:
|
||||
continue
|
||||
|
||||
# Get the file table
|
||||
file_entries = lineprog.header['file_entry']
|
||||
|
||||
# Process line program entries
|
||||
prevstate = None
|
||||
for entry in lineprog.get_entries():
|
||||
if entry.state is None:
|
||||
continue
|
||||
|
||||
# Look for end of sequence or new addresses
|
||||
state = entry.state
|
||||
if prevstate and state.address != prevstate.address:
|
||||
addr = prevstate.address
|
||||
file_index = prevstate.file - 1
|
||||
|
||||
if 0 <= file_index < len(file_entries):
|
||||
file_entry = file_entries[file_index]
|
||||
filename = file_entry.name.decode('utf-8') if isinstance(
|
||||
file_entry.name, bytes) else file_entry.name
|
||||
|
||||
# Update existing symbol or create new one
|
||||
if addr in self.symbols:
|
||||
self.symbols[addr].file = filename
|
||||
self.symbols[addr].line = prevstate.line
|
||||
else:
|
||||
# Create placeholder symbol for addresses without symbol table entry
|
||||
self.symbols[addr] = SymbolInfo(
|
||||
f"func_0x{addr:08x}",
|
||||
file=filename,
|
||||
line=prevstate.line
|
||||
)
|
||||
|
||||
prevstate = state
|
||||
|
||||
logger.debug("Loaded DWARF debug information")
|
||||
|
||||
def resolve(self, addr: int) -> SymbolInfo:
|
||||
"""Resolve an address to symbol information.
|
||||
|
||||
Args:
|
||||
addr: Function address to resolve
|
||||
|
||||
Returns:
|
||||
SymbolInfo object (may contain placeholder name if not found)
|
||||
"""
|
||||
# Exact match
|
||||
if addr in self.symbols:
|
||||
return self.symbols[addr]
|
||||
|
||||
# Try to find the containing function (address within function range)
|
||||
for sym_addr, sym_info in self.symbols.items():
|
||||
if sym_addr <= addr < sym_addr + sym_info.size:
|
||||
return sym_info
|
||||
|
||||
# Not found - return placeholder
|
||||
return SymbolInfo(f"unknown_0x{addr:08x}")
|
||||
|
||||
def resolve_name(self, addr: int) -> str:
|
||||
"""Resolve an address to a function name.
|
||||
|
||||
Args:
|
||||
addr: Function address
|
||||
|
||||
Returns:
|
||||
Function name string
|
||||
"""
|
||||
return self.resolve(addr).name
|
||||
|
||||
def resolve_location(self, addr: int) -> str:
|
||||
"""Resolve an address to a file:line location string.
|
||||
|
||||
Args:
|
||||
addr: Function address
|
||||
|
||||
Returns:
|
||||
Location string in format "file:line" or empty string
|
||||
"""
|
||||
info = self.resolve(addr)
|
||||
if info.file and info.line:
|
||||
return f"{info.file}:{info.line}"
|
||||
return ""
|
||||
|
||||
def get_all_symbols(self) -> Dict[int, SymbolInfo]:
|
||||
"""Get all loaded symbols.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping addresses to SymbolInfo objects
|
||||
"""
|
||||
return self.symbols.copy()
|
||||
315
host/miniprofiler/web_server.py
Normal file
315
host/miniprofiler/web_server.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Flask web server for MiniProfiler visualization.
|
||||
|
||||
Provides a web interface for real-time profiling visualization including
|
||||
flame graphs, timelines, and statistics tables.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
from flask_socketio import SocketIO, emit
|
||||
from typing import Optional
|
||||
import threading
|
||||
|
||||
from .serial_reader import SerialReader
|
||||
from .analyzer import ProfileAnalyzer
|
||||
from .symbolizer import Symbolizer
|
||||
from .protocol import ProfileRecord, Metadata, StatusInfo
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProfilerWebServer:
|
||||
"""Web server for profiler visualization and control."""
|
||||
|
||||
def __init__(self, host: str = '0.0.0.0', port: int = 5000):
|
||||
"""Initialize the web server.
|
||||
|
||||
Args:
|
||||
host: Host address to bind to
|
||||
port: Port number to listen on
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
# Initialize Flask app
|
||||
self.app = Flask(__name__,
|
||||
template_folder='../web/templates',
|
||||
static_folder='../web/static')
|
||||
self.app.config['SECRET_KEY'] = 'miniprofiler-secret-key'
|
||||
|
||||
# Initialize SocketIO
|
||||
self.socketio = SocketIO(self.app, cors_allowed_origins="*")
|
||||
|
||||
# Profiler components
|
||||
self.serial_reader: Optional[SerialReader] = None
|
||||
self.analyzer = ProfileAnalyzer()
|
||||
self.symbolizer: Optional[Symbolizer] = None
|
||||
self.metadata: Optional[Metadata] = None
|
||||
|
||||
# State
|
||||
self.is_connected = False
|
||||
self.is_profiling = False
|
||||
|
||||
# Setup routes
|
||||
self._setup_routes()
|
||||
self._setup_socketio_handlers()
|
||||
|
||||
def _setup_routes(self):
|
||||
"""Setup Flask HTTP routes."""
|
||||
|
||||
@self.app.route('/')
|
||||
def index():
|
||||
"""Main page."""
|
||||
return render_template('index.html')
|
||||
|
||||
@self.app.route('/api/status')
|
||||
def status():
|
||||
"""Get server status."""
|
||||
return jsonify({
|
||||
'connected': self.is_connected,
|
||||
'profiling': self.is_profiling,
|
||||
'has_data': len(self.analyzer.records) > 0,
|
||||
'record_count': len(self.analyzer.records)
|
||||
})
|
||||
|
||||
@self.app.route('/api/summary')
|
||||
def summary():
|
||||
"""Get profiling summary statistics."""
|
||||
return jsonify(self.analyzer.get_summary())
|
||||
|
||||
@self.app.route('/api/flamegraph')
|
||||
def flamegraph():
|
||||
"""Get flame graph data."""
|
||||
return jsonify(self.analyzer.to_flamegraph_json())
|
||||
|
||||
@self.app.route('/api/timeline')
|
||||
def timeline():
|
||||
"""Get timeline data."""
|
||||
return jsonify(self.analyzer.to_timeline_json())
|
||||
|
||||
@self.app.route('/api/statistics')
|
||||
def statistics():
|
||||
"""Get statistics table data."""
|
||||
return jsonify(self.analyzer.to_statistics_json())
|
||||
|
||||
def _setup_socketio_handlers(self):
|
||||
"""Setup SocketIO event handlers."""
|
||||
|
||||
@self.socketio.on('connect')
|
||||
def handle_connect():
|
||||
"""Handle client connection."""
|
||||
logger.info("Client connected")
|
||||
emit('status', {
|
||||
'connected': self.is_connected,
|
||||
'profiling': self.is_profiling
|
||||
})
|
||||
|
||||
@self.socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
"""Handle client disconnection."""
|
||||
logger.info("Client disconnected")
|
||||
|
||||
@self.socketio.on('connect_serial')
|
||||
def handle_connect_serial(data):
|
||||
"""Connect to serial port.
|
||||
|
||||
Args:
|
||||
data: Dict with 'port' and optional 'baudrate'
|
||||
"""
|
||||
port = data.get('port')
|
||||
baudrate = data.get('baudrate', 115200)
|
||||
elf_path = data.get('elf_path', None)
|
||||
|
||||
if not port:
|
||||
emit('error', {'message': 'No port specified'})
|
||||
return
|
||||
|
||||
try:
|
||||
# Load symbolizer if ELF path provided
|
||||
if elf_path:
|
||||
self.symbolizer = Symbolizer(elf_path)
|
||||
self.analyzer.symbolizer = self.symbolizer
|
||||
|
||||
# Create serial reader
|
||||
self.serial_reader = SerialReader(port, baudrate)
|
||||
|
||||
# Set up callbacks
|
||||
self.serial_reader.on_profile_data = self._on_profile_data
|
||||
self.serial_reader.on_metadata = self._on_metadata
|
||||
self.serial_reader.on_status = self._on_status
|
||||
self.serial_reader.on_error = self._on_error
|
||||
|
||||
# Connect
|
||||
if self.serial_reader.connect():
|
||||
self.serial_reader.start_reading()
|
||||
self.is_connected = True
|
||||
|
||||
# Request metadata
|
||||
self.serial_reader.get_metadata()
|
||||
|
||||
emit('connected', {'port': port, 'baudrate': baudrate})
|
||||
logger.info(f"Connected to {port} at {baudrate} baud")
|
||||
else:
|
||||
emit('error', {'message': f'Failed to connect to {port}'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting to serial: {e}")
|
||||
emit('error', {'message': str(e)})
|
||||
|
||||
@self.socketio.on('disconnect_serial')
|
||||
def handle_disconnect_serial():
|
||||
"""Disconnect from serial port."""
|
||||
if self.serial_reader:
|
||||
self.serial_reader.stop_reading()
|
||||
self.serial_reader.disconnect()
|
||||
self.serial_reader = None
|
||||
self.is_connected = False
|
||||
self.is_profiling = False
|
||||
emit('disconnected', {})
|
||||
logger.info("Disconnected from serial port")
|
||||
|
||||
@self.socketio.on('start_profiling')
|
||||
def handle_start_profiling():
|
||||
"""Start profiling on the device."""
|
||||
if not self.serial_reader or not self.is_connected:
|
||||
emit('error', {'message': 'Not connected to device'})
|
||||
return
|
||||
|
||||
if self.serial_reader.start_profiling():
|
||||
self.is_profiling = True
|
||||
emit('profiling_started', {})
|
||||
logger.info("Started profiling")
|
||||
else:
|
||||
emit('error', {'message': 'Failed to start profiling'})
|
||||
|
||||
@self.socketio.on('stop_profiling')
|
||||
def handle_stop_profiling():
|
||||
"""Stop profiling on the device."""
|
||||
if not self.serial_reader or not self.is_connected:
|
||||
emit('error', {'message': 'Not connected to device'})
|
||||
return
|
||||
|
||||
if self.serial_reader.stop_profiling():
|
||||
self.is_profiling = False
|
||||
emit('profiling_stopped', {})
|
||||
logger.info("Stopped profiling")
|
||||
else:
|
||||
emit('error', {'message': 'Failed to stop profiling'})
|
||||
|
||||
@self.socketio.on('clear_data')
|
||||
def handle_clear_data():
|
||||
"""Clear all profiling data."""
|
||||
self.analyzer.clear()
|
||||
emit('data_cleared', {})
|
||||
self._emit_data_update()
|
||||
logger.info("Cleared profiling data")
|
||||
|
||||
@self.socketio.on('reset_buffers')
|
||||
def handle_reset_buffers():
|
||||
"""Reset device buffers."""
|
||||
if not self.serial_reader or not self.is_connected:
|
||||
emit('error', {'message': 'Not connected to device'})
|
||||
return
|
||||
|
||||
if self.serial_reader.reset_buffers():
|
||||
emit('buffers_reset', {})
|
||||
logger.info("Reset device buffers")
|
||||
else:
|
||||
emit('error', {'message': 'Failed to reset buffers'})
|
||||
|
||||
def _on_profile_data(self, records):
|
||||
"""Callback for receiving profile data.
|
||||
|
||||
Args:
|
||||
records: List of ProfileRecord objects
|
||||
"""
|
||||
logger.debug(f"Received {len(records)} profile records")
|
||||
self.analyzer.add_records(records)
|
||||
self._emit_data_update()
|
||||
|
||||
def _on_metadata(self, metadata: Metadata):
|
||||
"""Callback for receiving metadata.
|
||||
|
||||
Args:
|
||||
metadata: Metadata object
|
||||
"""
|
||||
logger.info(f"Received metadata: {metadata.fw_version}, "
|
||||
f"MCU: {metadata.mcu_clock_hz / 1e6:.1f} MHz")
|
||||
self.metadata = metadata
|
||||
self.socketio.emit('metadata', {
|
||||
'fw_version': metadata.fw_version,
|
||||
'mcu_clock_hz': metadata.mcu_clock_hz,
|
||||
'timer_freq': metadata.timer_freq,
|
||||
'build_id': f"0x{metadata.elf_build_id:08X}"
|
||||
})
|
||||
|
||||
def _on_status(self, status: StatusInfo):
|
||||
"""Callback for receiving status updates.
|
||||
|
||||
Args:
|
||||
status: StatusInfo object
|
||||
"""
|
||||
logger.debug(f"Device status: profiling={status.is_profiling}, "
|
||||
f"records={status.records_captured}, "
|
||||
f"overflows={status.buffer_overflows}")
|
||||
self.socketio.emit('device_status', {
|
||||
'is_profiling': status.is_profiling,
|
||||
'records_captured': status.records_captured,
|
||||
'buffer_overflows': status.buffer_overflows,
|
||||
'buffer_usage': status.buffer_usage_percent
|
||||
})
|
||||
|
||||
def _on_error(self, error: Exception):
|
||||
"""Callback for serial errors.
|
||||
|
||||
Args:
|
||||
error: Exception that occurred
|
||||
"""
|
||||
logger.error(f"Serial error: {error}")
|
||||
self.socketio.emit('error', {'message': str(error)})
|
||||
|
||||
def _emit_data_update(self):
|
||||
"""Emit updated profiling data to all clients."""
|
||||
try:
|
||||
# Send summary
|
||||
summary = self.analyzer.get_summary()
|
||||
self.socketio.emit('summary_update', summary)
|
||||
|
||||
# Send flamegraph data
|
||||
flamegraph_data = self.analyzer.to_flamegraph_json()
|
||||
self.socketio.emit('flamegraph_update', flamegraph_data)
|
||||
|
||||
# Send statistics
|
||||
stats_data = self.analyzer.to_statistics_json()
|
||||
self.socketio.emit('statistics_update', stats_data)
|
||||
|
||||
# Send timeline data (can be large, so only send periodically)
|
||||
if len(self.analyzer.records) % 50 == 0: # Every 50 records
|
||||
timeline_data = self.analyzer.to_timeline_json()
|
||||
self.socketio.emit('timeline_update', timeline_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error emitting data update: {e}")
|
||||
|
||||
def run(self, debug: bool = False):
|
||||
"""Run the web server.
|
||||
|
||||
Args:
|
||||
debug: Enable debug mode
|
||||
"""
|
||||
logger.info(f"Starting web server on {self.host}:{self.port}")
|
||||
self.socketio.run(self.app, host=self.host, port=self.port, debug=debug)
|
||||
|
||||
|
||||
def create_app(host: str = '0.0.0.0', port: int = 5000) -> ProfilerWebServer:
|
||||
"""Create and configure the profiler web server.
|
||||
|
||||
Args:
|
||||
host: Host address
|
||||
port: Port number
|
||||
|
||||
Returns:
|
||||
Configured ProfilerWebServer instance
|
||||
"""
|
||||
return ProfilerWebServer(host, port)
|
||||
7
host/requirements.txt
Normal file
7
host/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Flask>=3.0.0
|
||||
Flask-SocketIO>=5.3.0
|
||||
pyserial>=3.5
|
||||
pyelftools>=0.29
|
||||
crc>=6.1.1
|
||||
python-socketio>=5.10.0
|
||||
eventlet>=0.33.3
|
||||
13
host/run.py
Executable file
13
host/run.py
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick start script for MiniProfiler."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from miniprofiler.cli import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
51
host/setup.py
Normal file
51
host/setup.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Setup script for MiniProfiler host application."""
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
with open("../README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name="miniprofiler",
|
||||
version="0.1.0",
|
||||
author="MiniProfiler Contributors",
|
||||
description="Host application for embedded STM32 profiling",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/yourusername/miniprofiler",
|
||||
packages=find_packages(),
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Embedded Systems",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
],
|
||||
python_requires=">=3.8",
|
||||
install_requires=[
|
||||
"Flask>=3.0.0",
|
||||
"Flask-SocketIO>=5.3.0",
|
||||
"pyserial>=3.5",
|
||||
"pyelftools>=0.29",
|
||||
"crc>=6.1.1",
|
||||
"python-socketio>=5.10.0",
|
||||
"eventlet>=0.33.3",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"miniprofiler=miniprofiler.cli:main",
|
||||
],
|
||||
},
|
||||
include_package_data=True,
|
||||
package_data={
|
||||
"miniprofiler": [
|
||||
"../web/templates/*.html",
|
||||
"../web/static/css/*.css",
|
||||
"../web/static/js/*.js",
|
||||
],
|
||||
},
|
||||
)
|
||||
1
host/tests/__init__.py
Normal file
1
host/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test utilities for MiniProfiler."""
|
||||
236
host/tests/sample_data_generator.py
Normal file
236
host/tests/sample_data_generator.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Sample data generator for testing the profiler without hardware.
|
||||
|
||||
Generates realistic profiling data that mimics an embedded application
|
||||
with nested function calls, varying execution times, and typical patterns.
|
||||
"""
|
||||
|
||||
import random
|
||||
from typing import List
|
||||
from miniprofiler.protocol import ProfileRecord, Metadata, ResponsePacket, ResponseType
|
||||
import struct
|
||||
|
||||
|
||||
# Sample function addresses (simulating typical embedded firmware)
|
||||
FUNCTIONS = {
|
||||
0x08000100: "main",
|
||||
0x08000200: "app_init",
|
||||
0x08000220: "peripheral_init",
|
||||
0x08000240: "clock_config",
|
||||
0x08000300: "app_loop",
|
||||
0x08000320: "process_sensors",
|
||||
0x08000340: "read_temperature",
|
||||
0x08000360: "read_pressure",
|
||||
0x08000380: "process_data",
|
||||
0x080003A0: "calculate_average",
|
||||
0x080003C0: "apply_filter",
|
||||
0x08000400: "update_display",
|
||||
0x08000420: "format_string",
|
||||
0x08000440: "send_uart",
|
||||
0x08000500: "handle_interrupt",
|
||||
0x08000520: "gpio_callback",
|
||||
}
|
||||
|
||||
|
||||
def generate_metadata() -> Metadata:
|
||||
"""Generate sample metadata packet."""
|
||||
return Metadata(
|
||||
mcu_clock_hz=168_000_000, # 168 MHz (typical STM32F4)
|
||||
timer_freq=1_000_000, # 1 MHz timer
|
||||
elf_build_id=0xDEADBEEF,
|
||||
fw_version="v1.0.0-test"
|
||||
)
|
||||
|
||||
|
||||
def generate_nested_calls(
|
||||
start_time: int,
|
||||
depth: int = 0,
|
||||
max_depth: int = 5
|
||||
) -> tuple[List[ProfileRecord], int]:
|
||||
"""Generate nested function calls recursively.
|
||||
|
||||
Args:
|
||||
start_time: Starting timestamp in microseconds
|
||||
depth: Current call stack depth
|
||||
max_depth: Maximum recursion depth
|
||||
|
||||
Returns:
|
||||
Tuple of (list of ProfileRecords, end_time)
|
||||
"""
|
||||
records = []
|
||||
current_time = start_time
|
||||
|
||||
# Select random functions based on depth
|
||||
if depth == 0:
|
||||
func_addr = 0x08000300 # app_loop
|
||||
num_children = random.randint(2, 4)
|
||||
elif depth == 1:
|
||||
func_addr = random.choice([0x08000320, 0x08000380, 0x08000400])
|
||||
num_children = random.randint(1, 3)
|
||||
else:
|
||||
func_addr = random.choice(list(FUNCTIONS.keys())[depth * 2:(depth + 1) * 2 + 4])
|
||||
num_children = random.randint(0, 2) if depth < max_depth else 0
|
||||
|
||||
entry_time = current_time
|
||||
current_time += random.randint(1, 10) # Entry overhead
|
||||
|
||||
# Generate child calls
|
||||
children_records = []
|
||||
if num_children > 0 and depth < max_depth:
|
||||
for _ in range(num_children):
|
||||
child_records, current_time = generate_nested_calls(
|
||||
current_time, depth + 1, max_depth
|
||||
)
|
||||
children_records.extend(child_records)
|
||||
current_time += random.randint(5, 20) # Gap between children
|
||||
|
||||
# Add some self-time for this function
|
||||
self_time = random.randint(10, 200)
|
||||
current_time += self_time
|
||||
|
||||
exit_time = current_time
|
||||
duration = exit_time - entry_time
|
||||
|
||||
# Create record for this function
|
||||
record = ProfileRecord(
|
||||
func_addr=func_addr,
|
||||
entry_time=entry_time,
|
||||
duration_us=duration,
|
||||
depth=depth
|
||||
)
|
||||
|
||||
records.append(record)
|
||||
records.extend(children_records)
|
||||
|
||||
return records, exit_time
|
||||
|
||||
|
||||
def generate_sample_profile_data(
|
||||
num_iterations: int = 10,
|
||||
time_per_iteration: int = 10000 # 10ms per iteration
|
||||
) -> List[ProfileRecord]:
|
||||
"""Generate sample profiling data simulating multiple loop iterations.
|
||||
|
||||
Args:
|
||||
num_iterations: Number of main loop iterations
|
||||
time_per_iteration: Approximate time per iteration in microseconds
|
||||
|
||||
Returns:
|
||||
List of ProfileRecord objects
|
||||
"""
|
||||
all_records = []
|
||||
current_time = 0
|
||||
|
||||
# Generate initialization sequence (runs once)
|
||||
init_records = [
|
||||
ProfileRecord(0x08000100, current_time, 5000, 0), # main
|
||||
ProfileRecord(0x08000200, current_time + 100, 1000, 1), # app_init
|
||||
ProfileRecord(0x08000220, current_time + 150, 300, 2), # peripheral_init
|
||||
ProfileRecord(0x08000240, current_time + 500, 400, 2), # clock_config
|
||||
]
|
||||
all_records.extend(init_records)
|
||||
current_time += 5000
|
||||
|
||||
# Generate main loop iterations
|
||||
for iteration in range(num_iterations):
|
||||
records, end_time = generate_nested_calls(
|
||||
current_time,
|
||||
depth=0,
|
||||
max_depth=4
|
||||
)
|
||||
all_records.extend(records)
|
||||
current_time = end_time + random.randint(50, 200) # Idle time
|
||||
|
||||
return all_records
|
||||
|
||||
|
||||
def generate_profile_data_packet(records: List[ProfileRecord]) -> bytes:
|
||||
"""Generate a binary PROFILE_DATA response packet.
|
||||
|
||||
Args:
|
||||
records: List of ProfileRecord objects
|
||||
|
||||
Returns:
|
||||
Binary packet data
|
||||
"""
|
||||
# Build payload: version (1B) + count (2B) + records
|
||||
payload = struct.pack('<BH', 0x01, len(records))
|
||||
|
||||
for record in records:
|
||||
payload += record.to_bytes()
|
||||
|
||||
# Create response packet
|
||||
packet = ResponsePacket(ResponseType.PROFILE_DATA, payload)
|
||||
return packet.to_bytes()
|
||||
|
||||
|
||||
def generate_metadata_packet() -> bytes:
|
||||
"""Generate a binary METADATA response packet.
|
||||
|
||||
Returns:
|
||||
Binary packet data
|
||||
"""
|
||||
metadata = generate_metadata()
|
||||
packet = ResponsePacket(ResponseType.METADATA, metadata.to_bytes())
|
||||
return packet.to_bytes()
|
||||
|
||||
|
||||
def save_sample_data(filename: str, num_iterations: int = 50):
|
||||
"""Generate and save sample profiling data to a file.
|
||||
|
||||
Args:
|
||||
filename: Output filename
|
||||
num_iterations: Number of loop iterations to generate
|
||||
"""
|
||||
records = generate_sample_profile_data(num_iterations)
|
||||
|
||||
with open(filename, 'wb') as f:
|
||||
# Write metadata packet
|
||||
f.write(generate_metadata_packet())
|
||||
|
||||
# Write profile data in chunks (simulate streaming)
|
||||
chunk_size = 20
|
||||
for i in range(0, len(records), chunk_size):
|
||||
chunk = records[i:i + chunk_size]
|
||||
f.write(generate_profile_data_packet(chunk))
|
||||
|
||||
print(f"Generated {len(records)} records in {filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Generate sample data
|
||||
records = generate_sample_profile_data(num_iterations=20)
|
||||
|
||||
print(f"Generated {len(records)} profiling records")
|
||||
print(f"\nFirst 10 records:")
|
||||
for i, record in enumerate(records[:10]):
|
||||
func_name = FUNCTIONS.get(record.func_addr, f"0x{record.func_addr:08x}")
|
||||
print(f" [{i}] {func_name:20s} @ {record.entry_time:8d}us, "
|
||||
f"duration: {record.duration_us:6d}us, depth: {record.depth}")
|
||||
|
||||
# Save to file
|
||||
save_sample_data("sample_profile_data.bin", num_iterations=50)
|
||||
|
||||
# Also generate JSON for testing web interface
|
||||
import json
|
||||
from miniprofiler.analyzer import ProfileAnalyzer
|
||||
|
||||
analyzer = ProfileAnalyzer()
|
||||
analyzer.add_records(records)
|
||||
|
||||
# Generate flamegraph data
|
||||
flamegraph_data = analyzer.to_flamegraph_json()
|
||||
with open("sample_flamegraph.json", 'w') as f:
|
||||
json.dump(flamegraph_data, f, indent=2)
|
||||
print(f"\nGenerated sample_flamegraph.json")
|
||||
|
||||
# Generate statistics data
|
||||
stats_data = analyzer.to_statistics_json()
|
||||
with open("sample_statistics.json", 'w') as f:
|
||||
json.dump(stats_data, f, indent=2)
|
||||
print(f"Generated sample_statistics.json")
|
||||
|
||||
# Generate timeline data
|
||||
timeline_data = analyzer.to_timeline_json()
|
||||
with open("sample_timeline.json", 'w') as f:
|
||||
json.dump(timeline_data, f, indent=2)
|
||||
print(f"Generated sample_timeline.json")
|
||||
282
host/web/static/css/style.css
Normal file
282
host/web/static/css/style.css
Normal file
@@ -0,0 +1,282 @@
|
||||
/* MiniProfiler Styles */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #3a3a3a;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.5em;
|
||||
color: #569cd6;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #9cdcfe;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Control Panel */
|
||||
.control-panel {
|
||||
background: #252526;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.connection-controls, .profiling-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="number"] {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 10px;
|
||||
background: #3c3c3c;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #d4d4d4;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"]:focus, input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: #569cd6;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
background: #0e639c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #1177bb;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #3a3a3a;
|
||||
color: #777;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button#stop-btn {
|
||||
background: #d16969;
|
||||
}
|
||||
|
||||
button#stop-btn:hover:not(:disabled) {
|
||||
background: #e67373;
|
||||
}
|
||||
|
||||
button#clear-btn, button#reset-btn {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
button#clear-btn:hover:not(:disabled), button#reset-btn:hover:not(:disabled) {
|
||||
background: #5e5e5e;
|
||||
}
|
||||
|
||||
/* Status Display */
|
||||
.status-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 1.5em;
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.status-indicator.profiling {
|
||||
color: #ce9178;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Metadata Panel */
|
||||
.metadata-panel, .summary-panel {
|
||||
background: #252526;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.metadata-panel h3, .summary-panel h3 {
|
||||
color: #9cdcfe;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
#metadata-content, #summary-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#summary-content p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 12px 24px;
|
||||
background: #2d2d30;
|
||||
border: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: #9cdcfe;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: #3e3e42;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #252526;
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
/* Tab Content */
|
||||
.tab-content {
|
||||
display: none;
|
||||
background: #252526;
|
||||
padding: 20px;
|
||||
border-radius: 0 8px 8px 8px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Flame Graph */
|
||||
#flamegraph {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* Override d3-flamegraph default styles for dark theme */
|
||||
.d3-flame-graph rect {
|
||||
stroke: #1e1e1e;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.d3-flame-graph text {
|
||||
fill: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.d3-flame-graph .label {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
#timeline {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* Statistics Table */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
color: #9cdcfe;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #3a3a3a;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.connection-controls, .profiling-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="number"], button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
340
host/web/static/js/app.js
Normal file
340
host/web/static/js/app.js
Normal file
@@ -0,0 +1,340 @@
|
||||
// MiniProfiler Web Application
|
||||
|
||||
// Global state
|
||||
let socket = null;
|
||||
let isConnected = false;
|
||||
let isProfiling = false;
|
||||
let flamegraph = null;
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeSocket();
|
||||
initializeFlameGraph();
|
||||
});
|
||||
|
||||
// Socket.IO Connection
|
||||
function initializeSocket() {
|
||||
socket = io();
|
||||
|
||||
socket.on('connect', function() {
|
||||
console.log('Connected to server');
|
||||
updateStatus('Connected to server', false);
|
||||
});
|
||||
|
||||
socket.on('disconnect', function() {
|
||||
console.log('Disconnected from server');
|
||||
updateStatus('Disconnected from server', false);
|
||||
isConnected = false;
|
||||
updateControlButtons();
|
||||
});
|
||||
|
||||
socket.on('connected', function(data) {
|
||||
console.log('Connected to serial:', data);
|
||||
isConnected = true;
|
||||
updateStatus(`Connected to ${data.port}`, true);
|
||||
updateControlButtons();
|
||||
document.getElementById('metadata-panel').style.display = 'block';
|
||||
});
|
||||
|
||||
socket.on('disconnected', function() {
|
||||
console.log('Disconnected from serial');
|
||||
isConnected = false;
|
||||
isProfiling = false;
|
||||
updateStatus('Disconnected', false);
|
||||
updateControlButtons();
|
||||
document.getElementById('metadata-panel').style.display = 'none';
|
||||
});
|
||||
|
||||
socket.on('profiling_started', function() {
|
||||
console.log('Profiling started');
|
||||
isProfiling = true;
|
||||
updateStatus('Profiling active', true, true);
|
||||
updateControlButtons();
|
||||
});
|
||||
|
||||
socket.on('profiling_stopped', function() {
|
||||
console.log('Profiling stopped');
|
||||
isProfiling = false;
|
||||
updateStatus('Profiling stopped', true);
|
||||
updateControlButtons();
|
||||
});
|
||||
|
||||
socket.on('metadata', function(data) {
|
||||
console.log('Metadata received:', data);
|
||||
displayMetadata(data);
|
||||
});
|
||||
|
||||
socket.on('device_status', function(data) {
|
||||
console.log('Device status:', data);
|
||||
if (data.buffer_overflows > 0) {
|
||||
console.warn(`Buffer overflows detected: ${data.buffer_overflows}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('summary_update', function(data) {
|
||||
updateSummary(data);
|
||||
});
|
||||
|
||||
socket.on('flamegraph_update', function(data) {
|
||||
updateFlameGraph(data);
|
||||
});
|
||||
|
||||
socket.on('statistics_update', function(data) {
|
||||
updateStatistics(data);
|
||||
});
|
||||
|
||||
socket.on('timeline_update', function(data) {
|
||||
updateTimeline(data);
|
||||
});
|
||||
|
||||
socket.on('error', function(data) {
|
||||
console.error('Error:', data.message);
|
||||
alert('Error: ' + data.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Connection Control
|
||||
function toggleConnection() {
|
||||
if (isConnected) {
|
||||
socket.emit('disconnect_serial');
|
||||
document.getElementById('connect-btn').textContent = 'Connect';
|
||||
} else {
|
||||
const port = document.getElementById('serial-port').value;
|
||||
const baudrate = parseInt(document.getElementById('baudrate').value);
|
||||
const elfPath = document.getElementById('elf-path').value;
|
||||
|
||||
if (!port) {
|
||||
alert('Please enter a serial port');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('connect_serial', {
|
||||
port: port,
|
||||
baudrate: baudrate,
|
||||
elf_path: elfPath || null
|
||||
});
|
||||
|
||||
document.getElementById('connect-btn').textContent = 'Disconnect';
|
||||
}
|
||||
}
|
||||
|
||||
function startProfiling() {
|
||||
socket.emit('start_profiling');
|
||||
}
|
||||
|
||||
function stopProfiling() {
|
||||
socket.emit('stop_profiling');
|
||||
}
|
||||
|
||||
function clearData() {
|
||||
if (confirm('Clear all profiling data?')) {
|
||||
socket.emit('clear_data');
|
||||
}
|
||||
}
|
||||
|
||||
function resetBuffers() {
|
||||
socket.emit('reset_buffers');
|
||||
}
|
||||
|
||||
// UI Updates
|
||||
function updateStatus(text, connected, profiling = false) {
|
||||
const statusText = document.getElementById('status-text');
|
||||
const statusIndicator = document.getElementById('connection-status');
|
||||
|
||||
statusText.textContent = text;
|
||||
|
||||
statusIndicator.classList.remove('connected', 'profiling');
|
||||
if (profiling) {
|
||||
statusIndicator.classList.add('profiling');
|
||||
} else if (connected) {
|
||||
statusIndicator.classList.add('connected');
|
||||
}
|
||||
}
|
||||
|
||||
function updateControlButtons() {
|
||||
document.getElementById('start-btn').disabled = !isConnected || isProfiling;
|
||||
document.getElementById('stop-btn').disabled = !isConnected || !isProfiling;
|
||||
document.getElementById('clear-btn').disabled = !isConnected;
|
||||
document.getElementById('reset-btn').disabled = !isConnected;
|
||||
}
|
||||
|
||||
function displayMetadata(data) {
|
||||
const content = document.getElementById('metadata-content');
|
||||
content.innerHTML = `
|
||||
<div><strong>Firmware:</strong> ${data.fw_version}</div>
|
||||
<div><strong>MCU Clock:</strong> ${(data.mcu_clock_hz / 1e6).toFixed(1)} MHz</div>
|
||||
<div><strong>Timer Freq:</strong> ${(data.timer_freq / 1e6).toFixed(1)} MHz</div>
|
||||
<div><strong>Build ID:</strong> ${data.build_id}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateSummary(data) {
|
||||
const content = document.getElementById('summary-content');
|
||||
|
||||
if (data.total_records === 0) {
|
||||
content.innerHTML = '<p>No profiling data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalTimeMs = (data.total_time_us / 1000).toFixed(2);
|
||||
const hottestTimeMs = (data.hottest_time_us / 1000).toFixed(2);
|
||||
|
||||
content.innerHTML = `
|
||||
<div><strong>Total Records:</strong> ${data.total_records}</div>
|
||||
<div><strong>Unique Functions:</strong> ${data.total_functions}</div>
|
||||
<div><strong>Total Time:</strong> ${totalTimeMs} ms</div>
|
||||
<div><strong>Hottest Function:</strong> ${data.hottest_function || 'N/A'}</div>
|
||||
<div><strong>Hottest Time:</strong> ${hottestTimeMs} ms</div>
|
||||
`;
|
||||
|
||||
document.getElementById('record-count').textContent = `Records: ${data.total_records}`;
|
||||
}
|
||||
|
||||
// Tab Management
|
||||
function showTab(tabName) {
|
||||
// Hide all tabs
|
||||
const tabs = document.querySelectorAll('.tab-content');
|
||||
tabs.forEach(tab => tab.classList.remove('active'));
|
||||
|
||||
const buttons = document.querySelectorAll('.tab-button');
|
||||
buttons.forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(tabName + '-tab').classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// Flame Graph
|
||||
function initializeFlameGraph() {
|
||||
const width = document.getElementById('flamegraph').offsetWidth;
|
||||
|
||||
flamegraph = d3.flamegraph()
|
||||
.width(width)
|
||||
.cellHeight(18)
|
||||
.transitionDuration(750)
|
||||
.minFrameSize(5)
|
||||
.transitionEase(d3.easeCubic)
|
||||
.sort(true)
|
||||
.title("")
|
||||
.differential(false)
|
||||
.selfValue(false);
|
||||
|
||||
// Color scheme
|
||||
flamegraph.setColorMapper((d, originalColor) => {
|
||||
// Color by depth for better visualization
|
||||
const hue = (d.data.depth * 30) % 360;
|
||||
return d3.hsl(hue, 0.6, 0.5);
|
||||
});
|
||||
|
||||
d3.select("#flamegraph")
|
||||
.datum({name: "root", value: 0, children: []})
|
||||
.call(flamegraph);
|
||||
}
|
||||
|
||||
function updateFlameGraph(data) {
|
||||
if (!flamegraph) {
|
||||
initializeFlameGraph();
|
||||
}
|
||||
|
||||
// Update flame graph with new data
|
||||
d3.select("#flamegraph")
|
||||
.datum(data)
|
||||
.call(flamegraph);
|
||||
}
|
||||
|
||||
// Timeline Visualization
|
||||
function updateTimeline(data) {
|
||||
if (!data || data.length === 0) {
|
||||
document.getElementById('timeline').innerHTML = '<p>No timeline data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to Plotly timeline format
|
||||
const traces = [];
|
||||
|
||||
// Group by depth for better visualization
|
||||
const maxDepth = Math.max(...data.map(d => d.depth));
|
||||
|
||||
for (let depth = 0; depth <= maxDepth; depth++) {
|
||||
const depthData = data.filter(d => d.depth === depth);
|
||||
|
||||
if (depthData.length === 0) continue;
|
||||
|
||||
const trace = {
|
||||
x: depthData.map(d => d.start),
|
||||
y: depthData.map(d => depth),
|
||||
text: depthData.map(d => `${d.name}<br>Duration: ${(d.duration / 1000).toFixed(3)} ms`),
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: depthData.map(d => Math.max(5, Math.log(d.duration + 1) * 2)),
|
||||
color: depthData.map(d => d.duration),
|
||||
colorscale: 'Viridis',
|
||||
showscale: depth === 0,
|
||||
colorbar: {
|
||||
title: 'Duration (μs)'
|
||||
}
|
||||
},
|
||||
name: `Depth ${depth}`,
|
||||
hovertemplate: '%{text}<extra></extra>'
|
||||
};
|
||||
|
||||
traces.push(trace);
|
||||
}
|
||||
|
||||
const layout = {
|
||||
title: 'Function Call Timeline',
|
||||
xaxis: {
|
||||
title: 'Time (μs)',
|
||||
gridcolor: '#444'
|
||||
},
|
||||
yaxis: {
|
||||
title: 'Call Depth',
|
||||
gridcolor: '#444'
|
||||
},
|
||||
paper_bgcolor: '#252526',
|
||||
plot_bgcolor: '#1e1e1e',
|
||||
font: {
|
||||
color: '#d4d4d4'
|
||||
},
|
||||
hovermode: 'closest',
|
||||
showlegend: false
|
||||
};
|
||||
|
||||
Plotly.newPlot('timeline', traces, layout, {responsive: true});
|
||||
}
|
||||
|
||||
// Statistics Table
|
||||
function updateStatistics(data) {
|
||||
const tbody = document.getElementById('statistics-body');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7">No data available</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
data.forEach(stat => {
|
||||
html += `
|
||||
<tr>
|
||||
<td>${stat.name}</td>
|
||||
<td><code>${stat.address}</code></td>
|
||||
<td>${stat.calls}</td>
|
||||
<td>${stat.total_us.toLocaleString()}</td>
|
||||
<td>${stat.avg_us.toFixed(2)}</td>
|
||||
<td>${stat.min_us}</td>
|
||||
<td>${stat.max_us}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
// Handle window resize for flame graph
|
||||
window.addEventListener('resize', function() {
|
||||
if (flamegraph) {
|
||||
const width = document.getElementById('flamegraph').offsetWidth;
|
||||
flamegraph.width(width);
|
||||
flamegraph.update();
|
||||
}
|
||||
});
|
||||
101
host/web/templates/index.html
Normal file
101
host/web/templates/index.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MiniProfiler</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/d3-flame-graph/4.1.3/d3-flamegraph.css">
|
||||
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-flame-graph/4.1.3/d3-flamegraph.min.js"></script>
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<h1>MiniProfiler</h1>
|
||||
<p class="subtitle">Real-time Embedded Profiling Visualization</p>
|
||||
</header>
|
||||
|
||||
<!-- Control Panel -->
|
||||
<div class="control-panel">
|
||||
<div class="connection-controls">
|
||||
<input type="text" id="serial-port" placeholder="/dev/ttyUSB0 or COM3" value="/dev/ttyUSB0">
|
||||
<input type="number" id="baudrate" placeholder="Baudrate" value="115200">
|
||||
<input type="text" id="elf-path" placeholder="Path to .elf file (optional)">
|
||||
<button id="connect-btn" onclick="toggleConnection()">Connect</button>
|
||||
</div>
|
||||
|
||||
<div class="profiling-controls">
|
||||
<button id="start-btn" onclick="startProfiling()" disabled>Start Profiling</button>
|
||||
<button id="stop-btn" onclick="stopProfiling()" disabled>Stop Profiling</button>
|
||||
<button id="clear-btn" onclick="clearData()" disabled>Clear Data</button>
|
||||
<button id="reset-btn" onclick="resetBuffers()" disabled>Reset Buffers</button>
|
||||
</div>
|
||||
|
||||
<div class="status-display">
|
||||
<span class="status-indicator" id="connection-status">●</span>
|
||||
<span id="status-text">Disconnected</span>
|
||||
<span id="record-count">Records: 0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Display -->
|
||||
<div id="metadata-panel" class="metadata-panel" style="display: none;">
|
||||
<h3>Device Information</h3>
|
||||
<div id="metadata-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Panel -->
|
||||
<div id="summary-panel" class="summary-panel">
|
||||
<h3>Summary</h3>
|
||||
<div id="summary-content">
|
||||
<p>No profiling data available</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab-button active" onclick="showTab('flamegraph')">Flame Graph</button>
|
||||
<button class="tab-button" onclick="showTab('timeline')">Timeline</button>
|
||||
<button class="tab-button" onclick="showTab('statistics')">Statistics</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div id="flamegraph-tab" class="tab-content active">
|
||||
<div id="flamegraph"></div>
|
||||
</div>
|
||||
|
||||
<div id="timeline-tab" class="tab-content">
|
||||
<div id="timeline"></div>
|
||||
</div>
|
||||
|
||||
<div id="statistics-tab" class="tab-content">
|
||||
<div class="table-container">
|
||||
<table id="statistics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Function</th>
|
||||
<th>Address</th>
|
||||
<th>Calls</th>
|
||||
<th>Total Time (μs)</th>
|
||||
<th>Avg Time (μs)</th>
|
||||
<th>Min Time (μs)</th>
|
||||
<th>Max Time (μs)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="statistics-body">
|
||||
<tr>
|
||||
<td colspan="7">No data available</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user