openvibers/openvibe.bot Commits

The openvibe.bot home shows real robots and models: the simulated rover and listed robots with their online state, every model it drives, how each connects, the first kit's parts, the API, robotics jobs and the questions people ask (#144)

OP OpenVibers committed 22 days ago · 6e54705a12 · 7 files, +492 −46

Changes

ddiff --git a/README.md b/README.md
iindex 69090d2..3471542 100644
---- a/README.md
++++ b/README.md
@@@ -13,7 +13,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
--- The front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
++- The front page, `GET /` (`server/web/home.js`, `public/home.css`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator, routines, webhooks and the agent switch), and data sections rendered on the server, each left out when it has nothing to show: numbers from the catalogue, the simulated rover and the listed robots with their online state, every catalogue model with its connection kind and controls, the connection kinds with their models, the first kit's parts list, robotics jobs from OpenVibe.Search (`server/web/network-reads.js`: OpenVibe.Work's documents, read anonymously with `BOT_SEARCH_URL`, cached 15 minutes) and the questions people ask first (JSON-LD WebSite, WebApplication, ItemList and FAQPage). It has its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
ddiff --git a/public/home.css b/public/home.css
nnew file mode 100644
iindex 0000000..3d5a89d
---- /dev/null
++++ b/public/home.css
@@@ -0,0 +1,95 @@
++/* OpenVibe.Bot's front page (server/web/home.js): the data sections around the shared showcase ones. Colours come from
++ the Frame's theme tokens, so the page follows the person's theme, light or dark. */
++.hm { padding-bottom: 32px; --hm-accent-text: color-mix(in srgb, var(--accent-light, #60a5fa) 45%, var(--text-primary, #e6edf7)); }
++/* accent-light is drawn for dark themes; text in it is mixed toward the text colour so it reads on light ones too. */
++.hm .sc-eyebrow { color: var(--hm-accent-text); }
++.hm a { text-underline-offset: 2px; }
++.hm-head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px 16px; flex-wrap: wrap; }
++.hm-head h2 { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; margin: 0 0 6px; }
++.hm-more { font-weight: 650; font-size: 14.5px; color: var(--hm-accent-text); white-space: nowrap; }
++.hm-sec ul { list-style: none; margin: 0; padding: 0; }
++
++/* The hero's terminal: always dark, like a terminal. */
++.hm-term { border-radius: 16px; background: #0b1220; color: #e6edf7; border: 1px solid rgba(148, 163, 184, .25); box-shadow: var(--shadow-lg, 0 18px 50px rgba(0, 0, 0, .25)); overflow: hidden; }
++.hm-term-top { display: flex; align-items: center; gap: 6px; margin: 0; padding: 10px 14px; background: rgba(148, 163, 184, .1); font-size: 12.5px; }
++.hm-term-top span { width: 10px; height: 10px; border-radius: 50%; background: #f87171; }
++.hm-term-top span:nth-child(2) { background: #fbbf24; } .hm-term-top span:nth-child(3) { background: #34d399; }
++.hm-term-top b { margin-left: 8px; font-weight: 600; color: #cbd5e1; }
++.hm-term pre { margin: 0; padding: 16px 16px 6px; font: 600 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
++.hm-prompt { color: #34d399; user-select: none; }
++.hm-term-steps { margin: 6px 0 0; padding: 0 16px 0 36px; font-size: 14px; line-height: 1.6; color: #cbd5e1; }
++.hm-term-steps a, .hm-term-foot a { color: #93c5fd; }
++.hm-term-foot { margin: 10px 0 0; padding: 10px 16px 14px; border-top: 1px solid rgba(148, 163, 184, .18); font-size: 13.5px; color: #cbd5e1; }
++
++/* Numbers from the catalogue and the directory. */
++.hm-stats-sec { margin-top: 40px; }
++.hm-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(128px, 100%), 1fr)); }
++.hm-stats a { display: grid; gap: 2px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; transition: border-color .15s; }
++.hm-stats a:hover, .hm-stats a:focus-visible { border-color: var(--accent, #3b82f6); }
++.hm-stats b { font-size: 30px; line-height: 1.1; letter-spacing: -.02em; }
++.hm-stats span { font-size: 13.5px; color: var(--text-secondary, #96a7c2); }
++
++/* Cards: robots, models, build pages. */
++.hm-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)); }
++.hm-card { display: flex; flex-direction: column; min-width: 0; border-radius: 16px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); transition: border-color .15s; }
++.hm-card:hover, .hm-card:focus-within { border-color: var(--accent, #3b82f6); }
++.hm-card-feature { background: linear-gradient(160deg, color-mix(in srgb, var(--accent, #3b82f6) 14%, var(--bg-secondary, #101828)), var(--bg-secondary, #101828) 70%); }
++.hm-card-link { flex: 1; display: grid; align-content: start; gap: 6px; padding: 16px 16px 12px; color: inherit; text-decoration: none; }
++.hm-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
++.hm-card-head b { font-size: 15.5px; overflow-wrap: anywhere; }
++.hm-card-text { font-size: 14px; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
++.hm-card-meta { font-size: 13px; color: var(--text-secondary, #96a7c2); }
++.hm-go { margin-top: 4px; font-size: 14px; font-weight: 700; color: var(--hm-accent-text); }
++.hm-grid-tight { grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }
++.hm-grid-tight .hm-card-link { padding-bottom: 16px; }
++
++.hm-state { flex: none; display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 700; color: var(--text-secondary, #96a7c2); }
++.hm-dot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; }
++.hm-state[data-state="online"] { color: color-mix(in srgb, var(--success, #22c55e) 45%, var(--text-primary, #e6edf7)); }
++.hm-state[data-state="online"] .hm-dot { background: var(--success, #22c55e); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success, #22c55e) 25%, transparent); }
++
++.hm-badge { flex: none; font-size: 11.5px; font-weight: 700; letter-spacing: .03em; padding: 2px 8px; border-radius: 999px; border: 1px solid currentColor; white-space: nowrap; }
++.hm-k-browser { color: color-mix(in srgb, #8b5cf6 45%, var(--text-primary, #e6edf7)); }
++.hm-k-phone { color: color-mix(in srgb, #ec4899 45%, var(--text-primary, #e6edf7)); }
++.hm-k-onboard { color: color-mix(in srgb, #22c55e 45%, var(--text-primary, #e6edf7)); }
++.hm-k-bridge { color: color-mix(in srgb, #f59e0b 45%, var(--text-primary, #e6edf7)); }
++.hm-k-server { color: color-mix(in srgb, #0ea5e9 45%, var(--text-primary, #e6edf7)); }
++.hm-sec .hm-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 16px 16px; }
++.hm-chips li { font-size: 12.5px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border-light, var(--border, #2c3d5c)); color: var(--text-secondary, #96a7c2); }
++
++/* How robots connect, as categories. */
++.hm-kinds { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }
++.hm-kinds > li { padding: 16px; border-radius: 16px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); min-width: 0; }
++.hm-kind-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 0 8px; }
++.hm-kind-n { font-size: 13px; color: var(--text-secondary, #96a7c2); }
++.hm-kind-text { margin: 0 0 8px; font-size: 14px; line-height: 1.5; }
++.hm-kind-models { margin: 0; font-size: 13.5px; line-height: 1.6; }
++.hm-kind-models a { color: var(--hm-accent-text); }
++
++/* The first kit's parts list. */
++.hm-kit { padding: 18px; border-radius: 16px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); }
++.hm-sec .hm-parts { columns: 2 260px; column-gap: 28px; }
++.hm-parts li { break-inside: avoid; display: grid; grid-template-columns: 2.4em minmax(0, 1fr); gap: 4px; padding: 7px 0; border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08)); font-size: 14.5px; }
++.hm-parts li b { color: var(--hm-accent-text); }
++.hm-parts small { display: block; color: var(--text-secondary, #96a7c2); font-size: 13px; }
++.hm-kit-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 0; }
++
++/* Compact rows: jobs. */
++.hm-rows { display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr)); }
++.hm-rows a { display: grid; gap: 2px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; min-width: 0; }
++.hm-rows a:hover, .hm-rows a:focus-visible { border-color: var(--accent, #3b82f6); }
++.hm-rows b { font-size: 14.5px; overflow-wrap: anywhere; }
++.hm-rows span { font-size: 13px; color: var(--text-secondary, #96a7c2); }
++
++/* Questions, answered on the page. */
++.hm-faq { display: grid; gap: 12px 28px; grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); }
++.hm-qa { padding: 4px 0 14px; border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08)); }
++.hm-qa h3 { margin: 0 0 6px; font-size: 16px; }
++.hm-qa p { margin: 0; font-size: 14.5px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }
++
++/* The shared steps and code grids ask for 280–320 px; a 320 px phone has 272. */
++.hm .sc-steps { grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
++.hm .sc-code { grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); }
++@media (max-width: 520px) { .hm .sc-code pre { white-space: pre-wrap; overflow-wrap: anywhere; } .hm-term pre { font-size: 13px; } }
++@media (max-width: 420px) { .sc-hero, .sc-sec, .sc-cta { padding-left: 16px; padding-right: 16px; } }
++@media (prefers-reduced-motion: reduce) { .hm-card, .hm-stats a, .hm-rows a { transition: none; } }
ddiff --git a/server/web/home.js b/server/web/home.js
iindex 20f6261..ccd953a 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -2,26 +2,31 @@
/**
* The front page of openvibe.bot (GET /), served by Bot itself in place of the frozen OpenVibe.Sites page:
-- * the OpenVibe Frame (openvibe-shared/shell: navbar, footer, theme) around openvibe-shared/showcase sections.
++ * the OpenVibe Frame (openvibe-shared/shell: navbar, footer, theme) around openvibe-shared/showcase sections and
++ * the page's own data sections (public/home.css).
*
-- * The copy says only what works today (README "Owns" and "Devices"): pairing with one pasted command, the
-- * panel's controls, the command gate and the latched e-stop (and a stop held on the robot itself), people and the queue,
-- * the embed under a Live stream (watch, and Drive for anyone allowed), the simulator, and the kits Bot has drivers for. Live video in the panel waits for OpenRestream to play a
-- * WHIP-published stream back, and the page says so rather than showing it.
++ * Every data section is real and server-rendered, and one with nothing to show is left out:
++ * - the catalogue (robot_profiles): the models Bot drives, how each connects, the numbers in the stat row;
++ * - the robots people list (robots.listed) with their online state, after the simulated rover anyone can drive;
++ * - the first kit's parts list (server/kits);
++ * - robotics jobs from OpenVibe.Search (OpenVibe.Work's documents), read anonymously and cached (web/network-reads.js).
++ * The copy says only what works today (README "Owns" and "Devices").
*
-- * renderHome({ config, signedIn }) the whole document
-- * HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
++ * renderHome({ config, signedIn, models, robots, kit, jobs }) the whole document
++ * HOME_CSP its Content-Security-Policy
*/
const shell = require('openvibe-shared/shell');
const showcase = require('openvibe-shared/showcase');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const appIcon = require('openvibe-shared/app-icon');
++const seo = require('openvibe-shared/seo');
const SITE_NAME = 'OpenVibe.Bot';
const NETWORK_URL = 'https://openvibe.network';
const DESCRIPTION = 'An open control panel for robots: pair a robot with one pasted command and drive it from any browser, '
+ 'with roles, limits and an emergency stop on every command.';
++const esc = showcase.esc;
/** The panel's own pages keep `default-src 'self'`; the front page also runs the OpenVibe Frame. */
const HOME_CSP = [
@@@ -40,26 +45,126 @@ const HOME_CSP = [
"form-action 'self' https://openvibe.network",
].join('; ');
--function sections({ signedIn }) {
++// How a robot reaches Bot, in the order a newcomer meets them (directory.CONNECT has the long words).
++const KINDS = [
++ ['browser', 'Simulated', 'No hardware at all: it runs inside Bot and in your browser.'],
++ ['phone', 'A phone', 'The phone\'s browser is the robot: scan a code, allow the camera, keep the page open.'],
++ ['onboard', 'On-board', 'The OpenVibe Node runs on the robot\'s own computer, such as a Raspberry Pi, and drives its hardware.'],
++ ['bridge', 'Bridge', 'The Node runs on a computer next to the robot and joins the robot\'s own link. Nothing on the robot changes.'],
++ ['server', 'Server-side', 'Nothing to install: Bot connects to the device itself, like an ONVIF camera.'],
++];
++// directory.js requires this module for framePage, so its words are read when a page renders, not at load.
++const capWord = (c) => require('./directory').CAPABILITY_WORDS[c] || c;
++const POLICY_WORDS = { private: 'Its owner and the people they add drive it', invite: 'People the owner invites drive it', queue: 'Anyone signed in can take a turn' };
++const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
++const link = (href, text, cls = '') => `<a${cls ? ` class="${cls}"` : ''} href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}>${esc(text)}</a>`;
++const sec = (id, title, lede, inner, more = '') => `<section class="sc-sec hm-sec" id="${id}" aria-labelledby="${id}-h"><div class="hm-head"><h2 id="${id}-h">${esc(title)}</h2>${more}</div>${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}${inner}</section>`;
++
++/** The model's first sentence, cut near `max` characters at a word. */
++function short(text, max = 120) {
++ const first = String(text || '').split(/(?<=[.:;])\s/)[0].replace(/[:;]$/, '.');
++ if (first.length <= max) return /[.!?]$/.test(first) || !first ? first : `${first}.`;
++ return `${first.slice(0, first.lastIndexOf(' ', max)).replace(/[,(]$/, '')}…`;
++}
++
++function hero({ signedIn, config }) {
++ const install = `curl -fsSL ${config.installer.scriptUrl} | sh`;
++ const aside = `<div class="hm-term" role="group" aria-label="Get a robot online">
++<p class="hm-term-top"><span></span><span></span><span></span><b>On the robot's computer</b></p>
++<pre tabindex="0"><code><span class="hm-prompt">$</span> ${esc(install)}</code></pre>
++<ol class="hm-term-steps"><li>It finds the kit and shows a short code.</li><li>Approve the code at ${link('/link', 'openvibe.bot/link')}.</li><li>Open the robot's panel and drive.</li></ol>
++<p class="hm-term-foot">Raspberry Pi OS or any Linux. No robot yet? ${link('/try', 'Drive the simulated rover')}.</p></div>`;
return showcase.hero({
eyebrow: 'OpenVibe.Bot · alpha',
title: 'Drive your robot', accent: 'from any browser.',
-- lede: 'An open control panel for robots. Add a robot, pair it with one pasted command, and drive it with a touch joystick, '
++ lede: 'An open control panel for robots. Pair a robot with one pasted command, then drive it with a touch joystick, '
+ 'the keyboard or a gamepad. Every command passes one gate, with roles, limits and an emergency stop that only you clear.',
actions: signedIn
? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'Try the panel', href: '/try' }, { label: 'How it works', href: '#how' }]
: [{ label: 'Try it now, no account', href: '/try', primary: true }, { label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Frobots' }, { label: 'How it works', href: '#how' }],
note: 'Alpha. A paired robot sends its camera over WebRTC to OpenRestream and the panel plays it live; the first supported kit is the Adeept 4WD Smart Car for Raspberry Pi.',
-- }) + showcase.steps({
++ aside: { html: aside },
++ });
++}
++
++/** Numbers that come straight from the catalogue and the directory. */
++function stats({ models, robots }) {
++ const official = models.filter((m) => !m.community);
++ const community = models.length - official.length;
++ const kinds = new Set(models.map((m) => m.connect)).size;
++ const caps = new Set(models.flatMap((m) => m.capabilities)).size;
++ const online = robots.filter((r) => r.online).length;
++ const items = [
++ [models.length, models.length === 1 ? 'robot model' : 'robot models', '/models'],
++ [kinds, kinds === 1 ? 'way to connect' : 'ways to connect', '#connect'],
++ [caps, caps === 1 ? 'kind of control' : 'kinds of control', '#models'],
++ community ? [community, community === 1 ? 'community profile' : 'community profiles', '/models#g-community'] : null,
++ online ? [online, online === 1 ? 'listed robot online' : 'listed robots online', '#now'] : null,
++ ].filter(Boolean);
++ if (!models.length) return '';
++ return `<section class="sc-sec hm-stats-sec" aria-label="OpenVibe.Bot in numbers"><ul class="hm-stats">${items.map(([n, label, href]) => `<li><a href="${esc(href)}"><b>${esc(n)}</b><span>${esc(label)}</span></a></li>`).join('')}</ul></section>`;
++}
++
++const stateDot = (online) => `<span class="hm-state" data-state="${online ? 'online' : 'offline'}"><span class="hm-dot" aria-hidden="true"></span>${online ? 'Online' : 'Offline'}</span>`;
++
++/** The simulated rover anyone can drive, then the robots people list (online first). */
++function onlineNow({ robots }) {
++ const sim = `<li class="hm-card hm-card-feature"><a class="hm-card-link" href="/try"><span class="hm-card-head"><b>Simulated rover</b>${stateDot(true)}</span>`
++ + '<span class="hm-card-text">A rover in a small arena with a camera, a range finder, line sensors, headlights and a horn. It runs in your browser.</span>'
++ + '<span class="hm-card-meta">Anyone can drive it, no account needed</span><span class="hm-go">Drive it now</span></a></li>';
++ const cards = robots.slice(0, 8).map((r) => `<li class="hm-card"><a class="hm-card-link" href="/r/${esc(r.id)}"><span class="hm-card-head"><b>${esc(r.name)}</b>${stateDot(r.online)}</span>`
++ + `<span class="hm-card-text">${esc(r.model_name)}${r.owner && r.owner.username ? ` · @${esc(r.owner.username)}` : ''}</span>`
++ + `<span class="hm-card-meta">${esc(POLICY_WORDS[r.access_policy] || '')}</span><span class="hm-go">${r.online ? 'Watch it live' : 'Its page'}</span></a></li>`).join('');
++ const lede = robots.length
++ ? 'Robots their owners list in the directory, and the simulated rover. Watch any of them live; some take visitors at the controls in turns.'
++ : 'The simulated rover is always on. Robots their owners list in the directory show here with their live panels.';
++ return sec('now', 'Drive one now', lede, `<ul class="hm-grid">${sim}${cards}</ul>`, link('/robots', 'The directory', 'hm-more'));
++}
++
++function modelsSection({ models }) {
++ if (!models.length) return '';
++ const card = (m) => `<li class="hm-card"><a class="hm-card-link" href="/models/${esc(m.id)}"><span class="hm-card-head"><b>${esc(m.name)}</b><span class="hm-badge hm-k-${esc(m.connect)}">${esc((KINDS.find((k) => k[0] === m.connect) || [, m.connect])[1])}</span></span>`
++ + `<span class="hm-card-text">${esc(short(m.description))}</span></a>`
++ + `${m.capabilities.length ? `<ul class="hm-chips">${m.capabilities.slice(0, 5).map((c) => `<li>${esc(capWord(c))}</li>`).join('')}${m.capabilities.length > 5 ? `<li>+${m.capabilities.length - 5}</li>` : ''}</ul>` : ''}</li>`;
++ return sec('models', 'Robots it drives today', `${plural(models.length, 'model has', 'models have')} a profile, and the panel draws each one's controls from it: a joystick for a base that drives, sliders for a pan-tilt head, buttons for lights and a horn.`,
++ `<ul class="hm-grid">${models.map(card).join('')}</ul>`, link('/models', 'All models', 'hm-more'));
++}
++
++/** The connection kinds as categories, each with the models that use it. */
++function connectSection({ models }) {
++ const groups = KINDS.map(([k, label, text]) => ({ k, label, text, ms: models.filter((m) => m.connect === k) })).filter((g) => g.ms.length);
++ if (!groups.length) return '';
++ const n = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight'][groups.length] || String(groups.length);
++ return sec('connect', `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, 'However a robot reaches OpenVibe, it gets the same panel, the same gate and the same emergency stop.',
++ `<ul class="hm-kinds">${groups.map((g) => `<li><p class="hm-kind-head"><span class="hm-badge hm-k-${esc(g.k)}">${esc(g.label)}</span><span class="hm-kind-n">${esc(plural(g.ms.length, 'model', 'models'))}</span></p>`
++ + `<p class="hm-kind-text">${esc(g.text)}</p><p class="hm-kind-models">${g.ms.map((m) => link(`/models/${m.id}`, m.name)).join(', ')}</p></li>`).join('')}</ul>`);
++}
++
++function startSection({ config }) {
++ const base = config.baseUrl;
++ return showcase.steps({
id: 'how',
-- title: 'Online in three steps',
-- lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router.',
++ title: 'Online in a minute',
++ lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router and no stock control server left exposed.',
items: [
-- { title: 'Add a robot', text: 'Name it and pick its profile. A simulated rover works straight away, before any hardware exists, and the one at /try needs no account at all.' },
-- { title: 'Pair it', text: 'Paste one command on the robot\'s computer. It installs the OpenVibe device agent with a one-time code; the robot\'s credential is stored hashed and can be revoked any time.' },
-- { title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included.' },
++ { title: 'Run one command on the robot', text: 'Paste the installer line on its Raspberry Pi or Linux computer. It installs the OpenVibe Node, finds the kit and shows a short code.' },
++ { title: 'Approve it', text: 'Sign in at openvibe.bot/link and enter the code. The robot\'s credential is stored hashed, and you can revoke it any time.' },
++ { title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included. Share it, list it, or bind it to your OpenVibe.Live channel.' },
++ ],
++ }) + showcase.code({
++ id: 'api',
++ title: 'The same robot from your own code',
++ lede: 'A REST API and the openvibe-sdk client, held to the same roles, limits and emergency stop as the panel. Profiles and kits are public reads.',
++ samples: [
++ { label: 'JavaScript (openvibe-sdk)', lang: 'js', code: "import { createClient } from 'openvibe-sdk/core';\nimport { createBotClient } from 'openvibe-sdk/bot';\n\nconst client = createClient({ credentials: 'include' });\nconst bot = createBotClient(client);\nconst { robots } = await bot.robots.list();\n\n// Half speed ahead for 300 ms, same gate as the joystick\nawait bot.robots.command(robots[0].id, {\n kind: 'drive', value: { throttle: 0.5, steer: 0 }, ms: 300,\n});" },
++ { label: 'Public reads (curl)', lang: 'sh', code: `# Every robot model and its controls\ncurl ${base}/api/v1/profiles\n\n# The kits, with their parts lists\ncurl ${base}/api/v1/kits` },
],
-- }) + showcase.features({
++ });
++}
++
++function panelSection() {
++ return showcase.features({
++ id: 'panel',
title: 'What the panel does',
items: [
{ icon: 'ov:games', title: 'Real controls', text: 'A touch joystick, the keyboard or a gamepad, plus buttons and sliders, laid out from the robot\'s profile, with a latency meter.' },
@@@ -69,21 +174,91 @@ function sections({ signedIn }) {
{ icon: 'ov:live', title: 'Show it on your channel', text: 'Bind the robot to your OpenVibe.Live channel and its panel shows under your stream: viewers follow its state, and anyone allowed to drive presses Drive and takes the controls right there.' },
{ icon: 'ov:code', title: 'An API with the same gate', text: 'A REST API for your own apps, held to the same roles, limits and e-stop as the panel.' },
],
-- }) + showcase.features({
-- title: 'Robots it drives today',
-- // One card per robot profile Bot ships (server/profiles): what each one drives, said as its profile says it.
-- lede: 'Each has a profile the panel draws its controls from. ONVIF PTZ cameras and a generic relay, for anything a local script can switch, come with them.',
++ });
++}
++
++function automationSection() {
++ return showcase.features({
++ id: 'automate',
++ title: 'Let it run without you',
++ lede: 'Routines, webhooks and your own agent all go through the gate a person does: the same limits, the same e-stop, the same audit.',
items: [
-- { icon: 'ov:check', title: 'Adeept 4WD Smart Car', text: 'The Raspberry Pi kit, with its parts list and build guide; a mecanum-wheel build has its own profile. The first robot on OpenVibe.Bot.' },
-- { icon: 'ov:check', title: 'ROS 2 robots', text: 'A TurtleBot 3 or 4, a Jackal or a home-built base on cmd_vel, mecanum and omni bases too: drive, camera, the distance ahead and the battery, through the OpenVibe Node.' },
-- { icon: 'ov:check', title: 'LOOI', text: 'The tracked desk robot with a phone for a face: drive and head tilt over Bluetooth from a computer running the OpenVibe Node and the community LOOI driver.' },
-- { icon: 'ov:check', title: 'Cozmo', text: 'Drive, head and lift, speech, its face display, lights and camera, through a computer next to it that joins the robot\'s Wi-Fi.' },
-- { icon: 'ov:check', title: 'A phone', text: 'Any phone as a robot\'s eyes, voice and face, from its browser with nothing to install: camera, speech, a full-screen face and its torch.' },
-- { icon: 'ov:check', title: 'A simulated rover', text: 'A rover in a small arena with a camera, a range finder, line sensors, headlights and a horn. Drive it at /try in your browser, with no account and no hardware.' },
++ { icon: 'ov:history', title: 'Routines', text: 'Plain step lines such as forward 2, wait 1, say Hello, run on demand, every few minutes, daily, or each time the robot comes online.', href: '/docs/automation' },
++ { icon: 'ov:bell', title: 'Signed webhooks', text: 'Online, offline, e-stop and refused commands sent to your own https address, signed like OpenVibe.Events deliveries.', href: '/docs/automation' },
++ { icon: 'ov:actor', title: 'Your agent drives', text: 'Turn on Your agent and your OpenVibe.Actor agent can see your robots and run steps on that one, as you and nobody else.', href: 'https://openvibe.actor/' },
++ { icon: 'ov:plug', title: 'Your computer, for your agents', text: 'The Node\'s computer plugin lets your agent work on your own machine, at a level you set on that machine: observe, ask, trusted or full.', href: '/docs/computers' },
],
-- }) + `<section class="sc-sec" id="build"><h2>Build for OpenVibe.Bot</h2>
--<p class="sc-lede">Write a driver for a robot, or describe one with a profile and let the panel render it. The driver and profile spec, with a validator and the simulated rover, lives on the build pages.</p>
--<p class="sc-actions"><a class="sc-btn sc-primary" href="/docs">Build for OpenVibe.Bot</a> <a class="sc-btn" href="/docs/drivers">Write a driver</a> <a class="sc-btn" href="/docs/profiles">Robot profiles</a></p></section>`;
++ });
++}
++
++function kitSection({ kit, models }) {
++ if (!kit || !kit.parts || !kit.parts.length) return '';
++ const model = models.find((m) => m.id === kit.profile_id);
++ const parts = kit.parts.map((p) => `<li><b>${esc(p.qty || 1)}×</b><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`).join('');
++ return sec('kit', `The first kit: ${kit.name}`, 'What to get for the robot OpenVibe.Bot was built around. The OpenVibe Node drives its Robot HAT directly, publishes the Pi camera and stops the motors the moment commands stop.',
++ `<div class="hm-kit"><ul class="hm-parts">${parts}</ul><p class="hm-kit-actions">${link(kit.build_guide_url, 'Adeept\'s build guide', 'sc-btn')}${model ? ` ${link(`/models/${model.id}`, 'Its model page', 'sc-btn')}` : ''} ${link('https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/hardware-adeept.md', 'Setup guide for the Node', 'sc-btn')}</p></div>`);
++}
++
++function docsSection() {
++ const docs = [
++ ['/docs/drivers', 'Write a driver', 'A separate program in any language that speaks JSON lines with the OpenVibe Node: every message and the safety rules.'],
++ ['/docs/profiles', 'Robot profiles', 'The JSON that says what a robot can do and which controls its panel shows, with a validator.'],
++ ['/docs/automation', 'Routines, webhooks and your agent', 'Steps on a schedule, signed webhooks for a robot\'s events, and the switch that lets your agent drive.'],
++ ['/docs/computers', 'Your computer for your agents', 'The Node\'s computer plugin: levels set on the computer, approvals there, and the API.'],
++ ['/robots/submit', 'Submit a robot', 'Describe your robot with a profile and point at its driver. Once reviewed, anyone can add one.'],
++ ['https://github.com/OpenVibers/OpenVibe.Bot', 'The source', 'OpenVibe.Bot and the OpenVibe Node are open source under the AGPL-3.0.'],
++ ];
++ return sec('build', 'Build for OpenVibe.Bot', 'Write a driver for a robot, or describe one with a profile and let the panel render it.',
++ `<ul class="hm-grid hm-grid-tight">${docs.map(([href, title, text]) => `<li class="hm-card"><a class="hm-card-link" href="${esc(href)}"${/^https?:/.test(href) ? ' rel="noopener"' : ''}><span class="hm-card-head"><b>${esc(title)}</b></span><span class="hm-card-text">${esc(text)}</span></a></li>`).join('')}</ul>`,
++ link('/docs', 'All build pages', 'hm-more'));
++}
++
++function jobsSection({ jobs }) {
++ if (!jobs || !jobs.length) return '';
++ const row = (j) => `<li><a href="${esc(j.url)}" rel="noopener"><b>${esc(j.title)}</b><span>${esc([j.location, j.remote ? 'Remote' : '', j.type].filter(Boolean).join(' · '))}</span></a></li>`;
++ return sec('jobs', 'Robotics jobs on OpenVibe.Work', 'Openings in robotics from the public job boards OpenVibe.Work gathers.',
++ `<ul class="hm-rows">${jobs.map(row).join('')}</ul>`, link('https://openvibe.work/jobs?q=robotics', 'More jobs', 'hm-more'));
++}
++
++/** Questions people ask first, answered from what Bot does today; the model list comes from the catalogue. */
++function faqItems({ models }) {
++ const names = models.filter((m) => !m.community).map((m) => m.name);
++ const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0] || '';
++ return [
++ { q: 'Do I need to open ports on my router?', a: 'No. The robot\'s computer connects out to OpenVibe, so it works behind an ordinary home or mobile connection. The Adeept kit\'s own control server, with its fixed password, is never run.' },
++ { q: 'Can I try it without a robot?', a: 'Yes. The simulated rover at openvibe.bot/try runs in your browser with no account and no hardware, through the same panel a paired robot gets.' },
++ list ? { q: 'Which robots work today?', a: `${list}. Each has a profile the panel draws its controls from, and anything a local script can switch works through the relay robot.` } : null,
++ { q: 'How does it keep the robot safe?', a: 'Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget. The emergency stop is latched until the owner clears it, a stop pressed on the robot holds until it is released there, and the device stops its motors by itself when commands stop arriving.' },
++ { q: 'Can other people drive my robot?', a: 'Only if you say so. Add people by @username as operators or viewers, or open a queue where signed-in visitors take timed turns. Listing a robot in the directory lets anyone watch it.' },
++ { q: 'Can I show it on my stream?', a: 'Yes. Bind the robot to your OpenVibe.Live channel and its panel shows under your stream, where anyone you allow can take the controls.' },
++ { q: 'Can my AI agent drive it?', a: 'Only when you turn on Your agent for that robot. Then your OpenVibe.Actor agent can read its state and run steps as you, through the same gate, and for nobody else.' },
++ { q: 'Is it open source?', a: 'Yes. OpenVibe.Bot and the OpenVibe Node that runs on the robot are on GitHub under the AGPL-3.0, and anyone can write a driver or submit a robot profile.' },
++ ].filter(Boolean);
++}
++
++function faqSection(items) {
++ return sec('faq', 'Questions', '', `<div class="hm-faq">${items.map((f) => `<div class="hm-qa"><h3>${esc(f.q)}</h3><p>${esc(f.a)}</p></div>`).join('')}</div>`);
++}
++
++function sections({ signedIn = false, config, models = [], robots = [], kit = null, jobs = [], faq = null } = {}) {
++ return hero({ signedIn, config })
++ + stats({ models, robots })
++ + onlineNow({ robots })
++ + modelsSection({ models })
++ + connectSection({ models })
++ + startSection({ config })
++ + panelSection()
++ + automationSection()
++ + kitSection({ kit, models })
++ + docsSection()
++ + jobsSection({ jobs })
++ + faqSection(faq || faqItems({ models }))
++ + showcase.cta({
++ title: 'Bring your robot online',
++ text: 'Start with the simulated rover, then pair the real one with one command.',
++ actions: signedIn ? [{ label: 'Add a robot', href: '/robots/new', primary: true }, { label: 'The directory', href: '/robots' }]
++ : [{ label: 'Try it now, no account', href: '/try', primary: true }, { label: 'Sign in and add a robot', href: '/auth/login?next=%2Frobots%2Fnew' }],
++ });
}
/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */
@@@ -116,7 +291,7 @@ function renderUpdates({ config }) {
});
}
--function renderHome({ config, signedIn = false }) {
++function renderHome({ config, signedIn = false, models = [], robots = [], kit = null, jobs = [] }) {
const nav = {
service: 'bot',
apiBase: NETWORK_URL,
@@@ -128,25 +303,34 @@ function renderHome({ config, signedIn = false }) {
notificationsRealtime: true,
};
const footer = { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
++ const url = `${config.baseUrl}/`;
++ const faq = faqItems({ models });
return shell.page({
name: SITE_NAME, service: 'bot', lang: 'en',
title: `${SITE_NAME}: drive your robot from any browser`,
siteName: SITE_NAME,
description: DESCRIPTION,
summary: DESCRIPTION,
-- canonical: `${config.baseUrl}/`,
++ canonical: url,
robots: 'index, follow',
++ jsonLd: [
++ seo.jsonLd.website({ name: SITE_NAME, url, description: DESCRIPTION }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url, description: DESCRIPTION, category: 'UtilitiesApplication', keywords: ['robot control panel', 'Raspberry Pi robot', 'ROS 2', 'Cozmo', 'telepresence'] }),
++ models.length ? seo.jsonLd.itemList('Robots OpenVibe.Bot drives', models.map((m) => ({ name: m.name, url: `${config.baseUrl}/models/${m.id}`, description: short(m.description, 160) })), { url }) : null,
++ seo.jsonLd.faq(faq.map((f) => ({ q: f.q, a: f.a }))),
++ ].filter(Boolean),
navbar: nav, footer, home: '/', navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
head: [
appIcon.headTags({ site: 'bot' }),
'<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`,
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
++ '<link rel="stylesheet" href="/home.css">',
].join('\n'),
body: `<div id="navbar-mount"></div>
--<main id="main" class="page">
--${sections({ signedIn })}
++<main id="main" class="page hm">
++${sections({ signedIn, config, models, robots, kit, jobs, faq })}
</main>`,
});
}
--module.exports = { renderHome, renderUpdates, framePage, HOME_CSP, sections };
++module.exports = { renderHome, renderUpdates, framePage, HOME_CSP, sections, faqItems };
ddiff --git a/server/web/network-reads.js b/server/web/network-reads.js
nnew file mode 100644
iindex 0000000..005a57b
---- /dev/null
++++ b/server/web/network-reads.js
@@@ -0,0 +1,66 @@
++'use strict';
++
++/**
++ * Public reads from the rest of the network for the front page: OpenVibe.Search's anonymous query API (public
++ * documents only), so the page can link robotics openings OpenVibe.Work gathers.
++ *
++ * createNetworkReads({ searchUrl, fetchImpl, log }) → { jobs() }
++ *
++ * jobs() never throws and never makes the page wait long: an answer is kept 15 minutes, a refresh runs in the
++ * background once it is stale, and the first read waits at most READ_WAIT_MS before the page goes out without it.
++ * No searchUrl (tests, a local run): every read is empty and nothing is fetched.
++ */
++const TTL_MS = 15 * 60 * 1000;
++const TIMEOUT_MS = 4000;
++const READ_WAIT_MS = 1200;
++const JOB_QUERY = 'robotics';
++const JOB_LIMIT = 6;
++const MAX_TITLE = 140;
++
++const clean = (s, max = MAX_TITLE) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, max);
++const httpsUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
++
++/** A Search document (owner work, type job) → what the page shows; anything without a title or an https link is dropped. */
++function jobOf(doc) {
++ const url = httpsUrl(doc && doc.canonical_url);
++ const title = clean(doc && doc.title);
++ if (!url || !title) return null;
++ const f = (doc && doc.facets) || {};
++ return { title, url, location: clean(f.location, 60), remote: f.remote === true, type: clean(String(f.type || '').split(/[,(]/)[0], 40) };
++}
++
++function createNetworkReads({ searchUrl = '', fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String(searchUrl || '').replace(/\/+$/, '');
++ const cache = new Map(); // key → { at, value, pending }
++
++ async function load(key, url, map) {
++ const r = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ if (!r.ok) throw new Error(`search answered ${r.status}`);
++ const body = await r.json();
++ return ((body && body.results) || []).map(map).filter(Boolean);
++ }
++
++ function read(key, url, map) {
++ if (!base) return Promise.resolve([]);
++ const hit = cache.get(key);
++ const fresh = hit && Date.now() - hit.at < TTL_MS;
++ if (hit && (fresh || hit.pending)) return Promise.resolve(hit.value);
++ const pending = load(key, url, map)
++ .then((value) => { cache.set(key, { at: Date.now(), value, pending: null }); return value; })
++ .catch((e) => {
++ log.warn(`[Bot] front page read ${key}: ${e.message}`);
++ // Keep what we had; try again after a minute rather than on every request.
++ cache.set(key, { at: Date.now() - TTL_MS + 60 * 1000, value: hit ? hit.value : [], pending: null });
++ return hit ? hit.value : [];
++ });
++ cache.set(key, { at: hit ? hit.at : 0, value: hit ? hit.value : [], pending });
++ if (hit) return Promise.resolve(hit.value);
++ return Promise.race([pending, new Promise((resolve) => setTimeout(() => resolve([]), READ_WAIT_MS).unref())]);
++ }
++
++ return {
++ jobs: () => read('jobs', `${base}/api/v1/search?q=${encodeURIComponent(JOB_QUERY)}&owner=work&type=job&limit=${JOB_LIMIT}`, jobOf),
++ };
++}
++
++module.exports = { createNetworkReads, jobOf };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex e37c7a6..70594dd 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -41,7 +41,8 @@ const { createMachineScreen } = require('../machine-screen');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { renderHome, renderUpdates, HOME_CSP } = require('./home');
--const { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, renderSubmit, renderSubmissions, modelOf } = require('./directory');
++const { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, renderSubmit, renderSubmissions, modelOf, sortModels } = require('./directory');
++const { createNetworkReads } = require('./network-reads');
const { LICENSES } = require('../submissions');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
@@@ -143,6 +144,7 @@ const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_S
function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, routines = null, webhooks = null, claims = null, releaseLastmod } = {}) {
const r = express.Router();
++ const networkReads = createNetworkReads({ searchUrl: config.search && config.search.url, log });
// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.
r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',
'Disallow: /robots/new', 'Disallow: /robots/submit', 'Disallow: /robots/submissions', 'Disallow: /pair/', 'Disallow: /panel/', 'Disallow: /install', 'Disallow: /link', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
@@@ -296,12 +298,25 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
// The front page of openvibe.bot (server/web/home.js): public, indexable, with the OpenVibe Frame. Its two
// actions differ by sign-in, so it is never stored by a shared cache.
-- r.get('/', (req, res) => {
-- res.setHeader('Content-Security-Policy', HOME_CSP);
-- res.setHeader('Cache-Control', 'private, no-cache');
-- res.setHeader('Vary', 'Cookie');
-- res.type('html').send(renderHome({ config, signedIn: !!req.viewer }));
++ // Its data sections: the catalogue, the robots people list (online first), the first kit, and robotics openings
++ // from OpenVibe.Search (cached; empty without BOT_SEARCH_URL).
++ r.get('/', async (req, res, next) => {
++ try {
++ const [models, rows, jobs] = await Promise.all([catalogue(), domain.robots.listListed({ limit: 24 }), networkReads.jobs()]);
++ const byId = new Map(models.map((m) => [m.id, m]));
++ const names = await namesOf(rows.map((row) => row.owner_subject));
++ const robots = (await Promise.all(rows.map(async (row) => ({
++ id: row.id, name: row.name, access_policy: row.access_policy, owner: names.get(row.owner_subject) || null, online: await onlineOf(row.id),
++ model_name: (byId.get(row.profile_id) || { name: row.profile_id.startsWith('local.') ? 'Its own profile' : row.profile_id }).name,
++ })))).sort((a, b) => Number(b.online) - Number(a.online));
++ const kit = kits.list()[0] || null;
++ res.setHeader('Content-Security-Policy', HOME_CSP);
++ res.setHeader('Cache-Control', 'private, no-cache');
++ res.setHeader('Vary', 'Cookie');
++ res.type('html').send(renderHome({ config, signedIn: !!req.viewer, models: sortModels(models), robots, kit, jobs }));
++ } catch (e) { next(e); }
});
++ r.get('/home.css', asset('home.css', 'text/css'));
// ── The directory (plan T15 "a robot directory (/robots)"): public pages inside the OpenVibe Frame. ──
// Every catalogue profile as a model; a community one carries its approved submission (who, driver, guide, key).
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 2f27851..ace275d 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -26,10 +26,33 @@ const { boot, check, done } = require('./helpers/app');
assert.match(html, /<link rel="canonical" href="http:\/\/bot\.test\/">/);
assert.match(html, /href="\/auth\/login\?next=%2Frobots">Sign in with OpenVibe/);
assert.strictEqual((html.match(/<h1[ >]/g) || []).length, 1, 'one h1');
-- // Every robot Bot ships a profile for is on the front page: six cards under "Robots it drives today".
-- const robots = (/<section class="sc-sec"[^>]*aria-labelledby="sc-robots-it-drives-today"[\s\S]*?<\/section>/.exec(html) || [''])[0];
-- assert.deepStrictEqual([...robots.matchAll(/<b>([^<]+)<\/b>/g)].map((m) => m[1]), ['Adeept 4WD Smart Car', 'ROS 2 robots', 'LOOI', 'Cozmo', 'A phone', 'A simulated rover']);
-- for (const id of ['adeept.adr036', 'ros2.diffdrive', 'looi', 'cozmo', 'phone.camera', 'sim.rover']) assert.ok(require('fs').existsSync(require('path').join(__dirname, '..', 'server', 'profiles', `${id}.json`)), `${id} is a shipped profile`);
++ // Every robot model in the catalogue (the shipped profiles) has a card under "Robots it drives today".
++ const models = (/<section class="sc-sec hm-sec" id="models"[\s\S]*?<\/section>/.exec(html) || [''])[0];
++ const names = [...models.matchAll(/<span class="hm-card-head"><b>([^<]+)<\/b>/g)].map((m) => m[1]);
++ const shipped = fs.readdirSync(path.join(__dirname, '..', 'server', 'profiles')).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'server', 'profiles', f), 'utf8')).name);
++ assert.deepStrictEqual(names.slice().sort(), shipped.slice().sort(), 'one card per catalogue model');
++ assert.match(html, new RegExp(`<b>${shipped.length}</b><span>robot models</span>`), 'the stat row counts the catalogue');
++ assert.ok(!/listed robots? online/.test(html), 'no online count while nothing listed is online');
++ // The simulated rover is always first under "Drive one now"; nothing else is listed yet.
++ const now = (/<section class="sc-sec hm-sec" id="now"[\s\S]*?<\/section>/.exec(html) || [''])[0];
++ assert.strictEqual((now.match(/class="hm-card[ "]/g) || []).length, 1);
++ assert.match(now, /href="\/try"/);
++ // The first kit's parts list, and no jobs section without OpenVibe.Search configured.
++ assert.match(html, /<h2 id="kit-h">The first kit: Adeept ADR036/);
++ assert.match(html, /<b>4×<\/b><span>TT DC motors<\/span>/);
++ assert.ok(!html.includes('id="jobs"'), 'a section with nothing to show is left out');
++ // JSON-LD: the site, the app, the models as an ItemList, and the questions the page shows as a FAQPage.
++ const ld = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const types = ld.map((x) => x['@type']);
++ for (const t of ['WebSite', 'WebApplication', 'ItemList', 'FAQPage']) assert.ok(types.includes(t), `JSON-LD ${t}`);
++ assert.strictEqual(ld.find((x) => x['@type'] === 'ItemList').numberOfItems, shipped.length);
++ const faq = (/<section class="sc-sec hm-sec" id="faq"[\s\S]*?<\/section>/.exec(html) || [''])[0];
++ const questions = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), questions, 'FAQPage holds exactly the questions shown');
++ assert.ok(questions.length >= 6);
++ const home = await get('/home.css');
++ assert.strictEqual(home.status, 200);
++ assert.match(home.headers.get('content-type'), /^text\/css/);
const css = html.match(/href="(\/shared\/showcase\.css\?v=[0-9a-f]{12})"/);
assert.ok(css, 'links the showcase stylesheet');
const sheet = await get(css[1]);
@@@ -42,7 +65,25 @@ const { boot, check, done } = require('./helpers/app');
const html = await (await get('/')).text();
assert.match(html, /sends its camera over WebRTC to OpenRestream and the panel plays it live/);
// ROS 2 left this list with Bot#95 (ros2.diffdrive, ros2.holonomic, the Node's plugins/ros2 driver).
-- for (const claim of [/ESP32/, /Android/, /arrive paired/i, /\bbuy\b/i, /webhook/i]) assert.ok(!claim.test(html), `no ${claim}`);
++ // Webhooks left this list when they shipped (routines and webhooks, migration 0011, /docs/automation).
++ for (const claim of [/ESP32/, /Android/, /arrive paired/i, /\bbuy\b/i]) assert.ok(!claim.test(html), `no ${claim}`);
++ });
++
++ await check('GET /: a listed robot shows under "Drive one now", online first, and the stat row counts it', async () => {
++ const { robot: rover } = await t.robot(alex, { name: 'Arena rover', profile_id: 'sim.rover', access_policy: 'queue' });
++ const { robot: car } = await t.robot(alex, { name: 'Garage car', profile_id: 'adeept.adr036' });
++ await t.domain.robots.setListed(car.id, true);
++ await t.domain.robots.setListed(rover.id, true);
++ await t.app.locals.sim.attach(rover.id);
++ const html = await (await get('/')).text();
++ const now = (/<section class="sc-sec hm-sec" id="now"[\s\S]*?<\/section>/.exec(html) || [''])[0];
++ const cards = [...now.matchAll(/<b>([^<]+)<\/b><span class="hm-state" data-state="(\w+)"/g)].map((m) => `${m[1]}:${m[2]}`);
++ assert.deepStrictEqual(cards, ['Simulated rover:online', 'Arena rover:online', 'Garage car:offline']);
++ assert.match(now, new RegExp(`href="/r/${car.id}"`));
++ assert.match(now, /Adeept ADR036 4WD Smart Car · @alex/);
++ assert.match(html, /<b>1<\/b><span>listed robot online<\/span>/);
++ await t.domain.robots.setListed(car.id, false);
++ await t.domain.robots.setListed(rover.id, false);
});
await check('GET /: signed in, the first action opens your robots', async () => {
ddiff --git a/test/network-reads.test.js b/test/network-reads.test.js
nnew file mode 100644
iindex 0000000..fb4fea3
---- /dev/null
++++ b/test/network-reads.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++// The front page's reads from OpenVibe.Search (server/web/network-reads.js): only public https documents become
++// rows, an answer is cached, a failure leaves the page without the section, and nothing is fetched unconfigured.
++const assert = require('assert');
++const { check, done } = require('./helpers/app');
++const { createNetworkReads, jobOf } = require('../server/web/network-reads');
++
++const quiet = { warn() {} };
++const doc = (o) => ({ owner: 'work', type: 'job', visibility: 'public', title: 'Robotics Engineer at Acme', canonical_url: 'https://openvibe.work/jobs/job_1', facets: { location: 'Munich', remote: false, type: 'Full Time, professional' }, ...o });
++
++(async () => {
++ await check('jobOf: title, https link and the facets the page shows; anything else is dropped', async () => {
++ assert.deepStrictEqual(jobOf(doc()), { title: 'Robotics Engineer at Acme', url: 'https://openvibe.work/jobs/job_1', location: 'Munich', remote: false, type: 'Full Time' });
++ assert.strictEqual(jobOf(doc({ canonical_url: 'javascript:alert(1)' })), null);
++ assert.strictEqual(jobOf(doc({ canonical_url: 'http://openvibe.work/jobs/job_1' })), null);
++ assert.strictEqual(jobOf(doc({ title: ' ' })), null);
++ assert.strictEqual(jobOf(doc({ facets: { remote: true } })).remote, true);
++ });
++
++ await check('no search URL: no fetch, no rows', async () => {
++ let calls = 0;
++ const reads = createNetworkReads({ searchUrl: '', fetchImpl: async () => { calls++; return null; }, log: quiet });
++ assert.deepStrictEqual(await reads.jobs(), []);
++ assert.strictEqual(calls, 0);
++ });
++
++ await check('one anonymous query, then the cached answer', async () => {
++ const urls = [];
++ const fetchImpl = async (url) => { urls.push(url); return { ok: true, json: async () => ({ results: [doc(), doc({ canonical_url: 'ftp://x' })] }) }; };
++ const reads = createNetworkReads({ searchUrl: 'https://search.example/', fetchImpl, log: quiet });
++ assert.strictEqual((await reads.jobs()).length, 1);
++ assert.strictEqual((await reads.jobs()).length, 1);
++ assert.strictEqual(urls.length, 1, 'cached');
++ assert.match(urls[0], /^https:\/\/search\.example\/api\/v1\/search\?q=robotics&owner=work&type=job&limit=6$/);
++ });
++
++ await check('Search failing: the page gets no rows and does not throw', async () => {
++ const reads = createNetworkReads({ searchUrl: 'https://search.example', fetchImpl: async () => ({ ok: false, status: 503 }), log: quiet });
++ assert.deepStrictEqual(await reads.jobs(), []);
++ const broken = createNetworkReads({ searchUrl: 'https://search.example', fetchImpl: async () => { throw new Error('down'); }, log: quiet });
++ assert.deepStrictEqual(await broken.jobs(), []);
++ });
++
++ done();
++})();