Thursday, 6 August 2026

AI - Status Update - Cybderdeck SmartCam

 I showed that it worked and that I can take photos with what is basically a Raspberry Beowulf Cluster.

Didn't last long...

Eventually, after a DietPi update I ran into errors and had to reconsider the design from its core. 
 
The initial setup used a single Python script running on the Cams head node. It pulled a video stream from the other Raspi and pushed that onto the Cams head node's little screen. Having the Hailo8 Kit, dedicated to object detection, in that loop caused errors after an OS update which made me doubt the core design of the system.
 
The better the base, the more flexible and buildable the core foundation is, the more advanced features can be added later and over time.
 
The idea is a no fixed end system to be out of the box, that is for the large industry, factory running, mass market serving developers. This is OpenSource turff and like in Skateboarding is falling a major part, beside having to hear Apache License means no pay... beside the American Dream, Freedom and its interwoven philosophical implications.

Another point is the chassis for the at this point dual cam system, having the raspis run hot over time, which still needs way too much duck tape. Version 4 is already created in the Printer Software and needs to manifest in the physical world now.

From here, a Summery of the new version with a full path, part of the Cyberdeck Project:

------------------ 

Below is a polished, self‑contained project manual for your website. It covers the design, the “why”, and a full walkthrough – perfect for others to replicate or for you to refer back to.

---

# SmartCam: a remote‑controlled dual‑camera cluster appliance

**A travel‑ready AI camera system built on a two‑node Beowulf cluster with a Waveshare screen, Hailo‑8L AI accelerator, and GPRS connectivity.**

---

## 1. Overview

The SmartCam is a head‑and‑sensor cluster designed for road trips, landmark identification, and real‑time AI‑assisted driving.  
It consists of two Raspberry Pi 5 nodes:

- **Node1 (head)** – DietPi, 8 GB RAM, HQ camera, Waveshare 3.5″ SPI display, Hailo‑8L AI kit, GPRS module.
- **Node7 (night sensor)** – Raspberry Pi OS Lite 64‑bit, 16 GB RAM, NoIR camera.

Both nodes are part of a larger Beowulf cluster and communicate through a dedicated **mpiuser** SSH fabric. The head node can **locally** preview the HQ camera or **remotely** pull the NoIR camera’s video stream via an SSH tunnel, displaying it directly on the Waveshare screen – all without a desktop environment, controllable by a local keyboard or by any SSH session in the cluster.

This document explains the system design, how each piece was built, and how to set it up from scratch.

---

## 2. Design philosophy

- **No X11, no desktop** – the Waveshare screen is written to directly via the Linux framebuffer (`/dev/fb0`). This is fast, lightweight, and crash‑resistant.
- **One communication layer** – all inter‑node traffic goes through the pre‑existing `mpiuser` SSH keys and the cluster‑wide `/etc/hosts`. No new passwords, no open ports.
- **Modular daemons** – each function (camera capture, stream fetching, display rendering, keyboard input, remote commands) runs in its own thread inside a single, restartable service.
- **Remote‑first** – the entire system is operated from an SSH terminal. The display can be toggled between cameras with a single command.

---

## 3. Hardware and wiring

| Component               | Connected to          | Notes                                  
|-------------------------------|-----------------------------|----------------------------------------
| HQ camera                | Node1 CSI             | IMX477 sensor, visible light           
| NoIR camera             | Node7 CSI             | OV5647 sensor, infrared‑sensitive     
| Waveshare 3.5″ SPI  | Node1 GPIO         | ILI9486 controller, 480×320, 16‑bit    
| Hailo‑8L                     | Node1 M.2 HAT   | PCIe, used for future AI inference     
| GPRS module            | Node1 UART        | Serial AT commands (future SMS alerts) 
| Keyboard                   | Node1 USB          | Used to toggle camera feed with F3     

---

## 4. Software architecture

