Skip to content

Commit 4611864

Browse files
committed
Add ASCII terminal emulator
1 parent db95ce0 commit 4611864

File tree

3 files changed

+306
-0
lines changed

3 files changed

+306
-0
lines changed

examples/interstate75/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
test_flight_data.json
2+
secrets.py

examples/interstate75/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,34 @@ Once connected to the I75 device:
2727
FLIGHT_FINDER_API_KEY = ""
2828
```
2929
Run the `flight_display.py` script to start displaying flights
30+
31+
32+
## Emulator
33+
34+
This emulator allows testing of the `flight_display.py` Micropython code without needing to connect to the actual Interstate75 hardware.
35+
36+
```bash
37+
python3 emulator.py
38+
```
39+
40+
### Test Flight Data
41+
42+
The emulator loads test data from `test_flight_data.json`. Create and edit this file to test different scenarios:
43+
44+
```json
45+
{
46+
"found": true,
47+
"distance_km": 5.2,
48+
"flight": {
49+
"number": "BA123",
50+
"aircraft": {
51+
"model": "Airbus A320-232"
52+
},
53+
"route": {
54+
"origin_iata": "LHR",
55+
"destination_iata": "CDG"
56+
}
57+
}
58+
}
59+
60+
If nothing is displayed / the emulator quits, check the "quiet time" settings in `flight_display.py` to ensure the current time is outside of the configured quiet period.

