diff --git a/docs/badge/firmware-development.rst b/docs/badge/firmware-development.rst index 65579de0eede0d11049c08ea03ebbb9a45bdae2c..f3338b31e5e9ae525c45e28074c82afa7fce21ae 100644 --- a/docs/badge/firmware-development.rst +++ b/docs/badge/firmware-development.rst @@ -53,7 +53,7 @@ For running the simulator, you'll need Python 3 with pygame and wasmer: :: $ python3 -m venv venv - $ venv/bin/pip install pygame requests + $ venv/bin/pip install pygame requests pymad $ venv/bin/pip install wasmer wasmer-compiler-cranelift .. warning:: diff --git a/docs/badge/programming.rst b/docs/badge/programming.rst index 57b99928f7588070727ed9146f494fdf6c1f5297..d504edcf1528d3c536f4d7595b2096ac63739e32 100644 --- a/docs/badge/programming.rst +++ b/docs/badge/programming.rst @@ -735,9 +735,8 @@ Currently the simulator supports the display, LEDs, the buttons, accelerometer (in 2D) and some static input values from the gyroscope, temperature sensor and pressure sensor. -It does **not** support any audio API, and in fact currently doesn't even stub -out the relevant API methods, so it will crash when attempting to run any Music -app. It also does not support positional captouch APIs. +It does **not** support most of the audio APIs. It also does not support +positional captouch APIs. To set the simulator up, clone the repository and prepare a Python virtual environment with the required packages: @@ -747,7 +746,7 @@ environment with the required packages: $ git clone https://git.flow3r.garden/flow3r/flow3r-firmware $ cd flow3r-firmware $ python3 -m venv venv - $ venv/bin/pip install pygame requests + $ venv/bin/pip install pygame requests pymad $ venv/bin/pip install wasmer wasmer-compiler-cranelift .. warning:: diff --git a/python_payload/apps/appearance/__init__.py b/python_payload/apps/appearance/__init__.py index 979081fd6d2c8c5c016500a254d4ec0bce0a4c1f..b00fe99e0b44e850ffdf494d9d63f5ef167cdf4d 100644 --- a/python_payload/apps/appearance/__init__.py +++ b/python_payload/apps/appearance/__init__.py @@ -4,6 +4,7 @@ from st3m import settings import leds import sys_display from st3m.ui import colours, led_patterns +from ctx import Context class App(Application): diff --git a/python_payload/apps/graphics_mode/__init__.py b/python_payload/apps/graphics_mode/__init__.py index 1de216533ba85cc2284098c37b189856e0af32b3..39da6c0fec1b846780d928601ce81ec9d17f46c5 100644 --- a/python_payload/apps/graphics_mode/__init__.py +++ b/python_payload/apps/graphics_mode/__init__.py @@ -1,5 +1,6 @@ from st3m.application import Application import math, random, sys_display +from ctx import Context class App(Application): diff --git a/python_payload/apps/mandelbrot/__init__.py b/python_payload/apps/mandelbrot/__init__.py index 0de97fb48b3be42019035e12ba80d1e51e9c0c81..d969755240f115f34d380e4adc61036924b3208d 100644 --- a/python_payload/apps/mandelbrot/__init__.py +++ b/python_payload/apps/mandelbrot/__init__.py @@ -1,5 +1,6 @@ from st3m.application import Application import sys_display, math, random +from ctx import Context class App(Application): @@ -36,7 +37,7 @@ class App(Application): if y < height: zy = y * (self.yb - self.ya) / (height - 1) + self.ya inners = 0 - for x in range(width / 2): + for x in range(width // 2): zx = x * (self.xb - self.xa) / (width - 1) + self.xa z = zy + zx * 1j c = z diff --git a/python_payload/apps/w1f1/k3yboard.py b/python_payload/apps/w1f1/k3yboard.py index c5333d1b783af816c258f27f67b0cba91a0bddae..a04a342012988139bfa7e781199ab8c5c57b581b 100644 --- a/python_payload/apps/w1f1/k3yboard.py +++ b/python_payload/apps/w1f1/k3yboard.py @@ -6,7 +6,7 @@ from ctx import Context import st3m.run from st3m import Responder, InputState from st3m.application import Application, ApplicationContext -from st3m.goose import ABCBase, Enum +from st3m.goose import ABCBase, Enum, Optional from st3m.ui.view import BaseView, ViewManager from st3m.utils import tau @@ -57,7 +57,7 @@ class TextInputModel(Model): def input_right(self) -> str: return self._input_right - def move_cursor(self, direction: self.CursorDirection) -> None: + def move_cursor(self, direction: "TextInputModel.CursorDirection") -> None: """ Moves the cursor one step in specified direction. Any pending input will be committed beforehand. """ diff --git a/python_payload/apps/wurzelitzer/__init__.py b/python_payload/apps/wurzelitzer/__init__.py index 3d6d207acb64d865ae6ecbea875ced2527c9fa08..63e8168e830b441aa029ce4293fd3534e8f1f24a 100644 --- a/python_payload/apps/wurzelitzer/__init__.py +++ b/python_payload/apps/wurzelitzer/__init__.py @@ -1,5 +1,6 @@ -import st3m.run, st3m.wifi, media, os, ctx +import st3m.run, st3m.wifi, media, uos, ctx from st3m.application import Application +from st3m.utils import sd_card_plugged RADIOSTATIONS = [ "http://radio-paralax.de:8000/", @@ -16,14 +17,15 @@ class App(Application): def __init__(self, app_ctx): super().__init__(app_ctx) self._streams = RADIOSTATIONS.copy() - for entry in os.ilistdir("/sd/"): - if entry[1] == 0x8000: - if ( - entry[0].endswith(".mp3") - or entry[0].endswith(".mod") - or entry[0].endswith(".mpg") - ): - self._streams.append("/sd/" + entry[0]) + if sd_card_plugged(): + for entry in uos.ilistdir("/sd/"): + if entry[1] == 0x8000: + if ( + entry[0].endswith(".mp3") + or entry[0].endswith(".mod") + or entry[0].endswith(".mpg") + ): + self._streams.append("/sd/" + entry[0]) if len(self._streams) > len(RADIOSTATIONS): # skip radio stations, they are available by going back self._stream_no = len(RADIOSTATIONS) diff --git a/python_payload/st3m/application.py b/python_payload/st3m/application.py index 38c1550b2fc25681f92d50c73b646f6f1ef3705d..6d213c1a5e5c60de5a0bd8cae726dec8c069299f 100644 --- a/python_payload/st3m/application.py +++ b/python_payload/st3m/application.py @@ -8,6 +8,7 @@ from st3m.input import InputState import st3m.wifi from st3m.goose import Optional, List, Dict from st3m.logging import Log +from st3m.utils import is_simulator from st3m import settings from ctx import Context from st3m.ui import led_patterns @@ -179,11 +180,13 @@ class BundleMetadata: containing_path = os.path.dirname(self.path) package_name = os.path.basename(self.path) - if sys.path[1].endswith("python_payload"): + if is_simulator(): # We are in the simulator. Hack around to get this to work. prefix = "/flash/sys" - assert containing_path.startswith(prefix) - containing_path = containing_path.replace(prefix, sys.path[1]) + if containing_path.startswith(prefix): + containing_path = containing_path.replace(prefix, sys.path[1]) + else: + containing_path = containing_path.replace("/flash", "/tmp/flow3r-sim") new_sys_path = old_sys_path + [containing_path] self._sys_path_set(new_sys_path) @@ -322,6 +325,7 @@ class MenuItemAppLaunch(MenuItem): self._instance = self._bundle.load() except BundleLoadException as e: log.error(f"Could not load {self.label()}: {e}") + sys.print_exception(e) err = LoadErrorView(e) vm.push(err) return diff --git a/python_payload/st3m/run.py b/python_payload/st3m/run.py index dc319b71716d850938cbaa657b09a8435c2baaa2..149847203a5993cb8660680b3049b061d88100ba 100644 --- a/python_payload/st3m/run.py +++ b/python_payload/st3m/run.py @@ -22,6 +22,7 @@ from st3m.about import About from st3m import settings_menu as settings, logging, processors, wifi from st3m.ui import led_patterns import st3m.wifi +import st3m.utils import captouch, audio, leds, gc, sys_buttons, sys_display, sys_mode, media, bl00mbox import os @@ -182,6 +183,9 @@ def run_app(klass, bundle_path=None): def _yeet_local_changes() -> None: + if st3m.utils.is_simulator(): + # not implemented in simulator + return os.remove("/flash/sys/.sys-installed") machine.reset() diff --git a/python_payload/st3m/ui/elements/overlays.py b/python_payload/st3m/ui/elements/overlays.py index c95b6e6932c44c2eb43e32356833eb83e07eef05..0651baf49cedd5d312c81c39798b7f3f007a145d 100644 --- a/python_payload/st3m/ui/elements/overlays.py +++ b/python_payload/st3m/ui/elements/overlays.py @@ -195,6 +195,7 @@ class Compositor(Responder): self._last_clip.copy(self._clip_rect) self._last_enabled = [] else: + self._last_fps_string = "" self._clip_rect.clear() for i in range(len(self._enabled)): redraw = ( diff --git a/python_payload/st3m/utils.py b/python_payload/st3m/utils.py index f6747b5058e1ea7b5991ea20547f2ec8c3fca53d..f16295eb23ccd960bfcf2d090b254f8b3dd3e090 100644 --- a/python_payload/st3m/utils.py +++ b/python_payload/st3m/utils.py @@ -1,5 +1,6 @@ import math import os +import sys_kernel try: import inspect @@ -146,4 +147,8 @@ def sd_card_plugged() -> bool: return False +def is_simulator() -> bool: + return sys_kernel.hardware_version() == "simulator" + + tau = math.pi * 2 diff --git a/sim/fakes/_sim.py b/sim/fakes/_sim.py index ead1033d92e8ab4afd1514b3f1da170ecc8e83c9..0881f3136b1f2ec7160f25884dd1c16597bf8776 100644 --- a/sim/fakes/_sim.py +++ b/sim/fakes/_sim.py @@ -25,6 +25,23 @@ SCREENSHOT = False SCREENSHOT_DELAY = 5 +def path_replace(p): + simpath = "/tmp/flow3r-sim" + projectpath = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + ) + if p.startswith("/flash/sys"): + p = p[len("/flash/sys") :] + p = projectpath + "/python_payload" + p + return p + if p.startswith("/flash"): + p = p[len("/flash") :] + p = simpath + p + return p + + return p + + class Input: """ Input implements an input overlay (for petals or buttons) that can be @@ -83,6 +100,8 @@ class Input: if ev.type == pygame.QUIT: pygame.quit() sys.exit() + if ev.type == pygame.USEREVENT: + _sim.render_gui_lazy() if prev_hover != self._mouse_hover: return True @@ -512,7 +531,7 @@ class Simulation: self._render_oled(self._oled_surface, fb) def set_led_rgb(self, ix, r, g, b): - self.led_state_buf[ix] = (r, g, b) + self.led_state_buf[ix] = (r * 255, g * 255, b * 255) def leds_update(self): for i, s in enumerate(_sim.led_state_buf): @@ -527,11 +546,23 @@ _sim = Simulation() class FramebufferManager: def __init__(self): self._free = [] - for _ in range(2): - fb, c = ctx._wasm.ctx_new_for_framebuffer(240, 240) + + # Significant difference between on-device Ctx and simulation Ctx: we + # render to a BRGA8 (24bpp color + 8bpp alpha) buffer instead of 16bpp + # RGB565 like the device does. This allows us to directly blit the ctx + # framebuffer into pygame's surfaces, which is a _huge_ speed benefit + # (difference between ~10FPS and 500+FPS!). + + for _ in range(1): + fb, c = ctx._wasm.ctx_new_for_framebuffer(240, 240, 240 * 4, ctx.RGBA8) ctx._wasm.ctx_apply_transform(c, 1, 0, 120, 0, 1, 120, 0, 0, 1) self._free.append((fb, c)) + self._overlay = ctx._wasm.ctx_new_for_framebuffer(240, 240, 240 * 4, ctx.RGBA8) + ctx._wasm.ctx_apply_transform(self._overlay[1], 1, 0, 120, 0, 1, 120, 0, 0, 1) + + self._output = ctx._wasm.ctx_new_for_framebuffer(240, 240, 240 * 4, ctx.BGRA8) + def get(self): if len(self._free) == 0: return None, None @@ -543,8 +574,42 @@ class FramebufferManager: def put(self, fb, ctx): self._free.append((fb, ctx)) + def get_overlay(self): + return self._overlay + + def get_output(self, fbp): + return self._output + + def draw(self, fb): + ctx._wasm.ctx_define_texture( + self._output[1], "!fb", 240, 240, 240 * 4, ctx.RGBA8, fb, 0 + ) + ctx._wasm.ctx_parse(self._output[1], "compositingMode copy") + ctx._wasm.ctx_draw_texture(self._output[1], "!fb", 0, 0, 240, 240) + + if overlay_clip[2] and overlay_clip[3]: + ctx._wasm.ctx_define_texture( + self._output[1], + "!overlay", + 240, + 240, + 240 * 4, + ctx.RGBA8, + self._overlay[0], + 0, + ) + ctx._wasm.ctx_parse(self._output[1], "compositingMode sourceOver") + ctx._wasm.ctx_draw_texture(self._output[1], "!overlay", 0, 0, 240, 240) + fbm = FramebufferManager() +overlay_ctxs = [] +overlay_clip = (0, 0, 240, 240) + + +def set_overlay_clip(x, y, x2, y2): + global overlay_clip + overlay_clip = (x, y, x2 - x, y2 - y) def get_ctx(): @@ -554,19 +619,32 @@ def get_ctx(): def get_overlay_ctx(): dctx = ctx._wasm.ctx_new_drawlist(240, 240) + overlay_ctxs.append(dctx) return ctx.Context(dctx) def display_update(subctx): _sim.process_events() + + if subctx._ctx in overlay_ctxs: + overlay_ctxs.remove(subctx._ctx) + fbp, c = fbm.get_overlay() + ctx._wasm.ctx_render_ctx(subctx._ctx, c) + ctx._wasm.ctx_destroy(subctx._ctx) + return + fbp, c = fbm.get() + if fbp is None: return ctx._wasm.ctx_render_ctx(subctx._ctx, c) ctx._wasm.ctx_destroy(subctx._ctx) - fb = ctx._wasm._i.exports.memory.uint8_view(fbp) + fbm.draw(fbp) + + fb = ctx._wasm._i.exports.memory.uint8_view(fbm.get_output(fbp)[0]) + _sim.render_display(fb) _sim.render_gui_now() diff --git a/sim/fakes/audio.py b/sim/fakes/audio.py index 1bcae08891e46314834c1499a25f0b332702f310..254ef1040aa284f1d82bf60f6becd3e48ac45b75 100644 --- a/sim/fakes/audio.py +++ b/sim/fakes/audio.py @@ -1,6 +1,12 @@ _volume = 0 _muted = False +INPUT_SOURCE_NONE = None +INPUT_SOURCE_AUTO = None +INPUT_SOURCE_HEADSET_MIC = None +INPUT_SOURCE_LINE_IN = None +INPUT_SOURCE_ONBOARD_MIC = None + def set_volume_dB(v: float) -> None: global _volume @@ -13,7 +19,7 @@ def get_volume_dB() -> float: def get_volume_relative() -> float: - return 0 + return (_volume + 47) / (47 + 14) def headphones_set_volume_dB(v: float) -> None: @@ -125,3 +131,35 @@ def line_in_get_allowed() -> bool: def onboard_mic_to_speaker_get_allowed() -> bool: return False + + +def input_thru_set_source(source): + pass + + +def input_thru_get_source(): + return None + + +def input_engines_get_source_avail(source): + return False + + +def headset_mic_get_allowed(): + return False + + +def input_engines_get_source(): + return None + + +def input_thru_get_mute(): + return False + + +def input_thru_set_mute(mute): + pass + + +def input_engines_set_source(source): + pass diff --git a/sim/fakes/bl00mbox.py b/sim/fakes/bl00mbox.py index a7f292e72284f2240f79138afb07738bd7b8765b..92bedec458c12b623f115564b0f7240b7fbdd35e 100644 --- a/sim/fakes/bl00mbox.py +++ b/sim/fakes/bl00mbox.py @@ -1,41 +1,110 @@ -class tinysynth: - def __init__(self, a): - pass +import pygame +from _sim import path_replace - def decay_ms(self, a): - pass - def decay(self, a): +class _mock(list): + def __init__(self, *args, **kwargs): pass - def waveform(self, a): - pass + def __getattr__(self, attr): + if attr in ["tone", "value"]: + return 0 + if attr in ["trigger_state"]: + return lambda *args: 0 + return _mock() - def tone(self, note): - pass + def __call__(self, *args, **kwargs): + return _mock() - def start(self): - pass - def sustain(self, a): +class Channel: + def __init__(self, id): pass - def attack_ms(self, a): - pass + def new(self, a, *args, **kwargs): + return a(self, *args, **kwargs) - def release_ms(self, a): + def clear(self): pass - def volume(self, a): - pass + mixer = None + channel_num = 0 + volume = 8000 - def stop(self): - pass +class _patches(_mock): + class sampler(_mock): + class Signals(_mock): + class Trigger(_mock): + def __init__(self, sampler): + self._sampler = sampler -class Channel: - def __init__(self, id): - pass + def start(self): + self._sampler._sound.set_volume( + self._sampler._channel.volume / 32767 + ) + self._sampler._sound.play() - def clear(self): - pass + def __init__(self, sampler): + self._sampler = sampler + self._trigger = patches.sampler.Signals.Trigger(sampler) + + @property + def trigger(self): + return self._trigger + + @trigger.setter + def trigger(self, val): + pass + + def _convert_filename(self, filename): + if filename.startswith("/flash/") or filename.startswith("/sd/"): + return filename + elif filename.startswith("/"): + return "/flash/" + filename + else: + return "/flash/sys/samples/" + filename + + def __init__(self, channel, path): + if type(path) == int: + self._signals = _mock() + return + self._sound = pygame.mixer.Sound(path_replace(self._convert_filename(path))) + self._signals = patches.sampler.Signals(self) + self._channel = channel + + @property + def signals(self): + return self._signals + + +class _helpers(_mock): + def sct_to_note_name(self, sct): + sct = sct - 18367 + 100 + octave = ((sct + 9 * 200) // 2400) + 4 + tones = ["A", "Bb", "B", "C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab"] + tone = tones[(sct // 200) % 12] + return tone + str(octave) + + def note_name_to_sct(self, name): + tones = ["A", "Bb", "B", "C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab"] + semitones = tones.index(name[0]) + if semitones > 2: + semitones -= 12 + if name[1] == "b": + octave = int(name[2:]) + semitones -= 1 + elif name[1] == "#": + octave = int(name[2:]) + semitones += 1 + else: + octave = int(name[1:]) + return 18367 + (octave - 4) * 2400 + (200 * semitones) + + def sct_to_freq(sct): + return 440 * 2 ** ((sct - 18367) / 2400) + + +plugins = _mock() +patches = _patches() +helpers = _helpers() diff --git a/sim/fakes/ctx.py b/sim/fakes/ctx.py index 925271793f7113c333034b43daec7040721ab1a6..608cef0dd03331caf136a8d7b0c63f7796fd0035 100644 --- a/sim/fakes/ctx.py +++ b/sim/fakes/ctx.py @@ -46,20 +46,14 @@ class Wasm: self._i.exports.ctx_parse(ctx, p) self.free(p) - def ctx_new_for_framebuffer(self, width, height): + def ctx_new_for_framebuffer(self, width, height, stride, format): """ Call ctx_new_for_framebuffer, but also first allocate the underlying framebuffer and return it alongside the Ctx*. """ - fb = self.malloc(width * height * 4) - # Significant difference between on-device Ctx and simulation Ctx: we - # render to a BRGA8 (24bpp color + 8bpp alpha) buffer instead of 16bpp - # RGB565 like the device does. This allows us to directly blit the ctx - # framebuffer into pygame's surfaces, which is a _huge_ speed benefit - # (difference between ~10FPS and 500+FPS!). - BRGA8 = 5 + fb = self.malloc(stride * height) return fb, self._i.exports.ctx_new_for_framebuffer( - fb, width, height, width * 4, BRGA8 + fb, width, height, stride, format ) def ctx_new_drawlist(self, width, height): @@ -69,6 +63,29 @@ class Wasm: args = [float(a) for a in args] return self._i.exports.ctx_apply_transform(ctx, *args) + def ctx_define_texture(self, ctx, eid, *args): + s = eid.encode("utf-8") + slen = len(s) + 1 + p = self.malloc(slen) + mem = self._i.exports.memory.uint8_view(p) + mem[0 : slen - 1] = s + mem[slen - 1] = 0 + res = self._i.exports.ctx_define_texture(ctx, p, *args) + self.free(p) + return res + + def ctx_draw_texture(self, ctx, eid, *args): + s = eid.encode("utf-8") + slen = len(s) + 1 + p = self.malloc(slen) + mem = self._i.exports.memory.uint8_view(p) + mem[0 : slen - 1] = s + mem[slen - 1] = 0 + args = [float(a) for a in args] + res = self._i.exports.ctx_draw_texture(ctx, p, *args) + self.free(p) + return res + def ctx_text_width(self, ctx, text): s = text.encode("utf-8") slen = len(s) + 1 @@ -80,15 +97,55 @@ class Wasm: self.free(p) return res + def ctx_x(self, ctx): + return self._i.exports.ctx_x(ctx) + + def ctx_y(self, ctx): + return self._i.exports.ctx_y(ctx) + + def ctx_logo(self, ctx, *args): + args = [float(a) for a in args] + return self._i.exports.ctx_logo(ctx, *args) + def ctx_destroy(self, ctx): return self._i.exports.ctx_destroy(ctx) def ctx_render_ctx(self, ctx, dctx): return self._i.exports.ctx_render_ctx(ctx, dctx) + def stbi_load_from_memory(self, buf): + p = self.malloc(len(buf)) + mem = self._i.exports.memory.uint8_view(p) + mem[0 : len(buf)] = buf + wh = self.malloc(4 * 3) + res = self._i.exports.stbi_load_from_memory(p, len(buf), wh, wh + 4, wh + 8, 4) + whmem = self._i.exports.memory.uint32_view(wh // 4) + r = (res, whmem[0], whmem[1], whmem[2]) + self.free(p) + self.free(wh) + + res, w, h, c = r + b = self._i.exports.memory.uint8_view(res) + if c == 3: + return r + for j in range(h): + for i in range(w): + b[i * 4 + j * w * 4 + 0] = int( + b[i * 4 + j * w * 4 + 0] * b[i * 4 + j * w * 4 + 3] / 255 + ) + b[i * 4 + j * w * 4 + 1] = int( + b[i * 4 + j * w * 4 + 1] * b[i * 4 + j * w * 4 + 3] / 255 + ) + b[i * 4 + j * w * 4 + 2] = int( + b[i * 4 + j * w * 4 + 2] * b[i * 4 + j * w * 4 + 3] / 255 + ) + return r + _wasm = Wasm() +_img_cache = {} + class Context: """ @@ -106,9 +163,13 @@ class Context: END = "end" MIDDLE = "middle" BEVEL = "bevel" + NONE = "none" + COPY = "copy" def __init__(self, _ctx): self._ctx = _ctx + self._font_size = 0 + self._line_width = 0 @property def image_smoothing(self): @@ -116,7 +177,7 @@ class Context: @image_smoothing.setter def image_smoothing(self, v): - self._emit(f"imageSmoothing 0") + self._emit(f"imageSmoothing {v}") @property def text_align(self): @@ -134,12 +195,21 @@ class Context: def text_baseline(self, v): self._emit(f"textBaseline {v}") + @property + def compositing_mode(self): + return Context.NONE + + @compositing_mode.setter + def compositing_mode(self, v): + self._emit(f"compositingMode {v}") + @property def line_width(self): - return None + return self._line_width @line_width.setter def line_width(self, v): + self._line_width = v self._emit(f"lineWidth {v:.3f}") @property @@ -152,10 +222,11 @@ class Context: @property def font_size(self): - return None + return self._font_size @font_size.setter def font_size(self, v): + self._font_size = v self._emit(f"fontSize {v:.3f}") @property @@ -166,23 +237,43 @@ class Context: def global_alpha(self, v): self._emit(f"globalAlpha {v:.3f}") + @property + def x(self): + return _wasm.ctx_x(self._ctx) + + @property + def y(self): + return _wasm.ctx_y(self._ctx) + def _emit(self, text): _wasm.ctx_parse(self._ctx, text) + def logo(self, x, y, dim): + _wasm.ctx_logo(self._ctx, x, y, dim) + return self + def move_to(self, x, y): - self._emit(f"moveTo {int(x)} {int(y)}") + self._emit(f"moveTo {x:.3f} {y:.3f}") return self def curve_to(self, a, b, c, d, e, f): - self._emit(f"curveTo {int(a)} {int(b)} {int(c)} {int(d)}") + self._emit(f"curveTo {a:.3f} {b:.3f} {c:.3f} {d:.3f} {e:.3f} {f:.3f}") + return self + + def quad_to(self, a, b, c, d): + self._emit(f"quadTo {a:.3f} {b:.3f} {c:.3f} {d:.3f}") return self def rel_move_to(self, x, y): - self._emit(f"relMoveTo {int(x)} {int(y)}") + self._emit(f"relMoveTo {x:.3f} {y:.3f}") return self def rel_curve_to(self, a, b, c, d, e, f): - self._emit(f"relCurveTo {int(a)} {int(b)} {int(c)} {int(d)}") + self._emit(f"relCurveTo {a:.3f} {b:.3f} {c:.3f} {d:.3f} {e:.3f} {f:.3f}") + return self + + def rel_quad_to(self, a, b, c, d): + self._emit(f"relQuadTo {a:.3f} {b:.3f} {c:.3f} {d:.3f}") return self def close_path(self): @@ -190,7 +281,7 @@ class Context: return self def translate(self, x, y): - self._emit(f"translate {int(x)} {int(y)}") + self._emit(f"translate {x:.3f} {y:.3f}") return self def scale(self, x, y): @@ -198,7 +289,11 @@ class Context: return self def line_to(self, x, y): - self._emit(f"lineTo {int(x)} {int(y)}") + self._emit(f"lineTo {x:.3f} {y:.3f}") + return self + + def rel_line_to(self, x, y): + self._emit(f"relLineTo {x:.3f} {y:.3f}") return self def rotate(self, v): @@ -236,21 +331,19 @@ class Context: def round_rectangle(self, x, y, width, height, radius): self._emit( - f"roundRectangle {int(x)} {int(y)} {int(width)} {int(height)} {radius}" + f"roundRectangle {x:.3f} {y:.3f} {width:.3f} {height:.3f} {radius:.3f}" ) return self - def image(self, path, x, y, width, height): - # TODO: replace with base64 encoded, decoded version of image - self._emit(f"save") - self._emit(f"rectangle {x} {y} {width} {height}") - self._emit(f"rgba 0.5 0.5 0.5 0.5") - self._emit(f"fill") - self._emit(f"rectangle {x} {y} {width} {height}") - self._emit(f"gray 1.0") - self._emit(f"lineWidth 1") - self._emit(f"stroke") - self._emit(f"restore") + def image(self, path, x, y, w, h): + if not path in _img_cache: + buf = open(path, "rb").read() + _img_cache[path] = _wasm.stbi_load_from_memory(buf) + img, width, height, components = _img_cache[path] + _wasm.ctx_define_texture( + self._ctx, path, width, height, width * components, RGBA8, img, 0 + ) + _wasm.ctx_draw_texture(self._ctx, path, x, y, w, h) return self def rectangle(self, x, y, width, height): @@ -279,7 +372,12 @@ class Context: ) return self - def add_stop(self, pos, red, green, blue, alpha): + def linear_gradient(self, x0, y0, x1, y1): + self._emit(f"linearGradient {x0:.3f} {y0:.3f} {x1:.3f} {y1:.3f}") + return self + + def add_stop(self, pos, color, alpha): + red, green, blue = color if red > 1.0 or green > 1.0 or blue > 1.0: red /= 255.0 green /= 255.0 @@ -302,9 +400,13 @@ class Context: ) return self + def begin_path(self): + self._emit(f"beginPath") + return self + def arc(self, x, y, radius, arc_from, arc_to, direction): self._emit( - f"arc {int(x)} {int(y)} {int(radius)} {arc_from:.4f} {arc_to:.4f} {1 if direction else 0}" + f"arc {x:.3f} {y:.3f} {radius:.3f} {arc_from:.4f} {arc_to:.4f} {1 if direction else 0}" ) return self @@ -312,7 +414,8 @@ class Context: return _wasm.ctx_text_width(self._ctx, text) def clip(self): - return + self._emit(f"clip") + return self def get_font_name(self, i): return [ @@ -339,3 +442,8 @@ class Context: self.line_to(-130, 130) self.line_to(-130, 0) return self + + +RGBA8 = 4 +BGRA8 = 5 +RGB565_BYTESWAPPED = 7 diff --git a/sim/fakes/gzip.py b/sim/fakes/gzip.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/sim/fakes/leds.py b/sim/fakes/leds.py index 8f5f837ed685045715a6fb2f903c5dc7ea208859..c650049c5d6fa1099ded219702ff60680c57e345 100644 --- a/sim/fakes/leds.py +++ b/sim/fakes/leds.py @@ -1,16 +1,21 @@ from _sim import _sim +from sys_colors import hsv_to_rgb +from math import tau import pygame def set_rgb(ix, r, g, b): - if r > 255: - r = 255 - if g > 255: - g = 255 - if b > 255: - b = 255 - _sim.set_led_rgb(ix, r, g, b) + if r > 1: + r /= 255 + if g > 1: + g /= 255 + if b > 1: + b /= 255 + r = min(1.0, max(0.0, r)) + g = min(1.0, max(0.0, g)) + b = min(1.0, max(0.0, b)) + _sim.set_led_rgb(ix, pow(r, 1 / 2.2), pow(g, 1 / 2.2), pow(b, 1 / 2.2)) def get_rgb(ix): @@ -27,30 +32,25 @@ def set_all_rgb(r, g, b): def set_hsv(ix, h, s, v): - color = pygame.Color(0) - h = int(h) - h = h % 360 - color.hsva = (h, s * 100, v * 100, 1.0) - r, g, b = color.r, color.g, color.b - r *= 255 - if r > 255: - r = 255 - g *= 255 - if g > 255: - g = 255 - b *= 255 - if b > 255: - b = 255 - _sim.set_led_rgb(ix, r, g, b) + set_rgb(ix, *hsv_to_rgb(h / 360 * tau, s, v)) + + +def set_all_hsv(h, s, v): + for i in range(40): + set_hsv(i, h, s, v) def set_slew_rate(b: int): pass # Better a no-op than not implemented at all. +def get_slew_rate(): + return 255 + + def update(): _sim.leds_update() - _sim.render_gui_lazy() + pygame.event.post(pygame.event.Event(pygame.USEREVENT, {})) def set_auto_update(b: int): diff --git a/sim/fakes/machine.py b/sim/fakes/machine.py index 34d8eb7610ca7b9f6de23e7189c2b911d2b8b775..6f649a4f1c7afd7b824f22ab37d183e6a15ac9fd 100644 --- a/sim/fakes/machine.py +++ b/sim/fakes/machine.py @@ -1,3 +1,4 @@ +import os import sys @@ -22,9 +23,17 @@ class ADC: return 3.8e6 / 2 +class I2C: + def __init__(self, chan, freq=None): + pass + + def scan(self): + return [] + + def reset(): print("beep boop i have reset") - sys.exit(0) + os.execv(sys.executable, ["python"] + sys.argv) def disk_mode_flash(): diff --git a/sim/fakes/media.py b/sim/fakes/media.py index 165690168bab09f1ad7003745149900a3217323b..4e2eb625e9d495a88a2e0c4f1ca6da57cadc6704 100644 --- a/sim/fakes/media.py +++ b/sim/fakes/media.py @@ -1,15 +1,57 @@ +import pygame +from _sim import path_replace + +try: + import mad +except ImportError: + mad = None + +_loaded = False +_duration = 0 + + def stop(): """ Stops media playback, frees resources. """ - pass + global _loaded + _loaded = False + pygame.mixer.music.stop() + pygame.mixer.music.unload() -def load(path): +def load(path, paused=False): """ Load path """ - pass + global _loaded, _duration + if path.startswith(("http://", "https://")): + return + pygame.mixer.music.load(path_replace(path)) + pygame.mixer.music.play() + if mad: + _duration = mad.MadFile(path_replace(path)).total_time() + if paused: + pygame.mixer.music.pause() + _loaded = True + + +def play(): + if not _loaded: + return + pygame.mixer.music.unpause() + + +def pause(): + if not _loaded: + return + pygame.mixer.music.pause() + + +def is_playing(): + if not _loaded: + return False + return pygame.mixer.music.get_busy() def draw(ctx): @@ -24,3 +66,47 @@ def think(delta_ms): Process ms amounts of media, queuing PCM data and preparing for draw() """ pass + + +def set_volume(vol): + if not _loaded: + return + pygame.mixer.music.set_volume(vol) + + +def get_volume(): + if not _loaded: + return 1.0 + return pygame.mixer.music.get_volume() + + +def get_position(): + if not _loaded: + return 0 + pos = pygame.mixer.music.get_pos() / 1000 + if pos < 0: + pos = get_duration() + return pos + + +def get_time(): + return get_position() + + +def seek(pos): + if not _loaded: + return + pygame.mixer.music.play() + pygame.mixer.music.rewind() + if mad: + pygame.mixer.music.set_pos(pos * get_duration()) + + +def get_duration(): + if not mad: + return 99999 + return _duration / 1000 + + +def is_visual(): + return False diff --git a/sim/fakes/network.py b/sim/fakes/network.py index b0f7197f8010ecc5460dd977ac109acc5398060a..894b585b249fb989fc59326f66796ddbb87fa616 100644 --- a/sim/fakes/network.py +++ b/sim/fakes/network.py @@ -1,4 +1,5 @@ STA_IF = 1 +STAT_CONNECTING = 0 def hostname(hostname: str) -> None: @@ -9,7 +10,7 @@ class WLAN: def __init__(self, mode): pass - def active(self, active): + def active(self, active=True): return True def scan(self): @@ -18,5 +19,14 @@ class WLAN: def connect(self, ssid, key=None): pass + def disconnect(self): + pass + def isconnected(self): return True + + def status(self, mode=None): + return STAT_CONNECTING + + def config(self, a): + return None diff --git a/sim/fakes/sys_colors.py b/sim/fakes/sys_colors.py index 6bed2077143c2c04aa33279861dc05147171cf30..e45b9a6959623ec7dcd9ffa01d7d7b7d0f73779c 100644 --- a/sim/fakes/sys_colors.py +++ b/sim/fakes/sys_colors.py @@ -1,15 +1,18 @@ from typing import Tuple +import pygame +from math import tau def hsv_to_rgb(h: float, s: float, v: float) -> Tuple[float, float, float]: - """ - Not implemented in sim. - """ - return (0.0, 0.0, 0.0) + color = pygame.Color(0) + color.hsva = ((h % tau) / tau * 360, s * 100, v * 100, 100) + return color.normalize()[:3] def rgb_to_hsv(r: float, g: float, b: float) -> Tuple[float, float, float]: - """ - Not implemented in sim. - """ - return (0.0, 0.0, 0.0) + r = min(1, max(0, r)) + g = min(1, max(0, g)) + b = min(1, max(0, b)) + color = pygame.Color(r, g, b) + h, s, v, a = color.hsva + return (h / 360 * tau, s / 100, v / 100) diff --git a/sim/fakes/sys_display.py b/sim/fakes/sys_display.py index a3e201677349b3b726a6ff2dd60c77f272991384..acac485d2af60555c04e7ea215d1fe05fa31d060 100644 --- a/sim/fakes/sys_display.py +++ b/sim/fakes/sys_display.py @@ -9,27 +9,67 @@ def pipe_available(): return True -def overlay_clip(x0, y0, x1, y1): +def get_mode(): + return osd + + +def set_mode(no): pass -def get_mode(): - return 0 +def set_default_mode(no): + pass -def set_mode(no): +def set_palette(pal): pass +def fb(mode): + return (bytearray(240 * 240 * 4), 240, 240, 240 * 4) + + +def fps(): + return 60.0 + + update = _sim.display_update get_ctx = _sim.get_ctx get_overlay_ctx = _sim.get_overlay_ctx +overlay_clip = _sim.set_overlay_clip osd = 256 def ctx(foo): + if foo == osd: + return _sim.get_overlay_ctx() return _sim.get_ctx() def set_backlight(a): pass + + +def fbconfig(a, b, c, d): + pass + + +default = 0 +rgb332 = 0 +sepia = 0 +cool = 0 +low_latency = 0 +direct_ctx = 0 +lock = 0 +EXPERIMENTAL_think_per_draw = 0 +smart_redraw = 0 +x2 = 0 +x3 = 0 +x4 = 0 +bpp1 = 0 +bpp2 = 0 +bpp4 = 0 +bpp8 = 0 +bpp16 = 0 +bpp24 = 0 +palette = 0 diff --git a/sim/fakes/sys_kernel.py b/sim/fakes/sys_kernel.py index e134708eb7558f023e2da8e942b49e424a38cc1e..632c8e59b69b1e8e3887b3fa1f269dcebf192e64 100644 --- a/sim/fakes/sys_kernel.py +++ b/sim/fakes/sys_kernel.py @@ -35,3 +35,20 @@ def i2c_scan(): def battery_charging(): return True + + +def firmware_version(): + return "0.0.0" + + +def hardware_version(): + return "simulator" + + +class FakeSchedulerSnapshot: + def __init__(self): + self.tasks = [] + + +def scheduler_snapshot(): + return FakeSchedulerSnapshot() diff --git a/sim/fakes/sys_scope.py b/sim/fakes/sys_scope.py index fb446bef77e81c6ac8332aa1edd2365667329541..2802761d56d74735ddc6d795a5afd25477828949 100644 --- a/sim/fakes/sys_scope.py +++ b/sim/fakes/sys_scope.py @@ -1,2 +1,5 @@ +from st3m.goose import Optional + + def get_buffer_x() -> Optional[memoryview]: - return None + return memoryview(b"") diff --git a/sim/fakes/urequests.py b/sim/fakes/urequests.py index 86e6520f3c845ae6ce3111b16ae7604229341fd6..7e8c80ebb97bf06db1679bd1e22164d6158e355c 100644 --- a/sim/fakes/urequests.py +++ b/sim/fakes/urequests.py @@ -1,2 +1,19 @@ -from typing import Any -from requests import Response, request, head, get, post, put, patch, delete +import requests + + +def mkmock(fn): + def mocked(*args, **kwargs): + # print(fn, *args, *kwargs) + return fn(*args, stream=True) + + return mocked + + +request = mkmock(requests.request) +head = mkmock(requests.head) +get = mkmock(requests.get) +post = mkmock(requests.post) +put = mkmock(requests.put) +patch = mkmock(requests.patch) +delete = mkmock(requests.delete) +Response = requests.Response diff --git a/sim/fakes/utarfile.py b/sim/fakes/utarfile.py index d3dca7651196ee3f2a2de661ac6bc54e8c44cfa4..706b1b0bf0025bde97bbf409c2c66b0c583d71bd 100644 --- a/sim/fakes/utarfile.py +++ b/sim/fakes/utarfile.py @@ -1,8 +1,8 @@ from typing import Any import tarfile -DIRTYPE = "dir" -REGTYPE = "file" +DIRTYPE = b"5" +REGTYPE = b"0" TarInfo = tarfile.TarInfo TarFile = tarfile.TarFile diff --git a/sim/requirements.txt b/sim/requirements.txt index 42e7cd3c2d45a0c1bec60289f1cefa8ac9269dbd..c82ae356d0f03d2e8ddb6c99ad2682c44b5eb22c 100644 --- a/sim/requirements.txt +++ b/sim/requirements.txt @@ -1,4 +1,5 @@ pygame requests +pymad wasmer wasmer-compiler-cranelift diff --git a/sim/run.py b/sim/run.py index e9436f5eba8c487ffe24e6586fc8f09860baa178..8f26fe483bec67ea9c110ad504632f1cc8ae451f 100755 --- a/sim/run.py +++ b/sim/run.py @@ -9,6 +9,7 @@ import os import sys import builtins import argparse +import traceback projectpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) @@ -16,6 +17,7 @@ projectpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) import random import pygame import cmath +import gzip import wasmer import wasmer_compiler_cranelift @@ -29,6 +31,10 @@ try: except ImportError: print("Warning: `requests` is missing so no `urequests` mock will exist") +try: + import mad +except ImportError: + print("Warning: `mad` is missing, MP3 support in `media` mock will be limited") sys_path_orig = sys.path @@ -107,6 +113,8 @@ def _mkmock(fun): os.listdir = _mkmock(os.listdir) os.stat = _mkmock(os.stat) +os.statvfs = _mkmock(os.statvfs) +os.mkdir = _mkmock(os.mkdir) builtins.open = _mkmock(builtins.open) orig_stat = os.stat @@ -121,6 +129,9 @@ def _stat(path): os.stat = _stat +sys.print_exception = lambda x: print(traceback.format_exc()) + + def sim_main(): parser = argparse.ArgumentParser() parser.add_argument( diff --git a/sim/wasm/build.sh b/sim/wasm/build.sh index 8055e671292427304a0621a3d28b7122cc249f0b..43af74969ddd3b6d8afc67a4cdf25b8031b1dbe0 100755 --- a/sim/wasm/build.sh +++ b/sim/wasm/build.sh @@ -18,7 +18,7 @@ emcc ctx.c \ -I ../../components/ctx/ \ -I ../../components/ctx/fonts/ \ -D SIMULATOR \ - -s EXPORTED_FUNCTIONS=_ctx_new_for_framebuffer,_ctx_new_drawlist,_ctx_parse,_ctx_apply_transform,_ctx_text_width,_ctx_render_ctx,_ctx_destroy,_malloc,_free \ + -s EXPORTED_FUNCTIONS=_ctx_new_for_framebuffer,_ctx_new_drawlist,_ctx_parse,_ctx_apply_transform,_ctx_text_width,_ctx_x,_ctx_y,_ctx_render_ctx,_ctx_logo,_ctx_define_texture,_ctx_draw_texture,_ctx_destroy,_stbi_load_from_memory,_malloc,_free \ --no-entry -flto -O3 \ -o ctx.wasm diff --git a/sim/wasm/ctx.wasm b/sim/wasm/ctx.wasm index 460f6fd617d0926b45c0b341d86fe8e607863c29..a295a5a8d6612e044da65e2653c1afa92ea677a2 100755 Binary files a/sim/wasm/ctx.wasm and b/sim/wasm/ctx.wasm differ