```
                ┌─────────────────────┐
                │     node1 (head)                    │
                │                                                 │
                │  smartcam-displayd            │
                │  ┌───────────────┐        │
                │  │ HQCamera              │        │  picamera2 → HQ frames
                │  ├───────────────┤        │
                │  │ NoirStream            │    ──┼── HTTP over SSH tunnel → node7:5000
                │  ├───────────────┤        │
                │  │ Display                    │   ──┼── writes to /dev/fb0
                │  ├───────────────┤        │
                │  │ Keyboard                │        │  evdev → F3 toggles source
                │  ├───────────────┤        │
                │  │ UnixSocket             │        │  /run/smartcam/control.sock
                │  └───────────────┘        │
                │                                                 │
                │  smartcam-tunnel               │  autossh -L 5099:localhost:5000 node7
                └─────────┬───────────┘
                          │  mpiuser SSH
                          ▼
                ┌─────────────────────┐
                │     node7 (night)                   │
                │                                                 │
                │  noir-streamer                     │  Flask MJPEG server on 127.0.0.1:5000
                │  (picamera2)                         │
                └─────────────────────┘
```

**Key flows:**

1. **node7** continuously captures NoIR frames and serves them as an MJPEG stream on `localhost:5000`.
2. **node1** runs an `autossh` tunnel that forwards its own `localhost:5099` to node7’s `localhost:5000`. The tunnel uses the `mpiuser` identity and reconnects automatically.
3. **node1**’s main daemon (`smartcam-displayd`):
   - Captures HQ frames locally via `picamera2`.
   - Reads the tunnelled MJPEG stream from `localhost:5099`.
   - Maintains a shared active source (`hq` or `noir`).
   - Continuously renders the selected source onto the Waveshare framebuffer.
   - Listens for `F3` keypresses on a USB keyboard to toggle the source.
   - Creates a Unix socket for remote commands (`switch hq`, `switch noir`, `status`, `stop`).

---
## 5. Setup instructions

### 5.1 Prerequisites

- Both Pis flashed with their respective OS images (DietPi on node1, Raspberry Pi OS Lite 64‑bit on node7).
- Cameras enabled (`raspi-config` or `dietpi-config`), SPI enabled on node1.
- Static IP addresses assigned:
  - **node1**: 192.168.178.30
  - **node7**: 192.168.178.39
- The cluster‑wide `/etc/hosts` on both Pis must contain:
  ```
  192.168.178.30   node1   raspi5
  192.168.178.39   node7   16gbraspi
  ```
- The `mpiuser` account exists on both nodes with SSH key‑based authentication set up.

### 5.2 node7 – NoIR MJPEG streamer

1. **Install dependencies** (if not already present):
   ```bash
   sudo apt update
   sudo apt install python3-picamera2 python3-flask python3-opencv
   ```

2. **Create the streamer script** at `/home/mpiuser/noir_stream.py`:

   ```python
   #!/usr/bin/env python3
   from flask import Flask, Response
   import picamera2
   import cv2
   import threading
   import time

   app = Flask(__name__)
   camera = None
   output_jpeg = None
   lock = threading.Lock()

   def init_camera():
       global camera
       camera = picamera2.Picamera2()
       config = camera.create_video_configuration(main={"size": (640, 480)})
       camera.configure(config)
       camera.start()
       time.sleep(1)

   def capture_loop():
       global output_jpeg
       while True:
           frame = camera.capture_array()
           rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
           ret, jpeg = cv2.imencode('.jpg', rgb)
           with lock:
               output_jpeg = jpeg.tobytes()
           time.sleep(0.05)

   @app.route('/stream.mjpg')
   def video_feed():
       def generate():
           while True:
               with lock:
                   if output_jpeg is not None:
                       yield (b'--frame\r\n'
                              b'Content-Type: image/jpeg\r\n\r\n' + output_jpeg + b'\r\n')
               time.sleep(0.05)
       return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')

   if __name__ == '__main__':
       init_camera()
       threading.Thread(target=capture_loop, daemon=True).start()
       app.run(host='127.0.0.1', port=5000, debug=False)
   ```

3. **Make it a systemd service** (`/etc/systemd/system/noir-streamer.service`):

   ```
   [Unit]
   Description=NoIR Camera MJPEG Streamer
   After=network.target dev-media1.device
   Wants=dev-media1.device

   [Service]
   ExecStartPre=/bin/sleep 2
   ExecStart=/usr/bin/python3 /home/mpiuser/noir_stream.py
   Restart=always
   User=mpiuser
   Group=mpiuser

   [Install]
   WantedBy=multi-user.target
   ```

