Reach beyond
the browser.

Your page gets one extra object before your first line runs: window.Arda. Everything the device can do — print, scan, dim the screen, keep data for when the line is down — is a call on it, in ordinary JavaScript that returns a promise. Nothing to install, nothing to import, nothing to compile.

01 / Surface

Fourteen groups.
One object.

Every group below is running on devices today; a line marked with a version needs that ArdaPlayer release or later. Use the ones your device needs and ignore the rest. The same object is there in developer mode on your laptop, in the kiosk on the device, and with no screen at all under -headless.

Device & settings Working

Arda.device() · Arda.config

Who am I, what am I on, what can a person do here — and the settings the operator set in the portal, as a frozen object on your first line.

  • deviceId · phase · hostname · model · ips · wifi
  • can: screen · keyboard · mouse · touch · audioOut · audioIn
  • Arda.config.currency ?? 'GBP' — absent, never null
  • a 'rows' setting arrives as an array of objects
  • per-device overrides applied; a portal change lands in ~3 s
  • Arda.config.set(key, value) — this device's own value, for a setting the manifest marks writable.device (v1.1.17)
  • a choice from the device's own printers or screens (v1.2.1)
  • Arda.battery.status() · Arda.on('battery') — level, charging, time left, where the machine has a battery (v1.3.0)

Datasets & outbox Working

Arda.db

Your data, kept on the device so the app works offline — only this device's share of it, encrypted. And a queue for what the app saves, which survives a power cut and sends itself when the line is back.

  • dataset(name) — asking is subscribing
  • push(dataset, data, key?) — at-least-once, 128 KB cap
  • status() — which datasets have landed; connected, and what is still queued (v1.2.0)
  • on('ack') — your records reached ArdaForm · sync() — a "Sync now" (v1.2.0)
  • declared in app/manifest.json

Files Working

/storage/…

Pictures and documents the project holds, copied to the device and checked, then used like any other path in your page.

  • <img src="/storage/catalogue/13388.webp">
  • read-only on the device, re-checked every sweep
  • folders the app needs at boot fetched first
  • 'files' event with done / total / bytes

Events Working

Arda.on(kind, fn)

The player telling the app what moved. Register as many listeners as you like.

  • 'sync' · 'files' · 'device' · 'config'
  • 'ack' — ArdaForm stored your pushes (v1.2.0)
  • 'barcode' · 'nfc'
  • 'update' · 'updateStart'
  • 'screens' — a monitor unplugged or plugged in
  • 'power' — the button was pressed; save what you must
  • console.* captured to the portal on demand

Keyboard Working

Arda.keyboard(opts)

The on-screen keyboard for a machine with none. The app opens it; a promise resolves the value, an object of fields, or null on cancel.

  • layouts: full · numeric · pin · date · time · datetime
  • fields with mask, prefix, suffix, secret
  • till-style money entry with decimals and pos
  • themeable, on a named screen if you like
  • sized to the screen, not the page's font size; theme {unit: '20px'} scales the whole keyboard (v1.1.12)

Screens & audio Working

Arda.screens · Arda.screen[name] · Arda.audio

The connected displays, their brightness, contrast and scale, the speakers — and a second screen as a real Document you append nodes into. On a device with two monitors, which one the app is on.

  • screens.list() · screens.ready() · screen['customer']
  • screens.controls() · screens.set(name, {brightness, contrast, volume, scale}, onboot)
  • scale 50–150 %, per screen · HDMI monitors over DDC/CI
  • audio.get() · audio.set({volume, muted}, onboot)
  • second screens as Documents: developer mode today
  • list() entries carry kind: 'app' — or 'video', a device monitor a camera can be shown on (v1.1.12, PC-type devices installed from its images)
  • on('screens') — list() is already current inside it, so a monitor that comes after the app can take a camera (v1.1.13)
  • screens.set(name, {primary: true}) — move the app to that monitor; the page starts over there (v1.1.12, the same devices)

Cameras Working

Arda.camera

An RTSP or IP camera — the CCTV over a bay, the door camera — on the screen. The player picks the best way this device has and keeps the picture live.

  • set(name, {url, user, password, fps, width, quality, hw}) · forget(name) — the login beside the link, never inside it (v1.1.14)
  • play(name, el, {maxLag, fit}) — the camera's own H.264/H.265 in a <video>, motion JPEG where it must
  • hardware decoding where the board has it — six at once on a Pi 4
  • always the newest frame; a camera that reboots comes back
  • url(name) · video(name) · status()
  • onvif({address, user, password}) — a camera's details and its streams · discover() — ONVIF cameras on the network (v1.1.14)
  • show(name, {screen, delay}) · shows() — full-screen on a device's second monitor while the app runs on the first; rtsp cameras (v1.1.12, a PC-type device installed from its images; not the Raspberry Pi image yet); delay 0–5000 ms or left to the player; rtmp:// sources; Reolink rtsp:// links played over RTMP (v1.1.14)

