diff --git a/examples/audio-classification/python/main.py b/examples/audio-classification/python/main.py index 41d7235..af02ff4 100644 --- a/examples/audio-classification/python/main.py +++ b/examples/audio-classification/python/main.py @@ -13,41 +13,13 @@ # Global state AUDIO_DIR = "/app/assets/audio" -audio_classifier = None - -def get_audio_classifier(): - """Lazy initialization of audio classifier""" - global audio_classifier - if audio_classifier is None: - try: - from arduino.app_peripherals.microphone import Microphone - try: - audio_classifier = AudioClassification(mic=None) - except: - class MockMicrophone: - def __init__(self): - self.sample_rate = 16000 - self.channels = 1 - def start_recording(self): pass - def stop_recording(self): pass - def read(self): return b'' - mock_mic = MockMicrophone() - audio_classifier = AudioClassification(mic=mock_mic) - except Exception as e: - raise e - return audio_classifier def parse_data(data): - """Parse incoming data - handle both string and dict""" if isinstance(data, str): - try: - return json.loads(data) - except: - return {} + return json.loads(data) return data if isinstance(data, dict) else {} def on_run_classification(sid, data): - """Run classification""" try: parsed_data = parse_data(data) confidence = parsed_data.get('confidence', 0.5) @@ -65,11 +37,9 @@ def on_run_classification(sid, data): return with open(file_path, "rb") as f: input_audio = io.BytesIO(f.read()) - if input_audio: - classifier = get_audio_classifier() start_time = time.time() * 1000 - results = classifier.classify_from_file(input_audio, confidence) + results = AudioClassification.classify_from_file(input_audio, confidence) diff = time.time() * 1000 - start_time response_data = { 'results': results, 'processing_time': diff } @@ -77,7 +47,7 @@ def on_run_classification(sid, data): response_data['classification'] = { 'class_name': results["class_name"], 'confidence': results["confidence"] } else: response_data['error'] = "No objects detected in the audio. Try to lower the confidence threshold." - + ui.send_message('classification_complete', response_data, sid) else: ui.send_message('classification_error', {'message': "No audio available for classification"}, sid) diff --git a/examples/bedtime-story-teller/README.md b/examples/bedtime-story-teller/README.md new file mode 100644 index 0000000..10b71f3 --- /dev/null +++ b/examples/bedtime-story-teller/README.md @@ -0,0 +1,175 @@ +# Bedtime Story Teller + +The **Bedtime Story Teller** example demonstrates how to build a generative AI application using the Arduino UNO Q. It uses a Large Language Model (LLM) to create personalized bedtime stories based on user-selected parameters like age, theme, and characters, streaming the result in real-time to a web interface. + +![Bedtime Story Teller Example](assets/docs_assets/thumbnail.png) + +## Description + +This App transforms the UNO Q into an AI storytelling assistant. It uses the `cloud_llm` Brick to connect to a cloud-based AI model and the `web_ui` Brick to provide a rich configuration interface. + +The workflow allows you to craft a story by selecting specific parameters—such as the child's age, story theme, tone, and specific characters—or to let the App **generate a story randomly** for a quick surprise. The backend constructs a detailed prompt, sends it to the AI model, and streams the generated story back to the browser text-token by text-token. + +## Bricks Used + +The bedtime story teller example uses the following Bricks: + +- `cloud_llm`: Brick to interact with cloud-based Large Language Models (LLMs) like Google Gemini, OpenAI GPT, or Anthropic Claude. +- `web_ui`: Brick to create the web interface for parameter input and story display. + +## Hardware and Software Requirements + +### Hardware + +- Arduino UNO Q (x1) +- USB-C® cable (for power and programming) (x1) + +### Software + +- Arduino App Lab + +**Note:** This example requires an active internet connection to reach the AI provider's API. You will also need a valid **API Key** for the service used (e.g., Google AI Studio API Key). + +## How to Use the Example + +This example requires a valid API Key from an LLM provider (Google Gemini, OpenAI GPT, or Anthropic Claude) and an internet connection. + +### Configure & Launch App + +1. **Duplicate the Example** + Since built-in examples are read-only, you must duplicate this App to edit the configuration. Click the arrow next to the App name and select **Duplicate** or click the **Copy and edit app** button on the top right corner of the App page. + ![Duplicate example](assets/docs_assets/duplicate-app.png) + +2. **Open Brick Configuration** + On the App page, locate the **Bricks** section on the left. Click on the **Cloud LLM** Brick, then click the **Brick Configuration** button on the right side of the screen. + ![Open Brick Configuration](assets/docs_assets/brick-config.png) + +3. **Add API Key** + In the configuration panel, enter your API Key into the corresponding field. This securely saves your credentials for the App to use. You can generate an API key from your preferred provider: + * **Google Gemini:** [Get API Key](https://aistudio.google.com/app/apikey) + * **OpenAI GPT:** [Get API Key](https://platform.openai.com/api-keys) + * **Anthropic Claude:** [Get API Key](https://console.anthropic.com/settings/keys) + + ![Enter your API KEY](assets/docs_assets/brick-credentials.png) + +4. **Run the App** + Launch the App by clicking the **Run** button in the top right corner. Wait for the App to start. + ![Launch the App](assets/docs_assets/launch-app.png) + +5. **Access the Web Interface** + Open the App in your browser at `:7000`. + +### Interacting with the App + +1. **Choose Your Path** + You have two options to create a story: + * **Option A: Manual Configuration** (Follow step 2) + * **Option B: Random Generation** (Skip to step 3) + +2. **Set Parameters (Manual)** + Use the interactive interface to configure the story details. The interface unlocks sections sequentially: + - **Age:** Select the target audience (3-5, 6-8, 9-12, 13-16 years, or Adult). + - **Theme:** Choose a genre (Fantasy/Adventure, Fairy Tale, Mystery/Horror, Science/Universe, Animals, or Comedy). + - **Story Type (Optional):** Fine-tune the narrative: + - *Tone:* e.g., Calm and sweet, Epic and adventurous, Tense and grotesque. + - *Ending:* e.g., Happy, With a moral, Open and mysterious. + - *Structure:* Classic, Chapter-based, or Episodic. + - *Duration:* Short (5 min), Medium (10-15 min), or Long (20+ min). + - **Characters:** You must add **at least one character** (max 5). Define their Name, Description, and Role (Protagonist, Antagonist, Positive/Negative Helper, or Other). + - **Generate:** Once ready, click the **Generate story** button. + +3. **Generate Randomly** + If you prefer a surprise, click the **Generate Randomly** button on the right side of the screen. The App will automatically select random options for age, theme, tone, and structure to create a unique story instantly. + +4. **Interact** + The story streams in real-time. Once complete, you can: + - **Copy** the text to your clipboard. + - Click **New story** to reset the interface and start over. + +## How it Works + +Once the App is running, it performs the following operations: + +- **User Input Collection**: The `web_ui` Brick serves an HTML page where users select story attributes via interactive "chips" and forms. +- **Prompt Engineering**: When the user requests a story, the Python backend receives a JSON object containing all parameters. It dynamically constructs a natural language prompt optimized for the LLM (e.g., "As a parent... I need a story about [Theme]..."). +- **AI Inference**: The `cloud_llm` Brick sends this prompt to the configured cloud provider using the API Key set in the Brick Configuration. +- **Stream Processing**: Instead of waiting for the full text, the backend receives the response in chunks (tokens) and forwards them immediately to the frontend via WebSockets, ensuring the user sees progress instantly. + +## Understanding the Code + +### 🔧 Backend (`main.py`) + +The Python script handles the logic of connecting to the AI and managing the data flow. Note that the API Key is not hardcoded; it is retrieved automatically from the Brick configuration. + +- **Initialization**: The `CloudLLM` is set up with a system prompt that enforces HTML formatting for the output. The `CloudModel` constants map to specific efficient model versions: + * `CloudModel.GOOGLE_GEMINI` → `gemini-2.5-flash` + * `CloudModel.OPENAI_GPT` → `gpt-4o-mini` + * `CloudModel.ANTHROPIC_CLAUDE` → `claude-3-7-sonnet-latest` + +```python +# The API Key is loaded automatically from the Brick Configuration +llm = CloudLLM( + model=CloudModel.GOOGLE_GEMINI, + system_prompt="You are a bedtime story teller. Your response must be the story itself, formatted directly in HTML..." +) +llm.with_memory() +``` + +- **Prompt Construction**: The `generate_story` function translates the structured data from the UI into a descriptive text prompt for the AI. + +```python +def generate_story(_, data): + # Extract parameters + age = data.get('age', 'any') + theme = data.get('theme', 'any') + + # Build natural language prompt + prompt_for_display = f"As a parent who loves to read bedtime stories to my {age} year old child..." + + # ... logic to append characters and settings ... + + # Stream response back to UI + prompt_for_llm = re.sub('<[^>]*>', '', prompt_for_display) # Clean tags for LLM + for resp in llm.chat_stream(prompt_for_llm): + ui.send_message("response", resp) + + ui.send_message("stream_end", {}) +``` + +### 🔧 Frontend (`app.js`) + +The JavaScript manages the complex UI interactions, random generation logic, and WebSocket communication. + +- **Random Generation**: If the user chooses "Generate Randomly", the frontend programmatically selects random chips from the available options and submits the request. + +```javascript +document.getElementById('generate-randomly-button').addEventListener('click', () => { + // Select random elements from the UI lists + const ageChips = document.querySelectorAll('.parameter-container:nth-child(1) .chip'); + const randomAgeChip = getRandomElement(ageChips); + // ... repeat for theme, tone, etc ... + + const storyData = { + age: randomAgeChip ? randomAgeChip.textContent.trim() : 'any', + // ... + characters: [], // Random stories use generic characters + }; + + generateStory(storyData); +}); +``` + +- **Socket Listeners**: The frontend listens for chunks of text and appends them to the display buffer, creating the streaming effect. + +```javascript +socket.on('response', (data) => { + document.getElementById('story-container').style.display = 'flex'; + storyBuffer += data; // Accumulate text +}); + +socket.on('stream_end', () => { + const storyResponse = document.getElementById('story-response'); + storyResponse.innerHTML = storyBuffer; // Final render + document.getElementById('loading-spinner').style.display = 'none'; +}); +``` diff --git a/examples/bedtime-story-teller/app.yaml b/examples/bedtime-story-teller/app.yaml new file mode 100644 index 0000000..2b59f5b --- /dev/null +++ b/examples/bedtime-story-teller/app.yaml @@ -0,0 +1,9 @@ +name: Bedtime story teller +icon: 🏰 +description: + This example shows how to create a bedtime story teller using Arduino. + It uses a cloud-based language model to generate a story based on user input and shows the story on a web interface. + +bricks: + - arduino:cloud_llm + - arduino:web_ui diff --git a/examples/bedtime-story-teller/assets/app.js b/examples/bedtime-story-teller/assets/app.js new file mode 100644 index 0000000..bf3d785 --- /dev/null +++ b/examples/bedtime-story-teller/assets/app.js @@ -0,0 +1,553 @@ +// SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +// +// SPDX-License-Identifier: MPL-2.0 + +const socket = io(`http://${window.location.host}`); + +let generateStoryButtonOriginalHTML = ''; // To store the original content of the generate story button +let storyBuffer = ''; + +// Error container elements +const errorContainer = document.getElementById('error-container'); + +function showError(message) { + errorContainer.textContent = message; + errorContainer.style.display = 'block'; +} + +function hideError() { + errorContainer.style.display = 'none'; + errorContainer.textContent = ''; +} + + +function handlePrompt(data) { + const promptContainer = document.getElementById('prompt-container'); + const promptDisplay = document.getElementById('prompt-display'); + promptDisplay.innerHTML = data; + promptContainer.style.display = 'block'; +} + +function handleResponse(data) { + document.getElementById('story-container').style.display = 'flex'; + storyBuffer += data; +} + +function handleStreamEnd() { + hideError(); // Hide any errors on successful stream end + + const storyResponse = document.getElementById('story-response'); + storyResponse.innerHTML = storyBuffer; + + document.getElementById('loading-spinner').style.display = 'none'; + const clearStoryButton = document.getElementById('clear-story-button'); + clearStoryButton.style.display = 'block'; + clearStoryButton.disabled = false; + + const generateStoryButton = document.querySelector('.generate-story-button'); + if (generateStoryButton) { + generateStoryButton.disabled = false; + generateStoryButton.innerHTML = generateStoryButtonOriginalHTML; // Restore original content + } +} + +function handleStoryError(data) { + // Hide the loading spinner + document.getElementById('loading-spinner').style.display = 'none'; + + // Restore the generate story button + const generateStoryButton = document.querySelector('.generate-story-button'); + if (generateStoryButton) { + generateStoryButton.disabled = false; + generateStoryButton.innerHTML = generateStoryButtonOriginalHTML; + } + + // Display the error message in the dedicated error container + showError(`An error occurred while generating the story: ${data.error}`); + + // Also show the "New story" button to allow the user to restart + const clearStoryButton = document.getElementById('clear-story-button'); + clearStoryButton.style.display = 'block'; + clearStoryButton.disabled = false; +} + +function initSocketIO() { + socket.on('prompt', handlePrompt); + socket.on('response', handleResponse); + socket.on('stream_end', handleStreamEnd); + socket.on('story_error', handleStoryError); + + socket.on('connect', () => { + hideError(); // Clear any previous errors on successful connection + }); + + socket.on('disconnect', () => { + showError("Connection to backend lost. Please refresh the page or check the backend server."); + }); +} + +function unlockAndOpenNext(currentContainer) { + const nextContainer = currentContainer.nextElementSibling; + if (nextContainer && nextContainer.classList.contains('parameter-container')) { + if (nextContainer.classList.contains('disabled')) { + nextContainer.classList.remove('disabled'); + const content = nextContainer.querySelector('.parameter-content'); + const arrow = nextContainer.querySelector('.arrow-icon'); + if (content.style.display !== 'block') { + content.style.display = 'block'; + arrow.classList.add('rotated'); + } + } + } +} + +function getRandomElement(elements) { + if (elements.length === 0) return null; + const randomIndex = Math.floor(Math.random() * elements.length); + return elements[randomIndex]; +} + +function setupChipSelection(container) { + const chips = container.querySelectorAll('.chip'); + const selectedValue = container.querySelector('.selected-value'); + chips.forEach(chip => { + chip.addEventListener('click', (event) => { + event.stopPropagation(); + const alreadySelected = chip.classList.contains('selected'); + chips.forEach(c => c.classList.remove('selected')); + chip.classList.add('selected'); + if (selectedValue) { + selectedValue.innerHTML = chip.innerHTML; + selectedValue.style.display = 'inline-flex'; + } + if (!alreadySelected) { + unlockAndOpenNext(container); + } + + // Collapse the current container + const content = container.querySelector('.parameter-content'); + const arrow = container.querySelector('.arrow-icon'); + content.style.display = 'none'; + arrow.classList.remove('rotated'); + + }); + }); +} + +function setupStoryTypeSelection(container) { + const paragraphs = container.querySelectorAll('.story-type-paragraph'); + paragraphs.forEach(paragraph => { + const chips = paragraph.querySelectorAll('.chip'); + chips.forEach(chip => { + chip.addEventListener('click', (event) => { + event.stopPropagation(); + const paragraphChips = paragraph.querySelectorAll('.chip'); + paragraphChips.forEach(c => c.classList.remove('selected')); + chip.classList.add('selected'); + updateStoryTypeHeader(container); + const selectedChips = container.querySelectorAll('.chip.selected'); + if (selectedChips.length === paragraphs.length) { + unlockAndOpenNext(container); + } + }); + }); + }); +} + +function updateStoryTypeHeader(container) { + const optionalText = container.querySelector('.optional-text'); + const selectedChips = container.querySelectorAll('.chip.selected'); + const content = container.querySelector('.parameter-content'); + const isOpen = content.style.display === 'block'; + optionalText.innerHTML = ''; + if (selectedChips.length === 0) { + optionalText.textContent = '(optional)'; + return; + } + if (isOpen) { + Array.from(selectedChips).forEach(chip => { + const pill = document.createElement('span'); + pill.className = 'selection-pill'; + pill.innerHTML = chip.innerHTML; + optionalText.appendChild(pill); + }); + } else { + const firstTwo = Array.from(selectedChips).slice(0, 2); + firstTwo.forEach(chip => { + const pill = document.createElement('span'); + pill.className = 'selection-pill'; + pill.innerHTML = chip.innerHTML; + optionalText.appendChild(pill); + }); + const remaining = selectedChips.length - 2; + if (remaining > 0) { + const plusSpan = document.createElement('span'); + plusSpan.className = 'plus-x'; + plusSpan.style.display = 'inline-block'; + plusSpan.textContent = `+${remaining}`; + optionalText.appendChild(plusSpan); + } + } +} + +function checkCharactersAndUnlockNext(charactersContainer) { + const characterGroups = charactersContainer.querySelectorAll('.character-input-group'); + let atLeastOneCharacterEntered = false; + characterGroups.forEach(group => { + const nameInput = group.querySelector('.character-name'); + const roleSelect = group.querySelector('.character-role'); + if (nameInput.value.trim() !== '' && roleSelect.value !== '') { + atLeastOneCharacterEntered = true; + } + }); + const generateButton = document.querySelector('.generate-story-button'); + if (atLeastOneCharacterEntered) { + unlockAndOpenNext(charactersContainer); + generateButton.style.display = 'flex'; + } else { + generateButton.style.display = 'none'; + } +} + +function gatherDataAndGenerateStory() { + document.querySelectorAll('.parameter-container').forEach(container => { + const content = container.querySelector('.parameter-content'); + if (content && content.style.display === 'block') { + content.style.display = 'none'; + const arrow = container.querySelector('.arrow-icon'); + if (arrow) { + arrow.classList.remove('rotated'); + } + } + }); + + const age = document.querySelector('.parameter-container:nth-child(1) .chip.selected')?.textContent.trim() || 'any'; + const theme = document.querySelector('.parameter-container:nth-child(2) .chip.selected')?.textContent.trim() || 'any'; + const storyTypeContainer = document.querySelector('.parameter-container:nth-child(3)'); + const tone = storyTypeContainer.querySelector('.story-type-paragraph:nth-child(1) .chip.selected')?.textContent.trim() || 'any'; + const endingType = storyTypeContainer.querySelector('.story-type-paragraph:nth-child(2) .chip.selected')?.textContent.trim() || 'any'; + const narrativeStructure = storyTypeContainer.querySelector('.story-type-paragraph:nth-child(3) .chip.selected')?.textContent.trim() || 'any'; + const duration = storyTypeContainer.querySelector('.story-type-paragraph:nth-child(4) .chip.selected')?.textContent.trim() || 'any'; + + const characters = []; + const characterGroups = document.querySelectorAll('.character-input-group'); + characterGroups.forEach(group => { + const name = group.querySelector('.character-name').value.trim(); + const role = group.querySelector('.character-role').value; + const description = group.querySelector('.character-description').value.trim(); + if (name && role) { + characters.push({ name, role, description }); + } + }); + + const other = document.querySelector('.other-textarea').value.trim(); + + const storyData = { + age, + theme, + tone, + endingType, + narrativeStructure, + duration, + characters, + other, + }; + + generateStory(storyData); +} + +function generateStory(data) { + hideError(); // Hide any errors when starting a new generation + document.querySelector('.story-output-placeholder').style.display = 'none'; + const responseArea = document.getElementById('story-response-area'); + responseArea.style.display = 'flex'; + document.getElementById('prompt-container').style.display = 'none'; + document.getElementById('prompt-display').textContent = ''; + document.getElementById('story-container').style.display = 'none'; + document.getElementById('story-response').innerHTML = ''; // Use innerHTML to clear + storyBuffer = ''; // Reset buffer + document.getElementById('loading-spinner').style.display = 'block'; // Show the general loading spinner + + const generateStoryButton = document.querySelector('.generate-story-button'); + if (generateStoryButton) { + generateStoryButton.disabled = true; + // Append the spinner instead of replacing innerHTML + generateStoryButton.innerHTML += '
'; + } + + document.getElementById('clear-story-button').style.display = 'none'; + socket.emit('generate_story', data); +} + +function resetStoryView() { + hideError(); // Hide any errors when resetting view + document.querySelector('.story-output-placeholder').style.display = 'flex'; + const responseArea = document.getElementById('story-response-area'); + responseArea.style.display = 'none'; + document.getElementById('prompt-container').style.display = 'none'; + document.getElementById('story-container').style.display = 'none'; + document.getElementById('prompt-display').innerHTML = ''; + document.getElementById('story-response').textContent = ''; + + // Reset parameter selections + document.querySelectorAll('.chip.selected').forEach(chip => { + chip.classList.remove('selected'); + }); + + document.querySelectorAll('.selected-value').forEach(selectedValue => { + selectedValue.innerHTML = ''; + selectedValue.style.display = 'none'; + }); + + // Reset Story type optional text + document.querySelectorAll('.parameter-container:nth-child(3) .optional-text').forEach(optionalText => { + optionalText.textContent = '(optional)'; + }); + + // Clear character inputs and remove extra groups + const characterInputGroups = document.querySelectorAll('.character-input-group'); + characterInputGroups.forEach((group, index) => { + if (index === 0) { // Only clear the first group, others will be removed + group.querySelector('.character-name').value = ''; + group.querySelector('.character-role').selectedIndex = 0; + group.querySelector('.character-description').value = ''; + group.querySelector('.delete-character-button').style.display = 'none'; + } else { + group.remove(); + } + }); + document.querySelector('.add-character-button').style.display = 'block'; // Ensure add character button is visible + + // Clear "Other" textarea + const otherTextarea = document.querySelector('.other-textarea'); + if (otherTextarea) { + otherTextarea.value = ''; + const charCounter = document.querySelector('.char-counter'); + if (charCounter) { + charCounter.textContent = `0 / ${otherTextarea.maxLength}`; + } + } + + // Restore "Generate story" button to original state + const generateStoryButton = document.querySelector('.generate-story-button'); + if (generateStoryButton) { + generateStoryButton.style.display = 'none'; // Keep hidden if no chars, will be set to flex by checkCharactersAndUnlockNext + generateStoryButton.disabled = false; + generateStoryButton.innerHTML = generateStoryButtonOriginalHTML; + } + + // Reset parameter containers state + const parameterContainers = document.querySelectorAll('.parameter-container'); + parameterContainers.forEach((container, index) => { + const content = container.querySelector('.parameter-content'); + const arrow = container.querySelector('.arrow-icon'); + + if (index === 0) { // Age container + content.style.display = 'block'; + arrow.classList.add('rotated'); + container.classList.remove('disabled'); + } else { + if (container.id !== 'prompt-container') { + container.classList.add('disabled'); + } + content.style.display = 'none'; + arrow.classList.remove('rotated'); + } + }); +} + +document.addEventListener('DOMContentLoaded', () => { + initSocketIO(); + + const generateStoryButton = document.querySelector('.generate-story-button'); + if (generateStoryButton) { + generateStoryButtonOriginalHTML = generateStoryButton.innerHTML; // Store original content + } + + const parameterContainers = document.querySelectorAll('.parameter-container'); + + parameterContainers.forEach((container, index) => { + if (index === 0) { + const content = container.querySelector('.parameter-content'); + const arrow = container.querySelector('.arrow-icon'); + content.style.display = 'block'; + arrow.classList.add('rotated'); + } else { + if (container.id !== 'prompt-container') { + container.classList.add('disabled'); + } + } + }); + + parameterContainers.forEach(container => { + const title = container.querySelector('.parameter-title').textContent; + const header = container.querySelector('.parameter-header'); + header.addEventListener('click', () => { + if (container.classList.contains('disabled')) return; + const content = container.querySelector('.parameter-content'); + const arrow = container.querySelector('.arrow-icon'); + arrow.classList.toggle('rotated'); + if (content.style.display === 'block') { + content.style.display = 'none'; + } else { + content.style.display = 'block'; + } + if (title === 'Story type') { + updateStoryTypeHeader(container); + } else if (title === 'Other') { + const textarea = container.querySelector('.other-textarea'); + const charCounter = container.querySelector('.char-counter'); + const maxLength = textarea.maxLength; + textarea.addEventListener('input', () => { + const currentLength = textarea.value.length; + charCounter.textContent = `${currentLength} / ${maxLength}`; + }); + } + }); + + if (title === 'Story type') { + setupStoryTypeSelection(container); + } else if (title === 'Characters') { + const charactersList = container.querySelector('.characters-list'); + charactersList.addEventListener('input', () => { + checkCharactersAndUnlockNext(container); + }); + container.querySelector('.add-character-button').addEventListener('click', () => { + checkCharactersAndUnlockNext(container); + }); + } else if (title === 'Other') { + container.querySelector('.other-textarea').addEventListener('input', () => unlockAndOpenNext(container), { once: true }); + } else { + setupChipSelection(container); + } + }); + + const addCharacterButton = document.querySelector('.add-character-button'); + const charactersList = document.querySelector('.characters-list'); + const characterInputGroup = document.querySelector('.character-input-group'); + addCharacterButton.addEventListener('click', () => { + const characterGroups = document.querySelectorAll('.character-input-group'); + if (characterGroups.length < 5) { + const newCharacterGroup = characterInputGroup.cloneNode(true); + newCharacterGroup.querySelector('.character-name').value = ''; + newCharacterGroup.querySelector('.character-role').selectedIndex = 0; + newCharacterGroup.querySelector('.character-description').value = ''; + const deleteButton = newCharacterGroup.querySelector('.delete-character-button'); + deleteButton.style.display = 'block'; + deleteButton.addEventListener('click', () => { + newCharacterGroup.remove(); + if (document.querySelectorAll('.character-input-group').length < 5) { + addCharacterButton.style.display = 'block'; + } + checkCharactersAndUnlockNext(document.querySelector('.parameter-container:nth-child(4)')); + }); + charactersList.appendChild(newCharacterGroup); + if (document.querySelectorAll('.character-input-group').length === 5) { + addCharacterButton.style.display = 'none'; + } + } + }); + + document.querySelector('.generate-story-button').addEventListener('click', gatherDataAndGenerateStory); + + + const modal = document.getElementById('new-story-modal'); + const clearButton = document.getElementById('clear-story-button'); + const closeButton = document.querySelector('.close-button'); + const confirmButton = document.getElementById('confirm-new-story-button'); + + clearButton.addEventListener('click', () => { + modal.style.display = 'flex'; + }); + + closeButton.addEventListener('click', () => { + modal.style.display = 'none'; + }); + + confirmButton.addEventListener('click', () => { + resetStoryView(); + modal.style.display = 'none'; + }); + + window.addEventListener('click', (event) => { + if (event.target == modal) { + modal.style.display = 'none'; + } + }); + + document.getElementById('copy-story-button').addEventListener('click', () => { + const storyText = document.getElementById('story-response').innerText; + const copyButton = document.getElementById('copy-story-button'); + const originalHTML = copyButton.innerHTML; + const textarea = document.createElement('textarea'); + textarea.value = storyText; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + copyButton.textContent = 'Copied!'; + copyButton.disabled = true; + } catch (err) { + console.error('Could not copy text: ', err); + } + document.body.removeChild(textarea); + + setTimeout(() => { + copyButton.innerHTML = originalHTML; + copyButton.disabled = false; + }, 2000); + }); + + document.getElementById('generate-randomly-button').addEventListener('click', () => { + hideError(); // Hide any errors when starting a new generation + // Age + const ageChips = document.querySelectorAll('.parameter-container:nth-child(1) .chip'); + const randomAgeChip = getRandomElement(ageChips); + const age = randomAgeChip ? randomAgeChip.textContent.trim() : 'any'; + + // Theme + const themeChips = document.querySelectorAll('.parameter-container:nth-child(2) .chip'); + const randomThemeChip = getRandomElement(themeChips); + const theme = randomThemeChip ? randomThemeChip.textContent.trim() : 'any'; + + // Story Type + const storyTypeContainer = document.querySelector('.parameter-container:nth-child(3)'); + + // Tone + const toneChips = storyTypeContainer.querySelectorAll('.story-type-paragraph:nth-child(1) .chip'); + const randomToneChip = getRandomElement(toneChips); + const tone = randomToneChip ? randomToneChip.textContent.trim() : 'any'; + + // Ending type + const endingTypeChips = storyTypeContainer.querySelectorAll('.story-type-paragraph:nth-child(2) .chip'); + const randomEndingTypeChip = getRandomElement(endingTypeChips); + const endingType = randomEndingTypeChip ? randomEndingTypeChip.textContent.trim() : 'any'; + + // Narrative structure + const narrativeStructureChips = storyTypeContainer.querySelectorAll('.story-type-paragraph:nth-child(3) .chip'); + const randomNarrativeStructureChip = getRandomElement(narrativeStructureChips); + const narrativeStructure = randomNarrativeStructureChip ? randomNarrativeStructureChip.textContent.trim() : 'any'; + + // Duration + const durationChips = storyTypeContainer.querySelectorAll('.story-type-paragraph:nth-child(4) .chip'); + const randomDurationChip = getRandomElement(durationChips); + const duration = randomDurationChip ? randomDurationChip.textContent.trim() : 'any'; + + // Characters and Other will be empty for random generation. + const characters = []; + const other = ''; + + const storyData = { + age, + theme, + tone, + endingType, + narrativeStructure, + duration, + characters, + other, + }; + + generateStory(storyData); + }); +}); \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/docs_assets/brick-config.png b/examples/bedtime-story-teller/assets/docs_assets/brick-config.png new file mode 100644 index 0000000..7036867 Binary files /dev/null and b/examples/bedtime-story-teller/assets/docs_assets/brick-config.png differ diff --git a/examples/bedtime-story-teller/assets/docs_assets/brick-credentials.png b/examples/bedtime-story-teller/assets/docs_assets/brick-credentials.png new file mode 100644 index 0000000..2571f5c Binary files /dev/null and b/examples/bedtime-story-teller/assets/docs_assets/brick-credentials.png differ diff --git a/examples/bedtime-story-teller/assets/docs_assets/duplicate-app.png b/examples/bedtime-story-teller/assets/docs_assets/duplicate-app.png new file mode 100644 index 0000000..7dcd058 Binary files /dev/null and b/examples/bedtime-story-teller/assets/docs_assets/duplicate-app.png differ diff --git a/examples/bedtime-story-teller/assets/docs_assets/launch-app.png b/examples/bedtime-story-teller/assets/docs_assets/launch-app.png new file mode 100644 index 0000000..d3fd254 Binary files /dev/null and b/examples/bedtime-story-teller/assets/docs_assets/launch-app.png differ diff --git a/examples/bedtime-story-teller/assets/docs_assets/thumbnail.png b/examples/bedtime-story-teller/assets/docs_assets/thumbnail.png new file mode 100644 index 0000000..5b2a196 Binary files /dev/null and b/examples/bedtime-story-teller/assets/docs_assets/thumbnail.png differ diff --git a/examples/bedtime-story-teller/assets/fonts/Open Sans/OFL.txt b/examples/bedtime-story-teller/assets/fonts/Open Sans/OFL.txt new file mode 100644 index 0000000..4fc6170 --- /dev/null +++ b/examples/bedtime-story-teller/assets/fonts/Open Sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/bedtime-story-teller/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf b/examples/bedtime-story-teller/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..548c15f Binary files /dev/null and b/examples/bedtime-story-teller/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/examples/bedtime-story-teller/assets/fonts/Roboto/OFL.txt b/examples/bedtime-story-teller/assets/fonts/Roboto/OFL.txt new file mode 100644 index 0000000..68f7a96 --- /dev/null +++ b/examples/bedtime-story-teller/assets/fonts/Roboto/OFL.txt @@ -0,0 +1,91 @@ +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf b/examples/bedtime-story-teller/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 0000000..3a2d704 Binary files /dev/null and b/examples/bedtime-story-teller/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf differ diff --git a/examples/bedtime-story-teller/assets/fonts/fonts.css b/examples/bedtime-story-teller/assets/fonts/fonts.css new file mode 100644 index 0000000..86cf716 --- /dev/null +++ b/examples/bedtime-story-teller/assets/fonts/fonts.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-display: swap; + src: url('Roboto/RobotoMono-VariableFont_wght.ttf') format('truetype'); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-display: swap; + src: url('Open Sans/OpenSans-VariableFont_wdth,wght.ttf') format('truetype'); +} \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/animal.svg b/examples/bedtime-story-teller/assets/img/animal.svg new file mode 100644 index 0000000..ff00044 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/animal.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/arrow-right.svg b/examples/bedtime-story-teller/assets/img/arrow-right.svg new file mode 100644 index 0000000..dd86a0d --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/arrow-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/bear.svg b/examples/bedtime-story-teller/assets/img/bear.svg new file mode 100644 index 0000000..df6e530 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/bear.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/book.svg b/examples/bedtime-story-teller/assets/img/book.svg new file mode 100644 index 0000000..af66630 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/book.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/castle.svg b/examples/bedtime-story-teller/assets/img/castle.svg new file mode 100644 index 0000000..548f42c --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/castle.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/comedy.svg b/examples/bedtime-story-teller/assets/img/comedy.svg new file mode 100644 index 0000000..78cc2ef --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/comedy.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/copy.svg b/examples/bedtime-story-teller/assets/img/copy.svg new file mode 100644 index 0000000..91fd3d9 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/bedtime-story-teller/assets/img/double-arrow.svg b/examples/bedtime-story-teller/assets/img/double-arrow.svg new file mode 100644 index 0000000..22eef1b --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/double-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/bedtime-story-teller/assets/img/doubt.svg b/examples/bedtime-story-teller/assets/img/doubt.svg new file mode 100644 index 0000000..36c98a0 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/doubt.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/fun.svg b/examples/bedtime-story-teller/assets/img/fun.svg new file mode 100644 index 0000000..24c3533 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/fun.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/ghost.svg b/examples/bedtime-story-teller/assets/img/ghost.svg new file mode 100644 index 0000000..690c35c --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/ghost.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/happy.svg b/examples/bedtime-story-teller/assets/img/happy.svg new file mode 100644 index 0000000..9386521 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/happy.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/hope.svg b/examples/bedtime-story-teller/assets/img/hope.svg new file mode 100644 index 0000000..bb1bf77 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/hope.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/logo.svg b/examples/bedtime-story-teller/assets/img/logo.svg new file mode 100644 index 0000000..0afb3af --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/logo.svg @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/magic.svg b/examples/bedtime-story-teller/assets/img/magic.svg new file mode 100644 index 0000000..a865312 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/magic.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/misterious.svg b/examples/bedtime-story-teller/assets/img/misterious.svg new file mode 100644 index 0000000..ca96938 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/misterious.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/stars.svg b/examples/bedtime-story-teller/assets/img/stars.svg new file mode 100644 index 0000000..e694f3f --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/stars.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/bedtime-story-teller/assets/img/storyteller-background.png b/examples/bedtime-story-teller/assets/img/storyteller-background.png new file mode 100644 index 0000000..2e3c16e Binary files /dev/null and b/examples/bedtime-story-teller/assets/img/storyteller-background.png differ diff --git a/examples/bedtime-story-teller/assets/img/sword.svg b/examples/bedtime-story-teller/assets/img/sword.svg new file mode 100644 index 0000000..00007be --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/sword.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/telescope.svg b/examples/bedtime-story-teller/assets/img/telescope.svg new file mode 100644 index 0000000..ba48236 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/telescope.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/bedtime-story-teller/assets/img/trash.svg b/examples/bedtime-story-teller/assets/img/trash.svg new file mode 100644 index 0000000..fa9d42d --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/bedtime-story-teller/assets/img/tv.svg b/examples/bedtime-story-teller/assets/img/tv.svg new file mode 100644 index 0000000..a6be80f --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/tv.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/img/wizard.svg b/examples/bedtime-story-teller/assets/img/wizard.svg new file mode 100644 index 0000000..50f56a3 --- /dev/null +++ b/examples/bedtime-story-teller/assets/img/wizard.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/index.html b/examples/bedtime-story-teller/assets/index.html new file mode 100644 index 0000000..4594c19 --- /dev/null +++ b/examples/bedtime-story-teller/assets/index.html @@ -0,0 +1,232 @@ + + + + + + + + Bedtime Story Teller + + + +
+
+
+

