<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[tilde industries]]></title><description><![CDATA[[~]]]></description><link>https://tilde.industries/</link><image><url>https://tilde.industries/favicon.png</url><title>tilde industries</title><link>https://tilde.industries/</link></image><generator>Ghost 2.13</generator><lastBuildDate>Fri, 22 May 2026 18:04:48 GMT</lastBuildDate><atom:link href="https://tilde.industries/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Sandglass]]></title><description><![CDATA[<!doctype html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Sandglass Grain Control</title>
    <script type="module" src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module"></script>
    <style>
        :root {
            --bg0: #0e1d2f;
            --bg1: #163a5f;
            --card: rgba(255, 255, 255, 0.14);
            --line: rgba(255, 255, 255, 0.2);
            --text: #f5f7ff;
            --muted: #d2def0;
            --accent: #ffd166;
            --gravity-accent: #88f6a0;
            --battery-accent: #38b6ff;
            --red: #ff6b6b;
            --green: #51cf66;
            --blue: #339af0;
            --ok: #88f6a0;
            --bad: #ff8f8f;
            --waiting: #ffb142;
        }

        * {
            box-sizing: border-box;
        }

        body {
            margin: 0;
            min-height:</style></head></html>]]></description><link>https://tilde.industries/sandglass/</link><guid isPermaLink="false">69e53b1da6ddb102946adda9</guid><dc:creator><![CDATA[Guru-san]]></dc:creator><pubDate>Sun, 19 Apr 2026 20:34:24 GMT</pubDate><content:encoded><![CDATA[<!doctype html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Sandglass Grain Control</title>
    <script type="module" src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module"></script>
    <style>
        :root {
            --bg0: #0e1d2f;
            --bg1: #163a5f;
            --card: rgba(255, 255, 255, 0.14);
            --line: rgba(255, 255, 255, 0.2);
            --text: #f5f7ff;
            --muted: #d2def0;
            --accent: #ffd166;
            --gravity-accent: #88f6a0;
            --battery-accent: #38b6ff;
            --red: #ff6b6b;
            --green: #51cf66;
            --blue: #339af0;
            --ok: #88f6a0;
            --bad: #ff8f8f;
            --waiting: #ffb142;
        }

        * {
            box-sizing: border-box;
        }

        body {
            margin: 0;
            min-height: 100vh;
            font-family: "Segoe UI", sans-serif;
            color: var(--text);
            background: radial-gradient(1200px 500px at 85% -10%, #2b78bf66 0%, transparent 65%),
                linear-gradient(135deg, var(--bg0), var(--bg1));
            display: grid;
            place-items: center;
            padding: 24px;
        }

        .panel {
            width: min(760px, 100%);
            border: 1px solid var(--line);
            background: var(--card);
            backdrop-filter: blur(8px);
            border-radius: 20px;
            padding: 24px;
            box-shadow: 0 16px 50px rgba(0, 0, 0, 0.35);
            position: relative;
        }

        .lang-toggle {
            position: absolute;
            top: 12px;
            right: 12px;
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 6px 10px;
            border-radius: 999px;
            background: rgba(8, 28, 48, 0.85);
            border: 1px solid var(--line);
            z-index: 2;
            font-size: 0.75rem;
            font-weight: 700;
        }

        .lang-toggle .flag-icon {
            width: 18px;
            height: 12px;
            border-radius: 2px;
            border: 1px solid rgba(255, 255, 255, 0.25);
            object-fit: cover;
        }

        .lang-toggle .lang-label {
            min-width: 24px;
            text-align: center;
            color: var(--accent);
        }

        h1 {
            margin: 0 0 8px;
            font-size: 1.8rem;
        }

        h3 {
            margin: 20px 0 8px;
            font-size: 0.85rem;
            text-transform: uppercase;
            color: var(--muted);
            letter-spacing: 0.05em;
        }

        .control-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
            flex-wrap: wrap;
            gap: 10px;
            margin-top: 20px;
            margin-bottom: 8px;
        }

        .control-header h3 {
            margin: 0;
        }

        .row {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            align-items: center;
            margin-bottom: 12px;
        }

        .settings-gear {
            margin-left: auto;
            width: 42px;
            height: 42px;
            padding: 0;
            font-size: 1.2rem;
            line-height: 1;
            display: inline-flex;
            align-items: center;
            justify-content: center;
        }

        .settings-panel {
            border: 1px solid var(--line);
            border-radius: 14px;
            background: rgba(8, 28, 48, 0.75);
            padding: 14px;
            margin-bottom: 14px;
        }

        button {
            border: 1px solid var(--line);
            background: #103150;
            color: var(--text);
            border-radius: 10px;
            padding: 10px 16px;
            font-weight: 600;
            cursor: pointer;
            transition: all 150ms ease;
        }

        button:hover:not(:disabled) {
            background: #154166;
        }

        button:disabled {
            opacity: 0.3;
            cursor: not-allowed;
        }

        .accent {
            background: #805f09;
            border-color: #f7cb5f66;
        }

        .save-btn {
            background: #2e7d32;
            border-color: #4caf5066;
        }

        .pill {
            border: 1px solid var(--line);
            border-radius: 999px;
            padding: 6px 12px;
            background: #0d2a46;
            font-size: 0.85rem;
        }

        .connected {
            color: var(--ok);
            border-color: var(--ok);
        }

        .disconnected {
            color: var(--bad);
            border-color: var(--bad);
        }

        .waiting {
            color: var(--waiting);
            border-color: var(--waiting);
        }

        .grid {
            display: grid;
            grid-template-columns: 1fr 120px;
            gap: 12px;
            align-items: center;
            margin-bottom: 10px;
        }

        input[type="range"] {
            width: 100%;
            accent-color: var(--accent);
            cursor: pointer;
        }

        .grav-slider {
            accent-color: var(--gravity-accent) !important;
        }

        input[type="color"] {
            width: 60px;
            height: 40px;
            border: 1px solid var(--line);
            border-radius: 8px;
            cursor: pointer;
            background: #0d2a46;
            padding: 4px;
        }

        .lat-slider {
            accent-color: #6ce5ff !important;
        }

        input[type="number"] {
            width: 100%;
            border: 1px solid var(--line);
            background: #0d2a46;
            color: var(--text);
            border-radius: 8px;
            padding: 8px;
            font-size: 0.9rem;
        }

        .timer-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 12px;
            margin-bottom: 10px;
        }

        .timer-field {
            display: flex;
            flex-direction: column;
            gap: 6px;
        }

        .timer-field label {
            font-size: 0.8rem;
            color: var(--muted);
            text-transform: uppercase;
            letter-spacing: 0.04em;
        }

        .value {
            font-weight: 700;
            color: var(--accent);
        }

        .log {
            margin-top: 15px;
            border: 1px solid var(--line);
            background: #081c30;
            border-radius: 12px;
            height: 150px;
            overflow-y: scroll;
            padding: 12px;
            font-family: 'Consolas', monospace;
            font-size: 12px;
            white-space: pre-wrap;
        }

        .terminal-input-row {
            display: grid;
            grid-template-columns: 1fr auto;
            gap: 10px;
            margin-top: 10px;
        }

        #serialCmdInput {
            width: 100%;
            border: 1px solid var(--line);
            background: #0d2a46;
            color: var(--text);
            border-radius: 8px;
            padding: 10px;
            font-size: 0.9rem;
        }

        .battery-footer {
            margin-top: 20px;
            padding-top: 15px;
            border-top: 1px solid var(--line);
            display: flex;
            justify-content: space-between;
            align-items: center;
        }


        .reset-btn-container {
            position: relative;
            display: inline-block;
        }

        #resetBtn {
            background: #3a2f2f;
            border-color: #8f5f5f;
            position: relative;
            overflow: hidden;
        }

        #resetBtn:hover:not(:disabled) {
            background: #4a3f3f;
        }

        #resetBtn.holding {
            background: #5a4f4f;
        }

        .hold-indicator {
            position: absolute;
            left: 0;
            top: 0;
            bottom: 0;
            background: linear-gradient(90deg, rgba(255, 100, 100, 0.6), rgba(255, 50, 50, 0.6));
            width: 0%;
            border-radius: 10px;
            transition: width 100ms linear;
            pointer-events: none;
            animation: pulse-red 1s infinite;
        }

        @keyframes pulse-red {

            0%,
            100% {
                box-shadow: inset 0 0 10px rgba(255, 0, 0, 0.3);
            }

            50% {
                box-shadow: inset 0 0 20px rgba(255, 0, 0, 0.6);
            }
        }

        #resetBtn.loading {
            background: #8f5f5f;
            animation: loading-pulse 1s infinite;
        }

        @keyframes loading-pulse {

            0%,
            100% {
                background: #8f5f5f;
                box-shadow: 0 0 10px rgba(255, 100, 100, 0.5);
            }

            50% {
                background: #a07070;
                box-shadow: 0 0 20px rgba(255, 100, 100, 0.8);
            }
        }

        #resetBtn:disabled {
            opacity: 0.3;
        }


        .color-preview {
            width: 24px;
            height: 24px;
            border-radius: 6px;
            border: 1px solid var(--line);
            background: #000;
            display: inline-block;
            vertical-align: middle;
            margin-left: 8px;
        }

        .pixel-grid-wrap {
            overflow-x: auto;
            margin-bottom: 10px;
        }

        .pixel-grid {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 2px;
        }

        .pixel-row {
            display: flex;
            flex-direction: row;
            gap: 2px;
        }

        .pixel-cell {
            width: 22px;
            height: 22px;
            border-radius: 3px;
            background: #0d2a46;
            border: 1px solid rgba(255, 255, 255, 0.1);
            cursor: pointer;
            transition: background 100ms, box-shadow 100ms;
        }

        .pixel-cell.on {
            background: var(--accent);
            border-color: var(--accent);
            box-shadow: 0 0 6px rgba(255, 209, 102, 0.6);
        }

        .pixel-grid-wrap.disabled .pixel-cell {
            cursor: not-allowed;
            opacity: 0.5;
        }


        .toggle-btn.active {
            background: #24613d;
            border-color: #61b57f;
        }

        .mute-btn {
            min-width: 160px;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
        }

        .speaker-icon {
            position: relative;
            display: inline-block;
            width: 18px;
            height: 18px;
            line-height: 18px;
            font-size: 16px;
        }

        .speaker-icon .mute-cross {
            position: absolute;
            right: -8px;
            top: -8px;
            color: var(--red);
            font-weight: 800;
            font-size: 14px;
            display: none;
            text-shadow: 0 0 6px rgba(0, 0, 0, 0.7);
        }

        .mute-btn.muted {
            background: #5a2828;
            border-color: #ff8f8f;
        }

        .mute-btn.muted .speaker-icon .mute-cross {
            display: inline;
        }

        .mode-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 10px;
            margin-bottom: 12px;
        }

        .mode-card {
            border: 1px solid var(--line);
            border-radius: 12px;
            background: rgba(13, 42, 70, 0.65);
            padding: 10px;
        }

        .mode-card button {
            width: 100%;
            margin-bottom: 8px;
        }

        .mode-card input[type="color"] {
            width: 100%;
            height: 36px;
        }

        .mode-btn.active {
            background: #1e5f92;
            border-color: #7ec8ff;
        }

        .firmware-card {
            border: 1px solid var(--line);
            border-radius: 12px;
            background: rgba(13, 42, 70, 0.65);
            padding: 10px;
            margin-bottom: 12px;
        }

        .firmware-note {
            color: var(--muted);
            font-size: 0.85rem;
            margin: 0 0 8px;
        }

        esp-web-install-button {
            --esp-tools-button-color: #1e5f92;
            --esp-tools-button-text-color: #f5f7ff;
            --esp-tools-button-border-radius: 10px;
        }
    </style>
</head>

