DeviceScript’s architecture allows accessing hardware through services instead of relying directly on each implementation.
If you come from the Arduino world, you might wonder why we need this layer to turn on an LED when digitalWrite(5, HIGH) is so straightforward.
That is an excellent question. And the answer is the difference between programming a chip and programming a solution.
To understand this separation, we need to distinguish services, clients, roles, and drivers, as well as the Jacdac protocol that communicates them.
The problem of hardware coupling
Let’s think about a program to control the temperature of a greenhouse.
- We read a DHT11 sensor on pin 4.
- We activate a relay for the heater on pin 12.
The code ends up filled with references to dht.read() and digitalWrite(12, ...).
A year later, we want to improve the system.
- We change the DHT11 sensor to a BME280 connected via I2C.
- We change the Arduino Uno for an ESP32 to add WiFi.
The result is that we have to modify a significant part of the program because the control logic is mixed with the hardware details.
DeviceScript solves this by separating the WHAT from the HOW.
What is a service
In DeviceScript, all hardware is defined by what it does, not by what it is.
A service is a standard contract that defines registers, commands, and events. For example, the Button service exposes, among other elements:
- It has a
pressurestate (how much it is pressed). - It has a
downevent (when it is pressed).
The main code doesn’t care if that button is:
- A mechanical push button on GPIO 5.
- A capacitive touch button.
- A virtual button on a web screen.
They all implement the Button service.
Think of Services like your PC’s Drivers. When you print a document, Word doesn’t care if the printer is HP, Epson, or Laser. Word talks to the “Printer Service”.
What is a role
A role is the client that our program declares to consume a service. DeviceScript uses the variable name as the name of the role and the instance.
Let’s think about a robot with two motors.
- Both are of the
Motortype (Service). - But one has the Role of “leftWheel” and the other “rightWheel”.
When in DeviceScript we do this:
const kitchenLight = startLightBulb({ pin: pins.GPIO2 })
const gardenLight = startLightBulb({ pin: pins.GPIO4 })We are defining two Roles.
- Each call mounts a
LightBulbserver on a pin. - The function returns the associated client, which we use via
kitchenLightorgardenLight.
From then on, our program only talks to kitchenLight. If tomorrow we change the pin of the kitchen light, we only change the role definition at the beginning. All the program logic remains intact.
Simulating thanks to abstraction
Why is this so important? Because it allows us the concept of a Digital Twin.
When we launch the simulator in VS Code, what we are doing is starting small software programs that “pretend” to be hardware.
- The simulator starts a “Button Server”.
- Your code looks for a “Button Role”.
- DeviceScript connects them.
Your code does not know that it is running in a simulator. It has no way of knowing. For it, there is a button service, and it responds to it.
This means we can:
Develop all the logic on the train or on the couch, without hardware.
Test edge cases (what if the temperature rises to 500ºC?) by moving a slider, without having to set fire to the real sensor.
Local servers and unassigned roles
In DeviceScript, we can work in two ways:
Mounting a local server
This is the “Maker” way. We define the hardware in the main.ts code itself.
// Bind the role 'myLed' to the physical pin GPIO2
const myLed = startLightBulb({ pin: pins.GPIO2 })Declaring a role
In large projects, the code should not know anything about pins. We just say: “I need a light called ‘status’”.
// main.ts
import { LightBulb } from "@devicescript/core"
const statusLight = new LightBulb()
setInterval(async () => {
await statusLight.toggle()
}, 500)The role is linked to a compatible server from DeviceScript tools. The program consumes the service without knowing the pin or the driver behind it.
This allows the same main.ts file to be compiled for an ESP32 board and a Raspberry Pi Pico, and use the appropriate pins on each, without touching a single line of code.
Architecture layers
The complete architecture looks like this:
Physical Hardware: The pin, the voltage, the silicon sensor.
Driver (Server): The code that reads the pin and translates it to the Jacdac standard (e.g., startButton).
Service: The standard that defines which messages can be sent and received (e.g., “Button Service”).
Role: The name we give to that capability in our program (e.g., “DoorbellButton”).
Client: Our code in main.ts that consumes the Role.
What this separation brings
- Portability: Your code survives hardware changes.
- Simulation: You can test without hardware.
- Clarity: The code talks about “doors” and “lights”, not “pins” and “voltages”.