Skip to content

SigmaScott/VirtualChameleon

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

VirtualChameleon

A virtual uChameleon USB device emulator that presents as a real serial port on Linux. Applications that normally talk to a physical uChameleon board (such as AllStarLink's app_rpt DAQ subsystem) can connect to the virtual serial port instead. Real-world sensor data is fed into the emulator via MQTT from remote physical devices.

Requirements

  • Linux (PTY-based virtual serial ports)
  • Python 3.11+
  • An MQTT broker (e.g. Mosquitto)
  • Remote sensor devices publishing to the MQTT broker

Installation

Clone the repository and install:

git clone <repo-url> VirtualChameleon
cd VirtualChameleon
python3.11 -m venv .venv
.venv/bin/pip install -e .

For development (includes pytest):

.venv/bin/pip install -e ".[dev]"

If your system lacks python3.11-venv, create with --without-pip and bootstrap:

python3.11 -m venv --without-pip .venv
curl -sS https://bootstrap.pypa.io/get-pip.py | .venv/bin/python3.11
.venv/bin/pip install -e .

Configuration

MQTT Credentials

Copy the example environment file and fill in your broker details:

cp .env.example .env

Edit .env:

MQTT_BROKER=192.168.1.50
MQTT_PORT=1883
MQTT_USERNAME=vchameleon
MQTT_PASSWORD=yourpassword
MQTT_TLS=false

Device Definitions

Edit config.yaml to define your virtual devices. Each device gets its own virtual serial port.

devices:
  - id: cham-1
    pins:
      1: inadc
      2: inadc
      3: inadc
      4: inadc
      5: inadc
      6: inadc
      7: inadc
      8: inadc
      9: inp
      10: inp
      11: inp
      12: inp
      13: in
      14: out
      15: out
      16: out
      17: out
      18: out

  - id: cham-2
    pins:
      1: inadc
      9: inp
      14: out

The id field is used in two places:

  • The PTY symlink path: /dev/vchameleon-cham-1
  • MQTT topic paths: physical/cham-1/pin/3/adc

Pin types and their constraints:

Type Description Valid Pins
inadc ADC analog input (0-255) 1-8 only
inp Digital input with pullup resistor 9-18 only
in Digital input without pullup 1-18
out Digital output 1-18

Only pins listed in the config are active. You don't need to define all 18.

Running

Foreground (development)

Requires root to create symlinks in /dev:

sudo .venv/bin/vchameleon -c config.yaml -e .env -v

This creates virtual serial ports at /dev/vchameleon-<device-id> for each configured device. Point your application (e.g. app_rpt's devnode setting) at that path.

As a Systemd Service

Deploy the project to /opt/vchameleon and set up the venv there:

sudo cp -r . /opt/vchameleon
sudo python3.11 -m venv --without-pip /opt/vchameleon/.venv
curl -sS https://bootstrap.pypa.io/get-pip.py | sudo /opt/vchameleon/.venv/bin/python3.11
sudo /opt/vchameleon/.venv/bin/pip install -e /opt/vchameleon

Install the config and service:

sudo mkdir -p /etc/vchameleon
sudo cp config.yaml /etc/vchameleon/
sudo cp .env /etc/vchameleon/
sudo chmod 600 /etc/vchameleon/.env
sudo cp vchameleon.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now vchameleon

Check status:

sudo systemctl status vchameleon
sudo journalctl -u vchameleon -f

Command Line Options

Flag Default Description
-c, --config /etc/vchameleon/config.yaml Path to device config YAML
-e, --env Auto-discover .env Path to MQTT credentials file
-v, --verbose Off Enable debug logging

MQTT Topic Hierarchy

Inbound (remote sensors to emulator)

The emulator subscribes to these topics. Your physical sensor devices publish here.

Topic Payload Description
physical/{device_id}/pin/{N}/state 0 or 1 Digital pin reading
physical/{device_id}/pin/{N}/adc 0-255 Analog pin reading (8-bit)
physical/{device_id}/status online or offline Remote device availability

Outbound (emulator to world)

The emulator publishes these when the connected application configures or controls pins.

Topic Payload Description
vchameleon/{device_id}/pin/{N}/config in, inp, inadc, or out Pin was reconfigured by the application
vchameleon/{device_id}/pin/{N}/output 0 or 1 Output pin was set by the application

Example Flow

A temperature sensor publishes an ADC reading:

physical/cham-1/pin/3/adc → "128"

The connected application sends adc 3\n over the virtual serial port and receives:

adc 3 128

The application sets an output pin high:

pin 14 hi\n  →  (sent by app over serial)

The emulator publishes:

vchameleon/cham-1/pin/14/output → "1"

Testing with a Serial Terminal

Connect to a virtual device with any serial tool:

picocom /dev/vchameleon-cham-1

Try these commands:

id                    → USB Chameleon
pin 14 state          → pin 14 0
pin 14 hi             → (no reply, sets output high)
pin 14 state          → pin 14 1
adc 1                 → adc 1 0  (or whatever the latest MQTT value is)
pin 9 monitor on      → (no reply, enables async notifications)

How It Works

Why Python

The emulator is I/O-bound, not CPU-bound. It multiplexes reads from multiple virtual serial ports, MQTT subscriptions, and periodic timers. Python's asyncio maps directly to this workload. The serial protocol is simple text parsing ("pin 3 1\n") that doesn't benefit from a compiled language.

Rust and Go were considered. Rust would have taken 3-5x longer to develop for the same functionality, with more manual PTY handling via libc FFI. Go was a reasonable alternative but offered no concrete advantage. Python's stdlib os.openpty() gives us virtual serial ports in a few lines, aiomqtt handles the broker connection, and asyncio.TaskGroup manages all concurrent tasks cleanly.

The target is Python 3.11+ for TaskGroup and modern union type syntax.

Virtual Serial Port via PTY

Linux pseudo-terminals (PTYs) are the mechanism. When the daemon starts, it calls os.openpty() which returns a master/slave file descriptor pair. The master side is what the emulator reads from and writes to. The slave side (/dev/pts/X) is what external applications open as their "serial port".

A symlink is created from /dev/vchameleon-{device_id} to the slave path so applications have a stable, predictable path to connect to regardless of which PTY number the kernel assigns.

The master fd is registered with asyncio's event loop via connect_read_pipe and connect_write_pipe for non-blocking I/O.

Device and Pin Model

Each virtual device maintains a collection of up to 18 pins, matching the physical uChameleon hardware. Pin type constraints are enforced at configuration time and on reconfiguration:

  • Pins 1-8 can be inadc, in, or out (they have ADC hardware)
  • Pins 9-18 can be inp, in, or out (they have pullup resistors)
  • inadc is rejected on pins 9-18, inp is rejected on pins 1-8

Dual-Mode on Pins 1-8

Just like the real hardware, pins 1-8 support simultaneous ADC and digital reads regardless of their configured type. A pin configured as out can still be read with adc N and receive ADC updates via MQTT. The ADC value and digital value are stored independently — an adc MQTT update does not affect pin N state, and a state MQTT update does not affect adc N.

This means:

  • adc N always works on pins 1-8, returning the last MQTT ADC value
  • pin N state always works on any configured pin, returning the last MQTT digital value
  • Pins 9-18 do not have ADC hardware, so adc N returns nothing for those pins

Each ADC-capable pin maintains a 30-sample circular buffer (300 seconds at 10-second intervals), plus all-time min/max tracking. These statistics are available for meter telemetry: current value, min, max, short-term min, short-term max, and short-term average. ADC history is preserved across pin type changes on pins 1-8.

When a pin is reconfigured (e.g. changed from in to out), the digital value resets to 0 and monitoring disables. On pins 9-18, ADC state also resets. On pins 1-8, ADC values and history are preserved since the ADC hardware is always available.

Serial Protocol

The emulator implements the subset of the uChameleon serial protocol that AllStarLink's DAQ subsystem uses:

Command Response Action
id USB Chameleon Hardcoded handshake response
led on/off (none) Tracks LED state internally
pin N in (none) Configure pin as digital input
pin N out (none) Configure pin as digital output
pin N state pin N V Read digital value (0 or 1)
pin N hi (none) Set output high
pin N lo (none) Set output low
pin N pullup 0/1 (none) Enable/disable pullup (pins 9-18)
pin N monitor on/off (none) Enable/disable async state change notifications
adc N adc N V Read analog value (0-255, pins 1-8 only)

Commands not used by the DAQ subsystem (PWM, SPI, UART, variables, events, wait) are not implemented and are silently ignored.

The id response is hardcoded to "USB Chameleon". The client code validates that "Chameleon" appears at byte offset 4 of the 13-byte response as a handshake gate. This string is the same for every device instance since the real hardware returns a static identifier.

MQTT Broker Unavailability

If the MQTT broker is unreachable at startup or drops mid-operation, the emulator does not crash. It retries every 5 seconds indefinitely. During disconnection:

  • All virtual serial ports remain open and responsive
  • Serial commands still get responses (using current/default pin values)
  • Outbound MQTT publishes are silently dropped
  • No inbound updates arrive, so pin values stay at whatever they were last set to (or defaults if the broker was never reachable)

When the broker becomes available again, the emulator reconnects, resubscribes to all device topics, and resumes normal operation.

MQTT as the Physical World

Since there is no physical hardware, MQTT bridges the gap. Remote sensor devices measure actual voltages, pin states, etc. and publish them to physical/{device_id}/pin/{N}/state or physical/{device_id}/pin/{N}/adc. The emulator subscribes to these topics and updates its internal pin state accordingly.

When the connected application sends adc 3\n, the emulator responds with whatever value was last received via MQTT for that pin. When a monitored digital pin changes state (detected by comparing old and new values on MQTT update), the emulator sends an unsolicited pin N V\n message over the serial port, exactly as the real hardware would.

In the outbound direction, when the application configures a pin or sets an output, the emulator publishes to vchameleon/{device_id}/pin/{N}/config or vchameleon/{device_id}/pin/{N}/output. This allows other systems to react to what the application is doing.

Frozen State (Remote Offline)

When the remote sensor device publishes physical/{device_id}/status → "offline", the emulator enters a frozen state for that device:

  • The virtual serial port stays open (the application remains connected)
  • All serial commands are silently ignored (no responses sent)
  • No MQTT messages are published
  • Pin state is frozen at last-known values

This simulates the real-world behavior of a uChameleon becoming unresponsive while still physically connected. When "online" is received, normal operation resumes immediately with the frozen state intact.

Multi-Device Support

Multiple devices are defined in config.yaml, each with a unique id. The daemon creates a separate PTY pair and asyncio task set for each device. All devices share a single MQTT connection. The MQTT bridge dispatches incoming messages to the correct device based on the {device_id} in the topic path.

Daemon Architecture

The main event loop uses asyncio.TaskGroup to run concurrently:

  • One MQTT client task (shared across all devices)
  • One serial read loop per device (reads from PTY master, dispatches to device command handler)
  • One ADC poll timer per device (placeholder for periodic sampling coordination)

Signal handlers for SIGTERM and SIGINT trigger a clean shutdown: PTY symlinks are removed, file descriptors are closed, and the process exits.

About

Appliction to simulate a Chameleon DAQ used in an Allstarlink Repeater and bridge it to MQQT sensors

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages