Jachi N. Karabelov
Published © GPL3+

The AI Newscaster: Zero-Dependency News with Arduino Uno Q

An edge-vision desktop companion that detects you and instantly fetches your news without bloated cloud APIs.

IntermediateFull instructions provided1 hour20
The AI Newscaster: Zero-Dependency News with Arduino Uno Q

Things used in this project

Hardware components

Arduino UNO Q
Arduino UNO Q
×1
KooingTech HBV-W202012HD USB Webcam
×1
Waveshare 2 inch LCD Module (ST7789V)
×1
1x4 Tactile Button Module
×1
Arduino 8-in-1 USB-C Hub
×1
5V/3A Power Bank
×1

Software apps and online services

Arduino App Lab
Edge Impulse Studio
Edge Impulse Studio

Hand tools and fabrication machines

Premium Male/Male Jumper Wires, 40 x 3" (75mm)
Premium Male/Male Jumper Wires, 40 x 3" (75mm)

Story

Read more

Code

sketch.ino

C/C++
MCU Controller for AI Newscaster
// =============================================================================
// sketch.ino  MCU Controller for AI Newscaster
// =============================================================================
// Runs on the STM32U585 MCU side of the Arduino UNO Q.
// Handles: ST7789V LCD rendering, 4-button input, and Bridge RPC
// communication with the MPU (Python) side.
//
// Pin Assignments:
//   LCD:     SPI1 (MOSI=D11, SCK=D13, CS=D10), DC=D9, RST=D8, BL=D7
//   Buttons: UP=D2, DOWN=D3, SELECT=D4, BACK=D5 (INPUT_PULLUP)
//
// Bridge RPC Protocol:
//   MCU  MPU calls:
//     "on_scan_pressed"            User pressed SELECT in IDLE
//     "on_category_select:<index>" User selected a category
//     "on_story_nav:<direction>"   User navigated stories ("up"/"down")
//     "on_back"                    User pressed BACK
//
//   MCU  MPU polls:
//     "get_state"                  Returns current system state string
//     "get_display_data"           Returns JSON with screen content
// =============================================================================

#include "display_driver.h"
#include <Arduino_RouterBridge.h>

// ---------------------------------------------------------------------------
// Display Instance
// ---------------------------------------------------------------------------
Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_RST);

// ---------------------------------------------------------------------------
// Button Definitions
// ---------------------------------------------------------------------------
#define BTN_UP 2
#define BTN_DOWN 3
#define BTN_SEL 4
#define BTN_BACK 5

#define NUM_BUTTONS 4
#define DEBOUNCE_MS 50
#define LONG_PRESS_MS 1500

enum ButtonEvent { EVT_NONE, EVT_SHORT, EVT_LONG };

struct Button {
  uint8_t pin;
  bool lastReading;
  bool stableState;
  unsigned long debounceTime;
  unsigned long pressStart;
  bool longHandled;
};

Button buttons[NUM_BUTTONS] = {{BTN_UP, HIGH, HIGH, 0, 0, false},
                               {BTN_DOWN, HIGH, HIGH, 0, 0, false},
                               {BTN_SEL, HIGH, HIGH, 0, 0, false},
                               {BTN_BACK, HIGH, HIGH, 0, 0, false}};

// Button index aliases
#define IDX_UP 0
#define IDX_DOWN 1
#define IDX_SEL 2
#define IDX_BACK 3

// ---------------------------------------------------------------------------
// System State
// ---------------------------------------------------------------------------
enum SystemState {
  STATE_IDLE,
  STATE_SCANNING,
  STATE_PICK_CATEGORY,
  STATE_FETCHING,
  STATE_BRIEFING,
  STATE_ERROR
};

SystemState currentState = STATE_IDLE;
SystemState previousState = STATE_IDLE;
bool screenDirty = true; // Flag to trigger screen redraw

// ---------------------------------------------------------------------------
// Display Data Buffers
// ---------------------------------------------------------------------------
// Story data (received from MPU via Bridge)
#define MAX_STORIES 20
#define MAX_HEADLINE 80
#define MAX_SUMMARY 300

struct Story {
  char headline[MAX_HEADLINE];
  char summary[MAX_SUMMARY];
  char category[20];
};

Story stories[MAX_STORIES];
int storyCount = 0;
int currentStory = 0;

// Topic selection state
#define NUM_CATEGORIES 7
const char *categoryLabels[NUM_CATEGORIES] = {
    "Business", "Entertainment", "General",   "Health",
    "Science",  "Sports",        "Technology"};
int categoryCursor = 0;

// Error message buffer
char errorMsg[120] = "";

// Scanning animation
unsigned long scanAnimFrame = 0;
unsigned long lastScanAnimMs = 0;