<body>
    <main class="panel">
        <button id="langToggle" class="lang-toggle" title="Switch language" aria-label="Switch language">
            <img class="flag-icon" src="https://flagcdn.com/gb.svg" alt="English">
            <img class="flag-icon" src="https://flagcdn.com/jp.svg" alt="Japanese">
            <span id="langToggleLabel" class="lang-label">EN</span>
        </button>

        <h1 id="mainTitle">Sandglass Control</h1>

        <div class="row">
            <button id="connectBtn" class="accent">Connect Device</button>
            <button id="disconnectBtn" disabled>Disconnect</button>
            <button id="refreshBtn" disabled>Refresh Data</button>
            <button id="saveBtn" class="save-btn" disabled>Save Changes</button>
            <span id="statusPill" class="pill disconnected">Disconnected</span>
            <button id="settingsToggle" class="settings-gear" title="Toggle Settings" aria-expanded="false" aria-controls="settingsPanel">&#9881;</button>
        </div>

        <section id="settingsPanel" class="settings-panel" hidden>
            <h3 id="firmwareTitle">Firmware</h3>
            <div class="firmware-card">
                <p id="firmwareNote" class="firmware-note">Install or update firmware over USB from this page.</p>
                <esp-web-install-button id="fwInstallBtn" data-fw-name="Sandglass Firmware" data-fw-version="0.2.0" data-chip-family="ESP32-C6" data-firmware-path="https://raw.githubusercontent.com/Kuristian/website-demo/main/sandglass-factory.bin">
                    <button id="fwInstallActivate" slot="activate">Install / Update Firmware</button>
                    <span id="fwInstallUnsupported" slot="unsupported">This browser or context does not support Web
                        Serial.</span>
                </esp-web-install-button>
            </div>

            <h3 id="webserialTitle">Webserial terminal</h3>
            <div id="log" class="log"></div>
            <div class="terminal-input-row">
                <input id="serialCmdInput" type="text" placeholder="Type a serial command (e.g. pixelart?)" disabled>
                <button id="serialCmdSendBtn" disabled>Send</button>
            </div>

            <h3 id="sleepTimeoutTitle">Sleep Timeout (0 = disabled)</h3>
            <div class="timer-grid">
                <div class="timer-field">
                    <label id="sleepMinutesLabel" for="sleepTimeoutMinutes">Minutes</label>
                    <input id="sleepTimeoutMinutes" type="number" min="0" max="60" step="1" value="2" disabled>
                </div>
                <div class="timer-field">
                    <label id="sleepSecondsLabel" for="sleepTimeoutSeconds">Seconds</label>
                    <input id="sleepTimeoutSeconds" type="number" min="0" max="59" step="1" value="0" disabled>
                </div>
            </div>

            <h3 id="systemMenusStatusTitle">System Menus Status</h3>
            <div class="row">
                <span class="pill"><span id="menuLabel">Menu</span>: <span id="currentMenuStatus" style="font-weight:700;">-</span></span>
                <span class="pill"><span id="timerLeftLabel">Timer Left</span>: <span id="currentTimerRemaining" style="font-weight:700;">-</span></span>
                <span class="pill"><span id="orientationLabel">Orientation</span>: <span id="currentOrientation" style="font-weight:700;">-</span></span>
                <span class="pill"><span id="fwShortLabel">FW</span>: <span id="currentFirmwareVersion" style="font-weight:700;">-</span></span>
                <span class="pill"><span id="sandColorLabel">Sand Colour</span>
                    <div id="previewPill" class="color-preview"></div>
                </span>
            </div>

            <h3 id="testingControlsTitle">Testing Controls</h3>
            <!-- <div class="grid">
                <input id="testSliderA" type="range" min="-1000" max="1000" step="1" value="0" disabled />
                <input id="testInputA" type="number" min="-1000" max="1000" value="0" disabled />
            </div>
            <div class="grid">
                <input id="testSliderB" type="range" min="-1000" max="1000" step="1" value="0" disabled />
                <input id="testInputB" type="number" min="-1000" max="1000" value="0" disabled />
            </div>
            <div class="row">
                <button id="testToggle1" class="toggle-btn" disabled>Test Toggle 1: OFF</button>
                <button id="testToggle2" class="toggle-btn" disabled>Test Toggle 2: OFF</button>
                <button id="testToggle3" class="toggle-btn" disabled>Test Toggle 3: OFF</button>
                <button id="testToggle4" class="toggle-btn" disabled>Test Toggle 4: OFF</button>
                <button id="testToggle5" class="toggle-btn" disabled>Test Toggle 5: OFF</button>
            </div> -->

            <h3 id="systemTitle">System</h3>
            <div class="row">
                <button id="muteToggle" class="toggle-btn mute-btn" disabled>
                    <span class="speaker-icon">&#128266;<span class="mute-cross">&#10005;</span></span>
                    <span id="muteToggleText">Sound: ON</span>
                </button>
                <div class="reset-btn-container">
                    <button id="resetBtn" disabled>Reset NVS Memory (Hold 5s)</button>
                    <div class="hold-indicator" id="holdIndicator"></div>
                </div>
            </div>

            <div class="battery-footer">
                <span id="systemStatusTitle">System Status</span>
                <div class="row" style="margin-bottom: 0;">
                    <span class="pill"><span id="voltageLabel">Voltage</span>: <span id="battVolts" style="color: var(--battery-accent); font-weight:700;">-</span>V</span>
                    <!-- <span class="pill">Charge: <span id="battPerc"
                            style="color: var(--battery-accent); font-weight:700;">-</span>%</span> -->
                </div>
            </div>
        </section>

        <h3 id="systemMenusTitle">System Menus</h3>
        <div class="mode-grid">
            <div class="mode-card">
                <button id="modeBtnTimer" class="mode-btn" disabled>Timer</button>
                <input id="modeColorTimer" type="color" value="#FFD166" disabled>
            </div>
            <div class="mode-card">
                <button id="modeBtnSandbox" class="mode-btn" disabled>Sandbox</button>
                <input id="modeColorSandbox" type="color" value="#51CF66" disabled>
            </div>
            <div class="mode-card">
                <button id="modeBtnPixelArt" class="mode-btn" disabled>Pixelart</button>
                <input id="modeColorPixelArt" type="color" value="#339AF0" disabled>
            </div>
        </div>

        <div class="control-header">
            <h3><span id="grainsTitle">Grains</span> (1 - <span id="maxLabel">90</span>)</h3>
            <span class="pill"><span id="currentLabel1">Current</span>: <span id="currentValue" class="value">-</span></span>
        </div>
        <div class="grid">
            <input id="grainSlider" type="range" min="1" max="90" step="1" value="45" disabled>
            <input id="grainInput" type="number" min="1" max="90" value="45" disabled>
        </div>

        <div class="control-header">
            <h3 id="gravityTitle">Gravity (-5G to 5G)</h3>
            <span class="pill"><span id="currentLabel2">Current</span>: <span id="currentGravValue" style="color: var(--gravity-accent); font-weight:700;">-</span>G</span>
        </div>
        <div class="grid">
            <input id="gravSlider" class="grav-slider" type="range" min="-32000" max="32000" step="100" value="9800" disabled>
            <input id="gravInput" type="number" min="-5" max="5" step="0.01" value="1.53" disabled>
        </div>

        <div class="control-header">
            <h3 id="lateralGravityTitle">Lateral Gravity (-5G to 5G)</h3>
            <span class="pill"><span id="currentLabel3">Current</span>: <span id="currentLatGravValue" style="color: #6ce5ff; font-weight:700;">-</span>G</span>
        </div>
        <div class="grid">
            <input id="latGravSlider" class="lat-slider" type="range" min="0" max="32000" step="100" value="3000" disabled>
            <input id="latGravInput" type="number" min="0" max="5" step="0.01" value="0.47" disabled>
        </div>

        <div class="control-header">
            <h3 id="sandFlowTitle">Sand flow</h3>
            <span class="pill"><span id="currentLabel4">Current</span>: <span id="currentNeckFlowValue" style="color: var(--accent); font-weight:700;">-</span>%</span>
        </div>
        <div class="grid">
            <input id="neckFlowSlider" type="range" min="1" max="100" step="1" value="70" disabled>
            <input id="neckFlowInput" type="number" min="1" max="100" step="1" value="70" disabled>
        </div>

        <h3 id="timerDurationTitle">Timer Duration (Minutes and Seconds)</h3>
        <div class="timer-grid">
            <div class="timer-field">
                <label id="timerMinutesLabel" for="timerDurationMinutes">Minutes</label>
                <input id="timerDurationMinutes" type="number" min="0" max="99" step="1" value="0" disabled>
            </div>
            <div class="timer-field">
                <label id="timerSecondsLabel" for="timerDurationSeconds">Seconds</label>
                <input id="timerDurationSeconds" type="number" min="0" max="59" step="1" value="0" disabled>
            </div>
        </div>

        <h3 id="pixelEditorTitle">Pixel Editor (14 x 24 Hourglass)</h3>
        <div class="row">
            <input id="pixelColorPicker" type="color" value="#FFD166" disabled>
            <span class="pill"><span id="pixelColorLabel">Pixel Colour</span>
                <div id="pixelPreviewPill" class="color-preview" style="background: #FFD166;"></div>
            </span>
        </div>
        <div class="pixel-grid-wrap disabled" id="pixelGridWrap">
            <div class="pixel-grid" id="pixelGrid"></div>
        </div>
        <div class="row">
            <button id="clearGridBtn" disabled>Clear All Pixels</button>
        </div>

    </main>

    <script>
        let port = null, reader = null, writer = null, keepReading = true;
        let batteryInterval = null, setupTimeout = null, commandQueue = [], isProcessingQueue = false;
        let setupConfirmed = false;
        window.colorState = { r: 0, g: 0, b: 0 };
        window.pixelColorState = '#FFD166';
        let userInteractingWithColorPicker = false;
        let maxSandComponent = 50;
        const modeOrder = ['TIMER', 'PIXELART', 'SANDBOX', 'POMODORO'];
        const modeCommandValue = { TIMER: 0, PIXELART: 1, SANDBOX: 2, POMODORO: 3 };
        const modeButtonId = {
            TIMER: 'modeBtnTimer',
            POMODORO: 'modeBtnPomodoro',
            SANDBOX: 'modeBtnSandbox',
            PIXELART: 'modeBtnPixelArt'
        };
        const modeColorId = {
            TIMER: 'modeColorTimer',
            POMODORO: 'modeColorPomodoro',
            SANDBOX: 'modeColorSandbox',
            PIXELART: 'modeColorPixelArt'
        };
        let activeMode = null;
        let muteEnabled = true;
        let installManifestObjectUrl = null;
        let currentLanguage = localStorage.getItem('sandglassLang') || 'en';
        let uiConnectionState = 'disconnected';
        let currentMenuStatusRaw = '-';
        let currentOrientationRaw = '-';

        const translations = {
            en: {
                pageTitle: 'Sandglass Control',
                connect: 'Connect Device',
                disconnect: 'Disconnect',
                refresh: 'Refresh Data',
                save: 'Save Changes',
                settingsTitle: 'Toggle Settings',
                firmware: 'Firmware',
                firmwareNote: 'Install or update firmware over USB from this page.',
                fwInstall: 'Install / Update Firmware',
                fwUnsupported: 'This browser or context does not support Web Serial.',
                webserial: 'Webserial terminal',
                serialPlaceholder: 'Type a serial command (e.g. pixelart?)',
                send: 'Send',
                sleepTimeout: 'Sleep Timeout (0 = disabled)',
                minutes: 'Minutes',
                seconds: 'Seconds',
                systemMenusStatus: 'System Menus Status',
                menu: 'Menu',
                timerLeft: 'Timer Left',
                orientation: 'Orientation',
                sandColour: 'Sand Colour',
                testingControls: 'Testing Controls',
                system: 'System',
                resetNvs: 'Reset NVS Memory (Hold 5s)',
                systemStatus: 'System Status',
                voltage: 'Voltage',
                systemMenus: 'System Menus',
                grains: 'Grains',
                gravity: 'Gravity (-5G to 5G)',
                lateralGravity: 'Lateral Gravity (-5G to 5G)',
                sandFlow: 'Sand flow',
                timerDuration: 'Timer Duration (Minutes and Seconds)',
                pixelEditor: 'Pixel Editor (14 x 24 Hourglass)',
                pixelColour: 'Pixel Colour',
                clearPixels: 'Clear All Pixels',
                current: 'Current',
                waiting: 'Waiting for Setup...',
                connected: 'Connected',
                disconnected: 'Disconnected',
                initializing: 'System: Initializing...',
                soundOn: 'Sound: ON',
                soundMuted: 'Sound: MUTED',
                on: 'ON',
                off: 'OFF',
                up: 'Up',
                down: 'Down',
                langLabel: 'EN',
                langToggleTitle: 'Switch language'
            },
            jp: {
                pageTitle: '砂時計コントロール',
                connect: 'デバイス接続',
                disconnect: '切断',
                refresh: 'データ更新',
                save: '変更を保存',
                settingsTitle: '設定を切り替え',
                firmware: 'ファームウェア',
                firmwareNote: 'このページからUSB経由でファームウェアをインストールまたは更新します。',
                fwInstall: 'ファームウェアをインストール / 更新',
                fwUnsupported: 'このブラウザまたはコンテキストはWeb Serialに対応していません。',
                webserial: 'WebSerialターミナル',
                serialPlaceholder: 'シリアルコマンドを入力 (例: pixelart?)',
                send: '送信',
                sleepTimeout: 'スリープタイムアウト (0 = 無効)',
                minutes: '分',
                seconds: '秒',
                systemMenusStatus: 'システムメニューステータス',
                menu: 'メニュー',
                timerLeft: '残り時間',
                orientation: '向き',
                sandColour: '砂の色',
                testingControls: 'テスト制御',
                system: 'システム',
                resetNvs: 'NVSメモリをリセット (5秒長押し)',
                systemStatus: 'システム状態',
                voltage: '電圧',
                systemMenus: 'システムメニュー',
                grains: '粒数',
                gravity: '重力 (-5G から 5G)',
                lateralGravity: '横方向重力 (-5G から 5G)',
                sandFlow: '砂の流量',
                timerDuration: 'タイマー時間 (分と秒)',
                pixelEditor: 'ピクセルエディタ (14 x 24 砂時計)',
                pixelColour: 'ピクセル色',
                clearPixels: '全ピクセルをクリア',
                current: '現在値',
                waiting: 'セットアップ待機中...',
                connected: '接続済み',
                disconnected: '未接続',
                initializing: 'システム: 初期化中...',
                soundOn: 'サウンド: ON',
                soundMuted: 'サウンド: ミュート',
                on: 'ON',
                off: 'OFF',
                up: '上',
                down: '下',
                langLabel: 'JP',
                langToggleTitle: '言語を切り替え'
            }
        };

        const t = (key) => (translations[currentLanguage] && translations[currentLanguage][key])
            || (translations.en && translations.en[key])
            || key;

        const el = (id) => document.getElementById(id);
        const logEl = el('log');

        function setText(id, value) {
            const node = el(id);
            if (node) node.textContent = value;
        }

        function translateMenuStatus(raw) {
            if (!raw || raw === '-') return '-';
            const upper = String(raw).toUpperCase();
            const mapped = {
                TIMER: { en: 'Timer', jp: 'タイマー' },
                PIXELART: { en: 'Pixelart', jp: 'ピクセルアート' },
                SANDBOX: { en: 'Sandbox', jp: 'サンドボックス' },
                POMODORO: { en: 'Pomodoro', jp: 'ポモドーロ' }
            };
            if (!mapped[upper]) return raw;
            return mapped[upper][currentLanguage] || mapped[upper].en;
        }

        function renderConnectionPill() {
            const pill = el('statusPill');
            if (!pill) return;
            if (uiConnectionState === 'waiting') {
                pill.textContent = t('waiting');
                pill.className = 'pill waiting';
            } else if (uiConnectionState === 'connected') {
                pill.textContent = t('connected');
                pill.className = 'pill connected';
            } else {
                pill.textContent = t('disconnected');
                pill.className = 'pill disconnected';
            }
        }

        function applyTranslations() {
            document.documentElement.lang = currentLanguage === 'jp' ? 'ja' : 'en';
            setText('mainTitle', t('pageTitle'));
            setText('connectBtn', t('connect'));
            setText('disconnectBtn', t('disconnect'));
            setText('refreshBtn', t('refresh'));
            setText('saveBtn', t('save'));
            setText('firmwareTitle', t('firmware'));
            setText('firmwareNote', t('firmwareNote'));
            setText('fwInstallActivate', t('fwInstall'));
            setText('fwInstallUnsupported', t('fwUnsupported'));
            setText('webserialTitle', t('webserial'));
            setText('serialCmdSendBtn', t('send'));
            setText('sleepTimeoutTitle', t('sleepTimeout'));
            setText('sleepMinutesLabel', t('minutes'));
            setText('sleepSecondsLabel', t('seconds'));
            setText('systemMenusStatusTitle', t('systemMenusStatus'));
            setText('menuLabel', t('menu'));
            setText('timerLeftLabel', t('timerLeft'));
            setText('orientationLabel', t('orientation'));
            setText('sandColorLabel', t('sandColour'));
            setText('testingControlsTitle', t('testingControls'));
            setText('systemTitle', t('system'));
            setText('resetBtn', t('resetNvs'));
            setText('systemStatusTitle', t('systemStatus'));
            setText('voltageLabel', t('voltage'));
            setText('systemMenusTitle', t('systemMenus'));
            setText('grainsTitle', t('grains'));
            setText('gravityTitle', t('gravity'));
            setText('lateralGravityTitle', t('lateralGravity'));
            setText('sandFlowTitle', t('sandFlow'));
            setText('timerDurationTitle', t('timerDuration'));
            setText('timerMinutesLabel', t('minutes'));
            setText('timerSecondsLabel', t('seconds'));
            setText('pixelEditorTitle', t('pixelEditor'));
            setText('pixelColorLabel', t('pixelColour'));
            setText('clearGridBtn', t('clearPixels'));
            setText('currentLabel1', t('current'));
            setText('currentLabel2', t('current'));
            setText('currentLabel3', t('current'));
            setText('currentLabel4', t('current'));

            const settingsBtn = el('settingsToggle');
            if (settingsBtn) settingsBtn.title = t('settingsTitle');
            const serialInput = el('serialCmdInput');
            if (serialInput) serialInput.placeholder = t('serialPlaceholder');

            const langToggle = el('langToggle');
            const langLabel = el('langToggleLabel');
            if (langToggle) {
                langToggle.title = t('langToggleTitle');
                langToggle.setAttribute('aria-label', t('langToggleTitle'));
            }
            if (langLabel) langLabel.textContent = t('langLabel');

            modeOrder.forEach((modeName) => {
                const modeText = translateMenuStatus(modeName);
                const modeBtn = el(modeButtonId[modeName]);
                if (modeBtn) modeBtn.textContent = modeText;
            });

            if (currentMenuStatusRaw !== '-') {
                setText('currentMenuStatus', translateMenuStatus(currentMenuStatusRaw));
            }
            if (currentOrientationRaw !== '-') {
                setText('currentOrientation', currentOrientationRaw === '1' ? t('up') : t('down'));
            }

            ['testToggle1', 'testToggle2', 'testToggle3', 'testToggle4', 'testToggle5'].forEach((id, idx) => {
                const btn = el(id);
                if (!btn) return;
                const on = btn.classList.contains('active');
                btn.textContent = `Test Toggle ${idx + 1}: ${on ? t('on') : t('off')}`;
            });

            setMuteState(muteEnabled);
            renderConnectionPill();
        }

        function setLanguage(language) {
            const next = language === 'jp' ? 'jp' : 'en';
            currentLanguage = next;
            localStorage.setItem('sandglassLang', currentLanguage);
            applyTranslations();
        }

        function toggleLanguage() {
            setLanguage(currentLanguage === 'en' ? 'jp' : 'en');
        }

        function configureInstallManifest() {
            const btn = el('fwInstallBtn');
            if (!btn) return;

            const firmwarePath = btn.dataset.firmwarePath;
            const chipFamily = btn.dataset.chipFamily || 'ESP32-C6';
            const fwName = btn.dataset.fwName || 'Sandglass Firmware';
            const fwVersion = btn.dataset.fwVersion || '0.0.0';

            if (!firmwarePath) {
                log('Firmware path missing on install button data-firmware-path');
                return;
            }

            const manifest = {
                name: fwName,
                version: fwVersion,
                new_install_prompt_erase: true,
                builds: [
                    {
                        chipFamily,
                        parts: [
                            { path: firmwarePath, offset: 0 }
                        ]
                    }
                ]
            };

            if (installManifestObjectUrl) {
                URL.revokeObjectURL(installManifestObjectUrl);
            }
            const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
            installManifestObjectUrl = URL.createObjectURL(blob);
            btn.manifest = installManifestObjectUrl;
        }

        function log(msg) {
            const ts = new Date().toLocaleTimeString([], { hour12: false });
            logEl.textContent += `[${ts}] ${msg}\n`;
            logEl.scrollTop = logEl.scrollHeight;
        }

        function send(cmd) {
            if (!port || !writer) return;
            commandQueue.push(cmd);
            processQueue();
        }

        function setPixelCellVisual(cell, isOn, colorHex) {
            cell.classList.toggle('on', isOn);
            if (isOn) {
                cell.style.background = colorHex;
                cell.style.borderColor = colorHex;
                cell.style.boxShadow = `0 0 6px ${colorHex}99`;
            } else {
                cell.style.background = '';
                cell.style.borderColor = '';
                cell.style.boxShadow = '';
            }
        }

        function clearPixelGridVisualState() {
            for (let row = 0; row < ROWS; row++) {
                for (let col = 0; col < COLS; col++) {
                    if (!isInHourglass(col, row)) continue;
                    pixelState[row][col] = false;
                    const cell = cells[row * COLS + col];
                    if (cell) setPixelCellVisual(cell, false, window.pixelColorState);
                }
            }
        }

        function setPixelFromDevice(col, row, r, g, b) {
            if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return;
            if (!isInHourglass(col, row)) return;
            const isOn = (r > 0 || g > 0 || b > 0);
            pixelState[row][col] = isOn;
            const cell = cells[row * COLS + col];
            if (!cell) return;
            const toPreviewChannel = (v) => {
                const clamped = Math.max(0, Math.min(255, Number(v) || 0));
                return clamped > 0 ? Math.max(48, clamped) : 0;
            };
            const colorHex = '#' + [toPreviewChannel(r), toPreviewChannel(g), toPreviewChannel(b)]
                .map(v => v.toString(16).padStart(2, '0').toUpperCase())
                .join('');
            setPixelCellVisual(cell, isOn, colorHex);
        }

        const GRAVITY_DISPLAY_MAX = 5;
        const GRAVITY_RAW_MAX = 32000;

        function rawGravityToDisplay(raw) {
            const numeric = Number(raw) || 0;
            return (numeric / GRAVITY_RAW_MAX * GRAVITY_DISPLAY_MAX).toFixed(2);
        }

        function gravityDisplayToRaw(displayValue) {
            const numeric = Math.max(-GRAVITY_DISPLAY_MAX, Math.min(GRAVITY_DISPLAY_MAX, Number(displayValue) || 0));
            return Math.round((numeric / GRAVITY_DISPLAY_MAX) * GRAVITY_RAW_MAX);
        }

        function lateralGravityDisplayToRaw(displayValue) {
            const numeric = Math.max(0, Math.min(GRAVITY_DISPLAY_MAX, Number(displayValue) || 0));
            return Math.round((numeric / GRAVITY_DISPLAY_MAX) * GRAVITY_RAW_MAX);
        }

        function rawFlowToPercent(raw) {
            const numeric = Math.max(0, Math.min(500, Number(raw) || 0));
            return Math.round(100 - ((numeric / 500) * 99));
        }

        function percentToRawFlow(percent) {
            const numeric = Math.max(1, Math.min(100, Number(percent) || 1));
            return Math.round(((100 - numeric) / 99) * 500);
        }

        function requestPixelArt() {
            send('pixelart?');
        }

        function updatePixelPreview() {
            el('pixelPreviewPill').style.background = window.pixelColorState;
        }

        async function processQueue() {
            if (isProcessingQueue || commandQueue.length === 0 || !writer) return;
            isProcessingQueue = true;
            const encoder = new TextEncoder();
            while (commandQueue.length > 0 && writer) {
                const cmd = commandQueue.shift();
                try {
                    await writer.write(encoder.encode(cmd + '\n'));
                    log(`>> ${cmd}`);
                } catch (err) { log(`Write Error: ${err.message}`); }
                await new Promise(r => setTimeout(r, 15));
            }
            isProcessingQueue = false;
        }

        // Build pixel grid
        const COLS = 14, ROWS = 24;
        const pixelState = Array.from({ length: ROWS }, () => new Array(COLS).fill(false));
        const gridEl = el('pixelGrid');
        // Cells stored row-major: cell at (col, row) = cells[row * COLS + col]
        const cells = new Array(ROWS * COLS).fill(null);

        // Drag state tracking
        let isMouseDown = false;

        // Stepped hourglass widths (24 rows total):
        // Top:    rows 0-3=14, 4-5=12, 6=10, 7=8, 8=6, 9=4, 10-11=2 (neck)
        // Bottom: mirror — rows 12-13=2, 14=4, 15=6, 16=8, 17=10, 18-19=12, 20-23=14
        function getHourglassWidth(row) {
            const r = row < 12 ? row : 23 - row; // mirror around center
            if (r <= 3) return 14;
            if (r <= 5) return 12;
            if (r <= 7) return 10;
            if (r === 8) return 8;
            if (r === 9) return 6;
            if (r === 10) return 4;
            return 2; // rows 10-11 (and mirrored 12-13) form the neck
        }

        function isInHourglass(col, row) {
            const width = getHourglassWidth(row);
            const startCol = (COLS - width) / 2;
            return col >= startCol && col < startCol + width;
        }

        for (let row = 0; row < ROWS; row++) {
            const rowEl = document.createElement('div');
            rowEl.className = 'pixel-row';
            const width = getHourglassWidth(row);
            const startCol = (COLS - width) / 2;
            for (let col = startCol; col < startCol + width; col++) {
                const cell = document.createElement('div');
                cell.className = 'pixel-cell';
                cell.dataset.col = col;
                cell.dataset.row = row;

                function togglePixel(col, row) {
                    if (el('pixelGridWrap').classList.contains('disabled')) return;
                    const newState = !pixelState[row][col];
                    pixelState[row][col] = newState;
                    const cell = cells[row * COLS + col];
                    if (newState) {
                        const rgb = hexToRgb(window.pixelColorState);
                        setPixelCellVisual(cell, true, window.pixelColorState);
                        send(`pixelrgb ${col} ${row} ${rgb.r} ${rgb.g} ${rgb.b}`);
                    } else {
                        setPixelCellVisual(cell, false, window.pixelColorState);
                        send(`pixel ${col} ${row} 0`);
                    }
                }

                cell.addEventListener('mousedown', (e) => {
                    if (e.button !== 0) return; // Only left click
                    if (el('pixelGridWrap').classList.contains('disabled')) return;
                    isMouseDown = true;
                    togglePixel(col, row);
                });

                cell.addEventListener('mouseover', (e) => {
                    if (!isMouseDown) return;
                    if (el('pixelGridWrap').classList.contains('disabled')) return;
                    togglePixel(col, row);
                });

                cells[row * COLS + col] = cell;
                rowEl.appendChild(cell);
            }
            gridEl.appendChild(rowEl);
        }

        // Global mouse up event to stop dragging
        document.addEventListener('mouseup', () => {
            isMouseDown = false;
        });

        el('clearGridBtn').onclick = () => {
            for (let row = 0; row < ROWS; row++) {
                for (let col = 0; col < COLS; col++) {
                    if (isInHourglass(col, row) && pixelState[row][col]) {
                        pixelState[row][col] = false;
                        setPixelCellVisual(cells[row * COLS + col], false, window.pixelColorState);
                        send(`pixel ${col} ${row} 0`);
                    }
                }
            }
        };

        function setUIState(state) {
            uiConnectionState = state;
            const connected = state === 'connected';
            const waiting = state === 'waiting';
            // const ids = ['disconnectBtn', 'refreshBtn', 'setBtn', 'setGravBtn', 'resetBtn', 'grainSlider', 'grainInput', 'gravSlider', 'gravInput'];
            const ids = ['disconnectBtn', 'refreshBtn', 'saveBtn', 'grainSlider', 'grainInput', 'gravSlider', 'gravInput', 'latGravSlider', 'latGravInput', 'neckFlowSlider', 'neckFlowInput', 'sleepTimeoutMinutes', 'sleepTimeoutSeconds', 'timerDurationMinutes', 'timerDurationSeconds', 'pixelColorPicker', 'resetBtn', 'clearGridBtn', 'testSliderA', 'testInputA', 'testSliderB', 'testInputB', 'testToggle1', 'testToggle2', 'testToggle3', 'testToggle4', 'testToggle5', 'modeBtnTimer', 'modeBtnPomodoro', 'modeBtnSandbox', 'modeBtnPixelArt', 'modeColorTimer', 'modeColorPomodoro', 'modeColorSandbox', 'modeColorPixelArt', 'muteToggle', 'serialCmdInput', 'serialCmdSendBtn'];
            ids.forEach(id => {
                const node = el(id);
                if (node) node.disabled = !connected;
            });
            el('pixelGridWrap').classList.toggle('disabled', !connected);
            el('connectBtn').disabled = (connected || waiting);
            renderConnectionPill();
            if (connected) {
                if (batteryInterval) clearInterval(batteryInterval);
                batteryInterval = setInterval(() => { send('battery charge?'); send('battery voltage?'); }, 10000);
            }
            else {
                currentMenuStatusRaw = '-';
                currentOrientationRaw = '-';
                el('currentMenuStatus').textContent = '-';
                el('currentTimerRemaining').textContent = '-';
                el('currentOrientation').textContent = '-';
                el('currentFirmwareVersion').textContent = '-';
                el('currentGravValue').textContent = '-';
                el('currentLatGravValue').textContent = '-';
                el('currentNeckFlowValue').textContent = '-';
                if (batteryInterval) clearInterval(batteryInterval);
                if (setupTimeout) clearTimeout(setupTimeout);
                commandQueue = [];
                setupConfirmed = false;
            }
        }

        function triggerInitialRequests() {
            if (setupConfirmed) return;
            setupConfirmed = true;
            if (setupTimeout) clearTimeout(setupTimeout);
            setUIState('connected');
            log(t('initializing'));

            // Phase 1: Critical UI data (fast)
            send('max grains?');
            send('max colour?');
            send('grains?');
            send('gravity?');
            send('lateral gravity?');
            send('neck flow?');
            send('sleep timeout?');
            send('menu status?');
            send('timer remaining?');
            send('timer duration?');
            send('orientation?');
            send('firmware version?');
            send('sand colour red?');
            send('sand colour green?');
            send('sand colour blue?');
            send('mode colour 0?');
            send('mode colour 1?');
            send('mode colour 2?');
            send('mode colour 3?');
            send('mute?');
            requestPixelArt();

            // Phase 2: Optional data (deferred, non-blocking)
            setTimeout(() => {
                send('test slider a?');
                send('test slider b?');
                send('test toggle 1?');
                send('test toggle 2?');
                send('test toggle 3?');
                send('test toggle 4?');
                send('test toggle 5?');
                send('battery charge?');
                send('battery voltage?');
            }, 500);
        }

        function updateColorPickerFromState() {
            if (!window.colorState || userInteractingWithColorPicker) return;
            const colorPicker = el('colorPicker');
            if (!colorPicker) return;
            const r = sandToDisplay(window.colorState.r);
            const g = sandToDisplay(window.colorState.g);
            const b = sandToDisplay(window.colorState.b);
            const hex = '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0').toUpperCase()).join('');
            colorPicker.value = hex;
        }

        function updatePreview() {
            if (!window.colorState) window.colorState = { r: 0, g: 0, b: 0 };
            el('previewPill').style.background = `rgb(${sandToDisplay(window.colorState.r)}, ${sandToDisplay(window.colorState.g)}, ${sandToDisplay(window.colorState.b)})`;
        }

        function sandToDisplay(v) {
            const safeMax = Math.max(1, maxSandComponent);
            const clamped = Math.min(safeMax, Math.max(0, Number(v) || 0));
            return Math.round((clamped / safeMax) * 255);
        }

        function displayToSand(v) {
            const safeMax = Math.max(1, maxSandComponent);
            const clamped = Math.min(255, Math.max(0, Number(v) || 0));
            return Math.round((clamped / 255) * safeMax);
        }

        function hexToSandRgb(hex) {
            const rgb = hexToRgb(hex);
            return {
                r: displayToSand(rgb.r),
                g: displayToSand(rgb.g),
                b: displayToSand(rgb.b)
            };
        }

        function setModePickerFromSand(modeName, r, g, b) {
            const hex = '#' + [sandToDisplay(r), sandToDisplay(g), sandToDisplay(b)]
                .map(x => x.toString(16).padStart(2, '0').toUpperCase())
                .join('');
            const picker = el(modeColorId[modeName]);
            if (picker) picker.value = hex;
        }

        function setTestToggleState(idx, on) {
            const id = `testToggle${idx}`;
            const btn = el(id);
            btn.classList.toggle('active', on);
            btn.textContent = `Test Toggle ${idx}: ${on ? t('on') : t('off')}`;
        }

        function setMuteState(isOn) {
            muteEnabled = !!isOn;
            const btn = el('muteToggle');
            btn.classList.toggle('muted', !muteEnabled);
            el('muteToggleText').textContent = muteEnabled ? t('soundOn') : t('soundMuted');
        }

        function setActiveMode(modeName) {
            activeMode = modeName;
            modeOrder.forEach((name) => {
                const btn = el(modeButtonId[name]);
                if (btn) btn.classList.toggle('active', name === modeName);
            });
        }

        function applyModeColor(modeName, throttled = false) {
            const rgb = hexToSandRgb(el(modeColorId[modeName]).value);
            const modeIdx = modeCommandValue[modeName];
            window.colorState = rgb;
            updateColorPickerFromState();
            updatePreview();
            if (throttled) {
                sendModeColorComponent(modeIdx, rgb.r, rgb.g, rgb.b);
            } else {
                send(`mode colour ${modeIdx} ${rgb.r} ${rgb.g} ${rgb.b}`);
            }
        }

        function switchMode(modeName, sendToDevice = true) {
            setActiveMode(modeName);
            if (sendToDevice) {
                send(`set menu ${modeCommandValue[modeName]}`);
                if (modeName === 'PIXELART') {
                    requestPixelArt();
                }
            }
            const rgb = hexToSandRgb(el(modeColorId[modeName]).value);
            window.colorState = rgb;
            updateColorPickerFromState();
            updatePreview();
        }

        function clampTimerSeconds(totalSeconds) {
            return Math.max(0, Math.min(5970, Number(totalSeconds) || 0));
        }

        function formatMinutesSeconds(totalSeconds) {
            const safe = clampTimerSeconds(totalSeconds);
            const minutes = Math.floor(safe / 60);
            const seconds = safe % 60;
            return `${minutes}:${String(seconds).padStart(2, '0')}`;
        }

        function timerPartsToSeconds(minutes, seconds) {
            const m = Math.max(0, Math.floor(Number(minutes) || 0));
            const s = Math.max(0, Math.floor(Number(seconds) || 0));
            return clampTimerSeconds((m * 60) + s);
        }

        function syncTimerInputsFromSeconds(totalSeconds) {
            const safe = clampTimerSeconds(totalSeconds);
            const minutes = Math.floor(safe / 60);
            const seconds = safe % 60;
            el('timerDurationMinutes').value = minutes;
            el('timerDurationSeconds').value = seconds;
        }

        function getTimerSecondsFromInputs() {
            return timerPartsToSeconds(el('timerDurationMinutes').value, el('timerDurationSeconds').value);
        }

        function setTimerDurationFromInputs(immediate = false) {
            const totalSeconds = getTimerSecondsFromInputs();
            syncTimerInputsFromSeconds(totalSeconds);
            if (immediate) {
                send(`timer duration ${totalSeconds}`);
            } else {
                stimerDuration(totalSeconds);
            }
        }

        function clampSleepTimeoutSeconds(totalSeconds) {
            return Math.max(0, Math.min(3600, Number(totalSeconds) || 0));
        }

        function formatSleepTimeoutMinutesSeconds(totalSeconds) {
            const safe = clampSleepTimeoutSeconds(totalSeconds);
            const minutes = Math.floor(safe / 60);
            const seconds = safe % 60;
            return { minutes, seconds };
        }

        function sleepTimeoutPartsToSeconds(minutes, seconds) {
            const m = Math.max(0, Math.floor(Number(minutes) || 0));
            const s = Math.max(0, Math.floor(Number(seconds) || 0));
            return clampSleepTimeoutSeconds((m * 60) + s);
        }

        function syncSleepTimeoutInputsFromSeconds(totalSeconds) {
            const safe = clampSleepTimeoutSeconds(totalSeconds);
            const minutes = Math.floor(safe / 60);
            const seconds = safe % 60;
            el('sleepTimeoutMinutes').value = minutes;
            el('sleepTimeoutSeconds').value = seconds;
        }

        function getSleepTimeoutSecondsFromInputs() {
            return sleepTimeoutPartsToSeconds(el('sleepTimeoutMinutes').value, el('sleepTimeoutSeconds').value);
        }

        function setSleepTimeoutFromInputs(immediate = false) {
            const totalSeconds = getSleepTimeoutSecondsFromInputs();
            syncSleepTimeoutInputsFromSeconds(totalSeconds);
            if (immediate) {
                send(`sleep timeout ${totalSeconds}`);
            } else {
                sSleepTimeoutThrottle(totalSeconds);
            }
        }

        function applyWheelToSleepTimeoutInput(id, deltaSeconds) {
            el(id).addEventListener('wheel', (event) => {
                if (el(id).disabled) return;
                event.preventDefault();
                const direction = event.deltaY < 0 ? 1 : -1;
                const next = clampSleepTimeoutSeconds(getSleepTimeoutSecondsFromInputs() + (direction * deltaSeconds));
                syncSleepTimeoutInputsFromSeconds(next);
                sSleepTimeoutThrottle(next);
            }, { passive: false });
        }

        function applyWheelToTimerInput(id, deltaSeconds) {
            el(id).addEventListener('wheel', (event) => {
                if (el(id).disabled) return;
                event.preventDefault();
                const direction = event.deltaY < 0 ? 1 : -1;
                const next = clampTimerSeconds(getTimerSecondsFromInputs() + (direction * deltaSeconds));
                syncTimerInputsFromSeconds(next);
                stimerDuration(next);
            }, { passive: false });
        }

        function parseLine(line) {
            if (!setupConfirmed && line.toLowerCase().includes("setup complete")) { triggerInitialRequests(); }

            // Max Handlers
            const mmg = line.match(/max grains\s*[=:]\s*(\d+)/i);
            if (mmg) { el('maxLabel').textContent = mmg[1]; el('grainSlider').max = mmg[1]; el('grainInput').max = mmg[1]; }

            const mmc = line.match(/max colour\s*[=:]\s*(\d+)/i);
            if (mmc) {
                maxSandComponent = Number(mmc[1]) || maxSandComponent;
                updateColorPickerFromState();
                updatePreview();
            }

            // Grains and Gravity
            const mg = line.match(/grains\s*[=:]\s*(\d+)/i); if (mg) { el('currentValue').textContent = mg[1]; el('grainSlider').value = mg[1]; el('grainInput').value = mg[1]; }
            const mlat = line.match(/(?:^|\])\s*lateral gravity\s*[=:]\s*(-?\d+)/i); if (mlat) { el('currentLatGravValue').textContent = rawGravityToDisplay(mlat[1]); el('latGravSlider').value = mlat[1]; el('latGravInput').value = rawGravityToDisplay(mlat[1]); }
            const mneckflow = line.match(/neck flow\s*[=:]\s*(\d+)/i);
            if (mneckflow) {
                const neckSlider = el('neckFlowSlider');
                const neckInput = el('neckFlowInput');
                const percent = rawFlowToPercent(mneckflow[1]);
                if (neckSlider) neckSlider.value = percent;
                if (neckInput) neckInput.value = percent;
                el('currentNeckFlowValue').textContent = percent;
            }
            const msleep = line.match(/sleep timeout\s*[=:]\s*(\d+)/i);
            if (msleep) {
                syncSleepTimeoutInputsFromSeconds(msleep[1]);
            }
            const mv = line.match(/(?:^|\])\s*gravity\s*[=:]\s*(-?\d+)/i); if (mv) { el('currentGravValue').textContent = rawGravityToDisplay(mv[1]); el('gravSlider').value = mv[1]; el('gravInput').value = rawGravityToDisplay(mv[1]); }
            const mmenu = line.match(/menu status\s*[=:]\s*([A-Za-z_]+)/i);
            if (mmenu) {
                currentMenuStatusRaw = mmenu[1];
                el('currentMenuStatus').textContent = translateMenuStatus(currentMenuStatusRaw);
                const modeName = mmenu[1].toUpperCase();
                if (modeOrder.includes(modeName)) setActiveMode(modeName);
            }
            const mmodeColor = line.match(/mode colour\s+(\d)\s*[=:]\s*(\d+)\s+(\d+)\s+(\d+)/i);
            if (mmodeColor) {
                const idx = Number(mmodeColor[1]);
                const r = Number(mmodeColor[2]);
                const g = Number(mmodeColor[3]);
                const b = Number(mmodeColor[4]);
                if (idx >= 0 && idx < modeOrder.length) {
                    const modeName = modeOrder[idx];
                    setModePickerFromSand(modeName, r, g, b);
                    if (activeMode === modeName) {
                        window.colorState = { r, g, b };
                        updateColorPickerFromState();
                        updatePreview();
                    }
                }
            }
            const mtimer = line.match(/timer remaining\s*[=:]\s*(\d+)/i) || line.match(/timer\s*[=:]\s*(\d+)/i) || line.match(/timer:\s*(\d+)/i);
            if (mtimer) { el('currentTimerRemaining').textContent = formatMinutesSeconds(mtimer[1]); }
            const mtimerDuration = line.match(/timer duration\s*[=:]\s*(\d+)/i);
            if (mtimerDuration) {
                syncTimerInputsFromSeconds(mtimerDuration[1]);
            }
            const morient = line.match(/orientation\s*[=:]\s*(\d+)/i);
            if (morient) { currentOrientationRaw = morient[1]; el('currentOrientation').textContent = morient[1] === '1' ? t('up') : t('down'); }
            const mfw = line.match(/firmware version\s*[=:]\s*([^\r\n]+)/i);
            if (mfw) { el('currentFirmwareVersion').textContent = mfw[1].trim(); }

            // Test controls
            // const mtestA = line.match(/test slider a\s*[=:]\s*(-?\d+)/i); if (mtestA) { el('testSliderA').value = mtestA[1]; el('testInputA').value = mtestA[1]; }
            // const mtestB = line.match(/test slider b\s*[=:]\s*(-?\d+)/i); if (mtestB) { el('testSliderB').value = mtestB[1]; el('testInputB').value = mtestB[1]; }
            // const mtestToggle = line.match(/test toggle\s*(\d)\s*[=:]\s*(\d)/i);
            // if (mtestToggle) {
            //     const idx = Number(mtestToggle[1]);
            //     const on = Number(mtestToggle[2]) === 1;
            //     if (idx >= 1 && idx <= 5) setTestToggleState(idx, on);
            // }
            const mmute = line.match(/mute\s*[=:]\s*(\d+)/i);
            if (mmute) {
                setMuteState(Number(mmute[1]) === 0);
            }

            const mpixelBegin = line.match(/pixel\s*art begin/i);
            if (mpixelBegin) {
                clearPixelGridVisualState();
            }

            const mpixelRgb = line.match(/pixelrgb\s+x\s*[=:]\s*(\d+)\s+y\s*[=:]\s*(\d+)\s+r\s*[=:]\s*(\d+)\s+g\s*[=:]\s*(\d+)\s+b\s*[=:]\s*(\d+)/i);
            if (mpixelRgb) {
                const col = Number(mpixelRgb[1]);
                const row = Number(mpixelRgb[2]);
                const r = Number(mpixelRgb[3]);
                const g = Number(mpixelRgb[4]);
                const b = Number(mpixelRgb[5]);
                setPixelFromDevice(col, row, r, g, b);
            }

            const mpixelState = line.match(/pixel\s+x\s*[=:]\s*(\d+)\s+y\s*[=:]\s*(\d+)\s+state\s*[=:]\s*(\d+)/i);
            if (mpixelState) {
                const col = Number(mpixelState[1]);
                const row = Number(mpixelState[2]);
                const on = Number(mpixelState[3]) === 1;
                if (on) {
                    const rgb = hexToRgb(window.pixelColorState || '#FFD166');
                    setPixelFromDevice(col, row, rgb.r, rgb.g, rgb.b);
                } else {
                    setPixelFromDevice(col, row, 0, 0, 0);
                }
            }

            // Colour Parsers - handles "red=10", "red:10", "sand colour red=10", etc.
            const mr = line.match(/(?:sand colour )?red\s*[=:]\s*(\d+)/i);
            if (mr) {
                if (!window.colorState) window.colorState = { r: 0, g: 0, b: 0 };
                window.colorState.r = parseInt(mr[1]);
                updateColorPickerFromState();
                updatePreview();
            }
            const mgreen = line.match(/(?:sand colour )?green\s*[=:]\s*(\d+)/i);
            if (mgreen) {
                if (!window.colorState) window.colorState = { r: 0, g: 0, b: 0 };
                window.colorState.g = parseInt(mgreen[1]);
                updateColorPickerFromState();
                updatePreview();
            }
            const mb = line.match(/(?:sand colour )?blue\s*[=:]\s*(\d+)/i);
            if (mb) {
                if (!window.colorState) window.colorState = { r: 0, g: 0, b: 0 };
                window.colorState.b = parseInt(mb[1]);
                updateColorPickerFromState();
                updatePreview();
            }

            // Battery
            const mt = line.match(/battery voltage\s*[=:]\s*([\d.]+)/i); if (mt) el('battVolts').textContent = mt[1];
            // const mc = line.match(/battery charge\s*[=:]\s*([\d.]+)/i); if (mc) el('battPerc').textContent = mc[1];
        }

        async function connect() {
            try {
                port = await navigator.serial.requestPort();
                await port.open({ baudRate: 921600 });
                writer = port.writable.getWriter();
                reader = port.readable.getReader();
                keepReading = true; setupConfirmed = false;
                setUIState('waiting');

                // Request pixel matrix immediately after a successful port open.
                clearPixelGridVisualState();
                requestPixelArt();

                // Request data immediately (don't wait for "setup complete")
                setupTimeout = setTimeout(() => triggerInitialRequests(), 100);

                const decoder = new TextDecoder();
                let partialLine = '';
                while (keepReading) {
                    try {
                        const { value, done } = await reader.read();
                        if (done) break;
                        partialLine += decoder.decode(value, { stream: true });
                        const lines = partialLine.split('\n');
                        partialLine = lines.pop();
                        lines.forEach(l => { if (l.trim()) { log(l.trim()); parseLine(l.trim()); } });
                    } catch (e) { break; }
                }
            } catch (err) { log(`Error: ${err.message}`); disconnect(); }
        }

        async function disconnect() {
            keepReading = false;
            if (reader) { try { await reader.cancel(); reader.releaseLock(); } catch (e) { } reader = null; }
            if (writer) { try { writer.releaseLock(); } catch (e) { } writer = null; }
            if (port) { try { await port.close(); } catch (e) { } port = null; }
            setUIState('disconnected');
            log('Released.');
        }

        el('connectBtn').onclick = connect;
        el('disconnectBtn').onclick = disconnect;
        el('refreshBtn').onclick = () => {
            send('max grains?'); send('max colour?'); send('grains?'); send('gravity?');
            send('lateral gravity?');
            send('neck flow?');
            send('sleep timeout?');
            send('menu status?'); send('timer remaining?'); send('orientation?'); send('firmware version?');
            send('timer duration?');
            send('mode colour 0?'); send('mode colour 1?'); send('mode colour 2?'); send('mode colour 3?');
            send('mute?');
            requestPixelArt();
            send('test slider a?'); send('test slider b?');
            send('test toggle 1?'); send('test toggle 2?'); send('test toggle 3?'); send('test toggle 4?'); send('test toggle 5?');
            send('sand colour red?'); send('sand colour green?'); send('sand colour blue?');
            send('battery charge?'); send('battery voltage?');
        };

        function sendManualSerialCommand() {
            const input = el('serialCmdInput');
            const cmd = input.value.trim();
            if (!cmd) return;
            send(cmd);
            input.value = '';
        }

        el('serialCmdSendBtn').onclick = sendManualSerialCommand;
        el('serialCmdInput').addEventListener('keydown', (event) => {
            if (event.key === 'Enter') {
                event.preventDefault();
                sendManualSerialCommand();
            }
        });

        el('saveBtn').onclick = () => send('nvs');
        el('langToggle').onclick = toggleLanguage;
        el('settingsToggle').onclick = () => {
            const panel = el('settingsPanel');
            panel.hidden = !panel.hidden;
            el('settingsToggle').setAttribute('aria-expanded', String(!panel.hidden));
        };

        function throttle(f, l) { let t; return function () { if (!t) { f.apply(this, arguments); t = true; setTimeout(() => t = false, l); } } }

        const nameMap = { r: 'red', g: 'green', b: 'blue' };

        function hexToRgb(hex) {
            const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
            return result ? {
                r: parseInt(result[1], 16),
                g: parseInt(result[2], 16),
                b: parseInt(result[3], 16)
            } : { r: 0, g: 0, b: 0 };
        }

        const sendColorComponent = throttle((r, g, b) => {
            send(`sand red ${r}`);
            send(`sand green ${g}`);
            send(`sand blue ${b}`);
        }, 200);

        const sendModeColorComponent = throttle((modeIdx, r, g, b) => {
            send(`mode colour ${modeIdx} ${r} ${g} ${b}`);
        }, 200);

        const colorPicker = el('colorPicker');
        if (colorPicker) {
            colorPicker.addEventListener('input', (e) => {
                userInteractingWithColorPicker = true;
                const rgb = hexToSandRgb(e.target.value);
                window.colorState = rgb;
                updatePreview();
                sendColorComponent(rgb.r, rgb.g, rgb.b);
                if (activeMode && modeColorId[activeMode]) {
                    el(modeColorId[activeMode]).value = e.target.value;
                    sendModeColorComponent(modeCommandValue[activeMode], rgb.r, rgb.g, rgb.b);
                }
            });

            colorPicker.addEventListener('change', (e) => {
                const rgb = hexToSandRgb(e.target.value);
                window.colorState = rgb;
                updatePreview();
                send(`sand red ${rgb.r}`);
                send(`sand green ${rgb.g}`);
                send(`sand blue ${rgb.b}`);
                userInteractingWithColorPicker = false;
                if (activeMode && modeColorId[activeMode]) {
                    el(modeColorId[activeMode]).value = e.target.value;
                    send(`mode colour ${modeCommandValue[activeMode]} ${rgb.r} ${rgb.g} ${rgb.b}`);
                }
            });

            colorPicker.addEventListener('mouseout', () => {
                userInteractingWithColorPicker = false;
            });

            colorPicker.addEventListener('touchend', () => {
                userInteractingWithColorPicker = false;
            });
        }

        el('pixelColorPicker').addEventListener('input', (e) => {
            window.pixelColorState = e.target.value;
            updatePixelPreview();
        });

        modeOrder.forEach((modeName) => {
            const modeButton = el(modeButtonId[modeName]);
            const modeColor = el(modeColorId[modeName]);

            if (modeButton) {
                modeButton.addEventListener('click', () => {
                    switchMode(modeName, true);
                });
            }

            if (modeColor) {
                modeColor.addEventListener('input', () => {
                    if (activeMode === modeName) {
                        applyModeColor(modeName, true);
                    }
                });

                modeColor.addEventListener('change', () => {
                    if (activeMode === modeName) {
                        applyModeColor(modeName, false);
                    }
                });
            }
        });

        updatePixelPreview();

        // Reset NVS Button with Hold Detection
        let resetHoldStart = null;
        let resetHoldInterval = null;
        const HOLD_TIME = 5000; // 5 seconds in milliseconds

        el('resetBtn').addEventListener('mousedown', (e) => {
            if (el('resetBtn').disabled) return;
            resetHoldStart = Date.now();
            el('resetBtn').classList.add('holding');

            resetHoldInterval = setInterval(() => {
                if (!resetHoldStart) {
                    clearInterval(resetHoldInterval);
                    return;
                }
                const elapsed = Date.now() - resetHoldStart;
                const progress = Math.min(elapsed / HOLD_TIME, 1) * 100;
                el('holdIndicator').style.width = progress + '%';

                if (elapsed >= HOLD_TIME) {
                    clearInterval(resetHoldInterval);
                    resetHoldStart = null;
                    triggerNVSReset();
                }
            }, 30);
        });

        el('resetBtn').addEventListener('mouseup', () => {
            el('resetBtn').classList.remove('holding');
            el('holdIndicator').style.width = '0%';
            clearInterval(resetHoldInterval);
            resetHoldStart = null;
        });

        el('resetBtn').addEventListener('mouseleave', () => {
            el('resetBtn').classList.remove('holding');
            el('holdIndicator').style.width = '0%';
            clearInterval(resetHoldInterval);
            resetHoldStart = null;
        });

        el('resetBtn').addEventListener('touchstart', (e) => {
            if (el('resetBtn').disabled) return;
            resetHoldStart = Date.now();
            el('resetBtn').classList.add('holding');

            resetHoldInterval = setInterval(() => {
                if (!resetHoldStart) {
                    clearInterval(resetHoldInterval);
                    return;
                }
                const elapsed = Date.now() - resetHoldStart;
                const progress = Math.min(elapsed / HOLD_TIME, 1) * 100;
                el('holdIndicator').style.width = progress + '%';

                if (elapsed >= HOLD_TIME) {
                    clearInterval(resetHoldInterval);
                    resetHoldStart = null;
                    triggerNVSReset();
                }
            }, 30);
        });

        el('resetBtn').addEventListener('touchend', () => {
            el('resetBtn').classList.remove('holding');
            el('holdIndicator').style.width = '0%';
            clearInterval(resetHoldInterval);
            resetHoldStart = null;
        });

        async function triggerNVSReset() {
            el('resetBtn').classList.add('loading');
            el('resetBtn').disabled = true;
            log('>> nvs reset');
            await send('nvs reset');

            // Wait briefly to receive the response
            setTimeout(() => {
                el('resetBtn').classList.remove('loading');
                el('resetBtn').disabled = false;
            }, 1500);
        }

        const sg = throttle((v) => send(`grains ${v}`), 200);
        const sv = throttle((v) => send(`gravity ${v}`), 200);
        const slat = throttle((v) => send(`lateral gravity ${v}`), 200);
        const sneckFlow = throttle((v) => send(`neck flow ${v}`), 200);
        const stimerDuration = throttle((v) => send(`timer duration ${v}`), 200);
        const stestA = throttle((v) => send(`test slider a ${v}`), 120);
        const stestB = throttle((v) => send(`test slider b ${v}`), 120);
        el('grainSlider').oninput = (e) => { el('grainInput').value = e.target.value; sg(e.target.value); };
        el('grainSlider').onchange = (e) => send(`grains ${e.target.value}`);
        el('grainInput').onchange = (e) => { el('grainSlider').value = e.target.value; send(`grains ${e.target.value}`); };
        el('gravSlider').oninput = (e) => { const raw = Number(e.target.value) || 0; el('gravInput').value = rawGravityToDisplay(raw); el('currentGravValue').textContent = rawGravityToDisplay(raw); sv(raw); };
        el('gravSlider').onchange = (e) => send(`gravity ${e.target.value}`);
        el('gravInput').onchange = (e) => { const raw = gravityDisplayToRaw(e.target.value); el('gravSlider').value = raw; el('gravInput').value = rawGravityToDisplay(raw); el('currentGravValue').textContent = rawGravityToDisplay(raw); send(`gravity ${raw}`); };
        el('latGravSlider').oninput = (e) => { const raw = Number(e.target.value) || 0; el('latGravInput').value = rawGravityToDisplay(raw); el('currentLatGravValue').textContent = rawGravityToDisplay(raw); slat(raw); };
        el('latGravSlider').onchange = (e) => send(`lateral gravity ${e.target.value}`);
        el('latGravInput').onchange = (e) => { const raw = lateralGravityDisplayToRaw(e.target.value); el('latGravSlider').value = raw; el('latGravInput').value = rawGravityToDisplay(raw); el('currentLatGravValue').textContent = rawGravityToDisplay(raw); send(`lateral gravity ${raw}`); };
        el('neckFlowSlider').oninput = (e) => {
            const percent = Math.max(1, Math.min(100, Number(e.target.value) || 1));
            const raw = percentToRawFlow(percent);
            el('neckFlowInput').value = percent;
            el('currentNeckFlowValue').textContent = percent;
            sneckFlow(raw);
        };
        el('neckFlowSlider').onchange = (e) => {
            const percent = Math.max(1, Math.min(100, Number(e.target.value) || 1));
            send(`neck flow ${percentToRawFlow(percent)}`);
        };
        el('neckFlowInput').onchange = (e) => {
            const raw = percentToRawFlow(e.target.value);
            const percent = rawFlowToPercent(raw);
            el('neckFlowInput').value = percent;
            el('neckFlowSlider').value = percent;
            el('currentNeckFlowValue').textContent = percent;
            send(`neck flow ${raw}`);
        };
        el('neckFlowInput').addEventListener('wheel', (e) => {
            if (el('neckFlowInput').disabled) return;
            e.preventDefault();
            const dir = e.deltaY < 0 ? 1 : -1;
            const nextPercent = Math.max(1, Math.min(100, (Number(el('neckFlowInput').value) || 1) + dir));
            const raw = percentToRawFlow(nextPercent);
            el('neckFlowInput').value = nextPercent;
            el('neckFlowSlider').value = nextPercent;
            el('currentNeckFlowValue').textContent = nextPercent;
            sneckFlow(raw);
        }, { passive: false });
        const sSleepTimeoutThrottle = throttle((v) => send(`sleep timeout ${v}`), 200);
        if (el('sleepTimeoutMinutes') && el('sleepTimeoutSeconds')) {
            el('sleepTimeoutMinutes').addEventListener('input', () => setSleepTimeoutFromInputs(false));
            el('sleepTimeoutMinutes').addEventListener('change', () => setSleepTimeoutFromInputs(true));
            el('sleepTimeoutSeconds').addEventListener('input', () => setSleepTimeoutFromInputs(false));
            el('sleepTimeoutSeconds').addEventListener('change', () => setSleepTimeoutFromInputs(true));
            applyWheelToSleepTimeoutInput('sleepTimeoutMinutes', 60);
            applyWheelToSleepTimeoutInput('sleepTimeoutSeconds', 1);
        }
        el('timerDurationMinutes').addEventListener('input', () => setTimerDurationFromInputs(false));
        el('timerDurationMinutes').addEventListener('change', () => setTimerDurationFromInputs(true));
        el('timerDurationSeconds').addEventListener('input', () => setTimerDurationFromInputs(false));
        el('timerDurationSeconds').addEventListener('change', () => setTimerDurationFromInputs(true));
        applyWheelToTimerInput('timerDurationMinutes', 60);
        applyWheelToTimerInput('timerDurationSeconds', 1);

        // el('testSliderA').oninput = (e) => { el('testInputA').value = e.target.value; stestA(e.target.value); };
        // el('testSliderA').onchange = (e) => send(`test slider a ${e.target.value}`);
        // el('testInputA').onchange = (e) => { el('testSliderA').value = e.target.value; send(`test slider a ${e.target.value}`); };
        // el('testSliderB').oninput = (e) => { el('testInputB').value = e.target.value; stestB(e.target.value); };
        // el('testSliderB').onchange = (e) => send(`test slider b ${e.target.value}`);
        // el('testInputB').onchange = (e) => { el('testSliderB').value = e.target.value; send(`test slider b ${e.target.value}`); };

        // ['testToggle1', 'testToggle2', 'testToggle3', 'testToggle4', 'testToggle5'].forEach((id, idx) => {
        //     el(id).addEventListener('click', () => {
        //         const toggleIndex = idx + 1;
        //         const on = !el(id).classList.contains('active');
        //         setTestToggleState(toggleIndex, on);
        //         send(`test toggle ${toggleIndex} ${on ? 1 : 0}`);
        //     });
        // });

        el('muteToggle').addEventListener('click', () => {
            const newMute = muteEnabled ? 1 : 0;
            send(`mute ${newMute}`);
            setMuteState(newMute === 0);
        });

        setLanguage(currentLanguage);
        setMuteState(true);

        configureInstallManifest();
    </script>