4. **Enable and start**:
   ```bash
   sudo systemctl daemon-reload
   sudo systemctl enable noir-streamer
   sudo systemctl start noir-streamer
   ```

5. **Verify**:
   ```bash
   curl http://localhost:5000/stream.mjpg
   ```
   You should see a continuous stream of binary JPEG frames.

### 5.3 node1 – SSH tunnel to node7

1. **Install autossh**:
   ```bash
   sudo apt install autossh
   ```

2. **Create the tunnel service** (`/etc/systemd/system/smartcam-tunnel.service`):

   ```
   [Unit]
   Description=SmartCam Night Stream Tunnel
   After=network-online.target
   Wants=network-online.target

   [Service]
   User=mpiuser
   ExecStart=/usr/bin/autossh -M 0 -N -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" -L 5099:localhost:5000 node7
   Restart=always
   RestartSec=10

   [Install]
   WantedBy=multi-user.target
   ```

3. **Ensure mpiuser can SSH to node7 without password** (run once):
   ```bash
   sudo -u mpiuser ssh -o StrictHostKeyChecking=accept-new mpiuser@node7 echo ok
   ```

4. **Start and test**:
   ```bash
   sudo systemctl enable smartcam-tunnel
   sudo systemctl start smartcam-tunnel
   curl http://localhost:5099/stream.mjpg   # should produce JPEG stream
   ```

### 5.4 node1 – Display daemon

1. **Install Python dependencies** (on DietPi, package names may vary slightly):
   ```bash
   sudo apt update
   sudo apt install python3-requests python3-picamera2 python3-pil python3-numpy python3-opencv python3-evdev
   ```

2. **Disable any display manager** (the daemon uses the raw framebuffer):
   ```bash
   sudo systemctl stop lightdm        # or whatever DM DietPi uses
   sudo systemctl disable lightdm
   ```
   After a reboot, you should only have a text console. The Waveshare screen may stay black – that’s fine.

3. **Identify the framebuffer device** – for the ILI9486, it’s `/dev/fb0`. Confirm with:
   ```bash
   cat /sys/class/graphics/fb0/name   # should output "fb_ili9486"
   ```