Bedtime Story Teller

+ +
+ +
+ +

Set parameters

+ +
+ +
+

Your story

+ +
+ +
+ +
+
+

Age

+ + arrow icon +
+
+
+ 3-5 years + 6-8 years + 9-12 years + 13-16 years + +
+
+
+ +
+
+

Theme

+ + arrow icon +
+
+
+ wizard iconFantasy / Adventure + castle iconFairy tale + magic iconMistery / Horror + telescope iconScience / Universe + animal iconAnimals + comedy iconComedy +
+
+
+ +
+
+

Story type

+ (optional) + arrow icon +
+
+
+

Tone

+
+ bear iconCalm and sweet + sword iconEpic and adventurous + fun iconCheerful and lively + magic iconMisterious and curious + wizard iconWise and thoughtful + ghost iconTense and grotesque +
+
+
+

Ending type

+
+ happy iconHappy and reassuring + misterious iconOpen and mysterious + doubt iconWith a moral + hope iconDramatic but with hope +
+
+
+

Narrative structure

+
+ castle iconClassic + book iconChapter based + tv iconEpisodic +
+
+
+

Duration (min)

+
+ Short 5 min + Medium 10-15 min + Long 20+ min +
+
+
+
+ +
+
+

Characters

+ (max 5) + arrow icon +
+
+
+
+
+
+ + +
+
+ + (optional) +
+
+ +
+
+
+ +
+
+
+ +
+
+

Other

+ (optional) + arrow icon +
+
+ +
0 / 200
+
+
+ + + +
+ +
+
+

Set parameters to generate story

+

or

+ +
+ +
+ +
+ +
+
+ + + + + + + diff --git a/examples/bedtime-story-teller/assets/libs/socket.io.min.js b/examples/bedtime-story-teller/assets/libs/socket.io.min.js new file mode 100644 index 0000000..530b185 --- /dev/null +++ b/examples/bedtime-story-teller/assets/libs/socket.io.min.js @@ -0,0 +1,6 @@ +/*! + * Socket.IO v4.8.1 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); \ No newline at end of file diff --git a/examples/bedtime-story-teller/assets/style.css b/examples/bedtime-story-teller/assets/style.css new file mode 100644 index 0000000..bc8ac0a --- /dev/null +++ b/examples/bedtime-story-teller/assets/style.css @@ -0,0 +1,808 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@import url("fonts/fonts.css"); + +/* + * This CSS is used to center the various elements on the screen + */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; margin: 0; + padding: 0; + background-color: transparent; + color: #333; +} + +.content-wrapper { + width: 100%; + max-width: 1320px; + margin: 0 auto; + padding: 20px; + box-sizing: border-box; +} + +.play-area { + width: 100%; + min-height: 100vh; + background-image: url('./img/storyteller-background.png'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-attachment: fixed; + display: flex; + justify-content: center; +} + +.main-content { + display: grid; + grid-template-columns: minmax(auto, 455px) 24px 1fr; + grid-template-rows: auto 1fr; + align-items: start; + width: 100%; + max-width: 1280px; +} + +.column-title { + font-family: "Open Sans"; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 160%; + letter-spacing: 0.16px; + color: white; + margin-bottom: 16px; +} + +.modal-body-title { + font-family: "Open Sans"; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 160%; + letter-spacing: 0.16px; + color: white; + margin-bottom: 16px; +} + +.main-content > .column-title:nth-child(1) { + grid-column: 1 / 2; + grid-row: 1 / 2; +} + +.your-story-header { + display: flex; + justify-content: space-between; + align-items: center; + grid-column: 3 / 4; + grid-row: 1 / 2; +} + +.your-story-header .column-title { + margin-bottom: 0; +} + +.main-content > .container { + grid-column: 1 / 2; + grid-row: 2 / 3; +} + +.story-output-container { + grid-column: 3 / 4; + grid-row: 2 / 3; + background-color: transparent; + border-radius: 8px; + padding: 24px; + box-sizing: border-box; + border: 2px solid #C9D2D2; + height: 100%; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.container { + width: 100%; + max-width: 550px; + min-width: 300px; + height: auto; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.arduino-text { + color: #25C2C7; + font-family: "Roboto Mono", monospace; + font-size: 20px; + font-weight: 600; + margin: 0; + font-style: normal; + line-height: 170%; + letter-spacing: 0.28px; +} + +.arduino-logo { + height: 48px; + width: auto; +} + +/* + * Components styling + */ + +.parameter-container.disabled { + opacity: 0.5; + pointer-events: none; + cursor: default; +} + +.parameter-container { + border-radius: 8px; + background-color: #ECF1F1; + margin-bottom: 8px; + padding: 16px; + cursor: pointer; +} + +.parameter-header { + display: flex; + align-items: center; + gap: 8px; + +} + +.parameter-header .arrow-icon { + margin-left: auto; + margin-right: 4px; +} + +.parameter-title { + font-size: 14px; + font-style: normal; + font-weight: 700; + line-height: 160%; + letter-spacing: 0.14px; + color: #2C353A; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + font-family: "Open Sans"; +} + +.arrow-icon { + transition: transform 0.3s ease; +} + +.arrow-icon.rotated { + transform: rotate(90deg); +} + +.parameter-content { + margin-top: 16px; + display: none; +} + +.chips-container { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + border-radius: 200px; + border: 1px solid #C9D2D2; + background: #FFF; + color: #2C353A; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 160%; + letter-spacing: 0.12px; + padding: 4px 12px; + white-space: nowrap; + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.chip img { + margin-right: 8px; +} + +.chip.selected { + background: #008184; + color: #FFF; + border-color: #008184; +} + + +.selection-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border: 1px solid #C9D2D2; + border-radius: 4px; + margin: 0px 4px 4px 0px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.selection-pill img { + margin-right: 4px; + height: 14px; + width: 14px; +} + +.selected-value, +.plus-x { + display: none; + align-items: center; + border-radius: 4px; + border: 1px solid #C9D2D2; + background: #ECF1F1; + padding: 2px 8px; + font-family: "Open Sans"; + font-size: 12px; + font-weight: 600; + color: #2C353A; + margin-left: 8px; +} + +.selected-value img { + margin-right: 4px; +} + +.selected-value img { + margin-right: 4px; +} + +.optional-text { + color: #5D6A6B; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 160%; + letter-spacing: 0.12px; + align-items: center; + gap: 4px; +} + +.story-type-selections { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + display: inline-block; + vertical-align: middle; + flex: 1; +} + +.paragraph-title { + color: #2C353A; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 160%; + letter-spacing: 0.12px; + margin: 0 0 8px 0; +} + +.other-textarea { + width: 100%; + min-height: 100px; + border-radius: 4px; + border: 1px solid #C9D2D2; + background: #FFF; + padding: 8px; + font-family: "Open Sans"; + font-size: 12px; + resize: vertical; + box-sizing: border-box; +} + +.char-counter { + text-align: right; + color: #5D6A6B; + font-family: "Open Sans"; + font-size: 10px; + margin-top: 4px; +} + +.generate-story-button { + border-radius: 8px; + background: #25C2C7; + color: #171E21; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 160%; + letter-spacing: 0.12px; + padding: 8px 16px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 16px; + margin-left: auto; + width: fit-content; +} + +.generate-story-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.story-output-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: white; + font-family: "Open Sans"; + font-size: 16px; + font-weight: 600; +} + +.story-output-placeholder p { + margin: 2px; +} + +.generate-randomly-button { + border-radius: 8px; + background: #25C2C7; + color: #171E21; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 160%; + letter-spacing: 0.12px; + padding: 8px 16px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; +} + +.character-input-group { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 0; + gap: 8px; +} + +.character-input-group:not(:last-child) { + border-bottom: 1px solid #C9D2D2; + padding-bottom: 8px; + margin-bottom: 8px; +} + +.character-inputs { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.delete-character-button { + background-color: #F6D9D5; + border: none; + border-radius: 8px; + padding: 5px 5px 2px 5px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.delete-character-button img { + width: 16px; + height: 16px; +} + +.character-first-row { + display: flex; + gap: 8px; +} + +.character-second-row { + display: flex; + align-items: center; + gap: 8px; +} + +.input-icon { + margin-right: 8px; +} + +.character-name, +.character-role, +.character-description { + width: 100%; + padding: 8px; + border-radius: 4px; + border: 1px solid #C9D2D2; + background: #FFF; + font-family: "Open Sans"; + font-size: 12px; +} + +.character-second-row .character-description { + flex-grow: 1; +} + +.add-character-container { + text-align: center; + margin-top: 16px; +} + +.add-character-button { + background-color: transparent; + border: none; + color: #008184; + padding: 8px 16px; + border-radius: 200px; + cursor: pointer; + font-family: "Open Sans"; + font-size: 12px; + font-weight: 600; +} + +#story-response-area { + height: 100%; + display: flex; + flex-direction: column; +} + +#loading-spinner { + margin: 20px auto; +} + +#story-response-area { + height: 100%; + display: flex; + flex-direction: column; +} + +.response-container { + background-color: #ECF1F1; + border-radius: 8px; + padding: 16px; + margin-bottom: 8px; + display: flex; + flex-direction: column; +} + +.response-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.icon-button { + background-color: transparent; + color: #008184; + padding: 8px 16px; + border-radius: 200px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; +} + +.response-header-buttons { + display: flex; + gap: 8px; +} + +.modal { + position: fixed; + z-index: 10; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.4); + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background-color: #fefefe; + padding: 20px; + border: 1px solid #888; + width: 80%; + max-width: 500px; + border-radius: 8px; + color: #2C353A; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #ddd; + padding-bottom: 10px; +} + +.modal-header h2 { + margin: 0; + font-size: 18px; +} + +.close-button { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; +} + +.close-button:hover, +.close-button:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + +.modal-body { + text-align: center; +} + +.modal-body p { + color: #2C353A; + font-size: 20px; + font-style: normal; + font-weight: 700; +} + +.modal-body-small { + font-size: 14px !important; + font-style: normal !important; + font-weight: 400 !important; + text-align: center !important; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + padding-top: 10px; +} + +#clear-story-button, +#confirm-new-story-button { + border-radius: 8px; + background: #25C2C7; + color: #171E21; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 160%; + letter-spacing: 0.12px; + padding: 8px 16px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.icon-button img { + height: 14px; + width: 14px; +} + +.response-title { + color: #2C353A; + font-family: "Open Sans"; + font-size: 16px; + font-weight: 700; + margin: 0; + flex-shrink: 0; +} + +.response-content { + color: #2C353A; + font-family: "Open Sans"; + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; + overflow-y: auto; + max-height: 60vh; /* Added for fixed height and scrollbar */ +} + +#prompt-display strong { + color: #008184; + font-weight: bold; +} + +.response-content strong { + color: #25C2C7; +} + +.story-type-paragraph { + margin-bottom: 8px; +} + +.response-title { + margin-bottom: 8px; +} + +#story-container .response-title, +#story-container .response-content { + color: #2C353A; +} + +.spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + width: 32px; + height: 32px; + animation: spin 1s linear infinite; + margin-left: 10px; + display: inline-block; + vertical-align: middle; +} + +.button-spinner.spinner { + width: 20px; + height: 20px; + border-width: 2px; + margin: 0; +} +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.error-message { + background-color: #f8d7da; + color: #721c24; + padding: 10px; + margin-top: 20px; + border-radius: 5px; + text-align: center; +} + + +/* + * Responsive design + */ + +@media (max-width: 1024px) { + .main-content { + grid-template-columns: 1fr; + gap: 24px; + } + + .main-content > .column-title:nth-child(1) { + grid-row: 1; + } + + .main-content > .container { + grid-row: 2; + grid-column: 1; + max-width: none; + width: 100%; + padding: 0; + } + + .your-story-header { + grid-row: 3; + grid-column: 1; + } + + + + .story-output-container { + grid-row: 4; + grid-column: 1; + padding: 0; + border: none; + } + + .main-content > div:nth-child(2) { + display: none; + } +} + +/* + * Responsive design + */ + +@media (max-width: 768px) { + .container { + max-width: none; + width: 100%; + padding: 20px; + min-height: 400px; + } + + .arduino-text { + font-size: 24px; + } + + .main-content { + gap: 20px; + flex-direction: column; + } +} + +@media (max-width: 480px) { + body { + padding: 8px 12px; + } + + .container { + padding: 16px; + } + + .arduino-logo { + height: 20px; + width: auto; + } + + #rescanButton { + font-size: 12px; + } +} + +/* Story Response Styling */ +#story-response { + white-space: normal; +} + +#story-response h1, +#story-response h2 { + margin-top: 0; + margin-bottom: 0.8em; +} + +#story-response p { + margin: 0 0 8px 0; +} + +#story-response::-webkit-scrollbar { + width: 8px; +} + +#story-response::-webkit-scrollbar-track { + background-color: #ECF1F1; +} + +#story-response::-webkit-scrollbar-thumb { + background-color: #C9D2D2; + border-radius: 4px; +} \ No newline at end of file diff --git a/examples/bedtime-story-teller/python/main.py b/examples/bedtime-story-teller/python/main.py new file mode 100644 index 0000000..b8f1a02 --- /dev/null +++ b/examples/bedtime-story-teller/python/main.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +import re +from arduino.app_bricks.cloud_llm import CloudLLM, CloudModel +from arduino.app_bricks.web_ui import WebUI +from arduino.app_utils import App + + +llm = CloudLLM( + model=CloudModel.GOOGLE_GEMINI, + system_prompt="You are a bedtime story teller. Your response must be the story itself, formatted directly in HTML. Do not wrap your response in markdown code blocks or any other formatting. Use heading tags like

,