// Fetching animation
unsigned long fetchAnimFrame = 0;
unsigned long lastFetchAnimMs = 0;

// ---------------------------------------------------------------------------
// MPU Polling
// ---------------------------------------------------------------------------
unsigned long lastPollMs = 0;
const unsigned long POLL_INTERVAL = 300; // Poll MPU every 300ms

// ---------------------------------------------------------------------------
// Button Reading
// ---------------------------------------------------------------------------

/**
 * Read a button and return its event type.
 * Handles debouncing and long-press detection.
 */
ButtonEvent readButton(int idx) {
  bool reading = digitalRead(buttons[idx].pin);

  if (reading != buttons[idx].lastReading) {
    buttons[idx].debounceTime = millis();
  }
  buttons[idx].lastReading = reading;

  if ((millis() - buttons[idx].debounceTime) > DEBOUNCE_MS) {
    if (reading != buttons[idx].stableState) {
      buttons[idx].stableState = reading;

      if (reading == LOW) {
        // Button just pressed
        buttons[idx].pressStart = millis();
        buttons[idx].longHandled = false;
      }

      if (reading == HIGH && !buttons[idx].longHandled) {
        // Button just released  short press
        return EVT_SHORT;
      }
    }

    // Check for long press while still held down
    if (buttons[idx].stableState == LOW && !buttons[idx].longHandled) {
      if ((millis() - buttons[idx].pressStart) > LONG_PRESS_MS) {
        buttons[idx].longHandled = true;
        return EVT_LONG;
      }
    }
  }

  return EVT_NONE;
}

// ---------------------------------------------------------------------------
// Screen Drawing Functions
// ---------------------------------------------------------------------------

/** IDLE screen: "Hello! Press SELECT to get your news..." */
void drawIdleScreen() {
  clearScreen();
  drawHeader("AI NEWSCASTER");

  drawCenteredMsg("Hello!", 3, CLR_HEADLINE, 80);
  drawCenteredMsg("Press SELECT to", 2, CLR_TEXT, 140);
  drawCenteredMsg("get your news...", 2, CLR_TEXT, 168);

  drawFooter("", "SEL: Scan");
}

/** SCANNING screen: "Scanning face..." with animated dots */
void drawScanningScreen() {
  if (screenDirty) {
    clearScreen();
    drawHeader("SCANNING");
    drawCenteredMsg("Hold still", 2, CLR_TEXT, 100);
    drawCenteredMsg("Scanning face", 2, CLR_WARN, 140);
    screenDirty = false;
  }

  // Animate dots
  if (millis() - lastScanAnimMs > 400) {
    lastScanAnimMs = millis();
    scanAnimFrame++;
    drawAnimDots(108, 170, scanAnimFrame);
  }
}

/** FETCHING NEWS screen */
void drawFetchingScreen() {
  if (screenDirty) {
    clearScreen();
    drawHeader("FETCHING NEWS");
    drawCenteredMsg("Welcome back!", 2, CLR_SUCCESS, 80);
    drawCenteredMsg("Getting your", 2, CLR_TEXT, 130);
    drawCenteredMsg("briefing", 2, CLR_TEXT, 158);
    screenDirty = false;
  }

  if (millis() - lastFetchAnimMs > 400) {
    lastFetchAnimMs = millis();
    fetchAnimFrame++;
    drawAnimDots(108, 190, fetchAnimFrame);
  }
}

/** BRIEFING screen: one story at a time, with headline + summary + counter */
void drawBriefingScreen() {
  clearScreen();
  drawHeader("YOUR BRIEFING");

  if (storyCount == 0) {
    drawCenteredMsg("No stories", 2, CLR_DIM, 120);
    drawCenteredMsg("available", 2, CLR_DIM, 148);
    drawFooter("", "BACK: Home");
    return;
  }

  Story &s = stories[currentStory];

  // Category label
  tft.setTextSize(1);
  tft.setTextColor(CLR_ACCENT);
  tft.setCursor(8, 36);
  tft.print(s.category);

  // Headline (larger font, word-wrapped)
  int afterHeadline =
      drawWordWrapped(8, 52, s.headline, SCREEN_W - 16, CLR_HEADLINE, 2);

  // Divider line
  tft.drawFastHLine(8, afterHeadline + 4, SCREEN_W - 16, CLR_DIM);

  // Summary (smaller font, word-wrapped)
  drawWordWrapped(8, afterHeadline + 12, s.summary, SCREEN_W - 16, CLR_TEXT, 1);

  // Story counter at bottom
  drawStoryCounter(currentStory + 1, storyCount, SCREEN_H - 38);

  drawFooter("UP/DN: Stories", "BACK: Home");
}

