Pollux Labs
Published © MIT

AI-powered Storyteller for Kids

RFID animal cards + Raspberry Pi + on-device AI = unique stories for kids, displayed on your phone.

IntermediateFull instructions provided3 hours276
AI-powered Storyteller for Kids

Things used in this project

Hardware components

Raspberry Pi 5
Raspberry Pi 5
×1
NeoPixel Ring: WS2812 5050 RGB LED
Adafruit NeoPixel Ring: WS2812 5050 RGB LED
×1
RFID Reader RC522
×1
RFID Cards
×1
Animal Stickers
×1
Jumper wires (generic)
Jumper wires (generic)
×1

Software apps and online services

VS Code
Microsoft VS Code

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)

Story

Read more

Schematics

Wiring diagram

Code

raspi-storyteller.py

Python
the core of the AI Storyteller
# -*- coding: utf-8 -*-
# Raspberry Pi AI Storyteller 
# Pollux Labs
# https://polluxlabs.io

import time
import logging
import threading
import json
import os
from flask import Flask, render_template, jsonify, request

# Import for the LED ring
from pi5neo import Pi5Neo
# Import for artificial intelligence
import ollama
# Import for the RFID reader
from rc522_spi_library import RC522SPILibrary, StatusCodes


# --- Configuration ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
MAP_FILE = 'animal_map.json'
STORY_GENERATION_DELAY = 7.0

# LED Configuration
LED_COUNT = 12
LED_PIN_SPI = '/dev/spidev0.0' # SPI path for the Pi 5

# --- Global Variables ---
animal_map = {}
last_unknown_uid = None
story_generation_timer = None
is_generating_story = False # "Busy" status to avoid conflicts
app_state = {
    "selected_animals": [],
    "story": {
        "protagonists": "No animals selected yet",
        "text": "Hold one or more animal cards to the reader to start a story."
    }
}

# --- Load and Save Functions ---
def load_animal_map():
    global animal_map
    if os.path.exists(MAP_FILE):
        with open(MAP_FILE, 'r') as f:
            animal_map = json.load(f)
    else:
        save_animal_map()

def save_animal_map():
    with open(MAP_FILE, 'w') as f:
        json.dump(animal_map, f, indent=4)

# --- Custom LED Ring Control Class for Animations ---
class LEDRing:
    def __init__(self, spi_path, count):
        self.pixels = Pi5Neo(spi_path, count)
        self.count = count
        self._animation_thread = None
        self._stop_animation = threading.Event()

    def solid(self, color_tuple):
        self.pixels.fill_strip(*color_tuple)
        self.pixels.update_strip()

    def off(self):
        self._stop_animation.set()
        if self._animation_thread:
            self._animation_thread.join()
        self.pixels.fill_strip(0, 0, 0)
        self.pixels.update_strip()

    def _glow_animation(self):
        """Endless loop for the glowing animation."""
        color = (0, 50, 150) # Dark Blue
        while not self._stop_animation.is_set():
            # Turn on LEDs one by one
            for i in range(self.count):
                if self._stop_animation.is_set(): break
                self.pixels.set_led_color(i, *color)
                self.pixels.update_strip()
                time.sleep(0.05)

            if self._stop_animation.is_set(): break
            time.sleep(0.3)

            # Turn off all LEDs at once
            self.pixels.clear_strip()
            self.pixels.update_strip()
            time.sleep(0.5)

    def thinking(self):
        """Starts the thinking animation in a separate thread."""
        if self._animation_thread and self._animation_thread.is_alive():
            return # Animation is already running
        self._stop_animation.clear()
        self._animation_thread = threading.Thread(target=self._glow_animation)
        self._animation_thread.daemon = True
        self._animation_thread.start()

    def stop_thinking(self):
        """Stops the thinking animation."""
        self._stop_animation.set()
        if self._animation_thread:
            self._animation_thread.join()
        self.off()

    def success(self):
        self.solid((0, 150, 0)) # Darker Green
        time.sleep(1.5)
        self.off()

# --- Flask Web App & API Endpoints ---
app = Flask(__name__)
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/manage')
def manage():
    return render_template('manage.html')

@app.route('/api/state')
def get_state():
    return jsonify(app_state)

@app.route('/api/cards')
def get_cards():
    global last_unknown_uid
    return jsonify({
        'animal_map': animal_map,
        'last_unknown_uid': last_unknown_uid
    })

