PTENES
MODULE 5.1 · FOUNDATIONS

🧠 Language model as the brain

A language model understands requests like “move the gripper to the cup,” but it makes mistakes, makes things up, and cannot sense the world. In this module, you’ll build an architecture that uses what it does well without letting it control the motors: the model chooses, the robot executes, within a closed list of actions with limits checked in code.

6
Topics
Advanced
Level
Foundation
Type
PC or Pi 5
Hardware
0%
0 of 0 topics read · Section 1 of 6

Detailed content

1

🧩 LLM, VLM, and VLA: what each one does in a robot

Three acronyms appear in every AI-powered robot project, and each has a different role. The LLM (large language model) takes text and returns text: it understands the request and decides which action makes sense. The VLM (vision-language model) takes an image along with text and answers questions about it: “there’s a blue cup on the left.” The VLA (vision-language-action model) goes a step further: it takes an image and an instruction and returns the joint movements themselves, several times per second.

🌱 New here?

A model is a file of numbers (the parameters, or weights) learned from many examples. Running the model to get a response is called inference. Text goes in and comes out in pieces called tokens (parts of words). In general, the more parameters a model has, the better its responses and the more memory and time inference requires.

inputmodeloutput textLLMtext orchosen actionimage+ text VLM“blue cupon the left”image+ instructionVLA joint anglesseveral times/s from top to bottom: the model gets closer and closer to the motors

How to read this map: Each row shows a type of model. On the left (cyan) is what it receives; on the right is what it returns. The LLM and VLM stop at words or a choice; the glowing VLA returns movement directly. This module uses the first two as planners; the VLA appears in module 5.4, with SmolVLA.

💬 LLM

Turns “move a little forward” into move 20 cm. Good at understanding requests, bad at measuring distance.

👁️ VLM

Describes the camera scene. It helps the LLM know what’s on the table, but it can get the color, number, or position wrong.

🦾 VLA

Trained on demonstrations using the arm itself. It controls the joints, so it needs data, testing, and even stricter limits.

Why learn this

Knowing which model does what helps you avoid the most common beginner mistake: asking a text model to control a motor in real time. Each type has its place in the robot stack, and choosing the right place is half the safety work.

Key concepts

LLM
text → text
VLM
image + text → text
VLA
image + text → movement
Inference
run the model
2

🛡️ Safe architecture: the model chooses, the robot executes

The language model stays at the high level: it reads the request and chooses an action. Everything that follows is regular code you wrote and tested. A validator on the Raspberry Pi or PC checks that the action exists and the numbers are within the limits. Only then does a line of text go over serial to the ESP32, which has its own limits and emergency stop. If the model makes something up, the response is stopped by the validator.

can make mistakescode you testedrequest modelvalidatorserialESP32 JSONMOVE 20limitsagain refuses and explainsSTOPaction not on the listor outside the limit

How to read this map: The dashed cyan box is the part that can make mistakes (the model); the teal box is deterministic code that always does the same thing. The dashed red arrow shows the refusal path. The STOP button acts directly on the ESP32, without going through the model or the Pi.

✓ Do

  • ✓Let the model choose from a few clearly named actions.
  • ✓Check everything in code before sending it over serial.
  • ✓Repeat the limits on the ESP32: it’s the last line of defense.
  • ✓Log every request, response, and decision.

✗ Don't

  • ✗Let the model generate and run code without review.
  • ✗Send the model’s text directly over serial.
  • ✗Ask the model for raw PWM values or motor angles.
  • ✗Depend on the model to stop the robot in an emergency.

Why learn this

Language models sometimes confidently give a wrong answer (this is called a hallucination). In a chat, that produces a bad sentence; in a robot, it could cause a collision. Separating “choosing” from “executing” lets you switch models, whether local or in the cloud, without changing what keeps things safe.

Key concepts

High level
what, not how
Validator
code that refuses
Hallucination
a confidently stated mistake
Final barrier
limits on the ESP32
3

📋 Closed list of actions in JSON (function calling)

Instead of asking for an open-ended answer, you give the model a menu: the actions the robot knows how to perform, each one’s parameters, and the exact response format. This idea is called function calling (function call) or tool use. Ollama accepts a JSON Schema in the format field and forces the response to follow that structure; the enum field lists the only permitted action names.

🌱 New here?

JSON is a data format with keys and values: {"acao": "girar", "parametros": {"graus": 90}}. A JSON Schema is the rule that says which keys exist and what type each value must be. The schema helps the model get the format right, but it doesn’t guarantee that a value is safe: 9000 degrees is still a valid number. That’s why the validator checks the limits afterward.