for titles and subtitles. Use or for bold text. Include relevant emojis. If the story is chapter-based, use heading tags for chapter titles.", +) +llm.with_memory() + +ui = WebUI() + + +def generate_story(_, data): + try: + age = data.get('age', 'any') + theme = data.get('theme', 'any') + tone = data.get('tone', 'any') + ending_type = data.get('endingType', 'any') + narrative_structure = data.get('narrativeStructure', 'any') + duration = data.get('duration', 'any') + characters = data.get('characters', []) + other = data.get('other', '') + + # Create a prompt with HTML for display + prompt_for_display = f"As a parent who loves to read bedtime stories to my {age} year old child, I need a delightful and age-appropriate story." + + if characters: + prompt_for_display += " Characters of the story: " + char_prompts = [] + for i, char in enumerate(characters): + ch = f"Character {i+1} ({char.get('name')}, {char.get('role')}" + ch += f", {char.get('description')})" if char.get('description') else ")" + char_prompts.append(ch) + prompt_for_display += ", ".join(char_prompts) + prompt_for_display += "." + + prompt_for_display += f" The story type is {theme}. The tone should be {tone}. The format should be a narrative-style story with a clear beginning, middle, and end, allowing for a smooth and engaging reading experience. The objective is to entertain and soothe the child before bedtime. Provide a brief introduction to set the scene and introduce the main character. The scope should revolve around the topic: managing emotions and conflicts. The length should be approximately {duration}. Please ensure the story has a {narrative_structure} narrative structure, leaving the child with a sense of {ending_type}. The language should be easy to understand and suitable for my child's age comprehension." + if other: + prompt_for_display += f"\n\nOther on optional stuff for the story: {other}" + + # Create a plain text prompt for the LLM by stripping HTML tags + prompt_for_llm = re.sub('<[^>]*>', '', prompt_for_display) + + # Send the display prompt to the UI + ui.send_message("prompt", prompt_for_display) + + # Use the plain text prompt for the LLM and stream the response + for resp in llm.chat_stream(prompt_for_llm): + ui.send_message("response", resp) + + # Signal the end of the stream + ui.send_message("stream_end", {}) + except Exception as e: + ui.send_message("story_error", {"error": str(e)}) + +ui.on_message("generate_story", generate_story) + +App.run() diff --git a/examples/led-matrix-painter/README.md b/examples/led-matrix-painter/README.md new file mode 100644 index 0000000..3ef18f7 --- /dev/null +++ b/examples/led-matrix-painter/README.md @@ -0,0 +1,189 @@ +# LED Matrix Painter + +The **LED Matrix Painter** example provides a web-based interface to draw, animate, and control the built-in LED Matrix of the Arduino UNO Q in real-time. It features a pixel editor with 8-bit brightness control, database storage for your designs, and a code generator to export your frames as ready-to-use C++ code. + +![LED Matrix Painter Example](assets/docs_assets/thumbnail.png) + +## Description + +This App allows you to design visuals for the 8x13 LED matrix directly from your browser. It uses the `web_ui` Brick to host a graphical editor where you can paint individual pixels, adjust their brightness, and apply transformations like flipping or rotating. Every change you make in the browser is immediately reflected on the physical board. + +The application uses the `dbstorage_sqlstore` Brick to automatically save your work in a local database. You can create multiple frames, organize them into animations, and use the "Code panel" to see the generated C++ code in real-time. + +Key features include: +- **Real-time Control:** Drawing on the web grid updates the UNO Q matrix instantly. +- **Grayscale Control:** 8 brightness presets (0-7) for intuitive pixel control, with full 8-bit precision (0-255) supported at the hardware level. +- **Persistent Storage:** Frames are automatically saved to a database, allowing you to build complex animations over time. +- **Transformation Tools:** Invert, rotate, or flip designs with a single click. +- **Animation Mode:** Sequence frames to create animations and preview them on the board. +- **Code Export:** Generate `uint32_t` arrays compatible with the `Arduino_LED_Matrix` library for use in standalone sketches. + +## Bricks Used + +The LED Matrix Painter example uses the following Bricks: + +- `web_ui`: Brick to create the interactive grid editor and manage API endpoints. +- `dbstorage_sqlstore`: Brick to persist frames and animation sequences using a SQLite database. + +## Hardware and Software Requirements + +### Hardware + +- Arduino UNO Q (x1) +- USB-C® cable (for power and programming) (x1) + +### Software + +- Arduino App Lab + +## How to Use the Example + +1. **Run the App** + Launch the example by clicking the **Run** button from Arduino App Lab. + +2. **Access the Editor** + Open the App in your browser at `:7000`. + +3. **Draw Frames** + - **Paint:** Click any cell in the central grid to turn it on. + - **Adjust Brightness:** Click an active cell again (or hover/wait) to open the floating slider and set the brightness level (0-7). + - **Preview:** Observe the UNO Q; the matrix updates instantly as you draw. + +4. **Use the Design Tools** + - **Transform:** Use the **Tools** panel on the left to **Flip Vertically/Horizontally**, **Rotate 180°**, **Invert Matrix** (negative), or **Invert Draw** (brightness). + - **Clear:** Use the **Clear Frame** button above the grid to reset the canvas. + +5. **Manage Frames (Bottom Panel)** + - **Auto-save:** Your work is saved to the database automatically. + - **Create:** Click the **+** button to add a new empty frame. + - **Edit Details:** Assign a **Name** and **Duration** (in milliseconds) for each frame using the inputs above the frame list. + - **Reorder:** Drag and drop frame thumbnails to change their sequence. + - **Load/Delete:** Use the **Load** and **Del** buttons on each thumbnail to switch between frames or remove them. + +6. **Create Animations** + - Switch the mode to **Animations** using the radio buttons in the bottom panel. + - Select multiple frames by clicking on their thumbnails (they will highlight). + - Click the **Play Animation** button below the grid to preview the sequence on the board. + +7. **Export Code** + - Toggle the **Code panel** switch in the top right header to view the C++ code for the current frame or animation in real-time. + - Click the **Export .h** button to download a header file containing your selected designs, ready to be included in an Arduino sketch. + +## How it Works + +The LED Matrix Painter relies on a synchronized data flow between the browser, the Python backend, and the hardware. + +**High-level data flow:** + +``` +Web Browser ──► HTTP API ──► Python Backend ──► Router Bridge ──► Arduino Sketch + │ │ + ▼ ▼ + SQLite Database LED Matrix Display +``` + +1. **Web Interface**: The `app.js` script captures clicks on the grid. It debounces these events and sends the pixel data to the backend via the `/persist_frame` endpoint. +2. **Python Backend**: + * **Data Model**: The `AppFrame` class normalizes the data, converting between frontend JSON, database records, and hardware byte arrays. + * **Persistence**: The `store.py` module uses `SQLStore` to save the frame data to a `frames` table in a SQLite database. + * **Bridge**: The `main.py` script sends the raw byte array to the board via `Bridge.call("draw", frame_bytes)`. +3. **Arduino Sketch**: The sketch receives the raw byte data and uses the `Arduino_LED_Matrix` library to render the grayscale image. + +## Understanding the Code + +### 🔧 Backend (`main.py`, `store.py` & `app_frame.py`) + +The Python backend manages the application logic, database, and hardware communication. + +- **Data Model (`app_frame.py`)**: The `AppFrame` class is the core data structure that acts as a bridge between the different components. It extends the base `Frame` class to add application-specific metadata like `id`, `name`, `position`, and `duration`. It handles three distinct data contracts: + - **API Contract**: `to_json()` / `from_json()` formats data for the web frontend. + - **Database Contract**: `to_record()` / `from_record()` formats data for `SQLStore` storage. + - **Hardware Contract**: `to_board_bytes()` packs pixels into the specific byte format expected by the Arduino sketch. + +```python +class AppFrame(Frame): + def to_record(self) -> dict: + """Convert to a database record dict for storage.""" + return { + "id": self.id, + "name": self.name, + "rows": json.dumps(self.arr.tolist()), # Serialize pixels to JSON string + "brightness_levels": int(self.brightness_levels), + # ... + } +``` + +- **Initialization**: + - `designer = FrameDesigner()`: Initializes the frame designer utility from `arduino.app_utils`, which provides the logic for transformation operations (invert, rotate, flip). + - `store.init_db()`: Creates the SQLite database and tables for storing frames if they don't exist. + +- **API Endpoints**: The backend exposes several HTTP endpoints using `ui.expose_api` to handle frontend requests: + - `POST /persist_frame`: Saves or updates frames in the database and updates the board. + - `POST /load_frame`: Loads a specific frame by ID or retrieves the last edited frame. + - `GET /list_frames`: Returns all saved frames to populate the bottom panel. + - `POST /play_animation`: Sends a sequence of frames to the Arduino to play as an animation. + - `POST /transform_frame`: Applies geometric transformations to the pixel data. + - `POST /export_frames`: Generates the C++ header file content. + +- **Hardware Update**: The `apply_frame_to_board` function sends the visual data to the microcontroller via the Bridge. + +```python +# main.py +def apply_frame_to_board(frame: AppFrame): + """Send frame bytes to the Arduino board.""" + frame_bytes = frame.to_board_bytes() + Bridge.call("draw", frame_bytes) +``` + +- **Code Generation**: The `AppFrame` class generates the C++ code displayed in the UI. It formats the internal array data into `uint32_t` hex values. + +```python +# app_frame.py +def to_c_string(self) -> str: + c_type = "uint32_t" + parts = [f"const {c_type} {self.name}[] = {{"] + # Converts pixel brightness data to uint32_t hex format + parts.append("};") + return "\n".join(parts) +``` + +### 🔧 Arduino Component (`sketch.ino`) + +The sketch is designed to be a passive renderer, accepting commands from the Python backend. + +- **Grayscale Setup**: The matrix is initialized with 8-bit grayscale support to allow for varying brightness levels. + +```cpp +void setup() { + matrix.begin(); + // configure grayscale bits to 8 so the display can accept 0..255 brightness + matrix.setGrayscaleBits(8); + Bridge.begin(); + // ... +} +``` + +- **Drawing**: The `draw` provider accepts a vector of bytes and renders it directly. + +```cpp +void draw(std::vector frame) { + matrix.draw(frame.data()); +} +``` + +### 🔧 Frontend (`app.js`) + +The JavaScript frontend handles the UI logic and data synchronization. + +- **Auto-Persist**: To provide a responsive experience, changes are saved automatically after a short delay (debounce), updating both the database and the board simultaneously. + +```javascript +// Unified persist: save to DB and update board together +function schedulePersist(){ + if (persistTimeout) clearTimeout(persistTimeout); + persistTimeout = setTimeout(()=> { + persistFrame(); + persistTimeout = null; + }, AUTO_PERSIST_DELAY_MS); +} +``` diff --git a/examples/led-matrix-painter/app.yaml b/examples/led-matrix-painter/app.yaml new file mode 100644 index 0000000..df0659a --- /dev/null +++ b/examples/led-matrix-painter/app.yaml @@ -0,0 +1,9 @@ +name: Led Matrix Painter +icon: 🟦 +description: + This example shows how to create a tool to design frames for an LED matrix using Arduino. + It provides a web interface where users can design frames and animations and export them as C/C++ code. + +bricks: + - arduino:web_ui + - arduino:dbstorage_sqlstore diff --git a/examples/led-matrix-painter/assets/app.js b/examples/led-matrix-painter/assets/app.js new file mode 100644 index 0000000..86a6cad --- /dev/null +++ b/examples/led-matrix-painter/assets/app.js @@ -0,0 +1,996 @@ +// SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +// +// SPDX-License-Identifier: MPL-2.0 + +// Simple frontend for 8x13 clickable grid +const gridEl = document.getElementById('grid'); +const vectorEl = document.getElementById('vector'); +const exportBtn = document.getElementById('export'); +const playAnimationBtn = document.getElementById('play-animation'); +const stopAnimationBtn = document.getElementById('stop-animation'); +const clearBtn = document.getElementById('clear'); +const invertBtn = document.getElementById('invert'); +const rotate180Btn = document.getElementById('rotate180'); +const flipHBtn = document.getElementById('flip-h'); +const flipVBtn = document.getElementById('flip-v'); +const frameTitle = document.getElementById('frame-title'); +const frameBackBtn = document.getElementById('frame-back'); +const frameForwardBtn = document.getElementById('frame-forward'); + +function showError(message) { + const errorContainer = document.getElementById('error-container'); + if (errorContainer) { + errorContainer.textContent = message; + errorContainer.style.display = 'block'; + } +} + +function hideError() { + const errorContainer = document.getElementById('error-container'); + if (errorContainer) { + errorContainer.textContent = ''; + errorContainer.style.display = 'none'; + } +} + +async function fetchWithHandling(url, options, responseType = 'json', context = 'performing operation') { + try { + const response = await fetch(url, options); + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'An unknown error occurred.' })); + throw new Error(error.message || `HTTP error! status: ${response.status}`); + } + hideError(); // Hide error on successful communication + + if (responseType === 'json') { + return await response.json(); + } else if (responseType === 'blob') { + return await response.blob(); + } else if (responseType === 'text') { + return await response.text(); + } + return response; + } catch (error) { + showError(`Failed to ${context}: ${error.message}`); + throw error; // Re-throw to allow specific handlers to catch it if needed + } +} + +const codePanelToggle = document.getElementById('code-panel-toggle'); +const codePanel = document.querySelector('.controls-section-right'); +if (codePanelToggle && codePanel) { + codePanelToggle.addEventListener('change', () => { + codePanel.style.display = codePanelToggle.checked ? 'flex' : 'none'; + }); + // set initial state + codePanel.style.display = codePanelToggle.checked ? 'flex' : 'none'; +} + +const ROWS = 8, COLS = 13; +let BRIGHTNESS_LEVELS = 8; +let cells = []; +let sessionFrames = []; +let loadedFrameId = null; // ID of the frame currently loaded in editor +let loadedFrame = null; // Full frame object currently loaded + +// Auto-persist timer (unified: board + DB together) +let persistTimeout = null; +const AUTO_PERSIST_DELAY_MS = 150; // 150ms unified delay + +async function loadConfig(brightnessSlider, brightnessValue){ + try{ + const data = await fetchWithHandling('/config', {}, 'json', 'load config'); + if(typeof data.brightness_levels === 'number' && data.brightness_levels >= 2){ + BRIGHTNESS_LEVELS = data.brightness_levels; + } + }catch(err){ + console.warn('[ui] unable to load config; using defaults', err); + } + const maxValue = Math.max(0, BRIGHTNESS_LEVELS - 1); + if(brightnessSlider){ + brightnessSlider.max = String(maxValue); + if(parseInt(brightnessSlider.value || '0') > maxValue){ + brightnessSlider.value = String(maxValue); + } + } + if(brightnessValue){ + const current = brightnessSlider ? parseInt(brightnessSlider.value) : maxValue; + brightnessValue.textContent = String(Math.min(current, maxValue)); + } +} + +function clampBrightness(v){ + if(Number.isNaN(v) || v < 0) return 0; + const maxValue = Math.max(0, BRIGHTNESS_LEVELS - 1); + return Math.min(v, maxValue); +} + +function collectGridBrightness(){ + const grid = []; + for(let r=0;r f.id === loadedFrameId); + if (currentIndex === -1) { + frameBackBtn.disabled = true; + frameForwardBtn.disabled = true; + return; + } + + frameBackBtn.disabled = currentIndex === 0; + frameForwardBtn.disabled = currentIndex === sessionFrames.length - 1; +} + +function markLoaded(frame){ + const oldFrameId = loadedFrameId; // Store the old ID + + // Remove marker from the old frame + if(oldFrameId !== null){ + const prev = document.querySelector(`#frames [data-id='${oldFrameId}']`); + if(prev) { + prev.classList.remove('loaded'); + prev.classList.remove('selected'); + } + } + + // Update the global state + loadedFrameId = frame ? frame.id : null; + loadedFrame = frame; + + // Add marker to the new frame + if(frame && frame.id){ + try{ + const el = document.querySelector(`#frames [data-id='${frame.id}']`); + if(el) { + el.classList.add('loaded'); + el.classList.add('selected'); + } + }catch(e){/* ignore */} + } + updateArrowButtonsState(); +} + +function clearLoaded(){ + if(loadedFrameId === null) return; + const prev = document.querySelector(`#frames [data-id='${loadedFrameId}']`); + if(prev) { + prev.classList.remove('loaded'); + prev.classList.remove('selected'); + } + loadedFrameId = null; + loadedFrame = null; + updateArrowButtonsState(); +} + +function makeGrid(){ + for(let r=0;r { + persistFrame(); + persistTimeout = null; + }, AUTO_PERSIST_DELAY_MS); +} + +async function persistFrame(){ + const grid = collectGridBrightness(); + // Backend is responsible for naming - send empty if no value + const frameName = (loadedFrame && loadedFrame.name) || ''; + const duration_ms = (loadedFrame && loadedFrame.duration_ms) || 1000; + + // Build payload with ID if we're updating an existing frame + const payload = { + rows: grid, + name: frameName, + duration_ms: duration_ms, + brightness_levels: BRIGHTNESS_LEVELS + }; + + if (loadedFrame && loadedFrame.id) { + payload.id = loadedFrame.id; + payload.position = loadedFrame.position; + } + + console.debug('[ui] persistFrame (save to DB + update board)', payload); + + try { + const data = await fetchWithHandling('/persist_frame', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }, 'json', 'persist frame'); + + if (data && data.ok && data.frame) { + // Update loaded frame reference + loadedFrame = data.frame; + loadedFrameId = data.frame.id; + // Show vector text + if (data.vector) showVectorText(data.vector); + // Refresh frames list to show updated version + refreshFrames(); + console.debug('[ui] frame persisted:', data.frame.id); + } + } catch (err) { + console.warn('[ui] persistFrame failed', err); + } +} + +function sendUpdateFromGrid(){ + // Legacy function - now calls schedulePersist + schedulePersist(); +} + +function getRows13(){ + const rows = []; + for(let r=0;r f.id); + const payload = { frames: frameIds, animations: [{name: animName, frames: frameIds}] }; + + console.debug('[ui] exportH payload', payload); + const data = await fetchWithHandling('/export_frames', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)}, 'json', 'export animation'); + + if (data && data.header) { + const blob = new Blob([data.header], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + } catch (err) { + // Error is already shown by fetchWithHandling + console.error('[ui] exportH failed', err); + } finally { + exportBtn.disabled = false; + } +} + +makeGrid(); +if (exportBtn) exportBtn.addEventListener('click', exportH); else console.warn('[ui] export button not found'); + +let animationTimeout = null; + +function displayFrame(frame) { + if (!frame) return; + + // Populate grid + setGridFromRows(frame.rows || []); + + // Populate name input + if (frameTitle) frameTitle.textContent = frame.name || `Frame ${frame.id}`; + + // Mark as loaded in sidebar + markLoaded(frame); +} + +async function playAnimation() { + if (!playAnimationBtn) return; + + // Stop any previous animation loop + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = null; + } + + try { + playAnimationBtn.disabled = true; + const frameIds = sessionFrames.map(f => f.id); + if (frameIds.length === 0) { + showError('No frames to play'); + playAnimationBtn.disabled = false; // re-enable button + return; + } + + console.debug(`[ui] playAnimation, frameIds=`, frameIds); + + const payload = { + frames: frameIds, + loop: false + }; + + const data = await fetchWithHandling('/play_animation', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }, 'json', 'play animation'); + + if (data.error) { + showError('Error: ' + data.error); + playAnimationBtn.disabled = false; + } else { + console.debug('[ui] Animation played successfully, frames=', data.frames_played); + showVectorText('Animation played: ' + data.frames_played + ' frames'); + + // Start frontend animation simulation + let currentFrameIndex = 0; + const animateNextFrame = () => { + if (currentFrameIndex >= sessionFrames.length) { + // Animation finished + playAnimationBtn.disabled = false; + animationTimeout = null; + return; + } + + const frame = sessionFrames[currentFrameIndex]; + displayFrame(frame); + + const duration = frame.duration_ms || 1000; + currentFrameIndex++; + + animationTimeout = setTimeout(animateNextFrame, duration); + }; + animateNextFrame(); + } + + } catch (err) { + console.error('[ui] playAnimation failed', err); + playAnimationBtn.disabled = false; // re-enable on error + } +} + +if (playAnimationBtn) playAnimationBtn.addEventListener('click', playAnimation); else console.warn('[ui] play animation button not found'); + +if (stopAnimationBtn) { + stopAnimationBtn.addEventListener('click', () => { + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = null; + playAnimationBtn.disabled = false; + showVectorText('Animation stopped'); + } + }); +} + +if (frameForwardBtn) { + frameForwardBtn.addEventListener('click', () => { + if (!loadedFrameId) return; + const currentIndex = sessionFrames.findIndex(f => f.id === loadedFrameId); + if (currentIndex < sessionFrames.length - 1) { + const nextFrame = sessionFrames[currentIndex + 1]; + loadFrameIntoEditor(nextFrame.id); + } + }); +} + +if (frameBackBtn) { + frameBackBtn.addEventListener('click', () => { + if (!loadedFrameId) return; + const currentIndex = sessionFrames.findIndex(f => f.id === loadedFrameId); + if (currentIndex > 0) { + const prevFrame = sessionFrames[currentIndex - 1]; + loadFrameIntoEditor(prevFrame.id); + } + }); +} + +// Save frame button removed - auto-persist replaces it +const animControls = document.getElementById('anim-controls'); +const animNameInput = document.getElementById('anim-name'); +// set default placeholder and default value +if (animNameInput) { + animNameInput.placeholder = 'Animation name (optional)'; + animNameInput.value = 'Animation'; +} + +// Enforce simple C-identifier rule on name inputs for exported symbols. +function normalizeSymbolInput(s){ + if(!s) return ''; + // Replace invalid chars with '_', and remove leading digits by prefixing 'f_' + let cand = ''; + for(const ch of s){ + if(/[A-Za-z0-9_]/.test(ch)) cand += ch; else cand += '_'; + } + if(/^[0-9]/.test(cand)) cand = 'f_' + cand; + return cand; +} + + + +if(animNameInput){ + animNameInput.addEventListener('blur', ()=>{ + animNameInput.value = normalizeSymbolInput(animNameInput.value.trim()) || ''; + }); +} + +// Save frame button removed - using auto-persist instead + +async function refreshFrames(){ + try{ + const data = await fetchWithHandling('/list_frames', {}, 'json', 'refresh frames'); + sessionFrames = data.frames || []; + renderFrames(); + + // Re-apply loaded state after rendering + if(loadedFrameId !== null && loadedFrame !== null){ + const el = document.querySelector(`#frames [data-id='${loadedFrameId}']`); + if(el) { + el.classList.add('loaded'); + el.classList.add('selected'); + } + } + updateArrowButtonsState(); + }catch(e){ console.warn(e) } +} + +// Function to make a text element editable on double-click +function createEditableField(element, onSave) { + element.addEventListener('dblclick', () => { + const originalValue = element.textContent; + const input = document.createElement('input'); + input.type = 'text'; + input.value = originalValue.replace(/ ms$/, ''); // Remove ' ms' for duration + + // Replace element with input + element.style.display = 'none'; + element.parentNode.insertBefore(input, element); + input.focus(); + + const saveAndRevert = () => { + const newValue = input.value.trim(); + input.remove(); + element.style.display = ''; + // Only save if the value has changed + if (newValue && newValue !== originalValue.replace(/ ms$/, '')) { + onSave(newValue); + } else { + element.textContent = originalValue; // Revert to original if empty or unchanged + } + }; + + input.addEventListener('blur', saveAndRevert); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + input.blur(); // Trigger blur to save + } else if (e.key === 'Escape') { + input.remove(); + element.style.display = ''; // Cancel editing + } + }); + }); +} + +function renderFrames(){ + const container = document.getElementById('frames'); + container.innerHTML = ''; + sessionFrames.forEach(f => { + const item = document.createElement('div'); item.className = 'frame-item'; item.draggable = true; item.dataset.id = f.id; + const thumb = document.createElement('div'); thumb.className = 'frame-thumb'; + // render a tiny grid by mapping the rows into colored blocks + const rows = f.rows || []; + for(let r=0;r 0; + } else if (typeof row === 'string') { + isOn = row[c] === '1'; + } + const dot = document.createElement('div'); dot.style.background = isOn ? '#3CE2FF' : 'transparent'; thumb.appendChild(dot); + } + } + const name = document.createElement('div'); name.className = 'frame-name'; name.textContent = f.name || ('Frame ' + f.id); + const duration = document.createElement('div'); duration.className = 'frame-duration'; duration.textContent = `${f.duration_ms || 1000} ms`; + + // Make name and duration editable + createEditableField(name, (newName) => { + const rows = (f.id === loadedFrameId) ? collectGridBrightness() : f.rows; + fetchWithHandling('/persist_frame', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ id: f.id, name: newName, duration_ms: f.duration_ms, rows: rows }) + }).then(() => refreshFrames()); + }); + + createEditableField(duration, (newDuration) => { + const durationMs = parseInt(newDuration, 10); + if (!isNaN(durationMs)) { + const rows = (f.id === loadedFrameId) ? collectGridBrightness() : f.rows; + fetchWithHandling('/persist_frame', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ id: f.id, name: f.name, duration_ms: durationMs, rows: rows }) + }).then(() => refreshFrames()); + } + }); + + // NEW CLICK LOGIC: Single-select and load + item.addEventListener('click', (e)=>{ + // Don't do anything if clicking inside an input field during editing + if (e.target.tagName === 'INPUT') return; + + // If it's already selected, do nothing + if (loadedFrameId === f.id) return; + + loadFrameIntoEditor(f.id); // This function already handles setting loadedFrameId and adding the .loaded class + }); + + // drag/drop handlers + item.addEventListener('dragstart', (ev)=>{ ev.dataTransfer.setData('text/plain', f.id); item.classList.add('dragging'); }); + item.addEventListener('dragend', ()=>{ item.classList.remove('dragging'); }); + item.addEventListener('dragover', (ev)=>{ ev.preventDefault(); item.classList.add('dragover'); }); + item.addEventListener('dragleave', ()=>{ item.classList.remove('dragover'); }); + item.addEventListener('drop', async (ev)=>{ + ev.preventDefault(); item.classList.remove('dragover'); + const draggedId = parseInt(ev.dataTransfer.getData('text/plain')); + const draggedEl = container.querySelector(`[data-id='${draggedId}']`); + if(draggedEl && draggedEl !== item){ + const rect = item.getBoundingClientRect(); + const mouseY = ev.clientY; + const itemMiddle = rect.top + rect.height / 2; + if (mouseY < itemMiddle) { + container.insertBefore(draggedEl, item); + } else { + container.insertBefore(draggedEl, item.nextSibling); + } + const order = Array.from(container.children).map(ch => parseInt(ch.dataset.id)).filter(id => !isNaN(id)); + await fetchWithHandling('/reorder_frames', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({order})}, 'json', 'reorder frames'); + await refreshFrames(); + } + }); + + item.appendChild(thumb); item.appendChild(name); item.appendChild(duration); + + container.appendChild(item); + }); + + // Add the "Add Frame" button at the end of the list + const newFrameBtn = document.createElement('button'); + newFrameBtn.className = 'add-frame-btn'; + newFrameBtn.title = 'Create new frame'; + newFrameBtn.innerHTML = 'Add Frame'; + newFrameBtn.addEventListener('click', handleNewFrameClick); + container.appendChild(newFrameBtn); +} + +// 'save-anim' button functionality has been removed as it is no longer part of the UI. + +// Mode toggle handling removed + +// Transform button handlers +async function transformFrame(op) { + console.debug(`[ui] ${op} button clicked (delegating to server)`); + const grid = collectGridBrightness(); + try { + const data = await fetchWithHandling('/transform_frame', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + op, + rows: grid, + brightness_levels: BRIGHTNESS_LEVELS + }) + }, 'json', `transform frame (${op})`); + + if (data && data.ok && data.frame) { + setGridFromRows(data.frame.rows); + if (data.vector) showVectorText(data.vector); + schedulePersist(); + } + } catch (e) { + console.warn(`[ui] ${op} failed`, e); + } +} + +if (rotate180Btn) { + rotate180Btn.addEventListener('click', () => transformFrame('rotate180')); +} +if (flipHBtn) { + flipHBtn.addEventListener('click', () => transformFrame('flip_h')); +} +if (flipVBtn) { + flipVBtn.addEventListener('click', () => transformFrame('flip_v')); +} +if (invertBtn) { + invertBtn.addEventListener('click', () => transformFrame('invert')); +} +const invertNotNullBtn = document.getElementById('invert-not-null'); +if (invertNotNullBtn) { + invertNotNullBtn.addEventListener('click', () => transformFrame('invert_not_null')); +} + +async function loadFrameIntoEditor(id){ + try { + const data = await fetchWithHandling('/load_frame', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({id}) + }, 'json', `load frame ${id}`); + + if(data && data.ok && data.frame){ + const f = data.frame; + + // Populate grid + setGridFromRows(f.rows || []); + + // Populate name input + if(frameTitle) frameTitle.textContent = f.name || `Frame ${f.id}`; + + // Mark as loaded in sidebar + markLoaded(f); + + // Show C vector representation (backend already sends it via load_frame) + if (data.vector) { + showVectorText(data.vector); + } + + console.debug('[ui] loaded frame into editor:', id); + } + } catch(err) { + console.warn('[ui] loadFrameIntoEditor failed', err); + } +} + +function setGridFromRows(rows){ + // rows: either list[list[int]] or list[str] + for(let r=0;r 0) { cells[idx].classList.add('on'); cells[idx].dataset.b = String(v); } else { cells[idx].classList.remove('on'); delete cells[idx].dataset.b; } + } else { + const s = (row || '').padEnd(COLS,'0'); + if(s[c] === '1') { cells[idx].classList.add('on'); cells[idx].dataset.b = String(Math.max(0, BRIGHTNESS_LEVELS - 1)); } else { cells[idx].classList.remove('on'); delete cells[idx].dataset.b; } + } + } + } +} + + + +async function deleteFrame(id){ + await fetchWithHandling('/delete_frame', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})}, 'json', `delete frame ${id}`); +} + +async function handleNewFrameClick() { + console.debug('[ui] new frame button clicked'); + + // Clear editor + cells.forEach(c => { c.classList.remove('on'); delete c.dataset.b; }); + showVectorText(''); + + // Clear loaded frame reference (we're creating new) + clearLoaded(); + + // Create empty frame in DB (no name = backend assigns progressive name) + const grid = collectGridBrightness(); // all zeros + try { + const data = await fetchWithHandling('/persist_frame', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ + rows: grid, + name: '', // empty name = backend will assign Frame {id} + duration_ms: 1000, + brightness_levels: BRIGHTNESS_LEVELS + }) + }, 'json', 'create new frame'); + + if (data && data.ok && data.frame) { + // Set name to the backend-assigned name (Frame {id}) + if(frameTitle) frameTitle.textContent = data.frame.name || `Frame ${data.frame.id}`; + + // Show C vector representation + if (data.vector) { + showVectorText(data.vector); + } + + // Refresh frames list + await refreshFrames(); + + // Mark as loaded + markLoaded(data.frame); + + console.debug('[ui] new frame created:', data.frame.id); + } + } catch(err) { + console.warn('[ui] failed to create new frame', err); + } +} + +// Initialize editor on page load +initEditor(); +refreshFrames(); + +if (clearBtn) { + clearBtn.addEventListener('click', ()=>{ + console.debug('[ui] clear button clicked'); + cells.forEach(c => { c.classList.remove('on'); delete c.dataset.b; }); + showVectorText(''); + schedulePersist(); + }); +} else { + console.warn('[ui] clear button not found'); +} + +// 'save-anim' button functionality has been removed as it is no longer part of the UI. + +document.addEventListener('DOMContentLoaded', () => { + let selectedTool = 'brush'; + gridEl.dataset.tool = selectedTool; + + const customSelect = document.querySelector('.custom-select'); + if (customSelect) { + const trigger = customSelect.querySelector('.custom-select__trigger'); + const options = customSelect.querySelectorAll('.custom-option'); + const triggerSvg = trigger.querySelector('svg.tool-icon'); + + trigger.addEventListener('click', () => { + customSelect.classList.toggle('open'); + }); + + options.forEach(option => { + option.addEventListener('click', () => { + const value = option.getAttribute('data-value'); + const svg = option.querySelector('svg.tool-icon'); + + triggerSvg.innerHTML = svg.innerHTML; + customSelect.classList.remove('open'); + + selectedTool = value; + gridEl.dataset.tool = selectedTool; + console.log('Selected tool:', value); + }); + }); + + window.addEventListener('click', (e) => { + if (!customSelect.contains(e.target)) { + customSelect.classList.remove('open'); + } + }); + } + + /* Brightness Alpha Slider */ + const brightnessAlphaSlider = document.getElementById('brightness-alpha-slider'); + const brightnessAlphaValue = document.getElementById('brightness-alpha-value'); + + if (brightnessAlphaSlider && brightnessAlphaValue) { + brightnessAlphaSlider.addEventListener('input', () => { + brightnessAlphaValue.textContent = brightnessAlphaSlider.value; + }); + } + + loadConfig(brightnessAlphaSlider, brightnessAlphaValue); + + let isDrawing = false; + + function draw(e) { + if (!e.target.classList.contains('cell')) return; + + const cell = e.target; + if (selectedTool === 'brush') { + const brightness = brightnessAlphaSlider.value; + cell.dataset.b = brightness; + } else if (selectedTool === 'eraser') { + delete cell.dataset.b; + } + } + + gridEl.addEventListener('mousedown', (e) => { + isDrawing = true; + draw(e); + }); + + gridEl.addEventListener('mousemove', (e) => { + if (isDrawing) { + draw(e); + } + }); + + window.addEventListener('mouseup', () => { + if (isDrawing) { + isDrawing = false; + schedulePersist(); + } + }); + + gridEl.addEventListener('mouseleave', () => { + if (isDrawing) { + isDrawing = false; + schedulePersist(); + } + }); +}); +// --- Option Buttons Functionality --- +const copyAnimBtn = document.getElementById('copy-anim'); +const deleteAnimBtn = document.getElementById('delete-anim'); +const durationAnimBtn = document.getElementById('duration-anim'); +const durationModal = document.getElementById('duration-modal'); +const closeModalBtn = document.querySelector('#duration-modal .close-button'); +const applyDurationBtn = document.getElementById('apply-duration'); +const allFramesDurationInput = document.getElementById('all-frames-duration'); + +if (copyAnimBtn) { + copyAnimBtn.addEventListener('click', async () => { + if (loadedFrameId === null) { + showError('Please select a frame to copy.'); + setTimeout(hideError, 3000); + return; + } + + try { + const frameToCopy = loadedFrame; + const newFramePayload = { + name: `${frameToCopy.name} (copy)`, + rows: frameToCopy.rows, + duration_ms: frameToCopy.duration_ms, + brightness_levels: frameToCopy.brightness_levels + }; + await fetchWithHandling('/persist_frame', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(newFramePayload) + }, 'json', 'create copied frame'); + } catch (err) { + console.error(`[ui] Failed to copy frame ${loadedFrameId}`, err); + } + + await refreshFrames(); + }); +} + +if (deleteAnimBtn) { + deleteAnimBtn.addEventListener('click', async () => { + if (loadedFrameId === null) { + showError('Please select a frame to delete.'); + setTimeout(hideError, 3000); + return; + } + + const idToDelete = loadedFrameId; + await deleteFrame(idToDelete); + + clearLoaded(); + await refreshFrames(); + + const frameToLoad = sessionFrames.find(f => f.id !== idToDelete) || (sessionFrames.length > 0 ? sessionFrames[0] : null); + + if (frameToLoad) { + await loadFrameIntoEditor(frameToLoad.id); + } else { + // If no frames are left, initEditor will create a new empty one + await initEditor(); + } + }); +} + +if (durationAnimBtn) { + durationAnimBtn.addEventListener('click', () => { + durationModal.style.display = 'block'; + }); +} + +if (closeModalBtn) { + closeModalBtn.addEventListener('click', () => { + durationModal.style.display = 'none'; + }); +} + +// Close modal if user clicks outside of it +window.addEventListener('click', (event) => { + if (event.target == durationModal) { + durationModal.style.display = 'none'; + } +}); + +if (applyDurationBtn) { + applyDurationBtn.addEventListener('click', async () => { + const newDuration = parseInt(allFramesDurationInput.value, 10); + if (isNaN(newDuration) || newDuration < 0) { + showError('Please enter a valid, non-negative duration.'); + setTimeout(hideError, 3000); + return; + } + + durationModal.style.display = 'none'; + + const updatePromises = sessionFrames.map(frame => { + const fullFrame = sessionFrames.find(f => f.id === frame.id); + if (fullFrame) { + const payload = { ...fullFrame, duration_ms: newDuration }; + return fetchWithHandling('/persist_frame', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }, 'json', `update duration for frame ${frame.id}`).catch(err => { + console.error(`[ui] Failed to update duration for frame ${frame.id}`, err); + return Promise.resolve(); + }); + } + return Promise.resolve(); + }); + + await Promise.all(updatePromises); + await refreshFrames(); + }); +} \ No newline at end of file diff --git a/examples/led-matrix-painter/assets/docs_assets/thumbnail.png b/examples/led-matrix-painter/assets/docs_assets/thumbnail.png new file mode 100644 index 0000000..6d90cff Binary files /dev/null and b/examples/led-matrix-painter/assets/docs_assets/thumbnail.png differ diff --git a/examples/led-matrix-painter/assets/fonts/Open Sans/OFL.txt b/examples/led-matrix-painter/assets/fonts/Open Sans/OFL.txt new file mode 100644 index 0000000..a5ec031 --- /dev/null +++ b/examples/led-matrix-painter/assets/fonts/Open Sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/examples/led-matrix-painter/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf b/examples/led-matrix-painter/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..548c15f Binary files /dev/null and b/examples/led-matrix-painter/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/examples/led-matrix-painter/assets/fonts/Roboto/OFL.txt b/examples/led-matrix-painter/assets/fonts/Roboto/OFL.txt new file mode 100644 index 0000000..68f7a96 --- /dev/null +++ b/examples/led-matrix-painter/assets/fonts/Roboto/OFL.txt @@ -0,0 +1,91 @@ +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/examples/led-matrix-painter/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf b/examples/led-matrix-painter/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 0000000..3a2d704 Binary files /dev/null and b/examples/led-matrix-painter/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf differ diff --git a/examples/led-matrix-painter/assets/fonts/fonts.css b/examples/led-matrix-painter/assets/fonts/fonts.css new file mode 100644 index 0000000..86cf716 --- /dev/null +++ b/examples/led-matrix-painter/assets/fonts/fonts.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-display: swap; + src: url('Roboto/RobotoMono-VariableFont_wght.ttf') format('truetype'); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-display: swap; + src: url('Open Sans/OpenSans-VariableFont_wdth,wght.ttf') format('truetype'); +} \ No newline at end of file diff --git a/examples/led-matrix-painter/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg b/examples/led-matrix-painter/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg new file mode 100644 index 0000000..c942003 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/examples/led-matrix-painter/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg.license b/examples/led-matrix-painter/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg.license new file mode 100644 index 0000000..c274485 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + +SPDX-License-Identifier: MPL-2.0 diff --git a/examples/led-matrix-painter/assets/img/add.svg b/examples/led-matrix-painter/assets/img/add.svg new file mode 100644 index 0000000..3e1b92c --- /dev/null +++ b/examples/led-matrix-painter/assets/img/add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/led-matrix-painter/assets/img/arrow-left.svg b/examples/led-matrix-painter/assets/img/arrow-left.svg new file mode 100644 index 0000000..2e47260 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/arrow-right.svg b/examples/led-matrix-painter/assets/img/arrow-right.svg new file mode 100644 index 0000000..df4d813 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/brush.svg b/examples/led-matrix-painter/assets/img/brush.svg new file mode 100644 index 0000000..c6b91a9 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/brush.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/clear.svg b/examples/led-matrix-painter/assets/img/clear.svg new file mode 100644 index 0000000..6adc05a --- /dev/null +++ b/examples/led-matrix-painter/assets/img/clear.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/copy.svg b/examples/led-matrix-painter/assets/img/copy.svg new file mode 100644 index 0000000..d5561d1 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/down-up.svg b/examples/led-matrix-painter/assets/img/down-up.svg new file mode 100644 index 0000000..4fe3e21 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/down-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/eraser.svg b/examples/led-matrix-painter/assets/img/eraser.svg new file mode 100644 index 0000000..be297ea --- /dev/null +++ b/examples/led-matrix-painter/assets/img/eraser.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/favicon.png b/examples/led-matrix-painter/assets/img/favicon.png new file mode 100644 index 0000000..019a8cf Binary files /dev/null and b/examples/led-matrix-painter/assets/img/favicon.png differ diff --git a/examples/led-matrix-painter/assets/img/flip-h.svg b/examples/led-matrix-painter/assets/img/flip-h.svg new file mode 100644 index 0000000..ef5cbe9 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/flip-h.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/examples/led-matrix-painter/assets/img/flip-v.svg b/examples/led-matrix-painter/assets/img/flip-v.svg new file mode 100644 index 0000000..c6cee9c --- /dev/null +++ b/examples/led-matrix-painter/assets/img/flip-v.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/examples/led-matrix-painter/assets/img/icon-play.svg b/examples/led-matrix-painter/assets/img/icon-play.svg new file mode 100644 index 0000000..631d8ea --- /dev/null +++ b/examples/led-matrix-painter/assets/img/icon-play.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/icon-stop.svg b/examples/led-matrix-painter/assets/img/icon-stop.svg new file mode 100644 index 0000000..5ea1ee7 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/icon-stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/invert-draw.svg b/examples/led-matrix-painter/assets/img/invert-draw.svg new file mode 100644 index 0000000..f634945 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/invert-draw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/examples/led-matrix-painter/assets/img/invert.svg b/examples/led-matrix-painter/assets/img/invert.svg new file mode 100644 index 0000000..5a6d613 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/invert.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/mouse-brush.svg b/examples/led-matrix-painter/assets/img/mouse-brush.svg new file mode 100644 index 0000000..5d4ccb0 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/mouse-brush.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/mouse-eraser.svg b/examples/led-matrix-painter/assets/img/mouse-eraser.svg new file mode 100644 index 0000000..f166f5a --- /dev/null +++ b/examples/led-matrix-painter/assets/img/mouse-eraser.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/rotate.svg b/examples/led-matrix-painter/assets/img/rotate.svg new file mode 100644 index 0000000..c429fc1 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/rotate.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/select-arrow.svg b/examples/led-matrix-painter/assets/img/select-arrow.svg new file mode 100644 index 0000000..b997ef1 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/select-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/time.svg b/examples/led-matrix-painter/assets/img/time.svg new file mode 100644 index 0000000..b757c71 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/time.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/img/trash.svg b/examples/led-matrix-painter/assets/img/trash.svg new file mode 100644 index 0000000..4002687 --- /dev/null +++ b/examples/led-matrix-painter/assets/img/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/led-matrix-painter/assets/index.html b/examples/led-matrix-painter/assets/index.html new file mode 100644 index 0000000..0e58eb0 --- /dev/null +++ b/examples/led-matrix-painter/assets/index.html @@ -0,0 +1,146 @@ + + + + + + + + LED Matrix Painter + + + + +
+
+
+
+ +

LED Matrix Painter

+
+
+
+ + +
+ + +
+
+
+ + +
+
+

Frame 1

+
+ + + + +
+
+
+
+
+
+
+
+ +
+

Project code preview

+