@app.route('/api/add_card', methods=['POST'])
def add_card():
    global animal_map, last_unknown_uid
    data = request.json
    uid = data.get('uid')
    animal = data.get('animal')
    if not uid or not animal:
        return jsonify({'success': False, 'message': 'UID or animal missing.'}), 400
    animal_map[uid] = animal
    save_animal_map()
    if uid == last_unknown_uid:
        last_unknown_uid = None
    logging.info(f"Card added: {uid} -> {animal}")
    return jsonify({'success': True})

@app.route('/api/delete_card', methods=['POST'])
def delete_card():
    global animal_map
    data = request.json
    uid = data.get('uid')
    if uid in animal_map:
        del animal_map[uid]
        save_animal_map()
        logging.info(f"Card deleted: {uid}")
        return jsonify({'success': True})
    return jsonify({'success': False, 'message': 'Card not found.'}), 404


# --- Core Logic (RFID & Story) ---
def trigger_story_generation():
    global app_state, led_ring, is_generating_story
    if not app_state["selected_animals"]:
        return

    is_generating_story = True
    logging.info(f"Timer expired. Creating story for: {app_state['selected_animals']}")
    
    try:
        led_ring.thinking() # Starts the endless animation

        if len(app_state["selected_animals"]) > 1:
            protagonists = ", ".join(app_state["selected_animals"][:-1]) + " and " + app_state["selected_animals"][-1]
        else:
            protagonists = app_state["selected_animals"][0]

        prompt = f"Tell a child-friendly story in which the following animals appear: {protagonists}. The story should not be longer than 15 sentences and always have a happy ending. Write only the story, no additional elements."

        response = ollama.chat(
            model='gemma3:1b',
            messages=[{'role': 'user', 'content': prompt}]
        )
        story_text = response['message']['content']
        app_state["story"] = {"protagonists": protagonists, "text": story_text}
        
        led_ring.stop_thinking() # Stops the animation
        led_ring.success()

    except Exception as e:
        logging.error(f"Error communicating with Ollama: {e}")
        app_state["story"]["text"] = "I couldn't create a story. Is Ollama running?"
        led_ring.solid((255, 0, 0))
        time.sleep(2)
        led_ring.off()
    finally:
        app_state["selected_animals"] = []
        is_generating_story = False

def rfid_main_loop():
    global last_unknown_uid, story_generation_timer, app_state, led_ring, is_generating_story
    logging.info("Starting RFID Storyteller...")

    reader = None
    try:
        reader = RC522SPILibrary(rst_pin=22)
        last_uid_processed = None
        led_ring.off() # Ensure the ring is off at the start

        while True:
            if is_generating_story:
                time.sleep(0.1)
                continue 

            status, _ = reader.request()
            if status == StatusCodes.OK:
                status, uid = reader.anticoll()
                if status == StatusCodes.OK and uid != last_uid_processed:
                    last_uid_processed = uid
                    uid_str = ":".join([f"{i:02X}" for i in uid])

                    if uid_str in animal_map:
                        animal = animal_map[uid_str]
                        logging.info(f"Known card: {uid_str} ({animal})")
                        led_ring.solid((0, 150, 0)) # Green
                        time.sleep(1)
                        led_ring.off() # Light off again

                        if story_generation_timer: story_generation_timer.cancel()
                        if animal not in app_state["selected_animals"]:
                            app_state["selected_animals"].append(animal)
                        app_state["story"] = {"protagonists": "", "text": ""}
                        story_generation_timer = threading.Timer(STORY_GENERATION_DELAY, trigger_story_generation)
                        story_generation_timer.start()
                    else:
                        logging.warning(f"Unknown card: {uid_str}.")
                        last_unknown_uid = uid_str
                        led_ring.solid((150, 80, 0)) # Orange
                        time.sleep(1.5)
                        led_ring.off() # Light off again
            else:
                last_uid_processed = None

            time.sleep(0.2)

    except KeyboardInterrupt:
        logging.info("Program is shutting down.")
    finally:
        if reader:
            reader.cleanup()
        led_ring.off()

if __name__ == '__main__':
    load_animal_map()
    led_ring = LEDRing(LED_PIN_SPI, LED_COUNT)
    
    web_thread = threading.Thread(target=lambda: app.run(host='0.0.0.0', port=5000, debug=False))
    web_thread.daemon = True
    web_thread.start()
    
    rfid_main_loop()

index.html