/** CATEGORY SELECTION  Single-select list */
void drawCategorySelectScreen() {
  clearScreen();
  drawHeader("SELECT CATEGORY");

  drawMenuList(categoryLabels, NUM_CATEGORIES, categoryCursor, 34);

  drawFooter("UP/DN: Move", "SEL: Confirm");
}

/** ERROR screen */
void drawErrorScreen() {
  clearScreen();
  drawHeader("ERROR");

  drawWordWrapped(16, 80, errorMsg, SCREEN_W - 32, CLR_ERROR, 2);

  drawFooter("", "SEL: OK");
}

// ---------------------------------------------------------------------------
// MPU Data Parsing
// ---------------------------------------------------------------------------

/**
 * Parse a simple key-value display data string from the MPU.
 *
 * Format from MPU (simplified JSON-like, pipe-separated fields):
 *   "state:briefing|story_idx:0|story_count:5|headline:...|summary:...|category:..."
 *   "state:enroll_capture|instruction:Look at camera|progress:3|total:15"
 *   "state:error|message:No internet connection."
 *
 * Full JSON parsing is expensive on MCU; we use a simple delimited format.
 */
String getField(const String &data, const String &key) {
  String search = key + ":";
  int startIdx = data.indexOf(search);
  if (startIdx < 0)
    return "";

  startIdx += search.length();
  int endIdx = data.indexOf('|', startIdx);
  if (endIdx < 0)
    endIdx = data.length();

  return data.substring(startIdx, endIdx);
}

/**
 * Process display data received from the MPU.
 * Updates local buffers and triggers screen redraws.
 */
void processDisplayData(const String &data) {
  String stateStr = getField(data, "state");

  SystemState newState = currentState;

  if (stateStr == "idle")
    newState = STATE_IDLE;
  else if (stateStr == "scanning")
    newState = STATE_SCANNING;
  else if (stateStr == "pick_category")
    newState = STATE_PICK_CATEGORY;
  else if (stateStr == "fetching")
    newState = STATE_FETCHING;
  else if (stateStr == "briefing")
    newState = STATE_BRIEFING;
  else if (stateStr == "error")
    newState = STATE_ERROR;

  // Update story data for briefing
  if (newState == STATE_BRIEFING) {
    String idxStr = getField(data, "story_idx");
    String countStr = getField(data, "story_count");

    if (countStr.length() > 0) {
      storyCount = countStr.toInt();
    }
    if (idxStr.length() > 0) {
      currentStory = idxStr.toInt();
    }

    String headline = getField(data, "headline");
    String category = getField(data, "category");

    if (currentStory >= 0 && currentStory < MAX_STORIES) {
      headline.toCharArray(stories[currentStory].headline, MAX_HEADLINE);
      category.toCharArray(stories[currentStory].category, 20);
    }
  }

  // Update error message
  if (newState == STATE_ERROR) {
    String msg = getField(data, "message");
    msg.toCharArray(errorMsg, sizeof(errorMsg));
  }

  // Detect state change
  if (newState != currentState) {
    previousState = currentState;
    currentState = newState;
    screenDirty = true;
  }
}

// ---------------------------------------------------------------------------
// Main State Machine  Screen Rendering
// ---------------------------------------------------------------------------
void renderCurrentScreen() {
  switch (currentState) {
  case STATE_IDLE:
    if (screenDirty) {
      drawIdleScreen();
      screenDirty = false;
    }
    break;

  case STATE_SCANNING:
    drawScanningScreen(); // Has its own dirty check + animation
    break;

  case STATE_PICK_CATEGORY:
    if (screenDirty) {
      drawCategorySelectScreen();
      screenDirty = false;
    }
    break;

  case STATE_FETCHING:
    drawFetchingScreen(); // Has its own dirty check + animation
    break;

  case STATE_BRIEFING:
    if (screenDirty) {
      drawBriefingScreen();
      screenDirty = false;
    }
    break;

  case STATE_ERROR:
    if (screenDirty) {
      drawErrorScreen();
      screenDirty = false;
    }
    break;
  }
}

