diff --git a/ports/esp32/boards/MICROHYDRA/launcher/launcher.py b/ports/esp32/boards/MICROHYDRA/launcher/launcher.py index 072a03837a2f..3e20394a583b 100644 --- a/ports/esp32/boards/MICROHYDRA/launcher/launcher.py +++ b/ports/esp32/boards/MICROHYDRA/launcher/launcher.py @@ -1,31 +1,26 @@ from machine import Pin, SDCard, SPI, RTC, ADC -import time, os, json, math, ntptime, network -from lib import keyboard, beeper -from lib import microhydra as mh +import time, os, math, ntptime, network +from lib import keyboard, beeper, battlevel import machine from lib import st7789py as st7789 from launcher.icons import icons, battery from font import vga1_8x16 as fontsmall from font import vga2_16x32 as font - - +from lib.mhconfig import Config """ -VERSION: 0.7 +VERSION: 0.8 CHANGES: - Adjusted battery level detection, improved launcher sort method, - added apps folders to import path, - added ability to jump to alphabetical location in apps list, - added new fbuf-based display driver to lib - -This program is designed to be used in conjunction with the "apploader.py" program, to select and launch MPy apps for the Cardputer. + Created mhconfig.Config, mhoverlay.UI_Overlay, cleaned up launcher.py, endured the horrors + Renamed constants to make them "real" constants, and added slight improvements to st7789fbuf.py + +This program is designed to be used in conjunction with "main.py" apploader, to select and launch MPy apps. The basic app loading logic works like this: - - apploader reads reset cause and RTC.memory to determine which app to launch - apploader launches 'launcher.py' when hard reset, or when RTC.memory is blank - launcher scans app directories on flash and SDCard to find apps @@ -36,34 +31,23 @@ - app at given path now has control of device. - pressing the reset button will relaunch the launcher program, and so will calling machine.reset() from the app. - - This approach was chosen to reduce the chance of conflicts or memory errors when switching apps. -Because MicroPython completely resets between apps, the only "wasted" ram from the app switching process will be from launcher.py - - +Because MicroPython completely resets between apps, the only "wasted" ram from the app switching process will be from main.py """ - - +#init beeper asap to help prevent volume bug +beep = beeper.Beeper() #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Constants: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -black = const(0) -white = const(65535) -default_ui_color = const(53243) -default_bg_color = const(4421) -default_ui_sound = const(True) -default_volume = const(2) - -appname_y = const(80) -target_vscsad = const(40) # scrolling display "center" +_APPNAME_Y = const(80) +_TARGET_VSCSAD = const(40) # scrolling display "center" -display_width = const(240) -display_height = const(135) +_DISPLAY_WIDTH = const(240) +_DISPLAY_HEIGHT = const(135) -max_wifi_attemps = const(1000) -max_ntp_attemps = const(10) +_MAX_WIFI_ATTEMPTS = const(1000) +_MAX_NTP_ATTEMPTS = const(10) @@ -220,13 +204,9 @@ def center_text_x(text, char_width = 16): # display is 240 px wide start_coord = 120 - (str_width // 2) - return start_coord, str_width + return start_coord - -def easeInCubic(x): - return x * x * x - -def easeOutCubic(x): +def ease_out_cubic(x): return 1 - ((1 - x) ** 3) @@ -244,29 +224,6 @@ def time_24_to_12(hour_24,minute): return time_string, ampm -def read_battery_level(adc): - """ - read approx battery level on the adc and return as int range 0 (low) to 3 (high) - """ - raw_value = adc.read_uv() # vbat has a voltage divider of 1/2 - - # more real-world data is needed to dial in battery level. - # the original values were low, so they will be adjusted based on feedback. - - #originally 525000 (1.05v) - if raw_value < 1575000: #3.15v - return 0 - #originally 1050000 (2.1v) - if raw_value < 1750000: #3.5v - return 1 - #originally 1575000 (3.15v) - if raw_value < 1925000: #3.85v - return 2 - # 2100000 (4.2v) - return 3 # 4.2v or higher - - - @@ -280,42 +237,16 @@ def read_battery_level(adc): def main_loop(): - + global beep #bump up our clock speed so the UI feels smoother (240mhz is the max officially supported, but the default is 160mhz) machine.freq(240_000_000) # load our config asap to support other processes - config_modified = False - #load config - try: - with open("config.json", "r") as conf: - config = json.loads(conf.read()) - ui_color = config["ui_color"] - bg_color = config["bg_color"] - ui_sound = config["ui_sound"] - volume = config["volume"] - wifi_ssid = config["wifi_ssid"] - wifi_pass = config["wifi_pass"] - sync_clock = config["sync_clock"] - timezone = config["timezone"] - except: - print("could not load settings from config.json. reloading default values.") - config_modified = True - ui_color = default_ui_color - bg_color = default_bg_color - ui_sound = default_ui_sound - volume = default_volume - wifi_ssid = '' - wifi_pass = '' - sync_clock = True - timezone = 0 - with open("config.json", "w") as conf: - config = {"ui_color":ui_color, "bg_color":bg_color, "ui_sound":ui_sound, "volume":volume, "wifi_ssid":'', "wifi_pass":'', 'sync_clock':True, 'timezone':0} - conf.write(json.dumps(config)) + config = Config() # sync our RTC on boot, if set in settings - syncing_clock = sync_clock + syncing_clock = config['sync_clock'] sync_ntp_attemps = 0 connect_wifi_attemps = 0 rtc = machine.RTC() @@ -333,7 +264,7 @@ def main_loop(): import micropython print(micropython.mem_info(),micropython.qstr_info()) - if wifi_ssid == '': + if config['wifi_ssid'] == '': syncing_clock = False # no point in wasting resources if wifi hasn't been setup elif rtc.datetime()[0] != 2000: #clock wasn't reset, assume that time has already been set syncing_clock = False @@ -343,7 +274,7 @@ def main_loop(): nic.active(True) if not nic.isconnected(): # try connecting try: - nic.connect(wifi_ssid, wifi_pass) + nic.connect(config['wifi_ssid'], config['wifi_pass']) except OSError as e: print("wifi_sync_rtc had this error when connecting:",e) @@ -356,19 +287,17 @@ def main_loop(): #init the keyboard kb = keyboard.KeyBoard() - pressed_keys = [] - prev_pressed_keys = [] + new_keys = [] - #init the ADC for the battery - batt = ADC(10) - batt.atten(ADC.ATTN_11DB) + #init the battery meter + batt = battlevel.Battery() #init driver for the graphics spi = SPI(1, baudrate=40000000, sck=Pin(36), mosi=Pin(35), miso=None) tft = st7789.ST7789( spi, - display_height, - display_width, + _DISPLAY_HEIGHT, + _DISPLAY_WIDTH, reset=Pin(33, Pin.OUT), cs=Pin(37, Pin.OUT), dc=Pin(34, Pin.OUT), @@ -377,12 +306,8 @@ def main_loop(): color_order=st7789.BGR ) - tft.vscrdef(40,display_width,40) - tft.vscsad(target_vscsad) - - mid_color = mh.mix_color565(bg_color, ui_color) - red_color = mh.color565_shiftred(ui_color) - green_color = mh.color565_shiftgreen(ui_color,0.4) + tft.vscrdef(40,_DISPLAY_WIDTH,40) + tft.vscsad(_TARGET_VSCSAD) nonscroll_elements_displayed = False @@ -391,49 +316,48 @@ def main_loop(): #this is used as a flag to tell a future loop to redraw the frame mid-scroll animation delayed_redraw = False - launching = False current_vscsad = 40 scroll_direction = 0 #1 for right, -1 for left, 0 for center refresh_timer = 0 #init the beeper! - beep = beeper.Beeper() + #beep = beeper.Beeper() #starupp sound - if ui_sound: + if config['ui_sound']: beep.play(('C3', ('F3'), ('A3'), ('F3','A3','C3'), - ('F3','A3','C3')),130,volume) + ('F3','A3','C3')),130,config['volume']) #init diplsay - tft.fill_rect(-40,0,280, display_height, bg_color) - tft.fill_rect(-40,0,280, 18, mid_color) - + tft.fill_rect(-40,0,280, _DISPLAY_HEIGHT, config['bg_color']) + tft.fill_rect(-40,0,280, 18, config.palette[2]) + tft.hline(-40,18,280,config.palette[0]) while True: # ----------------------- check for key presses on the keyboard. Only if they weren't already pressed. -------------------------- - pressed_keys = kb.get_pressed_keys() - if pressed_keys != prev_pressed_keys: + new_keys = kb.get_new_keys() + if new_keys: # ~~~~~~ check if the arrow keys are newly pressed ~~~~~ - if "/" in pressed_keys and "/" not in prev_pressed_keys: # right arrow + if "/" in new_keys: # right arrow app_selector_index += 1 #animation: scroll_direction = 1 - current_vscsad = target_vscsad - if ui_sound: - beep.play((("C5","D4"),"A4"), 80, volume) + current_vscsad = _TARGET_VSCSAD + if config['ui_sound']: + beep.play((("C5","D4"),"A4"), 80, config['volume']) - elif "," in pressed_keys and "," not in prev_pressed_keys: # left arrow + elif "," in new_keys: # left arrow app_selector_index -= 1 #animation: @@ -441,54 +365,42 @@ def main_loop(): scroll_direction = -1 #this prevents multiple scrolls from messing up the animation - current_vscsad = target_vscsad + current_vscsad = _TARGET_VSCSAD - if ui_sound: - beep.play((("B3","C5"),"A4"), 80, volume) + if config['ui_sound']: + beep.play((("B3","C5"),"A4"), 80, config['volume']) # ~~~~~~~~~~ check if GO or ENTER are pressed ~~~~~~~~~~ - if "GO" in pressed_keys or "ENT" in pressed_keys: + if "GO" in new_keys or "ENT" in new_keys: # special "settings" app options will have their own behaviour, otherwise launch the app if app_names[app_selector_index] == "UI Sound": - if ui_sound == 0: # currently muted, then unmute - ui_sound = True + if config['ui_sound'] == 0: # currently muted, then unmute + config['ui_sound'] = True force_redraw_display = True - beep.play(("C4","G4","G4"), 100, volume) - config_modified = True + beep.play(("C4","G4","G4"), 100, config['volume']) + else: # currently unmuted, then mute - ui_sound = False + config['ui_sound'] = False force_redraw_display = True - config_modified = True elif app_names[app_selector_index] == "Reload Apps": app_names, app_paths, sd = scan_apps(sd) app_selector_index = 0 current_vscsad = 42 # forces scroll animation triggers - if ui_sound: - beep.play(('F3','A3','C3'),100,volume) + if config['ui_sound']: + beep.play(('F3','A3','C3'),100,config['volume']) else: # ~~~~~~~~~~~~~~~~~~~ LAUNCH THE APP! ~~~~~~~~~~~~~~~~~~~~ #save config if it has been changed: - if config_modified: - with open("config.json", "w") as conf: - config = { - "ui_color":ui_color, - "bg_color":bg_color, - "ui_sound":ui_sound, - "volume":volume, - "wifi_ssid":wifi_ssid, - "wifi_pass":wifi_pass, - "sync_clock":sync_clock, - "timezone":timezone} - conf.write(json.dumps(config)) + config.save() # shut off the display - tft.fill(black) + tft.fill(0) tft.sleep_mode(True) Pin(38, Pin.OUT).value(0) #backlight off spi.deinit() @@ -499,15 +411,15 @@ def main_loop(): except: print("Tried to deinit SDCard, but failed.") - if ui_sound: - beep.play(('C4','B4','C5','C5'),100,volume) + if config['ui_sound']: + beep.play(('C4','B4','C5','C5'),100,config['volume']) launch_app(app_paths[app_names[app_selector_index]]) else: # keyboard shortcuts! - for key in pressed_keys: + for key in new_keys: # jump to letter: - if key not in prev_pressed_keys and len(key) == 1: # filter special keys and repeated presses + if len(key) == 1: # filter special keys and repeated presses if key in 'abcdefghijklmnopqrstuvwxyz1234567890': #search for that letter in the app list for idx, name in enumerate(app_names): @@ -517,19 +429,14 @@ def main_loop(): scroll_direction = -1 elif app_selector_index < idx: scroll_direction = 1 - current_vscsad = target_vscsad + current_vscsad = _TARGET_VSCSAD # go there! app_selector_index = idx - if ui_sound: - beep.play(("G3"), 100, volume) + if config['ui_sound']: + beep.play(("G3"), 100, config['volume']) found_key = True break - - # once we parse the keypresses for this loop, we need to store them for next loop - prev_pressed_keys = pressed_keys - - - + #wrap around our selector index, in case we go over or under the target amount app_selector_index = app_selector_index % len(app_names) @@ -558,57 +465,58 @@ def main_loop(): if scroll_direction != 0: tft.vscsad(current_vscsad % 240) if scroll_direction == 1: - current_vscsad += math.floor(easeOutCubic((current_vscsad - 40) / 120) * 10) + 5 + current_vscsad += math.floor(ease_out_cubic((current_vscsad - 40) / 120) * 10) + 5 if current_vscsad >= 160: current_vscsad = -80 scroll_direction = 0 else: - current_vscsad -= math.floor(easeOutCubic((current_vscsad - 40) / -120) * 10) + 5 + current_vscsad -= math.floor(ease_out_cubic((current_vscsad - 40) / -120) * 10) + 5 if current_vscsad <= -80: current_vscsad = 160 scroll_direction = 0 # if vscsad/scrolling is not centered, move it toward center! - if scroll_direction == 0 and current_vscsad != target_vscsad: + if scroll_direction == 0 and current_vscsad != _TARGET_VSCSAD: tft.vscsad(current_vscsad % 240) - if current_vscsad < target_vscsad: + if current_vscsad < _TARGET_VSCSAD: - current_vscsad += (abs(current_vscsad - target_vscsad) // 8) + 1 - elif current_vscsad > target_vscsad: - current_vscsad -= (abs(current_vscsad - target_vscsad) // 8) + 1 + current_vscsad += (abs(current_vscsad - _TARGET_VSCSAD) // 8) + 1 + elif current_vscsad > _TARGET_VSCSAD: + current_vscsad -= (abs(current_vscsad - _TARGET_VSCSAD) // 8) + 1 # if we are scrolling, we should change some UI elements until we finish - if nonscroll_elements_displayed and (current_vscsad != target_vscsad): - tft.fill_rect(0,133,240,2,bg_color) # erase scrollbar - tft.fill_rect(6,2,58,16,mid_color) # erase clock - tft.fill_rect(212,4,20,10,mid_color) # erase battery + if nonscroll_elements_displayed and (current_vscsad != _TARGET_VSCSAD): + tft.fill_rect(0,132,240,3,config['bg_color']) # erase scrollbar + tft.fill_rect(6,2,58,16,config.palette[2]) # erase clock + tft.fill_rect(212,4,20,10,config.palette[2]) # erase battery nonscroll_elements_displayed = False - elif nonscroll_elements_displayed == False and (current_vscsad == target_vscsad): + elif nonscroll_elements_displayed == False and (current_vscsad == _TARGET_VSCSAD): #scroll bar scrollbar_width = 240 // len(app_names) - tft.fill_rect((scrollbar_width * app_selector_index),133,scrollbar_width,2,mid_color) + tft.fill_rect((scrollbar_width * app_selector_index),133,scrollbar_width,2,config.palette[2]) + tft.hline(scrollbar_width * app_selector_index, 132, scrollbar_width, config.palette[0]) #clock _,_,_, hour_24, minute, _,_,_ = time.localtime() formatted_time, ampm = time_24_to_12(hour_24, minute) - tft.text(fontsmall, formatted_time, 6,2,ui_color, mid_color) - tft.text(fontsmall, ampm, 8 + (len(formatted_time) * 8),2,bg_color, mid_color) + tft.text(fontsmall, formatted_time, 6,2,config.palette[4], config.palette[2]) + tft.text(fontsmall, ampm, 8 + (len(formatted_time) * 8),1,config.palette[3], config.palette[2]) #battery - battlevel = read_battery_level(batt) - if battlevel == 3: - tft.bitmap_icons(battery, battery.FULL, (mid_color,green_color),212, 4) - elif battlevel == 2: - tft.bitmap_icons(battery, battery.HIGH, (mid_color,ui_color),212, 4) - elif battlevel == 1: - tft.bitmap_icons(battery, battery.LOW, (mid_color,ui_color),212, 4) + batt_lvl = batt.read_level() + if batt_lvl == 3: + tft.bitmap_icons(battery, battery.FULL, (config.palette[2],config.palette[4]),212, 4) + elif batt_lvl == 2: + tft.bitmap_icons(battery, battery.HIGH, (config.palette[2],config.palette[4]),212, 4) + elif batt_lvl == 1: + tft.bitmap_icons(battery, battery.LOW, (config.palette[2],config.palette[4]),212, 4) else: - tft.bitmap_icons(battery, battery.EMPTY, (mid_color,red_color),212, 4) + tft.bitmap_icons(battery, battery.EMPTY, (config.palette[2],config.extended_colors[0]),212, 4) nonscroll_elements_displayed = True @@ -626,35 +534,35 @@ def main_loop(): current_app_text = current_app_text[:12] + "..." #blackout the old text - tft.fill_rect(-40, appname_y, 280, 32, bg_color) + tft.fill_rect(-40, _APPNAME_Y, 280, 32, config['bg_color']) #draw new text - tft.text(font, current_app_text, center_text_x(current_app_text)[0], appname_y, ui_color, bg_color) + tft.text(font, current_app_text, center_text_x(current_app_text), _APPNAME_Y, config['ui_color'], config['bg_color']) if refresh_timer == 2 or force_redraw_display: # redraw icon refresh_timer = 0 delayed_redraw = False - #blackout old icon #TODO: delete this step when all text is replaced by icons - tft.fill_rect(96, 30, 48, 36, bg_color) + #blackout old icon + tft.fill_rect(96, 30, 48, 36, config['bg_color']) #special menu options for settings if current_app_text == "UI Sound": - if ui_sound: - tft.text(font, "On", center_text_x("On")[0], 36, ui_color, bg_color) + if config['ui_sound']: + tft.text(font, "On", center_text_x("On"), 36, config['ui_color'], config['bg_color']) else: - tft.text(font, "Off", center_text_x("Off")[0], 36, mid_color, bg_color) + tft.text(font, "Off", center_text_x("Off"), 36, config.palette[3], config['bg_color']) elif current_app_text == "Reload Apps": - tft.bitmap_icons(icons, icons.RELOAD, (bg_color,ui_color),104, 36) + tft.bitmap_icons(icons, icons.RELOAD, (config['bg_color'],config['ui_color']),104, 36) elif current_app_text == "Settings": - tft.bitmap_icons(icons, icons.GEAR, (bg_color,ui_color),104, 36) + tft.bitmap_icons(icons, icons.GEAR, (config['bg_color'],config['ui_color']),104, 36) elif app_paths[app_names[app_selector_index]][:3] == "/sd": - tft.bitmap_icons(icons, icons.SDCARD, (bg_color,ui_color),104, 36) + tft.bitmap_icons(icons, icons.SDCARD, (config['bg_color'],config['ui_color']),104, 36) else: - tft.bitmap_icons(icons, icons.FLASH, (bg_color,ui_color),104, 36) + tft.bitmap_icons(icons, icons.FLASH, (config['bg_color'],config['ui_color']),104, 36) @@ -683,17 +591,17 @@ def main_loop(): syncing_clock = False #apply our timezone offset time_list = list(rtc.datetime()) - time_list[4] = time_list[4] + timezone + time_list[4] = time_list[4] + config['timezone'] rtc.datetime(tuple(time_list)) print(f'RTC successfully synced to {rtc.datetime()} with {sync_ntp_attemps} attemps.') - elif sync_ntp_attemps >= max_ntp_attemps: + elif sync_ntp_attemps >= _MAX_NTP_ATTEMPTS: nic.disconnect() nic.active(False) #shut off wifi syncing_clock = False print(f"Syncing RTC aborted after {sync_ntp_attemps} attemps") - elif connect_wifi_attemps >= max_wifi_attemps: + elif connect_wifi_attemps >= _MAX_WIFI_ATTEMPTS: nic.disconnect() nic.active(False) #shut off wifi syncing_clock = False diff --git a/ports/esp32/boards/MICROHYDRA/lib/M5Sound.py b/ports/esp32/boards/MICROHYDRA/lib/M5Sound.py new file mode 100644 index 000000000000..f24765738f85 --- /dev/null +++ b/ports/esp32/boards/MICROHYDRA/lib/M5Sound.py @@ -0,0 +1,194 @@ +""" +/* + * ---------------------------------------------------------------------------- + * "THE BEER-WARE LICENSE" (Revision 42 modified): + * wrote this file. As long as you retain this notice and + * my credit somewhere you can do whatever you want with this stuff. If we + * meet some day, and you think this stuff is worth it, you can buy me a beer + * in return. + * ---------------------------------------------------------------------------- + */ +""" + +from machine import Pin, I2S +import time + +_PERIODS = ( # c0 thru a0 - how much to advance a sample pointer per frame for each note + b'\x01\x00\x00\x00\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00', + b'\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00', + b'\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00' +) + +_MIN_VOLUME = const(0) +_MAX_VOLUME = const(15) +@micropython.native +def _volume(volume): + return 15 - (0 if volume < 0 else 15 if volume > 15 else volume) + +@micropython.viper +def _vipmod(a:uint, b:uint) -> uint: + while a >= b: + a -= b + return a + +class Register: + def __init__(self, buf_start=0, sample=None, sample_len=0, pointer=0, note=0, period=1, period_mult=4, loop=False, volume=0): + self.buf_start = buf_start + self.sample = sample + self.pointer = pointer + self.period = period + self.note = note + self.period_mult = period_mult + self.sample_len = sample_len + self.loop = loop + self.volume = volume + + def copy(self): + registers = Register() + registers.buf_start = self.buf_start + registers.sample = self.sample + registers.pointer = self.pointer + registers.period = self.period + registers.note = self.note + registers.period_mult = self.period_mult + registers.sample_len = self.sample_len + registers.loop = self.loop + registers.volume = self.volume + return registers + + def __str__(self): + return f"{self.buf_start}: {self.sample} v:{self.volume} n:{self.note}" + +class M5Sound: + def __init__(self, buf_size=2048, rate=11025, channels=4, sck=41, ws=43, sd=42): + self._output = I2S( + 1, + sck=Pin(sck), + ws=Pin(ws), + sd=Pin(sd), + mode=I2S.TX, + bits=16, + format=I2S.STEREO, + rate=rate, + ibuf=buf_size + ) + + self._rate = rate + self._buf_size:int = buf_size + self._buffer = bytearray(buf_size*2) # twice for stereo + self.channels = channels + self._registers = [Register() for _ in range(channels)] + self._queues = [[] for _ in range(channels)] + self._last_tick = 0 + self._output.irq(self._process_buffer) + self._process_buffer(None) + + def __del__(self): + self._output.deinit() + +# @micropython.native + def _gen_buf_start(self): + return int((time.ticks_diff(time.ticks_us(), self._last_tick) // (1000000 / self._rate)) * 2) # stereo + +# @micropython.native + def play(self, sample, note=0, octave=4, volume=15, channel=0, loop=False): + registers = Register( + buf_start = self._gen_buf_start(), + sample = sample, + sample_len = len(sample) // 2, + loop = loop, + note = note % 12, + period_mult = 2 ** (octave + (note // 12)), + volume = volume + ) + self._queues[channel].append(registers) + +# @micropython.native + def stop(self, channel=0): + registers = Register() # default has empty sample + registers.buf_start = self._gen_buf_start() + self._queues[channel].append(registers) + +# @micropython.native + def setvolume(self, volume, channel=0): + if len(self._queues[channel]) > 0: + registers = self._queues[channel][-1].copy() + else: + registers = self._registers[channel].copy() + registers.buf_start = self._gen_buf_start() + registers.volume = volume + self._queues[channel].append(registers) + + @micropython.viper + def _clear_buffer(self): + buf = ptr16(self._buffer) + for i in range(0, int(self._buf_size)): + buf[i] = 0 + + @micropython.viper + def _fill_buffer(self, registers, end:int) -> bool: + buf = ptr16(self._buffer) + start = 1 + int(registers.buf_start) + smp = ptr16(registers.sample) + slen = uint(registers.sample_len) + ptr = uint(registers.pointer) + per = ptr8(_PERIODS[registers.note]) + perlen = uint(len(_PERIODS[registers.note])) + perptr = uint(registers.period) + permult = int(registers.period_mult) + vol = uint(_volume(registers.volume)) + loop = bool(registers.loop) + for i in range(start, end, 2): # odd word: right channel only + if ptr >= slen: # sample ended + if not loop: # stop playing + return False + ptr = uint(_vipmod(ptr, slen)) # or loop + bsmp = smp[ptr] + # ladies and gentlemen, the two's complement + bsmp = (bsmp & 0b1000000000000000) | ((bsmp & 0b0111111111111111) >> vol) + if (bsmp & 0b1000000000000000) != 0: + bsmp |= (0b111111111111111 << 15-vol) + buf[i] += bsmp + for _ in range(permult): # add together frame periods for different octaves + ptr += per[perptr] + perptr += uint(1) + if perptr >= perlen: + perptr = uint(0) + registers.buf_start = 0 + registers.pointer = ptr + registers.period = perptr + return True + +# @micropython.native + def _process_buffer(self, arg): + self._last_tick = time.ticks_us() + self._output.write(self._buffer) + self._clear_buffer() + + for ch in range(0, int(self.channels)): + playing = True + while playing: + registers = self._registers[ch] + + end = self._buf_size + if len(self._queues[ch]) > 0: + if self._queues[ch][0].buf_start >= self._buf_size: + self._queues[ch][0].buf_start -= self._buf_size + else: + end = self._queues[ch][0].buf_start + self._registers[ch] = self._queues[ch].pop(0) + else: + playing = False + + if registers.sample: + if not self._fill_buffer(registers, end): + registers.sample = None \ No newline at end of file diff --git a/ports/esp32/boards/MICROHYDRA/lib/battlevel.py b/ports/esp32/boards/MICROHYDRA/lib/battlevel.py new file mode 100644 index 000000000000..3ebf2277db85 --- /dev/null +++ b/ports/esp32/boards/MICROHYDRA/lib/battlevel.py @@ -0,0 +1,80 @@ +import machine + + + +# CONSTANTS: +# vbat has a voltage divider of 1/2 +_MIN_VALUE = const(1575000) # 3.15v +_MAX_VALUE = const(2100000) # 4.2v + +_LOW_THRESH = const(_MIN_VALUE + ((_MAX_VALUE - _MIN_VALUE) // 3)) +_HIGH_THRESH = const(_LOW_THRESH + ((_MAX_VALUE - _MIN_VALUE) // 3)) + + + +# CLASS Battery: +class Battery: + def __init__(self): + #init the ADC for the battery + self.adc = machine.ADC(10) + self.adc.atten(machine.ADC.ATTN_11DB) # needed to get apropriate range + + def read_pct(self): + """ + Return an approximate battery level as a percentage + """ + raw_value = self.adc.read_uv() + + if raw_value <= _MIN_VALUE: + return 0 + elif raw_value >= _MAX_VALUE: + return 100 + + delta_value = raw_value - _MIN_VALUE # shift range down + delta_max = _MAX_VALUE - _MIN_VALUE # shift range down + pct_value = int((delta_value / delta_max) * 100) + return (pct_value) + + def read_level(self): + """ + Read approx battery level on the adc and return as int range 0 (low) to 3 (high) + This is reccomended, as the readings are not very accurate, + and a percentage could therefore be misleading. + """ + raw_value = self.adc.read_uv() + if raw_value < _MIN_VALUE: + return 0 + if raw_value < _LOW_THRESH: + return 1 + if raw_value < _HIGH_THRESH: + return 2 + return 3 + +if __name__ == "__main__": + from lib import st7789fbuf, keyboard + from lib import microhydra as mh + from launcher.icons import battery + import time + from font import vga2_16x32 as font + from machine import SPI, Pin, PWM, reset, ADC + + tft = st7789fbuf.ST7789( + SPI(1, baudrate=40000000, sck=Pin(36), mosi=Pin(35), miso=None), + 135, + 240, + reset=Pin(33, Pin.OUT), + cs=Pin(37, Pin.OUT), + dc=Pin(34, Pin.OUT), + backlight=Pin(38, Pin.OUT), + rotation=1, + color_order=st7789fbuf.BGR + ) + batt = Battery() + + while True: + time.sleep(1) + tft.fill(0) + tft.bitmap_text(font, f"Batt level: {batt.read_level()}", 10,10, 65535) + tft.bitmap_text(font, f"pct: {batt.read_pct()}%", 10,50, 65535) + tft.show() + diff --git a/ports/esp32/boards/MICROHYDRA/lib/beeper.py b/ports/esp32/boards/MICROHYDRA/lib/beeper.py index 084378c65657..b1111277fd5a 100644 --- a/ports/esp32/boards/MICROHYDRA/lib/beeper.py +++ b/ports/esp32/boards/MICROHYDRA/lib/beeper.py @@ -1,16 +1,14 @@ from machine import I2S, Pin import math -fast_sin_len = const(320) -fast_sin_hz = const(25) -SCK_PIN = const(41) -WS_PIN = const(43) -SD_PIN = const(42) -I2S_ID = const(1) -BUFFER_LENGTH_IN_BYTES = const(2100) -SAMPLE_SIZE_IN_BITS = const(16) -FORMAT = I2S.STEREO -SAMPLE_RATE_IN_HZ = const(16000) +_SCK_PIN = const(41) +_WS_PIN = const(43) +_SD_PIN = const(42) +_I2S_ID = const(1) +_BUFFER_LENGTH_IN_BYTES = const(2048) +_SAMPLE_SIZE_IN_BITS = const(16) +_FORMAT = I2S.STEREO +_SAMPLE_RATE_IN_HZ = const(16000) volume_map = {0:1,1:4,2:10,3:16,4:20,5:28,6:36,7:50,8:60,9:80,10:127} tone_map = { @@ -56,15 +54,15 @@ class Beeper: def __init__(self, buf_size=4000): self._output = I2S( - I2S_ID, - sck=Pin(SCK_PIN), - ws=Pin(WS_PIN), - sd=Pin(SD_PIN), + _I2S_ID, + sck=Pin(_SCK_PIN), + ws=Pin(_WS_PIN), + sd=Pin(_SD_PIN), mode=I2S.TX, - bits=SAMPLE_SIZE_IN_BITS, - format=FORMAT, - rate=SAMPLE_RATE_IN_HZ, - ibuf=BUFFER_LENGTH_IN_BYTES) + bits=_SAMPLE_SIZE_IN_BITS, + format=_FORMAT, + rate=_SAMPLE_RATE_IN_HZ, + ibuf=_BUFFER_LENGTH_IN_BYTES) self._current_notes = [] diff --git a/ports/esp32/boards/MICROHYDRA/lib/keyboard.py b/ports/esp32/boards/MICROHYDRA/lib/keyboard.py index c2816c3f05f0..86fb85ac5dbd 100644 --- a/ports/esp32/boards/MICROHYDRA/lib/keyboard.py +++ b/ports/esp32/boards/MICROHYDRA/lib/keyboard.py @@ -1,7 +1,14 @@ from machine import Pin -import time + +""" +lib.keyboard version: 1.1 +changes: + Cleaned unused code. + Added KeyBoard.get_new_keys() +""" + #lookup values for our keyboard kc_shift = const(61) kc_fn = const(65) @@ -43,37 +50,8 @@ def __init__(self): #setup the "Go" button! self.go = Pin(0, Pin.IN, Pin.PULL_UP) - -# #setup column pins. These are read as inputs. -# c0 = Pin(13, Pin.IN, Pin.PULL_UP) -# c1 = Pin(15, Pin.IN, Pin.PULL_UP) -# c2 = Pin(3, Pin.IN, Pin.PULL_UP) -# c3 = Pin(4, Pin.IN, Pin.PULL_UP) -# c4 = Pin(5, Pin.IN, Pin.PULL_UP) -# c5 = Pin(6, Pin.IN, Pin.PULL_UP) -# c6 = Pin(7, Pin.IN, Pin.PULL_UP) -# -# #setup row pins. These are given to a 74hc138 "demultiplexer", which lets us turn 3 output pins into 8 outputs (8 rows) -# a0 = Pin(8, Pin.OUT) -# a1 = Pin(9, Pin.OUT) -# a2 = Pin(11, Pin.OUT) -# -# self.pinMap = { -# 'C0': c0, -# 'C1': c1, -# 'C2': c2, -# 'C3': c3, -# 'C4': c4, -# 'C5': c5, -# 'C6': c6, -# 'A0': a0, -# 'A1': a1, -# 'A2': a2, -# } -# -# self.key_state = [] - #setup column pins. These are read as inputs. + #setup column pins. These are read as inputs. self.c0 = Pin(13, Pin.IN, Pin.PULL_UP) self.c1 = Pin(15, Pin.IN, Pin.PULL_UP) self.c2 = Pin(3, Pin.IN, Pin.PULL_UP) @@ -88,7 +66,7 @@ def __init__(self): self.a2 = Pin(11, Pin.OUT) self.key_state = [] - + self.prev_key_state = [] def scan(self): """scan through the matrix to see what keys are pressed.""" @@ -101,14 +79,11 @@ def scan(self): self.a1.value( ( row & 0b010 ) >> 1) self.a2.value( ( row & 0b100 ) >> 2) - - #for i, col in enumerate(self.columns): #for i in range(0,7): # if not self.columns[i].value(): # button pressed # key_address = (i * 10) + row # self._key_list_buffer.append(key_address) - # I know this is ugly, it should be a loop. # but this scan can be slow, and doing this instead of a loop runs much faster: if not self.c6.value(): @@ -134,7 +109,6 @@ def get_pressed_keys(self): #update our scan results self.scan() - self.key_state = [] if self.go.value() == 0: @@ -143,10 +117,7 @@ def get_pressed_keys(self): if not self._key_list_buffer and not self.key_state: # if nothing is pressed, we can return an empty list return self.key_state - - if kc_fn in self._key_list_buffer: - #remove modifier keys which are already accounted for self._key_list_buffer.remove(kc_fn) if kc_shift in self._key_list_buffer: @@ -156,7 +127,6 @@ def get_pressed_keys(self): self.key_state.append(keymap_fn[keycode]) elif kc_shift in self._key_list_buffer: - #remove modifier keys which are already accounted for self._key_list_buffer.remove(kc_shift) @@ -168,13 +138,23 @@ def get_pressed_keys(self): self.key_state.append(keymap[keycode]) return self.key_state - - + + def get_new_keys(self): + """ + Return a list of keys which are newly pressed. + """ + self.prev_key_state = self.key_state + self.get_pressed_keys() + # Originally I wanted to use a set() for this, but with testing, this is apparantly faster. + return [key for key in self.key_state if key not in self.prev_key_state] if __name__ == "__main__": + import time kb = KeyBoard() - print(kb.get_pressed_keys()) + for _ in range(0,400): + print(kb.get_new_keys()) + time.sleep_ms(10) \ No newline at end of file diff --git a/ports/esp32/boards/MICROHYDRA/lib/mhconfig.py b/ports/esp32/boards/MICROHYDRA/lib/mhconfig.py new file mode 100644 index 000000000000..db4e978e5f23 --- /dev/null +++ b/ports/esp32/boards/MICROHYDRA/lib/mhconfig.py @@ -0,0 +1,278 @@ + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ CONSTANT ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +DEFAULT_CONFIG = {"ui_color":53243, "bg_color":4421, "ui_sound":True, "volume":2, "wifi_ssid":'', "wifi_pass":'', 'sync_clock':True, 'timezone':0} + +def mix(val2, val1, fac=0.5): + """Mix two values to the weight of fac""" + output = (val1 * fac) + (val2 * (1.0 - fac)) + return output + +def mix_angle_float(angle1, angle2, factor=0.5): + """take two angles as floats (range 0.0 to 1.0) and average them to the weight of factor. + Mainly for blending hue angles.""" + # Ensure hue values are in the range [0, 1) + angle1 = angle1 % 1 + angle2 = angle2 % 1 + + # Calculate the angular distance between hue1 and hue2 + angular_distance = (angle2 - angle1 + 0.5) % 1 - 0.5 + # Calculate the middle hue value + blended = (angle1 + angular_distance * factor) % 1 + + return blended + +def separate_color565(color): + """ + Separate a 16-bit 565 encoding into red, green, and blue components. + """ + red = (color >> 11) & 0x1F + green = (color >> 5) & 0x3F + blue = color & 0x1F + return red, green, blue + + +def combine_color565(red, green, blue): + """ + Combine red, green, and blue components into a 16-bit 565 encoding. + """ + # Ensure color values are within the valid range + red = max(0, min(red, 31)) + green = max(0, min(green, 63)) + blue = max(0, min(blue, 31)) + + # Pack the color values into a 16-bit integer + return (red << 11) | (green << 5) | blue + + +def rgb_to_hsv(r, g, b): + ''' + Convert an RGB float to an HSV float. + From: cpython/Lib/colorsys.py + ''' + maxc = max(r, g, b) + minc = min(r, g, b) + rangec = (maxc-minc) + v = maxc + if minc == maxc: + return 0.0, 0.0, v + s = rangec / maxc + rc = (maxc-r) / rangec + gc = (maxc-g) / rangec + bc = (maxc-b) / rangec + if r == maxc: + h = bc-gc + elif g == maxc: + h = 2.0+rc-bc + else: + h = 4.0+gc-rc + h = (h/6.0) % 1.0 + return h, s, v + + + +def hsv_to_rgb(h, s, v): + ''' + Convert an RGB float to an HSV float. + From: cpython/Lib/colorsys.py + ''' + if s == 0.0: + return v, v, v + i = int(h*6.0) + f = (h*6.0) - i + p = v*(1.0 - s) + q = v*(1.0 - s*f) + t = v*(1.0 - s*(1.0-f)) + i = i%6 + if i == 0: + return v, t, p + if i == 1: + return q, v, p + if i == 2: + return p, v, t + if i == 3: + return p, q, v + if i == 4: + return t, p, v + if i == 5: + return v, p, q + # Cannot get here + + +def mix_color565(color1, color2, mix_factor=0.5, hue_mix_fac=None, sat_mix_fac=None): + """ + High quality mixing of two rgb565 colors, by converting through HSV color space. + This function is probably too slow for running constantly in a loop, but should be good for occasional usage. + """ + if hue_mix_fac == None: + hue_mix_fac = mix_factor + if sat_mix_fac == None: + sat_mix_fac = mix_factor + + #separate to components + r1,g1,b1 = separate_color565(color1) + r2,g2,b2 = separate_color565(color2) + #convert to float 0.0 to 1.0 + r1 /= 31; r2 /= 31 + g1 /= 63; g2 /= 63 + b1 /= 31; b2 /= 31 + #convert to hsv 0.0 to 1.0 + h1,s1,v1 = rgb_to_hsv(r1,g1,b1) + h2,s2,v2 = rgb_to_hsv(r2,g2,b2) + + #mix the hue angle + hue = mix_angle_float(h1,h2,factor=hue_mix_fac) + #mix the rest + sat = mix(s1, s2, sat_mix_fac) + val = mix(v1, v2, mix_factor) + + #convert back to rgb floats + red,green,blue = hsv_to_rgb(hue,sat,val) + #convert back to 565 range + red = int(red * 31) + green = int(green * 63) + blue = int(blue * 31) + + return combine_color565(red,green,blue) + + + +def darker_color565(color,mix_factor=0.5): + """ + Get the darker version of a 565 color. + """ + #separate to components + r,g,b = separate_color565(color) + #convert to float 0.0 to 1.0 + r /= 31; g /= 63; b /= 31 + #convert to hsv 0.0 to 1.0 + h,s,v = rgb_to_hsv(r,g,b) + + #higher sat value is percieved as darker + s *= 1 + mix_factor + v *= 1 - mix_factor + + #convert back to rgb floats + r,g,b = hsv_to_rgb(h,s,v) + #convert back to 565 range + r = int(r * 31) + g = int(g * 63) + b = int(b * 31) + + return combine_color565(r,g,b) + + +def lighter_color565(color,mix_factor=0.5): + """ + Get the lighter version of a 565 color. + """ + #separate to components + r,g,b = separate_color565(color) + #convert to float 0.0 to 1.0 + r /= 31; g /= 63; b /= 31 + #convert to hsv 0.0 to 1.0 + h,s,v = rgb_to_hsv(r,g,b) + + #higher sat value is percieved as darker + s *= 1 - mix_factor + v *= 1 + mix_factor + + #convert back to rgb floats + r,g,b = hsv_to_rgb(h,s,v) + #convert back to 565 range + r = int(r * 31) + g = int(g * 63) + b = int(b * 31) + + return combine_color565(r,g,b) + + +def color565_shiftred(color, mix_factor=0.4, hue_mix_fac=0.8, sat_mix_fac=0.8): + """ + Simple convenience function which shifts a color toward red. + This was made for displaying 'negative' ui elements, while sticking to the central color theme. + """ + _RED = const(63488) + return mix_color565(color, _RED, mix_factor, hue_mix_fac, sat_mix_fac) + + +def color565_shiftgreen(color, mix_factor=0.1, hue_mix_fac=0.4, sat_mix_fac=0.1): + """ + Simple convenience function which shifts a color toward green. + This was made for displaying 'positive' ui elements, while sticking to the central color theme. + """ + _GREEN = const(2016) + return mix_color565(color, _GREEN, mix_factor, hue_mix_fac, sat_mix_fac) + +def color565_shiftblue(color, mix_factor=0.1, hue_mix_fac=0.4, sat_mix_fac=0.2): + """ + Simple convenience function which shifts a color toward blue. + """ + _BLUE = const(31) + return mix_color565(color, _BLUE, mix_factor, hue_mix_fac, sat_mix_fac) + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Config Class ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +class Config: + def __init__(self): + """ + This class aims to provide a convenient abstraction of the MicroHydra config.json + The goal of this class is to prevent internal-MicroHydra scripts from reimplementing the same code repeatedly, + and to provide easy to read methods for apps to access MicroHydra config values. + """ + import json + # initialize the config object with the values from config.json + try: + with open("config.json", "r") as conf: + self.config = json.loads(conf.read()) + except: + print("could not load settings from config.json. reloading default values.") + with open("config.json", "w") as conf: + self.config = DEFAULT_CONFIG + conf.write(json.dumps(self.config)) + # storing just the vals from the config lets us check later if any values have been modified + self.initial_values = tuple( self.config.values() ) + # generate an extended color palette + self.generate_palette() + + def save(self): + """If the config has been modified, save it to config.json""" + if tuple( self.config.values() ) != self.initial_values: + import json + with open("config.json", "w") as conf: + conf.write(json.dumps(self.config)) + + def generate_palette(self): + """ + Generate an expanded palette based on user-set UI/BG colors. + """ + ui_color = self.config['ui_color'] + bg_color = self.config['bg_color'] + mid_color = mix_color565(bg_color, ui_color, 0.5) + + + self.palette = ( + darker_color565(bg_color), # darker bg color + bg_color, # bg color + mix_color565(bg_color, ui_color, 0.25), # low-mid color + mid_color, # mid color + mix_color565(bg_color, ui_color, 0.75), # high-mid color + ui_color, # ui color + lighter_color565(ui_color), # lighter ui color + ) + + # Generate a further expanded palette, based on UI colors, shifted towards primary display colors. + self.rgb_colors = ( + color565_shiftred(lighter_color565(bg_color)), # red color + color565_shiftgreen(mid_color), # green color + color565_shiftblue(darker_color565(mid_color)) # blue color + ) + + def __getitem__(self, key): + # get item passthrough + return self.config[key] + + def __setitem__(self, key, new_val): + # item assignment passthrough + self.config[key] = new_val + + + diff --git a/ports/esp32/boards/MICROHYDRA/lib/mhoverlay.py b/ports/esp32/boards/MICROHYDRA/lib/mhoverlay.py new file mode 100644 index 000000000000..ebf8c89f854d --- /dev/null +++ b/ports/esp32/boards/MICROHYDRA/lib/mhoverlay.py @@ -0,0 +1,207 @@ +import time +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ UI_Overlay Class ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +class UI_Overlay: + def __init__(self, config, keyboard, display_fbuf=None, display_py=None): + """ + UI_Overlay aims to provide easy to use methods for displaying themed UI popups, and other Overlays. + params: + config:Config + - A 'lib.mhconfig.Config' object. + + keyboard:KeyBoard + - A 'KeyBoard' object from lib.keyboard + + display_fbuf:ST7789 + display_py:ST7789 + - An 'ST7789' object from lib.st7789py or lib.st7789fbuf + - One of them must be supplied. + """ + self.config = config + self.kb = keyboard + + # import our display to write to! + self.compatibility_mode = False # working with st7789fbuf + if display_fbuf: + self.display = display_fbuf + elif display_py: + from font import vga1_8x16 as font + self.display = display_py + self.compatibility_mode = True # for working with st7789py + self.font = font + else: + raise ValueError("UI_Overlay must be initialized with either 'display_fbuf' or 'display_py'.") + + @staticmethod + def split_lines(text, max_length=27): + """Split a string into multiple lines, based on max line-length.""" + lines = [] + current_line = '' + words = text.split() + + for word in words: + if len(word) + len(current_line) >= max_length: + lines.append(current_line) + current_line = word + elif len(current_line) == 0: + current_line += word + else: + current_line += ' ' + word + + lines.append(current_line) # add final line + + return lines + + def popup(self,text): + """ + Display a popup message with given text. + Blocks until any button is pressed. + """ + # split text into lines + lines = self.split_lines(text, max_length = 27) + try: + if self.compatibility_mode: + # use the st7789py driver to display popup + box_height = (len(lines) * 16) + 8 + box_width = (len(max(lines, key=len)) * 8) + 8 + box_x = 120 - (box_width // 2) + box_y = 67 - (box_height // 2) + + self.display.fill_rect(box_x, box_y, box_width, box_height, self.config.palette[0]) + self.display.rect(box_x-1, box_y-1, box_width+2, box_height+2, self.config.palette[2]) + self.display.rect(box_x-2, box_y-2, box_width+4, box_height+4, self.config.palette[3]) + self.display.rect(box_x-3, box_y-3, box_width+6, box_height+6, self.config.palette[4]) + + for idx, line in enumerate(lines): + centered_x = 120 - (len(line) * 4) + self.display.text(self.font, line, centered_x, box_y + 4 + (idx*16), self.config.palette[-1], self.config.palette[0]) + else: + #use the st7789fbuf driver to display popup + box_height = (len(lines) * 10) + 8 + box_width = (len(max(lines, key=len)) * 8) + 8 + box_x = 120 - (box_width // 2) + box_y = 67 - (box_height // 2) + + self.display.rect(box_x, box_y, box_width, box_height, self.config.palette[0], fill=True) + self.display.rect(box_x-1, box_y-1, box_width+2, box_height+2, self.config.palette[2], fill=False) + self.display.rect(box_x-2, box_y-2, box_width+4, box_height+4, self.config.palette[3], fill=False) + self.display.rect(box_x-3, box_y-3, box_width+6, box_height+6, self.config.palette[4], fill=False) + + for idx, line in enumerate(lines): + centered_x = 120 - (len(line) * 4) + self.display.text(line, centered_x, box_y + 4 + (idx*10), self.config.palette[-1]) + self.display.show() + + time.sleep_ms(200) + self.kb.get_new_keys() # run once to update keys + while True: + if self.kb.get_new_keys(): + return + except TypeError as e: + raise TypeError(f"popup() failed. Double check that 'UI_Overlay' object was initialized with correct keywords: {e}") + + def error(self,text): + """ + Display a popup error message with given text. + Blocks until any button is pressed. + """ + # split text into lines + lines = self.split_lines(text, max_length = 27) + try: + if self.compatibility_mode: + # use the st7789py driver to display popup + box_height = (len(lines) * 16) + 24 + box_width = (len(max(lines, key=len)) * 8) + 8 + box_x = 120 - (box_width // 2) + box_y = 67 - (box_height // 2) + + self.display.fill_rect(box_x, box_y, box_width, box_height, 0) + self.display.rect(box_x-1, box_y-1, box_width+2, box_height+2, self.config.rgb_colors[0]) + self.display.rect(box_x-2, box_y-2, box_width+4, box_height+4, self.config.palette[0]) + self.display.rect(box_x-3, box_y-3, box_width+6, box_height+6, self.config.rgb_colors[0]) + + self.display.text(self.font, "ERROR", 100, box_y + 4, self.config.rgb_colors[0]) + for idx, line in enumerate(lines): + centered_x = 120 - (len(line) * 4) + self.display.text(self.font, line, centered_x, box_y + 20 + (idx*16), 65535, 0) + else: + #use the st7789fbuf driver to display popup + box_height = (len(lines) * 10) + 20 + box_width = (len(max(lines, key=len)) * 8) + 8 + box_x = 120 - (box_width // 2) + box_y = 67 - (box_height // 2) + + self.display.rect(box_x, box_y, box_width, box_height, 0, fill=True) + self.display.rect(box_x-1, box_y-1, box_width+2, box_height+2, self.config.rgb_colors[0], fill=False) + self.display.rect(box_x-2, box_y-2, box_width+4, box_height+4, self.config.palette[0], fill=False) + self.display.rect(box_x-3, box_y-3, box_width+6, box_height+6, self.config.rgb_colors[0], fill=False) + + self.display.text("ERROR", 100, box_y + 4, self.config.rgb_colors[0]) + for idx, line in enumerate(lines): + centered_x = 120 - (len(line) * 4) + self.display.text(line, centered_x, box_y + 16 + (idx*10), 65535) + self.display.show() + + time.sleep_ms(200) + self.kb.get_new_keys() # run once to update keys + while True: + if self.kb.get_new_keys(): + return + time.sleep_ms(1) + except TypeError as e: + raise TypeError(f"error() failed. Double check that 'UI_Overlay' object was initialized with correct keywords: {e}") + + +if __name__ == "__main__": + # just for testing + reserved_bytearray = bytearray(240*135*2) + from lib import st7789fbuf, keyboard + from lib.mhconfig import Config + from machine import Pin, SPI + + tft = st7789fbuf.ST7789( + SPI(1, baudrate=40000000, sck=Pin(36), mosi=Pin(35), miso=None), + 135, + 240, + reset=Pin(33, Pin.OUT), + cs=Pin(37, Pin.OUT), + dc=Pin(34, Pin.OUT), + backlight=Pin(38, Pin.OUT), + rotation=1, + color_order=st7789fbuf.BGR, + reserved_bytearray=reserved_bytearray + ) + + kb = keyboard.KeyBoard() + config = Config() + overlay = UI_Overlay(config=config, keyboard=kb, display_fbuf=tft) + + # popup demo: + tft.fill(0) + tft.show() + time.sleep(0.5) + + overlay.popup("Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups.") + tft.fill(0) + tft.show() + time.sleep(0.5) + + overlay.error("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt") + tft.fill(0) + tft.show() + + # color palette + bar_width = 240 // len(config.palette) + for i in range(0,len(config.palette)): + tft.fill_rect(bar_width*i, 0, bar_width, 135, config.palette[i]) + + # extended colors + bar_width = 240 // len(config.rgb_colors) + for i in range(0,len(config.rgb_colors)): + tft.fill_rect(bar_width*i, 0, bar_width, 20, config.rgb_colors[i]) + + config.save() # this should do nothing + + tft.show() + time.sleep(2) + tft.fill(0) + tft.show() diff --git a/ports/esp32/boards/MICROHYDRA/lib/st7789fbuf.py b/ports/esp32/boards/MICROHYDRA/lib/st7789fbuf.py index b05da0ab99ab..ed89312495ca 100644 --- a/ports/esp32/boards/MICROHYDRA/lib/st7789fbuf.py +++ b/ports/esp32/boards/MICROHYDRA/lib/st7789fbuf.py @@ -384,6 +384,8 @@ class ST7789: custom_rotations (tuple): custom rotation definitions - ((width, height, xstart, ystart, madctl, needs_swap), ...) + + reserved_bytearray (bytearray): pre-allocated bytearray to use for framebuffer """ @@ -400,6 +402,7 @@ def __init__( color_order=BGR, custom_init=None, custom_rotations=None, + reserved_bytearray = None ): """ Initialize display. @@ -417,10 +420,13 @@ def __init__( raise ValueError("dc pin is required.") #init the fbuf + if reserved_bytearray == None: + reserved_bytearray = bytearray(height*width*2) + if rotation == 1 or rotation == 3: - self.fbuf = framebuf.FrameBuffer(bytearray(height*width*2), height, width, framebuf.RGB565) + self.fbuf = framebuf.FrameBuffer(reserved_bytearray, height, width, framebuf.RGB565) else: - self.fbuf = framebuf.FrameBuffer(bytearray(height*width*2), width, height, framebuf.RGB565) + self.fbuf = framebuf.FrameBuffer(reserved_bytearray, width, height, framebuf.RGB565) self.physical_width = self.width = width self.physical_height = self.height = height @@ -441,6 +447,7 @@ def __init__( self.rotation(self._rotation) self.needs_swap = True self.fill(0x0) + self.show() if backlight is not None: backlight.value(1) @@ -730,6 +737,8 @@ def line(self, x0, y0, x1, y1, color): y1 (int): End point y coordinate color (int): 565 encoded color """ + if self.needs_swap: + color = swap_bytes(color) self.fbuf.line(x0, y0, x1, y1, color) def vscrdef(self, tfa, vsa, bfa):