diff --git a/App/PortMaster/update_images.sh b/App/PortMaster/update_images.sh index 35f54498d..bd6db11e8 100644 --- a/App/PortMaster/update_images.sh +++ b/App/PortMaster/update_images.sh @@ -13,10 +13,7 @@ if [ -f /mnt/SDCARD/Persistent/portmaster/bin/python3 ] ; then $PM_PYTHON_PATH -m pip install --no-index --find-links=/mnt/SDCARD/App/PortMaster/pillow_offline Pillow fi else # Pixel2 stock portmaster - PM_PYTHON_PATH="/mnt/SDCARD/spruce/pixel2/bin/python" - if [ ! -d "/mnt/SDCARD/spruce/pixel2/lib/python3.10/site-packages/PIL/" ] ; then - $PM_PYTHON_PATH -m pip install --no-index --find-links=/mnt/SDCARD/App/PortMaster/pillow_offline Pillow - fi + PM_PYTHON_PATH="/usr/bin/python" fi diff --git a/App/PyUI/launch.sh b/App/PyUI/launch.sh index a3977020d..4b62444e0 100644 --- a/App/PyUI/launch.sh +++ b/App/PyUI/launch.sh @@ -214,7 +214,7 @@ case "$PLATFORM" in cd /usr/bin/ export PYSDL2_DLL_PATH="/usr/lib" - cmd="/mnt/SDCARD/spruce/pixel2/bin/MainUI \ + cmd="/usr/bin/MainUI \ /mnt/SDCARD/App/PyUI/main-ui/mainui.py \ -device GKD_PIXEL2 \ -logDir /mnt/SDCARD/Saves/spruce \ diff --git a/App/PyUI/main-ui/devices/device_common.py b/App/PyUI/main-ui/devices/device_common.py index 83922130e..a0f674167 100644 --- a/App/PyUI/main-ui/devices/device_common.py +++ b/App/PyUI/main-ui/devices/device_common.py @@ -531,6 +531,34 @@ def sync_hw_clock(self): except Exception as e: PyUiLogger.get_logger.error(f"Failed to run hwclock: {e}") + SPRUCE_HELPER_FUNCTIONS = "/mnt/SDCARD/spruce/scripts/helperFunctions.sh" + + def _apply_spruce_cpu_mode(self, shell_function): + """ + Hand off to the shell's CPU mode functions, which already know each + platform's cores and frequencies. Devices without them fall back to + set_smart via platform/device.sh, so this is safe everywhere. + """ + if not os.path.exists(self.SPRUCE_HELPER_FUNCTIONS): + return + + try: + subprocess.run( + ["/bin/sh", "-c", f". {self.SPRUCE_HELPER_FUNCTIONS} && {shell_function}"], + check=False, + timeout=10 + ) + except Exception as e: + PyUiLogger.get_logger().warning(f"Could not apply CPU mode {shell_function}: {e}") + + def set_cpu_low_power(self): + """Drop to the platform's powersave profile while the device sits idle.""" + self._apply_spruce_cpu_mode("set_powersave") + + def set_cpu_normal(self): + """Back to the mode the menu normally runs in.""" + self._apply_spruce_cpu_mode("set_smart") + def animation_divisor(self): return self.get_system_config().animation_speed(1) diff --git a/App/PyUI/main-ui/devices/miyoo/mini/miyoo_mini_common.py b/App/PyUI/main-ui/devices/miyoo/mini/miyoo_mini_common.py index 9ec62ce91..8e69d3afb 100644 --- a/App/PyUI/main-ui/devices/miyoo/mini/miyoo_mini_common.py +++ b/App/PyUI/main-ui/devices/miyoo/mini/miyoo_mini_common.py @@ -1,3 +1,4 @@ +import ctypes import re import tempfile import time @@ -37,6 +38,64 @@ MI_AO_GETVOLUME = 0xc008690c MI_AO_SETMUTE = 0x4008690d + +class MiDispCsc(ctypes.Structure): + """MI_DISP_Csc_t. Every field 0-100; stock defaults are 50 except saturation at 40.""" + _fields_ = [ + ("eCscMatrix", ctypes.c_uint32), + ("u32Luma", ctypes.c_uint32), + ("u32Contrast", ctypes.c_uint32), + ("u32Hue", ctypes.c_uint32), + ("u32Saturation", ctypes.c_uint32), + ] + + +class MiDispLcdParam(ctypes.Structure): + """MI_DISP_LcdParam_t -- 24 bytes, six u32.""" + _fields_ = [ + ("stCsc", MiDispCsc), + ("u32Sharpness", ctypes.c_uint32), + ] + + +class MiDispSyncInfo(ctypes.Structure): + """MI_DISP_SyncInfo_t. Read back and sent again untouched; only the size matters.""" + _fields_ = [ + ("bSynm", ctypes.c_uint8), + ("bIop", ctypes.c_uint8), + ("u8Intfb", ctypes.c_uint8), + ("u16Vact", ctypes.c_uint16), + ("u16Vbb", ctypes.c_uint16), + ("u16Vfb", ctypes.c_uint16), + ("u16Hact", ctypes.c_uint16), + ("u16Hbb", ctypes.c_uint16), + ("u16Hfb", ctypes.c_uint16), + ("u16Hmid", ctypes.c_uint16), + ("u16Bvact", ctypes.c_uint16), + ("u16Bvbb", ctypes.c_uint16), + ("u16Bvfb", ctypes.c_uint16), + ("u16Hpw", ctypes.c_uint16), + ("u16Vpw", ctypes.c_uint16), + ("bIdv", ctypes.c_uint8), + ("bIhs", ctypes.c_uint8), + ("bIvs", ctypes.c_uint8), + ("u32FrameRate", ctypes.c_uint32), + ] + + +class MiDispPubAttr(ctypes.Structure): + """MI_DISP_PubAttr_t.""" + _fields_ = [ + ("u32BgColor", ctypes.c_uint32), + ("eIntfType", ctypes.c_uint32), + ("eIntfSync", ctypes.c_uint32), + ("stSyncInfo", MiDispSyncInfo), + ] + + +E_MI_DISP_INTF_LCD = 6 +E_MI_DISP_OUTPUT_USER = 32 + class MiyooMiniCommon(MiyooDevice): OUTPUT_MIXER = 2 SOUND_DISABLED = 0 @@ -231,6 +290,14 @@ def _update_stock_config(self, key, value): try: # Only proceed if file exists if not os.path.isfile(path): + PyUiLogger.get_logger().warning(f"{path} does not exist, cannot store {key}") + return + + # On the OG Mini and the V4 this file is empty, which sent every write + # through the sed fallback below where there was nothing to match. Say + # so rather than spawning a sed per setting on every boot to no effect. + if os.path.getsize(path) == 0: + PyUiLogger.get_logger().warning(f"{path} is empty, cannot store {key}") return # Load existing JSON (fail silently if invalid) @@ -278,24 +345,431 @@ def _update_stock_config(self, key, value): ) except Exception: pass + BACKLIGHT_PWM_DUTY_CYCLE = "/sys/class/pwm/pwmchip0/pwm0/duty_cycle" + def _set_lumination_to_config(self): # Miyoo internally has lumination but it does not work self._update_stock_config("brightness", self.system_config.backlight) self.miyoo_mini_flip_shared_memory_writer.set_brightness(self.system_config.backlight) + self._write_backlight_pwm(self.system_config.backlight) + + def _write_backlight_pwm(self, backlight): + """ + Drive the backlight ourselves. + + keymon is running and does pick up the shared memory write above, but on + the OG Mini and the V4 that never reached the panel, so the backlight + setting did nothing at all. Writing the pwm channel is what the sprig + build does on this same hardware. Duty cycle matches what device_init + sets at boot, against the same period of 1000. + """ + if not os.path.exists(self.BACKLIGHT_PWM_DUTY_CYCLE): + return + + try: + duty_cycle = max(0, min(10, int(backlight))) * 10 + with open(self.BACKLIGHT_PWM_DUTY_CYCLE, "w") as f: + f.write(str(duty_cycle)) + except Exception as e: + PyUiLogger.get_logger().warning(f"Could not set backlight pwm: {e}") + + DISPLAY_DEVICE = "/dev/mi_disp" + DISPLAY_CONTROL_NODE = "/proc/mi_modules/mi_disp/mi_disp0" + # Colour balance channels. Never 0: that is a black screen with no way back + # from the menu. + MIN_CHANNEL_GAIN = 24 + MAX_CHANNEL_GAIN = 255 + + def _clamp_channel(self, value): + return int(max(self.MIN_CHANNEL_GAIN, min(self.MAX_CHANNEL_GAIN, round(value)))) + + _mi_disp_lib = None + _mi_disp_lib_tried = False + _mi_sys_lib = None + + def _load_mi_disp(self): + """ + The MI display library, or None. + + Lives in /customer/lib on the device, which the startup script already has + on LD_LIBRARY_PATH. Loaded lazily and only attempted once. + """ + if self._mi_disp_lib_tried: + return self._mi_disp_lib + + MiyooMiniCommon._mi_disp_lib_tried = True + + # libmi_disp.so does not carry its own dependencies -- loading it on its + # own fails with "undefined symbol: MI_SYS_Mmap". Pull what it leans on + # into the global symbol table first. They live in /config/lib, which the + # startup script already puts on LD_LIBRARY_PATH. + for dependency in ("libmi_sys.so", "libmi_common.so", "libmi_panel.so"): + try: + handle = ctypes.CDLL(dependency, mode=ctypes.RTLD_GLOBAL) + if dependency == "libmi_sys.so": + MiyooMiniCommon._mi_sys_lib = handle + PyUiLogger.get_logger().info(f"Loaded MI dependency {dependency}") + except Exception as e: + PyUiLogger.get_logger().info(f"MI dependency {dependency} not loaded: {e}") + + for name in ("libmi_disp.so", "/config/lib/libmi_disp.so"): + try: + MiyooMiniCommon._mi_disp_lib = ctypes.CDLL(name, mode=ctypes.RTLD_GLOBAL) + PyUiLogger.get_logger().info(f"Loaded MI display library from {name}") + return MiyooMiniCommon._mi_disp_lib + except Exception as e: + PyUiLogger.get_logger().info(f"Could not load {name}: {e}") + + PyUiLogger.get_logger().warning("No MI display library available") + return None + + # Observed on the V4 the first time the params are read back. Only used if + # the read fails, so that a bad read costs the settings rather than the + # picture. + DEFAULT_CSC_MATRIX = 3 + DEFAULT_SHARPNESS = 0 + + _last_csc = None + _lcd_output_ready = None + + def _read_lcd_param(self, lib, params): + try: + return lib.MI_DISP_GetLcdParam(0, ctypes.byref(params)) + except Exception as e: + PyUiLogger.get_logger().warning(f"MI_DISP_GetLcdParam unavailable: {e}") + return -1 + + def _prepare_lcd_output(self): + """ + Get the display device into a state where the lcd params can be read + and written, once per process and as early as we can manage. + + Getting there disturbs the picture, which is why it matters that this + happens here rather than on every apply. It used to run on all four of + the settings restored at startup and again on every change from the + menu. That is where the fuzzy static during boot came from, the flash + in the menus whenever a setting was applied, and -- since the + screensaver only redraws once a minute -- a single garbage frame left + sitting on screen underneath the clock rather than being replaced. + + Deliberately left where it is, on the first apply, rather than moved + into device init to get it done before SDL takes the display. That was + tried: the settings applied, but the device hung on shutdown, with the + screen stuck on the fuzz and needing the battery pulled. Moving it back + after SDL is up shut down cleanly again. Not chased further than that, + since with the device handed back below there is no longer a reason to + want it earlier. + + Escalates rather than reconfiguring up front, since the cheapest step + that works is the one that disturbs least. Only the last of these + touches the display at all: + + 1. Just read the params. If the device is already up, nothing else + is needed and nothing gets touched. + 2. MI_SYS_Init first. Every MI module wants this before it will + answer, and nothing in PyUI had called it -- SDL's mmiyoo backend + does its own, but through its own handle. This is the step worth + hoping for: it costs nothing on screen. + 3. MI_DISP_Enable on its own. + 4. MI_DISP_SetPubAttr as an lcd output, then enable. This is the + step the V4 actually needs, and the only one that touches the + display. Note it runs after step 3, which matters: on its own + MI_DISP_GetPubAttr comes back rc=31 and the struct would go out + zeroed, taking the panel timings with it, but following an enable + it returns rc=0 and the timings are read back and sent again + untouched. + """ + if MiyooMiniCommon._lcd_output_ready is not None: + return MiyooMiniCommon._lcd_output_ready + + MiyooMiniCommon._lcd_output_ready = False + + lib = self._load_mi_disp() + if lib is None: + return False + + params = MiDispLcdParam() + + rc = self._read_lcd_param(lib, params) + if rc == 0: + PyUiLogger.get_logger().info("MI display lcd params readable as is") + MiyooMiniCommon._lcd_output_ready = True + return True + + if self._mi_sys_lib is not None: + try: + rc_sys = self._mi_sys_lib.MI_SYS_Init() + rc = self._read_lcd_param(lib, params) + PyUiLogger.get_logger().info( + f"MI_SYS_Init rc={rc_sys}, GetLcdParam rc={rc}") + if rc == 0: + MiyooMiniCommon._lcd_output_ready = True + return True + except Exception as e: + PyUiLogger.get_logger().info(f"MI_SYS_Init unavailable: {e}") + + try: + rc_enable = lib.MI_DISP_Enable(0) + rc = self._read_lcd_param(lib, params) + PyUiLogger.get_logger().info( + f"MI_DISP_Enable rc={rc_enable}, GetLcdParam rc={rc}") + if rc == 0: + MiyooMiniCommon._lcd_output_ready = True + return True + + attrs = MiDispPubAttr() + rc_get = lib.MI_DISP_GetPubAttr(0, ctypes.byref(attrs)) + attrs.eIntfType = E_MI_DISP_INTF_LCD + attrs.eIntfSync = E_MI_DISP_OUTPUT_USER + rc_set = lib.MI_DISP_SetPubAttr(0, ctypes.byref(attrs)) + rc_enable = lib.MI_DISP_Enable(0) + rc = self._read_lcd_param(lib, params) + PyUiLogger.get_logger().info( + f"MI_DISP GetPubAttr rc={rc_get} SetPubAttr rc={rc_set} " + f"Enable rc={rc_enable}, GetLcdParam rc={rc}") + except Exception as e: + PyUiLogger.get_logger().warning(f"Could not enable MI display device: {e}") + return False + + MiyooMiniCommon._lcd_output_ready = rc == 0 + if not MiyooMiniCommon._lcd_output_ready: + PyUiLogger.get_logger().warning( + "MI display lcd params unreadable, screen settings will not apply") + return False + + self._release_display_if_params_survive(lib, params) + return True + + def _release_display_if_params_survive(self, lib, params): + """ + Hand the display device back if the lcd params can still be reached + without it. + + Stock never has this device enabled. The bug report dump has DevStatus + 0 with no channels enabled, and everything worked that way: SDL's + mmiyoo backend drives the panel through fb0 and gfx and leaves disp + alone. Enabling it is a change that outlives the call -- it switches on + a layer underneath an alpha blended osd (mi_fb0 reports ARGB8888 with + Enable Alpha Blend=1) with nothing feeding it, so it scans out whatever + was in that memory. That is the fuzz, and it is why the screensaver + still showed it a minute after the one and only reconfigure. + + So put it back if we can. If the params can still be read and written + with the device disabled then nothing was gained by holding it enabled, + and the picture is left the way stock has it. + + Both directions get tested, not just the read: writing is what actually + applies a setting, and it is the one that has to keep working. The + write puts back exactly what was just read, so it changes nothing. + """ + try: + rc_disable = lib.MI_DISP_Disable(0) + rc = self._read_lcd_param(lib, params) + rc_write = lib.MI_DISP_SetLcdParam(0, ctypes.byref(params)) if rc == 0 else -1 + except Exception as e: + PyUiLogger.get_logger().info(f"MI_DISP_Disable unavailable: {e}") + return + + PyUiLogger.get_logger().info( + f"MI_DISP_Disable rc={rc_disable}, GetLcdParam rc={rc}, " + f"SetLcdParam rc={rc_write}") + + if rc == 0 and rc_write == 0: + PyUiLogger.get_logger().info( + "MI display params still reachable disabled, leaving it that way") + return + + # Needed after all. Put it back and carry on, at the cost of the layer + # underneath being whatever it is. + try: + rc_enable = lib.MI_DISP_Enable(0) + rc = self._read_lcd_param(lib, params) + PyUiLogger.get_logger().info( + f"MI display params need it enabled, re-enabled rc={rc_enable}, " + f"GetLcdParam rc={rc}") + except Exception as e: + PyUiLogger.get_logger().warning(f"Could not re-enable MI display device: {e}") + + def _apply_lcd_csc(self, luma, contrast, hue, saturation): + """ + Drive brightness, contrast, hue and saturation through the LCD output's + colour space conversion. + + This is a different block from the one the proc 'csc' command reaches. + That one sits on the video path and does nothing to the menu, which is + why every attempt through it failed no matter which matrix was used. + This one is only reachable through the MI library, and only once + _prepare_lcd_output has had the device up. + + Nothing below that line touches how the display is configured. Writing + the csc coefficients on their own is not what disturbs the picture; + reconfiguring the output is, so that is done once and not from here. + + All four values are 0-100. Luma and contrast keep a floor so the panel + cannot be driven to something unreadable from the menu. + """ + if not self._prepare_lcd_output(): + return False + + lib = self._load_mi_disp() + if lib is None: + return False + + wanted = ( + self._clamp_csc(luma, floor=10), + self._clamp_csc(contrast, floor=10), + self._clamp_csc(hue), + self._clamp_csc(saturation), + ) + + # Every setting restored at startup calls through here, so without this + # the same four values get written three times over on every boot. + if MiyooMiniCommon._last_csc == wanted: + return True + + params = MiDispLcdParam() + rc = self._read_lcd_param(lib, params) + + PyUiLogger.get_logger().info( + f"MI_DISP_GetLcdParam rc={rc} matrix={params.stCsc.eCscMatrix} " + f"luma={params.stCsc.u32Luma} contrast={params.stCsc.u32Contrast} " + f"hue={params.stCsc.u32Hue} saturation={params.stCsc.u32Saturation} " + f"sharpness={params.u32Sharpness}") + + if rc != 0: + # The read came back empty, so the rest of the struct is zeroed and + # writing it as is would clear the matrix along with everything else. + # Fill in what the panel reported when the read did work. + params.stCsc.eCscMatrix = self.DEFAULT_CSC_MATRIX + params.u32Sharpness = self.DEFAULT_SHARPNESS + PyUiLogger.get_logger().warning( + f"MI_DISP_GetLcdParam failed rc={rc}, writing with matrix=" + f"{self.DEFAULT_CSC_MATRIX} sharpness={self.DEFAULT_SHARPNESS}") + + (params.stCsc.u32Luma, + params.stCsc.u32Contrast, + params.stCsc.u32Hue, + params.stCsc.u32Saturation) = wanted + + try: + rc = lib.MI_DISP_SetLcdParam(0, ctypes.byref(params)) + except Exception as e: + PyUiLogger.get_logger().warning(f"MI_DISP_SetLcdParam unavailable: {e}") + return False + + PyUiLogger.get_logger().info( + f"MI_DISP_SetLcdParam rc={rc} luma={params.stCsc.u32Luma} " + f"contrast={params.stCsc.u32Contrast} hue={params.stCsc.u32Hue} " + f"saturation={params.stCsc.u32Saturation}") + + if rc == 0: + MiyooMiniCommon._last_csc = wanted + return rc == 0 + + def _clamp_csc(self, value, floor=0): + return int(max(floor, min(100, round(value)))) + + + _display_fd = None + + def _ensure_display_device_open(self): + """ + Hold the display device open so its control node stays alive. + + /proc/mi_modules/mi_disp/mi_disp0 only exists while something has + /dev/mi_disp open. On stock firmware MainUI holds it; spruce kills MainUI + and renders through the framebuffer, so nothing did, the node was never + there, and every contrast and saturation write went nowhere. + + It has to stay open rather than being opened per write: closing the device + takes the node away again, and the display instance the csc values were + applied to goes with it, so anything written is immediately undone. This + is the same state stock runs in, and why sprig's shell script works on the + Flip -- something over there is already holding the device. + """ + if self._display_fd is not None: + return True + + try: + fd = os.open(self.DISPLAY_DEVICE, os.O_RDWR) + except Exception as e: + PyUiLogger.get_logger().warning(f"Could not open {self.DISPLAY_DEVICE}: {e}") + return False + + # Created by the driver's open handler, so it should already be there. + for _ in range(20): + if os.path.exists(self.DISPLAY_CONTROL_NODE): + self._display_fd = fd + PyUiLogger.get_logger().info( + f"Holding {self.DISPLAY_DEVICE} open so {self.DISPLAY_CONTROL_NODE} stays available") + return True + time.sleep(0.01) + + PyUiLogger.get_logger().warning( + f"{self.DISPLAY_CONTROL_NODE} did not appear after opening {self.DISPLAY_DEVICE}") + os.close(fd) + return False + + def _set_screen_values_to_config(self): + """ + Push brightness, contrast, hue, saturation and colour balance at the + display engine. + + Two separate paths, because the panel only listens to each for its own + half. Brightness, contrast, hue and saturation go through the LCD + output's colour space conversion, reachable only via the MI library. + Colour balance goes through the colortemp command on the disp proc node, + whose per channel values are gains with 128 as unity. + + Config values run 0-20 and both interfaces want a different scale, so + each is converted at the point of use. + """ + self._apply_lcd_csc( + luma=self.system_config.brightness * 5, + contrast=self.system_config.contrast * 5, + hue=self.system_config.hue * 5, + saturation=self.system_config.saturation * 5, + ) + + red = self._clamp_channel(self.get_disp_red()) + green = self._clamp_channel(self.get_disp_green()) + blue = self._clamp_channel(self.get_disp_blue()) + colortemp = f"colortemp 0 0 0 0 {blue} {green} {red}" + + if not self._ensure_display_device_open(): + return + + try: + with open(self.DISPLAY_CONTROL_NODE, "w") as f: + f.write(colortemp + "\n") + PyUiLogger.get_logger().info(f"Applied colour balance: [{colortemp}]") + except Exception as e: + PyUiLogger.get_logger().warning( + f"Could not apply colour balance [{colortemp}]: {e}") def _set_contrast_to_config(self): self._update_stock_config("contrast", self.system_config.contrast) - + self._set_screen_values_to_config() + def _set_saturation_to_config(self): - #Doesn't seem to work? self._update_stock_config("saturation", self.system_config.saturation) + self._set_screen_values_to_config() def _set_brightness_to_config(self): - #Doesn't seem to work? self._update_stock_config("lumination", self.system_config.brightness) + self._set_screen_values_to_config() def _set_hue_to_config(self): - pass + self._set_screen_values_to_config() + + def _set_disp_red_to_config(self): + self._set_screen_values_to_config() + + def _set_disp_green_to_config(self): + self._set_screen_values_to_config() + + def _set_disp_blue_to_config(self): + self._set_screen_values_to_config() def take_snapshot(self, path): return None @@ -474,6 +948,11 @@ def run_game(self, rom_info: RomInfo) -> subprocess.Popen: subprocess.run(cmds, cwd = directory, env=env) Display.init() + # RetroArch brings the display up itself, so whatever csc it left + # behind is not what we last wrote. Forget it so the next apply + # actually reaches the panel rather than matching a stale cache. + MiyooMiniCommon._last_csc = None + Controller.clear_input_queue() def double_init_sdl_display(self): diff --git a/App/PyUI/main-ui/display/display.py b/App/PyUI/main-ui/display/display.py index de10614cd..3fd5fd7dd 100644 --- a/App/PyUI/main-ui/display/display.py +++ b/App/PyUI/main-ui/display/display.py @@ -94,6 +94,7 @@ class Display: _image_texture_cache = ImageTextureCache() _text_texture_cache = TextTextureCache() _screensaver_active = False + _screensaver_lowered_cpu = False _screensaver_saved_lumination = None _problematic_images = set() # Class-level set to track images that won't load properly _problematic_image_keywords = [ @@ -379,10 +380,10 @@ def set_new_bg(cls, bg_path, is_custom_theme_background, retry=True): PyUiLogger.get_logger().error(f"Background path none") @classmethod - def set_page_bg(cls, page_bg): - if cls._screensaver_active: - return - background = Theme.background(page_bg) + def set_page_bg(cls, page_bg): + if cls._screensaver_active: + return + background = Theme.background(page_bg) if(background is not None and os.path.exists(background)): cls.set_new_bg(background, is_custom_theme_background=True) @@ -1043,10 +1044,30 @@ def blank_screen(cls): from display.screensaver import ScreenSaver ScreenSaver.render() + # Sitting on a still screensaver there is nothing left to do, so let the + # device idle down. Rendering first means ScreenSaver knows by now whether + # the background animates; gif and box art are left alone because they + # carry on doing real work. + if Theme.get_screensaver_low_power() and not ScreenSaver.is_animating(): + try: + Device.get_device().set_cpu_low_power() + cls._screensaver_lowered_cpu = True + except Exception as e: + PyUiLogger.get_logger().warning(f"Could not lower CPU for screensaver: {e}") + @classmethod def restore_from_blank(cls): if not cls._screensaver_active: return + + # Before anything redraws, so waking up doesn't happen at idle clocks. + if cls._screensaver_lowered_cpu: + cls._screensaver_lowered_cpu = False + try: + Device.get_device().set_cpu_normal() + except Exception as e: + PyUiLogger.get_logger().warning(f"Could not restore CPU after screensaver: {e}") + cls._screensaver_active = False #TODO make default false and fix everywhere @@ -1248,4 +1269,4 @@ def display_image(cls,image_path, duration_ms=0): Display.render_image(image_path,Device.get_device().screen_width()//2,Device.get_device().screen_height()//2,RenderMode.MIDDLE_CENTER_ALIGNED) Display.present() # Sleep for the specified duration in milliseconds - time.sleep(duration_ms / 1000) + time.sleep(duration_ms / 1000) diff --git a/App/PyUI/main-ui/display/screensaver.py b/App/PyUI/main-ui/display/screensaver.py index 54ca07b96..b49893f5a 100644 --- a/App/PyUI/main-ui/display/screensaver.py +++ b/App/PyUI/main-ui/display/screensaver.py @@ -16,30 +16,61 @@ class ScreenSaver: # Stored in the theme as bgImage to select the random box art mode BOXART_SENTINEL = "__boxart__" + # Clock is rendered as HH:MM and the battery moves slowly, so once a minute + # covers every widget we draw. + WIDGET_TYPES_THAT_CHANGE = ("clock", "date", "battery") + _animation_path = None _animation = None _animation_frame = 0 _animation_next_time = 0 _boxart_current = None _boxart_next_time = 0 + _widgets_next_time = 0 + _static_surface = None + _static_key = None @classmethod def render(cls): + widgets = [] try: renderer = Device.get_device() screen_w = renderer.screen_width() screen_h = renderer.screen_height() from display.display import Display + widgets = cls._get_enabled_widgets() + cls._render_background(screen_w, screen_h, Display) - widgets = cls._get_enabled_widgets() for widget in widgets: cls._render_widget(widget, screen_w, screen_h, Display) cls._present_without_bars(Display) except Exception as e: PyUiLogger.get_logger().error(f"ScreenSaver render error: {e}") + finally: + # Always push the deadline forward, even when the render above failed. + # Leaving it in the past means every poll retries straight away, which + # would spin the CPU and fill the log rather than fail quietly. + cls._schedule_next_widget_render(widgets) + + @classmethod + def _schedule_next_widget_render(cls, widgets): + """ + The clock, date and battery go stale on their own, so they need a redraw + of their own. Without this the screensaver only redraws when the + background wants to, meaning a static or solid colour background left the + time and battery frozen at whatever they were when it kicked in. + """ + if not any(w.get("type", "") in cls.WIDGET_TYPES_THAT_CHANGE for w in widgets): + cls._widgets_next_time = 0 + return + + # Land on the start of the next minute so the clock ticks over when it + # actually changes rather than drifting a little further out each time. + now = time.time() + cls._widgets_next_time = now + (60 - (now % 60)) @classmethod def render_if_needed(cls): @@ -48,11 +79,24 @@ def render_if_needed(cls): cls.render() elif cls._boxart_next_time and now >= cls._boxart_next_time: cls.render() + elif cls._widgets_next_time and now >= cls._widgets_next_time: + cls.render() + + @classmethod + def is_animating(cls): + """ + True when the background itself keeps changing -- gif frames or box art + rotation. Those need the CPU to go on doing real work, so the device + should not be idled down underneath them. + """ + return bool(cls._animation_path) or bool(cls._boxart_next_time) @classmethod def clear_cache(cls): cls._clear_animation() cls._clear_boxart() + cls._free_static_surface() + cls._widgets_next_time = 0 @classmethod def _present_without_bars(cls, Display): @@ -89,10 +133,12 @@ def _render_background(cls, screen_w, screen_h, Display): if bg_image == cls.BOXART_SENTINEL: cls._clear_animation() + cls._free_static_surface() cls._render_boxart_background(screen_w, screen_h, Display, bg_color) elif bg_image and os.path.exists(bg_image): cls._clear_boxart() if bg_image.lower().endswith(".gif"): + cls._free_static_surface() cls._render_gif_background(bg_image, screen_w, screen_h, Display, bg_color) else: cls._clear_animation() @@ -100,6 +146,7 @@ def _render_background(cls, screen_w, screen_h, Display): else: cls._clear_animation() cls._clear_boxart() + cls._free_static_surface() sdl2.SDL_SetRenderDrawColor(Display.renderer.sdlrenderer, bg_color[0], bg_color[1], bg_color[2], 255) sdl2.SDL_RenderClear(Display.renderer.sdlrenderer) @@ -114,24 +161,42 @@ def _render_background(cls, screen_w, screen_h, Display): @classmethod def _render_static_background(cls, bg_image, screen_w, screen_h, Display, bg_color, blur): - surface = sdl2.sdlimage.IMG_Load(bg_image.encode("utf-8")) - if surface: - if blur > 0: - surface = cls._apply_blur(surface, blur) - texture = sdl2.SDL_CreateTextureFromSurface(Display.renderer.renderer, surface) - if texture: - sdl2.SDL_SetTextureBlendMode(texture, sdl2.SDL_BLENDMODE_BLEND) - src_w = surface.contents.w - src_h = surface.contents.h - src = sdl2.SDL_Rect(0, 0, src_w, src_h) - dst = sdl2.SDL_Rect(0, 0, screen_w, screen_h) - sdl2.SDL_RenderCopy(Display.renderer.renderer, texture, src, dst) - sdl2.SDL_DestroyTexture(texture) - sdl2.SDL_FreeSurface(surface) - else: + # The decoded surface is kept between renders: loading it costs a read and + # a decode, and _apply_blur is a per-pixel loop in Python. The widgets + # redraw once a minute and neither should be paid again each time. + # A surface rather than a texture, so it survives Display.reinitialize() + # tearing the renderer down -- same as the box art and gif frames. + key = (bg_image, blur) + + if cls._static_surface is None or cls._static_key != key: + cls._free_static_surface() + + surface = sdl2.sdlimage.IMG_Load(bg_image.encode("utf-8")) + if surface: + if blur > 0: + surface = cls._apply_blur(surface, blur) + cls._static_surface = surface + cls._static_key = key + + if cls._static_surface is None: sdl2.SDL_SetRenderDrawColor(Display.renderer.sdlrenderer, bg_color[0], bg_color[1], bg_color[2], 255) sdl2.SDL_RenderClear(Display.renderer.sdlrenderer) + return + + texture = sdl2.SDL_CreateTextureFromSurface(Display.renderer.renderer, cls._static_surface) + if texture: + sdl2.SDL_SetTextureBlendMode(texture, sdl2.SDL_BLENDMODE_BLEND) + dst = sdl2.SDL_Rect(0, 0, screen_w, screen_h) + sdl2.SDL_RenderCopy(Display.renderer.renderer, texture, None, dst) + sdl2.SDL_DestroyTexture(texture) + + @classmethod + def _free_static_surface(cls): + if cls._static_surface is not None: + sdl2.SDL_FreeSurface(cls._static_surface) + cls._static_surface = None + cls._static_key = None @classmethod def _render_boxart_background(cls, screen_w, screen_h, Display, bg_color): diff --git a/App/PyUI/main-ui/games/utils/rom_list_verifier.py b/App/PyUI/main-ui/games/utils/rom_list_verifier.py new file mode 100644 index 000000000..17a80c72c --- /dev/null +++ b/App/PyUI/main-ui/games/utils/rom_list_verifier.py @@ -0,0 +1,91 @@ +import queue +import threading +import time + +from utils.logger import PyUiLogger + + +class RomListVerifier: + """ + Confirms cached rom listings against what is actually in the folder. + + The cached listing is keyed off the folder's modification date, and that date + on its own is not trustworthy. Archive tools (7-Zip, WinRAR, Windows' built in + extract) write the date stored inside the archive back onto the folder after + they have finished extracting into it, so a folder can gain games while its + date stays put or even moves backwards. When that happens the cache looks + valid but isn't, and the new games never show up. + + Reading the folder is the only reliable way to tell, but doing that before + every menu draws would make opening a system slower for everyone. So the + cached listing is shown straight away and confirmed on a worker thread. If it + turns out to be wrong the cache is corrected and the generation counter moves, + which the rom menus watch so they can rebuild themselves. + """ + + # Opening the game menu asks for a rom count for every system, so a whole + # card's worth of folders can end up queued at once. Pause between them so + # the sweep doesn't sit on the card while the menu is loading box art. + PAUSE_BETWEEN_CHECKS_SECONDS = 0.05 + + _queue = queue.Queue() + _worker = None + _worker_lock = threading.Lock() + + _state_lock = threading.Lock() + _generation = 0 + _queued = set() + + @classmethod + def generation(cls): + """Moves every time a cached listing is found to be out of date.""" + with cls._state_lock: + return cls._generation + + @classmethod + def schedule(cls, key, verify): + """ + Queue verify() for key. Keys already waiting are ignored, so a menu that + redraws every frame doesn't pile up thousands of identical checks. + """ + with cls._state_lock: + if key in cls._queued: + return + cls._queued.add(key) + + cls._ensure_worker() + cls._queue.put((key, verify)) + + @classmethod + def _ensure_worker(cls): + with cls._worker_lock: + if cls._worker is None or not cls._worker.is_alive(): + cls._worker = threading.Thread( + target=cls._worker_loop, + name="RomListVerifierThread", + daemon=True, + ) + cls._worker.start() + + @classmethod + def _worker_loop(cls): + while True: + key, verify = cls._queue.get() + changed = False + + try: + changed = verify() + except Exception as e: + PyUiLogger.get_logger().error(f"Error verifying rom list for '{key}': {e}") + + with cls._state_lock: + cls._queued.discard(key) + if changed: + cls._generation += 1 + + if changed: + PyUiLogger.get_logger().info(f"Rom list was out of date, refreshed [{key}]") + + # Counter is already updated, so this costs the user nothing -- it + # only spaces out the next folder we go and read. + time.sleep(cls.PAUSE_BETWEEN_CHECKS_SECONDS) diff --git a/App/PyUI/main-ui/games/utils/rom_utils.py b/App/PyUI/main-ui/games/utils/rom_utils.py index f2e6bb6ff..3b0a9a55d 100644 --- a/App/PyUI/main-ui/games/utils/rom_utils.py +++ b/App/PyUI/main-ui/games/utils/rom_utils.py @@ -3,10 +3,12 @@ from devices.device import Device from games.utils.game_system import GameSystem +from games.utils.rom_list_verifier import RomListVerifier from menus.games.file_based_game_system_config import FileBasedGameSystemConfig from utils.logger import PyUiLogger import os import json +import threading from pathlib import Path class RomUtils: @@ -19,7 +21,9 @@ def __init__(self, roms_path): "WSC":"WS" } - self._get_roms_cache: dict[tuple, tuple[list[str], list[str]]] = {} + # directory -> (folder date it was read at, game files, subfolders) + self._get_roms_cache: dict[str, tuple[float, list[str], list[str]]] = {} + self._cache_lock = threading.Lock() def get_cache_dir(self): return os.path.join(Device.get_device().get_saves_dir(),"cache") @@ -94,7 +98,7 @@ def _load_disk_cache(self,directory, mtime): return data["files"], data["folders"] else: PyUiLogger.get_logger().info(f"Folder update detected [{directory}]") - except (FileNotFoundError, json.JSONDecodeError): + except (FileNotFoundError, json.JSONDecodeError, KeyError, OSError): pass return None @@ -112,81 +116,155 @@ def _save_disk_cache(self,directory, mtime, files, folders): }, f) - def get_roms(self, game_system: GameSystem, directory=None): - directories_to_search = [directory] if directory else game_system.folder_paths - - all_valid_files = [] - all_valid_folders = [] + def _scan_directory(self, game_system: GameSystem, dir_to_search): + """ + Read a single directory and work out which entries count as games. + Uses scandir rather than listdir so the file/directory answer comes back + as part of the directory read instead of costing a separate lookup per + entry. On a folder holding a few thousand games that is the difference + between one read and several thousand, which matters on these devices. + """ config = game_system.game_system_config valid_suffix_set = {s.lower() for s in config.get_extlist()} ignore_set = set(config.get_ignore_list()) scan_subfolders = config.scan_subfolders() - for dir_to_search in directories_to_search: - try: - dir_mtime = os.path.getmtime(dir_to_search) - except OSError: - continue + valid_files = [] + valid_folders = [] - cache_key = (dir_to_search, dir_mtime) + try: + with os.scandir(dir_to_search) as entries: + for entry in entries: + name = entry.name - # --- In-memory cache --- - if cache_key in self._get_roms_cache: - files, folders = self._get_roms_cache[cache_key] - all_valid_files.extend(files) - all_valid_folders.extend(folders) - continue + if name.startswith('.'): + continue - # --- Disk cache --- - if Device.get_device().supports_caching_rom_lists(): - cached = self._load_disk_cache(dir_to_search, dir_mtime) - if cached: - self._get_roms_cache[cache_key] = cached - files, folders = cached - all_valid_files.extend(files) - all_valid_folders.extend(folders) - continue + try: + # follow_symlinks stays on to match the os.path.isdir this + # replaced, so symlinked rom folders keep working + is_dir = entry.is_dir() + except OSError: + is_dir = False - # --- Fresh scan --- - valid_files = [] - valid_folders = [] + if is_dir: + if not scan_subfolders: + continue + if name == "Imgs": + continue - try: - entries = os.listdir(dir_to_search) - except OSError: - continue + roms_sub, folders_sub = self.get_roms(game_system, entry.path) - for name in entries: - if name.startswith('.'): - continue + if roms_sub or folders_sub: + valid_folders.append(entry.path) - full_path = os.path.join(dir_to_search, name) + else: + dot = name.rfind('.') + suffix = name[dot:].lower() if dot != -1 else '' - if os.path.isdir(full_path): - if not scan_subfolders: - continue - if name == "Imgs": - continue + if (not valid_suffix_set and not name.endswith(('.xml', '.txt', '.db'))) or suffix in valid_suffix_set: + if name not in ignore_set: + valid_files.append(entry.path) + except OSError: + return valid_files, valid_folders + + return valid_files, valid_folders + + def _read_cache(self, dir_to_search, dir_mtime): + """ + The cached listing for a directory, or None if we have nothing usable. + + A folder date that has moved is a reliable sign the folder changed, so we + rescan on the spot. A date that hasn't moved proves nothing -- that is + what _verify_cached_listing sorts out afterwards. + """ + with self._cache_lock: + cached = self._get_roms_cache.get(dir_to_search) + + if cached is not None and cached[0] == dir_mtime: + return cached[1], cached[2] + + if not Device.get_device().supports_caching_rom_lists(): + return None - roms_sub, folders_sub = self.get_roms(game_system, full_path) + on_disk = self._load_disk_cache(dir_to_search, dir_mtime) - if roms_sub or folders_sub: - valid_folders.append(full_path) + if on_disk is not None: + with self._cache_lock: + self._get_roms_cache[dir_to_search] = (dir_mtime, on_disk[0], on_disk[1]) - else: - suffix = Path(name).suffix.lower() + return on_disk - if (not valid_suffix_set and not name.endswith(('.xml', '.txt', '.db'))) or suffix in valid_suffix_set: - if name not in ignore_set: - valid_files.append(full_path) + def _write_cache(self, dir_to_search, dir_mtime, files, folders): + if not Device.get_device().supports_caching_rom_lists(): + return - result = (valid_files, valid_folders) + with self._cache_lock: + self._get_roms_cache[dir_to_search] = (dir_mtime, files, folders) + + self._save_disk_cache(dir_to_search, dir_mtime, files, folders) + + def _verify_cached_listing(self, game_system: GameSystem, dir_to_search): + """ + Re-read a directory we served from cache and correct the cache if what we + handed the menu no longer matches what is actually there. Runs on the + RomListVerifier worker thread; returns True when the listing was wrong. + """ + try: + dir_mtime = os.path.getmtime(dir_to_search) + except OSError: + return False + + files, folders = self._scan_directory(game_system, dir_to_search) + + with self._cache_lock: + cached = self._get_roms_cache.get(dir_to_search) + + # Compare against the listing we served, whatever date it was stored + # under -- a date that shifted underneath us is not itself a change. + # Compared as sets because the order a directory reads back in can change + # on its own, and the menu sorts the list anyway. + if (cached is not None + and set(cached[1]) == set(files) + and set(cached[2]) == set(folders)): + if cached[0] != dir_mtime: + self._write_cache(dir_to_search, dir_mtime, files, folders) + return False + + self._write_cache(dir_to_search, dir_mtime, files, folders) + return True + + def get_roms(self, game_system: GameSystem, directory=None): + directories_to_search = [directory] if directory else game_system.folder_paths + + all_valid_files = [] + all_valid_folders = [] + + for dir_to_search in directories_to_search: + try: + dir_mtime = os.path.getmtime(dir_to_search) + except OSError: + continue + + cached = self._read_cache(dir_to_search, dir_mtime) + + if cached is not None: + files, folders = cached + all_valid_files.extend(files) + all_valid_folders.extend(folders) + + # The folder's date can't be trusted on its own -- archive tools + # rewrite it after extracting -- so confirm the listing in the + # background and let the menus know if it was stale. + RomListVerifier.schedule( + dir_to_search, + lambda d=dir_to_search: self._verify_cached_listing(game_system, d), + ) + continue - # --- Save caches --- - if Device.get_device().supports_caching_rom_lists(): - self._get_roms_cache[cache_key] = result - self._save_disk_cache(dir_to_search, dir_mtime, valid_files, valid_folders) + valid_files, valid_folders = self._scan_directory(game_system, dir_to_search) + self._write_cache(dir_to_search, dir_mtime, valid_files, valid_folders) all_valid_files.extend(valid_files) all_valid_folders.extend(valid_folders) diff --git a/App/PyUI/main-ui/menus/games/favorites_menu.py b/App/PyUI/main-ui/menus/games/favorites_menu.py index 850eef6cf..44502aee8 100644 --- a/App/PyUI/main-ui/menus/games/favorites_menu.py +++ b/App/PyUI/main-ui/menus/games/favorites_menu.py @@ -27,7 +27,7 @@ def _get_rom_list(self) -> list[GridOrListEntry]: rom_list.append( RomGridOrListEntry( display_name=display_name +" (" + self._extract_game_system(rom_info.rom_file_path)+")", - folder_name="Recents", + folder_name="Favorites", game_system=rom_info.game_system, rom_file_path=rom_info.rom_file_path, game_entry=None, diff --git a/App/PyUI/main-ui/menus/games/roms_menu_common.py b/App/PyUI/main-ui/menus/games/roms_menu_common.py index 6ee9a64f4..291039900 100644 --- a/App/PyUI/main-ui/menus/games/roms_menu_common.py +++ b/App/PyUI/main-ui/menus/games/roms_menu_common.py @@ -5,6 +5,7 @@ from controller.controller_inputs import ControllerInput from devices.device import Device from display.display import Display +from games.utils.rom_list_verifier import RomListVerifier from menus.games.game_config_menu import GameConfigMenu from menus.games.game_select_menu_popup import GameSelectMenuPopup from menus.games.in_game_menu_listener import InGameMenuListener @@ -72,28 +73,19 @@ def build_rom_selection_for_collection(self, collection): raw_rom_list = CollectionsManager.get_games_in_collection(collection) rom_list = [] + get_image_path_fn = get_rom_select_options_builder().get_image_path for rom_info in raw_rom_list: rom_file_name = RomFileNameUtils.get_rom_name_without_extensions(rom_info.game_system, rom_info.rom_file_path) - img_path = self._get_image_path(rom_info) - rom_list.append( - GridOrListEntry( - primary_text=self._remove_extension(rom_file_name) +" (" + self._extract_game_system(rom_info.rom_file_path)+")", - image_path=img_path, - image_path_selected=img_path, - description=collection, - icon=None, - value=rom_info) - ) rom_list.append( RomGridOrListEntry( display_name=self._remove_extension(rom_file_name) +" (" + self._extract_game_system(rom_info.rom_file_path)+")", - folder_name="Collections", + folder_name=collection, game_system=rom_info.game_system, rom_file_path=rom_info.rom_file_path, game_entry=None, prefer_savestate_screenshot=self.prefer_savestate_screenshot(), - get_image_path_fn=lambda a, b, c: img_path, + get_image_path_fn=get_image_path_fn, get_favorite_icon=None ) ) @@ -154,7 +146,7 @@ def create_view(self, page_name, rom_list, selected): veritcal_carousel=self.get_game_select_use_vertical_carousel(), ) - def _run_rom_selection(self, page_name, verify_system=True): + def _build_rom_list(self, verify_system=True): rom_list = self._get_rom_list() current_device = Device.get_device().get_device_name() @@ -170,9 +162,33 @@ def _run_rom_selection(self, page_name, verify_system=True): # Collections are fake without a system filtered_roms.append(rom_info_ui_entry) - rom_list = filtered_roms + return filtered_roms + + def _run_rom_selection(self, page_name, verify_system=True): + return self._run_rom_selection_for_rom_list( + page_name, + self._build_rom_list(verify_system), + refresh_rom_list=lambda: self._build_rom_list(verify_system) + ) + + def _keep_selection_on_refresh(self, rom_list, selected): + """Stay on the same game after the list has been rebuilt underneath us.""" + if(selected is None): + return selected + + previous_path = None + if(selected.get_selection() is not None and selected.get_selection().get_value() is not None): + previous_path = selected.get_selection().get_value().rom_file_path + + if(previous_path is not None): + for index, entry in enumerate(rom_list): + if(entry.get_value().rom_file_path == previous_path): + return Selection(entry, selected.get_input(), index) - return self._run_rom_selection_for_rom_list(page_name,rom_list) + # The game we were sitting on is gone, so stay as close to it as we can + index = min(selected.get_index(), max(len(rom_list) - 1, 0)) + entry = rom_list[index] if rom_list else None + return Selection(entry, selected.get_input(), index) def get_additional_menu_options(self): return [] @@ -199,9 +215,10 @@ def _check_for_last_subfolder_existance(self, last_subfolder, rom_list): def default_to_last_game_selection(self): return True - def _run_rom_selection_for_rom_list(self, page_name, rom_list) : + def _run_rom_selection_for_rom_list(self, page_name, rom_list, refresh_rom_list=None) : selected = Selection(None,None,0) view = None + rom_list_generation = RomListVerifier.generation() last_game_file_path, last_subfolder = PyUiState.get_last_game_selection(page_name) last_subfolder = self._check_for_last_subfolder_existance(last_subfolder, rom_list) @@ -215,6 +232,15 @@ def _run_rom_selection_for_rom_list(self, page_name, rom_list) : selected = Selection(None,None,index) while(selected is not None): + # A background check found the cached listing was out of date, so + # pick up what's actually in the folder now. + if(refresh_rom_list is not None and RomListVerifier.generation() != rom_list_generation): + rom_list_generation = RomListVerifier.generation() + rom_list = refresh_rom_list() + selected = self._keep_selection_on_refresh(rom_list, selected) + view = self.create_view(page_name,rom_list,selected) + PyUiLogger.get_logger().info(f"Refreshed rom list for {page_name}") + Display.set_page_bg(page_name) if(view is None): view = self.create_view(page_name,rom_list,selected) diff --git a/App/PyUI/main-ui/menus/settings/screensaver_settings_menu.py b/App/PyUI/main-ui/menus/settings/screensaver_settings_menu.py index b8d207971..8cfa93a51 100644 --- a/App/PyUI/main-ui/menus/settings/screensaver_settings_menu.py +++ b/App/PyUI/main-ui/menus/settings/screensaver_settings_menu.py @@ -51,6 +51,11 @@ def change_timeout(self, input): return self._set_screensaver_prop("screensaverTimeoutSec", new_val) + def toggle_low_power(self, input): + if input in (ControllerInput.A, ControllerInput.DPAD_LEFT, ControllerInput.DPAD_RIGHT): + current = Theme._data.get("screensaver", {}).get("lowPowerWhileIdle", True) + self._set_screensaver_prop("lowPowerWhileIdle", not current) + def change_overlay_opacity(self, input): current = Theme._data.get("screensaver", {}).get("overlayOpacity", 0.3) if ControllerInput.DPAD_RIGHT == input or ControllerInput.R1 == input: @@ -444,6 +449,19 @@ def build_options_list(self): ) ) + low_power = self._get_screensaver_prop("lowPowerWhileIdle", True) + option_list.append( + GridOrListEntry( + primary_text=Language.get("screensaverLowPower", "Low power while idle"), + value_text="< " + (Language.get("on", "On") if low_power else Language.get("off", "Off")) + " >", + image_path=None, + image_path_selected=None, + description=Language.get("screensaverLowPowerDesc", "Slow the CPU down while the screensaver is showing (skipped for animated backgrounds)"), + icon=None, + value=self.toggle_low_power + ) + ) + current_image = self._get_screensaver_prop("bgImage", "") option_list.append( GridOrListEntry( diff --git a/App/PyUI/main-ui/themes/theme.py b/App/PyUI/main-ui/themes/theme.py index 17bc98756..ee8fb41e3 100644 --- a/App/PyUI/main-ui/themes/theme.py +++ b/App/PyUI/main-ui/themes/theme.py @@ -1728,4 +1728,8 @@ def get_screensaver_timeout_sec(cls): @classmethod def get_screensaver_boxart_interval_sec(cls): interval = cls._data.get("screensaver", {}).get("boxartIntervalSec", 15) - return max(1, int(interval)) \ No newline at end of file + return max(1, int(interval)) + + @classmethod + def get_screensaver_low_power(cls): + return cls._data.get("screensaver", {}).get("lowPowerWhileIdle", True) \ No newline at end of file diff --git a/App/USBStorageMode/launch.sh b/App/USBStorageMode/launch.sh index f97c590ef..76490c2d9 100644 --- a/App/USBStorageMode/launch.sh +++ b/App/USBStorageMode/launch.sh @@ -29,7 +29,7 @@ case "$PLATFORM" in ;; "Pixel2") STORAGE_DEVICE="/dev/mmcblk0p3" - MOUNT_POINT="/storage/games-external" + MOUNT_POINT="/mnt/SDCARD/" USB_GADGET_PATH="/sys/kernel/config/usb_gadget/rockchip" USB_UDC_CONTROLLER="ff300000.usb" USB_CONFIG_PATH="$USB_GADGET_PATH/configs/b.1" @@ -253,10 +253,15 @@ while true; do sleep 3 stop_pyui_message_writer sync - cp "$STAGE_2_PATH" "$STAGE_2_TMP" && chmod +x "$STAGE_2_TMP" - export PATH=/usr/bin:/usr/sbin:/bin:/sbin - unset LD_LIBRARY_PATH - exec "$STAGE_2_TMP" --reboot + + if device_system_handles_sdcard_unmount; then + device_run_reboot_cmd + else + cp "$STAGE_2_PATH" "$STAGE_2_TMP" && chmod +x "$STAGE_2_TMP" + export PATH=/usr/bin:/usr/sbin:/bin:/sbin + unset LD_LIBRARY_PATH + exec "$STAGE_2_TMP" --reboot + fi fi log_and_display_message "USB Mode Active.\nPress A to exit and reboot your device." @@ -266,10 +271,15 @@ while true; do sleep 3 stop_pyui_message_writer sync - cp "$STAGE_2_PATH" "$STAGE_2_TMP" && chmod +x "$STAGE_2_TMP" - export PATH=/usr/bin:/usr/sbin:/bin:/sbin - unset LD_LIBRARY_PATH - exec "$STAGE_2_TMP" --reboot + + if device_system_handles_sdcard_unmount; then + device_run_reboot_cmd + else + cp "$STAGE_2_PATH" "$STAGE_2_TMP" && chmod +x "$STAGE_2_TMP" + export PATH=/usr/bin:/usr/sbin:/bin:/sbin + unset LD_LIBRARY_PATH + exec "$STAGE_2_TMP" --reboot + fi fi # Add a small sleep to prevent the loop from overwhelming the CPU sleep 1 diff --git a/App/adbd/config.json b/App/adbd/config.json.hidden similarity index 77% rename from App/adbd/config.json rename to App/adbd/config.json.hidden index cfa3d68d5..4dc8e039f 100644 --- a/App/adbd/config.json +++ b/App/adbd/config.json.hidden @@ -1,5 +1,5 @@ { - "label": "adbd", + "label": "Start ADB daemon", "icon": "adbd.png", "launch": "launch.sh", "description": "adbd", diff --git a/App/spruceRestore/UpgradeScripts/4.3.3.sh b/App/spruceRestore/UpgradeScripts/4.3.3.sh new file mode 100644 index 000000000..390e8b8da --- /dev/null +++ b/App/spruceRestore/UpgradeScripts/4.3.3.sh @@ -0,0 +1,109 @@ +#!/bin/sh + +# Two unrelated cleanups, both of things that will not correct themselves. +# +# Everything in here has to be safe to run more than once, because re-running is +# the normal case rather than an edge case. The updater deletes App/spruceRestore +# (it is in APP_DELETE_LIST in App/-Updater/delete_files.sh) before extracting, +# and .lastUpdate lives inside that folder. Nothing puts it back -- it is +# untracked, so it is not in the archive either. So after an in-device update +# there is no .lastUpdate, the runner falls back to 2.0.0, and every upgrade +# script runs again. +# +# +# 1. Clear the rom list cache written by 4.3.0 - 4.3.2 +# +# Those versions decided whether a cached rom listing was still good by checking +# the folder's modification date. That date is not trustworthy: archive tools +# (7-Zip, WinRAR, Windows' built in extract) write the date stored inside the +# archive back onto the folder after extracting into it, so a folder can gain +# games while its date stays put or moves backwards. When that happened the +# cached listing was kept and the new games never appeared, permanently. +# +# 4.3.3 confirms cached listings in the background and corrects them, so a stale +# cache now repairs itself. Clearing it here just means affected users get the +# right list the first time they open a system instead of seeing the old one +# flash up once beforehand. +# +# +# 2. Drop the GBA overlay viewport that only ever fitted a 640x480 screen +# +# Turning on Perfect Overlays used to write aspect_ratio_index = "23" (custom) +# and custom_viewport_height = "427" into the GBA core configs. 427 is not a +# general number: at 640 wide it is GBA's 3:2 to the pixel, so it was only ever +# right on the 640x480 devices the setting was offered on. GB.sh and GBC.sh +# never set either key, so GBA was the odd one out, and the pair is now gone +# from GBA.sh so the setting can be offered on the Brick and BrickPro too. +# +# Removing them from that script is not enough on its own. update_config_file +# only rewrites the keys it is about to write, so a config that already has +# these two keeps them, and applyPerfectOs.sh is guarded by the perfectOverlays +# flag, so a re-apply does not even run for anyone who already had it on. Left +# alone they would sit there permanently, and the only way out would be toggling +# the setting off and on again. +# +# Only the exact values the old script wrote are removed, and only when both are +# present together, since that pair is its signature. Anyone who has set their +# own aspect ratio or viewport height for GBA keeps it. There is deliberately no +# check of the perfectOverlays flag: anyone who turned the setting off already +# had both keys taken out by remove_overlay, which still lists them. + +TARGET_VERSION="4.3.3" + +HELPER_FUNCTIONS="/mnt/SDCARD/spruce/scripts/helperFunctions.sh" +if [ -f "$HELPER_FUNCTIONS" ]; then + . "$HELPER_FUNCTIONS" +else + echo "Error: helperFunctions.sh not found" + exit 1 +fi + +log_message "Starting upgrade to version $TARGET_VERSION" + +ROM_LIST_CACHE="/mnt/SDCARD/Saves/cache" + +if [ -d "$ROM_LIST_CACHE" ]; then + cached_count=$(find "$ROM_LIST_CACHE" -maxdepth 1 -name '*.json' | wc -l) + rm -rf "$ROM_LIST_CACHE" + log_message "Removed rom list cache ($cached_count cached folder listings)" +else + log_message "No rom list cache present, nothing to clear" +fi + +# The two cores GBA.sh applies its cfg to. +GBA_CFG_FILES="/mnt/SDCARD/RetroArch/.retroarch/config/gpSP/GBA.cfg +/mnt/SDCARD/RetroArch/.retroarch/config/mGBA/GBA.cfg" + +# Matched literally, single spaces and all, the way 4.1.2.sh matches this very +# key. Every upgrade script that has actually run on these devices sticks to +# plain anchored text and none of them use character classes, so this stays on +# the idiom that is known to work rather than betting on what the device's +# busybox was compiled with. update_config_file writes the line with echo, so +# the spacing is exactly this, and RetroArch writes it the same way. +ASPECT_LINE='^aspect_ratio_index = "23"$' +VIEWPORT_LINE='^custom_viewport_height = "427"$' + +# Not piped into the loop: that runs it in a subshell, and nothing it does to +# the surrounding shell survives. Neither path contains a space. +for cfg_file in $GBA_CFG_FILES; do + if [ ! -f "$cfg_file" ]; then + log_message "GBA overlay viewport: $cfg_file not present, nothing to do" + continue + fi + + if ! grep -q "$ASPECT_LINE" "$cfg_file" || ! grep -q "$VIEWPORT_LINE" "$cfg_file"; then + log_message "GBA overlay viewport: $cfg_file does not carry both keys, leaving it alone" + continue + fi + + if sed -e "/$ASPECT_LINE/d" -e "/$VIEWPORT_LINE/d" "$cfg_file" > "$cfg_file.tmp"; then + mv "$cfg_file.tmp" "$cfg_file" + log_message "GBA overlay viewport: removed aspect_ratio_index and custom_viewport_height from $cfg_file" + else + rm -f "$cfg_file.tmp" + log_message "GBA overlay viewport: could not rewrite $cfg_file, left unchanged" + fi +done + +log_message "Upgrade to version $TARGET_VERSION completed successfully" +exit 0 diff --git a/Emu/ATOMISWAVE/config.json b/Emu/ATOMISWAVE/config.json index 0238dcdfd..7589ef1eb 100644 --- a/Emu/ATOMISWAVE/config.json +++ b/Emu/ATOMISWAVE/config.json @@ -68,11 +68,11 @@ "GKD_PIXEL2" ], "options": [ - "Flycast-libretro", + "Flycast2021-libretro", "Flycast-standalone", - "Flycast-stock" + "Flycast2024-standalone" ], - "selected": "Flycast-stock", + "selected": "Flycast2021-libretro", "overrides": {} } }, diff --git a/Emu/DC/config.json b/Emu/DC/config.json index 84d3902a0..d1ffd5b8e 100644 --- a/Emu/DC/config.json +++ b/Emu/DC/config.json @@ -71,7 +71,7 @@ "options": [ "Flycast2021-libretro", "Flycast-standalone", - "Flycast-stock" + "Flycast2024-standalone" ], "selected": "Flycast2021-libretro", "overrides": {} diff --git a/Emu/MEDIA/bin64/mpv.gptk b/Emu/MEDIA/bin64/mpv.gptk deleted file mode 100755 index ee2eb61e1..000000000 --- a/Emu/MEDIA/bin64/mpv.gptk +++ /dev/null @@ -1,18 +0,0 @@ -b = space -a = 3 -a = add_shift -y = j -x = o -x = add_shift - -up = up -down = down -left = left -right = right - -l1 = 9 -r1 = 0 - -back = i -guide = q -guide = add_shift diff --git a/Emu/MEDIA/config.json b/Emu/MEDIA/config.json index 2785777bc..d893b2b1a 100644 --- a/Emu/MEDIA/config.json +++ b/Emu/MEDIA/config.json @@ -68,7 +68,6 @@ ], "options": [ "mpv", - "ffplay", "gme" ], "selected": "mpv", diff --git a/Emu/NAOMI/config.json b/Emu/NAOMI/config.json index c12d32acc..d566ff75e 100644 --- a/Emu/NAOMI/config.json +++ b/Emu/NAOMI/config.json @@ -68,11 +68,11 @@ "GKD_PIXEL2" ], "options": [ - "Flycast-libretro", + "Flycast2021-libretro", "Flycast-standalone", - "Flycast-stock" + "Flycast2024-standalone" ], - "selected": "Flycast-stock", + "selected": "Flycast2021-libretro", "overrides": {} } }, diff --git a/Emu/NDS/config.json b/Emu/NDS/config.json index be4bc711f..373383456 100755 --- a/Emu/NDS/config.json +++ b/Emu/NDS/config.json @@ -88,7 +88,6 @@ "GKD_PIXEL2" ], "options": [ - "DraStic-stock", "DraStic-trngaje" ], "selected": "DraStic-trngaje", diff --git a/Emu/PSP/default_configs/SYSTEM/ppsspp-Pixel2.ini b/Emu/PSP/default_configs/SYSTEM/ppsspp-Pixel2.ini index 1d19b60d7..51640edb6 100644 --- a/Emu/PSP/default_configs/SYSTEM/ppsspp-Pixel2.ini +++ b/Emu/PSP/default_configs/SYSTEM/ppsspp-Pixel2.ini @@ -571,7 +571,6 @@ AchievementsEnableRAIntegration = False AchievementsSaveStateInHardcoreMode = False [Recent] MaxRecent = 60 -FileName0 = /storage/games-external/Roms/PSP/Luxor - The Wrath of Set (USA).chd [Log] SYSTEMEnabled = True SYSTEMLevel = 2 diff --git a/RetroArch/ra32.a30 b/RetroArch/ra32.a30 index 8d47aca0b..c17ab40f9 100644 Binary files a/RetroArch/ra32.a30 and b/RetroArch/ra32.a30 differ diff --git a/Saves/spruce/spruce-config.json b/Saves/spruce/spruce-config.json index 2c7c36fc4..9605be711 100644 --- a/Saves/spruce/spruce-config.json +++ b/Saves/spruce/spruce-config.json @@ -424,7 +424,7 @@ "selected": "Custom" }, "perfectOverlays": { - "devices": ["MIYOO_A30", "MIYOO_FLIP", "MIYOO_MINI", "MIYOO_MINI_PLUS", "GKD_PIXEL2", "ANBERNIC_RG28XX", "ANBERNIC_RGXX640480"], + "devices": ["MIYOO_A30", "MIYOO_FLIP", "MIYOO_MINI", "MIYOO_MINI_PLUS", "GKD_PIXEL2", "ANBERNIC_RG28XX", "ANBERNIC_RGXX640480", "TRIMUI_BRICK", "TRIMUI_BRICK_PRO"], "display": "GB[C/A]: use Perfect Overlays", "description": "provided by 1PlayerInsertCoin and Mugwomp93", "options": [ @@ -529,6 +529,19 @@ "selected": "http" } }, + "LEDs Settings": { + "LEDsMode": { + "display": "LEDs mode", + "devices": ["GKD_PIXEL2"], + "description": "reload UI to apply", + "options": [ + "Off", + "Battery", + "Audio" + ], + "selected": "Battery" + } + }, "RGB LED Settings": { "disableLEDs": { "display": "Disable RGB LEDs in all contexts", diff --git a/autorun.inf b/autorun.inf index 4fd141067..a52084192 100644 --- a/autorun.inf +++ b/autorun.inf @@ -1,3 +1,3 @@ [AutoRun] Icon=spruce\www\spruce_sd.ico -label = spruce v4.2.0 +label = spruce v4.3.3 diff --git a/spruce/scripts/applySetting/PerfectOverlays/GBA.sh b/spruce/scripts/applySetting/PerfectOverlays/GBA.sh index 10eba95b5..9fd71767d 100644 --- a/spruce/scripts/applySetting/PerfectOverlays/GBA.sh +++ b/spruce/scripts/applySetting/PerfectOverlays/GBA.sh @@ -13,9 +13,7 @@ GBA_MGBA_GB_FILE=/mnt/SDCARD/RetroArch/.retroarch/config/mGBA/GBA.opt apply_overlay() { # Define configurations - GBA_CFG="aspect_ratio_index = \"23\" -custom_viewport_height = \"427\" -input_overlay = \"./.retroarch/overlay/Perfect/Perfect_GBA/Bright_Version/Perfect_GBA_bright_1playerinsertcoin_adapted.cfg\" + GBA_CFG="input_overlay = \"./.retroarch/overlay/Perfect/Perfect_GBA/Bright_Version/Perfect_GBA_bright_1playerinsertcoin_adapted.cfg\" input_overlay_enable = \"true\" input_overlay_opacity = \"1.000000\" input_player1_analog_dpad_mode = \"0\" diff --git a/spruce/scripts/button_actions.sh b/spruce/scripts/button_actions.sh index 82e923f3b..3d5162fc1 100644 --- a/spruce/scripts/button_actions.sh +++ b/spruce/scripts/button_actions.sh @@ -111,7 +111,7 @@ kill_pcsx() { kill_ra_and_standard_emulators() { log_message "button_actions.sh: Killing miscelaneous emus!" - killall -q -15 ra32.a30 ra32.mini ra32.universal ra64.universal ra64.pixel2 retroarch pico8_dyn pico8_64 flycast flycast-stock yabasanshiro yabasanshiro.trimui + killall -q -15 ra32.a30 ra32.mini ra32.universal ra64.universal ra64.pixel2 retroarch pico8_dyn pico8_64 flycast flycast2024 yabasanshiro yabasanshiro.trimui } kill_emulator() { diff --git a/spruce/scripts/emu/lib/advmame_functions.sh b/spruce/scripts/emu/lib/advmame_functions.sh index 5a12d5131..5b43e64cd 100644 --- a/spruce/scripts/emu/lib/advmame_functions.sh +++ b/spruce/scripts/emu/lib/advmame_functions.sh @@ -54,7 +54,7 @@ run_advmame() { "Pixel2") [ -f "$EMU_DIR/advmame.log" ] && rm "$EMU_DIR/advmame.log" export SDL_GAMECONTROLLERCONFIG="/mnt/SDCARD/Emu/PORTS/gamecontrollerdb_nintendo.txt" - /mnt/SDCARD/spruce/pixel2/bin/gptokeyb2 $EMU_DIR/advmame -c "$EMU_DIR/advmame.ini" & + gptokeyb2 $EMU_DIR/advmame -c "$EMU_DIR/advmame.ini" & $EMU_DIR/advmame -dir_rom "$ROM_DIR" "${GAME%.*}" -log [ -f "$EMU_DIR/advmame.log" ] && cp "$EMU_DIR/advmame.log" "$ADVMAME_LOG" kill -9 $(pidof gptokeyb2) diff --git a/spruce/scripts/emu/lib/drastic_functions.sh b/spruce/scripts/emu/lib/drastic_functions.sh index 3b4f33633..4e962af23 100644 --- a/spruce/scripts/emu/lib/drastic_functions.sh +++ b/spruce/scripts/emu/lib/drastic_functions.sh @@ -275,26 +275,18 @@ run_drastic_SmartProS() { ##### PIXEL 2 ##### run_drastic_Pixel2() { - if [ "$CORE" = "DraStic-stock" ]; then - run_drastic_stock_Pixel2 - elif [ "$CORE" = "DraStic-trngaje" ]; then + if [ "$CORE" = "DraStic-trngaje" ]; then run_drastic_trngaje_Pixel2 else display_core_unrecognized_for_platform_message fi } -run_drastic_stock_Pixel2() { - ready_arch_64_states - pin_to_dedicated_cores drastic64 2 - # Disable loging for now, it's writting a lot to it - ./drastic64 "$ROM_FILE" # > $(emu_log_file) 2>&1 - stash_arch_64_states -} - run_drastic_trngaje_Pixel2() { + ready_arch_64_states export LD_LIBRARY_PATH="$HOME/lib64_Pixel2_trngaje:$LD_LIBRARY_PATH" ./drastic "$ROM_FILE" > $(emu_log_file) 2>&1 + stash_arch_64_states } diff --git a/spruce/scripts/emu/lib/flycast_functions.sh b/spruce/scripts/emu/lib/flycast_functions.sh index 5767167d2..988a18fc6 100644 --- a/spruce/scripts/emu/lib/flycast_functions.sh +++ b/spruce/scripts/emu/lib/flycast_functions.sh @@ -52,8 +52,8 @@ run_flycast_standalone() { cd "$HOME" /mnt/SDCARD/spruce/scripts/asound-setup.sh - if [ "$CORE" = "Flycast-stock" ]; then - ./flycast-stock "$ROM_FILE" > $(emu_log_file) 2>&1 + if [ "$CORE" = "Flycast2024-standalone" ]; then + ./flycast2024 "$ROM_FILE" > $(emu_log_file) 2>&1 else ./flycast "$ROM_FILE" > $(emu_log_file) 2>&1 fi diff --git a/spruce/scripts/emu/lib/media_functions.sh b/spruce/scripts/emu/lib/media_functions.sh index 6991d2d7e..8eb74828f 100644 --- a/spruce/scripts/emu/lib/media_functions.sh +++ b/spruce/scripts/emu/lib/media_functions.sh @@ -89,10 +89,11 @@ run_mpv() { INPUT_CONF="/tmp/mpv_input.conf" printf 'VOLUME_UP ignore\nVOLUME_DOWN ignore' > $INPUT_CONF - /mnt/SDCARD/spruce/bin64/gptokeyb -k "mpv" -c "./bin64/mpv.gptk" & + gptokeyb -k "mpv" -c "./mpv.gptk" & sleep 0.5 - /usr/bin/mpv --fs --geometry="640x480" --hwdec=drm --vo=sdl \ + /usr/bin/mpv --profile=fast --fs --geometry="640x480" --hwdec=rkmpp \ + --vo=dmabuf-wayland --swapchain-depth=8 \ --input-conf=$INPUT_CONF --msg-level=all=warn \ "$ROM_FILE" > $(emu_log_file) 2>&1 diff --git a/spruce/scripts/emu/lib/network_functions.sh b/spruce/scripts/emu/lib/network_functions.sh index e61810cfc..6defcaa4e 100644 --- a/spruce/scripts/emu/lib/network_functions.sh +++ b/spruce/scripts/emu/lib/network_functions.sh @@ -65,6 +65,7 @@ handle_network_services() { if [ "$disable_wifi_in_game" = "True" ]; then if network_is_connected; then + device_wifi_power_off ifconfig wlan0 down & fi killall wpa_supplicant diff --git a/spruce/scripts/emu/standard_launch.sh b/spruce/scripts/emu/standard_launch.sh index 5e97b81aa..ecad014fa 100644 --- a/spruce/scripts/emu/standard_launch.sh +++ b/spruce/scripts/emu/standard_launch.sh @@ -68,7 +68,7 @@ case $EMU_NAME in ;; "DC"|"NAOMI"|"ATOMISWAVE") - if [ "$CORE" = "Flycast-standalone" ] || [ "$CORE" = "Flycast-stock" ]; then + if [ "$CORE" = "Flycast-standalone" ] || [ "$CORE" = "Flycast2024-standalone" ]; then . /mnt/SDCARD/spruce/scripts/emu/lib/flycast_functions.sh run_flycast_standalone elif [ "$CORE" = "Flycast-libretro" ]; then diff --git a/spruce/scripts/firstboot.sh b/spruce/scripts/firstboot.sh index f36d472b9..be867a77b 100644 --- a/spruce/scripts/firstboot.sh +++ b/spruce/scripts/firstboot.sh @@ -134,7 +134,7 @@ run_firstboot_package_phase() { ADVMAME_DIR="/mnt/SDCARD/Emu/ARCADE" ADVMAME_7Z="" case "$PLATFORM" in - "Brick" | "BrickPro" | "SmartPro" | "SmartProS" | "Flip") + "Brick" | "BrickPro" | "SmartPro" | "SmartProS" | "Flip" | "Pixel2") ADVMAME_7Z="$ADVMAME_DIR/advmame.7z" ;; esac diff --git a/spruce/scripts/headphones_watchdog.sh b/spruce/scripts/headphones_watchdog.sh new file mode 100644 index 000000000..1a91cfd92 --- /dev/null +++ b/spruce/scripts/headphones_watchdog.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +. /mnt/SDCARD/spruce/scripts/helperFunctions.sh + +PLAYBACK_PATH="Playback Path" +PLAYBACK_PATH_SPK="SPK" +PLAYBACK_PATH_HP="HP" + +# Initial value at boot, 0 unplugged, 1 plugged +case $(gpioget --numeric -c 2 22) in + 0) + amixer -c0 sset "${PLAYBACK_PATH}" "${PLAYBACK_PATH_SPK}" + PREV_VALUE=2 + ;; + 1) + amixer -c0 sset "${PLAYBACK_PATH}" "${PLAYBACK_PATH_HP}" + PREV_VALUE=1 + ;; +esac + +# Monitoring value, 1 plugged, 2 unplugged +gpiomon --format="%e" -c 2 22 | while read line; do + NEW_VALUE=$line + + if [ "$NEW_VALUE" != "$PREV_VALUE" ]; then + case "$NEW_VALUE" in + 2) + amixer -c0 sset "${PLAYBACK_PATH}" "${PLAYBACK_PATH_SPK}" + ;; + 1) + amixer -c0 sset "${PLAYBACK_PATH}" "${PLAYBACK_PATH_HP}" + ;; + esac + + VOLUME_LV=$(get_volume_level) + set_volume "$(( VOLUME_LV ))" + + PREV_VALUE="$NEW_VALUE" + fi +done diff --git a/spruce/scripts/helperFunctions.sh b/spruce/scripts/helperFunctions.sh index 0616777b8..48eb8c430 100644 --- a/spruce/scripts/helperFunctions.sh +++ b/spruce/scripts/helperFunctions.sh @@ -1107,8 +1107,9 @@ network_is_connected() { CHECK_ETH="${1:-false}" # Defaults to false if no argument iface_up=false + wifi_iface=$(ls /sys/class/net/ | grep wlan | head -1) - if ifconfig wlan0 | grep -qE "inet |inet6 " >/dev/null 2>&1; then + if ifconfig "$wifi_iface" | grep -qE "inet |inet6 " >/dev/null 2>&1; then iface_up=true fi diff --git a/spruce/scripts/leds_manager.sh b/spruce/scripts/leds_manager.sh new file mode 100644 index 000000000..2a75ae49f --- /dev/null +++ b/spruce/scripts/leds_manager.sh @@ -0,0 +1,128 @@ +#!/bin/sh + +. /mnt/SDCARD/spruce/scripts/helperFunctions.sh + +LEDS_STATE=(false false false false false) + +leds_audio() { + turn_off_led 0 + + cava -p /storage/.config/cava/config | while read value; do + # echo $value + if [ $value -lt 25 ] ; then + turn_off_led 1 + turn_off_led 2 + turn_off_led 3 + turn_off_led 4 + fi + + if [ $value -gt 25 ] ; then + turn_on_led 1 + turn_off_led 2 + turn_off_led 3 + turn_off_led 4 + fi + + if [ $value -gt 50 ] ; then + turn_on_led 2 + turn_off_led 3 + turn_off_led 4 + fi + + if [ $value -gt 75 ] ; then + turn_on_led 3 + turn_off_led 4 + fi + + if [ $value -gt 95 ] ; then + turn_on_led 4 + fi + done +} + +turn_on_led() { + if ! ${LEDS_STATE[$1]} ; then + echo 1 > /sys/class/leds/led-$1/brightness + LEDS_STATE[$1]=true + fi +} + +turn_off_led() { + if ${LEDS_STATE[$1]} ; then + echo 0 > /sys/class/leds/led-$1/brightness + LEDS_STATE[$1]=false + fi +} + +leds_battery() { + LOW_PERCENT="$(get_config_value '.menuOptions."Battery Settings".lowPowerWarningPercent.selected' "4")" + + while true; do + CAPACITY=$(device_get_battery_percent) + STATUS=$(device_get_charging_status) + + if [ "$STATUS" = "Charging" ] ; then + turn_on_led 0 + turn_on_led 1 + else + turn_off_led 0 + fi + + if [ $CAPACITY -le $LOW_PERCENT ] ; then + turn_on_led 0 + turn_off_led 1 + turn_off_led 2 + turn_off_led 3 + turn_off_led 4 + fi + + if [ $CAPACITY -gt $LOW_PERCENT ] ; then + turn_on_led 1 + turn_off_led 2 + turn_off_led 3 + turn_off_led 4 + fi + + if [ $CAPACITY -gt 25 ] ; then + turn_on_led 1 + turn_on_led 2 + turn_off_led 3 + turn_off_led 4 + fi + + if [ $CAPACITY -gt 50 ] ; then + turn_on_led 1 + turn_on_led 2 + turn_on_led 3 + turn_off_led 4 + fi + + if [ $CAPACITY -gt 75 ] ; then + turn_on_led 1 + turn_on_led 2 + turn_on_led 3 + turn_on_led 4 + fi + + sleep 10 + done +} + +LEDS_MODE="$(get_config_value '.menuOptions."LEDs Settings".LEDsMode.selected' "Battery")" + +case $LEDS_MODE in + "Battery") + leds_battery + ;; + "Audio") + leds_audio + ;; + *) + # Off + turn_off_led 0 + turn_off_led 1 + turn_off_led 2 + turn_off_led 3 + turn_off_led 4 + ;; +esac \ No newline at end of file diff --git a/spruce/scripts/platform/Pixel2.cfg b/spruce/scripts/platform/Pixel2.cfg index dd7e512d1..f24d04f6f 100644 --- a/spruce/scripts/platform/Pixel2.cfg +++ b/spruce/scripts/platform/Pixel2.cfg @@ -12,7 +12,7 @@ export REQ_MB_TO_UPDATE_FW=1280 export SYSTEM_JSON="/mnt/SDCARD/Saves/gkd-pixel2-system.json" export SYSTEM_PATH="/mnt/SDCARD/gkd" export SD_DEV="/dev/mmcblk0p3" -export SD_MOUNTPOINT="/storage/games-external" +export SD_MOUNTPOINT="/mnt/SDCARD/" export PLATFORM_ARCHITECTURE="aarch64" @@ -30,7 +30,7 @@ export DISPLAY_ASPECT_RATIO="4:3" ##### CPU SETTINGS ##### ######################## -# interactive conservative ondemand userspace powersave performance +# interactive conservative ondemand userspace powersave performance schedutil # 408000 600000 816000 1008000 1200000 1248000 1296000 1416000 1512000 export DEVICE_MIN_CORES_ONLINE=0 export DEVICE_MAX_CORES_ONLINE=0123 @@ -38,7 +38,7 @@ export DEVICE_POWERSAVE_LOW_FREQ=408000 export DEVICE_POWERSAVE_HIGH_FREQ=1200000 export CPU_SMART_CORES_ONLINE=0123 -export CPU_SMART_GOVENOR="conservative" +export CPU_SMART_GOVENOR="ondemand" export CPU_SMART_MIN_FREQ=408000 export CPU_SMART_MAX_FREQ=1416000 export CPU_PERF_MAX_FREQ=1416000 @@ -46,14 +46,14 @@ export CPU_OVERCLOCK_MAX_FREQ=1512000 export CONSERVATIVE_POLICY_DIR=/sys/devices/system/cpu/cpu0/cpufreq/conservative # dmc_ondemand userspace powersave performance simple_ondemand -# 194000000 328000000 1056000000 -export GPU_GOVENOR_DIR="/sys/class/devfreq/dmc" +# 200000000 300000000 400000000 520000000 +export GPU_GOVENOR_DIR="/sys/class/devfreq/ff400000.gpu" export GPU_SMART_GOVERNOR="powersave" -export GPU_SMART_MAX_FREQ="194000000" +export GPU_SMART_MAX_FREQ="200000000" export GPU_PERFORMANCE_GOVERNOR="dmc_ondemand" -export GPU_PERFORMANCE_MAX_FREQ="1056000000" +export GPU_PERFORMANCE_MAX_FREQ="400000000" export GPU_OVERCLOCK_GOVERNOR="performance" -export GPU_OVERCLOCK_MAX_FREQ="1056000000" +export GPU_OVERCLOCK_MAX_FREQ="520000000" ################################################## @@ -70,12 +70,12 @@ export WPA_SUPPLICANT_FILE="" ##### SOFTWARE CHARACTERISTICS AND QUIRKS ##### ############################################### -export PATH=/mnt/SDCARD/spruce/bin64:/usr/bin:/usr/sbin:/bin:/sbin:/mnt/SDCARD/spruce/pixel2/bin -export LD_LIBRARY_PATH="/usr/lib:/lib:/usr/lib/compat:/mnt/SDCARD/spruce/pixel2/lib" +export PATH=/mnt/SDCARD/spruce/bin64:/usr/bin:/usr/sbin:/bin:/sbin +export LD_LIBRARY_PATH="/usr/lib:/lib:/usr/lib/compat" export SPRUCE_ETC_DIR="/mnt/SDCARD/gkd/etc" -export DEVICE_PYTHON3_PATH=/mnt/SDCARD/spruce/pixel2/bin/python +export DEVICE_PYTHON3_PATH="/usr/bin/python" export DEVICE_SUPPORTS_PORTMASTER="false" -export PORTS_LD_LIBRARY_PATH="/usr/lib:/lib:/usr/lib/compat:/mnt/SDCARD/spruce/pixel2/lib" +export PORTS_LD_LIBRARY_PATH="/usr/lib:/lib:/usr/lib/compat" export DEVICE_CAN_USE_EXTERNAL_CONTROLLER="false" export DEVICE_USES_64_BIT_RA="true" export RA_BIN="ra64.pixel2" diff --git a/spruce/scripts/platform/device_functions/MiyooMini.sh b/spruce/scripts/platform/device_functions/MiyooMini.sh index d292415d8..5d265c282 100644 --- a/spruce/scripts/platform/device_functions/MiyooMini.sh +++ b/spruce/scripts/platform/device_functions/MiyooMini.sh @@ -56,15 +56,25 @@ device_init() { if [ "$variant" = "MIYOO_MINI_PLUS" ]; then # Screen is off by like ~8px unless you do this, not sure why cat /proc/ls + fi + + # Export and prime the pwm backlight channel. + # + # This used to sit inside the Plus branch above, which left the OG Mini and + # the V4 with no pwm0 node at all -- /sys/class/pwm/pwmchip0 exists but is + # never exported. Nothing could drive the backlight, and device_exit_sleep, + # which restores brightness through that same node, had nothing to write to + # either. Flip is deliberately left out: its backlight already works and it + # does not come through here today. + if [ "$variant" = "MIYOO_MINI_PLUS" ] || is_mini_og; then # export brightness settings - echo 0 > /sys/class/pwm/pwmchip0/export + [ -d /sys/class/pwm/pwmchip0/pwm0 ] || echo 0 > /sys/class/pwm/pwmchip0/export # Unsure what this value should be, 1k seems to work echo 1000 > /sys/class/pwm/pwmchip0/pwm0/period backlight=$(jq -r '.backlight' "$SYSTEM_JSON") duty_cycle=$((backlight * 10)) echo "$duty_cycle" > /sys/class/pwm/pwmchip0/pwm0/duty_cycle echo 1 > /sys/class/pwm/pwmchip0/pwm0/enable - fi killall -9 main ### SUPER important in preventing .tmp_update suicide } diff --git a/spruce/scripts/platform/device_functions/Pixel2.sh b/spruce/scripts/platform/device_functions/Pixel2.sh index 7c33787c8..dec10bf13 100755 --- a/spruce/scripts/platform/device_functions/Pixel2.sh +++ b/spruce/scripts/platform/device_functions/Pixel2.sh @@ -25,7 +25,7 @@ get_config_path() { } get_python_path() { - echo "/mnt/SDCARD/spruce/pixel2/bin/python" + echo "/usr/bin/python" } setup_for_retroarch(){ @@ -49,7 +49,13 @@ get_spruce_ra_cfg_location() { } set_loading_screen() { - THEME_NAME=$(jq -r '.theme' "$SYSTEM_JSON") + # SYSTEM_JSON doesn't exists yet during first boot + if [ -f "$SYSTEM_JSON" ]; then + THEME_NAME=$(jq -r '.theme' "$SYSTEM_JSON") + else + THEME_NAME="SPRUCE" + fi + LOADING_IMG="/mnt/SDCARD/Themes/${THEME_NAME}/skin/app_loading_merged.png" if [ ! -f "$LOADING_IMG" ]; then @@ -71,7 +77,12 @@ set_loading_screen() { magick composite -gravity center "$LAST_IMG" "$BG_IMG" "$LOADING_IMG" fi - /mnt/SDCARD/spruce/pixel2/bin/awww img "$LOADING_IMG" --transition-type none --no-resize + # Wait for sway socket to be available + while [ ! -S /var/run/0-runtime-dir/sway-ipc.0.sock ]; do + true + done + + swaymsg output "*" bg "$LOADING_IMG" fill } disable_swap() { @@ -83,14 +94,9 @@ disable_swap() { } device_init() { - touch /mnt/SDCARD/spruce/pixel2/bin/MainUI - mount --bind /mnt/SDCARD/spruce/pixel2/bin/python /mnt/SDCARD/spruce/pixel2/bin/MainUI sync_volume_level - disable_swap - - # Loading screen daemon - /mnt/SDCARD/spruce/pixel2/bin/awww-daemon --no-cache & set_loading_screen + set_loading_screen & } set_event_arg_for_idlemon() { @@ -102,13 +108,15 @@ check_if_fw_needs_update() { } enable_or_disable_rgb() { - log_message "rgb led not supported on this" -v + killall -q cava 2>/dev/null + killall -q leds_manager.sh 2>/dev/null + /mnt/SDCARD/spruce/scripts/leds_manager.sh & } prepare_for_pyui_launch(){ disable_dpad_mod set_overclock - echo "performance" > /sys/class/devfreq/dmc/governor + echo "performance" > "$GPU_GOVENOR_DIR/governor" ( # SDL2 takes forever, let it initialize before going to powersave sleep 5 @@ -123,8 +131,10 @@ post_pyui_exit(){ launch_startup_watchdogs(){ launch_common_startup_watchdogs_v2 "true" + /mnt/SDCARD/spruce/scripts/headphones_watchdog.sh & /mnt/SDCARD/spruce/scripts/theme_watchdog.sh & /mnt/SDCARD/spruce/scripts/enable_zram.sh & + /mnt/SDCARD/spruce/scripts/leds_manager.sh & } # 'Discharging', 'Charging', or 'Full' are possible values. Mind the capitalization. @@ -136,8 +146,23 @@ device_get_battery_percent() { cat "$BATTERY/capacity" } +device_wifi_power_on() { + rfkill unblock wifi + sleep 1 +} + +device_wifi_power_off() { + rfkill block wifi +} + sync_volume_level() { - VALUE=$(get_volume_level) + # SYSTEM_JSON doesn't exists yet during first boot + if [ -f "$SYSTEM_JSON" ]; then + VALUE=$(get_volume_level) + else + VALUE=10 + fi + set_volume "$VALUE" false } @@ -203,11 +228,21 @@ map_mainui_volume_to_system_value() { esac } +restore_audio() { + AUDIO_SINK=$(pactl list sinks short | grep rk817 | cut -c 0-2) + pactl suspend-sink "$AUDIO_SINK" 1 + + /mnt/SDCARD/spruce/scripts/headphones_watchdog.sh & +} + WAKE_ALARM_PATH="/sys/class/rtc/rtc0/wakealarm" device_enter_sleep() { turn_off_screen + amixer -c0 sset "Playback Path" "OFF" + pkill gpiomon + IDLE_TIMEOUT="$1" log_message "Entering sleep w/ IDLE_TIMEOUT of $IDLE_TIMEOUT" @@ -217,7 +252,11 @@ device_enter_sleep() { } device_exit_sleep() { + backlight=$(current_backlight) + set_backlight $backlight turn_on_screen + restore_audio + echo 0 >"$WAKE_ALARM_PATH" 2>/dev/null } @@ -240,7 +279,7 @@ device_wifi_is_available() { take_screenshot() { screenshot_path="$1" - /mnt/SDCARD/spruce/pixel2/bin/grim -o DSI-1 "${screenshot_path}" + grim -o DSI-1 "${screenshot_path}" } vibrate() { @@ -267,7 +306,7 @@ vibrate() { "Strong") intensity=0xFFFF ;; esac - /mnt/SDCARD/spruce/pixel2/bin/rumble $EVENT_PATH_READ_INPUTS_SPRUCE $intensity $duration + rumble $EVENT_PATH_READ_INPUTS_SPRUCE $intensity $duration } current_backlight() { @@ -327,7 +366,7 @@ set_event_arg() { send_menu_button_to_retroarch() { if pgrep "ra64.pixel2" >/dev/null; then - echo "MENU_TOGGLE" | /mnt/SDCARD/spruce/pixel2/bin/netcat -u -w0.1 127.0.0.1 55355 + echo "MENU_TOGGLE" | socat -t 0.1 - udp:127.0.0.1:55355 fi } diff --git a/spruce/scripts/platform/device_functions/utils/cpu_control_functions.sh b/spruce/scripts/platform/device_functions/utils/cpu_control_functions.sh index b0b2d63fa..d601384df 100644 --- a/spruce/scripts/platform/device_functions/utils/cpu_control_functions.sh +++ b/spruce/scripts/platform/device_functions/utils/cpu_control_functions.sh @@ -94,6 +94,10 @@ restore_cores_online() { set_powersave(){ log_message "set_powersave() called" if ! flag_check "setting_cpu"; then + # Was missing, unlike set_smart/set_performance/set_overclock: without it + # this neither takes the lock nor stops the flag_remove below from + # clearing a guard some other CPU change is relying on. + flag_add "setting_cpu" --tmp cores_online "$DEVICE_MIN_CORES_ONLINE" unlock_governor 2>/dev/null diff --git a/spruce/scripts/save_poweroff.sh b/spruce/scripts/save_poweroff.sh index cae5f47e7..e546cf1e9 100644 --- a/spruce/scripts/save_poweroff.sh +++ b/spruce/scripts/save_poweroff.sh @@ -11,7 +11,7 @@ SAVE_IMG="/mnt/SDCARD/spruce/imgs/save.png" EMU_PROCESSES="ra32.a30 ra32.mini ra32.universal ra64.universal ra64.pixel2 \ retroarch drastic drastic32 drastic64 pico8_dyn pico8_64 \ -flycast flycast-stock yabasanshiro yabasanshiro.trimui \ +flycast flycast2024 yabasanshiro yabasanshiro.trimui \ mupen64plus PPSSPPSDL PPSSPPSDL_TrimUI PPSSPPSDL_$PLATFORM" STAGE_2_SD_PATH=/mnt/SDCARD/spruce/scripts/save_poweroff_stage2.sh diff --git a/spruce/scripts/tasks/bugReport.sh b/spruce/scripts/tasks/bugReport.sh index 9a995c799..071e0d335 100644 --- a/spruce/scripts/tasks/bugReport.sh +++ b/spruce/scripts/tasks/bugReport.sh @@ -3,13 +3,166 @@ . /mnt/SDCARD/spruce/scripts/helperFunctions.sh output7z=/mnt/SDCARD/bug_report.7z +device_state=/mnt/SDCARD/Saves/spruce/device_state.log if [ -f $output7z ] ; then rm $output7z fi +# Hardware state that the logs don't record. Landing it in Saves/spruce as a +# .log means the include patterns below already pick it up. +dump_node() { + if [ -e "$1" ]; then + printf '%s = %s\n' "$1" "$(cat "$1" 2>/dev/null || echo '')" + else + printf '%s = \n' "$1" + fi +} + +{ + echo "==== device ====" + echo "date : $(date)" + echo "PLATFORM : $PLATFORM" + echo "DEVICE : $DEVICE" + if command -v get_miyoo_mini_variant >/dev/null 2>&1; then + echo "mini variant : $(get_miyoo_mini_variant 2>/dev/null)" + fi + echo "spruce version: $(cat /mnt/SDCARD/spruce/spruce 2>/dev/null)" + + echo + echo "==== display / backlight nodes ====" + echo "/sys/class/pwm/pwmchip0 :" + ls -1 /sys/class/pwm/pwmchip0 2>/dev/null || echo " " + dump_node /sys/class/pwm/pwmchip0/pwm0/duty_cycle + dump_node /sys/class/pwm/pwmchip0/pwm0/period + dump_node /sys/class/pwm/pwmchip0/pwm0/enable + dump_node /sys/devices/soc0/soc/1f003400.pwm/pwm/pwmchip0/pwm0/duty_cycle + echo "/proc/mi_modules :" + if [ -d /proc/mi_modules ]; then + # Listing one level down as well: the display settings need the exact + # node name inside mi_disp, and the top level listing does not show it. + for entry in /proc/mi_modules/*; do + if [ -d "$entry" ]; then + echo " $entry/" + for child in "$entry"/*; do + [ -e "$child" ] && echo " $(basename "$child")" + done + else + echo " $entry" + fi + done + else + echo " " + fi + + echo + echo "==== MI libraries (so we know the real names and where they live) ====" + for d in /config/lib /customer/lib /usr/lib; do + echo " $d:" + ls -1 "$d" 2>/dev/null | grep -i "^libmi" | sed 's/^/ /' || echo " " + done + + echo + echo "==== /dev mi nodes ====" + ls -l /dev/mi_* 2>/dev/null || echo " " + + echo + echo "==== probe: does opening the disp device create its proc node? ====" + echo "before:" + ls -1 /proc/mi_modules/mi_disp 2>/dev/null | sed 's/^/ /' + if [ -e /dev/mi_disp ]; then + # Just opening it, no ioctls. If the instance node appears afterwards then + # mi_disp0 is missing only because nothing had the display open. + ( exec 3<>/dev/mi_disp; sleep 1; exec 3>&- ) 2>/dev/null & + probe_pid=$! + sleep 0.3 + echo "during open:" + ls -1 /proc/mi_modules/mi_disp 2>/dev/null | sed 's/^/ /' + wait $probe_pid 2>/dev/null + echo "after close:" + ls -1 /proc/mi_modules/mi_disp 2>/dev/null | sed 's/^/ /' + else + echo " /dev/mi_disp does not exist, cannot probe" + fi + + echo + echo "==== csc probe: what does the driver say about each command? ====" + # colortemp is proven to reach the panel (a hard rgb cast showed up on screen) + # but csc produces no output and no visible effect. Feed the node a few things + # and read dmesg after each -- these drivers usually print usage on a command + # they do not recognise, which tells us the real argument list. + # Hold the device open ourselves for the duration. PyUI is stopped while a + # task runs, so its handle is gone and the node with it -- we cannot rely on + # anything else keeping it alive here. + if [ -e /dev/mi_disp ]; then + exec 3<>/dev/mi_disp + if [ -e /proc/mi_modules/mi_disp/mi_disp0 ]; then + for cmd in "help" "csc" "csc 0" "csc 0 3 50 50 50 0 0 0" "colortemp 0 0 0 0 128 128 128"; do + dmesg -c >/dev/null 2>&1 + echo " --- wrote: [$cmd]" + echo "$cmd" > /proc/mi_modules/mi_disp/mi_disp0 2>&1 + sleep 0.2 + dmesg 2>/dev/null | tail -12 | sed 's/^/ /' + done + echo + echo " --- CscMatrix sweep: saturation forced to 0, 3s per matrix ---" + echo " --- watch the screen and note which step turns it grey ---" + for m in 0 1 2 3 4 5 6 7; do + echo " step $m: csc 0 $m 50 50 50 0 0 0" + echo "csc 0 $m 50 50 50 0 0 0" > /proc/mi_modules/mi_disp/mi_disp0 2>&1 + sleep 3 + done + # put it back to something neutral before we let go + echo "csc 0 0 50 50 50 50 0 0" > /proc/mi_modules/mi_disp/mi_disp0 2>&1 + else + echo " node did not appear after opening /dev/mi_disp" + fi + exec 3>&- + else + echo " /dev/mi_disp does not exist" + fi + + echo + echo "==== what the display proc nodes report when read ====" + echo "--- /proc/mi_modules/fb/mi_fb0 ---" + head -c 4000 /proc/mi_modules/fb/mi_fb0 2>/dev/null || echo " " + echo + echo "--- /proc/mi_modules/mi_disp/mi_disp0 (opening the device ourselves) ---" + if [ -e /dev/mi_disp ]; then + exec 4<>/dev/mi_disp + head -c 4000 /proc/mi_modules/mi_disp/mi_disp0 2>/dev/null || echo " " + exec 4>&- + else + echo " /dev/mi_disp does not exist" + fi + echo + echo "--- /proc/mi_modules/mi_panel/* ---" + for n in /proc/mi_modules/mi_panel/*; do + case "$n" in *debug_*|*module_version*) continue ;; esac + echo " == $n" + head -c 2000 "$n" 2>/dev/null + done + echo + echo "--- /proc/mi_modules/common/pq_info ---" + head -c 2000 /proc/mi_modules/common/pq_info 2>/dev/null || echo " " + + echo + echo "==== kernel log tail (may show rejected display commands) ====" + dmesg 2>/dev/null | tail -40 || echo " " + + echo + echo "==== config files the display settings write to ====" + dump_node /appconfigs/system.json + dump_node /mnt/SDCARD/Saves/mini-flip-system.json + + echo + echo "==== processes that apply those settings ====" + ps 2>/dev/null | grep -iE "keymon|audioserver|MainUI|main$" | grep -v grep || echo " none running" +} > "$device_state" 2>&1 + 7zr a -spf2 "$output7z" \ -i'!/mnt/SDCARD/Saves/*.json' \ + -i'!/mnt/SDCARD/Saves/cache/*.json' \ -i'!/mnt/SDCARD/Saves/spruce/*.log' \ -i'!/mnt/SDCARD/Saves/spruce/*.json' \ -i'!/mnt/SDCARD/RetroArch/.retroarch/logs/*' \ diff --git a/spruce/scripts/tasks/clearwifi.sh b/spruce/scripts/tasks/clearwifi.sh index c7c037d58..89ea38405 100644 --- a/spruce/scripts/tasks/clearwifi.sh +++ b/spruce/scripts/tasks/clearwifi.sh @@ -14,10 +14,15 @@ if [ -n "$WPA_SUPPLICANT_FILE" ] ; then # Bring up interface to avoid issues with MainUI ifconfig wlan0 up -elif [ -d /storage/.cache/connman/ ] ; then # Connman - rm -r /storage/.cache/connman/[!settings]* - systemctl restart connman - connmanctl enable wifi +elif [ -d /storage/.config/NetworkManager/ ] ; then # NetworkManager + rfkill block wifi + + NUUID=$(nmcli -t -f UUID,DEVICE connection show | grep wlan | cut -d : -f 1) + echo "$NUUID" | while IFS= read -r line ; do + nmcli connection delete uuid "$line" + done + + rfkill unblock wifi fi log_message "Wifi: All networks forgotten by request of user." diff --git a/spruce/spruce b/spruce/spruce index f77856a6f..e91d9be2a 100644 --- a/spruce/spruce +++ b/spruce/spruce @@ -1 +1 @@ -4.3.1 +4.3.3