closed listmove5 to 100 cm turn−180 a 180°gripperopen | closestopno parameteronly these exist move · cm: 30✓ sends MOVE 30turn · degrees: 900✗ out of range jump · height: 1✗ action doesn’t exist

How to read this map: On the left is the menu you write in the code. On the right are three possible model responses. Only the first becomes a command; the red ones are rejected by the validator, and the robot stays still and explains why.

Goal: see what a valid model response looks like. This is the format required by the script in Topic 4; notice that reason is just text for the log and is never executed.

resposta-exemplo.json
{
  "acao": "girar",
  "parametros": { "graus": -90 },
  "motivo": "the user asked to turn left"
}

How to check: paste the block into a JSON validator (or run python3 -m json.tool resposta-exemplo.json); it should be accepted without errors. Change -90 with "left" and see that the type is no longer a number: the validator in Topic 4 would reject it.

Why learn this

The closed list turns an open-ended problem ("what will the model answer?") into a closed, testable problem ("which of the four actions did it choose, and is the number within the limit?"). It’s the same principle commercial assistants use to call tools, applied to a robot.

Key concepts

Function calling
model chooses a tool
JSON Schema
response structure
enum
only these names
Threshold
minimum and maximum
4

💻 Run a local model (Ollama on the Pi 5 or PC)

O Ollama downloads open models and runs them on your machine, through an HTTP API at http://localhost:11434. Your script talks to it just as it would to an internet service, except nothing leaves your computer. Cloud models also work with this architecture (the validator is the same); the course shows the local setup because it works without internet, has no per-request cost, and doesn’t send audio or images from your home outside.

your PC, Pi 5, or Jetson scriptPythonOllamaport 11434model in RAMESP32motors HTTPinternet:not needed USB

How to read this map: Everything inside the teal frame runs on your machine. The two arrows between the script and Ollama show the request and response over the local network (localhost). The dashed cyan cable is the serial connection to the ESP32, the same one from Module 3.5.

Where it runsModel size that fitsIn practice
Raspberry Pi 5 (8 GB), CPU onlysmall models, with 1 to 3 billion parametersa few seconds per short response
Pi 5 + AI HAT+ 2small LLMs and VLMs on the acceleratorcheck the Pi documentation for supported models
Jetson Orin Nano Supersmall and medium models, with a GPUfaster than the Pi, more expensive
PC with an NVIDIA GPUmedium models (7 to 14 billion) with room to sparealmost instant response

Step by step

1

Install Ollama

Follow the installer for your system at ollama.com/download (on Linux and the Pi, it’s a single command). It starts a service that listens on port 11434.

2

Choose and download a model

At ollama.com/library, filter for models that support "tools" and are small enough for your memory. Download with ollama pull <modelo>. Names and versions change quickly: check the current list.

3

Test in the terminal and install pyserial

Run the commands below. If curl returns JSON with "message", the server is ready for the script.

terminal
ollama pull <modelo>          # replace with the chosen name
ollama list                     # the model should appear here
curl http://localhost:11434/api/chat -d '{"model": "<modelo>", "stream": false,
  "messages": [{"role": "user", "content": "Reply only: ok"}]}'
python3 -m venv ~/robo-ia && source ~/robo-ia/bin/activate
pip install pyserial

Goal: the complete script for the safe architecture. It sends the request and schema to the local model, validates the response (action is on the list, parameters are within limits, nothing extra), asks for confirmation for large movements, and only then sends a line of text to the ESP32. With --simular, it prints the line instead of sending it, so you can test without hardware.

cerebro_llm.py
#!/usr/bin/env python3
"""Local-model brain: the model CHOOSES an action from the closed list,
the code VALIDATES it, and only then sends a line of text to the ESP32 over serial.
Usage: python3 cerebro_llm.py            (with ESP32)
       python3 cerebro_llm.py --simular  (without hardware: only prints)"""
import json, sys, time, urllib.request, urllib.error

OLLAMA_URL = "http://localhost:11434/api/chat"
MODELO = "<modelo>"          # replace with the name shown by: ollama list
PORTA = "/dev/ttyUSB0"       # USB: /dev/ttyUSB0 or /dev/ttyACM0 · Pi UART: /dev/serial0 · Windows: COM3
BAUD = 115200
CONFIRMAR_ACIMA_CM = 50        # moving farther than this requires typing "s" on the keyboard