+      
+
+
+
+
+ +
+
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+
+ +
+ + + + + + \ No newline at end of file diff --git a/examples/led-matrix-painter/assets/style.css b/examples/led-matrix-painter/assets/style.css new file mode 100644 index 0000000..807c1e4 --- /dev/null +++ b/examples/led-matrix-painter/assets/style.css @@ -0,0 +1,887 @@ +/* +SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + +SPDX-License-Identifier: MPL-2.0 +*/ + +@import url('fonts/fonts.css'); + +/* + * This CSS is used to center the various elements on the screen + */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Open Sans', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #DAE3E3; + line-height: 1.6; + color: #343a40; +} + +.container { + max-width: 1300px; + margin: 0 auto; + padding: 20px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding: 12px 0; +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.switch-container { + display: flex; + align-items: center; + gap: 8px; +} + + +.switch-label { + cursor: pointer; + font-size: 14px; + font-weight: 600; + color: #343a40; +} + +/* The switch - a checkbox with a custom look */ +.switch { + position: relative; + display: inline-block; + width: 34px; + height: 20px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 3px; + bottom: 3px; + background-color: white; + -webkit-transition: .4s; + transition: .4s; +} + +input:checked + .slider { + background-color: #008184; +} + +input:focus + .slider { + box-shadow: 0 0 1px #008184; +} + +input:checked + .slider:before { + -webkit-transform: translateX(14px); + -ms-transform: translateX(14px); + transform: translateX(14px); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 20px; +} + +.slider.round:before { + border-radius: 50%; +} + +.arduino-text { + color: #008184; + font-family: "Roboto Mono", monospace; + font-size: 20px; + font-weight: 700; + margin: 0; + font-style: normal; + line-height: 170%; + letter-spacing: 2.4px; +} + +.arduino-logo { + height: 24px; + width: auto; +} + +.main-content { + display: flex; + gap: 20px; + align-items: stretch; +} + +.left-sidebar { + max-width: 250px; + width: 100%; + display: flex; + flex-direction: column; + gap: 20px; +} + +.tools-box, .transform-box { + background: #ECF1F1; + padding: 16px; + border-radius: 8px; +} + +.transform-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.center-content { + background: #ECF1F1; + padding: 16px; + border-radius: 8px; + width: 100%; +} + +.center-content-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.frame-controls { + display: flex; + align-items: center; + gap: 16px; +} + +.frames-section-bottom { + margin-top: 20px; +} + +/* + * Styles for specific components required LED matrix + */ + +.legend { + display: flex; + gap: 16px; + margin-top: 8px; + font-size: 14px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.legend-color { + width: 12px; + height: 4px; + border-radius: 1px; +} + +.controls-section-right { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + background: #ECF1F1; + padding: 16px; + border-radius: 8px; +} + +.box-title { + color: #2C353A; + font-family: "Roboto Mono"; + font-size: 12px; + font-style: normal; + font-weight: 700; + line-height: 170%; + letter-spacing: 1.2px; + margin-bottom: 8px; +} + +.controls-section-left { + background: #ECF1F1; + padding: 16px; + border-radius: 8px; + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; +} + +.animation-controls-body { + display: flex; + flex-direction: row; + gap: 16px; +} + +.animation-options { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +.anim-option-btn { + border: 1px solid #C9D2D2; + border-radius: 8px; + padding: 6px; + background-color: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #2C353A; +} + +.anim-option-btn:hover { + background-color: rgba(0, 129, 132, 0.08); +} + +.anim-option-btn img { + width: 16px; + height: 16px; +} + +.animation-options .separator { + width: 100%; + height: 1px; + background-color: #C9D2D2; + margin: 4px 0; +} + +.animation-controls-main { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; +} + +.row { + margin-bottom: 12px; +} + +.movement-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.label { + font-weight: 600; + color: #2C353A; + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.label .emoji { + font-size: 24px; + line-height: 1; +} + +.value { + font-weight: 700; + font-size: 16px; +} + +.value.primary { + color: #16A588; +} + +.value.secondary { + color: #5D6A6B; + font-size: 14px; + font-weight: 400; +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: #e9ecef; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.progress-fill.primary { + background: linear-gradient(90deg, #16A588); +} + +.progress-fill.secondary { + background: linear-gradient(90deg, #5D6A6B); +} + +.error-message { + margin-top: 20px; + padding: 10px; + border-radius: 5px; + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +/* + * Responsive design + */ +@media (max-width: 1024px) { + .main-content { + flex-direction: column; + } + + .left-sidebar { + max-width: 100%; + flex-direction: row; + flex-wrap: wrap; + } + + .tools-box, .transform-box { + flex: 1; + min-width: 250px; + } + + .arduino-text { + font-size: 14px; + } + + .container { + padding: 15px; + } + + .arduino-logo { + height: 16px; + width: auto; + } + +} + +/* Specific styles for LED Matrix Painter components */ + +/* Grid and cells */ +.grid { + display: grid; + grid-template-columns: repeat(13, 40px); + grid-auto-rows: 40px; + gap: 6px; + margin: 16px auto; + background-color: #374146; + padding: 16px; + border-radius: 8px; + width: 624px; +} +.cell { + width: 40px; + height: 40px; + border-radius: 4px; + border: 1px solid #434F54; + background: #232B2E; + display: flex; + align-items: center; + justify-content: center; +} +/* brightness visual using background color intensity */ +.cell[data-b="0"] { background: #3CE2FF33; } +.cell[data-b="1"] { background: #3CE2FF50; } +.cell[data-b="2"] { background: #3CE2FF6D; } +.cell[data-b="3"] { background: #3CE2FF8A; } +.cell[data-b="4"] { background: #3CE2FFA7; } +.cell[data-b="5"] { background: #3CE2FFC4; } +.cell[data-b="6"] { background: #3CE2FFE1; } +.cell[data-b="7"] { background: #3CE2FF; } + +/* Frame previews */ +.sidebar-header { + display: flex; + gap: 8px; + justify-content: space-between; + align-items: center; +} + +.anim-buttons { + display: flex; + gap: 8px; +} + +/* Styling for the new icon-only buttons */ +.anim-buttons button { + padding: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + color: white; + border: none; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + transition: background 0.3s ease; +} + +#play-animation { + background: #008184; +} + +#play-animation:disabled { + background: #006567; +} + +#stop-animation { + background: rgba(0, 129, 132, 0.2); +} + + +.anim-buttons button:hover { + background: #006567; +} + +.anim-buttons button img { + width: 12px; + height: 12px; +} +.frames { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + height: auto; + align-items: flex-start; + align-items: center; +} +.frame-item { + display: flex; + flex-direction: column; + justify-content: space-around; + gap: 4px; + padding: 4px; + border: 1px solid #f0f0f0; + border-radius: 8px; + background: transparent; + width: 120px; + flex-shrink: 0; +} +.frame-thumb { + width: 112px; + height: 69px; + background: #374146; + border: 1px solid #ddd; + display: grid; + grid-template-columns: repeat(13, 1fr); + grid-auto-rows: 1fr; + gap: 1px; + box-sizing: border-box; +} +.frame-thumb > div { + border-radius: 1px; +} + +.frame-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + width: 100%; +} +.frame-item.dragging { + opacity: 0.5; +} +.frame-item.dragover { + outline: 2px dashed #0b76ff; +} +.frame-item.loaded { + border-color: rgba(11, 118, 255, 0.35); +} +.frame-item.loaded .frame-name { + font-weight: 700; +} +.frame-item.selected { + background-color: rgba(0, 129, 132, 0.2) !important; + border: 1px solid #008184 !important; +} + +/* General button and input styles */ +button { + padding: 8px; + border-radius: 8px; + background: #008184; + color: white; + font-size: 12px; + font-style: normal; + font-weight: 600; + border: none; + cursor: pointer; +} + +button:hover { + background: #006567; +} + +.transform-btn { + padding: 6px; + border: 1px solid #C9D2D2; + border-radius: 8px; + background: transparent; + color: #343a40; + font-size: 10px; + font-style: normal; + font-weight: 600; + cursor: pointer; +} + +.transform-btn:hover { + background-color: rgba(0, 129, 132, 0.08); +} + +input { + padding: 6px; +} + +/* Output Vector */ +#vector { + flex: 1; + box-sizing: border-box; + overflow: auto; + padding: 8px; + background: #DAE3E3; + border: 1px solid #ddd; + height: auto; + font-family: "Open Sans"; + font-size: 10px; + font-style: normal; + font-weight: 400; + color: #2C353A; + border-radius: 4px; + border: 1px solid#C9D2D2; +} + +/* Cell Slider */ +#cell-slider { + padding: 6px; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + display:none; + position:fixed; + z-index:1000; + align-items:center; + gap:8px; +} + +.vertical-separator { + display: inline-block; + width: 1px; + height: 35px; + background-color: #7F8C8D; + vertical-align: middle; +} + +.tool-controls { + display: flex; + align-items: center; +} + +/* Custom Select */ +.custom-select-wrapper { + position: relative; + display: inline-block; + user-select: none; + width: 48px; +} + +.custom-select { + position: relative; + width: 100%; +} + +.custom-select__trigger { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 12px 8px; + border-radius: 8px; + background: rgba(0, 129, 132, 0.2); + cursor: pointer; +} + +.custom-select.open .custom-options { + display: block; +} + +.custom-options { + position: absolute; + top: 100%; + left: 0; + right: 0; + display: none; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1); + border-radius: 0 0 8px 8px; + background: #fff; + z-index: 2; +} + +.custom-option { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 8px; + cursor: pointer; +} + +.custom-option:hover { + background-color: rgba(0, 129, 132, 0.08); +} + +.dropdown-arrow { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + transition: transform 0.2s ease-in-out; +} + +.custom-select.open .dropdown-arrow { + transform: translateY(-50%) rotate(180deg); +} + +.alpha-label { + font-family: "Open Sans"; + font-size: 10px; + font-style: normal; + font-weight: 600; + line-height: 160%; /* 16px */ + letter-spacing: 0.1px; +} + +.slider-limit-label { + font-size: 10px; + font-style: normal; + font-weight: 600; +} + +.tool-icon { + fill: #008184; +} + +#brightness-alpha-slider { + -webkit-appearance: none; + width: 100%; + background: transparent; + outline: none; + -webkit-transition: .2s; + transition: opacity .2s; + border-radius: 30px; +} + +/* Mouse-over effects */ +#brightness-alpha-slider:hover { + opacity: 1; +} + +/* The slider thumb (webKit) */ +#brightness-alpha-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #008184 !important; + cursor: pointer; + margin-top: -5px; + filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.25)); +} + +/* The slider thumb (Firefox) */ +#brightness-alpha-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #008184 !important; + cursor: pointer; + margin-top: -5px; + filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.25)); +} + +/* The slider track (webKit) */ +#brightness-alpha-slider::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + background: #008184 !important; + border-radius: 30px; +} + +/* The slider track (Firefox) */ +#brightness-alpha-slider::-moz-range-track { + width: 100%; + height: 6px; + background: #008184 !important; + border-radius: 30px; +} + +.grid[data-tool='brush'] { + cursor: url('img/mouse-brush.svg') 8 8, auto; +} + +.grid[data-tool='eraser'] { + cursor: url('img/mouse-eraser.svg') 8 8, auto; +} +#anim-controls { + display: none; +} +.frame-item.selected { + background-color: rgba(0, 129, 132, 0.2) !important; + border-color: rgba(0, 129, 132, 0.4); +} +.add-frame-btn { + margin-left: 8px; + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(0, 129, 132, 0.2); + border: none; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + transition: background 0.3s ease; + display: flex; + align-items: center; + justify-content: center; +} +.add-frame-btn:hover { + background: rgba(0, 129, 132, 0.3); +} +.add-frame-btn img { + width: 16px; + height: 16px; +} + +.frame-item:hover { + cursor: pointer; +} + +.frame-name, .frame-duration { + color: #2C353A; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 160%; + letter-spacing: 0.12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.modal { + display: none; + position: fixed; + z-index: 1001; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.4); +} +.modal-content { + background-color: #fefefe; + margin: 15% auto; + padding: 20px; + border: 1px solid #888; + width: 80%; + max-width: 400px; + border-radius: 8px; + text-align: left; +} + +#all-frames-duration { + margin-top: 16px; + width: 100%; + box-sizing: border-box; +} + +#apply-duration { + margin-top: 16px; + width: 100%; +} +.modal-content-title { + font-size: 12px; + font-style: normal; + font-weight: 600; + color: #2C353A; +} +.modal-content-p { + font-size:10px; + font-style: normal; + font-weight: 400; + color: #2C353A; +} +.close-button { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} +.close-button:hover, +.close-button:focus { + color: black; + text-decoration: none; + cursor: pointer; +} +#global-frame-inputs { + display: none; +} + +@media (max-width: 700px) { + .grid { + grid-template-columns: repeat(13, 20px); + grid-auto-rows: 20px; + gap: 3px; /* Half the original gap for smaller cells */ + width: calc(13 * 20px + 12 * 3px + 32px); /* 13 cells * 20px + 12 gaps * 3px + 2*16px padding */ + padding: 16px; /* Keep padding consistent, or adjust as needed */ + } + .cell { + width: 20px; + height: 20px; + } +} + diff --git a/examples/led-matrix-painter/python/app_frame.py b/examples/led-matrix-painter/python/app_frame.py new file mode 100644 index 0000000..db66c9b --- /dev/null +++ b/examples/led-matrix-painter/python/app_frame.py @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +import json +from arduino.app_utils import Frame + +class AppFrame(Frame): + """Extended Frame app_utils class with application-specific metadata. + + This subclass of `arduino.app_utils.Frame` takes care of Frame validation + and array management while adding application-specific attributes used + in the LED matrix tool app. + + Usage: + + # Create from JSON-serializable dict from API payload + frame = AppFrame.from_json({ + "id": 1, + "name": "My Frame", + "position": 0, + "duration_ms": 1000, + "rows": [[0, 255, 0], [255, 0, 255]], + "brightness_levels": 256, + }) + + # Convert to JSON-serializable dict for API responses + json_dict = frame.to_json() + + # Create from database record dict + record = { + "id": 1, + "name": "My Frame", + "position": 0, + "duration_ms": 1000, + "rows": json.dumps([[0, 255, 0], [255, 0, 255]]), + "brightness_levels": 256, + } + frame = AppFrame.from_record(record) + + # Convert to database record dict for storage + record_dict = frame.to_record() + + # Create empty AppFrame + empty_frame = AppFrame.create_empty( + id=2, + name="Empty Frame", + position=1, + duration_ms=500, + brightness_levels=256, + ) + + # Export to C string for embedding in source code + c_string = frame.to_c_string() + + # Mutate array values in-place + frame.set_value(0, 0, 128) + + # Mutate array in-place + frame.set_array(frame.arr * 0.5) + """ + def __init__( + self, + id: int, + name: str, + position: int, + duration_ms: int, + arr, + brightness_levels: int = 256 + ): + """Initialize the AppFrame instance with application-specific attributes. + + Args: + arr (numpy.ndarray): The array data for the frame. + brightness_levels (int): Number of brightness levels (default 256). + + Attributes: + id (int): database ID of the frame. + name (str): user-defined name of the frame. + position (int): user-defined position/order of the frame. + duration_ms (int): duration in milliseconds for animated frames. + """ + super().__init__(arr, brightness_levels=brightness_levels) # Initialize base Frame attributes + self.id = id + self.name = name + self.position = position + self.duration_ms = duration_ms + + # -- JSON serialization/deserialization for frontend -------------------------------- + @classmethod + def from_json(cls, data: dict) -> "AppFrame": + """Reconstruct an AppFrame from a JSON-serializable dict. + + This is the constructor used both for frontend payloads and + for DB records. + """ + id = data.get('id') + name = data.get('name') + position = data.get('position') + duration_ms = data.get('duration_ms') + rows = data.get('rows') + brightness_levels = data.get('brightness_levels') + return cls.from_rows(id, name, position, duration_ms, rows, brightness_levels=brightness_levels) + + def to_json(self) -> dict: + """Convert to a JSON-serializable dict for API responses""" + return { + "id": self.id, + "name": self.name, + "rows": self.arr.tolist(), + "brightness_levels": int(self.brightness_levels), + "position": self.position, + "duration_ms": int(self.duration_ms) if self.duration_ms is not None else 1000 + } + + # -- record serialization/deserialization for DB storage -------------------------------- + + @classmethod + def from_record(cls, record: dict) -> "AppFrame": + """Reconstruct an AppFrame from a database record dict.""" + id = record.get('id') + name = record.get('name') + position = record.get('position') + duration_ms = record.get('duration_ms') + rows = json.loads(record.get('rows')) + brightness_levels = record.get('brightness_levels') + return cls.from_rows(id, name, position, duration_ms, rows, brightness_levels=brightness_levels) + + def to_record(self) -> dict: + """Convert to a database record dict for storage.""" + return { + "id": self.id, + "name": self.name, + "rows": json.dumps(self.arr.tolist()), + "brightness_levels": int(self.brightness_levels), + "position": self.position, + "duration_ms": int(self.duration_ms) if self.duration_ms is not None else 1000 + } + + # -- other exports ---------------------------------------------------- + def to_c_string(self) -> str: + """Export the frame as a C vector string. + + The Frame is rescaled to the 0-255 range prior to exporting as board-compatible C source. + + Returns: + str: C source fragment containing a const array initializer. + """ + c_type = "uint32_t" + scaled_arr = self.rescale_quantized_frame(scale_max=255) + + parts = [f"const {c_type} {self.name}[] = {{"] + rows = scaled_arr.tolist() + # Emit the array as row-major integer values, preserving row breaks for readability + for r_idx, row in enumerate(rows): + line = ", ".join(str(int(v)) for v in row) + if r_idx < len(rows) - 1: + parts.append(f" {line},") + else: + parts.append(f" {line}") + parts.append("};") + parts.append("") + return "\n".join(parts) + + # -- create empty AppFrame -------------------------------- + @classmethod + def create_empty( + cls, + id: int, + name: str, + position: int, + duration_ms: int, + brightness_levels: int = 256, + ) -> "AppFrame": + """Create an empty AppFrame with all pixels set to 0. + + Args: + id (int): database ID of the frame. + name (str): user-defined name of the frame. + position (int): user-defined position/order of the frame. + duration_ms (int): duration in milliseconds for animated frames. + width (int): width of the frame in pixels. + height (int): height of the frame in pixels. + brightness_levels (int): number of brightness levels (default 256). + + Returns: + AppFrame: newly constructed empty AppFrame instance. + """ + import numpy as np + height = 8 + width = 13 + arr = np.zeros((height, width), dtype=np.uint8) + return cls(id, name, position, duration_ms, arr, brightness_levels=brightness_levels) + + # -- array/value in-place mutations wrappers -------------------------------- + def set_array(self, arr) -> "AppFrame": + super().set_array(arr) + return self + + def set_value(self, row: int, col: int, value: int) -> None: + return super().set_value(row, col, value) + + # -- animation export -------------------------------- + def to_animation_hex(self) -> list[str]: + """Convert frame to animation format: 5 hex strings [hex0, hex1, hex2, hex3, duration_ms]. + + This format is used by Arduino_LED_Matrix library for animations. + Each frame in an animation is represented as: + - 4 uint32_t values (128 bits total) for binary pixel data + - 1 uint32_t value for duration in milliseconds + + Returns: + list[str]: List of 5 hex strings in format ["0xHHHHHHHH", "0xHHHHHHHH", "0xHHHHHHHH", "0xHHHHHHHH", "duration"] + """ + # Rescale to 0-255 range for threshold + arr_scaled = self.rescale_quantized_frame(scale_max=255) + height, width = arr_scaled.shape + + # Convert to binary presence (non-zero pixels -> 1) + pixels = (arr_scaled > 0).astype(int).flatten().tolist() + + # Pad to 128 bits (4 * 32) + if len(pixels) > 128: + raise ValueError(f"Pixel buffer too large: {len(pixels)} > 128") + pixels += [0] * (128 - len(pixels)) + + # Pack into 4 uint32_t hex values + hex_values = [] + for i in range(0, 128, 32): + value = 0 + for j in range(32): + bit = int(pixels[i + j]) & 1 + value |= bit << (31 - j) + hex_values.append(f"0x{value:08x}") + + # Append duration_ms as last value + duration = int(self.duration_ms) if self.duration_ms is not None else 1000 + hex_values.append(str(duration)) + + return hex_values + + # -- Frame.from_rows override (for subclass construction only) --------------------------- + @classmethod + def from_rows( + cls, + id: int, + name: str, + position: int, + duration_ms: int, + rows: list[list[int]] | list[str], + brightness_levels: int = 256, + ) -> "AppFrame": + """Create an AppFrame from frontend rows. + + **Do NOT use it in the app directly, please use `AppFrame.from_json()` or `AppFrame.from_record()` instead.** + + This method overrides Frame.from_rows which constructs a Frame and it is intended + only for subclass construction and coherence with Frame API. + + We delegate parsing/validation to Frame.from_rows and then construct an + AppFrame instance with subclass-specific attributes. + + Args: + rows (list | list[str]): frontend rows representation (list of lists or list of strings). + brightness_levels (int): number of brightness levels (default 256). + + Attributes: + id (int): database ID of the frame. + name (str): user-defined name of the frame. + position (int): user-defined position/order of the frame. + duration_ms (int): duration in milliseconds for animated frames. + + Returns: + AppFrame: newly constructed AppFrame instance. + """ + # Use Frame to parse rows into a numpy array/frame and to validate it + frame_instance = super().from_rows(rows, brightness_levels=brightness_levels) + + # Get the validated numpy array as a writable copy + arr = frame_instance.arr.copy() + + # Construct an AppFrame using the validated numpy array and brightness_levels + return cls(id, name, position, duration_ms, arr, brightness_levels=frame_instance.brightness_levels) + diff --git a/examples/led-matrix-painter/python/main.py b/examples/led-matrix-painter/python/main.py new file mode 100644 index 0000000..0bc8745 --- /dev/null +++ b/examples/led-matrix-painter/python/main.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_bricks.web_ui import WebUI +from arduino.app_utils import App, Bridge, FrameDesigner, Logger +from app_frame import AppFrame # user module defining AppFrame +import store # user module for DB operations +import logging +import threading + +BRIGHTNESS_LEVELS = 8 # must match the frontend slider range (0..BRIGHTNESS_LEVELS-1) + +logger = Logger(__name__, level=logging.DEBUG) +ui = WebUI() +designer = FrameDesigner() + +logger.info("Initializing LED matrix tool") +store.init_db() +logger.info(f"Database initialized, brightness_levels={BRIGHTNESS_LEVELS}") + + +def get_config(): + """Expose runtime configuration for the frontend.""" + return { + 'brightness_levels': BRIGHTNESS_LEVELS, + 'width': designer.width, + 'height': designer.height, + } + + +def apply_frame_to_board(frame: AppFrame): + """Send frame bytes to the Arduino board.""" + frame_bytes = frame.to_board_bytes() + Bridge.call("draw", frame_bytes) + frame_label = f"name={frame.name}, id={frame.id if frame.id else 'None (preview)'}" + logger.debug(f"Frame sent to board: {frame_label}, bytes_len={len(frame_bytes)}") + + +def update_board(payload: dict): + """Update board display in real-time without persisting to DB. + + Used for live preview during editing. + Expected payload: {rows, name, id, position, duration_ms, brightness_levels} + """ + frame = AppFrame.from_json(payload) + apply_frame_to_board(frame) + vector_text = frame.to_c_string() + return {'ok': True, 'vector': vector_text} + + +def persist_frame(payload: dict): + """Persist frame to DB (insert new or update existing). + + Backend (store.save_frame) is responsible for assigning progressive names. + + Expected payload: {rows, name, id, position, duration_ms, brightness_levels} + """ + frame = AppFrame.from_json(payload) + + if frame.id is None: + # Insert new frame - backend assigns name if empty + logger.debug(f"Creating new frame: name='{frame.name}'") + frame.id = store.save_frame(frame) + # Reload frame to get backend-assigned name + record = store.get_frame_by_id(frame.id) + if record: + frame = AppFrame.from_record(record) + logger.info(f"New frame created: id={frame.id}, name={frame.name}") + else: + # Update existing frame + logger.debug(f"Updating frame: id={frame.id}, name={frame.name}") + store.update_frame(frame) + + apply_frame_to_board(frame) + vector_text = frame.to_c_string() + return {'ok': True, 'frame': frame.to_json(), 'vector': vector_text} + + +def bulk_update_frame_duration(payload) -> bool: + """Update the duration of all frames.""" + duration = payload.get('duration_ms', 1000) + logger.debug(f"Bulk updating frame duration: duration={duration}") + store.bulk_update_frame_duration(duration) + return True + + +def load_frame(payload: dict = None): + """Load a frame for editing or create empty if none exist. + + Optional payload: {id: int} to load specific frame + If no ID provided, loads last frame or creates empty + """ + fid = payload.get('id') if payload else None + + if fid is not None: + logger.debug(f"Loading frame by id: {fid}") + record = store.get_frame_by_id(fid) + if not record: + logger.warning(f"Frame not found: id={fid}") + return {'error': 'frame not found'} + frame = AppFrame.from_record(record) + logger.info(f"Frame loaded: id={frame.id}, name={frame.name}") + else: + # Get last frame or create empty + logger.debug("Loading last frame or creating empty") + frame = store.get_or_create_active_frame(brightness_levels=BRIGHTNESS_LEVELS) + logger.info(f"Active frame ready: id={frame.id}, name={frame.name}") + + apply_frame_to_board(frame) + vector_text = frame.to_c_string() + return {'ok': True, 'frame': frame.to_json(), 'vector': vector_text} + + +def list_frames(): + """Return list of frames for sidebar.""" + records = store.list_frames(order_by='position ASC, id ASC') + frames = [AppFrame.from_record(r).to_json() for r in records] + return {'frames': frames} + + +def get_frame(payload: dict): + """Get single frame by ID.""" + fid = payload.get('id') + record = store.get_frame_by_id(fid) + + if not record: + return {'error': 'not found'} + + frame = AppFrame.from_record(record) + return {'frame': frame.to_json()} + + +def delete_frame(payload: dict): + """Delete frame by ID.""" + fid = payload.get('id') + logger.info(f"Deleting frame: id={fid}") + store.delete_frame(fid) + return {'ok': True} + + +def reorder_frames(payload: dict): + """Reorder frames to match provided id list order.""" + order = payload.get('order', []) + logger.info(f"Reordering frames: new order={order}") + store.reorder_frames(order) + return {'ok': True} + + +def transform_frame(payload: dict): + """Apply transformation operation to a frame. + + Payload: {op: str, rows: list OR id: int} + Operations: invert, invert_not_null, rotate180, flip_h, flip_v + """ + op = payload.get('op') + if not op: + return {'error': 'op required'} + + # Load frame from rows or by ID + rows = payload.get('rows') + if rows is not None: + frame = AppFrame.from_json({'rows': rows, 'brightness_levels': BRIGHTNESS_LEVELS}) + logger.debug(f"Transforming frame from rows: op={op}") + else: + fid = payload.get('id') + if fid is None: + return {'error': 'id or rows required'} + record = store.get_frame_by_id(fid) + if not record: + return {'error': 'frame not found'} + frame = AppFrame.from_record(record) + logger.debug(f"Transforming frame by id: id={fid}, op={op}") + + # Apply transformation + operations = { + 'invert': designer.invert, + 'invert_not_null': designer.invert_not_null, + 'rotate180': designer.rotate180, + 'flip_h': designer.flip_horizontally, + 'flip_v': designer.flip_vertically, + } + if op not in operations: + logger.warning(f"Unsupported transform operation: {op}") + return {'error': 'unsupported op'} + + operations[op](frame) + logger.info(f"Transform applied: op={op}") + + # Return transformed frame (frontend will handle board update via persist) + return {'ok': True, 'frame': frame.to_json(), 'vector': frame.to_c_string()} + + +def export_frames(payload: dict = None): + """Export multiple frames into a single C header string. + + Payload (optional): {frames: [id,...], animations: [{name, frames}]} + - If no animations: exports frames as individual arrays (Frames mode) + - If animations present: exports as animation sequences (Animations mode) + """ + # Get frame IDs to export + if payload and payload.get('frames'): + frame_ids = [int(fid) for fid in payload['frames']] + logger.info(f"Exporting selected frames: ids={frame_ids}") + records = [store.get_frame_by_id(fid) for fid in frame_ids] + records = [r for r in records if r is not None] + else: + logger.info("Exporting all frames") + records = store.list_frames(order_by='position ASC, id ASC') + + logger.debug(f"Exporting {len(records)} frames to C header") + + # Build frame objects and check for duplicate names + frames = [AppFrame.from_record(r) for r in records] + frame_names = {} # name -> count + for frame in frames: + frame_names[frame.name] = frame_names.get(frame.name, 0) + 1 + + # Assign unique names if duplicates exist + name_counters = {} # name -> current index + for frame in frames: + if frame_names[frame.name] > 1: + # Duplicate detected, add suffix + if frame.name not in name_counters: + name_counters[frame.name] = 0 + # Use _idN suffix for uniqueness + frame._export_name = f"{frame.name}_id{frame.id}" + logger.debug(f"Duplicate name '{frame.name}' -> '{frame._export_name}'") + else: + # Unique name, use as-is + frame._export_name = frame.name + + # Check if we're in animations mode + animations = payload.get('animations') if payload else None + + if animations: + # Animation mode: export as animation sequences + logger.info(f"Animation mode: {len(animations)} animation(s)") + header_parts = [] + + for anim in animations: + anim_name = anim.get('name', 'Animation') + anim_frame_ids = anim.get('frames', []) + + # Get frames for this animation + anim_frames = [f for f in frames if f.id in anim_frame_ids] + + if not anim_frames: + continue + + # Build animation array + header_parts.append(f"// Animation: {anim_name}") + header_parts.append(f"const uint32_t {anim_name}[][5] = {{") + + for frame in anim_frames: + hex_values = frame.to_animation_hex() + hex_str = ", ".join(hex_values) + header_parts.append(f" {{{hex_str}}}, // {frame._export_name}") + + header_parts.append("};") + header_parts.append("") + + header = "\n".join(header_parts).strip() + "\n" + return {'header': header} + else: + # Frames mode: export individual frame arrays + header_parts = [] + for frame in frames: + header_parts.append(f"// {frame._export_name} (id {frame.id})") + header_parts.append(frame.to_c_string()) + + header = "\n".join(header_parts).strip() + "\n" + return {'header': header} + + +def play_animation_thread(animation_bytes): + try: + Bridge.call("play_animation", bytes(animation_bytes)) + logger.info("Animation sent to board successfully") + except Exception as e: + logger.warning(f"Failed to send animation to board: {e}") + +def play_animation(payload: dict): + """Play animation sequence on the board. + + Payload: {frames: [id,...], loop: bool} + - frames: list of frame IDs to play in sequence + - loop: whether to loop the animation (default: false) + """ + frame_ids = payload.get('frames', []) + loop = payload.get('loop', False) + + if not frame_ids: + logger.warning("play_animation called with no frames") + return {'error': 'no frames provided'} + + logger.info(f"Playing animation: frame_count={len(frame_ids)}, loop={loop}") + + # Load frames from DB + records = [store.get_frame_by_id(fid) for fid in frame_ids] + records = [r for r in records if r is not None] + + if not records: + logger.warning("No valid frames found for animation") + return {'error': 'no valid frames found'} + + frames = [AppFrame.from_record(r) for r in records] + logger.debug(f"Loaded {len(frames)} frames for animation") + + # Build animation data as bytes (std::vector in sketch) + # Each uint32_t is sent as 4 bytes (little-endian) + animation_bytes = bytearray() + for frame in frames: + hex_values = frame.to_animation_hex() + # First 4 are hex pixel data + for i in range(4): + value = int(hex_values[i], 16) + animation_bytes.extend(value.to_bytes(4, byteorder='little')) + # 5th is duration in ms + duration = int(hex_values[4]) + animation_bytes.extend(duration.to_bytes(4, byteorder='little')) + + logger.debug(f"Animation data prepared: {len(animation_bytes)} bytes ({len(animation_bytes)//20} frames)") + + # Run Bridge.call in a separate thread + thread = threading.Thread(target=play_animation_thread, args=(animation_bytes,)) + thread.start() + + return {'ok': True, 'frames_played': len(frames)} # Return immediately + + +ui.expose_api('POST', '/update_board', update_board) +ui.expose_api('POST', '/persist_frame', persist_frame) +ui.expose_api('POST', '/load_frame', load_frame) +ui.expose_api('GET', '/list_frames', list_frames) +ui.expose_api('POST', '/get_frame', get_frame) +ui.expose_api('POST', '/delete_frame', delete_frame) +ui.expose_api('POST', '/transform_frame', transform_frame) +ui.expose_api('POST', '/export_frames', export_frames) +ui.expose_api('POST', '/reorder_frames', reorder_frames) +ui.expose_api('POST', '/play_animation', play_animation) +ui.expose_api('GET', '/config', get_config) + +App.run() diff --git a/examples/led-matrix-painter/python/store.py b/examples/led-matrix-painter/python/store.py new file mode 100644 index 0000000..db766af --- /dev/null +++ b/examples/led-matrix-painter/python/store.py @@ -0,0 +1,208 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_bricks.dbstorage_sqlstore import SQLStore +from app_frame import AppFrame +from typing import Any + +DB_NAME = "led_matrix_frames" + +# Initialize and expose a module-level SQLStore instance +db = SQLStore(database_name=DB_NAME) + + +def init_db(): + """Start SQLStore and create the frames table. + + Call this from the application startup (it is intentionally + separated from module import so the application controls lifecycle). + """ + db.start() + db.create_table( + "frames", + { + "id": "INTEGER PRIMARY KEY", + "name": "TEXT", + "duration_ms": "INTEGER", + "position": "INTEGER", + "brightness_levels": "INTEGER", + "rows": "TEXT", # JSON string encoding of 2D array + } + ) + print("[db_frames] SQLStore started for frames persistence") + + +def list_frames(order_by: str = "position ASC, id ASC") -> list[dict[str, Any]]: + """Return ordered list of frame records (raw DB dicts). + + Returns: + list[dict]: list of frame records with all fields + """ + res = db.read("frames", order_by=order_by) or [] + return res + + +def get_frame_by_id(fid: int) -> dict[str, Any] | None: + """Return the raw DB record dict for a frame id. + + Args: + fid (int): frame id + + Returns: + dict | None: raw DB record dict or None if not found + """ + res = db.read("frames", condition=f"id = {int(fid)}") or [] + if not res: + return None + return res[0] + + +def save_frame(frame: AppFrame) -> int: + """Insert a new frame into DB and return assigned ID. + + Backend is responsible for assigning progressive names if name is empty. + + Args: + frame (AppFrame): frame to save (id will be ignored and assigned by DB) + + Returns: + int: newly assigned frame ID + """ + # Calculate next position + mx_rows = db.read("frames", columns=["MAX(position) as maxpos"]) or [] + maxpos = mx_rows[0].get("maxpos") if mx_rows and len(mx_rows) > 0 else None + next_position = (int(maxpos) if maxpos is not None else 0) + 1 + + # Use frame.position if set, otherwise use next_position + position = frame.position if frame.position is not None else next_position + + record = frame.to_record() + record['position'] = position + # Remove id from record (will be auto-assigned) + record.pop('id', None) + + db.store("frames", record, create_table=False) + + last = db.execute_sql("SELECT last_insert_rowid() as id") + new_id = last[0].get("id") if last else None + + # Backend responsibility: assign progressive name if empty + if new_id and (not frame.name or frame.name.strip() == ''): + frame.name = f'Frame {new_id}' + frame.id = new_id + db.update("frames", {"name": frame.name}, condition=f"id = {new_id}") + + return new_id + + +def update_frame(frame: AppFrame) -> bool: + """Update an existing frame in DB. + + Args: + frame (AppFrame): frame to update (must have valid id) + + Returns: + bool: True if update succeeded + """ + if frame.id is None: + raise ValueError("Cannot update frame without id") + + record = frame.to_record() + # Remove id from update dict (used in WHERE clause) + fid = record.pop('id') + + db.update("frames", record, condition=f"id = {int(fid)}") + return True + + +def bulk_update_frame_duration(duration) -> bool: + """Update the duration of all frames. + + Args: + duration (int): new duration in milliseconds + + Returns: + bool: True if update succeeded + """ + if duration < 1: + raise ValueError("Valid duration must be provided for bulk update") + db.update("frames", {"duration_ms": int(duration)}) + return True + +def delete_frame(fid: int) -> bool: + """Delete a frame and recompact positions. + + Args: + fid (int): frame id to delete + + Returns: + bool: True if deletion succeeded + """ + db.delete("frames", condition=f"id = {int(fid)}") + # Recompact positions + rows = db.read("frames", order_by="position ASC, id ASC") or [] + for pos, r in enumerate(rows, start=1): + db.update("frames", {"position": pos}, condition=f"id = {int(r.get('id'))}") + return True + + +def reorder_frames(order: list[int]) -> bool: + """Reorder frames by assigning new positions based on provided ID list. + + Args: + order (list[int]): list of frame IDs in desired order + + Returns: + bool: True if reorder succeeded + """ + for idx, fid in enumerate(order, start=1): + db.update("frames", {"position": idx}, condition=f"id = {int(fid)}") + return True + + +def get_last_frame() -> AppFrame | None: + """Get the last frame (highest position) or None if no frames exist. + + Returns: + AppFrame | None: last frame or None + """ + records = db.read("frames", order_by="position DESC, id DESC") or [] + if not records: + return None + return AppFrame.from_record(records[0]) + + +def get_or_create_active_frame(brightness_levels: int = 8) -> AppFrame: + """Get last frame or create empty frame if none exist. + + Backend is responsible for assigning progressive names via save_frame(). + + Args: + brightness_levels (int): brightness levels for new frame (default 8) + + Returns: + AppFrame: last existing frame or newly created empty frame + """ + last = get_last_frame() + if last is not None: + return last + + # Create empty frame with empty name (backend will assign Frame{id}) + frame = AppFrame.create_empty( + id=None, + name="", + position=1, + duration_ms=1000, + brightness_levels=brightness_levels + ) + + # Backend assigns ID and name automatically + frame.id = save_frame(frame) + + # Reload from DB to get the assigned name + record = get_frame_by_id(frame.id) + if record: + return AppFrame.from_record(record) + + return frame diff --git a/examples/led-matrix-painter/sketch/sketch.ino b/examples/led-matrix-painter/sketch/sketch.ino new file mode 100644 index 0000000..cac69d6 --- /dev/null +++ b/examples/led-matrix-painter/sketch/sketch.ino @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +// +// SPDX-License-Identifier: MPL-2.0 + +// Example sketch using Arduino_LED_Matrix and RouterBridge. This sketch +// exposes two providers: +// - "draw" which accepts a std::vector (by-value) and calls matrix.draw() +// - "play_animation" which accepts a byte array representing multiple frames +#include +#include +#include + +Arduino_LED_Matrix matrix; + +void draw(std::vector frame) { + if (frame.empty()) { + Serial.println("[sketch] draw called with empty frame"); + return; + } + Serial.print("[sketch] draw called, frame.size="); + Serial.println((int)frame.size()); + matrix.draw(frame.data()); +} + +// Play animation using std::vector to avoid C++ exception linking issues +// The data is sent as bytes from Python: each uint32_t is sent as 4 bytes (little-endian) +void play_animation(std::vector animation_bytes) { + if (animation_bytes.empty()) { + Serial.println("[sketch] play_animation called with empty data"); + return; + } + + // Each uint32_t is 4 bytes, each frame is 5 uint32_t (20 bytes) + const int BYTES_PER_FRAME = 20; + int frame_count = animation_bytes.size() / BYTES_PER_FRAME; + + Serial.print("[sketch] play_animation called, bytes="); + Serial.print((int)animation_bytes.size()); + Serial.print(", frame_count="); + Serial.println(frame_count); + + if (frame_count == 0) { + Serial.println("[sketch] Invalid animation data: not enough bytes"); + return; + } + + // Maximum 50 frames to avoid stack overflow + const int MAX_FRAMES = 50; + if (frame_count > MAX_FRAMES) { + Serial.print("[sketch] Too many frames, truncating to "); + Serial.println(MAX_FRAMES); + frame_count = MAX_FRAMES; + } + + // Static buffer to avoid dynamic allocation + static uint32_t animation[MAX_FRAMES][5]; + + // Convert bytes to uint32_t array + const uint8_t* data = animation_bytes.data(); + for (int i = 0; i < frame_count; i++) { + for (int j = 0; j < 5; j++) { + int byte_offset = (i * 5 + j) * 4; + // Reconstruct uint32_t from 4 bytes (little-endian) + animation[i][j] = ((uint32_t)data[byte_offset]) | + ((uint32_t)data[byte_offset + 1] << 8) | + ((uint32_t)data[byte_offset + 2] << 16) | + ((uint32_t)data[byte_offset + 3] << 24); + } + } + + // Load and play the sequence using the Arduino_LED_Matrix library + matrix.loadWrapper(animation, frame_count * 5 * sizeof(uint32_t)); + matrix.playSequence(false); // Don't loop by default + + Serial.println("[sketch] Animation playback complete"); +} + +void setup() { + matrix.begin(); + Serial.begin(115200); + // configure grayscale bits to 8 so the display can accept 0..255 brightness + // The MCU expects full-byte brightness values from the backend. + matrix.setGrayscaleBits(8); + matrix.clear(); + + Bridge.begin(); + + // Register the draw provider (by-value parameter). Using by-value avoids + // RPC wrapper template issues with const reference params. + Bridge.provide("draw", draw); + + // Register the animation player provider + Bridge.provide("play_animation", play_animation); +} + +void loop() { + delay(200); +} diff --git a/examples/led-matrix-painter/sketch/sketch.yaml b/examples/led-matrix-painter/sketch/sketch.yaml new file mode 100644 index 0000000..d9fe917 --- /dev/null +++ b/examples/led-matrix-painter/sketch/sketch.yaml @@ -0,0 +1,11 @@ +profiles: + default: + fqbn: arduino:zephyr:unoq + platforms: + - platform: arduino:zephyr + libraries: + - MsgPack (0.4.2) + - DebugLog (0.8.4) + - ArxContainer (0.7.0) + - ArxTypeTraits (0.3.1) +default_profile: default diff --git a/examples/mascot-jump-game/README.md b/examples/mascot-jump-game/README.md new file mode 100644 index 0000000..1599378 --- /dev/null +++ b/examples/mascot-jump-game/README.md @@ -0,0 +1,431 @@ +# Mascot Jump Game + +An endless runner game inspired by the classic browser dinosaur game, where you control an LED character jumping over electronic components. Features progressively increasing difficulty, score tracking, one-button gameplay, and synchronized LED matrix animations on the UNO Q. + +![Mascot Jump Game Example](assets/docs_assets/thumbnail.png) + +## Description + +The App uses the `web_ui` Brick to create a browser-based game with real-time communication between the UNO Q and a web interface. The backend manages game physics, collision detection, and scoring at 60 FPS, while the frontend renders the LED character using PNG images for different animations. + +![Mascot Jump Game - LED Character](assets/docs_assets/led_character_animation.png) + +Key features include: + +- LED character with six animation states (4 running patterns, jump, game over) +- Electronic component obstacles: resistors, transistors, and microchips +- Synchronized LED matrix display mirroring game state +- Progressive difficulty scaling with score +- Keyboard and mouse control +- Session high score tracking + +## Bricks Used + +The mascot jump game example uses the following Bricks: + +- `web_ui`: Brick to create a web interface with real-time communication between the browser and Arduino board with game state updates, input handling, and rendering synchronization. + +## Hardware and Software Requirements + +### Hardware + +- Arduino UNO Q (x1) +- USB-C® cable (for power and programming) (x1) + +### Software + +- Arduino App Lab + +**Note:** You can also run this example using your Arduino UNO Q as a Single Board Computer (SBC) using a [USB-C hub](https://store.arduino.cc/products/usb-c-to-hdmi-multiport-adapter-with-ethernet-and-usb-hub) with a mouse, keyboard and display attached. + +## How to Use the Example + +1. **Run the App** + +![Arduino App Lab - Run App](assets/docs_assets/launch-app.png) + +2. **Access the Web Interface** + +The App should open automatically in the web browser. You can also open it manually via `.local:7000`. The `WebUI` brick establishes a WebSocket connection for real-time communication between browser and UNO Q. + +3. **Wait for Game Initialization** + +The game loads and displays the LED character in idle state. The `GameState` class initializes with default parameters, while the Arduino sketch begins polling game state through `Bridge.call("get_led_state").result(gameState)`. + +4. **Start Playing** + +Press **SPACE** or **UP ARROW** to jump over obstacles. The keypress triggers a `player_action` WebSocket message to the backend, which validates and applies the jump physics. Use **R** to restart after game over. + +![Gameplay Example](assets/docs_assets/game_play_state.gif) + +5. **Avoid Obstacles** + +Jump over three types of electronic components: *resistors* (small), *transistors* (medium), and *microchips* (large). The backend's `spawn_obstacle()` creates new obstacles at random intervals, while the game loop moves them across the screen. Your score increases continuously based on survival time. + +6. **Game Over** + +When you hit an obstacle, `check_collisions()` detects the hit and triggers game over. Your final score and session high score are displayed. The LED character shows a fallen animation. Press **SPACE** to call `game.reset()` and restart. + +![Game Over Screen](assets/docs_assets/game_over_state.gif) + +7. **LED Matrix Synchronization** + +The LED matrix on your UNO Q mirrors the game state. The Arduino sketch calls `Bridge.call("get_led_state").result(gameState)` every 50 ms to get the current state (*running*, *jumping*, *game_over*, or *idle*), then displays the matching LED frame from `game_frames.h`. For more information about the LED matrix, see the [LED Matrix setion from the UNO Q user manual](https://docs.arduino.cc/tutorials/uno-q/user-manual/#led-matrix). + +![LED Matrix Frames](assets/docs_assets/led_matrix_frames.png) + +8. **Progressive Difficulty** + +The game speed increases as your score grows using `BASE_SPEED + (score / 1500.0)`. The `game_loop()` runs at 60 FPS, updating physics, moving obstacles, checking collisions, and broadcasting state to all connected clients. + +## How it Works + +Once the App is running, it performs the following operations: + +- **Managing game state and physics calculations on the backend.** + +The backend maintains the complete game state and physics engine: + +```python +from arduino.app_utils import * +from arduino.app_bricks.web_ui import WebUI +import time +import random +import threading +import json +... +class GameState: + def __init__(self): + self.reset() + self.high_score = 0 + + def reset(self): + self.mascot_y = GROUND_Y - MASCOT_HEIGHT + self.velocity_y = 0.0 + self.on_ground = True + self.obstacles = [] + self.score = 0 + self.game_over = False + self.speed = BASE_SPEED + self.last_spawn_time = time.time() + self.next_spawn_delay = random.uniform(SPAWN_MIN_MS/1000, SPAWN_MAX_MS/1000) + + def update_physics(self, dt): + if not self.on_ground: + self.velocity_y += GRAVITY * dt * 60 # Scale for 60 FPS base + self.mascot_y += self.velocity_y * dt * 60 + + # Ground collision + if self.mascot_y >= GROUND_Y - MASCOT_HEIGHT: + self.mascot_y = GROUND_Y - MASCOT_HEIGHT + self.velocity_y = 0.0 + self.on_ground = True +... +game = GameState() +``` + +The physics engine calculates gravity effects, jump trajectories, and collision boundaries at a fixed timestep for consistent gameplay. + +- **Providing LED matrix state through Bridge communication.** + +The LED Matrix on the UNO Q displays the game state in real-time with a simplified mascot design: + +```python +def get_led_state(): + global game_started + + if game.game_over: + return "game_over" + elif not game_started and game.score == 0: + return "idle" + elif not game.on_ground: + return "jumping" + else: + return "running" + +... +# Provide function to Arduino sketch +Bridge.provide("get_led_state", get_led_state) +``` + +The LED matrix shows different animations: + +- **Running State:** 4-frame animation cycling through leg positions +- **Jumping State:** Mascot in mid-air with arms spread +- **Idle State:** Standing mascot waiting to start +- **Game Over State:** Fallen mascot rotated 45 degrees with extended arms + +The Arduino sketch processes these states using the `Arduino_LED_Matrix` library: + +```cpp +#include +#include +#include "game_frames.h" + +Arduino_LED_Matrix matrix; + +int animationFrame = 0; +unsigned long lastFrameTime = 0; +const unsigned long ANIMATION_DELAY = 200; + +void setup() { + matrix.begin(); + matrix.setGrayscaleBits(3); // 3-bit grayscale (0-7 brightness levels) + Bridge.begin(); +} + +void loop() { + String gameState; + bool ok = Bridge.call("get_led_state").result(gameState); + + if (ok) { + if (gameState == "running") { + // Animate between four running frames + unsigned long currentTime = millis(); + if (currentTime - lastFrameTime > ANIMATION_DELAY) { + animationFrame = (animationFrame + 1) % 4; + lastFrameTime = currentTime; + } + + switch(animationFrame) { + case 0: matrix.draw(running_frame1); break; + case 1: matrix.draw(running_frame2); break; + case 2: matrix.draw(running_frame3); break; + case 3: matrix.draw(running_frame4); break; + } + } else if (gameState == "jumping") { + matrix.draw(jumping); + animationFrame = 0; + } else if (gameState == "game_over") { + matrix.draw(game_over); + animationFrame = 0; + } else if (gameState == "idle") { + matrix.draw(idle); + animationFrame = 0; + } else { + matrix.draw(idle); + } + } else { + matrix.draw(idle); + } + + delay(50); // Update at ~20 FPS +} +``` + +- **Processing user input through WebSocket events.** + +Input handling uses event-based communication: + +```python +def on_player_action(client_id, data): + global game_started + action = data.get('action') + + if action == 'jump': + game_started = True + if game.jump(): + ui.send_message('jump_confirmed', {'success': True}) + elif action == 'restart': + game.reset() + game_started = True # Game restarts + ui.send_message('game_reset', {'state': game.to_dict()}) + +ui = WebUI() +... +ui.on_message('player_action', on_player_action) +``` + +The backend validates inputs to prevent invalid actions, such as jumping while airborne or during the game-over state. + +- **Running the main game loop with fixed timestep updates.** + +The game loop runs at 60 FPS intervals: + +```python +def game_loop(): + global game_running, game_started + last_update = time.time() + + while game_running: + current_time = time.time() + dt = current_time - last_update + + if not game.game_over: + game.update_physics(dt) + game.update_obstacles(dt) + game.check_collisions() + game.score += int(60 * dt) + game.speed = BASE_SPEED + (game.score / 1500.0) + + ui.send_message('game_update', game.to_dict()) + + last_update = current_time + sleep_time = max(0, (1/FPS) - (time.time() - current_time)) + time.sleep(sleep_time) +``` + +- **Handling obstacle generation and collision detection.** + +The system manages three types of electronic component obstacles: + +```python +OBSTACLE_TYPES = [ + {'name': 'resistor', 'height': 28},    # Small + {'name': 'transistor', 'height': 38},  # Medium + {'name': 'microchip', 'height': 48}    # Large +] + +def spawn_obstacle(self): + obstacle_type = random.choice(OBSTACLE_TYPES) + height = obstacle_type['height'] + + obstacle = { + 'x': GAME_WIDTH + 30, + 'y': GROUND_Y - height, + 'width': OBSTACLE_WIDTH, + 'height': height, + 'type': obstacle_type['name'] + } + self.obstacles.append(obstacle) +``` + +- **Synchronizing game state with frontend rendering.** + +The frontend maintains rendering with PNG images for the LED character: + +```javascript +function loadLEDImages() { +   const imagesToLoad = [ + { key: 'move1', src: 'img/ledcharacter_move1.png' }, + { key: 'move2', src: 'img/ledcharacter_move2.png' }, + { key: 'move3', src: 'img/ledcharacter_move3.png' }, + { key: 'move4', src: 'img/ledcharacter_move4.png' }, + { key: 'jump', src: 'img/ledcharacter_jump.png' }, + { key: 'gameover', src: 'img/ledcharacter_gameover.png' } + ]; + ... +} + +// Cycle through movement patterns on each jump +socket.on('jump_confirmed', (data) => { +   if (data.success) { +       currentMovePattern = (currentMovePattern % 4) + 1; + } +}); + +function drawMascot() { + if (!gameConfig || !gameState || !imagesLoaded) return; + + const x = gameConfig.mascot_x; + const y = Math.round(gameState.mascot_y); + + let imageToUse = null; + + // Select appropriate image based on game state + if (gameState.game_over) { + imageToUse = ledImages.gameover; + } else if (!gameState.on_ground) { + imageToUse = ledImages.jump; + } else { + // Use current movement pattern + switch(currentMovePattern) { + case 1: imageToUse = ledImages.move1; break; + case 2: imageToUse = ledImages.move2; break; + case 3: imageToUse = ledImages.move3; break; + case 4: imageToUse = ledImages.move4; break; + default: imageToUse = ledImages.move1; + } + } + + ... +} +``` + +The high-level data flow looks like this: + +1. **User Input**: Player presses SPACE/UP or clicks to jump +2. **WebSocket**: Input is sent to backend +3. **Backend Processing**: Validates action and updates game state +4. **Game Loop (60 FPS)**: +- Physics update (such as gravity, velocity, and position) +- Collision detection +- State broadcast to clients +5. **Parallel Rendering**: +- Frontend: Canvas draws mascot and obstacles +- LED matrix update: UNO Q displays synchronized LED animations based on game state +6. **Visual Feedback**: Updated display on browser and LED matrix + +## Understanding the Code + +Here is a brief explanation of the App components: + +### 🔧 Backend (`main.py`) + +The Python® component manages all game logic and state. + +- **Game state management**: Tracks the LED character's position, velocity, obstacle locations, score, and game status +- **Physics engine**: Simulates gravity and jump mechanics with frame-independent movement at 60 FPS +- **Obstacle system**: Randomly spawns three types of electronic components (resistors, transistors, microchips) at intervals between 900-1500 ms, moves them across the screen, and removes them when off-screen +- **Collision detection**: Checks if the LED character intersects with any obstacles each frame and triggers game over on collision +- **Bridge communication**: Provides game state to the Arduino LED matrix through the `get_led_state` function +- **Game loop**: Updates physics, obstacles, and score 60 times per second, then broadcasts the game state to the web interface + +### 🔧 Frontend (`app.js` + `index.html`) + +The web interface renders the game using HTML5 Canvas and PNG images. + +- **Canvas rendering**: Displays the LED character using 6 PNG sprites, cycles through 4 running patterns with each jump, and renders electronic component obstacles at 60 FPS +- **Input handling**: Captures keyboard controls (**SPACE/UP** to jump, **R** to restart) and sends actions to the backend via WebSocket +- **Obstacle rendering**: Draws resistors with color bands (red, yellow, green), transistors with *TO-92* package and three pins, and microchips labeled IC555 +- **WebSocket communication**: Connects to the backend on page load, sends player actions, and receives real-time game state updates +- **Score display**: Shows current score and session high score with zero-padded formatting, updating in real-time + +### 🔧 Arduino Component (`sketch.ino` + `game_frames.h`) + +The Arduino sketch displays synchronized LED matrix animations. + +- **Bridge integration**: Retrieves the current game state from the Python® backend via Bridge communication +- **Animation system**: Plays different LED patterns based on game state (running, jumping, game over, or idle) +- **LED patterns**: Each frame is an 8x13 matrix (104 values) stored in `game_frames.h`: + +```cpp +// Example: Running frame 1 +uint8_t running_frame1[104] = { + 0,0,0,0,7,7,0,0,0,0,0,0,0, // Row 0: Head + 0,0,0,7,7,7,7,0,0,0,0,0,0, // Row 1: Body + 0,0,0,7,7,7,7,0,0,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,0, // Row 5: Body/legs + 0,0,7,0,0,0,0,7,0,0,0,0,0, // Row 6: Legs animated + 7,7,7,7,7,7,7,7,7,7,7,7,7 // Row 7: Ground line +}; +``` + +### 👾 Customizing LED Matrix Frames + +The LED matrix frames can be easily customized in `game_frames.h`. Each frame is 8 rows × 13 columns (104 values): + +- **Brightness values**: 0 (off), 1-3 (dim), 4-5 (medium), 6-7 (bright) +- **Row 7**: Always the ground line (all 7s) +- **Animation**: Only row 6 changes between running frames (leg positions) + +To create custom frames: + +1. Design your pattern on an 8×13 grid +2. Use values 0-7 for different brightness levels +3. Replace the array values in `game_frames.h` +4. Upload the sketch to see your custom mascot + +### 🕹️ Game Configuration + +Key constants that define the gameplay, found in `main.py` and can be modified: + +- **Physics**: Gravity (0.65), jump velocity (-12.5), ground position (240px) +- **Canvas**: 800x300px with LED character size of 44x48px +- **Obstacles**: Resistor (28px), Transistor (38px), Microchip (48px), width (18px) +- **Timing**: Base speed (6.0), spawn intervals (900-1500 ms), target 60 FPS +- **Difficulty**: Speed increases with score (score/1500 rate) + +You can adjust these values at the top of `main.py` to customize gameplay difficulty, physics, and visual layout. LED matrix frames can be customized in `game_frames.h` by modifying the 8x13 arrays. \ No newline at end of file diff --git a/examples/mascot-jump-game/app.yaml b/examples/mascot-jump-game/app.yaml new file mode 100644 index 0000000..da022dd --- /dev/null +++ b/examples/mascot-jump-game/app.yaml @@ -0,0 +1,6 @@ +name: Mascot Jump Game +icon: 🏃 +description: An endless runner game where you jump over electronic components with the LED character + +bricks: + - arduino:web_ui diff --git a/examples/mascot-jump-game/assets/app.js b/examples/mascot-jump-game/assets/app.js new file mode 100644 index 0000000..d55668a --- /dev/null +++ b/examples/mascot-jump-game/assets/app.js @@ -0,0 +1,507 @@ +// SPDX-FileCopyrightText: Copyright (C) 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Game configuration received from backend +let gameConfig = null; +let gameState = null; +let socket = null; + +// Canvas setup +let canvas = null; +let ctx = null; + +// Animation state +let currentMovePattern = 1; // Track movement pattern (1-4) +let blinkState = true; +let lastBlinkTime = Date.now(); +let animationId = null; + +// LED Character Images +let ledImages = { + move1: null, + move2: null, + move3: null, + move4: null, + jump: null, + gameover: null +}; + +// Track which images are loaded +let imagesLoaded = false; + +// Colors +const BG_COLOR = '#f5f5f5'; +const FG_COLOR = '#282828'; +const ACCENT_COLOR = '#3c3c3c'; + +document.addEventListener('DOMContentLoaded', () => { + loadLEDImages(); + initCanvas(); + initSocketIO(); + initInputHandlers(); + startGameLoop(); +}); + +function loadLEDImages() { + const imagesToLoad = [ + { key: 'move1', src: 'img/ledcharacter_move1.png' }, + { key: 'move2', src: 'img/ledcharacter_move2.png' }, + { key: 'move3', src: 'img/ledcharacter_move3.png' }, + { key: 'move4', src: 'img/ledcharacter_move4.png' }, + { key: 'jump', src: 'img/ledcharacter_jump.png' }, + { key: 'gameover', src: 'img/ledcharacter_gameover.png' } + ]; + + let loadedCount = 0; + + imagesToLoad.forEach(({ key, src }) => { + const img = new Image(); + img.onload = () => { + ledImages[key] = img; + loadedCount++; + if (loadedCount === imagesToLoad.length) { + imagesLoaded = true; + console.log('All LED character images loaded from img/ folder'); + } + }; + img.onerror = () => { + console.error(`Failed to load image: ${src}`); + // Try loading from root directory as fallback + const filename = src.split('/').pop(); + console.log(`Trying fallback path: ${filename}`); + img.src = filename; + }; + img.src = src; + }); +} + +function initCanvas() { + canvas = document.getElementById('gameCanvas'); + ctx = canvas.getContext('2d'); + + // Set canvas properties for pixels + ctx.imageSmoothingEnabled = false; + + // Handle window resize + window.addEventListener('resize', handleResize); + handleResize(); +} + +function handleResize() { + // Scale canvas to fit window while maintaining aspect ratio + const maxWidth = window.innerWidth - 40; + const maxHeight = window.innerHeight - 150; + const scale = Math.min(maxWidth / 800, maxHeight / 300, 1); + + if (scale < 1) { + canvas.style.width = `${800 * scale}px`; + canvas.style.height = `${300 * scale}px`; + } +} + +function initSocketIO() { + socket = io(`http://${window.location.host}`); + + socket.on('connect', () => { + console.log('Connected to game server'); + updateConnectionStatus(true); + socket.emit('client_connected', {}); + }); + + socket.on('disconnect', () => { + console.log('Disconnected from game server'); + updateConnectionStatus(false); + }); + + socket.on('game_init', (data) => { + console.log('Received game initialization:', data); + gameConfig = data.config; + gameState = data.state; + updateScoreDisplay(); + }); + + socket.on('game_update', (data) => { + gameState = data; + updateScoreDisplay(); + }); + + socket.on('game_reset', (data) => { + console.log('Game reset'); + gameState = data.state; + updateScoreDisplay(); + // Reset animation states + currentMovePattern = 1; + blinkState = true; + }); + + socket.on('jump_confirmed', (data) => { + if (data.success) { + console.log('⬆Jump confirmed'); + // Cycle to next movement pattern (1->2->3->4->1) + currentMovePattern = (currentMovePattern % 4) + 1; + } + }); + + socket.on('error', (error) => { + console.error('Socket error:', error); + showError('Connection error: ' + error); + }); +} + +function initInputHandlers() { + // Keyboard controls + document.addEventListener('keydown', handleKeyPress); + + // Touch/click controls for mobile + canvas.addEventListener('click', handleCanvasClick); + canvas.addEventListener('touchstart', handleCanvasTouch); + + // Prevent default touch behaviors + canvas.addEventListener('touchmove', (e) => e.preventDefault()); + canvas.addEventListener('touchend', (e) => e.preventDefault()); +} + +function handleKeyPress(e) { + switch(e.code) { + case 'Space': + case 'ArrowUp': + e.preventDefault(); + performAction(); + break; + case 'KeyR': + e.preventDefault(); + restartGame(); + break; + } +} + +function handleCanvasClick(e) { + e.preventDefault(); + performAction(); +} + +function handleCanvasTouch(e) { + e.preventDefault(); + performAction(); +} + +function performAction() { + if (!gameState) return; + + if (gameState.game_over) { + restartGame(); + } else { + jump(); + } +} + +function jump() { + if (socket && socket.connected) { + socket.emit('player_action', { action: 'jump' }); + } +} + +function restartGame() { + if (socket && socket.connected) { + socket.emit('player_action', { action: 'restart' }); + } +} + +function updateConnectionStatus(connected) { + const statusElement = document.getElementById('connectionStatus'); + if (statusElement) { + statusElement.className = `connection-status ${connected ? 'connected' : 'disconnected'}`; + statusElement.textContent = connected ? 'Connected' : 'Disconnected'; + } +} + +function updateScoreDisplay() { + if (!gameState) return; + + const scoreElement = document.getElementById('score'); + const highScoreElement = document.getElementById('highScore'); + + if (scoreElement) { + scoreElement.textContent = String(Math.floor(gameState.score)).padStart(5, '0'); + } + + if (highScoreElement) { + highScoreElement.textContent = String(Math.floor(gameState.high_score)).padStart(5, '0'); + } +} + +function showError(message) { + console.error(message); + + const errorContainer = document.getElementById('errorContainer'); + if (errorContainer) { + errorContainer.textContent = message; + errorContainer.style.display = 'block'; + setTimeout(() => { + errorContainer.style.display = 'none'; + }, 5000); + } +} + +// Drawing functions +function clearCanvas() { + ctx.fillStyle = BG_COLOR; + ctx.fillRect(0, 0, canvas.width, canvas.height); +} + +function drawGround() { + if (!gameConfig) return; + + // Ground line + ctx.strokeStyle = ACCENT_COLOR; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, gameConfig.ground_y + 1); + ctx.lineTo(canvas.width, gameConfig.ground_y + 1); + ctx.stroke(); + + // Ground texture dots + ctx.fillStyle = ACCENT_COLOR; + for (let x = 0; x < canvas.width; x += 14) { + ctx.fillRect(x, gameConfig.ground_y + 3, 1, 1); + } +} + +function drawMascot() { + if (!gameConfig || !gameState || !imagesLoaded) return; + + const x = gameConfig.mascot_x; + const y = Math.round(gameState.mascot_y); + + let imageToUse = null; + + // Select appropriate image based on game state + if (gameState.game_over) { + imageToUse = ledImages.gameover; + } else if (!gameState.on_ground) { + imageToUse = ledImages.jump; + } else { + // Use current movement pattern + switch(currentMovePattern) { + case 1: + imageToUse = ledImages.move1; + break; + case 2: + imageToUse = ledImages.move2; + break; + case 3: + imageToUse = ledImages.move3; + break; + case 4: + imageToUse = ledImages.move4; + break; + default: + imageToUse = ledImages.move1; + } + } + + // Draw the LED character image if available + if (imageToUse) { + // Draw image at original size or scale if needed + // Assuming the PNGs are sized appropriately for the mascot + ctx.drawImage(imageToUse, x, y, gameConfig.mascot_width, gameConfig.mascot_height); + } else { + // Fallback: draw a simple rectangle if image not loaded + ctx.fillStyle = FG_COLOR; + ctx.fillRect(x, y, gameConfig.mascot_width, gameConfig.mascot_height); + + // Simple face + ctx.fillStyle = BG_COLOR; + ctx.fillRect(x + 10, y + 10, 5, 5); + ctx.fillRect(x + 29, y + 10, 5, 5); + } +} + +function drawObstacles() { + if (!gameState || !gameState.obstacles) return; + + for (const obstacle of gameState.obstacles) { + const x = Math.round(obstacle.x); + const y = Math.round(obstacle.y); + const h = obstacle.height; + + // Determine obstacle type based on height + if (h <= 32) { + // Small: Resistor + drawResistor(x, y - 10); + } else if (h <= 42) { + // Medium: Transistor + drawTransistor(x, y - 8); + } else { + // Large: Microchip + drawMicrochip(x, y); + } + } +} + +function drawResistor(x, y) { + ctx.fillStyle = '#8B4513'; // Brown color for resistor body + ctx.fillRect(x, y + 8, 20, 14); + + // Resistor bands + ctx.fillStyle = '#FF0000'; // Red band + ctx.fillRect(x + 3, y + 8, 3, 14); + ctx.fillStyle = '#FFFF00'; // Yellow band + ctx.fillRect(x + 9, y + 8, 3, 14); + ctx.fillStyle = '#00FF00'; // Green band + ctx.fillRect(x + 15, y + 8, 3, 14); + + // Wires + ctx.fillStyle = '#606060'; + ctx.fillRect(x - 3, y + 13, 5, 3); + ctx.fillRect(x + 18, y + 13, 5, 3); + + // Add vertical wires + ctx.fillRect(x - 1, y + 3, 2, 10); + ctx.fillRect(x + 19, y + 3, 2, 10); +} + +function drawTransistor(x, y) { + // Pixel art transistor (medium obstacle) + ctx.fillStyle = FG_COLOR; + + // Main body (TO-92 package style) + ctx.fillRect(x + 2, y + 2, 16, 24); + + // Rounded top + ctx.fillRect(x + 4, y, 12, 3); + ctx.fillRect(x + 6, y - 1, 8, 1); + + // Three legs + ctx.fillStyle = '#606060'; + ctx.fillRect(x + 4, y + 26, 3, 12); + ctx.fillRect(x + 9, y + 26, 3, 12); + ctx.fillRect(x + 14, y + 26, 3, 12); + + // Label + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(x + 5, y + 8, 10, 10); + ctx.fillStyle = FG_COLOR; + ctx.font = '12px monospace'; + ctx.fillText('T', x + 8, y + 16); +} + +function drawMicrochip(x, y) { + // Pixel art microchip/IC (large obstacle) + ctx.fillStyle = FG_COLOR; + + // Main IC body + ctx.fillRect(x + 2, y + 10, 14, 20); + + // Notch at top + ctx.fillStyle = BG_COLOR; + ctx.fillRect(x + 7, y + 10, 4, 3); + + // IC pins + ctx.fillStyle = '#606060'; + for (let i = 0; i < 4; i++) { + // Left side pins + ctx.fillRect(x - 2, y + 14 + i*4, 4, 2); + // Right side pins + ctx.fillRect(x + 14, y + 14 + i*4, 4, 2); + } + + // Label on IC + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(x + 4, y + 16, 10, 8); + ctx.fillStyle = FG_COLOR; + ctx.font = '6px monospace'; + ctx.fillText('IC', x + 6, y + 21); + ctx.fillText('555', x + 5, y + 23); +} + +function drawGameOver() { + if (!gameState || !gameState.game_over) return; + + // Semi-transparent overlay + ctx.fillStyle = 'rgba(245, 245, 245, 0.8)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Game Over text + ctx.fillStyle = FG_COLOR; + ctx.font = 'bold 32px Consolas, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('GAME OVER', canvas.width/2, canvas.height/2 - 30); + + // Score display + ctx.font = '20px Consolas, monospace'; + ctx.fillText(`Score: ${Math.floor(gameState.score)}`, canvas.width/2, canvas.height/2); + + // Blinking restart prompt + const currentTime = Date.now(); + if (currentTime - lastBlinkTime > 500) { + blinkState = !blinkState; + lastBlinkTime = currentTime; + } + + if (blinkState) { + ctx.font = '16px Consolas, monospace'; + ctx.fillStyle = ACCENT_COLOR; + ctx.fillText('Press SPACE to restart', canvas.width/2, canvas.height/2 + 35); + } +} + +function drawDebugInfo() { + // Optional: Display debug information + if (!gameState || !gameConfig) return; + + ctx.fillStyle = ACCENT_COLOR; + ctx.font = '10px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + const debugInfo = [ + `FPS: ${(1000 / 16).toFixed(0)}`, + `Speed: ${gameState.speed.toFixed(1)}`, + `Obstacles: ${gameState.obstacles.length}`, + `Y: ${gameState.mascot_y.toFixed(0)}`, + `Vel: ${gameState.velocity_y.toFixed(1)}`, + `Pattern: ${currentMovePattern}`, + `Images: ${imagesLoaded ? 'Loaded' : 'Loading...'}` + ]; + + debugInfo.forEach((info, i) => { + ctx.fillText(info, 10, 10 + i * 12); + }); +} + +// Main game rendering loop +function render() { + clearCanvas(); + drawGround(); + drawObstacles(); + drawMascot(); + drawGameOver(); + + // Uncomment for debug info + //drawDebugInfo(); +} + +function startGameLoop() { + function loop() { + render(); + animationId = requestAnimationFrame(loop); + } + loop(); +} + +function stopGameLoop() { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } +} + +// Clean up on page unload +window.addEventListener('beforeunload', () => { + stopGameLoop(); + if (socket) { + socket.disconnect(); + } +}); \ No newline at end of file diff --git a/examples/mascot-jump-game/assets/docs_assets/game_over_state.gif b/examples/mascot-jump-game/assets/docs_assets/game_over_state.gif new file mode 100644 index 0000000..c36b1d5 Binary files /dev/null and b/examples/mascot-jump-game/assets/docs_assets/game_over_state.gif differ diff --git a/examples/mascot-jump-game/assets/docs_assets/game_play_state.gif b/examples/mascot-jump-game/assets/docs_assets/game_play_state.gif new file mode 100644 index 0000000..e1e8ac9 Binary files /dev/null and b/examples/mascot-jump-game/assets/docs_assets/game_play_state.gif differ diff --git a/examples/mascot-jump-game/assets/docs_assets/launch-app.png b/examples/mascot-jump-game/assets/docs_assets/launch-app.png new file mode 100644 index 0000000..5e18786 Binary files /dev/null and b/examples/mascot-jump-game/assets/docs_assets/launch-app.png differ diff --git a/examples/mascot-jump-game/assets/docs_assets/led_character_animation.png b/examples/mascot-jump-game/assets/docs_assets/led_character_animation.png new file mode 100644 index 0000000..5e48f19 Binary files /dev/null and b/examples/mascot-jump-game/assets/docs_assets/led_character_animation.png differ diff --git a/examples/mascot-jump-game/assets/docs_assets/led_matrix_frames.png b/examples/mascot-jump-game/assets/docs_assets/led_matrix_frames.png new file mode 100644 index 0000000..5470ca2 Binary files /dev/null and b/examples/mascot-jump-game/assets/docs_assets/led_matrix_frames.png differ diff --git a/examples/mascot-jump-game/assets/docs_assets/thumbnail.png b/examples/mascot-jump-game/assets/docs_assets/thumbnail.png new file mode 100644 index 0000000..285edbf Binary files /dev/null and b/examples/mascot-jump-game/assets/docs_assets/thumbnail.png differ diff --git a/examples/mascot-jump-game/assets/fonts/Open Sans/OFL.txt b/examples/mascot-jump-game/assets/fonts/Open Sans/OFL.txt new file mode 100644 index 0000000..a5ec031 --- /dev/null +++ b/examples/mascot-jump-game/assets/fonts/Open Sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/examples/mascot-jump-game/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf b/examples/mascot-jump-game/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..548c15f Binary files /dev/null and b/examples/mascot-jump-game/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/examples/mascot-jump-game/assets/fonts/Roboto/OFL.txt b/examples/mascot-jump-game/assets/fonts/Roboto/OFL.txt new file mode 100644 index 0000000..38d9750 --- /dev/null +++ b/examples/mascot-jump-game/assets/fonts/Roboto/OFL.txt @@ -0,0 +1,91 @@ +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/mascot-jump-game/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf b/examples/mascot-jump-game/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 0000000..3a2d704 Binary files /dev/null and b/examples/mascot-jump-game/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf differ diff --git a/examples/mascot-jump-game/assets/fonts/fonts.css b/examples/mascot-jump-game/assets/fonts/fonts.css new file mode 100644 index 0000000..86cf716 --- /dev/null +++ b/examples/mascot-jump-game/assets/fonts/fonts.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-display: swap; + src: url('Roboto/RobotoMono-VariableFont_wght.ttf') format('truetype'); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-display: swap; + src: url('Open Sans/OpenSans-VariableFont_wdth,wght.ttf') format('truetype'); +} \ No newline at end of file diff --git a/examples/mascot-jump-game/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg b/examples/mascot-jump-game/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg new file mode 100644 index 0000000..c942003 --- /dev/null +++ b/examples/mascot-jump-game/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/examples/mascot-jump-game/assets/img/favicon.png b/examples/mascot-jump-game/assets/img/favicon.png new file mode 100644 index 0000000..019a8cf Binary files /dev/null and b/examples/mascot-jump-game/assets/img/favicon.png differ diff --git a/examples/mascot-jump-game/assets/img/ledcharacter_gameover.png b/examples/mascot-jump-game/assets/img/ledcharacter_gameover.png new file mode 100644 index 0000000..1ee456f Binary files /dev/null and b/examples/mascot-jump-game/assets/img/ledcharacter_gameover.png differ diff --git a/examples/mascot-jump-game/assets/img/ledcharacter_jump.png b/examples/mascot-jump-game/assets/img/ledcharacter_jump.png new file mode 100644 index 0000000..499df74 Binary files /dev/null and b/examples/mascot-jump-game/assets/img/ledcharacter_jump.png differ diff --git a/examples/mascot-jump-game/assets/img/ledcharacter_move1.png b/examples/mascot-jump-game/assets/img/ledcharacter_move1.png new file mode 100644 index 0000000..c140792 Binary files /dev/null and b/examples/mascot-jump-game/assets/img/ledcharacter_move1.png differ diff --git a/examples/mascot-jump-game/assets/img/ledcharacter_move2.png b/examples/mascot-jump-game/assets/img/ledcharacter_move2.png new file mode 100644 index 0000000..ca4ce96 Binary files /dev/null and b/examples/mascot-jump-game/assets/img/ledcharacter_move2.png differ diff --git a/examples/mascot-jump-game/assets/img/ledcharacter_move3.png b/examples/mascot-jump-game/assets/img/ledcharacter_move3.png new file mode 100644 index 0000000..1187ff4 Binary files /dev/null and b/examples/mascot-jump-game/assets/img/ledcharacter_move3.png differ diff --git a/examples/mascot-jump-game/assets/img/ledcharacter_move4.png b/examples/mascot-jump-game/assets/img/ledcharacter_move4.png new file mode 100644 index 0000000..c3dde6e Binary files /dev/null and b/examples/mascot-jump-game/assets/img/ledcharacter_move4.png differ diff --git a/examples/mascot-jump-game/assets/img/logo.svg b/examples/mascot-jump-game/assets/img/logo.svg new file mode 100644 index 0000000..d23ae68 --- /dev/null +++ b/examples/mascot-jump-game/assets/img/logo.svg @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/examples/mascot-jump-game/assets/img/logo.svg.license b/examples/mascot-jump-game/assets/img/logo.svg.license new file mode 100644 index 0000000..c274485 --- /dev/null +++ b/examples/mascot-jump-game/assets/img/logo.svg.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + +SPDX-License-Identifier: MPL-2.0 diff --git a/examples/mascot-jump-game/assets/index.html b/examples/mascot-jump-game/assets/index.html new file mode 100644 index 0000000..8e0220e --- /dev/null +++ b/examples/mascot-jump-game/assets/index.html @@ -0,0 +1,54 @@ + + + + + + + + Mascot Jumpe Game + + + + +
Connecting...
+ +
+
+