// ---------------------------------------------------------------------------
// Main State Machine  Button Handling
// ---------------------------------------------------------------------------
void handleButtons() {
  ButtonEvent evtUp = readButton(IDX_UP);
  ButtonEvent evtDown = readButton(IDX_DOWN);
  ButtonEvent evtSel = readButton(IDX_SEL);
  ButtonEvent evtBack = readButton(IDX_BACK);

  switch (currentState) {

  // ----- IDLE -----
  case STATE_IDLE:
    if (evtSel == EVT_SHORT) {
      Bridge.call("on_scan_pressed");
    }
    break;

  // ----- PICK CATEGORY -----
  case STATE_PICK_CATEGORY:
    if (evtUp == EVT_SHORT) {
      categoryCursor =
          (categoryCursor > 0) ? categoryCursor - 1 : NUM_CATEGORIES - 1;
      screenDirty = true;
    }
    if (evtDown == EVT_SHORT) {
      categoryCursor =
          (categoryCursor < NUM_CATEGORIES - 1) ? categoryCursor + 1 : 0;
      screenDirty = true;
    }
    if (evtSel == EVT_SHORT) {
      char cmd[40];
      snprintf(cmd, sizeof(cmd), "on_category_select:%d", categoryCursor);
      Bridge.call(cmd);
    }
    if (evtBack == EVT_SHORT) {
      Bridge.call("on_back");
    }
    break;

  // ----- BRIEFING: Story navigation -----
  case STATE_BRIEFING:
    if (evtUp == EVT_SHORT || evtDown == EVT_SHORT) {
      if (evtUp == EVT_SHORT && currentStory > 0) {
        currentStory--;
        Bridge.call("on_story_nav:up");
        screenDirty = true;
      }
      if (evtDown == EVT_SHORT && currentStory < storyCount - 1) {
        currentStory++;
        Bridge.call("on_story_nav:down");
        screenDirty = true;
      }
    }
    if (evtBack == EVT_SHORT) {
      Bridge.call("on_back");
    }
    break;

  // ----- ERROR -----
  case STATE_ERROR:
    if (evtSel == EVT_SHORT || evtBack == EVT_SHORT) {
      Bridge.call("on_back");
    }
    break;

  // ----- SCANNING / FETCHING -----
  // No button handling in these states (system is working)
  default:
    break;
  }
}

// ---------------------------------------------------------------------------
// MPU Communication
// ---------------------------------------------------------------------------
void pollMPU() {
  if ((millis() - lastPollMs) < POLL_INTERVAL)
    return;
  lastPollMs = millis();

  // Poll for display data (includes state)
  String displayData;
  if (Bridge.call("get_display_data").result(displayData)) {
    if (displayData.length() > 0) {
      processDisplayData(displayData);
    }
  }

  // Poll for full summary if we are in the briefing state (bypasses payload
  // limits)
  if (currentState == STATE_BRIEFING) {
    String summaryData;
    if (Bridge.call("get_summary_data").result(summaryData)) {
      if (summaryData.length() > 0 &&
          String(stories[currentStory].summary) != summaryData) {
        summaryData.toCharArray(stories[currentStory].summary, MAX_SUMMARY);
        screenDirty = true;
      }
    }
  }
}

// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
void setup() {
  // Initialize Bridge communication
  Bridge.begin();

  // Initialize button pins
  pinMode(BTN_UP, INPUT_PULLUP);
  pinMode(BTN_DOWN, INPUT_PULLUP);
  pinMode(BTN_SEL, INPUT_PULLUP);
  pinMode(BTN_BACK, INPUT_PULLUP);

  // Initialize display
  initDisplay();

  // Draw the initial IDLE screen
  drawIdleScreen();
  screenDirty = false;
}

// ---------------------------------------------------------------------------
// Main Loop
// ---------------------------------------------------------------------------
void loop() {
  // 1. Read buttons and dispatch events
  handleButtons();

  // 2. Poll MPU for state updates and display data
  pollMPU();

  // 3. Render the current screen
  renderCurrentScreen();

  // 4. Bridge housekeeping
  Bridge.update();

  // Small delay to prevent tight-looping
  delay(10);
}

main.py

Python
MPU Controller for AI Newscaster
# =============================================================================
# main.py  MPU Controller for AI Newscaster
# =============================================================================
# Runs on the Qualcomm QRB2210 MPU (Linux) side of the Arduino UNO Q.
# Handles: news fetching (RSS), article formatting, face detection, 
# and Bridge RPC communication with the MCU.
#
# NOTE: Pressing SELECT will trigger a face presence scan via the 
# VideoObjectDetection brick. Once a face is detected, it will prompt 
# the user to select a category, then fetch news for that category.
#
# Bridge RPC Protocol:
#   Provides to MCU:
#     get_display_data()  Returns pipe-delimited display data string
#   Receives from MCU:
#     on_scan_pressed, on_category_select:<idx>, on_story_nav:<dir>, on_back
# =============================================================================

import os
import json
import time
import threading
import urllib.request
import xml.etree.ElementTree as ET

from arduino.app_utils import Bridge, App

try:
    from arduino.app_bricks.video_objectdetection import VideoObjectDetection
except ImportError:
    print("[WARN] VideoObjectDetection brick not available.")
    VideoObjectDetection = None
# =============================================================================
# Configuration
# =============================================================================

# File paths (on MPU filesystem)
BASE_DIR    = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
# Google News RSS categories (matching the MCU's category labels)
CATEGORIES = [
    "business", "entertainment", "general",
    "health", "science", "sports", "technology"
]