# Closed list: action -> {parameter: (minimum, maximum)} or {parameter: [permitted values]}
ACOES = {
    "andar": {"cm": (5, 100)},
    "girar": {"graus": (-180, 180)},
    "garra": {"estado": ["abrir", "fechar"]},
    "parar": {},
}

ESQUEMA = {
    "type": "object",
    "properties": {
        "acao": {"type": "string", "enum": list(ACOES)},
        "parametros": {"type": "object"},
        "motivo": {"type": "string"},
    },
    "required": ["acao", "parametros"],
}

SISTEMA = (
    "You control a small robot. Choose ONE action and respond only in JSON.\n"
    "Actions: andar {cm: 5 to 100, negative values aren’t allowed}; girar {graus: -180 to 180, "
    "negative = left}; garra {estado: open or close}; parar {}.\n"
    "If the request doesn't fit any action, or is dangerous, use stop and explain why."
)

def perguntar_modelo(pedido):
    """Send the request to Ollama and return the response dictionary (or raise an error)."""
    corpo = {
        "model": MODELO,
        "messages": [{"role": "system", "content": SISTEMA},
                     {"role": "user", "content": pedido}],
        "stream": False,           # one complete response, not pieces
        "format": ESQUEMA,          # force the JSON format
        "options": {"temperature": 0},
    }
    req = urllib.request.Request(OLLAMA_URL, data=json.dumps(corpo).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=120) as r:
        dados = json.loads(r.read())
    return json.loads(dados["message"]["content"])

def validar(resp):
    """Return (ok, line_or_reason, needs_confirmation). Never trust the model."""
    if not isinstance(resp, dict):
        return False, "response is not a JSON object", False
    acao = resp.get("acao")
    if acao not in ACOES:
        return False, f"action '{acao}' is not on the list", False
    params = resp.get("parametros") or {}
    if not isinstance(params, dict):
        return False, "parameters are not an object", False
    sobra = set(params) - set(ACOES[acao])
    if sobra:
        return False, f"unknown parameter: {', '.join(sorted(sobra))}", False
    valores = []
    for nome, regra in ACOES[acao].items():
        if nome not in params:
            return False, f"missing parameter '{nome}'", False
        v = params[nome]
        if isinstance(regra, list):                      # allowed values
            if v not in regra:
                return False, f"'{v}' is not allowed for {nome}", False
            valores.append(str(v).upper())
        else:                                            # numeric range
            if isinstance(v, bool) or not isinstance(v, (int, float)):
                return False, f"{nome} must be a number", False
            minimo, maximo = regra
            if not (minimo <= v <= maximo):
                return False, f"{nome}={v} is outside the limit ({minimo} to {maximo})", False
            valores.append(str(int(round(v))))
    linha = " ".join([acao.upper()] + valores)         # e.g.: ANDAR 30 · GARRA ABRIR · PARAR
    confirmar = acao == "andar" and params["cm"] > CONFIRMAR_ACIMA_CM
    return True, linha, confirmar

def abrir_serial(simular):
    if simular:
        return None
    import serial                                       # pip install pyserial
    s = serial.Serial(PORTA, BAUD, timeout=2)
    time.sleep(2)                                       # opening the port resets the ESP32: wait
    s.reset_input_buffer()
    return s

def enviar(s, linha):
    if s is None:
        return f"(simulated) {linha}"
    s.write((linha + "\n").encode())
    return s.readline().decode(errors="replace").strip() or "(no response from ESP32)"

def main():
    simular = "--simular" in sys.argv
    s = abrir_serial(simular)
    print("Brain ready. Enter a request (or 'sair').")
    while True:
        pedido = input("You: ").strip()
        if pedido.lower() in ("sair", "exit"):
            break
        if not pedido:
            continue
        try:
            resp = perguntar_modelo(pedido)
        except urllib.error.URLError as e:
            print(f"Ollama did not respond ({e}). Is it running? Try: ollama list"); continue
        except (json.JSONDecodeError, KeyError) as e:
            print(f"Rejected: unreadable response ({e})."); continue
        print("Modelo:", json.dumps(resp, ensure_ascii=False))
        ok, linha, confirmar = validar(resp)
        if not ok:
            print("Recusado:", linha); continue
        if confirmar and input(f"Confirm '{linha}'? (y/n) ").strip().lower() != "s":
            print("Canceled by you."); continue
        print("ESP32:", enviar(s, linha))
    if s:
        s.write(b"PARAR\n"); s.close()