4. **Create the daemon script** at `/usr/local/bin/smartcam-displayd`:
   (Use the full script from the final version below – it includes manual RGB565 packing to avoid Pillow encoder issues on DietPi.)

   ```python
   #!/usr/bin/env python3
   import os, mmap, time, threading, socket, signal
   import numpy as np
   import cv2
   from picamera2 import Picamera2
   import requests
   import evdev
   from evdev import ecodes
   from PIL import Image

   # ------ Configuration ------
   FRAMEBUFFER_DEV = '/dev/fb0'
   SCREEN_WIDTH    = 480
   SCREEN_HEIGHT   = 320
   NOIR_STREAM_URL = 'http://localhost:5099/stream.mjpg'
   KEYBOARD_DEV    = '/dev/input/by-id/usb-Logitech_USB_Keyboard-event-kbd'  # adjust to your keyboard
   CONTROL_SOCKET  = '/run/smartcam/control.sock'
   # ---------------------------

   class State:
       def __init__(self):
           self.lock = threading.Lock()
           self.active_source = 'hq'
           self.hq_frame = None
           self.noir_frame = None
           self.running = True

   state = State()

   # Open framebuffer
   fb_fd = os.open(FRAMEBUFFER_DEV, os.O_RDWR)
   buf_size = SCREEN_WIDTH * SCREEN_HEIGHT * 2  # 16 bits per pixel
   fb_buf = mmap.mmap(fb_fd, buf_size, mmap.MAP_SHARED, mmap.PROT_WRITE)

   def write_frame_to_fb(rgb_image):
       """Convert RGB numpy array to RGB565 and write to framebuffer."""
       img = Image.fromarray(rgb_image).resize((SCREEN_WIDTH, SCREEN_HEIGHT))
       arr = np.array(img, dtype=np.uint8)
       r = (arr[:,:,0] >> 3).astype(np.uint16)
       g = (arr[:,:,1] >> 2).astype(np.uint16)
       b = (arr[:,:,2] >> 3).astype(np.uint16)
       packed = (r << 11) | (g << 5) | b
       fb_buf.seek(0)
       fb_buf.write(packed.tobytes())

   class HQCamera(threading.Thread):
       def __init__(self):
           super().__init__(daemon=True)
           self.picam2 = Picamera2()
           config = self.picam2.create_video_configuration(main={"size": (640, 480)})
           self.picam2.configure(config)
       def run(self):
           self.picam2.start()
           time.sleep(1)
           while state.running:
               frame = self.picam2.capture_array()
               rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
               with state.lock:
                   state.hq_frame = rgb
               time.sleep(0.05)

   class NoirStream(threading.Thread):
       def __init__(self):
           super().__init__(daemon=True)
       def run(self):
           while state.running:
               try:
                   r = requests.get(NOIR_STREAM_URL, stream=True, timeout=2.0)
                   buf = b''
                   for chunk in r.iter_content(chunk_size=1024):
                       if not state.running: break
                       buf += chunk
                       a = buf.find(b'\xff\xd8')
                       b = buf.find(b'\xff\xd9')
                       if a != -1 and b != -1:
                           jpg = buf[a:b+2]
                           buf = buf[b+2:]
                           img = cv2.imdecode(np.frombuffer(jpg, dtype=np.uint8), cv2.IMREAD_COLOR)
                           if img is not None:
                               rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
                               with state.lock:
                                   state.noir_frame = rgb
               except Exception as e:
                   print(f"Noir stream error: {e}")
                   time.sleep(1)

   class Display(threading.Thread):
       def __init__(self):
           super().__init__(daemon=True)
       def run(self):
           while state.running:
               with state.lock:
                   source = state.active_source
                   frame = state.hq_frame if source == 'hq' else state.noir_frame
               if frame is not None:
                   write_frame_to_fb(frame)
               time.sleep(0.05)

   class Keyboard(threading.Thread):
       def __init__(self, dev_path):
           super().__init__(daemon=True)
           self.dev = evdev.InputDevice(dev_path)
       def run(self):
           for event in self.dev.read_loop():
               if event.type == ecodes.EV_KEY and event.value == 1:
                   if event.code == ecodes.KEY_F3:
                       with state.lock:
                           state.active_source = 'noir' if state.active_source == 'hq' else 'hq'
                           print(f"Switched to {state.active_source}")

   def command_server():
       if os.path.exists(CONTROL_SOCKET):
           os.remove(CONTROL_SOCKET)
       os.makedirs(os.path.dirname(CONTROL_SOCKET), exist_ok=True)
       server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
       server.bind(CONTROL_SOCKET)
       os.chmod(CONTROL_SOCKET, 0o660)
       server.listen(1)
       server.settimeout(0.5)
       while state.running:
           try:
               conn, _ = server.accept()
               data = conn.recv(1024).decode().strip()
               if data == 'switch hq':
                   state.active_source = 'hq'
                   conn.sendall(b'OK\n')
               elif data == 'switch noir':
                   state.active_source = 'noir'
                   conn.sendall(b'OK\n')
               elif data == 'status':
                   conn.sendall(f'Active source: {state.active_source}\n'.encode())
               elif data == 'stop':
                   state.running = False
                   conn.sendall(b'OK: stopping\n')
               else:
                   conn.sendall(b'ERROR\n')
               conn.close()
           except socket.timeout:
               continue
       server.close()
       os.remove(CONTROL_SOCKET)

   if __name__ == '__main__':
       # Auto-detect keyboard if hardcoded path doesn't exist
       import glob
       kbd_path = KEYBOARD_DEV
       if not os.path.exists(kbd_path):
           for dev in glob.glob('/dev/input/by-id/*-kbd'):
               kbd_path = dev
               break
       print(f"Using keyboard: {kbd_path}")
       threads = [
           HQCamera(),
           NoirStream(),
           Display(),
           Keyboard(kbd_path),
           threading.Thread(target=command_server, daemon=True)
       ]
       for t in threads:
           t.start()
       signal.pause()
   ```

5. **Create the systemd unit** (`/etc/systemd/system/smartcam-displayd.service`):

   ```
   [Unit]
   Description=SmartCam Display Daemon
   After=smartcam-tunnel.service
   Wants=smartcam-tunnel.service
   RuntimeDirectory=smartcam

   [Service]
   ExecStart=/usr/bin/python3 /usr/local/bin/smartcam-displayd
   Restart=always
   User=root
   Group=root

   [Install]
   WantedBy=multi-user.target
   ```