# =============================================================================
# Global State
# =============================================================================

# System state  determines what the MCU displays
current_state = "idle"

# Display data  pipe-delimited string sent to MCU
display_data = "state:idle"

# Configuration
config = {}

# Currently selected category
selected_category_idx = 0

# Briefing data
briefing_stories = []  # List of {headline, summary, category}
current_story_idx = 0

# Thread lock for state changes
state_lock = threading.Lock()

# Camera index
CAMERA_INDEX = 0

# Video Object Detector
video_detector = None
is_scanning = False
face_detected_event = threading.Event()
if VideoObjectDetection:
    try:
        video_detector = VideoObjectDetection(confidence=0.4, debounce_sec=1.5)
    except Exception as e:
        print(f"[WARN] Failed to initialize VideoObjectDetection: {e}")

# =============================================================================
# Configuration & Persistence
# =============================================================================

def load_config():
    """Load API keys and settings from config.json."""
    global config, CAMERA_INDEX
    if not os.path.exists(CONFIG_PATH):
        print(f"[WARN] Config file not found at {CONFIG_PATH}")
        print("Creating template config.json...")
        template = {
            "camera_index": 0,
            "news_country": "US"
        }
        with open(CONFIG_PATH, 'w') as f:
            json.dump(template, f, indent=2)
        print(f"[INFO] Template config.json created at {CONFIG_PATH}")
        config = template
        return True  # Continue with defaults

    with open(CONFIG_PATH, 'r') as f:
        config = json.load(f)

    CAMERA_INDEX = config.get("camera_index", 0)
    return True



# =============================================================================
# State Management
# =============================================================================

def set_state(new_state, data_fields=None):
    """
    Update the system state and display data.
    Thread-safe. The MCU polls get_display_data() to receive this.
    """
    global current_state, display_data

    with state_lock:
        current_state = new_state
        parts = [f"state:{new_state}"]
        if data_fields:
            for key, value in data_fields.items():
                parts.append(f"{key}:{value}")
        display_data = "|".join(parts)
        print(f"[STATE]  {new_state}")

# =============================================================================
# News Fetching
# =============================================================================

def fetch_news(topics):
    """
    Fetch top headlines using Google News RSS for the given topic categories.
    """
    country = config.get("news_country", "US")
    all_articles = []

    rss_topic_map = {
        "business": "BUSINESS",
        "entertainment": "ENTERTAINMENT",
        "general": "WORLD",
        "health": "HEALTH",
        "science": "SCIENCE",
        "sports": "SPORTS",
        "technology": "TECHNOLOGY"
    }

    for topic in topics:
        try:
            gnews_topic = rss_topic_map.get(topic.lower(), "WORLD")
            url = f"https://news.google.com/rss/headlines/section/topic/{gnews_topic}?hl=en-{country}&gl={country}&ceid={country}:en"

            req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
            with urllib.request.urlopen(req) as response:
                xml_data = response.read()

            root = ET.fromstring(xml_data)
            channel = root.find("channel")

            if channel is not None:
                items = channel.findall("item")[:5]
                for item in items:
                    title_elem = item.find("title")
                    desc_elem = item.find("description")
                    source_elem = item.find("source")

                    title = title_elem.text if title_elem is not None and title_elem.text else "Untitled"
                    desc = desc_elem.text if desc_elem is not None and desc_elem.text else ""
                    source = source_elem.text if source_elem is not None and source_elem.text else ""

                    all_articles.append({
                        "title": title,
                        "description": desc,
                        "source": source,
                        "category": topic
                    })
                print(f"[NEWS] Fetched {len(items)} articles for '{topic}'")
            else:
                print(f"[NEWS] RSS error for '{topic}' (no channel)")

        except Exception as e:
            print(f"[NEWS] Error fetching '{topic}': {e}")

    return all_articles

# =============================================================================
# News Formatting
# =============================================================================

def summarize_news(articles):
    """
    Return raw news stories (no AI summary) but utilize the full 250-char space.
    """
    stories = []
    if not articles:
        return stories

    print(f"[NEWS] Extracting {len(articles)} articles...")

    import re
    import html
    for i, article in enumerate(articles):
        desc = article.get("description", "No summary available.")
        desc = html.unescape(re.sub(r'<[^>]+>', '', desc))
        
        stories.append({
            "headline": (article.get("title", "Untitled"))[:80],
            "summary": desc[:250],
            "category": article.get("category", "").title()
        })
        print(f"  -> Formatted article {i+1}/{len(articles)}")

    return stories

# =============================================================================
# Scan & Category Selection
# =============================================================================

