"""LED strip simulator with visual CLI output.
This module provides a simulator for testing beacon visualization without
requiring a real WLED device. The simulator displays the LED strip state
as colored dots in the terminal using ANSI escape codes.
The simulator implements the LEDController interface and can be used as a
drop-in replacement for WLEDHTTPController or WLEDUDPController.
Example:
Simulate beacon visualization::
from ble2wled.simulator import LEDSimulator
from ble2wled import BeaconState, run_wled_beacons
# Create simulator
simulator = LEDSimulator(led_count=60, rows=10, cols=6)
# Create beacon state
beacon_state = BeaconState()
# Run animation with simulator (no real WLED needed)
run_wled_beacons(
simulator,
led_count=60,
beacon_state=beacon_state,
update_interval=0.05
)
"""
import threading
from typing import List
from .wled import LEDController
[docs]
class LEDSimulator(LEDController):
"""LED strip simulator with visual terminal output.
Displays the LED strip as a grid of colored dots in the terminal using
ANSI 24-bit color codes. Useful for testing beacon visualization without
a real WLED device.
The grid is arranged in rows x cols format. For example, with 60 LEDs in
a 10x6 grid, LED 0 is top-left, LED 5 is top-right, LED 6 is
second-row-left, etc.
Attributes:
led_count (int): Total number of LEDs.
rows (int): Number of rows in display grid.
cols (int): Number of columns in display grid.
current_leds (List[List[int]]): Current LED RGB values.
lock (threading.Lock): Thread-safe access to LED data.
Example:
Create a 10x6 grid simulator::
simulator = LEDSimulator(led_count=60, rows=10, cols=6)
leds = [[255, 0, 0] for _ in range(60)] # All red
simulator.update(leds)
"""
[docs]
def __init__(self, led_count: int = 60, rows: int = 10, cols: int = 6):
"""Initialize the LED simulator.
Args:
led_count (int): Total number of LEDs. Default: 60.
Must equal rows x cols.
rows (int): Number of rows in display grid. Default: 10.
cols (int): Number of columns in display grid. Default: 6.
Raises:
ValueError: If rows x cols does not equal led_count.
Example:
Create 120-LED simulator (12 rows x 10 cols)::
simulator = LEDSimulator(led_count=120, rows=12, cols=10)
"""
super().__init__(host="simulator", led_count=led_count)
if rows * cols != led_count:
raise ValueError(
f"rows ({rows}) x cols ({cols}) = {rows * cols} "
f"does not equal led_count ({led_count})"
)
self.led_count = led_count
self.rows = rows
self.cols = cols
self.current_leds: List[List[int]] = [[0, 0, 0] for _ in range(led_count)]
self.lock = threading.Lock()
self._running = False
[docs]
def update(self, leds: List[List[int]]) -> None:
"""Update and display the LED strip.
Updates the current LED state and renders the grid to terminal.
Uses ANSI 24-bit true color escape codes for each LED.
Args:
leds (List[List[int]]): LED data as list of [R, G, B] triplets.
Expected length: led_count.
Example:
Display a gradient::
import time
from ble2wled.simulator import LEDSimulator
simulator = LEDSimulator(led_count=60)
for i in range(60):
leds = [[0, 0, 0] for _ in range(60)]
leds[i] = [255, 100, 0] # Orange
simulator.update(leds)
time.sleep(0.05)
"""
with self.lock:
self.current_leds = [list(led) for led in leds]
self._render()
def _render(self) -> None:
"""Render the LED grid to terminal.
Clears the terminal and displays the LED grid as a table of colored
squares using ANSI 24-bit color codes. Each LED is represented by
a colored space character.
Uses terminal escape sequences:
- Clear screen: \\033[H\\033[J
- Set color: \\033[38;2;R;G;Bm (foreground)
- Reset color: \\033[0m
- Cursor position: \\033[H
"""
with self.lock:
# Clear screen and move cursor to top-left
print("\033[H\033[J", end="", flush=True)
# Print header
print("LED Strip Simulator - Press Ctrl+C to exit")
print("=" * (self.cols * 4 + 2))
# Print LED grid
for row in range(self.rows):
for col in range(self.cols):
led_idx = row * self.cols + col
r, g, b = self.current_leds[led_idx]
# ANSI 24-bit color code for foreground
color_code = f"\033[38;2;{r};{g};{b}m"
reset_code = "\033[0m"
# Print colored block (use block character for better visibility)
print(f"{color_code}█{reset_code} ", end="")
print() # Newline after each row
# Print footer with stats
print("=" * (self.cols * 4 + 2))
avg_brightness = sum(
(r + g + b) // 3 for r, g, b in self.current_leds
) / len(self.current_leds)
print(f"Average brightness: {avg_brightness:.1f}/255")
[docs]
def get_snapshot(self) -> List[List[int]]:
"""Get current LED state.
Returns:
List[List[int]]: Copy of current LED state as list of [R, G, B].
Example:
Check current state::
leds = simulator.get_snapshot()
print(f"LED 0 color: RGB{tuple(leds[0])}")
"""
with self.lock:
return [list(led) for led in self.current_leds]
[docs]
class MockBeaconGenerator:
"""Generate mock beacon data for simulator testing.
Creates synthetic BLE beacon data that changes over time to simulate
real beacon movement and signal strength variation.
Attributes:
beacon_ids (List[str]): List of simulated beacon IDs.
positions (dict): Current position (0.0-1.0) for each beacon.
rssi_base (dict): Base RSSI value for each beacon.
Example:
Generate beacon updates::
from ble2wled.simulator import MockBeaconGenerator
from ble2wled import BeaconState
generator = MockBeaconGenerator(num_beacons=3)
beacon_state = BeaconState()
for _ in range(100):
beacons = generator.update()
for beacon_id, rssi in beacons.items():
beacon_state.update(beacon_id, rssi)
"""
[docs]
def __init__(self, num_beacons: int = 3, rssi_range: tuple = (-90, -30)):
"""Initialize mock beacon generator.
Args:
num_beacons (int): Number of simulated beacons. Default: 3.
rssi_range (tuple): (min_rssi, max_rssi) signal range. Default: (-90, -30).
Typical RSSI range for BLE: -90 (far/weak) to -30 (close/strong).
Example:
Generate 5 beacons with custom RSSI range::
generator = MockBeaconGenerator(
num_beacons=5,
rssi_range=(-100, -20)
)
"""
self.num_beacons = num_beacons
self.rssi_range = rssi_range
self.beacon_ids = [f"beacon_{i}" for i in range(num_beacons)]
self.positions = {bid: i / num_beacons for i, bid in enumerate(self.beacon_ids)}
self.rssi_base = {
bid: rssi_range[0] + (i / num_beacons) * (rssi_range[1] - rssi_range[0])
for i, bid in enumerate(self.beacon_ids)
}
self._time = 0.0
[docs]
def update(self, time_delta: float = 0.1) -> dict:
"""Update beacon positions and signal strengths.
Beacons move in a circular pattern and signal strength oscillates
around their base RSSI value, simulating realistic beacon movement.
Args:
time_delta (float): Time step for simulation. Default: 0.1 seconds.
Returns:
dict: Beacon data as {beacon_id: rssi_value}.
Example:
Step through beacon updates::
import time
generator = MockBeaconGenerator(num_beacons=3)
for _ in range(100):
beacons = generator.update(time_delta=0.05)
# Update beacon_state with beacons data
time.sleep(0.05)
"""
import math
self._time += time_delta
beacons = {}
for i, beacon_id in enumerate(self.beacon_ids):
# Circular motion: beacon moves around circle over time
angle = 2 * math.pi * (self._time / 10.0 + i / self.num_beacons)
pos = 0.5 + 0.4 * math.cos(angle)
# Signal strength varies with position (closer = stronger)
# Add some noise for realism
noise = 3 * math.sin(self._time * 2 + i)
rssi_base = self.rssi_range[0] + pos * (
self.rssi_range[1] - self.rssi_range[0]
)
rssi = rssi_base + noise
beacons[beacon_id] = int(rssi)
return beacons