Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
emulator/
output/
tests/config.local.yaml
poetry.lock
.python-version

Expand Down
119 changes: 119 additions & 0 deletions docs/ttl_control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# TTL Control in ionpulse_sdk_core

## Overview

Each of the 32 (on the RFSoC it think it's 16?) TTL output channels can be in one of three states:

| State | Name | Behavior |
|---|---|---|
| 0 | OFF | Ionizer forces the line **LOW** (static override) |
| 1 | ON | Ionizer forces the line **HIGH** (static override) |
| 2 | CONTROL | sdk_core pulse sequence controls the line dynamically |

State 2 is what Ionizer shows in **blue** — it means the FPGA pulse sequence owns that channel. States 0 and 1 are static overrides that the pulse sequence cannot override.

---

## Hardware Mechanism: The Two-Mask System

The FPGA exposes two 32-bit registers via RPC:

```
setTTLMasks(uint32 high_mask, uint32 low_mask)
ttlMasks() → (uint32 high_mask, uint32 low_mask)
```

For each bit position `n` (TTL channel n):

| `high_mask[n]` | `low_mask[n]` | Result |
|---|---|---|
| 0 | 1 | Line forced **LOW** (state 0) |
| 1 | 0 | Line forced **HIGH** (state 1) |
| 0 | 0 | Line under **pulse sequence control** (state 2) |
| 1 | 1 | Undefined — do not use |

Setting a channel to state 2 means clearing **both** its bits in both masks. The FPGA pulse sequence then drives that line as programmed in the waveform.

### Per-channel helper logic

To set channel `n` to a given state, read the current masks, modify the two bits, then write back:

```python
def set_ttl_state(n, state, high_mask, low_mask):
bit = 1 << n
if state == 0: # forced LOW
high_mask &= ~bit
low_mask |= bit
elif state == 1: # forced HIGH
high_mask |= bit
low_mask &= ~bit
elif state == 2: # pulse-sequence control
high_mask &= ~bit
low_mask &= ~bit
return high_mask, low_mask
```

Reading the current state back:

```python
def get_ttl_state(n, high_mask, low_mask):
bit = 1 << n
if low_mask & bit: return 0 # forced LOW
if high_mask & bit: return 1 # forced HIGH
return 2 # pulse-sequence control
```

---

## RPC API Reference

These are msgpack-RPC calls on the sdk_core server:

```
# Read current override masks
(high_mask, low_mask) = rpc.call("ttlMasks")

# Write override masks (affects all 32 channels at once)
rpc.call("setTTLMasks", high_mask, low_mask)
```

Both masks must be sent together. Always do a read-modify-write when changing individual channels to avoid clobbering others.

---

## Pulse-Level Control (sdk_core side)

When a TTL channel is in state 2 (CONTROL), the pulse sequence drives it. Each pulse step in the waveform carries two TTL fields:

- **`ttl_pattern`** (uint32): The desired output state — which channels should be HIGH during this step.
- **`ttl_line_mask`** (uint32): Which channels are actually updated by this step. Channels not in the mask keep their previous value.

Only channels in state 2 (both override bits clear) respond to these pulse-sequence commands. Channels held in state 0 or 1 are statically overridden at the hardware level and will not follow the pulse sequence regardless of `ttl_pattern`.

---

## What to Implement in Your Server

1. **On startup / connect**: call `ttlMasks()` to read the current hardware state.
2. **Per-channel state control**: implement read-modify-write using `setTTLMasks` to set individual channels to state 0, 1, or 2.
3. **Expose three operations per channel**:
- Force LOW (state 0)
- Force HIGH (state 1)
- Release to pulse sequence (state 2 — the "blue" / CONTROL mode)
4. **Pulse sequence**: when building waveforms, pass `ttl_pattern` and `ttl_line_mask` per step. Only channels in state 2 will respond.
5. **Persistence**: save the mask state so it can be restored on reconnect (the hardware forgets on power cycle).

---

## Ionizer UI Mapping (for reference)

| Ionizer element | Meaning |
|---|---|
| StateButton **red** | Channel in state 0 (forced LOW) |
| StateButton **green** | Channel in state 1 (forced HIGH) |
| ControlButton **blue** (PI / "397 0th") | Channel in state 2 (pulse-sequence control) |
| ControlButton **black** | Ionizer is in manual override mode (state 0 or 1) |

The ControlButton is what switches between static override and pulse-sequence control. The StateButton only has effect when the ControlButton is not active.

Key source files: `FPGAConnection.cpp:96–120` (mask logic), `ExperimentsSheet.cpp:747–786` (UI handlers), `api.h:141–147` (RPC method names), `bp_dds.h:112–165` (pulse-level TTL fields).
128 changes: 128 additions & 0 deletions docs/ttl_server_implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# TTL Channel Control — Server Implementation

## Background

Each Zedboard FPGA TTL output channel can be in one of three states:

| State | Name | Behavior |
|-------|------|----------|
| 0 | OFF | Channel forced LOW (static override) |
| 1 | ON | Channel forced HIGH (static override) |
| 2 | CONTROL | Pulse sequence owns the channel |

The hardware exposes two RPC calls: `ttlMasks()` (read) and `setTTLMasks(high_mask, low_mask)` (write). Ionizer's C++ client implemented per-channel control via `FPGAConnection::setTTLlogicState()` / `getTTLlogicState()`. This document describes how ICON implements equivalent server-side control.

---

## Why not use `tiqi_zedboard.TTLs`?

The `TTLs` class (v1.3.0, available via the `zedboard` optional extra) exposes only binary ON/OFF control. Its `_set_channel_zedboard()` always writes a 1 into either `high_mask` or `low_mask`, making it impossible to express state 2 (CONTROL — both bits clear). The underlying RPC supports all three states, so `TTLController` works directly with the raw masks via `HardwareController.get_ttl_masks()` / `set_ttl_masks()`. No changes to tiqi-zedboard are required.

---

## Architecture

```
APIService
└── ttl: TTLController (pydase.DataService)
├── HardwareController → Zedboard RPC (ttlMasks / setTTLMasks)
└── TTLRepository → SQLite ttl_mask_states table (single row)
```

`TTLController` is added to `APIService` alongside the existing `StatusController`, `DevicesController`, etc. It owns a dedicated `HardwareController` connection (same pattern as `StatusController`, i.e. `connect=False` initially) and persists masks through `TTLRepository` backed by a single-row SQLite table.

---

## Mask Encoding

For channel `n`:

| `high_mask[n]` | `low_mask[n]` | State |
|---|---|---|
| 0 | 1 | 0 — forced LOW |
| 1 | 0 | 1 — forced HIGH |
| 0 | 0 | 2 — CONTROL (pulse sequence) |

Helper functions `_decode_state()` and `_encode_state()` in `ttl_controller.py` implement this logic.

---

## Files Changed

| File | Change |
|------|--------|
| `src/icon/config/v1.py` | Added `n_ttl_channels: int = 32` to `HardwareConfig` |
| `src/icon/server/hardware_processing/hardware_controller.py` | Added `get_ttl_masks()` and `set_ttl_masks()` |
| `src/icon/server/data_access/models/sqlite/ttl_mask_state.py` | New — SQLAlchemy model |
| `src/icon/server/data_access/models/sqlite/__init__.py` | Added `TTLMaskState` to `__all__` |
| `src/icon/server/data_access/repositories/ttl_repository.py` | New — upsert/read masks |
| `src/icon/server/api/ttl_controller.py` | New — pydase DataService |
| `src/icon/server/api/api_service.py` | Registered `self.ttl = TTLController()` |
| `src/icon/server/data_access/db_context/sqlite/alembic/versions/a1b2c3d4e5f6_add_ttl_mask_state_table.py` | New — migration |
| `tests/server/__init__.py` | New — missing package marker |
| `tests/server/api/test_ttl_controller.py` | New — unit tests |

---

## API

### `ttl.get_states() -> list[int]`
Returns a list of 32 integers (0/1/2), one per channel, read live from hardware.
Falls back to the last persisted masks if the hardware is unreachable.

### `ttl.set_state(channel: int, state: int) -> None`
Sets one channel to state 0, 1, or 2. Writes to hardware, then persists the
resulting masks to SQLite. Emits a `ttl.update` Socket.IO event.

### `ttl.get_masks() -> dict[str, int]`
Returns `{"high_mask": ..., "low_mask": ...}` from hardware (or DB fallback).

### `ttl.restore_masks() -> None`
Re-applies the last persisted masks to the hardware — useful after a power cycle.

---

## Persistence

The `ttl_mask_states` table holds at most one row (`id=1`):

```sql
CREATE TABLE ttl_mask_states (
id INTEGER PRIMARY KEY AUTOINCREMENT,
high_mask INTEGER NOT NULL,
low_mask INTEGER NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
```

Migration: `a1b2c3d4e5f6_add_ttl_mask_state_table.py` (down_revision: `fc9af856df20`).

---

## Configuration

`hardware.n_ttl_channels` in the YAML config (default 32). Set to 16 for RFSoC hardware.

```yaml
hardware:
host: zedboard.lab
port: 6007
n_ttl_channels: 32
```

---

## Manual Verification (with hardware)

```python
# Start ICON server, then from a pydase client:
client.proxy.ttl.get_states() # read all 32 channel states
client.proxy.ttl.set_state(0, 1) # force channel 0 HIGH
client.proxy.ttl.set_state(0, 2) # release channel 0 to pulse sequence
client.proxy.ttl.get_masks() # inspect raw masks
client.proxy.ttl.restore_masks() # re-apply persisted masks after power cycle
```

After `set_state()`, confirm the hardware line changes state. After restarting the
server, the masks should be readable from the DB via `get_masks()` (hardware path)
or restored via `restore_masks()`.
6 changes: 6 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import DashboardIcon from "@mui/icons-material/Dashboard";
import ScienceIcon from "@mui/icons-material/Science";
import TimelineIcon from "@mui/icons-material/Timeline";
import ToggleOnIcon from "@mui/icons-material/ToggleOn";
import { Outlet } from "react-router";
import { ReactRouterAppProvider } from "@toolpad/core/react-router";
import type { Navigation } from "@toolpad/core/AppProvider";
Expand Down Expand Up @@ -57,6 +58,11 @@ const NAVIGATION: Navigation = [
</SvgIcon>
),
},
{
segment: "ttl",
title: "TTL",
icon: <ToggleOnIcon />,
},
{
kind: "divider",
},
Expand Down
Loading
Loading