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.
- Linux (PTY-based virtual serial ports)
- Python 3.11+
- An MQTT broker (e.g. Mosquitto)
- Remote sensor devices publishing to the MQTT broker
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 .Copy the example environment file and fill in your broker details:
cp .env.example .envEdit .env:
MQTT_BROKER=192.168.1.50
MQTT_PORT=1883
MQTT_USERNAME=vchameleon
MQTT_PASSWORD=yourpassword
MQTT_TLS=false
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: outThe 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.
Requires root to create symlinks in /dev:
sudo .venv/bin/vchameleon -c config.yaml -e .env -vThis 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.
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/vchameleonInstall 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 vchameleonCheck status:
sudo systemctl status vchameleon
sudo journalctl -u vchameleon -f| 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 |
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 |
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 |
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"
Connect to a virtual device with any serial tool:
picocom /dev/vchameleon-cham-1Try 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)
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.
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.
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, orout(they have ADC hardware) - Pins 9-18 can be
inp,in, orout(they have pullup resistors) inadcis rejected on pins 9-18,inpis rejected 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 Nalways works on pins 1-8, returning the last MQTT ADC valuepin N statealways works on any configured pin, returning the last MQTT digital value- Pins 9-18 do not have ADC hardware, so
adc Nreturns 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.
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.
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.
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.
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.
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.
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.