Printers Working

Arda.printers

The player finds printers and moves bytes; what to print is yours. USB, network (port 9100) and Bluetooth all the same way.

  • list() → id · make · model · proto · dots · usable
  • print(id, bytes) — ESC/POS, ZPL, Star, TSPL, ESC/P (dot-matrix, v1.2.2), raw
  • print(id, bytes, {copies, signal}, onStatus) — many copies in one job, a status per copy: printing · queued · stalled · done · error · cancelled (v1.1.11)
  • a busy or stuck label printer no longer holds up the receipt printer beside it (v1.1.11)
  • discover() · add() · remove() — receipt printers on the device's own network, found and kept (v1.3.0)
  • width guessed and correctable from the portal
  • control bytes stripped from text

Serial Working

Arda.serial

USB-serial adapters and UARTs, opened raw. The player owns the port; the protocol is yours — scales, customer displays, PLCs, inverters.

  • list() → stable by-id names, vendor, driver
  • open(id, {baud, dataBits, parity, stopBits})
  • write(id, bytes) · read(id, {max, timeoutMs})
  • close(id)

Readers Working

Arda.on('barcode' | 'nfc')

Barcode and QR scanners and most RFID readers are keyboard wedges. The player reads them from the input layer, so a scan says which device produced it.

  • {kind, text, device, node}
  • two scanners on one machine never mix
  • nfc for readers recognised as card readers
  • the reader still types into a focused field

System & Wi-Fi Working

Arda.system · Arda.wifi

For an app that owns the machine: power it off at closing time, or offer a Wi-Fi screen that cannot strand the device.

  • system.shutdown() · system.reboot()
  • wifi.list() — strongest first, active and known flagged
  • wifi.connect(ssid, key) — tested before anything is kept
  • simulated on a developer's own machine
  • on Android shutdown() closes the player, reboot() needs device owner, Arda.on('back') hears the back key (v1.3.0)

Updates Working

Arda.update

A new version arrives staged and the player is about to apply it. Say nothing and it goes in five seconds; mid-transaction, buy a minute.

  • on('updateStart') — version, mode, kind, decideInMs
  • update.pause() — up to five times
  • update.apply() — reloads onto the new version
  • update.pending() — the round, at any time

Webhooks Working

Arda.webhook.call(slug, data)

Tell one of your own servers that something happened. ArdaForm makes the call from its servers and signs it — the key never sits on the device.

  • → {id, ok, status, ms, body, error}
  • a 4xx or 5xx is an answer; it rejects only when nobody was asked
  • defined per project: the Webhooks tab or /v1/webhooks
  • up to 64 KiB of JSON · 10 s · the first 4 KiB back
  • no URL: the calls are only recorded, read in the portal or through the API
  • on your desk, arda.dev.json lists them and the player calls them itself

Headless Working

Arda.headless

The same app with nothing to draw on — an end-of-day job, a sensor box, a back-office printer. Same object, no DOM.

  • ardaplayer -headless myapp/
  • Arda.headless === true before your first line
  • fetch and WebSocket to your own servers, like a page
  • document throws with the fix named
  • console.* is how it is heard
  • From v1.1.17: the app deployed to a device with no screen runs here too (in progress, not yet proved on a device)

GPIO, camera capture, face matching Planned

Digital I/O on boards that have it, capture and QR scanning through a camera attached to the device, and on-device face matching are designed around and not started. Showing a network camera on a screen works today (the cameras group); a QR flow uses a USB scanner through the readers group.

The HTTP API Working

The other side of the datasets: api.ardaform.net/v1, API keys per project, records in and out, and a keyless GET /v1/openapi.json for your client tooling. What a device pushes, your systems read; what your systems write, the devices sync.

02 / In practice

What it looks
like in a real app.

Eight short excerpts from the kinds of applications ArdaForm exists for. None of them need a build plugin or a native module — each is a script tag in a page.

till.js point of sale
// Queue the sale — travels when the line is up
await Arda.db.push('sales', sale)