Mascot Jump Game

+ +
+ +
+ + +
+
+ Score: + 00000 + HI: + 00000 +
+ +
+ + SPACE or Jump + + + + R Restart + +
+
+ + +
+
+ + + + + diff --git a/examples/mascot-jump-game/assets/libs/socket.io.min.js b/examples/mascot-jump-game/assets/libs/socket.io.min.js new file mode 100644 index 0000000..530b185 --- /dev/null +++ b/examples/mascot-jump-game/assets/libs/socket.io.min.js @@ -0,0 +1,6 @@ +/*! + * Socket.IO v4.8.1 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); \ No newline at end of file diff --git a/examples/mascot-jump-game/assets/style.css b/examples/mascot-jump-game/assets/style.css new file mode 100644 index 0000000..81a129a --- /dev/null +++ b/examples/mascot-jump-game/assets/style.css @@ -0,0 +1,354 @@ +/* +SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + +SPDX-License-Identifier: MPL-2.0 +*/ + +@import url('fonts/fonts.css'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; margin: 0; + background: #DAE3E3; + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + user-select: none; + -webkit-user-select: none; +} + +.container { + width: 100%; + max-width: 900px; + padding: 20px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; + padding: 0 10px; +} + +.arduino-text { + color: #008184; + font-family: "Roboto Mono", monospace; + font-size: 20px; + font-weight: 700; + margin: 0; + font-style: normal; + line-height: 170%; + letter-spacing: 2.4px; +} + +.arduino-logo { + height: 24px; + width: auto; +} + +/* Game container */ +.game-container { + background: white; + border-radius: 12px; + padding: 20px; + position: relative; +} + +/* Canvas */ +#gameCanvas { + display: block; + margin: 0 auto; + border: 2px solid #282828; + background: #f5f5f5; + border-radius: 8px; + cursor: pointer; + image-rendering: pixelated; + image-rendering: crisp-edges; + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-crisp-edges; + transition: transform 0.2s ease; +} + +#gameCanvas:hover { + transform: scale(1.005); +} + +#gameCanvas:active { + transform: scale(0.995); +} + +/* Game info section */ +.game-info { + margin-top: 20px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 10px; +} + +/* Score display */ +.score-display { + display: flex; + align-items: baseline; + gap: 15px; + font-size: 24px; + color: #282828; +} + +.score-label, +.high-score-label { + font-weight: 500; + opacity: 0.7; + font-size: 20px; + font-family: "Open Sans", monospace; +} + +.score-value, +.high-score-value { + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: 2px; + font-family: "Open Sans", monospace; +} + +.high-score-label { + margin-left: 10px; +} + +.high-score-value { + color: #008184; +} + +/* Controls info */ +.controls-info { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: #666; +} + +.control-item { + display: flex; + align-items: center; + gap: 5px; +} + +.key-icon { + display: inline-block; + padding: 3px 8px; + background: #f0f0f0; + border: 1px solid #d0d0d0; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + color: #282828; + box-shadow: 0 2px 0 #d0d0d0; + position: relative; + top: -1px; +} + +.control-divider { + color: #ccc; + font-size: 16px; +} + +/* Connection status */ +.connection-status { + position: fixed; + top: 20px; + right: 20px; + padding: 8px 16px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + transition: all 0.3s ease; + z-index: 1000; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.connection-status.connected { + background: #4caf50; + color: white; + animation: pulse 2s ease-out; + display: none; +} + +.connection-status.disconnected { + background: #f44336; + color: white; + animation: blink 1s infinite; +} + +/* Error message */ +.error-message { + margin-top: 15px; + padding: 10px 15px; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 6px; + color: #856404; + font-size: 14px; + text-align: center; + animation: slideIn 0.3s ease; +} + +/* Animations */ +@keyframes pulse { + 0% { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + 50% { + box-shadow: 0 2px 20px rgba(76, 175, 80, 0.4); + } + 100% { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } +} + +@keyframes blink { + 0%, 50%, 100% { + opacity: 1; + } + 25%, 75% { + opacity: 0.5; + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .header { + margin-bottom: 15px; + } + + .arduino-text { + font-size: 24px; + } + + .game-container { + padding: 15px; + } + + #gameCanvas { + max-width: 100%; + height: auto; + } + + .game-info { + flex-direction: column; + gap: 15px; + align-items: center; + text-align: center; + } + + .score-display { + font-size: 20px; + } + + .controls-info { + flex-wrap: wrap; + justify-content: center; + } + + .connection-status { + top: 10px; + right: 10px; + padding: 6px 12px; + font-size: 10px; + } +} + +@media (max-width: 480px) { + .arduino-text { + font-size: 20px; + } + + .arduino-logo { + height: 24px; + } + + .score-display { + font-size: 18px; + gap: 10px; + } + + .score-label, + .high-score-label { + font-size: 16px; + } + + .controls-info { + font-size: 12px; + } + + .key-icon { + padding: 2px 6px; + font-size: 10px; + } +} + +@media (hover: none) and (pointer: coarse) { + #gameCanvas { + cursor: default; + } + + #gameCanvas:hover { + transform: none; + } + + #gameCanvas:active { + transform: scale(0.98); + } + + .key-icon { + display: none; + } + + .control-item:first-child::before { + content: "Tap to "; + } + + .control-divider, + .control-item:last-child { + display: none; + } +} + +@media print { + body { + background: white; + } + + .connection-status { + display: none; + } + + .game-container { + box-shadow: none; + border: 1px solid #ddd; + } +} diff --git a/examples/mascot-jump-game/python/main.py b/examples/mascot-jump-game/python/main.py new file mode 100644 index 0000000..f591d47 --- /dev/null +++ b/examples/mascot-jump-game/python/main.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_utils import * +from arduino.app_bricks.web_ui import WebUI +import time +import random +import threading +import json + +# Game Constants +GAME_WIDTH = 800 +GAME_HEIGHT = 300 +GROUND_Y = 240 +FPS = 60 + +MASCOT_WIDTH = 44 +MASCOT_HEIGHT = 48 +MASCOT_X = 80 + +OBSTACLE_WIDTH = 18 +MIN_OBSTACLE_HEIGHT = 28 # Resistor height +MID_OBSTACLE_HEIGHT = 38 # Transistor height +MAX_OBSTACLE_HEIGHT = 48 # Microchip height + +# Obstacle types with their specific heights +OBSTACLE_TYPES = [ + {'name': 'resistor', 'height': 28}, # Small + {'name': 'transistor', 'height': 38}, # Medium + {'name': 'microchip', 'height': 48} # Large +] + +JUMP_VELOCITY = -12.5 +GRAVITY = 0.65 +BASE_SPEED = 6.0 + +SPAWN_MIN_MS = 900 +SPAWN_MAX_MS = 1500 + +class GameState: + """Manages the complete game state""" + def __init__(self): + self.reset() + self.high_score = 0 + + def reset(self): + """Reset game to initial state""" + self.mascot_y = GROUND_Y - MASCOT_HEIGHT + self.velocity_y = 0.0 + self.on_ground = True + self.obstacles = [] + self.score = 0 + self.game_over = False + self.speed = BASE_SPEED + self.last_spawn_time = time.time() + self.next_spawn_delay = random.uniform(SPAWN_MIN_MS/1000, SPAWN_MAX_MS/1000) + + def update_physics(self, dt): + """Update mascot physics""" + if not self.on_ground: + self.velocity_y += GRAVITY * dt * 60 # Scale for 60 FPS base + self.mascot_y += self.velocity_y * dt * 60 + + # Ground collision + if self.mascot_y >= GROUND_Y - MASCOT_HEIGHT: + self.mascot_y = GROUND_Y - MASCOT_HEIGHT + self.velocity_y = 0.0 + self.on_ground = True + + def update_obstacles(self, dt): + """Update obstacle positions and spawn new ones""" + current_time = time.time() + + # Move existing obstacles + for obstacle in self.obstacles: + obstacle['x'] -= self.speed * dt * 60 + + # Remove offscreen obstacles + self.obstacles = [obs for obs in self.obstacles if obs['x'] > -OBSTACLE_WIDTH - 10] + + # Spawn new obstacles + if current_time - self.last_spawn_time >= self.next_spawn_delay: + self.spawn_obstacle() + self.last_spawn_time = current_time + self.next_spawn_delay = random.uniform(SPAWN_MIN_MS/1000, SPAWN_MAX_MS/1000) + + def spawn_obstacle(self): + """Create a new obstacle""" + # Randomly select an obstacle type + obstacle_type = random.choice(OBSTACLE_TYPES) + height = obstacle_type['height'] + + obstacle = { + 'x': GAME_WIDTH + 30, + 'y': GROUND_Y - height, + 'width': OBSTACLE_WIDTH, + 'height': height, + 'type': obstacle_type['name'] + } + self.obstacles.append(obstacle) + + def check_collisions(self): + """Check for mascot-obstacle collisions""" + mascot_rect = { + 'x': MASCOT_X, + 'y': self.mascot_y, + 'width': MASCOT_WIDTH, + 'height': MASCOT_HEIGHT + } + + for obstacle in self.obstacles: + if self.rectangles_intersect(mascot_rect, obstacle): + self.game_over = True + self.high_score = max(self.high_score, self.score) + return True + return False + + def rectangles_intersect(self, rect1, rect2): + """Check if two rectangles intersect""" + return not (rect1['x'] + rect1['width'] < rect2['x'] or + rect2['x'] + rect2['width'] < rect1['x'] or + rect1['y'] + rect1['height'] < rect2['y'] or + rect2['y'] + rect2['height'] < rect1['y']) + + def jump(self): + """Make the mascot jump if on ground""" + if self.on_ground and not self.game_over: + self.velocity_y = JUMP_VELOCITY + self.on_ground = False + return True + return False + + def to_dict(self): + """Serialize game state for transmission""" + return { + 'mascot_y': self.mascot_y, + 'velocity_y': self.velocity_y, + 'on_ground': self.on_ground, + 'obstacles': self.obstacles, + 'score': self.score, + 'high_score': self.high_score, + 'game_over': self.game_over, + 'speed': self.speed + } + +# Initialize game and UI +game = GameState() +ui = WebUI() + +# Game loop control +game_running = True +game_thread = None +game_started = False # Track if game has started + +def get_led_state(): + """Return current LED state for the LED matrix display""" + global game_started + + if game.game_over: + return "game_over" + elif not game_started and game.score == 0: + return "idle" + elif not game.on_ground: + return "jumping" + else: + return "running" + +def game_loop(): + """Main game loop running at ~60 FPS""" + global game_running, game_started + last_update = time.time() + + while game_running: + current_time = time.time() + dt = current_time - last_update + + if not game.game_over: + # Update game logic + game.update_physics(dt) + game.update_obstacles(dt) + game.check_collisions() + + # Update score (approximately 1 point per frame at 60 FPS) + game.score += int(60 * dt) + + # Increase difficulty + game.speed = BASE_SPEED + (game.score / 1500.0) + + # Send game state to all connected clients + ui.send_message('game_update', game.to_dict()) + + last_update = current_time + + # Target 60 FPS + sleep_time = max(0, (1/FPS) - (time.time() - current_time)) + time.sleep(sleep_time) + +def on_player_action(client_id, data): + """Handle player input actions""" + global game_started + action = data.get('action') + + if action == 'jump': + game_started = True # Game starts on first jump + if game.jump(): + ui.send_message('jump_confirmed', {'success': True}) + elif action == 'restart': + game.reset() + game_started = True # Game restarts + ui.send_message('game_reset', {'state': game.to_dict()}) + +def on_client_connected(client_id, data): + """Send initial game state when client connects""" + ui.send_message('game_init', { + 'state': game.to_dict(), + 'config': { + 'width': GAME_WIDTH, + 'height': GAME_HEIGHT, + 'ground_y': GROUND_Y, + 'mascot_x': MASCOT_X, + 'mascot_width': MASCOT_WIDTH, + 'mascot_height': MASCOT_HEIGHT + } + }) + +# Register WebSocket event handlers +ui.on_message('player_action', on_player_action) +ui.on_message('client_connected', on_client_connected) + +# Provide the LED state function to the Arduino sketch +Bridge.provide("get_led_state", get_led_state) + +# Run the app +App.run(user_loop=game_loop) \ No newline at end of file diff --git a/examples/mascot-jump-game/python/requirements.txt b/examples/mascot-jump-game/python/requirements.txt new file mode 100644 index 0000000..5873083 --- /dev/null +++ b/examples/mascot-jump-game/python/requirements.txt @@ -0,0 +1 @@ +pygame==2.6.1 diff --git a/examples/mascot-jump-game/sketch/game_frames.h b/examples/mascot-jump-game/sketch/game_frames.h new file mode 100644 index 0000000..e5f0027 --- /dev/null +++ b/examples/mascot-jump-game/sketch/game_frames.h @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +// LED Matrix frames for Mascot Jump Game +// 8x13 LED matrix patterns (104 values) +// Simplified mascot design with animated legs +// Values: 0 (off), 5 (medium), 7 (brightest) for 3-bit grayscale + +// Running animation frame 1 +uint8_t running_frame1[104] = { + 0,0,0,0,7,7,0,0,0,0,0,0,0, + 0,0,0,7,7,7,7,0,0,0,0,0,0, + 0,0,0,7,3,3,7,0,0,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,0, + 0,0,7,0,0,0,0,7,0,0,0,0,0, + 7,7,7,7,7,7,7,7,7,7,7,7,7 // Ground line +}; + +// Running animation frame 2 +uint8_t running_frame2[104] = { + 0,0,0,0,7,7,0,0,0,0,0,0,0, + 0,0,0,7,7,7,7,0,0,0,0,0,0, + 0,0,0,7,3,3,7,0,0,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,0, + 7,7,7,7,7,7,7,7,7,7,7,7,7 +}; + +// Running animation frame 3 +uint8_t running_frame3[104] = { + 0,0,0,0,7,7,0,0,0,0,0,0,0, + 0,0,0,7,7,7,7,0,0,0,0,0,0, + 0,0,0,7,3,3,7,0,0,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,0, + 0,0,0,7,0,0,0,7,0,0,0,0,0, + 7,7,7,7,7,7,7,7,7,7,7,7,7 +}; + +// Running animation frame 4 +uint8_t running_frame4[104] = { + 0,0,0,0,7,7,0,0,0,0,0,0,0, + 0,0,0,7,7,7,7,0,0,0,0,0,0, + 0,0,0,7,3,3,7,0,0,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,0, + 0,0,0,0,7,7,0,0,0,0,0,0,0, + 7,7,7,7,7,7,7,7,7,7,7,7,7 +}; + +// Jumping frame - mascot in air with arms spread +uint8_t jumping[104] = { + 0,0,0,0,7,7,0,0,0,0,0,0,5, + 0,0,0,7,3,3,7,0,5,0,0,0,5, + 0,0,5,7,7,7,7,5,0,0,0,0,5, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,5, + 0,5,7,0,0,5,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0, + 7,7,7,7,7,7,7,7,7,7,7,7,7 +}; + +// Game over frame with X pattern +uint8_t game_over[104] = { + 0,0,7,7,7,0,0,0,0,0,4,0,4, + 0,7,7,3,7,7,0,7,0,0,0,4,0, + 0,7,3,7,7,7,7,0,0,0,4,0,4, + 0,7,7,7,7,7,7,0,0,0,0,0,0, + 0,0,7,7,7,7,7,7,7,0,0,0,0, + 0,7,0,0,7,0,0,0,0,0,0,0,0, + 0,0,0,0,7,0,0,0,0,0,0,0,0, + 7,7,7,7,7,7,7,7,7,7,7,7,7 +}; + +// Idle/waiting frame same as running frame with neutral legs +uint8_t idle[104] = { + 0,0,0,0,7,7,0,0,0,0,4,0,0, + 0,0,0,7,7,7,7,0,0,0,4,4,4, + 0,0,0,7,3,3,7,0,0,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,5,7,7,7,7,7,7,5,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,0, + 0,0,0,7,0,0,7,0,0,0,0,0,0, + 7,7,7,7,7,7,7,7,7,7,7,7,7 +}; \ No newline at end of file diff --git a/examples/mascot-jump-game/sketch/sketch.ino b/examples/mascot-jump-game/sketch/sketch.ino new file mode 100644 index 0000000..7f42f8e --- /dev/null +++ b/examples/mascot-jump-game/sketch/sketch.ino @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +// +// SPDX-License-Identifier: MPL-2.0 + +#include +#include +#include "game_frames.h" + +Arduino_LED_Matrix matrix; + +// Animation state tracking +int animationFrame = 0; +unsigned long lastFrameTime = 0; +const unsigned long ANIMATION_DELAY = 200; // milliseconds between frames + +void setup() { + matrix.begin(); + matrix.setGrayscaleBits(3); // Use 3-bit grayscale (0-7 levels) + Bridge.begin(); +} + +void loop() { + String gameState; + bool ok = Bridge.call("get_led_state").result(gameState); + + if (ok) { + if (gameState == "running") { + // Animate between four running frames for leg movement + unsigned long currentTime = millis(); + if (currentTime - lastFrameTime > ANIMATION_DELAY) { + animationFrame = (animationFrame + 1) % 4; + lastFrameTime = currentTime; + } + + switch(animationFrame) { + case 0: + matrix.draw(running_frame1); + break; + case 1: + matrix.draw(running_frame2); + break; + case 2: + matrix.draw(running_frame3); + break; + case 3: + matrix.draw(running_frame4); + break; + } + + } else if (gameState == "jumping") { + // Show jumping frame when mascot is in the air + matrix.draw(jumping); + animationFrame = 0; // Reset animation frame + + } else if (gameState == "game_over") { + // Show game over pattern + matrix.draw(game_over); + animationFrame = 0; + + } else if (gameState == "idle") { + // Show idle frame when game has not started + matrix.draw(idle); + animationFrame = 0; + + } else { + // Default to idle if state is unknown + matrix.draw(idle); + } + } else { + // If communication fails, show idle + matrix.draw(idle); + } + + delay(50); // Update LED matrix at around 20 FPS +} \ No newline at end of file diff --git a/examples/mascot-jump-game/sketch/sketch.yaml b/examples/mascot-jump-game/sketch/sketch.yaml new file mode 100644 index 0000000..cb644bb --- /dev/null +++ b/examples/mascot-jump-game/sketch/sketch.yaml @@ -0,0 +1,11 @@ +profiles: + default: + fqbn: arduino:zephyr:unoq + platforms: + - platform: arduino:zephyr + libraries: + - MsgPack (0.4.2) + - DebugLog (0.8.4) + - ArxContainer (0.7.0) + - ArxTypeTraits (0.3.1) +default_profile: default \ No newline at end of file diff --git a/examples/object-hunting/README.md b/examples/object-hunting/README.md new file mode 100644 index 0000000..574c5f9 --- /dev/null +++ b/examples/object-hunting/README.md @@ -0,0 +1,179 @@ +# Object Hunting + +The **Object Hunting Game** is an interactive scavenger hunt that uses real-time object detection. Players must locate specific physical objects in their environment using a USB camera connected to the Arduino UNO Q to win the game. + +**Note:** This example requires to be run using **Network Mode** or **Single-Board Computer (SBC) Mode**, since it requires a **USB-C® hub** and a **USB webcam**. + +![Object Hunting Game Example](assets/docs_assets/thumbnail.png) + +## Description + +This App creates an interactive game that recognizes real-world objects. It utilizes the `video_objectdetection` Brick to stream video from a USB webcam and perform continuous inference using the **YoloX Nano** model. The web interface challenges the user to find five specific items: **Book, Bottle, Chair, Cup, and Cell Phone**. + +**Key features include:** + +- Real-time video streaming and object recognition +- Interactive checklist that updates automatically when items are found +- Confidence threshold adjustment to tune detection sensitivity +- "Win" state triggering upon locating all target objects + +## Bricks Used + +The object hunting game example uses the following Bricks: + +- `web_ui`: Brick to create the interactive game interface and handle WebSocket communication. +- `video_objectdetection`: Brick that manages the USB camera stream, runs the machine learning model, and provides real-time detection results. + +## Hardware and Software Requirements + +### Hardware + +- Arduino UNO Q (x1) +- **USB-C® hub with external power (x1)** +- A power supply (5 V, 3 A) for the USB hub (x1) +- **USB Webcam** (x1) + +### Software + +- Arduino App Lab +**Important:** A **USB-C® hub is mandatory** for this example to connect the USB Webcam. +**Note:** You must connect the USB camera **before** running the App. If the camera is not connected or not detected, the App will fail to start. + +## How to Use the Example + +1. **Hardware Setup** + Connect your **USB Webcam** to a powered **USB-C® hub** attached to the UNO Q. Ensure the hub is powered to support the camera. + ![Hardware setup](assets/docs_assets/hardware-setup.png) + +2. **Run the App** + Launch the App from Arduino App Lab. + *Note: If the App stops immediately after clicking Run, check your USB camera connection.* + ![Arduino App Lab - Run App](assets/docs_assets/launch-app.png) + +3. **Access the Web Interface** + Open the App in your browser at `:7000`. The interface will load, showing the game introduction and the video feed placeholder. + +4. **Start the Game** + Click the **Start Game** button. The interface will switch to the gameplay view, displaying the live video feed and the list of objects to find. + +5. **Hunt for Objects** + Point the camera at the required items (Book, Bottle, Chair, Cup, Cell Phone). When the system detects an object with sufficient confidence, it will automatically mark it as "Found" in the UI. + +6. **Adjust Sensitivity** + If the camera is not detecting objects easily, or is detecting them incorrectly, use the **Confidence Level** slider on the right. + - **Lower value:** Detects objects more easily but may produce false positives. + - **Higher value:** Requires a clearer view of the object to trigger a match. + +7. **Win the Game** + Once all five objects are checked off the list, a "You found them all!" screen appears. You can click **Play Again** to reset the list and restart. + +## How it Works + +The application relies on a continuous data pipeline between the hardware, the inference engine, and the web browser. + +**High-level data flow:** + +``` + USB Camera ──► VideoObjectDetection ──► Inference Model (YoloX) + │ │ + │ (MJPEG Stream) │ (Detection Events) + ▼ ▼ + Frontend (Browser) ◄── WebUI Brick + │ + └──► WebSocket (Threshold Control) +``` + +- **Video Streaming**: The `video_objectdetection` Brick captures video from the USB camera and hosts a low-latency stream on port `4912`. The frontend embeds this stream via an ` + + + + +
+
+

Find all these objects to win!

+
    +
  • book iconbook
  • +
  • bottle iconbottle
  • +
  • chair iconchair
  • +
  • cup iconcup
  • +
  • cell phone iconcell phone
  • +
+
+
Use your camera to detect the target objects.
+
Adjust confidence level to help detection.
+
+ +
+ + + + +
+ + + + + + + + + + + + + diff --git a/examples/object-hunting/assets/libs/socket.io.min.js b/examples/object-hunting/assets/libs/socket.io.min.js new file mode 100644 index 0000000..530b185 --- /dev/null +++ b/examples/object-hunting/assets/libs/socket.io.min.js @@ -0,0 +1,6 @@ +/*! + * Socket.IO v4.8.1 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); \ No newline at end of file diff --git a/examples/object-hunting/assets/style.css b/examples/object-hunting/assets/style.css new file mode 100644 index 0000000..d1cf57f --- /dev/null +++ b/examples/object-hunting/assets/style.css @@ -0,0 +1,895 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@import url("fonts/fonts.css"); + +/* + * This CSS is used to center the various elements on the screen + */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; margin: 0; + padding: 20px; + background-color: #DAE3E3; + color: #333; + display: flex; + flex-direction: column; + align-items: center; +} + +.main-content { + display: flex; + gap: 24px; + align-items: stretch; + justify-content: center; + width: 100%; + max-width: 1280px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +} + +.container { + width: 100%; + max-width: 550px; + min-width: 500px; + height: auto; + min-height: auto; + border-radius: 16px; + background: #FFF; + padding: 24px; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: #ECF1F1; +} + +.right-column { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + max-width: 550px; +} + +#game-intro { + justify-content: flex-start; + gap: 24px; + align-items: center; +} + +#game-content { + display: flex; + flex-direction: column; + gap: 24px; + flex-grow: 1; +} + +.objects-list-intro { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 24px; +} + +.object-item-intro { + display: grid; + grid-template-columns: 48px 1fr; + align-items: center; + gap: 16px; + font-size: 16px; + text-transform: capitalize; + color: #2C353A; +} + +.object-item-intro img { + width: 48px; + height: 48px; +} + +.error-message { + margin-top: 20px; + padding: 10px; + border-radius: 5px; + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.container-right { + background-color: #ECF1F1; + padding: 16px; + border-radius: 16px; +} + +h2 { + color: #008184; + font-family: "Roboto Mono"; + font-size: 28px; + font-style: normal; + font-weight: 700; + line-height: 170%; + letter-spacing: 0.28px; + text-align: center; + margin: 0; +} + +/* + * Components styling + */ + +#videoFeedContainer { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 20px; + width: 100%; + margin-left: auto; + margin-right: auto; +} + +#videoCanvas { + display: block; + width: 100%; + height: auto; + background-color: #333; +} + +.camera-status { + display: flex; + align-items: center; + flex-direction: column; + gap: 16px; +} + +.search { + display: flex; + align-items: center; + gap: 8px; + color: #2C353A; + font-family: "Roboto Mono", monospace; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 170%; + letter-spacing: 1.92px; +} + +.search-small { + color: #2C353A; + text-align: center; + font-family: "Open Sans"; + font-size: 14px; + font-style: normal; + font-weight: 400; +} + +#scanInfo { + text-align: center; + margin-bottom: 24px; + display: flex; + flex-direction: column; + gap: 8px; + align-self: stretch; + border-radius: 8px; + background: #FFF; +} + +#scanMessage { + text-align: center; + font-family: "Open Sans"; + font-size: 10px; + color: #2C353A; + line-height: 160%; + letter-spacing: 0.5px; +} + +#recentScansList { + list-style-type: none; + padding: 0; + flex: 1; +} + +#recentScansList li { + border-top: 1px solid #C9D2D2; + border-bottom: 1px solid #C9D2D2; + padding: 16px 0; + font-size: 12px; + color: #2C353A; +} + +.error-message { + color: red; + font-weight: bold; + text-align: center; + margin-top: 10px; +} + +#rescan-button-container { + display: flex; + justify-content: flex-end; +} + +#rescanButton { + background-color: #008184; + color: #fff; + border: none; + border-radius: 8px; + padding: 8px 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + outline: none; + box-shadow: none; + font-family: "Open Sans"; + font-size: 14px; + font-style: normal; + line-height: 160%; +} + +.arduino-text { + color: #008184; + font-family: "Roboto Mono", monospace; + font-size: 20px; + font-weight: 600; + margin: 0; + font-style: normal; + line-height: 170%; + letter-spacing: 0.28px; +} + +.icon-wrapper { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + border-radius: 50%; + transition: background-color 0.2s ease; + position: relative; +} + +.icon-wrapper:hover { + background-color: rgba(0, 129, 132, 0.1); + cursor: pointer; +} + +.icon-wrapper::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-family: "Open Sans", sans-serif; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; + z-index: 1000; + margin-bottom: 5px; +} + +.icon-wrapper::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 4px solid transparent; + border-top-color: #333; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; + z-index: 1000; + margin-bottom: 1px; +} + +.icon-wrapper:hover::after, +.icon-wrapper:hover::before { + opacity: 1; + visibility: visible; +} + +.icon-wrapper.tooltip-success::after { + background-color: #16A588; + color: #FFF; + opacity: 1; + visibility: visible; +} + +.icon-wrapper.tooltip-success::before { + border-top-color: #16A588; + opacity: 1; + visibility: visible; +} + +.icon { + width: 12px; + height: 12px; + display: block; +} + +.delete-scan-logo:hover { + cursor: pointer; + background-color: rgba(0, 129, 132, 0.1); + padding: 4px; + border-radius: 50%; +} + +.arduino-logo { + height: 32px; + width: auto; +} + +.delete-scan-logo { + width: 18px; + height: 18px; + border-radius: 50%; + transition: background-color 0.2s ease; + padding: 4px; + +} + +.recent-scans-title-container { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + color: #2C353A; + justify-content: space-between; +} + +.recent-scans-title { + color: #2C353A; + font-family: "Roboto Mono", monospace; + font-size: 12px; + font-style: normal; + font-weight: 700; + line-height: 170%; + letter-spacing: 1.2px; + margin: 0; +} + +.no-recent-scans { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #5D6A6B; + gap: 8px; + margin: auto; +} + +.scan-container { + display: flex; + justify-content: space-between; + align-items: center; +} + +#scan-message { + font-family: "Open Sans"; + font-size: 10px; + font-style: normal; + font-weight: 400; + line-height: 160%; + letter-spacing: 0.1px; + color: #2C353A; + text-align: center; + +} + +.scan-header { + color: #2C353A; + font-family: "Open Sans"; + font-size: 10px; + font-style: normal; + font-weight: 400; + line-height: 160%; + letter-spacing: 0.5px; + align-self: stretch; +} + +.scan-content, +.scan-content-time { + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 160%; + color: #2C353A; + font-family: "Open Sans"; + letter-spacing: 0.5px; +} + +.scan-content-time { + font-weight: 400; +} + +.cell-border { + border-top: 1px solid #ccc; +} + +.scan-cell-container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 16px 0; +} + +.scan-error { + color: #b00020; + background: #ffeaea; + padding: 8px 12px; + border-radius: 4px; + margin-top: 8px; + font-weight: bold; +} + +/* + * Responsive design + */ + +@media (max-width: 1024px) { + .main-content { + flex-direction: column; + align-items: center; + gap: 24px; + } + + .container, .right-column { + width: 100%; + max-width: 800px; + } +} + +@media (max-width: 480px) { + .container { + min-width: 300px; + background-color: red; + } +} + +.scan-error { + text-align: center; +} + +.scan-header { + font-family: "Open Sans"; + font-size: 10px; + font-style: normal; + font-weight: 400; + color: #2C353A; +} + +.scan-content { + position: relative; + display: flex; + gap: 8px; + align-items: center; +} + +/* + * Responsive design + */ + + @media (max-width: 768px) { + body { + padding: 12px 16px; + } + + .container { + max-width: none; + padding: 20px; + min-height: auto; + } + + .arduino-text { + font-size: 24px; + } + + .main-content { + gap: 20px; + flex-direction: column; + } +} + +@media (max-width: 480px) { + body { + padding: 8px 12px; + } + + .container { + padding: 16px; + } + + .arduino-logo { + height: 20px; + width: auto; + } + + #rescanButton { + font-size: 12px; + } +} + + +/* Added from anomaly-detection */ + +.control-group { + position: relative; +} + +.slider-box { + display: flex; + align-items: center; + gap: 10px; +} + +.control-confidence { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.control-confidence-description { + color: #2C353A; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; +} + +.control-group label { + color: #2C353A; + font-family: "Roboto Mono", monospace; + font-size: 12px; + font-style: normal; + font-weight: 700; + line-height: 170%; + letter-spacing: 1.2px; +} + +#confidenceSlider { + width: 100%; + height: 6px; + border-radius: 3px; + background: #DAE3E3; + outline: none; + -webkit-appearance: none; + appearance: none; + position: relative; + margin: 20px 0 10px 0; +} + +#confidenceSlider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: #008184; + cursor: pointer; + position: relative; + bottom: 3px; + z-index: 2; +} + +#confidenceSlider::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: #DAE3E3; +} + +#confidenceSlider::-moz-range-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: #DAE3E3; + border: none; +} + +.slider-container { + position: relative; + width: 100%; +} + +.slider-progress { + position: absolute; + top: 20px; + left: 0; + height: 6px; + background: #008184; + border-radius: 3px; + pointer-events: none; + z-index: 1; + transition: width 0.1s ease; +} + +.confidence-value-display { + position: absolute; + top: -3px; + transform: translateX(-50%); + color: #008184; + padding: 2px 6px; + pointer-events: none; + z-index: 3; + white-space: nowrap; + transition: left 0.1s ease; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 160%; + letter-spacing: 0.12px; +} + +.confidence-limits { + color: #2C353A; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 160%; + letter-spacing: 0.12px; + margin-top: 10px; +} + +.btn-tertiary { + border-radius: 6px; + border: 1px solid #C9D2D2; + background: white; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 4px 8px; + cursor: pointer; + transition: all 0.3s ease; + font-size: 12px; + min-width: 50px; + height: 36px; +} + +.confidence-input { + border: none; + background: transparent; + font-size: 12px; + font-weight: inherit; + color: inherit; + text-align: center; + width: 32px; + padding: 0; + margin: 0; + outline: none; + cursor: text; +} + +.confidence-input:focus { + background: rgba(0, 129, 132, 0.1); + border-radius: 2px; +} + +.reset-icon { + width: 18px; + height: 18px; + opacity: 0.7; + transition: opacity 0.3s ease; + cursor: pointer; +} + +.reset-icon svg path { + fill: black; +} + +.btn-tertiary:hover .reset-icon { + opacity: 1; +} + +.feedback-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +.feedback-text { + color: #5D6A6B; + text-align: center; +} +#recentDetections { + list-style-type: none; + padding: 0; + flex: 1; +} + +#recentDetections li { + border-top: 1px solid #C9D2D2; + border-bottom: 1px solid #C9D2D2; + font-size: 12px; + color: #2C353A; + padding: 16px 0; + word-break: break-all; + display: flex; + justify-content: space-between; + align-items: center; +} + +.detection-info { + display: flex; + align-items: center; + gap: 8px; +} + +.detection-time-info { + display: flex; + align-items: center; + gap: 8px; +} + + +#recentClassifications { + list-style-type: none; + padding: 0; + flex: 1; +} + + +/* Custom styles for layout */ + +.main-content > .container { + flex-grow: 1; +} + +.right-column { + flex-shrink: 0; +} + +@media (max-width: 1024px) { + #videoFeedContainer iframe, + #win-screen { + width: 100%; + height: auto; + aspect-ratio: 1024 / 768; + } +} + +.feedback-detection { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; +} + +.feedback-detection .percentage { + font-size: 24px; + font-weight: bold; + color: #008184; +} + +.feedback-detection img { + max-width: 64px; + max-height: 64px; +} + +.feedback-detection p { + font-size: 18px; + font-weight: 500; + color: #2C353A; + margin: 0; +} + + + +#video-container { + padding: 24px 0px; +} + +.hidden { + display: none !important; +} + +#objects-to-find-list .object-item { + padding: 10px; + display: grid; + grid-template-columns: 48px 1fr; + align-items: center; + gap: 16px; + font-size: 16px; + text-transform: capitalize; + color: #2C353A; +} + +#objects-to-find-list .object-item img { + width: 48px; + height: 48px; +} + +#objects-to-find-list .object-item.found { + background-color: #DCF2ED; + border: 1px solid #16A588; + border-radius: 8px; + padding: 12px 24px 12px 16px; + grid-template-columns: 48px 1fr auto; + margin-bottom: 8px; +} + +.found-icon { + width: 24px !important; + height: 24px !important; +} + +#objects-to-find-list { + margin-top: 16px; +} + +.btn-primary { + background-color: #008184; + color: white; + border: none; + padding: 10px 20px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 0 auto; + cursor: pointer; + border-radius: 8px; + width: fit-content; +} + +.chip { + background-color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + color: #333; + border: 1px solid #ccc; +} + +#win-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + text-align: center; + height: 768px; + box-sizing: border-box; + padding: 0 32px; +} + +.congratulations-icon { + width: 40px; + height: 40px; +} + +.sub-text { + margin: 16px 0; + text-align: center; + font-family: "Open Sans"; + font-size: 16px; + color: #2C353A; +} \ No newline at end of file diff --git a/examples/object-hunting/python/main.py b/examples/object-hunting/python/main.py new file mode 100644 index 0000000..e6624ea --- /dev/null +++ b/examples/object-hunting/python/main.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_utils import App +from arduino.app_bricks.web_ui import WebUI +from arduino.app_bricks.video_objectdetection import VideoObjectDetection +from datetime import datetime, UTC + +ui = WebUI() +detection_stream = VideoObjectDetection() + +ui.on_message("override_th", lambda sid, threshold: detection_stream.override_threshold(threshold)) + +def send_detections_to_ui(detections: dict): + for key, value in detections.items(): + entry = { + "content": key, + "timestamp": datetime.now(UTC).isoformat() + } + ui.send_message("detection", message=entry) + +detection_stream.on_detect_all(send_detections_to_ui) + +App.run() diff --git a/examples/theremin/README.md b/examples/theremin/README.md new file mode 100644 index 0000000..2894c29 --- /dev/null +++ b/examples/theremin/README.md @@ -0,0 +1,166 @@ +# Theremin Simulator + +The **Theremin Simulator** example lets you create and control a virtual theremin instrument using an interactive web interface, producing synthesized audio output through a connected **USB** audio device with low latency. + +**Note:** This example requires to be run using **Network Mode** or **Single-Board Computer (SBC)**, since it requires a **USB-C® hub** and a **USB speaker**. + +![Theremin Simulator](assets/docs_assets/theremin-simulator.png) + +This App creates a virtual instrument that generates real-time audio by creating sine waves at varying frequencies and amplitudes based on user input. The workflow involves receiving mouse or touch coordinates from the frontend and updating a **Wave Generator** Brick, which handles the audio synthesis, smoothing, and streaming to the **USB device** automatically. + +**Key features include:** + +- Real-time audio synthesis with low latency +- Interactive web interface for pitch and volume control +- Visual waveform display showing frequency and amplitude +- Automatic envelope smoothing (attack, release, glide) for natural sound +- Support for USB speakers and wireless USB audio receivers + +## Bricks Used + +The theremin simulator example uses the following Bricks: + +- `web_ui`: Brick that provides the web interface and a WebSocket channel for real-time control of the theremin. +- `wave_generator`: Brick that handles audio synthesis, envelope control (smoothing), and streaming to the USB audio device. + +## Hardware and Software Requirements + +### Hardware + +- Arduino UNO Q (x1) +- **USB-C® hub with external power (x1)** +- A power supply (5 V, 3 A) for the USB hub (x1) +- A **USB audio device** (choose one): + - **USB speaker** (cabled) + - **USB wireless speaker receiver/dongle** (2.4 GHz, non-Bluetooth) +- A **power supply** (5 V, 3 A) for the USB hub (e.g. a phone charger) + +### Software + +- Arduino App Lab + +**Important:** A **USB-C® hub is mandatory** for this example. The UNO Q's single port must be used for the hub, which provides the necessary connections for both the power supply and the USB audio device. Consequently, this example must be run in **[Network Mode](learn/network-mode)** or **[SBC Mode](learn/single-board-computer)**. + +**Note:** **HDMI audio** and **Bluetooth® Speakers** are not supported by this App. + +## How to Use the Example + +1. **Hardware Setup** + Connect your **USB audio device** (e.g., USB speaker, wireless USB receiver) to a powered **USB-C® hub** attached to the UNO Q. Ensure the hub is powered. + +2. **Run the App** + Launch the App from Arduino App Lab. Wait until the App has launched completely. + +3. **Access the Web Interface** + Open the App in your browser at `:7000` (typically `192.168.x.x`). + +4. **Turn on Power** + Locate the orange control panel at the bottom of the interface. Click the **POWER** switch to toggle it **ON** (the small LED indicator will light up). + *Note: No sound will be produced if this switch is OFF.* + +5. **Set Master Volume** + Use the **+** and **-** buttons near the **VOL** indicator to adjust the master volume. This sets the maximum output limit for the application. + +6. **Play the Instrument** + Drag your mouse (or use your finger on a touchscreen) inside the large gray background area: + - **Horizontal (Left ↔ Right):** Controls **Pitch**. Moving right increases the frequency (higher notes). + - **Vertical (Bottom ↕ Top):** Controls **Note Volume**. Moving up increases the amplitude (louder). Moving to the very bottom silences the note. + +7. **Visualize Audio** + Observe the screen in the center of the panel, which visualizes the real-time sine wave, frequency (Hz), and amplitude data. You can also toggle the **GRID** switch to visually reference specific pitch intervals. + +## How it Works + +The application relies on a continuous data pipeline between the web interface and the audio synthesis engine. + +**High-level data flow:** + +``` +Web Browser Interaction ──► WebSocket ──► Python Backend + ▲ │ + │ ▼ + (Visual Updates) (Glide & Synthesis) + │ │ + └─ WebSocket ◄── State ◄── Sine Wave Generation + │ + ▼ + USB Audio Output +``` + +- **User Interaction**: The frontend captures mouse/touch coordinates and sends them to the backend via the `web_ui` Brick's WebSocket channel. +- **Audio Synthesis**: The `wave_generator` Brick runs in the background. It takes the target frequency and amplitude and applies a **glide algorithm** to transition smoothly between notes. +- **Envelope Smoothing**: The Brick automatically handles attack, release, and glide to ensure the audio changes sound natural and analog-like, rather than robotic. +- **Audio Output**: The Brick streams the generated sine wave directly to the **USB** audio device. + +## Understanding the Code + +### 🔧 Backend (`main.py`) + +The Python script simplifies audio logic by utilizing the `WaveGenerator` Brick. + +- **Initialization**: Configures the audio engine with specific parameters (sine wave, 16kHz sample rate) and envelope settings (attack, release, glide). +- **Frequency Calculation**: Maps the X-axis input (0.0 to 1.0) exponentially to a frequency range of 20 Hz to ~8000 Hz. +- **Event Handling**: Listens for `theremin:move` events from the frontend to update frequency and amplitude. + +```python +wave_gen = WaveGenerator(sample_rate=16000, ...) + +def _freq_from_x(x): + # Exponential mapping from 20Hz up to Nyquist frequency + return 20.0 * ((SAMPLE_RATE / 2.0 / 20.0) ** x) + +def on_move(sid, data): + # Calculate target frequency and amplitude based on coordinates + freq = _freq_from_x(data.get("x")) + amp = max(0.0, min(1.0, 1.0 - float(data.get("y")))) + + wave_gen.set_frequency(freq) + wave_gen.set_amplitude(amp) +``` + +### 🔧 Frontend (`main.js`) + +The web interface handles user input and visualization. + +- **Input Capture**: Event listeners track `mousemove`, `touchmove`, and `touchstart` to capture user interaction. +- **Throttling**: Emissions to the backend are throttled to approximately 80 Hz (~12 ms) to prevent network overload while maintaining responsiveness. +- **Visual Feedback**: The canvas draws a real-time sine wave animation based on the amplitude and frequency data received back from the server. + +```javascript +// Send normalized coordinates (0.0 - 1.0) to backend +socket.emit('theremin:move', { x, y }); + +// Receive state for visualization +socket.on('theremin:state', (data) => { + updateStateDisplay(data.freq, data.amp); +}); +``` + +## Troubleshooting + +### "No USB speaker found" error +If the application fails to start and you see an error regarding the speaker: +**Fix:** +1. Ensure a **powered USB-C® hub** is connected to the UNO Q. +2. Verify the **USB audio device** is connected to the hub and turned on. +3. Restart the application. + +### No Sound Output +If the interface works but there is no sound: +- **Power Button:** Ensure the **POWER** switch in the web UI is **ON**. +- **Pointer Position:** Ensure you are interacting with the upper part of the play area (bottom is zero volume). +- **Volume Controls:** Increase the volume using the **+** button in the UI. +- **Hardware Volume:** Check the physical volume control on your speaker. +- **Audio Device:** Remember that **HDMI audio** and **Bluetooth® speakers** are not supported. + +### Choppy or Crackling Audio +- **CPU Load:** Close other applications running on the Arduino UNO Q. +- **Power Supply:** Ensure you are using a stable 5 V, 3 A power supply for the USB-C® hub. Insufficient power often degrades USB audio performance. + +## Technical Details + +- **Sample rate:** 16,000 Hz +- **Audio format:** 32-bit float, little-endian +- **Latency:** ~30 ms block duration +- **Frequency range:** ~20 Hz to ~8,000 Hz +- **Envelope:** Attack (0.01s), Release (0.03s), Glide (0.02s) diff --git a/examples/theremin/app.yaml b/examples/theremin/app.yaml new file mode 100644 index 0000000..78ddd6b --- /dev/null +++ b/examples/theremin/app.yaml @@ -0,0 +1,6 @@ +name: Theremin simulator +icon: 🎼 +description: A simple theremin simulator that generates audio based on user input. +bricks: + - arduino:web_ui + - arduino:wave_generator diff --git a/examples/theremin/assets/docs_assets/hardware-setup.png b/examples/theremin/assets/docs_assets/hardware-setup.png new file mode 100644 index 0000000..46a1932 Binary files /dev/null and b/examples/theremin/assets/docs_assets/hardware-setup.png differ diff --git a/examples/theremin/assets/docs_assets/launch-app-theremin.png b/examples/theremin/assets/docs_assets/launch-app-theremin.png new file mode 100644 index 0000000..50928ef Binary files /dev/null and b/examples/theremin/assets/docs_assets/launch-app-theremin.png differ diff --git a/examples/theremin/assets/docs_assets/theremin-simulator.png b/examples/theremin/assets/docs_assets/theremin-simulator.png new file mode 100644 index 0000000..84aa2e2 Binary files /dev/null and b/examples/theremin/assets/docs_assets/theremin-simulator.png differ diff --git a/examples/theremin/assets/fonts/Open Sans/OFL.txt b/examples/theremin/assets/fonts/Open Sans/OFL.txt new file mode 100644 index 0000000..d2a4922 --- /dev/null +++ b/examples/theremin/assets/fonts/Open Sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/examples/theremin/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf b/examples/theremin/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..548c15f Binary files /dev/null and b/examples/theremin/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/examples/theremin/assets/fonts/Roboto/OFL.txt b/examples/theremin/assets/fonts/Roboto/OFL.txt new file mode 100644 index 0000000..5d6f71c --- /dev/null +++ b/examples/theremin/assets/fonts/Roboto/OFL.txt @@ -0,0 +1,91 @@ +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/theremin/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf b/examples/theremin/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 0000000..3a2d704 Binary files /dev/null and b/examples/theremin/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf differ diff --git a/examples/theremin/assets/fonts/fonts.css b/examples/theremin/assets/fonts/fonts.css new file mode 100644 index 0000000..86cf716 --- /dev/null +++ b/examples/theremin/assets/fonts/fonts.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-display: swap; + src: url('Roboto/RobotoMono-VariableFont_wght.ttf') format('truetype'); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-display: swap; + src: url('Open Sans/OpenSans-VariableFont_wdth,wght.ttf') format('truetype'); +} \ No newline at end of file diff --git a/examples/theremin/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg b/examples/theremin/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg new file mode 100644 index 0000000..c942003 --- /dev/null +++ b/examples/theremin/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/examples/theremin/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg.license b/examples/theremin/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg.license new file mode 100644 index 0000000..c274485 --- /dev/null +++ b/examples/theremin/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + +SPDX-License-Identifier: MPL-2.0 diff --git a/examples/theremin/assets/img/favicon.png b/examples/theremin/assets/img/favicon.png new file mode 100644 index 0000000..019a8cf Binary files /dev/null and b/examples/theremin/assets/img/favicon.png differ diff --git a/examples/theremin/assets/img/grid.svg b/examples/theremin/assets/img/grid.svg new file mode 100644 index 0000000..4c189b7 --- /dev/null +++ b/examples/theremin/assets/img/grid.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/examples/theremin/assets/img/info.svg b/examples/theremin/assets/img/info.svg new file mode 100644 index 0000000..809cb3e --- /dev/null +++ b/examples/theremin/assets/img/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/theremin/assets/img/nodata.svg b/examples/theremin/assets/img/nodata.svg new file mode 100644 index 0000000..1b6f895 --- /dev/null +++ b/examples/theremin/assets/img/nodata.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/theremin/assets/img/play-area.svg b/examples/theremin/assets/img/play-area.svg new file mode 100644 index 0000000..5b52b0a --- /dev/null +++ b/examples/theremin/assets/img/play-area.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/examples/theremin/assets/img/power-off.svg b/examples/theremin/assets/img/power-off.svg new file mode 100644 index 0000000..b94ba3a --- /dev/null +++ b/examples/theremin/assets/img/power-off.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/theremin/assets/img/power-on.svg b/examples/theremin/assets/img/power-on.svg new file mode 100644 index 0000000..d362c30 --- /dev/null +++ b/examples/theremin/assets/img/power-on.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/theremin/assets/img/switch-off.svg b/examples/theremin/assets/img/switch-off.svg new file mode 100644 index 0000000..3a2894d --- /dev/null +++ b/examples/theremin/assets/img/switch-off.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/examples/theremin/assets/img/switch-on.svg b/examples/theremin/assets/img/switch-on.svg new file mode 100644 index 0000000..5e16d1d --- /dev/null +++ b/examples/theremin/assets/img/switch-on.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/examples/theremin/assets/img/theremin-on.svg b/examples/theremin/assets/img/theremin-on.svg new file mode 100644 index 0000000..a17ad1e --- /dev/null +++ b/examples/theremin/assets/img/theremin-on.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/theremin/assets/img/theremin.svg b/examples/theremin/assets/img/theremin.svg new file mode 100644 index 0000000..4937b9d --- /dev/null +++ b/examples/theremin/assets/img/theremin.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/theremin/assets/img/volume.svg b/examples/theremin/assets/img/volume.svg new file mode 100644 index 0000000..5c560dc --- /dev/null +++ b/examples/theremin/assets/img/volume.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/examples/theremin/assets/index.html b/examples/theremin/assets/index.html new file mode 100644 index 0000000..dca4cf9 --- /dev/null +++ b/examples/theremin/assets/index.html @@ -0,0 +1,79 @@ + + + + + + + + Theremin simulator + + + +
+
+

Theremin simulator

+ +
+
+
+
+
+ Theremin +
+
Freq: - Hz
+
Amp: -
+ +
+ Power + Access +
POWER
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
VOL
+ Grid Toggle +
GRID
+
+
+
PITCH
+
+
VOLUME
+ +
+
+ +
+ + + + + \ No newline at end of file diff --git a/examples/theremin/assets/libs/socket.io.min.js b/examples/theremin/assets/libs/socket.io.min.js new file mode 100644 index 0000000..530b185 --- /dev/null +++ b/examples/theremin/assets/libs/socket.io.min.js @@ -0,0 +1,6 @@ +/*! + * Socket.IO v4.8.1 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); \ No newline at end of file diff --git a/examples/theremin/assets/main.js b/examples/theremin/assets/main.js new file mode 100644 index 0000000..a14d227 --- /dev/null +++ b/examples/theremin/assets/main.js @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +(function(){ + const TEST_MODE = false; + const socket = io({ transports: ['websocket'] }); + const playArea = document.getElementById('play-area'); + const powerBtn = document.getElementById('power-btn'); + const accessBtn = document.getElementById('access-btn'); + const gridToggleBtn = document.getElementById('grid-toggle-btn'); + const freqDisplay = document.querySelector('#freq-display span'); + const ampDisplay = document.querySelector('#amp-display span'); + const visualizer = document.getElementById('visualizer'); + const visualizerCtx = visualizer.getContext('2d'); + const trailCanvas = document.getElementById('trail-canvas'); + const trailCtx = trailCanvas.getContext('2d'); + + const thereminSvg = document.getElementById('theremin-svg'); + + let currentVolume = 80; // Default volume (0-100) + let powerOn = false; + let accessOn = false; + let isGridOn = false; + let isDown = false; + let lastPos = { x: 0, y: 1 }; + let lastEmit = 0; + const EMIT_MIN_MS = 12; // throttle emits to ~80Hz + + // --- Canvas setup --- + function resizeCanvas() { + const rect = playArea.getBoundingClientRect(); + trailCanvas.width = rect.width; + trailCanvas.height = rect.height; + } + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + + + // --- Control Buttons --- + let testModeInterval = null; + + function updateStateDisplay(freq, amp) { + if (freq !== undefined) { + freqDisplay.textContent = Math.round(freq); + } + if (amp !== undefined) { + ampDisplay.textContent = amp.toFixed(2); + } + if (freq !== undefined && amp !== undefined) { + drawVisualizer(freq, amp); + } + } + + + + accessBtn.addEventListener('click', () => { + powerOn = !powerOn; + accessBtn.src = powerOn ? 'img/switch-on.svg' : 'img/switch-off.svg'; + powerBtn.src = powerOn ? 'img/power-on.svg' : 'img/power-off.svg'; + thereminSvg.src = powerOn ? 'img/theremin-on.svg' : 'img/theremin.svg'; + socket.emit('theremin:power', { on: powerOn }); + + if (powerOn) { + if (TEST_MODE) { + if (testModeInterval) clearInterval(testModeInterval); + testModeInterval = setInterval(() => { + const randomFreq = Math.random() * 500 + 20; // Freq between 20 and 1020 + const randomAmp = Math.random(); + updateStateDisplay(randomFreq, randomAmp); + }, 100); + } + } else { + if (testModeInterval) { + clearInterval(testModeInterval); + testModeInterval = null; + } + updateStateDisplay(0, 0); // Reset to silent + } + }); + + gridToggleBtn.addEventListener('click', () => { + isGridOn = !isGridOn; + gridToggleBtn.src = isGridOn ? 'img/switch-on.svg' : 'img/switch-off.svg'; + playArea.classList.toggle('grid-on', isGridOn); + }); + + const volumeBtn = document.getElementById('volume-btn'); + + if (volumeBtn) { + volumeBtn.addEventListener('click', (event) => { + const plusBtn = event.target.closest('#volume-plus-btn'); + const minusBtn = event.target.closest('#volume-minus-btn'); + + let newVolume = currentVolume; + + if (plusBtn) { + newVolume = Math.min(100, currentVolume + 10); + } else if (minusBtn) { + newVolume = Math.max(0, currentVolume - 10); + } + + if (newVolume !== currentVolume) { + currentVolume = newVolume; + updateVolumeIndicator(currentVolume); + socket.emit('theremin:set_volume', { volume: newVolume }); + } + }); + } + + + + // --- Mouse Trail --- + const trailParticles = []; + + function addTrailParticle(x, y) { + trailParticles.push({ + x: x, + y: y, + size: 8, + opacity: 1, + }); + } + + function animateTrail() { + trailCtx.clearRect(0, 0, trailCanvas.width, trailCanvas.height); + + trailCtx.strokeStyle = 'yellow'; + trailCtx.lineCap = 'round'; + trailCtx.lineJoin = 'round'; + + for (let i = 1; i < trailParticles.length; i++) { + const p1 = trailParticles[i - 1]; + const p2 = trailParticles[i]; + + trailCtx.beginPath(); + trailCtx.moveTo(p1.x, p1.y); + trailCtx.lineTo(p2.x, p2.y); + + trailCtx.lineWidth = p2.size; + trailCtx.globalAlpha = p2.opacity; + + trailCtx.stroke(); + } + trailCtx.globalAlpha = 1.0; // Reset globalAlpha + + // Particle update logic + for (let i = 0; i < trailParticles.length; i++) { + const p = trailParticles[i]; + p.opacity -= 0.05; + p.size -= 0.2; + if (p.opacity <= 0 || p.size <= 0) { + trailParticles.splice(i, 1); + i--; + } + } + requestAnimationFrame(animateTrail); + } + animateTrail(); + + + // --- Theremin Play Area Logic --- + function sendPos(e){ + if(!powerOn || accessOn) return; + const now = Date.now(); + if(now - lastEmit < EMIT_MIN_MS) return; + lastEmit = now; + const rect = playArea.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + if(x === lastPos.x && y === lastPos.y) return; + lastPos.x = x; lastPos.y = y; + socket.emit('theremin:move', { x, y, ts: Date.now() }); + } + + function sendStop(){ + socket.emit('theremin:move', { x: (lastPos.x || 0), y: 1, ts: Date.now() }); + } + + playArea.addEventListener('mousemove', (e) => { + if (powerOn) { + const rect = playArea.getBoundingClientRect(); + addTrailParticle(e.clientX - rect.left, e.clientY - rect.top); + showDot(e.clientX, e.clientY); + sendPos(e); + } + }); + + playArea.addEventListener('mouseenter', (e) => { + if (powerOn) { + showDot(e.clientX, e.clientY); + } + }); + + playArea.addEventListener('mouseleave', (e) => { + if (powerOn) { + removeDot(); + sendStop(); + } + }); + + playArea.addEventListener('touchstart', (e) => { + e.preventDefault(); + isDown = true; + const touch = e.touches[0]; + sendPos(touch); + showDot(touch.clientX, touch.clientY); + if (powerOn) { + const rect = playArea.getBoundingClientRect(); + addTrailParticle(touch.clientX - rect.left, touch.clientY - rect.top); + } + }); + + playArea.addEventListener('touchmove', (e) => { + if(isDown) { + e.preventDefault(); + const touch = e.touches[0]; + sendPos(touch); + showDot(touch.clientX, touch.clientY); + if (powerOn) { + const rect = playArea.getBoundingClientRect(); + addTrailParticle(touch.clientX - rect.left, touch.clientY - rect.top); + } + } + }); + + playArea.addEventListener('touchend', (e) => { + e.preventDefault(); + if(isDown){ + isDown = false; + sendStop(); + removeDot(); + } + }); + + // --- Visual Indicators --- + function showDot(x, y){ + let dot = document.getElementById('lock-dot'); + if(!dot){ dot = document.createElement('div'); dot.id = 'lock-dot'; playArea.appendChild(dot); } + const r = playArea.getBoundingClientRect(); + dot.style.left = (x - r.left) + 'px'; + dot.style.top = (y - r.top) + 'px'; + } + + function removeDot(){ const d = document.getElementById('lock-dot'); if(d) d.remove(); } + + playArea.addEventListener('dragstart', (e) => { e.preventDefault(); }); + + function drawVisualizer(freq, amp) { + const width = visualizer.width; + const height = visualizer.height; + const mid = height / 2; + + visualizerCtx.clearRect(0, 0, width, height); + visualizerCtx.strokeStyle = '#25C2C7'; + visualizerCtx.lineWidth = 2; + visualizerCtx.beginPath(); + visualizerCtx.moveTo(0, mid); + + if (amp > 0) { + const freqScale = freq / 500; + for (let i = 0; i < width; i++) { + const y = mid + (amp * (height / 2) * Math.sin(i * freqScale * 2 * Math.PI / width)); + visualizerCtx.lineTo(i, y); + } + } else { + visualizerCtx.lineTo(width, mid); + } + visualizerCtx.stroke(); + } + + function updateVolumeIndicator(volume) { + const indicator = document.getElementById('volume-indicator'); + if (indicator) { + const angle = ((volume / 100.0) - 0.5) * 180; // -90 to +90 degrees + indicator.style.transform = `rotate(${angle}deg)`; + } + } + + // --- Socket Event Handlers --- + socket.on('theremin:state', (s) => { + if (accessOn) return; + updateStateDisplay(s.freq, s.amp); + }); + + socket.on('theremin:volume', (v) => { + if(v.volume !== undefined) { + currentVolume = v.volume; + updateVolumeIndicator(v.volume); + } + }); + + +})(); \ No newline at end of file diff --git a/examples/theremin/assets/style.css b/examples/theremin/assets/style.css new file mode 100644 index 0000000..330a768 --- /dev/null +++ b/examples/theremin/assets/style.css @@ -0,0 +1,374 @@ +/* +SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + +SPDX-License-Identifier: MPL-2.0 +*/ + +@import url('fonts/fonts.css'); + +/* typography utilities used across examples */ +body { + font-family: 'Open Sans', sans-serif; + display: flex; + justify-content: center; + margin: 0; + background-color: #ECF1F1; + color: #2C353A; + padding: 16px 40px 24px 40px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + position: sticky; + top: 0; + background: #ECF1F1; + z-index: 1000; + padding-top: 16px; +} + +.arduino-text { + color: #008184; + font-family: "Roboto Mono", monospace; + font-size: 20px; + font-weight: 600; + margin: 0; + font-style: normal; + line-height: 170%; + letter-spacing: 2.4px; +} + +.arduino-logo { + height: 24px; + width: auto; +} + +#app { + max-width: 1400px; + text-align: center; + box-sizing: border-box; +} + +/* theme variables */ +:root { + --accent: #008184 +} + +h1 { + margin: 0 0 10px; + color: var(--accent) +} + +#main-content { + display: flex; + align-items: center; +} + +#play-area { + width: 100%; + min-width: 1280px; + height: 700px; + background-image: url(img/play-area.svg); + background-size: cover; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: #6c757d; + position: relative; + user-select: none; + touch-action: none; + gap: 16px; + padding: 16px; + box-sizing: border-box; +} +#trail-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10; /* Ensure trail is visible */ +} + +#grid-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: none; + pointer-events: none; + background-image: repeating-linear-gradient(90deg, #C9D2D2 0 1px, transparent 1px 50px); +} + +#play-area.grid-on #grid-overlay { + display: block; +} + +#right-controls { + display: flex; + flex-direction: column; + gap: 16px; + width: 100px; + height: 700px; +} + +#volume-control-area, +#grid-toggle-area { + height: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +#volume-control-area svg { + width: 100%; + height: auto; +} + +#volume-knob { + cursor: pointer; +} + +#theremin-container { + position: relative; + width: 80%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; +} + +#theremin-svg { + width: 95%; + height: 100%; + pointer-events: none; /* Ensure mouse events pass through to playArea */ +} + +#theremin-display { + position: absolute; + left: 25.2%; + top: 78%; + width: 54.4%; + height: 18.45%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + color: white; + font-family: "Roboto Mono", monospace; +} + +#freq-display, #amp-display { + position: absolute; + top: 8px; + font-size: 12px; + font-family: "Open Sans"; +} + +#freq-display { + left: 160px; +} + +#amp-display { + right: 270px; +} + +#visualizer { + position: absolute; + bottom: 10px; + left: 149px; + width: 275px; + height: 100px; +} + +.theremin-controls { + position: absolute; + z-index: 15; +} + +.theremin-controls-led { + left: 350px; + bottom: 75px; +} + +.theremin-controls-power { + left: 400px; + bottom: 75px; +} + +.theremin-controls-volume { + left: 765px; + bottom: 60px; +} + +.theremin-controls-grid { + left: 855px; + bottom: 75px; +} + + +.control-btn { + width: 40px; + height: 40px; + cursor: pointer; + padding: 5px; + transition: all 0.2s ease; +} + +.control-btn-volume { + width: 60px; + height: 60px; + cursor: pointer; + border-radius: 50%; + padding: 5px; + transition: all 0.2s ease; +} + +.axis { + position: absolute; + background-color: white; + z-index: 10; +} + +.x-axis { + bottom: 16px; + left: 16px; + right: 16px; + height: 2px; +} + +.x-axis::after { + content: ''; + position: absolute; + top: -4px; + right: -10px; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 10px solid white; +} + +.y-axis { + top: 16px; + left: 16px; + bottom: 16px; + width: 2px; +} + +.y-axis::before { + content: ''; + position: absolute; + left: -4px; + top: -10px; /* Keep the arrow pointing outwards */ + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 10px solid white; +} + +.axis-label { + position: absolute; + color: #2C353A; + text-align: center; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 600; + z-index: 10; +} + +.x-label { + bottom: 24px; + right: 24px; + color: white; +} + +.y-label { + top: 16px; + left: 32px; +} + +#status-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; +} + +#status { + font-size: 13px; + color: #5d6a6b +} + +/* lock dot shown when area is locked */ +#lock-dot { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + pointer-events: none; + transform: translate(-50%, -50%); + box-shadow: 0 0 8px rgba(0, 129, 132, 0.6) +} + +/* + * Responsive design + */ +@media (max-width: 768px) { + body { + padding: 12px 20px; + } + + .arduino-text { + font-size: 14px; + } + + .arduino-logo { + height: 16px; + width: auto; + } +} + +.control-label { + position: absolute; + color: white; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 160%; /* 19.2px */ + letter-spacing: 0.12px; + z-index: 15; + pointer-events: none; +} + +#power-label { + left: 401px; + bottom: 55px; + width: 40px; + text-align: center; +} + +#volume-label { + left: 785px; + bottom: 55px; + width: 40px; + text-align: center; +} + +#volume-indicator { + transform-origin: 34px 25px; + transition: transform 0.2s ease-in-out; + pointer-events: none; +} + +#grid-label { + left: 860px; + bottom: 55px; + width: 40px; + text-align: center; +} \ No newline at end of file diff --git a/examples/theremin/python/main.py b/examples/theremin/python/main.py new file mode 100644 index 0000000..3266f97 --- /dev/null +++ b/examples/theremin/python/main.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_bricks.web_ui import WebUI +from arduino.app_bricks.wave_generator import WaveGenerator +from arduino.app_utils import App, Logger + + +logger = Logger("theremin") + +# configuration +SAMPLE_RATE = 16000 + +# Wave generator brick - handles audio generation and streaming automatically +wave_gen = WaveGenerator( + sample_rate=SAMPLE_RATE, + wave_type="sine", + block_duration=0.03, + attack=0.01, + release=0.03, + glide=0.02, +) + +# Set initial state +wave_gen.set_frequency(440.0) +wave_gen.set_amplitude(0.0) + + +# --- Web UI and event handlers ----------------------------------------------------- +# The WaveGenerator brick handles audio generation and streaming automatically in +# a background thread. We only need to update frequency and amplitude via its API. +ui = WebUI() + + +def on_connect(sid, data=None): + state = wave_gen.get_state() + ui.send_message("theremin:state", {"freq": state["frequency"], "amp": state["amplitude"]}) + ui.send_message("theremin:volume", {"volume": state["volume"]}) + + +def _freq_from_x(x): + return 20.0 * ((SAMPLE_RATE / 2.0 / 20.0) ** x) + + +def on_move(sid, data=None): + """Update desired frequency/amplitude. + + The WaveGenerator brick handles smooth transitions automatically using + the configured envelope parameters (attack, release, glide). + """ + d = data or {} + x = float(d.get("x", 0.0)) + y = float(d.get("y", 1.0)) + freq = d.get("freq") + freq = float(freq) if freq is not None else _freq_from_x(x) + amp = max(0.0, min(1.0, 1.0 - float(y))) + + logger.debug(f"on_move: x={x:.3f}, y={y:.3f} -> freq={freq:.1f}Hz, amp={amp:.3f}") + + # Update wave generator state + wave_gen.set_frequency(freq) + wave_gen.set_amplitude(amp) + + ui.send_message("theremin:state", {"freq": freq, "amp": amp}, room=sid) + + +def on_power(sid, data=None): + d = data or {} + on = bool(d.get("on", False)) + if not on: + wave_gen.set_amplitude(0.0) + + +def on_set_volume(sid, data=None): + d = data or {} + volume = int(d.get("volume", 100)) + volume = max(0, min(100, volume)) + wave_gen.set_volume(volume) + ui.send_message("theremin:volume", {"volume": volume}) + + +ui.on_connect(on_connect) +ui.on_message("theremin:move", on_move) +ui.on_message("theremin:power", on_power) +ui.on_message("theremin:set_volume", on_set_volume) + +# Run the app - WaveGenerator handles audio generation automatically +App.run() diff --git a/examples/vibration-anomaly-detection/README.md b/examples/vibration-anomaly-detection/README.md new file mode 100644 index 0000000..227a1f2 --- /dev/null +++ b/examples/vibration-anomaly-detection/README.md @@ -0,0 +1,160 @@ +# Vibration Anomaly Detection + +The **Vibration Anomaly Detection** example creates a smart vibration detector that monitors a fan (or any vibrating machinery) for anomalies. It visualizes raw accelerometer data in real-time and allows users to dynamically adjust the anomaly detection sensitivity through a web dashboard. + +![Vibration Monitoring](assets/docs_assets/vibration-anomaly.png) + +## Description + +Monitor the physical status of a fan in real-time. This example uses a Modulino Movement to capture acceleration data and a dedicated Brick to detect vibration anomalies. + +Unlike simple threshold detectors, this App provides: +* **Live Data Visualization:** A real-time scrolling plot of X, Y, and Z acceleration. +* **Dynamic Sensitivity:** A slider to adjust the anomaly scoring threshold on the fly. +* **History:** A log of the most recent detected anomalies with timestamps. + +## Bricks Used + +The example uses the following Bricks: + +- `web_ui`: Brick to create a web interface to display the dashboard. +- `vibration_anomaly_detection`: Brick that processes accelerometer data to detect irregular vibration patterns. + +## Hardware and Software Requirements + +### Hardware + +- Arduino UNO Q (x1) +- Modulino Movement (LSM6DSOX) (x1) +- Qwiic Cable (x1) +- USB-C® to USB-A Cable (x1) + +### Software + +- Arduino App Lab + +**Note:** You can also run this example using your Arduino UNO Q as a Single Board Computer (SBC) using a [USB-C hub](https://store.arduino.cc/products/usb-c-to-hdmi-multiport-adapter-with-ethernet-and-usb-hub) with a mouse, keyboard, and monitor attached. + +## How to Use the Example + +1. **Hardware Setup** + Connect the Modulino Movement sensor to the Arduino UNO Q via the Qwiic connector. + +2. **Run the App** + Launch the App from Arduino App Lab. + +3. **Access the Web Interface** + Open the App in your browser at `:7000`. + +4. **Monitor Vibrations** + Observe the **Accelerometer Data** chart to see the live vibration waveforms. + +5. **Adjust Sensitivity** + Use the **Set anomaly score** slider to adjust how sensitive the detector is. + - **Lower values (1):** High sensitivity (small vibrations trigger alerts). + - **Higher values (10):** Low sensitivity (requires strong, irregular vibrations to trigger). + +6. **Trigger an Anomaly** + Shake the sensor or attach it to a fan to simulate an anomaly. The "Feedback" section will show a warning, and the event will be logged in "Recent Anomalies". + +## How it Works + +Once the App is running, it performs the following operations: + +- **Acquisition**: The sketch reads the sensor every 16ms and sends data to Python via Bridge. +- **Processing**: The backend converts units, feeds the detection Brick, and streams data to the UI. +- **Detection**: The Brick analyzes vibration patterns and triggers an event if the threshold is exceeded. +- **Tuning**: Slider adjustments in the web interface instantly update the detection sensitivity. + +## Understanding the Code + +Here is a brief explanation of the App components: + +### 🔧 Backend (`main.py`) + +The Python backend serves as the central hub. It performs the following tasks: + +- Initializes the `vibration_anomaly_detection` Brick. +- Receives raw sensor data via `Bridge`, converts it from gravity units ($g$) to acceleration ($m/s^2$), and forwards it to the UI for plotting. +- Accumulates samples in the detection Brick. +- Listens for threshold overrides from the UI to update the detection sensitivity in real-time. +- Broadcasts anomaly alerts containing the anomaly score and timestamp. + +**Data Processing Logic:** + +The `record_sensor_movement` function receives the raw data, converts the units, feeds the detector, and simultaneously pushes the data to the frontend for the live plot. + +```python +def record_sensor_movement(x: float, y: float, z: float): + # Convert g -> m/s^2 for the detector + x_ms2 = x * 9.81 + y_ms2 = y * 9.81 + z_ms2 = z * 9.81 + + # Forward raw data to UI for plotting + ui.send_message('sample', {'x': x_ms2, 'y': y_ms2, 'z': z_ms2}) + + # Forward samples to the vibration_detection brick + vibration_detection.accumulate_samples((x_ms2, y_ms2, z_ms2)) +``` + +**Dynamic Thresholds:** + +When you move the slider in the browser, the frontend emits an event. The backend updates the detection brick's sensitivity immediately. + +```python +def on_override_th(value: float): + logger.info(f"Setting new anomaly threshold: {value}") + vibration_detection.anomaly_detection_threshold = value +``` + +### 💻 Frontend (`index.html` + `app.js`) + +The web interface handles visualization and user input: + +- **Real-time Plotting:** Uses an HTML5 Canvas to draw the live X, Y, Z acceleration waveforms. +- **Interactive Controls:** Sends slider values to the backend to tune the algorithm parameters. +- **Alert System:** Visualizes anomalies with status icons and maintains a chronological list of recent detections. + +**Visualizing the Data:** + +The frontend receives the `sample` event and pushes it into an array. The `drawPlot` function clears the canvas and redraws the lines for X, Y, and Z to create the scrolling chart effect. + +```javascript +function drawPlot() { + if (!hasDataFromBackend) return; + + // Clear the canvas before drawing the new frame + ctx.clearRect(0, 0, currentWidth, currentHeight); + + // ... grid drawing code ... + + // Draw series (X, Y, Z) + drawSeries('x','#0068C9'); + drawSeries('y','#FF9900'); + drawSeries('z','#FF2B2B'); +} +``` + +### 🔧 Arduino Component (`sketch.ino`) + +The firmware reads the Modulino Movement sensor every 16ms. It sends the X, Y, and Z values to the Python backend using `Bridge.notify`. + +```cpp +void loop() { + // ... timing logic (16ms interval) ... + + // Read new movement data from the sensor + has_movement = movement.update(); + + if(has_movement == 1) { + // Get acceleration values + x_accel = movement.getX(); + y_accel = movement.getY(); + z_accel = movement.getZ(); + + // Send data to Python + Bridge.notify("record_sensor_movement", x_accel, y_accel, z_accel); + } +} +``` \ No newline at end of file diff --git a/examples/vibration-anomaly-detection/app.yaml b/examples/vibration-anomaly-detection/app.yaml new file mode 100644 index 0000000..e5605ca --- /dev/null +++ b/examples/vibration-anomaly-detection/app.yaml @@ -0,0 +1,7 @@ +name: Fan Vibration Monitoring +icon: 🌀 +description: Monitor fan vibrations and detect anomalies + +bricks: + - arduino:web_ui + - arduino:vibration_anomaly_detection diff --git a/examples/vibration-anomaly-detection/assets/app.js b/examples/vibration-anomaly-detection/assets/app.js new file mode 100644 index 0000000..788c237 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/app.js @@ -0,0 +1,335 @@ +// SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +// +// SPDX-License-Identifier: MPL-2.0 + +const canvas = document.getElementById('plot'); +const ctx = canvas.getContext('2d'); +const maxSamples = 200; +const samples = []; +let errorContainer; + +const recentAnomaliesElement = document.getElementById('recentClassifications'); +let anomalies = []; +const MAX_RECENT_ANOMALIES = 5; + +let hasDataFromBackend = false; // New global flag + +const accelerometerDataDisplay = document.getElementById('accelerometer-data-display'); +const noAccelerometerDataPlaceholder = document.getElementById('no-accelerometer-data'); + +function drawPlot() { + if (!hasDataFromBackend) return; // Only draw if we have data + + const currentWidth = canvas.clientWidth; + const currentHeight = canvas.clientHeight; + + if (canvas.width !== currentWidth || canvas.height !== currentHeight) { + canvas.width = currentWidth; + canvas.height = currentHeight; + } + // Clear the canvas before drawing the new frame! + ctx.clearRect(0, 0, currentWidth, currentHeight); + // All grid lines (every 0.5) - same size + ctx.strokeStyle = '#31333F99'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + for (let i=0; i<=8; i++){ + const y = 10 + i*((currentHeight-20)/8); + ctx.moveTo(40,y); + ctx.lineTo(currentWidth,y); + } + ctx.stroke(); + + // Y-axis labels (-2.0 to 2.0 every 0.5) + ctx.fillStyle = '#666'; + ctx.font = '400 14px Arial'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + + for (let i=0; i<=8; i++) { + const y = 10 + i*((currentHeight-20)/8); + const value = (4.0 - i * 1.0).toFixed(1); + ctx.fillText(value, 35, y); + } + + // draw each series + function drawSeries(key, color) { + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i=0;imaxSamples) samples.shift(); + if (!hasDataFromBackend) { // Check if this is the first data received + hasDataFromBackend = true; + renderAccelerometerData(); + } + drawPlot(); +} + +/* + * Socket initialization. We need it to communicate with the server + */ +const socket = io(`http://${window.location.host}`); // Initialize socket.io connection + +const feedbackContentWrapper = document.getElementById('feedback-content-wrapper'); +let feedbackTimeout; + +// ... (existing code between) + +// Start the application +document.addEventListener('DOMContentLoaded', () => { + initSocketIO(); + renderAccelerometerData(); // Initial render for accelerometer + renderAnomalies(); // Initial render for anomalies + updateFeedback(null); // Initial feedback state + initializeConfidenceSlider(); // Initialize the confidence slider + + // Popover logic + document.querySelectorAll('.info-btn.confidence').forEach(img => { + const popover = img.nextElementSibling; + img.addEventListener('mouseenter', () => { + popover.style.display = 'block'; + }); + img.addEventListener('mouseleave', () => { + popover.style.display = 'none'; + }); + }); + + document.querySelectorAll('.info-btn.accelerometer-data').forEach(img => { + const popover = img.nextElementSibling; + img.addEventListener('mouseenter', () => { + popover.style.display = 'block'; + }); + img.addEventListener('mouseleave', () => { + popover.style.display = 'none'; + }); + }); +}); + +function initializeConfidenceSlider() { + const confidenceSlider = document.getElementById('confidenceSlider'); + const confidenceInput = document.getElementById('confidenceInput'); + const confidenceResetButton = document.getElementById('confidenceResetButton'); + + confidenceSlider.addEventListener('input', updateConfidenceDisplay); + confidenceInput.addEventListener('input', handleConfidenceInputChange); + confidenceInput.addEventListener('blur', validateConfidenceInput); + updateConfidenceDisplay(); + + confidenceResetButton.addEventListener('click', (e) => { + if (e.target.classList.contains('reset-icon') || e.target.closest('.reset-icon')) { + resetConfidence(); + } + }); +} + +function handleConfidenceInputChange() { + const confidenceInput = document.getElementById('confidenceInput'); + const confidenceSlider = document.getElementById('confidenceSlider'); + + let value = parseInt(confidenceInput.value, 10); + + if (isNaN(value)) value = 5; + if (value < 1) value = 1; + if (value > 10) value = 10; + + confidenceSlider.value = value; + updateConfidenceDisplay(); +} + +function validateConfidenceInput() { + const confidenceInput = document.getElementById('confidenceInput'); + let value = parseInt(confidenceInput.value, 10); + + if (isNaN(value)) value = 5; + if (value < 1) value = 1; + if (value > 10) value = 10; + + confidenceInput.value = value.toFixed(0); + + handleConfidenceInputChange(); +} + +function updateConfidenceDisplay() { + const confidenceSlider = document.getElementById('confidenceSlider'); + const confidenceInput = document.getElementById('confidenceInput'); + const confidenceValueDisplay = document.getElementById('confidenceValueDisplay'); + const sliderProgress = document.getElementById('sliderProgress'); + + const value = parseFloat(confidenceSlider.value); + socket.emit('override_th', value / 10); // Send scaled confidence to backend (0.1 to 1.0) + const percentage = (value - confidenceSlider.min) / (confidenceSlider.max - confidenceSlider.min) * 100; + + const displayValue = value.toFixed(0); + confidenceValueDisplay.textContent = displayValue; + + if (document.activeElement !== confidenceInput) { + confidenceInput.value = displayValue; + } + + sliderProgress.style.width = percentage + '%'; + confidenceValueDisplay.style.left = percentage + '%'; +} + +function resetConfidence() { + const confidenceSlider = document.getElementById('confidenceSlider'); + const confidenceInput = document.getElementById('confidenceInput'); + + confidenceSlider.value = '5'; + confidenceInput.value = '5'; + updateConfidenceDisplay(); +} + +function initSocketIO() { + socket.on('anomaly_detected', async (message) => { + if (!hasDataFromBackend) { // Check if this is the first data received + hasDataFromBackend = true; + renderAccelerometerData(); + } + printAnomalies(message); + renderAnomalies(); + try { + const parsedAnomaly = JSON.parse(message); + updateFeedback(parsedAnomaly.score); // Pass the anomaly score + } catch (e) { + console.error("Failed to parse anomaly message for feedback:", message, e); + updateFeedback(null); // Fallback to no anomaly feedback + } + }); + + socket.on('sample', (s) => { + pushSample(s); + }); + + socket.on('connect', () => { + if (errorContainer) { + errorContainer.style.display = 'none'; + errorContainer.textContent = ''; + } + }); + + socket.on('disconnect', () => { + errorContainer = document.getElementById('error-container'); + if (errorContainer) { + errorContainer.textContent = 'Connection to the board lost. Please check the connection.'; + errorContainer.style.display = 'block'; + } + }); +} + +// ... (existing printAnomalies and renderAnomalies functions) + +function updateFeedback(anomalyScore = null) { + clearTimeout(feedbackTimeout); // Clear any existing timeout + + if (!hasDataFromBackend) { + feedbackContentWrapper.innerHTML = ` + + `; + return; + } + + if (anomalyScore !== null) { // Anomaly detected + feedbackContentWrapper.innerHTML = ` + + `; + feedbackTimeout = setTimeout(() => { + updateFeedback(null); // Reset after 3 seconds + }, 3000); + } else { // No anomaly or reset + feedbackContentWrapper.innerHTML = ` + + `; + } +} + +function printAnomalies(newAnomaly) { + anomalies.unshift(newAnomaly); + if (anomalies.length > MAX_RECENT_ANOMALIES) { anomalies.pop(); } +} + +function renderAnomalies() { + recentAnomaliesElement.innerHTML = ``; // Clear the list + + if (anomalies.length === 0) { + recentAnomaliesElement.innerHTML = ` +
+ +

No recent anomalies

+
+ `; + return; + } + + anomalies.forEach((anomaly) => { + try { + const parsedAnomaly = JSON.parse(anomaly); + + if (Object.keys(parsedAnomaly).length === 0) { + return; // Skip empty anomaly objects + } + + const listItem = document.createElement('li'); + listItem.className = 'anomaly-list-item'; + + const score = parsedAnomaly.score.toFixed(1); + const date = new Date(parsedAnomaly.timestamp); + + const timeString = date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const dateString = date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).replace(/ /g, ' '); + + listItem.innerHTML = ` + ${score} + Anomaly + ${timeString} - ${dateString} + `; + + recentAnomaliesElement.appendChild(listItem); + + } catch (e) { + console.error("Failed to parse anomaly data:", anomaly, e); + if(recentAnomaliesElement.getElementsByClassName('anomaly-error').length === 0) { + const errorRow = document.createElement('div'); + errorRow.className = 'anomaly-error'; + errorRow.textContent = `Error processing anomaly data. Check console for details.`; + recentAnomaliesElement.appendChild(errorRow); + } + } + }); +} + +function renderAccelerometerData() { + if (hasDataFromBackend) { + accelerometerDataDisplay.style.display = 'block'; + noAccelerometerDataPlaceholder.style.display = 'none'; + drawPlot(); + } else { + accelerometerDataDisplay.style.display = 'none'; + noAccelerometerDataPlaceholder.style.display = 'flex'; // Use flex for centering content + } +} diff --git a/examples/vibration-anomaly-detection/assets/docs_assets/vibration-anomaly.png b/examples/vibration-anomaly-detection/assets/docs_assets/vibration-anomaly.png new file mode 100644 index 0000000..7269e56 Binary files /dev/null and b/examples/vibration-anomaly-detection/assets/docs_assets/vibration-anomaly.png differ diff --git a/examples/vibration-anomaly-detection/assets/fonts/OFL.txt b/examples/vibration-anomaly-detection/assets/fonts/OFL.txt new file mode 100644 index 0000000..38d9750 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/fonts/OFL.txt @@ -0,0 +1,91 @@ +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/vibration-anomaly-detection/assets/fonts/Open Sans/OFL.txt b/examples/vibration-anomaly-detection/assets/fonts/Open Sans/OFL.txt new file mode 100644 index 0000000..4fc6170 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/fonts/Open Sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/vibration-anomaly-detection/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf b/examples/vibration-anomaly-detection/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..548c15f Binary files /dev/null and b/examples/vibration-anomaly-detection/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/examples/vibration-anomaly-detection/assets/fonts/RobotoMono-VariableFont_wght.ttf b/examples/vibration-anomaly-detection/assets/fonts/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 0000000..3a2d704 Binary files /dev/null and b/examples/vibration-anomaly-detection/assets/fonts/RobotoMono-VariableFont_wght.ttf differ diff --git a/examples/vibration-anomaly-detection/assets/fonts/fonts.css b/examples/vibration-anomaly-detection/assets/fonts/fonts.css new file mode 100644 index 0000000..cd34127 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/fonts/fonts.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-display: swap; + src: url('RobotoMono-VariableFont_wght.ttf') format('truetype'); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-display: swap; + src: url('Open Sans/OpenSans-VariableFont_wdth,wght.ttf') format('truetype'); +} diff --git a/examples/vibration-anomaly-detection/assets/fonts/roboto-mono.css b/examples/vibration-anomaly-detection/assets/fonts/roboto-mono.css new file mode 100644 index 0000000..5b62031 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/fonts/roboto-mono.css @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-display: swap; + src: url('RobotoMono-VariableFont_wght.ttf') format('truetype'); +} diff --git a/examples/vibration-anomaly-detection/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg b/examples/vibration-anomaly-detection/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg new file mode 100644 index 0000000..c942003 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/examples/vibration-anomaly-detection/assets/img/bad.svg b/examples/vibration-anomaly-detection/assets/img/bad.svg new file mode 100644 index 0000000..9f5f4c0 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/img/bad.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/vibration-anomaly-detection/assets/img/good.svg b/examples/vibration-anomaly-detection/assets/img/good.svg new file mode 100644 index 0000000..d96f98b --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/img/good.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/examples/vibration-anomaly-detection/assets/img/info.svg b/examples/vibration-anomaly-detection/assets/img/info.svg new file mode 100644 index 0000000..809cb3e --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/img/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/vibration-anomaly-detection/assets/img/no-data.png b/examples/vibration-anomaly-detection/assets/img/no-data.png new file mode 100644 index 0000000..16cb547 Binary files /dev/null and b/examples/vibration-anomaly-detection/assets/img/no-data.png differ diff --git a/examples/vibration-anomaly-detection/assets/img/reset.svg b/examples/vibration-anomaly-detection/assets/img/reset.svg new file mode 100644 index 0000000..2ca50b3 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/img/reset.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/vibration-anomaly-detection/assets/index.html b/examples/vibration-anomaly-detection/assets/index.html new file mode 100644 index 0000000..173a4d7 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/index.html @@ -0,0 +1,107 @@ + + + + + + + + Vibration Anomaly Detection + + + + + +
+
+

Vibration Anomaly Detection

+ +
+ +
+
+
+
Accelerometer Data (m/s2)
+ Info +
Live visualization of raw accelerometer data (X, Y, Z axes) from the board's Movement module. Horizontal axis represents time; vertical axis shows acceleration in m/s2
+
+
+ +
+
+
+ X +
+
+
+ Y +
+
+
+ Z +
+
+
+
+ No data +

No data

+
+
+ +
+
+
+
+
+
+ + Info +
It quantifies how far the current vibration is from the model's 'normal' clusters.
+ + Scores above your threshold are flagged as an anomaly.
+
+ +
+
+ 1 +
+
+
5
+ +
+ 10 +
+
+
+
+ +
+ +
+ +
+
+
+

Recent Anomalies

+
+
    +
    +
    +
    +
    + +
    + + + + + \ No newline at end of file diff --git a/examples/vibration-anomaly-detection/assets/libs/socket.io.min.js b/examples/vibration-anomaly-detection/assets/libs/socket.io.min.js new file mode 100644 index 0000000..530b185 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/libs/socket.io.min.js @@ -0,0 +1,6 @@ +/*! + * Socket.IO v4.8.1 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); \ No newline at end of file diff --git a/examples/vibration-anomaly-detection/assets/style.css b/examples/vibration-anomaly-detection/assets/style.css new file mode 100644 index 0000000..761b1f8 --- /dev/null +++ b/examples/vibration-anomaly-detection/assets/style.css @@ -0,0 +1,477 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@import url("fonts/fonts.css"); + +/* + * This CSS is used to center the various elements on the screen + */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + background-color: #DAE3E3; + line-height: 1.6; + color: #343a40; + padding: 24px 40px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding: 12px 0; +} + +.arduino-text { + color: #008184; + font-family: "Roboto Mono", monospace; + font-size: 20px; + font-weight: 700; + margin: 0; + font-style: normal; + line-height: 170%; + letter-spacing: 2.4px; +} + +.arduino-logo { + height: 32px; + width: auto; +} + +.main-content { + display: flex; + gap: 30px; + align-items: flex-start; +} + +/* + * Styles for specific components required by Anomaly Detection + */ + +.legend { + display: flex; + gap: 16px; + margin-top: 8px; + font-size: 14px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.legend-color { + width: 12px; + height: 4px; + border-radius: 1px; +} + +.controls-section-right { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.box-title { + color: #2C353A; + font-family: "Roboto Mono"; + font-size: 12px; + font-style: normal; + font-weight: 700; + line-height: 170%; + letter-spacing: 1.2px; + margin-bottom: 16px; +} + +.controls-section-left { + background: #ECF1F1; + padding: 16px; + border-radius: 8px; + min-width: 750px; + width: 100%; + display: flex; + flex-direction: column; +} + +.right-column { + display: flex; + flex-direction: column; + gap: 32px; + width: 100%; + max-width: 550px; +} + +.right-column .container:last-child { + flex-grow: 1; +} + +.container-right { + background: #ECF1F1; + padding: 16px; + border-radius: 8px; +} + +.error-message { + margin-top: 20px; + padding: 10px; + border-radius: 5px; + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.recent-scans-title-container { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; +} + +.recent-scans-title { + color: #2C353A; + font-family: "Roboto Mono", monospace; + font-size: 12px; + font-style: normal; + font-weight: 700; + line-height: 170%; + letter-spacing: 1.2px; + margin: 0; +} + +#recentClassifications { + list-style-type: none; + padding: 0; + flex: 1; +} + +/* + * Responsive design + */ +@media (max-width: 768px) { + .main-content { + flex-direction: column; + } + + .right-column { + max-width: 100%; + } + + .arduino-text { + font-size: 14px; + } + + .container { + padding: 15px; + } + + .controls-section-left { + min-width: 330px; + } + + .arduino-logo { + height: 16px; + width: auto; + } + +} + +@media (max-width: 1024px) and (min-width: 769px) { + .controls-section-left { + min-width: 490px; + } +} + +@media (max-width: 480px) { + .controls-section-left { + min-width: 170px; + } +} + +.info-btn { + width: 14px; + height: 14px; + cursor: pointer; + border-radius: 50%; + background-color: #C9D2D2; + padding: 2px; + transition: background 0.2s; + position: relative; +} + +.popover { + position: absolute; + left: 5%; + top: 70%; + margin-left: 8px; + display: none; + background: #fff; + padding: 16px 24px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + z-index: 10; + width: 300px; + color: #2C353A; + font-weight: 100; + font-family: "Open Sans"; + font-size: 12px; + line-height: 170%; + letter-spacing: 0.12px; +} + +.popover.active { + display: block; +} + +.no-recent-anomalies { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #5D6A6B; + gap: 8px; + margin: auto; +} + +.no-recent-anomalies p { + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 160%; /* 19.2px */ + letter-spacing: 0.12px; +} + +.feedback-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 64px; +} + +.feedback-text { + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 160%; /* 19.2px */ + letter-spacing: 0.12px; +} + +.no-data-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #5D6A6B; + gap: 8px; + margin: auto; +} + +.no-data-placeholder p { + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 160%; /* 19.2px */ + letter-spacing: 0.12px; +} + +.control-group { + position: relative; +} + +.slider-box { + display: flex; + align-items: center; + gap: 10px; +} + +.control-confidence { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +#confidenceSlider { + width: 100%; + height: 6px; + border-radius: 3px; + background: #DAE3E3; + outline: none; + -webkit-appearance: none; + appearance: none; + position: relative; + margin: 20px 0 10px 0; +} + +#confidenceSlider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: #008184; + cursor: pointer; + position: relative; + bottom: 3px; + z-index: 2; +} + +#confidenceSlider::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: #DAE3E3; +} + +#confidenceSlider::-moz-range-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: #DAE3E3; + border: none; +} + +.slider-container { + position: relative; + width: 100%; +} + +.slider-progress { + position: absolute; + top: 20px; + left: 0; + height: 6px; + background: #008184; + border-radius: 3px; + pointer-events: none; + z-index: 1; + transition: width 0.1s ease; +} + +.confidence-value-display { + position: absolute; + top: -3px; + transform: translateX(-50%); + color: #008184; + padding: 2px 6px; + pointer-events: none; + z-index: 3; + white-space: nowrap; + transition: left 0.1s ease; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 160%; + letter-spacing: 0.12px; +} + +.confidence-limits { + color: #2C353A; + font-family: "Open Sans"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 160%; + letter-spacing: 0.12px; + margin-top: 10px; +} + +.btn-tertiary { + border-radius: 6px; + border: 1px solid #C9D2D2; + background: white; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 4px 8px; + cursor: pointer; + transition: all 0.3s ease; + font-size: 12px; + min-width: 50px; + height: 36px; +} + +.confidence-input { + border: none; + background: transparent; + font-size: 12px; + font-weight: inherit; + color: inherit; + text-align: center; + width: 40px; + padding: 0; + margin: 0; + outline: none; + cursor: text; +} + +.confidence-input:focus { + background: rgba(0, 129, 132, 0.1); + border-radius: 2px; +} + +.reset-icon { + width: 18px; + height: 18px; + opacity: 0.7; + transition: opacity 0.3s ease; + cursor: pointer; +} + +.reset-icon svg path { + fill: black; +} + +.btn-tertiary:hover .reset-icon { + opacity: 1; +} + +.anomaly-list-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 0; + border-bottom: 1px solid #DAE3E3; +} +.anomaly-score { + font-weight: normal; + color: #2C353A; + font-size: 12px; +} +.anomaly-text { + color: #2C353A; + font-size: 12px; +} +.anomaly-time { + color: #2C353A; + font-size: 12px; +} +#accelerometer-data-display { + margin-top: 20px; +} + +#plot { + width: 100%; + height: 250px; +} diff --git a/examples/vibration-anomaly-detection/python/main.py b/examples/vibration-anomaly-detection/python/main.py new file mode 100644 index 0000000..4c41a85 --- /dev/null +++ b/examples/vibration-anomaly-detection/python/main.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +import json +from datetime import datetime +from arduino.app_utils import * +from arduino.app_bricks.web_ui import WebUI +from arduino.app_bricks.vibration_anomaly_detection import VibrationAnomalyDetection + +logger = Logger("vibration-detector") + +vibration_detection = VibrationAnomalyDetection(anomaly_detection_threshold=1.0) + +def on_override_th(value: float): + logger.info(f"Setting new anomaly threshold: {value}") + vibration_detection.anomaly_detection_threshold = value + +ui = WebUI() +ui.on_message("override_th", lambda sid, threshold: on_override_th(threshold)) + +def get_fan_status(anomaly_detected: bool): + return { + "anomaly": anomaly_detected, + "status_text": "Anomaly detected!" if anomaly_detected else "No anomaly" + } + +# Register action to take after successful detection +def on_detected_anomaly(anomaly_score: float, classification: dict): + anomaly_payload = { + "score": anomaly_score, + "timestamp": datetime.now().isoformat() + } + ui.send_message('anomaly_detected', json.dumps(anomaly_payload)) + ui.send_message('fan_status_update', get_fan_status(True)) + +vibration_detection.on_anomaly(on_detected_anomaly) + +def record_sensor_movement(x: float, y: float, z: float): + # Convert g -> m/s^2 for the detector + x_ms2 = x * 9.81 + y_ms2 = y * 9.81 + z_ms2 = z * 9.81 + + # Forward raw data to UI for plotting + ui.send_message('sample', {'x': x_ms2, 'y': y_ms2, 'z': z_ms2}) + + # Forward samples to the vibration_detection brick + vibration_detection.accumulate_samples((x_ms2, y_ms2, z_ms2)) + +# Register the Bridge RPC provider so the sketch can call into Python +Bridge.provide("record_sensor_movement", record_sensor_movement) + +App.run() diff --git a/examples/vibration-anomaly-detection/sketch/sketch.ino b/examples/vibration-anomaly-detection/sketch/sketch.ino new file mode 100644 index 0000000..a65cc2c --- /dev/null +++ b/examples/vibration-anomaly-detection/sketch/sketch.ino @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +// +// SPDX-License-Identifier: MPL-2.0 + +#include +#include + +// Create a ModulinoMovement object +ModulinoMovement movement; + +float x_accel, y_accel, z_accel; // Accelerometer values in g + +unsigned long previousMillis = 0; // Stores last time values were updated +const long interval = 16; // Interval at which to read (16ms) - sampling rate of 62.5Hz and should be adjusted based on model definition +int has_movement = 0; // Flag to indicate if movement data is available + +void setup() { + Bridge.begin(); + + // Initialize Modulino I2C communication + Modulino.begin(Wire1); + + // Detect and connect to movement sensor module + while (!movement.begin()) { + delay(1000); + } +} + +void loop() { + unsigned long currentMillis = millis(); // Get the current time + + if (currentMillis - previousMillis >= interval) { + // Save the last time you updated the values + previousMillis = currentMillis; + + // Read new movement data from the sensor + has_movement = movement.update(); + if(has_movement == 1) { + // Get acceleration values + x_accel = movement.getX(); + y_accel = movement.getY(); + z_accel = movement.getZ(); + + Bridge.notify("record_sensor_movement", x_accel, y_accel, z_accel); + } + + } +} diff --git a/examples/vibration-anomaly-detection/sketch/sketch.yaml b/examples/vibration-anomaly-detection/sketch/sketch.yaml new file mode 100644 index 0000000..a3d6ef3 --- /dev/null +++ b/examples/vibration-anomaly-detection/sketch/sketch.yaml @@ -0,0 +1,17 @@ +profiles: + default: + fqbn: arduino:zephyr:unoq + platforms: + - platform: arduino:zephyr + libraries: + - MsgPack (0.4.2) + - DebugLog (0.8.4) + - ArxContainer (0.7.0) + - ArxTypeTraits (0.3.1) + - Arduino_Modulino (0.6.1) + - Arduino_HS300x (1.0.0) + - Arduino_LPS22HB (1.0.2) + - Arduino_LSM6DSOX (1.1.2) + - STM32duino VL53L4CD (1.0.5) + - STM32duino VL53L4ED (1.0.1) +default_profile: default