examples/interstate75/emulator.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
#!/usr/bin/env python3
2+
"""
3+
ASCII Emulator for Interstate75 LED Matrix Display (32x64 pixels)
4+
This emulator allows testing the Micropython flight_display.py code on a laptop.
5+
"""
6+
7+
import time
8+
import sys
9+
import os
10+
import json
11+
from typing import Optional, Dict, Any
12+
13+
# mock Micropython modules that don't exist in regular Python
14+
class MockMachine:
15+
pass
16+
17+
class MockNetwork:
18+
STA_IF = 0
19+
20+
class WLAN:
21+
def __init__(self, interface):
22+
self.interface = interface
23+
self._active = False
24+
self._ssid = None
25+
self._password = None
26+
27+
def active(self, state=None):
28+
if state is not None:
29+
self._active = state
30+
return self._active
31+
32+
def config(self, **kwargs):
33+
pass
34+
35+
def connect(self, ssid, password):
36+
self._ssid = ssid
37+
self._password = password
38+
39+
def status(self):
40+
return 3 # Connected
41+
42+
def ifconfig(self):
43+
return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8')
44+
45+
class MockNTPTime:
46+
host = "pool.ntp.org"
47+
48+
@staticmethod
49+
def settime():
50+
pass
51+
52+
class MockUrequests:
53+
class Response:
54+
def __init__(self, status_code, json_data):
55+
self.status_code = status_code
56+
self._json_data = json_data
57+
58+
def json(self):
59+
return self._json_data
60+
61+
def close(self):
62+
pass
63+
64+
@staticmethod
65+
def get(url, headers=None):
66+
test_data_file = os.path.join(os.path.dirname(__file__), 'test_flight_data.json')
67+
68+
try:
69+
with open(test_data_file, 'r') as f:
70+
test_data = json.load(f)
71+
print(f"[EMULATOR] Loaded test data from {test_data_file}")
72+
except FileNotFoundError:
73+
print(f"[EMULATOR] Warning: {test_data_file} not found, using default data")
74+
test_data = {
75+
"found": True,
76+
"distance_km": 5.2,
77+
"flight": {
78+
"number": "BA123",
79+
"aircraft": {
80+
"model": "Airbus A320-232"
81+
},
82+
"route": {
83+
"origin_iata": "LHR",
84+
"destination_iata": "CDG"
85+
}
86+
}
87+
}
88+
except json.JSONDecodeError as e:
89+
print(f"[EMULATOR] Error parsing {test_data_file}: {e}")
90+
return MockUrequests.Response(500, {})
91+
92+
print(f"[EMULATOR] Mock HTTP GET: {url}")
93+
return MockUrequests.Response(200, test_data)
94+
95+
# mock Interstate75 display
96+
class MockDisplay:
97+
def __init__(self, width, height):
98+
self.width = width
99+
self.height = height
100+
# store both character and color for each pixel: (char, color)
101+
# though we're not actually rendering colors in ASCII, yet...
102+
self.buffer = [[(' ', 'BLACK') for _ in range(width)] for _ in range(height)]
103+
self.current_pen = 'BLACK'
104+
self.font = "bitmap8"
105+
106+
def create_pen(self, r, g, b):
107+
"""Return a color name based on RGB values"""
108+
if r == 0 and g == 0 and b == 0:
109+
return 'BLACK'
110+
elif r > 200 and g > 200 and b > 200:
111+
return 'WHITE'
112+
elif b > g and b > r:
113+
return 'BLUE'
114+
elif r > g and r > b:
115+
return 'RED'
116+
elif g > r and g > b:
117+
return 'GREEN'
118+
elif g > 0 and b > 0 and r == 0:
119+
return 'CYAN'
120+
elif r > 0 and b > 0 and g == 0:
121+
return 'MAGENTA'
122+
elif r > 0 and g > 0 and b == 0:
123+
return 'YELLOW'
124+
else:
125+
return 'WHITE'
126+
127+
def set_pen(self, color):
128+
self.current_pen = color
129+
130+
def set_font(self, font):
131+
self.font = font
132+
133+
def clear(self):
134+
"""Clear the buffer"""
135+
for y in range(self.height):
136+
for x in range(self.width):
137+
self.buffer[y][x] = (' ', self.current_pen)
138+
139+
def text(self, text, x, y, width, scale):
140+
"""Draw text on the buffer"""
141+
# reduce character spacing from 6 to 4 pixels for tighter rendering
142+
char_width = 4
143+
text = str(text)
144+
145+
for i, char in enumerate(text):
146+
char_x = x + (i * char_width)
147+
if char_x >= self.width:
148+
break
149+
if 0 <= y < self.height and 0 <= char_x < self.width:
150+
self.buffer[y][char_x] = (char, self.current_pen)
151+
152+
def rectangle(self, x, y, width, height):
153+
"""Draw a filled rectangle"""
154+
for dy in range(height):
155+
for dx in range(width):
156+
px = x + dx
157+
py = y + dy
158+
if 0 <= px < self.width and 0 <= py < self.height:
159+
self.buffer[py][px] = ('█', self.current_pen)
160+
161+
class MockInterstate75:
162+
COLOR_ORDER_RGB = 0
163+
COLOR_ORDER_GRB = 1
164+
165+
def __init__(self, display, color_order):
166+
self.display = MockDisplay(64, 32)
167+
self.width = 64
168+
self.height = 32
169+
self.color_order = color_order
170+
171+
def update(self):
172+
"""Render the display buffer to ASCII - simplified without colors"""
173+
# build the entire output as a string first, then print once
174+
output = []
175+
176+
output.append('\033[2J\033[H')
177+
output.append("╔" + "═" * self.width + "╗\n")
178+
179+
for row in range(0, self.height, 2):
180+
output.append("║")
181+
for col in range(self.width):
182+
top_char, top_color = self.display.buffer[row][col]
183+
if row + 1 < self.height:
184+
bottom_char, bottom_color = self.display.buffer[row + 1][col]
185+
else:
186+
bottom_char, bottom_color = ' ', 'BLACK'
187+
188+
if top_char not in (' ', '█'):
189+
output.append(top_char)
190+
elif bottom_char not in (' ', '█'):
191+
output.append(bottom_char)
192+
elif top_color == 'BLACK' and bottom_color == 'BLACK':
193+
output.append(' ')
194+
elif top_color == bottom_color and top_color != 'BLACK':
195+
output.append('█')
196+
elif top_color != 'BLACK' and bottom_color == 'BLACK':
197+
output.append(' ')
198+
elif top_color == 'BLACK' and bottom_color != 'BLACK':
199+
output.append(' ')
200+
else:
201+
output.append('▀')
202+
output.append("║\n")
203+
204+
output.append("╚" + "═" * self.width + "╝\n")
205+
output.append("32x64 LED Matrix Emulator | Ctrl+C to exit\n")
206+
207+
print(''.join(output), end='', flush=True)
208+
209+
# install mocks into sys.modules so imports work
210+
sys.modules['machine'] = MockMachine()
211+
sys.modules['network'] = MockNetwork()
212+
sys.modules['ntptime'] = MockNTPTime()
213+
sys.modules['urequests'] = MockUrequests()
214+
215+
# mock the Interstate75 module
216+
class Interstate75Module:
217+
DISPLAY_INTERSTATE75_64X32 = 0
218+
Interstate75 = MockInterstate75
219+
220+
sys.modules['interstate75'] = Interstate75Module()
221+
222+
# now we can import and run the actual flight display code
223+
# we need to override the main() function to avoid the infinite loop
224+
if __name__ == "__main__":
225+
print("Starting Interstate75 LED Matrix Emulator...")
226+
print("This emulator displays ASCII output in your terminal.")
227+
print()
228+
229+
import flight_display
230+
231+
original_main = flight_display.main
232+
233+
def emulator_main():
234+
"""Modified main function for emulator testing"""
235+
class Secrets:
236+
WIFI_SSID = "TestNetwork"
237+
WIFI_PASSWORD = "password123"
238+
FLIGHT_FINDER_API_KEY = "test-api-key"
239+
240+
sys.modules['secrets'] = Secrets()
241+
242+
print("[EMULATOR] Connecting to WiFi (mocked)...")
243+
flight_display.network_connect(Secrets.WIFI_SSID, Secrets.WIFI_PASSWORD)
244+
time.sleep(1)
245+
246+
print("[EMULATOR] Syncing time (mocked)...")
247+
time.sleep(1)
248+
249+
iterations = 5
250+
251+
for i in range(iterations):
252+
print(f"\n[EMULATOR] Iteration {i + 1}/{iterations}")
253+
254+
if flight_display.is_quiet_period():
255+
print("[EMULATOR] Quiet time detected")
256+
flight_display.clear_display()
257+
else:
258+
flight_data = flight_display.fetch_flight_data(Secrets.FLIGHT_FINDER_API_KEY)
259+
flight_display.display_flight_data(flight_data)
260+
261+
for second in range(flight_display.REFRESH_INTERVAL):
262+
progress = second / flight_display.REFRESH_INTERVAL
263+
flight_display.draw_countdown(progress)
264+
flight_display.i75.update()
265+
time.sleep(1)
266+
267+
print("\n[EMULATOR] Test complete!")
268+
269+
try:
270+
emulator_main()
271+
except KeyboardInterrupt:
272+
print("\n[EMULATOR] Stopped by user")
273+
sys.exit(0)

0 commit comments

Comments
 (0)