</body>

</html><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2026/05/PXL_20260508_185245909.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2026/05/PXL_20260508_185319276.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2026/05/PXL_20260508_185723125.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2026/05/PXL_20260508_185609727.jpg" class="kg-image"></figure><hr><h2 id="instructions">Instructions</h2><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2026/05/photo_2026-05-22_12-33-50---Copy.jpg" class="kg-image"></figure>]]></content:encoded></item><item><title><![CDATA[HackerOne bug pin]]></title><description><![CDATA[<p>We were commissioned by HackerOne to make a pin for Black Hat USA 2023~ </p><p>The pin came in 2 parts, the logo was pre-soldered and only needed a battery to light up! </p><p>The Bug is a SMD soldering kit.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/09/6-LEDs-front-edit.jpg" class="kg-image"></figure><figure class="kg-card kg-embed-card"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Can you hack our soldering challenge <a href="https://twitter.com/hashtag/BHUSA?src=hash&amp;ref_src=twsrc%5Etfw">#BHUSA</a>? 🎩<br><br>Today is your last</p></blockquote></figure>]]></description><link>https://tilde.industries/hackerone-bug/</link><guid isPermaLink="false">64e51c41a6ddb102946adbd7</guid><dc:creator><![CDATA[Guru-san]]></dc:creator><pubDate>Tue, 12 Sep 2023 20:37:54 GMT</pubDate><content:encoded><![CDATA[<p>We were commissioned by HackerOne to make a pin for Black Hat USA 2023~ </p><p>The pin came in 2 parts, the logo was pre-soldered and only needed a battery to light up! </p><p>The Bug is a SMD soldering kit.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/09/6-LEDs-front-edit.jpg" class="kg-image"></figure><figure class="kg-card kg-embed-card"><blockquote class="twitter-tweet"><p lang="en" dir="ltr">Can you hack our soldering challenge <a href="https://twitter.com/hashtag/BHUSA?src=hash&amp;ref_src=twsrc%5Etfw">#BHUSA</a>? 🎩<br><br>Today is your last chance to put your your soldering skills to the test at booth 2640! <br><br>Book a meeting with our team or take a look at the HackerOne platform for your chance to put together your Black Hat exclusive LED pin. 🧑‍🏭 <a href="https://t.co/xI1snp3D24">pic.twitter.com/xI1snp3D24</a></p>&mdash; HackerOne (@Hacker0x01) <a href="https://twitter.com/Hacker0x01/status/1689713381706518528?ref_src=twsrc%5Etfw">August 10, 2023</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<figcaption>The workshop at Black Hat 2023~</figcaption></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/08/tildexHackerOne.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/09/back-edit.jpg" class="kg-image"></figure><p>Instructions:</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/08/Hackerone_A4_Final.jpg" class="kg-image"></figure><p></p>]]></content:encoded></item><item><title><![CDATA[tilde~mate]]></title><description><![CDATA[<p></p>]]></description><link>https://tilde.industries/tilde-mate/</link><guid isPermaLink="false">6439d40fa6ddb102946adbb7</guid><dc:creator><![CDATA[Guru-san]]></dc:creator><pubDate>Fri, 14 Apr 2023 22:30:50 GMT</pubDate><content:encoded><![CDATA[<p></p>]]></content:encoded></item><item><title><![CDATA[MCH2022 Butterfly SAO]]></title><description><![CDATA[<p>The MCH2022 Butterfly as a badge addon~</p><figure class="kg-card kg-embed-card"><iframe width="200" height="113" src="https://www.youtube.com/embed/FXDnII-1AvY?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="Butterfly add-on ID demo for MCH2022 badge"></iframe></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/05/PXL_20230324_173802171.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/image.png" class="kg-image"></figure><h3 id="soldering-instruction">Soldering instruction</h3><p>Here is a reference of the back for soldering. All the part are straightforward apart from the LEDs, just watch out for the EEPROM dot!</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/PXL_20220730_205202960.jpg" class="kg-image"></figure><p>Orientation for the LEDs:</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/Butterfly-Instruction-LEDs-1.png" class="kg-image"></figure><h3 id="soldering-tips">Soldering tips</h3><p>Start by soldering the corners of the LEDs highlighted in</p>]]></description><link>https://tilde.industries/mch2022-butterfly/</link><guid isPermaLink="false">62e585b2a6ddb102946adb4f</guid><dc:creator><![CDATA[Guru-san]]></dc:creator><pubDate>Sat, 30 Jul 2022 20:48:54 GMT</pubDate><content:encoded><![CDATA[<p>The MCH2022 Butterfly as a badge addon~</p><figure class="kg-card kg-embed-card"><iframe width="200" height="113" src="https://www.youtube.com/embed/FXDnII-1AvY?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="Butterfly add-on ID demo for MCH2022 badge"></iframe></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/05/PXL_20230324_173802171.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/image.png" class="kg-image"></figure><h3 id="soldering-instruction">Soldering instruction</h3><p>Here is a reference of the back for soldering. All the part are straightforward apart from the LEDs, just watch out for the EEPROM dot!</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/PXL_20220730_205202960.jpg" class="kg-image"></figure><p>Orientation for the LEDs:</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/Butterfly-Instruction-LEDs-1.png" class="kg-image"></figure><h3 id="soldering-tips">Soldering tips</h3><p>Start by soldering the corners of the LEDs highlighted in yellow, they are easier and it makes soldering the rest easier. </p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/Butterfly-Instruction-LEDs-to-solder-first-1.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/PXL_20220730_200840307.jpg" class="kg-image"><figcaption>1) Put a blob of solder down</figcaption></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/PXL_20220730_201554034.jpg" class="kg-image"><figcaption>2) Solder the LED reverse mount with the right orientation</figcaption></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/PXL_20220730_202311686.jpg" class="kg-image"><figcaption>3) Solder the rest of the LED pads carefully!</figcaption></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/07/PXL_20220730_213055553-1.jpg" class="kg-image"><figcaption>⚠️ The lack of soldermask makes it trickier than it looks... Here are some location that bridge easily!</figcaption></figure><h3 id="badge-app">Badge App</h3><p>First update the firmware by pressing "OS update" in the main menu  </p><p>Second go to "Hatchery &gt; ESP32 native binaries &gt; Hardware &gt; MCH2022 Butterfly [~]" which will install the app  </p><p>Third return to the home screen, go to "Apps" and launch "MCH2022 Butterfly [~]"!</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/05/PXL_20230324_173823429.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2023/05/PXL_20230324_173658253.jpg" class="kg-image"></figure>]]></content:encoded></item><item><title><![CDATA[NEKO'S EXPERIMENT PAGE DO NOT TOUCH]]></title><description><![CDATA[<figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/01/EBAY_120DEG5MP.jpg" class="kg-image"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/01/EBAY_5MP120DEnc.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/01/Drawing_TailoredForCameras.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/02/EBAY_CamerawithCable1.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/02/Drawing_3Lasers_.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/02/Collision.png" class="kg-image"></figure>]]></description><link>https://tilde.industries/nekos-experiment-page-do-not-touch/</link><guid isPermaLink="false">61f1444c1eae40044448c581</guid><dc:creator><![CDATA[Neko]]></dc:creator><pubDate>Wed, 26 Jan 2022 12:57:34 GMT</pubDate><content:encoded><![CDATA[<figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/01/EBAY_120DEG5MP.jpg" class="kg-image"></figure><p></p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/01/EBAY_5MP120DEnc.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/01/Drawing_TailoredForCameras.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/02/EBAY_CamerawithCable1.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/02/Drawing_3Lasers_.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/02/Collision.png" class="kg-image"></figure>]]></content:encoded></item><item><title><![CDATA[I can't believe it's going to work]]></title><description><![CDATA[<p>You might have met him before, when WiFi wasn't working at home on a train journey in the middle of the countryside.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/image.png" class="kg-image"></figure><h3 id="a-pin-of-your-shirt-or-sao-for-your-electronic-badge"><strong>A pin of your shirt or <strong>SAO for your electronic badge</strong></strong></h3><p>At the flip of a switch, his eye will happily blink different colors~</p><p>The friendly dinosaur can</p>]]></description><link>https://tilde.industries/i-cant-believe-its-going-to-work/</link><guid isPermaLink="false">617873791eae40044448c54a</guid><dc:creator><![CDATA[Guru-san]]></dc:creator><pubDate>Tue, 26 Oct 2021 21:36:15 GMT</pubDate><content:encoded><![CDATA[<p>You might have met him before, when WiFi wasn't working at home on a train journey in the middle of the countryside.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/image.png" class="kg-image"></figure><h3 id="a-pin-of-your-shirt-or-sao-for-your-electronic-badge"><strong>A pin of your shirt or <strong>SAO for your electronic badge</strong></strong></h3><p>At the flip of a switch, his eye will happily blink different colors~</p><p>The friendly dinosaur can be built in two ways, a pin powered by a CR2032 battery or as an electronic badge addon. </p><p>Parts to build both versions are included in the kit.</p><p>Assembly instruction and parts list found bellow.</p><a href="https://www.tindie.com/products/17114/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a>
<iframe width="560" height="315" src="https://www.youtube.com/embed/UPQ5wcmupIQ?controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; encrypted-media" allowfullscreen></iframe><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_132023.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200609_224306.jpg" class="kg-image"></figure><h2 id="pin-mode-">Pin mode:</h2><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/AssemblyInstrukt_PinKex.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_135947-1.jpg" class="kg-image"></figure><h2 id="badge-addon-mode-">Badge addon mode:</h2><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/AssemblyInstrukt_SAO_Kex.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_132303-1.jpg" class="kg-image"></figure><h2 id="parts-list-">Parts list:</h2><head>
<style>
table, th, td {
  border-collapse: collapse;
}
th, td {
  padding: 1px;
}
th {
  text-align: left;
}
table, tr, th, td {
	text-align: left;
}
</style>
</head>
<table style="width:75%">
  <tr>
    <th>QTY</th>
    <th>Part</th>
    <th>Reference</th>
  </tr>
  <tr>
    <td>1</td>
    <td>RGB blinking LED</td>
    <td>0805</td>
  </tr>
  <tr>
    <td>1</td>
    <td>Resistor</td>
    <td>0805 ~1KΩ</td>
  </tr>
  <tr>
    <td>1</td>
    <td>Battery holder</td>
    <td>Keystone 3002</td>
  </tr>
    <tr>
    <td>1</td>
    <td>Switch</td>
    <td>SPDT PCM12</td>
  </tr>
  
    <tr>
    <td>1</td>
    <td>SAO header</td>
    <td>2x2 SMD 2.54mm pin header</td>
</tr>
	
    <tr>
    <td>1</td>
    <td>Butterfly Clasp Pin</td>
    <td></td>
  </tr>
</table>]]></content:encoded></item><item><title><![CDATA[Jack-o'-Lantern]]></title><description><![CDATA[<p>A kit for a jack-O'-lantern ornament, the flickering yellow LEDs emulate the light of a candle.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/PXL_20211020_214438401_edit.jpg" class="kg-image"></figure><p>The level of soldering is beginner to intermediate, the lantern can be powered via CR2032 coin cell or USB C. The parts are half through hole, half SMD and all included!</p><h3></h3><a href="https://www.tindie.com/products/tilde/jack-o-lantern/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a><figure class="kg-card kg-embed-card"><iframe width="200" height="150" src="https://www.youtube.com/embed/mzS2SSFCYuU?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/PXL_20211020_214233787_edit.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/PXL_20211020_215619448_edit.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/PXL_20211020_213242895night_edit.jpg" class="kg-image"></figure><h3 id="instructions-">Instructions:</h3><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-1.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-2.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-3.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-4-1.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-5-1.png" class="kg-image"></figure><h3 id="part-list">Part list</h3>]]></description><link>https://tilde.industries/jackolantern/</link><guid isPermaLink="false">61709fe21eae40044448c533</guid><dc:creator><![CDATA[Guru-san]]></dc:creator><pubDate>Wed, 20 Oct 2021 23:15:51 GMT</pubDate><content:encoded><![CDATA[<p>A kit for a jack-O'-lantern ornament, the flickering yellow LEDs emulate the light of a candle.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/PXL_20211020_214438401_edit.jpg" class="kg-image"></figure><p>The level of soldering is beginner to intermediate, the lantern can be powered via CR2032 coin cell or USB C. The parts are half through hole, half SMD and all included!</p><h3></h3><a href="https://www.tindie.com/products/tilde/jack-o-lantern/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a><figure class="kg-card kg-embed-card"><iframe width="200" height="150" src="https://www.youtube.com/embed/mzS2SSFCYuU?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/PXL_20211020_214233787_edit.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/PXL_20211020_215619448_edit.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/10/PXL_20211020_213242895night_edit.jpg" class="kg-image"></figure><h3 id="instructions-">Instructions:</h3><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-1.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-2.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-3.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-4-1.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/12/Step-5-1.png" class="kg-image"></figure><h3 id="part-list">Part list</h3><ul><li>1x Front PCB of the selected colour</li><li>1x Back PCB</li><li>4x Flickering yellow LEDs</li><li>2x Yellow LEDs</li><li>9x Resistors</li><li>1x Switch</li><li>1x USB C connector</li><li>1x Battery holder</li><li>3x Spacers</li><li>1x Diffuser</li></ul>]]></content:encoded></item><item><title><![CDATA[Tilde.Café Pin Set]]></title><description><![CDATA[<p>A set of 3 coffee bean pins organised neatly into one panel - just break them out* and enjoy! The minimalist design is meant to be fitting for any accessory or garment.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/09/PXL_20201212_192052233-1.jpg" class="kg-image"></figure><a href="https://www.tindie.com/products/tilde/coffee-beans-pin-set/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/09/PXL_20201212_190418407.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/09/PXL_20201212_190820118.jpg" class="kg-image"></figure>]]></description><link>https://tilde.industries/tilde-cafe-pin-set/</link><guid isPermaLink="false">614a6f3b1eae40044448c522</guid><dc:creator><![CDATA[Guru-san]]></dc:creator><pubDate>Wed, 06 Oct 2021 19:07:00 GMT</pubDate><content:encoded><![CDATA[<p>A set of 3 coffee bean pins organised neatly into one panel - just break them out* and enjoy! The minimalist design is meant to be fitting for any accessory or garment.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/09/PXL_20201212_192052233-1.jpg" class="kg-image"></figure><a href="https://www.tindie.com/products/tilde/coffee-beans-pin-set/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/09/PXL_20201212_190418407.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/09/PXL_20201212_190820118.jpg" class="kg-image"></figure>]]></content:encoded></item><item><title><![CDATA[The Little Prince pin set]]></title><description><![CDATA[<p>A set of 2 pins featuring the Little Prince and his friend the Fox, happily shining together.</p><p>Keep one, give the other to your loved ones.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201222_163956869_YELLOW.jpg" class="kg-image"></figure><h3 id="a-pair-of-complimentary-pins-for-your-shirt-or-bag"><strong><strong>A pair of complimentary pins for your shirt or bag</strong></strong></h3><p>At the flip of a switch, the stars will light up~</p><p>No assembly required,</p>]]></description><link>https://tilde.industries/the-little-prince-pin-set/</link><guid isPermaLink="false">5fe53e6f1eae40044448c4c1</guid><dc:creator><![CDATA[Neko]]></dc:creator><pubDate>Fri, 25 Dec 2020 01:22:00 GMT</pubDate><content:encoded><![CDATA[<p>A set of 2 pins featuring the Little Prince and his friend the Fox, happily shining together.</p><p>Keep one, give the other to your loved ones.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201222_163956869_YELLOW.jpg" class="kg-image"></figure><h3 id="a-pair-of-complimentary-pins-for-your-shirt-or-bag"><strong><strong>A pair of complimentary pins for your shirt or bag</strong></strong></h3><p>At the flip of a switch, the stars will light up~</p><p>No assembly required, just snap both pins from the panel and insert a CR2032 battery.</p><a href="https://www.tindie.com/products/22291/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201224_133556533_2_edit.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201224_144019320_edit.jpg" class="kg-image"></figure><figure class="kg-card kg-embed-card"><iframe width="200" height="113" src="https://www.youtube.com/embed/LNZjTJlG86k?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></figure><h3 id="parts-list-">Parts list:</h3><head>
<style>
table, th, td {
  border-collapse: collapse;
}
th, td {
  padding: 1px;
}
th {
  text-align: left;
}
table, tr, th, td {
	text-align: left;
}
</style>
</head>
<table style="width:75%">
  <tr>
    <th>QTY</th>
    <th>Part</th>
  </tr>
  <tr>
    <td>1</td>
    <td>panel of two pins</td>
  </tr>
</table>]]></content:encoded></item><item><title><![CDATA[Joule Thief Cat]]></title><description><![CDATA[<p>An adorable but mischievous cat who feeds on electricity, using every bit of energy left in discharged batteries.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201222_160744245_edit--2-.jpg" class="kg-image"></figure><h3 id="an-energy-harvesting-friend">An energy harvesting friend</h3><p>Insert your discharged AA battery from your remote controller, multimeter, or other appliances, to give it a second life and light up the eyes of the Cat! But</p>]]></description><link>https://tilde.industries/joulethiefcat/</link><guid isPermaLink="false">5f84a9031eae40044448c45d</guid><dc:creator><![CDATA[opeRaptor]]></dc:creator><pubDate>Mon, 12 Oct 2020 19:06:23 GMT</pubDate><content:encoded><![CDATA[<p>An adorable but mischievous cat who feeds on electricity, using every bit of energy left in discharged batteries.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201222_160744245_edit--2-.jpg" class="kg-image"></figure><h3 id="an-energy-harvesting-friend">An energy harvesting friend</h3><p>Insert your discharged AA battery from your remote controller, multimeter, or other appliances, to give it a second life and light up the eyes of the Cat! But first you must help him get his parts back in place!</p><p>The Joule Thief Cat is a beginner-friendly soldering kit. All the parts are through-hole, and are included.</p><p><strong>Find the assembly instructions below!</strong></p><a href="https://www.tindie.com/products/22272/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20200916_231000758_EDIT.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201222_163144801_edit--2-.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201223_215913157_edit.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/12/PXL_20201224_135911218_edit.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/01/EiEsluwXYAAHR6-.jpeg" class="kg-image"></figure><h3 id="instructions-">Instructions:</h3><p>↓日本語の説明は下にあります↓</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/01/Comic_001.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2021/01/JT-Kit-Instructions-BETA.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/08/JTKJPCover.png" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/08/JTKJP.png" class="kg-image"></figure><h3 id="parts-list-">Parts list:</h3><head>
<style>
table, th, td {
  border-collapse: collapse;
}
th, td {
  padding: 1px;
}
th {
  text-align: left;
}
table, tr, th, td {
	text-align: left;
}
</style>
</head>
<table style="width:75%">
  <tr>
    <th>QTY</th>
    <th>Part</th>
    <th>Reference (all parts are through-hole)</th>
  </tr>
  <tr>
    <td>2</td>
    <td>Yellow LEDs</td>
    <td>Flat</td>
  </tr>
  <tr>
    <td>1</td>
    <td>Transistor</td>
    <td>NPN</td>
  </tr>
  <tr>
    <td>1</td>
    <td>Capacitor</td>
    <td>10nF</td>
  </tr>
    <tr>
    <td>1</td>
    <td>Resistor</td>
    <td>360 ohms</td>
  </tr>
  
    <tr>
    <td>1</td>
    <td>Ferrite</td>
</tr>
	
    <tr>
    <td>2</td>
    <td>Wires</td>
    <td>solid core ~ 24AWG 20cm</td>
  </tr>
 
	
    <tr>
    <td>1</td>
    <td>Battery holder</td>
    <td>single AA battery holder</td>
  </tr>
</table>]]></content:encoded></item><item><title><![CDATA[CYBER SCARAB]]></title><description><![CDATA[<p><a href="http://tilde.industries/hhcyber-scarab">Originally created as a companion to Bastet</a>, the <a href="https://hackerhotel.nl/">HackerHotel2020</a> badge, our CYBER SCARAB holds 2 shiny blinky jewels said to contain immense power.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_195202.jpg" class="kg-image"></figure><h3 id="a-pin-of-your-shirt-or-sao-for-your-electronic-badge"><strong>A pin of your shirt or <strong>SAO for your electronic badge</strong></strong></h3><p>The CYBER SCARAB, powered with a CR2032 coincell battery can be assembled as a pin to</p>]]></description><link>https://tilde.industries/cyber-scarab/</link><guid isPermaLink="false">5ee0bd0d1eae40044448c409</guid><dc:creator><![CDATA[opeRaptor]]></dc:creator><pubDate>Wed, 10 Jun 2020 11:21:03 GMT</pubDate><content:encoded><![CDATA[<p><a href="http://tilde.industries/hhcyber-scarab">Originally created as a companion to Bastet</a>, the <a href="https://hackerhotel.nl/">HackerHotel2020</a> badge, our CYBER SCARAB holds 2 shiny blinky jewels said to contain immense power.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_195202.jpg" class="kg-image"></figure><h3 id="a-pin-of-your-shirt-or-sao-for-your-electronic-badge"><strong>A pin of your shirt or <strong>SAO for your electronic badge</strong></strong></h3><p>The CYBER SCARAB, powered with a CR2032 coincell battery can be assembled as a pin to wear on your clothes or as a badge addon! Parts to build both versions are included in the kit.</p><p>Assembly instructions and parts list below.</p><a href="https://www.tindie.com/products/20421/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a><figure class="kg-card kg-embed-card"><iframe width="480" height="270" src="https://www.youtube.com/embed/LSqKrak603c?feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_164744.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2022/11/IMG_20221111_190059_760_Edit-1.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200609_225122--1-.jpg" class="kg-image"></figure><h2 id="pin-mode-">Pin mode:</h2><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/AssemblyInstrukt_PinScarab.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_171123.jpg" class="kg-image"></figure><h2 id="badge-addon-mode-">Badge addon mode:</h2><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/AssemblyInstrukt_SAOScarab.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_163952.jpg" class="kg-image"></figure><h2 id="parts-list-">Parts list:</h2><head>
<style>
table, th, td {
  border-collapse: collapse;
}
th, td {
  padding: 1px;
}
th {
  text-align: left;
}
table, tr, th, td {
	text-align: left;
}
</style>
</head>
<table style="width:75%">
  <tr>
    <th>QTY</th>
    <th>Part</th>
    <th>Reference</th>
  </tr>
  <tr>
    <td>2</td>
    <td>RGB blinking LED</td>
    <td>0805</td>
  </tr>
  <tr>
    <td>2</td>
    <td>Resistor</td>
    <td>0805 ~1KΩ</td>
  </tr>
  <tr>
    <td>1</td>
    <td>Battery holder</td>
    <td>Keystone 3002</td>
  </tr>
    <tr>
    <td>1</td>
    <td>Switch</td>
    <td>SPDT PCM12</td>
  </tr>
  
    <tr>
    <td>1</td>
    <td>SAO header</td>
    <td>2x2 SMD 2.54mm pin header</td>
 </tr>
	
    <tr>
    <td>1</td>
    <td>Butterfly Clasp Pin</td>
    <td></td>
  </tr>
</table>]]></content:encoded></item><item><title><![CDATA[Tyrannopixelus Rex - CL_002]]></title><description><![CDATA[<p>You might have met him before, when WiFi wasn't working at home on a train journey in the middle of the countryside.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_134605.jpg" class="kg-image"></figure><h3 id="a-pin-of-your-shirt-or-sao-for-your-electronic-badge"><strong>A pin of your shirt or <strong>SAO for your electronic badge</strong></strong></h3><p>At the flip of a switch, his eye will happily blink different colors~</p><p>The friendly dinosaur can</p>]]></description><link>https://tilde.industries/tyrannopixelus-rex/</link><guid isPermaLink="false">5edcc9851eae40044448c3cb</guid><dc:creator><![CDATA[opeRaptor]]></dc:creator><pubDate>Sun, 07 Jun 2020 13:40:38 GMT</pubDate><content:encoded><![CDATA[<p>You might have met him before, when WiFi wasn't working at home on a train journey in the middle of the countryside.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_134605.jpg" class="kg-image"></figure><h3 id="a-pin-of-your-shirt-or-sao-for-your-electronic-badge"><strong>A pin of your shirt or <strong>SAO for your electronic badge</strong></strong></h3><p>At the flip of a switch, his eye will happily blink different colors~</p><p>The friendly dinosaur can be built in two ways, a pin powered by a CR2032 battery or as an electronic badge addon. </p><p>Parts to build both versions are included in the kit.</p><p>Assembly instruction and parts list found bellow.</p><a href="https://www.tindie.com/products/17114/?ref=offsite_badges&utm_source=sellers_tilde&utm_medium=badges&utm_campaign=badge_medium"><img src="https://d2ss6ovg47m0r5.cloudfront.net/badges/tindie-larges.png" alt="I sell on Tindie" width="300" height="150"></a>
<figure class="kg-card kg-embed-card"><iframe width="200" height="113" src="https://www.youtube.com/embed/UPQ5wcmupIQ?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_132023.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200609_224306.jpg" class="kg-image"></figure><h2 id="pin-mode-">Pin mode:</h2><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/AssemblyInstrukt_PinKex.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_135947-1.jpg" class="kg-image"></figure><h2 id="badge-addon-mode-">Badge addon mode:</h2><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/AssemblyInstrukt_SAO_Kex.jpg" class="kg-image"></figure><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/06/IMG_20200607_132303-1.jpg" class="kg-image"></figure><h2 id="parts-list-">Parts list:</h2><head>
<style>
table, th, td {
  border-collapse: collapse;
}
th, td {
  padding: 1px;
}
th {
  text-align: left;
}
table, tr, th, td {
	text-align: left;
}
</style>
</head>
<table style="width:75%">
  <tr>
    <th>QTY</th>
    <th>Part</th>
    <th>Reference</th>
  </tr>
  <tr>
    <td>1</td>
    <td>RGB blinking LED</td>
    <td>0805</td>
  </tr>
  <tr>
    <td>1</td>
    <td>Resistor</td>
    <td>0805 ~1KΩ</td>
  </tr>
  <tr>
    <td>1</td>
    <td>Battery holder</td>
    <td>Keystone 3002</td>
  </tr>
    <tr>
    <td>1</td>
    <td>Switch</td>
    <td>SPDT PCM12</td>
  </tr>
  
    <tr>
    <td>1</td>
    <td>SAO header</td>
    <td>2x2 SMD 2.54mm pin header</td>
</tr>
	
    <tr>
    <td>1</td>
    <td>Butterfly Clasp Pin</td>
    <td></td>
  </tr>
</table>]]></content:encoded></item><item><title><![CDATA[Nixie Notifier]]></title><description><![CDATA[<p>Large Z568M nixie tube driven by a <a href="https://tilde.industries/nixie-badge-add-on/">Nixie Badge Add-on</a> and a ESP8266.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/04/photo_2020-04-03_23-51-18.jpg" class="kg-image"></figure><p>A python script counts the number of unread Telegram messages and sends an HTTP GET to the Nixie Notifier with the number desired.<br><br>The Nixie Notifier runs a webserver on the ESP8266 and receives requests through HTTP</p>]]></description><link>https://tilde.industries/nixie-notifier/</link><guid isPermaLink="false">5e8ee1b21eae40044448c381</guid><dc:creator><![CDATA[opeRaptor]]></dc:creator><pubDate>Thu, 09 Apr 2020 09:24:05 GMT</pubDate><content:encoded><![CDATA[<p>Large Z568M nixie tube driven by a <a href="https://tilde.industries/nixie-badge-add-on/">Nixie Badge Add-on</a> and a ESP8266.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/04/photo_2020-04-03_23-51-18.jpg" class="kg-image"></figure><p>A python script counts the number of unread Telegram messages and sends an HTTP GET to the Nixie Notifier with the number desired.<br><br>The Nixie Notifier runs a webserver on the ESP8266 and receives requests through HTTP from anywhere on the LAN. The Nixie Badge Add-on boost 5V to 180V and via its I2C interface, takes care of the high voltage level shifting.</p><figure class="kg-card kg-image-card"><img src="https://tilde.industries/content/images/2020/04/photo_2020-04-04_00-55-17.jpg" class="kg-image"></figure><p>Alternating between two numbers makes it possible to count beyond 9:</p><figure class="kg-card kg-embed-card"><iframe width="480" height="270" src="https://www.youtube.com/embed/3buAOeDXnkA?feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></figure>]]></content:encoded></item><item><title><![CDATA[Cancelled]]></title><description><![CDATA[<p>Cancelled</p>]]></description><link>https://tilde.industries/cancel/</link><guid isPermaLink="false">5d6eece58b089573a819f3f8</guid><dc:creator><![CDATA[opeRaptor]]></dc:creator><pubDate>Tue, 03 Sep 2019 22:45:28 GMT</pubDate><content:encoded><![CDATA[<p>Cancelled</p>]]></content:encoded></item><item><title><![CDATA[Success!]]></title><description><![CDATA[<p></p>]]></description><link>https://tilde.industries/success/</link><guid isPermaLink="false">5d6eecc28b089573a819f3f5</guid><dc:creator><![CDATA[opeRaptor]]></dc:creator><pubDate>Tue, 03 Sep 2019 22:44:33 GMT</pubDate><content:encoded><![CDATA[<p></p>]]></content:encoded></item></channel></rss>