def on_scan_pressed():
    """User pressed SELECT in IDLE  trigger scan, then go to category selection."""
    # Prevent multiple threads from starting
    if current_state != "idle":
        return True

    def scan_thread():
        global briefing_stories, current_story_idx, is_scanning

        set_state("scanning")

        if video_detector:
            print("[SCAN] Waiting for face...")
            is_scanning = True
            face_detected_event.clear()
            
            # Wait up to 5 seconds for the camera to detect something
            found = face_detected_event.wait(timeout=5.0)
            is_scanning = False
            
            if not found:
                print("[SCAN] No face detected. Returning to idle.")
                set_state("error", {"message": "No face detected"})
                time.sleep(2)
                set_state("idle")
                return
            else:
                print("[SCAN] Face detected! Proceeding to fetch...")
        else:
            print("[SCAN] Video detector not available, skipping scan.")
            time.sleep(1)

        set_state("pick_category")

    thread = threading.Thread(target=scan_thread, daemon=True)
    thread.start()
    return True


def on_category_select(args):
    """User selected a category  fetch news for it."""
    global selected_category_idx
    try:
        selected_category_idx = int(args)
    except ValueError:
        selected_category_idx = 0
        
    if 0 <= selected_category_idx < len(CATEGORIES):
        selected_category = CATEGORIES[selected_category_idx]
    else:
        selected_category = "general"
        
    def fetch_thread():
        global briefing_stories, current_story_idx
        set_state("fetching")
        
        articles = fetch_news([selected_category])
        if not articles:
            set_state("error", {"message": "No news found. Check WiFi."})
            return
            
        stories = summarize_news(articles)
        if stories:
            briefing_stories = stories
            current_story_idx = 0
            set_state("briefing", format_story_data(0))
        else:
            set_state("error", {"message": "News service unavailable."})
            
    thread = threading.Thread(target=fetch_thread, daemon=True)
    thread.start()
    return True


def on_story_nav(args):
    """User navigated stories (up/down)."""
    global current_story_idx

    if args == "up" and current_story_idx > 0:
        current_story_idx -= 1
    elif args == "down" and current_story_idx < len(briefing_stories) - 1:
        current_story_idx += 1

    set_state("briefing", format_story_data(current_story_idx))
    return True


def on_back():
    """User pressed BACK  return to idle from most states."""
    set_state("idle")
    return True




# =============================================================================
# Display Data Formatting
# =============================================================================

def format_story_data(story_idx):
    """Format a story for the MCU display data protocol."""
    if not briefing_stories or story_idx >= len(briefing_stories):
        return {
            "story_idx": "0",
            "story_count": "0",
            "headline": "No stories available",
            "category": ""
        }

    story = briefing_stories[story_idx]
    
    def clean_text(text, max_len):
        text = text.replace("\n", " ").replace("\r", " ").replace("|", "-")
        return text[:max_len]

    return {
        "story_idx": str(story_idx),
        "story_count": str(len(briefing_stories)),
        "headline": clean_text(story["headline"], 80),
        "category": clean_text(story.get("category", ""), 15)
    }




# =============================================================================
# Bridge RPC Providers
# =============================================================================

def get_display_data():
    """Returns the current display data string. Called by MCU polling."""
    return display_data

def get_summary_data():
    """Returns JUST the summary for the current story to bypass RPC limits."""
    if current_state != "briefing" or not briefing_stories:
        return ""
    if current_story_idx >= len(briefing_stories):
        return ""
    
    story = briefing_stories[current_story_idx]
    text = story.get("summary", "")
    text = text.replace("\n", " ").replace("\r", " ").replace("|", "-")
    return text[:250]

# =============================================================================
# Bridge Registration
# =============================================================================

def register_bridge_handlers():
    """Register all Bridge RPC handlers."""

    # Simple handlers (no arguments)
    Bridge.provide("on_scan_pressed",           on_scan_pressed)
    Bridge.provide("on_back",                   on_back)

    # Data provider
    Bridge.provide("get_display_data",          get_display_data)
    Bridge.provide("get_summary_data",          get_summary_data)

    # Parameterized handlers
    for i in range(len(CATEGORIES)):
        name = f"on_category_select:{i}"
        Bridge.provide(name, lambda idx=i: on_category_select(str(idx)))

    Bridge.provide("on_story_nav:up",   lambda: on_story_nav("up"))
    Bridge.provide("on_story_nav:down", lambda: on_story_nav("down"))

# =============================================================================
# Main Entry Point
# =============================================================================