HTML
Website on which the story appears
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Raspi Storyteller</title>
    <style>
        :root {
            --bg-color-light: #f0f8ff;
            --text-color-light: #333;
            --container-bg-light: #ffffff;
            --header-color-light: #4a90e2;
            --protagonist-color-light: #50a3a2;
            --collection-text-light: #555;

            --bg-color-dark: #121212;
            --text-color-dark: #e0e0e0;
            --container-bg-dark: #1e1e1e;
            --header-color-dark: #7bb0ff;
            --protagonist-color-dark: #80cbc4;
            --collection-text-dark: #bbb;
        }
        body { 
            font-family: 'Segoe UI', sans-serif; 
            background-color: var(--bg-color-light); 
            color: var(--text-color-light); 
            display: flex; 
            justify-content: center; 
            align-items: center; 
            min-height: 100vh; 
            margin: 0; 
            padding: 20px; 
            box-sizing: border-box;
            transition: background-color 0.3s, color 0.3s;
        }
        body.dark-mode {
            background-color: var(--bg-color-dark);
            color: var(--text-color-dark);
        }
        .container { 
            background-color: var(--container-bg-light); 
            border-radius: 15px; 
            box-shadow: 0 8px 16px rgba(0,0,0,0.1); 
            padding: 40px; 
            max-width: 600px; 
            width: 100%; 
            text-align: center; 
            position: relative; 
            min-height: 300px;
            transition: background-color 0.3s;
        }
        body.dark-mode .container {
            background-color: var(--container-bg-dark);
            box-shadow: 0 8px 16px rgba(0,0,0,0.3);
        }
        h1 { 
            color: var(--header-color-light); 
            font-size: 2em; 
            margin-bottom: 20px; 
        }
        body.dark-mode h1 {
            color: var(--header-color-dark);
        }
        #story-container { min-height: 150px; }
        #collection-info {
            color: var(--collection-text-light);
            font-size: 1.3em;
        }
        body.dark-mode #collection-info {
            color: var(--collection-text-dark);
        }
        #collection-info span {
            color: var(--protagonist-color-light);
            font-weight: bold;
        }
        body.dark-mode #collection-info span {
            color: var(--protagonist-color-dark);
        }
        #protagonists {
            color: var(--protagonist-color-light);
            font-size: 1.5em;
            font-weight: bold;
            margin-bottom: 20px;
        }
        body.dark-mode #protagonists {
            color: var(--protagonist-color-dark);
        }
        #story-text {
            font-size: 1.2em;
            line-height: 1.6;
            text-align: left;
            white-space: pre-wrap;
        }
        .nav-link { 
            position: absolute; 
            top: 20px; 
            right: 20px; 
            color: var(--header-color-light); 
            text-decoration: none; 
            font-weight: bold; 
        }
        body.dark-mode .nav-link {
            color: var(--header-color-dark);
        }
        .theme-switch {
            position: absolute;
            top: 20px;
            left: 20px;
            display: inline-block;
            width: 50px;
            height: 24px;
        }
        .theme-switch input { display: none; }
        .slider {
            position: absolute;
            cursor: pointer;
            top: 0; left: 0; right: 0; bottom: 0;
            background-color: #ccc;
            transition: .4s;
            border-radius: 24px;
        }
        .slider:before {
            position: absolute;
            content: "";
            height: 18px;
            width: 18px;
            left: 3px;
            bottom: 3px;
            background-color: white;
            transition: .4s;
            border-radius: 50%;
        }
        input:checked + .slider { background-color: #4a90e2; }
        input:checked + .slider:before { transform: translateX(26px); }
    </style>
</head>
<body>
    <div class="container">
        <label class="theme-switch">
            <input type="checkbox" id="theme-toggle">
            <span class="slider"></span>
        </label>
        <a href="/manage" class="nav-link">Manage Cards &rarr;</a>
        <h1>Raspi Storyteller</h1>
        
        <div id="story-container">
            <!-- Content is loaded dynamically -->
        </div>
    </div>

    <script>
        const storyContainer = document.getElementById('story-container');
        const themeToggle = document.getElementById('theme-toggle');
        const body = document.body;

        // Dark Mode Logic
        function setDarkMode(isDark) {
            if (isDark) {
                body.classList.add('dark-mode');
                themeToggle.checked = true;
                localStorage.setItem('theme', 'dark');
            } else {
                body.classList.remove('dark-mode');
                themeToggle.checked = false;
                localStorage.setItem('theme', 'light');
            }
        }

        themeToggle.addEventListener('change', () => {
            setDarkMode(themeToggle.checked);
        });

        // Apply saved theme on page load
        document.addEventListener('DOMContentLoaded', () => {
            const savedTheme = localStorage.getItem('theme') || 'light';
            setDarkMode(savedTheme === 'dark');
        });


        // Fetch state from server
        async function fetchState() {
            try {
                const response = await fetch('/api/state');
                const state = await response.json();

                storyContainer.innerHTML = ''; // Clear

                if (state.selected_animals.length > 0) {
                    const animalList = state.selected_animals.join(', ');
                    storyContainer.innerHTML = `
                        <div id="collection-info">
                            <p>Your story will feature:</p>
                            <span>${animalList}</span>
                        </div>
                    `;
                } else {
                    storyContainer.innerHTML = `
                        <p id="protagonists">Story about: ${state.story.protagonists}</p>
                        <p id="story-text">${state.story.text}</p>
                    `;
                }
            } catch (error) {
                console.error("Error fetching state:", error);
                storyContainer.innerHTML = "<p>Connection to the storyteller lost.</p>";
            }
        }
        
        setInterval(fetchState, 2000);
        fetchState();
    </script>
</body>
</html>

manage.html

HTML
Website where you manage the RFID cards
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Manage Cards - Raspi Storyteller</title>
    <style>
        :root {
            --bg-color-light: #f4f7f6;
            --text-color-light: #333;
            --container-bg-light: #ffffff;
            --header-color-light: #4a90e2;
            --border-color-light: #e0e0e0;
            --table-header-bg-light: #f8f8f8;
            --form-bg-light: #e8f4f8;
            --input-border-light: #ccc;
            --uid-note-color-light: #666;

            --bg-color-dark: #121212;
            --text-color-dark: #e0e0e0;
            --container-bg-dark: #1e1e1e;
            --header-color-dark: #7bb0ff;
            --border-color-dark: #444;
            --table-header-bg-dark: #2c2c2c;
            --form-bg-dark: #252a2e;
            --input-border-dark: #555;
            --uid-note-color-dark: #aaa;
        }
        body { 
            font-family: sans-serif; 
            background-color: var(--bg-color-light); 
            color: var(--text-color-light); 
            margin: 0; 
            padding: 20px;
            transition: background-color 0.3s, color 0.3s;
        }
        body.dark-mode {
            background-color: var(--bg-color-dark);
            color: var(--text-color-dark);
        }
        .container { 
            max-width: 800px; 
            margin: auto; 
            background: var(--container-bg-light); 
            padding: 25px; 
            border-radius: 10px; 
            box-shadow: 0 4px 8px rgba(0,0,0,0.1);
            transition: background-color 0.3s;
            position: relative;
        }
        body.dark-mode .container {
            background-color: var(--container-bg-dark);
            box-shadow: 0 4px 8px rgba(0,0,0,0.3);
        }
        h1, h2 { 
            color: var(--header-color-light); 
            border-bottom: 2px solid var(--border-color-light); 
            padding-bottom: 10px; 
        }
        body.dark-mode h1, body.dark-mode h2 {
            color: var(--header-color-dark);
            border-bottom-color: var(--border-color-dark);
        }
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { 
            padding: 12px; 
            text-align: left; 
            border-bottom: 1px solid var(--border-color-light); 
        }
        body.dark-mode th, body.dark-mode td {
            border-bottom-color: var(--border-color-dark);
        }
        th { background-color: var(--table-header-bg-light); }
        body.dark-mode th { background-color: var(--table-header-bg-dark); }
        .form-container { 
            background-color: var(--form-bg-light); 
            padding: 20px; 
            border-radius: 8px; 
            margin-top: 30px; 
        }
        body.dark-mode .form-container {
            background-color: var(--form-bg-dark);
        }
        input[type="text"] { 
            width: calc(100% - 22px); 
            padding: 10px; 
            border: 1px solid var(--input-border-light); 
            border-radius: 4px; 
            background-color: var(--container-bg-light);
            color: var(--text-color-light);
        }
        body.dark-mode input[type="text"] {
            background-color: var(--bg-color-dark);
            color: var(--text-color-dark);
            border-color: var(--input-border-dark);
        }
        .btn { padding: 10px 15px; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
        .btn-primary { background-color: #50a3a2; color: white; }
        .btn-danger { background-color: #e57373; color: white; }
        .nav-link { 
            display: inline-block; 
            margin-bottom: 20px; 
            color: var(--header-color-light); 
            text-decoration: none; 
            font-weight: bold; 
        }
        body.dark-mode .nav-link {
            color: var(--header-color-dark);
        }
        .uid-note { 
            font-style: italic; 
            color: var(--uid-note-color-light); 
            margin-top: 10px; 
        }
        body.dark-mode .uid-note {
            color: var(--uid-note-color-dark);
        }
        .header-controls {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 20px;
        }
        .theme-switch {
            position: relative;
            display: inline-block;
            width: 50px;
            height: 24px;
        }
        .theme-switch input { display: none; }
        .slider {
            position: absolute;
            cursor: pointer;
            top: 0; left: 0; right: 0; bottom: 0;
            background-color: #ccc;
            transition: .4s;
            border-radius: 24px;
        }
        .slider:before {
            position: absolute;
            content: "";
            height: 18px;
            width: 18px;
            left: 3px;
            bottom: 3px;
            background-color: white;
            transition: .4s;
            border-radius: 50%;
        }
        input:checked + .slider { background-color: #4a90e2; }
        input:checked + .slider:before { transform: translateX(26px); }
    </style>
</head>
<body>
    <div class="container">
        <div class="header-controls">
            <a href="/" class="nav-link">&larr; Back to the Story</a>
            <label class="theme-switch">
                <input type="checkbox" id="theme-toggle">
                <span class="slider"></span>
            </label>
        </div>
        <h1>Manage Cards</h1>

        <h2>Registered Animals</h2>
        <table id="cards-table">
            <thead>
                <tr>
                    <th>Card UID</th>
                    <th>Assigned Animal</th>
                    <th>Action</th>
                </tr>
            </thead>
            <tbody>
                <!-- Content is loaded dynamically -->
            </tbody>
        </table>

        <div class="form-container">
            <h2>Register New Card</h2>
            <p class="uid-note">Hold a new card to the reader. Its UID will appear below automatically.</p>
            <form id="add-card-form">
                <p><strong>Card UID:</strong><br>
                <input type="text" id="new-card-uid" name="uid" placeholder="UID appears here..." readonly required></p>
                
                <p><strong>Animal Name (e.g., "a cheeky monkey"):</strong><br>
                <input type="text" id="new-card-animal" name="animal" placeholder="Enter animal..." required></p>
                
                <button type="submit" class="btn btn-primary">Save Card</button>
            </form>
        </div>
    </div>

    <script>
        const cardsTableBody = document.querySelector("#cards-table tbody");
        const addCardForm = document.getElementById("add-card-form");
        const newCardUidInput = document.getElementById("new-card-uid");
        const newCardAnimalInput = document.getElementById("new-card-animal");
        const themeToggle = document.getElementById('theme-toggle');
        const body = document.body;

        // Dark Mode Logic
        function setDarkMode(isDark) {
            if (isDark) {
                body.classList.add('dark-mode');
                themeToggle.checked = true;
                localStorage.setItem('theme', 'dark');
            } else {
                body.classList.remove('dark-mode');
                themeToggle.checked = false;
                localStorage.setItem('theme', 'light');
            }
        }

        themeToggle.addEventListener('change', () => {
            setDarkMode(themeToggle.checked);
        });

        // Apply saved theme on page load
        document.addEventListener('DOMContentLoaded', () => {
            const savedTheme = localStorage.getItem('theme') || 'light';
            setDarkMode(savedTheme === 'dark');
        });

        // Loads and displays the card data
        async function loadCards() {
            const response = await fetch('/api/cards');
            const data = await response.json();

            cardsTableBody.innerHTML = ''; // Clear

            for (const [uid, animal] of Object.entries(data.animal_map)) {
                const row = cardsTableBody.insertRow();
                row.innerHTML = `
                    <td>${uid}</td>
                    <td>${animal}</td>
                    <td><button class="btn btn-danger" onclick="deleteCard('${uid}')">Delete</button></td>
                `;
            }

            if (data.last_unknown_uid) {
                newCardUidInput.value = data.last_unknown_uid;
            }
        }

        // Deletes a card
        async function deleteCard(uid) {
            if (!confirm(`Are you sure you want to delete the card ${uid}?`)) return;

            await fetch('/api/delete_card', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ uid: uid })
            });
            loadCards();
        }

        // Event listener for adding a card
        addCardForm.addEventListener('submit', async (event) => {
            event.preventDefault();
            const uid = newCardUidInput.value;
            const animal = newCardAnimalInput.value;

            if (!uid) {
                alert("Please hold a new card to the reader first.");
                return;
            }

            await fetch('/api/add_card', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ uid: uid, animal: animal })
            });
            
            newCardUidInput.value = '';
            newCardAnimalInput.value = '';
            loadCards();
        });

        loadCards();
        setInterval(loadCards, 3000);
    </script>
</body>
</html>

Credits

Pollux Labs
13 projects • 23 followers
I work in the field of user experience. Besides that, as a maker, I create projects focused on electronics, microcontrollers, and AI.

Comments