Flamingo_Control CLAUDE.md
This project uses a Python virtual environment located at `.venv/` in the project root.
Claude Code Project Guidelines
Environment Setup
Python Virtual Environment
This project uses a Python virtual environment located at .venv/ in the project root.
To activate the virtual environment:
source .venv/bin/activate
To install/update dependencies:
source .venv/bin/activate
pip install -r requirements.txt
To run tests or scripts:
source .venv/bin/activate
python test_3d_visualization.py # or any other script
Important Notes:
- Always activate the virtual environment before running Python commands
- The
.venv/directory is already configured with project dependencies - Use
requirements.txtfor production dependencies - Use
requirements-minimal-3d.txtfor minimal 3D visualization setup
Development Workflow
Remote Testing Requirement
IMPORTANT: This project is tested on a remote computer that is physically connected to the microscope hardware, NOT on the local development machine.
Workflow Requirements:
- Always commit changes after making modifications
- Always push to GitHub immediately after committing
- User tests on remote PC - changes cannot be tested locally
- Wait for test results before proceeding with additional changes
Why This Matters:
- The microscope hardware is only accessible from the remote PC
- Local testing is not possible for hardware-dependent features
- Changes must be pushed to GitHub for the user to pull and test
- Do not make multiple sets of changes without getting test feedback
Best Practice:
1. Make focused changes to address specific issue
2. git commit with clear description
3. git push origin main
4. STOP and wait for user feedback from remote testing
5. Analyze test results before next iteration
Documentation Structure
Root Directory - User-Facing Documentation ONLY
The main Flamingo_Control/ directory should contain only documentation that end users need:
README.md- Project overview and quick startINSTALLATION.md- Installation and setup instructions- Usage guides and how-to documents
Do NOT place technical reports, implementation details, or development logs in the root directory.
Claude Reports Directory - Technical Documentation
All technical reports, implementation details, session summaries, and development documentation should be placed OUTSIDE the repository in:
/home/msnelson/LSControl/claude-reports/
IMPORTANT: This directory is at the same level as Flamingo_Control/, NOT inside it. Reports should NOT be committed to GitHub.
Naming Convention:
All files in claude-reports/ must follow this naming pattern:
YYYY-MM-DD-descriptive-name.md
Examples:
2024-11-05-position-display-fix.md2024-11-04-mvc-architecture-implementation.md2024-11-05-network-path-solution.md
What Goes in claude-reports/
Include:
- Implementation reports and technical summaries
- Bug fix documentation and root cause analysis
- Architecture decisions and design documentation
- Session summaries and work logs
- Integration verification reports
- Code refactoring summaries
- API documentation for internal components
- Development insights and lessons learned
Do NOT Include:
- User-facing installation guides
- Usage tutorials for end users
- Project README content
- Marketing or overview materials
File Organization Rules
Creating New Documentation
When creating any technical or development documentation:
- Always place it in
claude-reports/ - Always include the date in the filename:
YYYY-MM-DD- - Use lowercase with hyphens:
network-path-solutionnotNetwork_Path_Solution - Be descriptive but concise in the filename
Updating Existing Documentation
- User docs (README, INSTALLATION): Update in place in root
- Technical docs: Create a new dated file in
claude-reports/ - Reference the previous report if updating/superseding it
Example Structure
LSControl/
├── Flamingo_Control/ # Git repository (goes to GitHub)
│ ├── README.md # User-facing project overview
│ ├── INSTALLATION.md # User-facing setup guide
│ ├── .claude/
│ │ └── claude.md # This file
│ └── src/ # Source code
└── claude-reports/ # Technical docs (NOT in git)
├── 2024-11-04-mvc-refactor.md
├── 2024-11-05-position-fix.md
├── 2024-11-05-network-paths.md
└── 2024-11-05-gui-improvements.md
Why This Structure?
- Clean Root: Users see only what they need without wading through development history
- Chronological: Date-prefixed files naturally sort chronologically
- Discoverable: All technical docs in one place (
claude-reports/) - Organized: Clear separation between user docs and developer docs
- Maintainable: Easy to archive or reference historical implementations
Commit Guidelines
When committing documentation:
- DO NOT commit technical reports - they belong in
claude-reports/outside the repository - Commits should reference the report file in the commit message (e.g., "See claude-reports/2024-11-06-stage-control.md")
- Keep user-facing docs (README, INSTALLATION) up to date with actual functionality
- Only commit user-facing documentation in the repository root
TCP Protocol Structure
Binary Command/Response Format
The Flamingo microscope uses a 128-byte fixed binary protocol for all TCP communication (both commands sent TO microscope and responses FROM microscope).
Protocol Structure (128 bytes total)
Byte Offset | Size | Field Name | Type | Description
------------|------|-----------------|---------|----------------------------------
0-3 | 4 | Start Marker | uint32 | 0xF321E654 (validates packet)
4-7 | 4 | Command Code | uint32 | Command identifier (see CommandCodes.h)
8-11 | 4 | Status | uint32 | Status code (1=IDLE, 0=BUSY, etc.)
12-15 | 4 | cmdBits0 | int32 | Parameter 0 (usage varies by command)
16-19 | 4 | cmdBits1 | int32 | Parameter 1
20-23 | 4 | cmdBits2 | int32 | Parameter 2
24-27 | 4 | cmdBits3 | int32 | Parameter 3
28-31 | 4 | cmdBits4 | int32 | Parameter 4
32-35 | 4 | cmdBits5 | int32 | Parameter 5
36-39 | 4 | cmdBits6 | int32 | Parameter 6
40-47 | 8 | Value | double | Floating-point value
48-51 | 4 | addDataBytes | uint32 | Size of additional data after packet
52-123 | 72 | Data | bytes | Arbitrary data field (null-padded)
124-127 | 4 | End Marker | uint32 | 0xFEDC4321 (validates packet)
Python struct Format String
struct.Struct("I I I I I I I I I I d I 72s I")
# │ │ │ │ │ │ │ │ │ │ │ │ │ │
# │ │ │ │ │ │ │ │ │ │ │ │ │ └─ End Marker
# │ │ │ │ │ │ │ │ │ │ │ │ └──── Data (72 bytes)
# │ │ │ │ │ │ │ │ │ │ │ └─────── addDataBytes
# │ │ │ │ │ │ │ │ │ │ └────────── Value (double)
# │ │ │ │ │ │ │ │ │ └──────────── cmdBits6 (Param[6])
# │ │ │ │ │ │ │ │ └────────────── cmdBits5 (Param[5])
# │ │ │ │ │ │ │ └──────────────── cmdBits4 (Param[4])
# │ │ │ │ │ │ └────────────────── cmdBits3 (Param[3])
# │ │ │ │ │ └──────────────────── cmdBits2 (Param[2])
# │ │ │ │ └────────────────────── cmdBits1 (Param[1])
# │ │ │ └──────────────────────── cmdBits0 (Param[0])
# │ │ └────────────────────────── Status
# │ └──────────────────────────── Command Code
# └────────────────────────────── Start Marker
Two-Part Responses
Some commands send additional data after the 128-byte structure:
- 128-byte Binary Acknowledgment - Standard protocol structure
- Additional Data - Variable-length data (size indicated by
addDataBytes)
Examples:
SCOPE_SETTINGS_LOAD (4105): Sends 128-byte ack + ~2800 bytes of settings textSCOPE_SETTINGS_SAVE (4104): Receives 128-byte command + settings file data
IMPORTANT: When reading these responses:
- Always read the 128-byte ack first
- Check
addDataBytesfield or useselect()to detect additional data - Read additional data in chunks until socket is empty
- Do NOT decode the 128-byte ack as UTF-8 text (it's binary protocol)
- Only decode the additional data as text if it's a text response
Field Usage by Command Type
Different commands use the fields differently:
Position Commands (STAGE_POSITION_SET):
cmdBits0(Param[0]): Axis code (1=X, 2=Y, 3=Z, 4=R)cmdBits6(Param[6]): MUST be0x80000000(TRIGGER_CALL_BACK) for responseValue: Position in millimeters or degrees
Camera Query Commands:
cmdBits6(Param[6]): MUST be0x80000000(TRIGGER_CALL_BACK) for responseCAMERA_PIXEL_FIELD_OF_VIEW_GET: Returns pixel size inValuefield (mm/pixel)CAMERA_IMAGE_SIZE_GET: Returns dimensions in parameter fields
System State:
SYSTEM_STATE_GET: Returns state inStatusfield (1=IDLE, 0=BUSY)cmdBits3(Param[3]): May contain state code (40962=IDLE)
File Transfer Commands:
addDataBytes: Contains size of file being transferred- Command structure sent first, then file data
Workflow Commands (WORKFLOW_START):
cmdBits6(Param[6]): Workflow behavior flags (see below)addDataBytes: Size of workflow file data- Old code used
0x00000001(EXPERIMENT_TIME_REMAINING)
Command Data Bits Flags (params[6] / cmdBits6)
The cmdBits6 field (params[6]) contains bit flags that control command behavior.
These flags can be combined using bitwise OR (|). From CommandCodes.h:
enum COMMAND_DATA_BITS {
TRIGGER_CALL_BACK = 0x80000000, // Query commands - triggers response
EXPERIMENT_TIME_REMAINING = 0x00000001, // Timelapse/long experiments
STAGE_POSITIONS_IN_BUFFER = 0x00000002, // Multi-position workflows
MAX_PROJECTION = 0x00000004, // Z-stack MIP computation
SAVE_TO_DISK = 0x00000008, // Save images (vs. live view only)
STAGE_NOT_UPDATE_CLIENT = 0x00000010, // Suppress position updates
STAGE_ZSWEEP = 0x00000020, // Z-stack operation
}
Usage Examples:
Query command (MUST have response):
params[6] = 0x80000000 # TRIGGER_CALL_BACK
Z-stack with MIP saved to disk:
params[6] = 0x00000020 | 0x00000004 | 0x00000008 # ZSWEEP | MAX_PROJ | SAVE
Multi-position timelapse:
params[6] = 0x00000002 | 0x00000008 | 0x00000001 # POSITIONS | SAVE | TIME
CRITICAL: Query/GET Commands Require TRIGGER_CALL_BACK Flag:
- For query commands (e.g.,
CAMERA_IMAGE_SIZE_GET,STAGE_POSITION_GET),cmdBits6(Param[6]) MUST be set to0x80000000 - This is the
COMMAND_DATA_BITS_TRIGGER_CALL_BACKflag fromCommandCodes.h - Without this flag, the microscope receives the command but does not send a response
- Result: 3-second timeout waiting for response that never arrives
- Always set params[6] = 0x80000000 for any GET/query command
- DO NOT use TRIGGER_CALL_BACK for workflow commands - use workflow-specific flags
Example (correct):
cmd_bytes = encoder.encode_command(
code=CAMERA_IMAGE_SIZE_GET,
status=0,
params=[0, 0, 0, 0, 0, 0, 0x80000000], # TRIGGER_CALL_BACK flag
value=0.0,
data=b''
)
Example (incorrect - will timeout):
cmd_bytes = encoder.encode_command(
code=CAMERA_IMAGE_SIZE_GET,
status=0,
params=[0, 0, 0, 0, 0, 0, 0], # Missing TRIGGER_CALL_BACK - no response!
value=0.0,
data=b''
)
Packet Validation
Both start and end markers must be correct:
- Start:
0xF321E654 - End:
0xFEDC4321
If markers don't match, packet is invalid/corrupted.
Log File Analysis - Client ID Identification
IMPORTANT: When analyzing server log files to debug command issues:
- Working C++ GUI commands:
clientID ≠ 0(typicallyclientID = 24or other non-zero values) - Python GUI commands:
clientID = 0
Field Name Mapping - C++ Server Logs vs Python params Array
CRITICAL: The C++ SCommand struct has hardwareID/subsystemID/clientID BEFORE int32Data0/int32Data1/int32Data2!
C++ SCommand Struct Field Order (128 bytes):
Bytes 0-3: cmdStart (start marker 0xF321E654)
Bytes 4-7: cmd (command code)
Bytes 8-11: status
Bytes 12-15: hardwareID ← Server logs call this "hardwareID"
Bytes 16-19: subsystemID ← Server logs call this "subsystemID"
Bytes 20-23: clientID ← Server logs call this "clientID"
Bytes 24-27: int32Data0 ← Server logs call this "int32Data0" (LASER INDEX here!)
Bytes 28-31: int32Data1 ← Server logs call this "int32Data1"
Bytes 32-35: int32Data2 ← Server logs call this "int32Data2"
Bytes 36-39: cmdDataBits0 ← Server logs call this "cmdDataBits0"
Bytes 40-47: doubleData
Bytes 48-51: additionalDataBytes
Bytes 52-123: buffer[72]
Bytes 124-127: cmdEnd (end marker 0xFEDC4321)
Python params Array Usage (MATCHES C++ struct order directly):
params[0] = hardwareID (typically 0) → byte offset 12-15
params[1] = subsystemID (typically 0) → byte offset 16-19
params[2] = clientID (0 for Python GUI, non-zero for C++) → byte offset 20-23
params[3] = int32Data0 (axis/laser_index) → byte offset 24-27 ← CRITICAL!
params[4] = int32Data1 → byte offset 28-31
params[5] = int32Data2 → byte offset 32-35
params[6] = cmdDataBits0 (typically 0x80000000 for queries) → byte offset 36-39
Example Usage:
Stage position query (X-axis):
params = [0, 0, 0, 1, 0, 0, 0x80000000] # axis=1 in params[3]
Laser enable (laser 3):
params = [0, 0, 0, 3, 0, 0, 0x80000000] # laser_index=3 in params[3]
Why This Matters: The params array is packed DIRECTLY into the C++ struct - no remapping!
- params[0] → hardwareID at byte offset 12
- params[3] → int32Data0 at byte offset 24 (where axis/laser index goes)
Implementation
See src/py2flamingo/core/tcp_protocol.py:
ProtocolEncoder.encode_command()- Creates 128-byte packetsProtocolDecoder.decode_command()- Parses 128-byte packets
Communication Architecture
Queue-Based Communication Pattern:
The system uses a queue-based architecture to avoid socket contention between threads:
Application Code
↓ (put command)
Command Queue
↓ (send thread reads)
TCP Socket → Microscope
↓ (response)
Listener Thread
↓ (parse & route)
Other Data Queue
↓ (get response)
Application Code
Key Components:
commandqueue: Commands to send to microscopesendevent: Triggers send thread to process command queueother_dataqueue: Responses from microscope (populated by listener)command_listen_thread: Continuously reads socket, routes responses to queues
Why This Pattern:
- Prevents race conditions (only listener thread reads from socket)
- Multiple threads can send commands safely via queue
- Listener routes responses based on command code
- No blocking - uses event signaling
Implementation: All command sending (including debug queries) uses this pattern:
- Clear
other_dataqueue - Put command on
commandqueue - Set
sendevent - Wait for response on
other_dataqueue
See position_controller.py:debug_query_command() for reference implementation.
Error Handling Guidelines
Unified Error Format Requirements
IMPORTANT: All new code and refactored code MUST use the unified error handling framework defined in src/py2flamingo/core/errors.py. This ensures consistent error reporting, better debugging, and a professional user experience.
Error Handling Principles
- Use FlamingoError and Subclasses: Never raise generic Python exceptions (ValueError, RuntimeError, etc.) directly
- Include Rich Context: Every error MUST include WHERE, WHAT, and WHY information
- Separate User vs Technical Messages: User-friendly messages for UI, technical details for logs
- Use Error Codes: Enable programmatic error handling with specific error codes
- Log at Appropriate Levels: Use severity levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
Required Error Components
Every error MUST include:
from py2flamingo.core.errors import (
FlamingoError, ErrorCode, ErrorContext, ErrorSeverity,
ConnectionError, HardwareError, ValidationError, TimeoutError
)
# Create context for WHERE and WHAT
context = ErrorContext(
module="module_name", # Which module
function="function_name", # Which function
operation="what_was_attempted", # What operation
component="affected_component", # Which component (e.g., "Y-axis")
attempted_value=value, # What value caused the error
valid_range="0.0-12.0mm" # What the valid range is
)
# Raise appropriate error type
raise HardwareError(
code=ErrorCode.HARDWARE_STAGE_LIMIT_EXCEEDED,
message="Position out of range", # User-friendly
technical_details=f"Y={value}mm exceeds max 12.0mm", # Technical
context=context,
severity=ErrorSeverity.ERROR
)
Error Categories and When to Use Them
| Category | Use For | Example Codes | |----------|---------|---------------| | ConnectionError | Network/socket issues | CONNECTION_TIMEOUT, CONNECTION_REFUSED | | HardwareError | Microscope hardware problems | HARDWARE_STAGE_MOVEMENT_FAILED | | ValidationError | Input validation failures | VALIDATION_OUT_OF_RANGE | | TimeoutError | Operation timeouts | TIMEOUT_MOTION_COMPLETE |
Service Layer Pattern
class ServiceClass:
def __init__(self):
self.logger = logging.getLogger(__name__)
self.error_logger = ErrorLogger(self.logger)
def method(self, param):
context = ErrorContext(
module="service_name",
function="method",
operation="operation_description"
)
try:
# Operation
result = self._do_something()
if not result.success:
# Use the error from result
self.error_logger.log_error(result.error)
raise result.error
except socket.timeout as e:
# Wrap standard exceptions
error = self.error_logger.wrap_and_log(
e,
ErrorCode.TIMEOUT_COMMAND_RESPONSE,
"Operation timed out",
context=context
)
raise error from e
Controller Layer Pattern
Controllers can either:
- Return tuples with error objects (for backward compatibility):
def connect(self, ip: str, port: int) -> Tuple[bool, str, Optional[FlamingoError]]:
try:
self._service.connect(ip, port)
return (True, f"Connected to {ip}:{port}", None)
except FlamingoError as e:
self.error_logger.log_error(e)
return (False, e.get_user_message(), e)
- Raise exceptions (preferred for new code):
def connect(self, ip: str, port: int) -> None:
# Let FlamingoError propagate to view layer
self._service.connect(ip, port)
View Layer Pattern
try:
self.controller.operation()
self.show_success("Operation completed")
except FlamingoError as e:
# Error already logged by lower layers
formatter = ErrorFormatter()
# Show appropriate dialog based on severity
if e.severity in [ErrorSeverity.ERROR, ErrorSeverity.CRITICAL]:
QMessageBox.critical(
self,
f"{e.category.value.title()} Error",
formatter.format_for_user(e)
)
else:
QMessageBox.warning(
self,
f"{e.category.value.title()}",
formatter.format_for_user(e)
)
except Exception as e:
# Unexpected error - should be rare
self.logger.exception("Unexpected error")
QMessageBox.critical(
self,
"Unexpected Error",
"An unexpected error occurred. Check the log file for details."
)
DO NOT Do This
# BAD: Generic exception with string message
raise ValueError("Position out of range")
# BAD: Logging without context
self.logger.error("Failed")
# BAD: Returning None on error
if error:
return None
# BAD: Catching all exceptions
except Exception as e:
print(str(e))
# BAD: Tuple returns without error object
return (False, "Connection failed")
Error Code Ranges
Error codes are organized by category:
- 1000-1999: Connection errors
- 2000-2999: Hardware errors
- 3000-3999: Validation errors
- 4000-4999: Timeout errors
- 5000-5999: Filesystem errors
- 6000-6999: Configuration errors
- 7000-7999: State errors
- 8000-8999: Protocol errors
- 9000-9999: System errors
When adding new error codes, add them to the appropriate range in src/py2flamingo/core/errors.py.
Migration from Old Patterns
When refactoring existing code:
- Replace generic exceptions with FlamingoError subclasses
- Add ErrorContext with complete WHERE/WHAT/WHY information
- Use ErrorLogger for consistent logging
- Preserve backward compatibility during transition (can return error objects in tuples)
- Update tests to expect FlamingoError types
Testing Error Handling
def test_invalid_position():
"""Test that invalid position raises appropriate error."""
controller = StageController()
with pytest.raises(HardwareError) as exc_info:
controller.move_to_position(y=15.0) # Max is 12.0
error = exc_info.value
assert error.code == ErrorCode.HARDWARE_STAGE_LIMIT_EXCEEDED
assert error.context.component == "Y-axis"
assert error.context.attempted_value == 15.0
assert "12.0" in error.context.valid_range
Benefits of Unified Error Handling
- Consistent User Experience: All errors look and behave the same way
- Better Debugging: Rich context shows exactly what went wrong and why
- Programmatic Handling: Error codes enable specific error recovery
- Professional Logs: Structured logging with appropriate severity levels
- Maintainable Code: Clear patterns for error handling throughout codebase
Connection Initialization Flow
Signal Ordering for Connection
When connecting to the microscope, signals must be emitted in a specific order to avoid race conditions:
User clicks Connect
↓
TCP connection established
↓
connection_established.emit() ← Triggers: enable controls, status indicator
↓
Settings retrieval (PAUSES SocketReader for synchronous I/O)
↓
settings_loaded.emit() ← Triggers: position queries
↓
Position queries complete
Key Signals (ConnectionView):
connection_established- TCP connection succeeded (immediate feedback)settings_loaded- Settings retrieval completed (safe to use async socket operations)connection_error- Communication error occurred (e.g., settings timeout)
Why This Order Matters:
The SocketReader is paused during settings retrieval to allow synchronous text reading. Any async operations (like position queries) that start before settings complete will have their responses lost because the reader isn't processing messages.
Implementation:
connection_established→ enables controls, sets status indicatorsettings_loaded→ starts position queries via_on_settings_loaded()connection_error→ sets ERROR status, re-enables Connect button
See src/py2flamingo/views/connection_view.py and src/py2flamingo/application.py.
Async Socket Reader Architecture
Overview
The Flamingo Control system uses a background socket reader for non-blocking command/response handling. This prevents socket buffer buildup and ensures unsolicited callbacks (like STAGE_MOTION_STOPPED) are never missed.
Why Async Reading?
Problem with Synchronous Reading:
- GUI freezes during blocking socket reads
- Socket buffer fills up during concurrent operations (live view + stage movement)
- Unsolicited callbacks can be missed or delayed
- Position updates sent at 40Hz during motion can overwhelm the buffer
Solution - Background Reader:
- Dedicated thread continuously drains the command socket
- Messages are parsed and routed to appropriate queues
- Commands wait on response queues (non-blocking to GUI)
- Callbacks are delivered via registered handlers
Architecture Components
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ MicroscopeCmd │ │ MotionTracker │ │ Other Services│ │
│ │ Service │ │ │ │ │ │
│ └────────┬────────┘ └────────┬────────┘ └───────┬───────┘ │
│ │ │ │ │
│ │ send_command_async │ register_callback │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ TCPConnection │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ CommandClient │ │ │
│ │ │ ┌─────────────┐ ┌──────────────────────────┐ │ │ │
│ │ │ │ SocketReader│────▶│ MessageDispatcher │ │ │ │
│ │ │ │ (bg thread) │ │ │ │ │ │
│ │ │ └──────┬──────┘ │ ┌──────────────────┐ │ │ │ │
│ │ │ │ │ │ Pending Requests │ │ │ │ │
│ │ │ │ │ │ (response queues)│ │ │ │ │
│ │ │ │ │ └──────────────────┘ │ │ │ │
│ │ │ │ │ ┌──────────────────┐ │ │ │ │
│ │ │ │ │ │ Callback Handlers│ │ │ │ │
│ │ │ │ │ │ (unsolicited) │ │ │ │ │
│ │ │ │ │ └──────────────────┘ │ │ │ │
│ │ │ │ └──────────────────────────┘ │ │ │
│ │ └─────────┼───────────────────────────────────────────┘ │ │
│ └────────────┼─────────────────────────────────────────────┘ │
│ │ │
└───────────────┼─────────────────────────────────────────────────┘
│
▼
┌───────────────┐
│ Command Socket│
│ (TCP 53717) │
└───────────────┘
Key Classes
SocketReader (src/py2flamingo/core/socket_reader.py)
Background thread that continuously reads 128-byte messages from the command socket.
class SocketReader:
MESSAGE_SIZE = 128
START_MARKER = 0xF321E654
END_MARKER = 0xFEDC4321
def _read_loop(self):
"""Main loop - reads messages, handles additional data, dispatches"""
while self._running:
data = self._receive_message() # 128 bytes
message = self._parse_message(data)
if message.is_valid:
# CRITICAL: Read additional data BEFORE next message
if message.additional_data_size > 0:
additional = self._read_additional_data(message.additional_data_size)
message.additional_data = additional
self._dispatcher.dispatch(message)
MessageDispatcher
Routes parsed messages to appropriate destinations:
class MessageDispatcher:
def dispatch(self, message: ParsedMessage):
# 1. Check if response to pending request
if message.command_code in self._pending_requests:
self._pending_requests[command_code].put(message)
return
# 2. Check if unsolicited callback with handler
if message.command_code in self._callback_handlers:
for handler in handlers:
handler(message)
return
# 3. Unhandled - log for debugging
logger.debug(f"Unhandled message 0x{command_code:04X}")
ParsedMessage Dataclass
Structured representation of a 128-byte protocol message:
@dataclass
class ParsedMessage:
raw_data: bytes # Original 128 bytes
start_marker: int # 0xF321E654
command_code: int # Command identifier
status_code: int # Response status
hardware_id: int # params[0]
subsystem_id: int # params[1]
client_id: int # params[2]
int32_data0: int # params[3] - axis, laser index, etc.
int32_data1: int # params[4]
int32_data2: int # params[5]
cmd_data_bits: int # params[6] - flags
value: float # Double value (position, power, etc.)
additional_data_size: int # Bytes following this message
data_field: bytes # 72-byte data buffer
end_marker: int # 0xFEDC4321
timestamp: float # When received
additional_data: Optional[bytes] = None # Extra data after message
Handling Additional Data
CRITICAL: Some commands return extra data beyond the 128-byte response. This data MUST be read before the next message or the reader will lose sync.
Normal Message:
┌──────────────────────────────────┐
│ 128-byte Message │
│ (start marker ... end marker) │
└──────────────────────────────────┘
Message with Additional Data:
┌──────────────────────────────────┐ ┌────────────────────┐
│ 128-byte Message │ │ Additional Data │
│ (addDataBytes = N) │ │ (N bytes) │
└──────────────────────────────────┘ └────────────────────┘
Commands that return additional data:
SCOPE_SETTINGS_LOAD (4105)- ~2800 bytes settings textSAVE_LOCATIONS_GET (24585)- saved position data- Various query commands with string responses
The SocketReader handles this automatically:
if message.additional_data_size > 0:
additional = self._read_additional_data(message.additional_data_size)
message.additional_data = additional
Unsolicited Callbacks
The microscope sends some messages without being asked. These are critical to capture:
| Command Code | Name | Description |
|--------------|------|-------------|
| 0x6010 (24592) | STAGE_MOTION_STOPPED | Stage finished moving |
Registering a callback handler:
# In MotionTracker
connection.register_callback(
0x6010, # STAGE_MOTION_STOPPED
self._on_motion_stopped
)
def _on_motion_stopped(self, message: ParsedMessage):
if message.status_code == 1: # Success
self.logger.info("Motion complete!")
self._callback_queue.put(message)
Resync Mechanism
If the reader gets out of sync (e.g., missed some bytes), it will see invalid markers. After 5 consecutive invalid messages, it attempts to resync:
def _try_resync(self):
"""Scan for start marker to realign message boundaries"""
search_data = self._socket.recv(512)
marker_pos = search_data.find(START_MARKER_BYTES)
if marker_pos >= 0:
# Found marker - realign and continue
...
Usage Examples
Sending a Command with Response
# MicroscopeCommandService automatically uses async when available
result = service._query_command(
command_code=STAGE_POSITION_GET,
command_name="POSITION_GET",
params=[0, 0, 0, 1, 0, 0, TRIGGER_CALL_BACK], # Axis=X
value=0.0
)
What happens internally:
- Service encodes 128-byte command
- Registers pending request with dispatcher (returns Queue)
- Sends command via socket
- Background reader receives response
- Dispatcher puts response in the Queue
- Service gets response from Queue (with timeout)
Waiting for Motion Complete
# MotionTracker uses callback queue
tracker = MotionTracker(connection=connection)
success = tracker.wait_for_motion_complete(timeout=30.0)
What happens internally:
- MotionTracker registers callback for
STAGE_MOTION_STOPPED - When motion completes, microscope sends callback
- Background reader receives and dispatches to handler
- Handler puts message in internal queue
wait_for_motion_completepolls queue until message arrives
Configuration
The async reader is enabled by default:
# In TCPConnection.__init__
def __init__(self, use_async_reader: bool = True):
...
# To disable (use synchronous mode):
connection = TCPConnection(use_async_reader=False)
Logging and Debugging
The async reader logs useful debug information:
INFO - Started async socket reader
INFO - Registered callback handler for 0x6010
DEBUG - Read 2800 additional bytes for SCOPE_SETTINGS_LOAD
DEBUG - Dispatched response for 0x6008
WARNING - Invalid message markers: start=0x00000000 (consecutive: 1)
INFO - Attempting to resync stream...
INFO - Resync successful
INFO - SocketReader read loop exiting. Stats: {'messages_read': 150, ...}
Statistics
Access reader statistics for debugging:
stats = connection.get_async_stats()
# Returns:
# {
# 'reader': {
# 'messages_read': 150,
# 'parse_errors': 2,
# 'socket_errors': 0,
# 'bytes_read': 21504
# },
# 'dispatcher': {
# 'messages_received': 150,
# 'responses_dispatched': 145,
# 'callbacks_dispatched': 5,
# 'messages_dropped': 0
# }
# }
UI Development Guidelines
Window Geometry Persistence
IMPORTANT: All new windows and dialogs must implement geometry persistence so users don't have to reposition them every time they open.
Required Implementation Pattern
Every new QWidget-based window or QDialog should:
- Accept
geometry_managerparameter in__init__:
def __init__(self, ..., geometry_manager: 'WindowGeometryManager' = None, parent=None):
super().__init__(parent)
self._geometry_manager = geometry_manager
self._geometry_restored = False
- Add
showEventto restore geometry on first show:
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
if not self._geometry_restored and self._geometry_manager:
self._geometry_manager.restore_geometry("UniqueWindowName", self)
self._geometry_restored = True
- Add
hideEventand/orcloseEventto save geometry:
def hideEvent(self, event: QHideEvent) -> None:
if self._geometry_manager:
self._geometry_manager.save_geometry("UniqueWindowName", self)
super().hideEvent(event)
def closeEvent(self, event: QCloseEvent) -> None:
if self._geometry_manager:
self._geometry_manager.save_geometry("UniqueWindowName", self)
event.accept()
- For windows with QSplitters, also save/restore splitter state:
# In showEvent:
self._geometry_manager.restore_splitter_state("WindowName", "splitter_id", self.splitter)
# In closeEvent/hideEvent:
self._geometry_manager.save_splitter_state("WindowName", "splitter_id", self.splitter)
- Pass geometry_manager when creating the window (usually in
FlamingoApplication):
self.my_window = MyWindow(
...,
geometry_manager=self.geometry_manager
)
Key Files
- Service:
src/py2flamingo/services/window_geometry_manager.py - Storage:
window_geometry.json(auto-created in project root)
Windows Currently Implementing Geometry Persistence
MainWindowCameraLiveViewerImageControlsWindowStageChamberVisualizationWindowSample3DVisualizationWindow(with splitter)SampleViewLED2DOverviewDialog(usesapp.geometry_manager)
Windows NOT Yet Implemented (lower priority)
LED2DOverviewResultWindow- dynamically created, needs geometry_manager passed throughPositionHistoryDialog- modal dialog
Workflow System Reference
Comprehensive Workflow Documentation
For detailed information about the Flamingo workflow system, refer to:
/home/msnelson/LSControl/claude-reports/WORKFLOW_REFERENCE.md
This reference document covers:
- Workflow file format - Complete structure and syntax
- Field reference - All fields with types, ranges, and validation rules
- TCP protocol integration - How workflows are transmitted to the microscope
- Workflow types and flags - cmdDataBits0 flag combinations for each workflow type
- Server-side validation - What the C++ server validates and error messages
- Current implementation architecture - Python component responsibilities
- Example workflows - Snapshot, Z-Stack, and Tile scan templates
- Common issues - Troubleshooting guide
Quick Reference
Workflow Command Codes
12292 (0x3004)- WORKFLOW_START12293 (0x3005)- WORKFLOW_STOP12331 (0x302B)- CHECK_STACK
cmdDataBits0 Flags for Workflows
# DO NOT use TRIGGER_CALL_BACK (0x80000000) for workflow commands!
EXPERIMENT_TIME_REMAINING = 0x00000001 # Timelapse
STAGE_POSITIONS_IN_BUFFER = 0x00000002 # Multi-position
MAX_PROJECTION = 0x00000004 # Z-stack MIP
SAVE_TO_DISK = 0x00000008 # Save images
STAGE_ZSWEEP = 0x00000020 # Z-stack operation
Common Flag Combinations
| Type | Flags | Value | |------|-------|-------| | Snapshot (save) | SAVE_TO_DISK | 0x00000008 | | Z-Stack (save) | ZSWEEP | SAVE_TO_DISK | 0x00000028 | | Z-Stack with MIP | ZSWEEP | MAX_PROJECTION | SAVE_TO_DISK | 0x0000002C |
Illumination Source Format
Laser 3 488 nm = 5.00 1 # "power on/off" format (5% power, enabled)
LED_RGB_Board = 50.0 1 # 50% power, enabled
Data Save Locations
Save paths are embedded in workflow files, NOT queried via TCP:
SAVE_LOCATIONS_GET (0x6009)= saved stage positions (NOT data directories)- No "list available drives" command exists
Workflow fields:
Save image drive = /media/deploy/ctlsm1 # Base path (microscope perspective)
Save image directory = experiment_01 # Subdirectory
Save image data = Tiff # Format: NotSaved, Tiff, BigTiff, Raw
Save to subfolders = true # Organize by S{sample}/t{timepoint}/
Server creates: {drive}/{datetime}_{directory}/S001_t000001_V001_R0001_X001_Y001_C01_I0.tiff
Acquisition Lock System
Overview
The application provides an acquisition lock mechanism to prevent accidental interference with scanning operations. When an acquisition is in progress (e.g., LED 2D Overview scan), microscope controls are automatically disabled while visualization controls remain enabled.
Important Terminology:
- "Acquisition" = Client-side scanning processes (LED 2D Overview, tile collection, etc.)
- "Workflow" = Server-side Flamingo Workflow feature (reserved term - server handles its own locking)
Using the Acquisition Lock
For New Scanning Functions
Any new scanning/acquisition process should use the lock to prevent interference:
def start_scan(self):
"""Start a scanning operation."""
# Lock microscope controls at the start
if self._app:
self._app.start_acquisition("My Scan Name")
try:
# ... perform scanning operations ...
self._do_scan()
finally:
# ALWAYS unlock when done (success, error, or cancel)
if self._app:
self._app.stop_acquisition("My Scan Name")
def cancel_scan(self):
"""Cancel the scan."""
self._cancelled = True
# Unlock will happen in the scan completion handler
Key Methods (FlamingoApplication)
# Check if acquisition is running
if app.is_acquisition_in_progress:
# Don't start another acquisition
return
# Start acquisition (returns False if already in progress)
success = app.start_acquisition("LED 2D Overview")
# Stop acquisition (safe to call even if not in progress)
app.stop_acquisition("LED 2D Overview")
Signals
# Connect to acquisition state changes
app.acquisition_started.connect(self._on_acquisition_started)
app.acquisition_stopped.connect(self._on_acquisition_stopped)
def _on_acquisition_started(self):
self.my_stage_slider.setEnabled(False)
def _on_acquisition_stopped(self):
self.my_stage_slider.setEnabled(True)
Currently Connected Views
The following views automatically disable controls during acquisition:
| View | Controls Disabled | |------|-------------------| | Sample View | Position sliders (X,Y,Z,R), position edits, illumination panel | | Stage Control View | All movement controls (go-to buttons, jog buttons, spinboxes) | | Stage Chamber Visualization | Position sliders |
Adding Acquisition Lock to New Views
For any new view with stage movement controls:
- Add a disable method to your view:
def set_stage_controls_enabled(self, enabled: bool) -> None:
"""Enable/disable stage controls during acquisition."""
self.x_slider.setEnabled(enabled)
self.y_slider.setEnabled(enabled)
# ... other movement controls
# Keep visualization controls enabled!
- Connect signals in FlamingoApplication.setup_dependencies():
# In setup_dependencies():
if self.my_new_view:
self.acquisition_started.connect(
lambda: self.my_new_view.set_stage_controls_enabled(False)
)
self.acquisition_stopped.connect(
lambda: self.my_new_view.set_stage_controls_enabled(True)
)
Or for views created dynamically (like Sample View):
# When creating the view:
self.acquisition_started.connect(
lambda: self.my_view.set_stage_controls_enabled(False)
)
self.acquisition_stopped.connect(
lambda: self.my_view.set_stage_controls_enabled(True)
)
Controls That Should Remain Enabled
During acquisition, these should stay enabled so users can monitor progress:
- Napari 3D viewer and visualization
- Image display and contrast adjustment
- MIP visualization controls
- Progress bars and status displays
- Cancel buttons
Implementation Files
- State management:
src/py2flamingo/application.py(FlamingoApplication class) - Example usage:
src/py2flamingo/workflows/led_2d_overview_workflow.py - View integration:
src/py2flamingo/views/sample_view.py(set_stage_controls_enabled)
Code Quality Guidelines
Avoid Hardcoded Values
IMPORTANT: Hardcoded values should be avoided at all costs. They create brittle code that breaks when conditions change.
Bad Pattern - Hardcoded Delays:
# BAD: Magic number that may not work in all conditions
MIN_WORKFLOW_EXECUTION_TIME = 2.0 # What if workflow takes longer to start?
if elapsed < MIN_WORKFLOW_EXECUTION_TIME:
return # Ignore early signals
Good Pattern - State Transitions:
# GOOD: Track actual state changes
self._workflow_running = False
def _on_workflow_started(self, message):
self._workflow_running = True
def _on_system_idle(self, message):
if not self._workflow_running:
return # Ignore - workflow hasn't started yet
self._workflow_running = False
self._completion_event.set()
Principles:
- Use state machines instead of timing assumptions
- Track transitions (not-running → running → completed)
- Query actual state rather than assuming based on time
- Make constants configurable if they must exist (user settings, not magic numbers)
Common Violations:
- Hardcoded delays (
time.sleep(2.0)) - Magic timeout values that assume certain performance
- Assumptions about how long operations take
- Fixed retry counts without considering actual conditions
Exception: Some hardware protocols have documented timing requirements. These should be:
- Documented with references to hardware specs
- Named clearly (
HARDWARE_SETTLE_TIME_MS = 50 # Per datasheet section 4.2) - Placed in a configuration or constants module
Last Updated: 2026-01-28 Maintained By: Claude Code assistant