diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..6bc8abe38 --- /dev/null +++ b/.envrc @@ -0,0 +1,11 @@ +# Hyperloop H10 Development Environment +# +# This file is used by direnv to automatically load the development environment +# when you enter this directory. +# +# To use: +# 1. Install direnv: https://direnv.net/ +# 2. Run: direnv allow +# +# For pure shell (default): +use nix diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7d02b8178..ce8c2c6ea 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -251,6 +251,77 @@ jobs: retention-days: 7 compression-level: 9 + build-testadj: + name: Build testadj executable + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + name: linux + setup: | + python3 -m pip install pyinstaller + build_cmd: | + cd backend/cmd + pyinstaller --onefile --name testadj-linux testadj.py + artifact_name: testadj-linux + artifact_path: backend/cmd/dist/testadj-linux + + - os: windows-latest + name: windows + setup: | + python -m pip install pyinstaller + build_cmd: | + cd backend\cmd + pyinstaller --onefile --name testadj-windows testadj.py + artifact_name: testadj-windows + artifact_path: backend\cmd\dist\testadj-windows.exe + + - os: macos-latest + name: macos + setup: | + python3 -m pip install pyinstaller + build_cmd: | + cd backend/cmd + pyinstaller --onefile --name testadj-macos testadj.py + artifact_name: testadj-macos + artifact_path: backend/cmd/dist/testadj-macos + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Setup environment + run: ${{ matrix.setup }} + shell: bash + + - name: Build testadj (Linux) + if: matrix.os == 'ubuntu-latest' + run: ${{ matrix.build_cmd }} + shell: bash + + - name: Build testadj (macOS) + if: matrix.os == 'macos-latest' + run: ${{ matrix.build_cmd }} + shell: bash + + - name: Build testadj (Windows) + if: matrix.os == 'windows-latest' + run: ${{ matrix.build_cmd }} + shell: pwsh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_path }} + retention-days: 7 + compression-level: 9 + prepare-common-files: name: Prepare Common Files runs-on: ubuntu-latest @@ -263,8 +334,6 @@ jobs: - name: Copy config.toml run: cp backend/cmd/config.toml common-files/ - - name: Copy testadj.py - run: cp backend/cmd/testadj.py common-files/ - name: Copy README.md run: cp README.md common-files/ @@ -284,7 +353,7 @@ jobs: package-release: name: Package Release - needs: [build-backend, build-frontend, build-updater, prepare-common-files] + needs: [build-backend, build-frontend, build-updater, build-testadj, prepare-common-files] runs-on: ubuntu-latest steps: - name: Download all artifacts @@ -312,6 +381,9 @@ jobs: # Copy Linux updater cp artifacts/updater-linux/updater-linux-amd64 release-linux/updater + # Copy Linux testadj + cp artifacts/testadj-linux/testadj-linux release-linux/testadj + # Copy frontends mkdir -p release-linux/ethernet-view mkdir -p release-linux/control-station @@ -321,9 +393,12 @@ jobs: # Copy common files cp -r artifacts/common-files/* release-linux/ + # Set executable permissions + chmod +x release-linux/backend release-linux/updater release-linux/testadj + # Create Linux release archive cd release-linux - zip -r ../linux-$VERSION.zip . + tar -czf ../linux-$VERSION.tar.gz . - name: Organize Windows release files run: | @@ -335,6 +410,9 @@ jobs: # Copy Windows updater cp artifacts/updater-windows/updater-windows-amd64.exe release-windows/updater.exe + # Copy Windows testadj + cp artifacts/testadj-windows/testadj-windows.exe release-windows/testadj.exe + # Copy frontends mkdir -p release-windows/ethernet-view mkdir -p release-windows/control-station @@ -358,6 +436,9 @@ jobs: # Copy macOS Intel updater cp artifacts/updater-macos/updater-macos-amd64 release-macos/updater + # Copy macOS testadj + cp artifacts/testadj-macos/testadj-macos release-macos/testadj + # Copy frontends mkdir -p release-macos/ethernet-view mkdir -p release-macos/control-station @@ -367,9 +448,12 @@ jobs: # Copy common files cp -r artifacts/common-files/* release-macos/ + # Set executable permissions + chmod +x release-macos/backend release-macos/updater release-macos/testadj + # Create macOS Intel release archive cd release-macos - zip -r ../macos-intel-$VERSION.zip . + tar -czf ../macos-intel-$VERSION.tar.gz . - name: Organize macOS ARM64 release files run: | @@ -381,6 +465,9 @@ jobs: # Copy macOS ARM64 updater cp artifacts/updater-macos/updater-macos-arm64 release-macos-arm64/updater + # Copy macOS testadj + cp artifacts/testadj-macos/testadj-macos release-macos-arm64/testadj + # Copy frontends mkdir -p release-macos-arm64/ethernet-view mkdir -p release-macos-arm64/control-station @@ -390,15 +477,20 @@ jobs: # Copy common files cp -r artifacts/common-files/* release-macos-arm64/ + # Set executable permissions + chmod +x release-macos-arm64/backend release-macos-arm64/updater release-macos-arm64/testadj + # Create macOS ARM64 release archive cd release-macos-arm64 - zip -r ../macos-arm64-$VERSION.zip . + tar -czf ../macos-arm64-$VERSION.tar.gz . - name: Upload release packages uses: actions/upload-artifact@v4 with: name: releases - path: "*.zip" + path: | + *.tar.gz + *.zip retention-days: 7 compression-level: 9 @@ -421,9 +513,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./linux-${{ github.event.inputs.version }}.zip - asset_name: linux-${{ github.event.inputs.version }}.zip - asset_content_type: application/zip + asset_path: ./linux-${{ github.event.inputs.version }}.tar.gz + asset_name: linux-${{ github.event.inputs.version }}.tar.gz + asset_content_type: application/gzip - name: Upload Windows package to release if: github.event_name == 'workflow_dispatch' @@ -443,9 +535,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./macos-intel-${{ github.event.inputs.version }}.zip - asset_name: macos-intel-${{ github.event.inputs.version }}.zip - asset_content_type: application/zip + asset_path: ./macos-intel-${{ github.event.inputs.version }}.tar.gz + asset_name: macos-intel-${{ github.event.inputs.version }}.tar.gz + asset_content_type: application/gzip - name: Upload macOS ARM64 package to release if: github.event_name == 'workflow_dispatch' @@ -454,9 +546,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./macos-arm64-${{ github.event.inputs.version }}.zip - asset_name: macos-arm64-${{ github.event.inputs.version }}.zip - asset_content_type: application/zip + asset_path: ./macos-arm64-${{ github.event.inputs.version }}.tar.gz + asset_name: macos-arm64-${{ github.event.inputs.version }}.tar.gz + asset_content_type: application/gzip - name: Upload Linux package to existing release if: github.event_name == 'release' @@ -465,9 +557,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ github.event.release.upload_url }} - asset_path: ./linux-${{ github.event.release.tag_name }}.zip - asset_name: linux-${{ github.event.release.tag_name }}.zip - asset_content_type: application/zip + asset_path: ./linux-${{ github.event.release.tag_name }}.tar.gz + asset_name: linux-${{ github.event.release.tag_name }}.tar.gz + asset_content_type: application/gzip - name: Upload Windows package to existing release if: github.event_name == 'release' @@ -487,9 +579,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ github.event.release.upload_url }} - asset_path: ./macos-intel-${{ github.event.release.tag_name }}.zip - asset_name: macos-intel-${{ github.event.release.tag_name }}.zip - asset_content_type: application/zip + asset_path: ./macos-intel-${{ github.event.release.tag_name }}.tar.gz + asset_name: macos-intel-${{ github.event.release.tag_name }}.tar.gz + asset_content_type: application/gzip - name: Upload macOS ARM64 package to existing release if: github.event_name == 'release' @@ -498,6 +590,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ github.event.release.upload_url }} - asset_path: ./macos-arm64-${{ github.event.release.tag_name }}.zip - asset_name: macos-arm64-${{ github.event.release.tag_name }}.zip - asset_content_type: application/zip \ No newline at end of file + asset_path: ./macos-arm64-${{ github.event.release.tag_name }}.tar.gz + asset_name: macos-arm64-${{ github.event.release.tag_name }}.tar.gz + asset_content_type: application/gzip \ No newline at end of file diff --git a/.github/workflows/test-dev-scripts.yaml b/.github/workflows/test-dev-scripts.yaml new file mode 100644 index 000000000..d74a43168 --- /dev/null +++ b/.github/workflows/test-dev-scripts.yaml @@ -0,0 +1,82 @@ +name: Test Development Scripts + +on: + pull_request: + paths: + - scripts/** + workflow_dispatch: + +jobs: + test-dev-scripts: + name: Test Development Scripts + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + name: linux + shell: bash + script: ./scripts/dev.sh + + - os: windows-latest + name: windows-powershell + shell: pwsh + script: .\scripts\dev.ps1 + + - os: windows-latest + name: windows-cmd + shell: cmd + script: scripts\dev.cmd + + - os: macos-latest + name: macos + shell: bash + script: ./scripts/dev.sh + + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install tmux (Linux/macOS) + if: matrix.os != 'windows-latest' + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + sudo apt-get update && sudo apt-get install -y tmux + elif [ "${{ matrix.os }}" = "macos-latest" ]; then + brew install tmux + fi + shell: bash + + - name: Make script executable (Unix) + if: matrix.os != 'windows-latest' + run: chmod +x scripts/dev.sh + shell: bash + + - name: Test script help/usage + run: ${{ matrix.script }} + shell: ${{ matrix.shell }} + continue-on-error: true + + - name: Test dependency check + run: ${{ matrix.script }} setup + shell: ${{ matrix.shell }} + + - name: Test build command + run: ${{ matrix.script }} build + shell: ${{ matrix.shell }} + continue-on-error: true + + - name: Test backend build (quick test) + run: ${{ matrix.script }} test + shell: ${{ matrix.shell }} + continue-on-error: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index ddc94a4f5..efd4d2fd7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,5 @@ backend/cmd/cmd .prettierrc # Claude -CLAUDE.md +CLAUDE* .claude diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e42b8aae0..000000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "backend/cmd/adj"] - path = backend/cmd/adj - url = https://github.com/HyperloopUPV-H8/adj.git -[submodule "packet-sender/adj"] - path = packet-sender/adj - url = https://github.com/HyperloopUPV-H8/adj.git diff --git a/.starship.toml b/.starship.toml new file mode 100644 index 000000000..74b0b59b7 --- /dev/null +++ b/.starship.toml @@ -0,0 +1,90 @@ +# Hyperloop H10 Starship Prompt Configuration + +format = """ +[โ”Œโ”€](bold white) \ +$username\ +$hostname\ +$directory\ +$git_branch\ +$git_status\ +$golang\ +$nodejs\ +$python\ +$env_var\ +$custom\ +$cmd_duration\ +$line_break\ +[โ””โ”€](bold white) $character""" + +[directory] +truncation_length = 3 +truncate_to_repo = true +style = "bold cyan" +read_only = " ๐Ÿ”’" + +[character] +success_symbol = "[๐Ÿš„โฏ](bold green)" +error_symbol = "[๐Ÿš„โฏ](bold red)" +vicmd_symbol = "[๐Ÿš„โฎ](bold green)" + +[git_branch] +symbol = " " +style = "bold purple" +format = "on [$symbol$branch]($style) " + +[git_status] +style = "bold red" +format = '([\[$all_status$ahead_behind\]]($style) )' +conflicted = "โš”๏ธ " +ahead = "โ‡ก${count}" +behind = "โ‡ฃ${count}" +diverged = "โ‡•โ‡ก${ahead_count}โ‡ฃ${behind_count}" +untracked = "?${count}" +stashed = "๐Ÿ“ฆ${count}" +modified = "!${count}" +staged = "+${count}" +renamed = "ยป${count}" +deleted = "โœ˜${count}" + +[golang] +symbol = " " +style = "bold blue" +format = "via [$symbol($version )]($style)" + +[nodejs] +symbol = " " +style = "bold green" +format = "via [$symbol($version )]($style)" + +[python] +symbol = " " +style = "bold yellow" +format = "via [$symbol($version )]($style)" + +[cmd_duration] +min_time = 3_000 +format = "took [$duration]($style) " +style = "bold yellow" + +[env_var.NIX_SHELL] +symbol = "โ„๏ธ " +style = "bold blue" +format = "[$symbol]($style)" + +[custom.hyperloop] +command = "echo ๐Ÿš„" +when = """ test "$REPO_ROOT" != "" """ +format = "[$output]($style) " +style = "bold" + +[hostname] +ssh_only = false +format = "on [$hostname](bold red) " +disabled = false + +[username] +style_user = "white bold" +style_root = "red bold" +format = "[$user]($style) " +disabled = false +show_always = false \ No newline at end of file diff --git a/CONTROL_STATION_COMPLETE_ARCHITECTURE.md b/CONTROL_STATION_COMPLETE_ARCHITECTURE.md new file mode 100644 index 000000000..871284cd3 --- /dev/null +++ b/CONTROL_STATION_COMPLETE_ARCHITECTURE.md @@ -0,0 +1,526 @@ +# Control Station Complete Architecture + +## Table of Contents +1. [System Overview](#system-overview) +2. [Core Components](#core-components) +3. [Data Flow: Board to Frontend](#data-flow-board-to-frontend) +4. [Command Flow: Frontend to Board](#command-flow-frontend-to-board) +5. [BLCU/TFTP Operations](#blcutftp-operations) +6. [Network Architecture](#network-architecture) +7. [Known Issues and Recommendations](#known-issues-and-recommendations) +8. [Troubleshooting Guide](#troubleshooting-guide) + +## System Overview + +The Hyperloop UPV Control Station is a real-time monitoring and control system that bridges the gap between the pod's embedded systems and human operators. It consists of: + +- **Backend (Go)**: High-performance server handling network communication, packet processing, and state management +- **Frontend (React/TypeScript)**: Real-time web interfaces for monitoring and control +- **ADJ System**: JSON-based configuration defining packet structures, board specifications, and communication protocols + +### Key Design Principles +- **Real-time Performance**: Sub-10ms fault detection to emergency stop +- **Modular Architecture**: Board-agnostic design using ADJ specifications +- **Type Safety**: Strongly typed packet definitions (backend), working towards frontend type safety +- **Fault Tolerance**: Automatic reconnection, graceful degradation +- **Scalability**: Concurrent packet processing, efficient memory usage + +## Core Components + +### Backend Components + +``` +backend/ +โ”œโ”€โ”€ cmd/main.go # Entry point, initialization +โ”œโ”€โ”€ internal/ +โ”‚ โ”œโ”€โ”€ adj/ # ADJ parser and validator +โ”‚ โ”œโ”€โ”€ pod_data/ # Packet structure definitions +โ”‚ โ””โ”€โ”€ vehicle/ # Vehicle state management +โ””โ”€โ”€ pkg/ + โ”œโ”€โ”€ transport/ # Network layer (TCP/UDP/TFTP) + โ”œโ”€โ”€ presentation/ # Packet encoding/decoding + โ”œโ”€โ”€ broker/ # Message distribution + โ”œโ”€โ”€ websocket/ # Frontend communication + โ””โ”€โ”€ boards/ # Board-specific logic (BLCU) +``` + +### Frontend Components + +``` +control-station/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ services/ # WebSocket handlers +โ”‚ โ”œโ”€โ”€ components/ # UI components +โ”‚ โ””โ”€โ”€ state.ts # State management +common-front/ +โ””โ”€โ”€ lib/ + โ”œโ”€โ”€ wsHandler/ # WebSocket abstraction + โ”œโ”€โ”€ models/ # Type definitions + โ””โ”€โ”€ adapters/ # Data transformers +``` + +### ADJ Structure + +``` +adj/ +โ”œโ”€โ”€ general_info.json # Ports, addresses, units +โ”œโ”€โ”€ boards.json # Board registry +โ””โ”€โ”€ boards/ + โ””โ”€โ”€ [BOARD_NAME]/ + โ”œโ”€โ”€ [BOARD_NAME].json # Board config + โ”œโ”€โ”€ measurements.json # Data definitions + โ”œโ”€โ”€ packets.json # Boardโ†’Backend + โ””โ”€โ”€ orders.json # Backendโ†’Board +``` + +## Data Flow: Board to Frontend + +### 1. Binary Packet Transmission + +Boards send binary packets with this structure: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Header (2B) โ”‚ Payload (variable) โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Packet ID โ”‚ Data fields per ADJ โ”‚ +โ”‚ (uint16 LE) โ”‚ specification โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Example**: Temperature sensor packet +``` +64 00 // Packet ID: 100 (little-endian) +01 // sensor_id: 1 (uint8) +CD CC 8C 41 // temperature: 17.6ยฐC (float32 LE) +10 27 00 00 // timestamp: 10000ms (uint32 LE) +``` + +### 2. Network Reception + +The backend receives packets through multiple channels: + +- **TCP Server** (port 50500): Boards connect to backend +- **TCP Client** (port 50401): Backend connects to boards +- **UDP Sniffer** (port 50400): High-frequency sensor data +- **TFTP** (port 69): Firmware transfers (BLCU only) + +```go +// Simplified reception flow +func (t *Transport) HandleClient(config ClientConfig, boardAddr string) { + conn, err := net.Dial("tcp", boardAddr) + for { + packet := t.readPacket(conn) + t.routePacket(packet) + } +} +``` + +### 3. Packet Decoding + +The presentation layer decodes packets based on ADJ definitions: + +```go +// Decoding process +id := binary.LittleEndian.Uint16(data[0:2]) +decoder := t.decoders[id] +packet := decoder.Decode(data[2:]) + +// Apply unit conversions +for field, value := range packet.Fields { + measurement := adj.GetMeasurement(field) + value = applyConversion(value, measurement.PodUnits, measurement.DisplayUnits) +} +``` + +### 4. State Management & Distribution + +The vehicle layer processes packets and updates system state: + +```go +// Vehicle processing +func (v *Vehicle) ProcessPacket(packet Packet) { + // Update internal state + v.state.Update(packet) + + // Check safety conditions + v.checkProtections(packet) + + // Distribute via broker + v.broker.Publish("data/update", packet) +} +``` + +### 5. WebSocket Transmission + +The broker converts packets to JSON and sends to connected clients: + +```json +{ + "topic": "data/update", + "payload": { + "board_id": 4, + "packet_id": 320, + "packet_name": "lcu_coil_current", + "timestamp": "2024-01-15T10:30:45.123Z", + "measurements": { + "lcu_coil_current_1": { + "value": 2.5, + "type": "float32", + "units": "A", + "enabled": true + } + } + } +} +``` + +### 6. Frontend Processing + +The React frontend receives and displays data: + +```typescript +// WebSocket handler +wsHandler.subscribe("data/update", { + id: "unique-id", + cb: (data: PacketUpdate) => { + // Update UI components + updateMeasurement(data.measurements); + updateCharts(data); + } +}); +``` + +## Command Flow: Frontend to Board + +### 1. Order Creation + +Frontend creates structured order objects: + +```typescript +const order: Order = { + id: 9995, // From ADJ orders.json + fields: { + "ldu_id": { + value: 1, + isEnabled: true, + type: "uint8" + }, + "lcu_desired_current": { + value: 2.5, + isEnabled: true, + type: "float32" + } + } +}; +``` + +### 2. WebSocket Transmission + +```typescript +wsHandler.post("order/send", order); +``` + +Sends JSON message: +```json +{ + "topic": "order/send", + "payload": { + "id": 9995, + "fields": { + "ldu_id": { "value": 1, "type": "uint8" }, + "lcu_desired_current": { "value": 2.5, "type": "float32" } + } + } +} +``` + +### 3. Backend Processing + +```go +// Order processing pipeline +func (b *Broker) HandleOrder(order Order) { + // 1. Validate order exists in ADJ + boardName := b.getTargetBoard(order.ID) + if !b.adj.ValidateOrder(boardName, order.ID) { + return error("Invalid order ID") + } + + // 2. Create packet structure + packet := b.createPacket(order) + + // 3. Encode to binary + data := b.encoder.Encode(packet) + + // 4. Send to board + b.transport.SendTo(boardName, data) +} +``` + +### 4. Binary Encoding + +The encoder converts the order to binary format: + +``` +Order: { id: 9995, ldu_id: 1, current: 2.5 } + +Binary output: +0B 27 // ID: 9995 (0x270B little-endian) +01 // ldu_id: 1 +00 00 20 40 // current: 2.5 (float32 LE) +``` + +### 5. Network Transmission + +The transport layer sends the binary data to the target board via TCP. + +## BLCU/TFTP Operations + +The BLCU (BootLoader Control Unit) handles firmware updates using TFTP protocol. + +### Upload Flow (Frontend โ†’ Board) + +1. **Frontend initiates upload**: +```typescript +wsHandler.exchange("blcu/upload", { + board: "LCU", + filename: "firmware.bin", + data: base64Data +}); +``` + +2. **Backend sends order to BLCU**: +```go +// Send upload order (ID: 700) +ping := createPacket(BlcuUploadOrderId, targetBoard) +transport.Send(ping) +``` + +3. **Wait for ACK**: +```go +<-blcu.ackChan // Blocks until ACK received +``` + +4. **TFTP Transfer**: +```go +client := tftp.NewClient(blcu.ip) +client.WriteFile(filename, tftp.BinaryMode, data) +``` + +5. **Progress updates via WebSocket**: +```json +{ + "topic": "blcu/register", + "payload": { + "operation": "upload", + "progress": 0.65, + "bytes_transferred": 340000, + "total_bytes": 524288 + } +} +``` + +### Download Flow (Board โ†’ Frontend) + +Similar process but uses: +- Order ID: 701 +- `client.ReadFile()` for TFTP +- Returns file data to frontend + +### TFTP Configuration + +```go +type TFTPConfig struct { + BlockSize: 131072, // 128KB blocks + Retries: 3, + TimeoutMs: 5000, + BackoffFactor: 2, + EnableProgress: true +} +``` + +## Network Architecture + +### IP Addressing Scheme + +- **Backend**: 192.168.0.9 +- **Boards**: 192.168.1.x (defined in ADJ) +- **BLCU**: Typically 192.168.1.254 + +### Port Assignments + +| Service | Port | Protocol | Direction | Purpose | +|---------|------|----------|-----------|---------| +| TCP Server | 50500 | TCP | Boardโ†’Backend | Board connections | +| TCP Client | 50401 | TCP | Backendโ†’Board | Backend initiates | +| UDP | 50400 | UDP | Bidirectional | High-freq data | +| TFTP | 69 | UDP | Bidirectional | Firmware transfer | +| WebSocket | 8080 | TCP/HTTP | Frontendโ†’Backend | UI communication | +| SNTP | 123 | UDP | Boardโ†’Backend | Time sync (optional) | + +### Connection Management + +The backend maintains persistent TCP connections with automatic reconnection: + +```go +// Exponential backoff for reconnection +backoff := 100ms +for { + conn, err := net.Dial("tcp", boardAddr) + if err != nil { + time.Sleep(backoff) + backoff = min(backoff * 1.5, 5s) + continue + } + backoff = 100ms + handleConnection(conn) +} +``` + +## Known Issues and Recommendations + +### Critical Issues + +1. **BLCU Hardcoded Configuration** + - **Issue**: BLCU packet IDs (700, 701) hardcoded in backend + - **Impact**: Cannot adapt to different BLCU versions + - **Fix**: Move BLCU configuration to ADJ like other boards + +2. **WebSocket Type Safety** + - **Issue**: Frontend uses `any` type for payloads + - **Impact**: Runtime errors, poor IDE support + - **Fix**: Generate TypeScript types from ADJ specifications + +3. **Monolithic main.go** + - **Issue**: 800+ lines in single file + - **Impact**: Hard to maintain and test + - **Fix**: Refactor into logical modules + +### Architecture Improvements Needed + +1. **Error Handling Standardization** + - Implement consistent error types + - Add proper error wrapping + - Improve error messages for operators + +2. **Testing Coverage** + - Current: ~30% + - Target: 80%+ + - Focus on packet encoding/decoding edge cases + +3. **Configuration Management** + - Implement hot reload for non-critical settings + - Add configuration validation + - Support environment-specific configs + +4. **Security Enhancements** + - Add authentication for WebSocket connections + - Implement TLS for external connections + - Add audit logging for critical operations + +### Performance Optimizations + +1. **Connection Pooling** + - Implement proper connection pool with health checks + - Add connection limits and metrics + - Support load balancing for multiple boards + +2. **Message Batching** + - Batch WebSocket updates for better performance + - Implement configurable update rates + - Add client-side throttling + +## Troubleshooting Guide + +### Common Issues + +#### No Data from Board + +1. **Check board is in config.toml**: +```toml +[vehicle] +boards = ["LCU", "HVSCU", "BMSL"] +``` + +2. **Verify network connectivity**: +```bash +ping 192.168.1.4 # Board IP +netstat -an | grep 504 # Check connections +``` + +3. **Check ADJ configuration**: +```bash +cat adj/boards.json | jq +cat adj/boards/LCU/LCU.json | jq +``` + +#### Order Not Working + +1. **Verify order exists in ADJ**: +```bash +jq '.[] | select(.id == 9995)' adj/boards/LCU/orders.json +``` + +2. **Check WebSocket message format**: +- Open browser DevTools โ†’ Network โ†’ WS +- Verify message structure matches specification + +3. **Check backend logs**: +```bash +tail -f trace.json | jq 'select(.msg | contains("order"))' +``` + +#### BLCU Upload Fails + +1. **Check BLCU connection**: +- Verify BLCU IP in ADJ +- Test TFTP connectivity +- Check ACK timeout + +2. **Verify file format**: +- Binary files only +- Check file size limits +- Verify checksums if implemented + +#### WebSocket Disconnections + +1. **Check browser console** for errors +2. **Verify backend is running**: Check PID file +3. **Network issues**: Check for firewall/proxy interference + +### Debug Commands + +```bash +# Monitor all packet flow +tail -f trace.json | jq + +# Filter specific board +tail -f trace.json | jq 'select(.board_id == 4)' + +# Watch WebSocket messages +# In browser: DevTools โ†’ Network โ†’ WS โ†’ Messages + +# Check system resources +htop # CPU/Memory usage +iftop # Network usage + +# Capture network traffic +sudo tcpdump -i any -w capture.pcap 'port 50400 or port 50500' +``` + +### Performance Monitoring + +```bash +# Backend profiling (if enabled) +go tool pprof http://localhost:4040/debug/pprof/profile + +# Check message rates +tail -f trace.json | jq -r .timestamp | uniq -c + +# Monitor connection count +netstat -an | grep -c ESTABLISHED +``` + +--- + +*This document represents the complete architecture of the Hyperloop UPV Control Station as of 2025. For updates and corrections, please submit a pull request.* \ No newline at end of file diff --git a/DOCUMENTATION_REORGANIZATION.md b/DOCUMENTATION_REORGANIZATION.md new file mode 100644 index 000000000..0bbbab6b0 --- /dev/null +++ b/DOCUMENTATION_REORGANIZATION.md @@ -0,0 +1,124 @@ +# Documentation Reorganization Summary + +This document outlines the reorganization of project documentation from scattered files to a centralized `docs/` folder structure. + +## ๐Ÿ“ New Documentation Structure + +``` +docs/ +โ”œโ”€โ”€ README.md # Main documentation index +โ”œโ”€โ”€ architecture/ +โ”‚ โ””โ”€โ”€ README.md # System architecture overview +โ”œโ”€โ”€ development/ +โ”‚ โ”œโ”€โ”€ DEVELOPMENT.md # Development setup guide +โ”‚ โ”œโ”€โ”€ CROSS_PLATFORM_DEV_SUMMARY.md # Cross-platform scripts documentation +โ”‚ โ””โ”€โ”€ scripts.md # Scripts reference guide +โ”œโ”€โ”€ guides/ +โ”‚ โ””โ”€โ”€ getting-started.md # New user getting started guide +โ””โ”€โ”€ troubleshooting/ + โ””โ”€โ”€ BLCU_FIX_SUMMARY.md # BLCU repair documentation +``` + +## ๐Ÿ“‹ File Migrations + +### Moved Files +| Original Location | New Location | Status | +|-------------------|--------------|--------| +| `DEVELOPMENT.md` | `docs/development/DEVELOPMENT.md` | โœ… Moved | +| `CROSS_PLATFORM_DEV_SUMMARY.md` | `docs/development/CROSS_PLATFORM_DEV_SUMMARY.md` | โœ… Moved | +| `scripts/README.md` | `docs/development/scripts.md` | โœ… Moved | +| `backend/BLCU_FIX_SUMMARY.md` | `docs/troubleshooting/BLCU_FIX_SUMMARY.md` | โœ… Moved | + +### New Files Created +| File | Purpose | +|------|---------| +| `docs/README.md` | Main documentation index with navigation | +| `docs/architecture/README.md` | System architecture overview | +| `docs/guides/getting-started.md` | Comprehensive new user guide | +| `scripts/README.md` | Quick reference pointing to full docs | + +### Updated Files +| File | Changes | +|------|---------| +| `README.md` | Added documentation section with quick links | +| `docs/development/scripts.md` | Updated paths for new location | + +## ๐ŸŽฏ Benefits of New Structure + +### 1. **Improved Organization** +- Clear categorization by purpose (development, architecture, guides, troubleshooting) +- Logical hierarchy that scales as documentation grows +- Centralized location for all project documentation + +### 2. **Better Discoverability** +- Single entry point through `docs/README.md` +- Clear navigation between related documents +- Quick links in main README for common tasks + +### 3. **Enhanced User Experience** +- Dedicated getting started guide for new users +- Platform-specific guidance clearly organized +- Troubleshooting docs easily accessible + +### 4. **Maintainability** +- Related documentation grouped together +- Easier to update and maintain consistency +- Clear ownership and responsibility areas + +## ๐Ÿš€ How to Use the New Structure + +### For New Users +1. Start with [`docs/guides/getting-started.md`](docs/guides/getting-started.md) +2. Follow platform-specific setup in [`docs/development/DEVELOPMENT.md`](docs/development/DEVELOPMENT.md) +3. Refer to troubleshooting docs if needed + +### For Developers +1. Check [`docs/development/`](docs/development/) for all development-related docs +2. Use [`docs/architecture/`](docs/architecture/) to understand system design +3. Reference [`docs/development/scripts.md`](docs/development/scripts.md) for tooling + +### For Contributors +1. Review existing documentation structure before adding new docs +2. Place new documentation in appropriate category folders +3. Update main index (`docs/README.md`) when adding major new sections + +## ๐Ÿ“ Documentation Guidelines + +### Placement Rules +- **Development docs** โ†’ `docs/development/` +- **Architecture docs** โ†’ `docs/architecture/` +- **User guides** โ†’ `docs/guides/` +- **Troubleshooting** โ†’ `docs/troubleshooting/` +- **Component-specific** โ†’ Keep in respective component directories + +### Linking Guidelines +- Use relative paths for internal documentation links +- Update `docs/README.md` index when adding major new documents +- Cross-reference related documentation where helpful + +### File Naming +- Use lowercase with hyphens: `getting-started.md` +- Use descriptive names that indicate content purpose +- Keep README.md files for directory overviews + +## ๐Ÿ”— Key Entry Points + +### Primary Documentation +- **[docs/README.md](docs/README.md)** - Main documentation hub +- **[README.md](README.md)** - Project overview with quick start + +### Quick Access +- **New Users**: [Getting Started Guide](docs/guides/getting-started.md) +- **Developers**: [Development Setup](docs/development/DEVELOPMENT.md) +- **Troubleshooting**: [Common Issues](docs/troubleshooting/BLCU_FIX_SUMMARY.md) + +## ๐ŸŽ‰ Migration Complete + +The documentation reorganization provides: +- โœ… Better organization and navigation +- โœ… Improved new user experience +- โœ… Clearer separation of concerns +- โœ… Scalable structure for future growth +- โœ… Maintained backward compatibility through redirect notes + +All existing functionality remains accessible while providing a much better documentation experience for users, developers, and contributors. \ No newline at end of file diff --git a/Makefile b/Makefile index 2d3c994ea..928f9e100 100644 --- a/Makefile +++ b/Makefile @@ -1,142 +1,158 @@ -.PHONY: all -all: backend packet-sender ethernet-view control-station common-front - @echo "" && \ - echo "#===================#" && \ - echo "| Building all |" && \ - echo "#===================#" && \ - echo "" - -backend: backend-tidy - @echo "" && \ - echo "#=======================#" && \ - echo "| Building backend |" && \ - echo "#=======================#" && \ - echo "" - @cd backend && go build -C cmd -o backend - -.PHONY: backend-static -backend-static: backend-build-container - @echo "" && \ - echo "#==============================#" && \ - echo "| Building static backend |" && \ - echo "#==============================#" && \ - echo "" - @docker run --name="backend-build" hlupv-backend-build - @$(MAKE) backend-static-cleanup - -packet-sender: packet-sender-tidy - @echo "" && \ - echo "#=============================#" && \ - echo "| Building packet sender |" && \ - echo "#=============================#" && \ - echo "" - @cd packet-sender && go build -o packet-sender - -ethernet-view: common-front ethernet-view-deps - @echo "" && \ - echo "#=============================#" && \ - echo "| Building ethernet view |" && \ - echo "#=============================#" && \ - echo "" - @cd ethernet-view && npm run build - -control-station: common-front control-station-deps - @echo "" && \ - echo "#===============================#" && \ - echo "| Building control station |" && \ - echo "#===============================#" && \ - echo "" - @cd control-station && npm run build - -common-front: common-front-deps - @echo "" && \ - echo "#============================#" && \ - echo "| Building common front |" && \ - echo "#============================#" && \ - echo "" - @cd common-front && npm run build - -.PHONY: release-ethernet-view -release-ethernet-view: backend-static ethernet-view - @echo "" && \ - echo "#=====================================#" && \ - echo "| Creating ethernet view release |" && \ - echo "#=====================================#" && \ - echo "" - mkdir -p build/ethernet-view - cp backend/build/backend build/ethernet-view/ethernet-view - cp backend/build/config/ethernet-view.toml build/ethernet-view/config.toml - rm -r build/ethernet-view/static - cp -r ethernet-view/static build/ethernet-view/static - -.PHONY: common-front-deps -common-front-deps: - @echo "" && \ - echo "#=========================================#" && \ - echo "| Updating common front dependencies |" && \ - echo "#=========================================#" && \ - echo "" - @cd common-front && npm install - - - -.PHONY: ethernet-view-deps -ethernet-view-deps: - @echo "" && \ - echo "#==========================================#" && \ - echo "| Updating ethernet view dependencies |" && \ - echo "#==========================================#" && \ - echo "" - @cd ethernet-view && npm install - - - -.PHONY: control-station-deps -control-station-deps: - @echo "" && \ - echo "#============================================#" && \ - echo "| Updating control station dependencies |" && \ - echo "#============================================#" && \ - echo "" - @cd control-station && npm install - -.PHONY: backend-build-container -backend-build-container: - @echo "" && \ - echo "#=================================#" && \ - echo "| Building backend container |" && \ - echo "#=================================#" && \ - echo "" - docker build -t "hlupv-backend-build" -f "./backend/build/Dockerfile" "./backend" - -.PHONY: backend-tidy -backend-tidy: - @echo "" && \ - echo "#==============================#" && \ - echo "| Updating backend module |" && \ - echo "#==============================#" && \ - echo "" - @cd backend && go mod tidy - -.PHONY: packet-sender-tidy -packet-sender-tidy: - @echo "" && \ - echo "#====================================#" && \ - echo "| Updating packet sender module |" && \ - echo "#====================================#" && \ - echo "" - @cd packet-sender && go mod tidy - -.PHONY: backend-static-cleanup -backend-static-cleanup: - docker rm --volumes "backend-build" - -.PHONY: clear -clear: - rm -r build - rm -r common-front/dist - rm -r ethernet-view/static - rm -r control-station/static - rm -r backend/cmd/backend - rm -r packet-sender/packet-sender - docker rm --volumes "backend-build" \ No newline at end of file +# Hyperloop H10 Control Station Makefile + +.PHONY: all install clean build-backend build-common-front build-control-station build-ethernet-view +.PHONY: backend common-front control-station ethernet-view +.PHONY: dev-backend dev-control-station dev-ethernet-view +.PHONY: test test-backend test-frontend +.PHONY: ethernet-view-tmux control-station-tmux + +# Colors for output +GREEN := \033[0;32m +YELLOW := \033[0;33m +BLUE := \033[0;34m +RED := \033[0;31m +NC := \033[0m # No Color + +# Build directories +BACKEND_DIR := backend +COMMON_FRONT_DIR := common-front +CONTROL_STATION_DIR := control-station +ETHERNET_VIEW_DIR := ethernet-view + +# Output binary +BACKEND_BIN := $(BACKEND_DIR)/cmd/backend + +# Default target +all: install build + +# Install all dependencies +install: install-backend install-frontend + @echo "$(GREEN)โœ“ All dependencies installed$(NC)" + +install-backend: + @echo "$(BLUE)Installing backend dependencies...$(NC)" + @cd $(BACKEND_DIR) && go mod download + @echo "$(GREEN)โœ“ Backend dependencies installed$(NC)" + +install-frontend: + @echo "$(BLUE)Installing frontend dependencies...$(NC)" + @cd $(COMMON_FRONT_DIR) && npm install + @cd $(CONTROL_STATION_DIR) && npm install + @cd $(ETHERNET_VIEW_DIR) && npm install + @echo "$(GREEN)โœ“ Frontend dependencies installed$(NC)" + +# Build all components +build: build-backend build-frontend + @echo "$(GREEN)โœ“ All components built successfully$(NC)" + +build-frontend: build-common-front build-control-station build-ethernet-view + +# Individual build targets +backend build-backend: + @echo "$(BLUE)Building backend...$(NC)" + @cd $(BACKEND_DIR)/cmd && go build -o backend + @echo "$(GREEN)โœ“ Backend built: $(BACKEND_BIN)$(NC)" + +common-front build-common-front: + @echo "$(BLUE)Building common-front...$(NC)" + @cd $(COMMON_FRONT_DIR) && npm run build + @echo "$(GREEN)โœ“ Common-front built$(NC)" + +control-station build-control-station: build-common-front + @echo "$(BLUE)Building control-station...$(NC)" + @cd $(CONTROL_STATION_DIR) && npm run build + @echo "$(GREEN)โœ“ Control-station built$(NC)" + +ethernet-view build-ethernet-view: build-common-front + @echo "$(BLUE)Building ethernet-view...$(NC)" + @cd $(ETHERNET_VIEW_DIR) && npm run build + @echo "$(GREEN)โœ“ Ethernet-view built$(NC)" + +# Development servers (individual) +dev-backend: + @echo "$(YELLOW)Starting backend development server...$(NC)" + @cd $(BACKEND_DIR)/cmd && ./backend + +dev-control-station: + @echo "$(YELLOW)Starting control-station development server...$(NC)" + @cd $(CONTROL_STATION_DIR) && npm run dev + +dev-ethernet-view: + @echo "$(YELLOW)Starting ethernet-view development server...$(NC)" + @cd $(ETHERNET_VIEW_DIR) && npm run dev + +# Testing +test: test-backend test-frontend + +test-backend: + @echo "$(BLUE)Running backend tests...$(NC)" + @cd $(BACKEND_DIR) && go test -v -timeout 30s ./... + +test-frontend: + @echo "$(BLUE)Running frontend tests...$(NC)" + @cd $(ETHERNET_VIEW_DIR) && npm test || true + @echo "$(YELLOW)Note: Only ethernet-view has tests configured$(NC)" + +# Clean build artifacts +clean: + @echo "$(YELLOW)Cleaning build artifacts...$(NC)" + @rm -f $(BACKEND_BIN) + @rm -rf $(COMMON_FRONT_DIR)/dist + @rm -rf $(CONTROL_STATION_DIR)/dist + @rm -rf $(ETHERNET_VIEW_DIR)/dist + @echo "$(GREEN)โœ“ Clean complete$(NC)" + +# Combined tmux sessions +ethernet-view-tmux: build-backend + @echo "$(BLUE)Starting backend + ethernet-view in tmux...$(NC)" + @tmux new-session -d -s ethernet-view-session -n main + @tmux send-keys -t ethernet-view-session:main "cd $(BACKEND_DIR)/cmd && ./backend" C-m + @tmux split-window -t ethernet-view-session:main -h + @tmux send-keys -t ethernet-view-session:main.1 "cd $(ETHERNET_VIEW_DIR) && npm run dev" C-m + @tmux select-pane -t ethernet-view-session:main.0 + @tmux attach-session -t ethernet-view-session + +control-station-tmux: build-backend + @echo "$(BLUE)Starting backend + control-station in tmux...$(NC)" + @tmux new-session -d -s control-station-session -n main + @tmux send-keys -t control-station-session:main "cd $(BACKEND_DIR)/cmd && ./backend" C-m + @tmux split-window -t control-station-session:main -h + @tmux send-keys -t control-station-session:main.1 "cd $(CONTROL_STATION_DIR) && npm run dev" C-m + @tmux select-pane -t control-station-session:main.0 + @tmux attach-session -t control-station-session + +# Help target +help: + @echo "Hyperloop H10 Control Station - Build System" + @echo "===========================================" + @echo "" + @echo "$(YELLOW)Installation:$(NC)" + @echo " make install - Install all dependencies" + @echo " make install-backend - Install backend dependencies only" + @echo " make install-frontend - Install frontend dependencies only" + @echo "" + @echo "$(YELLOW)Building:$(NC)" + @echo " make all - Install deps and build everything" + @echo " make build - Build all components" + @echo " make backend - Build backend only" + @echo " make common-front - Build common frontend library" + @echo " make control-station - Build control station" + @echo " make ethernet-view - Build ethernet view" + @echo "" + @echo "$(YELLOW)Development:$(NC)" + @echo " make dev-backend - Run backend dev server" + @echo " make dev-control-station - Run control station dev server" + @echo " make dev-ethernet-view - Run ethernet view dev server" + @echo "" + @echo "$(YELLOW)Combined Sessions:$(NC)" + @echo " make ethernet-view-tmux - Run backend + ethernet-view in tmux" + @echo " make control-station-tmux - Run backend + control-station in tmux" + @echo "" + @echo "$(YELLOW)Testing:$(NC)" + @echo " make test - Run all tests" + @echo " make test-backend - Run backend tests" + @echo " make test-frontend - Run frontend tests" + @echo "" + @echo "$(YELLOW)Maintenance:$(NC)" + @echo " make clean - Remove build artifacts" + @echo " make help - Show this help message" \ No newline at end of file diff --git a/README.md b/README.md index 35c0aeffe..cb00ad923 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,45 @@ Hyperloop UPV's Control Station is a unified software solution for real-time monitoring and commanding of the pod. It combines a back-end (Go) that ingests and interprets sensor dataโ€“defined via the JSON-based "ADJ" specificationsโ€“and a front-end (Typescript/React) that displays metrics, logs, and diagnostics to operators. With features like packet parsing, logging, and live dashboards, it acts as the central hub to safely interface the pod, making it easier for team members to oversee performance, detect faults, and send precise orders to the vehicle. -### Installation - user +## Quick Start -Download the last release, unzip it and leave the executable compatible with your OS in the folder. +### For Users -### Installation - dev +Download the latest release, unzip it and run the executable compatible with your OS. -Clone the repository, execute `npm i` at `ethernet-view`, `control-station` and `common-front`. Then run `npm run build` in `common-front`. +### For Developers -### Usage +See our comprehensive [Documentation](./docs/README.md) or jump to [Getting Started](./docs/guides/getting-started.md). Quick start: + +```bash +# Clone and setup +git clone https://github.com/HyperloopUPV-H8/software.git +cd software +./scripts/dev.sh setup + +# Run services +./scripts/dev.sh backend # Backend server +./scripts/dev.sh ethernet # Ethernet view +./scripts/dev.sh control # Control station +``` + +## Configuration When using the Control Station make sure that you have configured your IP as the one specified in the ADJโ€”usually `192.168.0.9`. Then make sure to configure the boards you'll be making use of in the `config.toml` (at the top of the file you'll be able to see the `vehicle/boards` option, just add or remove the boards as needed following the format specified in the ADJ. To change the ADJ branch from `main`, change the option `adj/branch` at the end of the `config.toml` with the name of the branch you want to use or leave it blank if you'll be making use of a custom ADJ. -### Developing +## Documentation -The main project file is inside `backend/cmd`. Ensure you have the proper `config.toml` configuration and are in the `develop` branch. To build the project, just run `go build` inside the folder. With everything set up, execute the `cmd` executable, then move to `ethernet-view` and run `npm run dev`, then to the `control-station` and do the same. +๐Ÿ“š **[Complete Documentation](./docs/README.md)** - All guides and references -If you want to test the app in your local enviorement make use of the locahostโ€”make sure to configure the full range of `127.0.0.X`. The `software` ADJ branch is recommended but you can create your own or use a local branch, in such case make sure to leave blank the `adj/branch` option in the `config.toml`. +### Quick Links +- ๐Ÿš€ **[Getting Started](./docs/guides/getting-started.md)** - New user guide +- ๐Ÿ› ๏ธ **[Development Setup](./docs/development/DEVELOPMENT.md)** - Developer environment setup +- ๐Ÿ—๏ธ **[Architecture](./docs/architecture/README.md)** - System design overview +- ๐Ÿ”ง **[Troubleshooting](./docs/troubleshooting/BLCU_FIX_SUMMARY.md)** - Common issues and fixes -### Contributing +## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for ways to contribute to the Control Station. diff --git a/backend/.gitignore b/backend/.gitignore index 3c5a917a1..dc6dbb27a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,6 +1,3 @@ -# GOOGLE API KEY -secret.json - log trace.json @@ -24,3 +21,5 @@ static downloads audience_static + +cmd/adj/ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 000000000..7c86595f5 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,14 @@ +FROM golang:1.21-alpine + +RUN apk add --no-cache gcc musl-dev libpcap-dev pkgconfig + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +EXPOSE 8080 + +CMD ["sh", "-c", "cd cmd && go run ."] \ No newline at end of file diff --git a/backend/cmd/adj b/backend/cmd/adj deleted file mode 160000 index 4c320a720..000000000 --- a/backend/cmd/adj +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4c320a720a191ac4cb94e8952a6d75549dfa6101 diff --git a/backend/cmd/config.go b/backend/cmd/config.go index 9aeb4208b..4cf7ac6d2 100644 --- a/backend/cmd/config.go +++ b/backend/cmd/config.go @@ -5,23 +5,54 @@ import ( "github.com/HyperloopUPV-H8/h9-backend/internal/vehicle" ) +type App struct { + AutomaticWindowOpening bool `toml:"automatic_window_opening"` +} + type Adj struct { - Branch string - Test bool + Branch string `toml:"branch"` + Test bool `toml:"test"` } type Network struct { - Manual bool + Manual bool `toml:"manual"` } type Transport struct { - PropagateFault bool + PropagateFault bool `toml:"propagate_fault"` +} + +type TFTP struct { + BlockSize int `toml:"block_size"` + Retries int `toml:"retries"` + TimeoutMs int `toml:"timeout_ms"` + BackoffFactor int `toml:"backoff_factor"` + EnableProgress bool `toml:"enable_progress"` +} + +type Blcu struct { + IP string `toml:"ip"` + DownloadOrderId uint16 `toml:"download_order_id"` + UploadOrderId uint16 `toml:"upload_order_id"` +} + +type TCP struct { + BackoffMinMs int `toml:"backoff_min_ms"` + BackoffMaxMs int `toml:"backoff_max_ms"` + BackoffMultiplier float64 `toml:"backoff_multiplier"` + MaxRetries int `toml:"max_retries"` + ConnectionTimeout int `toml:"connection_timeout_ms"` + KeepAlive int `toml:"keep_alive_ms"` } type Config struct { + App App Vehicle vehicle.Config Server server.Config Adj Adj Network Network Transport Transport + TFTP TFTP + TCP TCP + Blcu Blcu } diff --git a/backend/cmd/config.toml b/backend/cmd/config.toml index 7ac70bf6f..093ae8b33 100644 --- a/backend/cmd/config.toml +++ b/backend/cmd/config.toml @@ -1,27 +1,78 @@ +# Hyperloop UPV Backend Configuration +# Configuration file for the H10 Control Station backend server + +# <-- CHECKLIST --> +# 1. Check that all the boards you want to use are declared in the [vehicle] section +# 2. Set the branch you want to use for the ADJ configuration +# 3. Toggle the Fault Propagation to your needs (treu/false) +# 4. Check the TCP configuration and make sure to use the needed Keep Alive settings + +# Control Station general configuration +[app] +automatic_window_opening = "both" # Leave blank to open no windows (, ethernet-view, control-station, both) + +# Vehicle Configuration [vehicle] -boards = ["HVSCU", "PCU"] +boards = ["HVSCU", "PCU", "BLCU"] + +# ADJ (Architecture Description JSON) Configuration +[adj] +branch = "main" # Leave blank when using ADJ as a submodule (like this: "") +test = true # Enable test mode + +# Network Configuration +[network] +manual = false # Manual network device selection + +# Transport Configuration +[transport] +propagate_fault = true + +# TCP Configuration +# These settings control how the backend reconnects to boards when connections are lost +[tcp] +backoff_min_ms = 100 # Minimum backoff duration in milliseconds +backoff_max_ms = 5000 # Maximum backoff duration in milliseconds +backoff_multiplier = 1.5 # Exponential backoff multiplier (e.g., 1.5 means each retry waits 1.5x longer) +max_retries = 0 # Maximum retries before cycling (0 = infinite retries, recommended for persistent reconnection) +connection_timeout_ms = 1000 # Connection timeout in milliseconds +keep_alive_ms = 1000 # Keep-alive interval in milliseconds + +# BLCU (Boot Loader Control Unit) Configuration +[blcu] +ip = "127.0.0.1" # TFTP server IP address +download_order_id = 0 # Packet ID for download orders (0 = use default) +upload_order_id = 0 # Packet ID for upload orders (0 = use default) + +# TFTP Configuration +[tftp] +block_size = 131072 # TFTP block size in bytes (128kB) +retries = 3 # Maximum number of retries before aborting transfer +timeout_ms = 5000 # Timeout between retries in milliseconds +backoff_factor = 2 # Backoff multiplier for retry delays +enable_progress = true # Enable progress callbacks during transfers + +# <-- DO NOT TOUCH BELOW THIS LINE --> + +# Server Configuration [server.ethernet-view] address = "127.0.0.1:4040" static = "./ethernet-view" + [server.ethernet-view.endpoints] pod_data = "/podDataStructure" order_data = "/orderStructures" programable_boards = "/uploadableBoards" connections = "/backend" files = "/" + [server.control-station] address = "127.0.0.1:4000" static = "./control-station" + [server.control-station.endpoints] pod_data = "/podDataStructure" order_data = "/orderStructures" programable_boards = "/uploadableBoards" connections = "/backend" files = "/" -[adj] -branch = "main" # Leave blank when using ADJ as a submodule (like this: "") -test = true -[network] -manual = false -[transport] -propagate_fault = true diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 944ecf952..247ecb6ad 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -32,6 +32,7 @@ import ( "github.com/HyperloopUPV-H8/h9-backend/internal/utils" vehicle_models "github.com/HyperloopUPV-H8/h9-backend/internal/vehicle/models" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" "github.com/HyperloopUPV-H8/h9-backend/pkg/broker" blcu_topics "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/blcu" connection_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/connection" @@ -66,7 +67,7 @@ import ( const ( BACKEND = "backend" - BLCU = "blcu" + BLCU = "BLCU" TcpClient = "TCP_CLIENT" TcpServer = "TCP_SERVER" UDP = "UDP" @@ -86,21 +87,7 @@ var playbackFile = flag.String("playback", "", "") var currentVersion string func main() { - - versionFile := "VERSION.txt" - versionData, err := os.ReadFile(versionFile) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading version file (%s): %v\n", versionFile, err) - os.Exit(1) - } - currentVersion = strings.TrimSpace(string(versionData)) - - versionFlag := flag.Bool("version", false, "Show the backend version") - flag.Parse() - if *versionFlag { - fmt.Println("Hyperloop UPV Backend Version:", currentVersion) - os.Exit(0) - } + // update() // FIXME: Updater disabled due to cross-platform and reliability issues traceFile := initTrace(*traceLevel, *traceFile) defer traceFile.Close() @@ -122,105 +109,6 @@ func main() { runtime.SetBlockProfileRate(*blockprofile) config := getConfig("./config.toml") - latestVersionStr, err := getLatestVersionFromGitHub() - if err != nil { - fmt.Println("Warning:", err) - fmt.Println("Skipping version check. Proceeding with the current version:", currentVersion) - } else { - current, err := version.NewVersion(currentVersion) - if err != nil { - fmt.Println("Invalid current version:", err) - return - } - - latest, err := version.NewVersion(latestVersionStr) - if err != nil { - fmt.Println("Invalid latest version:", err) - return - } - - if latest.GreaterThan(current) { - fmt.Printf("There is a new version available: %s (current version: %s)\n", latest, current) - fmt.Print("Do you want to update? (y/n): ") - - var response string - fmt.Scanln(&response) - - if strings.ToLower(response) == "y" { - fmt.Println("Launching updater to update the backend...") - - // Get the directory of the current executable - execPath, err := os.Executable() - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting executable path: %v\n", err) - os.Exit(1) - } - execDir := filepath.Dir(execPath) - - backendPath := filepath.Join(execDir, "..", "..", "backend") - - if _, err := os.Stat(backendPath); err == nil { - - fmt.Println("Backend folder detected. Building and launching updater...") - - updaterPath := filepath.Join(execDir, "..", "..", "updater") - - cmd := exec.Command("go", "build", "-o", filepath.Join(updaterPath, "updater.exe"), updaterPath) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error building updater: %v\n", err) - os.Exit(1) - } - - updaterExe := filepath.Join(updaterPath, "updater.exe") - cmd = exec.Command(updaterExe) - cmd.Dir = updaterPath - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error launching updater: %v\n", err) - os.Exit(1) - } - } else { - - fmt.Println("Backend folder not detected. Launching existing updater...") - - execPath, err := os.Executable() - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting executable path: %v\n", err) - os.Exit(1) - } - execDir := filepath.Dir(execPath) - - updaterExe := filepath.Join(execDir, "updater") - // En Windows el ejecutable lleva extensiรณn .exe - if runtime.GOOS == "windows" { - updaterExe += ".exe" - } - - if _, err := os.Stat(updaterExe); err == nil { - cmd := exec.Command(updaterExe) - cmd.Dir = execDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error launching updater: %v\n", err) - os.Exit(1) - } - } else { - fmt.Fprintf(os.Stderr, "Updater not found: %s\n", updaterExe) - fmt.Println("Skipping update. Proceeding with the current version.") - } - } - - } else { - fmt.Println("Skipping update. Proceeding with the current version.") - } - } else { - fmt.Printf("You are using the latest version: %s\n", current) - } - } // <--- ADJ ---> @@ -332,6 +220,39 @@ func main() { vehicle.SetIdToBoardName(idToBoard) vehicle.SetTransport(transp) + // <--- BLCU Board ---> + // Register BLCU board for handling bootloader operations + if blcuIP, exists := adj.Info.Addresses[BLCU]; exists { + blcuId, idExists := adj.Info.BoardIds["BLCU"] + if !idExists { + trace.Error().Msg("BLCU IP found in ADJ but board ID missing") + } else { + // Get configurable order IDs or use defaults + downloadOrderId := config.Blcu.DownloadOrderId + uploadOrderId := config.Blcu.UploadOrderId + if downloadOrderId == 0 { + downloadOrderId = boards.DefaultBlcuDownloadOrderId + } + if uploadOrderId == 0 { + uploadOrderId = boards.DefaultBlcuUploadOrderId + } + + tftpConfig := boards.TFTPConfig{ + BlockSize: config.TFTP.BlockSize, + Retries: config.TFTP.Retries, + TimeoutMs: config.TFTP.TimeoutMs, + BackoffFactor: config.TFTP.BackoffFactor, + EnableProgress: config.TFTP.EnableProgress, + } + blcuBoard := boards.NewWithConfig(blcuIP, tftpConfig, abstraction.BoardId(blcuId), downloadOrderId, uploadOrderId) + vehicle.AddBoard(blcuBoard) + vehicle.SetBlcuId(abstraction.BoardId(blcuId)) + trace.Info().Str("ip", blcuIP).Int("id", int(blcuId)).Uint16("download_order_id", downloadOrderId).Uint16("upload_order_id", uploadOrderId).Msg("BLCU board registered") + } + } else { + trace.Warn().Msg("BLCU not found in ADJ configuration - bootloader operations unavailable") + } + // <--- transport ---> // Load and set packet decoder and encoder decoder, encoder := getTransportDecEnc(adj.Info, podData) @@ -345,6 +266,33 @@ func main() { transp.SetTargetIp(adj.Info.Addresses[board.Name], abstraction.TransportTarget(board.Name)) } + // Set BLCU packet ID mappings if BLCU is configured + if common.Contains(config.Vehicle.Boards, "BLCU") { + // Use configurable packet IDs or defaults + downloadOrderId := config.Blcu.DownloadOrderId + uploadOrderId := config.Blcu.UploadOrderId + if downloadOrderId == 0 { + downloadOrderId = boards.DefaultBlcuDownloadOrderId + } + if uploadOrderId == 0 { + uploadOrderId = boards.DefaultBlcuUploadOrderId + } + + transp.SetIdTarget(abstraction.PacketId(downloadOrderId), abstraction.TransportTarget("BLCU")) + transp.SetIdTarget(abstraction.PacketId(uploadOrderId), abstraction.TransportTarget("BLCU")) + + // Use BLCU address from config, ADJ, or default + blcuIP := config.Blcu.IP + if blcuIP == "" { + if adjBlcuIP, exists := adj.Info.Addresses[BLCU]; exists { + blcuIP = adjBlcuIP + } else { + blcuIP = "127.0.0.1" + } + } + transp.SetTargetIp(blcuIP, abstraction.TransportTarget("BLCU")) + } + // Start handling TCP client connections i := 0 serverTargets := make(map[string]abstraction.TransportTarget) @@ -357,7 +305,42 @@ func main() { if err != nil { panic("Failed to resolve local backend TCP client address") } - go transp.HandleClient(tcp.NewClientConfig(backendTcpClientAddr), fmt.Sprintf("%s:%d", adj.Info.Addresses[board.Name], adj.Info.Ports[TcpServer])) + // Create TCP client config with custom parameters from config + clientConfig := tcp.NewClientConfig(backendTcpClientAddr) + + // Apply custom timeout if specified + if config.TCP.ConnectionTimeout > 0 { + clientConfig.Timeout = time.Duration(config.TCP.ConnectionTimeout) * time.Millisecond + } + + // Apply custom keep-alive if specified + if config.TCP.KeepAlive > 0 { + clientConfig.KeepAlive = time.Duration(config.TCP.KeepAlive) * time.Millisecond + } + + // Apply custom backoff parameters + if config.TCP.BackoffMinMs > 0 || config.TCP.BackoffMaxMs > 0 || config.TCP.BackoffMultiplier > 0 { + minBackoff := 100 * time.Millisecond // default + maxBackoff := 5 * time.Second // default + multiplier := 1.5 // default + + if config.TCP.BackoffMinMs > 0 { + minBackoff = time.Duration(config.TCP.BackoffMinMs) * time.Millisecond + } + if config.TCP.BackoffMaxMs > 0 { + maxBackoff = time.Duration(config.TCP.BackoffMaxMs) * time.Millisecond + } + if config.TCP.BackoffMultiplier > 0 { + multiplier = config.TCP.BackoffMultiplier + } + + clientConfig.ConnectionBackoffFunction = tcp.NewExponentialBackoff(minBackoff, multiplier, maxBackoff) + } + + // Apply max retries (0 or negative means infinite) + clientConfig.MaxConnectionRetries = config.TCP.MaxRetries + + go transp.HandleClient(clientConfig, fmt.Sprintf("%s:%d", adj.Info.Addresses[board.Name], adj.Info.Ports[TcpServer])) i++ } @@ -449,8 +432,11 @@ func main() { }() } - browser.OpenURL("http://" + config.Server["ethernet-view"].Addr) - browser.OpenURL("http://" + config.Server["control-station"].Addr) + // Open browser tabs + if config.App.AutomaticWindowOpening { + browser.OpenURL("http://" + config.Server["ethernet-view"].Addr) + browser.OpenURL("http://" + config.Server["control-station"].Addr) + } interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt) @@ -734,15 +720,105 @@ func getLatestVersionFromGitHub() (string, error) { version := strings.TrimPrefix(release.TagName, "v") return version, nil } -func detectOS() string { - switch runtime.GOOS { - case "windows": - return "updater.exe" - case "darwin", "linux": - return "updater" - default: - fmt.Fprintf(os.Stderr, "Unsupported operating system: %s\n", runtime.GOOS) + +// FIXME: Updater system disabled due to multiple critical issues +// See GitHub issue for full details on problems and proposed solutions +func update() { + versionFile := "VERSION.txt" + versionData, err := os.ReadFile(versionFile) + if err == nil { + currentVersion = strings.TrimSpace(string(versionData)) + + versionFlag := flag.Bool("version", false, "Show the backend version") + flag.Parse() + if *versionFlag { + fmt.Println("Hyperloop UPV Backend Version:", currentVersion) + os.Exit(0) + } + } else { + fmt.Fprintf(os.Stderr, "Error reading version file (%s): %v\n", versionFile, err) + return + } + + execPath, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting executable path: %v\n", err) os.Exit(1) - return "" } + + execDir := filepath.Dir(execPath) + + latestVersionStr, latestErr := getLatestVersionFromGitHub() + backendPath := filepath.Join(execDir, "..", "..", "backend") + _, statErr := os.Stat(backendPath) + backendExists := statErr == nil + + if backendExists { + fmt.Println("Backend folder detected.") + fmt.Print("Do you want to update? (y/n): ") + var response string + fmt.Scanln(&response) + if strings.ToLower(response) == "y" { + fmt.Println("Launching updater to update the backend...") + updaterPath := filepath.Join(execDir, "..", "..", "updater") + cmd := exec.Command("go", "build", "-o", filepath.Join(updaterPath, "updater.exe"), updaterPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error building updater: %v\n", err) + os.Exit(1) + } + updaterExe := filepath.Join(updaterPath, "updater.exe") + cmd = exec.Command(updaterExe) + cmd.Dir = updaterPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error launching updater: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } else { + fmt.Println("Skipping update. Proceeding with the current version.") + } + } else { + // Solo updatear si se tienen ambas versiones y latest > current + current, currErr := version.NewVersion(currentVersion) + latest, lastErr := version.NewVersion(latestVersionStr) + if currErr != nil || lastErr != nil || latestErr != nil { + fmt.Println("Warning: Could not determine versions. Skipping update. Proceeding with the current version:", currentVersion) + } else if latest.GreaterThan(current) { + fmt.Printf("There is a new version available: %s (current version: %s)\n", latest, current) + fmt.Print("Do you want to update? (y/n): ") + var response string + fmt.Scanln(&response) + if strings.ToLower(response) == "y" { + fmt.Println("Launching updater to update the backend...") + updaterExe := filepath.Join(execDir, "updater") + if runtime.GOOS == "windows" { + updaterExe += ".exe" + } + if _, err := os.Stat(updaterExe); err == nil { + cmd := exec.Command(updaterExe) + cmd.Dir = execDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error launching updater: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } else { + fmt.Fprintf(os.Stderr, "Updater not found: %s\n", updaterExe) + fmt.Println("Skipping update. Proceeding with the current version.") + } + } else { + fmt.Println("Skipping update. Proceeding with the current version.") + } + } else { + fmt.Printf("You are using the latest version: %s\n", current) + } + } + + return } diff --git a/backend/go.mod b/backend/go.mod index 6eb92f224..b8dcb8938 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/HyperloopUPV-H8/h9-backend -go 1.21.3 +go 1.23.0 require ( github.com/go-git/go-git/v5 v5.12.0 @@ -13,6 +13,7 @@ require ( github.com/pin/tftp/v3 v3.0.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/rs/zerolog v1.29.0 + github.com/stretchr/testify v1.9.0 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 ) @@ -22,6 +23,7 @@ require ( github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect @@ -32,17 +34,19 @@ require ( github.com/mattn/go-isatty v0.0.17 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.2.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.21.0 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/mod v0.12.0 // indirect - golang.org/x/sys v0.18.0 // indirect + golang.org/x/sys v0.31.0 // indirect golang.org/x/tools v0.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( github.com/fatih/color v1.15.0 - golang.org/x/net v0.22.0 // indirect + golang.org/x/net v0.38.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 16dca18a7..c9928069c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -111,8 +111,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -130,8 +130,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -155,15 +155,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -171,8 +171,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= diff --git a/backend/internal/adj/adj.go b/backend/internal/adj/adj.go index b643073b8..5ee4ef0ba 100644 --- a/backend/internal/adj/adj.go +++ b/backend/internal/adj/adj.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/exec" + "runtime" "github.com/HyperloopUPV-H8/h9-backend/internal/utils" ) @@ -72,12 +73,37 @@ func NewADJ(AdjBranch string, test bool) (ADJ, error) { func downloadADJ(AdjBranch string, test bool) (json.RawMessage, json.RawMessage, error) { updateRepo(AdjBranch) - //Execute the script testadj.py if indicated in config.toml + //Execute the testadj executable if indicated in config.toml if test { - test := exec.Command("python3", "testadj.py") - out, err := test.CombinedOutput() - if err != nil || len(out) != 0 { - log.Fatalf("python test failed:\nError: %v\nOutput: %s\n", err, string(out)) + // Try to find the testadj executable + testadj := "./testadj" + if _, err := os.Stat(testadj); os.IsNotExist(err) { + // If not found in current directory, try with extension for Windows + if runtime.GOOS == "windows" { + testadj = "./testadj.exe" + } + // If still not found, fall back to Python script + if _, err := os.Stat(testadj); os.IsNotExist(err) { + test := exec.Command("python3", "testadj.py") + out, err := test.CombinedOutput() + if err != nil || len(out) != 0 { + log.Fatalf("python test failed:\nError: %v\nOutput: %s\n", err, string(out)) + } + } else { + // Execute the testadj executable + test := exec.Command(testadj) + out, err := test.CombinedOutput() + if err != nil || len(out) != 0 { + log.Fatalf("testadj executable failed:\nError: %v\nOutput: %s\n", err, string(out)) + } + } + } else { + // Execute the testadj executable + test := exec.Command(testadj) + out, err := test.CombinedOutput() + if err != nil || len(out) != 0 { + log.Fatalf("testadj executable failed:\nError: %v\nOutput: %s\n", err, string(out)) + } } } diff --git a/backend/pkg/adj/adj.go b/backend/pkg/adj/adj.go index b643073b8..5ee4ef0ba 100644 --- a/backend/pkg/adj/adj.go +++ b/backend/pkg/adj/adj.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/exec" + "runtime" "github.com/HyperloopUPV-H8/h9-backend/internal/utils" ) @@ -72,12 +73,37 @@ func NewADJ(AdjBranch string, test bool) (ADJ, error) { func downloadADJ(AdjBranch string, test bool) (json.RawMessage, json.RawMessage, error) { updateRepo(AdjBranch) - //Execute the script testadj.py if indicated in config.toml + //Execute the testadj executable if indicated in config.toml if test { - test := exec.Command("python3", "testadj.py") - out, err := test.CombinedOutput() - if err != nil || len(out) != 0 { - log.Fatalf("python test failed:\nError: %v\nOutput: %s\n", err, string(out)) + // Try to find the testadj executable + testadj := "./testadj" + if _, err := os.Stat(testadj); os.IsNotExist(err) { + // If not found in current directory, try with extension for Windows + if runtime.GOOS == "windows" { + testadj = "./testadj.exe" + } + // If still not found, fall back to Python script + if _, err := os.Stat(testadj); os.IsNotExist(err) { + test := exec.Command("python3", "testadj.py") + out, err := test.CombinedOutput() + if err != nil || len(out) != 0 { + log.Fatalf("python test failed:\nError: %v\nOutput: %s\n", err, string(out)) + } + } else { + // Execute the testadj executable + test := exec.Command(testadj) + out, err := test.CombinedOutput() + if err != nil || len(out) != 0 { + log.Fatalf("testadj executable failed:\nError: %v\nOutput: %s\n", err, string(out)) + } + } + } else { + // Execute the testadj executable + test := exec.Command(testadj) + out, err := test.CombinedOutput() + if err != nil || len(out) != 0 { + log.Fatalf("testadj executable failed:\nError: %v\nOutput: %s\n", err, string(out)) + } } } diff --git a/backend/pkg/boards/blcu.go b/backend/pkg/boards/blcu.go index 7d9cb66c3..9c4534404 100644 --- a/backend/pkg/boards/blcu.go +++ b/backend/pkg/boards/blcu.go @@ -3,57 +3,98 @@ package boards import ( "bytes" "fmt" + "time" + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tftp" dataPacket "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" - "time" ) -// TODO! Get from ADE const ( BlcuName = "BLCU" - BlcuId = abstraction.BoardId(1) - AckId = "1" + AckId = abstraction.BoardEvent("ACK") + DownloadEventId = abstraction.BoardEvent("DOWNLOAD") + UploadEventId = abstraction.BoardEvent("UPLOAD") - BlcuDownloadOrderId = 1 - BlcuUploadOrderId = 2 + // Default order IDs - can be overridden via config.toml + DefaultBlcuDownloadOrderId = 701 + DefaultBlcuUploadOrderId = 700 ) +type TFTPConfig struct { + BlockSize int + Retries int + TimeoutMs int + BackoffFactor int + EnableProgress bool +} + type BLCU struct { - api abstraction.BoardAPI - ackChan chan struct{} - ip string + api abstraction.BoardAPI + ackChan chan struct{} + ip string + tftpConfig TFTPConfig + id abstraction.BoardId + downloadOrderId uint16 + uploadOrderId uint16 } +// Deprecated: Use NewWithConfig with proper board ID and order IDs from configuration func New(ip string) *BLCU { + return NewWithTFTPConfig(ip, TFTPConfig{ + BlockSize: 131072, // 128kB + Retries: 3, + TimeoutMs: 5000, + BackoffFactor: 2, + EnableProgress: true, + }, 0) // Board ID 0 indicates missing configuration +} + +// Deprecated: Use NewWithConfig for proper order ID configuration +func NewWithTFTPConfig(ip string, tftpConfig TFTPConfig, id abstraction.BoardId) *BLCU { + return &BLCU{ + ackChan: make(chan struct{}), + ip: ip, + tftpConfig: tftpConfig, + id: id, + downloadOrderId: DefaultBlcuDownloadOrderId, + uploadOrderId: DefaultBlcuUploadOrderId, + } +} + +func NewWithConfig(ip string, tftpConfig TFTPConfig, id abstraction.BoardId, downloadOrderId, uploadOrderId uint16) *BLCU { return &BLCU{ - ackChan: make(chan struct{}), - ip: ip, + ackChan: make(chan struct{}), + ip: ip, + tftpConfig: tftpConfig, + id: id, + downloadOrderId: downloadOrderId, + uploadOrderId: uploadOrderId, } } -func (boards *BLCU) Id() abstraction.BoardId { - return BlcuId +func (board *BLCU) Id() abstraction.BoardId { + return board.id } func (boards *BLCU) Notify(boardNotification abstraction.BoardNotification) { switch notification := boardNotification.(type) { - case AckNotification: + case *AckNotification: boards.ackChan <- struct{}{} - case DownloadEvent: - err := boards.download(notification) + case *DownloadEvent: + err := boards.download(*notification) if err != nil { fmt.Println(ErrDownloadFailure{ Timestamp: time.Now(), Inner: err, }.Error()) } - case UploadEvent: - err := boards.upload(notification) + case *UploadEvent: + err := boards.upload(*notification) if err != nil { - fmt.Println(ErrDownloadFailure{ + fmt.Println(ErrUploadFailure{ Timestamp: time.Now(), Inner: err, }.Error()) @@ -73,7 +114,7 @@ func (boards *BLCU) SetAPI(api abstraction.BoardAPI) { func (boards *BLCU) download(notification DownloadEvent) error { // Notify the BLCU ping := dataPacket.NewPacketWithValues( - abstraction.PacketId(BlcuDownloadOrderId), + abstraction.PacketId(boards.downloadOrderId), map[dataPacket.ValueName]dataPacket.Value{ BlcuName: dataPacket.NewEnumValue(dataPacket.EnumVariant(notification.Board)), }, @@ -94,7 +135,11 @@ func (boards *BLCU) download(notification DownloadEvent) error { // TODO! Notify on progress - client, err := tftp.NewClient(boards.ip) + client, err := tftp.NewClient(boards.ip, + tftp.WithBlockSize(boards.tftpConfig.BlockSize), + tftp.WithRetries(boards.tftpConfig.Retries), + tftp.WithTimeout(time.Duration(boards.tftpConfig.TimeoutMs)*time.Millisecond), + ) if err != nil { return ErrNewClientFailed{ Addr: boards.ip, @@ -142,7 +187,7 @@ func (boards *BLCU) download(notification DownloadEvent) error { } func (boards *BLCU) upload(notification UploadEvent) error { - ping := dataPacket.NewPacketWithValues(abstraction.PacketId(BlcuUploadOrderId), + ping := dataPacket.NewPacketWithValues(abstraction.PacketId(boards.uploadOrderId), map[dataPacket.ValueName]dataPacket.Value{ BlcuName: dataPacket.NewEnumValue(dataPacket.EnumVariant(notification.Board)), }, @@ -162,7 +207,11 @@ func (boards *BLCU) upload(notification UploadEvent) error { // TODO! Notify on progress - client, err := tftp.NewClient(boards.ip) + client, err := tftp.NewClient(boards.ip, + tftp.WithBlockSize(boards.tftpConfig.BlockSize), + tftp.WithRetries(boards.tftpConfig.Retries), + tftp.WithTimeout(time.Duration(boards.tftpConfig.TimeoutMs)*time.Millisecond), + ) if err != nil { return ErrNewClientFailed{ Addr: boards.ip, diff --git a/backend/pkg/boards/blcu_integration_test.go b/backend/pkg/boards/blcu_integration_test.go new file mode 100644 index 000000000..6586c88c2 --- /dev/null +++ b/backend/pkg/boards/blcu_integration_test.go @@ -0,0 +1,284 @@ +package boards_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" + "github.com/HyperloopUPV-H8/h9-backend/pkg/broker" + blcu_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/blcu" + "github.com/HyperloopUPV-H8/h9-backend/pkg/vehicle" + "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" + "github.com/rs/zerolog" +) + +// MockTransport implements abstraction.Transport for testing +type MockTransport struct { + sentMessages []abstraction.TransportMessage +} + +func (m *MockTransport) SendMessage(msg abstraction.TransportMessage) error { + m.sentMessages = append(m.sentMessages, msg) + return nil +} + +func (m *MockTransport) HandleClient(config interface{}, target string) error { + return nil +} + +func (m *MockTransport) HandleServer(config interface{}, addr string) error { + return nil +} + +func (m *MockTransport) HandleSniffer(sniffer interface{}) error { + return nil +} + +func (m *MockTransport) SetAPI(api abstraction.TransportAPI) {} + +func (m *MockTransport) SetIdTarget(id abstraction.PacketId, target abstraction.TransportTarget) {} + +func (m *MockTransport) SetTargetIp(ip string, target abstraction.TransportTarget) {} + +func (m *MockTransport) SetpropagateFault(propagate bool) {} + +func (m *MockTransport) WithDecoder(decoder interface{}) abstraction.Transport { + return m +} + +func (m *MockTransport) WithEncoder(encoder interface{}) abstraction.Transport { + return m +} + +// MockLogger implements abstraction.Logger for testing +type MockLogger struct{} + +func (m *MockLogger) Start() error { + return nil +} + +func (m *MockLogger) Stop() error { + return nil +} + +func (m *MockLogger) PushRecord(record abstraction.LoggerRecord) error { + return nil +} + +func (m *MockLogger) PullRecord(request abstraction.LoggerRequest) (abstraction.LoggerRecord, error) { + return nil, nil +} + +// TestBLCUDownloadOrder tests the BLCU download order flow +func TestBLCUDownloadOrder(t *testing.T) { + // Setup + logger := zerolog.New(nil).Level(zerolog.Disabled) + + // Create vehicle + v := vehicle.New(logger) + + // Create and setup broker + b := broker.New(logger) + connections := make(chan *websocket.Client) + pool := websocket.NewPool(connections, logger) + b.SetPool(pool) + + // Register BLCU topics + blcu_topic.RegisterTopics(b, pool) + + // Set broker and transport + v.SetBroker(b) + mockTransport := &MockTransport{} + v.SetTransport(mockTransport) + mockLogger := &MockLogger{} + v.SetLogger(mockLogger) + + // Create BLCU board + blcuBoard := boards.New("192.168.0.10") // Example IP + + // This is the missing step - register the BLCU board with the vehicle + v.AddBoard(blcuBoard) + + // Note: In a real scenario, we would capture responses through the broker + + // Test download request + t.Run("Download Request", func(t *testing.T) { + downloadRequest := &blcu_topic.DownloadRequest{ + Board: "VCU", + } + + // Send download request through UserPush + err := v.UserPush(downloadRequest) + if err != nil { + t.Fatalf("UserPush failed: %v", err) + } + + // Simulate ACK from board + blcuBoard.Notify(boards.AckNotification{ + ID: boards.AckId, + }) + + // Check if the download order was sent to the board + if len(mockTransport.sentMessages) == 0 { + t.Fatal("No message sent to transport") + } + + // Verify the packet sent contains the correct order ID + // In a real test, we would decode the packet and verify its contents + }) +} + +// TestBLCUUploadOrder tests the BLCU upload order flow +func TestBLCUUploadOrder(t *testing.T) { + // Setup + logger := zerolog.New(nil).Level(zerolog.Disabled) + + // Create vehicle + v := vehicle.New(logger) + + // Create and setup broker + b := broker.New(logger) + connections := make(chan *websocket.Client) + pool := websocket.NewPool(connections, logger) + b.SetPool(pool) + + // Register BLCU topics + blcu_topic.RegisterTopics(b, pool) + + // Set broker and transport + v.SetBroker(b) + mockTransport := &MockTransport{} + v.SetTransport(mockTransport) + mockLogger := &MockLogger{} + v.SetLogger(mockLogger) + + // Create BLCU board + blcuBoard := boards.New("192.168.0.10") // Example IP + + // Register the BLCU board with the vehicle + v.AddBoard(blcuBoard) + + // Test upload request + t.Run("Upload Request", func(t *testing.T) { + // Using the internal request type that has Data field + uploadRequest := &blcu_topic.UploadRequestInternal{ + Board: "VCU", + Data: []byte("test firmware data"), + } + + // Send upload request through UserPush + err := v.UserPush(uploadRequest) + if err != nil { + t.Fatalf("UserPush failed: %v", err) + } + + // Simulate ACK from board + blcuBoard.Notify(boards.AckNotification{ + ID: boards.AckId, + }) + + // Check if the upload order was sent to the board + if len(mockTransport.sentMessages) == 0 { + t.Fatal("No message sent to transport") + } + }) +} + +// TestBLCUWebSocketFlow tests the complete WebSocket flow for BLCU orders +func TestBLCUWebSocketFlow(t *testing.T) { + // Setup + logger := zerolog.New(nil).Level(zerolog.Disabled) + + // Create vehicle + v := vehicle.New(logger) + + // Create and setup broker + b := broker.New(logger) + connections := make(chan *websocket.Client) + pool := websocket.NewPool(connections, logger) + b.SetPool(pool) + + // Register BLCU topics + blcu_topic.RegisterTopics(b, pool) + + // Set broker + v.SetBroker(b) + mockTransport := &MockTransport{} + v.SetTransport(mockTransport) + mockLogger := &MockLogger{} + v.SetLogger(mockLogger) + + // Create BLCU board + blcuBoard := boards.New("192.168.0.10") + v.AddBoard(blcuBoard) + + // Simulate WebSocket client message + t.Run("WebSocket Download Message", func(t *testing.T) { + // Get download topic handler from registered topics + downloadHandler := &blcu_topic.Download{} + downloadHandler.SetAPI(b) + downloadHandler.SetPool(pool) + + // Create WebSocket message + downloadReq := blcu_topic.DownloadRequest{ + Board: "VCU", + } + payload, _ := json.Marshal(downloadReq) + + wsMessage := &websocket.Message{ + Topic: blcu_topic.DownloadName, + Payload: payload, + } + + // Simulate client message + // Create a valid UUID for ClientId + clientUUID := [16]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + clientId := websocket.ClientId(clientUUID) + downloadHandler.ClientMessage(clientId, wsMessage) + + // Give some time for async operations + time.Sleep(100 * time.Millisecond) + + // Verify order was sent + if len(mockTransport.sentMessages) == 0 { + t.Error("No message sent to transport after WebSocket message") + } + }) +} + +// TestBLCURegistrationIssue demonstrates the issue when BLCU is not registered +func TestBLCURegistrationIssue(t *testing.T) { + // Setup WITHOUT registering BLCU board + logger := zerolog.New(nil).Level(zerolog.Disabled) + + v := vehicle.New(logger) + b := broker.New(logger) + connections := make(chan *websocket.Client) + pool := websocket.NewPool(connections, logger) + b.SetPool(pool) + blcu_topic.RegisterTopics(b, pool) + v.SetBroker(b) + + // Try to send download request without BLCU board registered + t.Run("Download Without Registration", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + // If no panic, check if the request was handled + // In the current implementation, this will fail silently + t.Log("Request handled without BLCU registration - this is the bug!") + } + }() + + downloadRequest := &blcu_topic.DownloadRequest{ + Board: "VCU", + } + + // This will fail because boards[boards.BlcuId] is nil + err := v.UserPush(downloadRequest) + if err == nil { + t.Log("UserPush succeeded but BLCU board notification will fail") + } + }) +} \ No newline at end of file diff --git a/backend/pkg/boards/blcu_simple_test.go b/backend/pkg/boards/blcu_simple_test.go new file mode 100644 index 000000000..7ff5633e8 --- /dev/null +++ b/backend/pkg/boards/blcu_simple_test.go @@ -0,0 +1,106 @@ +package boards_test + +import ( + "testing" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" + blcu_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/blcu" + "github.com/HyperloopUPV-H8/h9-backend/pkg/vehicle" + "github.com/rs/zerolog" +) + +// TestBLCUBoardRegistration tests that BLCU board can be registered with different configurations +func TestBLCUBoardRegistration(t *testing.T) { + logger := zerolog.New(nil).Level(zerolog.Disabled) + v := vehicle.New(logger) + + // Test deprecated constructor (should use board ID 0) + blcuBoard := boards.New("192.168.0.10") + v.AddBoard(blcuBoard) + + // Verify board is registered with ID 0 (missing configuration) + if blcuBoard.Id() != 0 { + t.Errorf("Expected board ID 0 for deprecated constructor, got %d", blcuBoard.Id()) + } +} + +// TestBLCUWithCustomConfiguration tests BLCU with custom board ID and order IDs +func TestBLCUWithCustomConfiguration(t *testing.T) { + logger := zerolog.New(nil).Level(zerolog.Disabled) + v := vehicle.New(logger) + + // Test new constructor with custom configuration + tftpConfig := boards.TFTPConfig{ + BlockSize: 131072, + Retries: 3, + TimeoutMs: 5000, + BackoffFactor: 2, + EnableProgress: true, + } + + customBoardId := abstraction.BoardId(7) + customDownloadOrderId := uint16(801) + customUploadOrderId := uint16(802) + + blcuBoard := boards.NewWithConfig("192.168.0.10", tftpConfig, customBoardId, customDownloadOrderId, customUploadOrderId) + v.AddBoard(blcuBoard) + + // Verify board is registered with custom ID + if blcuBoard.Id() != customBoardId { + t.Errorf("Expected board ID %d, got %d", customBoardId, blcuBoard.Id()) + } +} + +// TestBLCUWithDefaultConfiguration tests BLCU with default order IDs +func TestBLCUWithDefaultConfiguration(t *testing.T) { + logger := zerolog.New(nil).Level(zerolog.Disabled) + v := vehicle.New(logger) + + // Test deprecated constructor (should use default order IDs) + tftpConfig := boards.TFTPConfig{ + BlockSize: 131072, + Retries: 3, + TimeoutMs: 5000, + BackoffFactor: 2, + EnableProgress: true, + } + + boardId := abstraction.BoardId(7) + blcuBoard := boards.NewWithTFTPConfig("192.168.0.10", tftpConfig, boardId) + v.AddBoard(blcuBoard) + + // Verify board is registered + if blcuBoard.Id() != boardId { + t.Errorf("Expected board ID %d, got %d", boardId, blcuBoard.Id()) + } +} + +// TestBLCURequestStructures tests the request structures +func TestBLCURequestStructures(t *testing.T) { + // Test download request + downloadReq := &blcu_topic.DownloadRequest{ + Board: "VCU", + } + if downloadReq.Topic() != "blcu/downloadRequest" { + t.Errorf("Expected topic 'blcu/downloadRequest', got '%s'", downloadReq.Topic()) + } + + // Test upload request + uploadReq := &blcu_topic.UploadRequest{ + Board: "VCU", + File: "dGVzdCBkYXRh", // base64 for "test data" + } + if uploadReq.Topic() != "blcu/uploadRequest" { + t.Errorf("Expected topic 'blcu/uploadRequest', got '%s'", uploadReq.Topic()) + } + + // Test internal upload request + uploadReqInternal := &blcu_topic.UploadRequestInternal{ + Board: "VCU", + Data: []byte("test data"), + } + if uploadReqInternal.Topic() != "blcu/uploadRequest" { + t.Errorf("Expected topic 'blcu/uploadRequest', got '%s'", uploadReqInternal.Topic()) + } +} \ No newline at end of file diff --git a/backend/pkg/broker/topics/blcu/blcu_test.go b/backend/pkg/broker/topics/blcu/blcu_test.go index 8c17935ee..fd3136224 100644 --- a/backend/pkg/broker/topics/blcu/blcu_test.go +++ b/backend/pkg/broker/topics/blcu/blcu_test.go @@ -36,10 +36,11 @@ func (api MockAPI) UserPush(push abstraction.BrokerPush) error { errorFlag = false log.Printf("Output matches") return nil - case blcu.UploadRequest: - if push.(blcu.UploadRequest).Board != "test" || string(push.(blcu.UploadRequest).Data) != "test" { + case *blcu.UploadRequestInternal: + req := push.(*blcu.UploadRequestInternal) + if req.Board != "test" || string(req.Data) != "test" { errorFlag = true - fmt.Printf("Expected board 'test' and data 'test', got board '%s' and data '%s'\n", push.(blcu.UploadRequest).Board, string(push.(blcu.UploadRequest).Data)) + fmt.Printf("Expected board 'test' and data 'test', got board '%s' and data '%s'\n", req.Board, string(req.Data)) return &OutputNotMatchingError{} } errorFlag = false @@ -151,8 +152,8 @@ func TestBLCUTopic_Upload_Push(t *testing.T) { upload.SetAPI(api) upload.SetPool(pool) - // Simulate sending a download request - request := blcu.UploadRequest{Board: "test", Data: []byte("test")} + // Simulate sending an upload request + request := blcu.UploadRequest{Board: "test", File: "dGVzdA=="} // "test" in base64 err = upload.Push(request) if err != nil { t.Fatal("Error pushing upload request:", err) @@ -188,7 +189,8 @@ func TestBLCUTopic_Upload_ClientMessage(t *testing.T) { upload := blcu.Upload{} upload.SetAPI(&MockAPI{}) - payload := blcu.UploadRequest{Board: "test", Data: []byte("test")} + // Use base64 encoded data as the frontend would send + payload := blcu.UploadRequest{Board: "test", File: "dGVzdA=="} // "test" in base64 payloadBytes, _ := json.Marshal(payload) upload.ClientMessage(websocket.ClientId{0}, &websocket.Message{ diff --git a/backend/pkg/broker/topics/blcu/download.go b/backend/pkg/broker/topics/blcu/download.go index f98c65225..8ee022956 100644 --- a/backend/pkg/broker/topics/blcu/download.go +++ b/backend/pkg/broker/topics/blcu/download.go @@ -29,19 +29,32 @@ func (request DownloadRequest) Topic() abstraction.BrokerTopic { } func (download *Download) Push(push abstraction.BrokerPush) error { - switch push.Topic() { - case boards.DownloadSuccess{}.Topic(): + switch p := push.(type) { + case *boards.DownloadSuccess: + // Send success response with the downloaded data + response := map[string]interface{}{ + "percentage": 100, + "failure": false, + "file": p.Data, // The downloaded file data + } + payload, _ := json.Marshal(response) err := download.pool.Write(download.client, websocket.Message{ - Topic: push.Topic(), - Payload: nil, + Topic: DownloadName, + Payload: payload, }) if err != nil { return err } - case boards.DownloadFailure{}.Topic(): + case *boards.DownloadFailure: + // Send failure response + response := map[string]interface{}{ + "percentage": 0, + "failure": true, + } + payload, _ := json.Marshal(response) err := download.pool.Write(download.client, websocket.Message{ - Topic: push.Topic(), - Payload: nil, + Topic: DownloadName, + Payload: payload, }) if err != nil { return err @@ -74,7 +87,7 @@ func (download *Download) handleDownload(message *websocket.Message) error { return err } - pushErr := download.api.UserPush(downloadRequest) + pushErr := download.api.UserPush(&downloadRequest) return pushErr } diff --git a/backend/pkg/broker/topics/blcu/upload.go b/backend/pkg/broker/topics/blcu/upload.go index 85b803474..82e2b1278 100644 --- a/backend/pkg/broker/topics/blcu/upload.go +++ b/backend/pkg/broker/topics/blcu/upload.go @@ -1,6 +1,7 @@ package blcu import ( + "encoding/base64" "encoding/json" "fmt" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" @@ -22,31 +23,50 @@ func (upload *Upload) Topic() abstraction.BrokerTopic { type UploadRequest struct { Board string `json:"board"` - Data []byte `json:"data"` + File string `json:"file"` // Base64 encoded file data from frontend } func (request UploadRequest) Topic() abstraction.BrokerTopic { return "blcu/uploadRequest" } -func (upload *Upload) Push(push abstraction.BrokerPush) error { - // Switch success/failure - // upload.pool.Write(ID, message) +// UploadRequestInternal is the internal representation with decoded data +type UploadRequestInternal struct { + Board string + Data []byte +} - switch push.Topic() { - case boards.UploadSuccess{}.Topic(): +func (request UploadRequestInternal) Topic() abstraction.BrokerTopic { + return "blcu/uploadRequest" +} + +func (upload *Upload) Push(push abstraction.BrokerPush) error { + switch push.(type) { + case *boards.UploadSuccess: + // Send success response + response := map[string]interface{}{ + "percentage": 100, + "failure": false, + } + payload, _ := json.Marshal(response) err := upload.pool.Write(upload.client, websocket.Message{ - Topic: push.Topic(), - Payload: nil, + Topic: UploadName, + Payload: payload, }) if err != nil { return err } - case boards.UploadFailure{}.Topic(): + case *boards.UploadFailure: + // Send failure response + response := map[string]interface{}{ + "percentage": 0, + "failure": true, + } + payload, _ := json.Marshal(response) err := upload.pool.Write(upload.client, websocket.Message{ - Topic: push.Topic(), - Payload: nil, + Topic: UploadName, + Payload: payload, }) if err != nil { return err @@ -79,7 +99,19 @@ func (upload *Upload) handleUpload(message *websocket.Message) error { return err } - pushErr := upload.api.UserPush(uploadRequest) + // Decode base64 file data + fileData, err := base64.StdEncoding.DecodeString(uploadRequest.File) + if err != nil { + return fmt.Errorf("failed to decode base64 file data: %w", err) + } + + // Create the internal upload event with decoded data + internalRequest := &UploadRequestInternal{ + Board: uploadRequest.Board, + Data: fileData, + } + + pushErr := upload.api.UserPush(internalRequest) return pushErr } diff --git a/backend/pkg/logger/data/logger.go b/backend/pkg/logger/data/logger.go index 4198b18c7..7bbd3fcb5 100644 --- a/backend/pkg/logger/data/logger.go +++ b/backend/pkg/logger/data/logger.go @@ -5,6 +5,7 @@ import ( "os" "path" "strconv" + "strings" "sync" "sync/atomic" "time" @@ -95,7 +96,7 @@ func (sublogger *Logger) PushRecord(record abstraction.LoggerRecord) error { valueRepresentation = string(value.Variant()) } - saveFile, err := sublogger.getFile(valueName) + saveFile, err := sublogger.getFile(valueName, dataRecord.From) if err != nil { return err } @@ -120,7 +121,7 @@ func (sublogger *Logger) PushRecord(record abstraction.LoggerRecord) error { return writeErr } -func (sublogger *Logger) getFile(valueName data.ValueName) (*file.CSV, error) { +func (sublogger *Logger) getFile(valueName data.ValueName, board string) (*file.CSV, error) { sublogger.fileLock.Lock() defer sublogger.fileLock.Unlock() @@ -129,17 +130,18 @@ func (sublogger *Logger) getFile(valueName data.ValueName) (*file.CSV, error) { return valueFile, nil } - valueFileRaw, err := sublogger.createFile(valueName) + valueFileRaw, err := sublogger.createFile(valueName, board) sublogger.saveFiles[valueName] = file.NewCSV(valueFileRaw) return sublogger.saveFiles[valueName], err } -func (sublogger *Logger) createFile(valueName data.ValueName) (*os.File, error) { +func (sublogger *Logger) createFile(valueName data.ValueName, board string) (*os.File, error) { filename := path.Join( "logger", loggerHandler.Timestamp.Format(loggerHandler.TimestampFormat), "data", + strings.ToUpper(board), fmt.Sprintf("%s.csv", valueName), ) diff --git a/backend/pkg/transport/network/tcp/client.go b/backend/pkg/transport/network/tcp/client.go index 82817a668..2fb683820 100644 --- a/backend/pkg/transport/network/tcp/client.go +++ b/backend/pkg/transport/network/tcp/client.go @@ -39,27 +39,34 @@ func (client *Client) Dial() (net.Conn, error) { var err error var conn net.Conn client.logger.Info().Msg("dialing") - // The max connection retries will not work because the for loop never completes, it always returns and the function is called again by transport. + for client.config.MaxConnectionRetries <= 0 || client.currentRetries < client.config.MaxConnectionRetries { + // Increment retry counter and calculate backoff client.currentRetries++ - conn, err = client.config.DialContext(client.config.Context, "tcp", client.address) backoffDuration := client.config.ConnectionBackoffFunction(client.currentRetries) - client.logger.Error().Stack().Err(err).Dur("backoff", backoffDuration).Int("retries", client.currentRetries+1).Msg("retrying") + client.logger.Error().Stack().Err(err).Dur("backoff", backoffDuration).Int("retry", client.currentRetries).Msg("retrying after backoff") + + // Sleep for backoff duration time.Sleep(backoffDuration) + conn, err = client.config.DialContext(client.config.Context, "tcp", client.address) + if err == nil { client.logger.Info().Msg("connected") client.currentRetries = 0 return conn, nil } + + // Check if context was cancelled if client.config.Context.Err() != nil { client.logger.Error().Stack().Err(client.config.Context.Err()).Msg("canceled") return nil, client.config.Context.Err() } + // Check if we should retry this error if netErr, ok := err.(net.Error); !client.config.TryReconnect || (!errors.Is(err, syscall.ECONNREFUSED) && (!ok || !netErr.Timeout())) { - client.logger.Error().Stack().Err(err).Msg("failed") + client.logger.Error().Stack().Err(err).Msg("failed with non-retryable error") return nil, err } } diff --git a/backend/pkg/transport/network/tcp/reconnection_test.go b/backend/pkg/transport/network/tcp/reconnection_test.go new file mode 100644 index 000000000..e2012ad20 --- /dev/null +++ b/backend/pkg/transport/network/tcp/reconnection_test.go @@ -0,0 +1,291 @@ +package tcp + +import ( + "context" + "fmt" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/rs/zerolog" +) + +// MockTCPServer simulates a board's TCP server that can be stopped and restarted +type MockTCPServer struct { + addr string + listener net.Listener + mu sync.Mutex + running bool + stopCh chan struct{} + + // Tracking + connectionCount int32 + lastConnTime time.Time +} + +// NewMockTCPServer creates a new mock TCP server +func NewMockTCPServer(addr string) *MockTCPServer { + return &MockTCPServer{ + addr: addr, + stopCh: make(chan struct{}), + } +} + +// Start starts the mock server +func (s *MockTCPServer) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return fmt.Errorf("server already running") + } + + listener, err := net.Listen("tcp", s.addr) + if err != nil { + return err + } + + s.listener = listener + s.running = true + s.stopCh = make(chan struct{}) + + go s.acceptLoop() + + return nil +} + +// Stop stops the mock server +func (s *MockTCPServer) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running { + return fmt.Errorf("server not running") + } + + close(s.stopCh) + s.running = false + return s.listener.Close() +} + +// acceptLoop handles incoming connections +func (s *MockTCPServer) acceptLoop() { + for { + conn, err := s.listener.Accept() + if err != nil { + select { + case <-s.stopCh: + return + default: + continue + } + } + + atomic.AddInt32(&s.connectionCount, 1) + s.mu.Lock() + s.lastConnTime = time.Now() + s.mu.Unlock() + + // Handle connection (just keep it open for this test) + go func(c net.Conn) { + defer c.Close() + // Keep connection alive until server stops + <-s.stopCh + }(conn) + } +} + +// GetConnectionCount returns the number of connections received +func (s *MockTCPServer) GetConnectionCount() int { + return int(atomic.LoadInt32(&s.connectionCount)) +} + +// GetLastConnectionTime returns the time of the last connection +func (s *MockTCPServer) GetLastConnectionTime() time.Time { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastConnTime +} + +// TestExponentialBackoffReconnection tests the exponential backoff behavior +func TestExponentialBackoffReconnection(t *testing.T) { + // Setup logger + logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() + + // Find an available port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to find available port: %v", err) + } + serverAddr := listener.Addr().String() + listener.Close() + + // Create mock server + mockServer := NewMockTCPServer(serverAddr) + + // Start the server initially + err = mockServer.Start() + if err != nil { + t.Fatalf("Failed to start mock server: %v", err) + } + + // Create client config with specific backoff parameters + clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") + config := NewClientConfig(clientAddr) + config.Context = context.Background() + config.TryReconnect = true + config.MaxConnectionRetries = 5 // Will cycle after 5 retries + config.ConnectionBackoffFunction = NewExponentialBackoff( + 100*time.Millisecond, // min + 2.0, // multiplier + 2*time.Second, // max + ) + + // Create client + client := NewClient(serverAddr, config, logger) + + // Test 1: Initial connection should succeed + t.Run("InitialConnection", func(t *testing.T) { + conn, err := client.Dial() + if err != nil { + t.Fatalf("Initial connection failed: %v", err) + } + conn.Close() + + if mockServer.GetConnectionCount() != 1 { + t.Errorf("Expected 1 connection, got %d", mockServer.GetConnectionCount()) + } + }) + + // Test 2: Test reconnection with exponential backoff + t.Run("ExponentialBackoffReconnection", func(t *testing.T) { + // Stop the server to simulate disconnection + err := mockServer.Stop() + if err != nil { + t.Fatalf("Failed to stop server: %v", err) + } + + // Reset connection count + mockServer = NewMockTCPServer(serverAddr) + + // Track retry attempts and timings + retryTimes := make([]time.Time, 0) + startTime := time.Now() + + // Start a goroutine to track connection attempts + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + go func() { + for { + select { + case <-ctx.Done(): + return + default: + beforeCount := mockServer.GetConnectionCount() + time.Sleep(50 * time.Millisecond) + afterCount := mockServer.GetConnectionCount() + if afterCount > beforeCount { + retryTimes = append(retryTimes, time.Now()) + } + } + } + }() + + // Wait a bit, then restart the server after some retries + go func() { + time.Sleep(1 * time.Second) // Let client retry a few times + mockServer.Start() + }() + + // Try to connect (this should retry with exponential backoff) + config.Context = ctx + client = NewClient(serverAddr, config, logger) + conn, err := client.Dial() + if err != nil { + t.Fatalf("Failed to reconnect: %v", err) + } + conn.Close() + + // Verify exponential backoff timing + if len(retryTimes) < 2 { + t.Skip("Not enough retry attempts captured") + } + + // Check that retries follow exponential pattern + // First retry should be after ~100ms, second after ~200ms, third after ~400ms, etc. + expectedDelays := []time.Duration{ + 100 * time.Millisecond, + 200 * time.Millisecond, + 400 * time.Millisecond, + 800 * time.Millisecond, + } + + for i := 1; i < len(retryTimes) && i < len(expectedDelays); i++ { + actualDelay := retryTimes[i].Sub(retryTimes[i-1]) + expectedDelay := expectedDelays[i-1] + + // Allow 20% tolerance for timing + minDelay := time.Duration(float64(expectedDelay) * 0.8) + maxDelay := time.Duration(float64(expectedDelay) * 1.2) + + if actualDelay < minDelay || actualDelay > maxDelay { + t.Logf("Retry %d: expected delay ~%v, got %v", i, expectedDelay, actualDelay) + } + } + + totalTime := time.Since(startTime) + t.Logf("Total reconnection time: %v with %d retries", totalTime, len(retryTimes)) + }) + + // Test 3: Test max retries behavior and cycling + t.Run("MaxRetriesCycling", func(t *testing.T) { + // Stop the server again + mockServer.Stop() + + // Create a client with very short backoff for faster testing + config := NewClientConfig(clientAddr) + config.Context = context.Background() + config.TryReconnect = true + config.MaxConnectionRetries = 3 // Small number for quick cycling + config.ConnectionBackoffFunction = NewExponentialBackoff( + 10*time.Millisecond, // min + 1.5, // multiplier + 50*time.Millisecond, // max + ) + + client := NewClient(serverAddr, config, logger) + + // This should fail with ErrTooManyRetries + _, err := client.Dial() + if _, ok := err.(ErrTooManyRetries); !ok { + t.Errorf("Expected ErrTooManyRetries, got %T: %v", err, err) + } + + // Verify retry count was reset for next attempt + // (This is implicit in the implementation - the next Dial will start fresh) + }) +} + +// TestPersistentReconnection tests that the transport layer keeps trying to reconnect +func TestPersistentReconnection(t *testing.T) { + // This test would require the full transport setup + // For now, we're testing the client behavior directly + t.Skip("Full transport test requires more setup") +} + +// BenchmarkExponentialBackoff benchmarks the backoff calculation +func BenchmarkExponentialBackoff(b *testing.B) { + backoff := NewExponentialBackoff( + 100*time.Millisecond, + 1.5, + 5*time.Second, + ) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = backoff(i % 20) // Test various retry counts + } +} \ No newline at end of file diff --git a/backend/pkg/transport/network/tcp/simple_reconnection_test.go b/backend/pkg/transport/network/tcp/simple_reconnection_test.go new file mode 100644 index 000000000..ce76a0dc8 --- /dev/null +++ b/backend/pkg/transport/network/tcp/simple_reconnection_test.go @@ -0,0 +1,238 @@ +package tcp + +import ( + "context" + "net" + "sync" + "testing" + "time" + + "github.com/rs/zerolog" +) + +// TestSimpleReconnectionScenario demonstrates a simple board disconnection and reconnection +func TestSimpleReconnectionScenario(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() + + // Setup: Create a simple TCP server that simulates a board + boardAddr := "127.0.0.1:0" + listener, err := net.Listen("tcp", boardAddr) + if err != nil { + t.Fatalf("Failed to create listener: %v", err) + } + boardAddr = listener.Addr().String() + + // Server state + var serverMu sync.Mutex + serverRunning := true + connections := 0 + connectionTimes := []time.Time{} + + // Run the mock board server + go func() { + for serverRunning { + conn, err := listener.Accept() + if err != nil { + continue + } + + serverMu.Lock() + connections++ + connectionTimes = append(connectionTimes, time.Now()) + t.Logf("Board accepted connection #%d at %v", connections, time.Now().Format("15:04:05.000")) + serverMu.Unlock() + + // Keep connection open for a bit, then close to simulate disconnection + go func(c net.Conn) { + time.Sleep(500 * time.Millisecond) + c.Close() + }(conn) + } + }() + + // Configure client with exponential backoff + clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") + config := NewClientConfig(clientAddr) + config.Context = context.Background() + config.TryReconnect = true + config.MaxConnectionRetries = 0 // Infinite retries + config.ConnectionBackoffFunction = NewExponentialBackoff( + 100*time.Millisecond, // min backoff + 1.5, // multiplier + 2*time.Second, // max backoff + ) + + // Create client + client := NewClient(boardAddr, config, logger) + + // Test scenario + t.Log("=== Starting reconnection test scenario ===") + + // Phase 1: Initial connection + t.Log("Phase 1: Establishing initial connection...") + conn, err := client.Dial() + if err != nil { + t.Fatalf("Initial connection failed: %v", err) + } + t.Log("โœ“ Initial connection successful") + + // Wait a moment for server to register the connection + time.Sleep(50 * time.Millisecond) + + // Verify we have 1 connection + serverMu.Lock() + if connections != 1 { + t.Errorf("Expected 1 connection, got %d", connections) + } + serverMu.Unlock() + + // Close connection to simulate disconnection + conn.Close() + time.Sleep(100 * time.Millisecond) + + // Phase 2: Board goes offline (close listener) + t.Log("\nPhase 2: Simulating board going offline...") + listener.Close() + serverRunning = false + time.Sleep(100 * time.Millisecond) + + // Try to connect while board is offline (this should retry with backoff) + dialDone := make(chan error, 1) + dialStart := time.Now() + + go func() { + _, err := client.Dial() + dialDone <- err + }() + + // Let it retry a few times + t.Log("Client attempting to reconnect (board is offline)...") + time.Sleep(800 * time.Millisecond) + + // Phase 3: Board comes back online + t.Log("\nPhase 3: Bringing board back online...") + listener, err = net.Listen("tcp", boardAddr) + if err != nil { + t.Fatalf("Failed to restart listener: %v", err) + } + defer listener.Close() + + serverRunning = true + go func() { + for serverRunning { + conn, err := listener.Accept() + if err != nil { + continue + } + + serverMu.Lock() + connections++ + connectionTimes = append(connectionTimes, time.Now()) + t.Logf("Board accepted reconnection #%d at %v", connections, time.Now().Format("15:04:05.000")) + serverMu.Unlock() + + // Keep this connection alive + go func(c net.Conn) { + buf := make([]byte, 1024) + for { + _, err := c.Read(buf) + if err != nil { + return + } + } + }(conn) + } + }() + + // Wait for reconnection + select { + case err := <-dialDone: + if err != nil { + t.Fatalf("Reconnection failed: %v", err) + } + reconnectTime := time.Since(dialStart) + t.Logf("โœ“ Reconnection successful after %v", reconnectTime) + case <-time.After(5 * time.Second): + t.Fatal("Reconnection timed out after 5 seconds") + } + + // Verify we have 2 connections total + serverMu.Lock() + if connections != 2 { + t.Errorf("Expected 2 total connections, got %d", connections) + } + + // Log backoff pattern + if len(connectionTimes) >= 2 { + t.Log("\n=== Connection Timeline ===") + for i, connTime := range connectionTimes { + if i == 0 { + t.Logf("Connection %d: %v (initial)", i+1, connTime.Format("15:04:05.000")) + } else { + backoff := connTime.Sub(connectionTimes[i-1]) + t.Logf("Connection %d: %v (after %v backoff)", i+1, connTime.Format("15:04:05.000"), backoff) + } + } + } + serverMu.Unlock() + + // Cleanup + serverRunning = false + listener.Close() + + t.Log("\nโœ“ Test completed successfully - exponential backoff reconnection works!") +} + +// TestReconnectionMetrics tests and logs the exponential backoff timing +func TestReconnectionMetrics(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() + + // Create a server that never accepts connections to measure pure backoff timing + clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") + config := NewClientConfig(clientAddr) + config.Context = context.Background() + config.TryReconnect = true + config.MaxConnectionRetries = 5 // Try 5 times (faster test) + config.ConnectionBackoffFunction = NewExponentialBackoff( + 50*time.Millisecond, // min + 2.0, // multiplier + 1*time.Second, // max + ) + + client := NewClient("127.0.0.1:9999", config, logger) // Non-existent server + + startTime := time.Now() + _, err := client.Dial() + totalTime := time.Since(startTime) + + if _, ok := err.(ErrTooManyRetries); !ok { + t.Errorf("Expected ErrTooManyRetries, got %T: %v", err, err) + } + + t.Log("\n=== Exponential Backoff Timing ===") + t.Log("Configuration:") + t.Logf(" Min backoff: 50ms") + t.Logf(" Multiplier: 2.0") + t.Logf(" Max backoff: 1s") + t.Logf(" Max retries: 5") + t.Log("\nExpected backoff sequence:") + + expectedTotal := time.Duration(0) + for i := 1; i <= 5; i++ { + backoff := time.Duration(float64(50*time.Millisecond) * float64(uint(1)< 1*time.Second { + backoff = 1 * time.Second + } + expectedTotal += backoff + t.Logf(" Retry %d: %v (cumulative: %v)", i, backoff, expectedTotal) + } + + t.Logf("\nActual total time: %v", totalTime) + t.Logf("Expected total time: ~%v", expectedTotal) + + // Allow some tolerance for connection attempt time + tolerance := 2 * time.Second + if totalTime < expectedTotal-tolerance || totalTime > expectedTotal+tolerance { + t.Logf("Warning: Actual time differs significantly from expected") + } +} \ No newline at end of file diff --git a/backend/pkg/transport/transport.go b/backend/pkg/transport/transport.go index 8fb199e4e..c185e3c02 100644 --- a/backend/pkg/transport/transport.go +++ b/backend/pkg/transport/transport.go @@ -13,6 +13,7 @@ import ( "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/sniffer" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tcp" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tftp" + "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/presentation" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/session" "github.com/rs/zerolog" @@ -46,24 +47,40 @@ type Transport struct { } // HandleClient connects to the specified client and handles its messages. This method blocks. -// This method will try to reconnect to the client if it disconnects mid way through, but after -// enough retries, it will stop. +// This method will continuously try to reconnect to the client if it disconnects, +// applying exponential backoff between attempts. func (transport *Transport) HandleClient(config tcp.ClientConfig, remote string) error { client := tcp.NewClient(remote, config, transport.logger) defer transport.logger.Warn().Str("remoteAddress", remote).Msg("abort connection") + var hasConnected = false for { conn, err := client.Dial() if err != nil { transport.logger.Debug().Stack().Err(err).Str("remoteAddress", remote).Msg("dial failed") + + // Only return if reconnection is disabled if !config.TryReconnect { + if hasConnected { + transport.SendFault() + } transport.errChan <- err return err } + // For ErrTooManyRetries, we still want to continue retrying + // The client will reset its retry counter on the next Dial() call + if _, ok := err.(tcp.ErrTooManyRetries); ok { + transport.logger.Warn().Str("remoteAddress", remote).Msg("reached max retries, will continue attempting to reconnect") + // Add a longer delay before restarting the retry cycle + time.Sleep(config.ConnectionBackoffFunction(config.MaxConnectionRetries)) + } + continue } + hasConnected = true + err = transport.handleTCPConn(conn) if errors.Is(err, error(ErrTargetAlreadyConnected{})) { transport.logger.Warn().Stack().Err(err).Str("remoteAddress", remote).Msg("multiple connections for same target") @@ -71,12 +88,14 @@ func (transport *Transport) HandleClient(config tcp.ClientConfig, remote string) return err } if err != nil { - transport.logger.Debug().Stack().Err(err).Str("remoteAddress", remote).Msg("dial failed") + transport.logger.Debug().Stack().Err(err).Str("remoteAddress", remote).Msg("connection lost") if !config.TryReconnect { + transport.SendFault() transport.errChan <- err return err } + // Connection was lost, continue trying to reconnect continue } } @@ -367,10 +386,10 @@ func (transport *Transport) consumeErrors() { } func (transport *Transport) SendFault() { - // err := transport.SendMessage(NewPacketMessage(data.NewPacket(0))) - // if err != nil { - // transport.errChan <- err - // } + err := transport.SendMessage(NewPacketMessage(data.NewPacket(0))) + if err != nil { + transport.errChan <- err + } } func (transport *Transport) SetpropagateFault(enabled bool) { diff --git a/backend/pkg/transport/transport_test.go b/backend/pkg/transport/transport_test.go index 698f32bc2..58325f82a 100644 --- a/backend/pkg/transport/transport_test.go +++ b/backend/pkg/transport/transport_test.go @@ -1,68 +1,646 @@ -package transport_test +package transport import ( "context" + "encoding/binary" + "fmt" "net" - "os" "sync" "testing" "time" - transport_module "github.com/HyperloopUPV-H8/h9-backend/pkg/transport" + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tcp" + "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" + "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/presentation" "github.com/rs/zerolog" ) -func TestTransport(t *testing.T) { - logger := zerolog.New(os.Stdout) - transport := transport_module.NewTransport(logger) +// TestTransportAPI implements abstraction.TransportAPI for testing +type TestTransportAPI struct { + mu sync.RWMutex + connectionUpdates []ConnectionUpdate + notifications []abstraction.TransportNotification +} - // Create a context that cancels after a timeout - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Millisecond) - defer cancel() +type ConnectionUpdate struct { + Target abstraction.TransportTarget + IsConnected bool + Timestamp time.Time +} - var wg sync.WaitGroup +func NewTestTransportAPI() *TestTransportAPI { + return &TestTransportAPI{ + connectionUpdates: make([]ConnectionUpdate, 0), + notifications: make([]abstraction.TransportNotification, 0), + } +} - wg.Add(1) - go func() { - defer wg.Done() - err := transport.HandleServer(tcp.NewServerConfig(), "127.0.0.1:8080") +func (api *TestTransportAPI) ConnectionUpdate(target abstraction.TransportTarget, isConnected bool) { + api.mu.Lock() + defer api.mu.Unlock() + api.connectionUpdates = append(api.connectionUpdates, ConnectionUpdate{ + Target: target, + IsConnected: isConnected, + Timestamp: time.Now(), + }) +} + +func (api *TestTransportAPI) Notification(notification abstraction.TransportNotification) { + api.mu.Lock() + defer api.mu.Unlock() + api.notifications = append(api.notifications, notification) +} + +func (api *TestTransportAPI) GetConnectionUpdates() []ConnectionUpdate { + api.mu.RLock() + defer api.mu.RUnlock() + updates := make([]ConnectionUpdate, len(api.connectionUpdates)) + copy(updates, api.connectionUpdates) + return updates +} + +func (api *TestTransportAPI) GetNotifications() []abstraction.TransportNotification { + api.mu.RLock() + defer api.mu.RUnlock() + notifications := make([]abstraction.TransportNotification, len(api.notifications)) + copy(notifications, api.notifications) + return notifications +} + +func (api *TestTransportAPI) Reset() { + api.mu.Lock() + defer api.mu.Unlock() + api.connectionUpdates = api.connectionUpdates[:0] + api.notifications = api.notifications[:0] +} + +// MockBoardServer simulates a vehicle board +type MockBoardServer struct { + address string + listener net.Listener + mu sync.RWMutex + running bool + connections []net.Conn + packetsRecv []abstraction.Packet + encoder *presentation.Encoder + decoder *presentation.Decoder +} + +func NewMockBoardServer(address string) *MockBoardServer { + logger := zerolog.Nop() + return &MockBoardServer{ + address: address, + connections: make([]net.Conn, 0), + packetsRecv: make([]abstraction.Packet, 0), + encoder: presentation.NewEncoder(binary.BigEndian, logger), + decoder: presentation.NewDecoder(binary.BigEndian, logger), + } +} + +func (s *MockBoardServer) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return fmt.Errorf("server already running") + } + + listener, err := net.Listen("tcp", s.address) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", s.address, err) + } + + s.listener = listener + s.running = true + + go s.acceptLoop() + + return nil +} + +func (s *MockBoardServer) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running { + return nil + } + + s.running = false + + // Close all connections + for _, conn := range s.connections { + conn.Close() + } + s.connections = s.connections[:0] + + // Close listener + if s.listener != nil { + err := s.listener.Close() + s.listener = nil + return err + } + + return nil +} + +func (s *MockBoardServer) acceptLoop() { + for { + conn, err := s.listener.Accept() if err != nil { - t.Errorf("Error creating server at 127.0.0.1:8080: %s", err) + s.mu.RLock() + running := s.running + s.mu.RUnlock() + if !running { + return + } + continue } + + s.mu.Lock() + s.connections = append(s.connections, conn) + s.mu.Unlock() + + go s.handleConnection(conn) + } +} + +func (s *MockBoardServer) handleConnection(conn net.Conn) { + defer func() { + conn.Close() + s.mu.Lock() + // Remove connection from list + for i, c := range s.connections { + if c == conn { + s.connections = append(s.connections[:i], s.connections[i+1:]...) + break + } + } + s.mu.Unlock() }() + + for { + s.mu.RLock() + running := s.running + s.mu.RUnlock() + + if !running { + return + } + + // Set read timeout to avoid blocking forever + conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + + packet, err := s.decoder.DecodeNext(conn) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return + } + + s.mu.Lock() + s.packetsRecv = append(s.packetsRecv, packet) + s.mu.Unlock() + } +} - time.Sleep(10 * time.Millisecond) +func (s *MockBoardServer) GetReceivedPackets() []abstraction.Packet { + s.mu.RLock() + defer s.mu.RUnlock() + packets := make([]abstraction.Packet, len(s.packetsRecv)) + copy(packets, s.packetsRecv) + return packets +} + +func (s *MockBoardServer) GetConnectionCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.connections) +} - // Simulate client interaction - addr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:3000") - wg.Add(1) +// Test utilities +func createTestTransport(t *testing.T) (*Transport, *TestTransportAPI) { + logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() + + transport := NewTransport(logger). + WithEncoder(presentation.NewEncoder(binary.BigEndian, logger)). + WithDecoder(presentation.NewDecoder(binary.BigEndian, logger)) + + api := NewTestTransportAPI() + transport.SetAPI(api) + + return transport, api +} + +func getAvailablePort(t testing.TB) string { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to get available port: %v", err) + } + defer listener.Close() + return listener.Addr().String() +} + +// waitForCondition waits for a condition to be true within a timeout +func waitForCondition(condition func() bool, timeout time.Duration, message string) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if condition() { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("timeout waiting for condition: %s", message) +} + +// Unit Tests +func TestTransport_Creation(t *testing.T) { + logger := zerolog.Nop() + transport := NewTransport(logger) + + if transport == nil { + t.Fatal("Transport should not be nil") + } + if transport.connectionsMx == nil { + t.Fatal("Transport connectionsMx should not be nil") + } + if transport.connections == nil { + t.Fatal("Transport connections should not be nil") + } + if transport.ipToTarget == nil { + t.Fatal("Transport ipToTarget should not be nil") + } + if transport.idToTarget == nil { + t.Fatal("Transport idToTarget should not be nil") + } +} + +func TestTransport_SetIdTarget(t *testing.T) { + transport, _ := createTestTransport(t) + + transport.SetIdTarget(100, "TEST_BOARD") + transport.SetIdTarget(200, "ANOTHER_BOARD") + + // Access the internal map to verify + if target := transport.idToTarget[100]; target != abstraction.TransportTarget("TEST_BOARD") { + t.Errorf("Expected TEST_BOARD, got %s", target) + } + if target := transport.idToTarget[200]; target != abstraction.TransportTarget("ANOTHER_BOARD") { + t.Errorf("Expected ANOTHER_BOARD, got %s", target) + } +} + +func TestTransport_SetTargetIp(t *testing.T) { + transport, _ := createTestTransport(t) + + transport.SetTargetIp("192.168.1.100", "TEST_BOARD") + transport.SetTargetIp("192.168.1.101", "ANOTHER_BOARD") + + // Access the internal map to verify + if target := transport.ipToTarget["192.168.1.100"]; target != abstraction.TransportTarget("TEST_BOARD") { + t.Errorf("Expected TEST_BOARD, got %s", target) + } + if target := transport.ipToTarget["192.168.1.101"]; target != abstraction.TransportTarget("ANOTHER_BOARD") { + t.Errorf("Expected ANOTHER_BOARD, got %s", target) + } +} + +// Integration Tests +func TestTransport_ClientServerConnection(t *testing.T) { + transport, api := createTestTransport(t) + + // Setup board configuration + boardIP := "127.0.0.1" + boardPort := getAvailablePort(t) + target := abstraction.TransportTarget("TEST_BOARD") + + transport.SetTargetIp(boardIP, target) + transport.SetIdTarget(100, target) + + // Create and start mock board server + mockBoard := NewMockBoardServer(boardPort) + err := mockBoard.Start() + if err != nil { + t.Fatalf("Failed to start mock board: %v", err) + } + defer mockBoard.Stop() + + // Configure client + clientAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to resolve client address: %v", err) + } + + clientConfig := tcp.NewClientConfig(clientAddr) + clientConfig.TryReconnect = false // Don't retry for this test + + // Start client connection in goroutine + clientDone := make(chan error, 1) go func() { - defer wg.Done() - err := transport.HandleClient(tcp.NewClientConfig(addr), "127.0.0.1:8080") - if err != nil { - t.Errorf("Error creating client at 127.0.0.1:3000: %s", err) + err := transport.HandleClient(clientConfig, boardPort) + clientDone <- err + }() + + // Ensure cleanup + defer func() { + mockBoard.Stop() + // Wait for client to finish + select { + case <-clientDone: + case <-time.After(1 * time.Second): + // Client should exit when board stops } }() + + // Wait for connection + err = waitForCondition(func() bool { + return mockBoard.GetConnectionCount() > 0 + }, 2*time.Second, "Board should receive connection") + if err != nil { + t.Fatal(err) + } + + // Verify connection update was sent + err = waitForCondition(func() bool { + updates := api.GetConnectionUpdates() + return len(updates) > 0 && updates[len(updates)-1].IsConnected + }, 2*time.Second, "Should receive connection update") + if err != nil { + t.Fatal(err) + } + + // Stop the board to trigger disconnection + mockBoard.Stop() + + // Wait for client to detect disconnection + select { + case err := <-clientDone: + // Client should exit due to connection loss + if err == nil { + t.Error("Expected error from client due to disconnection") + } + case <-time.After(2 * time.Second): + t.Fatal("Client should have detected disconnection") + } + + // Verify disconnection update + err = waitForCondition(func() bool { + updates := api.GetConnectionUpdates() + return len(updates) >= 2 && !updates[len(updates)-1].IsConnected + }, 2*time.Second, "Should receive disconnection update") + if err != nil { + t.Fatal(err) + } +} - // Create client with wrong address - addr, _ = net.ResolveTCPAddr("tcp", "127.0.0.1:3030") - wg.Add(1) +func TestTransport_PacketSending(t *testing.T) { + transport, api := createTestTransport(t) + + // Setup + boardIP := "127.0.0.1" + boardPort := getAvailablePort(t) + target := abstraction.TransportTarget("TEST_BOARD") + packetID := abstraction.PacketId(100) + + transport.SetTargetIp(boardIP, target) + transport.SetIdTarget(packetID, target) + + // Create mock board + mockBoard := NewMockBoardServer(boardPort) + err := mockBoard.Start() + if err != nil { + t.Fatalf("Failed to start mock board: %v", err) + } + defer mockBoard.Stop() + + // Start client + clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") + clientConfig := tcp.NewClientConfig(clientAddr) + clientConfig.TryReconnect = false + + clientDone := make(chan struct{}) go func() { - defer wg.Done() - err := transport.HandleClient(tcp.NewClientConfig(addr), "127.0.0.1:8000") - if err == nil { - t.Errorf("Expected error creating client at wrong address, got nil") + defer close(clientDone) + transport.HandleClient(clientConfig, boardPort) + }() + + // Ensure cleanup + defer func() { + mockBoard.Stop() + select { + case <-clientDone: + case <-time.After(1 * time.Second): } }() + + // Wait for connection + err = waitForCondition(func() bool { + return mockBoard.GetConnectionCount() > 0 + }, 2*time.Second, "Should establish connection") + if err != nil { + t.Fatal(err) + } + + // Create and send packet + testPacket := data.NewPacket(packetID) + testPacket.SetTimestamp(time.Now()) + + err = transport.SendMessage(NewPacketMessage(testPacket)) + if err != nil { + t.Fatalf("Failed to send packet: %v", err) + } + + // Verify packet was received by board + err = waitForCondition(func() bool { + packets := mockBoard.GetReceivedPackets() + return len(packets) > 0 && packets[0].Id() == packetID + }, 2*time.Second, "Board should receive the packet") + if err != nil { + t.Fatal(err) + } + + // Verify no error notifications + notifications := api.GetNotifications() + for _, notification := range notifications { + if errNotif, ok := notification.(ErrorNotification); ok { + t.Errorf("Unexpected error notification: %v", errNotif.Err) + } + } +} - // Wait for context cancellation or error +func TestTransport_UnknownTarget(t *testing.T) { + transport, api := createTestTransport(t) + + // Try to send packet to unknown target + unknownPacket := data.NewPacket(999) // Unknown packet ID + unknownPacket.SetTimestamp(time.Now()) + + err := transport.SendMessage(NewPacketMessage(unknownPacket)) + if err == nil { + t.Fatal("Expected error when sending to unknown target") + } + + // Should be ErrUnrecognizedId + var unrecognizedErr ErrUnrecognizedId + if !ErrorAs(err, &unrecognizedErr) { + t.Errorf("Expected ErrUnrecognizedId, got %T: %v", err, err) + } else if unrecognizedErr.Id != abstraction.PacketId(999) { + t.Errorf("Expected packet ID 999, got %d", unrecognizedErr.Id) + } + + // Verify error notification + err = waitForCondition(func() bool { + notifications := api.GetNotifications() + if len(notifications) == 0 { + return false + } + _, isErrorNotif := notifications[len(notifications)-1].(ErrorNotification) + return isErrorNotif + }, 2*time.Second, "Should receive error notification") + if err != nil { + t.Fatal(err) + } +} + +func TestTransport_ReconnectionBehavior(t *testing.T) { + transport, api := createTestTransport(t) + + // Setup + boardIP := "127.0.0.1" + boardPort := getAvailablePort(t) + target := abstraction.TransportTarget("RECONNECT_BOARD") + + transport.SetTargetIp(boardIP, target) + transport.SetIdTarget(100, target) + + // Create mock board + mockBoard := NewMockBoardServer(boardPort) + err := mockBoard.Start() + if err != nil { + t.Fatalf("Failed to start mock board: %v", err) + } + + // Configure client with fast reconnection for testing + clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") + clientConfig := tcp.NewClientConfig(clientAddr) + clientConfig.TryReconnect = true + clientConfig.MaxConnectionRetries = 0 // Infinite retries + clientConfig.ConnectionBackoffFunction = tcp.NewExponentialBackoff( + 10*time.Millisecond, // Fast for testing + 1.5, + 100*time.Millisecond, + ) + + // Start client with proper cleanup + ctx, cancel := context.WithCancel(context.Background()) + clientConfig.Context = ctx + + clientDone := make(chan struct{}) go func() { - wg.Wait() + defer close(clientDone) + transport.HandleClient(clientConfig, boardPort) }() - - <-ctx.Done() // Wait for timeout or manual cancel - if ctx.Err() == context.DeadlineExceeded { - t.Logf("Test completed by timeout") + + // Ensure cleanup happens + defer func() { + cancel() + mockBoard.Stop() + // Wait for client goroutine to finish + select { + case <-clientDone: + case <-time.After(1 * time.Second): + t.Log("Warning: client goroutine did not finish within timeout") + } + }() + + // Wait for initial connection + err = waitForCondition(func() bool { + return mockBoard.GetConnectionCount() > 0 + }, 3*time.Second, "Should establish initial connection") + if err != nil { + t.Fatal(err) + } + + // Verify connection update + err = waitForCondition(func() bool { + updates := api.GetConnectionUpdates() + return len(updates) > 0 && updates[len(updates)-1].IsConnected + }, 2*time.Second, "Should receive connection update") + if err != nil { + t.Fatal(err) + } + + // Simulate board restart + mockBoard.Stop() + + // Wait for disconnection detection + err = waitForCondition(func() bool { + updates := api.GetConnectionUpdates() + for i := len(updates) - 1; i >= 0; i-- { + if !updates[i].IsConnected && updates[i].Target == target { + return true + } + } + return false + }, 3*time.Second, "Should detect disconnection") + if err != nil { + t.Fatal(err) + } + + // Restart board + mockBoard = NewMockBoardServer(boardPort) + err = mockBoard.Start() + if err != nil { + t.Fatalf("Failed to restart mock board: %v", err) + } + + // Wait for reconnection + err = waitForCondition(func() bool { + return mockBoard.GetConnectionCount() > 0 + }, 5*time.Second, "Should reconnect to restarted board") + if err != nil { + t.Fatal(err) + } + + // Verify reconnection update + err = waitForCondition(func() bool { + updates := api.GetConnectionUpdates() + if len(updates) < 3 { // Initial connect, disconnect, reconnect + return false + } + // Look for a connection update after the disconnection + for i := len(updates) - 1; i >= 0; i-- { + if updates[i].IsConnected && updates[i].Target == target { + // Make sure this is after a disconnection + for j := i - 1; j >= 0; j-- { + if !updates[j].IsConnected && updates[j].Target == target { + return true + } + } + } + } + return false + }, 5*time.Second, "Should receive reconnection update") + if err != nil { + t.Fatal(err) } } + +// Helper function to mimic errors.As behavior +func ErrorAs(err error, target interface{}) bool { + switch target := target.(type) { + case *ErrUnrecognizedId: + if e, ok := err.(ErrUnrecognizedId); ok { + *target = e + return true + } + case *ErrConnClosed: + if e, ok := err.(ErrConnClosed); ok { + *target = e + return true + } + } + return false +} \ No newline at end of file diff --git a/backend/pkg/vehicle/constructor.go b/backend/pkg/vehicle/constructor.go index 93c2944fd..e71cd407b 100644 --- a/backend/pkg/vehicle/constructor.go +++ b/backend/pkg/vehicle/constructor.go @@ -75,3 +75,8 @@ func (vehicle *Vehicle) SetIpToBoardId(ipToBoardId map[string]abstraction.BoardI vehicle.ipToBoardId = ipToBoardId vehicle.trace.Info().Msg("set ip to board id") } + +func (vehicle *Vehicle) SetBlcuId(id abstraction.BoardId) { + vehicle.BlcuId = id + vehicle.trace.Info().Uint16("blcu_id", uint16(id)).Msg("set blcu id") +} diff --git a/backend/pkg/vehicle/notification.go b/backend/pkg/vehicle/notification.go index 7fcbd016b..72fde59c3 100644 --- a/backend/pkg/vehicle/notification.go +++ b/backend/pkg/vehicle/notification.go @@ -43,7 +43,6 @@ func (vehicle *Vehicle) Notification(notification abstraction.TransportNotificat } func (vehicle *Vehicle) handlePacketNotification(notification transport.PacketNotification) error { - var from string var to string switch p := notification.Packet.(type) { @@ -55,19 +54,20 @@ func (vehicle *Vehicle) handlePacketNotification(notification transport.PacketNo return errors.Join(fmt.Errorf("update data to frontend (data with id %d from %s to %s)", p.Id(), notification.From, notification.To), err) } - from_ip := strings.Split(notification.From, ":")[0] - to_ip := strings.Split(notification.To, ":")[0] - - if from_ip == "192.168.0.9" { - from = "backend" - } else { - from = vehicle.idToBoardName[uint16(vehicle.ipToBoardId[from_ip])] + from, exists := vehicle.idToBoardName[uint16(notification.Packet.Id())] + if !exists { + from = notification.From } - if to_ip == "192.168.0.9" { + to_ip := strings.Split(notification.To, ":")[0] + + if to_ip == "192.168.0.9" || to_ip == "127.0.0.9" { to = "backend" } else { - to = vehicle.idToBoardName[uint16(vehicle.ipToBoardId[to_ip])] + to, exists = vehicle.idToBoardName[uint16(notification.Packet.Id())] + if !exists { + to = notification.From + } } err = vehicle.logger.PushRecord(&data_logger.Record{ @@ -131,7 +131,7 @@ func (vehicle *Vehicle) handlePacketNotification(notification transport.PacketNo return errors.Join(fmt.Errorf("remove state orders (state orders from %s to %s)", notification.From, notification.To), err) } case *blcu_packet.Ack: - vehicle.boards[boards.BlcuId].Notify(abstraction.BoardNotification( + vehicle.boards[vehicle.BlcuId].Notify(abstraction.BoardNotification( &boards.AckNotification{ ID: boards.AckId, }, diff --git a/backend/pkg/vehicle/vehicle.go b/backend/pkg/vehicle/vehicle.go index 790a212f2..b067bef3c 100644 --- a/backend/pkg/vehicle/vehicle.go +++ b/backend/pkg/vehicle/vehicle.go @@ -33,6 +33,7 @@ type Vehicle struct { updateFactory *update_factory.UpdateFactory idToBoardName map[uint16]string ipToBoardId map[string]abstraction.BoardId + BlcuId abstraction.BoardId trace zerolog.Logger } @@ -89,28 +90,46 @@ func (vehicle *Vehicle) UserPush(push abstraction.BrokerPush) error { status.Fulfill(status.Enable()) } - case blcu_topic.DownloadName: + case "blcu/downloadRequest": download := push.(*blcu_topic.DownloadRequest) - vehicle.boards[boards.BlcuId].Notify(abstraction.BoardNotification( - &boards.DownloadEvent{ - BoardEvent: boards.AckId, - BoardID: boards.BlcuId, - Board: download.Board, - }, - )) - - case blcu_topic.UploadName: - upload := push.(*blcu_topic.UploadRequest) - - vehicle.boards[boards.BlcuId].Notify(abstraction.BoardNotification( - &boards.UploadEvent{ - BoardEvent: boards.AckId, - Board: upload.Board, - Data: upload.Data, - Length: len(upload.Data), - }, - )) + if board, exists := vehicle.boards[vehicle.BlcuId]; exists { + board.Notify(abstraction.BoardNotification( + &boards.DownloadEvent{ + BoardEvent: boards.DownloadEventId, + BoardID: vehicle.BlcuId, + Board: download.Board, + }, + )) + } else { + fmt.Fprintf(os.Stderr, "BLCU board not registered\n") + } + + case "blcu/uploadRequest": + // Handle both UploadRequest and UploadRequestInternal + var uploadEvent *boards.UploadEvent + switch u := push.(type) { + case *blcu_topic.UploadRequestInternal: + uploadEvent = &boards.UploadEvent{ + BoardEvent: boards.UploadEventId, + Board: u.Board, + Data: u.Data, + Length: len(u.Data), + } + case *blcu_topic.UploadRequest: + // This shouldn't happen as the handler should convert to Internal + fmt.Fprintf(os.Stderr, "received raw UploadRequest, expected UploadRequestInternal\n") + return nil + default: + fmt.Fprintf(os.Stderr, "unknown upload type: %T\n", push) + return nil + } + + if board, exists := vehicle.boards[vehicle.BlcuId]; exists { + board.Notify(abstraction.BoardNotification(uploadEvent)) + } else { + fmt.Fprintf(os.Stderr, "BLCU board not registered\n") + } default: fmt.Printf("unknow topic %s\n", push.Topic()) diff --git a/common-front/lib/components/LiveStreamPlayer/LiveStreamPlayer.tsx b/common-front/lib/components/LiveStreamPlayer/LiveStreamPlayer.tsx new file mode 100644 index 000000000..7278c68de --- /dev/null +++ b/common-front/lib/components/LiveStreamPlayer/LiveStreamPlayer.tsx @@ -0,0 +1,39 @@ +import React, { useEffect, useRef } from 'react'; +import Hls from 'hls.js'; + +type Props = { + src: string; + width?: string; + height?: string; + borderRadius?: string; +}; + +export const LiveStreamPlayer = ({ src, width = '100%', height = '100%', borderRadius = '8px' }: Props) => { + const videoRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + + if (video) { + if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = src; + } else if (Hls.isSupported()) { + const hls = new Hls(); + hls.loadSource(src); + hls.attachMedia(video); + + return () => hls.destroy(); + } else { + console.error('HLS not supported'); + } + } + }, [src]); + + return ( +