def main():
    print("=" * 60)
    print("  AI NEWSCASTER  Starting up...")
    print("=" * 60)

    # Load configuration
    load_config()



    # Bind Vision Callbacks
    if video_detector:
        def on_all_detections(detections: dict):
            if not detections:
                return
            print(f"[VISION] Detections: {detections}")
            
            global is_scanning
            if is_scanning:
                face_detected_event.set()

        try:
            video_detector.on_detect_all(on_all_detections)
            print("[INFO] Video Object Detection bound to AI-Newscaster-FaceDetect model")
        except Exception as e:
            print(f"[WARN] Failed to bind vision callbacks: {e}")

    # Register Bridge handlers
    register_bridge_handlers()
    print("[INFO] Bridge RPC handlers registered")

    # Set initial state
    set_state("idle")

    print("[INFO] AI Newscaster ready!")
    print("=" * 60)

    # Start the App Lab event loop
    App.run()


if __name__ == "__main__":
    main()

README.md

Markdown
README.md
#  AI Newscaster

An intelligent, face-detecting news briefing device built on the Arduino UNO Q (4GB).

Press the button  the webcam scans for a person  select a category  your news briefing appears on the LCD.

---

## Table of Contents

1. [Overview](#overview)
2. [Bill of Materials](#bill-of-materials)
3. [Phase 1: Setup](#phase-1-setup)
4. [Phase 2: Hardware Wiring](#phase-2-hardware-wiring)
5. [Phase 3: Edge Impulse Model Training](#phase-3-edge-impulse-model-training)
6. [Phase 4: App Lab Project Setup](#phase-4-app-lab-project-setup)
7. [Phase 5: Code Deployment](#phase-5-code-deployment)
8. [Phase 6: Usage Guide](#phase-6-usage-guide)
9. [License](#license)

---

## Overview

The AI Newscaster runs on the Arduino UNO Q's dual-processor architecture:

| Side | Processor | Role |
|------|-----------|------|
| **MPU** (Linux) | Qualcomm QRB2210 | Video object detection (Person), RSS fetching |
| **MCU** (Arduino) | STM32U585 | LCD rendering, button input, screen state machine |

The two processors communicate via **Bridge RPC** (Remote Procedure Calls).

### Key Features
- **On-device person detection**  uses the native VideoObjectDetection brick to wake up.
- **Zero-Dependency Architecture**  uses standard Python libraries for lightning-fast, crash-free execution.
- **No phone or computer needed**  the entire UX happens on the device.

---

## Bill of Materials

### Core Components

| # | Component | Purpose |
|---|-----------|---------|
| 1 | Arduino UNO Q (4GB) | Main board (QRB2210 MPU + STM32U585 MCU) |
| 2 | KooingTech HBV-W202012HD USB UVC mini webcam | Face capture |
| 3 | Waveshare 2" LCD Module (240320, ST7789V) | Display output |
| 4 | 14 Tactile Button Module | User input (UP/DOWN/SELECT/BACK) |
| 5 | Arduino 8-in-1 USB-C Hub | Peripheral connectivity |
| 6 | 5V/3A 10000mAh Power Bank | Portable power |

---

## Phase 1: Setup

This project uses a 100% free, private, and local approach. It uses RSS feeds for lightning-fast news retrieval, and native computer vision to detect when a user is present. You do **not** need any API keys or Google Cloud projects!

### 1.1 RSS News Feeds

News is fetched directly from Google News RSS feeds using Python's built-in `urllib` and `xml` libraries. It requires no authentication, has no strict rate limits, and requires zero external Python packages to install.

### 1.2 Arduino App Lab & WiFi Setup

1. Download and install **Arduino App Lab** (version 0.7 or newer).
2. Connect your **Arduino UNO Q** to your computer using a USB-C cable.
3. Open Arduino App Lab and ensure your board is detected in the top menu.
4. **Connect the board to WiFi**:
   - Open the **Terminal** tab at the bottom of the App Lab window (this connects you directly to the board's Linux MPU).
   - Type the following command and press Enter to open the visual network manager:
     ```nmtui```
   - A visual menu will appear in your terminal. Use your keyboard's **Arrow Keys** to select **"Activate a connection"** and press Enter.
   - Select your WiFi network from the list, press Enter, type your password, and hit Enter again.
   - Once connected, press **ESC** a few times to exit the menu.

---

## Phase 2: Hardware Wiring

### Pin Assignment Table

#### ST7789V LCD  MCU (SPI)

| LCD Pin | UNO Q Pin | Function |
|---------|-----------|----------|
| VCC | **3.3V** | Power (**NOT 5V!**) |
| GND | GND | Ground |
| DIN (MOSI) | D11 | SPI Data |
| CLK (SCK) | D13 | SPI Clock |
| CS | D10 | Chip Select |
| DC | D9 | Data/Command |
| RST | D8 | Reset |
| BL | D7 | Backlight |

#### 4-Button Module  MCU

| Button | UNO Q Pin | Function |
|--------|-----------|----------|
| Button 1 | D2 | UP |
| Button 2 | D3 | DOWN |
| Button 3 | D4 | SELECT |
| Button 4 | D5 | BACK |
| GND | GND | Common ground |

#### USB Webcam  MPU (via hub)

| Connection | Path |
|------------|------|
| Webcam USB |  USB-C Hub  UNO Q USB-C port |

### Assembly Steps

1. **Place the UNO Q** on your work surface, USB-C port facing you.
2. **Wire the LCD:** Connect VCC3.3V, GNDGND, then the 6 signal wires (DIND11, CLKD13, CSD10, DCD9, RSTD8, BLD7). Use short wires for SPI signals.
3. **Wire the buttons:** Connect 4 signal pins to D2-D5, and the module's GND to board GND. The sketch uses `INPUT_PULLUP`, so buttons should pull pins LOW when pressed.
4. **Connect the USB hub:** Plug into UNO Q's USB-C port. Plug webcam into a USB-A port on the hub.
5. **Power:** Connect your PC to the hub's PD passthrough or directly to the UNO Q.

---

## Phase 3: Edge Impulse Model Training

Train a single-class **face detection** model. This model only needs to detect *that a face exists*  identification is handled by the device's native flow.

### 3.1 Create Project
1. Log in to [Edge Impulse Studio](https://studio.edgeimpulse.com)
2. Create a new project: **AI-Newscaster-FaceDetect**

### 3.2 Upload Data
1. Collect 200-500 face images (varied lighting, angles, skin tones)
   - Recommended: use a public dataset like [WIDER FACE](http://shuoyang1213.me/WIDERFACE/)
2. **Data acquisition**  **Add data**  **Upload data**
3. Select **Automatically split between training and testing**
4. Confirm this is an **Object Detection** project

### 3.3 Label Data
1. **Data acquisition**  **AI Labeling**
2. Select **Bounding box labeling with OWL-ViT**
3. Set prompt: `"A human face (face, 0.2)"`
4. Click **Label Preview Data**, verify accuracy
5. Click **Label Dataset**
6. Review and manually fix any bad labels

### 3.4 Design Impulse
1. **Impulse design**  **Create Impulse**
2. Set target device: **Arduino UNO Q**
3. Image input: **320320**, resize mode: **Fit shortest axis**
4. Processing block: **Image**
5. Learning block: **Object Detection (Images)**
6. **Save Impulse**

### 3.5 Train
1. **Impulse design**  **Image**: color depth **RGB**, click **Save parameters**, then **Generate features**
2. **Impulse design**  **Object detection**: 
   - Under **Neural network architecture**, click **Choose a different model** and select **MobileNetV2 SSD FPN-Lite (320x320 only)**.
   - Number of training cycles: **50**
   - Learning rate: **0.1**
3. Click **Save & train**
4. Target: **mAP@50 > 0.50** (unoptimized float32)

### 3.6 Deploy
1. **Model testing**  **Classify all** (verify on test data)
2. **Deployment**  search **Arduino UNO Q**  **Unoptimized (float32)**  **Build**
3. Note your project ID from the URL (you'll need it for `app.yaml`)

---

## Phase 4: App Lab Project Setup

### 4.1 Create Project
1. Open Arduino App Lab
2. Create a new project: **AI Newscaster**

### 4.2 Add Video Object Detection Brick
1. Go to the **Bricks** panel
2. Add **Video Object Detection** (`video_object_detection`)
3. Under **AI Models**, link your Edge Impulse account
4. Select the **AI-Newscaster-FaceDetect** model
5. Verify `app.yaml` has the correct `model_id` (edit manually via terminal if needed)

### 4.3 Install Arduino Libraries
In App Lab's library manager, install:
- **Adafruit GFX Library**
- **Adafruit ST7735 and ST7789 Library**

---

## Phase 5: Code Deployment

### 5.1 Configure app.yaml
Check `app.yaml` and confirm it has your actual Edge Impulse project ID.

### 5.2 Deploy
1. Click **Run** in App Lab
2. App Lab will:
   - Compile the C++ sketch for the STM32
   - Deploy the video_object_detection Brick container
   - Start the Python environment

---

## Phase 6: Usage Guide

### Daily Use
1. Power on the device (plug in the power bank).
2. Wait for the LCD to show **"Hello! Press SELECT to get your news..."**
3. Press **SELECT** to trigger the camera.
4. Look into the camera! Once it detects a face, you will be prompted to select a news category.
5. Use **UP/DOWN** to highlight a category and press **SELECT** to confirm.
6. The device fetches your news. Once your briefing appears, use **UP/DOWN** to navigate stories.
7. Press **BACK** to return to the idle screen.

---

## License

This project is open source. Built for the Arduino Project Hub.

Credits

Jachi N. Karabelov
1 project • 1 follower

Comments