if __name__ == "__main__":
    main()

How to check

  • ✓With --simular, "walk thirty centimeters" shows the model's JSON, and "ESP32: (simulated) ANDAR 30".
  • ✓"walk two meters" is rejected (cm is outside the limit) or becomes stop; "walk 80 cm" asks for confirmation first.
  • ✓"make a coffee" ends in a refusal or in PARAR, never in an invented action.
  • ✗"Ollama did not respond"? The service may be stopped or the model name may not match ollama list.

💡 Test the validator without a model

In the Python interactive shell, run from cerebro_llm import validar and call validar({"acao": "girar", "parametros": {"graus": 900}}). Write about ten invalid cases (nonexistent action, text instead of a number, extra parameter). If all are rejected, your robot is protected against any model you plug in later.

Why learn this

With a local model, you can test as much as you like, with no quota or account, and the robot keeps working without Wi-Fi. And since the validator is in your code, switching Ollama for a cloud model means changing only the function perguntar_modelo.

Key concepts

Ollama
local model server
localhost:11434
the API on your machine
temperature 0
more predictable response
--simular
test without hardware
5

👁️ VLM: “what do you see?” through the camera

A VLM receives a photo and a question. In Ollama, the image is sent as base64 in the images field of the message. The safe approach is the same as before: the VLM describes, and that description is included as context in the request to the LLM (or in the same model, if it accepts images and tools). It doesn't control a motor. For exact positioning, the detector from module 3.3 is still more reliable; the VLM helps with open-ended questions: “is there anything in the way?”, “which object is red?”

Goal: take a photo with the USB camera (or Camera Module 3 via OpenCV) and ask the local VLM what's in the scene. You need a model with vision: in the Ollama library, filter by “vision”.

o_que_ve.py
#!/usr/bin/env python3
"""Take a photo and ask the local VLM what's in it. pip install opencv-python"""
import base64, json, sys, urllib.request
import cv2

MODELO_VISAO = "<modelo-de-visao>"   # a model with 'vision' at ollama.com/library
PERGUNTA = sys.argv[1] if len(sys.argv) > 1 else "List the objects on the table, with their color and position (left, center, right)."

cam = cv2.VideoCapture(0)                # 0 = first camera
for _ in range(5):                       # discard dark frames at the start
    ok, quadro = cam.read()
cam.release()
if not ok:
    sys.exit("Camera didn't open: check the index (0, 1...) and the cable.")
quadro = cv2.resize(quadro, (640, 480))   # smaller image = faster response
cv2.imwrite("foto.jpg", quadro)
_, jpg = cv2.imencode(".jpg", quadro)

corpo = {
    "model": MODELO_VISAO, "stream": False, "options": {"temperature": 0},
    "messages": [{"role": "user", "content": PERGUNTA + " Answer in English, in up to 3 sentences.",
                  "images": [base64.b64encode(jpg.tobytes()).decode()]}],
}
req = urllib.request.Request("http://localhost:11434/api/chat", data=json.dumps(corpo).encode(),
                             headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=180) as r:
    print(json.loads(r.read())["message"]["content"])

How to check: the file foto.jpg appears in the folder, and the terminal shows a short description of the scene. Put two objects of different colors on the table and switch their sides: the description should follow them. If it gets it wrong, you've just seen why the VLM can't control the motor.

✓ Good use of the VLM

  • ✓Give the LLM context: “there's a blue cup and a ball on the table”.
  • ✓Answer open-ended questions from the user about the scene.
  • ✓Check after the action: “is the gripper holding something?”.

✗ Bad use of the VLM

  • ✗Ask for coordinates in millimeters and send the arm there.
  • ✗Use it as a safety sensor (“is anyone nearby?”).
  • ✗Call it on every video frame: it's too slow for that.

Why learn this

Combining vision and language lets the robot respond to requests that refer to the world (“pick up the red one”). Knowing where the VLM performs well and where it makes mistakes prepares you for the VLA in module 5.4, which takes the same idea all the way to movement.

Key concepts

images (base64)
photo inside the JSON
Context
description that helps the LLM
Detector vs. VLM
position vs. meaning
Resolution
smaller = faster
6

🚨 Safeguards: limits, confirmation, and emergency stop

Safety in an AI-powered robot is built in layers, and each layer trusts none of the previous ones. The validator on the Pi is the first. The ESP32 checks the limits again and rejects any line it doesn't understand. A command watchdog stops the motors if no command arrives for a short time. And a physical stop button cuts off movement without depending on any software.