// Bytes for the printer; the receipt layout is yours
const [printer] = await Arda.printers.list()
if (printer?.usable) {
  await Arda.printers.print(printer.id, escpos({
    width: printer.dots,   // 576 · corrected in the portal if wrong
    lines: renderReceipt(sale),
    cut:   true, drawer: true,
  }))
}
gate.js access control
// A USB QR scanner is a keyboard wedge — attributed per device
Arda.on('barcode', async (scan) => {
  const members = await Arda.db.dataset('members')
  const m = members[scan.text]
  if (!m) return ui.refuse('Not a member')

  // The barrier relay hangs off a USB-serial adapter
  const [port] = await Arda.serial.list()
  await Arda.serial.open(port.id, { baud: 9600 })
  await Arda.serial.write(port.id, openPulse())

  await Arda.db.push('entries', { member: m.id, at: now() })
})
checkout.js touch entry
// A till has no keyboard. Ask for one when you need it.
const r = await Arda.keyboard({
  layout: 'numeric',
  fields: [
    { name: 'price', caption: 'Price', prefix: '£', decimals: 2, pos: true },
    { name: 'date',  caption: 'Collect on', mask: '00/00/0000' },
  ],
})
if (r === null) return           // cancelled
// r.price === '4.50'   r.date === '12/03/2026'

// A manager's PIN — the one layout a real keyboard can type into
const pin = await Arda.keyboard({ layout: 'pin', title: 'Override' })
board.js a second screen & closing time
// The customer display is a real Document
await Arda.screens.ready()
const total = document.createElement('div')
Arda.screen['customer']?.body.appendChild(total)
total.textContent = '£12.99'     // still updates the other screen

// Dim the panel at night, remembered across reboots
await Arda.screens.set(null, { brightness: 30 }, true)
await Arda.audio.set({ volume: 40 })

// And when the shop closes
await Arda.system.shutdown()
picker.js the bay's camera beside the list
// The stream URL carries the camera's password —
// keep it in a per-device secret setting, not in the app
const [bay] = Arda.config.cameras ?? []   // a 'rows' setting
await Arda.camera.set('bay', { url: bay.url, fps: 15 })

// Its own H.264 in a <video>, or motion JPEG where the
// engine cannot decode it. Falls behind? It jumps to live.
const stop = Arda.camera.play('bay', document.getElementById('cctv'), {
  fit: 'cover', maxLag: 2.5,
})
kitchen.js calling your own server
// The order is safe first — the outbox never waits on the network
await Arda.db.push('orders', order)

// Then tell the kitchen system now. ArdaForm makes the call,
// signed, so the kitchen's key never sits on the till.
try {
  const r = await Arda.webhook.call('kitchen', { order: order.id, items: order.items })
  if (!r.ok) ui.warn('Kitchen answered ' + r.status)  // an answer, not an error
} catch (e) {
  ui.warn(e.message)   // nobody was asked: offline, limit reached…
}
labels.js a run of labels, copy by copy
// 23 copies in one job, over one connection (v1.1.11)
const stop = new AbortController()
await Arda.printers.print(label.id, tspl(tote), { copies: 23, signal: stop.signal }, (s) => {
  if (s.state === 'printing') ui.show(s.done + ' of ' + s.copies)
  if (s.state === 'stalled')  ui.show('Printer stopped: ' + s.code)  // not the end:
})                                         // it carries on when the roll is in
// stop.abort() ends it between copies, never inside one.
// confirmed: false means the printer TOOK the copy, not that it came
// out — most printers say nothing back, so time the paper yourself.
wall.js a camera on the second monitor
// A device with two monitors: the app on one, a camera full-screen
// on the other, drawn by the player itself (v1.1.12). Needs a PC-type
// (x86) device installed from the v1.1.12 images or later; the Raspberry
// Pi image does not have it yet, nor does a device that only got the player.
const [wall] = Arda.screens.list().filter((s) => s.kind === 'video')
if (wall) {
  await Arda.camera.set('yard', { url: Arda.config.yard_camera })   // rtsp://…
  await Arda.camera.show('yard', { screen: wall.name })
}
// show(null, {screen}) clears it; shows() says what is on each screen.
// Which monitor is the app's: the portal's Primary screen, or
// Arda.screens.set(name, {primary: true}) — once, not on every load.
// A monitor plugged in or switched on later (v1.1.13): list() is current
// inside the listener, so adopt the new video monitor there.
Arda.on('screens', () => {
  const w = Arda.screens.list().find((s) => s.kind === 'video')
  if (w) Arda.camera.show('yard', { screen: w.name })
})
03 / Ground rules