6. **Enable and start**:
   ```bash
   sudo systemctl daemon-reload
   sudo systemctl enable smartcam-displayd
   sudo systemctl start smartcam-displayd
   ```

   The Waveshare screen should now show the HQ camera preview.

### 5.5 Remote control script

Create `/usr/local/bin/smartcam-ctl`:

```bash
#!/bin/bash
SOCKET="/run/smartcam/control.sock"
if [ ! -S "$SOCKET" ]; then
    echo "Daemon not running"
    exit 1
fi
if [ $# -eq 0 ]; then
    echo "Usage: $0 {switch hq|switch noir|status|stop}"
    exit 1
fi
echo "$*" | socat - UNIX-CONNECT:"$SOCKET"
```

Make it executable:

```bash
sudo chmod +x /usr/local/bin/smartcam-ctl
```

Now any user with root privileges (or via `sudo`) can control the display.

---

## 6. Usage

### Local control

- **F3** on the USB keyboard toggles between **HQ camera** and **NoIR camera**.
- The display updates automatically.

### Remote control (from any SSH session)

```bash
# From your workstation (or another cluster node)
ssh J4v@node1

# Check current source
sudo smartcam-ctl status

# Switch to night vision
sudo smartcam-ctl switch noir

# Switch back to HQ
sudo smartcam-ctl switch hq

# Stop the daemon (if needed)
sudo smartcam-ctl stop
```

You can also view the NoIR stream directly on your local machine by setting up an ad‑hoc tunnel:

```bash
ssh -L 5099:localhost:5000 mpiuser@node7
# Then open http://localhost:5099/stream.mjpg in a browser or VLC.
```

### Starting and stopping the services

```bash
# On node7
sudo systemctl start|stop|restart noir-streamer

# On node1
sudo systemctl start|stop|restart smartcam-tunnel
sudo systemctl start|stop|restart smartcam-displayd
```

All services are enabled to start automatically on boot.

---

## 7. Troubleshooting

| Symptom                                  | Likely cause                         | Fix                                                  
|------------------------------------------|--------------------------------------|--------------------
| `ModuleNotFoundError: requests`        
| Missing Python packages              
| `sudo apt install python3-requests python3-pil python3-evdev ...`       
| `FileNotFoundError: /dev/fb1`            
| Wrong framebuffer device             
| Check `/proc/fb` and adjust `FRAMEBUFFER_DEV` to `/dev/fb0`             
| Screen stays black after daemon starts   
| Wrong framebuffer or desktop running 
| Stop lightdm, ensure SPI overlay loaded, check `cat /sys/class/graphics/fb0/name` 
| `curl localhost:5099` – empty reply      
| Tunnel up but streamer not serving   
| Check `noir-streamer` status on node7, ensure it’s listening on 127.0.0.1 
| `autossh` fails with “Address already in use” 
| Something else using port 5099    
| `sudo lsof -i :5099` and kill the process                               
| Keyboard F3 not working                  
| Wrong input device path              
| List with `ls /dev/input/by-id/*kbd*` and update `KEYBOARD_DEV`         
| Pillow encoder error (`RGB;16`)          
| DietPi missing Pillow encoders       
| Use the manual numpy RGB565 conversion (already in the provided script) 


---

## 8. Next steps – towards an AI travel companion

This base system is now fully remote‑operable and stable. The next phases include:

- **Hailo‑8L AI integration** – a new thread inside `smartcam-displayd` runs object detection on the HQ camera frames, draws bounding boxes, and logs findings.
- **Video recording with detection logs** – triggered by a new socket command, records a video file and timestamps every detection.
- **GPRS alerting** – on specific AI events (landmark, traffic jam), send an SMS via the serial‑attached SIM800 module.
- **Local RAG travel guide** – a quantised LLM runs on node7’s 16 GB RAM, fed with guidebook PDFs; node1 queries it on demand and overlays answers on the screen.

All these extensions use the same SSH tunnel and Unix socket architecture, preserving security and simplicity.

---

*Built with a Beowulf‑style mpiuser communication layer, DietPi on the head, and Raspberry Pi OS Lite on the sensor node. No cloud, no external dependencies – everything runs on the edge.*