4 · stop button (cuts power to the motors) 3 · watchdog: stops if no new command arrives2 · ESP32 checks the limits again 1 · validator on the Pi / PCmotor

How to read this map: the motor is in the center; for it to move, none of the four surrounding layers can have blocked the command. The outer red layer is the only one that works even if the program freezes: it's physical. If one layer fails, the outer one still holds.

Goal: the ESP32 side of the text-line protocol (the same one used in module 3.5), with its own limits, an OK/ERROR response, a 2-second watchdog, and a stop button on GPIO 13. The motor functions only print messages; replace them with your motor code (Track 1).

esp32_executor.ino
// Safe executor: receives "ANDAR 30", "GIRAR -90", "GARRA ABRIR", "PARAR" (ESP32, pacote esp32 3.x)
const int PINO_PARADA = 13;              // button connected to GND (uses internal pull-up)
const unsigned long WATCHDOG_MS = 2000;  // no command for 2 s: stop
unsigned long ultimoComando = 0;
bool movendo = false;

void pararMotores() { movendo = false; Serial.println("# motors stopped"); }  // replace with your driver
void andar(int cm)    { movendo = true; Serial.printf("# moving %d cm\n", cm); }
void girar(int graus) { movendo = true; Serial.printf("# turning %d degrees\n", graus); }
void garra(bool abrir) { Serial.println(abrir ? "# opening gripper" : "# closing gripper"); }

void setup() {
  Serial.begin(115200);
  pinMode(PINO_PARADA, INPUT_PULLUP);
  pararMotores();
  Serial.println("PRONTO");
}

void loop() {
  if (digitalRead(PINO_PARADA) == LOW) { pararMotores(); while (digitalRead(PINO_PARADA) == LOW) delay(10); }
  if (movendo && millis() - ultimoComando > WATCHDOG_MS) pararMotores();
  if (!Serial.available()) return;
  String linha = Serial.readStringUntil('\n'); linha.trim();
  if (linha.length() == 0 || linha.length() > 32) { Serial.println("ERRO linha invalida"); return; }
  ultimoComando = millis();
  int esp = linha.indexOf(' ');
  String cmd = esp < 0 ? linha : linha.substring(0, esp);
  String arg = esp < 0 ? "" : linha.substring(esp + 1);
  if (cmd == "PARAR") { pararMotores(); Serial.println("OK PARAR"); }
  else if (cmd == "ANDAR") { int v = arg.toInt(); if (v < 5 || v > 100) Serial.println("ERRO limite"); else { andar(v); Serial.println("OK ANDAR"); } }
  else if (cmd == "GIRAR") { int v = arg.toInt(); if (v < -180 || v > 180) Serial.println("ERRO limite"); else { girar(v); Serial.println("OK GIRAR"); } }
  else if (cmd == "GARRA" && (arg == "ABRIR" || arg == "FECHAR")) { garra(arg == "ABRIR"); Serial.println("OK GARRA"); }
  else { pararMotores(); Serial.println("ERRO comando desconhecido"); }
}

How to check

  • ✓In the Serial Monitor (115200, line ending "Newline"), "ANDAR 30" responds with "OK ANDAR" and, 2 s later, "# motors stopped".
  • ✓"ANDAR 500" responds with "ERRO limite" even if the Pi failed to block it.
  • ✓With the button on GPIO 13 pressed, any movement stops immediately. Test it in Wokwi too.

💡 A real stop is physical

The button on GPIO 13 depends on the program running. For a powerful robot (an arm or a heavy base), also install a switch in series with the motors' power supply, within reach. Before each test, press it once to make sure it works.

Why learn this

The next modules add an arm that picks up objects and a voice that gives commands. With all four layers in place, any model you test—local or cloud-based, large or small—operates within a box you control.

Key concepts

Layers
none trusts the others
Watchdog
no command, stop
Confirmation
a human approves the big move
Physical stop
cuts the power

✅ Module summary

✓
LLM, VLM, and VLA: text, images with text, and movement; each has its place in the stack.
✓
The model chooses, the robot executes: the code validates before any movement.
✓
Closed list in JSON: action enum, numeric limits, nothing extra.
✓
Local Ollama: API at localhost:11434, no internet; cloud works too with the same validator.
✓
Four safety layers: validator, limits on the ESP32, watchdog, and physical stop.

To go further

Next module

5.2 · Local voice: listen with Whisper and speak with Piper, connecting voice to this module's brain.