The things worth
knowing first.

  1. 01
    The player talks to ArdaForm, not your appYour app never connects to ArdaForm itself. Datasets, the outbox, settings and files all come through window.Arda.
  2. 02
    Developer and device are identicalSame object, same sealed data, same sync. Developer mode is local by default; switch the device on when you want the portal.
  3. 03
    Asking is subscribingThe first call for a dataset starts its sync and the player remembers. Listen for 'sync' and read again.
  4. 04
    The portal's copy winsData on the device is a copy and always catches up with the portal's. Your app's own screen state belongs in localStorage.
  5. 05
    Text to a printer is a code pageA £ sent as UTF-8 prints two wrong glyphs and shifts the row. Send the printer's own language.
  6. 06
    The player is going; you opt outAn update applies five seconds after you are told unless you pause it. Most apps should say nothing.
manifest.json app/
{
  "datasets": [
    { "key": "catalogue", "access": "ro", "partition": "branch", "needForBoot": true },
    { "key": "sales", "partition": "branch", "retention": { "days": 400 } }
  ],
  "settings": [
    { "key": "currency", "caption": "Currency", "type": "select",
      "options": ["GBP", "EUR"], "default": "GBP" },
    { "key": "till_no", "caption": "Till number", "type": "number", "perDevice": true }
  ],
  "files": { "needForBoot": ["banners"] }
}
i
Definitions are yours, values are the operator's The manifest ships with the app and declares its datasets and setting fields. The portal shows them and edits values — per project, per device — in its standard form, or in a settings page of your own. Additions apply when a release is packed; nothing is ever deleted for you.
04 / Settings pages

The app's own
settings page.

An app can bring its own page for its settings. The portal shows it under Setup › App settings, and on a device's Setup tab, in place of the standard form — which stays one click away. The settings are still the manifest's: the page proposes values, the portal checks them, and nothing is saved until the operator presses Update.

The page Working

gui/settings/index.html

A gui/ folder beside app/ in the same repository. The portal reads it; it is never sent to a device.

  • gui/settings/ — index.html and its files
  • gui/icon.svg or icon.png — optional
  • gui/gui.json — {"settings": {"title", "height"}}, optional
  • HTML, CSS, JS, JSON, images, fonts · up to 200 files, 5 MB
  • a commit that changes only gui/ does not update the devices

ArdaGUI Working

<script src="/_arda/gui.js">

The helper the page loads. It tells the page what is being edited and carries its proposals back.

  • ready(fn) · definitions · values · secrets
  • set(key, value) · set({k: v, …}) → {ok, errors}
  • scope: 'project' | 'device' · following · inherited · follow(key)
  • expand({title}) · collapse() · expanded
  • on('values' | 'view' | 'theme') · resize(h) · autoResize(false)
  • --arda-bg --arda-fg --arda-muted --arda-border --arda-accent --arda-font, data-theme

What the page cannot do

a sandboxed frame on gui.ardaform.com

The page runs on a site of its own, apart from ardaform.net, and its one way out is a private channel to the portal.

  • no network — fetch, XHR, WebSocket, outside images and fonts are refused
  • no cookies, no localStorage
  • no popups, forms or downloads
  • one page — navigating away closes it
  • shown inside the portal only; opened in a tab of its own it is refused
  • secret row fields never reach it: it may replace one, never read it
gui/settings/index.html excerpt
<script src="/_arda/gui.js"></script>
<script>
// Once the portal has said what is being edited
ArdaGUI.ready(function (ctx) {
  draw(ArdaGUI.definitions, ArdaGUI.values)
})
// Saved, or changed in the standard form
ArdaGUI.on('values', function (values) {
  draw(ArdaGUI.definitions, values)
})
// A proposal: nothing is saved until the operator presses Update
slider.onchange = function () {
  ArdaGUI.set('font_size', Number(slider.value)).then(function (r) {
    if (!r.ok) showErrors(r.errors)   // {font_size: 'Text size must be a number'}
  })
}
</script>

Plans, your desk, and pages without the helper

Settings pages come with every package but Free. On Free the portal shows the standard form and says that the app has a page of its own.

On your desk, ardaplayer -dev app/ prints settings page: http://127.0.0.1:<port>/__arda/gui/: the page hosted the way the portal hosts it, with an Apply that puts the accepted values into the running app's Arda.config, in memory only (v1.1.13).

A page may also skip gui.js and speak the messages itself: the portal says hello with a private port, the page answers ready, and the settings travel over that port. Such a page should set color-scheme itself, or it is drawn on white in a dark portal.

Next

From a folder
to a fleet.

The whole path, with pictures: write the app, connect the repository, make a device, adopt it, watch it run.