diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..78d5b400 --- /dev/null +++ b/.clang-format @@ -0,0 +1,164 @@ +BasedOnStyle: Google +AccessModifierOffset: -4 +AlignAfterOpenBracket: Align +AlignConsecutiveMacros: true +AlignConsecutiveAssignments: true +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: Left +AlignOperands: true +AlignTrailingComments: true +AllowAllArgumentsOnNextLine: true +AllowAllConstructorInitializersOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: false +AllowShortLambdasOnASingleLine: All +AllowShortIfStatementsOnASingleLine: WithoutElse +AllowShortLoopsOnASingleLine: true +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: true + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Custom +BreakBeforeInheritanceComma: false +BreakInheritanceList: BeforeColon +BreakBeforeTernaryOperators: true +BreakConstructorInitializersBeforeComma: false +BreakConstructorInitializers: BeforeColon +BreakAfterJavaFieldAnnotations: false +BreakStringLiterals: true +ColumnLimit: 120 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DeriveLineEnding: true +DerivePointerAlignment: true +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IncludeBlocks: Regroup +IncludeCategories: + - Regex: '^' + Priority: 2 + SortPriority: 0 + - Regex: '^<.*\.h>' + Priority: 1 + SortPriority: 0 + - Regex: '^<.*' + Priority: 2 + SortPriority: 0 + - Regex: '.*' + Priority: 3 + SortPriority: 0 +IncludeIsMainRegex: '([-_](test|unittest))?$' +IncludeIsMainSourceRegex: '' +IndentCaseLabels: true +IndentGotoLabels: true +IndentPPDirectives: None +IndentWidth: 4 +IndentWrappedFunctionNames: false +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: false +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBinPackProtocolList: Never +ObjCBlockIndentWidth: 2 +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: true +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 200 +PointerAlignment: Left +RawStringFormats: + - Language: Cpp + Delimiters: + - cc + - CC + - cpp + - Cpp + - CPP + - 'c++' + - 'C++' + CanonicalDelimiter: '' + BasedOnStyle: google + - Language: TextProto + Delimiters: + - pb + - PB + - proto + - PROTO + EnclosingFunctions: + - EqualsProto + - EquivToProto + - PARSE_PARTIAL_TEXT_PROTO + - PARSE_TEST_PROTO + - PARSE_TEXT_PROTO + - ParseTextOrDie + - ParseTextProtoOrDie + CanonicalDelimiter: '' + BasedOnStyle: google +ReflowComments: true +SortIncludes: false +SortUsingDeclarations: true +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: false +SpacesInConditionalStatement: false +SpacesInContainerLiterals: true +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +SpaceBeforeSquareBrackets: false +Standard: Auto +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TabWidth: 4 +UseCRLF: false +UseTab: Never diff --git a/.github/workflows/launcher-build.yml b/.github/workflows/launcher-build.yml index 708818c2..60c84179 100644 --- a/.github/workflows/launcher-build.yml +++ b/.github/workflows/launcher-build.yml @@ -169,7 +169,6 @@ jobs: run: | mkdir -p projects/APPLaunch/dist/bin cp projects/AppStore/dist/M5CardputerZero-AppStore projects/APPLaunch/dist/bin/ - cp projects/AppStore/dist/APPLaunch/bin/appstore.py projects/APPLaunch/dist/bin/ cp projects/Calculator/dist/M5CardputerZero-Calculator projects/APPLaunch/dist/bin/ cp projects/ZClaw/dist/ZClaw projects/APPLaunch/dist/bin/ mkdir -p projects/APPLaunch/APPLaunch/applications diff --git a/SDK b/SDK index 421e7f6b..730634b0 160000 --- a/SDK +++ b/SDK @@ -1 +1 @@ -Subproject commit 421e7f6b80e38312c7cab729299692a62f978c6d +Subproject commit 730634b0fec605c2087836fd35b37d3cf21b1a4c diff --git a/ext_components/cp0_lvgl/Kconfig b/ext_components/cp0_lvgl/Kconfig index c1fbf1c9..cf03eb54 100644 --- a/ext_components/cp0_lvgl/Kconfig +++ b/ext_components/cp0_lvgl/Kconfig @@ -13,4 +13,67 @@ config CP0_LVGL_USE_ZMQ_RPC bool "Enable cp0 lvgl local ZMQ automation RPC" default n +menu "Optional runtime services" + +config CP0_LVGL_INIT_FILESYSTEM + bool "Initialize filesystem service" + default n +config CP0_LVGL_INIT_CONFIG + bool "Initialize configuration service" + default n +config CP0_LVGL_INIT_PTY + bool "Initialize PTY service" + default n +config CP0_LVGL_INIT_RPC + bool "Initialize RPC service" + default n +config CP0_LVGL_INIT_AUDIO + bool "Initialize audio service" + default n +config CP0_LVGL_INIT_PROCESS + bool "Initialize process service" + default n +config CP0_LVGL_INIT_SUDO + bool "Initialize sudo signals" + default n +config CP0_LVGL_INIT_OSINFO + bool "Initialize OS information service" + default n +config CP0_LVGL_INIT_SCREENSHOT + bool "Initialize screenshot service" + default n +config CP0_LVGL_INIT_LORA + bool "Initialize LoRa service" + default n +config CP0_LVGL_INIT_WIFI + bool "Initialize Wi-Fi service" + select CP0_LVGL_INIT_PROCESS + default n +config CP0_LVGL_INIT_BLUETOOTH + bool "Initialize Bluetooth service" + default n +config CP0_LVGL_INIT_SETTINGS + bool "Initialize settings service" + default n +config CP0_LVGL_INIT_BQ27220 + bool "Initialize BQ27220 service" + default n +config CP0_LVGL_INIT_IMU + bool "Initialize IMU service" + default n +config CP0_LVGL_INIT_SAVED_SETTINGS + bool "Apply saved LVGL settings" + default n +config CP0_LVGL_INIT_BATTERY + bool "Initialize battery service" + default n +config CP0_LVGL_INIT_CAMERA + bool "Initialize camera service" + default n +config CP0_LVGL_INIT_SOUNDCARD + bool "Initialize sound card service" + default n + +endmenu + endif diff --git a/ext_components/cp0_lvgl/SConstruct b/ext_components/cp0_lvgl/SConstruct index a8ddf60d..a6a6f557 100644 --- a/ext_components/cp0_lvgl/SConstruct +++ b/ext_components/cp0_lvgl/SConstruct @@ -14,24 +14,64 @@ if "CONFIG_CP0_LVGL_COMPONENT_ENABLED" in os.environ or "CONFIG_SIGSLOT_COMPONEN SRCS = [] INCLUDE = [ADir('include')] PRIVATE_INCLUDE = [] - REQUIREMENTS = ['lvgl_component', 'eventpp', 'Miniaudio', 'RadioLib'] + REQUIREMENTS = ['lvgl_component', 'eventpp'] STATIC_LIB = [] DYNAMIC_LIB = [] DEFINITIONS = [] DEFINITIONS_PRIVATE = [] LDFLAGS = [] LINK_SEARCH_PATH = [] + optional_services = { + 'AUDIO': 'audio', + 'BATTERY': 'battery', + 'BQ27220': 'bq27220', + 'CAMERA': 'camera', + 'CONFIG': 'config', + 'FILESYSTEM': 'filesystem', + 'IMU': 'imu', + 'LORA': 'lara', + 'OSINFO': 'osinfo', + 'PROCESS': 'process', + 'PTY': 'pty', + 'SCREENSHOT': 'screenshot', + 'SETTINGS': 'settings', + 'SOUNDCARD': 'soundcard', + 'WIFI': 'network', + } + + def service_enabled(name): + return 'CONFIG_CP0_LVGL_INIT_' + name in os.environ + + for name in optional_services: + if service_enabled(name): + DEFINITIONS += ['-DCONFIG_CP0_LVGL_INIT_' + name + '=1'] + for name in ['RPC', 'SUDO', 'BLUETOOTH', 'SAVED_SETTINGS']: + if service_enabled(name): + DEFINITIONS += ['-DCONFIG_CP0_LVGL_INIT_' + name + '=1'] + + if service_enabled('AUDIO'): + REQUIREMENTS += ['Miniaudio'] + if service_enabled('LORA'): + REQUIREMENTS += ['RadioLib'] + SRCS += Glob('src/*.c*') if 'CONFIG_V9_5_LV_USE_SDL' in os.environ: DEFINITIONS += ['-DHAL_PLATFORM_SDL=1'] DEFINITIONS += pkg_config_cflags('sdl2') REQUIREMENTS += pkg_config_ldflags('sdl2') - SRCS += Glob('src/sdl/*.c*') + platform_srcs = Glob('src/sdl/*.c*') + platform_srcs += [ + File('src/cp0/cp0_camera_viewport.cpp'), + File('src/cp0/cp0_desktop_exec_policy.cpp'), + File('src/cp0/cp0_process_commands.cpp'), + File('src/cp0/cp0_process_lifecycle.cpp'), + ] else: - SRCS += Glob('src/cp0/*.c*') + platform_srcs = Glob('src/cp0/*.c*') REQUIREMENTS += ['input', 'xkbcommon', 'udev', 'pthread'] REQUIREMENTS += [':libdbus-1.so.3'] - REQUIREMENTS += ['camera', 'camera-base', 'jpeg'] + if service_enabled('CAMERA'): + REQUIREMENTS += ['camera', 'camera-base', 'jpeg'] PRIVATE_INCLUDE += [toolchain_path('usr', 'include', 'dbus-1.0'), toolchain_path('usr', 'lib', env.get('GCC_DUMPMACHINE', ''), 'dbus-1.0', 'include')] @@ -43,6 +83,19 @@ if "CONFIG_CP0_LVGL_COMPONENT_ENABLED" in os.environ or "CONFIG_SIGSLOT_COMPONEN if 'zmq' not in REQUIREMENTS: REQUIREMENTS += ['zmq'] + source_selected_services = {'AUDIO', 'CAMERA', 'IMU', 'LORA'} + for source in platform_srcs: + source_name = str(source).lower() + disabled = any(token in source_name and not service_enabled(name) + for name, token in optional_services.items() + if name in source_selected_services) + # The LoRa implementation historically used "lara" in one filename, + # while the bridge service uses the correct "lora" spelling. + if not service_enabled('LORA') and ('lora' in source_name or 'lara' in source_name): + disabled = True + if not disabled: + SRCS.append(source) + env["COMPONENTS"].append( { "target": os.path.basename(env["component_dir"]), diff --git a/ext_components/cp0_lvgl/assets/cp0_status_battery_background.inc b/ext_components/cp0_lvgl/assets/cp0_status_battery_background.inc new file mode 100644 index 00000000..92ee6b98 --- /dev/null +++ b/ext_components/cp0_lvgl/assets/cp0_status_battery_background.inc @@ -0,0 +1,194 @@ +static const uint8_t cp0_status_battery_background_map[] = { + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa5, 0x63, 0xa7, 0xf6, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xf8, + 0xa6, 0x63, 0xa7, 0xc2, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa7, 0x64, 0xa8, 0xc6, + 0xa6, 0x63, 0xa7, 0x52, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa5, 0x64, 0xa7, 0x54, + 0x00, 0x00, 0x00, 0x00, 0xa5, 0x63, 0xa7, 0x8a, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0x89, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa8, 0x63, 0xa8, 0x51, + 0xa6, 0x63, 0xa7, 0xc2, 0xa5, 0x63, 0xa7, 0xf6, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, 0xa6, 0x63, 0xa8, 0xff, + 0xa6, 0x63, 0xa8, 0xff, 0xa5, 0x63, 0xa7, 0xf6, 0xa6, 0x63, 0xa7, 0xc2, + 0xa5, 0x63, 0xa6, 0x51, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 +}; diff --git a/ext_components/cp0_lvgl/assets/cp0_status_time_background.inc b/ext_components/cp0_lvgl/assets/cp0_status_time_background.inc new file mode 100644 index 00000000..29c498e5 --- /dev/null +++ b/ext_components/cp0_lvgl/assets/cp0_status_time_background.inc @@ -0,0 +1,216 @@ +static const uint8_t cp0_status_time_background_map[] = { + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x98, 0xf6, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xf8, + 0x33, 0x65, 0x98, 0xc2, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x34, 0x66, 0x99, 0xc6, 0x32, 0x64, 0x98, 0x52, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x34, 0x67, 0x98, 0x54, 0x00, 0x00, 0x00, 0x00, + 0x32, 0x66, 0x98, 0x8a, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x32, 0x66, 0x99, 0x89, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, 0x65, 0x9a, 0x51, + 0x33, 0x65, 0x98, 0xc2, 0x33, 0x66, 0x98, 0xf6, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, + 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x99, 0xff, 0x33, 0x66, 0x98, 0xf6, + 0x33, 0x65, 0x98, 0xc2, 0x32, 0x65, 0x97, 0x51, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 +}; diff --git a/ext_components/cp0_lvgl/include/commount.h b/ext_components/cp0_lvgl/include/commount.h index 945d6219..08830e31 100644 --- a/ext_components/cp0_lvgl/include/commount.h +++ b/ext_components/cp0_lvgl/include/commount.h @@ -9,6 +9,11 @@ void init_lvgl_event_cpp(); void init_lvgl_env(); void init_lvgl_saved_settings(); void init_sudo_signals(); +#ifdef __cplusplus +void deinit_sudo(void) noexcept; +#else +void deinit_sudo(void); +#endif #ifdef __cplusplus } diff --git a/ext_components/cp0_lvgl/include/cp0_config_json.h b/ext_components/cp0_lvgl/include/cp0_config_json.h new file mode 100644 index 00000000..2dc85cb4 --- /dev/null +++ b/ext_components/cp0_lvgl/include/cp0_config_json.h @@ -0,0 +1,456 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +// Minimal JSON <-> flat "dotted key" store used by the unified device config +// (~/.config/cardputerzero/config.json). The launcher's config system keeps a +// flat key=value model internally; nested JSON objects map to dotted keys, e.g. +// camera.resolution.width -> { "camera": { "resolution": { "width": .. }}} +// This keeps the shared contract with other apps (the camera app reads +// camera.resolution.{width,height}) while the launcher still uses simple keys. +// +// Only objects, strings and numbers are emitted; arrays/true/false/null are +// tolerated on read but never written (the launcher is the sole writer). + +#include +#include +#include +#include +#include +#include +#include + +namespace cp0cfg { + +inline bool looks_numeric(const std::string &v) +{ + if (v.empty()) + return false; + size_t i = 0; + if (v[0] == '-') { + if (v.size() == 1) + return false; + i = 1; + } + for (; i < v.size(); ++i) + if (!std::isdigit(static_cast(v[i]))) + return false; + std::int64_t n = 0; + const auto result = std::from_chars(v.data(), v.data() + v.size(), n); + if (result.ec != std::errc{} || result.ptr != v.data() + v.size()) + return false; + // Only treat as a JSON number if it round-trips, so values like "007" keep + // their string form and get_str() returns them unchanged. + return std::to_string(n) == v; +} + +struct JsonNode { + std::map children; + std::string value; + bool numeric = false; +}; + +inline void json_escape(const std::string &s, std::string &out) +{ + static constexpr char hex[] = "0123456789abcdef"; + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + out += "\\u00"; + out += hex[(static_cast(c) >> 4) & 0x0f]; + out += hex[static_cast(c) & 0x0f]; + } else { + out += c; + } + break; + } + } +} + +inline void json_emit(const JsonNode &node, std::string &out, int indent) +{ + if (node.children.empty()) { + if (node.numeric) { + out += node.value; + } else { + out += '"'; + json_escape(node.value, out); + out += '"'; + } + return; + } + const std::string pad(static_cast(indent) * 2, ' '); + const std::string pad2(static_cast(indent + 1) * 2, ' '); + out += "{\n"; + size_t i = 0; + for (const auto &kv : node.children) { + out += pad2; + out += '"'; + json_escape(kv.first, out); + out += "\": "; + json_emit(kv.second, out, indent + 1); + if (++i < node.children.size()) + out += ','; + out += '\n'; + } + out += pad; + out += '}'; +} + +inline std::string to_json(const std::vector> &kv) +{ + JsonNode root; + for (const auto &e : kv) { + const std::string &key = e.first; + if (key.empty()) + continue; + JsonNode *cur = &root; + size_t start = 0; + while (true) { + size_t dot = key.find('.', start); + std::string seg = (dot == std::string::npos) ? key.substr(start) + : key.substr(start, dot - start); + if (seg.empty()) + break; + cur = &cur->children[seg]; + if (dot == std::string::npos) { + cur->value = e.second; + cur->numeric = looks_numeric(e.second); + break; + } + start = dot + 1; + } + } + if (root.children.empty()) + return "{}\n"; + std::string out; + json_emit(root, out, 0); + out += '\n'; + return out; +} + +class JsonReader { +public: + JsonReader(const std::string &text, std::vector> &out) + : p_(text.c_str()), end_(text.c_str() + text.size()), out_(out) + { + } + + bool parse() + { + if (static_cast(end_ - p_) > kMaxDocumentLength) + return false; + skip_ws(); + if (p_ >= end_ || *p_ != '{') + return false; + if (!parse_object(std::string(), 1)) + return false; + skip_ws(); + return p_ == end_; + } + +private: + static constexpr size_t kMaxDocumentLength = 1024 * 1024; + static constexpr size_t kMaxNestingDepth = 64; + static constexpr size_t kMaxStringLength = 64 * 1024; + + const char *p_; + const char *end_; + std::vector> &out_; + + void skip_ws() + { + while (p_ < end_ && (*p_ == ' ' || *p_ == '\t' || *p_ == '\n' || *p_ == '\r')) + ++p_; + } + + static int hex_value(char c) + { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + + bool parse_hex_quad(std::uint32_t &value) + { + if (end_ - p_ < 4) return false; + value = 0; + for (int i = 0; i < 4; ++i) { + const int digit = hex_value(*p_++); + if (digit < 0) return false; + value = (value << 4) | static_cast(digit); + } + return true; + } + + static void append_utf8(std::uint32_t value, std::string &out) + { + if (value <= 0x7f) { + out += static_cast(value); + } else if (value <= 0x7ff) { + out += static_cast(0xc0 | (value >> 6)); + out += static_cast(0x80 | (value & 0x3f)); + } else if (value <= 0xffff) { + out += static_cast(0xe0 | (value >> 12)); + out += static_cast(0x80 | ((value >> 6) & 0x3f)); + out += static_cast(0x80 | (value & 0x3f)); + } else { + out += static_cast(0xf0 | (value >> 18)); + out += static_cast(0x80 | ((value >> 12) & 0x3f)); + out += static_cast(0x80 | ((value >> 6) & 0x3f)); + out += static_cast(0x80 | (value & 0x3f)); + } + } + + bool parse_string(std::string &s) + { + if (p_ >= end_ || *p_ != '"') + return false; + ++p_; + while (p_ < end_ && *p_ != '"') { + char c = *p_++; + if (static_cast(c) < 0x20) + return false; + if (c == '\\' && p_ < end_) { + char e = *p_++; + switch (e) { + case 'b': s += '\b'; break; + case 'f': s += '\f'; break; + case 'n': s += '\n'; break; + case 'r': s += '\r'; break; + case 't': s += '\t'; break; + case '"': s += '"'; break; + case '\\': s += '\\'; break; + case '/': s += '/'; break; + case 'u': { + std::uint32_t value = 0; + if (!parse_hex_quad(value)) return false; + if (value >= 0xd800 && value <= 0xdbff) { + if (end_ - p_ < 2 || p_[0] != '\\' || p_[1] != 'u') return false; + p_ += 2; + std::uint32_t low = 0; + if (!parse_hex_quad(low) || low < 0xdc00 || low > 0xdfff) return false; + value = 0x10000 + ((value - 0xd800) << 10) + (low - 0xdc00); + } else if (value >= 0xdc00 && value <= 0xdfff) { + return false; + } + append_utf8(value, s); + break; + } + default: return false; + } + } else { + s += c; + } + if (s.size() > kMaxStringLength) + return false; + } + if (p_ >= end_) + return false; + ++p_; // closing quote + return true; + } + + bool parse_number_token(std::string &token) + { + const char *start = p_; + if (p_ < end_ && *p_ == '-') ++p_; + if (p_ >= end_) return false; + if (*p_ == '0') { + ++p_; + if (p_ < end_ && std::isdigit(static_cast(*p_))) return false; + } else if (*p_ >= '1' && *p_ <= '9') { + while (p_ < end_ && std::isdigit(static_cast(*p_))) ++p_; + } else { + return false; + } + bool integer = true; + if (p_ < end_ && *p_ == '.') { + integer = false; + ++p_; + const char *fraction = p_; + while (p_ < end_ && std::isdigit(static_cast(*p_))) ++p_; + if (p_ == fraction) return false; + } + if (p_ < end_ && (*p_ == 'e' || *p_ == 'E')) { + integer = false; + ++p_; + if (p_ < end_ && (*p_ == '+' || *p_ == '-')) ++p_; + const char *exponent = p_; + while (p_ < end_ && std::isdigit(static_cast(*p_))) ++p_; + if (p_ == exponent) return false; + } + token.assign(start, static_cast(p_ - start)); + if (integer) { + std::int64_t value = 0; + const auto result = std::from_chars(token.data(), token.data() + token.size(), value); + if (result.ec != std::errc{} || result.ptr != token.data() + token.size()) return false; + } + return true; + } + + bool consume_literal(const char *literal) + { + const char *cursor = literal; + while (*cursor && p_ < end_ && *p_ == *cursor) { + ++p_; + ++cursor; + } + return *cursor == '\0'; + } + + bool skip_value(size_t depth) + { + if (depth > kMaxNestingDepth) return false; + skip_ws(); + if (p_ >= end_) return false; + if (*p_ == '"') { + std::string ignored; + return parse_string(ignored); + } + if (*p_ == '{') return skip_object(depth + 1); + if (*p_ == '[') return skip_array(depth + 1); + if (*p_ == 't') return consume_literal("true"); + if (*p_ == 'f') return consume_literal("false"); + if (*p_ == 'n') return consume_literal("null"); + std::string ignored; + return parse_number_token(ignored); + } + + bool skip_array(size_t depth) + { + if (depth > kMaxNestingDepth || p_ >= end_ || *p_++ != '[') return false; + skip_ws(); + if (p_ < end_ && *p_ == ']') { ++p_; return true; } + while (skip_value(depth)) { + skip_ws(); + if (p_ < end_ && *p_ == ',') { ++p_; continue; } + if (p_ < end_ && *p_ == ']') { ++p_; return true; } + return false; + } + return false; + } + + bool skip_object(size_t depth) + { + if (depth > kMaxNestingDepth || p_ >= end_ || *p_++ != '{') return false; + skip_ws(); + if (p_ < end_ && *p_ == '}') { ++p_; return true; } + while (p_ < end_) { + skip_ws(); + std::string key; + if (!parse_string(key)) return false; + skip_ws(); + if (p_ >= end_ || *p_++ != ':') return false; + if (!skip_value(depth)) return false; + skip_ws(); + if (p_ < end_ && *p_ == ',') { ++p_; continue; } + if (p_ < end_ && *p_ == '}') { ++p_; return true; } + return false; + } + return false; + } + + bool parse_value(const std::string &prefix, size_t depth) + { + skip_ws(); + if (p_ >= end_) + return false; + char c = *p_; + if (c == '{') + return parse_object(prefix, depth + 1); + if (c == '[') + return skip_array(depth + 1); + if (c == '"') { + std::string s; + if (!parse_string(s)) + return false; + record(prefix, s); + return true; + } + std::string tok; + if (c == 't' && consume_literal("true")) + tok = "1"; + else if (c == 'f' && consume_literal("false")) + tok = "0"; + else if (c == 'n' && consume_literal("null")) + tok = ""; + else if (!parse_number_token(tok)) + return false; + record(prefix, tok); + return true; + } + + bool parse_object(const std::string &prefix, size_t depth) + { + if (depth > kMaxNestingDepth) + return false; + skip_ws(); + if (p_ >= end_ || *p_ != '{') + return false; + ++p_; + skip_ws(); + if (p_ < end_ && *p_ == '}') { + ++p_; + return true; + } + while (p_ < end_) { + skip_ws(); + std::string key; + if (!parse_string(key)) + return false; + skip_ws(); + if (p_ >= end_ || *p_ != ':') + return false; + ++p_; + std::string child = prefix.empty() ? key : (prefix + "." + key); + if (!parse_value(child, depth)) + return false; + skip_ws(); + if (p_ < end_ && *p_ == ',') { + ++p_; + continue; + } + if (p_ < end_ && *p_ == '}') { + ++p_; + return true; + } + return false; + } + return false; + } + + void record(const std::string &prefix, const std::string &val) + { + if (!prefix.empty()) + out_.emplace_back(prefix, val); + } +}; + +inline bool from_json(const std::string &text, + std::vector> &out) +{ + try { + std::vector> parsed; + JsonReader reader(text, parsed); + if (!reader.parse()) + return false; + out.swap(parsed); + return true; + } catch (...) { + return false; + } +} + +} // namespace cp0cfg diff --git a/ext_components/cp0_lvgl/include/cp0_dispatch_testable.hpp b/ext_components/cp0_lvgl/include/cp0_dispatch_testable.hpp index ca545ba1..7adcff6b 100644 --- a/ext_components/cp0_lvgl/include/cp0_dispatch_testable.hpp +++ b/ext_components/cp0_lvgl/include/cp0_dispatch_testable.hpp @@ -10,4 +10,15 @@ bool dispatch_task(Scheduler &&scheduler, Task &&task) return std::forward(scheduler)(std::forward(task)); } +template +bool run_on_dispatch_failure(bool dispatched, Fallback &&fallback) noexcept +{ + if (dispatched) return false; + try { + std::forward(fallback)(); + } catch (...) { + } + return true; +} + } // namespace cp0_testable diff --git a/ext_components/cp0_lvgl/include/cp0_font_service.hpp b/ext_components/cp0_lvgl/include/cp0_font_service.hpp new file mode 100644 index 00000000..8dffafc0 --- /dev/null +++ b/ext_components/cp0_lvgl/include/cp0_font_service.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "lvgl/lvgl.h" + +#if LV_USE_FREETYPE +#include "lvgl/src/libs/freetype/lv_freetype.h" +#else +#ifndef LV_FREETYPE_FONT_STYLE_NORMAL +typedef int lv_freetype_font_style_t; +#define LV_FREETYPE_FONT_STYLE_NORMAL 0 +#define LV_FREETYPE_FONT_STYLE_ITALIC (1 << 0) +#define LV_FREETYPE_FONT_STYLE_BOLD (1 << 1) +#endif +#ifndef LV_FREETYPE_FONT_RENDER_MODE_BITMAP +typedef int lv_freetype_font_render_mode_t; +#define LV_FREETYPE_FONT_RENDER_MODE_BITMAP 0 +#define LV_FREETYPE_FONT_RENDER_MODE_OUTLINE 1 +#endif +#endif + +#include +#include +#include +#include + +class Cp0FontService +{ +public: + Cp0FontService() = default; + ~Cp0FontService(); + + Cp0FontService(const Cp0FontService &) = delete; + Cp0FontService &operator=(const Cp0FontService &) = delete; + + lv_font_t *get(const char *font_name, uint16_t size, + lv_freetype_font_style_t style = LV_FREETYPE_FONT_STYLE_NORMAL, + lv_freetype_font_render_mode_t render_mode = LV_FREETYPE_FONT_RENDER_MODE_BITMAP); + lv_font_t *get_mono(const char *font_name, uint16_t size, + lv_freetype_font_style_t style = LV_FREETYPE_FONT_STYLE_NORMAL); + + lv_font_t *fallback(uint16_t size) const; + void release(); + +private: +#if LV_USE_FREETYPE + static std::string resolve_path(const char *font_name); + static std::string key(const std::string &path, uint16_t size, + lv_freetype_font_style_t style, + lv_freetype_font_render_mode_t render_mode); + std::unordered_map fonts_; + std::mutex mutex_; +#endif +}; + +Cp0FontService &cp0_fonts(); diff --git a/ext_components/cp0_lvgl/include/cp0_lvgl_app.h b/ext_components/cp0_lvgl/include/cp0_lvgl_app.h index 6a601dcd..d2739bce 100644 --- a/ext_components/cp0_lvgl/include/cp0_lvgl_app.h +++ b/ext_components/cp0_lvgl/include/cp0_lvgl_app.h @@ -29,6 +29,7 @@ typedef struct { int signal; char security[32]; int in_use; + int saved; } cp0_wifi_ap_t; typedef struct { @@ -144,6 +145,14 @@ int cp0_dir_watch_poll(cp0_watcher_t watcher); void cp0_dir_watch_stop(cp0_watcher_t watcher); int cp0_network_list(cp0_netif_info_t *entries, int max_entries, int *out_count); +int cp0_wifi_status_read(cp0_wifi_status_t *status); +int cp0_wifi_scan(cp0_wifi_ap_t *entries, int max_entries); +int cp0_wifi_connect(const char *ssid, const char *password); +int cp0_wifi_profile_forget(const char *ssid); +int cp0_wifi_profile_exists(const char *ssid); +int cp0_wifi_disconnect_active(void); +int cp0_wifi_radio_enabled(void); +int cp0_wifi_radio_set_enabled(int enabled); int cp0_process_exec_blocking(const char *exec_path, volatile int *home_key_flag, int keep_root); cp0_pid_t cp0_process_spawn(const char *exec_path, int keep_root); diff --git a/ext_components/cp0_lvgl/include/cp0_lvgl_app_page_assets.h b/ext_components/cp0_lvgl/include/cp0_lvgl_app_page_assets.h new file mode 100644 index 00000000..f0e744f6 --- /dev/null +++ b/ext_components/cp0_lvgl/include/cp0_lvgl_app_page_assets.h @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "lvgl/lvgl.h" + +LV_IMAGE_DECLARE(cp0_status_time_background); +LV_IMAGE_DECLARE(cp0_status_battery_background); +LV_IMAGE_DECLARE(cp0_status_ethernet); diff --git a/ext_components/cp0_lvgl/include/cp0_lvgl_app_runner.hpp b/ext_components/cp0_lvgl/include/cp0_lvgl_app_runner.hpp new file mode 100644 index 00000000..6116c98f --- /dev/null +++ b/ext_components/cp0_lvgl/include/cp0_lvgl_app_runner.hpp @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "ui_app_page.hpp" + +#include +#include +#include + +struct Cp0LvglRunOptions +{ + std::function after_lvgl_init; + std::function after_resource_init; + std::function setup; + std::function should_quit; + std::function teardown; +}; + +struct Cp0LvglAppHooks +{ + std::function after_lvgl_init; + std::function after_resource_init; + std::function after_page_init; + std::function should_quit; +}; + +int cp0_lvgl_run(Cp0LvglRunOptions options = {}); +void cp0_lvgl_wake(); + +template +int cp0_lvgl_run_app(Cp0LvglAppHooks hooks = {}) +{ + std::unique_ptr page; + Cp0LvglRunOptions options; + options.after_lvgl_init = std::move(hooks.after_lvgl_init); + options.after_resource_init = std::move(hooks.after_resource_init); + options.should_quit = std::move(hooks.should_quit); + options.setup = [&]() { + page = cp0_lvgl_start_app(); + if (!page || !page->screen()) return false; + if (hooks.after_page_init) + hooks.after_page_init(*page); + return true; + }; + options.teardown = [&]() { page.reset(); }; + return cp0_lvgl_run(std::move(options)); +} diff --git a/ext_components/cp0_lvgl/include/compat/input_keys.h b/ext_components/cp0_lvgl/include/input_keys.h similarity index 94% rename from ext_components/cp0_lvgl/include/compat/input_keys.h rename to ext_components/cp0_lvgl/include/input_keys.h index 77c7374a..933483d5 100644 --- a/ext_components/cp0_lvgl/include/compat/input_keys.h +++ b/ext_components/cp0_lvgl/include/input_keys.h @@ -1,5 +1,5 @@ #pragma once -// Cross-platform KEY_* constants +// Shared cross-platform KEY_* constants for cp0_lvgl and its applications. // On Linux: use the real header. On macOS/Windows: define ourselves. #ifdef __linux__ @@ -18,6 +18,7 @@ #define KEY_8 9 #define KEY_9 10 #define KEY_0 11 +#define KEY_MINUS 12 #define KEY_BACKSPACE 14 #define KEY_TAB 15 #define KEY_Q 16 @@ -50,6 +51,7 @@ #define KEY_B 48 #define KEY_N 49 #define KEY_M 50 +#define KEY_DOT 52 #define KEY_SPACE 57 #define KEY_LEFTALT 56 #define KEY_CAPSLOCK 58 diff --git a/ext_components/cp0_lvgl/include/keyboard_input.h b/ext_components/cp0_lvgl/include/keyboard_input.h index 55bda152..388fb36f 100644 --- a/ext_components/cp0_lvgl/include/keyboard_input.h +++ b/ext_components/cp0_lvgl/include/keyboard_input.h @@ -1,11 +1,12 @@ -#ifndef __MAIN__H__ -#define __MAIN__H__ +#ifndef CP0_LVGL_KEYBOARD_INPUT_H +#define CP0_LVGL_KEYBOARD_INPUT_H -#include #include +#include +#include #ifdef __cplusplus extern "C" { -#endif +#endif /* CP0_LVGL_KEYBOARD_INPUT_H */ // modifier bitmask #define KBD_MOD_SHIFT (1u << 0) @@ -32,6 +33,8 @@ struct key_item { STAILQ_ENTRY(key_item) entries; }; +typedef void (*cp0_keyboard_key_handler_t)(const struct key_item *item); + STAILQ_HEAD(keyboard_queue_t, key_item); extern struct keyboard_queue_t keyboard_queue; extern pthread_mutex_t keyboard_mutex; @@ -42,6 +45,7 @@ extern volatile uint32_t LV_EVENT_KEYBOARD; void *keyboard_read_thread(void *argv); int cp0_keyboard_inject(uint32_t key_code, int key_state, uint32_t mods); int cp0_keyboard_inject_text(const char *utf8); +void cp0_keyboard_set_global_key_handler(cp0_keyboard_key_handler_t handler); const char *kbd_state_name(int state); void kbd_dump_keymap_table(void); #ifdef __cplusplus diff --git a/ext_components/cp0_lvgl/include/ui_app_page.hpp b/ext_components/cp0_lvgl/include/ui_app_page.hpp new file mode 100644 index 00000000..b7f43429 --- /dev/null +++ b/ext_components/cp0_lvgl/include/ui_app_page.hpp @@ -0,0 +1,365 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "lvgl/lvgl.h" +#include "cp0_lvgl_app.h" +#include "cp0_lvgl_app_page_assets.h" +#include "hal_lvgl_bsp.h" + +#include +#include +#include +#include +#include +#include + +class AppTopBarRegion; +class AppTopBarComponent; +namespace cp0::status { class Lifecycle; } + +class UIAppContainer +{ +public: + UIAppContainer() = default; + explicit UIAppContainer(int height); + ~UIAppContainer(); + + void set_height(int height); + lv_obj_t *create(lv_obj_t *parent); + + lv_obj_t *get() const { return container_; } + +private: + static void container_delete_cb(lv_event_t *event); + int height_ = 150; + lv_obj_t *container_ = nullptr; +}; + +class AppPageRoot +{ +public: + AppPageRoot(); + virtual ~AppPageRoot(); + + AppPageRoot(const AppPageRoot &) = delete; + AppPageRoot &operator=(const AppPageRoot &) = delete; + AppPageRoot(AppPageRoot &&) = delete; + AppPageRoot &operator=(AppPageRoot &&) = delete; + + lv_obj_t *screen() const { return root_screen_; } + lv_group_t *input_group() const { return input_group_; } + + void enable_top_bar(); + void disable_top_bar(); + bool top_bar_enabled() const; + void set_page_title(const std::string &title); + void update_time(); + void update_status(); + void update_battery(const cp0_battery_info_t &battery); + void update_datetime_status() { update_time(); } + void update_status_bar() { update_status(); } + void update_battery_status(const cp0_battery_info_t &battery) { update_battery(battery); } + AppTopBarComponent *add_top_bar_component( + std::unique_ptr component); + bool remove_top_bar_component(const std::string &id); + AppTopBarComponent *top_bar_component(const std::string &id) const; + void clear_content(); + + template + lv_obj_t *add_bar(Component &&component) + { + return component.create(root_screen_); + } + + std::string page_title_ = "APP"; + lv_group_t *input_group_ = nullptr; + lv_obj_t *root_screen_ = nullptr; + std::function navigate_home; + bool has_bottom_bar_ = false; + int top_bar_height_px_ = 20; + +private: + static void screen_delete_cb(lv_event_t *event); + void create_base_ui(); + void create_input_group(); + void release_root_screen(); + + std::unique_ptr top_bar_region_; +}; + +class AppContentRegion : virtual public AppPageRoot +{ +public: + AppContentRegion(); + + virtual ~AppContentRegion(); + + void refresh(); + + // Kept for source compatibility with existing applications. + void refash() { refresh(); } + + lv_obj_t *ui_APP_Container = nullptr; + +private: + static void container_delete_cb(lv_event_t *event); + UIAppContainer app_container_; +}; + +class AppBottomBarRegion : virtual public AppPageRoot, virtual public AppContentRegion +{ +public: + AppBottomBarRegion(); + + virtual ~AppBottomBarRegion(); + + lv_obj_t *ui_BOTTOM_Container = nullptr; + +private: + static void container_delete_cb(lv_event_t *event); +}; + +class AppTopBarComponent +{ +public: + // Components live inside a 20 px bar with 2 px vertical clearance. + static constexpr int kMaximumHeight = 16; + // Used when a component does not provide a useful width recommendation. + static constexpr int kDefaultRecommendedWidth = 20; + + explicit AppTopBarComponent(std::string id, + int recommended_width = kDefaultRecommendedWidth, + int height = kMaximumHeight); + virtual ~AppTopBarComponent(); + + AppTopBarComponent(const AppTopBarComponent &) = delete; + AppTopBarComponent &operator=(const AppTopBarComponent &) = delete; + AppTopBarComponent(AppTopBarComponent &&) = delete; + AppTopBarComponent &operator=(AppTopBarComponent &&) = delete; + + const std::string &id() const { return id_; } + int height() const { return height_; } + int recommended_width() const { return recommended_width_; } + lv_obj_t *obj() const { return obj_; } + + void set_visible(bool visible); + bool visible() const; + + virtual void activate() {} + virtual void deactivate() {} + virtual void refresh() {} + +protected: + // The base class creates and owns obj. Derived components add their LVGL + // children here and can use obj() later for state updates. + virtual void on_create(lv_obj_t *obj) = 0; + virtual void on_object_deleted() {} + virtual bool resources_ready() const { return true; } + void set_width(int width); + void unmount(); + +private: + friend class UIAppTopBar; + + static void object_delete_cb(lv_event_t *event); + lv_obj_t *mount(lv_obj_t *parent); + void detach_from_deleted_parent(); + + std::string id_; + int recommended_width_; + int height_; + lv_obj_t *obj_ = nullptr; +}; + +class AppTopBarTitleComponent final : public AppTopBarComponent +{ +public: + AppTopBarTitleComponent(); + ~AppTopBarTitleComponent() override; + void set_title(const std::string &title); + +protected: + void on_create(lv_obj_t *obj) override; + void on_object_deleted() override; + bool resources_ready() const override; + +private: + static void child_delete_cb(lv_event_t *event); + std::string title_ = "APP"; + lv_obj_t *label_ = nullptr; +}; + +class AppTopBarTimeComponent final : public AppTopBarComponent +{ +public: + AppTopBarTimeComponent(); + ~AppTopBarTimeComponent() override; + void refresh() override; + +protected: + void on_create(lv_obj_t *obj) override; + void on_object_deleted() override; + bool resources_ready() const override; + +private: + static void child_delete_cb(lv_event_t *event); + lv_obj_t *label_ = nullptr; +}; + +class AppTopBarBatteryComponent final : public AppTopBarComponent +{ +public: + AppTopBarBatteryComponent(); + ~AppTopBarBatteryComponent() override; + void update(const cp0_battery_info_t &battery); + void deactivate() override; + +protected: + void on_create(lv_obj_t *obj) override; + void on_object_deleted() override; + bool resources_ready() const override; + +private: + static void child_delete_cb(lv_event_t *event); + static void blink_cb(void *object, int32_t opacity); + void set_charging(bool charging); + void set_low_battery_blink(bool enabled); + + bool charging_ = false; + bool low_battery_blink_ = false; + lv_obj_t *battery_bar_ = nullptr; + lv_obj_t *charge_wave_ = nullptr; + lv_obj_t *label_ = nullptr; +}; + +class AppTopBarNetworkComponent final : public AppTopBarComponent +{ +public: + AppTopBarNetworkComponent(); + ~AppTopBarNetworkComponent() override; + void refresh() override; + +protected: + void on_create(lv_obj_t *obj) override; + void on_object_deleted() override; + bool resources_ready() const override; + +private: + static void child_delete_cb(lv_event_t *event); + lv_obj_t *ethernet_image_ = nullptr; + lv_obj_t *wifi_panel_ = nullptr; + lv_obj_t *wifi_bars_[4] = {}; +}; + +class UIAppTopBar +{ +public: + UIAppTopBar(); + ~UIAppTopBar(); + void set_height(int height) { height_ = height; } + + lv_obj_t *create(lv_obj_t *parent); + void set_title(const std::string &title); + void update_time(); + void update_network(); + void update_battery(const cp0_battery_info_t &battery); + void set_visible(bool visible); + bool visible() const; + void set_active(bool active); + void detach_from_deleted_parent(); + AppTopBarComponent *add_component(std::unique_ptr component); + bool remove_component(const std::string &id); + AppTopBarComponent *component(const std::string &id) const; + lv_obj_t *container() const { return container_; } + +private: + friend class AppTopBarRegion; + + void set_delete_callback(std::function callback) + { + delete_callback_ = std::move(callback); + } + static void container_delete_cb(lv_event_t *event); + static void custom_container_delete_cb(lv_event_t *event); + int height_ = 20; + bool active_ = false; + lv_obj_t *container_ = nullptr; + lv_obj_t *custom_container_ = nullptr; + std::unique_ptr title_; + std::unique_ptr network_; + std::unique_ptr time_; + std::unique_ptr battery_; + std::unordered_map> components_; + std::function delete_callback_; +}; + +class AppTopBarRegion +{ +public: + explicit AppTopBarRegion(AppPageRoot &page); + ~AppTopBarRegion(); + + void enable(); + void disable(); + void on_screen_deleted(); + bool enabled() const { return enabled_; } + void set_page_title(const std::string &title); + void update_time(); + void update_status(); + void update_battery(const cp0_battery_info_t &battery); + void update_datetime_status() { update_time(); } + void update_status_bar() { update_status(); } + void update_battery_status(const cp0_battery_info_t &battery) { update_battery(battery); } + AppTopBarComponent *add_component(std::unique_ptr component); + bool remove_component(const std::string &id); + AppTopBarComponent *component(const std::string &id) const; + lv_obj_t *container() const { return top_bar_.container(); } + +private: + static void battery_event_cb(lv_event_t *event); + static void time_timer_cb(lv_timer_t *timer); + static void status_timer_cb(lv_timer_t *timer); + void on_top_bar_deleted(); + + UIAppTopBar top_bar_; + AppPageRoot &page_; + bool enabled_ = false; + std::unique_ptr lifecycle_; +}; + +class AppPageLayout : virtual public AppContentRegion +{ +public: + AppPageLayout(); + virtual ~AppPageLayout() = default; +}; + +class AppPageWithBottomBarLayout : virtual public AppContentRegion, + virtual public AppBottomBarRegion +{ +public: + AppPageWithBottomBarLayout(); + virtual ~AppPageWithBottomBarLayout() = default; +}; + +class AppPage : public AppPageLayout +{ +public: + AppPage() = default; + ~AppPage() override = default; +}; + +void cp0_lvgl_start_app_page(AppPageRoot &page); + +template +std::unique_ptr cp0_lvgl_start_app(Args &&...args) +{ + auto page = std::make_unique(std::forward(args)...); + cp0_lvgl_start_app_page(*page); + return page; +} diff --git a/ext_components/cp0_lvgl/src/commount.cpp b/ext_components/cp0_lvgl/src/commount.cpp index 7120d20a..4fcc776b 100644 --- a/ext_components/cp0_lvgl/src/commount.cpp +++ b/ext_components/cp0_lvgl/src/commount.cpp @@ -1,17 +1,100 @@ #include "hal_lvgl_bsp.h" #include "commount.h" #include "cp0_lvgl_app.h" +#include "cp0_network_api_contract.hpp" +#include "cp0_integer_codec.hpp" +#include "cp0_sync_signal.hpp" #include "lvgl/lvgl.h" +#include #include #include #include #include +#include #define def_hal_fun(arg, name) eventpp::CallbackList name; #include "signal_register_plan.h" #undef def_hal_fun eventpp::EventQueue)> cp0_task_queue; + +extern "C" int cp0_wifi_status_read(cp0_wifi_status_t *status) +{ + if (!status) + return -1; + + *status = {}; + int result = -1; + cp0::signal::invoke_noexcept([&] { cp0_signal_wifi_api({"Status"}, [&](int code, std::string data) { + if (code != 0 || !cp0::network::decode_status_payload(data, *status)) { + result = code != 0 ? code : -1; + return; + } + result = 0; + }); }); + return result; +} + +namespace { + +int wifi_command(const std::list &args) +{ + int result = -1; + cp0::signal::invoke_noexcept([&] { + cp0_signal_wifi_api(args, [&](int code, std::string) { result = code; }); + }); + return result; +} + +} // namespace + +extern "C" int cp0_wifi_scan(cp0_wifi_ap_t *entries, int max_entries) +{ + if (!entries || max_entries <= 0) + return 0; + std::fill_n(entries, static_cast(max_entries), cp0_wifi_ap_t{}); + const int request_limit = std::min(max_entries, CP0_WIFI_AP_MAX); + int count = 0; + cp0::signal::invoke_noexcept([&] { cp0_signal_wifi_api({"Scan", std::to_string(request_limit)}, [&](int code, std::string data) { + if (code < 0) + return; + count = cp0::network::decode_scan_payload(data, entries, max_entries); + }); }); + return count; +} + +extern "C" int cp0_wifi_connect(const char *ssid, const char *password) +{ + if (!ssid || !ssid[0]) + return -1; + return password && password[0] ? wifi_command({"Connect", ssid, password}) + : wifi_command({"Connect", ssid}); +} + +extern "C" int cp0_wifi_profile_forget(const char *ssid) +{ + return ssid && ssid[0] ? wifi_command({"ProfileForget", ssid}) : -1; +} + +extern "C" int cp0_wifi_profile_exists(const char *ssid) +{ + return ssid && ssid[0] ? wifi_command({"ProfileExists", ssid}) : 0; +} + +extern "C" int cp0_wifi_disconnect_active(void) +{ + return wifi_command({"ProfileDisconnectActive"}); +} + +extern "C" int cp0_wifi_radio_enabled(void) +{ + return wifi_command({"RadioEnabled"}); +} + +extern "C" int cp0_wifi_radio_set_enabled(int enabled) +{ + return wifi_command({"RadioSetEnabled", enabled ? "on" : "off"}); +} static std::atomic_bool queue_run_flage{true}; extern "C" void init_lvgl_event_cpp() { @@ -29,16 +112,20 @@ extern "C" void init_lvgl_event_cpp() static int config_get_int(const char *key, int default_val) { int val = default_val; - cp0_signal_config_api({"GetInt", key ? std::string(key) : std::string(), std::to_string(default_val)}, + cp0::signal::invoke_noexcept([&] { cp0_signal_config_api({"GetInt", key ? std::string(key) : std::string(), std::to_string(default_val)}, [&](int code, std::string data) { - if (code == 0) val = std::atoi(data.c_str()); - }); + int parsed = 0; + if (code == 0 && cp0_integer_codec::parse_int(data, parsed)) + val = parsed; + }); }); return val; } static void saved_volume_write(int val) { - cp0_signal_audio_api({"VolumeWrite", std::to_string(val)}, nullptr); + cp0::signal::invoke_noexcept([&] { + cp0_signal_audio_api({"VolumeWrite", std::to_string(val)}, nullptr); + }); } extern "C" void init_lvgl_saved_settings() diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_audio_capture.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_audio_capture.cpp new file mode 100644 index 00000000..389d165c --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_audio_capture.cpp @@ -0,0 +1,212 @@ +#include "cp0_audio_capture.hpp" +#include "../cp0_callback_contract.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "miniaudio.h" + +class Cp0AudioCapture::Impl +{ +public: + static constexpr const char* kTmpFile = "/tmp/rec.tmp.wav"; + static constexpr int kWaveformSize = 128; + + ~Impl() { stop(false); } + + static void data_callback(ma_device* device, void* output, const void* input, ma_uint32 frame_count) + { + auto* self = static_cast(device->pUserData); + if(self) self->on_data(input, frame_count); + (void)output; + } + + int start() + { + if(encoder_) + { + report(-4, "working"); + return 0; + } + + encoder_ = std::make_unique(); + device_ = std::make_unique(); + ma_encoder_config encoder_config = ma_encoder_config_init(ma_encoding_format_wav, ma_format_s16, 2, 48000); + if(ma_encoder_init_file(kTmpFile, &encoder_config, encoder_.get()) != MA_SUCCESS) + { + report(-1, "Failed to initialize output file.\n"); + encoder_.reset(); + device_.reset(); + return -1; + } + + ma_device_config device_config = ma_device_config_init(ma_device_type_capture); + device_config.capture.format = encoder_->config.format; + device_config.capture.channels = encoder_->config.channels; + device_config.sampleRate = encoder_->config.sampleRate; + device_config.dataCallback = data_callback; + device_config.pUserData = this; + if(ma_device_init(nullptr, &device_config, device_.get()) != MA_SUCCESS) + { + report(-3, "Failed to initialize capture device.\n"); + ma_encoder_uninit(encoder_.get()); + encoder_.reset(); + device_.reset(); + return -2; + } + if(ma_device_start(device_.get()) != MA_SUCCESS) + { + ma_device_uninit(device_.get()); + ma_encoder_uninit(encoder_.get()); + encoder_.reset(); + device_.reset(); + report(-3, "Failed to start device.\n"); + return -3; + } + return 0; + } + + void stop(bool report_inactive = true) + { + if(!device_) + { + if(report_inactive) report(-5, "stop"); + return; + } + ma_device_uninit(device_.get()); + if(encoder_) ma_encoder_uninit(encoder_.get()); + device_.reset(); + encoder_.reset(); + } + + int pause() { return device_ && ma_device_stop(device_.get()) == MA_SUCCESS ? 0 : (device_ ? -2 : -1); } + int resume() { return device_ && ma_device_start(device_.get()) == MA_SUCCESS ? 0 : (device_ ? -2 : -1); } + + int save(const std::string& path) + { + if(path.empty()) return -1; + if(device_) stop(); + if(std::rename(kTmpFile, path.c_str()) == 0) return 0; + + int saved_errno = errno; + int result = copy_file(kTmpFile, path); + if(result == 0) + { + std::remove(kTmpFile); + return 0; + } + errno = saved_errno; + return result; + } + + void on_data(const void* input, ma_uint32 frame_count) + { + if(encoder_) ma_encoder_write_pcm_frames(encoder_.get(), input, frame_count, nullptr); + if(!waveform_enabled_.load() || !input || frame_count == 0) return; + auto callback = status_callback(); + cp0::callback::invoke(callback, 1, build_waveform(input, frame_count)); + } + + std::string build_waveform(const void* input, ma_uint32 frame_count) + { + const auto* samples = static_cast(input); + ma_uint32 channels = encoder_ && encoder_->config.channels > 0 ? encoder_->config.channels : 1; + ma_uint32 sample_count = frame_count * channels; + int16_t peak = 0; + double sum_sq = 0.0; + for(ma_uint32 i = 0; i < sample_count; ++i) + { + if(std::abs(samples[i]) > std::abs(peak)) peak = samples[i]; + double sample = static_cast(samples[i]) / 32768.0; + sum_sq += sample * sample; + } + float rms = sample_count ? static_cast(std::sqrt(sum_sq / sample_count)) : 0.0f; + float db = std::max(-36.0f, 20.0f * std::log10(rms + 1e-6f)); + float normalized = (db + 36.0f) / 36.0f; + if(peak < 0) normalized = -normalized; + + std::array ordered{}; + { + std::lock_guard lock(waveform_mutex_); + waveform_[waveform_index_] = std::max(-1.0f, std::min(1.0f, normalized)); + waveform_index_ = (waveform_index_ + 1) % kWaveformSize; + for(int i = 0; i < kWaveformSize; ++i) ordered[i] = waveform_[(waveform_index_ + i) % kWaveformSize]; + } + std::string result(sizeof(float) * kWaveformSize, '\0'); + std::memcpy(&result[0], ordered.data(), result.size()); + return result; + } + + static int copy_file(const std::string& source_path, const std::string& destination_path) + { + FILE* source = std::fopen(source_path.c_str(), "rb"); + if(!source) return -1; + FILE* destination = std::fopen(destination_path.c_str(), "wb"); + if(!destination) + { + std::fclose(source); + return -2; + } + char buffer[4096]; + size_t count = 0; + int result = 0; + while((count = std::fread(buffer, 1, sizeof(buffer), source)) > 0) + { + if(std::fwrite(buffer, 1, count, destination) != count) + { + result = -3; + break; + } + } + if(std::ferror(source)) result = -4; + std::fclose(destination); + std::fclose(source); + return result; + } + + StatusCallback status_callback() + { + std::lock_guard lock(callback_mutex_); + return callback_; + } + + void set_status_callback(StatusCallback callback) + { + std::lock_guard lock(callback_mutex_); + callback_ = std::move(callback); + } + + void report(int code, const std::string& message) + { + auto callback = status_callback(); + cp0::callback::invoke(callback, code, message); + } + + StatusCallback callback_; + std::mutex callback_mutex_; + std::unique_ptr device_; + std::unique_ptr encoder_; + std::array waveform_{}; + size_t waveform_index_ = 0; + std::mutex waveform_mutex_; + std::atomic waveform_enabled_{false}; +}; + +Cp0AudioCapture::Cp0AudioCapture() : impl_(std::make_unique()) {} +Cp0AudioCapture::~Cp0AudioCapture() = default; +void Cp0AudioCapture::set_status_callback(StatusCallback callback) { impl_->set_status_callback(std::move(callback)); } +void Cp0AudioCapture::set_waveform_enabled(bool enabled) { impl_->waveform_enabled_.store(enabled); } +int Cp0AudioCapture::start() { return impl_->start(); } +void Cp0AudioCapture::stop() { impl_->stop(); } +int Cp0AudioCapture::pause() { return impl_->pause(); } +int Cp0AudioCapture::resume() { return impl_->resume(); } +int Cp0AudioCapture::save(const std::string& path) { return impl_->save(path); } +bool Cp0AudioCapture::active() const { return static_cast(impl_->device_); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_audio_capture.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_audio_capture.hpp new file mode 100644 index 00000000..1f52cbd4 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_audio_capture.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +class Cp0AudioCapture +{ +public: + using StatusCallback = std::function; + + Cp0AudioCapture(); + ~Cp0AudioCapture(); + + Cp0AudioCapture(const Cp0AudioCapture&) = delete; + Cp0AudioCapture& operator=(const Cp0AudioCapture&) = delete; + + void set_status_callback(StatusCallback callback); + void set_waveform_enabled(bool enabled); + + int start(); + void stop(); + int pause(); + int resume(); + int save(const std::string& path); + bool active() const; + +private: + class Impl; + std::unique_ptr impl_; +}; diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.cpp new file mode 100644 index 00000000..df2056d7 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.cpp @@ -0,0 +1,89 @@ +#include "cp0_audio_mixer_control.hpp" + +#include +#include +#include +#include +#include + +bool Cp0AudioMixerControl::parse_volume_line(const char *line, int &percent) +{ + if (!line) + return false; + const char *marker = std::strchr(line, '%'); + if (!marker) + return false; + const char *start = marker; + while (start > line && start[-1] >= '0' && start[-1] <= '9') + --start; + if (start == marker) + return false; + if (start > line && !std::isspace(static_cast(start[-1]))) + return false; + + int parsed = 0; + const auto result = std::from_chars(start, marker, parsed); + if (result.ec != std::errc{} || result.ptr != marker || parsed < 0 || parsed > 100) + return false; + percent = parsed; + return true; +} + +int Cp0AudioMixerControl::read_volume() const +{ + FILE *pipe = popen("pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null", "r"); + if (!pipe) + return -1; + + char buffer[256]; + int volume = -1; + while (fgets(buffer, sizeof(buffer), pipe)) { + if (parse_volume_line(buffer, volume)) + break; + } + pclose(pipe); + return volume; +} + +int Cp0AudioMixerControl::write_volume(int percent) const +{ + percent = clamp_percent(percent); + char command[128]; + std::snprintf(command, sizeof(command), + "pactl set-sink-volume @DEFAULT_SINK@ %d%%", percent); + return std::system(command) == 0 ? percent : -1; +} + +int Cp0AudioMixerControl::read_mute() const +{ + FILE *pipe = popen("pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null", "r"); + if (!pipe) + return -1; + + char buffer[128] = {}; + int muted = -1; + while (fgets(buffer, sizeof(buffer), pipe)) { + if (std::strstr(buffer, "yes")) { + muted = 1; + break; + } + if (std::strstr(buffer, "no")) { + muted = 0; + break; + } + } + pclose(pipe); + return muted; +} + +int Cp0AudioMixerControl::toggle_mute() const +{ + if (std::system("pactl set-sink-mute @DEFAULT_SINK@ toggle >/dev/null 2>&1") != 0) + return -1; + return read_mute(); +} + +int Cp0AudioMixerControl::clamp_percent(int percent) +{ + return std::max(0, std::min(100, percent)); +} diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.hpp new file mode 100644 index 00000000..d28da98e --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.hpp @@ -0,0 +1,14 @@ +#pragma once + +class Cp0AudioMixerControl +{ +public: + static bool parse_volume_line(const char *line, int &percent); + int read_volume() const; + int write_volume(int percent) const; + int read_mute() const; + int toggle_mute() const; + +private: + static int clamp_percent(int percent); +}; diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_audio_system_sound_player.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_audio_system_sound_player.cpp new file mode 100644 index 00000000..56c8a73e --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_audio_system_sound_player.cpp @@ -0,0 +1,171 @@ +#include "cp0_audio_system_sound_player.hpp" + +#include "hal_lvgl_bsp.h" +#include "miniaudio.h" + +#include +#include +#include + +class Cp0SystemSoundPlayer::Impl +{ +public: + static constexpr std::size_t kSoundCount = 3; + + Impl() + : names{"Ding2.wav", "key_back.wav", "key_back.wav"} + { + load(); + } + + ~Impl() + { + std::lock_guard lock(mutex); + unload(); + if (initialized) { + ma_engine_uninit(&engine); + ma_context_uninit(&context); + } + } + + int initialize() + { + if (initialized) + return 0; + ma_backend backends[] = {ma_backend_pulseaudio}; + if (ma_context_init(backends, 1, nullptr, &context) != MA_SUCCESS) + return -1; + + ma_engine_config config = ma_engine_config_init(); + config.pContext = &context; + config.channels = 2; + config.sampleRate = 48000; + if (ma_engine_init(&config, &engine) != MA_SUCCESS) { + ma_context_uninit(&context); + return -2; + } + initialized = true; + return 0; + } + + int load() + { + if (loaded) + return 0; + int result = initialize(); + if (result != 0) + return result; + + for (std::size_t i = 0; i < names.size(); ++i) { + const std::string path = resolve_asset(names[i]); + if (path.empty()) + continue; + if (ma_sound_init_from_file(&engine, path.c_str(), MA_SOUND_FLAG_DECODE, + nullptr, nullptr, &sounds[i]) == MA_SUCCESS) + slots[i] = true; + } + loaded = std::any_of(slots.begin(), slots.end(), [](bool slot) { return slot; }); + return loaded ? 0 : -3; + } + + void unload() + { + for (std::size_t i = 0; i < sounds.size(); ++i) { + if (slots[i]) { + ma_sound_uninit(&sounds[i]); + slots[i] = false; + } + } + loaded = false; + } + + static std::string resolve_asset(const std::string &name) + { + if (name.empty()) + return {}; + if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos) + return name; + const std::string path = cp0_file_path(name); + return path.empty() ? name : path; + } + + ma_context context{}; + ma_engine engine{}; + std::array sounds{}; + std::array slots{}; + std::array names; + bool initialized = false; + bool loaded = false; + bool enabled = true; + mutable std::mutex mutex; +}; + +Cp0SystemSoundPlayer::Cp0SystemSoundPlayer() + : impl_(std::make_unique()) +{ +} + +Cp0SystemSoundPlayer::~Cp0SystemSoundPlayer() = default; + +int Cp0SystemSoundPlayer::reload(const std::vector &names) +{ + std::lock_guard lock(impl_->mutex); + for (std::size_t i = 0; i < impl_->names.size() && i < names.size(); ++i) + if (!names[i].empty()) + impl_->names[i] = names[i]; + impl_->unload(); + return impl_->load(); +} + +bool Cp0SystemSoundPlayer::play_index(std::size_t index) +{ + std::lock_guard lock(impl_->mutex); + if (!impl_->enabled || (impl_->load() != 0 && !impl_->loaded) || + index >= impl_->sounds.size() || !impl_->slots[index]) + return false; + ma_sound *sound = &impl_->sounds[index]; + if (ma_sound_is_playing(sound)) + return true; + return ma_sound_seek_to_pcm_frame(sound, 0) == MA_SUCCESS && + ma_sound_start(sound) == MA_SUCCESS; +} + +bool Cp0SystemSoundPlayer::play_named(const std::string &name) +{ + std::lock_guard lock(impl_->mutex); + if (!impl_->enabled || (impl_->load() != 0 && !impl_->loaded)) + return false; + for (std::size_t i = 0; i < impl_->names.size(); ++i) { + if (impl_->names[i] != name || !impl_->slots[i]) + continue; + ma_sound *sound = &impl_->sounds[i]; + if (ma_sound_is_playing(sound)) + return true; + return ma_sound_seek_to_pcm_frame(sound, 0) == MA_SUCCESS && + ma_sound_start(sound) == MA_SUCCESS; + } + return false; +} + +bool Cp0SystemSoundPlayer::contains(const std::string &name) const +{ + std::lock_guard lock(impl_->mutex); + return std::find(impl_->names.begin(), impl_->names.end(), name) != impl_->names.end(); +} + +void Cp0SystemSoundPlayer::set_enabled(bool enabled) +{ + std::lock_guard lock(impl_->mutex); + impl_->enabled = enabled; +} + +bool Cp0SystemSoundPlayer::enabled() const +{ + std::lock_guard lock(impl_->mutex); + return impl_->enabled; +} + +std::size_t Cp0SystemSoundPlayer::sound_count() const +{ + return Impl::kSoundCount; +} diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_audio_system_sound_player.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_audio_system_sound_player.hpp new file mode 100644 index 00000000..795ea4d9 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_audio_system_sound_player.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +class Cp0SystemSoundPlayer +{ +public: + Cp0SystemSoundPlayer(); + ~Cp0SystemSoundPlayer(); + + Cp0SystemSoundPlayer(const Cp0SystemSoundPlayer &) = delete; + Cp0SystemSoundPlayer &operator=(const Cp0SystemSoundPlayer &) = delete; + + int reload(const std::vector &names); + bool play_index(std::size_t index); + bool play_named(const std::string &name); + bool contains(const std::string &name) const; + void set_enabled(bool enabled); + bool enabled() const; + std::size_t sound_count() const; + +private: + class Impl; + std::unique_ptr impl_; +}; diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_bluetooth_api.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_bluetooth_api.cpp new file mode 100644 index 00000000..128e2c32 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_bluetooth_api.cpp @@ -0,0 +1,94 @@ +#include "cp0_bluetooth_backend.hpp" +#include "../cp0_init_once.hpp" +#include "../cp0_bluetooth_api_contract.hpp" + +#include "hal_lvgl_bsp.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::string encode_status(const cp0_bt_status_t &status) +{ + std::ostringstream output; + output << status.powered << '\t' + << cp0::bluetooth::sanitize_wire_field(status.address) << '\t' + << status.discoverable << '\t' + << cp0::bluetooth::sanitize_wire_field(status.alias); + return output.str(); +} + +std::string encode_devices(const cp0_bt_device_t *devices, int count) +{ + std::ostringstream output; + for(int i = 0; devices && i < count; ++i) + { + output << cp0::bluetooth::sanitize_wire_field(devices[i].address) << '\t' + << devices[i].rssi << '\t' + << devices[i].connected << '\t' << devices[i].paired << '\t' + << devices[i].trusted << '\t' + << cp0::bluetooth::sanitize_wire_field(devices[i].name) << '\n'; + } + return output.str(); +} + +void report(const std::function &callback, int code, const std::string &data) +{ + cp0::bluetooth::invoke_callback(callback, code, data); +} + +template +cp0::bluetooth::Reply load_devices(int requested_count, Loader loader) +{ + std::vector devices(static_cast(requested_count)); + int count = loader(devices.empty() ? nullptr : devices.data(), static_cast(devices.size())); + return {count, encode_devices(devices.data(), count)}; +} + +void api_call(std::list args, std::function callback) +{ + using namespace cp0_bluetooth_backend; + cp0::bluetooth::Request request; + if (!cp0::bluetooth::parse_request(args, request)) { + report(callback, -1, "invalid bt api request"); + return; + } + cp0::bluetooth::invoke_backend(callback, [&]() -> cp0::bluetooth::Reply { + using cp0::bluetooth::Command; + if(request.command == Command::Status) return {0, encode_status(status())}; + if(request.command == Command::Power) return {set_power(request.value), {}}; + if(request.command == Command::Alias) return {set_alias(request.text.c_str()), {}}; + if(request.command == Command::Discoverable) return {set_discoverable(request.value), {}}; + if(request.command == Command::Scan) return load_devices(request.max_count, scan); + if(request.command == Command::DiscoveryStart) return {start_discovery(), {}}; + if(request.command == Command::DiscoveryStop) return {stop_discovery(), {}}; + if(request.command == Command::List) + return load_devices(request.max_count, [](cp0_bt_device_t *out, int count) { return list(out, count, false); }); + if(request.command == Command::ConnectedList) + return load_devices(request.max_count, [](cp0_bt_device_t *out, int count) { return list(out, count, true); }); + if(request.command == Command::Pair) return {pair(request.text.c_str()), {}}; + if(request.command == Command::Connect) return {connect(request.text.c_str()), {}}; + if(request.command == Command::Disconnect) return {disconnect(request.text.c_str()), {}}; + return {cp0_bluetooth_backend::remove(request.text.c_str()), {}}; + }); +} + +} // namespace + +extern "C" void init_bluetooth(void) +{ + static cp0::InitOnce initialized; + initialized.run([] { + return static_cast(cp0_signal_bt_api.append( + [](std::list args, + std::function callback) { + api_call(std::move(args), std::move(callback)); + })); + }); +} diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_bluetooth_backend.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_bluetooth_backend.hpp new file mode 100644 index 00000000..2155e368 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_bluetooth_backend.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +namespace cp0_bluetooth_backend { + +cp0_bt_status_t status(); +int set_power(int on); +int set_alias(const char *alias); +int set_discoverable(int on); +int start_discovery(); +int stop_discovery(); +int scan(cp0_bt_device_t *out, int max_devices); +int list(cp0_bt_device_t *out, int max_devices, bool connected_only); +int pair(const char *address); +int connect(const char *address); +int disconnect(const char *address); +int remove(const char *address); + +} // namespace cp0_bluetooth_backend diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_bluez_dbus_client.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_bluez_dbus_client.cpp new file mode 100644 index 00000000..9a179ce0 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_bluez_dbus_client.cpp @@ -0,0 +1,663 @@ +#include "cp0_bluez_dbus_client.hpp" +#include "cp0_lvgl_log.h" + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +namespace { + +static constexpr const char *kBluezService = "org.bluez"; +static constexpr const char *kObjectManagerIface = "org.freedesktop.DBus.ObjectManager"; +static constexpr const char *kPropertiesIface = "org.freedesktop.DBus.Properties"; +static constexpr const char *kAdapterIface = "org.bluez.Adapter1"; +static constexpr const char *kDeviceIface = "org.bluez.Device1"; +static constexpr const char *kAgentManagerIface = "org.bluez.AgentManager1"; +static constexpr const char *kAgentIface = "org.bluez.Agent1"; +static constexpr const char *kAgentPath = "/com/cardputerzero/applaunch/agent"; +static constexpr int kCallTimeoutMs = 15000; + +struct DeviceInfo { + std::string path; + cp0_bt_device_t dev{}; +}; + +struct ManagedSnapshot { + std::string adapter_path; + cp0_bt_status_t status{}; + std::vector devices; +}; + +static void copy_string(char *dst, size_t dst_size, const std::string &src) +{ + if (!dst || dst_size == 0) + return; + std::strncpy(dst, src.c_str(), dst_size - 1); + dst[dst_size - 1] = '\0'; +} + +static bool is_bt_address(const char *address) +{ + if (!address || std::strlen(address) != 17) + return false; + for (int i = 0; i < 17; ++i) { + unsigned char ch = static_cast(address[i]); + if ((i + 1) % 3 == 0) { + if (ch != ':') + return false; + } else if (!std::isxdigit(ch)) { + return false; + } + } + return true; +} + +static std::string uppercase_address(const char *address) +{ + std::string out(address ? address : ""); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char ch) { + return static_cast(std::toupper(ch)); + }); + return out; +} + +class ScopedError { +public: + ScopedError() { dbus_error_init(&err_); } + ~ScopedError() + { + if (dbus_error_is_set(&err_)) + dbus_error_free(&err_); + } + DBusError *get() { return &err_; } + bool is_set() const { return dbus_error_is_set(&err_); } + const char *message() const { return err_.message ? err_.message : "unknown"; } + +private: + DBusError err_{}; +}; + +class ScopedMessage { +public: + explicit ScopedMessage(DBusMessage *msg = nullptr) : msg_(msg) {} + ~ScopedMessage() + { + if (msg_) + dbus_message_unref(msg_); + } + DBusMessage *get() const { return msg_; } + DBusMessage *release() + { + DBusMessage *tmp = msg_; + msg_ = nullptr; + return tmp; + } + explicit operator bool() const { return msg_ != nullptr; } + +private: + DBusMessage *msg_ = nullptr; +}; + +static bool iter_is_type(DBusMessageIter *iter, int type) +{ + return dbus_message_iter_get_arg_type(iter) == type; +} + +static bool append_basic_variant(DBusMessageIter *iter, const char *signature, int type, const void *value) +{ + DBusMessageIter variant; + if (!dbus_message_iter_open_container(iter, DBUS_TYPE_VARIANT, signature, &variant)) + return false; + if (!dbus_message_iter_append_basic(&variant, type, value)) { + dbus_message_iter_abandon_container(iter, &variant); + return false; + } + return dbus_message_iter_close_container(iter, &variant); +} + +class BluezDbusClient { +public: + ~BluezDbusClient() + { + if (conn_ && agent_registered_) { + unregister_agent(); + dbus_connection_unregister_object_path(conn_, kAgentPath); + } + if (conn_) + dbus_connection_unref(conn_); + } + + cp0_bt_status_t status() + { + ManagedSnapshot snapshot; + if (!get_managed_objects(snapshot)) + return {}; + return snapshot.status; + } + + int set_power(int on) + { + std::string adapter = adapter_path(); + if (adapter.empty()) + return -1; + dbus_bool_t powered = on ? TRUE : FALSE; + return set_property_bool(adapter.c_str(), kAdapterIface, "Powered", powered) ? 0 : -1; + } + + int set_discoverable(int on) + { + std::string adapter = adapter_path(); + if (adapter.empty()) + return -1; + dbus_bool_t discoverable = on ? TRUE : FALSE; + return set_property_bool(adapter.c_str(), kAdapterIface, "Discoverable", discoverable) ? 0 : -1; + } + + int set_alias(const char *alias) + { + if (!alias || !alias[0]) + return -1; + std::string adapter = adapter_path(); + if (adapter.empty()) + return -1; + return set_property_string(adapter.c_str(), kAdapterIface, "Alias", alias) ? 0 : -1; + } + + int start_discovery() + { + ManagedSnapshot snapshot; + if (!get_managed_objects(snapshot) || snapshot.adapter_path.empty()) + return -1; + if (!snapshot.status.powered) { + cp0_zmq_log("bt", "scan on rejected: adapter is powered off"); + return -1; + } + return call_no_args(snapshot.adapter_path.c_str(), kAdapterIface, "StartDiscovery") ? 0 : -1; + } + + int stop_discovery() + { + std::string adapter = adapter_path(); + if (adapter.empty()) + return 0; + return call_no_args(adapter.c_str(), kAdapterIface, "StopDiscovery") ? 0 : -1; + } + + int list(cp0_bt_device_t *out, int max_devices, bool connected_only) + { + if (!out || max_devices <= 0) + return 0; + ManagedSnapshot snapshot; + if (!get_managed_objects(snapshot)) + return -1; + int count = 0; + for (const DeviceInfo &info : snapshot.devices) { + if (count >= max_devices) + break; + if (!info.dev.address[0]) + continue; + if (connected_only && !info.dev.connected) + continue; + out[count++] = info.dev; + } + return count; + } + + int pair(const char *address) + { + if (!is_bt_address(address)) + return -1; + ensure_agent(); + return device_method(address, "Pair"); + } + + int connect(const char *address) + { + if (!is_bt_address(address)) + return -1; + ensure_agent(); + return device_method(address, "Connect"); + } + + int disconnect(const char *address) + { + if (!is_bt_address(address)) + return -1; + return device_method(address, "Disconnect"); + } + + int remove(const char *address) + { + if (!is_bt_address(address)) + return -1; + ManagedSnapshot snapshot; + if (!get_managed_objects(snapshot) || snapshot.adapter_path.empty()) + return -1; + std::string device = find_device_path(snapshot, address); + if (device.empty()) + return -1; + + ScopedMessage msg(dbus_message_new_method_call(kBluezService, snapshot.adapter_path.c_str(), + kAdapterIface, "RemoveDevice")); + if (!msg) + return -1; + DBusMessageIter iter; + dbus_message_iter_init_append(msg.get(), &iter); + const char *path = device.c_str(); + if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &path)) + return -1; + return send_blocking(msg.get()) ? 0 : -1; + } + +private: + DBusConnection *conn_ = nullptr; + bool object_registered_ = false; + bool agent_registered_ = false; + std::mutex mutex_; + + bool ensure_connection() + { + if (conn_) + return true; + ScopedError err; + conn_ = dbus_bus_get(DBUS_BUS_SYSTEM, err.get()); + if (err.is_set() || !conn_) { + cp0_zmq_logf("bt", "system dbus connect failed: %s", err.message()); + return false; + } + dbus_connection_set_exit_on_disconnect(conn_, FALSE); + return true; + } + + DBusMessage *send_with_reply(DBusMessage *msg) + { + if (!ensure_connection() || !msg) + return nullptr; + ScopedError err; + DBusMessage *reply = dbus_connection_send_with_reply_and_block(conn_, msg, kCallTimeoutMs, err.get()); + if (err.is_set()) { + cp0_zmq_logf("bt", "dbus call %s failed: %s", dbus_message_get_member(msg), err.message()); + return nullptr; + } + return reply; + } + + bool send_blocking(DBusMessage *msg) + { + ScopedMessage reply(send_with_reply(msg)); + if (!reply) + return false; + return dbus_message_get_type(reply.get()) != DBUS_MESSAGE_TYPE_ERROR; + } + + bool call_no_args(const char *path, const char *iface, const char *method) + { + ScopedMessage msg(dbus_message_new_method_call(kBluezService, path, iface, method)); + return msg && send_blocking(msg.get()); + } + + bool set_property_bool(const char *path, const char *iface, const char *property, dbus_bool_t value) + { + ScopedMessage msg(dbus_message_new_method_call(kBluezService, path, kPropertiesIface, "Set")); + if (!msg) + return false; + DBusMessageIter iter; + dbus_message_iter_init_append(msg.get(), &iter); + const char *iface_ptr = iface; + const char *prop_ptr = property; + if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &iface_ptr) || + !dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &prop_ptr) || + !append_basic_variant(&iter, DBUS_TYPE_BOOLEAN_AS_STRING, DBUS_TYPE_BOOLEAN, &value)) + return false; + return send_blocking(msg.get()); + } + + bool set_property_string(const char *path, const char *iface, const char *property, const char *value) + { + ScopedMessage msg(dbus_message_new_method_call(kBluezService, path, kPropertiesIface, "Set")); + if (!msg) + return false; + DBusMessageIter iter; + dbus_message_iter_init_append(msg.get(), &iter); + const char *iface_ptr = iface; + const char *prop_ptr = property; + const char *value_ptr = value ? value : ""; + if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &iface_ptr) || + !dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &prop_ptr) || + !append_basic_variant(&iter, DBUS_TYPE_STRING_AS_STRING, DBUS_TYPE_STRING, &value_ptr)) + return false; + return send_blocking(msg.get()); + } + + std::string adapter_path() + { + ManagedSnapshot snapshot; + if (!get_managed_objects(snapshot)) + return {}; + return snapshot.adapter_path; + } + + int device_method(const char *address, const char *method) + { + ManagedSnapshot snapshot; + if (!get_managed_objects(snapshot)) + return -1; + std::string path = find_device_path(snapshot, address); + if (path.empty()) + return -1; + return call_no_args(path.c_str(), kDeviceIface, method) ? 0 : -1; + } + + static std::string find_device_path(const ManagedSnapshot &snapshot, const char *address) + { + std::string want = uppercase_address(address); + for (const DeviceInfo &info : snapshot.devices) { + if (uppercase_address(info.dev.address) == want) + return info.path; + } + return {}; + } + + bool get_managed_objects(ManagedSnapshot &snapshot) + { + std::lock_guard lock(mutex_); + if (!ensure_connection()) + return false; + + ScopedMessage msg(dbus_message_new_method_call(kBluezService, "/", kObjectManagerIface, + "GetManagedObjects")); + if (!msg) + return false; + ScopedMessage reply(send_with_reply(msg.get())); + if (!reply) + return false; + + DBusMessageIter iter; + if (!dbus_message_iter_init(reply.get(), &iter) || !iter_is_type(&iter, DBUS_TYPE_ARRAY)) + return false; + parse_objects(&iter, snapshot); + return !snapshot.adapter_path.empty(); + } + + static void parse_objects(DBusMessageIter *objects_iter, ManagedSnapshot &snapshot) + { + DBusMessageIter objects; + dbus_message_iter_recurse(objects_iter, &objects); + while (dbus_message_iter_get_arg_type(&objects) == DBUS_TYPE_DICT_ENTRY) { + DBusMessageIter object_entry; + dbus_message_iter_recurse(&objects, &object_entry); + + const char *path = nullptr; + dbus_message_iter_get_basic(&object_entry, &path); + dbus_message_iter_next(&object_entry); + if (iter_is_type(&object_entry, DBUS_TYPE_ARRAY)) + parse_interfaces(path ? path : "", &object_entry, snapshot); + + dbus_message_iter_next(&objects); + } + } + + static void parse_interfaces(const char *path, DBusMessageIter *ifaces_iter, ManagedSnapshot &snapshot) + { + DBusMessageIter ifaces; + dbus_message_iter_recurse(ifaces_iter, &ifaces); + while (dbus_message_iter_get_arg_type(&ifaces) == DBUS_TYPE_DICT_ENTRY) { + DBusMessageIter iface_entry; + dbus_message_iter_recurse(&ifaces, &iface_entry); + + const char *iface = nullptr; + dbus_message_iter_get_basic(&iface_entry, &iface); + dbus_message_iter_next(&iface_entry); + + if (iface && iter_is_type(&iface_entry, DBUS_TYPE_ARRAY)) { + if (!std::strcmp(iface, kAdapterIface)) { + if (snapshot.adapter_path.empty()) + snapshot.adapter_path = path; + parse_adapter_properties(&iface_entry, snapshot.status); + } else if (!std::strcmp(iface, kDeviceIface)) { + DeviceInfo info; + info.path = path; + parse_device_properties(&iface_entry, info.dev); + snapshot.devices.push_back(info); + } + } + + dbus_message_iter_next(&ifaces); + } + } + + static void parse_adapter_properties(DBusMessageIter *props_iter, cp0_bt_status_t &status) + { + DBusMessageIter props; + dbus_message_iter_recurse(props_iter, &props); + while (dbus_message_iter_get_arg_type(&props) == DBUS_TYPE_DICT_ENTRY) { + DBusMessageIter entry; + dbus_message_iter_recurse(&props, &entry); + const char *name = nullptr; + dbus_message_iter_get_basic(&entry, &name); + dbus_message_iter_next(&entry); + if (name && iter_is_type(&entry, DBUS_TYPE_VARIANT)) { + DBusMessageIter value; + dbus_message_iter_recurse(&entry, &value); + if (!std::strcmp(name, "Powered") && iter_is_type(&value, DBUS_TYPE_BOOLEAN)) { + dbus_bool_t powered = FALSE; + dbus_message_iter_get_basic(&value, &powered); + status.powered = powered ? 1 : 0; + } else if (!std::strcmp(name, "Address") && iter_is_type(&value, DBUS_TYPE_STRING)) { + const char *address = ""; + dbus_message_iter_get_basic(&value, &address); + copy_string(status.address, sizeof(status.address), address ? address : ""); + } else if (!std::strcmp(name, "Discoverable") && iter_is_type(&value, DBUS_TYPE_BOOLEAN)) { + dbus_bool_t discoverable = FALSE; + dbus_message_iter_get_basic(&value, &discoverable); + status.discoverable = discoverable ? 1 : 0; + } else if (!std::strcmp(name, "Alias") && iter_is_type(&value, DBUS_TYPE_STRING)) { + const char *alias = ""; + dbus_message_iter_get_basic(&value, &alias); + copy_string(status.alias, sizeof(status.alias), alias ? alias : ""); + } + } + dbus_message_iter_next(&props); + } + } + + static void parse_device_properties(DBusMessageIter *props_iter, cp0_bt_device_t &dev) + { + DBusMessageIter props; + dbus_message_iter_recurse(props_iter, &props); + while (dbus_message_iter_get_arg_type(&props) == DBUS_TYPE_DICT_ENTRY) { + DBusMessageIter entry; + dbus_message_iter_recurse(&props, &entry); + const char *name = nullptr; + dbus_message_iter_get_basic(&entry, &name); + dbus_message_iter_next(&entry); + if (name && iter_is_type(&entry, DBUS_TYPE_VARIANT)) { + DBusMessageIter value; + dbus_message_iter_recurse(&entry, &value); + parse_device_property(name, &value, dev); + } + dbus_message_iter_next(&props); + } + } + + static void parse_device_property(const char *name, DBusMessageIter *value, cp0_bt_device_t &dev) + { + int type = dbus_message_iter_get_arg_type(value); + if (!std::strcmp(name, "Address") && type == DBUS_TYPE_STRING) { + const char *address = ""; + dbus_message_iter_get_basic(value, &address); + copy_string(dev.address, sizeof(dev.address), address ? address : ""); + } else if ((!std::strcmp(name, "Name") || (!dev.name[0] && !std::strcmp(name, "Alias"))) && + type == DBUS_TYPE_STRING) { + const char *text = ""; + dbus_message_iter_get_basic(value, &text); + copy_string(dev.name, sizeof(dev.name), text ? text : ""); + } else if (!std::strcmp(name, "RSSI") && type == DBUS_TYPE_INT16) { + dbus_int16_t rssi = 0; + dbus_message_iter_get_basic(value, &rssi); + dev.rssi = rssi; + } else if (!std::strcmp(name, "Connected") && type == DBUS_TYPE_BOOLEAN) { + dbus_bool_t v = FALSE; + dbus_message_iter_get_basic(value, &v); + dev.connected = v ? 1 : 0; + } else if (!std::strcmp(name, "Paired") && type == DBUS_TYPE_BOOLEAN) { + dbus_bool_t v = FALSE; + dbus_message_iter_get_basic(value, &v); + dev.paired = v ? 1 : 0; + } else if (!std::strcmp(name, "Trusted") && type == DBUS_TYPE_BOOLEAN) { + dbus_bool_t v = FALSE; + dbus_message_iter_get_basic(value, &v); + dev.trusted = v ? 1 : 0; + } + } + + void ensure_agent() + { + std::lock_guard lock(mutex_); + if (!ensure_connection()) + return; + register_object_path(); + register_agent(); + } + + void register_object_path() + { + if (object_registered_) + return; + static DBusObjectPathVTable vtable = { + nullptr, + BluezDbusClient::agent_message, + nullptr, + nullptr, + nullptr, + nullptr, + }; + object_registered_ = dbus_connection_register_object_path(conn_, kAgentPath, &vtable, this); + if (!object_registered_) + cp0_zmq_log("bt", "register agent object path failed"); + } + + void register_agent() + { + if (agent_registered_ || !object_registered_) + return; + ScopedMessage msg(dbus_message_new_method_call(kBluezService, "/org/bluez", + kAgentManagerIface, "RegisterAgent")); + if (!msg) + return; + DBusMessageIter iter; + dbus_message_iter_init_append(msg.get(), &iter); + const char *path = kAgentPath; + const char *capability = "NoInputNoOutput"; + if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &path) || + !dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &capability)) + return; + if (send_blocking(msg.get())) { + agent_registered_ = true; + request_default_agent(); + } + } + + void request_default_agent() + { + ScopedMessage msg(dbus_message_new_method_call(kBluezService, "/org/bluez", + kAgentManagerIface, "RequestDefaultAgent")); + if (!msg) + return; + DBusMessageIter iter; + dbus_message_iter_init_append(msg.get(), &iter); + const char *path = kAgentPath; + if (dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &path)) + send_blocking(msg.get()); + } + + void unregister_agent() + { + ScopedMessage msg(dbus_message_new_method_call(kBluezService, "/org/bluez", + kAgentManagerIface, "UnregisterAgent")); + if (!msg) + return; + DBusMessageIter iter; + dbus_message_iter_init_append(msg.get(), &iter); + const char *path = kAgentPath; + if (dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &path)) + send_blocking(msg.get()); + agent_registered_ = false; + } + + static DBusHandlerResult agent_message(DBusConnection *connection, DBusMessage *message, void *) + { + const char *iface = dbus_message_get_interface(message); + const char *member = dbus_message_get_member(message); + if (!iface || std::strcmp(iface, kAgentIface) != 0 || !member) + return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; + + ScopedMessage reply(create_agent_reply(message, member)); + if (!reply) + return DBUS_HANDLER_RESULT_NEED_MEMORY; + dbus_connection_send(connection, reply.get(), nullptr); + dbus_connection_flush(connection); + return DBUS_HANDLER_RESULT_HANDLED; + } + + static DBusMessage *create_agent_reply(DBusMessage *message, const char *member) + { + if (!std::strcmp(member, "RequestPinCode")) { + DBusMessage *reply = dbus_message_new_method_return(message); + if (!reply) + return nullptr; + const char *pin = "0000"; + if (!dbus_message_append_args(reply, DBUS_TYPE_STRING, &pin, DBUS_TYPE_INVALID)) { + dbus_message_unref(reply); + return nullptr; + } + return reply; + } + if (!std::strcmp(member, "RequestPasskey")) { + DBusMessage *reply = dbus_message_new_method_return(message); + if (!reply) + return nullptr; + dbus_uint32_t passkey = 0; + if (!dbus_message_append_args(reply, DBUS_TYPE_UINT32, &passkey, DBUS_TYPE_INVALID)) { + dbus_message_unref(reply); + return nullptr; + } + return reply; + } + return dbus_message_new_method_return(message); + } +}; + +static BluezDbusClient &client() +{ + static BluezDbusClient c; + return c; +} + +} // namespace + +namespace cp0_bluez_dbus { + +cp0_bt_status_t status() { return client().status(); } +int set_power(int on) { return client().set_power(on); } +int set_alias(const char *alias) { return client().set_alias(alias); } +int set_discoverable(int on) { return client().set_discoverable(on); } +int start_discovery() { return client().start_discovery(); } +int stop_discovery() { return client().stop_discovery(); } +int list(cp0_bt_device_t *out, int max_devices, bool connected_only) +{ + return client().list(out, max_devices, connected_only); +} +int pair(const char *address) { return client().pair(address); } +int connect(const char *address) { return client().connect(address); } +int disconnect(const char *address) { return client().disconnect(address); } +int remove(const char *address) { return client().remove(address); } + +} // namespace cp0_bluez_dbus diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_bluez_dbus_client.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_bluez_dbus_client.hpp new file mode 100644 index 00000000..13c19c07 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_bluez_dbus_client.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +namespace cp0_bluez_dbus { + +cp0_bt_status_t status(); +int set_power(int enabled); +int set_alias(const char *alias); +int set_discoverable(int enabled); +int start_discovery(); +int stop_discovery(); +int list(cp0_bt_device_t *out, int max_devices, bool connected_only); +int pair(const char *address); +int connect(const char *address); +int disconnect(const char *address); +int remove(const char *address); + +} // namespace cp0_bluez_dbus diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_camera_frame_codec.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_camera_frame_codec.cpp new file mode 100644 index 00000000..86ee7c55 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_camera_frame_codec.cpp @@ -0,0 +1,157 @@ +#include "cp0_camera_frame_codec.hpp" + +#if CP0_CAMERA_HAS_LIBCAMERA + +#include +#include +#include + +#include +#include + +namespace cp0_camera_frame_codec { +namespace { + +void rgb565_to_rgb888(uint16_t pixel, uint8_t &red, uint8_t &green, uint8_t &blue) +{ + red = ((pixel >> 11) & 0x1F) << 3; + green = ((pixel >> 5) & 0x3F) << 2; + blue = (pixel & 0x1F) << 3; + red |= red >> 5; + green |= green >> 6; + blue |= blue >> 5; +} + +uint16_t rgb888_to_rgb565(uint8_t red, uint8_t green, uint8_t blue) +{ + return static_cast(((red & 0xF8) << 8) | + ((green & 0xFC) << 3) | + (blue >> 3)); +} + +} // namespace + +bool is_supported_preview_format(const libcamera::PixelFormat &format) +{ + return format == libcamera::formats::RGB888 || + format == libcamera::formats::BGR888 || + format == libcamera::formats::XRGB8888 || + format == libcamera::formats::XBGR8888 || + format == libcamera::formats::RGB565; +} + +bool convert_to_rgb888(const uint8_t *source, size_t bytes_used, int width, int height, + int stride, const libcamera::PixelFormat &format, + std::vector &rgb) +{ + if (!source || width <= 0 || height <= 0) + return false; + const bool is_rgb888 = format == libcamera::formats::RGB888; + const bool is_bgr888 = format == libcamera::formats::BGR888; + const bool is_xrgb8888 = format == libcamera::formats::XRGB8888; + const bool is_xbgr8888 = format == libcamera::formats::XBGR8888; + const bool is_rgb565 = format == libcamera::formats::RGB565; + const int bytes_per_pixel = is_rgb888 || is_bgr888 ? 3 : is_rgb565 ? 2 : 4; + const int minimum_stride = width * bytes_per_pixel; + const int row_stride = stride > 0 ? stride : minimum_stride; + if (row_stride < minimum_stride) + return false; + + rgb.assign(width * height * 3, 0); + for (int y = 0; y < height; ++y) { + const size_t row_offset = static_cast(y) * row_stride; + if (row_offset + minimum_stride > bytes_used) + return y > 0; + + const uint8_t *line = source + row_offset; + const int destination_y = height - 1 - y; + for (int x = 0; x < width; ++x) { + const int destination_x = width - 1 - x; + const int destination = (destination_y * width + destination_x) * 3; + uint8_t red = 0; + uint8_t green = 0; + uint8_t blue = 0; + if (is_rgb888) { + const uint8_t *pixel = line + x * 3; + red = pixel[0]; green = pixel[1]; blue = pixel[2]; + } else if (is_bgr888) { + const uint8_t *pixel = line + x * 3; + blue = pixel[0]; green = pixel[1]; red = pixel[2]; + } else if (is_xrgb8888) { + const uint8_t *pixel = line + x * 4; + blue = pixel[0]; green = pixel[1]; red = pixel[2]; + } else if (is_xbgr8888) { + const uint8_t *pixel = line + x * 4; + red = pixel[0]; green = pixel[1]; blue = pixel[2]; + } else if (is_rgb565) { + const uint8_t *pixel = line + x * 2; + rgb565_to_rgb888(static_cast(pixel[0] | (pixel[1] << 8)), + red, green, blue); + } + rgb[destination + 0] = red; + rgb[destination + 1] = green; + rgb[destination + 2] = blue; + } + } + return true; +} + +bool convert_to_rgb565(const uint8_t *source, size_t bytes_used, int width, int height, + int stride, const libcamera::PixelFormat &format, + std::vector &rgb565) +{ + std::vector rgb; + if (!convert_to_rgb888(source, bytes_used, width, height, stride, format, rgb)) + return false; + rgb565.assign(width * height, 0); + for (int i = 0; i < width * height; ++i) + rgb565[i] = rgb888_to_rgb565(rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]); + return true; +} + +std::string make_frame_payload(const std::vector &rgb565, int width, int height) +{ + char header[64]; + const int header_length = std::snprintf(header, sizeof(header), + "FRAME %d %d RGB565\n", width, height); + std::string payload(header, header_length); + payload.append(reinterpret_cast(rgb565.data()), + rgb565.size() * sizeof(uint16_t)); + return payload; +} + +bool save_jpeg_rgb888(const std::string &path, const uint8_t *rgb, int width, int height, + int quality) +{ + FILE *file = std::fopen(path.c_str(), "wb"); + if (!file) + return false; + + jpeg_compress_struct compressor; + jpeg_error_mgr error; + compressor.err = jpeg_std_error(&error); + jpeg_create_compress(&compressor); + jpeg_stdio_dest(&compressor, file); + compressor.image_width = width; + compressor.image_height = height; + compressor.input_components = 3; + compressor.in_color_space = JCS_RGB; + jpeg_set_defaults(&compressor); + jpeg_set_quality(&compressor, quality, TRUE); + jpeg_start_compress(&compressor, TRUE); + while (compressor.next_scanline < compressor.image_height) { + JSAMPROW row[] = { + const_cast(&rgb[compressor.next_scanline * width * 3]) + }; + jpeg_write_scanlines(&compressor, row, 1); + } + jpeg_finish_compress(&compressor); + jpeg_destroy_compress(&compressor); + std::fclose(file); + chmod(path.c_str(), 0666); + return true; +} + +} // namespace cp0_camera_frame_codec + +#endif diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_camera_frame_codec.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_camera_frame_codec.hpp new file mode 100644 index 00000000..b9ef0f9e --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_camera_frame_codec.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +#if __has_include() && __has_include() +#define CP0_CAMERA_HAS_LIBCAMERA 1 +#include + +namespace cp0_camera_frame_codec { + +bool is_supported_preview_format(const libcamera::PixelFormat &format); +bool convert_to_rgb888(const uint8_t *source, size_t bytes_used, int width, int height, + int stride, const libcamera::PixelFormat &format, + std::vector &rgb); +bool convert_to_rgb565(const uint8_t *source, size_t bytes_used, int width, int height, + int stride, const libcamera::PixelFormat &format, + std::vector &rgb565); +std::string make_frame_payload(const std::vector &rgb565, int width, int height); +bool save_jpeg_rgb888(const std::string &path, const uint8_t *rgb, int width, int height, + int quality = 90); + +} // namespace cp0_camera_frame_codec +#else +#define CP0_CAMERA_HAS_LIBCAMERA 0 +#endif diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_camera_viewport.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_camera_viewport.cpp new file mode 100644 index 00000000..25083273 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_camera_viewport.cpp @@ -0,0 +1,97 @@ +#include "cp0_camera_viewport.hpp" + +#include +#include +#include + +namespace cp0_camera { +namespace { + +int pan_position(int position, int steps) +{ + const long long moved = static_cast(position) + + static_cast(steps) * Viewport::PAN_STEP_PERCENT; + return static_cast(std::max(0LL, std::min(100LL, moved))); +} + +int scaled_dimension(int dimension, int zoom_percent) +{ + const long long scaled = static_cast(dimension) * 100 / zoom_percent; + return static_cast(std::max(1LL, std::min( + static_cast(std::numeric_limits::max()), + scaled))); +} + +int position_offset(int available, int position_percent) +{ + return static_cast(static_cast(available) * position_percent / 100); +} + +} // namespace + +void Viewport::zoom_in() +{ + zoom_percent_ = zoom_percent_ < MEDIUM_ZOOM_PERCENT ? MEDIUM_ZOOM_PERCENT : MAX_ZOOM_PERCENT; +} + +void Viewport::zoom_out() +{ + zoom_percent_ = zoom_percent_ > MEDIUM_ZOOM_PERCENT ? MEDIUM_ZOOM_PERCENT : DEFAULT_ZOOM_PERCENT; + if (zoom_percent_ == DEFAULT_ZOOM_PERCENT) + { + view_x_percent_ = CENTER_PERCENT; + view_y_percent_ = CENTER_PERCENT; + } +} + +void Viewport::pan(int horizontal_steps, int vertical_steps) +{ + if (zoom_percent_ == DEFAULT_ZOOM_PERCENT) + return; + + view_x_percent_ = pan_position(view_x_percent_, horizontal_steps); + view_y_percent_ = pan_position(view_y_percent_, vertical_steps); +} + +int Viewport::zoom_percent() const +{ + return zoom_percent_; +} + +int Viewport::view_x_percent() const +{ + return view_x_percent_; +} + +int Viewport::view_y_percent() const +{ + return view_y_percent_; +} + +std::string Viewport::status_text() const +{ + char buffer[96]; + std::snprintf(buffer, sizeof(buffer), "ZOOM %d %d %d\n", + zoom_percent_, view_x_percent_, view_y_percent_); + return std::string(buffer); +} + +CropRectangle Viewport::crop(const CropRectangle &bounds) const +{ + if (zoom_percent_ == DEFAULT_ZOOM_PERCENT || bounds.width <= 0 || bounds.height <= 0) + return bounds; + + const int crop_width = scaled_dimension(bounds.width, zoom_percent_); + const int crop_height = scaled_dimension(bounds.height, zoom_percent_); + const int available_x = std::max(0, bounds.width - crop_width); + const int available_y = std::max(0, bounds.height - crop_height); + + return { + bounds.x + position_offset(available_x, view_x_percent_), + bounds.y + position_offset(available_y, view_y_percent_), + crop_width, + crop_height, + }; +} + +} // namespace cp0_camera diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_camera_viewport.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_camera_viewport.hpp new file mode 100644 index 00000000..6b2a62b4 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_camera_viewport.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include + +namespace cp0_camera { + +struct CropRectangle +{ + int x = 0; + int y = 0; + int width = 0; + int height = 0; +}; + +class Viewport +{ +public: + static constexpr int DEFAULT_ZOOM_PERCENT = 100; + static constexpr int MEDIUM_ZOOM_PERCENT = 250; + static constexpr int MAX_ZOOM_PERCENT = 500; + static constexpr int CENTER_PERCENT = 50; + static constexpr int PAN_STEP_PERCENT = 8; + + void zoom_in(); + void zoom_out(); + void pan(int horizontal_steps, int vertical_steps); + + int zoom_percent() const; + int view_x_percent() const; + int view_y_percent() const; + std::string status_text() const; + CropRectangle crop(const CropRectangle &bounds) const; + +private: + int zoom_percent_ = DEFAULT_ZOOM_PERCENT; + int view_x_percent_ = CENTER_PERCENT; + int view_y_percent_ = CENTER_PERCENT; +}; + +} // namespace cp0_camera diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_desktop_exec_policy.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_desktop_exec_policy.cpp new file mode 100644 index 00000000..0bbec613 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_desktop_exec_policy.cpp @@ -0,0 +1,69 @@ +#include "cp0_desktop_exec_policy.hpp" + +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#endif + +namespace cp0_desktop_exec_policy { +namespace { + +bool contains_shell_meta(const char *text) +{ + static const char *kShellMetacharacters = "|&;<>`$\\\n\r"; + return !text || std::strpbrk(text, kShellMetacharacters) != nullptr; +} + +std::string first_token(const char *exec) +{ + std::istringstream stream(exec ? exec : ""); + std::string token; + stream >> token; + return token; +} + +bool file_executable(const std::string &path) +{ +#if defined(_WIN32) + (void)path; + return false; +#else + struct stat status; + return stat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) && + access(path.c_str(), X_OK) == 0; +#endif +} + +Result reject(const char *reason) +{ + return {false, reason}; +} + +} // namespace + +Result evaluate(const char *exec) +{ + if (!exec || !exec[0]) return reject("empty Exec"); + if (std::strlen(exec) > 512) return reject("Exec too long"); + if (contains_shell_meta(exec)) return reject("Exec contains shell metacharacters"); + + const std::string token = first_token(exec); + if (token.empty()) return reject("missing executable"); + + if (token.find('/') != std::string::npos) { + return file_executable(token) ? Result{true, {}} + : reject("executable path is not executable"); + } + + static const char *kAllowedNames[] = {"bash", "python3", "vim", "vi", "nano", "sh"}; + const bool allowed = + std::find(std::begin(kAllowedNames), std::end(kAllowedNames), token) != + std::end(kAllowedNames); + return allowed ? Result{true, {}} : reject("executable name is not allowlisted"); +} + +} // namespace cp0_desktop_exec_policy diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_desktop_exec_policy.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_desktop_exec_policy.hpp new file mode 100644 index 00000000..aa3d2d5e --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_desktop_exec_policy.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace cp0_desktop_exec_policy { + +struct Result +{ + bool allowed; + std::string reason; +}; + +Result evaluate(const char *exec); + +} // namespace cp0_desktop_exec_policy diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_external_app_runner.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_external_app_runner.cpp new file mode 100644 index 00000000..1b29d8bb --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_external_app_runner.cpp @@ -0,0 +1,142 @@ +#include "cp0_external_app_runner.hpp" + +#include "../cp0_external_process_group.hpp" +#include "cp0_process_commands.hpp" + +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#include +#endif + +extern "C" void __attribute__((weak)) keyboard_pause(void) {} +extern "C" void __attribute__((weak)) keyboard_resume(void) {} + +namespace cp0_external_app_runner { +namespace { + +const char *keyboard_device() +{ + const char *configured = std::getenv("APPLAUNCH_LINUX_KEYBOARD_DEVICE"); + return configured ? configured : "/dev/input/by-path/platform-3f804000.i2c-event"; +} + +} // namespace + +int run(const char *command, volatile int *home_key_flag, bool keep_root) +{ +#if defined(_WIN32) + (void)command; + (void)home_key_flag; + (void)keep_root; + return -1; +#else + keyboard_pause(); + const bool subreaper = cp0_process_group::enable_subreaper(); + + const int keyboard_fd = open(keyboard_device(), O_RDONLY | O_NONBLOCK); + if (keyboard_fd < 0) { + std::perror("[cp0] open evdev"); + keyboard_resume(); + return -1; + } + std::printf("[cp0] Opened evdev %s (no EVIOCGRAB; shared with child)\n", keyboard_device()); + std::fflush(stdout); + + const pid_t pid = fork(); + if (pid < 0) { + close(keyboard_fd); + keyboard_resume(); + return -1; + } + if (pid == 0) { + close(keyboard_fd); + setpgid(0, 0); + if (keep_root) + execlp("/bin/sh", "sh", "-c", command, static_cast(nullptr)); + else + cp0_process_commands::exec_shell_as_configured_user(command); + _exit(127); + } + + setpgid(pid, pid); + std::fprintf(stderr, + "[process] external app leader=%d pgid=%d subreaper=%d\n", + static_cast(pid), + static_cast(pid), + subreaper ? 1 : 0); + + auto esc_down_since = std::chrono::steady_clock::time_point{}; + auto term_start = std::chrono::steady_clock::time_point{}; + bool esc_down = false; + bool raw_esc_down = false; + bool term_sent = false; + bool kill_sent = false; + bool leader_reaped = false; + int status = 0; + + while (true) { + cp0_process_group::reap_available(pid, pid, status, leader_reaped); + if (!cp0_process_group::exists(pid)) break; + + struct input_event event; + while (read(keyboard_fd, &event, sizeof(event)) == static_cast(sizeof(event))) { + if (event.type == EV_KEY && event.code == KEY_ESC) { + if (event.value == 1) + raw_esc_down = true; + else if (event.value == 0) + raw_esc_down = false; + } + } + + const bool esc_now = raw_esc_down || (home_key_flag && *home_key_flag); + if (esc_now && !esc_down) { + esc_down = true; + esc_down_since = std::chrono::steady_clock::now(); + } else if (!esc_now) { + esc_down = false; + } + + if (esc_down && !term_sent) { + const auto held_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - esc_down_since) + .count(); + if (held_ms >= 3000) { + term_sent = true; + term_start = std::chrono::steady_clock::now(); + std::fprintf(stderr, "[process] ESC timeout: SIGTERM pgid=%d\n", static_cast(pid)); + killpg(pid, SIGTERM); + } + } + if (term_sent && !kill_sent && + std::chrono::duration_cast( + std::chrono::steady_clock::now() - term_start) + .count() >= 2) { + kill_sent = true; + std::fprintf(stderr, "[process] grace timeout: SIGKILL pgid=%d\n", static_cast(pid)); + killpg(pid, SIGKILL); + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + cp0_process_group::reap_available(pid, pid, status, leader_reaped); + std::fprintf(stderr, + "[process] external app group drained pgid=%d leader_reaped=%d\n", + static_cast(pid), + leader_reaped ? 1 : 0); + close(keyboard_fd); + keyboard_resume(); + std::printf("[cp0] Returned to launcher\n"); + std::fflush(stdout); + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +#endif +} + +} // namespace cp0_external_app_runner diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_external_app_runner.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_external_app_runner.hpp new file mode 100644 index 00000000..365bef89 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_external_app_runner.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace cp0_external_app_runner { + +int run(const char *command, volatile int *home_key_flag, bool keep_root); + +} // namespace cp0_external_app_runner diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_framebuffer_codec.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_framebuffer_codec.cpp new file mode 100644 index 00000000..482de232 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_framebuffer_codec.cpp @@ -0,0 +1,124 @@ +#include "cp0_framebuffer_codec.hpp" + +#include +#include + +namespace cp0::framebuffer { +namespace { + +bool checked_add(size_t lhs, size_t rhs, size_t &result) +{ + if (lhs > std::numeric_limits::max() - rhs) return false; + result = lhs + rhs; + return true; +} + +bool checked_multiply(size_t lhs, size_t rhs, size_t &result) +{ + if (lhs != 0 && rhs > std::numeric_limits::max() / lhs) return false; + result = lhs * rhs; + return true; +} + +uint8_t expand_channel(uint32_t pixel, Channel channel) +{ + if (channel.length == 0 || channel.length > 8 || channel.offset >= 32 || + channel.length > 32 - channel.offset) + return 0; + const uint32_t maximum = (uint32_t {1} << channel.length) - 1; + return static_cast(((pixel >> channel.offset) & maximum) * 255 / maximum); +} + +} // namespace + +bool encode_ppm(const uint8_t *data, + size_t data_size, + const Layout &layout, + std::vector &out, + std::string &error) +{ + out.clear(); + error.clear(); + if (!data || layout.width == 0 || layout.height == 0 || + (layout.bits_per_pixel != 16 && layout.bits_per_pixel != 32)) { + error = "unsupported framebuffer format"; + return false; + } + + const size_t pixel_bytes = layout.bits_per_pixel / 8; + size_t row_pixels_bytes = 0; + size_t x_offset_bytes = 0; + if (!checked_multiply(layout.width, pixel_bytes, row_pixels_bytes) || + !checked_multiply(layout.x_offset, pixel_bytes, x_offset_bytes) || + x_offset_bytes > layout.line_length || + row_pixels_bytes > layout.line_length - x_offset_bytes) { + error = "framebuffer bounds mismatch"; + return false; + } + + size_t last_source_y = 0; + size_t last_row_offset = 0; + if (!checked_add(layout.y_offset, layout.height - 1, last_source_y) || + !checked_multiply(last_source_y, layout.line_length, last_row_offset) || + !checked_add(last_row_offset, x_offset_bytes, last_row_offset) || + last_row_offset > data_size || row_pixels_bytes > data_size - last_row_offset) { + error = "framebuffer bounds mismatch"; + return false; + } + + size_t pixel_count = 0; + size_t output_bytes = 0; + if (!checked_multiply(layout.width, layout.height, pixel_count) || + !checked_multiply(pixel_count, size_t {3}, output_bytes)) { + error = "framebuffer dimensions overflow"; + return false; + } + const std::string header = "P6\n" + std::to_string(layout.width) + " " + + std::to_string(layout.height) + "\n255\n"; + size_t total_size = 0; + if (!checked_add(header.size(), output_bytes, total_size)) { + error = "framebuffer dimensions overflow"; + return false; + } + + out.assign(header.begin(), header.end()); + out.resize(total_size); + uint8_t *destination = out.data() + header.size(); + for (uint32_t y = 0; y < layout.height; ++y) { + size_t source_y = 0; + size_t row_offset = 0; + if (!checked_add(y, layout.y_offset, source_y) || + !checked_multiply(source_y, layout.line_length, row_offset) || + !checked_add(row_offset, x_offset_bytes, row_offset) || + row_offset > data_size || row_pixels_bytes > data_size - row_offset) { + out.clear(); + error = "framebuffer bounds mismatch"; + return false; + } + const uint8_t *row = data + row_offset; + for (uint32_t x = 0; x < layout.width; ++x) { + uint8_t red = 0; + uint8_t green = 0; + uint8_t blue = 0; + if (layout.bits_per_pixel == 16) { + uint16_t pixel = 0; + std::memcpy(&pixel, row + x * 2, sizeof(pixel)); + red = static_cast(((pixel >> 11) & 0x1f) * 255 / 31); + green = static_cast(((pixel >> 5) & 0x3f) * 255 / 63); + blue = static_cast((pixel & 0x1f) * 255 / 31); + } else { + uint32_t pixel = 0; + std::memcpy(&pixel, row + x * 4, sizeof(pixel)); + red = expand_channel(pixel, layout.red); + green = expand_channel(pixel, layout.green); + blue = expand_channel(pixel, layout.blue); + } + *destination++ = red; + *destination++ = green; + *destination++ = blue; + } + } + return true; +} + +} // namespace cp0::framebuffer diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_framebuffer_codec.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_framebuffer_codec.hpp new file mode 100644 index 00000000..73405def --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_framebuffer_codec.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +namespace cp0::framebuffer { + +struct Channel +{ + uint8_t offset = 0; + uint8_t length = 0; +}; + +struct Layout +{ + uint32_t width = 0; + uint32_t height = 0; + uint32_t x_offset = 0; + uint32_t y_offset = 0; + uint32_t bits_per_pixel = 0; + size_t line_length = 0; + Channel red; + Channel green; + Channel blue; +}; + +bool encode_ppm(const uint8_t *data, + size_t data_size, + const Layout &layout, + std::vector &out, + std::string &error); + +} // namespace cp0::framebuffer diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_keymap.c b/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_keymap.c new file mode 100644 index 00000000..1bccd98a --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_keymap.c @@ -0,0 +1,141 @@ +#include "cp0_keyboard_keymap.h" + +#include "input_keys.h" +#include "../../../../SDK/components/utilities/include/sample_log.h" + +#include +#include +#include +#include +#include +#include + +#define TCA8418_KEYMAP_PATH "/usr/share/keymaps/tca8418_keypad_m5stack_keymap.map" +#define TCA8418_KEYMAP_MAX_ENTRIES 64 + +struct runtime_keymap_entry { + uint32_t keycode; + char sym_name[64]; + char utf8[16]; +}; + +static const struct cp0_keyboard_keymap_entry default_keymap[] = { + {26, "exclam", "!"}, + {27, "at", "@"}, + {39, "numbersign", "#"}, + {40, "dollar", "$"}, + {41, "percent", "%"}, + {43, "asciicircum", "^"}, + {51, "ampersand", "&"}, + {52, "asterisk", "*"}, + {53, "parenleft", "("}, + {54, "parenright", ")"}, + {55, "asciitilde", "~"}, + {69, "grave", "`"}, + {70, "underscore", "_"}, + {71, "minus", "-"}, + {72, "plus", "+"}, + {73, "equal", "="}, + {74, "bracketleft", "["}, + {75, "bracketright", "]"}, + {76, "braceleft", "{"}, + {77, "braceright", "}"}, + {79, "semicolon", ";"}, + {80, "colon", ":"}, + {81, "apostrophe", "'"}, + {82, "quotedbl", "\""}, + {83, "less", "<"}, + {85, "greater", ">"}, + {86, "backslash", "\\"}, + {89, "bar", "|"}, + {90, "comma", ","}, + {91, "period", "."}, + {92, "slash", "/"}, + {93, "question", "?"}, +}; + +static struct runtime_keymap_entry runtime_keymap[TCA8418_KEYMAP_MAX_ENTRIES]; +static size_t runtime_keymap_size; +static int runtime_keymap_loaded; + +static char *trim_ascii(char *text) +{ + while (isspace((unsigned char)*text)) + ++text; + char *end = text + strlen(text); + while (end > text && isspace((unsigned char)end[-1])) + *--end = '\0'; + return text; +} + +static int parse_keymap_line(char *line, struct runtime_keymap_entry *out) +{ + char *comment = strchr(line, '#'); + if (comment) + *comment = '\0'; + + char *body = trim_ascii(line); + if (body[0] == '\0') + return 0; + + uint32_t keycode = 0; + char sym_name[sizeof(out->sym_name)] = {0}; + if (sscanf(body, "keycode %u = %63s", &keycode, sym_name) != 2) + return 0; + + out->keycode = keycode; + snprintf(out->sym_name, sizeof(out->sym_name), "%s", sym_name); + xkb_keysym_t sym = xkb_keysym_from_name(out->sym_name, XKB_KEYSYM_NO_FLAGS); + if (sym == XKB_KEY_NoSymbol || + xkb_keysym_to_utf8(sym, out->utf8, sizeof(out->utf8)) <= 0) + out->utf8[0] = '\0'; + return 1; +} + +void cp0_keyboard_keymap_load(void) +{ + if (access(TCA8418_KEYMAP_PATH, F_OK) != 0) + return; + + FILE *file = fopen(TCA8418_KEYMAP_PATH, "r"); + if (!file) { + SLOGW("[KBD] failed to open %s: %s; using built-in keymap", + TCA8418_KEYMAP_PATH, strerror(errno)); + return; + } + + char line[256]; + size_t count = 0; + while (count < TCA8418_KEYMAP_MAX_ENTRIES && fgets(line, sizeof(line), file)) { + struct runtime_keymap_entry entry = {0}; + if (parse_keymap_line(line, &entry)) + runtime_keymap[count++] = entry; + } + fclose(file); + + runtime_keymap_size = count; + runtime_keymap_loaded = 1; + SLOGI("[KBD] loaded %zu TCA8418 keymap entries from %s", + runtime_keymap_size, TCA8418_KEYMAP_PATH); +} + +const struct cp0_keyboard_keymap_entry *cp0_keyboard_keymap_lookup(uint32_t keycode) +{ + static struct cp0_keyboard_keymap_entry mapped; + if (runtime_keymap_loaded) { + for (size_t i = 0; i < runtime_keymap_size; ++i) { + if (runtime_keymap[i].keycode == keycode) { + mapped.keycode = runtime_keymap[i].keycode; + mapped.sym_name = runtime_keymap[i].sym_name; + mapped.utf8 = runtime_keymap[i].utf8; + return &mapped; + } + } + return NULL; + } + + for (size_t i = 0; i < sizeof(default_keymap) / sizeof(default_keymap[0]); ++i) + if (default_keymap[i].keycode == keycode) + return &default_keymap[i]; + return NULL; +} diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_keymap.h b/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_keymap.h new file mode 100644 index 00000000..533b3d50 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_keymap.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include "../cp0_keyboard_key_contract.h" + +struct cp0_keyboard_keymap_entry { + uint32_t keycode; + const char *sym_name; + const char *utf8; +}; + +void cp0_keyboard_keymap_load(void); +const struct cp0_keyboard_keymap_entry *cp0_keyboard_keymap_lookup(uint32_t keycode); diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_lvgl_input.c b/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_lvgl_input.c new file mode 100644 index 00000000..bcc50411 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_lvgl_input.c @@ -0,0 +1,123 @@ +#include "cp0_keyboard_lvgl_input.h" +#include "../cp0_keyboard_queue.h" +#include "../cp0_keyboard_input_lifecycle.h" + +#include "input_keys.h" +#include "keyboard_input.h" +#include "lvgl/lvgl.h" +#include "../../../../SDK/components/utilities/include/sample_log.h" + +#include +#include + +#undef SLOGD +#define SLOGD(...) do { } while (0) + +static cp0_keyboard_key_handler_t global_key_handler; +static cp0_keyboard_input_lifecycle_t keypad_lifecycle; + +static void keypad_delete_cb(lv_event_t *event) +{ + cp0_keyboard_input_handle_deleted( + &keypad_lifecycle, lv_event_get_user_data(event)); +} + +__attribute__((weak)) void ui_global_hint_on_key(const struct key_item *item) +{ + (void)item; +} + +__attribute__((weak)) int ui_screensaver_filter_key(const struct key_item *item) +{ + (void)item; + return 0; +} + +void cp0_keyboard_set_global_key_handler(cp0_keyboard_key_handler_t handler) +{ + global_key_handler = handler; +} + +static uint32_t lvgl_key_from_evdev(uint16_t code) +{ + switch (code) { + case KEY_UP: return LV_KEY_UP; + case KEY_DOWN: return LV_KEY_DOWN; + case KEY_RIGHT: return LV_KEY_RIGHT; + case KEY_LEFT: return LV_KEY_LEFT; + case KEY_ESC: return LV_KEY_ESC; + case KEY_DELETE: return LV_KEY_DEL; + case KEY_BACKSPACE: return LV_KEY_BACKSPACE; + case KEY_ENTER: return LV_KEY_ENTER; + case KEY_NEXT: return LV_KEY_NEXT; + case KEY_TAB: return KEY_TAB; + case KEY_PREVIOUS: return LV_KEY_PREV; + case KEY_HOME: return LV_KEY_HOME; + case KEY_END: return LV_KEY_END; + default: return code; + } +} + +static void keypad_read(lv_indev_t *indev, lv_indev_data_t *data) +{ + (void)indev; + data->key = 0; + data->state = LV_INDEV_STATE_RELEASED; + data->continue_reading = false; + + struct key_item *item = cp0_keyboard_queue_pop(); + if (item) { + + char utf8_debug[64] = ""; + int debug_length = 0; + for (int i = 0; i < (int)sizeof(item->utf8) && item->utf8[i] && debug_length < 60; ++i) { + unsigned char value = (unsigned char)item->utf8[i]; + if (value >= 0x20 && value < 0x7f) + utf8_debug[debug_length++] = (char)value; + else + debug_length += snprintf(utf8_debug + debug_length, + sizeof(utf8_debug) - (size_t)debug_length, + "\\x%02x", value); + } + utf8_debug[debug_length] = '\0'; + SLOGD("[INDEV] dequeue code=%u state=%s sym=%s utf8='%s' cp=0x%x active_screen=%p", + item->key_code, kbd_state_name(item->key_state), item->sym_name, + utf8_debug, item->codepoint, (void *)lv_screen_active()); + + int swallowed = ui_screensaver_filter_key(item); + if (!swallowed) { + lv_obj_t *root = lv_screen_active(); + if (root) + lv_obj_send_event(root, (lv_event_code_t)LV_EVENT_KEYBOARD, item); + if (global_key_handler) + global_key_handler(item); + else + ui_global_hint_on_key(item); + + data->key = lvgl_key_from_evdev((uint16_t)item->key_code); + if (data->key) + data->state = (lv_indev_state_t)item->key_state; + } + if (data->key || swallowed) + data->continue_reading = cp0_keyboard_queue_has_data(); + free(item); + } +} + +void cp0_keyboard_create_lvgl_input_devices(void) +{ +#if LV_USE_EVDEV + const char *mouse_device = getenv("LV_LINUX_MOUSE_DEVICE"); + if (mouse_device && mouse_device[0] != '\0') + lv_evdev_create(LV_INDEV_TYPE_POINTER, mouse_device); +#endif + + if (!cp0_keyboard_input_begin_create(&keypad_lifecycle)) return; + lv_indev_t *indev = lv_indev_create(); + if (indev) { + lv_indev_set_type(indev, LV_INDEV_TYPE_KEYPAD); + lv_indev_set_read_cb(indev, keypad_read); + lv_indev_add_event_cb(indev, keypad_delete_cb, LV_EVENT_DELETE, indev); + } + cp0_keyboard_input_finish_create(&keypad_lifecycle, indev); +} diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_lvgl_input.h b/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_lvgl_input.h new file mode 100644 index 00000000..cc9bfa13 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_keyboard_lvgl_input.h @@ -0,0 +1,3 @@ +#pragma once + +void cp0_keyboard_create_lvgl_input_devices(void); diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_backend.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_backend.hpp new file mode 100644 index 00000000..bb02b07d --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_backend.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +namespace cp0_lora_backend { + +bool initialize(); +void poll(); +void get_info(cp0_lora_info_t *info, bool drain_events); +bool send_text(const char *payload); +void start_receive(); +void set_tx_mode(bool enabled); +void shutdown(); + +} // namespace cp0_lora_backend diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_device_discovery.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_device_discovery.cpp new file mode 100644 index 00000000..3f1fb1b9 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_device_discovery.cpp @@ -0,0 +1,54 @@ +#include "cp0_lora_device_discovery.hpp" + +#include +#include +#include + +namespace cp0_lora_device_discovery { + +size_t collect_spi_candidates(char out[][64], size_t max_count, const char *preferred) +{ + if (out == nullptr || max_count == 0) return 0; + + size_t count = 0; + auto append_candidate = [&](const char *path) { + if (path == nullptr || path[0] == '\0') return; + for (size_t i = 0; i < count; ++i) { + if (strcmp(out[i], path) == 0) return; + } + if (count < max_count) { + snprintf(out[count], 64, "%s", path); + ++count; + } + }; + + append_candidate(preferred); + append_candidate("/dev/spidev0.1"); + append_candidate("/dev/spidev0.0"); + + DIR *dir = opendir("/dev"); + if (dir != nullptr) { + while (dirent *entry = readdir(dir)) { + if (strncmp(entry->d_name, "spidev", 6) != 0) continue; + char full_path[64]; + constexpr char dev_prefix[] = "/dev/"; + const size_t prefix_len = strlen(dev_prefix); + const size_t name_len = strlen(entry->d_name); + if (name_len >= sizeof(full_path) - prefix_len) continue; + memcpy(full_path, dev_prefix, prefix_len); + memcpy(full_path + prefix_len, entry->d_name, name_len + 1); + append_candidate(full_path); + } + closedir(dir); + } + + static const char *fallbacks[] = { + "/dev/spidev0.1", "/dev/spidev0.0", "/dev/spidev1.0", "/dev/spidev1.1", + "/dev/spidev2.0", "/dev/spidev2.1", "/dev/spidev3.0", "/dev/spidev3.1", + "/dev/spidev4.0", "/dev/spidev4.1", + }; + for (const char *fallback : fallbacks) append_candidate(fallback); + return count; +} + +} // namespace cp0_lora_device_discovery diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_device_discovery.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_device_discovery.hpp new file mode 100644 index 00000000..af04620e --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_device_discovery.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace cp0_lora_device_discovery { + +size_t collect_spi_candidates(char out[][64], size_t max_count, const char *preferred); + +} // namespace cp0_lora_device_discovery diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio.cpp new file mode 100644 index 00000000..30b39c45 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio.cpp @@ -0,0 +1,314 @@ +#include "cp0_lora_gpio.hpp" +#include "cp0_lora_gpio_offset_policy.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#if __has_include() +#include +#include +#define CP0_LORA_GPIO_HAS_CDEV 1 +#else +#define CP0_LORA_GPIO_HAS_CDEV 0 +#endif + +#ifndef SLOGI +#define SLOGI(fmt, ...) std::printf("[cp0_lora] " fmt "\n", ##__VA_ARGS__) +#endif + +namespace cp0_lora_backend { +namespace { + +int write_text_file(const char *path, const char *value) +{ + int fd = open(path, O_WRONLY); + if (fd < 0) return -1; + const ssize_t result = write(fd, value, strlen(value)); + close(fd); + return result < 0 ? -1 : 0; +} + +int export_if_needed(int gpio) +{ + char path[64]; + snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/value", gpio); + if (access(path, F_OK) == 0) return 0; + char gpio_text[16]; + snprintf(gpio_text, sizeof(gpio_text), "%d", gpio); + if (write_text_file("/sys/class/gpio/export", gpio_text) < 0 && errno != EBUSY) return -1; + usleep(100000); + return 0; +} + +int set_direction(int gpio, const char *direction) +{ + char path[64]; + snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/direction", gpio); + return write_text_file(path, direction); +} + +int init_input(int gpio) +{ + return export_if_needed(gpio) < 0 || set_direction(gpio, "in") < 0 ? -1 : 0; +} + +int set_value(int gpio, int value) +{ + char path[64]; + snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/value", gpio); + return write_text_file(path, value ? "1" : "0"); +} + +int init_input_irq_sysfs(int gpio, int *line_fd) +{ + if (line_fd == nullptr || init_input(gpio) < 0) return -1; + char edge_path[64]; + snprintf(edge_path, sizeof(edge_path), "/sys/class/gpio/gpio%d/edge", gpio); + if (write_text_file(edge_path, "rising") < 0) return -1; + char value_path[64]; + snprintf(value_path, sizeof(value_path), "/sys/class/gpio/gpio%d/value", gpio); + int fd = open(value_path, O_RDONLY | O_NONBLOCK); + if (fd < 0) return -1; + char dummy = 0; + lseek(fd, 0, SEEK_SET); + (void)read(fd, &dummy, 1); + *line_fd = fd; + return 0; +} + +#if CP0_LORA_GPIO_HAS_CDEV +bool open_input_line(const char *chip_path, int offset, int *line_fd) +{ + if (chip_path == nullptr || line_fd == nullptr) return false; + int chip_fd = open(chip_path, O_RDONLY); + if (chip_fd < 0) return false; + gpiohandle_request request{}; + request.lines = 1; + request.lineoffsets[0] = (uint32_t)offset; + request.flags = GPIOHANDLE_REQUEST_INPUT; + snprintf(request.consumer_label, sizeof(request.consumer_label), "applaunch-lora-in"); + const bool ok = ioctl(chip_fd, GPIO_GET_LINEHANDLE_IOCTL, &request) == 0; + close(chip_fd); + if (ok) *line_fd = request.fd; + return ok; +} + +bool get_input_line_value(int line_fd, int *value) +{ + if (line_fd < 0 || value == nullptr) return false; + gpiohandle_data data{}; + if (ioctl(line_fd, GPIOHANDLE_GET_LINE_VALUES_IOCTL, &data) < 0) return false; + *value = data.values[0] ? 1 : 0; + return true; +} + +bool open_input_event_line(const char *chip_path, int offset, int *line_fd) +{ + if (chip_path == nullptr || line_fd == nullptr) return false; + int chip_fd = open(chip_path, O_RDONLY); + if (chip_fd < 0) return false; + gpioevent_request request{}; + request.lineoffset = (uint32_t)offset; + request.handleflags = GPIOHANDLE_REQUEST_INPUT; + request.eventflags = GPIOEVENT_REQUEST_RISING_EDGE; + snprintf(request.consumer_label, sizeof(request.consumer_label), "applaunch-lora-irq"); + const bool ok = ioctl(chip_fd, GPIO_GET_LINEEVENT_IOCTL, &request) == 0; + close(chip_fd); + if (!ok) return false; + *line_fd = request.fd; + (void)fcntl(*line_fd, F_SETFL, fcntl(*line_fd, F_GETFL, 0) | O_NONBLOCK); + return true; +} + +bool line_name_matches(const char *name) +{ + static const char *names[] = {"G5_HAT_5VOUT_EN", "HAT_5VOUT_EN", "PG5", "G5"}; + if (name == nullptr || name[0] == '\0') return false; + for (const char *candidate : names) { + if (strcmp(name, candidate) == 0) return true; + } + return false; +} +#endif + +} // namespace + +int gpio_init_output(int gpio, int value) +{ + if (export_if_needed(gpio) < 0) return -1; + if (set_direction(gpio, value ? "high" : "low") == 0) return 0; + if (set_direction(gpio, "out") < 0) return -1; + return set_value(gpio, value); +} + +int gpio_get_value(int gpio) +{ + char path[64]; + snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/value", gpio); + int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + char value = '0'; + const ssize_t result = read(fd, &value, 1); + close(fd); + return result <= 0 ? -1 : (value == '0' ? 0 : 1); +} + +bool gpio_find_named_line(char *chip_path, size_t chip_path_size, int *offset) +{ +#if CP0_LORA_GPIO_HAS_CDEV + if (chip_path == nullptr || chip_path_size == 0 || offset == nullptr) return false; + for (int chip_index = 0; chip_index < 8; ++chip_index) { + char path[64]; + snprintf(path, sizeof(path), "/dev/gpiochip%d", chip_index); + int chip_fd = open(path, O_RDONLY); + if (chip_fd < 0) continue; + gpiochip_info chip_info{}; + if (ioctl(chip_fd, GPIO_GET_CHIPINFO_IOCTL, &chip_info) == 0) { + for (int line = 0; line < (int)chip_info.lines; ++line) { + gpioline_info line_info{}; + line_info.line_offset = line; + if (ioctl(chip_fd, GPIO_GET_LINEINFO_IOCTL, &line_info) < 0) continue; + if (line_name_matches(line_info.name) || line_name_matches(line_info.consumer)) { + snprintf(chip_path, chip_path_size, "%s", path); + *offset = line; + close(chip_fd); + return true; + } + } + } + close(chip_fd); + } +#else + (void)chip_path; + (void)chip_path_size; + (void)offset; +#endif + return false; +} + +bool gpio_open_output_line(const char *chip_path, int offset, int value, int *line_fd) +{ +#if CP0_LORA_GPIO_HAS_CDEV + if (chip_path == nullptr || line_fd == nullptr) return false; + int chip_fd = open(chip_path, O_RDONLY); + if (chip_fd < 0) return false; + gpiohandle_request request{}; + request.lines = 1; + request.lineoffsets[0] = (uint32_t)offset; + request.flags = GPIOHANDLE_REQUEST_OUTPUT; + request.default_values[0] = (uint8_t)(value ? 1 : 0); + snprintf(request.consumer_label, sizeof(request.consumer_label), "applaunch-lora-5v"); + const bool ok = ioctl(chip_fd, GPIO_GET_LINEHANDLE_IOCTL, &request) == 0; + close(chip_fd); + if (ok) *line_fd = request.fd; + return ok; +#else + (void)chip_path; (void)offset; (void)value; (void)line_fd; + return false; +#endif +} + +bool gpio_set_output_line_value(int line_fd, int value) +{ +#if CP0_LORA_GPIO_HAS_CDEV + if (line_fd < 0) return false; + gpiohandle_data data{}; + data.values[0] = (uint8_t)(value ? 1 : 0); + return ioctl(line_fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &data) == 0; +#else + (void)line_fd; (void)value; + return false; +#endif +} + +int gpio_init_output_any(const char *chip_env_name, const char *offset_env_name, + int gpio, int value, int *line_fd, const char *line_name) +{ + const auto offset_resolution = cp0_lora_gpio_offset_policy::resolve( + offset_env_name ? getenv(offset_env_name) : nullptr, gpio); + if (!offset_resolution.valid()) return -1; + if (line_fd && *line_fd >= 0) return 0; +#if CP0_LORA_GPIO_HAS_CDEV + const char *chip_env = chip_env_name ? getenv(chip_env_name) : nullptr; + char chip_path[64] = "/dev/gpiochip0"; + const int offset = offset_resolution.offset; + if (chip_env && chip_env[0]) snprintf(chip_path, sizeof(chip_path), "%s", chip_env); + if (line_fd && gpio_open_output_line(chip_path, offset, value, line_fd)) { + SLOGI("LoRa GPIO %s via cdev: %s[%d]=%d", line_name ? line_name : "out", chip_path, offset, value); + return 0; + } +#endif + if (gpio_init_output(gpio, value) == 0) return 0; + SLOGI("LoRa GPIO %s init failed: gpio=%d errno=%d", line_name ? line_name : "out", gpio, errno); + return -1; +} + +int gpio_init_input_any(const char *chip_env_name, const char *offset_env_name, + int gpio, int *line_fd, const char *line_name) +{ + const auto offset_resolution = cp0_lora_gpio_offset_policy::resolve( + offset_env_name ? getenv(offset_env_name) : nullptr, gpio); + if (!offset_resolution.valid()) return -1; + if (line_fd && *line_fd >= 0) return 0; +#if CP0_LORA_GPIO_HAS_CDEV + const char *chip_env = chip_env_name ? getenv(chip_env_name) : nullptr; + char chip_path[64] = "/dev/gpiochip0"; + const int offset = offset_resolution.offset; + if (chip_env && chip_env[0]) snprintf(chip_path, sizeof(chip_path), "%s", chip_env); + if (line_fd && open_input_line(chip_path, offset, line_fd)) { + SLOGI("LoRa GPIO %s via cdev: %s[%d]", line_name ? line_name : "in", chip_path, offset); + return 0; + } +#endif + if (init_input(gpio) == 0) return 0; + SLOGI("LoRa GPIO %s input init failed: gpio=%d errno=%d", line_name ? line_name : "in", gpio, errno); + return -1; +} + +int gpio_init_input_irq_any(const char *chip_env_name, const char *offset_env_name, + int gpio, int *line_fd, const char *line_name) +{ + const auto offset_resolution = cp0_lora_gpio_offset_policy::resolve( + offset_env_name ? getenv(offset_env_name) : nullptr, gpio); + if (!offset_resolution.valid()) return -1; + if (line_fd && *line_fd >= 0) return 0; +#if CP0_LORA_GPIO_HAS_CDEV + const char *chip_env = chip_env_name ? getenv(chip_env_name) : nullptr; + char chip_path[64] = "/dev/gpiochip0"; + const int offset = offset_resolution.offset; + if (chip_env && chip_env[0]) snprintf(chip_path, sizeof(chip_path), "%s", chip_env); + if (line_fd && open_input_event_line(chip_path, offset, line_fd)) { + SLOGI("LoRa GPIO %s irq-event via cdev: %s[%d]", line_name ? line_name : "irq", chip_path, offset); + return 0; + } +#endif + if (line_fd && init_input_irq_sysfs(gpio, line_fd) == 0) { + SLOGI("LoRa GPIO %s irq-event via sysfs: gpio%d rising", line_name ? line_name : "irq", gpio); + return 0; + } + return -1; +} + +int gpio_get_value_any(int gpio, int line_fd) +{ +#if CP0_LORA_GPIO_HAS_CDEV + int value = 0; + if (line_fd >= 0 && get_input_line_value(line_fd, &value)) return value; +#endif + return gpio_get_value(gpio); +} + +int gpio_set_value_any(int gpio, int line_fd, int value) +{ +#if CP0_LORA_GPIO_HAS_CDEV + if (line_fd >= 0) return gpio_set_output_line_value(line_fd, value) ? 0 : -1; +#endif + return set_value(gpio, value); +} + +} // namespace cp0_lora_backend diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio.hpp new file mode 100644 index 00000000..07f0e3dc --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace cp0_lora_backend { + +int gpio_init_output(int gpio, int value); +int gpio_get_value(int gpio); +int gpio_init_output_any(const char *chip_env_name, const char *offset_env_name, + int gpio, int value, int *line_fd, const char *line_name); +int gpio_init_input_any(const char *chip_env_name, const char *offset_env_name, + int gpio, int *line_fd, const char *line_name); +int gpio_init_input_irq_any(const char *chip_env_name, const char *offset_env_name, + int gpio, int *line_fd, const char *line_name); +int gpio_get_value_any(int gpio, int line_fd); +int gpio_set_value_any(int gpio, int line_fd, int value); +bool gpio_find_named_line(char *chip_path, size_t chip_path_size, int *offset); +bool gpio_open_output_line(const char *chip_path, int offset, int value, int *line_fd); +bool gpio_set_output_line_value(int line_fd, int value); + +} // namespace cp0_lora_backend diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio_offset_policy.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio_offset_policy.hpp new file mode 100644 index 00000000..7c233080 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_gpio_offset_policy.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +namespace cp0_lora_gpio_offset_policy { + +constexpr int MIN_OFFSET = 0; +constexpr int MAX_OFFSET = 65535; + +enum class Source +{ + DEFAULT_VALUE, + ENVIRONMENT, + INVALID, +}; + +struct Resolution +{ + Source source = Source::INVALID; + int offset = 0; + + bool valid() const { return source != Source::INVALID; } + bool overridden() const { return source == Source::ENVIRONMENT; } +}; + +inline Resolution resolve(const char *environment_value, int default_offset) +{ + auto in_range = [](int value) { + return value >= MIN_OFFSET && value <= MAX_OFFSET; + }; + if (!environment_value) { + return in_range(default_offset) + ? Resolution{Source::DEFAULT_VALUE, default_offset} : Resolution{}; + } + if (!environment_value[0]) return {}; + + const std::string text(environment_value); + int parsed = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), parsed, 10); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || + !in_range(parsed)) + return {}; + return {Source::ENVIRONMENT, parsed}; +} + +} // namespace cp0_lora_gpio_offset_policy diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_hat_power_controller.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_hat_power_controller.cpp new file mode 100644 index 00000000..665f4f59 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_hat_power_controller.cpp @@ -0,0 +1,114 @@ +#include "cp0_lora_hat_power_controller.hpp" + +#include "cp0_lora_gpio.hpp" +#include "cp0_lora_gpio_offset_policy.hpp" + +#include +#include +#include +#include + +#ifndef SLOGI +#define SLOGI(fmt, ...) std::printf("[cp0_lora] " fmt "\n", ##__VA_ARGS__) +#endif + +#if __has_include() +#define CP0_HAT_POWER_HAS_GPIO_CDEV 1 +#else +#define CP0_HAT_POWER_HAS_GPIO_CDEV 0 +#endif + +namespace cp0_lora_hat_power_controller { +namespace { + +int line_fd = -1; +int line_offset = 5; +char chip_path[64] = ""; + +void log_result(const char *stage, int sysfs_result, int gpio_value, bool cdev_ok) +{ + const char *chip = chip_path[0] ? chip_path : "sysfs"; + const char *value = gpio_value < 0 ? "read_fail" : (gpio_value ? "HIGH" : "LOW"); + SLOGI("5VDBG %s cdev=%s chip=%s[%d] sysfs_ret=%d gpio5=%s", + stage ? stage : "?", cdev_ok ? "ok" : "fail", chip, line_offset, + sysfs_result, value); +} + +enum class PrepareResult +{ + READY, + UNAVAILABLE, + INVALID_OFFSET, +}; + +PrepareResult prepare_line() +{ +#if CP0_HAT_POWER_HAS_GPIO_CDEV + const char *chip_env = getenv("HAT_5VOUT_CHIP"); + const char *offset_env = getenv("HAT_5VOUT_OFFSET"); + const auto offset_resolution = cp0_lora_gpio_offset_policy::resolve(offset_env, 5); + if (!offset_resolution.valid()) return PrepareResult::INVALID_OFFSET; + if ((chip_env && chip_env[0]) || offset_resolution.overridden()) { + snprintf(chip_path, sizeof(chip_path), "%s", + chip_env && chip_env[0] ? chip_env : "/dev/gpiochip0"); + line_offset = offset_resolution.offset; + } else if (!cp0_lora_backend::gpio_find_named_line( + chip_path, sizeof(chip_path), &line_offset)) { + snprintf(chip_path, sizeof(chip_path), "/dev/gpiochip0"); + line_offset = 5; + } + if (line_fd >= 0) return PrepareResult::READY; + return cp0_lora_backend::gpio_open_output_line( + chip_path, line_offset, 1, &line_fd) ? PrepareResult::READY + : PrepareResult::UNAVAILABLE; +#else + const auto offset_resolution = cp0_lora_gpio_offset_policy::resolve( + getenv("HAT_5VOUT_OFFSET"), 5); + return offset_resolution.valid() ? PrepareResult::UNAVAILABLE + : PrepareResult::INVALID_OFFSET; +#endif +} + +} // namespace + +bool enable(int fallback_gpio) +{ +#if CP0_HAT_POWER_HAS_GPIO_CDEV + const PrepareResult prepared = prepare_line(); + if (prepared == PrepareResult::INVALID_OFFSET) { + SLOGI("5VDBG invalid HAT_5VOUT_OFFSET; refusing GPIO fallback"); + return false; + } + if (prepared == PrepareResult::READY && + cp0_lora_backend::gpio_set_output_line_value(line_fd, 0)) { + const int value = cp0_lora_backend::gpio_get_value(fallback_gpio); + log_result("cdev_set", 0, value, true); + usleep(50000); + return true; + } +#else + if (prepare_line() == PrepareResult::INVALID_OFFSET) { + SLOGI("5VDBG invalid HAT_5VOUT_OFFSET; refusing GPIO fallback"); + return false; + } +#endif + const int result = cp0_lora_backend::gpio_init_output(fallback_gpio, 0); + const int value = cp0_lora_backend::gpio_get_value(fallback_gpio); + log_result("sysfs_set", result, value, false); + if (result == 0) { + usleep(50000); + return true; + } + log_result("enable_fail", result, value, false); + return false; +} + +void shutdown() +{ + if (line_fd >= 0) { + close(line_fd); + line_fd = -1; + } +} + +} // namespace cp0_lora_hat_power_controller diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_hat_power_controller.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_hat_power_controller.hpp new file mode 100644 index 00000000..fd4daa5a --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_hat_power_controller.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace cp0_lora_hat_power_controller { + +bool enable(int fallback_gpio); +void shutdown(); + +} // namespace cp0_lora_hat_power_controller diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_pi4io_controller.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_pi4io_controller.cpp new file mode 100644 index 00000000..ccfaea35 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_pi4io_controller.cpp @@ -0,0 +1,149 @@ +#include "cp0_lora_pi4io_controller.hpp" + +#include +#include +#include +#include +#include + +#if __has_include() +#include +#include +#define CP0_PI4IO_HAS_LINUX_I2CDEV 1 +#else +#define CP0_PI4IO_HAS_LINUX_I2CDEV 0 +#endif + +namespace cp0_lora_pi4io_controller { +namespace { + +constexpr int I2C_BUS = 1; +constexpr int SDA_GPIO = 2; +constexpr int SCL_GPIO = 3; +constexpr uint8_t I2C_ADDRESS = 0x43; + +char status_text[160] = "I2C 0x43 not checked"; +uint8_t output_cache = 0x00; +uint8_t config_cache = 0xFF; +uint8_t polarity_cache = 0x00; + +bool open_bus(int *fd) +{ +#if !CP0_PI4IO_HAS_LINUX_I2CDEV + if (fd) *fd = -1; + snprintf(status_text, sizeof(status_text), + "I2C dev header missing, cannot access 0x%02X", I2C_ADDRESS); + return false; +#else + if (fd == nullptr) { + snprintf(status_text, sizeof(status_text), "I2C fd pointer invalid"); + return false; + } + char path[64]; + snprintf(path, sizeof(path), "/dev/i2c-%d", I2C_BUS); + *fd = open(path, O_RDWR); + if (*fd < 0) { + snprintf(status_text, sizeof(status_text), + "open %s failed, SDA:%d SCL:%d errno=%d", + path, SDA_GPIO, SCL_GPIO, errno); + return false; + } + return true; +#endif +} + +bool select_device(int fd) +{ + if (fd < 0) { + snprintf(status_text, sizeof(status_text), + "I2C fd invalid for 0x%02X", I2C_ADDRESS); + return false; + } +#if CP0_PI4IO_HAS_LINUX_I2CDEV + if (ioctl(fd, I2C_SLAVE, I2C_ADDRESS) < 0) { + snprintf(status_text, sizeof(status_text), + "select 0x%02X failed on /dev/i2c-%d errno=%d", + I2C_ADDRESS, I2C_BUS, errno); + return false; + } + return true; +#else + return false; +#endif +} + +bool write_register(int fd, uint8_t reg, uint8_t value) +{ + uint8_t data[2] = {reg, value}; + return write(fd, data, sizeof(data)) == (ssize_t)sizeof(data); +} + +bool probe(int fd) +{ + uint8_t reg = 0x00; + if (write(fd, ®, 1) != 1) { + snprintf(status_text, sizeof(status_text), + "I2C 0x%02X not found on /dev/i2c-%d (SDA:%d SCL:%d)", + I2C_ADDRESS, I2C_BUS, SDA_GPIO, SCL_GPIO); + return false; + } + snprintf(status_text, sizeof(status_text), + "I2C 0x%02X found on /dev/i2c-%d (SDA:%d SCL:%d)", + I2C_ADDRESS, I2C_BUS, SDA_GPIO, SCL_GPIO); + return true; +} + +bool initialize(int fd) +{ + if (fd < 0) { + snprintf(status_text, sizeof(status_text), + "I2C IO init invalid fd for 0x%02X", I2C_ADDRESS); + return false; + } + + polarity_cache = 0x00; + output_cache = 0x01; + config_cache = 0xFE; + struct RegisterWrite { + uint8_t reg; + uint8_t value; + const char *name; + }; + const RegisterWrite writes[] = { + {0x02, polarity_cache, "POL"}, + {0x01, output_cache, "OUT"}, + {0x03, config_cache, "CFG"}, + }; + for (const auto &item : writes) { + errno = 0; + if (!write_register(fd, item.reg, item.value)) { + snprintf(status_text, sizeof(status_text), + "I2C IO write %s failed at 0x%02X errno=%d", + item.name, I2C_ADDRESS, errno); + return false; + } + } + + snprintf(status_text, sizeof(status_text), + "I2C IO init ok OUT=0x%02X POL=0x%02X CFG=0x%02X P0=HIGH", + output_cache, polarity_cache, config_cache); + return true; +} + +} // namespace + +bool scan_and_initialize() +{ + int fd = -1; + if (!open_bus(&fd)) return false; + const bool ok = select_device(fd) && probe(fd) && initialize(fd); + close(fd); + return ok; +} + +const char *status() +{ + return status_text; +} + +} // namespace cp0_lora_pi4io_controller diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_pi4io_controller.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_pi4io_controller.hpp new file mode 100644 index 00000000..751be625 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_pi4io_controller.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace cp0_lora_pi4io_controller { + +bool scan_and_initialize(); +const char *status(); + +} // namespace cp0_lora_pi4io_controller diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_runtime_policy.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_runtime_policy.cpp new file mode 100644 index 00000000..3d9c9c9d --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_runtime_policy.cpp @@ -0,0 +1,28 @@ +#include "cp0_lora_runtime_policy.hpp" + +namespace cp0_lora_runtime_policy { +namespace { + +bool interval_elapsed(uint64_t start_ms, uint64_t now_ms, uint64_t interval_ms) +{ + return now_ms >= start_ms && now_ms - start_ms >= interval_ms; +} + +} // namespace + +bool should_timeout_transmit(bool initialized, bool tx_in_progress, + bool radio_available, uint64_t tx_start_ms, + uint64_t now_ms) +{ + return initialized && tx_in_progress && radio_available && tx_start_ms != 0 && + interval_elapsed(tx_start_ms, now_ms, TX_TIMEOUT_MS); +} + +bool should_send_demo(bool initialized, bool tx_mode, bool tx_in_progress, + uint64_t last_auto_tx_ms, uint64_t now_ms) +{ + return initialized && tx_mode && !tx_in_progress && + interval_elapsed(last_auto_tx_ms, now_ms, AUTO_TX_INTERVAL_MS); +} + +} // namespace cp0_lora_runtime_policy diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_runtime_policy.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_runtime_policy.hpp new file mode 100644 index 00000000..6a40c7bc --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_runtime_policy.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace cp0_lora_runtime_policy { + +constexpr uint64_t TX_TIMEOUT_MS = 4000; +constexpr uint64_t AUTO_TX_INTERVAL_MS = 2000; + +bool should_timeout_transmit(bool initialized, bool tx_in_progress, + bool radio_available, uint64_t tx_start_ms, + uint64_t now_ms); + +bool should_send_demo(bool initialized, bool tx_mode, bool tx_in_progress, + uint64_t last_auto_tx_ms, uint64_t now_ms); + +} // namespace cp0_lora_runtime_policy diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_spi_device.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_spi_device.cpp new file mode 100644 index 00000000..d33fb0e5 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_spi_device.cpp @@ -0,0 +1,110 @@ +#include "cp0_lora_spi_device.hpp" + +#include +#include +#include +#include + +#if __has_include() && __has_include() +#include +#include +#else +extern "C" int ioctl(int fd, unsigned long request, ...); +struct spi_ioc_transfer { + unsigned long tx_buf; + unsigned long rx_buf; + uint32_t len; + uint32_t speed_hz; + uint16_t delay_usecs; + uint8_t bits_per_word; + uint8_t cs_change; + uint32_t pad; +}; +#ifndef SPI_MODE_0 +#define SPI_MODE_0 0 +#endif +#ifndef SPI_IOC_WR_MODE +#define SPI_IOC_WR_MODE 0 +#endif +#ifndef SPI_IOC_WR_BITS_PER_WORD +#define SPI_IOC_WR_BITS_PER_WORD 0 +#endif +#ifndef SPI_IOC_WR_MAX_SPEED_HZ +#define SPI_IOC_WR_MAX_SPEED_HZ 0 +#endif +#ifndef SPI_IOC_MESSAGE +#define SPI_IOC_MESSAGE(N) 0 +#endif +#endif + +namespace cp0_lora { + +SpiDevice::SpiDevice(const char *path, uint32_t speed_hz) : speed_hz_(speed_hz) +{ + set_path(path); +} + +SpiDevice::~SpiDevice() +{ + close(); +} + +const char *SpiDevice::path() const +{ + return path_; +} + +void SpiDevice::set_path(const char *path) +{ + if (is_open()) + close(); + std::snprintf(path_, sizeof(path_), "%s", path ? path : ""); +} + +bool SpiDevice::is_open() const +{ + return fd_ >= 0; +} + +bool SpiDevice::open() +{ + if (is_open()) + return true; + fd_ = ::open(path_, O_RDWR); + if (fd_ < 0) + return false; + + uint8_t mode = static_cast(SPI_MODE_0); + uint8_t bits = 8; + if (ioctl(fd_, SPI_IOC_WR_MODE, &mode) < 0 || + ioctl(fd_, SPI_IOC_WR_BITS_PER_WORD, &bits) < 0 || + ioctl(fd_, SPI_IOC_WR_MAX_SPEED_HZ, &speed_hz_) < 0) { + close(); + return false; + } + return true; +} + +void SpiDevice::close() +{ + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } +} + +bool SpiDevice::transfer(const uint8_t *tx, uint8_t *rx, size_t length) +{ + if (!is_open()) + return false; + struct spi_ioc_transfer transfer; + std::memset(&transfer, 0, sizeof(transfer)); + transfer.tx_buf = reinterpret_cast(tx); + transfer.rx_buf = reinterpret_cast(rx); + transfer.len = static_cast(length); + transfer.speed_hz = speed_hz_; + transfer.bits_per_word = 8; + return ioctl(fd_, SPI_IOC_MESSAGE(1), &transfer) >= 0; +} + +} // namespace cp0_lora diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lora_spi_device.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_lora_spi_device.hpp new file mode 100644 index 00000000..5a865807 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lora_spi_device.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace cp0_lora { + +class SpiDevice +{ +public: + explicit SpiDevice(const char *path = "/dev/spidev0.1", uint32_t speed_hz = 1000000); + ~SpiDevice(); + + SpiDevice(const SpiDevice &) = delete; + SpiDevice &operator=(const SpiDevice &) = delete; + + const char *path() const; + void set_path(const char *path); + bool is_open() const; + bool open(); + void close(); + bool transfer(const uint8_t *tx, uint8_t *rx, size_t length); + +private: + int fd_ = -1; + char path_[64] = {}; + uint32_t speed_hz_; +}; + +} // namespace cp0_lora diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.c b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.c index 62a0a009..6e1a9e43 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.c +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.c @@ -2,31 +2,193 @@ #include "lvgl/lvgl.h" #include "commount.h" #include "cp0_lvgl.h" +#include "../cp0_init_plan.h" +#include + +static uint64_t enabled_features(void) +{ + uint64_t features = 0; +#ifdef CONFIG_CP0_LVGL_INIT_FILESYSTEM + features |= CP0_INIT_FEATURE_FILESYSTEM; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_CONFIG + features |= CP0_INIT_FEATURE_CONFIG; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_PTY + features |= CP0_INIT_FEATURE_PTY; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_RPC + features |= CP0_INIT_FEATURE_RPC; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_AUDIO + features |= CP0_INIT_FEATURE_AUDIO; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_PROCESS + features |= CP0_INIT_FEATURE_PROCESS; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SUDO + features |= CP0_INIT_FEATURE_SUDO; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_OSINFO + features |= CP0_INIT_FEATURE_OSINFO; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SCREENSHOT + features |= CP0_INIT_FEATURE_SCREENSHOT; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_LORA + features |= CP0_INIT_FEATURE_LORA; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_WIFI + features |= CP0_INIT_FEATURE_WIFI; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_BLUETOOTH + features |= CP0_INIT_FEATURE_BLUETOOTH; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SETTINGS + features |= CP0_INIT_FEATURE_SETTINGS; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_BQ27220 + features |= CP0_INIT_FEATURE_BQ27220; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_IMU + features |= CP0_INIT_FEATURE_IMU; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS + features |= CP0_INIT_FEATURE_SAVED_SETTINGS; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_BATTERY + features |= CP0_INIT_FEATURE_BATTERY; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_CAMERA + features |= CP0_INIT_FEATURE_CAMERA; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SOUNDCARD + features |= CP0_INIT_FEATURE_SOUNDCARD; +#endif + return features; +} + +static int run_init_step(cp0_init_step_t step) +{ + switch (step) { + case CP0_INIT_STEP_ENV: init_lvgl_env(); break; + case CP0_INIT_STEP_EVENT: init_lvgl_event(); break; + case CP0_INIT_STEP_FILESYSTEM: +#ifdef CONFIG_CP0_LVGL_INIT_FILESYSTEM + init_filesystem(); +#endif + break; + case CP0_INIT_STEP_CONFIG: +#ifdef CONFIG_CP0_LVGL_INIT_CONFIG + init_config(); +#endif + break; + case CP0_INIT_STEP_PTY: +#ifdef CONFIG_CP0_LVGL_INIT_PTY + init_pty(); +#endif + break; + case CP0_INIT_STEP_DISPLAY: + init_freambuffer_disp(); + return lv_display_get_default() != NULL; + case CP0_INIT_STEP_INPUT: init_input(); break; + case CP0_INIT_STEP_RPC: +#ifdef CONFIG_CP0_LVGL_INIT_RPC + init_rpc(); +#endif + break; + case CP0_INIT_STEP_AUDIO: +#ifdef CONFIG_CP0_LVGL_INIT_AUDIO + init_audio(); +#endif + break; + case CP0_INIT_STEP_PROCESS: +#ifdef CONFIG_CP0_LVGL_INIT_PROCESS + init_process(); +#endif + break; + case CP0_INIT_STEP_SUDO: +#ifdef CONFIG_CP0_LVGL_INIT_SUDO + init_sudo_signals(); +#endif + break; + case CP0_INIT_STEP_OSINFO: +#ifdef CONFIG_CP0_LVGL_INIT_OSINFO + init_osinfo(); +#endif + break; + case CP0_INIT_STEP_SCREENSHOT: +#ifdef CONFIG_CP0_LVGL_INIT_SCREENSHOT + init_screenshot(); +#endif + break; + case CP0_INIT_STEP_LORA: +#ifdef CONFIG_CP0_LVGL_INIT_LORA + init_lora(); +#endif + break; + case CP0_INIT_STEP_WIFI: +#ifdef CONFIG_CP0_LVGL_INIT_WIFI + init_wifi(); +#endif + break; + case CP0_INIT_STEP_BLUETOOTH: +#ifdef CONFIG_CP0_LVGL_INIT_BLUETOOTH + init_bluetooth(); +#endif + break; + case CP0_INIT_STEP_SETTINGS: +#ifdef CONFIG_CP0_LVGL_INIT_SETTINGS + init_settings(); +#endif + break; + case CP0_INIT_STEP_BQ27220: +#ifdef CONFIG_CP0_LVGL_INIT_BQ27220 + init_bq27220(); +#endif + break; + case CP0_INIT_STEP_IMU: +#ifdef CONFIG_CP0_LVGL_INIT_IMU + init_imu(); +#endif + break; + case CP0_INIT_STEP_SAVED_SETTINGS: +#ifdef CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS + init_lvgl_saved_settings(); +#endif + break; + case CP0_INIT_STEP_BATTERY: +#ifdef CONFIG_CP0_LVGL_INIT_BATTERY + init_battery(); +#endif + break; + case CP0_INIT_STEP_CAMERA: +#ifdef CONFIG_CP0_LVGL_INIT_CAMERA + init_camera(); +#endif + break; + case CP0_INIT_STEP_SOUNDCARD: +#ifdef CONFIG_CP0_LVGL_INIT_SOUNDCARD + init_soundcard(); +#endif + break; + } + return 1; +} + void cp0_lvgl_init(void) { - init_lvgl_env(); - init_lvgl_event(); - init_filesystem(); - init_config(); - init_pty(); - init_freambuffer_disp(); - if (lv_display_get_default() == NULL) - return; - init_input(); - init_rpc(); - init_audio(); - init_process(); - init_sudo_signals(); - init_osinfo(); - init_screenshot(); - init_lora(); - init_wifi(); - init_bluetooth(); - init_settings(); - init_bq27220(); - init_imu(); - init_lvgl_saved_settings(); - init_battery(); - init_camera(); - init_soundcard(); + static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; + static size_t next_step = 0; + cp0_init_step_t plan[CP0_INIT_STEP_SOUNDCARD + 1]; + const size_t step_count = cp0_build_init_plan(enabled_features(), plan, + sizeof(plan) / sizeof(plan[0])); + + pthread_mutex_lock(&mutex); + while (next_step < step_count) { + if (!run_init_step(plan[next_step])) + break; + ++next_step; + } + pthread_mutex_unlock(&mutex); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.h b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.h index a378920a..3fafb6f9 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.h +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.h @@ -6,13 +6,29 @@ extern "C" { void init_freambuffer_disp(); void init_input(); +void deinit_input(void); void init_filesystem(void); void init_audio(); +#ifdef __cplusplus +void deinit_audio(void) noexcept; +#else +void deinit_audio(void); +#endif void init_pty(void); +#ifdef __cplusplus +void deinit_pty(void) noexcept; +#else +void deinit_pty(void); +#endif void init_config(void); void init_process(void); void init_screenshot(void); void init_rpc(void); +#ifdef __cplusplus +void deinit_rpc(void) noexcept; +#else +void deinit_rpc(void); +#endif void init_lora(void); void init_wifi(); void init_bluetooth(void); @@ -20,8 +36,14 @@ void init_settings(void); void init_osinfo(void); void init_bq27220(void); void init_imu(void); +void deinit_imu(void); void init_battery(); void init_camera(void); +#ifdef __cplusplus +void deinit_camera(void) noexcept; +#else +void deinit_camera(void); +#endif void init_soundcard(void); #ifdef __cplusplus } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp index aced687c..4e3adbf3 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp @@ -1,14 +1,13 @@ #include "hal_lvgl_bsp.h" +#include "../cp0_audio_api_contract.hpp" +#include "cp0_audio_capture.hpp" +#include "cp0_audio_mixer_control.hpp" +#include "cp0_audio_system_sound_player.hpp" +#include "../cp0_signal_registration.hpp" +#include "../cp0_audio_runtime_lifecycle.hpp" #include -#include #include -#include -#include -#include -#include -#include -#include #include #include #include @@ -16,6 +15,7 @@ #include #include #include +#include #define MINIAUDIO_IMPLEMENTATION #include "miniaudio.h" @@ -24,51 +24,33 @@ class AudioSystem { public: - static constexpr const char* kCapTmpFile = "/tmp/rec.tmp.wav"; typedef std::function callback_t; typedef std::list arg_t; - AudioSystem() + AudioSystem() = default; + ~AudioSystem() { - init_system_sounds(); + shutdown(); } - ~AudioSystem() + void shutdown() noexcept { - uninit_system_play(); + try { set_status_callback(nullptr); } catch (...) {} + try { capture_.set_status_callback(nullptr); } catch (...) {} + try { capture_.stop(); } catch (...) {} + try { stop_play_device(false); } catch (...) {} } public: std::function _cap_status_callback; - std::unique_ptr ma_cp0_cap_device; - std::unique_ptr ma_cp0_cap_encoder; - + std::mutex callback_mutex_; std::unique_ptr ma_cp0_play_device; std::unique_ptr ma_cp0_play_decoder; std::atomic play_finished_reported_{false}; - ma_context system_play_context_{}; - ma_engine system_play_engine_{}; - std::array system_sounds_{}; - std::array system_sound_loaded_slots_{}; - bool system_play_inited_ = false; - bool system_sounds_loaded_ = false; - std::mutex system_play_mutex_; - std::array system_sound_names_ = {"Ding2.wav", "key_back.wav", "key_back.wav"}; - bool system_sound_enabled_ = true; - - static constexpr int kRecWaveformSize = 128; - std::array rec_waveform_{}; - size_t rec_waveform_index_ = 0; - std::mutex rec_waveform_mutex_; - std::atomic rec_waveform_enabled_{false}; - - static void cap_data_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) - { - AudioSystem* self = (AudioSystem*)pDevice->pUserData; - if (self) self->on_cap_data(pInput, frameCount); - (void)pOutput; - } + Cp0SystemSoundPlayer system_sounds_; + Cp0AudioMixerControl mixer_; + Cp0AudioCapture capture_; static void play_data_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) { @@ -85,8 +67,10 @@ class AudioSystem ma_silence_pcm_frames(silence, frameCount - framesRead, pDevice->playback.format, pDevice->playback.channels); } bool finished = (result == MA_AT_END || framesRead < frameCount); - if(finished && !self->play_finished_reported_.exchange(true) && self->_cap_status_callback) - self->_cap_status_callback(0, "play over\n"); + if(finished && !self->play_finished_reported_.exchange(true)) { + auto callback = self->status_callback(); + cp0::audio::invoke_callback(callback, 0, "play over\n"); + } (void)pInput; } @@ -102,7 +86,7 @@ class AudioSystem ma_cp0_play_decoder = std::make_unique(); result = ma_decoder_init_file(wav.c_str(), NULL, ma_cp0_play_decoder.get()); if (result != MA_SUCCESS) { - if(_cap_status_callback)_cap_status_callback(-2, "Could not load file\n"); + report_status(-2, "Could not load file\n"); ma_cp0_play_decoder.reset(); ma_cp0_play_device.reset(); return -2; @@ -117,7 +101,7 @@ class AudioSystem if (ma_device_init(NULL, &deviceConfig, ma_cp0_play_device.get()) != MA_SUCCESS) { ma_decoder_uninit(ma_cp0_play_decoder.get()); - if(_cap_status_callback)_cap_status_callback(-3, "Failed to open playback device.\n"); + report_status(-3, "Failed to open playback device.\n"); ma_cp0_play_decoder.reset(); ma_cp0_play_device.reset(); return -3; @@ -126,7 +110,7 @@ class AudioSystem if (ma_device_start(ma_cp0_play_device.get()) != MA_SUCCESS) { ma_device_uninit(ma_cp0_play_device.get()); ma_decoder_uninit(ma_cp0_play_decoder.get()); - if(_cap_status_callback)_cap_status_callback(-4, "Failed to start playback device.\n"); + report_status(-4, "Failed to start playback device.\n"); ma_cp0_play_decoder.reset(); ma_cp0_play_device.reset(); return -4; @@ -144,10 +128,31 @@ class AudioSystem private: + callback_t status_callback() + { + std::lock_guard lock(callback_mutex_); + return _cap_status_callback; + } + + void set_status_callback(callback_t callback) + { + std::lock_guard lock(callback_mutex_); + _cap_status_callback = std::move(callback); + } + + void report_status(int code, const std::string &data) + { + auto callback = status_callback(); + cp0::audio::invoke_callback(callback, code, data); + } + void report(callback_t callback, int code, const std::string& data) { - if(callback) callback(code, data); - else if(_cap_status_callback) _cap_status_callback(code, data); + if (!callback) { + report_status(code, data); + return; + } + cp0::audio::invoke_callback(callback, code, data); } static std::string first_arg_after_command(const arg_t& arg) @@ -170,112 +175,6 @@ class AudioSystem return path.empty() ? file : path; } - int init_system_play_locked() - { - if(system_play_inited_) return 0; - - ma_backend backends[] = { - ma_backend_pulseaudio - }; - - ma_result result = ma_context_init( - backends, - sizeof(backends) / sizeof(backends[0]), - NULL, - &system_play_context_ - ); - if(result != MA_SUCCESS) return -1; - - ma_engine_config engineConfig = ma_engine_config_init(); - engineConfig.pContext = &system_play_context_; - engineConfig.pPlaybackDeviceID = NULL; - engineConfig.channels = 2; - engineConfig.sampleRate = 48000; - - result = ma_engine_init(&engineConfig, &system_play_engine_); - if(result != MA_SUCCESS) - { - ma_context_uninit(&system_play_context_); - return -2; - } - - system_play_inited_ = true; - return 0; - } - - int load_system_sounds_locked() - { - if(system_sounds_loaded_) return 0; - - int ret = init_system_play_locked(); - if(ret != 0) return ret; - - for(size_t i = 0; i < system_sound_names_.size(); ++i) - { - std::string path = resolve_play_file(system_sound_names_[i], true); - if(path.empty()) continue; - - ma_result result = ma_sound_init_from_file( - &system_play_engine_, - path.c_str(), - MA_SOUND_FLAG_DECODE, - NULL, - NULL, - &system_sounds_[i] - ); - if(result != MA_SUCCESS) - { - printf("load system sound failed: %s\n", path.c_str()); - continue; - } - system_sound_loaded_slots_[i] = true; - } - - system_sounds_loaded_ = std::any_of( - system_sound_loaded_slots_.begin(), - system_sound_loaded_slots_.end(), - [](bool loaded) { return loaded; } - ); - if(!system_sounds_loaded_) return -3; - return 0; - } - - void unload_system_sounds_locked() - { - for(size_t i = 0; i < system_sounds_.size(); ++i) - { - if(system_sound_loaded_slots_[i]) - { - ma_sound_uninit(&system_sounds_[i]); - system_sound_loaded_slots_[i] = false; - } - } - system_sounds_loaded_ = false; - } - - int reload_system_sounds_locked() - { - unload_system_sounds_locked(); - return load_system_sounds_locked(); - } - - void uninit_system_play() - { - std::lock_guard lock(system_play_mutex_); - - if(system_sounds_loaded_) - { - unload_system_sounds_locked(); - } - - if(system_play_inited_) - { - ma_engine_uninit(&system_play_engine_); - ma_context_uninit(&system_play_context_); - system_play_inited_ = false; - } - } - void stop_play_device(bool report_state) { play_finished_reported_.store(false); @@ -289,331 +188,74 @@ class AudioSystem ma_decoder_uninit(ma_cp0_play_decoder.get()); ma_cp0_play_decoder.reset(); } - if(report_state && _cap_status_callback) _cap_status_callback(0, "play stop\n"); - } - - static int copy_file(const std::string& src_path, const std::string& dst_path) - { - FILE* src = std::fopen(src_path.c_str(), "rb"); - if(!src) return -1; - - FILE* dst = std::fopen(dst_path.c_str(), "wb"); - if(!dst) - { - std::fclose(src); - return -2; - } - - char buf[4096]; - size_t n = 0; - int ret = 0; - while((n = std::fread(buf, 1, sizeof(buf), src)) > 0) - { - if(std::fwrite(buf, 1, n, dst) != n) - { - ret = -3; - break; - } - } - if(std::ferror(src)) ret = -4; - - std::fclose(dst); - std::fclose(src); - return ret; - } - - int save_cap_file(const std::string& dst_path) - { - if(dst_path.empty()) return -1; - if(std::rename(kCapTmpFile, dst_path.c_str()) == 0) return 0; - - int saved_errno = errno; - int ret = copy_file(kCapTmpFile, dst_path); - if(ret == 0) - { - std::remove(kCapTmpFile); - return 0; - } - errno = saved_errno; - return ret; - } - - void on_cap_data(const void* input, ma_uint32 frameCount) - { - if(ma_cp0_cap_encoder) - { - ma_encoder_write_pcm_frames(ma_cp0_cap_encoder.get(), input, frameCount, NULL); - } - - if(!rec_waveform_enabled_.load() || !_cap_status_callback || input == NULL || frameCount == 0) - { - return; - } - - std::string waveform = build_rec_waveform(input, frameCount); - if(!waveform.empty()) - { - _cap_status_callback(1, waveform); - } + if(report_state) report_status(0, "play stop\n"); } - std::string build_rec_waveform(const void* input, ma_uint32 frameCount) - { - const int16_t* samples = static_cast(input); - ma_uint32 channels = 1; - if(ma_cp0_cap_encoder && ma_cp0_cap_encoder.get()->config.channels > 0) - { - channels = ma_cp0_cap_encoder.get()->config.channels; - } - - ma_uint32 sampleCount = frameCount * channels; - int16_t peak = 0; - double sumSq = 0.0; - for(ma_uint32 i = 0; i < sampleCount; i++) - { - if(std::abs(samples[i]) > std::abs(peak)) - { - peak = samples[i]; - } - double s = static_cast(samples[i]) / 32768.0; - sumSq += s * s; - } - - float rms = (sampleCount > 0) ? static_cast(std::sqrt(sumSq / sampleCount)) : 0.0f; - float db = 20.0f * std::log10(rms + 1e-6f); - if(db < -36.0f) db = -36.0f; - float dbNorm = (db + 36.0f) / 36.0f; - if(peak < 0) dbNorm = -dbNorm; - - std::array waveform{}; - { - std::lock_guard lock(rec_waveform_mutex_); - rec_waveform_[rec_waveform_index_] = std::max(-1.0f, std::min(1.0f, dbNorm)); - rec_waveform_index_ = (rec_waveform_index_ + 1) % kRecWaveformSize; - - for(int i = 0; i < kRecWaveformSize; i++) - { - size_t idx = (rec_waveform_index_ + kRecWaveformSize - kRecWaveformSize + i) % kRecWaveformSize; - waveform[i] = rec_waveform_[idx]; - } - } - - std::string out(sizeof(float) * kRecWaveformSize, '\0'); - std::memcpy(&out[0], waveform.data(), out.size()); - return out; - } - - static bool arg_is_enable(const std::string& arg) - { - return arg == "1" || arg == "on" || arg == "true" || arg == "enable" || arg == "enabled"; - } - - static bool arg_is_disable(const std::string& arg) - { - return arg == "0" || arg == "off" || arg == "false" || arg == "disable" || arg == "disabled"; - } - - int start_cap_device() - { - ma_result result; - ma_encoder_config encoderConfig; - ma_device_config deviceConfig; - if(!ma_cp0_cap_encoder) - { - ma_cp0_cap_encoder = std::make_unique(); - ma_cp0_cap_device = std::make_unique(); - - encoderConfig = ma_encoder_config_init(ma_encoding_format_wav, ma_format_s16, 2, 48000); - if (ma_encoder_init_file(kCapTmpFile, &encoderConfig, ma_cp0_cap_encoder.get()) != MA_SUCCESS) { - if(_cap_status_callback)_cap_status_callback(-1, "Failed to initialize output file.\n"); - ma_cp0_cap_encoder.reset(); - ma_cp0_cap_device.reset(); - return -1; - } - deviceConfig = ma_device_config_init(ma_device_type_capture); - deviceConfig.capture.format = ma_cp0_cap_encoder.get()->config.format; - deviceConfig.capture.channels = ma_cp0_cap_encoder.get()->config.channels; - deviceConfig.sampleRate = ma_cp0_cap_encoder.get()->config.sampleRate; - deviceConfig.dataCallback = cap_data_callback; - deviceConfig.pUserData = this; - result = ma_device_init(NULL, &deviceConfig, ma_cp0_cap_device.get()); - if (result != MA_SUCCESS) { - if(_cap_status_callback)_cap_status_callback(-3, "Failed to initialize capture device.\n"); - ma_encoder_uninit(ma_cp0_cap_encoder.get()); - ma_cp0_cap_encoder.reset(); - ma_cp0_cap_device.reset(); - return -2; - } - result = ma_device_start(ma_cp0_cap_device.get()); - if (result != MA_SUCCESS) { - ma_device_uninit(ma_cp0_cap_device.get()); - ma_encoder_uninit(ma_cp0_cap_encoder.get()); - ma_cp0_cap_encoder.reset(); - ma_cp0_cap_device.reset(); - if(_cap_status_callback)_cap_status_callback(-3, "Failed to start device.\n"); - return -3; - } - } - else - { - if(_cap_status_callback)_cap_status_callback(-4, "working"); - } - return 0; - } - void stop_cap_device() - { - if(ma_cp0_cap_device) - { - ma_device_uninit(ma_cp0_cap_device.get()); - if(ma_cp0_cap_encoder) ma_encoder_uninit(ma_cp0_cap_encoder.get()); - ma_cp0_cap_device.reset(); - ma_cp0_cap_encoder.reset(); - } - else - { - if(_cap_status_callback)_cap_status_callback(-5, "stop"); - } - } public: - int init_system_sounds() - { - std::lock_guard lock(system_play_mutex_); - return load_system_sounds_locked(); - } - void system_play(std::string name) { - if(!system_sound_enabled_) return; - - { - std::lock_guard lock(system_play_mutex_); - if(!system_sounds_loaded_ && load_system_sounds_locked() != 0) return; - - for(size_t i = 0; i < system_sound_names_.size(); ++i) - { - if(system_sound_names_[i] != name) continue; - if(!system_sound_loaded_slots_[i]) return; - - ma_sound* sound = &system_sounds_[i]; - if(ma_sound_is_playing(sound)) return; - ma_sound_seek_to_pcm_frame(sound, 0); - ma_sound_start(sound); - return; - } + if(system_sounds_.contains(name)) { + system_sounds_.play_named(name); + return; } - std::string file = resolve_play_file(name, true); - if(!file.empty()) play(file); - } - - bool system_play_index(size_t index) - { - if(!system_sound_enabled_) return false; - - std::lock_guard lock(system_play_mutex_); - if(!system_sounds_loaded_ && load_system_sounds_locked() != 0) - { - return false; - } - if(index >= system_sounds_.size() || !system_sound_loaded_slots_[index]) - { - return false; - } - - ma_sound* sound = &system_sounds_[index]; - if(ma_sound_is_playing(sound)) return true; - if(ma_sound_seek_to_pcm_frame(sound, 0) != MA_SUCCESS) return false; - return ma_sound_start(sound) == MA_SUCCESS; + if(!file.empty() && system_sounds_.enabled()) play(file); } void SetSystemSoundNames(arg_t arg, callback_t callback) { - { - std::lock_guard lock(system_play_mutex_); - auto it = arg.begin(); - if(it != arg.end()) ++it; - for(size_t i = 0; i < system_sound_names_.size() && it != arg.end(); ++i, ++it) - { - if(!it->empty()) system_sound_names_[i] = *it; - } - - int ret = reload_system_sounds_locked(); - if(ret != 0) - { - report(callback, ret, "system sound reload failed\n"); - return; - } - } - report(callback, 0, "ok"); + std::vector names; + auto it = arg.begin(); + if(it != arg.end()) ++it; + for(; it != arg.end(); ++it) names.push_back(*it); + int ret = system_sounds_.reload(names); + report(callback, ret, ret == 0 ? "ok" : "system sound reload failed\n"); } - void SystemSoundPlay(arg_t arg, callback_t callback) + void SystemSoundPlay(int index, callback_t callback) { - int index = std::atoi(first_arg_after_command(arg).c_str()); - if(index < 0 || index >= static_cast(system_sound_names_.size())) + if(index < 0 || index >= static_cast(system_sounds_.sound_count())) { report(callback, -1, "invalid system sound index\n"); return; } - if(!system_sound_enabled_) + if(!system_sounds_.enabled()) { report(callback, 0, "system sound disabled\n"); return; } - bool played = system_play_index(static_cast(index)); + bool played = system_sounds_.play_index(static_cast(index)); report(callback, played ? 0 : -2, played ? "system sound play\n" : "system sound play failed\n"); } - void SystemSoundEnable(arg_t arg, callback_t callback) + void SystemSoundEnable(bool enabled, callback_t callback) { - std::string value = first_arg_after_command(arg); - if(arg_is_disable(value)) - system_sound_enabled_ = false; - else if(value.empty() || arg_is_enable(value)) - system_sound_enabled_ = true; - else - system_sound_enabled_ = std::atoi(value.c_str()) != 0; - report(callback, 0, system_sound_enabled_ ? "1" : "0"); + system_sounds_.set_enabled(enabled); + report(callback, 0, system_sounds_.enabled() ? "1" : "0"); } void cap(bool enable) { if(enable) { - start_cap_device(); + capture_.start(); }else{ - stop_cap_device(); + capture_.stop(); } } void setup(std::list arg, std::function callback) { - if(arg.empty()) return; - auto arg1 = arg.begin(); - if(*arg1 == "set_callback") - { - _cap_status_callback = callback; - } - else if(*arg1 == "set_waveform" || *arg1 == "waveform") - { - auto arg2 = std::next(arg1); - if(arg2 != arg.end()) - { - if(arg_is_enable(*arg2)) - { - rec_waveform_enabled_.store(true); - } - else if(arg_is_disable(*arg2)) - { - rec_waveform_enabled_.store(false); - } - } - else - { - rec_waveform_enabled_.store(true); - } + cp0::audio::SetupRequest request; + if (!cp0::audio::parse_setup_request(arg, request)) { + report(callback, -1, cp0::audio::invalid_setup_request_message()); + return; } - else if(*arg1 == "stop_play") - { + if(request.command == cp0::audio::SetupCommand::SetCallback) { + set_status_callback(callback); + capture_.set_status_callback(callback); + } else if(request.command == cp0::audio::SetupCommand::SetWaveform) { + capture_.set_waveform_enabled(request.waveform_enabled); + } else { stop_play_device(false); } } @@ -677,38 +319,38 @@ class AudioSystem void Cap(arg_t arg, callback_t callback) { (void)arg; - int ret = start_cap_device(); + int ret = capture_.start(); report(callback, ret, ret == 0 ? "cap start\n" : "cap failed\n"); } void CapPause(arg_t arg, callback_t callback) { (void)arg; - if(!ma_cp0_cap_device) + if(!capture_.active()) { report(callback, -1, "cap not started\n"); return; } - ma_result ret = ma_device_stop(ma_cp0_cap_device.get()); - report(callback, ret == MA_SUCCESS ? 0 : -2, ret == MA_SUCCESS ? "cap pause\n" : "cap pause failed\n"); + int ret = capture_.pause(); + report(callback, ret, ret == 0 ? "cap pause\n" : "cap pause failed\n"); } void CapContinue(arg_t arg, callback_t callback) { (void)arg; - if(!ma_cp0_cap_device) + if(!capture_.active()) { report(callback, -1, "cap not started\n"); return; } - ma_result ret = ma_device_start(ma_cp0_cap_device.get()); - report(callback, ret == MA_SUCCESS ? 0 : -2, ret == MA_SUCCESS ? "cap continue\n" : "cap continue failed\n"); + int ret = capture_.resume(); + report(callback, ret, ret == 0 ? "cap continue\n" : "cap continue failed\n"); } void CapEnd(arg_t arg, callback_t callback) { (void)arg; - stop_cap_device(); + capture_.stop(); report(callback, 0, "cap stop\n"); } @@ -720,53 +362,49 @@ class AudioSystem report(callback, -1, "CapFileSave need file\n"); return; } - if(ma_cp0_cap_device) - { - stop_cap_device(); - } - int ret = save_cap_file(file); + int ret = capture_.save(file); report(callback, ret, ret == 0 ? "cap file saved\n" : "cap file save failed\n"); } void SetCallback(arg_t arg, callback_t callback) { (void)arg; - _cap_status_callback = callback; + set_status_callback(callback); + capture_.set_status_callback(callback); } void VolumeRead(arg_t arg, callback_t callback) { (void)arg; - int val = read_system_volume(); + int val = mixer_.read_volume(); report(callback, val >= 0 ? 0 : -1, std::to_string(val)); } - void VolumeWrite(arg_t arg, callback_t callback) + void VolumeWrite(int val, callback_t callback) { - int val = parse_volume_arg(arg); - int ret = write_system_volume(val); + int ret = mixer_.write_volume(val); report(callback, ret >= 0 ? 0 : -1, std::to_string(ret)); } void MuteRead(arg_t arg, callback_t callback) { (void)arg; - int val = read_system_mute(); + int val = mixer_.read_mute(); report(callback, val >= 0 ? 0 : -1, std::to_string(val)); } void MuteToggle(arg_t arg, callback_t callback) { (void)arg; - int val = toggle_system_mute(); + int val = mixer_.toggle_mute(); report(callback, val >= 0 ? 0 : -1, std::to_string(val)); } void api_call(arg_t arg, callback_t callback) { - if(arg.empty()) - { - report(callback, -1, "empty audio api\n"); + cp0::audio::ApiRequest request; + if(!cp0::audio::parse_api_request(arg, request)) { + report(callback, -1, cp0::audio::invalid_api_request_message()); return; } #define map_fun(name) {#name, std::bind(&AudioSystem::name, this, std::placeholders::_1, std::placeholders::_2)} @@ -784,16 +422,26 @@ class AudioSystem map_fun(CapFileSave), map_fun(SetCallback), map_fun(VolumeRead), - map_fun(VolumeWrite), map_fun(MuteRead), map_fun(MuteToggle), map_fun(SetSystemSoundNames), - map_fun(SystemSoundPlay), - map_fun(SystemSoundEnable) }; #undef map_fun + if(request.command == cp0::audio::ApiCommand::VolumeWrite) { + VolumeWrite(request.value, callback); + return; + } + if(request.command == cp0::audio::ApiCommand::SystemSoundPlay) { + SystemSoundPlay(request.value, callback); + return; + } + if(request.command == cp0::audio::ApiCommand::SystemSoundEnable) { + SystemSoundEnable(request.enabled, callback); + return; + } + for (const auto& it : cmd_map) { if (it.first == arg.front()) @@ -805,86 +453,79 @@ class AudioSystem report(callback, -1, "unknown audio api\n"); } - static int read_system_volume() - { - FILE *p = popen("pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null", "r"); - if (!p) return -1; - char buf[256]; - int val = -1; - while (fgets(buf, sizeof(buf), p)) { - char *pct = strchr(buf, '%'); - if (pct) { - char *start = pct; - while (start > buf && start[-1] >= '0' && start[-1] <= '9') { - --start; - } - val = clamp_percent(atoi(start)); - break; - } - } - pclose(p); - return val; - } +}; - static int write_system_volume(int val) - { - val = clamp_percent(val); +namespace { - char cmd[128]; - snprintf(cmd, sizeof(cmd), "pactl set-sink-volume @DEFAULT_SINK@ %d%%", val); - return system(cmd) == 0 ? val : -1; - } +using AudioRegistrations = cp0::SignalRegistrationBundle< + decltype(cp0_signal_audio_play), decltype(cp0_signal_audio_cap), + decltype(cp0_signal_audio_setup), decltype(cp0_signal_audio_api), + decltype(cp0_signal_system_play)>; - static int read_system_mute() - { - FILE *p = popen("pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null", "r"); - if (!p) return -1; - char buf[128] = {}; - int muted = -1; - while (fgets(buf, sizeof(buf), p)) { - if (strstr(buf, "yes")) { muted = 1; break; } - if (strstr(buf, "no")) { muted = 0; break; } - } - pclose(p); - return muted; - } +std::mutex g_audio_mutex; +std::shared_ptr g_audio; +AudioRegistrations g_audio_registrations; +cp0::audio::RuntimeLifecycle g_audio_lifecycle; - static int toggle_system_mute() - { - if (system("pactl set-sink-mute @DEFAULT_SINK@ toggle >/dev/null 2>&1") != 0) - return -1; - return read_system_mute(); - } - - static int parse_volume_arg(const arg_t& arg) - { - std::string value = first_arg_after_command(arg); - if (value.empty()) return 0; - return clamp_percent(std::atoi(value.c_str())); - } - - static int clamp_percent(int pct) - { - return std::max(0, std::min(100, pct)); - } -}; +} // namespace extern "C" void init_audio(void) { - std::shared_ptr audio = std::make_shared(); - cp0_signal_audio_play.append([audio](std::string wav) - { audio->play(wav); }); - - cp0_signal_audio_cap.append([audio](bool enable) - { audio->cap(enable); }); - - cp0_signal_audio_setup.append([audio](std::list arg, std::function callback) - { audio->setup(arg, callback); }); - - cp0_signal_audio_api.append([audio](std::list arg, std::function callback) - { audio->api_call(arg, callback); }); - - cp0_signal_system_play.append([audio](std::string name) - { audio->system_play(name); }); + std::lock_guard lock(g_audio_mutex); + if (!g_audio_lifecycle.begin_init()) return; + try { + auto active_audio = std::make_shared(); + if (!g_audio_registrations.replace( + cp0::bind_signal(cp0_signal_audio_play, + [active_audio](std::string wav) { active_audio->play(std::move(wav)); }), + cp0::bind_signal(cp0_signal_audio_cap, + [active_audio](bool enable) { active_audio->cap(enable); }), + cp0::bind_signal(cp0_signal_audio_setup, + [active_audio](std::list arg, + std::function callback) { + try { + active_audio->setup(std::move(arg), callback); + } catch (...) { + cp0::audio::invoke_callback( + callback, -1, "audio backend failure\n"); + } + }), + cp0::bind_signal(cp0_signal_audio_api, + [active_audio](std::list arg, + std::function callback) { + try { + active_audio->api_call(std::move(arg), callback); + } catch (...) { + cp0::audio::invoke_callback( + callback, -1, "audio backend failure\n"); + } + }), + cp0::bind_signal(cp0_signal_system_play, + [active_audio](std::string name) { + active_audio->system_play(std::move(name)); + }))) { + g_audio_lifecycle.rollback_init(); + return; + } + g_audio = std::move(active_audio); + g_audio_lifecycle.commit_init(); + } catch (...) { + g_audio_registrations.reset(); + g_audio.reset(); + g_audio_lifecycle.rollback_init(); + } +} +extern "C" void deinit_audio(void) noexcept +{ + std::shared_ptr audio; + try { + std::lock_guard lock(g_audio_mutex); + if (!g_audio_lifecycle.begin_shutdown()) return; + g_audio_registrations.reset(); + audio = std::move(g_audio); + } catch (...) { + return; + } + if (audio) audio->shutdown(); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_battery.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_battery.cpp index 4a6722e5..903ce53a 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_battery.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_battery.cpp @@ -2,40 +2,63 @@ #include "lvgl/lvgl.h" #include "cp0_lvgl.h" #include "cp0_lvgl_app.h" +#include "../cp0_battery_lifecycle.hpp" +#include "../cp0_battery_publish_gate.hpp" +#include "../cp0_signal_registration.hpp" + #include #include -class BatterySystem -{ -public: - void pub() - { - if (lv_c_event[CP0_C_EVENT_BATTERY] == 0) - return; +namespace { +void publish_battery() +{ + static cp0::battery::PublishGate gate; + gate.run([] { + const uint32_t event_code = lv_c_event[CP0_C_EVENT_BATTERY]; + if (event_code == 0) return; cp0_battery_info_t info = cp0_battery_read(); - lv_obj_t *root = lv_display_get_screen_active(NULL); - if (root != NULL) - lv_obj_send_event(root, (lv_event_code_t)lv_c_event[CP0_C_EVENT_BATTERY], (void *)&info); - } -}; + lv_obj_t *root = lv_display_get_screen_active(nullptr); + if (root) + lv_obj_send_event(root, static_cast(event_code), &info); + }); +} -static void battery_timer_cb(lv_timer_t *timer) +void battery_timer_cb(lv_timer_t *) { - auto *battery = static_cast(lv_timer_get_user_data(timer)); - if (battery != nullptr) - battery->pub(); + publish_battery(); } +cp0::battery::Lifecycle &battery_lifecycle() +{ + static cp0::battery::Lifecycle lifecycle; + return lifecycle; +} + +} // namespace + extern "C" void init_battery() { - static std::shared_ptr battery; - if (battery) - return; - - battery = std::make_shared(); - BatterySystem *battery_ptr = battery.get(); - cp0_signal_battery_pub.append([battery_ptr](std::function fun) - { battery_ptr->pub(); }); - lv_timer_create(battery_timer_cb, 3000, battery_ptr); + battery_lifecycle().start( + [] { + using Registration = cp0::SignalRegistration; + auto registration = std::make_shared(); + const bool registered = registration->replace( + cp0_signal_battery_pub, [](std::function) { publish_battery(); }); + return cp0::battery::LifecycleResource{ + registered, [registration] { registration->reset(); }}; + }, + [] { + lv_timer_t *timer = lv_timer_create(battery_timer_cb, 3000, nullptr); + return cp0::battery::LifecycleResource{ + timer != nullptr, [timer] { + cp0::battery::release_timer_if_runtime_active( + timer, lv_is_initialized(), lv_timer_delete); + }}; + }); +} + +extern "C" void deinit_battery() +{ + battery_lifecycle().stop(); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_bluetooth.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_bluetooth.cpp index 925b99b9..0dd7f6c0 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_bluetooth.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_bluetooth.cpp @@ -1,748 +1,75 @@ -#include "cp0_lvgl_app.h" -#include "cp0_lvgl_log.h" -#include "hal_lvgl_bsp.h" +#include "cp0_bluetooth_backend.hpp" +#include "cp0_bluez_dbus_client.hpp" -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include -extern "C" { -#include -} - -namespace { - -static constexpr const char *kBluezService = "org.bluez"; -static constexpr const char *kObjectManagerIface = "org.freedesktop.DBus.ObjectManager"; -static constexpr const char *kPropertiesIface = "org.freedesktop.DBus.Properties"; -static constexpr const char *kAdapterIface = "org.bluez.Adapter1"; -static constexpr const char *kDeviceIface = "org.bluez.Device1"; -static constexpr const char *kAgentManagerIface = "org.bluez.AgentManager1"; -static constexpr const char *kAgentIface = "org.bluez.Agent1"; -static constexpr const char *kAgentPath = "/com/cardputerzero/applaunch/agent"; -static constexpr int kCallTimeoutMs = 15000; - -struct DeviceInfo { - std::string path; - cp0_bt_device_t dev{}; -}; +namespace cp0_bluetooth_backend { -struct ManagedSnapshot { - std::string adapter_path; - cp0_bt_status_t status{}; - std::vector devices; -}; - -static void copy_string(char *dst, size_t dst_size, const std::string &src) +cp0_bt_status_t status() { - if (!dst || dst_size == 0) - return; - std::strncpy(dst, src.c_str(), dst_size - 1); - dst[dst_size - 1] = '\0'; + return cp0_bluez_dbus::status(); } -static bool is_bt_address(const char *address) +int set_power(int enabled) { - if (!address || std::strlen(address) != 17) - return false; - for (int i = 0; i < 17; ++i) { - unsigned char ch = static_cast(address[i]); - if ((i + 1) % 3 == 0) { - if (ch != ':') - return false; - } else if (!std::isxdigit(ch)) { - return false; - } - } - return true; + return cp0_bluez_dbus::set_power(enabled); } -static std::string uppercase_address(const char *address) +int set_alias(const char *alias) { - std::string out(address ? address : ""); - std::transform(out.begin(), out.end(), out.begin(), [](unsigned char ch) { - return static_cast(std::toupper(ch)); - }); - return out; + return cp0_bluez_dbus::set_alias(alias); } -static std::string nth_arg(const std::list &arg, size_t index) +int set_discoverable(int enabled) { - auto it = arg.begin(); - std::advance(it, std::min(index, arg.size())); - return it == arg.end() ? std::string() : *it; + return cp0_bluez_dbus::set_discoverable(enabled); } -static std::string encode_bt_status(const cp0_bt_status_t &st) +int start_discovery() { - std::ostringstream oss; - oss << st.powered << '\t' << st.address << '\t' << st.discoverable << '\t' << st.alias; - return oss.str(); + return cp0_bluez_dbus::start_discovery(); } -static std::string encode_bt_scan(const cp0_bt_device_t *devices, int count) +int stop_discovery() { - std::ostringstream oss; - for (int i = 0; devices && i < count; ++i) { - oss << devices[i].address << '\t' - << devices[i].rssi << '\t' - << devices[i].connected << '\t' - << devices[i].paired << '\t' - << devices[i].trusted << '\t' - << devices[i].name << '\n'; - } - return oss.str(); + return cp0_bluez_dbus::stop_discovery(); } -static void report(std::function callback, int code, const std::string &data) +int scan(cp0_bt_device_t *out, int max_devices) { - if (callback) - callback(code, data); + if (!out || max_devices <= 0) + return 0; + if (start_discovery() != 0) + return -1; + std::this_thread::sleep_for(std::chrono::seconds(4)); + stop_discovery(); + return list(out, max_devices, false); } -class ScopedError { -public: - ScopedError() { dbus_error_init(&err_); } - ~ScopedError() - { - if (dbus_error_is_set(&err_)) - dbus_error_free(&err_); - } - DBusError *get() { return &err_; } - bool is_set() const { return dbus_error_is_set(&err_); } - const char *message() const { return err_.message ? err_.message : "unknown"; } - -private: - DBusError err_{}; -}; - -class ScopedMessage { -public: - explicit ScopedMessage(DBusMessage *msg = nullptr) : msg_(msg) {} - ~ScopedMessage() - { - if (msg_) - dbus_message_unref(msg_); - } - DBusMessage *get() const { return msg_; } - DBusMessage *release() - { - DBusMessage *tmp = msg_; - msg_ = nullptr; - return tmp; - } - explicit operator bool() const { return msg_ != nullptr; } - -private: - DBusMessage *msg_ = nullptr; -}; - -static bool iter_is_type(DBusMessageIter *iter, int type) +int list(cp0_bt_device_t *out, int max_devices, bool connected_only) { - return dbus_message_iter_get_arg_type(iter) == type; + return cp0_bluez_dbus::list(out, max_devices, connected_only); } -static bool append_basic_variant(DBusMessageIter *iter, const char *signature, int type, const void *value) +int pair(const char *address) { - DBusMessageIter variant; - if (!dbus_message_iter_open_container(iter, DBUS_TYPE_VARIANT, signature, &variant)) - return false; - if (!dbus_message_iter_append_basic(&variant, type, value)) { - dbus_message_iter_abandon_container(iter, &variant); - return false; - } - return dbus_message_iter_close_container(iter, &variant); + return cp0_bluez_dbus::pair(address); } -class BluezDbusClient { -public: - ~BluezDbusClient() - { - if (conn_ && agent_registered_) { - unregister_agent(); - dbus_connection_unregister_object_path(conn_, kAgentPath); - } - if (conn_) - dbus_connection_unref(conn_); - } - - cp0_bt_status_t status() - { - ManagedSnapshot snapshot; - if (!get_managed_objects(snapshot)) - return {}; - return snapshot.status; - } - - int set_power(int on) - { - std::string adapter = adapter_path(); - if (adapter.empty()) - return -1; - dbus_bool_t powered = on ? TRUE : FALSE; - return set_property_bool(adapter.c_str(), kAdapterIface, "Powered", powered) ? 0 : -1; - } - - int set_discoverable(int on) - { - std::string adapter = adapter_path(); - if (adapter.empty()) - return -1; - dbus_bool_t discoverable = on ? TRUE : FALSE; - return set_property_bool(adapter.c_str(), kAdapterIface, "Discoverable", discoverable) ? 0 : -1; - } - - int set_alias(const char *alias) - { - if (!alias || !alias[0]) - return -1; - std::string adapter = adapter_path(); - if (adapter.empty()) - return -1; - return set_property_string(adapter.c_str(), kAdapterIface, "Alias", alias) ? 0 : -1; - } - - int start_discovery() - { - ManagedSnapshot snapshot; - if (!get_managed_objects(snapshot) || snapshot.adapter_path.empty()) - return -1; - if (!snapshot.status.powered) { - cp0_zmq_log("bt", "scan on rejected: adapter is powered off"); - return -1; - } - return call_no_args(snapshot.adapter_path.c_str(), kAdapterIface, "StartDiscovery") ? 0 : -1; - } - - int stop_discovery() - { - std::string adapter = adapter_path(); - if (adapter.empty()) - return 0; - return call_no_args(adapter.c_str(), kAdapterIface, "StopDiscovery") ? 0 : -1; - } - - int list(cp0_bt_device_t *out, int max_devices, bool connected_only) - { - if (!out || max_devices <= 0) - return 0; - ManagedSnapshot snapshot; - if (!get_managed_objects(snapshot)) - return -1; - int count = 0; - for (const DeviceInfo &info : snapshot.devices) { - if (count >= max_devices) - break; - if (!info.dev.address[0]) - continue; - if (connected_only && !info.dev.connected) - continue; - out[count++] = info.dev; - } - return count; - } - - int pair(const char *address) - { - if (!is_bt_address(address)) - return -1; - ensure_agent(); - return device_method(address, "Pair"); - } - - int connect(const char *address) - { - if (!is_bt_address(address)) - return -1; - ensure_agent(); - return device_method(address, "Connect"); - } - - int disconnect(const char *address) - { - if (!is_bt_address(address)) - return -1; - return device_method(address, "Disconnect"); - } - - int remove(const char *address) - { - if (!is_bt_address(address)) - return -1; - ManagedSnapshot snapshot; - if (!get_managed_objects(snapshot) || snapshot.adapter_path.empty()) - return -1; - std::string device = find_device_path(snapshot, address); - if (device.empty()) - return -1; - - ScopedMessage msg(dbus_message_new_method_call(kBluezService, snapshot.adapter_path.c_str(), - kAdapterIface, "RemoveDevice")); - if (!msg) - return -1; - DBusMessageIter iter; - dbus_message_iter_init_append(msg.get(), &iter); - const char *path = device.c_str(); - if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &path)) - return -1; - return send_blocking(msg.get()) ? 0 : -1; - } - -private: - DBusConnection *conn_ = nullptr; - bool object_registered_ = false; - bool agent_registered_ = false; - std::mutex mutex_; - - bool ensure_connection() - { - if (conn_) - return true; - ScopedError err; - conn_ = dbus_bus_get(DBUS_BUS_SYSTEM, err.get()); - if (err.is_set() || !conn_) { - cp0_zmq_logf("bt", "system dbus connect failed: %s", err.message()); - return false; - } - dbus_connection_set_exit_on_disconnect(conn_, FALSE); - return true; - } - - DBusMessage *send_with_reply(DBusMessage *msg) - { - if (!ensure_connection() || !msg) - return nullptr; - ScopedError err; - DBusMessage *reply = dbus_connection_send_with_reply_and_block(conn_, msg, kCallTimeoutMs, err.get()); - if (err.is_set()) { - cp0_zmq_logf("bt", "dbus call %s failed: %s", dbus_message_get_member(msg), err.message()); - return nullptr; - } - return reply; - } - - bool send_blocking(DBusMessage *msg) - { - ScopedMessage reply(send_with_reply(msg)); - if (!reply) - return false; - return dbus_message_get_type(reply.get()) != DBUS_MESSAGE_TYPE_ERROR; - } - - bool call_no_args(const char *path, const char *iface, const char *method) - { - ScopedMessage msg(dbus_message_new_method_call(kBluezService, path, iface, method)); - return msg && send_blocking(msg.get()); - } - - bool set_property_bool(const char *path, const char *iface, const char *property, dbus_bool_t value) - { - ScopedMessage msg(dbus_message_new_method_call(kBluezService, path, kPropertiesIface, "Set")); - if (!msg) - return false; - DBusMessageIter iter; - dbus_message_iter_init_append(msg.get(), &iter); - const char *iface_ptr = iface; - const char *prop_ptr = property; - if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &iface_ptr) || - !dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &prop_ptr) || - !append_basic_variant(&iter, DBUS_TYPE_BOOLEAN_AS_STRING, DBUS_TYPE_BOOLEAN, &value)) - return false; - return send_blocking(msg.get()); - } - - bool set_property_string(const char *path, const char *iface, const char *property, const char *value) - { - ScopedMessage msg(dbus_message_new_method_call(kBluezService, path, kPropertiesIface, "Set")); - if (!msg) - return false; - DBusMessageIter iter; - dbus_message_iter_init_append(msg.get(), &iter); - const char *iface_ptr = iface; - const char *prop_ptr = property; - const char *value_ptr = value ? value : ""; - if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &iface_ptr) || - !dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &prop_ptr) || - !append_basic_variant(&iter, DBUS_TYPE_STRING_AS_STRING, DBUS_TYPE_STRING, &value_ptr)) - return false; - return send_blocking(msg.get()); - } - - std::string adapter_path() - { - ManagedSnapshot snapshot; - if (!get_managed_objects(snapshot)) - return {}; - return snapshot.adapter_path; - } - - int device_method(const char *address, const char *method) - { - ManagedSnapshot snapshot; - if (!get_managed_objects(snapshot)) - return -1; - std::string path = find_device_path(snapshot, address); - if (path.empty()) - return -1; - return call_no_args(path.c_str(), kDeviceIface, method) ? 0 : -1; - } - - static std::string find_device_path(const ManagedSnapshot &snapshot, const char *address) - { - std::string want = uppercase_address(address); - for (const DeviceInfo &info : snapshot.devices) { - if (uppercase_address(info.dev.address) == want) - return info.path; - } - return {}; - } - - bool get_managed_objects(ManagedSnapshot &snapshot) - { - std::lock_guard lock(mutex_); - if (!ensure_connection()) - return false; - - ScopedMessage msg(dbus_message_new_method_call(kBluezService, "/", kObjectManagerIface, - "GetManagedObjects")); - if (!msg) - return false; - ScopedMessage reply(send_with_reply(msg.get())); - if (!reply) - return false; - - DBusMessageIter iter; - if (!dbus_message_iter_init(reply.get(), &iter) || !iter_is_type(&iter, DBUS_TYPE_ARRAY)) - return false; - parse_objects(&iter, snapshot); - return !snapshot.adapter_path.empty(); - } - - static void parse_objects(DBusMessageIter *objects_iter, ManagedSnapshot &snapshot) - { - DBusMessageIter objects; - dbus_message_iter_recurse(objects_iter, &objects); - while (dbus_message_iter_get_arg_type(&objects) == DBUS_TYPE_DICT_ENTRY) { - DBusMessageIter object_entry; - dbus_message_iter_recurse(&objects, &object_entry); - - const char *path = nullptr; - dbus_message_iter_get_basic(&object_entry, &path); - dbus_message_iter_next(&object_entry); - if (iter_is_type(&object_entry, DBUS_TYPE_ARRAY)) - parse_interfaces(path ? path : "", &object_entry, snapshot); - - dbus_message_iter_next(&objects); - } - } - - static void parse_interfaces(const char *path, DBusMessageIter *ifaces_iter, ManagedSnapshot &snapshot) - { - DBusMessageIter ifaces; - dbus_message_iter_recurse(ifaces_iter, &ifaces); - while (dbus_message_iter_get_arg_type(&ifaces) == DBUS_TYPE_DICT_ENTRY) { - DBusMessageIter iface_entry; - dbus_message_iter_recurse(&ifaces, &iface_entry); - - const char *iface = nullptr; - dbus_message_iter_get_basic(&iface_entry, &iface); - dbus_message_iter_next(&iface_entry); - - if (iface && iter_is_type(&iface_entry, DBUS_TYPE_ARRAY)) { - if (!std::strcmp(iface, kAdapterIface)) { - if (snapshot.adapter_path.empty()) - snapshot.adapter_path = path; - parse_adapter_properties(&iface_entry, snapshot.status); - } else if (!std::strcmp(iface, kDeviceIface)) { - DeviceInfo info; - info.path = path; - parse_device_properties(&iface_entry, info.dev); - snapshot.devices.push_back(info); - } - } - - dbus_message_iter_next(&ifaces); - } - } - - static void parse_adapter_properties(DBusMessageIter *props_iter, cp0_bt_status_t &status) - { - DBusMessageIter props; - dbus_message_iter_recurse(props_iter, &props); - while (dbus_message_iter_get_arg_type(&props) == DBUS_TYPE_DICT_ENTRY) { - DBusMessageIter entry; - dbus_message_iter_recurse(&props, &entry); - const char *name = nullptr; - dbus_message_iter_get_basic(&entry, &name); - dbus_message_iter_next(&entry); - if (name && iter_is_type(&entry, DBUS_TYPE_VARIANT)) { - DBusMessageIter value; - dbus_message_iter_recurse(&entry, &value); - if (!std::strcmp(name, "Powered") && iter_is_type(&value, DBUS_TYPE_BOOLEAN)) { - dbus_bool_t powered = FALSE; - dbus_message_iter_get_basic(&value, &powered); - status.powered = powered ? 1 : 0; - } else if (!std::strcmp(name, "Address") && iter_is_type(&value, DBUS_TYPE_STRING)) { - const char *address = ""; - dbus_message_iter_get_basic(&value, &address); - copy_string(status.address, sizeof(status.address), address ? address : ""); - } else if (!std::strcmp(name, "Discoverable") && iter_is_type(&value, DBUS_TYPE_BOOLEAN)) { - dbus_bool_t discoverable = FALSE; - dbus_message_iter_get_basic(&value, &discoverable); - status.discoverable = discoverable ? 1 : 0; - } else if (!std::strcmp(name, "Alias") && iter_is_type(&value, DBUS_TYPE_STRING)) { - const char *alias = ""; - dbus_message_iter_get_basic(&value, &alias); - copy_string(status.alias, sizeof(status.alias), alias ? alias : ""); - } - } - dbus_message_iter_next(&props); - } - } - - static void parse_device_properties(DBusMessageIter *props_iter, cp0_bt_device_t &dev) - { - DBusMessageIter props; - dbus_message_iter_recurse(props_iter, &props); - while (dbus_message_iter_get_arg_type(&props) == DBUS_TYPE_DICT_ENTRY) { - DBusMessageIter entry; - dbus_message_iter_recurse(&props, &entry); - const char *name = nullptr; - dbus_message_iter_get_basic(&entry, &name); - dbus_message_iter_next(&entry); - if (name && iter_is_type(&entry, DBUS_TYPE_VARIANT)) { - DBusMessageIter value; - dbus_message_iter_recurse(&entry, &value); - parse_device_property(name, &value, dev); - } - dbus_message_iter_next(&props); - } - } - - static void parse_device_property(const char *name, DBusMessageIter *value, cp0_bt_device_t &dev) - { - int type = dbus_message_iter_get_arg_type(value); - if (!std::strcmp(name, "Address") && type == DBUS_TYPE_STRING) { - const char *address = ""; - dbus_message_iter_get_basic(value, &address); - copy_string(dev.address, sizeof(dev.address), address ? address : ""); - } else if ((!std::strcmp(name, "Name") || (!dev.name[0] && !std::strcmp(name, "Alias"))) && - type == DBUS_TYPE_STRING) { - const char *text = ""; - dbus_message_iter_get_basic(value, &text); - copy_string(dev.name, sizeof(dev.name), text ? text : ""); - } else if (!std::strcmp(name, "RSSI") && type == DBUS_TYPE_INT16) { - dbus_int16_t rssi = 0; - dbus_message_iter_get_basic(value, &rssi); - dev.rssi = rssi; - } else if (!std::strcmp(name, "Connected") && type == DBUS_TYPE_BOOLEAN) { - dbus_bool_t v = FALSE; - dbus_message_iter_get_basic(value, &v); - dev.connected = v ? 1 : 0; - } else if (!std::strcmp(name, "Paired") && type == DBUS_TYPE_BOOLEAN) { - dbus_bool_t v = FALSE; - dbus_message_iter_get_basic(value, &v); - dev.paired = v ? 1 : 0; - } else if (!std::strcmp(name, "Trusted") && type == DBUS_TYPE_BOOLEAN) { - dbus_bool_t v = FALSE; - dbus_message_iter_get_basic(value, &v); - dev.trusted = v ? 1 : 0; - } - } - - void ensure_agent() - { - std::lock_guard lock(mutex_); - if (!ensure_connection()) - return; - register_object_path(); - register_agent(); - } - - void register_object_path() - { - if (object_registered_) - return; - static DBusObjectPathVTable vtable = { - nullptr, - BluezDbusClient::agent_message, - nullptr, - nullptr, - nullptr, - nullptr, - }; - object_registered_ = dbus_connection_register_object_path(conn_, kAgentPath, &vtable, this); - if (!object_registered_) - cp0_zmq_log("bt", "register agent object path failed"); - } - - void register_agent() - { - if (agent_registered_ || !object_registered_) - return; - ScopedMessage msg(dbus_message_new_method_call(kBluezService, "/org/bluez", - kAgentManagerIface, "RegisterAgent")); - if (!msg) - return; - DBusMessageIter iter; - dbus_message_iter_init_append(msg.get(), &iter); - const char *path = kAgentPath; - const char *capability = "NoInputNoOutput"; - if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &path) || - !dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &capability)) - return; - if (send_blocking(msg.get())) { - agent_registered_ = true; - request_default_agent(); - } - } - - void request_default_agent() - { - ScopedMessage msg(dbus_message_new_method_call(kBluezService, "/org/bluez", - kAgentManagerIface, "RequestDefaultAgent")); - if (!msg) - return; - DBusMessageIter iter; - dbus_message_iter_init_append(msg.get(), &iter); - const char *path = kAgentPath; - if (dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &path)) - send_blocking(msg.get()); - } - - void unregister_agent() - { - ScopedMessage msg(dbus_message_new_method_call(kBluezService, "/org/bluez", - kAgentManagerIface, "UnregisterAgent")); - if (!msg) - return; - DBusMessageIter iter; - dbus_message_iter_init_append(msg.get(), &iter); - const char *path = kAgentPath; - if (dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &path)) - send_blocking(msg.get()); - agent_registered_ = false; - } - - static DBusHandlerResult agent_message(DBusConnection *connection, DBusMessage *message, void *) - { - const char *iface = dbus_message_get_interface(message); - const char *member = dbus_message_get_member(message); - if (!iface || std::strcmp(iface, kAgentIface) != 0 || !member) - return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; - - ScopedMessage reply(create_agent_reply(message, member)); - if (!reply) - return DBUS_HANDLER_RESULT_NEED_MEMORY; - dbus_connection_send(connection, reply.get(), nullptr); - dbus_connection_flush(connection); - return DBUS_HANDLER_RESULT_HANDLED; - } - - static DBusMessage *create_agent_reply(DBusMessage *message, const char *member) - { - if (!std::strcmp(member, "RequestPinCode")) { - DBusMessage *reply = dbus_message_new_method_return(message); - if (!reply) - return nullptr; - const char *pin = "0000"; - if (!dbus_message_append_args(reply, DBUS_TYPE_STRING, &pin, DBUS_TYPE_INVALID)) { - dbus_message_unref(reply); - return nullptr; - } - return reply; - } - if (!std::strcmp(member, "RequestPasskey")) { - DBusMessage *reply = dbus_message_new_method_return(message); - if (!reply) - return nullptr; - dbus_uint32_t passkey = 0; - if (!dbus_message_append_args(reply, DBUS_TYPE_UINT32, &passkey, DBUS_TYPE_INVALID)) { - dbus_message_unref(reply); - return nullptr; - } - return reply; - } - return dbus_message_new_method_return(message); - } -}; - -static BluezDbusClient &client() +int connect(const char *address) { - static BluezDbusClient c; - return c; + return cp0_bluez_dbus::connect(address); } -static int bt_scan(cp0_bt_device_t *out, int max_devices) +int disconnect(const char *address) { - if (!out || max_devices <= 0) - return 0; - if (client().start_discovery() != 0) - return -1; - std::this_thread::sleep_for(std::chrono::seconds(4)); - client().stop_discovery(); - return client().list(out, max_devices, false); + return cp0_bluez_dbus::disconnect(address); } -static void bt_api_call(std::list arg, std::function callback) +int remove(const char *address) { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "BtStatus") { - report(callback, 0, encode_bt_status(client().status())); - } else if (cmd == "BtPower") { - report(callback, client().set_power(std::atoi(nth_arg(arg, 1).c_str())), ""); - } else if (cmd == "BtAlias") { - report(callback, client().set_alias(nth_arg(arg, 1).c_str()), ""); - } else if (cmd == "BtDiscoverable") { - report(callback, client().set_discoverable(std::atoi(nth_arg(arg, 1).c_str())), ""); - } else if (cmd == "BtScan") { - int max_count = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : CP0_BT_DEVICE_MAX; - std::vector devices(std::max(0, max_count)); - int count = bt_scan(devices.empty() ? nullptr : devices.data(), static_cast(devices.size())); - report(callback, count, encode_bt_scan(devices.data(), count)); - } else if (cmd == "BtDiscoveryStart") { - report(callback, client().start_discovery(), ""); - } else if (cmd == "BtDiscoveryStop") { - report(callback, client().stop_discovery(), ""); - } else if (cmd == "BtList") { - int max_count = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : CP0_BT_DEVICE_MAX; - std::vector devices(std::max(0, max_count)); - int count = client().list(devices.empty() ? nullptr : devices.data(), static_cast(devices.size()), false); - report(callback, count, encode_bt_scan(devices.data(), count)); - } else if (cmd == "BtConnectedList") { - int max_count = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : CP0_BT_DEVICE_MAX; - std::vector devices(std::max(0, max_count)); - int count = client().list(devices.empty() ? nullptr : devices.data(), static_cast(devices.size()), true); - report(callback, count, encode_bt_scan(devices.data(), count)); - } else if (cmd == "BtPair") { - report(callback, client().pair(nth_arg(arg, 1).c_str()), ""); - } else if (cmd == "BtConnect") { - report(callback, client().connect(nth_arg(arg, 1).c_str()), ""); - } else if (cmd == "BtDisconnect") { - report(callback, client().disconnect(nth_arg(arg, 1).c_str()), ""); - } else if (cmd == "BtRemove") { - report(callback, client().remove(nth_arg(arg, 1).c_str()), ""); - } else { - report(callback, -1, "unknown bt api command"); - } + return cp0_bluez_dbus::remove(address); } -} // namespace - -extern "C" void init_bluetooth(void) -{ - cp0_signal_bt_api.append([](std::list arg, std::function callback) { - bt_api_call(std::move(arg), std::move(callback)); - }); -} +} // namespace cp0_bluetooth_backend diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_bq27220.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_bq27220.cpp index ad89335a..0ee7d1b1 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_bq27220.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_bq27220.cpp @@ -1,20 +1,23 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" +#include "../cp0_battery_api_contract.hpp" +#include "../cp0_callback_result.hpp" +#include "../cp0_battery_codec.hpp" +#include "../cp0_battery_lifecycle.hpp" +#include "../cp0_signal_registration.hpp" #include "../cp0_battery_testable.hpp" +#include "../cp0_callback_contract.hpp" #include -#include -#include #include #include #include #include #include #include -#include #include #include -#include +#include #include #include #include @@ -33,21 +36,6 @@ namespace { - constexpr int kBatteryCurrentMaxMa = 5000; - - static bool is_charging_status(const char *status) - { - return std::strcmp(status, "Charging") == 0; - } - - static int sanitize_battery_current_ma(int current_ma, bool) - { - if (std::abs(current_ma) > kBatteryCurrentMaxMa) { - return INT32_MIN; // sentinel: value out of plausible range - } - return current_ma; - } - class Bq27220System { public: @@ -56,16 +44,17 @@ class Bq27220System void api_call(arg_t arg, callback_t callback) { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "Read") { - cp0_battery_info_t info = read(); - report(callback, info.valid ? 0 : -1, encode(info)); - } else if (cmd == "Calibrate") { - const int index = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : -1; - report(callback, calibrate(index), ""); - } else { - report(callback, -1, "unknown bq27220 api command"); - } + cp0::CallbackResult result(std::move(callback)); + result.guard(-1, "battery api failure", [&] { + cp0::battery::ApiReply reply; + { + std::lock_guard lock(mutex_); + reply = cp0::battery::dispatch_api_request( + arg, [this] { return read(); }, + [this](int index) { return calibrate(index); }); + } + result.complete(reply.code, reply.data); + }); } cp0_battery_info_t read() @@ -91,23 +80,9 @@ class Bq27220System ok = ok && read_string(path, status, sizeof(status)); if (ok) { - const bool is_charging = is_charging_status(status); - // power_supply convention: negative = discharging, positive = charging - const double current_ma = current_raw / 1000.0; - const int rounded_current_ma = sanitize_battery_current_ma(round_to_int(current_ma), is_charging); - if (cp0_battery_testable::measurement_is_valid( - static_cast(present), static_cast(capacity), - rounded_current_ma, static_cast(temp_raw)) && - cp0_battery_testable::power_supply_status_is_known(status)) { - info.soc = static_cast(capacity); - info.voltage_mv = static_cast(voltage_uv / 1000); - info.current_ma = rounded_current_ma; - info.avg_current_ma = rounded_current_ma; - info.temperature_c10 = static_cast(temp_raw); - info.flags = is_charging ? 1 : 0; - info.valid = 1; - return info; - } + return cp0::battery::from_power_supply( + static_cast(present), static_cast(capacity), + voltage_uv, current_raw, static_cast(temp_raw), status); } } @@ -142,7 +117,7 @@ class Bq27220System int ret = ioctl(fd, I2C_RDWR, &data); int saved_errno = errno; close(fd); - return ret == 0 ? 0 : -saved_errno; + return ret >= 0 ? 0 : -saved_errno; #else (void)command_index; return -1; @@ -150,66 +125,11 @@ class Bq27220System } private: + std::mutex mutex_; + static constexpr const char *kI2cDev = "/dev/i2c-1"; static constexpr int kI2cAddr = 0x55; - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) { - callback(code, data); - } - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - std::advance(it, static_cast(std::min(index, arg.size()))); - return it == arg.end() ? std::string() : *it; - } - - static int round_to_int(double value) - { - return static_cast(value >= 0 ? value + 0.5 : value - 0.5); - } - - static std::string encode(const cp0_battery_info_t &info) - { - std::ostringstream os; - os << info.voltage_mv << ',' - << info.current_ma << ',' - << info.temperature_c10 << ',' - << info.soc << ',' - << info.remain_mah << ',' - << info.full_mah << ',' - << info.flags << ',' - << info.avg_current_ma << ',' - << info.valid; - return os.str(); - } - - static bool decode(const std::string &data, cp0_battery_info_t *info) - { - if (!info) { - return false; - } - cp0_battery_info_t parsed{}; - char comma; - std::istringstream is(data); - if (is >> parsed.voltage_mv >> comma && - is >> parsed.current_ma >> comma && - is >> parsed.temperature_c10 >> comma && - is >> parsed.soc >> comma && - is >> parsed.remain_mah >> comma && - is >> parsed.full_mah >> comma && - is >> parsed.flags >> comma && - is >> parsed.avg_current_ma >> comma && - is >> parsed.valid) { - *info = parsed; - return true; - } - return false; - } - static int read_long(const char *path, long *value) { if (!path || !value) return 0; @@ -252,9 +172,9 @@ class Bq27220System static int has_file(const char *dir, const char *name) { - char path[320]; - std::snprintf(path, sizeof(path), "%s/%s", dir, name); - return access(path, R_OK) == 0; + if (!dir || !name) return 0; + const std::string path = std::string(dir) + "/" + name; + return access(path.c_str(), R_OK) == 0; } static int find_power_supply(char *out, size_t out_len) @@ -263,50 +183,45 @@ class Bq27220System DIR *dp = opendir(base); if (!dp) return 0; - char fallback[320] = {0}; + std::string fallback; struct dirent *ent = nullptr; while ((ent = readdir(dp)) != nullptr) { if (ent->d_name[0] == '.') continue; - char dir[320]; - std::snprintf(dir, sizeof(dir), "%s/%s", base, ent->d_name); + const std::string dir = std::string(base) + "/" + ent->d_name; char type[64] = {0}; - char type_path[384]; - std::snprintf(type_path, sizeof(type_path), "%s/type", dir); - if (!read_string(type_path, type, sizeof(type)) || + const std::string type_path = dir + "/type"; + if (!read_string(type_path.c_str(), type, sizeof(type)) || !cp0_battery_testable::power_supply_type_is_battery(type) || - !has_file(dir, "present") || - !has_file(dir, "capacity") || - !has_file(dir, "voltage_now") || - (!has_file(dir, "current_instant") && !has_file(dir, "current_now")) || - !has_file(dir, "temp") || - !has_file(dir, "status")) { + !has_file(dir.c_str(), "present") || + !has_file(dir.c_str(), "capacity") || + !has_file(dir.c_str(), "voltage_now") || + (!has_file(dir.c_str(), "current_instant") && !has_file(dir.c_str(), "current_now")) || + !has_file(dir.c_str(), "temp") || + !has_file(dir.c_str(), "status")) { continue; } if (std::strstr(ent->d_name, "bq27220") || std::strstr(ent->d_name, "bq27")) { - std::snprintf(out, out_len, "%s", dir); + if (dir.size() >= out_len) { + closedir(dp); + return 0; + } + std::memcpy(out, dir.c_str(), dir.size() + 1); closedir(dp); return 1; } - if (fallback[0] == 0) { - std::snprintf(fallback, sizeof(fallback), "%s", dir); - } + if (fallback.empty()) fallback = dir; } closedir(dp); - if (fallback[0]) { - std::snprintf(out, out_len, "%s", fallback); + if (!fallback.empty() && fallback.size() < out_len) { + std::memcpy(out, fallback.c_str(), fallback.size() + 1); return 1; } return 0; } -public: - static bool decode_info(const std::string &data, cp0_battery_info_t *info) - { - return decode(data, info); - } }; } // namespace @@ -316,30 +231,48 @@ extern "C" { cp0_battery_info_t cp0_battery_read(void) { cp0_battery_info_t info{}; - cp0_signal_bq27220_api({"Read"}, [&](int code, std::string data) { - if (code == 0) { - Bq27220System::decode_info(data, &info); - } - }); + try { + cp0_signal_bq27220_api({"Read"}, [&](int code, std::string data) { + if (code == 0) cp0::battery::decode_info(data, &info); + }); + } catch (...) { + info = {}; + } return info; } int cp0_bq27220_calibrate(int command_index) { int ret = -1; - cp0_signal_bq27220_api({"Calibrate", std::to_string(command_index)}, [&](int code, std::string data) { - (void)data; - ret = code; - }); + try { + cp0_signal_bq27220_api({"Calibrate", std::to_string(command_index)}, [&](int code, std::string data) { + (void)data; + ret = code; + }); + } catch (...) { + ret = -1; + } return ret; } void init_bq27220(void) { - std::shared_ptr bq27220 = std::make_shared(); - cp0_signal_bq27220_api.append([bq27220](std::list arg, std::function callback) { - bq27220->api_call(std::move(arg), std::move(callback)); - }); + static cp0::battery::Lifecycle lifecycle; + lifecycle.start( + [] { + auto bq27220 = std::make_shared(); + using Registration = cp0::SignalRegistration; + auto registration = std::make_shared(); + const bool registered = registration->replace( + cp0_signal_bq27220_api, + [bq27220](std::list arg, + std::function callback) { + bq27220->api_call(std::move(arg), std::move(callback)); + }); + return cp0::battery::LifecycleResource{ + registered, [registration] { registration->reset(); }}; + }, + [] { return cp0::battery::LifecycleResource{true, {}}; }); } } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_camera.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_camera.cpp index 9365ae36..8220a23e 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_camera.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_camera.cpp @@ -1,11 +1,15 @@ #include "hal_lvgl_bsp.h" +#include "cp0_camera_frame_codec.hpp" +#include "cp0_camera_viewport.hpp" +#include "../cp0_camera_api_contract.hpp" +#include "../cp0_callback_contract.hpp" +#include "../cp0_signal_registration.hpp" #include #include #include #include #include -#include #include #include #include @@ -19,9 +23,7 @@ #include #include -#if __has_include() && __has_include() -#define CP0_CAMERA_HAS_LIBCAMERA 1 -#include +#if CP0_CAMERA_HAS_LIBCAMERA #include #include #include @@ -36,8 +38,6 @@ #include #include #include -#else -#define CP0_CAMERA_HAS_LIBCAMERA 0 #endif /* @@ -62,6 +62,14 @@ class CameraSystem close_camera(); } + void shutdown() + { + close_camera(); + std::lock_guard lock(mutex_); + status_callback_ = {}; + frame_callback_ = {}; + } + void api_call(arg_t arg, callback_t callback) { if (arg.empty()) @@ -126,9 +134,7 @@ class CameraSystem int stream_stride_ = 320 * 2; libcamera::PixelFormat stream_format_ = libcamera::formats::RGB565; libcamera::Rectangle scaler_crop_max_{}; - int zoom_percent_ = 100; - int view_x_percent_ = 50; - int view_y_percent_ = 50; + cp0_camera::Viewport viewport_; std::atomic capture_requested_{false}; std::string pending_capture_path_; @@ -138,10 +144,11 @@ class CameraSystem void report(callback_t callback, int code, const std::string &data) { - if (callback) - callback(code, data); - else if (status_callback_) - status_callback_(code, data); + if (!callback) { + std::lock_guard lock(mutex_); + callback = status_callback_; + } + cp0::callback::invoke(callback, code, data); } static std::string nth_arg(const arg_t &arg, size_t n) @@ -153,33 +160,32 @@ class CameraSystem return *it; } - static int to_int(const std::string &value, int fallback) - { - if (value.empty()) - return fallback; - char *end = nullptr; - long ret = std::strtol(value.c_str(), &end, 10); - return end && *end == '\0' ? static_cast(ret) : fallback; - } - void SetCallback(arg_t arg, callback_t callback) { (void)arg; - status_callback_ = callback; + { + std::lock_guard lock(mutex_); + status_callback_ = callback; + } report(callback, 0, "camera callback set\n"); } void SetFrameCallback(arg_t arg, callback_t callback) { (void)arg; - frame_callback_ = callback; + { + std::lock_guard lock(mutex_); + frame_callback_ = callback; + } report(callback, 0, "camera frame callback set\n"); } void Open(arg_t arg, callback_t callback) { - const int width = to_int(nth_arg(arg, 1), 320); - const int height = to_int(nth_arg(arg, 2), 150); + const int width = cp0_camera_api::parse_integer_argument( + nth_arg(arg, 1), cp0_camera_api::DEFAULT_WIDTH); + const int height = cp0_camera_api::parse_integer_argument( + nth_arg(arg, 2), cp0_camera_api::DEFAULT_HEIGHT); const int ret = open_camera(width, height); report(callback, ret, ret == 0 ? "camera open\n" : "camera open failed\n"); } @@ -204,8 +210,10 @@ class CameraSystem void Capture(arg_t arg, callback_t callback) { std::string path = nth_arg(arg, 1); - const int width = to_int(nth_arg(arg, 2), 320); - const int height = to_int(nth_arg(arg, 3), 150); + const int width = cp0_camera_api::parse_integer_argument( + nth_arg(arg, 2), cp0_camera_api::DEFAULT_WIDTH); + const int height = cp0_camera_api::parse_integer_argument( + nth_arg(arg, 3), cp0_camera_api::DEFAULT_HEIGHT); const int ret = capture(path, width, height, callback); if (ret != 0) @@ -236,9 +244,13 @@ class CameraSystem { (void)arg; #if CP0_CAMERA_HAS_LIBCAMERA - std::lock_guard lock(mutex_); - zoom_percent_ = zoom_percent_ < 250 ? 250 : 500; - report(callback, 0, zoom_status_text_locked()); + std::string payload; + { + std::lock_guard lock(mutex_); + viewport_.zoom_in(); + payload = viewport_.status_text(); + } + report(callback, 0, payload); #else report(callback, -10, "camera unavailable: libcamera/jpeg headers not found\n"); #endif @@ -248,13 +260,13 @@ class CameraSystem { (void)arg; #if CP0_CAMERA_HAS_LIBCAMERA - std::lock_guard lock(mutex_); - zoom_percent_ = zoom_percent_ > 250 ? 250 : 100; - if (zoom_percent_ == 100) { - view_x_percent_ = 50; - view_y_percent_ = 50; + std::string payload; + { + std::lock_guard lock(mutex_); + viewport_.zoom_out(); + payload = viewport_.status_text(); } - report(callback, 0, zoom_status_text_locked()); + report(callback, 0, payload); #else report(callback, -10, "camera unavailable: libcamera/jpeg headers not found\n"); #endif @@ -263,14 +275,15 @@ class CameraSystem void Pan(arg_t arg, callback_t callback) { #if CP0_CAMERA_HAS_LIBCAMERA - const int dx = to_int(nth_arg(arg, 1), 0); - const int dy = to_int(nth_arg(arg, 2), 0); - std::lock_guard lock(mutex_); - if (zoom_percent_ > 100) { - view_x_percent_ = std::max(0, std::min(100, view_x_percent_ + dx * 8)); - view_y_percent_ = std::max(0, std::min(100, view_y_percent_ + dy * 8)); + const int dx = cp0_camera_api::parse_integer_argument(nth_arg(arg, 1), 0); + const int dy = cp0_camera_api::parse_integer_argument(nth_arg(arg, 2), 0); + std::string payload; + { + std::lock_guard lock(mutex_); + viewport_.pan(dx, dy); + payload = viewport_.status_text(); } - report(callback, 0, zoom_status_text_locked()); + report(callback, 0, payload); #else (void)arg; report(callback, -10, "camera unavailable: libcamera/jpeg headers not found\n"); @@ -308,14 +321,6 @@ class CameraSystem return std::string(path); } - static bool is_supported_preview_format(const libcamera::PixelFormat &format) - { - return format == libcamera::formats::RGB888 || - format == libcamera::formats::BGR888 || - format == libcamera::formats::XRGB8888 || - format == libcamera::formats::XBGR8888 || - format == libcamera::formats::RGB565; - } static void dma_buf_sync(int fd, uint64_t flags) { @@ -325,44 +330,16 @@ class CameraSystem ioctl(fd, DMA_BUF_IOCTL_SYNC, &sync); } - static void rgb565_to_rgb888(uint16_t p, uint8_t &r, uint8_t &g, uint8_t &b) - { - r = ((p >> 11) & 0x1F) << 3; - g = ((p >> 5) & 0x3F) << 2; - b = (p & 0x1F) << 3; - r |= r >> 5; - g |= g >> 6; - b |= b >> 5; - } - - static uint16_t rgb888_to_rgb565(uint8_t r, uint8_t g, uint8_t b) - { - return static_cast(((r & 0xF8) << 8) | - ((g & 0xFC) << 3) | - (b >> 3)); - } - - std::string zoom_status_text_locked() const - { - char buf[96]; - std::snprintf(buf, sizeof(buf), "ZOOM %d %d %d\n", zoom_percent_, view_x_percent_, view_y_percent_); - return std::string(buf); - } libcamera::Rectangle crop_rect_locked() const { - if (zoom_percent_ <= 100 || scaler_crop_max_.width <= 0 || scaler_crop_max_.height <= 0) - return scaler_crop_max_; - - const int max_width = static_cast(scaler_crop_max_.width); - const int max_height = static_cast(scaler_crop_max_.height); - int crop_w = std::max(1, max_width * 100 / zoom_percent_); - int crop_h = std::max(1, max_height * 100 / zoom_percent_); - int max_x = std::max(0, max_width - crop_w); - int max_y = std::max(0, max_height - crop_h); - int x = scaler_crop_max_.x + max_x * std::max(0, std::min(100, view_x_percent_)) / 100; - int y = scaler_crop_max_.y + max_y * std::max(0, std::min(100, view_y_percent_)) / 100; - return libcamera::Rectangle(x, y, crop_w, crop_h); + const cp0_camera::CropRectangle crop = viewport_.crop({ + scaler_crop_max_.x, + scaler_crop_max_.y, + static_cast(scaler_crop_max_.width), + static_cast(scaler_crop_max_.height), + }); + return libcamera::Rectangle(crop.x, crop.y, crop.width, crop.height); } void apply_crop_locked(libcamera::Request *request) const @@ -372,122 +349,6 @@ class CameraSystem request->controls().set(libcamera::controls::ScalerCrop, crop_rect_locked()); } - bool convert_to_rgb565(const uint8_t *src, size_t bytes_used, std::vector &rgb565) - { - std::vector rgb; - if (!convert_to_rgb888(src, bytes_used, rgb)) - return false; - rgb565.assign(stream_w_ * stream_h_, 0); - for (int i = 0; i < stream_w_ * stream_h_; ++i) - rgb565[i] = rgb888_to_rgb565(rgb[i * 3 + 0], rgb[i * 3 + 1], rgb[i * 3 + 2]); - return true; - } - - std::string make_frame_payload(const std::vector &rgb565, int width, int height) - { - std::string payload; - char header[64]; - const int header_len = std::snprintf(header, sizeof(header), "FRAME %d %d RGB565\n", width, height); - payload.assign(header, header_len); - payload.append(reinterpret_cast(rgb565.data()), rgb565.size() * sizeof(uint16_t)); - return payload; - } - - bool convert_to_rgb888(const uint8_t *src, size_t bytes_used, std::vector &rgb) - { - const bool is_rgb888 = stream_format_ == libcamera::formats::RGB888; - const bool is_bgr888 = stream_format_ == libcamera::formats::BGR888; - const bool is_xrgb8888 = stream_format_ == libcamera::formats::XRGB8888; - const bool is_xbgr8888 = stream_format_ == libcamera::formats::XBGR8888; - const bool is_rgb565 = stream_format_ == libcamera::formats::RGB565; - const int bytes_per_pixel = is_rgb888 || is_bgr888 ? 3 : is_rgb565 ? 2 : 4; - const int min_stride = stream_w_ * bytes_per_pixel; - const int row_stride = stream_stride_ > 0 ? stream_stride_ : min_stride; - - if (row_stride < min_stride) - return false; - - rgb.assign(stream_w_ * stream_h_ * 3, 0); - for (int y = 0; y < stream_h_; ++y) - { - const size_t row_offset = static_cast(y) * row_stride; - if (row_offset + min_stride > bytes_used) - return y > 0; - - const uint8_t *line = src + row_offset; - const int dst_y = stream_h_ - 1 - y; - for (int x = 0; x < stream_w_; ++x) - { - const int dst_x = stream_w_ - 1 - x; - const int dst = (dst_y * stream_w_ + dst_x) * 3; - uint8_t r = 0, g = 0, b = 0; - - if (is_rgb888) - { - const uint8_t *p = line + x * 3; - r = p[0]; g = p[1]; b = p[2]; - } - else if (is_bgr888) - { - const uint8_t *p = line + x * 3; - b = p[0]; g = p[1]; r = p[2]; - } - else if (is_xrgb8888) - { - const uint8_t *p = line + x * 4; - b = p[0]; g = p[1]; r = p[2]; - } - else if (is_xbgr8888) - { - const uint8_t *p = line + x * 4; - r = p[0]; g = p[1]; b = p[2]; - } - else if (is_rgb565) - { - const uint8_t *p = line + x * 2; - rgb565_to_rgb888(static_cast(p[0] | (p[1] << 8)), r, g, b); - } - - rgb[dst + 0] = r; - rgb[dst + 1] = g; - rgb[dst + 2] = b; - } - } - return true; - } - - static bool save_jpeg_rgb888(const std::string &path, const uint8_t *rgb, int width, int height, int quality = 90) - { - FILE *fp = std::fopen(path.c_str(), "wb"); - if (!fp) - return false; - - jpeg_compress_struct cinfo; - jpeg_error_mgr jerr; - cinfo.err = jpeg_std_error(&jerr); - jpeg_create_compress(&cinfo); - jpeg_stdio_dest(&cinfo, fp); - cinfo.image_width = width; - cinfo.image_height = height; - cinfo.input_components = 3; - cinfo.in_color_space = JCS_RGB; - jpeg_set_defaults(&cinfo); - jpeg_set_quality(&cinfo, quality, TRUE); - jpeg_start_compress(&cinfo, TRUE); - - while (cinfo.next_scanline < cinfo.image_height) - { - JSAMPROW row_pointer[1]; - row_pointer[0] = const_cast(&rgb[cinfo.next_scanline * width * 3]); - jpeg_write_scanlines(&cinfo, row_pointer, 1); - } - - jpeg_finish_compress(&cinfo); - jpeg_destroy_compress(&cinfo); - std::fclose(fp); - chmod(path.c_str(), 0666); - return true; - } int open_camera_impl(int width, int height) { @@ -556,7 +417,7 @@ class CameraSystem } cfg = config_->at(0); - if (!is_supported_preview_format(cfg.pixelFormat)) + if (!cp0_camera_frame_codec::is_supported_preview_format(cfg.pixelFormat)) { close_camera_locked(); return -8; @@ -628,6 +489,8 @@ class CameraSystem return ret; std::lock_guard lock(mutex_); + if (capture_requested_.load()) + return -13; pending_capture_path_ = path_arg.empty() ? make_photo_path() : path_arg; pending_capture_callback_ = callback; capture_requested_.store(true); @@ -657,10 +520,16 @@ class CameraSystem int save_h = 0; int frame_w = 0; int frame_h = 0; - bool should_capture = capture_requested_.exchange(false); + bool should_capture = false; { std::lock_guard lock(mutex_); + should_capture = capture_requested_.exchange(false); + if (should_capture) + { + save_path = pending_capture_path_; + callback = pending_capture_callback_; + } auto map_it = mapped_buffers_.find(buffer); if (map_it != mapped_buffers_.end()) { @@ -671,16 +540,17 @@ class CameraSystem bytes_used = std::min(bytes_used, static_cast(metadata.planes()[0].bytesused)); dma_buf_sync(map_it->second.fd, DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ); - if (frame_callback_ && convert_to_rgb565(src, bytes_used, frame_rgb565)) + if (frame_callback_ && cp0_camera_frame_codec::convert_to_rgb565( + src, bytes_used, stream_w_, stream_h_, stream_stride_, stream_format_, + frame_rgb565)) { frame_w = stream_w_; frame_h = stream_h_; frame_callback = frame_callback_; } - if (should_capture && convert_to_rgb888(src, bytes_used, rgb)) + if (should_capture && cp0_camera_frame_codec::convert_to_rgb888( + src, bytes_used, stream_w_, stream_h_, stream_stride_, stream_format_, rgb)) { - save_path = pending_capture_path_; - callback = pending_capture_callback_; save_w = stream_w_; save_h = stream_h_; } @@ -689,11 +559,15 @@ class CameraSystem } if (frame_callback && !frame_rgb565.empty()) - frame_callback(0, make_frame_payload(frame_rgb565, frame_w, frame_h)); + cp0::callback::invoke( + frame_callback, 0, + cp0_camera_frame_codec::make_frame_payload(frame_rgb565, frame_w, frame_h)); - if (!save_path.empty()) + if (should_capture) { - bool ok = save_jpeg_rgb888(save_path, rgb.data(), save_w, save_h, 90); + const bool ok = !save_path.empty() && !rgb.empty() && save_w > 0 && save_h > 0 && + cp0_camera_frame_codec::save_jpeg_rgb888( + save_path, rgb.data(), save_w, save_h, 90); report(callback, ok ? 0 : -12, ok ? save_path + "\n" : "camera save failed\n"); } @@ -713,6 +587,8 @@ class CameraSystem const bool was_streaming = streaming_; streaming_ = false; capture_requested_.store(false); + pending_capture_path_.clear(); + pending_capture_callback_ = nullptr; frame_callback_ = nullptr; if (camera_) @@ -773,16 +649,60 @@ class CameraSystem void close_camera() { #if CP0_CAMERA_HAS_LIBCAMERA - std::lock_guard lock(mutex_); - close_camera_locked(); + callback_t cancelled_capture_callback; + bool cancelled_capture = false; + { + std::lock_guard lock(mutex_); + cancelled_capture = capture_requested_.load(); + if (cancelled_capture) + cancelled_capture_callback = pending_capture_callback_; + close_camera_locked(); + } + if (cancelled_capture) + report(cancelled_capture_callback, -12, "camera capture failed\n"); #endif } }; +namespace { + +std::shared_ptr &camera_system() +{ + static auto camera = std::make_shared(); + return camera; +} + +cp0::SignalRegistration &camera_registration() +{ + static cp0::SignalRegistration registration; + return registration; +} + +} // namespace + extern "C" void init_camera(void) { - std::shared_ptr camera = std::make_shared(); + const auto camera = camera_system(); + camera_registration().replace(cp0_signal_camera_api, + [camera](std::list arg, + std::function callback) { + try { + camera->api_call(std::move(arg), callback); + } catch (...) { + cp0::callback::invoke( + callback, -1, "camera backend failure\n"); + } + }); +} - cp0_signal_camera_api.append([camera](std::list arg, std::function callback) - { camera->api_call(arg, callback); }); +extern "C" void deinit_camera(void) noexcept +{ + try { + camera_registration().reset(); + } catch (...) { + } + try { + camera_system()->shutdown(); + } catch (...) { + } } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_config.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_config.cpp index 03e72174..92276044 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_config.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_config.cpp @@ -1,307 +1,100 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" -#include "../cp0_config_json.h" +#include "cp0_config_json.h" +#include "../cp0_config_service.hpp" +#include "../cp0_signal_registration.hpp" -#include #include #include -#include -#include -#include +#include +#include #include -#include #include -#include -#include -#include #include #include -// Unified per-user device config. Everything the launcher persists (app -// visibility, brightness, camera resolution, ...) lives in a single JSON file -// under the user's home, shared with other apps (e.g. the camera app reads -// camera.resolution.{width,height} from here). -#define MAX_ENTRIES 64 -#define KEY_MAX 64 -#define VAL_MAX 256 +namespace { -class ConfigSystem +std::string config_dir() { -public: - typedef std::function callback_t; - typedef std::list arg_t; - - ConfigSystem() - { - init(); - } - void init() - { - std::lock_guard lock(mutex_); - load_locked(); - } - - int get_int(const char *key, int default_val) - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - int idx = find_entry_locked(key); - if (idx < 0) return default_val; - return std::atoi(entries_[idx].val); - } - - void set_int(const char *key, int val) - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - int idx = get_or_create_entry_locked(key); - if (idx < 0) return; - std::snprintf(entries_[idx].val, VAL_MAX, "%d", val); - } - - const char *get_str(const char *key, const char *default_val) - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - int idx = find_entry_locked(key); - if (idx < 0) return default_val; - return entries_[idx].val; - } - - void set_str(const char *key, const char *val) - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - int idx = get_or_create_entry_locked(key); - if (idx < 0) return; - copy_cstr(entries_[idx].val, val ? val : "", VAL_MAX); - } - - int save() - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - return save_locked(); - } - - void api_call(arg_t arg, callback_t callback) - { - if (arg.empty()) { - report(callback, -1, "empty config api\n"); - return; - } - - const std::string cmd = arg.front(); - if (cmd == "Init") { - init(); - report(callback, 0, "ok"); - } else if (cmd == "GetInt") { - const std::string key = nth_arg(arg, 1); - int default_val = parse_int(nth_arg(arg, 2), 0); - int val = get_int(key.c_str(), default_val); - report(callback, 0, std::to_string(val)); - } else if (cmd == "SetInt") { - const std::string key = nth_arg(arg, 1); - int val = parse_int(nth_arg(arg, 2), 0); - set_int(key.c_str(), val); - report(callback, 0, "ok"); - } else if (cmd == "GetStr") { - const std::string key = nth_arg(arg, 1); - const std::string default_val = nth_arg(arg, 2); - report(callback, 0, get_str(key.c_str(), default_val.c_str())); - } else if (cmd == "SetStr") { - const std::string key = nth_arg(arg, 1); - const std::string val = nth_arg(arg, 2); - set_str(key.c_str(), val.c_str()); - report(callback, 0, "ok"); - } else if (cmd == "Save") { - int ret = save(); - report(callback, ret, ret == 0 ? "ok" : "save failed"); - } else { - report(callback, -1, "unknown config api\n"); - } - } - -private: - struct Entry { - char key[KEY_MAX]; - char val[VAL_MAX]; - }; - - Entry entries_[MAX_ENTRIES] = {}; - int count_ = 0; - bool loaded_ = false; - std::mutex mutex_; - - static std::string config_dir() - { - const char *home = std::getenv("HOME"); - std::string base = (home && home[0]) ? std::string(home) : std::string("/root"); - return base + "/.config/cardputerzero"; - } - - static std::string config_file() - { - return config_dir() + "/config.json"; - } - - void ensure_loaded_locked() - { - if (!loaded_) load_locked(); - } - - void add_entry_locked(const std::string &key, const std::string &val) - { - if (key.empty() || count_ >= MAX_ENTRIES) return; - copy_cstr(entries_[count_].key, key.c_str(), KEY_MAX); - copy_cstr(entries_[count_].val, val.c_str(), VAL_MAX); - count_++; - } - - void load_locked() - { - count_ = 0; - loaded_ = true; - - std::string text; - if (!read_file(config_file().c_str(), text)) - return; - std::vector> kv; - if (!cp0cfg::from_json(text, kv)) - return; - for (const auto &e : kv) - add_entry_locked(e.first, e.second); - } - - int save_locked() - { - std::vector> kv; - kv.reserve(count_); - for (int i = 0; i < count_; i++) - kv.emplace_back(entries_[i].key, entries_[i].val); - const std::string json = cp0cfg::to_json(kv); - - const std::string dir = config_dir(); - const char *home = std::getenv("HOME"); - std::string base = (home && home[0]) ? std::string(home) : std::string("/root"); - if (!ensure_dir((base + "/.config").c_str())) - return -1; - if (!ensure_dir(dir.c_str())) - return -1; - - const std::string path = config_file(); - const std::string tmp_path = path + ".tmp"; - FILE *fp = std::fopen(tmp_path.c_str(), "w"); - if (!fp) return -1; - size_t written = std::fwrite(json.data(), 1, json.size(), fp); - if (written != json.size()) { - std::fclose(fp); - std::remove(tmp_path.c_str()); - return -1; - } - if (std::fflush(fp) != 0 || fsync(fileno(fp)) != 0) { - std::fclose(fp); - std::remove(tmp_path.c_str()); - return -1; - } - std::fclose(fp); - if (std::rename(tmp_path.c_str(), path.c_str()) != 0) { - std::remove(tmp_path.c_str()); - return -1; - } - fsync_dir(dir.c_str()); - sync(); - return 0; - } + const char *home = std::getenv("HOME"); + const std::string base = home && home[0] ? home : "/root"; + return base + "/.config/cardputerzero"; +} - static bool read_file(const char *path, std::string &out) - { - FILE *fp = std::fopen(path, "r"); - if (!fp) return false; - char buf[512]; - size_t n; - while ((n = std::fread(buf, 1, sizeof(buf), fp)) > 0) - out.append(buf, n); - std::fclose(fp); - return true; - } +std::string config_file() +{ + return config_dir() + "/config.json"; +} - static bool ensure_dir(const char *path) - { - if (!path || !path[0]) return false; - if (mkdir(path, 0755) == 0) return true; - return errno == EEXIST; - } +bool ensure_dir(const std::string &path) +{ + return mkdir(path.c_str(), 0755) == 0 || errno == EEXIST; +} - static void fsync_dir(const char *path) - { +void fsync_dir(const std::string &path) +{ #ifdef O_DIRECTORY - int fd = open(path, O_RDONLY | O_DIRECTORY); + const int fd = open(path.c_str(), O_RDONLY | O_DIRECTORY); #else - int fd = open(path, O_RDONLY); + const int fd = open(path.c_str(), O_RDONLY); #endif - if (fd >= 0) { - fsync(fd); - close(fd); - } - } - - int find_entry_locked(const char *key) const - { - if (!key || key[0] == '\0') return -1; - for (int i = 0; i < count_; i++) { - if (std::strcmp(entries_[i].key, key) == 0) return i; - } - return -1; - } - - int get_or_create_entry_locked(const char *key) - { - int idx = find_entry_locked(key); - if (idx >= 0) return idx; - if (!key || key[0] == '\0' || count_ >= MAX_ENTRIES) return -1; - idx = count_++; - copy_cstr(entries_[idx].key, key, KEY_MAX); - entries_[idx].val[0] = '\0'; - return idx; - } - - static void copy_cstr(char *dst, const char *src, size_t dst_size) - { - if (!dst || dst_size == 0) return; - if (!src) src = ""; - std::strncpy(dst, src, dst_size - 1); - dst[dst_size - 1] = '\0'; + if (fd >= 0) { + fsync(fd); + close(fd); } +} - static std::string nth_arg(const arg_t& arg, size_t index) - { - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); i++) ++it; - return it == arg.end() ? std::string() : *it; - } +bool load_config(cp0cfg::Entries &entries) +{ + FILE *fp = std::fopen(config_file().c_str(), "r"); + if (!fp) + return false; + std::string text; + char buffer[512]; + size_t count = 0; + while ((count = std::fread(buffer, 1, sizeof(buffer), fp)) > 0) + text.append(buffer, count); + const bool read_ok = std::ferror(fp) == 0; + std::fclose(fp); + return read_ok && cp0cfg::from_json(text, entries); +} - static int parse_int(const std::string& value, int default_val) - { - if (value.empty()) return default_val; - return std::atoi(value.c_str()); - } +int save_config(const cp0cfg::Entries &entries) +{ + const char *home = std::getenv("HOME"); + const std::string base = home && home[0] ? home : "/root"; + const std::string dir = config_dir(); + if (!ensure_dir(base + "/.config") || !ensure_dir(dir)) + return -1; - static void report(callback_t callback, int code, const std::string& data) - { - if (callback) callback(code, data); + const std::string path = config_file(); + const std::string temporary = path + ".tmp"; + const std::string json = cp0cfg::to_json(entries); + FILE *fp = std::fopen(temporary.c_str(), "w"); + if (!fp) + return -1; + const bool written = std::fwrite(json.data(), 1, json.size(), fp) == json.size(); + const bool flushed = written && std::fflush(fp) == 0 && fsync(fileno(fp)) == 0; + const bool closed = std::fclose(fp) == 0; + if (!flushed || !closed || std::rename(temporary.c_str(), path.c_str()) != 0) { + std::remove(temporary.c_str()); + return -1; } -}; + fsync_dir(dir); + sync(); + return 0; +} -extern "C" { +} // namespace -void init_config(void) +extern "C" void init_config(void) { - std::shared_ptr config = std::make_shared(); - cp0_signal_config_api.append([config](std::list arg, std::function callback) { - config->api_call(std::move(arg), std::move(callback)); + static cp0::SignalRegistration registration; + auto config = std::make_shared(load_config, save_config, "\n"); + registration.replace(cp0_signal_config_api, [config](std::list arguments, + std::function callback) { + config->api_call(std::move(arguments), std::move(callback)); }); } - -} diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_filesystem.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_filesystem.cpp index 20a60d8f..75f97aea 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_filesystem.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_filesystem.cpp @@ -1,8 +1,12 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" +#include "../cp0_filesystem_api.hpp" +#include "../cp0_posix_filesystem.hpp" +#include "../cp0_resource_path_policy.hpp" +#include "../cp0_c_api_boundary.hpp" +#include "../cp0_init_once.hpp" +#include "../cp0_integer_codec.hpp" -#include -#include #include #include #include @@ -13,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -27,86 +32,14 @@ class Cp0Filesystem { public: void api_call(const std::list &arg, std::function callback) { - auto report = [&](int code, const std::string &data) { - if (callback) callback(code, data); - }; - - if (arg.empty()) { - report(-1, "missing command"); - return; - } - - const std::string &cmd = arg.front(); - auto value_arg = [&]() -> std::string { - return arg.size() >= 2 ? *std::next(arg.begin()) : std::string(); - }; - - if (cmd == "Path") { - report(0, resolve_path(value_arg())); - } else if (cmd == "DirList") { - std::string data; - report(encode_dir_entries(value_arg().c_str(), data), data); - } else if (cmd == "DirListDetail") { - std::string data; - report(encode_dir_entries_detail(value_arg().c_str(), data), data); - } else if (cmd == "Exists") { - report(0, access(value_arg().c_str(), R_OK) == 0 ? "1" : "0"); - } else if (cmd == "ReadFile") { - const auto max_it = arg.size() >= 3 ? std::next(arg.begin(), 2) : arg.end(); - const size_t max_bytes = max_it == arg.end() ? std::numeric_limits::max() - : static_cast(std::strtoull(max_it->c_str(), nullptr, 10)); - std::string data; - report(read_file_limited(value_arg(), max_bytes, data), data); - } else if (cmd == "EnsureDirForUser") { - const std::string path = value_arg(); - int ret = mkdir(path.c_str(), 0755); - struct stat st{}; - if ((ret != 0 && errno != EEXIST) || stat(path.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) { - report(-1, ""); - } else { - if (getuid() == 0) { - uid_t uid = static_cast(std::atoi(std::getenv("SUDO_UID") ? std::getenv("SUDO_UID") : "1000")); - gid_t gid = static_cast(std::atoi(std::getenv("SUDO_GID") ? std::getenv("SUDO_GID") : "1000")); - if (chown(path.c_str(), uid, gid) != 0) { - report(-1, ""); - return; - } - } - report(0, ""); - } - } else if (cmd == "Touch") { - const std::string path = value_arg(); - const auto slash = path.find_last_of('/'); - if (slash != std::string::npos && slash > 0) { - const std::string parent = path.substr(0, slash); - struct stat st{}; - if (mkdir(parent.c_str(), 0755) != 0 && errno != EEXIST) { - report(-1, ""); - return; - } - if (stat(parent.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) { - report(-1, ""); - return; - } - } - FILE *file = fopen(path.c_str(), "a"); - if (file) fclose(file); - report(file ? 0 : -1, ""); - } else if (cmd == "Remove") { - report(std::remove(value_arg().c_str()) == 0 ? 0 : -1, ""); - } else if (cmd == "WatchStart") { - cp0_watcher_t watcher = watch_start(value_arg().c_str()); - report(watcher ? 0 : -1, std::to_string(reinterpret_cast(watcher))); - } else if (cmd == "WatchPoll") { - auto *watcher = reinterpret_cast(parse_uintptr(value_arg())); - report(0, std::to_string(watch_poll(watcher))); - } else if (cmd == "WatchStop") { - auto *watcher = reinterpret_cast(parse_uintptr(value_arg())); - watch_stop(watcher); - report(0, ""); - } else { - report(-2, "unknown command: " + cmd); - } + cp0_filesystem_api::Operations operations; + operations.resolve_path = resolve_path; + operations.ensure_directory = ensure_directory; + operations.watch_start = [this](const char *path) { return dir_watch_start(path); }; + operations.watch_poll = [this](cp0_watcher_t watcher) { return dir_watch_poll(watcher); }; + operations.watch_stop = [this](cp0_watcher_t watcher) { return dir_watch_stop(watcher); }; + operations.detail_errno_on_open_failure = false; + cp0_filesystem_api::dispatch(arg, operations, std::move(callback)); } std::string path(std::string file) @@ -128,25 +61,17 @@ class Cp0Filesystem { cp0_watcher_t dir_watch_start(const char *path) { - int code = -1; - std::string data; - invoke({"WatchStart", path ? path : ""}, code, data); - return code == 0 ? reinterpret_cast(parse_uintptr(data)) : nullptr; + return watchers_.add(native_watch_start(path)); } int dir_watch_poll(cp0_watcher_t watcher) { - int code = -1; - std::string data; - invoke({"WatchPoll", std::to_string(reinterpret_cast(watcher))}, code, data); - return code == 0 ? std::atoi(data.c_str()) : code; + return watchers_.poll(watcher, native_watch_poll); } - void dir_watch_stop(cp0_watcher_t watcher) + int dir_watch_stop(cp0_watcher_t watcher) { - int code = -1; - std::string data; - invoke({"WatchStop", std::to_string(reinterpret_cast(watcher))}, code, data); + return watchers_.stop(watcher, native_watch_stop) ? 0 : -1; } private: @@ -166,41 +91,34 @@ class Cp0Filesystem { }); } - static uintptr_t parse_uintptr(const std::string &value) + static int ensure_directory(const std::string &path) { - return static_cast(std::strtoull(value.c_str(), nullptr, 0)); - } - - static std::string lower_ext(const std::string &file) - { - const auto dot = file.find_last_of('.'); - if (dot == std::string::npos) return ""; - - std::string ext = file.substr(dot + 1); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return ext; - } - - static bool is_image_ext(const std::string &ext) - { - return ext == "png" || ext == "gif" || ext == "jpg" || ext == "jpeg" || ext == "svg"; - } - - static bool is_audio_ext(const std::string &ext) - { - return ext == "wav" || ext == "mp3" || ext == "ogg"; - } - - static bool is_font_ext(const std::string &ext) - { - return ext == "ttf" || ext == "otf"; - } + uid_t uid = 0; + gid_t gid = 0; + if (getuid() == 0) { + std::uintmax_t parsed_uid = 0; + std::uintmax_t parsed_gid = 0; + const std::uintmax_t invalid_uid = + static_cast(std::numeric_limits::max()); + const std::uintmax_t invalid_gid = + static_cast(std::numeric_limits::max()); + if (!cp0_integer_codec::parse_non_root_identity( + std::getenv("SUDO_UID"), invalid_uid, parsed_uid) || + !cp0_integer_codec::parse_non_root_identity( + std::getenv("SUDO_GID"), invalid_gid, parsed_gid)) + return -1; + uid = static_cast(parsed_uid); + gid = static_cast(parsed_gid); + } - static bool has_lvgl_drive(const std::string &file) - { - return file.size() >= 2 && std::isalpha(static_cast(file[0])) && file[1] == ':'; + const int result = mkdir(path.c_str(), 0755); + struct stat status{}; + if ((result != 0 && errno != EEXIST) || stat(path.c_str(), &status) != 0 || + !S_ISDIR(status.st_mode)) { + return -1; + } + if (getuid() != 0) return 0; + return chown(path.c_str(), uid, gid) == 0 ? 0 : -1; } static bool starts_with(const std::string &value, const char *prefix) @@ -209,14 +127,6 @@ class Cp0Filesystem { return value.compare(0, prefix_str.size(), prefix_str) == 0; } - static std::string strip_app_root_prefix(const std::string &file) - { - const std::string app_root_prefix = std::string(kAppRoot) + "/"; - if (starts_with(file, app_root_prefix.c_str())) return file.substr(app_root_prefix.size()); - if (starts_with(file, "APPLaunch/")) return file.substr(std::strlen("APPLaunch/")); - return file; - } - static std::string lvgl_root_path(std::string rel) { while (!rel.empty() && rel.front() == '/') { @@ -227,9 +137,9 @@ class Cp0Filesystem { static std::string resolve_lvgl_image_path(const std::string &file) { - if (has_lvgl_drive(file)) return file; + if (cp0::filesystem::has_lvgl_drive(file)) return file; - const std::string rel = strip_app_root_prefix(file); + const std::string rel = cp0::filesystem::strip_app_root_prefix(file); if (!rel.empty() && rel.front() == '/') return rel; if (starts_with(rel, "share/images/")) return lvgl_root_path(rel); @@ -237,6 +147,18 @@ class Cp0Filesystem { return lvgl_root_path("share/images/" + rel); } + static std::string resolve_native_resource_path(const std::string &directory, + const char *prefix, + const std::string &file) + { + std::string relative = cp0::filesystem::strip_app_root_prefix(file); + if (!relative.empty() && relative.front() == '/') return relative; + const std::string resource_prefix = std::string(prefix) + "/"; + if (starts_with(relative, resource_prefix.c_str())) + relative.erase(0, resource_prefix.size()); + return directory + "/" + relative; + } + static std::string resolve_path(const std::string &file) { if (file.empty()) return ""; @@ -256,127 +178,24 @@ class Cp0Filesystem { if (file == "keyboard_map") return "/usr/share/keymaps/tca8418_keypad_m5stack_keymap.map"; if (file == "store_sync_cmd") return std::string("python ") + kAppRoot + "/bin/store_cache_sync.py"; - const std::string ext = lower_ext(file); - if (is_image_ext(ext)) return resolve_lvgl_image_path(file); - if (is_audio_ext(ext)) return std::string(kAppRoot) + "/share/audio/" + file; - if (is_font_ext(ext)) return std::string(kAppRoot) + "/share/font/" + file; + const cp0::filesystem::ResourceKind kind = cp0::filesystem::classify_resource(file); + if (kind != cp0::filesystem::ResourceKind::none && + cp0::filesystem::has_parent_component(file)) + return ""; + if (kind == cp0::filesystem::ResourceKind::image) return resolve_lvgl_image_path(file); + if (kind == cp0::filesystem::ResourceKind::audio) + return resolve_native_resource_path( + std::string(kAppRoot) + "/share/audio", "share/audio", file); + if (kind == cp0::filesystem::ResourceKind::font) + return resolve_native_resource_path( + std::string(kAppRoot) + "/share/font", "share/font", file); return file; } static int list_dir(const char *path, cp0_dirent_t *entries, int max_entries, int *out_count) { - if (out_count) *out_count = 0; - if (!path || !out_count) return -1; - if (!entries || max_entries <= 0) return 0; - - DIR *dir = opendir(path); - if (!dir) return -1; - - struct dirent *ent; - while ((ent = readdir(dir)) != nullptr) { - if (is_dot_entry(ent->d_name)) continue; - if (*out_count >= max_entries) break; - std::strncpy(entries[*out_count].name, ent->d_name, sizeof(entries[*out_count].name) - 1); - entries[*out_count].name[sizeof(entries[*out_count].name) - 1] = '\0'; - entries[*out_count].is_dir = (ent->d_type == DT_DIR) ? 1 : 0; - (*out_count)++; - } - - closedir(dir); - return 0; - } - - static int encode_dir_entries(const char *path, std::string &data) - { - DIR *dir = opendir(path); - if (!dir) return -errno; - try { - std::ostringstream out; - struct dirent *ent; - while ((ent = readdir(dir)) != nullptr) { - if (is_dot_entry(ent->d_name)) continue; - bool is_dir = ent->d_type == DT_DIR; - if (ent->d_type == DT_UNKNOWN) { - const std::string full = std::string(path) + (std::string(path) == "/" ? "" : "/") + ent->d_name; - struct stat st{}; - is_dir = stat(full.c_str(), &st) == 0 && S_ISDIR(st.st_mode); - } - out << (is_dir ? 'D' : 'F') << '\t' << encode_field(ent->d_name) << '\n'; - } - data = out.str(); - } catch (const std::bad_alloc &) { - closedir(dir); - return -ENOMEM; - } - closedir(dir); - return 0; - } - - static int read_file_limited(const std::string &path, size_t max_bytes, std::string &data) - { - std::ifstream file(path, std::ios::binary | std::ios::ate); - if (!file) return errno ? -errno : -EIO; - const std::streamoff length = file.tellg(); - if (length < 0) return -EIO; - if (static_cast(length) > max_bytes) return -EFBIG; - try { - data.resize(static_cast(length)); - } catch (const std::bad_alloc &) { - return -ENOMEM; - } - file.seekg(0); - if (length > 0 && !file.read(&data[0], length)) { - data.clear(); - return -EIO; - } - return 0; - } - - static int encode_dir_entries_detail(const char *path, std::string &data) - { - DIR *dir = opendir(path); - if (!dir) return -1; - try { - std::ostringstream out; - struct dirent *ent; - while ((ent = readdir(dir)) != nullptr) { - if (is_dot_entry(ent->d_name)) continue; - const std::string full = std::string(path) + (std::string(path) == "/" ? "" : "/") + ent->d_name; - struct stat st; - if (stat(full.c_str(), &st) != 0) continue; - out << (S_ISDIR(st.st_mode) ? 'D' : 'F') << '\t' << static_cast(st.st_size) - << '\t' << encode_field(ent->d_name) << '\n'; - } - data = out.str(); - } catch (const std::bad_alloc &) { - closedir(dir); - return -ENOMEM; - } - closedir(dir); - return 0; - } - - static bool is_dot_entry(const char *name) - { - return name && name[0] == '.' && - (name[1] == '\0' || (name[1] == '.' && name[2] == '\0')); - } - - static std::string encode_field(const char *value) - { - static constexpr char hex[] = "0123456789ABCDEF"; - std::string encoded; - for (const unsigned char c : std::string(value ? value : "")) { - if (c == '%' || c == '\t' || c == '\n' || c == '\r') { - encoded.push_back('%'); - encoded.push_back(hex[c >> 4]); - encoded.push_back(hex[c & 0x0f]); - } else { - encoded.push_back(static_cast(c)); - } - } - return encoded; + return cp0_posix_filesystem::list_directory(path, entries, max_entries, out_count, false); } static int decode_dir_entries(const std::string &data, cp0_dirent_t *entries, int max_entries, int *out_count) @@ -427,7 +246,7 @@ class Cp0Filesystem { return true; } - static cp0_watcher_t watch_start(const char *path) + static cp0_watcher_t native_watch_start(const char *path) { if (!path) return nullptr; @@ -450,7 +269,7 @@ class Cp0Filesystem { return watcher; } - static int watch_poll(cp0_watcher_t watcher) + static int native_watch_poll(cp0_watcher_t watcher) { if (!watcher) return -1; @@ -460,7 +279,7 @@ class Cp0Filesystem { return (n > 0) ? 1 : 0; } - static void watch_stop(cp0_watcher_t watcher) + static void native_watch_stop(cp0_watcher_t watcher) { if (!watcher) return; @@ -469,6 +288,8 @@ class Cp0Filesystem { close(w->inotify_fd); std::free(w); } + + cp0_filesystem_api::WatcherRegistry watchers_; }; Cp0Filesystem &filesystem() @@ -485,11 +306,13 @@ std::string cp0_file_path(std::string file) extern "C" const char *cp0_file_path_c(const char *file) { - static thread_local std::unordered_map paths; - std::string key = file ? std::string(file) : std::string(); - auto it = paths.find(key); - if (it == paths.end()) it = paths.emplace(key, cp0_file_path(key)).first; - return it->second.c_str(); + return cp0::invoke_c_api_or("", [file] { + static thread_local std::unordered_map paths; + std::string key = file ? std::string(file) : std::string(); + auto it = paths.find(key); + if (it == paths.end()) it = paths.emplace(key, cp0_file_path(key)).first; + return it->second.c_str(); + }); } extern "C" int cp0_dir_list(const char *path, cp0_dirent_t *entries, int max_entries, int *out_count) @@ -509,12 +332,17 @@ extern "C" int cp0_dir_watch_poll(cp0_watcher_t watcher) extern "C" void cp0_dir_watch_stop(cp0_watcher_t watcher) { - filesystem().dir_watch_stop(watcher); + (void)filesystem().dir_watch_stop(watcher); } extern "C" void init_filesystem(void) { - cp0_signal_filesystem_api.append([](std::list arg, std::function callback) { - filesystem().api_call(arg, std::move(callback)); + static cp0::InitOnce initialized; + initialized.run([] { + return static_cast(cp0_signal_filesystem_api.append( + [](std::list arg, + std::function callback) { + filesystem().api_call(arg, std::move(callback)); + })); }); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_freambuffer.c b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_freambuffer.c index 3409e59e..15bcfd17 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_freambuffer.c +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_freambuffer.c @@ -6,6 +6,8 @@ #include #include #include "cp0_lvgl.h" +#include "../cp0_display_screenshot_contract.hpp" +#include static int find_st7789v_fbdev(char *dev_path, size_t buf_size) { @@ -40,6 +42,12 @@ static int find_st7789v_fbdev(char *dev_path, size_t buf_size) void init_freambuffer_disp() { + static pthread_mutex_t init_mutex = PTHREAD_MUTEX_INITIALIZER; + pthread_mutex_lock(&init_mutex); + if (lv_display_get_default() != NULL) { + pthread_mutex_unlock(&init_mutex); + return; + } #if LV_USE_LINUX_DRM const char *device = getenv("LV_LINUX_DRM_DEVICE"); char *detected_device = NULL; @@ -51,6 +59,7 @@ void init_freambuffer_disp() if (device == NULL || device[0] == '\0') { fprintf(stderr, "Failed to find a connected DRM device\n"); lv_free(detected_device); + pthread_mutex_unlock(&init_mutex); return; } @@ -59,6 +68,7 @@ void init_freambuffer_disp() if (disp == NULL) { fprintf(stderr, "Failed to create DRM display\n"); lv_free(detected_device); + pthread_mutex_unlock(&init_mutex); return; } @@ -66,6 +76,7 @@ void init_freambuffer_disp() fprintf(stderr, "Failed to initialize DRM device: %s\n", device); lv_display_delete(disp); lv_free(detected_device); + pthread_mutex_unlock(&init_mutex); return; } @@ -78,6 +89,7 @@ void init_freambuffer_disp() lv_display_t *disp = lv_linux_fbdev_create(); if (disp == NULL) { printf("Failed to create fbdev display!\n"); + pthread_mutex_unlock(&init_mutex); return; } @@ -99,4 +111,5 @@ void init_freambuffer_disp() lv_coord_t h = lv_display_get_vertical_resolution(disp); printf("Framebuffer resolution: %dx%d\n", w, h); #endif + pthread_mutex_unlock(&init_mutex); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_imu.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_imu.cpp index fe16f770..434ec7b5 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_imu.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_imu.cpp @@ -1,10 +1,15 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" #include "../cp0_app_internal_utils.h" +#include "../cp0_callback_contract.hpp" +#include "../cp0_imu_codec.hpp" +#include "../cp0_imu_worker_contract.hpp" +#include "../cp0_signal_registration.hpp" #include #include #include +#include #include #include #include @@ -132,8 +137,26 @@ class ImuSystem { using callback_t = std::function; using arg_t = std::list; + ~ImuSystem() { stop(); } + + void stop() noexcept + { + std::thread worker; + { + std::lock_guard lock(worker_mutex_); + stopping_ = true; + cancel_calibration_.store(true); + calibration_wake_.notify_all(); + if (calibration_worker_.joinable()) + worker = std::move(calibration_worker_); + } + cp0::imu::cancel_and_join_worker(worker, [] {}, [] {}); + calibrating_.store(false); + } + void api_call(arg_t arg, callback_t callback) { + try { if (arg.empty()) { report(callback, -1, "empty imu api\n"); return; @@ -150,13 +173,15 @@ class ImuSystem { } report(callback, -1, "unknown imu api\n"); + } catch (...) { + report(callback, -1, "imu api failure\n"); + } } private: - void report(callback_t callback, int code, const std::string &data) + void report(const callback_t &callback, int code, const std::string &data) { - if (callback) - callback(code, data); + cp0::callback::invoke(callback, code, data); } void read(arg_t arg, callback_t callback) @@ -164,7 +189,7 @@ class ImuSystem { (void)arg; cp0_compass_info_t info{}; int ret = read_info(&info); - report(callback, ret, std::string(reinterpret_cast(&info), sizeof(info))); + report(callback, ret, cp0::imu::encode_info(info)); } void calibrate(callback_t callback) @@ -172,16 +197,38 @@ class ImuSystem { #if defined(_WIN32) report(callback, -1, "compass calibration unavailable\n"); #else - if (calibrating_.exchange(true)) { - report(callback, 1, "compass calibration already running\n"); - return; - } - - std::thread([this]() { - calibrate_worker(); - calibrating_.store(false); - }).detach(); - report(callback, 0, "compass calibration started\n"); + cp0::imu::prepare_then_report( + [this] { + std::lock_guard lock(worker_mutex_); + if (stopping_) return cp0::imu::StartOutcome::Stopping; + if (calibrating_.exchange(true)) + return cp0::imu::StartOutcome::AlreadyRunning; + try { + cp0::imu::reap_completed_worker(calibration_worker_, false); + cancel_calibration_.store(false); + calibration_worker_ = std::thread([this]() { + try { + calibrate_worker(); + } catch (...) { + } + calibrating_.store(false); + }); + return cp0::imu::StartOutcome::Started; + } catch (...) { + calibrating_.store(false); + return cp0::imu::StartOutcome::ThreadCreationFailed; + } + }, + [this, &callback](cp0::imu::StartOutcome outcome) { + if (outcome == cp0::imu::StartOutcome::Started) + report(callback, 0, "compass calibration started\n"); + else if (outcome == cp0::imu::StartOutcome::AlreadyRunning) + report(callback, 1, "compass calibration already running\n"); + else if (outcome == cp0::imu::StartOutcome::Stopping) + report(callback, -1, "compass calibration stopping\n"); + else + report(callback, -1, "compass calibration unavailable\n"); + }); #endif } @@ -275,7 +322,8 @@ class ImuSystem { int samples = 0; auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); - while (std::chrono::steady_clock::now() < deadline) { + while (!cancel_calibration_.load() && + std::chrono::steady_clock::now() < deadline) { float mag_x = 0.0f; float mag_y = 0.0f; float mag_z = 0.0f; @@ -288,10 +336,12 @@ class ImuSystem { max_z = std::max(max_z, mag_z); samples++; } - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::unique_lock lock(calibration_wait_mutex_); + calibration_wake_.wait_for(lock, std::chrono::milliseconds(20), + [this] { return cancel_calibration_.load(); }); } - if (samples < 10) + if (cancel_calibration_.load() || samples < 10) return; std::lock_guard lock(bias_mutex_); @@ -303,6 +353,12 @@ class ImuSystem { #endif std::atomic calibrating_{false}; + std::atomic cancel_calibration_{false}; + std::mutex worker_mutex_; + std::mutex calibration_wait_mutex_; + std::condition_variable calibration_wake_; + std::thread calibration_worker_; + bool stopping_ = false; std::mutex bias_mutex_; bool mag_bias_valid_ = false; float mag_bias_x_ = 0.0f; @@ -310,6 +366,20 @@ class ImuSystem { float mag_bias_z_ = 0.0f; }; +using ImuRegistration = cp0::SignalRegistration; + +struct ImuRuntime { + std::mutex mutex; + ImuRegistration registration; + std::shared_ptr service; +}; + +ImuRuntime &imu_runtime() +{ + static ImuRuntime runtime; + return runtime; +} + } // namespace extern "C" int cp0_compass_read(cp0_compass_read_cb_t callback, void *user) @@ -317,29 +387,62 @@ extern "C" int cp0_compass_read(cp0_compass_read_cb_t callback, void *user) cp0_compass_info_t info{}; clear_info(&info, "IMU unavailable", 0); int ret = -1; - cp0_signal_imu_api({"Read"}, [&](int code, std::string data) { - ret = code; - if (data.size() == sizeof(info)) - std::memcpy(&info, data.data(), sizeof(info)); - }); - if (callback) - callback(ret, &info, user); + try { + cp0_signal_imu_api({"Read"}, [&](int code, std::string data) { + ret = code; + if (!cp0::imu::decode_info(data, info)) + ret = -1; + }); + } catch (...) { + ret = -1; + } + cp0::callback::invoke_direct(callback, ret, &info, user); return ret; } extern "C" int cp0_compass_calibrate(void) { int ret = -1; - cp0_signal_imu_api({"Calibrate"}, [&](int code, std::string) { - ret = code; - }); + try { + cp0_signal_imu_api({"Calibrate"}, [&](int code, std::string) { + ret = code; + }); + } catch (...) { + ret = -1; + } return ret; } extern "C" void init_imu(void) { - std::shared_ptr imu = std::make_shared(); - cp0_signal_imu_api.append([imu](std::list arg, std::function callback) { - imu->api_call(std::move(arg), std::move(callback)); - }); + try { + ImuRuntime &runtime = imu_runtime(); + std::lock_guard lock(runtime.mutex); + if (runtime.service) return; + auto imu = std::make_shared(); + if (!runtime.registration.replace( + cp0_signal_imu_api, + [imu](std::list arg, + std::function callback) { + imu->api_call(std::move(arg), std::move(callback)); + })) + return; + runtime.service = std::move(imu); + } catch (...) { + } +} + +extern "C" void deinit_imu(void) +{ + try { + ImuRuntime &runtime = imu_runtime(); + std::shared_ptr imu; + { + std::lock_guard lock(runtime.mutex); + runtime.registration.reset(); + imu = std::move(runtime.service); + } + if (imu) imu->stop(); + } catch (...) { + } } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_keyboard.c b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_keyboard.c index 9496f8cc..cef66499 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_keyboard.c +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_keyboard.c @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #ifdef __linux__ @@ -12,6 +11,7 @@ #endif #include #include +#include #ifdef __linux__ #include #include @@ -20,9 +20,14 @@ #include #include #else -#include "compat/input_keys.h" +#include "input_keys.h" #endif #include "cp0_lvgl_app.h" +#include "cp0_keyboard_keymap.h" +#include "cp0_keyboard_lvgl_input.h" +#include "../cp0_keyboard_thread_lifecycle.h" +#include "../cp0_keyboard_queue.h" +#include "../cp0_keyboard_text.h" #include "keyboard_input.h" #include "lvgl/lvgl.h" #include "../../../../SDK/components/utilities/include/sample_log.h" @@ -40,6 +45,10 @@ volatile int LVGL_RUN_FLAGE = 1; volatile uint32_t LV_EVENT_KEYBOARD; static volatile int keyboard_paused_flag = 0; +static atomic_bool keyboard_shutdown_requested = false; +static pthread_t keyboard_read_thread_id; +static cp0_keyboard_thread_lifecycle_t keyboard_thread_lifecycle = {0}; +static pthread_mutex_t keyboard_thread_mutex = PTHREAD_MUTEX_INITIALIZER; #ifdef __linux__ static struct libinput *g_libinput = NULL; void keyboard_pause(void) { @@ -107,127 +116,25 @@ void kbd_dump_keymap_table(void) void kbd_dump_keymap_table(void) {} #endif -__attribute__((weak)) void ui_global_hint_on_key(const struct key_item *elm) -{ - (void)elm; -} - -__attribute__((weak)) int ui_screensaver_filter_key(const struct key_item *elm) -{ - (void)elm; - return 0; -} - static const char *getenv_default(const char *name, const char *dflt) { const char *value = getenv(name); return (value && value[0] != '\0') ? value : dflt; } -static int cp0_evdev_process_key(uint16_t code) -{ - switch (code) { - case KEY_UP: - return LV_KEY_UP; - case KEY_DOWN: - return LV_KEY_DOWN; - case KEY_RIGHT: - return LV_KEY_RIGHT; - case KEY_LEFT: - return LV_KEY_LEFT; - case KEY_ESC: - return LV_KEY_ESC; - case KEY_DELETE: - return LV_KEY_DEL; - case KEY_BACKSPACE: - return LV_KEY_BACKSPACE; - case KEY_ENTER: - return LV_KEY_ENTER; - case KEY_NEXT: - return LV_KEY_NEXT; - case KEY_TAB: - return KEY_TAB; - case KEY_PREVIOUS: - return LV_KEY_PREV; - case KEY_HOME: - return LV_KEY_HOME; - case KEY_END: - return LV_KEY_END; - default: - return code; - } -} - -static void cp0_keypad_read_cb(lv_indev_t *indev, lv_indev_data_t *data) -{ - (void)indev; - - data->state = LV_INDEV_STATE_RELEASED; - data->continue_reading = false; - - pthread_mutex_lock(&keyboard_mutex); - if (!STAILQ_EMPTY(&keyboard_queue)) { - struct key_item *elm = STAILQ_FIRST(&keyboard_queue); - STAILQ_REMOVE_HEAD(&keyboard_queue, entries); - - char utf8_dbg[64] = ""; - int di = 0; - for (int i = 0; i < (int)sizeof(elm->utf8) && elm->utf8[i] && di < 60; i++) { - unsigned char c = (unsigned char)elm->utf8[i]; - if (c >= 0x20 && c < 0x7f) - utf8_dbg[di++] = (char)c; - else - di += snprintf(utf8_dbg + di, 64 - di, "\\x%02x", c); - } - utf8_dbg[di] = '\0'; - SLOGD("[INDEV] dequeue code=%u state=%s sym=%s utf8='%s' cp=0x%x active_screen=%p", - elm->key_code, kbd_state_name(elm->key_state), elm->sym_name, - utf8_dbg, elm->codepoint, (void *)lv_screen_active()); - - int swallowed = ui_screensaver_filter_key(elm); - if (!swallowed) { - lv_obj_t *root = lv_screen_active(); - if (root) - lv_obj_send_event(root, (lv_event_code_t)LV_EVENT_KEYBOARD, elm); - - ui_global_hint_on_key(elm); - - data->key = cp0_evdev_process_key(elm->key_code); - if (data->key) - data->state = (lv_indev_state_t)elm->key_state; - } - if (data->key || swallowed) { - data->continue_reading = !STAILQ_EMPTY(&keyboard_queue); - } - free(elm); - } - pthread_mutex_unlock(&keyboard_mutex); -} - -static void cp0_create_lvgl_input_devices(void) -{ - const char *mouse_device = getenv_default("LV_LINUX_MOUSE_DEVICE", NULL); - if (mouse_device) - lv_evdev_create(LV_INDEV_TYPE_POINTER, mouse_device); - - lv_indev_t *indev = lv_indev_create(); - if (indev != NULL) { - lv_indev_set_type(indev, LV_INDEV_TYPE_KEYPAD); - lv_indev_set_read_cb(indev, cp0_keypad_read_cb); - } -} - /* ============================================================ * Parameters * ============================================================ */ #define EVDEV_KEYCODE_OFFSET 8 -#define REPEAT_DELAY_MS 500 /* delay before first repeat */ +#define REPEAT_DELAY_MS 500 /* delay before repeating text input */ +#define NAV_REPEAT_DELAY_MS 180 /* shorter delay for directional navigation */ #define REPEAT_RATE_MS 30 /* interval between subsequent repeats */ /* ============================================================ * libinput open/close callbacks * ============================================================ */ static int open_restricted(const char *path, int flags, void *user_data) { + (void)user_data; int fd = open(path, flags); if (fd < 0) { fprintf(stderr, "Failed to open %s: %s\n", path, strerror(errno)); @@ -242,219 +149,12 @@ static int open_restricted(const char *path, int flags, void *user_data) { */ return fd; } -static void close_restricted(int fd, void *user_data) { close(fd); } +static void close_restricted(int fd, void *user_data) { (void)user_data; close(fd); } static const struct libinput_interface interface = { .open_restricted = open_restricted, .close_restricted = close_restricted, }; -/* ============================================================ - * TCA8418 custom keycode mapping table - * ============================================================ */ -#define TCA8418_KEYMAP_PATH "/usr/share/keymaps/tca8418_keypad_m5stack_keymap.map" -#define TCA8418_KEYMAP_MAX_ENTRIES 64 - -struct tca8418_keymap_entry { - uint32_t keycode; - const char *sym_name; - const char *utf8; -}; - -static const struct tca8418_keymap_entry tca8418_default_keymap[] = { - { 26, "exclam", "!" }, - - { 27, "at", "@" }, - { 39, "numbersign", "#" }, - { 40, "dollar", "$" }, - { 41, "percent", "%" }, - { 43, "asciicircum", "^" }, - { 51, "ampersand", "&" }, - { 52, "asterisk", "*" }, - { 53, "parenleft", "(" }, - { 54, "parenright", ")" }, - - { 55, "asciitilde", "~" }, - { 69, "grave", "`" }, - { 70, "underscore", "_" }, - { 71, "minus", "-" }, - { 72, "plus", "+" }, - { 73, "equal", "=" }, - { 74, "bracketleft", "[" }, - { 75, "bracketright", "]" }, - { 76, "braceleft", "{" }, - { 77, "braceright", "}" }, - - { 79, "semicolon", ";" }, - { 80, "colon", ":" }, - { 81, "apostrophe", "'" }, - { 82, "quotedbl", "\"" }, - { 83, "less", "<" }, - { 85, "greater", ">" }, - { 86, "backslash", "\\" }, - { 89, "bar", "|" }, - - { 90, "comma", "," }, - { 91, "period", "." }, - { 92, "slash", "/" }, - { 93, "question", "?" }, -}; - -struct tca8418_runtime_keymap_entry { - uint32_t keycode; - char sym_name[64]; - char utf8[16]; -}; - -static struct tca8418_runtime_keymap_entry tca8418_runtime_keymap[TCA8418_KEYMAP_MAX_ENTRIES]; -static size_t tca8418_runtime_keymap_size = 0; -static int tca8418_runtime_keymap_loaded = 0; - -static const struct tca8418_keymap_entry * -tca8418_keymap_lookup(uint32_t keycode) { - static struct tca8418_keymap_entry mapped; - - if (tca8418_runtime_keymap_loaded) { - for (size_t i = 0; i < tca8418_runtime_keymap_size; i++) { - if (tca8418_runtime_keymap[i].keycode == keycode) { - mapped.keycode = tca8418_runtime_keymap[i].keycode; - mapped.sym_name = tca8418_runtime_keymap[i].sym_name; - mapped.utf8 = tca8418_runtime_keymap[i].utf8; - return &mapped; - } - } - return NULL; - } - - for (size_t i = 0; i < sizeof(tca8418_default_keymap) / sizeof(tca8418_default_keymap[0]); i++) - if (tca8418_default_keymap[i].keycode == keycode) - return &tca8418_default_keymap[i]; - return NULL; -} - -static char *trim_ascii(char *s) -{ - while (isspace((unsigned char)*s)) - s++; - - char *end = s + strlen(s); - while (end > s && isspace((unsigned char)end[-1])) - *--end = '\0'; - return s; -} - -static int tca8418_parse_keymap_line(char *line, - struct tca8418_runtime_keymap_entry *out) -{ - char *comment = strchr(line, '#'); - if (comment) - *comment = '\0'; - - char *body = trim_ascii(line); - if (body[0] == '\0') - return 0; - - uint32_t evdev_keycode = 0; - char sym_name[sizeof(out->sym_name)] = {0}; - if (sscanf(body, "keycode %u = %63s", &evdev_keycode, sym_name) != 2) - return 0; - - out->keycode = evdev_keycode; - snprintf(out->sym_name, sizeof(out->sym_name), "%s", sym_name); - - xkb_keysym_t sym = xkb_keysym_from_name(out->sym_name, XKB_KEYSYM_NO_FLAGS); - if (sym == XKB_KEY_NoSymbol || - xkb_keysym_to_utf8(sym, out->utf8, sizeof(out->utf8)) <= 0) { - out->utf8[0] = '\0'; - } - return 1; -} - -static void tca8418_load_runtime_keymap(void) -{ - if (access(TCA8418_KEYMAP_PATH, F_OK) != 0) - return; - - FILE *fp = fopen(TCA8418_KEYMAP_PATH, "r"); - if (!fp) { - SLOGW("[KBD] failed to open %s: %s; using built-in keymap", - TCA8418_KEYMAP_PATH, strerror(errno)); - return; - } - - char line[256]; - size_t count = 0; - while (count < TCA8418_KEYMAP_MAX_ENTRIES && fgets(line, sizeof(line), fp)) { - struct tca8418_runtime_keymap_entry entry = {0}; - if (!tca8418_parse_keymap_line(line, &entry)) - continue; - tca8418_runtime_keymap[count++] = entry; - } - fclose(fp); - - tca8418_runtime_keymap_size = count; - tca8418_runtime_keymap_loaded = 1; - SLOGI("[KBD] loaded %zu TCA8418 keymap entries from %s", - tca8418_runtime_keymap_size, TCA8418_KEYMAP_PATH); -} - - -/* ============================================================ - * Control-key -> terminal control-character mapping table - * When xkbcommon does not produce utf8 for function keys, fill in ANSI escape sequences as fallback - * ============================================================ */ -struct ctrl_key_utf8_entry { - uint32_t keycode; /* KEY_xxx from linux/input.h */ - const char *utf8; /* terminal control character / ANSI escape sequence */ -}; - -static const struct ctrl_key_utf8_entry ctrl_key_utf8_map[] = { - /* ---- single-byte control characters ---- */ - { KEY_ENTER, "\r" }, /* CR (0x0D) */ - { KEY_KPENTER, "\r" }, /* CR keypad Enter */ - { KEY_BACKSPACE, "\x7f" }, /* DEL (0x7F) */ - { KEY_TAB, "\t" }, /* HT (0x09) */ - { KEY_ESC, "\x1b" }, /* ESC (0x1B) */ - - /* ---- ANSI arrow keys ---- */ - { KEY_UP, "\033[A" }, /* CSI A */ - { KEY_DOWN, "\033[B" }, /* CSI B */ - { KEY_RIGHT, "\033[C" }, /* CSI C */ - { KEY_LEFT, "\033[D" }, /* CSI D */ - - /* ---- ANSI editing keys ---- */ - { KEY_HOME, "\033[H" }, /* CSI H (or "\033[1~") */ - { KEY_END, "\033[F" }, /* CSI F (or "\033[4~") */ - { KEY_DELETE, "\033[3~" }, /* SS3 ~ */ - { KEY_INSERT, "\033[2~" }, /* CSI ~ */ - { KEY_PAGEUP, "\033[5~" }, /* CSI ~ */ - { KEY_PAGEDOWN, "\033[6~" }, /* CSI ~ */ - - /* ---- F1-F12 ---- */ - { KEY_F1, "\033OP" }, - { KEY_F2, "\033OQ" }, - { KEY_F3, "\033OR" }, - { KEY_F4, "\033OS" }, - { KEY_F5, "\033[15~"}, - { KEY_F6, "\033[17~"}, - { KEY_F7, "\033[18~"}, - { KEY_F8, "\033[19~"}, - { KEY_F9, "\033[20~"}, - { KEY_F10, "\033[21~"}, - { KEY_F11, "\033[23~"}, - { KEY_F12, "\033[24~"}, -}; - -static const char *ctrl_key_utf8_lookup(uint32_t keycode) { - for (size_t i = 0; i < sizeof(ctrl_key_utf8_map) / sizeof(ctrl_key_utf8_map[0]); i++) - if (ctrl_key_utf8_map[i].keycode == keycode) - return ctrl_key_utf8_map[i].utf8; - return NULL; -} - - - - - /* ============================================================ * Keyboard context * ============================================================ */ @@ -522,41 +222,22 @@ static void update_leds(struct kbd_ctx *kc) { * Enqueue * ============================================================ */ static void enqueue_key(const struct key_item *src) { - struct key_item *elm = malloc(sizeof(*elm)); - if (!elm) return; - *elm = *src; - elm->flage = 0; // mark as needing free + struct key_item item = *src; + item.flage = 0; /* DEBUG: every raw key event from keyboard thread */ char utf8_dbg[64] = ""; int di = 0; - for (int i = 0; i < (int)sizeof(elm->utf8) && elm->utf8[i] && di < 60; i++) { - unsigned char c = (unsigned char)elm->utf8[i]; + for (int i = 0; i < (int)sizeof(item.utf8) && item.utf8[i] && di < 60; i++) { + unsigned char c = (unsigned char)item.utf8[i]; if (c >= 0x20 && c < 0x7f) utf8_dbg[di++] = (char)c; else di += snprintf(utf8_dbg+di, 64-di, "\\x%02x", c); } utf8_dbg[di] = '\0'; SLOGD("[KBD] enqueue code=%u state=%s sym=%s utf8='%s' cp=0x%x mods=0x%x run=%d home_flag=%d", - elm->key_code, kbd_state_name(elm->key_state), elm->sym_name, - utf8_dbg, elm->codepoint, elm->mods, LVGL_RUN_FLAGE, LVGL_HOME_KEY_FLAG); - - if(elm->key_code == KEY_ESC) { - LVGL_HOME_KEY_FLAG = elm->key_state; - SLOGI("[KBD] LVGL_HOME_KEY_FLAG := %d", LVGL_HOME_KEY_FLAG); - } - - if(LVGL_RUN_FLAGE) - { - pthread_mutex_lock(&keyboard_mutex); - STAILQ_INSERT_TAIL(&keyboard_queue, elm, entries); - pthread_mutex_unlock(&keyboard_mutex); - } - else - { - SLOGW("[KBD] dropped (LVGL_RUN_FLAGE=0, external app running)"); - free(elm); - } - + item.key_code, kbd_state_name(item.key_state), item.sym_name, + utf8_dbg, item.codepoint, item.mods, LVGL_RUN_FLAGE, LVGL_HOME_KEY_FLAG); + (void)cp0_keyboard_queue_push(&item); } int cp0_keyboard_inject(uint32_t key_code, int key_state, uint32_t mods) @@ -569,7 +250,7 @@ int cp0_keyboard_inject(uint32_t key_code, int key_state, uint32_t mods) item.key_code = key_code; item.key_state = key_state; item.mods = mods; - const char *ctrl = ctrl_key_utf8_lookup(key_code); + const char *ctrl = cp0_keyboard_control_utf8(key_code); if (ctrl) snprintf(item.utf8, sizeof(item.utf8), "%s", ctrl); snprintf(item.sym_name, sizeof(item.sym_name), "RPC_%u", key_code); enqueue_key(&item); @@ -578,23 +259,19 @@ int cp0_keyboard_inject(uint32_t key_code, int key_state, uint32_t mods) int cp0_keyboard_inject_text(const char *utf8) { - if (!utf8) + if (!utf8 || cp0_keyboard_utf8_validate(utf8) != 0) return -1; - const unsigned char *cursor = (const unsigned char *)utf8; + const char *cursor = utf8; while (*cursor) { - size_t length = 1; - if ((*cursor & 0xe0) == 0xc0) length = 2; - else if ((*cursor & 0xf0) == 0xe0) length = 3; - else if ((*cursor & 0xf8) == 0xf0) length = 4; - - for (size_t i = 1; i < length; ++i) - if (!cursor[i] || (cursor[i] & 0xc0) != 0x80) - return -1; + size_t length = 0; + uint32_t codepoint = 0; + if (cp0_keyboard_utf8_decode_one(cursor, &codepoint, &length) != 0) return -1; struct key_item item = {0}; item.key_state = KBD_KEY_RELEASED; memcpy(item.utf8, cursor, length); + item.codepoint = codepoint; snprintf(item.sym_name, sizeof(item.sym_name), "RPC_TEXT"); enqueue_key(&item); cursor += length; @@ -606,9 +283,14 @@ int cp0_keyboard_inject_text(const char *utf8) * Key repeat control * ============================================================ */ static void repeat_start(struct kbd_ctx *kc) { + const uint32_t key_code = kc->repeat_template.key_code; + const uint32_t delay_ms = + key_code == KEY_UP || key_code == KEY_DOWN || + key_code == KEY_LEFT || key_code == KEY_RIGHT + ? NAV_REPEAT_DELAY_MS : REPEAT_DELAY_MS; struct itimerspec ts = { .it_interval = { .tv_sec = 0, .tv_nsec = (long)REPEAT_RATE_MS * 1000000L }, - .it_value = { .tv_sec = 0, .tv_nsec = (long)REPEAT_DELAY_MS * 1000000L }, + .it_value = { .tv_sec = 0, .tv_nsec = (long)delay_ms * 1000000L }, }; timerfd_settime(kc->repeat_fd, 0, &ts, NULL); kc->repeating = true; @@ -620,33 +302,6 @@ static void repeat_stop(struct kbd_ctx *kc) { } /* Encode a UTF-32 code point as UTF-8 and return the byte count */ -static int utf32_to_utf8(uint32_t cp, char *out, size_t n) { - if (n < 1) return 0; - if (cp < 0x80) { - if (n < 2) return 0; - out[0] = (char)cp; out[1] = '\0'; return 1; - } else if (cp < 0x800) { - if (n < 3) return 0; - out[0] = 0xC0 | (cp >> 6); - out[1] = 0x80 | (cp & 0x3F); - out[2] = '\0'; return 2; - } else if (cp < 0x10000) { - if (n < 4) return 0; - out[0] = 0xE0 | (cp >> 12); - out[1] = 0x80 | ((cp >> 6) & 0x3F); - out[2] = 0x80 | (cp & 0x3F); - out[3] = '\0'; return 3; - } else if (cp < 0x110000) { - if (n < 5) return 0; - out[0] = 0xF0 | (cp >> 18); - out[1] = 0x80 | ((cp >> 12) & 0x3F); - out[2] = 0x80 | ((cp >> 6) & 0x3F); - out[3] = 0x80 | (cp & 0x3F); - out[4] = '\0'; return 4; - } - out[0] = '\0'; return 0; -} - /* ============================================================ * Core: handle one key event * ============================================================ */ @@ -658,7 +313,7 @@ static void process_key(struct kbd_ctx *kc, uint32_t code, int pressed) item.key_state = pressed ? KBD_KEY_PRESSED : KBD_KEY_RELEASED; /* ---------- 1. TCA8418 custom keycodes first ---------- */ - const struct tca8418_keymap_entry *mapped = tca8418_keymap_lookup(code); + const struct cp0_keyboard_keymap_entry *mapped = cp0_keyboard_keymap_lookup(code); if (mapped) { xkb_keysym_t sym = xkb_keysym_from_name(mapped->sym_name, XKB_KEYSYM_NO_FLAGS); @@ -746,7 +401,7 @@ static void process_key(struct kbd_ctx *kc, uint32_t code, int pressed) item.utf8, sizeof(item.utf8)); if (item.utf8[0] == '\0' && item.codepoint != 0) { /* get_utf8 filters control characters; fall back to manual encoding */ - utf32_to_utf8(item.codepoint, item.utf8, sizeof(item.utf8)); + (void)cp0_keyboard_utf32_to_utf8(item.codepoint, item.utf8, sizeof(item.utf8)); } if (item.codepoint == 0) item.codepoint = xkb_state_key_get_utf32(kc->state, keycode); @@ -756,7 +411,7 @@ static void process_key(struct kbd_ctx *kc, uint32_t code, int pressed) /* xkbcommon does not produce utf8 for function keys (UP/DOWN/ENTER/BACKSPACE, etc.), * manually fill ANSI/VT100 terminal control characters here for upper layers */ if (item.utf8[0] == '\0') { - const char *ctrl = ctrl_key_utf8_lookup(code); + const char *ctrl = cp0_keyboard_control_utf8(code); if (ctrl) { snprintf(item.utf8, sizeof(item.utf8), "%s", ctrl); } @@ -852,8 +507,6 @@ static void kbd_wake_up(struct kbd_ctx *kc) { * Thread main loop * ============================================================ */ void *keyboard_read_thread(void *argv) { - STAILQ_INIT(&keyboard_queue); - char *device_path_arg = argv ? (char *)argv : NULL; const char *device_path = device_path_arg ? device_path_arg : "/dev/input/by-path/platform-3f804000.i2c-event"; @@ -893,7 +546,8 @@ void *keyboard_read_thread(void *argv) { SLOGI("Start listening for keyboard input (%s)", device_path); libinput_dispatch(kc.li); - while (1) { + while (!atomic_load_explicit(&keyboard_shutdown_requested, + memory_order_acquire)) { if (keyboard_paused_flag) { usleep(50000); continue; @@ -937,8 +591,10 @@ void *keyboard_read_thread(void *argv) { } out: + g_libinput = NULL; if (kc.repeat_fd >= 0) close(kc.repeat_fd); free_xkb(&kc); + if (kc.dev) libinput_path_remove_device(kc.dev); if (kc.li) libinput_unref(kc.li); free(device_path_arg); return NULL; @@ -946,9 +602,13 @@ void *keyboard_read_thread(void *argv) { void init_input(void) { - static int input_initialized = 0; - if (input_initialized) + pthread_mutex_lock(&keyboard_thread_mutex); + if (!cp0_keyboard_thread_can_init(&keyboard_thread_lifecycle)) { + pthread_mutex_unlock(&keyboard_thread_mutex); return; + } + + cp0_keyboard_queue_init(); if (LV_EVENT_KEYBOARD == 0) LV_EVENT_KEYBOARD = lv_event_register_id(); @@ -960,22 +620,47 @@ void init_input(void) setenv("APPLAUNCH_LINUX_KEYBOARD_DEVICE", keyboard_device, 1); setenv("LV_LINUX_KEYBOARD_DEVICE", keyboard_device, 1); - tca8418_load_runtime_keymap(); + cp0_keyboard_keymap_load(); char *keyboard_device_arg = strdup(keyboard_device); if (keyboard_device_arg == NULL) { perror("strdup keyboard_device"); + pthread_mutex_unlock(&keyboard_thread_mutex); return; } - pthread_t keyboard_read_thread_id; + atomic_store_explicit(&keyboard_shutdown_requested, false, + memory_order_release); if (pthread_create(&keyboard_read_thread_id, NULL, keyboard_read_thread, keyboard_device_arg) != 0) { perror("pthread_create keyboard_read_thread"); free(keyboard_device_arg); + pthread_mutex_unlock(&keyboard_thread_mutex); return; } - pthread_detach(keyboard_read_thread_id); - cp0_create_lvgl_input_devices(); - input_initialized = 1; + cp0_keyboard_thread_mark_started(&keyboard_thread_lifecycle); + cp0_keyboard_create_lvgl_input_devices(); + pthread_mutex_unlock(&keyboard_thread_mutex); +} + +void deinit_input(void) +{ + pthread_t thread; + int should_join = 0; + atomic_store_explicit(&keyboard_shutdown_requested, true, + memory_order_release); + pthread_mutex_lock(&keyboard_thread_mutex); + should_join = cp0_keyboard_thread_begin_deinit( + &keyboard_thread_lifecycle); + if (should_join) thread = keyboard_read_thread_id; + pthread_mutex_unlock(&keyboard_thread_mutex); + if (!should_join) return; + const int joined = !pthread_equal(thread, pthread_self()) && + pthread_join(thread, NULL) == 0; + pthread_mutex_lock(&keyboard_thread_mutex); + if (joined) + cp0_keyboard_thread_finish_deinit(&keyboard_thread_lifecycle); + else + cp0_keyboard_thread_cancel_deinit(&keyboard_thread_lifecycle); + pthread_mutex_unlock(&keyboard_thread_mutex); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_lara.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_lara.cpp index 1aec6e6a..7fc30fda 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_lara.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_lara.cpp @@ -1,4 +1,11 @@ #include "cp0_lvgl_app.h" +#include "cp0_lora_backend.hpp" +#include "cp0_lora_device_discovery.hpp" +#include "cp0_lora_gpio.hpp" +#include "cp0_lora_hat_power_controller.hpp" +#include "cp0_lora_pi4io_controller.hpp" +#include "cp0_lora_runtime_policy.hpp" +#include "cp0_lora_spi_device.hpp" #include "../cp0_app_internal_utils.h" #include "hal_lvgl_bsp.h" @@ -9,10 +16,6 @@ #include #include #include -#include -#include -#include -#include #include #include @@ -27,48 +30,6 @@ #define HAS_LINUX_GPIO_CDEV 0 #endif -#if __has_include() && __has_include() -#include -#include -#else -extern "C" int ioctl(int fd, unsigned long request, ...); -struct spi_ioc_transfer { - unsigned long tx_buf; - unsigned long rx_buf; - uint32_t len; - uint32_t speed_hz; - uint16_t delay_usecs; - uint8_t bits_per_word; - uint8_t cs_change; - uint32_t pad; -}; -#ifndef SPI_MODE_0 -#define SPI_MODE_0 0 -#endif -#ifndef SPI_IOC_WR_MODE -#define SPI_IOC_WR_MODE 0 -#endif -#ifndef SPI_IOC_WR_BITS_PER_WORD -#define SPI_IOC_WR_BITS_PER_WORD 0 -#endif -#ifndef SPI_IOC_WR_MAX_SPEED_HZ -#define SPI_IOC_WR_MAX_SPEED_HZ 0 -#endif -#ifndef SPI_IOC_MESSAGE -#define SPI_IOC_MESSAGE(N) 0 -#endif -#endif - -#if __has_include() -#include -#define CP0_LORA_HAS_LINUX_I2CDEV 1 -#else -#define CP0_LORA_HAS_LINUX_I2CDEV 0 -#ifndef I2C_SLAVE -#define I2C_SLAVE 0x0703 -#endif -#endif - #include "RadioLib.h" #if __has_include() #include "hal/RPi/PiHal.h" @@ -94,503 +55,100 @@ namespace cp0_lora_backend { // ============================================================ // Hardware configuration and state // ============================================================ -static int g_spi_fd = -1; -static bool g_lora_tx_mode = false; -static bool g_lora_selected_tx_mode = false; -static bool g_lora_tx_in_progress = false; -static bool g_lora_pending_rx_after_tx = false; -static uint64_t g_lora_last_auto_tx_ms = 0; -static char g_spi_device[64] = "/dev/spidev0.1"; -static unsigned int g_spi_speed = 1000000; -static int g_lora_sck_gpio = 11; -static int g_lora_mosi_gpio = 10; -static int g_lora_miso_gpio = 9; -static int g_lora_power_gpio = 5; -static int g_lora_nss_gpio = 7; -static bool g_lora_nss_manual = false; -static int g_lora_rst_gpio = 26; -static int g_lora_irq_gpio = 23; -static int g_lora_busy_gpio = 22; -static int g_lora_rst_fd = -1; -static int g_lora_busy_fd = -1; -static int g_lora_irq_fd = -1; -static int g_lora_nss_fd = -1; -static volatile bool g_lora_initialized = false; -static bool g_lora_hw_ready = false; -static bool g_lora_irq_poll_fallback = true; -static volatile bool g_lora_rx_done = false; -static volatile bool g_lora_tx_done = false; -static uint32_t g_lora_tx_counter = 0; -static uint64_t g_lora_tx_start_ms = 0; -static uint64_t g_lora_sent_popup_until_ms = 0; -static char g_lora_last_rx[128] = {0}; -static char g_lora_last_tx[128] = "Hello from M5 LoRa-1262"; -static char g_lora_tx_input[128] = ""; -static bool g_lora_has_sent_message = false; -static float g_lora_last_rssi = 0.0f; -static float g_lora_last_snr = 0.0f; -static const char *g_lora_cfg_freq = "869.525024MHz"; -static const char *g_lora_cfg_bw = "250kHz"; -static const char *g_lora_cfg_sf = "SF7"; -static const char *g_lora_cfg_cr = "4/5"; -static const char *g_lora_cfg_sync = "0x34"; -static const char *g_lora_cfg_preamble = "20"; -static const char *g_lora_cfg_power = "10dBm"; -static const char *g_lora_cfg_tcxo = "0.0V(disabled)"; -static char g_lora_last_diag[256] = "idle"; -static char g_lora_probe_summary[256] = "probe not started"; -static char g_lora_probe_display[128] = "SPI: probing..."; -static const int g_pi4io_i2c_bus = 1; -static const int g_pi4io_sda_gpio = 2; -static const int g_pi4io_scl_gpio = 3; -static const uint8_t g_pi4io_i2c_addr = 0x43; -static bool g_pi4io_found = false; -static bool g_pi4io_initialized = false; -static char g_pi4io_status[160] = "I2C 0x43 not checked"; -static uint8_t g_pi4io_output_cache = 0x00; -static uint8_t g_pi4io_config_cache = 0xFF; -static uint8_t g_pi4io_polarity_cache = 0x00; -static int g_hat_5vout_fd = -1; -static int g_hat_5vout_offset = 5; -static char g_hat_5vout_chip[64] = ""; -static int g_hat_5vout_last_sysfs_ret = -999; -static int g_hat_5vout_last_value = -1; -static bool g_hat_5vout_last_cdev_ok = false; +struct LoraRuntimeState +{ + cp0_lora::SpiDevice spi; + Module *radio_module = nullptr; + SX1262 *radio = nullptr; + + int power_gpio = 5; + int rst_gpio = 26; + int irq_gpio = 23; + int busy_gpio = 22; + int rst_fd = -1; + int busy_fd = -1; + int irq_fd = -1; + bool nss_manual = false; + bool irq_poll_fallback = true; + + volatile bool initialized = false; + bool hw_ready = false; + bool tx_mode = false; + bool selected_tx_mode = false; + bool tx_in_progress = false; + bool pending_rx_after_tx = false; + volatile bool rx_done = false; + volatile bool tx_done = false; + bool rx_event = false; + bool tx_event = false; + bool has_sent_message = false; + + uint32_t tx_counter = 0; + uint64_t tx_start_ms = 0; + uint64_t last_auto_tx_ms = 0; + float last_rssi = 0.0f; + float last_snr = 0.0f; + char last_rx[128] = {}; + char last_tx[128] = "Hello from M5 LoRa-1262"; + char last_diag[256] = "idle"; + char probe_summary[256] = "probe not started"; + char probe_display[128] = "SPI: probing..."; + + void reset_radio() + { + delete radio; + radio = nullptr; + delete radio_module; + radio_module = nullptr; + } + + void close_gpio_lines() + { + close_fd(rst_fd); + close_fd(busy_fd); + close_fd(irq_fd); + } + +private: + static void close_fd(int &fd) + { + if (fd >= 0) { + ::close(fd); + fd = -1; + } + } +}; + +static LoraRuntimeState g_lora; // Forward declarations static uint64_t get_monotonic_ms(void); static bool lora_spi_transfer(const uint8_t *tx, uint8_t *rx, size_t len); -static int gpio_set_value(int gpio, int value); -#if HAS_LINUX_GPIO_CDEV -static bool gpio_open_output_line(const char *chip_path, int offset, int value, int *line_fd); -static bool gpio_set_output_line_value(int line_fd, int value); -#endif -static int gpio_init_output_any(const char *chip_env_name, const char *offset_env_name, int gpio, int value, int *line_fd, const char *line_name); -static int gpio_init_input_any(const char *chip_env_name, const char *offset_env_name, int gpio, int *line_fd, const char *line_name); -static int gpio_get_value_any(int gpio, int line_fd); -static int gpio_set_value_any(int gpio, int line_fd, int value); -static size_t collect_spi_candidates(char out[][64], size_t max_count, const char *preferred); static void resolve_lora_spi_device(void); static bool probe_lora_spi_device(void); -static bool hat_5vout_enable(void); -static bool hat_5vout_prepare_line(void); -static void lora_update_power_debug(const char *stage, int sysfs_ret, int gpio_value, bool cdev_ok); -static bool pi4io_scan_and_init_before_lora(void); -static bool pi4io_open_bus(int *fd); -static bool pi4io_select_device(int fd); -static bool pi4io_write_reg(int fd, uint8_t reg, uint8_t value); -static bool pi4io_probe_device(int fd); -static bool pi4io_init_device(int fd); static void lora_apply_mode(bool tx_mode); static void lora_start_receive_mode(void); static void lora_send_demo_packet(void); static void lora_service_irq_once(void); static void lora_check_tx_fallback(void); static void lora_set_diag_step(const char *step, int code, const char *detail); -static void lora_refresh_status(const char *prefix); static const char *lora_radiolib_status_text(int16_t state); static bool lora_send_text_packet(const char *payload); static void lora_init_hardware(void); -// ============================================================ -// GPIO / SPI / I2C low level (ported from UserDemo) -// ============================================================ - -static int write_text_file(const char *path, const char *value) -{ - int fd = open(path, O_WRONLY); - if (fd < 0) return -1; - ssize_t ret = write(fd, value, strlen(value)); - close(fd); - return ret < 0 ? -1 : 0; -} - -static int gpio_export_if_needed(int gpio) -{ - char path[64]; - snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/value", gpio); - if (access(path, F_OK) == 0) return 0; - char gpio_str[16]; - snprintf(gpio_str, sizeof(gpio_str), "%d", gpio); - if (write_text_file("/sys/class/gpio/export", gpio_str) < 0 && errno != EBUSY) { - return -1; - } - usleep(100000); - return 0; -} - -static int gpio_set_direction(int gpio, const char *direction) -{ - char path[64]; - snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/direction", gpio); - return write_text_file(path, direction); -} - -static int gpio_init_input(int gpio) -{ - return gpio_export_if_needed(gpio) < 0 || gpio_set_direction(gpio, "in") < 0 ? -1 : 0; -} - -static int gpio_open_value_fd(int gpio) -{ - char path[64]; - snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/value", gpio); - return open(path, O_RDONLY | O_NONBLOCK); -} - -static int gpio_init_input_irq_sysfs(int gpio, int *line_fd) -{ - if (line_fd == NULL) return -1; - if (gpio_init_input(gpio) < 0) return -1; - char edge_path[64]; - snprintf(edge_path, sizeof(edge_path), "/sys/class/gpio/gpio%d/edge", gpio); - if (write_text_file(edge_path, "rising") < 0) return -1; - int fd = gpio_open_value_fd(gpio); - if (fd < 0) return -1; - char dummy = 0; - lseek(fd, 0, SEEK_SET); - (void)read(fd, &dummy, 1); - *line_fd = fd; - return 0; -} - -static int gpio_init_output(int gpio, int value) -{ - if (gpio_export_if_needed(gpio) < 0) return -1; - if (value) { - if (gpio_set_direction(gpio, "high") == 0) return 0; - } else { - if (gpio_set_direction(gpio, "low") == 0) return 0; - } - if (gpio_set_direction(gpio, "out") < 0) return -1; - return gpio_set_value(gpio, value); -} - -static int gpio_get_value(int gpio) -{ - char path[64]; - char value = '0'; - snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/value", gpio); - int fd = open(path, O_RDONLY); - if (fd < 0) return -1; - ssize_t ret = read(fd, &value, 1); - close(fd); - if (ret <= 0) return -1; - return value == '0' ? 0 : 1; -} - -static int gpio_set_value(int gpio, int value) -{ - char path[64]; - snprintf(path, sizeof(path), "/sys/class/gpio/gpio%d/value", gpio); - return write_text_file(path, value ? "1" : "0"); -} - -#if HAS_LINUX_GPIO_CDEV -static bool gpio_open_input_line(const char *chip_path, int offset, int *line_fd) -{ - if (chip_path == NULL || line_fd == NULL) return false; - int chip_fd = open(chip_path, O_RDONLY); - if (chip_fd < 0) return false; - struct gpiohandle_request req; - memset(&req, 0, sizeof(req)); - req.lines = 1; - req.lineoffsets[0] = (uint32_t)offset; - req.flags = GPIOHANDLE_REQUEST_INPUT; - snprintf(req.consumer_label, sizeof(req.consumer_label), "applaunch-lora-in"); - if (ioctl(chip_fd, GPIO_GET_LINEHANDLE_IOCTL, &req) < 0) { - close(chip_fd); - return false; - } - close(chip_fd); - *line_fd = req.fd; - return true; -} - -static bool gpio_get_input_line_value(int line_fd, int *value) -{ - if (line_fd < 0 || value == NULL) return false; - struct gpiohandle_data data; - memset(&data, 0, sizeof(data)); - if (ioctl(line_fd, GPIOHANDLE_GET_LINE_VALUES_IOCTL, &data) < 0) return false; - *value = data.values[0] ? 1 : 0; - return true; -} - -static bool gpio_open_input_event_line(const char *chip_path, int offset, int *line_fd) -{ - if (chip_path == NULL || line_fd == NULL) return false; - int chip_fd = open(chip_path, O_RDONLY); - if (chip_fd < 0) return false; - struct gpioevent_request req; - memset(&req, 0, sizeof(req)); - req.lineoffset = (uint32_t)offset; - req.handleflags = GPIOHANDLE_REQUEST_INPUT; - req.eventflags = GPIOEVENT_REQUEST_RISING_EDGE; - snprintf(req.consumer_label, sizeof(req.consumer_label), "applaunch-lora-irq"); - if (ioctl(chip_fd, GPIO_GET_LINEEVENT_IOCTL, &req) < 0) { - close(chip_fd); - return false; - } - close(chip_fd); - *line_fd = req.fd; - (void)fcntl(*line_fd, F_SETFL, fcntl(*line_fd, F_GETFL, 0) | O_NONBLOCK); - return true; -} - -static bool gpio_line_name_matches(const char *name) -{ - static const char *candidates[] = { - "G5_HAT_5VOUT_EN", "HAT_5VOUT_EN", "PG5", "G5", - }; - if (name == NULL || name[0] == '\0') return false; - for (size_t i = 0; i < sizeof(candidates)/sizeof(candidates[0]); ++i) { - if (strcmp(name, candidates[i]) == 0) return true; - } - return false; -} - -static bool gpio_find_named_line(char *chip_path, size_t chip_path_size, int *offset) -{ - if (chip_path == NULL || chip_path_size == 0 || offset == NULL) return false; - for (int chip_index = 0; chip_index < 8; ++chip_index) { - char path[64]; - snprintf(path, sizeof(path), "/dev/gpiochip%d", chip_index); - int chip_fd = open(path, O_RDONLY); - if (chip_fd < 0) continue; - struct gpiochip_info chip_info; - memset(&chip_info, 0, sizeof(chip_info)); - if (ioctl(chip_fd, GPIO_GET_CHIPINFO_IOCTL, &chip_info) < 0) { - close(chip_fd); continue; - } - for (int line = 0; line < (int)chip_info.lines; ++line) { - struct gpioline_info line_info; - memset(&line_info, 0, sizeof(line_info)); - line_info.line_offset = line; - if (ioctl(chip_fd, GPIO_GET_LINEINFO_IOCTL, &line_info) < 0) continue; - if (gpio_line_name_matches(line_info.name) || gpio_line_name_matches(line_info.consumer)) { - snprintf(chip_path, chip_path_size, "%s", path); - *offset = line; - close(chip_fd); - return true; - } - } - close(chip_fd); - } - return false; -} - -static bool gpio_open_output_line(const char *chip_path, int offset, int value, int *line_fd) -{ - if (chip_path == NULL || line_fd == NULL) return false; - int chip_fd = open(chip_path, O_RDONLY); - if (chip_fd < 0) return false; - struct gpiohandle_request req; - memset(&req, 0, sizeof(req)); - req.lines = 1; - req.lineoffsets[0] = (uint32_t)offset; - req.flags = GPIOHANDLE_REQUEST_OUTPUT; - req.default_values[0] = (uint8_t)(value ? 1 : 0); - snprintf(req.consumer_label, sizeof(req.consumer_label), "applaunch-lora-5v"); - if (ioctl(chip_fd, GPIO_GET_LINEHANDLE_IOCTL, &req) < 0) { - close(chip_fd); - return false; - } - close(chip_fd); - *line_fd = req.fd; - return true; -} - -static bool gpio_set_output_line_value(int line_fd, int value) -{ - if (line_fd < 0) return false; - struct gpiohandle_data data; - memset(&data, 0, sizeof(data)); - data.values[0] = (uint8_t)(value ? 1 : 0); - return ioctl(line_fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &data) == 0; -} -#endif - -static int gpio_init_output_any(const char *chip_env_name, const char *offset_env_name, int gpio, int value, int *line_fd, const char *line_name) -{ - if (line_fd && *line_fd >= 0) return 0; -#if HAS_LINUX_GPIO_CDEV - const char *chip_env = getenv(chip_env_name); - const char *offset_env = getenv(offset_env_name); - char chip_path[64] = "/dev/gpiochip0"; - int offset = gpio; - if (chip_env && chip_env[0]) snprintf(chip_path, sizeof(chip_path), "%s", chip_env); - if (offset_env && offset_env[0]) offset = atoi(offset_env); - if (line_fd && gpio_open_output_line(chip_path, offset, value, line_fd)) { - SLOGI("LoRa GPIO %s via cdev: %s[%d]=%d", line_name ? line_name : "out", chip_path, offset, value); - return 0; - } -#endif - if (gpio_init_output(gpio, value) == 0) return 0; - SLOGI("LoRa GPIO %s init failed: gpio=%d errno=%d", line_name ? line_name : "out", gpio, errno); - return -1; -} - -static int gpio_init_input_any(const char *chip_env_name, const char *offset_env_name, int gpio, int *line_fd, const char *line_name) -{ - if (line_fd && *line_fd >= 0) return 0; -#if HAS_LINUX_GPIO_CDEV - const char *chip_env = getenv(chip_env_name); - const char *offset_env = getenv(offset_env_name); - char chip_path[64] = "/dev/gpiochip0"; - int offset = gpio; - if (chip_env && chip_env[0]) snprintf(chip_path, sizeof(chip_path), "%s", chip_env); - if (offset_env && offset_env[0]) offset = atoi(offset_env); - if (line_fd && gpio_open_input_line(chip_path, offset, line_fd)) { - SLOGI("LoRa GPIO %s via cdev: %s[%d]", line_name ? line_name : "in", chip_path, offset); - return 0; - } -#endif - if (gpio_init_input(gpio) == 0) return 0; - SLOGI("LoRa GPIO %s input init failed: gpio=%d errno=%d", line_name ? line_name : "in", gpio, errno); - return -1; -} - -static int gpio_init_input_irq_any(const char *chip_env_name, const char *offset_env_name, int gpio, int *line_fd, const char *line_name) -{ - if (line_fd && *line_fd >= 0) return 0; -#if HAS_LINUX_GPIO_CDEV - const char *chip_env = getenv(chip_env_name); - const char *offset_env = getenv(offset_env_name); - char chip_path[64] = "/dev/gpiochip0"; - int offset = gpio; - if (chip_env && chip_env[0]) snprintf(chip_path, sizeof(chip_path), "%s", chip_env); - if (offset_env && offset_env[0]) offset = atoi(offset_env); - if (line_fd && gpio_open_input_event_line(chip_path, offset, line_fd)) { - SLOGI("LoRa GPIO %s irq-event via cdev: %s[%d]", line_name ? line_name : "irq", chip_path, offset); - return 0; - } -#endif - if (line_fd && gpio_init_input_irq_sysfs(gpio, line_fd) == 0) { - SLOGI("LoRa GPIO %s irq-event via sysfs: gpio%d rising", line_name ? line_name : "irq", gpio); - return 0; - } - return -1; -} - -static int gpio_get_value_any(int gpio, int line_fd) -{ -#if HAS_LINUX_GPIO_CDEV - int value = 0; - if (line_fd >= 0 && gpio_get_input_line_value(line_fd, &value)) return value; -#endif - return gpio_get_value(gpio); -} - -static int gpio_set_value_any(int gpio, int line_fd, int value) -{ -#if HAS_LINUX_GPIO_CDEV - if (line_fd >= 0) return gpio_set_output_line_value(line_fd, value) ? 0 : -1; -#endif - return gpio_set_value(gpio, value); -} - -static size_t collect_spi_candidates(char out[][64], size_t max_count, const char *preferred) -{ - if (out == NULL || max_count == 0) return 0; - size_t count = 0; - auto append_candidate = [&](const char *path) { - if (path == NULL || path[0] == '\0') return; - for (size_t i = 0; i < count; ++i) if (strcmp(out[i], path) == 0) return; - if (count < max_count) { snprintf(out[count], 64, "%s", path); ++count; } - }; - append_candidate(preferred); - append_candidate("/dev/spidev0.1"); - append_candidate("/dev/spidev0.0"); - DIR *dir = opendir("/dev"); - if (dir != NULL) { - struct dirent *entry = NULL; - while ((entry = readdir(dir)) != NULL) { - if (strncmp(entry->d_name, "spidev", 6) != 0) continue; - char full_path[64]; - const char *dev_prefix = "/dev/"; - const size_t prefix_len = strlen(dev_prefix); - const size_t name_len = strlen(entry->d_name); - if (name_len >= sizeof(full_path) - prefix_len) continue; - memcpy(full_path, dev_prefix, prefix_len); - memcpy(full_path + prefix_len, entry->d_name, name_len + 1); - append_candidate(full_path); - } - closedir(dir); - } - const char *fallbacks[] = { - "/dev/spidev0.1", "/dev/spidev0.0", "/dev/spidev1.0", "/dev/spidev1.1", - "/dev/spidev2.0", "/dev/spidev2.1", "/dev/spidev3.0", "/dev/spidev3.1", - "/dev/spidev4.0", "/dev/spidev4.1", - }; - for (size_t i = 0; i < sizeof(fallbacks)/sizeof(fallbacks[0]); ++i) append_candidate(fallbacks[i]); - return count; -} - -static void lora_update_power_debug(const char *stage, int sysfs_ret, int gpio_value, bool cdev_ok) -{ - char text[256]; - const char *chip_text = g_hat_5vout_chip[0] ? g_hat_5vout_chip : "sysfs"; - const char *value_text = gpio_value < 0 ? "read_fail" : (gpio_value ? "HIGH" : "LOW"); - snprintf(text, sizeof(text), "5VDBG %s cdev=%s chip=%s[%d] sysfs_ret=%d gpio5=%s", - stage ? stage : "?", cdev_ok ? "ok" : "fail", chip_text, g_hat_5vout_offset, sysfs_ret, value_text); - SLOGI("%s", text); -} - -static bool hat_5vout_prepare_line(void) -{ -#if HAS_LINUX_GPIO_CDEV - const char *chip_env = getenv("HAT_5VOUT_CHIP"); - const char *offset_env = getenv("HAT_5VOUT_OFFSET"); - if (chip_env && chip_env[0]) { - snprintf(g_hat_5vout_chip, sizeof(g_hat_5vout_chip), "%s", chip_env); - g_hat_5vout_offset = offset_env && offset_env[0] ? atoi(offset_env) : 5; - } else if (!gpio_find_named_line(g_hat_5vout_chip, sizeof(g_hat_5vout_chip), &g_hat_5vout_offset)) { - snprintf(g_hat_5vout_chip, sizeof(g_hat_5vout_chip), "/dev/gpiochip0"); - g_hat_5vout_offset = 5; - } - if (g_hat_5vout_fd >= 0) { g_hat_5vout_last_cdev_ok = true; return true; } - if (gpio_open_output_line(g_hat_5vout_chip, g_hat_5vout_offset, 1, &g_hat_5vout_fd)) { - g_hat_5vout_last_cdev_ok = true; return true; - } - g_hat_5vout_last_cdev_ok = false; -#endif - return false; -} - static bool lora_spi_transfer(const uint8_t *tx, uint8_t *rx, size_t len) { - if (g_spi_fd < 0) return false; - struct spi_ioc_transfer tr; - memset(&tr, 0, sizeof(tr)); - tr.tx_buf = (unsigned long)tx; - tr.rx_buf = (unsigned long)rx; - tr.len = (uint32_t)len; - tr.speed_hz = g_spi_speed; - tr.bits_per_word = 8; - int ret = ioctl(g_spi_fd, SPI_IOC_MESSAGE(1), &tr); - return ret >= 0; + return g_lora.spi.transfer(tx, rx, len); } static bool lora_open_runtime_spi(void) { - if (g_spi_fd >= 0) return true; - g_spi_fd = open(g_spi_device, O_RDWR); - if (g_spi_fd < 0) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "runtime SPI open failed on %s", g_spi_device); - return false; - } - uint8_t mode = (uint8_t)SPI_MODE_0; - uint8_t bits = 8; - if (ioctl(g_spi_fd, SPI_IOC_WR_MODE, &mode) < 0 || - ioctl(g_spi_fd, SPI_IOC_WR_BITS_PER_WORD, &bits) < 0 || - ioctl(g_spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, &g_spi_speed) < 0) { - close(g_spi_fd); g_spi_fd = -1; - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "runtime SPI config failed on %s", g_spi_device); - return false; - } - return true; + if (g_lora.spi.open()) + return true; + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), + "runtime SPI open/config failed on %s", g_lora.spi.path()); + return false; } static bool sx1262_wait_while_busy(unsigned int timeout_ms) @@ -598,7 +156,7 @@ static bool sx1262_wait_while_busy(unsigned int timeout_ms) const unsigned int sleep_us = 1000; unsigned int waited_ms = 0; while (waited_ms < timeout_ms) { - int busy = gpio_get_value_any(g_lora_busy_gpio, g_lora_busy_fd); + int busy = gpio_get_value_any(g_lora.busy_gpio, g_lora.busy_fd); if (busy < 0) return false; if (busy == 0) return true; usleep(sleep_us); @@ -609,9 +167,9 @@ static bool sx1262_wait_while_busy(unsigned int timeout_ms) static bool sx1262_reset(void) { - if (gpio_set_value_any(g_lora_rst_gpio, g_lora_rst_fd, 0) < 0) return false; + if (gpio_set_value_any(g_lora.rst_gpio, g_lora.rst_fd, 0) < 0) return false; usleep(20000); - if (gpio_set_value_any(g_lora_rst_gpio, g_lora_rst_fd, 1) < 0) return false; + if (gpio_set_value_any(g_lora.rst_gpio, g_lora.rst_fd, 1) < 0) return false; usleep(10000); return sx1262_wait_while_busy(200); } @@ -626,141 +184,24 @@ static bool sx1262_get_status_raw(uint8_t *status) return true; } -static bool hat_5vout_enable(void) -{ - bool cdev_ok = false; -#if HAS_LINUX_GPIO_CDEV - if (hat_5vout_prepare_line()) { - if (gpio_set_output_line_value(g_hat_5vout_fd, 0)) { - cdev_ok = true; - g_hat_5vout_last_sysfs_ret = 0; - g_hat_5vout_last_value = gpio_get_value(g_lora_power_gpio); - lora_update_power_debug("cdev_set", g_hat_5vout_last_sysfs_ret, g_hat_5vout_last_value, cdev_ok); - usleep(50000); - return true; - } - } -#endif - g_hat_5vout_last_sysfs_ret = gpio_init_output(g_lora_power_gpio, 0); - g_hat_5vout_last_value = gpio_get_value(g_lora_power_gpio); - lora_update_power_debug("sysfs_set", g_hat_5vout_last_sysfs_ret, g_hat_5vout_last_value, cdev_ok); - if (g_hat_5vout_last_sysfs_ret == 0) { usleep(50000); return true; } - lora_update_power_debug("enable_fail", g_hat_5vout_last_sysfs_ret, g_hat_5vout_last_value, cdev_ok); - return false; -} - -static bool pi4io_open_bus(int *fd) -{ -#if !CP0_LORA_HAS_LINUX_I2CDEV - if (fd) *fd = -1; - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C dev header missing, cannot access 0x%02X", g_pi4io_i2c_addr); - return false; -#else - if (fd == NULL) { snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C fd pointer invalid"); return false; } - char dev_path[64]; - snprintf(dev_path, sizeof(dev_path), "/dev/i2c-%d", g_pi4io_i2c_bus); - *fd = open(dev_path, O_RDWR); - if (*fd < 0) { - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "open %s failed, SDA:%d SCL:%d errno=%d", - dev_path, g_pi4io_sda_gpio, g_pi4io_scl_gpio, errno); - return false; - } - return true; -#endif -} - -static bool pi4io_select_device(int fd) -{ - if (fd < 0) { snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C fd invalid for 0x%02X", g_pi4io_i2c_addr); return false; } - if (ioctl(fd, I2C_SLAVE, g_pi4io_i2c_addr) < 0) { - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "select 0x%02X failed on /dev/i2c-%d errno=%d", - g_pi4io_i2c_addr, g_pi4io_i2c_bus, errno); - return false; - } - return true; -} - -static bool pi4io_write_reg(int fd, uint8_t reg, uint8_t value) -{ - uint8_t buf[2] = {reg, value}; - return write(fd, buf, sizeof(buf)) == (ssize_t)sizeof(buf); -} - -static bool pi4io_probe_device(int fd) -{ - uint8_t reg = 0x00; - if (write(fd, ®, 1) != 1) { - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C 0x%02X not found on /dev/i2c-%d (SDA:%d SCL:%d)", - g_pi4io_i2c_addr, g_pi4io_i2c_bus, g_pi4io_sda_gpio, g_pi4io_scl_gpio); - return false; - } - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C 0x%02X found on /dev/i2c-%d (SDA:%d SCL:%d)", - g_pi4io_i2c_addr, g_pi4io_i2c_bus, g_pi4io_sda_gpio, g_pi4io_scl_gpio); - return true; -} - -static bool pi4io_init_device(int fd) -{ - if (fd < 0) { snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C IO init invalid fd for 0x%02X", g_pi4io_i2c_addr); return false; } - g_pi4io_polarity_cache = 0x00; - g_pi4io_output_cache = 0x01; - g_pi4io_config_cache = 0xFE; - errno = 0; - if (!pi4io_write_reg(fd, 0x02, g_pi4io_polarity_cache)) { - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C IO write POL failed at 0x%02X errno=%d", g_pi4io_i2c_addr, errno); - return false; - } - errno = 0; - if (!pi4io_write_reg(fd, 0x01, g_pi4io_output_cache)) { - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C IO write OUT failed at 0x%02X errno=%d", g_pi4io_i2c_addr, errno); - return false; - } - errno = 0; - if (!pi4io_write_reg(fd, 0x03, g_pi4io_config_cache)) { - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C IO write CFG failed at 0x%02X errno=%d", g_pi4io_i2c_addr, errno); - return false; - } - snprintf(g_pi4io_status, sizeof(g_pi4io_status), "I2C IO init ok OUT=0x%02X POL=0x%02X CFG=0x%02X P0=HIGH", - g_pi4io_output_cache, g_pi4io_polarity_cache, g_pi4io_config_cache); - return true; -} - -static bool pi4io_scan_and_init_before_lora(void) -{ - int fd = -1; - bool ok = false; - g_pi4io_found = false; - g_pi4io_initialized = false; - if (!pi4io_open_bus(&fd)) return false; - do { - if (!pi4io_select_device(fd)) break; - if (!pi4io_probe_device(fd)) break; - g_pi4io_found = true; - if (!pi4io_init_device(fd)) break; - g_pi4io_initialized = true; - ok = true; - } while (0); - if (fd >= 0) close(fd); - return ok; -} - static bool probe_lora_spi_device(void) { const char *spi_env = getenv("LORA_SPI_DEV"); char candidates[16][64] = {{0}}; - const size_t candidate_count = collect_spi_candidates(candidates, 16, spi_env); + const size_t candidate_count = + cp0_lora_device_discovery::collect_spi_candidates(candidates, 16, spi_env); char summary[256] = {0}; if (access("/dev", F_OK) != 0) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "Linux /dev not available; LoRa SPI HAL requires Raspberry Pi Linux runtime"); - snprintf(g_lora_probe_summary, sizeof(g_lora_probe_summary), "no /dev directory visible"); - snprintf(g_lora_probe_display, sizeof(g_lora_probe_display), "SPI: /dev unavailable"); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "Linux /dev not available; LoRa SPI HAL requires Raspberry Pi Linux runtime"); + snprintf(g_lora.probe_summary, sizeof(g_lora.probe_summary), "no /dev directory visible"); + snprintf(g_lora.probe_display, sizeof(g_lora.probe_display), "SPI: /dev unavailable"); return false; } if (candidate_count == 0) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "no /dev/spidev* found; enable SPI on Raspberry Pi OS"); - snprintf(g_lora_probe_summary, sizeof(g_lora_probe_summary), "probe aborted: no spidev nodes"); - snprintf(g_lora_probe_display, sizeof(g_lora_probe_display), "SPI: no spidev found"); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "no /dev/spidev* found; enable SPI on Raspberry Pi OS"); + snprintf(g_lora.probe_summary, sizeof(g_lora.probe_summary), "probe aborted: no spidev nodes"); + snprintf(g_lora.probe_display, sizeof(g_lora.probe_display), "SPI: no spidev found"); return false; } SLOGI("LoRa SPI probe policy: prefer SPI0 only, CE1 then CE0"); @@ -772,54 +213,45 @@ static bool probe_lora_spi_device(void) strncat(summary, dev, sizeof(summary) - strlen(summary) - 1); } if (spi_env && spi_env[0]) { - snprintf(g_lora_probe_summary, sizeof(g_lora_probe_summary), "probe order: %.96s%s%.128s", + snprintf(g_lora.probe_summary, sizeof(g_lora.probe_summary), "probe order: %.96s%s%.128s", spi_env, summary[0] ? ", " : "", summary); - snprintf(g_lora_probe_display, sizeof(g_lora_probe_display), "Try: %.96s -> 0.1 -> 0.0", spi_env); + snprintf(g_lora.probe_display, sizeof(g_lora.probe_display), "Try: %.96s -> 0.1 -> 0.0", spi_env); } else { - snprintf(g_lora_probe_summary, sizeof(g_lora_probe_summary), "probe order: %.224s", summary); - snprintf(g_lora_probe_display, sizeof(g_lora_probe_display), "Try: /dev/spidev0.1 -> /dev/spidev0.0"); + snprintf(g_lora.probe_summary, sizeof(g_lora.probe_summary), "probe order: %.224s", summary); + snprintf(g_lora.probe_display, sizeof(g_lora.probe_display), "Try: /dev/spidev0.1 -> /dev/spidev0.0"); } auto try_probe = [](const char *dev) -> bool { if (dev == NULL || dev[0] == '\0' || access(dev, F_OK) != 0) return false; - snprintf(g_spi_device, sizeof(g_spi_device), "%s", dev); - g_lora_nss_manual = false; - const char *cs_name = strstr(g_spi_device, "spidev0.1") ? "SPI0-CE1" : (strstr(g_spi_device, "spidev0.0") ? "SPI0-CE0" : "non-SPI0"); - SLOGI("LoRa probe: trying %s [%s] (cs=hw-auto)", g_spi_device, cs_name); - g_lora_initialized = false; - if (g_spi_fd >= 0) { close(g_spi_fd); g_spi_fd = -1; } - if (gpio_init_output_any("LORA_RST_CHIP", "LORA_RST_OFFSET", g_lora_rst_gpio, 1, &g_lora_rst_fd, "RST") < 0) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "RST gpio init failed on %s", g_spi_device); + g_lora.spi.set_path(dev); + g_lora.nss_manual = false; + const char *spi_path = g_lora.spi.path(); + const char *cs_name = strstr(spi_path, "spidev0.1") ? "SPI0-CE1" : (strstr(spi_path, "spidev0.0") ? "SPI0-CE0" : "non-SPI0"); + SLOGI("LoRa probe: trying %s [%s] (cs=hw-auto)", spi_path, cs_name); + g_lora.initialized = false; + g_lora.spi.close(); + if (gpio_init_output_any("LORA_RST_CHIP", "LORA_RST_OFFSET", g_lora.rst_gpio, 1, &g_lora.rst_fd, "RST") < 0) { + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "RST gpio init failed on %s", spi_path); return false; } if (!sx1262_reset()) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "RST/BUSY handshake failed on %s", g_spi_device); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "RST/BUSY handshake failed on %s", spi_path); return false; } uint8_t status = 0; - g_spi_fd = open(g_spi_device, O_RDWR); - if (g_spi_fd < 0) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "SPI open failed on %s", g_spi_device); - return false; - } - uint8_t mode = (uint8_t)SPI_MODE_0; - uint8_t bits = 8; - if (ioctl(g_spi_fd, SPI_IOC_WR_MODE, &mode) < 0 || - ioctl(g_spi_fd, SPI_IOC_WR_BITS_PER_WORD, &bits) < 0 || - ioctl(g_spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, &g_spi_speed) < 0) { - close(g_spi_fd); g_spi_fd = -1; - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "SPI config failed on %s", g_spi_device); + if (!g_lora.spi.open()) { + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "SPI open/config failed on %s", spi_path); return false; } bool ok = sx1262_get_status_raw(&status); - close(g_spi_fd); g_spi_fd = -1; + g_lora.spi.close(); if (!ok) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "status read failed on %s", g_spi_device); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "status read failed on %s", spi_path); return false; } - SLOGI("LoRa probe: %s [%s] (cs=hw-auto) status=0x%02X", g_spi_device, cs_name, status); - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "probe ok on %s[%s] cs=hw-auto status=0x%02X", g_spi_device, cs_name, status); - snprintf(g_lora_probe_display, sizeof(g_lora_probe_display), "FOUND: %s (%s)", g_spi_device, cs_name); + SLOGI("LoRa probe: %s [%s] (cs=hw-auto) status=0x%02X", spi_path, cs_name, status); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "probe ok on %s[%s] cs=hw-auto status=0x%02X", spi_path, cs_name, status); + snprintf(g_lora.probe_display, sizeof(g_lora.probe_display), "FOUND: %s (%s)", spi_path, cs_name); return true; }; @@ -827,8 +259,8 @@ static bool probe_lora_spi_device(void) for (size_t i = 0; i < candidate_count; ++i) { if (try_probe(candidates[i])) return true; } - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "all SPI buses probed, no SX1262 response (%.192s)", g_lora_probe_summary); - snprintf(g_lora_probe_display, sizeof(g_lora_probe_display), "NOT FOUND: tried 0.1 and 0.0"); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "all SPI buses probed, no SX1262 response (%.192s)", g_lora.probe_summary); + snprintf(g_lora.probe_display, sizeof(g_lora.probe_display), "NOT FOUND: tried 0.1 and 0.0"); return false; } @@ -836,16 +268,17 @@ static void resolve_lora_spi_device(void) { const char *spi_env = getenv("LORA_SPI_DEV"); char candidates[16][64] = {{0}}; - const size_t candidate_count = collect_spi_candidates(candidates, 16, spi_env); + const size_t candidate_count = + cp0_lora_device_discovery::collect_spi_candidates(candidates, 16, spi_env); if (spi_env != NULL && spi_env[0] != '\0' && access(spi_env, F_OK) == 0) { - snprintf(g_spi_device, sizeof(g_spi_device), "%s", spi_env); return; + g_lora.spi.set_path(spi_env); return; } for (size_t i = 0; i < candidate_count; ++i) { if (access(candidates[i], F_OK) == 0) { - snprintf(g_spi_device, sizeof(g_spi_device), "%s", candidates[i]); return; + g_lora.spi.set_path(candidates[i]); return; } } - snprintf(g_spi_device, sizeof(g_spi_device), "%s", spi_env && spi_env[0] ? spi_env : "/dev/spidev0.1"); + g_lora.spi.set_path(spi_env && spi_env[0] ? spi_env : "/dev/spidev0.1"); } @@ -860,12 +293,12 @@ class LinuxRadioLibHal : public PiHal { void pinMode(uint32_t pin, uint32_t mode) override { if (pin == RADIOLIB_NC) return; if (mode == GpioModeOutput) { - if (pin == (uint32_t)g_lora_rst_gpio) { - (void)gpio_init_output_any("LORA_RST_CHIP", "LORA_RST_OFFSET", (int)pin, 1, &g_lora_rst_fd, "RST"); + if (pin == (uint32_t)g_lora.rst_gpio) { + (void)gpio_init_output_any("LORA_RST_CHIP", "LORA_RST_OFFSET", (int)pin, 1, &g_lora.rst_fd, "RST"); } } else { - if (pin == (uint32_t)g_lora_busy_gpio) { - (void)gpio_init_input_any("LORA_BUSY_CHIP", "LORA_BUSY_OFFSET", (int)pin, &g_lora_busy_fd, "BUSY"); + if (pin == (uint32_t)g_lora.busy_gpio) { + (void)gpio_init_input_any("LORA_BUSY_CHIP", "LORA_BUSY_OFFSET", (int)pin, &g_lora.busy_fd, "BUSY"); } } } @@ -873,14 +306,14 @@ class LinuxRadioLibHal : public PiHal { void digitalWrite(uint32_t pin, uint32_t value) override { if (pin == RADIOLIB_NC) return; int line_fd = -1; - if (pin == (uint32_t)g_lora_rst_gpio) line_fd = g_lora_rst_fd; + if (pin == (uint32_t)g_lora.rst_gpio) line_fd = g_lora.rst_fd; (void)gpio_set_value_any((int)pin, line_fd, value ? 1 : 0); } uint32_t digitalRead(uint32_t pin) override { if (pin == RADIOLIB_NC) return 0; int line_fd = -1; - if (pin == (uint32_t)g_lora_busy_gpio) line_fd = g_lora_busy_fd; + if (pin == (uint32_t)g_lora.busy_gpio) line_fd = g_lora.busy_fd; int value = gpio_get_value_any((int)pin, line_fd); return value > 0 ? 1U : 0U; } @@ -920,8 +353,6 @@ class LinuxRadioLibHal : public PiHal { }; static LinuxRadioLibHal g_lora_radio_hal; -static Module *g_lora_radio_module = NULL; -static SX1262 *g_lora_radio = NULL; static uint64_t get_monotonic_ms(void) { @@ -932,9 +363,9 @@ static uint64_t get_monotonic_ms(void) static void lora_set_diag_step(const char *step, int code, const char *detail) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "%s%s%s | rc=%d", + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "%s%s%s | rc=%d", step ? step : "diag", (detail && detail[0]) ? " | " : "", (detail && detail[0]) ? detail : "", code); - SLOGI("LoRa diag: %s", g_lora_last_diag); + SLOGI("LoRa diag: %s", g_lora.last_diag); } static const char *lora_radiolib_status_text(int16_t state) @@ -955,233 +386,231 @@ static const char *lora_radiolib_status_text(int16_t state) static void lora_capture_device_errors(const char *stage, uint16_t irq_status) { - if (!g_lora_initialized || g_lora_radio == NULL) return; + if (!g_lora.initialized || g_lora.radio == NULL) return; SLOGI("LoRa error: %s irq=0x%04X", stage ? stage : "radio_err", irq_status); - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "%s irq=0x%04X", stage ? stage : "radio_err", irq_status); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "%s irq=0x%04X", stage ? stage : "radio_err", irq_status); } static bool lora_send_text_packet(const char *payload) { - if (!g_lora_initialized || g_lora_radio == NULL) { + if (!g_lora.initialized || g_lora.radio == NULL) { SLOGI("LoRa TX: not initialized"); return false; } if (payload == NULL || payload[0] == '\0') return false; - if (g_lora_tx_in_progress) return false; - snprintf(g_lora_last_tx, sizeof(g_lora_last_tx), "%s", payload); - g_lora_has_sent_message = true; - g_lora_tx_done = false; - g_lora_rx_done = false; - g_lora_pending_rx_after_tx = true; - g_lora_tx_mode = false; - g_lora_selected_tx_mode = false; - (void)g_lora_radio->standby(); - int16_t state = g_lora_radio->startTransmit((uint8_t *)g_lora_last_tx, strlen(g_lora_last_tx)); + if (g_lora.tx_in_progress) return false; + snprintf(g_lora.last_tx, sizeof(g_lora.last_tx), "%s", payload); + g_lora.has_sent_message = true; + g_lora.tx_done = false; + g_lora.rx_done = false; + g_lora.pending_rx_after_tx = true; + g_lora.tx_mode = false; + g_lora.selected_tx_mode = false; + (void)g_lora.radio->standby(); + int16_t state = g_lora.radio->startTransmit((uint8_t *)g_lora.last_tx, strlen(g_lora.last_tx)); if (state != RADIOLIB_ERR_NONE) { - g_lora_tx_in_progress = false; - g_lora_pending_rx_after_tx = false; + g_lora.tx_in_progress = false; + g_lora.pending_rx_after_tx = false; SLOGI("LoRa TX: startTransmit failed rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); return false; } - g_lora_tx_in_progress = true; - g_lora_tx_start_ms = g_lora_last_auto_tx_ms = get_monotonic_ms(); - SLOGI("LoRa TX: sending '%s'", g_lora_last_tx); + g_lora.tx_in_progress = true; + g_lora.tx_start_ms = g_lora.last_auto_tx_ms = get_monotonic_ms(); + SLOGI("LoRa TX: sending '%s'", g_lora.last_tx); return true; } static void lora_send_demo_packet(void) { - if (!g_lora_initialized || g_lora_radio == NULL) return; - if (!g_lora_tx_mode) return; - snprintf(g_lora_last_tx, sizeof(g_lora_last_tx), "Hello from M5 LoRa-1262 #%lu", (unsigned long)g_lora_tx_counter); - g_lora_has_sent_message = true; - g_lora_pending_rx_after_tx = false; - g_lora_tx_done = false; - g_lora_rx_done = false; - int16_t state = g_lora_radio->startTransmit((uint8_t *)g_lora_last_tx, strlen(g_lora_last_tx)); + if (!g_lora.initialized || g_lora.radio == NULL) return; + if (!g_lora.tx_mode) return; + snprintf(g_lora.last_tx, sizeof(g_lora.last_tx), "Hello from M5 LoRa-1262 #%lu", (unsigned long)g_lora.tx_counter); + g_lora.has_sent_message = true; + g_lora.pending_rx_after_tx = false; + g_lora.tx_done = false; + g_lora.rx_done = false; + int16_t state = g_lora.radio->startTransmit((uint8_t *)g_lora.last_tx, strlen(g_lora.last_tx)); if (state != RADIOLIB_ERR_NONE) { - g_lora_tx_in_progress = false; + g_lora.tx_in_progress = false; SLOGI("LoRa TX: demo startTransmit failed rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); return; } - g_lora_tx_in_progress = true; - g_lora_tx_start_ms = g_lora_last_auto_tx_ms = get_monotonic_ms(); - SLOGI("LoRa TX: demo sending '%s'", g_lora_last_tx); - ++g_lora_tx_counter; + g_lora.tx_in_progress = true; + g_lora.tx_start_ms = g_lora.last_auto_tx_ms = get_monotonic_ms(); + SLOGI("LoRa TX: demo sending '%s'", g_lora.last_tx); + ++g_lora.tx_counter; } static void lora_start_receive_mode(void) { - if (!g_lora_initialized || g_lora_radio == NULL) { + if (!g_lora.initialized || g_lora.radio == NULL) { SLOGI("LoRa RX: startReceive skipped, not initialized"); return; } - if (g_lora_tx_in_progress) { + if (g_lora.tx_in_progress) { SLOGI("LoRa RX: startReceive skipped, TX in progress"); - g_lora_pending_rx_after_tx = true; + g_lora.pending_rx_after_tx = true; return; } - g_lora_tx_mode = false; - g_lora_selected_tx_mode = false; - g_lora_pending_rx_after_tx = false; + g_lora.tx_mode = false; + g_lora.selected_tx_mode = false; + g_lora.pending_rx_after_tx = false; SLOGI("LoRa RX: startReceive()"); - int16_t state = g_lora_radio->startReceive(); + int16_t state = g_lora.radio->startReceive(); SLOGI("LoRa RX: startReceive rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); if (state != RADIOLIB_ERR_NONE) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "startReceive rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "startReceive rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); } } static void lora_apply_mode(bool tx_mode) { - g_lora_selected_tx_mode = tx_mode; - if (!g_lora_initialized || g_lora_radio == NULL) { + g_lora.selected_tx_mode = tx_mode; + if (!g_lora.initialized || g_lora.radio == NULL) { SLOGI("LoRa mode: not initialized"); return; } if (tx_mode) { - g_lora_pending_rx_after_tx = false; - g_lora_tx_mode = true; - g_lora_last_auto_tx_ms = get_monotonic_ms(); - if (g_lora_tx_in_progress) { + g_lora.pending_rx_after_tx = false; + g_lora.tx_mode = true; + g_lora.last_auto_tx_ms = get_monotonic_ms(); + if (g_lora.tx_in_progress) { SLOGI("LoRa mode: TX already in progress"); return; } - int16_t state = g_lora_radio->standby(); + int16_t state = g_lora.radio->standby(); if (state == RADIOLIB_ERR_NONE) { SLOGI("LoRa mode: TX ready"); } else { SLOGI("LoRa mode: set TX failed rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); } } else { - if (g_lora_tx_in_progress) { - g_lora_pending_rx_after_tx = true; + if (g_lora.tx_in_progress) { + g_lora.pending_rx_after_tx = true; SLOGI("LoRa mode: TX in progress, will RX after done"); return; } - g_lora_pending_rx_after_tx = false; - g_lora_tx_mode = false; - g_lora_last_auto_tx_ms = get_monotonic_ms(); + g_lora.pending_rx_after_tx = false; + g_lora.tx_mode = false; + g_lora.last_auto_tx_ms = get_monotonic_ms(); lora_start_receive_mode(); } } static void lora_service_irq_once(void) { - if (!g_lora_initialized || g_lora_radio == NULL) return; + if (!g_lora.initialized || g_lora.radio == NULL) return; bool irq_event = false; - if (!g_lora_irq_poll_fallback && g_lora_irq_fd >= 0) { + if (!g_lora.irq_poll_fallback && g_lora.irq_fd >= 0) { struct pollfd pfd; memset(&pfd, 0, sizeof(pfd)); - pfd.fd = g_lora_irq_fd; + pfd.fd = g_lora.irq_fd; pfd.events = POLLIN | POLLPRI; if (poll(&pfd, 1, 0) > 0 && (pfd.revents & (POLLIN | POLLPRI))) { irq_event = true; #if HAS_LINUX_GPIO_CDEV struct gpioevent_data event_data; - while (read(g_lora_irq_fd, &event_data, sizeof(event_data)) == (ssize_t)sizeof(event_data)) {} + while (read(g_lora.irq_fd, &event_data, sizeof(event_data)) == (ssize_t)sizeof(event_data)) {} #else char value_buf[8]; - lseek(g_lora_irq_fd, 0, SEEK_SET); - while (read(g_lora_irq_fd, value_buf, sizeof(value_buf)) > 0) { lseek(g_lora_irq_fd, 0, SEEK_SET); break; } + lseek(g_lora.irq_fd, 0, SEEK_SET); + while (read(g_lora.irq_fd, value_buf, sizeof(value_buf)) > 0) { lseek(g_lora.irq_fd, 0, SEEK_SET); break; } #endif } } - uint32_t irq_flags = g_lora_radio->getIrqFlags(); + uint32_t irq_flags = g_lora.radio->getIrqFlags(); if (irq_flags != RADIOLIB_SX126X_IRQ_NONE || irq_event) { SLOGI("LoRa IRQ: event=%d flags=0x%08lX tx_in_progress=%d tx_mode=%d", - irq_event ? 1 : 0, (unsigned long)irq_flags, g_lora_tx_in_progress ? 1 : 0, g_lora_tx_mode ? 1 : 0); + irq_event ? 1 : 0, (unsigned long)irq_flags, g_lora.tx_in_progress ? 1 : 0, g_lora.tx_mode ? 1 : 0); } if (!irq_event && irq_flags == RADIOLIB_SX126X_IRQ_NONE) return; - if (g_lora_tx_in_progress) { + if (g_lora.tx_in_progress) { if (irq_flags & RADIOLIB_SX126X_IRQ_TX_DONE) { - int16_t state = g_lora_radio->finishTransmit(); + int16_t state = g_lora.radio->finishTransmit(); if (state == RADIOLIB_ERR_NONE) { - g_lora_tx_done = true; + g_lora.tx_done = true; } else { - g_lora_tx_in_progress = false; + g_lora.tx_in_progress = false; SLOGI("LoRa TX: finishTransmit failed rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); } } else if (irq_flags & RADIOLIB_SX126X_IRQ_TIMEOUT) { - g_lora_tx_in_progress = false; - g_lora_tx_start_ms = 0; + g_lora.tx_in_progress = false; + g_lora.tx_start_ms = 0; lora_capture_device_errors("TX irq timeout", 0); - if (g_lora_pending_rx_after_tx || !g_lora_tx_mode) lora_start_receive_mode(); + if (g_lora.pending_rx_after_tx || !g_lora.tx_mode) lora_start_receive_mode(); } return; } if (irq_flags & RADIOLIB_SX126X_IRQ_RX_DONE) { - uint8_t rx_buf[sizeof(g_lora_last_rx)] = {0}; - int16_t state = g_lora_radio->readData(rx_buf, sizeof(g_lora_last_rx) - 1); + uint8_t rx_buf[sizeof(g_lora.last_rx)] = {0}; + int16_t state = g_lora.radio->readData(rx_buf, sizeof(g_lora.last_rx) - 1); SLOGI("LoRa RX: readData rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); if (state == RADIOLIB_ERR_NONE) { - memcpy(g_lora_last_rx, rx_buf, sizeof(g_lora_last_rx)); - g_lora_last_rx[sizeof(g_lora_last_rx) - 1] = '\0'; - g_lora_last_rssi = g_lora_radio->getRSSI(); - g_lora_last_snr = g_lora_radio->getSNR(); - g_lora_rx_done = true; - SLOGI("LoRa RX OK: '%s' RSSI=%.1f SNR=%.1f", g_lora_last_rx, g_lora_last_rssi, g_lora_last_snr); + memcpy(g_lora.last_rx, rx_buf, sizeof(g_lora.last_rx)); + g_lora.last_rx[sizeof(g_lora.last_rx) - 1] = '\0'; + g_lora.last_rssi = g_lora.radio->getRSSI(); + g_lora.last_snr = g_lora.radio->getSNR(); + g_lora.rx_done = true; + SLOGI("LoRa RX OK: '%s' RSSI=%.1f SNR=%.1f", g_lora.last_rx, g_lora.last_rssi, g_lora.last_snr); } else if (state != RADIOLIB_ERR_CRC_MISMATCH) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "readData rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "readData rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); } - if (!g_lora_tx_mode) lora_start_receive_mode(); + if (!g_lora.tx_mode) lora_start_receive_mode(); } else if (irq_flags & (RADIOLIB_SX126X_IRQ_CRC_ERR | RADIOLIB_SX126X_IRQ_HEADER_ERR)) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "RX crc/header error irq=0x%04lX", (unsigned long)irq_flags); - SLOGI("LoRa RX error: %s", g_lora_last_diag); - if (!g_lora_tx_mode) lora_start_receive_mode(); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "RX crc/header error irq=0x%04lX", (unsigned long)irq_flags); + SLOGI("LoRa RX error: %s", g_lora.last_diag); + if (!g_lora.tx_mode) lora_start_receive_mode(); } else if (irq_flags & RADIOLIB_SX126X_IRQ_TIMEOUT) { - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "RX timeout irq=0x%04lX", (unsigned long)irq_flags); - SLOGI("LoRa RX timeout: %s", g_lora_last_diag); + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "RX timeout irq=0x%04lX", (unsigned long)irq_flags); + SLOGI("LoRa RX timeout: %s", g_lora.last_diag); } } static void lora_check_tx_fallback(void) { - if (!g_lora_initialized || !g_lora_tx_in_progress || g_lora_radio == NULL) return; uint64_t now_ms = get_monotonic_ms(); - if (g_lora_tx_start_ms != 0 && now_ms - g_lora_tx_start_ms >= 4000ULL) { - g_lora_tx_in_progress = false; - g_lora_tx_start_ms = 0; - g_lora_last_auto_tx_ms = now_ms; + if (cp0_lora_runtime_policy::should_timeout_transmit( + g_lora.initialized, g_lora.tx_in_progress, g_lora.radio != NULL, + g_lora.tx_start_ms, now_ms)) { + g_lora.tx_in_progress = false; + g_lora.tx_start_ms = 0; + g_lora.last_auto_tx_ms = now_ms; lora_capture_device_errors("TX timeout", 0); - (void)g_lora_radio->standby(); - if (g_lora_pending_rx_after_tx || !g_lora_tx_mode) lora_start_receive_mode(); + (void)g_lora.radio->standby(); + if (g_lora.pending_rx_after_tx || !g_lora.tx_mode) lora_start_receive_mode(); } } -static bool g_lora_rx_event = false; -static bool g_lora_tx_event = false; - static void lora_poll_hardware(void) { - if (!g_lora_initialized) return; + if (!g_lora.initialized) return; lora_service_irq_once(); lora_check_tx_fallback(); - if (g_lora_tx_done) { - g_lora_tx_done = false; - g_lora_tx_event = true; - g_lora_tx_in_progress = false; - g_lora_tx_start_ms = 0; - if (g_lora_pending_rx_after_tx || !g_lora_tx_mode) { + if (g_lora.tx_done) { + g_lora.tx_done = false; + g_lora.tx_event = true; + g_lora.tx_in_progress = false; + g_lora.tx_start_ms = 0; + if (g_lora.pending_rx_after_tx || !g_lora.tx_mode) { lora_start_receive_mode(); } } - if (g_lora_rx_done) { - g_lora_rx_done = false; - g_lora_rx_event = true; + if (g_lora.rx_done) { + g_lora.rx_done = false; + g_lora.rx_event = true; } - if (g_lora_initialized && g_lora_tx_mode && !g_lora_tx_in_progress) { - uint64_t now_ms = get_monotonic_ms(); - if (now_ms - g_lora_last_auto_tx_ms >= 2000ULL) { - lora_send_demo_packet(); - } + uint64_t now_ms = get_monotonic_ms(); + if (cp0_lora_runtime_policy::should_send_demo( + g_lora.initialized, g_lora.tx_mode, g_lora.tx_in_progress, + g_lora.last_auto_tx_ms, now_ms)) { + lora_send_demo_packet(); } } @@ -1192,39 +621,38 @@ static void lora_poll_hardware(void) static void lora_init_hardware(void) { - delete g_lora_radio; g_lora_radio = NULL; - delete g_lora_radio_module; g_lora_radio_module = NULL; + g_lora.reset_radio(); lora_set_diag_step("i2c_scan", 0, "scan 0x43 before LoRa init"); - if (pi4io_scan_and_init_before_lora()) { - lora_set_diag_step("i2c_scan", 0, g_pi4io_status); + if (cp0_lora_pi4io_controller::scan_and_initialize()) { + lora_set_diag_step("i2c_scan", 0, cp0_lora_pi4io_controller::status()); } else { - lora_set_diag_step("i2c_scan", 1, g_pi4io_status); + lora_set_diag_step("i2c_scan", 1, cp0_lora_pi4io_controller::status()); } lora_set_diag_step("power_enable", 0, "start"); - if (!hat_5vout_enable()) { + if (!cp0_lora_hat_power_controller::enable(g_lora.power_gpio)) { SLOGI("Status: GPIO5 low set failed"); lora_set_diag_step("power_enable", 1, "GPIO5 low set failed"); } usleep(100000); lora_set_diag_step("reset_gpio_init", 0, "prepare rst pin"); - if (gpio_init_output_any("LORA_RST_CHIP", "LORA_RST_OFFSET", g_lora_rst_gpio, 1, &g_lora_rst_fd, "RST") < 0) { - g_lora_initialized = false; g_lora_hw_ready = false; + if (gpio_init_output_any("LORA_RST_CHIP", "LORA_RST_OFFSET", g_lora.rst_gpio, 1, &g_lora.rst_fd, "RST") < 0) { + g_lora.initialized = false; g_lora.hw_ready = false; lora_set_diag_step("reset_gpio_init", 1, "rst gpio init failed"); return; } - if (gpio_init_input_any("LORA_BUSY_CHIP", "LORA_BUSY_OFFSET", g_lora_busy_gpio, &g_lora_busy_fd, "BUSY") < 0) { - g_lora_initialized = false; g_lora_hw_ready = false; + if (gpio_init_input_any("LORA_BUSY_CHIP", "LORA_BUSY_OFFSET", g_lora.busy_gpio, &g_lora.busy_fd, "BUSY") < 0) { + g_lora.initialized = false; g_lora.hw_ready = false; lora_set_diag_step("busy_gpio_init", 1, "busy gpio init failed"); return; } lora_set_diag_step("hard_reset", 0, "toggle rst before probe"); if (!sx1262_reset()) { - g_lora_initialized = false; g_lora_hw_ready = false; + g_lora.initialized = false; g_lora.hw_ready = false; lora_set_diag_step("hard_reset", 1, "rst/busy handshake failed"); return; } @@ -1233,48 +661,48 @@ static void lora_init_hardware(void) resolve_lora_spi_device(); if (!probe_lora_spi_device()) { - g_lora_initialized = false; g_lora_hw_ready = false; - lora_set_diag_step("probe_spi", 1, g_lora_last_diag); + g_lora.initialized = false; g_lora.hw_ready = false; + lora_set_diag_step("probe_spi", 1, g_lora.last_diag); return; } lora_set_diag_step("pre_begin_prepare", 0, "reset again before RadioLib begin"); if (!sx1262_reset()) { - g_lora_initialized = false; g_lora_hw_ready = false; + g_lora.initialized = false; g_lora.hw_ready = false; lora_set_diag_step("pre_begin_prepare", 1, "rst/busy handshake failed before RadioLib begin"); return; } lora_set_diag_step("prepare_irq", 0, "init irq pin"); - if (gpio_init_input_irq_any("LORA_IRQ_CHIP", "LORA_IRQ_OFFSET", g_lora_irq_gpio, &g_lora_irq_fd, "IRQ") < 0) { - g_lora_irq_poll_fallback = true; + if (gpio_init_input_irq_any("LORA_IRQ_CHIP", "LORA_IRQ_OFFSET", g_lora.irq_gpio, &g_lora.irq_fd, "IRQ") < 0) { + g_lora.irq_poll_fallback = true; lora_set_diag_step("prepare_irq", 1, "irq gpio init failed, fallback=poll"); } else { - g_lora_irq_poll_fallback = false; + g_lora.irq_poll_fallback = false; lora_set_diag_step("prepare_irq", 0, "irq gpio ok"); } lora_set_diag_step("runtime_spi", 0, "open SPI for RadioLib runtime"); if (!lora_open_runtime_spi()) { - g_lora_initialized = false; g_lora_hw_ready = false; - lora_set_diag_step("runtime_spi", 1, g_lora_last_diag); + g_lora.initialized = false; g_lora.hw_ready = false; + lora_set_diag_step("runtime_spi", 1, g_lora.last_diag); return; } lora_set_diag_step("radiolib_setup", 0, "create module"); - g_lora_nss_manual = false; - g_lora_radio_module = new Module(&g_lora_radio_hal, RADIOLIB_NC, - (uint32_t)g_lora_irq_gpio, (uint32_t)g_lora_rst_gpio, (uint32_t)g_lora_busy_gpio); - g_lora_radio = new SX1262(g_lora_radio_module); + g_lora.nss_manual = false; + g_lora.radio_module = new Module(&g_lora_radio_hal, RADIOLIB_NC, + (uint32_t)g_lora.irq_gpio, (uint32_t)g_lora.rst_gpio, (uint32_t)g_lora.busy_gpio); + g_lora.radio = new SX1262(g_lora.radio_module); - if (g_lora_radio_module == NULL || g_lora_radio == NULL) { - g_lora_initialized = false; g_lora_hw_ready = false; + if (g_lora.radio_module == NULL || g_lora.radio == NULL) { + g_lora.initialized = false; g_lora.hw_ready = false; lora_set_diag_step("radiolib_setup", 1, "allocation failed"); return; } lora_set_diag_step("radiolib_begin", 0, "configure sx1262 via RadioLib"); - int16_t state = g_lora_radio->begin( + int16_t state = g_lora.radio->begin( 868.0f, // frequency MHz 125.0f, // bandwidth kHz 12, // spreading factor @@ -1287,24 +715,24 @@ static void lora_init_hardware(void) ); if (state != RADIOLIB_ERR_NONE) { - g_lora_initialized = false; g_lora_hw_ready = false; - snprintf(g_lora_last_diag, sizeof(g_lora_last_diag), "RadioLib begin rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); + g_lora.initialized = false; g_lora.hw_ready = false; + snprintf(g_lora.last_diag, sizeof(g_lora.last_diag), "RadioLib begin rc=%d(%s)", (int)state, lora_radiolib_status_text(state)); SLOGI("LoRa init failed: rc=%d (%s)", (int)state, lora_radiolib_status_text(state)); - lora_set_diag_step("radiolib_begin", state, g_lora_last_diag); + lora_set_diag_step("radiolib_begin", state, g_lora.last_diag); return; } - (void)g_lora_radio->setCurrentLimit(140); - (void)g_lora_radio->setDio2AsRfSwitch(true); + (void)g_lora.radio->setCurrentLimit(140); + (void)g_lora.radio->setDio2AsRfSwitch(true); - g_lora_initialized = true; - g_lora_hw_ready = true; - g_lora_tx_mode = false; - g_lora_selected_tx_mode = false; - g_lora_tx_in_progress = false; - g_lora_pending_rx_after_tx = false; - g_lora_tx_start_ms = 0; - g_lora_last_auto_tx_ms = get_monotonic_ms(); + g_lora.initialized = true; + g_lora.hw_ready = true; + g_lora.tx_mode = false; + g_lora.selected_tx_mode = false; + g_lora.tx_in_progress = false; + g_lora.pending_rx_after_tx = false; + g_lora.tx_start_ms = 0; + g_lora.last_auto_tx_ms = get_monotonic_ms(); lora_set_diag_step("ready", 0, "LoRa init finished"); SLOGI("LoRa: init done, auto enter RX"); @@ -1314,116 +742,66 @@ static void lora_init_hardware(void) -static void fill_info(cp0_lora_info_t *info, bool drain_events) +void get_info(cp0_lora_info_t *info, bool drain_events) { if (!info) return; memset(info, 0, sizeof(*info)); - info->initialized = g_lora_initialized ? 1 : 0; - info->hw_ready = g_lora_hw_ready ? 1 : 0; - info->tx_mode = g_lora_tx_mode ? 1 : 0; - info->tx_in_progress = g_lora_tx_in_progress ? 1 : 0; - info->has_sent_message = g_lora_has_sent_message ? 1 : 0; - info->rx_event = g_lora_rx_event ? 1 : 0; - info->tx_event = g_lora_tx_event ? 1 : 0; - cp0_copy_cstr(info->spi_device, sizeof(info->spi_device), g_spi_device); - cp0_copy_cstr(info->last_rx, sizeof(info->last_rx), g_lora_last_rx); - cp0_copy_cstr(info->last_tx, sizeof(info->last_tx), g_lora_last_tx); - cp0_copy_cstr(info->diag, sizeof(info->diag), g_lora_last_diag); - cp0_copy_cstr(info->probe_summary, sizeof(info->probe_summary), g_lora_probe_summary); - cp0_copy_cstr(info->probe_display, sizeof(info->probe_display), g_lora_probe_display); - cp0_copy_cstr(info->pi4io_status, sizeof(info->pi4io_status), g_pi4io_status); - info->rssi = g_lora_last_rssi; - info->snr = g_lora_last_snr; + info->initialized = g_lora.initialized ? 1 : 0; + info->hw_ready = g_lora.hw_ready ? 1 : 0; + info->tx_mode = g_lora.tx_mode ? 1 : 0; + info->tx_in_progress = g_lora.tx_in_progress ? 1 : 0; + info->has_sent_message = g_lora.has_sent_message ? 1 : 0; + info->rx_event = g_lora.rx_event ? 1 : 0; + info->tx_event = g_lora.tx_event ? 1 : 0; + cp0_copy_cstr(info->spi_device, sizeof(info->spi_device), g_lora.spi.path()); + cp0_copy_cstr(info->last_rx, sizeof(info->last_rx), g_lora.last_rx); + cp0_copy_cstr(info->last_tx, sizeof(info->last_tx), g_lora.last_tx); + cp0_copy_cstr(info->diag, sizeof(info->diag), g_lora.last_diag); + cp0_copy_cstr(info->probe_summary, sizeof(info->probe_summary), g_lora.probe_summary); + cp0_copy_cstr(info->probe_display, sizeof(info->probe_display), g_lora.probe_display); + cp0_copy_cstr(info->pi4io_status, sizeof(info->pi4io_status), cp0_lora_pi4io_controller::status()); + info->rssi = g_lora.last_rssi; + info->snr = g_lora.last_snr; if (drain_events) { - g_lora_rx_event = false; - g_lora_tx_event = false; + g_lora.rx_event = false; + g_lora.tx_event = false; } } -} // namespace cp0_lora_backend - -class LoraSystem +bool initialize() { -public: - void api_call(std::list arg, std::function callback) - { - if (arg.empty()) { - report(callback, -1, "missing lora api command\n"); - return; - } - - const std::string command = arg.front(); - if (command == "Init") { - if (!cp0_lora_backend::g_lora_initialized && !cp0_lora_backend::g_lora_hw_ready) - cp0_lora_backend::lora_init_hardware(); - report(callback, cp0_lora_backend::g_lora_hw_ready ? 0 : -1, ""); - return; - } - if (command == "Poll" || command == "Info") { - cp0_lora_info_t info{}; - if (command == "Poll") cp0_lora_backend::lora_poll_hardware(); - cp0_lora_backend::fill_info(&info, command == "Poll"); - report(callback, 0, std::string(reinterpret_cast(&info), sizeof(info))); - return; - } - if (command == "SendText") { - std::string payload = first_arg_after_command(arg); - report(callback, cp0_lora_backend::lora_send_text_packet(payload.c_str()) ? 0 : -1, ""); - return; - } - if (command == "StartReceive") { - cp0_lora_backend::lora_start_receive_mode(); - report(callback, 0, ""); - return; - } - if (command == "SetTxMode") { - cp0_lora_backend::lora_apply_mode(std::atoi(first_arg_after_command(arg).c_str()) != 0); - report(callback, 0, ""); - return; - } - if (command == "Shutdown") { - shutdown(); - report(callback, 0, ""); - return; - } + if (!g_lora.initialized && !g_lora.hw_ready) lora_init_hardware(); + return g_lora.hw_ready; +} - report(callback, -1, "unknown lora api\n"); - } +void poll() +{ + lora_poll_hardware(); +} -private: - static std::string first_arg_after_command(const std::list& arg) - { - if (arg.size() < 2) return ""; - return *std::next(arg.begin()); - } +bool send_text(const char *payload) +{ + return lora_send_text_packet(payload); +} - static void report(std::function callback, int code, const std::string& data) - { - if (callback) callback(code, data); - } +void start_receive() +{ + lora_start_receive_mode(); +} - static void shutdown() - { - using namespace cp0_lora_backend; - delete g_lora_radio; - g_lora_radio = NULL; - delete g_lora_radio_module; - g_lora_radio_module = NULL; - if (g_spi_fd >= 0) { close(g_spi_fd); g_spi_fd = -1; } - if (g_lora_rst_fd >= 0) { close(g_lora_rst_fd); g_lora_rst_fd = -1; } - if (g_lora_busy_fd >= 0) { close(g_lora_busy_fd); g_lora_busy_fd = -1; } - if (g_lora_irq_fd >= 0) { close(g_lora_irq_fd); g_lora_irq_fd = -1; } - if (g_lora_nss_fd >= 0) { close(g_lora_nss_fd); g_lora_nss_fd = -1; } - if (g_hat_5vout_fd >= 0) { close(g_hat_5vout_fd); g_hat_5vout_fd = -1; } - g_lora_initialized = false; - g_lora_hw_ready = false; - } -}; +void set_tx_mode(bool enabled) +{ + lora_apply_mode(enabled); +} -extern "C" void init_lora(void) +void shutdown() { - std::shared_ptr lora = std::make_shared(); - cp0_signal_lora_api.append([lora](std::list arg, std::function callback) { - lora->api_call(arg, callback); - }); + g_lora.reset_radio(); + g_lora.spi.close(); + g_lora.close_gpio_lines(); + cp0_lora_hat_power_controller::shutdown(); + g_lora.initialized = false; + g_lora.hw_ready = false; } + +} // namespace cp0_lora_backend diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_lora_service.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_lora_service.cpp new file mode 100644 index 00000000..fcab3fd5 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_lora_service.cpp @@ -0,0 +1,72 @@ +#include "cp0_lora_backend.hpp" + +#include "cp0_lvgl_app.h" +#include "../cp0_lora_contract.hpp" +#include "../cp0_callback_contract.hpp" +#include "../cp0_callback_result.hpp" +#include "../cp0_signal_registration.hpp" +#include "hal_lvgl_bsp.h" + +#include +#include +#include +#include +#include + +namespace { + +cp0::lora::Operations hardware_operations() +{ + cp0::lora::Operations operations; + operations.initialize = cp0_lora_backend::initialize; + operations.poll = cp0_lora_backend::poll; + operations.read_info = cp0_lora_backend::get_info; + operations.send_text = [](const std::string &payload) { + return cp0_lora_backend::send_text(payload.c_str()); + }; + operations.start_receive = cp0_lora_backend::start_receive; + operations.set_tx_mode = cp0_lora_backend::set_tx_mode; + operations.shutdown = cp0_lora_backend::shutdown; + return operations; +} + +std::shared_ptr &lora_service() +{ + static auto service = std::make_shared(hardware_operations()); + return service; +} + +cp0::SignalRegistration &lora_registration() +{ + static cp0::SignalRegistration registration; + return registration; +} + +} // namespace + +extern "C" void init_lora(void) +{ + const auto active_service = lora_service(); + lora_registration().replace( + cp0_signal_lora_api, + [active_service](std::list arguments, + std::function callback) { + cp0::CallbackResult completion(std::move(callback)); + completion.guard(-1, "lora api failure", [&] { + const cp0::lora::Result result = active_service->call(arguments); + completion.complete(result.code, result.payload); + }); + }); +} + +extern "C" void deinit_lora(void) noexcept +{ + try { + lora_registration().reset(); + } catch (...) { + } + try { + (void)lora_service()->call({"Shutdown"}); + } catch (...) { + } +} diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_network.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_network.cpp index 26411c2a..43b088b5 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_network.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_network.cpp @@ -1,11 +1,15 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" #include "../cp0_app_internal_utils.h" +#include "../cp0_network_api_contract.hpp" +#include "../cp0_callback_result.hpp" +#include "../cp0_signal_registration.hpp" +#include "cp0_network_policy.hpp" #include #include #include -#include +#include #include #include #include @@ -14,6 +18,7 @@ #include #include #include +#include #include #include @@ -69,41 +74,63 @@ class WifiSystem { update_status_cache(); worker_ = std::thread([this]() { poll_loop(); }); - worker_.detach(); + } + + ~WifiSystem() { stop(); } + + void stop() noexcept + { + running_.store(false); + wake_.notify_all(); + if (worker_.joinable()) worker_.join(); } void api_call(arg_t arg, callback_t callback) { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "Status") { + cp0::CallbackResult result(std::move(callback)); + try { + cp0::network::ApiRequest request; + if (!cp0::network::parse_api_request(arg, request)) { + result.complete(-1, cp0::network::invalid_api_request_message()); + return; + } + switch (request.command) { + case cp0::network::ApiCommand::Status: { cp0_wifi_status_t st = get_status(); - report(callback, 0, encode_status(st)); - } else if (cmd == "Scan") { - int max_count = arg.size() >= 2 ? std::atoi(second_arg(arg).c_str()) : CP0_WIFI_AP_MAX; - std::vector aps(std::max(0, max_count)); + result.complete(0, cp0::network::encode_status_payload(st)); + break; + } + case cp0::network::ApiCommand::Scan: { + std::vector aps(static_cast(request.scan_limit)); int count = scan(aps.empty() ? nullptr : aps.data(), static_cast(aps.size())); - report(callback, count, encode_scan(aps.data(), count)); - } else if (cmd == "Connect") { - const std::string ssid = nth_arg(arg, 1); - const std::string password = nth_arg(arg, 2); - report(callback, connect(ssid.c_str(), password.empty() ? nullptr : password.c_str()), ""); - } else if (cmd == "Disconnect") { - report(callback, disconnect(), ""); - } else if (cmd == "ProfileForget") { - const std::string ssid = nth_arg(arg, 1); - report(callback, profile_forget(ssid.c_str()), ""); - } else if (cmd == "ProfileExists") { - const std::string ssid = nth_arg(arg, 1); - report(callback, profile_exists(ssid.c_str()), ""); - } else if (cmd == "ProfileDisconnectActive") { - report(callback, profile_disconnect_active(), ""); - } else if (cmd == "RadioEnabled") { - report(callback, radio_enabled(), ""); - } else if (cmd == "RadioSetEnabled") { - const std::string state = nth_arg(arg, 1); - report(callback, radio_set_enabled(state == "on" || state == "1" || state == "true"), ""); - } else { - report(callback, -1, "unknown wifi api command"); + result.complete(count, cp0::network::encode_scan_payload(aps.data(), count)); + break; + } + case cp0::network::ApiCommand::Connect: + result.complete( + connect(request.ssid.c_str(), request.password.empty() ? nullptr : request.password.c_str()), ""); + break; + case cp0::network::ApiCommand::Disconnect: + result.complete(disconnect(), ""); + break; + case cp0::network::ApiCommand::ProfileForget: + result.complete(profile_forget(request.ssid.c_str()), ""); + break; + case cp0::network::ApiCommand::ProfileExists: + result.complete(profile_exists(request.ssid.c_str()), ""); + break; + case cp0::network::ApiCommand::ProfileDisconnectActive: + result.complete(profile_disconnect_active(), ""); + break; + case cp0::network::ApiCommand::RadioEnabled: + result.complete(radio_enabled(), ""); + break; + case cp0::network::ApiCommand::RadioSetEnabled: + result.complete(radio_set_enabled(request.radio_enabled), ""); + break; + } + } catch (...) { + result.complete(-1, "network api failure"); } } @@ -124,19 +151,28 @@ class WifiSystem if (cp0_process_capture_argv(scan_argv, output, sizeof(output)) != 0) return 0; - std::vector aps; - std::istringstream lines(output); - std::string line; - while (std::getline(lines, line)) { - if (!line.empty() && line.back() == '\r') - line.pop_back(); - if (line.empty()) - continue; + std::unordered_set saved_profiles; + char profiles_output[4096] = {}; + const char *profiles_argv[] = { + "nmcli", "-t", "--escape", "no", "-f", "NAME", "con", "show", nullptr}; + if (cp0_process_capture_argv(profiles_argv, profiles_output, sizeof(profiles_output)) == 0) { + std::istringstream lines(profiles_output); + std::string profile; + while (std::getline(lines, profile)) { + if (!profile.empty() && profile.back() == '\r') profile.pop_back(); + if (!profile.empty()) saved_profiles.insert(profile); + } + } + std::vector aps; + for (const auto &parsed : cp0::network::parse_scan_output(output)) { cp0_wifi_ap_t ap{}; - if (!parse_scan_line(line, ap)) - continue; - upsert_ap(aps, ap); + cp0_copy_string(ap.ssid, sizeof(ap.ssid), parsed.ssid); + ap.signal = parsed.signal; + cp0_copy_string(ap.security, sizeof(ap.security), parsed.security); + ap.in_use = parsed.in_use ? 1 : 0; + ap.saved = saved_profiles.count(parsed.ssid) != 0 ? 1 : 0; + aps.push_back(ap); } const int count = static_cast(aps.size()); @@ -236,10 +272,7 @@ class WifiSystem const char *argv[] = {"nmcli", "radio", "wifi", nullptr}; if (cp0_process_capture_argv(argv, output, sizeof(output)) != 0) return 0; - std::string state(output); - while (!state.empty() && (state.back() == '\n' || state.back() == '\r' || state.back() == ' ' || state.back() == '\t')) - state.pop_back(); - return state == "enabled" ? 1 : 0; + return cp0::network::parse_radio_enabled(output) ? 1 : 0; } int radio_set_enabled(bool enabled) @@ -255,91 +288,16 @@ class WifiSystem cp0_wifi_status_t cache_{}; std::thread worker_; std::atomic running_{true}; - - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) - callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - return it == arg.end() ? std::string() : *it; - } - - static std::string second_arg(const arg_t &arg) - { - return nth_arg(arg, 1); - } - - static std::vector split_colon(const std::string &line) - { - std::vector cols; - std::string current; - for (char ch : line) { - if (ch == ':') { - cols.push_back(current); - current.clear(); - } else { - current.push_back(ch); - } - } - cols.push_back(current); - return cols; - } - - static bool parse_scan_line(const std::string &line, cp0_wifi_ap_t &ap) - { - auto cols = split_colon(line); - if (cols.size() < 4 || cols[0].empty()) - return false; - cp0_copy_string(ap.ssid, sizeof(ap.ssid), cols[0]); - ap.signal = std::atoi(cols[1].c_str()); - cp0_copy_string(ap.security, sizeof(ap.security), cols[2]); - ap.in_use = cols[3].find('*') != std::string::npos ? 1 : 0; - return true; - } - - static void upsert_ap(std::vector &aps, const cp0_wifi_ap_t &ap) - { - auto it = std::find_if(aps.begin(), aps.end(), [&](const cp0_wifi_ap_t &existing) { - return std::strcmp(existing.ssid, ap.ssid) == 0; - }); - if (it == aps.end()) { - aps.push_back(ap); - return; - } - - int in_use = it->in_use || ap.in_use; - if (ap.signal > it->signal) - *it = ap; - it->in_use = in_use; - } - - static std::string encode_status(const cp0_wifi_status_t &st) - { - std::ostringstream oss; - oss << st.connected << ':' << st.ssid << ':' << st.ip << ':' << st.signal << ':' << st.ethernet; - return oss.str(); - } - - static std::string encode_scan(const cp0_wifi_ap_t *aps, int count) - { - std::ostringstream oss; - for (int i = 0; aps && i < count; ++i) { - oss << aps[i].ssid << ':' << aps[i].signal << ':' << aps[i].security << ':' << aps[i].in_use << '\n'; - } - return oss.str(); - } + std::condition_variable wake_; + std::mutex wake_mutex_; void poll_loop() { while (running_.load()) { update_status_cache(); - std::this_thread::sleep_for(std::chrono::seconds(3)); + std::unique_lock lock(wake_mutex_); + wake_.wait_for(lock, std::chrono::seconds(3), + [this] { return !running_.load(); }); } } @@ -351,26 +309,6 @@ class WifiSystem cache_ = st; } - // nmcli 在 -t (terse) 模式下用 ':' 分隔字段,值里的 ':' 与 '\' 会被转义为 "\:" / "\\"。 - static std::vector split_terse_fields(const std::string &line) - { - std::vector fields; - std::string cur; - for (size_t i = 0; i < line.size(); ++i) { - char c = line[i]; - if (c == '\\' && i + 1 < line.size()) { - cur.push_back(line[++i]); - } else if (c == ':') { - fields.push_back(cur); - cur.clear(); - } else { - cur.push_back(c); - } - } - fields.push_back(cur); - return fields; - } - static void read_status(cp0_wifi_status_t &st) { char output[4096] = {}; @@ -380,45 +318,11 @@ class WifiSystem // 显示为 "--" 时被误判为未连接(#37)。 const char *status_argv[] = {"nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "dev", "status", nullptr}; if (cp0_process_capture_argv(status_argv, output, sizeof(output)) == 0) { - std::istringstream lines(output); - std::string line; - bool wifi_found = false; - while (std::getline(lines, line)) { - if (!line.empty() && line.back() == '\r') - line.pop_back(); - std::vector f = split_terse_fields(line); - if (f.size() < 4) - continue; - const std::string &device = f[0]; - const std::string &type = f[1]; - const std::string &state = f[2]; - const std::string &connection = f[3]; - bool state_connected = state.rfind("connected", 0) == 0; // "connected" / "connected (externally)" - bool has_connection = !connection.empty() && connection != "--"; - - if (type == "ethernet" && state_connected) { - st.ethernet = 1; // 有线网口已连接(#37 网口图标) - continue; - } - - if (type == "wifi" && !wifi_found) { - wifi_found = true; - if (state_connected || has_connection) { - st.connected = 1; - wifi_iface = device; - if (has_connection) { - // Imager/netplan-provisioned networks are named - // "netplan--". Strip that prefix so the UI - // shows the plain SSID instead of the profile name (#66). - std::string display = connection; - const std::string prefix = "netplan-" + device + "-"; - if (display.rfind(prefix, 0) == 0) - display = display.substr(prefix.size()); - cp0_copy_string(st.ssid, sizeof(st.ssid), display.c_str()); - } - } - } - } + const auto parsed = cp0::network::parse_device_status(output); + st.connected = parsed.connected ? 1 : 0; + st.ethernet = parsed.ethernet ? 1 : 0; + wifi_iface = parsed.wifi_interface; + cp0_copy_string(st.ssid, sizeof(st.ssid), parsed.ssid); } if (!st.connected) @@ -429,34 +333,16 @@ class WifiSystem const char *signal_argv[] = {"nmcli", "-t", "-f", "IN-USE,SIGNAL,SSID", "dev", "wifi", "list", "--rescan", "no", nullptr}; if (cp0_process_capture_argv(signal_argv, output, sizeof(output)) == 0) { - std::istringstream lines(output); - std::string line; - while (std::getline(lines, line)) { - if (!line.empty() && line.back() == '\r') - line.pop_back(); - if (line.rfind("*:", 0) != 0) - continue; - std::vector f = split_terse_fields(line); - if (f.size() >= 2) - st.signal = std::atoi(f[1].c_str()); - // CONNECTION 为 "--" 时(外部连接)用当前接入的 SSID 兜底 - if (st.ssid[0] == '\0' && f.size() >= 3 && !f[2].empty()) - cp0_copy_string(st.ssid, sizeof(st.ssid), f[2]); - break; - } + cp0::network::Status parsed; + parsed.ssid = st.ssid; + cp0::network::apply_active_wifi(output, parsed); + st.signal = parsed.signal; + cp0_copy_string(st.ssid, sizeof(st.ssid), parsed.ssid); } const char *ip_argv[] = {"ip", "-4", "-o", "addr", "show", wifi_iface.c_str(), nullptr}; if (cp0_process_capture_argv(ip_argv, output, sizeof(output)) == 0) { - std::string line(output); - auto pos = line.find("inet "); - if (pos != std::string::npos) { - std::string ip = line.substr(pos + 5); - auto slash = ip.find('/'); - if (slash != std::string::npos) - ip.resize(slash); - cp0_copy_string(st.ip, sizeof(st.ip), ip); - } + cp0_copy_string(st.ip, sizeof(st.ip), cp0::network::parse_ipv4_address(output)); } } @@ -466,22 +352,50 @@ class WifiSystem const char *argv[] = {"nmcli", "-t", "-f", "NAME", "con", "show", "--active", nullptr}; if (cp0_process_capture_argv(argv, output, sizeof(output)) != 0) return {}; - std::istringstream lines(output); - std::string line; - while (std::getline(lines, line)) { - if (!line.empty() && line.back() == '\r') - line.pop_back(); - if (!line.empty() && line != "lo") - return line; - } - return {}; + return cp0::network::first_active_connection(output); } }; +using WifiRegistration = cp0::SignalRegistration; + +struct WifiRuntime { + std::mutex mutex; + WifiRegistration registration; + std::shared_ptr service; +}; + +WifiRuntime &wifi_runtime() +{ + static WifiRuntime runtime; + return runtime; +} + extern "C" void init_wifi(void) { - auto wifi = std::make_shared(); - cp0_signal_wifi_api.append([wifi](std::list arg, std::function callback) { - wifi->api_call(std::move(arg), std::move(callback)); + static std::once_flag initialized; + std::call_once(initialized, []() { + auto wifi = std::make_shared(); + WifiRuntime &runtime = wifi_runtime(); + if (!runtime.registration.replace( + cp0_signal_wifi_api, + [wifi](std::list arg, + std::function callback) { + wifi->api_call(std::move(arg), std::move(callback)); + })) + return; + std::lock_guard lock(runtime.mutex); + runtime.service = std::move(wifi); }); } + +extern "C" void deinit_wifi(void) +{ + WifiRuntime &runtime = wifi_runtime(); + runtime.registration.reset(); + std::shared_ptr wifi; + { + std::lock_guard lock(runtime.mutex); + wifi = std::move(runtime.service); + } + if (wifi) wifi->stop(); +} diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_osinfo.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_osinfo.cpp index b397d612..a47e77a9 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_osinfo.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_osinfo.cpp @@ -1,6 +1,11 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" #include "../cp0_app_internal_utils.h" +#include "../cp0_callback_contract.hpp" +#include "../cp0_osinfo_codec.hpp" +#include "../cp0_osinfo_contract.hpp" +#include "../cp0_signal_registration.hpp" +#include "../cp0_sync_signal.hpp" #include #include @@ -8,7 +13,6 @@ #include #include #include -#include #include #include #include @@ -24,66 +28,45 @@ class OsInfoSystem using callback_t = std::function; using arg_t = std::list; + OsInfoSystem() + { + operations_.read_eth_info = [](bool, cp0_eth_info_t *info) { + return network_default_info_read(info); + }; + operations_.list_networks = [](cp0_netif_info_t *entries, int capacity, int *count) { + return cp0_network_list(entries, capacity, count); + }; + operations_.read_account_info = account_info_read; + operations_.set_time = [](const std::string ×tamp) { return time_set(timestamp.c_str()); }; + operations_.read_local_time = read_local_time; + operations_.random_u32 = random_u32; + operations_.get_ntp = ntp_get; + operations_.set_ntp = ntp_set; + operations_.apt_update_background = apt_update_background; + operations_.update_launcher_background = update_launcher_background; + } + void api_call(arg_t arg, callback_t callback) { - const std::string cmd = nth_arg(arg, 0); - if (cmd == "NetworkDefaultInfoRead" || cmd == "EthInfoRead") { - cp0_eth_info_t info{}; - int ret = network_default_info_read(&info); - report(callback, ret, encode_eth_info(info)); - } else if (cmd == "NetworkList") { - cp0_netif_info_t entries[64]{}; - int count = 0; - int ret = cp0_network_list(entries, 64, &count); - std::ostringstream out; - for (int i = 0; ret == 0 && i < count; ++i) - out << entries[i].iface << '\t' << entries[i].ipv4 << '\t' - << entries[i].netmask << '\t' << entries[i].is_up << '\n'; - report(callback, ret, out.str()); - } else if (cmd == "AccountInfoRead") { - cp0_account_info_t info{}; - int ret = account_info_read(&info); - report(callback, ret, encode_account_info(info)); - } else if (cmd == "TimeSet") { - report(callback, time_set(nth_arg(arg, 1).c_str()), ""); - } else if (cmd == "LocalTime") { - std::time_t now = std::time(nullptr); - struct tm value{}; - if (!localtime_r(&now, &value)) { - report(callback, errno ? -errno : -1, ""); - return; - } - std::ostringstream out; - out << value.tm_year + 1900 << ',' << value.tm_mon + 1 << ',' << value.tm_mday << ',' - << value.tm_hour << ',' << value.tm_min << ',' << value.tm_sec; - report(callback, 0, out.str()); - } else if (cmd == "RandomU32") { - report(callback, 0, std::to_string(random_u32())); - } else if (cmd == "NtpGet") { - report(callback, ntp_get(), ""); - } else if (cmd == "NtpSet") { - report(callback, ntp_set(nth_arg(arg, 1) == "1"), ""); - } else if (cmd == "AptUpdateBackground") { - report(callback, apt_update_background(), ""); - } else if (cmd == "UpdateLauncherBackground") { - report(callback, update_launcher_background(), ""); - } else { - report(callback, -1, "unknown osinfo api command"); - } + const cp0::osinfo::Result result = cp0::osinfo::dispatch(arg, operations_); + cp0::callback::invoke(callback, result.code, result.payload); } static int api_simple(const arg_t &arg, std::string *out = nullptr) { int result = -1; - cp0_signal_osinfo_api(arg, [&](int code, std::string data) { + if (out) out->clear(); + cp0::signal::invoke_noexcept([&] { cp0_signal_osinfo_api(arg, [&](int code, std::string data) { result = code; if (out) *out = std::move(data); - }); + }); }); return result; } private: + cp0::osinfo::Operations operations_; + static uint32_t random_u32() noexcept { try { @@ -96,32 +79,17 @@ class OsInfoSystem } } - static void report(callback_t callback, int code, const std::string &data) + static int read_local_time(std::tm *value) { - if (callback) - callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - std::advance(it, std::min(index, arg.size())); - return it == arg.end() ? std::string() : *it; - } - - static void clear_net_info(cp0_eth_info_t *info) - { - if (!info) - return; - std::memset(info, 0, sizeof(*info)); - cp0_copy_cstr(info->ipv4, sizeof(info->ipv4), "N/A"); - cp0_copy_cstr(info->gateway, sizeof(info->gateway), "N/A"); - cp0_copy_cstr(info->mac, sizeof(info->mac), "N/A"); + if (!value) + return -1; + const std::time_t now = std::time(nullptr); + return localtime_r(&now, value) ? 0 : (errno ? -errno : -1); } static int network_default_info_read(cp0_eth_info_t *info) { - clear_net_info(info); + cp0::osinfo::clear_eth_info(info); if (!info) return -1; @@ -235,39 +203,6 @@ class OsInfoSystem return cp0_process_run_argv(argv, 1); } - static std::string encode_eth_info(const cp0_eth_info_t &info) - { - return std::string(info.ipv4) + "\n" + info.gateway + "\n" + info.mac; - } - - static void decode_eth_info(const std::string &data, cp0_eth_info_t *info) - { - clear_net_info(info); - if (!info) - return; - std::istringstream lines(data); - std::string line; - if (std::getline(lines, line)) cp0_copy_string(info->ipv4, sizeof(info->ipv4), line); - if (std::getline(lines, line)) cp0_copy_string(info->gateway, sizeof(info->gateway), line); - if (std::getline(lines, line)) cp0_copy_string(info->mac, sizeof(info->mac), line); - } - - static std::string encode_account_info(const cp0_account_info_t &info) - { - return std::string(info.user) + "\n" + info.hostname; - } - - static void decode_account_info(const std::string &data, cp0_account_info_t *info) - { - if (!info) - return; - std::memset(info, 0, sizeof(*info)); - std::istringstream lines(data); - std::string line; - if (std::getline(lines, line)) cp0_copy_string(info->user, sizeof(info->user), line); - if (std::getline(lines, line)) cp0_copy_string(info->hostname, sizeof(info->hostname), line); - } - public: static int api_eth_info(const char *command, cp0_eth_info_t *info) { @@ -275,8 +210,8 @@ class OsInfoSystem return -1; std::string data; int ret = api_simple({command}, &data); - if (ret == 0) - decode_eth_info(data, info); + if (ret == 0 && !cp0::osinfo::decode_eth_info_strict(data, info)) + ret = -1; return ret; } @@ -286,16 +221,17 @@ class OsInfoSystem return -1; std::string data; int ret = api_simple({"AccountInfoRead"}, &data); - if (ret == 0) - decode_account_info(data, info); + if (ret == 0 && !cp0::osinfo::decode_account_info_strict(data, info)) + ret = -1; return ret; } }; extern "C" void init_osinfo(void) { + static cp0::SignalRegistration registration; auto osinfo = std::make_shared(); - cp0_signal_osinfo_api.append([osinfo](std::list arg, std::function callback) { + registration.replace(cp0_signal_osinfo_api, [osinfo](std::list arg, std::function callback) { osinfo->api_call(std::move(arg), std::move(callback)); }); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_process.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_process.cpp index f4f90c44..aea50f68 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_process.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_process.cpp @@ -1,48 +1,26 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" #include "../cp0_app_internal_utils.h" -#include "../cp0_external_process_group.hpp" +#include "cp0_process_commands.hpp" +#include "cp0_desktop_exec_policy.hpp" +#include "cp0_external_app_runner.hpp" +#include "cp0_process_lifecycle.hpp" +#include "../cp0_process_api_contract.hpp" +#include "../cp0_signal_registration.hpp" -#include -#include #include #include #include #include -#include #include #include -#include #include #include -#include -#include #include #include #include #include -#if !defined(_WIN32) -#include -#include -#include -#include -#include -#include -#endif - -#if !defined(_WIN32) -#include -#endif - -extern "C" { - extern void keyboard_pause(void); - extern void keyboard_resume(void); -} - -extern "C" void __attribute__((weak)) keyboard_pause(void) {} -extern "C" void __attribute__((weak)) keyboard_resume(void) {} - class ProcessSystem { public: @@ -51,320 +29,115 @@ class ProcessSystem void api_call(arg_t arg, callback_t callback) { + try { const std::string cmd = arg.empty() ? "" : arg.front(); if (cmd == "ExecBlocking") { - const std::string exec_path = nth_arg(arg, 1); - volatile int *home_key_flag = decode_flag_ptr(nth_arg(arg, 2)); - int keep_root = std::atoi(nth_arg(arg, 3).c_str()); - report(callback, exec_blocking(exec_path.c_str(), home_key_flag, keep_root), ""); + uintptr_t pointer = 0; + bool keep_root = false; + const auto *exec_path = cp0_process_api_contract::argument_at(arg, 1); + const auto *pointer_text = cp0_process_api_contract::argument_at(arg, 2); + const auto *keep_root_text = cp0_process_api_contract::argument_at(arg, 3); + if (!cp0_process_api_contract::has_exact_arguments(arg, 4) || !exec_path || + exec_path->empty() || !pointer_text || !keep_root_text || + !cp0_process_api_contract::parse_pointer(*pointer_text, pointer) || + !cp0_process_api_contract::parse_bool(*keep_root_text, keep_root)) + return invalid(callback); + report(callback, + cp0_external_app_runner::run( + exec_path->c_str(), reinterpret_cast(pointer), keep_root), + ""); } else if (cmd == "Spawn") { - const std::string exec_path = nth_arg(arg, 1); - int keep_root = std::atoi(nth_arg(arg, 2).c_str()); - cp0_pid_t pid = spawn(exec_path.c_str(), keep_root); + bool keep_root = false; + const auto *exec_path = cp0_process_api_contract::argument_at(arg, 1); + const auto *flag = cp0_process_api_contract::argument_at(arg, 2); + if (!cp0_process_api_contract::has_exact_arguments(arg, 3) || !exec_path || + exec_path->empty() || !flag || + !cp0_process_api_contract::parse_bool(*flag, keep_root)) + return invalid(callback); + cp0_pid_t pid = cp0_process_lifecycle::spawn(exec_path->c_str(), keep_root); report(callback, pid < 0 ? -1 : 0, std::to_string(pid)); } else if (cmd == "Stop") { - stop(static_cast(std::atoi(nth_arg(arg, 1).c_str()))); + int pid = 0; + const auto *text = cp0_process_api_contract::argument_at(arg, 1); + if (!cp0_process_api_contract::has_exact_arguments(arg, 2) || !text || + !cp0_process_api_contract::parse_pid(*text, pid)) + return invalid(callback); + cp0_process_lifecycle::stop(static_cast(pid)); report(callback, 0, ""); } else if (cmd == "CheckLock") { + const auto *path = cp0_process_api_contract::argument_at(arg, 1); + if (!cp0_process_api_contract::has_exact_arguments(arg, 2) || !path || path->empty()) + return invalid(callback); int holder_pid = 0; - int ret = check_lock(nth_arg(arg, 1).c_str(), &holder_pid); + int ret = cp0_process_lifecycle::check_lock(path->c_str(), &holder_pid); report(callback, ret, std::to_string(holder_pid)); } else if (cmd == "Kill") { - int pid = std::atoi(nth_arg(arg, 1).c_str()); - int grace_ms = std::atoi(nth_arg(arg, 2).c_str()); - kill_process(pid, grace_ms); + int pid = 0; + int grace_ms = 0; + const auto *pid_text = cp0_process_api_contract::argument_at(arg, 1); + const auto *grace_text = cp0_process_api_contract::argument_at(arg, 2); + if (!cp0_process_api_contract::has_exact_arguments(arg, 3) || !pid_text || + !grace_text || !cp0_process_api_contract::parse_pid(*pid_text, pid) || + !cp0_process_api_contract::parse_grace_ms(*grace_text, grace_ms)) + return invalid(callback); + cp0_process_lifecycle::kill(pid, grace_ms); report(callback, 0, ""); } else if (cmd == "RunArgv") { - int background = std::atoi(nth_arg(arg, 1).c_str()); - std::vector argv = args_from(arg, 2); - report(callback, run_argv(argv, background), ""); + bool background = false; + const auto *flag = cp0_process_api_contract::argument_at(arg, 1); + if (!cp0_process_api_contract::has_at_least_arguments(arg, 3) || !flag || + !cp0_process_api_contract::parse_bool(*flag, background)) + return invalid(callback); + report(callback, cp0_process_commands::run_argv( + cp0_process_api_contract::arguments_from(arg, 2), background), ""); } else if (cmd == "RunSudo") { - // args: "RunSudo" [args...] - const std::string password = nth_arg(arg, 1); - std::vector argv = args_from(arg, 2); - report(callback, run_sudo(password, argv), ""); + if (!cp0_process_api_contract::has_at_least_arguments(arg, 3)) + return invalid(callback); + report(callback, cp0_process_commands::run_sudo( + *cp0_process_api_contract::argument_at(arg, 1), + cp0_process_api_contract::arguments_from(arg, 2)), ""); } else if (cmd == "CaptureArgv") { - std::vector argv = args_from(arg, 1); + if (!cp0_process_api_contract::has_at_least_arguments(arg, 2)) + return invalid(callback); std::string output; - int ret = capture_argv(argv, output); + int ret = cp0_process_commands::capture_argv( + cp0_process_api_contract::arguments_from(arg, 1), output); report(callback, ret, output); } else if (cmd == "AdbStatus") { + if (!cp0_process_api_contract::has_exact_arguments(arg, 1)) return invalid(callback); std::string output; - report(callback, capture_argv({cp0_file_path("adb_helper"), "status"}, output), output); + const int result = cp0_process_commands::capture_argv( + {"systemctl", "is-active", "adbd.service"}, output); + report(callback, 0, result == 0 ? "adbd=active\n" : "adbd=inactive\n"); } else if (cmd == "DesktopExecIsSafe") { + const auto *exec = cp0_process_api_contract::argument_at(arg, 1); + if (!cp0_process_api_contract::has_exact_arguments(arg, 2) || !exec || exec->empty()) + return invalid(callback); char reason[128] = {}; - int safe = cp0_desktop_exec_is_safe(nth_arg(arg, 1).c_str(), reason, sizeof(reason)); + int safe = cp0_desktop_exec_is_safe(exec->c_str(), reason, sizeof(reason)); report(callback, safe ? 0 : -1, reason); } else if (cmd == "Shutdown") { + if (!cp0_process_api_contract::has_exact_arguments(arg, 1)) return invalid(callback); system_shutdown(); report(callback, 0, ""); } else if (cmd == "Reboot") { + if (!cp0_process_api_contract::has_exact_arguments(arg, 1)) return invalid(callback); system_reboot(); report(callback, 0, ""); } else if (cmd == "DelayMs") { - std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, std::atoi(nth_arg(arg, 1).c_str())))); + int delay_ms = 0; + const auto *text = cp0_process_api_contract::argument_at(arg, 1); + if (!cp0_process_api_contract::has_exact_arguments(arg, 2) || !text || + !cp0_process_api_contract::parse_delay_ms(*text, delay_ms)) + return invalid(callback); + std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); report(callback, 0, ""); } else { report(callback, -1, "unknown process api command"); } - } - - int exec_blocking(const char *exec_path, volatile int *home_key_flag, int keep_root) - { -#if defined(_WIN32) - (void)exec_path; - (void)home_key_flag; - (void)keep_root; - return -1; -#else - return exec_blocking_cp0(exec_path, home_key_flag, keep_root); -#endif - } - - cp0_pid_t spawn(const char *exec_path, int keep_root) - { -#if defined(_WIN32) - (void)exec_path; - (void)keep_root; - return -1; -#else - cp0_process_group::enable_subreaper(); - pid_t pid = fork(); - if (pid < 0) - return -1; - if (pid == 0) { - setpgid(0, 0); - if (keep_root) - execlp("/bin/sh", "sh", "-c", exec_path, static_cast(nullptr)); - else - exec_as_user(exec_path); - _exit(127); - } - setpgid(pid, pid); - return static_cast(pid); -#endif - } - - void stop(cp0_pid_t pid) - { -#if !defined(_WIN32) - if (pid <= 0) - return; - if (!cp0_process_group::terminate_and_reap(static_cast(pid), - static_cast(pid))) - std::fprintf(stderr, "[process] failed to stop and reap pgid=%d\n", - static_cast(pid)); -#else - (void)pid; -#endif - } - - int check_lock(const char *lock_path, int *holder_pid) - { - if (holder_pid) - *holder_pid = 0; -#if defined(_WIN32) - (void)lock_path; - return 0; -#else - if (!lock_path || !holder_pid) - return -1; - int fd = open(lock_path, O_CREAT | O_RDWR, 0666); - if (fd < 0) - return -1; - struct flock fl; - std::memset(&fl, 0, sizeof(fl)); - fl.l_type = F_WRLCK; - fl.l_whence = SEEK_SET; - if (fcntl(fd, F_GETLK, &fl) == -1) { - close(fd); - return -1; - } - close(fd); - if (fl.l_type != F_UNLCK) { - *holder_pid = fl.l_pid; - return fl.l_pid; - } - return 0; -#endif - } - - void kill_process(int pid, int grace_ms) - { -#if !defined(_WIN32) - if (pid <= 0) - return; - killpg(pid, SIGINT); - auto start = std::chrono::steady_clock::now(); - while (true) { - int status = 0; - if (waitpid(pid, &status, WNOHANG) != 0) - return; - auto now = std::chrono::steady_clock::now(); - if (std::chrono::duration_cast(now - start).count() >= grace_ms) { - killpg(pid, SIGKILL); - waitpid(pid, &status, 0); - return; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } catch (...) { + report(callback, -1, "process api failure"); } -#else - (void)pid; - (void)grace_ms; -#endif - } - - int run_argv(const std::vector &argv, int background) - { -#if defined(_WIN32) - (void)argv; - (void)background; - return -1; -#else - if (argv.empty() || argv[0].empty()) - return -EINVAL; - - pid_t pid = fork(); - if (pid < 0) - return -errno; - - if (pid == 0) { - if (background) - redirect_to_devnull(); - auto raw = make_argv(argv); - execvp(raw[0], raw.data()); - _exit(127); - } - - if (background) - return 0; - - int status = 0; - while (waitpid(pid, &status, 0) < 0) { - if (errno != EINTR) - return -errno; - } - if (WIFEXITED(status)) - return WEXITSTATUS(status); - if (WIFSIGNALED(status)) - return 128 + WTERMSIG(status); - return -1; -#endif - } - - // Run a command via `sudo -k -S -- argv...`, feeding `password\n` to stdin. - // -k avoids reusing sudo's timestamp cache, so each prompt verifies the - // password the user just typed instead of accepting a previous success. - // Returns the child's exit code, or a negative errno on fork/pipe failure. - int run_sudo(const std::string &password, const std::vector &argv) - { -#if defined(_WIN32) - (void)password; (void)argv; - return -1; -#else - if (argv.empty() || argv[0].empty()) - return -EINVAL; - - // Build: sudo -k -S -- - std::vector sudo_argv = {"sudo", "-k", "-S", "--"}; - sudo_argv.insert(sudo_argv.end(), argv.begin(), argv.end()); - - int stdin_pipe[2]; - if (pipe(stdin_pipe) != 0) - return -errno; - - pid_t pid = fork(); - if (pid < 0) { - close(stdin_pipe[0]); - close(stdin_pipe[1]); - return -errno; - } - - if (pid == 0) { - // Child: redirect stdin from pipe, suppress stdout/stderr - close(stdin_pipe[1]); - dup2(stdin_pipe[0], STDIN_FILENO); - if (stdin_pipe[0] > STDIN_FILENO) - close(stdin_pipe[0]); - int devnull = open("/dev/null", O_WRONLY); - if (devnull >= 0) { - dup2(devnull, STDOUT_FILENO); - dup2(devnull, STDERR_FILENO); - if (devnull > STDERR_FILENO) close(devnull); - } - auto raw = make_argv(sudo_argv); - execvp(raw[0], raw.data()); - _exit(127); - } - - // Parent: write password + newline, then close write-end - close(stdin_pipe[0]); - std::string pw_line = password + "\n"; - ::write(stdin_pipe[1], pw_line.c_str(), pw_line.size()); - close(stdin_pipe[1]); - - int status = 0; - while (waitpid(pid, &status, 0) < 0) { - if (errno != EINTR) return -errno; - } - if (WIFEXITED(status)) return WEXITSTATUS(status); - if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); - return -1; -#endif - } - - int capture_argv(const std::vector &argv, std::string &output) { - output.clear(); -#if defined(_WIN32) - (void)argv; - return -1; -#else - if (argv.empty() || argv[0].empty()) - return -EINVAL; - - int pipefd[2]; - if (pipe(pipefd) != 0) - return -errno; - - pid_t pid = fork(); - if (pid < 0) { - close(pipefd[0]); - close(pipefd[1]); - return -errno; - } - - if (pid == 0) { - close(pipefd[0]); - dup2(pipefd[1], STDOUT_FILENO); - int devnull = open("/dev/null", O_WRONLY); - if (devnull >= 0) { - dup2(devnull, STDERR_FILENO); - if (devnull > STDERR_FILENO) - close(devnull); - } - if (pipefd[1] > STDERR_FILENO) - close(pipefd[1]); - auto raw = make_argv(argv); - execvp(raw[0], raw.data()); - _exit(127); - } - - close(pipefd[1]); - char buf[256]; - ssize_t n = 0; - while ((n = read(pipefd[0], buf, sizeof(buf))) > 0) - output.append(buf, static_cast(n)); - close(pipefd[0]); - - int status = 0; - while (waitpid(pid, &status, 0) < 0) { - if (errno != EINTR) - return -errno; - } - if (WIFEXITED(status)) - return WEXITSTATUS(status); - return -1; -#endif } void system_shutdown() @@ -375,7 +148,7 @@ class ProcessSystem #else std::printf("[CP0] shutdown\n"); const std::vector argv = {"sudo", "poweroff"}; - run_argv(argv, 1); + cp0_process_commands::run_argv(argv, true); #endif } @@ -387,309 +160,19 @@ class ProcessSystem #else std::printf("[CP0] reboot\n"); const std::vector argv = {"sudo", "reboot"}; - run_argv(argv, 1); + cp0_process_commands::run_argv(argv, true); #endif } private: static void report(callback_t callback, int code, const std::string &data) { - if (callback) - callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - return it == arg.end() ? std::string() : *it; - } - - static std::vector args_from(const arg_t &arg, size_t index) - { - std::vector out; - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - for (; it != arg.end(); ++it) - out.push_back(*it); - return out; - } - - static volatile int *decode_flag_ptr(const std::string &text) - { - if (text.empty() || text == "0") - return nullptr; - uintptr_t raw = static_cast(std::strtoull(text.c_str(), nullptr, 10)); - return reinterpret_cast(raw); - } - - static bool is_nologin_shell(const char *shell) - { - if (!shell || !shell[0]) - return true; - return std::strstr(shell, "nologin") != nullptr || std::strstr(shell, "/bin/false") != nullptr; - } - - static std::string config_get_str(const char *key, const char *default_val) - { - std::string value = default_val ? default_val : ""; - cp0_signal_config_api({"GetStr", key ? std::string(key) : std::string(), value}, - [&](int code, std::string data) { - if (code == 0) value = std::move(data); - }); - return value; - } - - static const char *get_run_user() - { - static thread_local std::string cfg; - cfg = config_get_str("run_as_user", nullptr); - if (!cfg.empty()) - return cfg.c_str(); - - struct passwd *pwd; - setpwent(); - while ((pwd = getpwent()) != nullptr) { - if (pwd->pw_uid >= 1000 && pwd->pw_uid < 65534 && !is_nologin_shell(pwd->pw_shell)) { - endpwent(); - return pwd->pw_name; - } - } - endpwent(); - return "pi"; - } - - static void exec_as_user(const char *exec_path) - { -#if defined(_WIN32) - (void)exec_path; -#else - const char *user = get_run_user(); - if (getuid() == 0 && std::strcmp(user, "root") != 0) { - struct passwd *pw = getpwnam(user); - if (pw) { - initgroups(pw->pw_name, pw->pw_gid); - setgid(pw->pw_gid); - setuid(pw->pw_uid); - setenv("HOME", pw->pw_dir, 1); - setenv("USER", pw->pw_name, 1); - setenv("LOGNAME", pw->pw_name, 1); - setenv("SHELL", pw->pw_shell[0] ? pw->pw_shell : "/bin/bash", 1); - chdir(pw->pw_dir); - } - } - execlp("/bin/sh", "sh", "-c", exec_path, static_cast(nullptr)); -#endif - } - - static std::vector make_argv(const std::vector &argv) - { - std::vector raw; - raw.reserve(argv.size() + 1); - for (const auto &arg : argv) - raw.push_back(const_cast(arg.c_str())); - raw.push_back(nullptr); - return raw; - } - - static void redirect_to_devnull() - { -#if !defined(_WIN32) - int fd = open("/dev/null", O_RDWR); - if (fd < 0) - return; - dup2(fd, STDIN_FILENO); - dup2(fd, STDOUT_FILENO); - dup2(fd, STDERR_FILENO); - if (fd > STDERR_FILENO) - close(fd); -#endif + cp0_process_api_contract::invoke_callback_safely(callback, code, data); } - int exec_blocking_sdl(const char *exec_path, volatile int *home_key_flag, int keep_root) + static void invalid(const callback_t &callback) { -#if defined(_WIN32) - (void)exec_path; - (void)home_key_flag; - (void)keep_root; - return -1; -#else - (void)keep_root; - const bool subreaper = cp0_process_group::enable_subreaper(); - pid_t pid = fork(); - if (pid < 0) - return -1; - if (pid == 0) { - setpgid(0, 0); - execlp("/bin/sh", "sh", "-c", exec_path, static_cast(nullptr)); - _exit(127); - } - setpgid(pid, pid); - std::fprintf(stderr, "[process] external app leader=%d pgid=%d subreaper=%d\n", - static_cast(pid), static_cast(pid), subreaper ? 1 : 0); - int status = 0; - bool leader_reaped = false; - int home_status = 0; - std::chrono::steady_clock::time_point home_start; - std::chrono::steady_clock::time_point term_start; - while (true) { - cp0_process_group::reap_available(pid, pid, status, leader_reaped); - if (!cp0_process_group::exists(pid)) - break; - - if (home_key_flag) { - if (home_status == 0 && *home_key_flag) { - home_status = 1; - home_start = std::chrono::steady_clock::now(); - } else if (home_status == 1) { - if (*home_key_flag) { - auto elapsed = std::chrono::steady_clock::now() - home_start; - if (std::chrono::duration_cast(elapsed).count() >= 3) { - home_status = 2; - term_start = std::chrono::steady_clock::now(); - std::fprintf(stderr, "[process] ESC timeout: SIGTERM pgid=%d\n", - static_cast(pid)); - killpg(pid, SIGTERM); - } - } else { - home_status = 0; - } - } else if (home_status == 2) { - auto elapsed = std::chrono::steady_clock::now() - term_start; - if (std::chrono::duration_cast(elapsed).count() >= 2) { - home_status = 3; - std::fprintf(stderr, "[process] grace timeout: SIGKILL pgid=%d\n", - static_cast(pid)); - killpg(pid, SIGKILL); - } - } - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - cp0_process_group::reap_available(pid, pid, status, leader_reaped); - std::fprintf(stderr, "[process] external app group drained pgid=%d leader_reaped=%d\n", - static_cast(pid), leader_reaped ? 1 : 0); - if (home_key_flag) - *home_key_flag = 0; - if (WIFEXITED(status)) - return WEXITSTATUS(status); - return -1; -#endif - } - - int exec_blocking_cp0(const char *exec_path, volatile int *home_key_flag, int keep_root) - { -#if defined(_WIN32) - (void)exec_path; - (void)home_key_flag; - (void)keep_root; - return -1; -#else - keyboard_pause(); - const bool subreaper = cp0_process_group::enable_subreaper(); - - int evfd = open(get_kbd_device(), O_RDONLY | O_NONBLOCK); - if (evfd < 0) { - std::perror("[cp0] open evdev"); - keyboard_resume(); - return -1; - } - std::printf("[cp0] Opened evdev %s (no EVIOCGRAB; shared with child)\n", get_kbd_device()); - std::fflush(stdout); - - pid_t pid = fork(); - if (pid < 0) { - close(evfd); - keyboard_resume(); - return -1; - } - if (pid == 0) { - close(evfd); - setpgid(0, 0); - if (keep_root) - execlp("/bin/sh", "sh", "-c", exec_path, static_cast(nullptr)); - else - exec_as_user(exec_path); - _exit(127); - } - setpgid(pid, pid); - std::fprintf(stderr, "[process] external app leader=%d pgid=%d subreaper=%d\n", - static_cast(pid), static_cast(pid), subreaper ? 1 : 0); - - auto esc_down_since = std::chrono::steady_clock::time_point{}; - auto term_start = std::chrono::steady_clock::time_point{}; - bool esc_down = false; - bool raw_esc_down = false; - bool term_sent = false; - bool kill_sent = false; - bool leader_reaped = false; - int status = 0; - - while (true) { - cp0_process_group::reap_available(pid, pid, status, leader_reaped); - if (!cp0_process_group::exists(pid)) - break; - - struct input_event ev; - while (read(evfd, &ev, sizeof(ev)) == static_cast(sizeof(ev))) { - if (ev.type == EV_KEY && ev.code == KEY_ESC) { - if (ev.value == 1) { - raw_esc_down = true; - } else if (ev.value == 0) { - raw_esc_down = false; - } - } - } - - const bool esc_now = raw_esc_down || (home_key_flag && *home_key_flag); - if (esc_now && !esc_down) { - esc_down = true; - esc_down_since = std::chrono::steady_clock::now(); - } else if (!esc_now) { - esc_down = false; - } - - if (esc_down && !term_sent) { - auto held_ms = std::chrono::duration_cast( - std::chrono::steady_clock::now() - esc_down_since).count(); - if (held_ms >= 3000) { - term_sent = true; - term_start = std::chrono::steady_clock::now(); - std::fprintf(stderr, "[process] ESC timeout: SIGTERM pgid=%d\n", - static_cast(pid)); - killpg(pid, SIGTERM); - } - } - if (term_sent && !kill_sent && - std::chrono::duration_cast( - std::chrono::steady_clock::now() - term_start).count() >= 2) { - kill_sent = true; - std::fprintf(stderr, "[process] grace timeout: SIGKILL pgid=%d\n", - static_cast(pid)); - killpg(pid, SIGKILL); - } - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - cp0_process_group::reap_available(pid, pid, status, leader_reaped); - std::fprintf(stderr, "[process] external app group drained pgid=%d leader_reaped=%d\n", - static_cast(pid), leader_reaped ? 1 : 0); - - close(evfd); - keyboard_resume(); - std::printf("[cp0] Returned to launcher\n"); - std::fflush(stdout); - if (WIFEXITED(status)) - return WEXITSTATUS(status); - return -1; -#endif - } - - static const char *get_kbd_device() - { - const char *env = std::getenv("APPLAUNCH_LINUX_KEYBOARD_DEVICE"); - return env ? env : "/dev/input/by-path/platform-3f804000.i2c-event"; + report(callback, -1, "invalid process api arguments"); } public: @@ -705,98 +188,87 @@ class ProcessSystem } }; -static bool contains_shell_meta(const char *s) -{ - if (!s) - return true; - static const char *kMeta = "|&;<>`$\\\n\r"; - return std::strpbrk(s, kMeta) != nullptr; -} - -static std::string first_token(const char *exec) -{ - std::istringstream iss(exec ? exec : ""); - std::string token; - iss >> token; - return token; -} - -static bool file_executable(const std::string &path) -{ -#if defined(_WIN32) - (void)path; - return false; -#else - struct stat st; - return stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode) && access(path.c_str(), X_OK) == 0; -#endif -} - extern "C" void init_process(void) { - auto process = std::make_shared(); - cp0_signal_process_api.append([process](std::list arg, std::function callback) { - process->api_call(std::move(arg), std::move(callback)); + cp0_process_api_contract::invoke_c_api_void([] { + static auto process = std::make_shared(); + static cp0::SignalRegistration registration; + const auto active_process = process; + registration.replace(cp0_signal_process_api, [active_process](std::list arg, std::function callback) { + active_process->api_call(std::move(arg), std::move(callback)); + }); }); } extern "C" int cp0_process_exec_blocking(const char *exec_path, volatile int *home_key_flag, int keep_root) { - return ProcessSystem::api_simple({"ExecBlocking", exec_path ? exec_path : "", - std::to_string(reinterpret_cast(home_key_flag)), - std::to_string(keep_root)}); + return cp0_process_api_contract::invoke_c_api([&] { + return ProcessSystem::api_simple({"ExecBlocking", exec_path ? exec_path : "", + std::to_string(reinterpret_cast(home_key_flag)), + std::to_string(keep_root)}); + }); } extern "C" cp0_pid_t cp0_process_spawn(const char *exec_path, int keep_root) { - std::string data; - int ret = ProcessSystem::api_simple({"Spawn", exec_path ? exec_path : "", std::to_string(keep_root)}, &data); - return ret == 0 ? static_cast(std::atoi(data.c_str())) : -1; + const int result = cp0_process_api_contract::invoke_c_api([&] { + std::string data; + int ret = ProcessSystem::api_simple({"Spawn", exec_path ? exec_path : "", std::to_string(keep_root)}, &data); + int pid = 0; + return ret == 0 && cp0_process_api_contract::parse_spawn_response(data, pid) ? pid : -1; + }); + return result < 0 ? static_cast(-1) : static_cast(result); } extern "C" void cp0_process_stop(cp0_pid_t pid) { - ProcessSystem::api_simple({"Stop", std::to_string(pid)}); + cp0_process_api_contract::invoke_c_api_void( + [&] { ProcessSystem::api_simple({"Stop", std::to_string(pid)}); }); } extern "C" int cp0_process_check_lock(const char *lock_path, int *holder_pid) { - std::string data; - int ret = ProcessSystem::api_simple({"CheckLock", lock_path ? lock_path : ""}, &data); - if (holder_pid) - *holder_pid = std::atoi(data.c_str()); - return ret; + if (holder_pid) *holder_pid = 0; + return cp0_process_api_contract::invoke_c_api([&] { + std::string data; + int ret = ProcessSystem::api_simple({"CheckLock", lock_path ? lock_path : ""}, &data); + int parsed_pid = 0; + if (holder_pid) { + if (!cp0_process_api_contract::parse_lock_holder_response(data, parsed_pid)) return -1; + *holder_pid = parsed_pid; + } + return ret; + }); } extern "C" void cp0_process_kill(int pid, int grace_ms) { - ProcessSystem::api_simple({"Kill", std::to_string(pid), std::to_string(grace_ms)}); + cp0_process_api_contract::invoke_c_api_void([&] { + ProcessSystem::api_simple({"Kill", std::to_string(pid), std::to_string(grace_ms)}); + }); } extern "C" int cp0_process_run_argv(const char *const *argv, int background) { - std::list args = {"RunArgv", std::to_string(background)}; - if (argv) { - for (int i = 0; argv[i]; ++i) - args.push_back(argv[i]); - } - return ProcessSystem::api_simple(args); + return cp0_process_api_contract::invoke_c_api([&] { + std::list args = {"RunArgv", std::to_string(background)}; + if (argv) for (int i = 0; argv[i]; ++i) args.push_back(argv[i]); + return ProcessSystem::api_simple(args); + }); } extern "C" int cp0_process_capture_argv(const char *const *argv, char *out, int out_size) { if (out && out_size > 0) out[0] = '\0'; - std::list args = {"CaptureArgv"}; - if (argv) { - for (int i = 0; argv[i]; ++i) - args.push_back(argv[i]); - } - std::string data; - int ret = ProcessSystem::api_simple(args, &data); - if (out && out_size > 0) - cp0_copy_string(out, out_size, data); - return ret; + return cp0_process_api_contract::invoke_c_api([&] { + std::list args = {"CaptureArgv"}; + if (argv) for (int i = 0; argv[i]; ++i) args.push_back(argv[i]); + std::string data; + int ret = ProcessSystem::api_simple(args, &data); + if (out && out_size > 0) cp0_copy_string(out, out_size, data); + return ret; + }); } // Run argv via `sudo -S`, feeding password to stdin. @@ -804,12 +276,10 @@ extern "C" int cp0_process_capture_argv(const char *const *argv, char *out, int // argv: null-terminated array of command + args (must not include "sudo"). extern "C" int cp0_process_run_sudo(const char *password, const char *const *argv) { - std::list args = {"RunSudo", password ? password : ""}; - if (argv) { - for (int i = 0; argv[i]; ++i) - args.push_back(argv[i]); - } - return ProcessSystem::api_simple(args); + return cp0_process_api_contract::invoke_c_api([&] { + return ProcessSystem::api_simple( + cp0_process_api_contract::make_run_sudo_request(password, argv)); + }); } extern "C" int cp0_file_read_first_line(const char *path, char *out, int out_size) @@ -818,55 +288,34 @@ extern "C" int cp0_file_read_first_line(const char *path, char *out, int out_siz out[0] = '\0'; if (!path || !out || out_size <= 0) return -1; - std::ifstream file(path); - if (!file.is_open()) - return -1; - std::string line; - if (!std::getline(file, line)) - return -1; - if (!line.empty() && line.back() == '\r') - line.pop_back(); - cp0_copy_string(out, out_size, line); - return 0; + return cp0_process_api_contract::invoke_c_api([&] { + std::ifstream file(path); + if (!file.is_open()) return -1; + std::string line; + if (!std::getline(file, line)) return -1; + if (!line.empty() && line.back() == '\r') line.pop_back(); + cp0_copy_string(out, out_size, line); + return 0; + }); } extern "C" int cp0_desktop_exec_is_safe(const char *exec, char *reason, int reason_size) { - auto fail = [reason, reason_size](const char *msg) { - cp0_copy_cstr(reason, reason_size, msg ? msg : "unsafe Exec"); - return 0; - }; - - if (!exec || !exec[0]) - return fail("empty Exec"); - if (std::strlen(exec) > 512) - return fail("Exec too long"); - if (contains_shell_meta(exec)) - return fail("Exec contains shell metacharacters"); - - const std::string token = first_token(exec); - if (token.empty()) - return fail("missing executable"); - - if (token.find('/') != std::string::npos) { - if (!file_executable(token)) - return fail("executable path is not executable"); - return 1; - } - - static const char *kAllowedNames[] = {"bash", "python3", "vim", "vi", "nano", "sh"}; - if (std::find(std::begin(kAllowedNames), std::end(kAllowedNames), token) != std::end(kAllowedNames)) - return 1; - - return fail("executable name is not allowlisted"); + return cp0_process_api_contract::invoke_c_api([&] { + const cp0_desktop_exec_policy::Result result = cp0_desktop_exec_policy::evaluate(exec); + if (!result.allowed) cp0_copy_string(reason, reason_size, result.reason); + return result.allowed ? 1 : 0; + }); } extern "C" void cp0_system_shutdown(void) { - ProcessSystem::api_simple({"Shutdown"}); + cp0_process_api_contract::invoke_c_api_void( + [] { ProcessSystem::api_simple({"Shutdown"}); }); } extern "C" void cp0_system_reboot(void) { - ProcessSystem::api_simple({"Reboot"}); + cp0_process_api_contract::invoke_c_api_void( + [] { ProcessSystem::api_simple({"Reboot"}); }); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_pty.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_pty.cpp index 78aea037..ef4d963e 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_pty.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_pty.cpp @@ -1,336 +1 @@ -#include "hal_lvgl_bsp.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#if defined(__linux__) -#include -#endif - -namespace { - -typedef void *pty_handle_t; - -struct cp0_pty_handle { - int master_fd; - pid_t child_pid; -}; - -class PtySystem { -public: - typedef std::function callback_t; - typedef std::list arg_t; - - pty_handle_t open(const char *cmd, const char *const *args, int cols, int rows) - { -#if defined(__linux__) - if (!cmd || !cmd[0]) return NULL; - - int master_fd = -1; - struct winsize ws = {}; - ws.ws_col = cols; - ws.ws_row = rows; - std::string run_as_user = config_get_str("run_as_user", ""); - - pid_t pid = forkpty(&master_fd, NULL, NULL, &ws); - if (pid < 0) return NULL; - - if (pid == 0) { - setenv("TERM", "vt100", 1); - drop_root_user(run_as_user); - - if (args) - execvp(cmd, const_cast(args)); - else - execlp(cmd, cmd, static_cast(NULL)); - _exit(127); - } - - int flags = fcntl(master_fd, F_GETFL); - if (flags >= 0) fcntl(master_fd, F_SETFL, flags | O_NONBLOCK); - - cp0_pty_handle *pty = static_cast(std::malloc(sizeof(cp0_pty_handle))); - if (!pty) { - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); - ::close(master_fd); - return NULL; - } - pty->master_fd = master_fd; - pty->child_pid = pid; - return pty; -#else - (void)cmd; - (void)args; - (void)cols; - (void)rows; - return NULL; -#endif - } - - int read(pty_handle_t pty, char *buf, size_t buf_size) - { -#if defined(__linux__) - if (!pty || !buf || buf_size == 0) return -1; - cp0_pty_handle *h = static_cast(pty); - ssize_t n = ::read(h->master_fd, buf, buf_size); - if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) return 0; - return -1; - } - return static_cast(n); -#else - (void)pty; - (void)buf; - (void)buf_size; - return -1; -#endif - } - - int write(pty_handle_t pty, const char *buf, size_t len) - { -#if defined(__linux__) - if (!pty || !buf) return -1; - cp0_pty_handle *h = static_cast(pty); - return static_cast(::write(h->master_fd, buf, len)); -#else - (void)pty; - (void)buf; - (void)len; - return -1; -#endif - } - - int check_child(pty_handle_t pty, int *exit_status) - { -#if defined(__linux__) - if (!pty) return -1; - cp0_pty_handle *h = static_cast(pty); - int status = 0; - pid_t r = waitpid(h->child_pid, &status, WNOHANG); - if (r == 0) return 0; - if (r > 0) { - if (exit_status) *exit_status = WIFEXITED(status) ? WEXITSTATUS(status) : -1; - return 1; - } - return -1; -#else - (void)pty; - (void)exit_status; - return -1; -#endif - } - - void close(pty_handle_t pty) - { -#if defined(__linux__) - if (!pty) return; - cp0_pty_handle *h = static_cast(pty); - kill(h->child_pid, SIGKILL); - waitpid(h->child_pid, NULL, 0); - ::close(h->master_fd); - std::free(h); -#else - (void)pty; -#endif - } - - void api_call(arg_t arg, callback_t callback) - { - if (arg.empty()) { - report(callback, -1, "empty pty api\n"); - return; - } - - const std::string cmd = arg.front(); - if (cmd == "Open") { - api_open(arg, callback); - } else if (cmd == "Read") { - api_read(arg, callback); - } else if (cmd == "Write") { - api_write(arg, callback); - } else if (cmd == "CheckChild") { - api_check_child(arg, callback); - } else if (cmd == "Close") { - pty_handle_t pty = parse_handle(nth_arg(arg, 1)); - close(pty); - report(callback, 0, ""); - } else { - report(callback, -1, "unknown pty api: " + cmd + "\n"); - } - } - -private: - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - if (index >= arg.size()) return ""; - auto it = arg.begin(); - std::advance(it, index); - return *it; - } - - static std::string handle_to_string(pty_handle_t pty) - { - std::ostringstream os; - os << reinterpret_cast(pty); - return os.str(); - } - - static pty_handle_t parse_handle(const std::string &value) - { - if (value.empty()) return NULL; - char *end = NULL; - uintptr_t raw = static_cast(std::strtoull(value.c_str(), &end, 0)); - if (!end || *end != '\0') return NULL; - return reinterpret_cast(raw); - } - - static int parse_int(const std::string &value, int fallback) - { - if (value.empty()) return fallback; - char *end = NULL; - long parsed = std::strtol(value.c_str(), &end, 10); - return (end && *end == '\0') ? static_cast(parsed) : fallback; - } - - static std::string config_get_str(const char *key, const char *default_val) - { - std::string value = default_val ? default_val : ""; - cp0_signal_config_api({"GetStr", key ? std::string(key) : std::string(), value}, - [&](int code, std::string data) { - if (code == 0) value = std::move(data); - }); - return value; - } - - static void drop_root_user(const std::string &configured_user) - { -#if defined(__linux__) - if (getuid() != 0) return; - - const char *username = configured_user.empty() ? NULL : configured_user.c_str(); - if (!username) { - struct passwd *p = NULL; - setpwent(); - while ((p = getpwent()) != NULL) { - if (p->pw_uid >= 1000 && p->pw_uid < 65534 && - p->pw_shell && p->pw_shell[0] && - !std::strstr(p->pw_shell, "nologin") && - !std::strstr(p->pw_shell, "/bin/false")) { - username = p->pw_name; - break; - } - } - endpwent(); - } - if (!username) username = "pi"; - - struct passwd *pw = getpwnam(username); - if (pw && std::strcmp(username, "root") != 0) { - initgroups(pw->pw_name, pw->pw_gid); - setgid(pw->pw_gid); - setuid(pw->pw_uid); - setenv("HOME", pw->pw_dir, 1); - setenv("USER", pw->pw_name, 1); - setenv("LOGNAME", pw->pw_name, 1); - setenv("SHELL", pw->pw_shell[0] ? pw->pw_shell : "/bin/bash", 1); - chdir(pw->pw_dir); - } -#endif - } - - void api_open(const arg_t &arg, callback_t callback) - { - std::string exec = nth_arg(arg, 1); - int cols = parse_int(nth_arg(arg, 2), 80); - int rows = parse_int(nth_arg(arg, 3), 24); - if (exec.empty()) { - report(callback, -1, "empty pty command\n"); - return; - } - - std::vector argv_storage; - for (auto it = arg.begin(); it != arg.end(); ++it) { - size_t index = static_cast(std::distance(arg.begin(), it)); - if (index >= 4) argv_storage.push_back(*it); - } - - std::vector argv; - if (!argv_storage.empty()) { - for (const std::string &item : argv_storage) argv.push_back(item.c_str()); - argv.push_back(NULL); - } - - pty_handle_t pty = open(exec.c_str(), argv.empty() ? NULL : argv.data(), cols, rows); - if (!pty) { - report(callback, -1, "open pty failed\n"); - return; - } - report(callback, 0, handle_to_string(pty)); - } - - void api_read(const arg_t &arg, callback_t callback) - { - pty_handle_t pty = parse_handle(nth_arg(arg, 1)); - int max_len = parse_int(nth_arg(arg, 2), 4096); - if (max_len <= 0) max_len = 4096; - - std::string out(static_cast(max_len), '\0'); - int n = read(pty, &out[0], out.size()); - if (n < 0) { - report(callback, -1, "read pty failed\n"); - return; - } - out.resize(static_cast(n)); - report(callback, n, out); - } - - void api_write(const arg_t &arg, callback_t callback) - { - pty_handle_t pty = parse_handle(nth_arg(arg, 1)); - std::string data = nth_arg(arg, 2); - int n = write(pty, data.c_str(), data.size()); - report(callback, n, n < 0 ? "write pty failed\n" : ""); - } - - void api_check_child(const arg_t &arg, callback_t callback) - { - pty_handle_t pty = parse_handle(nth_arg(arg, 1)); - int status = 0; - int ret = check_child(pty, &status); - report(callback, ret, std::to_string(status)); - } -}; - -} // namespace - -extern "C" void init_pty(void) -{ - std::shared_ptr pty = std::make_shared(); - cp0_signal_pty_api.append([pty](std::list arg, std::function callback) { - pty->api_call(std::move(arg), std::move(callback)); - }); -} +#include "../cp0_pty_posix.inc" diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_rpc.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_rpc.cpp index ff9ac736..da94aeb8 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_rpc.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_rpc.cpp @@ -1,5 +1,7 @@ #include "cp0_lvgl.h" #include "cp0_lvgl_app.h" +#include "../cp0_init_once.hpp" +#include "../cp0_rpc_runtime_contract.hpp" #ifndef CP0_LVGL_USE_ZMQ_RPC #define CP0_LVGL_USE_ZMQ_RPC 0 @@ -7,6 +9,7 @@ #if CP0_LVGL_USE_ZMQ_RPC +#include "cp0_framebuffer_codec.hpp" #include "keyboard_input.h" #include @@ -16,6 +19,8 @@ #include #include #include +#include +#include #include #include #include @@ -88,59 +93,36 @@ bool capture_ppm(std::vector &out, std::string &error) return false; } - const std::string header = "P6\n" + std::to_string(vinfo.xres) + " " + - std::to_string(vinfo.yres) + "\n255\n"; - out.assign(header.begin(), header.end()); - out.resize(header.size() + static_cast(vinfo.xres) * vinfo.yres * 3); - uint8_t *dst = out.data() + header.size(); - const size_t pixel_bytes = vinfo.bits_per_pixel / 8; - for (uint32_t y = 0; y < vinfo.yres; ++y) { - const size_t row_offset = static_cast(y + vinfo.yoffset) * finfo.line_length + - static_cast(vinfo.xoffset) * pixel_bytes; - if (row_offset + static_cast(vinfo.xres) * pixel_bytes > map_size) { - munmap(const_cast(fb), map_size); - close(fd); - error = "framebuffer bounds mismatch"; - out.clear(); - return false; - } - const uint8_t *row = fb + row_offset; - for (uint32_t x = 0; x < vinfo.xres; ++x) { - uint8_t r = 0, g = 0, b = 0; - if (vinfo.bits_per_pixel == 16) { - uint16_t px = 0; - std::memcpy(&px, row + x * 2, sizeof(px)); - r = static_cast(((px >> 11) & 0x1f) * 255 / 31); - g = static_cast(((px >> 5) & 0x3f) * 255 / 63); - b = static_cast((px & 0x1f) * 255 / 31); - } else { - uint32_t px = 0; - std::memcpy(&px, row + x * 4, sizeof(px)); - r = static_cast((px >> vinfo.red.offset) & 0xff); - g = static_cast((px >> vinfo.green.offset) & 0xff); - b = static_cast((px >> vinfo.blue.offset) & 0xff); - } - *dst++ = r; - *dst++ = g; - *dst++ = b; - } - } + const cp0::framebuffer::Layout layout { + vinfo.xres, + vinfo.yres, + vinfo.xoffset, + vinfo.yoffset, + vinfo.bits_per_pixel, + finfo.line_length, + {static_cast(vinfo.red.offset), static_cast(vinfo.red.length)}, + {static_cast(vinfo.green.offset), static_cast(vinfo.green.length)}, + {static_cast(vinfo.blue.offset), static_cast(vinfo.blue.length)}, + }; + const bool encoded = cp0::framebuffer::encode_ppm(fb, map_size, layout, out, error); munmap(const_cast(fb), map_size); close(fd); - return true; + return encoded; } -void key_subscriber(std::shared_ptr context) +void key_subscriber(std::shared_ptr context, + const std::atomic *stopping) { try { zmq::socket_t sub(*context, zmq::socket_type::sub); sub.set(zmq::sockopt::linger, 0); sub.set(zmq::sockopt::subscribe, ""); sub.connect(env_or_default("CP0_ZMQ_KEY_ENDPOINT", kDefaultKeyEndpoint)); - for (;;) { + while (!stopping->load()) { RpcKeyEvent event {}; zmq::recv_buffer_result_t result = sub.recv(zmq::buffer(&event, sizeof(event))); + if (stopping->load()) break; if (!result || result->truncated() || result->size != sizeof(event) || event.magic != kKeyMagic) continue; @@ -157,8 +139,10 @@ void send_text(zmq::socket_t &socket, const std::string &text) socket.send(zmq::buffer(text), zmq::send_flags::none); } -void rpc_broker(std::shared_ptr context) +void rpc_broker(std::shared_ptr context, + const std::atomic *stopping, std::promise ready) { + bool readiness_reported = false; try { zmq::socket_t rep(*context, zmq::socket_type::rep); zmq::socket_t pub(*context, zmq::socket_type::pub); @@ -166,13 +150,16 @@ void rpc_broker(std::shared_ptr context) pub.set(zmq::sockopt::linger, 0); rep.bind(env_or_default("CP0_ZMQ_RPC_ENDPOINT", kDefaultRpcEndpoint)); pub.bind(env_or_default("CP0_ZMQ_KEY_ENDPOINT", kDefaultKeyEndpoint)); + ready.set_value(true); + readiness_reported = true; std::fprintf(stderr, "[rpc] automation broker ready pid=%d endpoint=%s\n", static_cast(getpid()), env_or_default("CP0_ZMQ_RPC_ENDPOINT", kDefaultRpcEndpoint)); - for (;;) { + while (!stopping->load()) { zmq::message_t request; if (!rep.recv(request, zmq::recv_flags::none)) continue; + if (stopping->load()) break; std::string text(static_cast(request.data()), request.size()); std::istringstream input(text); std::string command; @@ -240,22 +227,124 @@ void rpc_broker(std::shared_ptr context) send_text(rep, "ERR unknown command"); } } catch (const zmq::error_t &e) { + if (!readiness_reported) + ready.set_value(false); std::fprintf(stderr, "[rpc] broker unavailable pid=%d: %s\n", static_cast(getpid()), e.what()); + } catch (const std::exception &e) { + if (!readiness_reported) + ready.set_value(false); + std::fprintf(stderr, "[rpc] broker stopped pid=%d: %s\n", + static_cast(getpid()), e.what()); + } catch (...) { + if (!readiness_reported) + ready.set_value(false); + std::fprintf(stderr, "[rpc] broker stopped pid=%d: unknown error\n", + static_cast(getpid())); + } +} + +class RpcRuntime { +public: + ~RpcRuntime() { stop(); } + + bool start() + { + std::lock_guard lock(mutex_); + if (state_.running()) return true; + if (!state_.can_start()) return false; + + stopping_.store(false); + auto context = std::make_shared(1); + std::promise ready; + std::future readiness = ready.get_future(); + std::thread broker; + std::thread subscriber; + try { + broker = std::thread(rpc_broker, context, &stopping_, std::move(ready)); + } catch (...) { + return false; + } + const bool ready_ok = cp0::rpc::await_readiness(readiness); + if (!ready_ok) { + rollback_start(context, subscriber, broker); + return false; + } + try { + subscriber = std::thread(key_subscriber, context, &stopping_); + } catch (...) { + rollback_start(context, subscriber, broker); + return false; + } + context_ = std::move(context); + subscriber_ = std::move(subscriber); + broker_ = std::move(broker); + state_.mark_started(); + return true; + } + + void stop() noexcept + { + std::lock_guard lock(mutex_); + if (state_.can_start()) return; + if (state_.running()) state_.mark_stopping(); + const auto stopped = cp0::rpc::shutdown_runtime( + [&] { stopping_.store(true); }, + [&] { if (context_) context_->shutdown(); }, + [&] { if (subscriber_.joinable()) subscriber_.join(); }, + [&] { if (broker_.joinable()) broker_.join(); }); + if (stopped.workers_joined()) { + context_.reset(); + state_.mark_stopped(); + } + } + +private: + void rollback_start(const std::shared_ptr &context, + std::thread &subscriber, std::thread &broker) noexcept + { + const auto stopped = cp0::rpc::shutdown_runtime( + [&] { stopping_.store(true); }, + [&] { if (context) context->shutdown(); }, + [&] { if (subscriber.joinable()) subscriber.join(); }, + [&] { if (broker.joinable()) broker.join(); }); + if (!stopped.workers_joined()) { + context_ = context; + if (subscriber.joinable()) subscriber_ = std::move(subscriber); + if (broker.joinable()) broker_ = std::move(broker); + state_.mark_stopping(); + } } + + std::mutex mutex_; + std::atomic stopping_{false}; + cp0::rpc::RuntimeState state_; + std::shared_ptr context_; + std::thread subscriber_; + std::thread broker_; +}; + +RpcRuntime &rpc_runtime() +{ + static RpcRuntime runtime; + return runtime; } } // namespace extern "C" void init_rpc(void) { - auto context = std::make_shared(1); - std::thread(key_subscriber, context).detach(); - std::thread(rpc_broker, std::move(context)).detach(); + try { (void)rpc_runtime().start(); } catch (...) {} +} + +extern "C" void deinit_rpc(void) noexcept +{ + try { rpc_runtime().stop(); } catch (...) {} } #else extern "C" void init_rpc(void) {} +extern "C" void deinit_rpc(void) noexcept {} #endif diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_screenshot.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_screenshot.cpp index 2d57493b..23c31102 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_screenshot.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_screenshot.cpp @@ -1,9 +1,12 @@ #include "hal_lvgl_bsp.h" +#include "../cp0_callback_contract.hpp" +#include "../cp0_display_screenshot_contract.hpp" +#include "../cp0_init_once.hpp" #include -#include #include #include +#include #include #include @@ -31,41 +34,19 @@ class ScreenshotSystem { void api_call(arg_t arg, callback_t callback) { - if (arg.empty()) { - report(callback, -1, "empty screenshot api\n"); + cp0::screenshot::Request request; + if (!cp0::screenshot::parse_request(arg, request)) { + report(callback, -1, cp0::screenshot::invalid_request_message()); return; } - - if (arg.front() == "Save") { - Save(std::move(arg), std::move(callback)); - return; - } - - report(callback, -1, "unknown screenshot api\n"); + int ret = save_to_bmp(request.directory.c_str()); + report(callback, ret, ret == 0 ? "screenshot saved\n" : "screenshot failed\n"); } private: - static void report(callback_t callback, int code, const std::string &data) + static void report(const callback_t &callback, int code, const std::string &data) { - if (callback) callback(code, data); - } - - static std::string first_arg_after_command(const arg_t &arg) - { - if (arg.size() < 2) return ""; - return *std::next(arg.begin()); - } - - void Save(arg_t arg, callback_t callback) - { - std::string dir = first_arg_after_command(arg); - if (dir.empty()) { - report(callback, -1, "Save need dir\n"); - return; - } - - int ret = save_to_bmp(dir.c_str()); - report(callback, ret, ret == 0 ? "screenshot saved\n" : "screenshot failed\n"); + cp0::callback::invoke(callback, code, data); } static int save_to_bmp(const char *dir) @@ -82,17 +63,25 @@ class ScreenshotSystem { return -2; } - int w = vinfo.xres; - int h = vinfo.yres; - int bpp = vinfo.bits_per_pixel; + int w = static_cast(vinfo.xres); + int h = static_cast(vinfo.yres); + int bpp = static_cast(vinfo.bits_per_pixel); + if (!cp0::screenshot::valid_dimensions(w, h) || (bpp != 16 && bpp != 32)) { + close(fd); + return -3; + } int fb_line_len = w * (bpp / 8); struct fb_fix_screeninfo finfo; if (ioctl(fd, FBIOGET_FSCREENINFO, &finfo) == 0) fb_line_len = finfo.line_length; - size_t fb_size = fb_line_len * h; - void *fbmem = mmap(NULL, fb_size, PROT_READ, MAP_SHARED, fd, 0); + cp0::screenshot::FramebufferLayout layout; + if (!cp0::screenshot::framebuffer_layout(w, h, bpp, fb_line_len, layout)) { + close(fd); + return -3; + } + void *fbmem = mmap(NULL, layout.mapped_size, PROT_READ, MAP_SHARED, fd, 0); if (fbmem == MAP_FAILED) { close(fd); return -3; @@ -111,27 +100,29 @@ class ScreenshotSystem { } time_t now = time(NULL); - struct tm *t = localtime(&now); - char filename[512]; - snprintf(filename, sizeof(filename), "%s/scr_%04d%02d%02d_%02d%02d%02d.bmp", - dir, t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, - t->tm_hour, t->tm_min, t->tm_sec); + struct tm local = {}; + std::string filename; + if (!localtime_r(&now, &local) || + !cp0::screenshot::make_output_path(dir, + {local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, + local.tm_hour, local.tm_min, local.tm_sec}, false, filename)) { + munmap(fbmem, layout.mapped_size); + close(fd); + return -4; + } - FILE *fp = fopen(filename, "wb"); + FILE *fp = fopen(filename.c_str(), "wb"); if (!fp) { - munmap(fbmem, fb_size); + munmap(fbmem, layout.mapped_size); close(fd); return -4; } int row_size = w * 3; - int pad = (4 - (row_size % 4)) % 4; - int bmp_row = row_size + pad; - uint32_t img_size = bmp_row * h; - uint32_t file_size = 54 + img_size; + int pad = static_cast(layout.bmp_row_size) - row_size; fputc('B', fp); fputc('M', fp); - write_le32(fp, file_size); + write_le32(fp, layout.bmp_file_size); write_le16(fp, 0); write_le16(fp, 0); write_le32(fp, 54); write_le32(fp, 40); @@ -140,7 +131,7 @@ class ScreenshotSystem { write_le16(fp, 1); write_le16(fp, 24); write_le32(fp, 0); - write_le32(fp, img_size); + write_le32(fp, layout.bmp_image_size); write_le32(fp, 2835); write_le32(fp, 2835); write_le32(fp, 0); write_le32(fp, 0); @@ -168,11 +159,14 @@ class ScreenshotSystem { if (pad > 0) fwrite(padding, 1, pad, fp); } - fclose(fp); - munmap(fbmem, fb_size); + int write_failed = ferror(fp); + if (fclose(fp) != 0) write_failed = 1; + munmap(fbmem, layout.mapped_size); close(fd); - printf("[SCREENSHOT] Saved: %s (%dx%d %dbpp)\n", filename, w, h, bpp); + if (write_failed) return -5; + + printf("[SCREENSHOT] Saved: %s (%dx%d %dbpp)\n", filename.c_str(), w, h, bpp); return 0; } }; @@ -181,8 +175,13 @@ class ScreenshotSystem { extern "C" void init_screenshot(void) { - auto screenshot = std::make_shared(); - cp0_signal_screenshot_api.append([screenshot](std::list arg, std::function callback) { - screenshot->api_call(std::move(arg), std::move(callback)); + static cp0::InitOnce initialized; + initialized.run([] { + auto screenshot = std::make_shared(); + return static_cast(cp0_signal_screenshot_api.append( + [screenshot](std::list arg, + std::function callback) { + screenshot->api_call(std::move(arg), std::move(callback)); + })); }); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_settings.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_settings.cpp index 19771451..40d657a8 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_settings.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_settings.cpp @@ -2,8 +2,10 @@ #include "cp0_lvgl_app.h" #include "cp0_lvgl_log.h" +#include "../cp0_settings_policy.hpp" +#include "../cp0_signal_registration.hpp" +#include "../cp0_sync_signal.hpp" -#include #include #include #include @@ -11,7 +13,7 @@ #include #include #include -#include +#include #include #include #include @@ -136,58 +138,91 @@ class SettingsSystem { public: using callback_t = std::function; - using arg_t = std::list; - - SettingsSystem() - { - apply_extport_config(); - } + using arg_t = cp0::settings::Arguments; void api_call(arg_t arg, callback_t callback) { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "BacklightRead") { + try { + if (!cp0::settings::valid_arguments(arg)) { + report(callback, -1, "invalid settings api request"); + return; + } + switch (cp0::settings::command_from(arg)) { + case cp0::settings::Command::BacklightRead: { int val = backlight_read(); report(callback, val < 0 ? -1 : 0, std::to_string(val)); - } else if (cmd == "BacklightMax") { + break; + } + case cp0::settings::Command::BacklightMax: { int val = backlight_max(); report(callback, val < 0 ? -1 : 0, std::to_string(val)); - } else if (cmd == "BacklightWrite") { - int val = backlight_write(std::atoi(nth_arg(arg, 1).c_str())); + break; + } + case cp0::settings::Command::BacklightWrite: { + int requested = 0; + if (!cp0::settings::integer_argument( + arg, 1, 0, std::numeric_limits::max(), requested)) { + report(callback, -1, "invalid settings api request"); + break; + } + int val = backlight_write(requested); report(callback, val < 0 ? -1 : 0, std::to_string(val)); - } else if (cmd == "Log") { - const std::string topic = nth_arg(arg, 1); - const std::string message = nth_arg(arg, 2); + break; + } + case cp0::settings::Command::Log: { + const std::string topic = cp0::settings::argument_at(arg, 1); + const std::string message = cp0::settings::argument_at(arg, 2); settings_log(topic.c_str(), message.c_str()); report(callback, 0, ""); - } else if (cmd == "TimeStr") { + break; + } + case cp0::settings::Command::TimeStr: { char buf[32] = {}; time_str(buf, sizeof(buf)); report(callback, 0, buf); - } else if (cmd == "GpioSet") { - const std::string name = nth_arg(arg, 1); - int val = std::atoi(nth_arg(arg, 2).c_str()); - int ret = set_named_gpio(name.c_str(), val); + break; + } + case cp0::settings::Command::GpioSet: { + const auto output = cp0::settings::power_output_from_name(cp0::settings::argument_at(arg, 1)); + int requested = 0; + if (!cp0::settings::integer_argument(arg, 2, 0, 1, requested)) { + report(callback, -1, "invalid settings api request"); + break; + } + int ret = set_named_gpio(output, requested); report(callback, ret, ret == 0 ? "ok" : std::string("gpio set failed: ") + std::to_string(-ret)); - } else if (cmd == "GpioGet") { - const std::string name = nth_arg(arg, 1); - int val = get_named_gpio(name.c_str()); + break; + } + case cp0::settings::Command::GpioGet: { + int val = get_named_gpio(cp0::settings::power_output_from_name(cp0::settings::argument_at(arg, 1))); if (val < 0) report(callback, val, std::string("gpio get failed: ") + std::to_string(-val)); else report(callback, 0, std::to_string(val)); - } else { + break; + } + case cp0::settings::Command::Unknown: + default: report(callback, -1, "unknown settings api command"); + break; + } + } catch (...) { + report(callback, -1, "settings api failure"); } } - static int api_int(const arg_t &arg, int default_value = -1) + static int api_int(const arg_t &arg, + int default_value, + int minimum, + int maximum) { int result = default_value; - cp0_signal_settings_api(arg, [&](int code, std::string data) { - if (code >= 0) - result = std::atoi(data.c_str()); - }); + cp0::signal::invoke_noexcept([&] { cp0_signal_settings_api(arg, [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && + cp0::settings::parse_integer_response(data, minimum, maximum, parsed)) + result = parsed; + }); }); return result; } @@ -196,10 +231,10 @@ class SettingsSystem if (!buf || buf_size <= 0) return; buf[0] = '\0'; - cp0_signal_settings_api({"TimeStr"}, [&](int code, std::string data) { + cp0::signal::invoke_noexcept([&] { cp0_signal_settings_api({"TimeStr"}, [&](int code, std::string data) { if (code == 0) copy_string(buf, static_cast(buf_size), data); - }); + }); }); if (buf[0] == '\0') fallback_time_str(buf, buf_size); } @@ -210,27 +245,24 @@ class SettingsSystem void report(callback_t callback, int code, const std::string &data) { - if (callback) + if (!callback) return; + try { callback(code, data); + } catch (...) { + } } - void apply_extport_config() - { - set_named_gpio("GROVE5V", config_get_int("extport_usb", 1)); - set_named_gpio("EXT5V", config_get_int("extport_5vout", 1)); - } - - int set_named_gpio(const char *name, int val) + int set_named_gpio(cp0::settings::PowerOutput output, int val) { std::lock_guard lock(gpio_mutex_); - if (is_grove5v_name(name)) { + if (output == cp0::settings::PowerOutput::Grove5V) { int ret = write_led_value(kGrove5vLedPaths, val); if (ret != -ENOENT) return ret; const ExtPortGpioMap &map = active_gpio_map_locked(); return set_gpio_value(map.chip_path, map.grove5v_line, map.grove5v_active_low, "GROVE5V", val); } - if (is_ext5v_name(name)) { + if (output == cp0::settings::PowerOutput::Ext5V) { int ret = write_led_value(kExt5vLedPaths, val); if (ret != -ENOENT) return ret; @@ -240,17 +272,17 @@ class SettingsSystem return -EINVAL; } - int get_named_gpio(const char *name) + int get_named_gpio(cp0::settings::PowerOutput output) { std::lock_guard lock(gpio_mutex_); - if (is_grove5v_name(name)) { + if (output == cp0::settings::PowerOutput::Grove5V) { int value = read_led_value(kGrove5vLedPaths); if (value != -ENOENT) return value; const ExtPortGpioMap &map = active_gpio_map_locked(); return get_gpio_value(map.chip_path, map.grove5v_line, map.grove5v_active_low, "GROVE5V"); } - if (is_ext5v_name(name)) { + if (output == cp0::settings::PowerOutput::Ext5V) { int value = read_led_value(kExt5vLedPaths); if (value != -ENOENT) return value; @@ -270,7 +302,7 @@ class SettingsSystem int set_gpio_value(const char *chip_path, unsigned int line, bool active_low, const char *consumer, int val) { - int bit = (val ? 1 : 0) ^ (active_low ? 1 : 0); + int bit = cp0::settings::physical_line_value(val, active_low); int fd = gpio_v2_request_output(chip_path, line, consumer, bit); if (fd < 0) return fd; @@ -281,13 +313,13 @@ class SettingsSystem int get_gpio_value(const char *chip_path, unsigned int line, bool active_low, const char *consumer) { int value = gpio_v2_read_input(chip_path, line, consumer); - return value < 0 ? value : (value ^ (active_low ? 1 : 0)); + return value < 0 ? value : cp0::settings::logical_line_value(value, active_low); } template static int write_led_value(const char *const (&paths)[N], int val) { - const char value = val ? '1' : '0'; + const char value = cp0::settings::switch_value(val) ? '1' : '0'; for (const char *path : paths) { int fd = open(path, O_WRONLY | O_CLOEXEC); if (fd < 0) { @@ -321,39 +353,15 @@ class SettingsSystem return -err; if (size == 0) return -EIO; - return std::strtol(buf, nullptr, 10) != 0 ? 1 : 0; + int value = 0; + return cp0::settings::parse_integer_file_line( + std::string_view(buf, static_cast(size)), 0, 1, value) + ? value + : -EINVAL; } return -ENOENT; } - static int config_get_int(const char *key, int default_val) - { - int val = default_val; - cp0_signal_config_api({"GetInt", key ? std::string(key) : std::string(), std::to_string(default_val)}, - [&](int code, std::string data) { - if (code == 0) - val = std::atoi(data.c_str()); - }); - return val; - } - - static bool is_grove5v_name(const char *name) - { - return name && (std::strcmp(name, "GROVE5V") == 0 || std::strcmp(name, "extport_usb") == 0); - } - - static bool is_ext5v_name(const char *name) - { - return name && (std::strcmp(name, "EXT5V") == 0 || std::strcmp(name, "extport_5vout") == 0); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - std::advance(it, std::min(index, arg.size())); - return it == arg.end() ? std::string() : *it; - } - static void copy_string(char *dst, size_t dst_size, const std::string &src) { if (!dst || dst_size == 0) @@ -375,26 +383,34 @@ class SettingsSystem std::snprintf(buf, static_cast(buf_size), "%02d:%02d", t->tm_hour, t->tm_min); } - static int read_int_file(const char *path, int default_value) + static int read_int_file(const char *path, + int default_value, + int minimum, + int maximum) { FILE *f = std::fopen(path, "r"); if (!f) return default_value; - int val = default_value; - if (std::fscanf(f, "%d", &val) != 1) - val = default_value; + char buffer[64] = {}; + const bool read_succeeded = std::fgets(buffer, sizeof(buffer), f) != nullptr; std::fclose(f); - return val; + int value = default_value; + return read_succeeded && cp0::settings::parse_integer_file_line( + buffer, minimum, maximum, value) + ? value + : default_value; } int backlight_read() { - return read_int_file("/sys/class/backlight/backlight/brightness", -1); + return read_int_file("/sys/class/backlight/backlight/brightness", -1, 0, + std::numeric_limits::max()); } int backlight_max() { - return read_int_file("/sys/class/backlight/backlight/max_brightness", 100); + return read_int_file("/sys/class/backlight/backlight/max_brightness", 100, 1, + std::numeric_limits::max()); } int backlight_write(int val) @@ -407,9 +423,9 @@ class SettingsSystem FILE *f = std::fopen("/sys/class/backlight/backlight/brightness", "w"); if (!f) return -1; - std::fprintf(f, "%d", val); - std::fclose(f); - return val; + const int written = std::fprintf(f, "%d", val); + const int close_result = std::fclose(f); + return written > 0 && close_result == 0 ? val : -1; } void settings_log(const char *topic, const char *message) @@ -427,24 +443,32 @@ extern "C" void init_settings(void) cp0_zmq_log_init(); cp0_zmq_log("settings", "init_settings"); auto settings = std::make_shared(); - cp0_signal_settings_api.append([settings](std::list arg, std::function callback) { - settings->api_call(std::move(arg), std::move(callback)); - }); + static cp0::SignalRegistration registration; + registration.replace(cp0_signal_settings_api, + [settings](std::list arg, + std::function callback) { + settings->api_call(std::move(arg), std::move(callback)); + }); } extern "C" int cp0_backlight_read(void) { - return SettingsSystem::api_int({"BacklightRead"}); + return SettingsSystem::api_int( + {"BacklightRead"}, -1, 0, std::numeric_limits::max()); } extern "C" int cp0_backlight_max(void) { - return SettingsSystem::api_int({"BacklightMax"}, 100); + return SettingsSystem::api_int( + {"BacklightMax"}, 100, 1, std::numeric_limits::max()); } extern "C" int cp0_backlight_write(int val) { - return SettingsSystem::api_int({"BacklightWrite", std::to_string(val)}); + if (val < 0) return -1; + return SettingsSystem::api_int( + {"BacklightWrite", std::to_string(val)}, -1, 0, + std::numeric_limits::max()); } extern "C" void cp0_time_str(char *buf, int buf_size) diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_soundcard.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_soundcard.cpp index b25282ca..e00954cf 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_soundcard.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_soundcard.cpp @@ -1,13 +1,13 @@ #include "hal_lvgl_bsp.h" #include "cp0_lvgl_app.h" +#include "../cp0_callback_contract.hpp" +#include "../cp0_soundcard_contract.hpp" +#include "../cp0_signal_registration.hpp" #include -#include -#include #include #include #include -#include #include #include @@ -19,42 +19,32 @@ class SoundcardSystem using callback_t = std::function; using arg_t = std::list; - void api_call(arg_t arg, callback_t callback) + SoundcardSystem() { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "ListCards") { - report(callback, 0, list_cards()); - } else if (cmd == "ListControls") { - int card_idx = std::atoi(nth_arg(arg, 1).c_str()); - report(callback, 0, list_controls(card_idx)); - } else if (cmd == "GetControlDetail") { - int card_idx = std::atoi(nth_arg(arg, 1).c_str()); - const std::string ctrl_name = nth_arg(arg, 2); - report(callback, 0, get_control_detail(card_idx, ctrl_name)); - } else if (cmd == "SetControl") { - int card_idx = std::atoi(nth_arg(arg, 1).c_str()); - const std::string ctrl_name = nth_arg(arg, 2); - const std::string val_str = nth_arg(arg, 3); - int ret = set_control(card_idx, ctrl_name, val_str); - report(callback, ret, std::to_string(ret)); - } else { - report(callback, -1, "unknown soundcard api command"); - } + operations_.read_cards = [] { return capture_argv({"cat", "/proc/asound/cards"}); }; + operations_.read_controls = [](int card_index) { + const std::string card = std::to_string(card_index); + return capture_argv({"amixer", "-c", card.c_str()}); + }; + operations_.read_control_detail = [](int card_index, const std::string &control) { + const std::string card = std::to_string(card_index); + return capture_argv({"amixer", "-c", card.c_str(), "sget", control.c_str()}); + }; + operations_.set_control = [](int card_index, const std::string &control, + const std::string &value) { + const std::string card = std::to_string(card_index); + return run_argv({"amixer", "-c", card.c_str(), "sset", control.c_str(), value.c_str()}); + }; } -private: - void report(callback_t callback, int code, const std::string &data) + void api_call(arg_t arg, callback_t callback) { - if (callback) - callback(code, data); + const cp0::soundcard::Result result = cp0::soundcard::dispatch(arg, operations_); + cp0::callback::invoke(callback, result.code, result.payload); } - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - std::advance(it, std::min(index, arg.size())); - return it == arg.end() ? std::string() : *it; - } +private: + cp0::soundcard::Operations operations_; static std::string capture_argv(std::initializer_list args) { @@ -85,178 +75,15 @@ class SoundcardSystem return rc; } - static std::string trim(const std::string &s) - { - size_t a = s.find_first_not_of(" \t\r\n"); - if (a == std::string::npos) return {}; - size_t b = s.find_last_not_of(" \t\r\n"); - return s.substr(a, b - a + 1); - } - - static bool parse_limits(const std::string &line, int &mn, int &mx) - { - size_t p = line.find("Limits:"); - if (p == std::string::npos) return false; - std::string rest = line.substr(p + 7); - for (const char *pfx : {"Playback ", "Capture "}) { - if (rest.find(pfx) == 0) { rest = rest.substr(std::strlen(pfx)); break; } - } - int a = 0, b = 0; - if (std::sscanf(rest.c_str(), " %d - %d", &a, &b) == 2) { - mn = a; mx = b; return true; - } - return false; - } - - static int parse_current_val(const std::string &line) - { - size_t p = line.find(": "); - if (p == std::string::npos) return -1; - int v = 0; - if (std::sscanf(line.c_str() + p + 2, " %d", &v) == 1) return v; - return -1; - } - - static std::string extract_value_str(const std::string &line) - { - static const char *prefixes[] = { - "Mono:", "Front Left:", "Front Right:", "Rear Left:", "Rear Right:", - "Center:", "LFE:", "Side Left:", "Side Right:", "Capture:", "Playback:", - "Item0:", nullptr - }; - for (int i = 0; prefixes[i]; ++i) { - size_t p = line.find(prefixes[i]); - if (p != std::string::npos) return trim(line.substr(p)); - } - return trim(line); - } - - static bool is_value_line(const std::string &tl) - { - static const char *prefixes[] = { - "Mono:", "Front Left:", "Front Right:", "Rear Left:", "Rear Right:", - "Center:", "LFE:", "Side Left:", "Side Right:", "Capture:", "Playback:", - "Item0:", nullptr - }; - for (int i = 0; prefixes[i]; ++i) - if (tl.rfind(prefixes[i], 0) == 0) return true; - return false; - } - - static std::string list_cards() - { - std::string raw = capture_argv({"cat", "/proc/asound/cards"}); - std::ostringstream oss; - std::istringstream ss(raw); - std::string line; - while (std::getline(ss, line)) { - int idx = -1; - if (std::sscanf(line.c_str(), " %d [", &idx) != 1 || idx < 0) continue; - - const char *rb = std::strchr(line.c_str(), ']'); - std::string display_name; - if (rb) { - const char *colon = std::strchr(rb, ':'); - if (colon) { - const char *dash = std::strchr(colon, '-'); - if (dash) display_name = trim(std::string(dash + 1)); - } - } - if (display_name.empty()) { - const char *lb = std::strchr(line.c_str(), '['); - if (lb && rb && rb > lb + 1) - display_name = trim(std::string(lb + 1, rb)); - } - - char buf[16]; - std::snprintf(buf, sizeof(buf), "Card %d", idx); - std::string card_name = std::string(buf) + (display_name.empty() ? "" : ": " + display_name); - oss << idx << '\t' << card_name << '\n'; - } - return oss.str(); - } - - static std::string list_controls(int card_index) - { - char card_str[16]; - std::snprintf(card_str, sizeof(card_str), "%d", card_index); - std::string raw = capture_argv({"amixer", "-c", card_str}); - - std::ostringstream oss; - if (raw.empty()) return oss.str(); - - std::istringstream ss(raw); - std::string line; - - struct CtrlAccum { - std::string name; - std::string type; - int min_val = 0; - int max_val = 0; - int step = 1; - std::string current_str; - int current_val = 0; - bool active = false; - } cur; - - auto flush = [&]() { - if (cur.active && !cur.name.empty()) { - oss << cur.name << '\t' << cur.type << '\t' - << cur.min_val << '\t' << cur.max_val << '\t' - << cur.step << '\t' << cur.current_str << '\t' - << cur.current_val << '\n'; - } - cur = CtrlAccum{}; - }; - - while (std::getline(ss, line)) { - std::string tl = trim(line); - if (tl.rfind("Simple mixer control", 0) == 0) { - flush(); - size_t q1 = tl.find('\''); - size_t q2 = (q1 != std::string::npos) ? tl.find('\'', q1 + 1) : std::string::npos; - if (q1 != std::string::npos && q2 != std::string::npos) - cur.name = tl.substr(q1 + 1, q2 - q1 - 1); - cur.active = true; - continue; - } - if (!cur.active) continue; - - if (tl.rfind("Capabilities:", 0) == 0) { - cur.type = (tl.find("enum") != std::string::npos) ? "ENUMERATED" : "INTEGER"; - } else if (tl.rfind("Limits:", 0) == 0) { - parse_limits(tl, cur.min_val, cur.max_val); - } else if (cur.current_str.empty() && is_value_line(tl)) { - cur.current_str = extract_value_str(tl); - int v = parse_current_val(tl); - if (v >= 0) cur.current_val = v; - } - } - flush(); - return oss.str(); - } - - static std::string get_control_detail(int card_index, const std::string &ctrl_name) - { - char card_str[16]; - std::snprintf(card_str, sizeof(card_str), "%d", card_index); - return capture_argv({"amixer", "-c", card_str, "sget", ctrl_name.c_str()}); - } - - static int set_control(int card_index, const std::string &ctrl_name, const std::string &val_str) - { - char card_str[16]; - std::snprintf(card_str, sizeof(card_str), "%d", card_index); - return run_argv({"amixer", "-c", card_str, "sset", ctrl_name.c_str(), val_str.c_str()}); - } }; } // namespace extern "C" void init_soundcard(void) { + static cp0::SignalRegistration registration; auto soundcard = std::make_shared(); - cp0_signal_soundcard_api.append([soundcard](std::list arg, std::function callback) { + registration.replace(cp0_signal_soundcard_api, [soundcard](std::list arg, std::function callback) { soundcard->api_call(std::move(arg), std::move(callback)); }); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_network_policy.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_network_policy.cpp new file mode 100644 index 00000000..5ceb1d49 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_network_policy.cpp @@ -0,0 +1,180 @@ +#include "cp0_network_policy.hpp" + +#include +#include +#include +#include +#include + +namespace cp0::network { +namespace { + +void trim_carriage_return(std::string &line) +{ + if (!line.empty() && line.back() == '\r') + line.pop_back(); +} + +void upsert_access_point(std::vector &access_points, AccessPoint candidate) +{ + auto existing = std::find_if(access_points.begin(), access_points.end(), [&](const AccessPoint &access_point) { + return access_point.ssid == candidate.ssid; + }); + if (existing == access_points.end()) { + access_points.push_back(std::move(candidate)); + return; + } + + const bool in_use = existing->in_use || candidate.in_use; + if (candidate.signal > existing->signal) + *existing = std::move(candidate); + existing->in_use = in_use; +} + +bool parse_signal(std::string_view text, int &signal) +{ + if (text.empty()) + return false; + int parsed = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), parsed); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || parsed < 0 || parsed > 100) + return false; + signal = parsed; + return true; +} + +} // namespace + +std::vector split_terse_fields(const std::string &line) +{ + std::vector fields; + std::string current; + for (size_t index = 0; index < line.size(); ++index) { + const char character = line[index]; + if (character == '\\' && index + 1 < line.size()) { + current.push_back(line[++index]); + } else if (character == ':') { + fields.push_back(current); + current.clear(); + } else { + current.push_back(character); + } + } + fields.push_back(current); + return fields; +} + +std::vector parse_scan_output(const std::string &output) +{ + std::vector access_points; + std::istringstream lines(output); + std::string line; + while (std::getline(lines, line)) { + trim_carriage_return(line); + if (line.empty()) + continue; + + const auto fields = split_terse_fields(line); + if (fields.size() != 4 || fields[0].empty()) + continue; + + AccessPoint access_point; + access_point.ssid = fields[0]; + if (!parse_signal(fields[1], access_point.signal)) + continue; + access_point.security = fields[2]; + access_point.in_use = fields[3].find('*') != std::string::npos; + upsert_access_point(access_points, std::move(access_point)); + } + return access_points; +} + +Status parse_device_status(const std::string &output) +{ + Status status; + bool wifi_found = false; + std::istringstream lines(output); + std::string line; + while (std::getline(lines, line)) { + trim_carriage_return(line); + const auto fields = split_terse_fields(line); + if (fields.size() != 4) + continue; + + const bool connected = fields[2].rfind("connected", 0) == 0; + const bool has_connection = !fields[3].empty() && fields[3] != "--"; + if (fields[1] == "ethernet" && connected) { + status.ethernet = true; + continue; + } + if (fields[1] != "wifi" || wifi_found) + continue; + + wifi_found = true; + if (!connected && !has_connection) + continue; + status.connected = true; + status.wifi_interface = fields[0]; + if (has_connection) { + status.ssid = fields[3]; + const std::string prefix = "netplan-" + fields[0] + "-"; + if (status.ssid.rfind(prefix, 0) == 0) + status.ssid.erase(0, prefix.size()); + } + } + return status; +} + +void apply_active_wifi(const std::string &output, Status &status) +{ + std::istringstream lines(output); + std::string line; + while (std::getline(lines, line)) { + trim_carriage_return(line); + const auto fields = split_terse_fields(line); + if (fields.size() != 3 || fields[0] != "*") + continue; + if (!parse_signal(fields[1], status.signal)) + continue; + if (status.ssid.empty() && fields.size() >= 3) + status.ssid = fields[2]; + return; + } +} + +std::string parse_ipv4_address(const std::string &output) +{ + const size_t address_start = output.find("inet "); + if (address_start == std::string::npos) + return {}; + + const size_t value_start = address_start + 5; + size_t value_end = output.find('/', value_start); + if (value_end == std::string::npos) + value_end = output.find_first_of(" \t\r\n", value_start); + return output.substr(value_start, value_end - value_start); +} + +bool parse_radio_enabled(const std::string &output) +{ + const size_t start = output.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) + return false; + const size_t end = output.find_last_not_of(" \t\r\n"); + return output.substr(start, end - start + 1) == "enabled"; +} + +std::string first_active_connection(const std::string &output) +{ + std::istringstream lines(output); + std::string line; + while (std::getline(lines, line)) { + trim_carriage_return(line); + const auto fields = split_terse_fields(line); + if (!fields.empty() && !fields[0].empty() && fields[0] != "lo") + return fields[0]; + } + return {}; +} + +} // namespace cp0::network diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_network_policy.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_network_policy.hpp new file mode 100644 index 00000000..3ba10c75 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_network_policy.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +namespace cp0::network { + +struct AccessPoint +{ + std::string ssid; + int signal = 0; + std::string security; + bool in_use = false; +}; + +struct Status +{ + bool connected = false; + bool ethernet = false; + std::string wifi_interface; + std::string ssid; + std::string ipv4; + int signal = 0; +}; + +std::vector split_terse_fields(const std::string &line); +std::vector parse_scan_output(const std::string &output); +Status parse_device_status(const std::string &output); +void apply_active_wifi(const std::string &output, Status &status); +std::string parse_ipv4_address(const std::string &output); +bool parse_radio_enabled(const std::string &output); +std::string first_active_connection(const std::string &output); + +} // namespace cp0::network diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_process_commands.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_process_commands.cpp new file mode 100644 index 00000000..47175b72 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_process_commands.cpp @@ -0,0 +1,253 @@ +#include "cp0_process_commands.hpp" + +#include "cp0_lvgl_app.h" +#include "hal_lvgl_bsp.h" +#include "../cp0_sync_signal.hpp" + +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#include +#endif + +namespace cp0_process_commands { +namespace { + +std::vector make_argv(const std::vector &argv) +{ + std::vector raw; + raw.reserve(argv.size() + 1); + for (const auto &arg : argv) + raw.push_back(const_cast(arg.c_str())); + raw.push_back(nullptr); + return raw; +} + +#if !defined(_WIN32) +int wait_for_child(pid_t pid, bool report_signal = true) +{ + int status = 0; + while (waitpid(pid, &status, 0) < 0) { + if (errno != EINTR) + return -errno; + } + if (WIFEXITED(status)) + return WEXITSTATUS(status); + if (report_signal && WIFSIGNALED(status)) + return 128 + WTERMSIG(status); + return -1; +} + +bool write_all(int fd, const char *data, size_t size) +{ + size_t offset = 0; + while (offset < size) { + const ssize_t written = ::write(fd, data + offset, size - offset); + if (written > 0) { + offset += static_cast(written); + continue; + } + if (written < 0 && errno == EINTR) + continue; + return false; + } + return true; +} + +void redirect_to_devnull() +{ + int fd = open("/dev/null", O_RDWR); + if (fd < 0) + return; + dup2(fd, STDIN_FILENO); + dup2(fd, STDOUT_FILENO); + dup2(fd, STDERR_FILENO); + if (fd > STDERR_FILENO) + close(fd); +} + +bool is_nologin_shell(const char *shell) +{ + if (!shell || !shell[0]) + return true; + return std::strstr(shell, "nologin") != nullptr || + std::strstr(shell, "/bin/false") != nullptr; +} + +std::string configured_run_user() +{ + std::string user; + cp0::signal::invoke_noexcept([&] { cp0_signal_config_api({"GetStr", "run_as_user", ""}, + [&](int code, std::string data) { + if (code == 0) + user = std::move(data); + }); }); + if (!user.empty()) + return user; + + struct passwd *pwd; + setpwent(); + while ((pwd = getpwent()) != nullptr) { + if (pwd->pw_uid >= 1000 && pwd->pw_uid < 65534 && + !is_nologin_shell(pwd->pw_shell)) { + user = pwd->pw_name; + break; + } + } + endpwent(); + return user.empty() ? "pi" : user; +} +#endif + +} // namespace + +int run_argv(const std::vector &argv, bool background) +{ +#if defined(_WIN32) + (void)argv; + (void)background; + return -1; +#else + if (argv.empty() || argv[0].empty()) + return -EINVAL; + + pid_t pid = fork(); + if (pid < 0) + return -errno; + if (pid == 0) { + if (background) + redirect_to_devnull(); + auto raw = make_argv(argv); + execvp(raw[0], raw.data()); + _exit(127); + } + return background ? 0 : wait_for_child(pid); +#endif +} + +int run_sudo(const std::string &password, const std::vector &argv) +{ +#if defined(_WIN32) + (void)password; + (void)argv; + return -1; +#else + if (argv.empty() || argv[0].empty()) + return -EINVAL; + + std::vector sudo_argv = {"sudo", "-k", "-S", "--"}; + sudo_argv.insert(sudo_argv.end(), argv.begin(), argv.end()); + + int stdin_pipe[2]; + if (pipe(stdin_pipe) != 0) + return -errno; + pid_t pid = fork(); + if (pid < 0) { + close(stdin_pipe[0]); + close(stdin_pipe[1]); + return -errno; + } + if (pid == 0) { + close(stdin_pipe[1]); + dup2(stdin_pipe[0], STDIN_FILENO); + if (stdin_pipe[0] > STDIN_FILENO) + close(stdin_pipe[0]); + int devnull = open("/dev/null", O_WRONLY); + if (devnull >= 0) { + dup2(devnull, STDOUT_FILENO); + dup2(devnull, STDERR_FILENO); + if (devnull > STDERR_FILENO) + close(devnull); + } + auto raw = make_argv(sudo_argv); + execvp(raw[0], raw.data()); + _exit(127); + } + + close(stdin_pipe[0]); + const std::string password_line = password + "\n"; + const bool password_written = + write_all(stdin_pipe[1], password_line.data(), password_line.size()); + const int write_error = password_written ? 0 : errno; + close(stdin_pipe[1]); + const int child_result = wait_for_child(pid); + return password_written ? child_result : -(write_error ? write_error : EIO); +#endif +} + +int capture_argv(const std::vector &argv, std::string &output) +{ + output.clear(); +#if defined(_WIN32) + (void)argv; + return -1; +#else + if (argv.empty() || argv[0].empty()) + return -EINVAL; + + int output_pipe[2]; + if (pipe(output_pipe) != 0) + return -errno; + pid_t pid = fork(); + if (pid < 0) { + close(output_pipe[0]); + close(output_pipe[1]); + return -errno; + } + if (pid == 0) { + close(output_pipe[0]); + dup2(output_pipe[1], STDOUT_FILENO); + int devnull = open("/dev/null", O_WRONLY); + if (devnull >= 0) { + dup2(devnull, STDERR_FILENO); + if (devnull > STDERR_FILENO) + close(devnull); + } + if (output_pipe[1] > STDERR_FILENO) + close(output_pipe[1]); + auto raw = make_argv(argv); + execvp(raw[0], raw.data()); + _exit(127); + } + + close(output_pipe[1]); + char buffer[256]; + ssize_t count; + while ((count = read(output_pipe[0], buffer, sizeof(buffer))) > 0) + output.append(buffer, static_cast(count)); + close(output_pipe[0]); + return wait_for_child(pid, false); +#endif +} + +void exec_shell_as_configured_user(const char *command) +{ +#if defined(_WIN32) + (void)command; +#else + if (!command || !command[0]) + _exit(127); + const std::string user = configured_run_user(); + if (getuid() == 0 && user != "root") { + struct passwd *pwd = getpwnam(user.c_str()); + if (!pwd || initgroups(pwd->pw_name, pwd->pw_gid) != 0 || + setgid(pwd->pw_gid) != 0 || setuid(pwd->pw_uid) != 0 || + setenv("HOME", pwd->pw_dir, 1) != 0 || + setenv("USER", pwd->pw_name, 1) != 0 || + setenv("LOGNAME", pwd->pw_name, 1) != 0 || + setenv("SHELL", pwd->pw_shell[0] ? pwd->pw_shell : "/bin/bash", 1) != 0 || + chdir(pwd->pw_dir) != 0) + _exit(126); + } + execlp("/bin/sh", "sh", "-c", command, static_cast(nullptr)); +#endif +} + +} // namespace cp0_process_commands diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_process_commands.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_process_commands.hpp new file mode 100644 index 00000000..46d8838f --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_process_commands.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace cp0_process_commands { + +int run_argv(const std::vector &argv, bool background); +int run_sudo(const std::string &password, const std::vector &argv); +int capture_argv(const std::vector &argv, std::string &output); + +// Replaces the child process image and only returns on unsupported platforms. +void exec_shell_as_configured_user(const char *command); + +} // namespace cp0_process_commands diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_process_lifecycle.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_process_lifecycle.cpp new file mode 100644 index 00000000..74e45991 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_process_lifecycle.cpp @@ -0,0 +1,109 @@ +#include "cp0_process_lifecycle.hpp" + +#include "../cp0_external_process_group.hpp" +#include "cp0_process_commands.hpp" + +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#endif + +namespace cp0_process_lifecycle { + +cp0_pid_t spawn(const char *command, bool keep_root) +{ +#if defined(_WIN32) + (void)command; + (void)keep_root; + return -1; +#else + cp0_process_group::enable_subreaper(); + const pid_t pid = fork(); + if (pid < 0) return -1; + if (pid == 0) { + setpgid(0, 0); + if (keep_root) + execlp("/bin/sh", "sh", "-c", command, static_cast(nullptr)); + else + cp0_process_commands::exec_shell_as_configured_user(command); + _exit(127); + } + setpgid(pid, pid); + return static_cast(pid); +#endif +} + +void stop(cp0_pid_t pid) +{ +#if defined(_WIN32) + (void)pid; +#else + if (pid <= 0) return; + if (!cp0_process_group::terminate_and_reap(static_cast(pid), + static_cast(pid))) { + std::fprintf(stderr, + "[process] failed to stop and reap pgid=%d\n", + static_cast(pid)); + } +#endif +} + +int check_lock(const char *path, int *holder_pid) +{ + if (holder_pid) *holder_pid = 0; +#if defined(_WIN32) + (void)path; + return 0; +#else + if (!path || !holder_pid) return -1; + const int fd = open(path, O_CREAT | O_RDWR, 0666); + if (fd < 0) return -1; + + struct flock lock; + std::memset(&lock, 0, sizeof(lock)); + lock.l_type = F_WRLCK; + lock.l_whence = SEEK_SET; + if (fcntl(fd, F_GETLK, &lock) == -1) { + close(fd); + return -1; + } + close(fd); + if (lock.l_type == F_UNLCK) return 0; + *holder_pid = lock.l_pid; + return lock.l_pid; +#endif +} + +void kill(int pid, int grace_ms) +{ +#if defined(_WIN32) + (void)pid; + (void)grace_ms; +#else + if (pid <= 0) return; + killpg(pid, SIGINT); + const auto start = std::chrono::steady_clock::now(); + while (true) { + int status = 0; + if (waitpid(pid, &status, WNOHANG) != 0) return; + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + if (elapsed >= grace_ms) { + killpg(pid, SIGKILL); + waitpid(pid, &status, 0); + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +#endif +} + +} // namespace cp0_process_lifecycle diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_process_lifecycle.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_process_lifecycle.hpp new file mode 100644 index 00000000..26083160 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_process_lifecycle.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +namespace cp0_process_lifecycle { + +cp0_pid_t spawn(const char *command, bool keep_root); +void stop(cp0_pid_t pid); +int check_lock(const char *path, int *holder_pid); +void kill(int pid, int grace_ms); + +} // namespace cp0_process_lifecycle diff --git a/ext_components/cp0_lvgl/src/cp0_alsa_parser.cpp b/ext_components/cp0_lvgl/src/cp0_alsa_parser.cpp new file mode 100644 index 00000000..e012e31f --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_alsa_parser.cpp @@ -0,0 +1,164 @@ +#include "cp0_alsa_parser.hpp" + +#include +#include +#include +#include + +namespace cp0_alsa_parser { +namespace { + +std::string trim(const std::string &text) +{ + const size_t first = text.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const size_t last = text.find_last_not_of(" \t\r\n"); + return text.substr(first, last - first + 1); +} + +bool parse_limits(const std::string &line, int &minimum, int &maximum) +{ + const size_t marker = line.find("Limits:"); + if (marker == std::string::npos) return false; + std::string values = trim(line.substr(marker + 7)); + for (const char *prefix : {"Playback ", "Capture "}) { + if (values.rfind(prefix, 0) == 0) { + values = values.substr(std::strlen(prefix)); + break; + } + } + const char *cursor = values.data(); + const char *end = cursor + values.size(); + auto first = std::from_chars(cursor, end, minimum); + if (first.ec != std::errc{}) return false; + cursor = first.ptr; + while (cursor < end && (*cursor == ' ' || *cursor == '\t')) ++cursor; + if (cursor == end || *cursor++ != '-') return false; + while (cursor < end && (*cursor == ' ' || *cursor == '\t')) ++cursor; + auto second = std::from_chars(cursor, end, maximum); + if (second.ec != std::errc{}) return false; + cursor = second.ptr; + while (cursor < end && (*cursor == ' ' || *cursor == '\t')) ++cursor; + return cursor == end; +} + +bool is_value_line(const std::string &line) +{ + static constexpr const char *prefixes[] = { + "Mono:", "Front Left:", "Front Right:", "Rear Left:", "Rear Right:", + "Center:", "LFE:", "Side Left:", "Side Right:", "Capture:", "Playback:", + "Item0:", + }; + for (const char *prefix : prefixes) { + if (line.rfind(prefix, 0) == 0) return true; + } + return false; +} + +bool parse_current_value(const std::string &line, int &value) +{ + const size_t separator = line.find(": "); + if (separator == std::string::npos) return false; + const char *cursor = line.c_str() + separator + 2; + while (*cursor && *cursor != '-' && (*cursor < '0' || *cursor > '9')) ++cursor; + if (!*cursor) return false; + const char *end = line.data() + line.size(); + const auto parsed = std::from_chars(cursor, end, value); + return parsed.ec == std::errc{}; +} + +struct Control { + std::string name; + std::string type; + int minimum = 0; + int maximum = 0; + int step = 1; + std::string current_text; + int current_value = 0; + bool active = false; +}; + +} // namespace + +std::string encode_cards(const std::string &raw_cards) +{ + std::ostringstream output; + std::istringstream lines(raw_cards); + std::string line; + while (std::getline(lines, line)) { + const char *cursor = line.data(); + const char *end = cursor + line.size(); + while (cursor < end && (*cursor == ' ' || *cursor == '\t')) ++cursor; + int index = -1; + const auto parsed = std::from_chars(cursor, end, index); + if (parsed.ec != std::errc{} || index < 0) continue; + cursor = parsed.ptr; + while (cursor < end && (*cursor == ' ' || *cursor == '\t')) ++cursor; + if (cursor == end || *cursor != '[') continue; + + const size_t right_bracket = line.find(']'); + std::string display_name; + if (right_bracket != std::string::npos) { + const size_t colon = line.find(':', right_bracket); + const size_t separator = colon == std::string::npos + ? std::string::npos + : line.find(" - ", colon); + if (separator != std::string::npos) display_name = trim(line.substr(separator + 3)); + } + if (display_name.empty()) { + const size_t left_bracket = line.find('['); + if (left_bracket != std::string::npos && right_bracket != std::string::npos && + right_bracket > left_bracket + 1) { + display_name = trim(line.substr(left_bracket + 1, + right_bracket - left_bracket - 1)); + } + } + output << index << "\tCard " << index; + if (!display_name.empty()) output << ": " << display_name; + output << '\n'; + } + return output.str(); +} + +std::string encode_controls(const std::string &raw_controls) +{ + std::ostringstream output; + std::istringstream lines(raw_controls); + Control current; + auto flush = [&]() { + if (current.active && !current.name.empty()) { + output << current.name << '\t' << current.type << '\t' << current.minimum << '\t' + << current.maximum << '\t' << current.step << '\t' << current.current_text + << '\t' << current.current_value << '\n'; + } + current = Control{}; + }; + + std::string line; + while (std::getline(lines, line)) { + const std::string text = trim(line); + if (text.rfind("Simple mixer control", 0) == 0) { + flush(); + const size_t first_quote = text.find('\''); + const size_t second_quote = first_quote == std::string::npos + ? std::string::npos + : text.find('\'', first_quote + 1); + if (second_quote != std::string::npos) { + current.name = text.substr(first_quote + 1, second_quote - first_quote - 1); + } + current.active = true; + } else if (current.active && text.rfind("Capabilities:", 0) == 0) { + current.type = text.find("enum") != std::string::npos ? "ENUMERATED" : "INTEGER"; + } else if (current.active && text.rfind("Limits:", 0) == 0) { + parse_limits(text, current.minimum, current.maximum); + } else if (current.active && current.current_text.empty() && is_value_line(text)) { + current.current_text = text; + int value = 0; + if (parse_current_value(text, value)) current.current_value = value; + } + } + flush(); + return output.str(); +} + +} // namespace cp0_alsa_parser diff --git a/ext_components/cp0_lvgl/src/cp0_alsa_parser.hpp b/ext_components/cp0_lvgl/src/cp0_alsa_parser.hpp new file mode 100644 index 00000000..bc30f948 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_alsa_parser.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace cp0_alsa_parser { + +std::string encode_cards(const std::string &raw_cards); +std::string encode_controls(const std::string &raw_controls); + +} // namespace cp0_alsa_parser diff --git a/ext_components/cp0_lvgl/src/cp0_audio_api_contract.cpp b/ext_components/cp0_lvgl/src/cp0_audio_api_contract.cpp new file mode 100644 index 00000000..4983afb0 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_audio_api_contract.cpp @@ -0,0 +1,145 @@ +#include "cp0_audio_api_contract.hpp" + +#include +#include +#include + +namespace cp0::audio { +namespace { + +bool parse_integer(std::string_view text, int minimum, int maximum, int &value) +{ + if (text.empty()) + return false; + int parsed = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), parsed); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || + parsed < minimum || parsed > maximum) + return false; + value = parsed; + return true; +} + +bool parse_boolean(std::string_view text, bool &enabled) +{ + if (text == "1" || text == "on" || text == "true" || text == "enable" || text == "enabled") { + enabled = true; + return true; + } + if (text == "0" || text == "off" || text == "false" || text == "disable" || text == "disabled") { + enabled = false; + return true; + } + return false; +} + +bool is_no_argument_command(const std::string &command, ApiCommand &parsed) +{ + if (command == "PlayPause") parsed = ApiCommand::PlayPause; + else if (command == "PlayContinue") parsed = ApiCommand::PlayContinue; + else if (command == "PlayEnd") parsed = ApiCommand::PlayEnd; + else if (command == "Cap") parsed = ApiCommand::Cap; + else if (command == "CapPause") parsed = ApiCommand::CapPause; + else if (command == "CapContinue") parsed = ApiCommand::CapContinue; + else if (command == "CapEnd") parsed = ApiCommand::CapEnd; + else if (command == "SetCallback") parsed = ApiCommand::SetCallback; + else if (command == "VolumeRead") parsed = ApiCommand::VolumeRead; + else if (command == "MuteRead") parsed = ApiCommand::MuteRead; + else if (command == "MuteToggle") parsed = ApiCommand::MuteToggle; + else return false; + return true; +} + +} // namespace + +bool parse_api_request(const std::list &args, ApiRequest &request) +{ + request = {}; + if (args.empty()) + return false; + auto argument = args.begin(); + const std::string &command = *argument++; + + if (is_no_argument_command(command, request.command)) + return args.size() == 1; + if (command == "PlayFile" || command == "Play" || command == "CapFileSave") { + request.command = command == "PlayFile" ? ApiCommand::PlayFile : + (command == "Play" ? ApiCommand::Play : ApiCommand::CapFileSave); + if (args.size() != 2 || argument->empty()) + return false; + request.path = *argument; + return true; + } + if (command == "VolumeWrite") { + request.command = ApiCommand::VolumeWrite; + return args.size() == 2 && parse_integer(*argument, 0, 100, request.value); + } + if (command == "SystemSoundPlay") { + request.command = ApiCommand::SystemSoundPlay; + return args.size() == 2 && parse_integer(*argument, 0, 2, request.value); + } + if (command == "SystemSoundEnable") { + request.command = ApiCommand::SystemSoundEnable; + if (args.size() == 1) { + request.enabled = true; + return true; + } + return args.size() == 2 && parse_boolean(*argument, request.enabled); + } + if (command == "SetSystemSoundNames") { + request.command = ApiCommand::SetSystemSoundNames; + if (args.size() > 4) + return false; + request.names.assign(argument, args.end()); + return true; + } + return false; +} + +bool parse_setup_request(const std::list &args, SetupRequest &request) +{ + request = {}; + if (args.empty()) + return false; + const std::string &command = args.front(); + if (command == "set_callback") { + request.command = SetupCommand::SetCallback; + return args.size() == 1; + } + if (command == "stop_play") { + request.command = SetupCommand::StopPlay; + return args.size() == 1; + } + if (command == "set_waveform" || command == "waveform") { + request.command = SetupCommand::SetWaveform; + if (args.size() == 1) { + request.waveform_enabled = true; + return true; + } + auto value = std::next(args.begin()); + return args.size() == 2 && parse_boolean(*value, request.waveform_enabled); + } + return false; +} + +const char *invalid_api_request_message() +{ + return "invalid audio api request\n"; +} + +const char *invalid_setup_request_message() +{ + return "invalid audio setup request\n"; +} + +void invoke_callback(const std::function &callback, + int code, const std::string &data) noexcept +{ + if (!callback) return; + try { + callback(code, data); + } catch (...) { + } +} + +} // namespace cp0::audio diff --git a/ext_components/cp0_lvgl/src/cp0_audio_api_contract.hpp b/ext_components/cp0_lvgl/src/cp0_audio_api_contract.hpp new file mode 100644 index 00000000..fb92a22c --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_audio_api_contract.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +namespace cp0::audio { + +enum class ApiCommand { + PlayFile, + Play, + PlayPause, + PlayContinue, + PlayEnd, + Cap, + CapPause, + CapContinue, + CapEnd, + CapFileSave, + SetCallback, + VolumeRead, + VolumeWrite, + MuteRead, + MuteToggle, + SetSystemSoundNames, + SystemSoundPlay, + SystemSoundEnable, +}; + +struct ApiRequest +{ + ApiCommand command = ApiCommand::Play; + std::string path; + int value = 0; + bool enabled = false; + std::vector names; +}; + +enum class SetupCommand { SetCallback, SetWaveform, StopPlay }; + +struct SetupRequest +{ + SetupCommand command = SetupCommand::SetCallback; + bool waveform_enabled = false; +}; + +bool parse_api_request(const std::list &args, ApiRequest &request); +bool parse_setup_request(const std::list &args, SetupRequest &request); +const char *invalid_api_request_message(); +const char *invalid_setup_request_message(); +void invoke_callback(const std::function &callback, + int code, const std::string &data) noexcept; + +} // namespace cp0::audio diff --git a/ext_components/cp0_lvgl/src/cp0_audio_runtime_lifecycle.hpp b/ext_components/cp0_lvgl/src/cp0_audio_runtime_lifecycle.hpp new file mode 100644 index 00000000..994cd8ce --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_audio_runtime_lifecycle.hpp @@ -0,0 +1,39 @@ +#pragma once + +namespace cp0::audio { + +class RuntimeLifecycle +{ +public: + bool begin_init() noexcept + { + if (state_ != State::INACTIVE) return false; + state_ = State::INITIALIZING; + return true; + } + + void commit_init() noexcept + { + if (state_ == State::INITIALIZING) state_ = State::ACTIVE; + } + + void rollback_init() noexcept + { + if (state_ == State::INITIALIZING) state_ = State::INACTIVE; + } + + bool begin_shutdown() noexcept + { + if (state_ == State::INACTIVE) return false; + state_ = State::INACTIVE; + return true; + } + + bool active() const noexcept { return state_ == State::ACTIVE; } + +private: + enum class State { INACTIVE, INITIALIZING, ACTIVE }; + State state_ = State::INACTIVE; +}; + +} // namespace cp0::audio diff --git a/ext_components/cp0_lvgl/src/cp0_battery_api_contract.cpp b/ext_components/cp0_lvgl/src/cp0_battery_api_contract.cpp new file mode 100644 index 00000000..1806442c --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_battery_api_contract.cpp @@ -0,0 +1,78 @@ +#include "cp0_battery_api_contract.hpp" + +#include "cp0_battery_codec.hpp" + +#include + +namespace cp0::battery { +namespace { + +constexpr int kCalibrationCommandCount = 4; + +std::string argument_at(const std::list &arguments, std::size_t index) +{ + if (index >= arguments.size()) return {}; + auto it = arguments.begin(); + std::advance(it, static_cast(index)); + return *it; +} + +bool parse_calibration_index(const std::string &text, int &index) +{ + if (text.size() != 1 || text.front() < '0' || + text.front() >= static_cast('0' + kCalibrationCommandCount)) + return false; + index = text.front() - '0'; + return true; +} + +ApiParseResult failure(const std::string &error) +{ + ApiParseResult result; + result.error = error; + return result; +} + +} // namespace + +ApiParseResult parse_api_request(const std::list &arguments) +{ + if (arguments.empty()) return failure("unknown bq27220 api command"); + + ApiParseResult result; + if (arguments.front() == "Read") { + if (arguments.size() != 1) return failure("invalid bq27220 Read arguments"); + result.request.command = ApiCommand::read; + } else if (arguments.front() == "Calibrate") { + if (arguments.size() != 2 || + !parse_calibration_index(argument_at(arguments, 1), result.request.calibration_index)) + return failure("invalid bq27220 calibration command"); + result.request.command = ApiCommand::calibrate; + } else { + return failure("unknown bq27220 api command"); + } + result.ok = true; + return result; +} + +ApiReply dispatch_api_request(const std::list &arguments, + const ReadOperation &read, + const CalibrateOperation &calibrate) +{ + try { + const ApiParseResult parsed = parse_api_request(arguments); + if (!parsed.ok) return {-1, parsed.error}; + + if (parsed.request.command == ApiCommand::read) { + if (!read) return {-1, "battery reader unavailable"}; + const cp0_battery_info_t info = read(); + return {info.valid == 1 ? 0 : -1, encode_info(info)}; + } + if (!calibrate) return {-1, "battery calibration unavailable"}; + return {calibrate(parsed.request.calibration_index), {}}; + } catch (...) { + return {-1, "battery backend failure"}; + } +} + +} // namespace cp0::battery diff --git a/ext_components/cp0_lvgl/src/cp0_battery_api_contract.hpp b/ext_components/cp0_lvgl/src/cp0_battery_api_contract.hpp new file mode 100644 index 00000000..67531905 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_battery_api_contract.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include +#include +#include + +namespace cp0::battery { + +enum class ApiCommand { + read, + calibrate, +}; + +struct ApiRequest { + ApiCommand command = ApiCommand::read; + int calibration_index = -1; +}; + +struct ApiParseResult { + bool ok = false; + ApiRequest request; + std::string error; +}; + +struct ApiReply { + int code = -1; + std::string data; +}; + +using ReadOperation = std::function; +using CalibrateOperation = std::function; + +ApiParseResult parse_api_request(const std::list &arguments); +ApiReply dispatch_api_request(const std::list &arguments, + const ReadOperation &read, + const CalibrateOperation &calibrate); + +} // namespace cp0::battery diff --git a/ext_components/cp0_lvgl/src/cp0_battery_codec.cpp b/ext_components/cp0_lvgl/src/cp0_battery_codec.cpp new file mode 100644 index 00000000..23137e2a --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_battery_codec.cpp @@ -0,0 +1,97 @@ +#include "cp0_battery_codec.hpp" + +#include "cp0_battery_testable.hpp" + +#include +#include +#include + +namespace cp0::battery { + +std::string encode_info(const cp0_battery_info_t &info) +{ + std::ostringstream output; + output << info.voltage_mv << ',' + << info.current_ma << ',' + << info.temperature_c10 << ',' + << info.soc << ',' + << info.remain_mah << ',' + << info.full_mah << ',' + << info.flags << ',' + << info.avg_current_ma << ',' + << info.valid; + return output.str(); +} + +bool decode_info(const std::string &data, cp0_battery_info_t *info) +{ + if (!info) return false; + + cp0_battery_info_t parsed{}; + char separator = '\0'; + std::istringstream input(data); + const auto comma = [&]() { + return static_cast(input >> separator) && separator == ','; + }; + if (input >> parsed.voltage_mv && comma() && + input >> parsed.current_ma && comma() && + input >> parsed.temperature_c10 && comma() && + input >> parsed.soc && comma() && + input >> parsed.remain_mah && comma() && + input >> parsed.full_mah && comma() && + input >> parsed.flags && comma() && + input >> parsed.avg_current_ma && comma() && + input >> parsed.valid && (input >> std::ws).eof()) { + if (!info_is_valid(parsed)) return false; + *info = parsed; + return true; + } + return false; +} + +bool info_is_valid(const cp0_battery_info_t &info) +{ + if (info.valid != 0 && info.valid != 1) return false; + if (info.voltage_mv < 0 || info.voltage_mv > 20000 || + info.current_ma < -5000 || info.current_ma > 5000 || + info.avg_current_ma < -5000 || info.avg_current_ma > 5000 || + info.temperature_c10 < -400 || info.temperature_c10 > 1000 || + info.soc < 0 || info.soc > 100 || info.remain_mah < 0 || info.full_mah < 0 || + info.flags < 0) + return false; + if (info.full_mah > 0 && info.remain_mah > info.full_mah) return false; + return true; +} + +cp0_battery_info_t from_power_supply(int present, + int capacity_percent, + long voltage_uv, + long current_ua, + int temperature_c10, + const char *status) +{ + cp0_battery_info_t info{}; + constexpr long kMaximumVoltageUv = 20000000; + constexpr long kMinimumCurrentUa = -5000499; + constexpr long kMaximumCurrentUa = 5000499; + if (voltage_uv < 0 || voltage_uv > kMaximumVoltageUv || + current_ua < kMinimumCurrentUa || current_ua > kMaximumCurrentUa) + return info; + const int current_ma = static_cast(std::lround(current_ua / 1000.0)); + if (!cp0_battery_testable::measurement_is_valid( + present, capacity_percent, current_ma, temperature_c10) || + !cp0_battery_testable::power_supply_status_is_known(status)) { + return info; + } + + info.voltage_mv = static_cast(voltage_uv / 1000); + info.current_ma = current_ma; + info.temperature_c10 = temperature_c10; + info.soc = capacity_percent; + info.flags = std::strcmp(status, "Charging") == 0 ? 1 : 0; + info.avg_current_ma = current_ma; + info.valid = 1; + return info; +} + +} // namespace cp0::battery diff --git a/ext_components/cp0_lvgl/src/cp0_battery_codec.hpp b/ext_components/cp0_lvgl/src/cp0_battery_codec.hpp new file mode 100644 index 00000000..dec5a161 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_battery_codec.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include + +namespace cp0::battery { + +std::string encode_info(const cp0_battery_info_t &info); +bool decode_info(const std::string &data, cp0_battery_info_t *info); +bool info_is_valid(const cp0_battery_info_t &info); + +cp0_battery_info_t from_power_supply(int present, + int capacity_percent, + long voltage_uv, + long current_ua, + int temperature_c10, + const char *status); + +} // namespace cp0::battery diff --git a/ext_components/cp0_lvgl/src/cp0_battery_lifecycle.cpp b/ext_components/cp0_lvgl/src/cp0_battery_lifecycle.cpp new file mode 100644 index 00000000..1a34d74e --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_battery_lifecycle.cpp @@ -0,0 +1,81 @@ +#include "cp0_battery_lifecycle.hpp" + +#include + +namespace cp0::battery { + +namespace { + +void release_safely(std::function &release) noexcept +{ + if (!release) return; + try { + release(); + } catch (...) { + } + release = {}; +} + +} // namespace + +Lifecycle::~Lifecycle() +{ + stop(); +} + +bool Lifecycle::start(const Factory &handler_factory, const Factory &timer_factory) +{ + std::lock_guard lock(mutex_); + if (active_) + return true; + if (!handler_factory || !timer_factory) + return false; + + LifecycleResource handler; + LifecycleResource timer; + try { + handler = handler_factory(); + if (!handler.valid) { + release_safely(handler.release); + return false; + } + timer = timer_factory(); + } catch (...) { + release_safely(handler.release); + return false; + } + if (!timer.valid) { + release_safely(timer.release); + release_safely(handler.release); + return false; + } + + release_handler_ = std::move(handler.release); + release_timer_ = std::move(timer.release); + active_ = true; + return true; +} + +void Lifecycle::stop() +{ + std::function release_timer; + std::function release_handler; + { + std::lock_guard lock(mutex_); + if (!active_) + return; + active_ = false; + release_timer = std::move(release_timer_); + release_handler = std::move(release_handler_); + } + release_safely(release_timer); + release_safely(release_handler); +} + +bool Lifecycle::active() const +{ + std::lock_guard lock(mutex_); + return active_; +} + +} // namespace cp0::battery diff --git a/ext_components/cp0_lvgl/src/cp0_battery_lifecycle.hpp b/ext_components/cp0_lvgl/src/cp0_battery_lifecycle.hpp new file mode 100644 index 00000000..5b19304b --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_battery_lifecycle.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +namespace cp0::battery { + +struct LifecycleResource { + bool valid = false; + std::function release; +}; + +template +bool release_timer_if_runtime_active(TimerHandle timer, bool runtime_active, + Delete &&delete_timer) noexcept +{ + if (!timer || !runtime_active) return false; + try { + std::forward(delete_timer)(timer); + } catch (...) { + } + return true; +} + +class Lifecycle { +public: + using Factory = std::function; + + ~Lifecycle(); + + bool start(const Factory &handler_factory, const Factory &timer_factory); + void stop(); + bool active() const; + +private: + mutable std::mutex mutex_; + bool active_ = false; + std::function release_handler_; + std::function release_timer_; +}; + +} // namespace cp0::battery diff --git a/ext_components/cp0_lvgl/src/cp0_battery_publish_gate.hpp b/ext_components/cp0_lvgl/src/cp0_battery_publish_gate.hpp new file mode 100644 index 00000000..d1fd46c1 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_battery_publish_gate.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace cp0::battery { + +class PublishGate +{ +public: + template + bool run(Operation &&operation) noexcept + { + bool expected = false; + if (!active_.compare_exchange_strong(expected, true)) return false; + try { + std::forward(operation)(); + } catch (...) { + } + active_.store(false); + return true; + } + +private: + std::atomic active_{false}; +}; + +} // namespace cp0::battery diff --git a/ext_components/cp0_lvgl/src/cp0_battery_runtime.hpp b/ext_components/cp0_lvgl/src/cp0_battery_runtime.hpp new file mode 100644 index 00000000..3a70008d --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_battery_runtime.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +extern "C" void deinit_battery(void); + +namespace cp0::battery { + +template +void shutdown_runtime(StopBattery &&stop_battery, + DeinitializeLvgl &&deinitialize_lvgl) noexcept +{ + try { + std::forward(stop_battery)(); + } catch (...) { + } + try { + std::forward(deinitialize_lvgl)(); + } catch (...) { + } +} + +} // namespace cp0::battery diff --git a/ext_components/cp0_lvgl/src/cp0_bluetooth_api_contract.cpp b/ext_components/cp0_lvgl/src/cp0_bluetooth_api_contract.cpp new file mode 100644 index 00000000..1754dd03 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_bluetooth_api_contract.cpp @@ -0,0 +1,109 @@ +#include "cp0_bluetooth_api_contract.hpp" + +#include +#include +#include +#include + +namespace cp0::bluetooth { +namespace { + +bool parse_integer(std::string_view text, int minimum, int maximum, int &value) +{ + if (text.empty()) return false; + int parsed = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), parsed); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || + parsed < minimum || parsed > maximum) + return false; + value = parsed; + return true; +} + +bool valid_address(const std::string &address) +{ + if (address.size() != 17) return false; + for (std::size_t index = 0; index < address.size(); ++index) { + if ((index + 1) % 3 == 0) { + if (address[index] != ':') return false; + } else if (!std::isxdigit(static_cast(address[index]))) { + return false; + } + } + return true; +} + +bool has_wire_control(const std::string &value) +{ + for (unsigned char character : value) + if (character < 0x20 || character == 0x7f) return true; + return false; +} + +} // namespace + +bool parse_request(const std::list &arguments, Request &request) +{ + request = {}; + if (arguments.empty()) return false; + const std::string &command = arguments.front(); + auto argument = std::next(arguments.begin()); + + if (command == "BtStatus" || command == "BtDiscoveryStart" || + command == "BtDiscoveryStop") { + if (arguments.size() != 1) return false; + request.command = command == "BtStatus" ? Command::Status : + (command == "BtDiscoveryStart" ? Command::DiscoveryStart : Command::DiscoveryStop); + return true; + } + if (command == "BtPower" || command == "BtDiscoverable") { + request.command = command == "BtPower" ? Command::Power : Command::Discoverable; + return arguments.size() == 2 && parse_integer(*argument, 0, 1, request.value); + } + if (command == "BtAlias") { + request.command = Command::Alias; + if (arguments.size() != 2 || argument->empty() || argument->size() >= 64 || + has_wire_control(*argument)) + return false; + request.text = *argument; + return true; + } + if (command == "BtScan" || command == "BtList" || command == "BtConnectedList") { + request.command = command == "BtScan" ? Command::Scan : + (command == "BtList" ? Command::List : Command::ConnectedList); + if (arguments.size() == 1) return true; + return arguments.size() == 2 && parse_integer(*argument, 1, 16, request.max_count); + } + if (command == "BtPair" || command == "BtConnect" || command == "BtDisconnect" || + command == "BtRemove") { + request.command = command == "BtPair" ? Command::Pair : + (command == "BtConnect" ? Command::Connect : + (command == "BtDisconnect" ? Command::Disconnect : Command::Remove)); + if (arguments.size() != 2 || !valid_address(*argument)) return false; + request.text = *argument; + return true; + } + return false; +} + +std::string sanitize_wire_field(std::string value) +{ + for (char &character : value) { + const unsigned char byte = static_cast(character); + if (byte < 0x20 || byte == 0x7f) + character = ' '; + } + return value; +} + +void invoke_callback(const std::function &callback, + int code, const std::string &data) noexcept +{ + if (!callback) return; + try { + callback(code, data); + } catch (...) { + } +} + +} // namespace cp0::bluetooth diff --git a/ext_components/cp0_lvgl/src/cp0_bluetooth_api_contract.hpp b/ext_components/cp0_lvgl/src/cp0_bluetooth_api_contract.hpp new file mode 100644 index 00000000..5358a6a0 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_bluetooth_api_contract.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +namespace cp0::bluetooth { + +enum class Command { + Status, Power, Alias, Discoverable, Scan, DiscoveryStart, DiscoveryStop, + List, ConnectedList, Pair, Connect, Disconnect, Remove, +}; + +struct Request { + Command command = Command::Status; + int value = 0; + int max_count = 16; + std::string text; +}; + +struct Reply { + int code = -1; + std::string data; +}; + +bool parse_request(const std::list &arguments, Request &request); +std::string sanitize_wire_field(std::string value); +void invoke_callback(const std::function &callback, + int code, const std::string &data) noexcept; + +template +void invoke_backend(const std::function &callback, + BackendOperation operation) noexcept +{ + Reply reply; + try { + reply = operation(); + } catch (...) { + reply = {-1, "bluetooth backend failure"}; + } + invoke_callback(callback, reply.code, reply.data); +} + +} // namespace cp0::bluetooth diff --git a/ext_components/cp0_lvgl/src/cp0_c_api_boundary.hpp b/ext_components/cp0_lvgl/src/cp0_c_api_boundary.hpp new file mode 100644 index 00000000..f79e0874 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_c_api_boundary.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace cp0 { + +template +Result invoke_c_api_or(Result fallback, Operation &&operation) noexcept +{ + try { + return std::forward(operation)(); + } catch (...) { + return fallback; + } +} + +} // namespace cp0 diff --git a/ext_components/cp0_lvgl/src/cp0_callback_contract.hpp b/ext_components/cp0_lvgl/src/cp0_callback_contract.hpp new file mode 100644 index 00000000..90c3ccd2 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_callback_contract.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +namespace cp0::callback { + +inline void invoke(const std::function &callback, + int code, const std::string &payload) noexcept +{ + if (!callback) return; + try { + callback(code, payload); + } catch (...) { + } +} + +template +inline void invoke_direct(Callback &&callback, Arguments &&...arguments) noexcept +{ + if constexpr (std::is_pointer_v>) { + if (!callback) return; + } + try { + callback(std::forward(arguments)...); + } catch (...) { + } +} + +template +inline void invoke_if_present(Owner *owner, Callback &&callback) noexcept +{ + if (!owner) return; + try { + std::forward(callback)(*owner); + } catch (...) { + } +} + +} // namespace cp0::callback diff --git a/ext_components/cp0_lvgl/src/cp0_callback_result.hpp b/ext_components/cp0_lvgl/src/cp0_callback_result.hpp new file mode 100644 index 00000000..f69b85ac --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_callback_result.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +namespace cp0 { + +class CallbackResult +{ +public: + using Callback = std::function; + + explicit CallbackResult(Callback callback) : callback_(std::move(callback)) {} + + bool complete(int code, const std::string &data) noexcept + { + if (completed_) return false; + completed_ = true; + if (!callback_) return true; + try { + callback_(code, data); + } catch (...) { + } + return true; + } + + bool completed() const noexcept { return completed_; } + + template + void guard(int failure_code, const std::string &failure_data, + Operation &&operation) noexcept + { + try { + std::forward(operation)(); + } catch (...) { + complete(failure_code, failure_data); + } + } + +private: + Callback callback_; + bool completed_ = false; +}; + +} // namespace cp0 diff --git a/ext_components/cp0_lvgl/src/cp0_camera_api_contract.cpp b/ext_components/cp0_lvgl/src/cp0_camera_api_contract.cpp new file mode 100644 index 00000000..a6125c12 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_camera_api_contract.cpp @@ -0,0 +1,13 @@ +#include "cp0_camera_api_contract.hpp" +#include "cp0_integer_codec.hpp" + +namespace cp0_camera_api { + +int parse_integer_argument(const std::string &value, int fallback) +{ + int parsed = 0; + return cp0_integer_codec::parse_int_with_leading_whitespace(value, parsed) + ? parsed : fallback; +} + +} // namespace cp0_camera_api diff --git a/ext_components/cp0_lvgl/src/cp0_camera_api_contract.hpp b/ext_components/cp0_lvgl/src/cp0_camera_api_contract.hpp new file mode 100644 index 00000000..5153a4f4 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_camera_api_contract.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace cp0_camera_api { + +constexpr int DEFAULT_WIDTH = 320; +constexpr int DEFAULT_HEIGHT = 150; + +int parse_integer_argument(const std::string &value, int fallback); + +} // namespace cp0_camera_api diff --git a/ext_components/cp0_lvgl/src/cp0_config_json.h b/ext_components/cp0_lvgl/src/cp0_config_json.h index 674d3990..2dbf6219 100644 --- a/ext_components/cp0_lvgl/src/cp0_config_json.h +++ b/ext_components/cp0_lvgl/src/cp0_config_json.h @@ -6,291 +6,5 @@ #pragma once -// Minimal JSON <-> flat "dotted key" store used by the unified device config -// (~/.config/cardputerzero/config.json). The launcher's config system keeps a -// flat key=value model internally; nested JSON objects map to dotted keys, e.g. -// camera.resolution.width -> { "camera": { "resolution": { "width": .. }}} -// This keeps the shared contract with other apps (the camera app reads -// camera.resolution.{width,height}) while the launcher still uses simple keys. -// -// Only objects, strings and numbers are emitted; arrays/true/false/null are -// tolerated on read but never written (the launcher is the sole writer). - -#include -#include -#include -#include -#include -#include - -namespace cp0cfg { - -inline bool looks_numeric(const std::string &v) -{ - if (v.empty()) - return false; - size_t i = 0; - if (v[0] == '-') { - if (v.size() == 1) - return false; - i = 1; - } - for (; i < v.size(); ++i) - if (!std::isdigit(static_cast(v[i]))) - return false; - // Only treat as a JSON number if it round-trips, so values like "007" keep - // their string form and get_str() returns them unchanged. - long n = std::atol(v.c_str()); - return std::to_string(n) == v; -} - -struct JsonNode { - std::map children; - std::string value; - bool numeric = false; -}; - -inline void json_escape(const std::string &s, std::string &out) -{ - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: out += c; break; - } - } -} - -inline void json_emit(const JsonNode &node, std::string &out, int indent) -{ - if (node.children.empty()) { - if (node.numeric) { - out += node.value; - } else { - out += '"'; - json_escape(node.value, out); - out += '"'; - } - return; - } - const std::string pad(static_cast(indent) * 2, ' '); - const std::string pad2(static_cast(indent + 1) * 2, ' '); - out += "{\n"; - size_t i = 0; - for (const auto &kv : node.children) { - out += pad2; - out += '"'; - json_escape(kv.first, out); - out += "\": "; - json_emit(kv.second, out, indent + 1); - if (++i < node.children.size()) - out += ','; - out += '\n'; - } - out += pad; - out += '}'; -} - -inline std::string to_json(const std::vector> &kv) -{ - JsonNode root; - for (const auto &e : kv) { - const std::string &key = e.first; - if (key.empty()) - continue; - JsonNode *cur = &root; - size_t start = 0; - while (true) { - size_t dot = key.find('.', start); - std::string seg = (dot == std::string::npos) ? key.substr(start) - : key.substr(start, dot - start); - if (seg.empty()) - break; - cur = &cur->children[seg]; - if (dot == std::string::npos) { - cur->value = e.second; - cur->numeric = looks_numeric(e.second); - break; - } - start = dot + 1; - } - } - if (root.children.empty()) - return "{}\n"; - std::string out; - json_emit(root, out, 0); - out += '\n'; - return out; -} - -class JsonReader { -public: - JsonReader(const std::string &text, std::vector> &out) - : p_(text.c_str()), end_(text.c_str() + text.size()), out_(out) - { - } - - bool parse() - { - skip_ws(); - if (p_ >= end_ || *p_ != '{') - return false; - return parse_object(std::string()); - } - -private: - const char *p_; - const char *end_; - std::vector> &out_; - - void skip_ws() - { - while (p_ < end_ && (*p_ == ' ' || *p_ == '\t' || *p_ == '\n' || *p_ == '\r')) - ++p_; - } - - bool parse_string(std::string &s) - { - if (p_ >= end_ || *p_ != '"') - return false; - ++p_; - while (p_ < end_ && *p_ != '"') { - char c = *p_++; - if (c == '\\' && p_ < end_) { - char e = *p_++; - switch (e) { - case 'n': s += '\n'; break; - case 'r': s += '\r'; break; - case 't': s += '\t'; break; - case '"': s += '"'; break; - case '\\': s += '\\'; break; - case '/': s += '/'; break; - default: s += e; break; - } - } else { - s += c; - } - } - if (p_ >= end_) - return false; - ++p_; // closing quote - return true; - } - - bool skip_container(char close) - { - ++p_; // opening bracket/brace - while (p_ < end_) { - skip_ws(); - if (p_ >= end_) - return false; - if (*p_ == close) { - ++p_; - return true; - } - if (*p_ == '"') { - std::string t; - if (!parse_string(t)) - return false; - } else if (*p_ == '{') { - if (!skip_container('}')) - return false; - } else if (*p_ == '[') { - if (!skip_container(']')) - return false; - } else { - ++p_; - } - } - return false; - } - - bool parse_value(const std::string &prefix) - { - skip_ws(); - if (p_ >= end_) - return false; - char c = *p_; - if (c == '{') - return parse_object(prefix); - if (c == '[') - return skip_container(']'); - if (c == '"') { - std::string s; - if (!parse_string(s)) - return false; - record(prefix, s); - return true; - } - const char *start = p_; - while (p_ < end_ && *p_ != ',' && *p_ != '}' && *p_ != ']' && *p_ != ' ' && - *p_ != '\t' && *p_ != '\n' && *p_ != '\r') - ++p_; - std::string tok(start, static_cast(p_ - start)); - if (tok.empty()) - return false; - if (tok == "true") - tok = "1"; - else if (tok == "false") - tok = "0"; - else if (tok == "null") - tok = ""; - record(prefix, tok); - return true; - } - - bool parse_object(const std::string &prefix) - { - skip_ws(); - if (p_ >= end_ || *p_ != '{') - return false; - ++p_; - skip_ws(); - if (p_ < end_ && *p_ == '}') { - ++p_; - return true; - } - while (p_ < end_) { - skip_ws(); - std::string key; - if (!parse_string(key)) - return false; - skip_ws(); - if (p_ >= end_ || *p_ != ':') - return false; - ++p_; - std::string child = prefix.empty() ? key : (prefix + "." + key); - if (!parse_value(child)) - return false; - skip_ws(); - if (p_ < end_ && *p_ == ',') { - ++p_; - continue; - } - if (p_ < end_ && *p_ == '}') { - ++p_; - return true; - } - return false; - } - return false; - } - - void record(const std::string &prefix, const std::string &val) - { - if (!prefix.empty()) - out_.emplace_back(prefix, val); - } -}; - -inline bool from_json(const std::string &text, - std::vector> &out) -{ - JsonReader reader(text, out); - return reader.parse(); -} - -} // namespace cp0cfg +// Compatibility include for code that still references cp0_lvgl/src. +#include "../include/cp0_config_json.h" diff --git a/ext_components/cp0_lvgl/src/cp0_config_service.cpp b/ext_components/cp0_lvgl/src/cp0_config_service.cpp new file mode 100644 index 00000000..3e72775f --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_config_service.cpp @@ -0,0 +1,162 @@ +#include "cp0_config_service.hpp" +#include "cp0_integer_codec.hpp" + +#include + +namespace cp0cfg { +namespace { + +std::string argument_at(const ConfigService::Arguments &arguments, size_t index) +{ + auto it = arguments.begin(); + for (size_t i = 0; i < index && it != arguments.end(); ++i) + ++it; + return it == arguments.end() ? std::string() : *it; +} + +void report(const ConfigService::Callback &callback, int code, const std::string &data) +{ + if (!callback) return; + try { + callback(code, data); + } catch (...) { + } +} + +} // namespace + +int parse_legacy_integer(const std::string &value, int fallback_if_empty) +{ + return cp0_integer_codec::parse_int_prefix_clamped(value, fallback_if_empty); +} + +ConfigService::ConfigService(Loader loader, Saver saver, std::string error_suffix) + : loader_(std::move(loader)), saver_(std::move(saver)), error_suffix_(std::move(error_suffix)) +{ + init(); +} + +void ConfigService::init() +{ + (void)reload(); +} + +bool ConfigService::reload() +{ + Entries loaded; + try { + if (!loader_ || !loader_(loaded)) return false; + } catch (...) { + return false; + } + + Entries sanitized; + sanitized.reserve(loaded.size() < kMaxEntries ? loaded.size() : kMaxEntries); + for (const auto &entry : loaded) { + if (entry.first.empty() || sanitized.size() >= kMaxEntries) + continue; + sanitized.emplace_back(entry.first.substr(0, kMaxKeyLength), + entry.second.substr(0, kMaxValueLength)); + } + std::lock_guard lock(mutex_); + entries_ = std::move(sanitized); + return true; +} + +std::string ConfigService::get_string(const std::string &key, const std::string &fallback) const +{ + if (key.empty()) + return fallback; + for (const auto &entry : entries_) + if (entry.first == key) + return entry.second; + return fallback; +} + +int ConfigService::get_integer(const std::string &key, int fallback) const +{ + if (key.empty()) + return fallback; + for (const auto &entry : entries_) + if (entry.first == key) + return parse_legacy_integer(entry.second, 0); + return fallback; +} + +void ConfigService::set_string(const std::string &key, const std::string &value) +{ + if (key.empty()) + return; + for (auto &entry : entries_) { + if (entry.first == key) { + entry.second = value.substr(0, kMaxValueLength); + return; + } + } + if (entries_.size() >= kMaxEntries) + return; + entries_.emplace_back(key.substr(0, kMaxKeyLength), value.substr(0, kMaxValueLength)); +} + +void ConfigService::set_integer(const std::string &key, int value) +{ + set_string(key, std::to_string(value)); +} + +int ConfigService::save(const Entries &entries) const +{ + try { + return saver_ ? saver_(entries) : -1; + } catch (...) { + return -1; + } +} + +void ConfigService::api_call(Arguments arguments, Callback callback) +{ + if (arguments.empty()) { + report(callback, -1, "empty config api" + error_suffix_); + return; + } + + const std::string command = arguments.front(); + if (command == "Init") { + const bool loaded = reload(); + report(callback, loaded ? 0 : -1, loaded ? "ok" : "load failed"); + return; + } + if (command == "Save") { + Entries snapshot; + { + std::lock_guard lock(mutex_); + snapshot = entries_; + } + const int result = save(snapshot); + report(callback, result, result == 0 ? "ok" : "save failed"); + return; + } + + int result_code = 0; + std::string result_data; + { + std::lock_guard lock(mutex_); + if (command == "GetInt") { + const int fallback = parse_legacy_integer(argument_at(arguments, 2), 0); + result_data = std::to_string(get_integer(argument_at(arguments, 1), fallback)); + } else if (command == "SetInt") { + set_integer(argument_at(arguments, 1), parse_legacy_integer(argument_at(arguments, 2), 0)); + result_data = "ok"; + } else if (command == "GetStr") { + result_data = get_string(argument_at(arguments, 1), argument_at(arguments, 2)); + } else if (command == "SetStr") { + set_string(argument_at(arguments, 1), argument_at(arguments, 2)); + result_data = "ok"; + } else { + result_code = -1; + result_data = "unknown config api" + error_suffix_; + } + } + report(callback, result_code, result_data); +} + +} // namespace cp0cfg diff --git a/ext_components/cp0_lvgl/src/cp0_config_service.hpp b/ext_components/cp0_lvgl/src/cp0_config_service.hpp new file mode 100644 index 00000000..167a26b2 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_config_service.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace cp0cfg { + +using Entries = std::vector>; + +class ConfigService { +public: + using Callback = std::function; + using Arguments = std::list; + using Loader = std::function; + using Saver = std::function; + + ConfigService(Loader loader, Saver saver, std::string error_suffix = {}); + + void init(); + void api_call(Arguments arguments, Callback callback); + +private: + static constexpr size_t kMaxEntries = 64; + static constexpr size_t kMaxKeyLength = 63; + static constexpr size_t kMaxValueLength = 255; + + Loader loader_; + Saver saver_; + std::string error_suffix_; + Entries entries_; + mutable std::mutex mutex_; + + std::string get_string(const std::string &key, const std::string &fallback) const; + int get_integer(const std::string &key, int fallback) const; + void set_string(const std::string &key, const std::string &value); + void set_integer(const std::string &key, int value); + bool reload(); + int save(const Entries &entries) const; +}; + +int parse_legacy_integer(const std::string &value, int fallback_if_empty); + +} // namespace cp0cfg diff --git a/ext_components/cp0_lvgl/src/cp0_dispatch_shutdown_gate.hpp b/ext_components/cp0_lvgl/src/cp0_dispatch_shutdown_gate.hpp new file mode 100644 index 00000000..34e209f2 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_dispatch_shutdown_gate.hpp @@ -0,0 +1,182 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace cp0::dispatch { + +class ShutdownGate { +public: + class Lease; + + class Reservation { + public: + Reservation() = default; + Reservation(const Reservation &) = delete; + Reservation &operator=(const Reservation &) = delete; + + Reservation(Reservation &&other) noexcept + : gate_(other.gate_), generation_(other.generation_) + { + other.gate_ = nullptr; + } + + Reservation &operator=(Reservation &&other) noexcept + { + if (this == &other) return *this; + release(); + gate_ = other.gate_; + generation_ = other.generation_; + other.gate_ = nullptr; + return *this; + } + + ~Reservation() { release(); } + + explicit operator bool() const noexcept { return gate_ != nullptr; } + uint64_t generation() const noexcept { return generation_; } + void release() noexcept + { + if (!gate_) return; + ShutdownGate *gate = std::exchange(gate_, nullptr); + gate->complete_pending(); + } + + private: + friend class ShutdownGate; + Reservation(ShutdownGate *gate, uint64_t generation) + : gate_(gate), generation_(generation) + { + } + + ShutdownGate *gate_ = nullptr; + uint64_t generation_ = 0; + }; + + class Lease { + public: + Lease() = default; + Lease(const Lease &) = delete; + Lease &operator=(const Lease &) = delete; + + Lease(Lease &&other) noexcept + : gate_(other.gate_), allowed_(other.allowed_) + { + other.gate_ = nullptr; + other.allowed_ = false; + } + + Lease &operator=(Lease &&other) noexcept + { + if (this == &other) return *this; + complete(); + gate_ = other.gate_; + allowed_ = other.allowed_; + other.gate_ = nullptr; + other.allowed_ = false; + return *this; + } + + ~Lease() { complete(); } + + explicit operator bool() const noexcept { return allowed_; } + + private: + friend class ShutdownGate; + Lease(ShutdownGate *gate, bool allowed) + : gate_(gate), allowed_(allowed) + { + } + + void complete() noexcept + { + if (!gate_) return; + ShutdownGate *gate = std::exchange(gate_, nullptr); + allowed_ = false; + gate->complete_pending(); + } + + ShutdownGate *gate_ = nullptr; + bool allowed_ = false; + }; + + std::optional reserve() + { + std::lock_guard lock(mutex_); + if (!accepting_) return std::nullopt; + ++pending_; + return Reservation(this, generation_); + } + + Lease acquire(Reservation &&reservation) noexcept + { + ShutdownGate *gate = std::exchange(reservation.gate_, nullptr); + if (gate != this) { + if (gate) gate->complete_pending(); + return {}; + } + bool allowed = false; + { + std::lock_guard lock(mutex_); + allowed = accepting_ && reservation.generation_ == generation_; + } + return Lease(this, allowed); + } + + uint64_t begin_shutdown() noexcept + { + std::lock_guard lock(mutex_); + accepting_ = false; + return ++generation_; + } + + void resume() noexcept + { + std::lock_guard lock(mutex_); + accepting_ = true; + } + + void wait_for_drain() + { + std::unique_lock lock(mutex_); + drained_.wait(lock, [this] { return pending_ == 0; }); + } + + bool accepting() const noexcept + { + std::lock_guard lock(mutex_); + return accepting_; + } + + uint64_t generation() const noexcept + { + std::lock_guard lock(mutex_); + return generation_; + } + + std::size_t pending() const noexcept + { + std::lock_guard lock(mutex_); + return pending_; + } + +private: + void complete_pending() noexcept + { + std::lock_guard lock(mutex_); + if (pending_ == 0) return; + --pending_; + if (pending_ == 0) drained_.notify_all(); + } + + mutable std::mutex mutex_; + std::condition_variable drained_; + bool accepting_ = true; + uint64_t generation_ = 1; + std::size_t pending_ = 0; +}; + +} // namespace cp0::dispatch diff --git a/ext_components/cp0_lvgl/src/cp0_display_screenshot_contract.cpp b/ext_components/cp0_lvgl/src/cp0_display_screenshot_contract.cpp new file mode 100644 index 00000000..04d6f63a --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_display_screenshot_contract.cpp @@ -0,0 +1,105 @@ +#include "cp0_display_screenshot_contract.hpp" + +#include +#include +#include +#include + +namespace { + +constexpr int kMaximumDimension = 16384; +constexpr std::size_t kMaximumPathLength = 511; + +bool checked_multiply(std::size_t left, std::size_t right, std::size_t &result) +{ + if (left != 0 && right > std::numeric_limits::max() / left) + return false; + result = left * right; + return true; +} + +} // namespace + +extern "C" int cp0_display_dimension_or_default(const char *text, int default_value) +{ + if (!text || !text[0]) + return default_value; + const std::string_view value(text); + int parsed = 0; + const auto result = std::from_chars(value.data(), value.data() + value.size(), parsed); + if (result.ec != std::errc{} || result.ptr != value.data() + value.size() || + parsed <= 0 || parsed > kMaximumDimension) + return default_value; + return parsed; +} + +namespace cp0::screenshot { + +bool parse_request(const std::list &args, Request &request) +{ + request = {}; + if (args.size() != 2 || args.front() != "Save") + return false; + request.directory = args.back(); + return !request.directory.empty() && request.directory.size() <= kMaximumPathLength; +} + +const char *invalid_request_message() +{ + return "invalid screenshot api request\n"; +} + +bool valid_dimensions(int width, int height) +{ + return width > 0 && width <= kMaximumDimension && height > 0 && height <= kMaximumDimension; +} + +bool make_output_path(const std::string &directory, const Timestamp ×tamp, + bool sdl_backend, std::string &path) +{ + path.clear(); + if (directory.empty() || timestamp.year < 1900 || timestamp.year > 9999 || + timestamp.month < 1 || timestamp.month > 12 || timestamp.day < 1 || timestamp.day > 31 || + timestamp.hour < 0 || timestamp.hour > 23 || timestamp.minute < 0 || timestamp.minute > 59 || + timestamp.second < 0 || timestamp.second > 60) + return false; + char suffix[64]; + const int length = std::snprintf(suffix, sizeof(suffix), + "/scr_%04d%02d%02d_%02d%02d%02d%s.bmp", timestamp.year, timestamp.month, + timestamp.day, timestamp.hour, timestamp.minute, timestamp.second, + sdl_backend ? "_sdl" : ""); + if (length <= 0 || static_cast(length) >= sizeof(suffix) || + directory.size() + static_cast(length) > kMaximumPathLength) + return false; + path = directory + suffix; + return true; +} + +bool framebuffer_layout(int width, int height, int bits_per_pixel, + int line_length, FramebufferLayout &layout) +{ + layout = {}; + if (!valid_dimensions(width, height) || (bits_per_pixel != 16 && bits_per_pixel != 32)) + return false; + const std::size_t minimum_line = static_cast(width) * + static_cast(bits_per_pixel / 8); + if (line_length < 0 || static_cast(line_length) < minimum_line) + return false; + if (!checked_multiply(static_cast(line_length), + static_cast(height), layout.mapped_size)) + return false; + + const std::size_t raw_row = static_cast(width) * 3U; + const std::size_t bmp_row = (raw_row + 3U) & ~std::size_t{3U}; + std::size_t image_size = 0; + if (!checked_multiply(bmp_row, static_cast(height), image_size) || + image_size > std::numeric_limits::max() - 54U || + bmp_row > std::numeric_limits::max()) + return false; + layout.bmp_row_size = static_cast(bmp_row); + layout.bmp_image_size = static_cast(image_size); + layout.bmp_file_size = static_cast(image_size + 54U); + return true; +} + +} // namespace cp0::screenshot diff --git a/ext_components/cp0_lvgl/src/cp0_display_screenshot_contract.hpp b/ext_components/cp0_lvgl/src/cp0_display_screenshot_contract.hpp new file mode 100644 index 00000000..69a4459b --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_display_screenshot_contract.hpp @@ -0,0 +1,51 @@ +#pragma once + +#ifdef __cplusplus +#include +#include +#include +#include + +extern "C" { +#endif + +int cp0_display_dimension_or_default(const char *text, int default_value); + +#ifdef __cplusplus +} + +namespace cp0::screenshot { + +struct Request +{ + std::string directory; +}; + +struct Timestamp +{ + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int minute = 0; + int second = 0; +}; + +struct FramebufferLayout +{ + std::size_t mapped_size = 0; + std::uint32_t bmp_row_size = 0; + std::uint32_t bmp_image_size = 0; + std::uint32_t bmp_file_size = 0; +}; + +bool parse_request(const std::list &args, Request &request); +const char *invalid_request_message(); +bool valid_dimensions(int width, int height); +bool make_output_path(const std::string &directory, const Timestamp ×tamp, + bool sdl_backend, std::string &path); +bool framebuffer_layout(int width, int height, int bits_per_pixel, + int line_length, FramebufferLayout &layout); + +} // namespace cp0::screenshot +#endif diff --git a/ext_components/cp0_lvgl/src/cp0_filesystem_api.cpp b/ext_components/cp0_lvgl/src/cp0_filesystem_api.cpp new file mode 100644 index 00000000..5a454264 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_filesystem_api.cpp @@ -0,0 +1,180 @@ +#include "cp0_filesystem_api.hpp" + +#include "cp0_callback_result.hpp" +#include "cp0_integer_codec.hpp" +#include "cp0_posix_filesystem.hpp" + +#include +#include +#include +#include +#include +#include + +namespace cp0_filesystem_api { +namespace { + +bool parse_size(const std::string &value, size_t &size) +{ + return cp0_integer_codec::parse_decimal( + value, size_t{0}, std::numeric_limits::max(), size); +} + +bool valid_path(const std::string &path) +{ + return !path.empty() && path.find('\0') == std::string::npos; +} + +int touch_file(const std::string &path) +{ + const auto slash = path.find_last_of('/'); + if (slash != std::string::npos && slash > 0) { + const std::string parent = path.substr(0, slash); + struct stat status{}; + if ((mkdir(parent.c_str(), 0755) != 0 && errno != EEXIST) || + stat(parent.c_str(), &status) != 0 || !S_ISDIR(status.st_mode)) { + return -1; + } + } + + FILE *file = fopen(path.c_str(), "a"); + if (!file) return -1; + fclose(file); + return 0; +} + +} // namespace + +cp0_watcher_t WatcherRegistry::add(cp0_watcher_t watcher) +{ + if (!watcher) return nullptr; + std::lock_guard lock(mutex_); + while (next_handle_ == 0 || watchers_.count(next_handle_) != 0) ++next_handle_; + const uintptr_t handle = next_handle_++; + watchers_.emplace(handle, watcher); + return reinterpret_cast(handle); +} + +std::string encode_watcher_handle(cp0_watcher_t watcher) +{ + const uintptr_t value = reinterpret_cast(watcher); + return value == 0 ? std::string() : std::to_string(value); +} + +bool decode_watcher_handle(const std::string &text, cp0_watcher_t &watcher) +{ + watcher = nullptr; + uintptr_t parsed = 0; + if (!cp0_integer_codec::parse_decimal( + text, uintptr_t{1}, std::numeric_limits::max(), parsed)) + return false; + watcher = reinterpret_cast(parsed); + return true; +} + +void dispatch(const std::list &arguments, const Operations &operations, + Callback callback) +{ + cp0::CallbackResult result(std::move(callback)); + auto report = [&](int code, std::string data) { + result.complete(code, data); + }; + try { + if (arguments.empty()) { + report(-1, "missing command"); + return; + } + + const std::string &command = arguments.front(); + const std::string value = + arguments.size() >= 2 ? *std::next(arguments.begin()) : std::string(); + if (command == "Path") { + if (arguments.size() != 2 || value.find('\0') != std::string::npos || + !operations.resolve_path) { + report(-1, "invalid Path arguments"); + return; + } + report(0, operations.resolve_path(value)); + } else if (command == "DirList" || command == "DirListDetail") { + if (arguments.size() != 2 || !valid_path(value)) { + report(-1, "invalid directory path"); + return; + } + const bool detail = command == "DirListDetail"; + std::string data; + const int code = cp0_posix_filesystem::encode_directory( + value.c_str(), detail, + detail ? operations.detail_errno_on_open_failure : true, data); + report(code, std::move(data)); + } else if (command == "Exists") { + if (arguments.size() != 2 || !valid_path(value)) { + report(-1, "invalid filesystem path"); + return; + } + report(0, access(value.c_str(), R_OK) == 0 ? "1" : "0"); + } else if (command == "ReadFile") { + if ((arguments.size() != 2 && arguments.size() != 3) || !valid_path(value)) { + report(-1, "invalid ReadFile arguments"); + return; + } + const auto max_it = + arguments.size() >= 3 ? std::next(arguments.begin(), 2) : arguments.end(); + size_t max_bytes = std::numeric_limits::max(); + if (max_it != arguments.end() && !parse_size(*max_it, max_bytes)) { + report(-1, "invalid ReadFile size"); + return; + } + std::string data; + const int code = cp0_posix_filesystem::read_file_limited(value, max_bytes, data); + report(code, std::move(data)); + } else if (command == "EnsureDirForUser") { + if (arguments.size() != 2 || !valid_path(value) || !operations.ensure_directory) { + report(-1, "invalid directory path"); + return; + } + report(operations.ensure_directory(value), ""); + } else if (command == "Touch") { + if (arguments.size() != 2 || !valid_path(value)) { + report(-1, "invalid filesystem path"); + return; + } + report(touch_file(value), ""); + } else if (command == "Remove") { + if (arguments.size() != 2 || !valid_path(value)) { + report(-1, "invalid filesystem path"); + return; + } + report(std::remove(value.c_str()) == 0 ? 0 : -1, ""); + } else if (command == "WatchStart") { + if (arguments.size() != 2 || !valid_path(value) || !operations.watch_start) { + report(-1, "invalid watch path"); + return; + } + const cp0_watcher_t watcher = operations.watch_start(value.c_str()); + report(watcher ? 0 : -1, encode_watcher_handle(watcher)); + } else if (command == "WatchPoll") { + cp0_watcher_t watcher = nullptr; + if (arguments.size() != 2 || !decode_watcher_handle(value, watcher) || + !operations.watch_poll) { + report(-1, "invalid watcher handle"); + return; + } + const int poll_result = operations.watch_poll(watcher); + report(poll_result < 0 ? poll_result : 0, std::to_string(poll_result)); + } else if (command == "WatchStop") { + cp0_watcher_t watcher = nullptr; + if (arguments.size() != 2 || !decode_watcher_handle(value, watcher) || + !operations.watch_stop) { + report(-1, "invalid watcher handle"); + return; + } + report(operations.watch_stop(watcher), ""); + } else { + report(-2, "unknown command: " + command); + } + } catch (...) { + report(-1, "filesystem operation failed"); + } +} + +} // namespace cp0_filesystem_api diff --git a/ext_components/cp0_lvgl/src/cp0_filesystem_api.hpp b/ext_components/cp0_lvgl/src/cp0_filesystem_api.hpp new file mode 100644 index 00000000..d7503273 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_filesystem_api.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include +#include +#include +#include +#include + +namespace cp0_filesystem_api { + +using Callback = std::function; + +struct Operations { + std::function resolve_path; + std::function ensure_directory; + std::function watch_start; + std::function watch_poll; + std::function watch_stop; + bool detail_errno_on_open_failure = true; +}; + +class WatcherRegistry { +public: + cp0_watcher_t add(cp0_watcher_t watcher); + + template + int poll(cp0_watcher_t watcher, Poll operation) + { + std::lock_guard lock(mutex_); + const uintptr_t key = reinterpret_cast(watcher); + const auto it = watchers_.find(key); + if (it == watchers_.end()) return -1; + return operation(it->second); + } + + template + bool stop(cp0_watcher_t watcher, Stop operation) + { + std::lock_guard lock(mutex_); + const uintptr_t key = reinterpret_cast(watcher); + const auto it = watchers_.find(key); + if (it == watchers_.end()) return false; + cp0_watcher_t native = it->second; + watchers_.erase(it); + operation(native); + return true; + } + +private: + std::mutex mutex_; + std::unordered_map watchers_; + uintptr_t next_handle_ = 1; +}; + +std::string encode_watcher_handle(cp0_watcher_t watcher); +bool decode_watcher_handle(const std::string &text, cp0_watcher_t &watcher); + +void dispatch(const std::list &arguments, const Operations &operations, + Callback callback); + +} // namespace cp0_filesystem_api diff --git a/ext_components/cp0_lvgl/src/cp0_font_cache_policy.hpp b/ext_components/cp0_lvgl/src/cp0_font_cache_policy.hpp new file mode 100644 index 00000000..1d477275 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_font_cache_policy.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace cp0::font { + +template +bool should_cache(Pointer font) +{ + return font != nullptr; +} + +} // namespace cp0::font diff --git a/ext_components/cp0_lvgl/src/cp0_font_service.cpp b/ext_components/cp0_lvgl/src/cp0_font_service.cpp new file mode 100644 index 00000000..dd3c89c0 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_font_service.cpp @@ -0,0 +1,111 @@ +#include "cp0_font_service.hpp" + +#include "cp0_font_cache_policy.hpp" +#include "cp0_sync_signal.hpp" +#include "hal_lvgl_bsp.h" + +#include + +Cp0FontService::~Cp0FontService() +{ + release(); +} + +lv_font_t *Cp0FontService::get(const char *font_name, uint16_t size, + lv_freetype_font_style_t style, + lv_freetype_font_render_mode_t render_mode) +{ +#if LV_USE_FREETYPE + std::lock_guard lock(mutex_); + const std::string path = resolve_path(font_name); + if (path.empty()) return fallback(size); + + const std::string font_key = key(path, size, style, render_mode); + const auto found = fonts_.find(font_key); + if (found != fonts_.end()) return found->second ? found->second : fallback(size); + + lv_font_t *font = lv_freetype_font_create(path.c_str(), render_mode, size, style); + if (!cp0::font::should_cache(font)) return fallback(size); + try { + fonts_.emplace(font_key, font); + } catch (...) { + lv_freetype_font_delete(font); + return fallback(size); + } + return font; +#else + (void)font_name; + (void)style; + (void)render_mode; + return fallback(size); +#endif +} + +lv_font_t *Cp0FontService::get_mono(const char *font_name, uint16_t size, + lv_freetype_font_style_t style) +{ + return get(font_name, size, style, + static_cast(2)); +} + +#if LV_USE_FREETYPE +std::string Cp0FontService::resolve_path(const char *font_name) +{ + if (!font_name || !font_name[0]) return {}; + std::string value(font_name); + if (value.find('/') != std::string::npos || + (value.size() > 1 && value[1] == ':')) return value; + + std::string resolved; + cp0::signal::invoke_noexcept([&] { cp0_signal_filesystem_api({"Path", value}, [&](int code, std::string data) { + if (code == 0) resolved = std::move(data); + }); }); + return resolved.empty() ? value : resolved; +} + +std::string Cp0FontService::key(const std::string &path, uint16_t size, + lv_freetype_font_style_t style, + lv_freetype_font_render_mode_t render_mode) +{ + return path + "#" + std::to_string(size) + "#" + + std::to_string(static_cast(style)) + "#" + + std::to_string(static_cast(render_mode)); +} +#endif + +lv_font_t *Cp0FontService::fallback(uint16_t size) const +{ +#if LV_FONT_MONTSERRAT_20 + if (size >= 18) return const_cast(&lv_font_montserrat_20); +#endif +#if LV_FONT_MONTSERRAT_14 + if (size >= 14) return const_cast(&lv_font_montserrat_14); +#endif +#if LV_FONT_MONTSERRAT_12 + if (size >= 12) return const_cast(&lv_font_montserrat_12); +#endif +#if LV_FONT_MONTSERRAT_10 + (void)size; + return const_cast(&lv_font_montserrat_10); +#else + (void)size; + return const_cast(LV_FONT_DEFAULT); +#endif +} + +void Cp0FontService::release() +{ +#if LV_USE_FREETYPE + std::lock_guard lock(mutex_); + for (auto &font : fonts_) { + if (font.second) lv_freetype_font_delete(font.second); + } + fonts_.clear(); +#endif +} + +Cp0FontService &cp0_fonts() +{ + static Cp0FontService service; + return service; +} diff --git a/ext_components/cp0_lvgl/src/cp0_imu_codec.cpp b/ext_components/cp0_lvgl/src/cp0_imu_codec.cpp new file mode 100644 index 00000000..865685c3 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_imu_codec.cpp @@ -0,0 +1,27 @@ +#include "cp0_imu_codec.hpp" + +#include +#include + +namespace cp0::imu { + +static_assert(std::is_trivially_copyable::value, + "IMU signal payload requires a trivially copyable C structure"); + +std::string encode_info(const cp0_compass_info_t &info) +{ + return std::string(reinterpret_cast(&info), sizeof(info)); +} + +bool decode_info(const std::string &payload, cp0_compass_info_t &info) +{ + if (payload.size() != sizeof(info)) + return false; + + cp0_compass_info_t decoded{}; + std::memcpy(&decoded, payload.data(), sizeof(decoded)); + info = decoded; + return true; +} + +} // namespace cp0::imu diff --git a/ext_components/cp0_lvgl/src/cp0_imu_codec.hpp b/ext_components/cp0_lvgl/src/cp0_imu_codec.hpp new file mode 100644 index 00000000..8959d760 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_imu_codec.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include + +namespace cp0::imu { + +std::string encode_info(const cp0_compass_info_t &info); +bool decode_info(const std::string &payload, cp0_compass_info_t &info); + +} // namespace cp0::imu diff --git a/ext_components/cp0_lvgl/src/cp0_imu_worker_contract.hpp b/ext_components/cp0_lvgl/src/cp0_imu_worker_contract.hpp new file mode 100644 index 00000000..ba147f39 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_imu_worker_contract.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +namespace cp0::imu { + +enum class StartOutcome { + Started, + AlreadyRunning, + Stopping, + ThreadCreationFailed, +}; + +template +StartOutcome prepare_then_report(Prepare &&prepare, Report &&report) noexcept +{ + StartOutcome outcome = StartOutcome::ThreadCreationFailed; + try { + outcome = std::forward(prepare)(); + } catch (...) { + outcome = StartOutcome::ThreadCreationFailed; + } + try { + std::forward(report)(outcome); + } catch (...) { + } + return outcome; +} + +inline bool reap_completed_worker(std::thread &worker, bool active) +{ + if (!worker.joinable() || active) return false; + worker.join(); + return true; +} + +template +bool cancel_and_join_worker(std::thread &worker, Cancel &&cancel, + Wake &&wake) noexcept +{ + try { + std::forward(cancel)(); + std::forward(wake)(); + if (!worker.joinable()) return false; + worker.join(); + return true; + } catch (...) { + return false; + } +} + +} // namespace cp0::imu diff --git a/ext_components/cp0_lvgl/src/cp0_init_once.hpp b/ext_components/cp0_lvgl/src/cp0_init_once.hpp new file mode 100644 index 00000000..73f6ce82 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_init_once.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace cp0 { + +class InitOnce +{ +public: + template + bool run(Initializer &&initializer) + { + try { + std::call_once(flag_, [&] { + if (!std::forward(initializer)()) throw Retry{}; + }); + return true; + } catch (const Retry &) { + return false; + } + } + +private: + struct Retry {}; + std::once_flag flag_; +}; + +} // namespace cp0 diff --git a/ext_components/cp0_lvgl/src/cp0_init_plan.c b/ext_components/cp0_lvgl/src/cp0_init_plan.c new file mode 100644 index 00000000..8e69abcc --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_init_plan.c @@ -0,0 +1,53 @@ +#include "cp0_init_plan.h" + +typedef struct optional_step { + uint64_t feature; + cp0_init_step_t step; +} optional_step_t; + +static void append_step(cp0_init_step_t step, cp0_init_step_t *steps, size_t capacity, size_t *count) +{ + if (*count < capacity && steps != NULL) + steps[*count] = step; + ++(*count); +} + +size_t cp0_build_init_plan(uint64_t features, cp0_init_step_t *steps, size_t capacity) +{ + static const optional_step_t before_display[] = { + {CP0_INIT_FEATURE_FILESYSTEM, CP0_INIT_STEP_FILESYSTEM}, + {CP0_INIT_FEATURE_CONFIG, CP0_INIT_STEP_CONFIG}, + {CP0_INIT_FEATURE_PTY, CP0_INIT_STEP_PTY}, + }; + static const optional_step_t after_input[] = { + {CP0_INIT_FEATURE_RPC, CP0_INIT_STEP_RPC}, + {CP0_INIT_FEATURE_AUDIO, CP0_INIT_STEP_AUDIO}, + {CP0_INIT_FEATURE_PROCESS, CP0_INIT_STEP_PROCESS}, + {CP0_INIT_FEATURE_SUDO, CP0_INIT_STEP_SUDO}, + {CP0_INIT_FEATURE_OSINFO, CP0_INIT_STEP_OSINFO}, + {CP0_INIT_FEATURE_SCREENSHOT, CP0_INIT_STEP_SCREENSHOT}, + {CP0_INIT_FEATURE_LORA, CP0_INIT_STEP_LORA}, + {CP0_INIT_FEATURE_WIFI, CP0_INIT_STEP_WIFI}, + {CP0_INIT_FEATURE_BLUETOOTH, CP0_INIT_STEP_BLUETOOTH}, + {CP0_INIT_FEATURE_SETTINGS, CP0_INIT_STEP_SETTINGS}, + {CP0_INIT_FEATURE_BQ27220, CP0_INIT_STEP_BQ27220}, + {CP0_INIT_FEATURE_IMU, CP0_INIT_STEP_IMU}, + {CP0_INIT_FEATURE_SAVED_SETTINGS, CP0_INIT_STEP_SAVED_SETTINGS}, + {CP0_INIT_FEATURE_BATTERY, CP0_INIT_STEP_BATTERY}, + {CP0_INIT_FEATURE_CAMERA, CP0_INIT_STEP_CAMERA}, + {CP0_INIT_FEATURE_SOUNDCARD, CP0_INIT_STEP_SOUNDCARD}, + }; + size_t count = 0; + size_t index; + append_step(CP0_INIT_STEP_ENV, steps, capacity, &count); + append_step(CP0_INIT_STEP_EVENT, steps, capacity, &count); + for (index = 0; index < sizeof(before_display) / sizeof(before_display[0]); ++index) + if ((features & before_display[index].feature) != 0) + append_step(before_display[index].step, steps, capacity, &count); + append_step(CP0_INIT_STEP_DISPLAY, steps, capacity, &count); + append_step(CP0_INIT_STEP_INPUT, steps, capacity, &count); + for (index = 0; index < sizeof(after_input) / sizeof(after_input[0]); ++index) + if ((features & after_input[index].feature) != 0) + append_step(after_input[index].step, steps, capacity, &count); + return count; +} diff --git a/ext_components/cp0_lvgl/src/cp0_init_plan.h b/ext_components/cp0_lvgl/src/cp0_init_plan.h new file mode 100644 index 00000000..68256aac --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_init_plan.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum cp0_init_step { + CP0_INIT_STEP_ENV, + CP0_INIT_STEP_EVENT, + CP0_INIT_STEP_FILESYSTEM, + CP0_INIT_STEP_CONFIG, + CP0_INIT_STEP_PTY, + CP0_INIT_STEP_DISPLAY, + CP0_INIT_STEP_INPUT, + CP0_INIT_STEP_RPC, + CP0_INIT_STEP_AUDIO, + CP0_INIT_STEP_PROCESS, + CP0_INIT_STEP_SUDO, + CP0_INIT_STEP_OSINFO, + CP0_INIT_STEP_SCREENSHOT, + CP0_INIT_STEP_LORA, + CP0_INIT_STEP_WIFI, + CP0_INIT_STEP_BLUETOOTH, + CP0_INIT_STEP_SETTINGS, + CP0_INIT_STEP_BQ27220, + CP0_INIT_STEP_IMU, + CP0_INIT_STEP_SAVED_SETTINGS, + CP0_INIT_STEP_BATTERY, + CP0_INIT_STEP_CAMERA, + CP0_INIT_STEP_SOUNDCARD, +} cp0_init_step_t; + +enum { + CP0_INIT_FEATURE_FILESYSTEM = UINT64_C(1) << 0, + CP0_INIT_FEATURE_CONFIG = UINT64_C(1) << 1, + CP0_INIT_FEATURE_PTY = UINT64_C(1) << 2, + CP0_INIT_FEATURE_RPC = UINT64_C(1) << 3, + CP0_INIT_FEATURE_AUDIO = UINT64_C(1) << 4, + CP0_INIT_FEATURE_PROCESS = UINT64_C(1) << 5, + CP0_INIT_FEATURE_SUDO = UINT64_C(1) << 6, + CP0_INIT_FEATURE_OSINFO = UINT64_C(1) << 7, + CP0_INIT_FEATURE_SCREENSHOT = UINT64_C(1) << 8, + CP0_INIT_FEATURE_LORA = UINT64_C(1) << 9, + CP0_INIT_FEATURE_WIFI = UINT64_C(1) << 10, + CP0_INIT_FEATURE_BLUETOOTH = UINT64_C(1) << 11, + CP0_INIT_FEATURE_SETTINGS = UINT64_C(1) << 12, + CP0_INIT_FEATURE_BQ27220 = UINT64_C(1) << 13, + CP0_INIT_FEATURE_IMU = UINT64_C(1) << 14, + CP0_INIT_FEATURE_SAVED_SETTINGS = UINT64_C(1) << 15, + CP0_INIT_FEATURE_BATTERY = UINT64_C(1) << 16, + CP0_INIT_FEATURE_CAMERA = UINT64_C(1) << 17, + CP0_INIT_FEATURE_SOUNDCARD = UINT64_C(1) << 18, +}; + +size_t cp0_build_init_plan(uint64_t features, cp0_init_step_t *steps, size_t capacity); + +#ifdef __cplusplus +} +#endif diff --git a/ext_components/cp0_lvgl/src/cp0_integer_codec.hpp b/ext_components/cp0_lvgl/src/cp0_integer_codec.hpp new file mode 100644 index 00000000..a71bb232 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_integer_codec.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace cp0_integer_codec { + +namespace detail { + +inline bool is_ascii_space(char character) +{ + return character == ' ' || character == '\t' || character == '\n' || + character == '\r' || character == '\f' || character == '\v'; +} + +inline const char *skip_ascii_space(const char *begin, const char *end) +{ + while (begin < end && is_ascii_space(*begin)) ++begin; + return begin; +} + +} // namespace detail + +template +inline bool parse_decimal(std::string_view text, + Integer minimum, + Integer maximum, + Integer &value) +{ + if (text.empty() || minimum > maximum) return false; + Integer parsed = 0; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, parsed, 10); + if (result.ec != std::errc{} || result.ptr != end || parsed < minimum || parsed > maximum) + return false; + value = parsed; + return true; +} + +inline bool parse_int(const std::string &text, int &value) +{ + return parse_decimal(text, std::numeric_limits::min(), + std::numeric_limits::max(), value); +} + +inline bool parse_int_with_leading_whitespace(const std::string &text, int &value) +{ + const char *end = text.data() + text.size(); + const char *begin = detail::skip_ascii_space(text.data(), end); + if (begin == end) return false; + bool positive_sign = *begin == '+'; + if (positive_sign && ++begin == end) return false; + int parsed = 0; + const auto result = std::from_chars(begin, end, parsed, 10); + if (result.ec != std::errc{} || result.ptr != end) return false; + value = parsed; + return true; +} + +inline int parse_int_prefix_clamped(const std::string &text, int fallback_if_empty) +{ + if (text.empty()) return fallback_if_empty; + const char *end = text.data() + text.size(); + const char *begin = detail::skip_ascii_space(text.data(), end); + bool negative = begin < end && *begin == '-'; + if (begin < end && *begin == '+') ++begin; + int parsed = 0; + const auto result = std::from_chars(begin, end, parsed, 10); + if (result.ptr == begin) return 0; + if (result.ec == std::errc::result_out_of_range) + return negative ? std::numeric_limits::min() : std::numeric_limits::max(); + return parsed; +} + +inline bool parse_non_root_identity(const char *text, std::uintmax_t invalid_sentinel, + std::uintmax_t &value) +{ + if (!text || !text[0] || invalid_sentinel <= 1) return false; + return parse_decimal(std::string_view(text), std::uintmax_t{1}, + invalid_sentinel - 1, value); +} + +} // namespace cp0_integer_codec diff --git a/ext_components/cp0_lvgl/src/cp0_keyboard_input_lifecycle.h b/ext_components/cp0_lvgl/src/cp0_keyboard_input_lifecycle.h new file mode 100644 index 00000000..3635d7d9 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_keyboard_input_lifecycle.h @@ -0,0 +1,30 @@ +#pragma once + +typedef struct { + void *handle; + int creating; +} cp0_keyboard_input_lifecycle_t; + +static inline int cp0_keyboard_input_begin_create(cp0_keyboard_input_lifecycle_t *state) +{ + if (!state || state->handle || state->creating) return 0; + state->creating = 1; + return 1; +} + +static inline void cp0_keyboard_input_finish_create(cp0_keyboard_input_lifecycle_t *state, + void *handle) +{ + if (!state) return; + state->handle = handle; + state->creating = 0; +} + +static inline int cp0_keyboard_input_handle_deleted(cp0_keyboard_input_lifecycle_t *state, + const void *handle) +{ + if (!state || !handle || state->handle != handle) return 0; + state->handle = 0; + state->creating = 0; + return 1; +} diff --git a/ext_components/cp0_lvgl/src/cp0_keyboard_key_contract.h b/ext_components/cp0_lvgl/src/cp0_keyboard_key_contract.h new file mode 100644 index 00000000..e56786ab --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_keyboard_key_contract.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include "input_keys.h" + +#ifdef __cplusplus +extern "C" { +#endif + +static inline const char *cp0_keyboard_control_utf8(uint32_t keycode) +{ + static const struct { + uint32_t keycode; + const char *utf8; + } controls[] = { + {KEY_ENTER, "\r"}, {KEY_KPENTER, "\r"}, + {KEY_BACKSPACE, "\x7f"}, {KEY_TAB, "\t"}, {KEY_ESC, "\x1b"}, + {KEY_UP, "\033[A"}, {KEY_DOWN, "\033[B"}, + {KEY_RIGHT, "\033[C"}, {KEY_LEFT, "\033[D"}, + {KEY_HOME, "\033[H"}, {KEY_END, "\033[F"}, + {KEY_DELETE, "\033[3~"}, {KEY_INSERT, "\033[2~"}, + {KEY_PAGEUP, "\033[5~"}, {KEY_PAGEDOWN, "\033[6~"}, + {KEY_F1, "\033OP"}, {KEY_F2, "\033OQ"}, + {KEY_F3, "\033OR"}, {KEY_F4, "\033OS"}, + {KEY_F5, "\033[15~"}, {KEY_F6, "\033[17~"}, + {KEY_F7, "\033[18~"}, {KEY_F8, "\033[19~"}, + {KEY_F9, "\033[20~"}, {KEY_F10, "\033[21~"}, + {KEY_F11, "\033[23~"}, {KEY_F12, "\033[24~"}, + }; + for (size_t i = 0; i < sizeof(controls) / sizeof(controls[0]); ++i) + if (controls[i].keycode == keycode) return controls[i].utf8; + return NULL; +} + +#ifdef __cplusplus +} +#endif diff --git a/ext_components/cp0_lvgl/src/cp0_keyboard_queue.c b/ext_components/cp0_lvgl/src/cp0_keyboard_queue.c new file mode 100644 index 00000000..2c17990b --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_keyboard_queue.c @@ -0,0 +1,61 @@ +#include "cp0_keyboard_queue.h" + +#include "input_keys.h" + +#include + +static pthread_once_t queue_once = PTHREAD_ONCE_INIT; + +static void initialize_queue(void) +{ + STAILQ_INIT(&keyboard_queue); +} + +void cp0_keyboard_queue_init(void) +{ + pthread_once(&queue_once, initialize_queue); +} + +int cp0_keyboard_queue_push(const struct key_item *item) +{ + if (!item) return -1; + cp0_keyboard_queue_init(); + struct key_item *copy = malloc(sizeof(*copy)); + if (!copy) return -1; + *copy = *item; + copy->flage = 0; + if (copy->key_code == KEY_ESC) LVGL_HOME_KEY_FLAG = copy->key_state; + if (!LVGL_RUN_FLAGE) { + free(copy); + return 0; + } + pthread_mutex_lock(&keyboard_mutex); + STAILQ_INSERT_TAIL(&keyboard_queue, copy, entries); + pthread_mutex_unlock(&keyboard_mutex); + return 0; +} + +struct key_item *cp0_keyboard_queue_pop(void) +{ + cp0_keyboard_queue_init(); + pthread_mutex_lock(&keyboard_mutex); + struct key_item *item = STAILQ_FIRST(&keyboard_queue); + if (item) STAILQ_REMOVE_HEAD(&keyboard_queue, entries); + pthread_mutex_unlock(&keyboard_mutex); + return item; +} + +int cp0_keyboard_queue_has_data(void) +{ + cp0_keyboard_queue_init(); + pthread_mutex_lock(&keyboard_mutex); + const int result = !STAILQ_EMPTY(&keyboard_queue); + pthread_mutex_unlock(&keyboard_mutex); + return result; +} + +void cp0_keyboard_queue_clear(void) +{ + struct key_item *item; + while ((item = cp0_keyboard_queue_pop()) != NULL) free(item); +} diff --git a/ext_components/cp0_lvgl/src/cp0_keyboard_queue.h b/ext_components/cp0_lvgl/src/cp0_keyboard_queue.h new file mode 100644 index 00000000..4a7ce363 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_keyboard_queue.h @@ -0,0 +1,17 @@ +#pragma once + +#include "keyboard_input.h" + +#ifdef __cplusplus +extern "C" { +#endif + +void cp0_keyboard_queue_init(void); +int cp0_keyboard_queue_push(const struct key_item *item); +struct key_item *cp0_keyboard_queue_pop(void); +int cp0_keyboard_queue_has_data(void); +void cp0_keyboard_queue_clear(void); + +#ifdef __cplusplus +} +#endif diff --git a/ext_components/cp0_lvgl/src/cp0_keyboard_text.c b/ext_components/cp0_lvgl/src/cp0_keyboard_text.c new file mode 100644 index 00000000..c287fc07 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_keyboard_text.c @@ -0,0 +1,108 @@ +#include "cp0_keyboard_text.h" + +static int continuation(unsigned char value) +{ + return (value & 0xc0u) == 0x80u; +} + +int cp0_keyboard_utf8_decode_one(const char *text, uint32_t *codepoint, size_t *length) +{ + if (!text || !codepoint || !length || text[0] == '\0') return -1; + const unsigned char *input = (const unsigned char *)text; + uint32_t decoded = 0; + size_t bytes = 0; + if (input[0] <= 0x7f) { + decoded = input[0]; + bytes = 1; + } else if (input[0] >= 0xc2 && input[0] <= 0xdf && input[1] != '\0' && + continuation(input[1])) { + decoded = ((uint32_t)(input[0] & 0x1f) << 6) | (uint32_t)(input[1] & 0x3f); + bytes = 2; + } else if (input[0] >= 0xe0 && input[0] <= 0xef && input[1] != '\0' && + input[2] != '\0' && continuation(input[1]) && continuation(input[2])) { + decoded = ((uint32_t)(input[0] & 0x0f) << 12) | + ((uint32_t)(input[1] & 0x3f) << 6) | (uint32_t)(input[2] & 0x3f); + if (decoded < 0x800 || (decoded >= 0xd800 && decoded <= 0xdfff)) return -1; + bytes = 3; + } else if (input[0] >= 0xf0 && input[0] <= 0xf4 && input[1] != '\0' && + input[2] != '\0' && input[3] != '\0' && continuation(input[1]) && + continuation(input[2]) && continuation(input[3])) { + decoded = ((uint32_t)(input[0] & 0x07) << 18) | + ((uint32_t)(input[1] & 0x3f) << 12) | + ((uint32_t)(input[2] & 0x3f) << 6) | (uint32_t)(input[3] & 0x3f); + if (decoded < 0x10000 || decoded > 0x10ffff) return -1; + bytes = 4; + } else { + return -1; + } + *codepoint = decoded; + *length = bytes; + return 0; +} + +int cp0_keyboard_utf8_validate(const char *text) +{ + if (!text) return -1; + while (*text) { + uint32_t codepoint = 0; + size_t length = 0; + if (cp0_keyboard_utf8_decode_one(text, &codepoint, &length) != 0) return -1; + (void)codepoint; + text += length; + } + return 0; +} + +size_t cp0_keyboard_utf8_copy(char *output, size_t output_size, const char *text) +{ + if (!output || output_size == 0) return 0; + output[0] = '\0'; + if (!text || cp0_keyboard_utf8_validate(text) != 0) return 0; + size_t written = 0; + while (*text) { + uint32_t codepoint = 0; + size_t length = 0; + if (cp0_keyboard_utf8_decode_one(text, &codepoint, &length) != 0 || + written + length >= output_size) + break; + (void)codepoint; + for (size_t i = 0; i < length; ++i) output[written + i] = text[i]; + written += length; + text += length; + } + output[written] = '\0'; + return written; +} + +int cp0_keyboard_utf32_to_utf8(uint32_t codepoint, char *output, size_t output_size) +{ + if (!output || output_size == 0 || codepoint > 0x10ffff || + (codepoint >= 0xd800 && codepoint <= 0xdfff)) + return -1; + size_t length = 0; + if (codepoint <= 0x7f) { + length = 1; + if (output_size < 2) return -1; + output[0] = (char)codepoint; + } else if (codepoint <= 0x7ff) { + length = 2; + if (output_size < 3) return -1; + output[0] = (char)(0xc0 | (codepoint >> 6)); + output[1] = (char)(0x80 | (codepoint & 0x3f)); + } else if (codepoint <= 0xffff) { + length = 3; + if (output_size < 4) return -1; + output[0] = (char)(0xe0 | (codepoint >> 12)); + output[1] = (char)(0x80 | ((codepoint >> 6) & 0x3f)); + output[2] = (char)(0x80 | (codepoint & 0x3f)); + } else { + length = 4; + if (output_size < 5) return -1; + output[0] = (char)(0xf0 | (codepoint >> 18)); + output[1] = (char)(0x80 | ((codepoint >> 12) & 0x3f)); + output[2] = (char)(0x80 | ((codepoint >> 6) & 0x3f)); + output[3] = (char)(0x80 | (codepoint & 0x3f)); + } + output[length] = '\0'; + return (int)length; +} diff --git a/ext_components/cp0_lvgl/src/cp0_keyboard_text.h b/ext_components/cp0_lvgl/src/cp0_keyboard_text.h new file mode 100644 index 00000000..f8ef06a5 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_keyboard_text.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +int cp0_keyboard_utf8_decode_one(const char *text, uint32_t *codepoint, size_t *length); +int cp0_keyboard_utf8_validate(const char *text); +size_t cp0_keyboard_utf8_copy(char *output, size_t output_size, const char *text); +int cp0_keyboard_utf32_to_utf8(uint32_t codepoint, char *output, size_t output_size); + +#ifdef __cplusplus +} +#endif diff --git a/ext_components/cp0_lvgl/src/cp0_keyboard_thread_lifecycle.h b/ext_components/cp0_lvgl/src/cp0_keyboard_thread_lifecycle.h new file mode 100644 index 00000000..24429ac3 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_keyboard_thread_lifecycle.h @@ -0,0 +1,46 @@ +#pragma once + +typedef struct { + int initialized; + int thread_started; + int deinitializing; +} cp0_keyboard_thread_lifecycle_t; + +static inline int cp0_keyboard_thread_can_init( + const cp0_keyboard_thread_lifecycle_t *state) +{ + return state && !state->initialized; +} + +static inline void cp0_keyboard_thread_mark_started( + cp0_keyboard_thread_lifecycle_t *state) +{ + if (!state) return; + state->initialized = 1; + state->thread_started = 1; +} + +static inline int cp0_keyboard_thread_begin_deinit( + cp0_keyboard_thread_lifecycle_t *state) +{ + if (!state || !state->initialized || !state->thread_started || + state->deinitializing) + return 0; + state->deinitializing = 1; + return 1; +} + +static inline void cp0_keyboard_thread_finish_deinit( + cp0_keyboard_thread_lifecycle_t *state) +{ + if (!state) return; + state->initialized = 0; + state->thread_started = 0; + state->deinitializing = 0; +} + +static inline void cp0_keyboard_thread_cancel_deinit( + cp0_keyboard_thread_lifecycle_t *state) +{ + if (state) state->deinitializing = 0; +} diff --git a/ext_components/cp0_lvgl/src/cp0_lora_contract.cpp b/ext_components/cp0_lvgl/src/cp0_lora_contract.cpp new file mode 100644 index 00000000..66fb22d3 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_lora_contract.cpp @@ -0,0 +1,96 @@ +#include "cp0_lora_contract.hpp" + +#include +#include + +namespace cp0::lora { +namespace { + +std::string argument_at(const std::list &arguments, size_t index) +{ + auto iterator = arguments.begin(); + for (size_t current = 0; current < index && iterator != arguments.end(); ++current) + ++iterator; + return iterator == arguments.end() ? std::string() : *iterator; +} + +bool valid_text_payload(const std::string &payload) +{ + return !payload.empty() && payload.size() <= kMaxTextPayloadBytes && + payload.find('\0') == std::string::npos; +} + +} // namespace + +std::string encode_info(const cp0_lora_info_t &info) +{ + return {reinterpret_cast(&info), sizeof(info)}; +} + +bool decode_info(const std::string &payload, cp0_lora_info_t *info) +{ + if (!info || payload.size() != sizeof(*info)) + return false; + std::memcpy(info, payload.data(), sizeof(*info)); + return true; +} + +Result dispatch(const std::list &arguments, const Operations &operations) +{ + try { + if (arguments.empty()) + return {-1, "missing lora api command\n"}; + + const std::string &command = arguments.front(); + if (command == "Init") { + return operations.initialize && operations.initialize() ? Result{0, {}} : Result{-1, {}}; + } + if (command == "Poll" || command == "Info") { + if (!operations.read_info || (command == "Poll" && !operations.poll)) + return {-1, {}}; + if (command == "Poll") + operations.poll(); + cp0_lora_info_t info{}; + operations.read_info(&info, command == "Poll"); + return {0, encode_info(info)}; + } + if (command == "SendText") { + const std::string payload = argument_at(arguments, 1); + if (!valid_text_payload(payload) || !operations.send_text) + return {-1, {}}; + return {operations.send_text(payload) ? 0 : -1, {}}; + } + if (command == "StartReceive") { + if (!operations.start_receive) + return {-1, {}}; + operations.start_receive(); + return {0, {}}; + } + if (command == "SetTxMode") { + const std::string mode = argument_at(arguments, 1); + if ((mode != "0" && mode != "1") || !operations.set_tx_mode) + return {-1, {}}; + operations.set_tx_mode(mode == "1"); + return {0, {}}; + } + if (command == "Shutdown") { + if (!operations.shutdown) + return {-1, {}}; + operations.shutdown(); + return {0, {}}; + } + return {-1, "unknown lora api\n"}; + } catch (...) { + return {-1, "lora backend failure\n"}; + } +} + +Service::Service(Operations operations) : operations_(std::move(operations)) {} + +Result Service::call(const std::list &arguments) +{ + std::lock_guard lock(mutex_); + return dispatch(arguments, operations_); +} + +} // namespace cp0::lora diff --git a/ext_components/cp0_lvgl/src/cp0_lora_contract.hpp b/ext_components/cp0_lvgl/src/cp0_lora_contract.hpp new file mode 100644 index 00000000..073cd059 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_lora_contract.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include +#include +#include +#include + +namespace cp0::lora { + +constexpr size_t kMaxTextPayloadBytes = 127; + +struct Result { + int code = -1; + std::string payload; +}; + +struct Operations { + std::function initialize; + std::function poll; + std::function read_info; + std::function send_text; + std::function start_receive; + std::function set_tx_mode; + std::function shutdown; +}; + +std::string encode_info(const cp0_lora_info_t &info); +bool decode_info(const std::string &payload, cp0_lora_info_t *info); +Result dispatch(const std::list &arguments, const Operations &operations); + +class Service { +public: + explicit Service(Operations operations); + Result call(const std::list &arguments); + +private: + Operations operations_; + std::mutex mutex_; +}; + +} // namespace cp0::lora diff --git a/ext_components/cp0_lvgl/src/cp0_lvgl_app_page_assets.c b/ext_components/cp0_lvgl/src/cp0_lvgl_app_page_assets.c new file mode 100644 index 00000000..a02e0281 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_lvgl_app_page_assets.c @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "cp0_lvgl_app_page_assets.h" + +#include "../assets/cp0_status_time_background.inc" +#include "../assets/cp0_status_battery_background.inc" + +const lv_image_dsc_t cp0_status_time_background = { + .header = { + .magic = LV_IMAGE_HEADER_MAGIC, + .cf = LV_COLOR_FORMAT_ARGB8888, + .flags = 0, + .w = 40, + .h = 16, + .stride = 40 * 4, + }, + .data_size = sizeof(cp0_status_time_background_map), + .data = cp0_status_time_background_map, +}; + +const lv_image_dsc_t cp0_status_battery_background = { + .header = { + .magic = LV_IMAGE_HEADER_MAGIC, + .cf = LV_COLOR_FORMAT_ARGB8888, + .flags = 0, + .w = 36, + .h = 16, + .stride = 36 * 4, + }, + .data_size = sizeof(cp0_status_battery_background_map), + .data = cp0_status_battery_background_map, +}; diff --git a/ext_components/cp0_lvgl/src/cp0_lvgl_app_runner.cpp b/ext_components/cp0_lvgl/src/cp0_lvgl_app_runner.cpp new file mode 100644 index 00000000..f99582c0 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_lvgl_app_runner.cpp @@ -0,0 +1,203 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "cp0_lvgl_app_runner.hpp" + +#include "cp0_runner_contract.hpp" +#include "cp0_runner_shutdown.hpp" +#include "hal_lvgl_bsp.h" + +#include +#include +#include +#include +#include + +namespace { + +sem_t lvgl_sem; +std::mutex runner_mutex; +cp0::RunnerLifetime runner_lifetime; +bool semaphore_ready = false; + +void post_lvgl_semaphore() +{ + std::lock_guard lock(runner_mutex); + if (semaphore_ready) sem_post(&lvgl_sem); +} + +void resume_cb(void *) +{ + cp0::invoke_resume_callback([] { post_lvgl_semaphore(); }); +} + +void wait_for_lvgl(uint32_t milliseconds) +{ + struct timespec deadline; +#if defined(__linux__) + clock_gettime(CLOCK_MONOTONIC, &deadline); +#else + clock_gettime(CLOCK_REALTIME, &deadline); +#endif + deadline.tv_nsec += (milliseconds % 1000) * 1000000; + deadline.tv_sec += milliseconds / 1000 + deadline.tv_nsec / 1000000000; + deadline.tv_nsec %= 1000000000; + + int result; + do { +#if defined(__linux__) + result = sem_clockwait(&lvgl_sem, CLOCK_MONOTONIC, &deadline); +#else + result = sem_timedwait(&lvgl_sem, &deadline); +#endif + } while (result != 0 && errno == EINTR); +} + +void stop_imu() noexcept +{ +#ifdef CONFIG_CP0_LVGL_INIT_IMU + deinit_imu(); +#endif +} + +void stop_audio() noexcept +{ +#ifdef CONFIG_CP0_LVGL_INIT_AUDIO + deinit_audio(); +#endif +} + +void stop_pty() noexcept +{ +#ifdef CONFIG_CP0_LVGL_INIT_PTY + deinit_pty(); +#endif +} + +void stop_camera() noexcept +{ +#ifdef CONFIG_CP0_LVGL_INIT_CAMERA + deinit_camera(); +#endif +} + +void stop_sudo() noexcept +{ +#ifdef CONFIG_CP0_LVGL_INIT_SUDO + deinit_sudo(); +#endif +} + +void stop_rpc() noexcept +{ +#if defined(CONFIG_CP0_LVGL_INIT_RPC) && !defined(HAL_PLATFORM_SDL) + deinit_rpc(); +#endif +} + +void stop_lora() noexcept +{ +#ifdef CONFIG_CP0_LVGL_INIT_LORA + deinit_lora(); +#endif +} + +} // namespace + +void cp0_lvgl_wake() +{ + post_lvgl_semaphore(); +} + +int cp0_lvgl_run(Cp0LvglRunOptions options) +{ + { + std::lock_guard lock(runner_mutex); + if (!runner_lifetime.claim()) { + std::fprintf(stderr, "cp0_lvgl: runner supports one run per process\n"); + return 1; + } + if (sem_init(&lvgl_sem, 0, 0) != 0) { + runner_lifetime.initialization_failed(); + return 1; + } + semaphore_ready = true; + } + + lv_init(); + lv_timer_handler_set_resume_cb(resume_cb, nullptr); + bool cleaned_up = false; + auto cleanup = [&](bool teardown) { + if (cleaned_up) return false; + cleaned_up = true; + + bool teardown_failed = false; + if (teardown && options.teardown) { + try { + options.teardown(); + } catch (...) { + std::fprintf(stderr, "cp0_lvgl: teardown threw an exception\n"); + teardown_failed = true; + } + } + lv_timer_handler_set_resume_cb(nullptr, nullptr); + cp0::runner::shutdown_services( + stop_sudo, stop_rpc, stop_camera, stop_imu, stop_audio, stop_pty, + deinit_input, deinit_wifi, stop_lora, deinit_battery, lv_deinit); + std::lock_guard lock(runner_mutex); + semaphore_ready = false; + sem_destroy(&lvgl_sem); + runner_lifetime.finish(); + return teardown_failed; + }; + + bool setup_attempted = false; + try { + if (options.after_lvgl_init) + options.after_lvgl_init(); + + cp0_lvgl_init(); + if (options.after_resource_init) + options.after_resource_init(); + + if (!lv_display_get_default()) { + std::fprintf(stderr, "cp0_lvgl: failed to create LVGL display\n"); + cleanup(false); + return 1; + } + + if (options.setup) { + setup_attempted = true; + if (!options.setup()) { + cleanup(true); + return 1; + } + } + + lv_obj_invalidate(lv_screen_active()); + lv_refr_now(nullptr); + + while (!options.should_quit || !options.should_quit()) { + uint32_t milliseconds = lv_timer_handler(); + if (options.should_quit && options.should_quit()) + break; + if (milliseconds == LV_NO_TIMER_READY) + sem_wait(&lvgl_sem); + else + wait_for_lvgl(milliseconds); + } + + return cleanup(true) ? 1 : 0; + } catch (...) { + std::fprintf(stderr, "cp0_lvgl: runner callback threw an exception\n"); + cleanup(setup_attempted); + return 1; + } +} diff --git a/ext_components/cp0_lvgl/src/cp0_network_api_contract.cpp b/ext_components/cp0_lvgl/src/cp0_network_api_contract.cpp new file mode 100644 index 00000000..e2e7b872 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_network_api_contract.cpp @@ -0,0 +1,228 @@ +#include "cp0_network_api_contract.hpp" + +#include +#include +#include +#include +#include + +namespace cp0::network { +namespace { + +bool parse_integer(std::string_view text, int minimum, int maximum, int &value) +{ + if (text.empty()) + return false; + int parsed = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), parsed); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || + parsed < minimum || parsed > maximum) + return false; + value = parsed; + return true; +} + +std::string escape_field(std::string_view field) +{ + std::string encoded; + encoded.reserve(field.size()); + for (const char character : field) { + switch (character) { + case '\\': encoded += "\\\\"; break; + case ':': encoded += "\\:"; break; + case '\n': encoded += "\\n"; break; + case '\r': encoded += "\\r"; break; + default: encoded += character; break; + } + } + return encoded; +} + +bool split_record(std::string_view record, std::vector &fields) +{ + fields.clear(); + std::string field; + for (std::size_t index = 0; index < record.size(); ++index) { + const char character = record[index]; + if (character == ':') { + fields.push_back(field); + field.clear(); + continue; + } + if (character != '\\') { + field += character; + continue; + } + if (++index >= record.size()) + return false; + switch (record[index]) { + case '\\': field += '\\'; break; + case ':': field += ':'; break; + case 'n': field += '\n'; break; + case 'r': field += '\r'; break; + default: return false; + } + } + fields.push_back(field); + return true; +} + +template +void copy_string(char (&destination)[Size], const std::string &source) +{ + std::snprintf(destination, Size, "%s", source.c_str()); +} + +bool exact_size(const std::list &args, std::size_t size) +{ + return args.size() == size; +} + +} // namespace + +bool parse_api_request(const std::list &args, ApiRequest &request) +{ + request = {}; + if (args.empty()) + return false; + + auto argument = args.begin(); + const std::string &command = *argument++; + if (command == "Status") { + request.command = ApiCommand::Status; + return exact_size(args, 1); + } + if (command == "Scan") { + request.command = ApiCommand::Scan; + if (exact_size(args, 1)) + return true; + return exact_size(args, 2) && + parse_integer(*argument, 0, CP0_WIFI_AP_MAX, request.scan_limit); + } + if (command == "Connect") { + request.command = ApiCommand::Connect; + if (args.size() != 2 && args.size() != 3) + return false; + request.ssid = *argument++; + if (request.ssid.empty()) + return false; + if (argument != args.end()) + request.password = *argument; + return true; + } + if (command == "Disconnect") { + request.command = ApiCommand::Disconnect; + return exact_size(args, 1); + } + if (command == "ProfileForget" || command == "ProfileExists") { + request.command = command == "ProfileForget" ? ApiCommand::ProfileForget : ApiCommand::ProfileExists; + if (!exact_size(args, 2)) + return false; + request.ssid = *argument; + return !request.ssid.empty(); + } + if (command == "ProfileDisconnectActive") { + request.command = ApiCommand::ProfileDisconnectActive; + return exact_size(args, 1); + } + if (command == "RadioEnabled") { + request.command = ApiCommand::RadioEnabled; + return exact_size(args, 1); + } + if (command == "RadioSetEnabled") { + request.command = ApiCommand::RadioSetEnabled; + if (!exact_size(args, 2)) + return false; + const std::string &state = *argument; + if (state == "on" || state == "1" || state == "true") + request.radio_enabled = true; + else if (state != "off" && state != "0" && state != "false") + return false; + return true; + } + return false; +} + +const char *invalid_api_request_message() +{ + return "invalid wifi api request"; +} + +std::string encode_status_payload(const cp0_wifi_status_t &status) +{ + return std::to_string(status.connected ? 1 : 0) + ':' + escape_field(status.ssid) + ':' + + escape_field(status.ip) + ':' + std::to_string(status.signal) + ':' + + std::to_string(status.ethernet ? 1 : 0); +} + +bool decode_status_payload(const std::string &payload, cp0_wifi_status_t &status) +{ + std::vector fields; + int connected = 0; + int signal = 0; + int ethernet = 0; + if (!split_record(payload, fields) || fields.size() != 5 || + !parse_integer(fields[0], 0, 1, connected) || + !parse_integer(fields[3], 0, 100, signal) || + !parse_integer(fields[4], 0, 1, ethernet)) + return false; + + cp0_wifi_status_t decoded{}; + decoded.connected = connected; + copy_string(decoded.ssid, fields[1]); + copy_string(decoded.ip, fields[2]); + decoded.signal = signal; + decoded.ethernet = ethernet; + status = decoded; + return true; +} + +std::string encode_scan_payload(const cp0_wifi_ap_t *access_points, int count) +{ + if (!access_points || count <= 0) + return {}; + std::string payload; + for (int index = 0; index < count; ++index) { + const auto &access_point = access_points[index]; + payload += escape_field(access_point.ssid) + ':' + std::to_string(access_point.signal) + ':' + + escape_field(access_point.security) + ':' + std::to_string(access_point.in_use ? 1 : 0) + ':' + + std::to_string(access_point.saved ? 1 : 0) + '\n'; + } + return payload; +} + +int decode_scan_payload(const std::string &payload, cp0_wifi_ap_t *access_points, int capacity) +{ + if (!access_points || capacity <= 0) + return 0; + int count = 0; + std::size_t start = 0; + while (start < payload.size() && count < capacity) { + const std::size_t end = payload.find('\n', start); + const std::string_view record(payload.data() + start, + (end == std::string::npos ? payload.size() : end) - start); + start = end == std::string::npos ? payload.size() : end + 1; + if (record.empty()) + continue; + + std::vector fields; + int signal = 0; + int in_use = 0; + int saved = 0; + if (!split_record(record, fields) || (fields.size() != 4 && fields.size() != 5) || fields[0].empty() || + !parse_integer(fields[1], 0, 100, signal) || + !parse_integer(fields[3], 0, 1, in_use) || + (fields.size() == 5 && !parse_integer(fields[4], 0, 1, saved))) + continue; + cp0_wifi_ap_t decoded{}; + copy_string(decoded.ssid, fields[0]); + decoded.signal = signal; + copy_string(decoded.security, fields[2]); + decoded.in_use = in_use; + decoded.saved = saved; + access_points[count++] = decoded; + } + return count; +} + +} // namespace cp0::network diff --git a/ext_components/cp0_lvgl/src/cp0_network_api_contract.hpp b/ext_components/cp0_lvgl/src/cp0_network_api_contract.hpp new file mode 100644 index 00000000..6e5d3f6c --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_network_api_contract.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include +#include + +namespace cp0::network { + +enum class ApiCommand { + Status, + Scan, + Connect, + Disconnect, + ProfileForget, + ProfileExists, + ProfileDisconnectActive, + RadioEnabled, + RadioSetEnabled, +}; + +struct ApiRequest +{ + ApiCommand command = ApiCommand::Status; + int scan_limit = CP0_WIFI_AP_MAX; + std::string ssid; + std::string password; + bool radio_enabled = false; +}; + +bool parse_api_request(const std::list &args, ApiRequest &request); +const char *invalid_api_request_message(); + +std::string encode_status_payload(const cp0_wifi_status_t &status); +bool decode_status_payload(const std::string &payload, cp0_wifi_status_t &status); +std::string encode_scan_payload(const cp0_wifi_ap_t *access_points, int count); +int decode_scan_payload( + const std::string &payload, cp0_wifi_ap_t *access_points, int capacity); + +} // namespace cp0::network diff --git a/ext_components/cp0_lvgl/src/cp0_osinfo_codec.cpp b/ext_components/cp0_lvgl/src/cp0_osinfo_codec.cpp new file mode 100644 index 00000000..7b59abe8 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_osinfo_codec.cpp @@ -0,0 +1,107 @@ +#include "cp0_osinfo_codec.hpp" + +#include "cp0_app_internal_utils.h" + +#include +#include + +namespace cp0::osinfo { + +void clear_eth_info(cp0_eth_info_t *info) +{ + if (!info) + return; + std::memset(info, 0, sizeof(*info)); + cp0_copy_cstr(info->ipv4, sizeof(info->ipv4), "N/A"); + cp0_copy_cstr(info->gateway, sizeof(info->gateway), "N/A"); + cp0_copy_cstr(info->mac, sizeof(info->mac), "N/A"); +} + +std::string encode_eth_info(const cp0_eth_info_t &info) +{ + return std::string(info.ipv4) + "\n" + info.gateway + "\n" + info.mac; +} + +void decode_eth_info(const std::string &payload, cp0_eth_info_t *info) +{ + clear_eth_info(info); + if (!info) + return; + + std::istringstream lines(payload); + std::string line; + if (std::getline(lines, line)) + cp0_copy_string(info->ipv4, sizeof(info->ipv4), line); + if (std::getline(lines, line)) + cp0_copy_string(info->gateway, sizeof(info->gateway), line); + if (std::getline(lines, line)) + cp0_copy_string(info->mac, sizeof(info->mac), line); +} + +bool decode_eth_info_strict(const std::string &payload, cp0_eth_info_t *info) +{ + clear_eth_info(info); + if (!info) + return false; + const size_t first = payload.find('\n'); + const size_t second = first == std::string::npos ? std::string::npos : payload.find('\n', first + 1); + if (first == std::string::npos || second == std::string::npos || + payload.find('\n', second + 1) != std::string::npos) + return false; + cp0_copy_string(info->ipv4, sizeof(info->ipv4), payload.substr(0, first)); + cp0_copy_string(info->gateway, sizeof(info->gateway), payload.substr(first + 1, second - first - 1)); + cp0_copy_string(info->mac, sizeof(info->mac), payload.substr(second + 1)); + return true; +} + +std::string encode_account_info(const cp0_account_info_t &info) +{ + return std::string(info.user) + "\n" + info.hostname; +} + +void decode_account_info(const std::string &payload, cp0_account_info_t *info) +{ + if (!info) + return; + std::memset(info, 0, sizeof(*info)); + + std::istringstream lines(payload); + std::string line; + if (std::getline(lines, line)) + cp0_copy_string(info->user, sizeof(info->user), line); + if (std::getline(lines, line)) + cp0_copy_string(info->hostname, sizeof(info->hostname), line); +} + +bool decode_account_info_strict(const std::string &payload, cp0_account_info_t *info) +{ + if (!info) + return false; + std::memset(info, 0, sizeof(*info)); + const size_t separator = payload.find('\n'); + if (separator == std::string::npos || payload.find('\n', separator + 1) != std::string::npos) + return false; + cp0_copy_string(info->user, sizeof(info->user), payload.substr(0, separator)); + cp0_copy_string(info->hostname, sizeof(info->hostname), payload.substr(separator + 1)); + return true; +} + +std::string encode_network_list(const cp0_netif_info_t *entries, int count) +{ + std::ostringstream out; + for (int i = 0; entries && i < count; ++i) { + out << entries[i].iface << '\t' << entries[i].ipv4 << '\t' + << entries[i].netmask << '\t' << entries[i].is_up << '\n'; + } + return out.str(); +} + +std::string encode_local_time(const std::tm &value) +{ + std::ostringstream out; + out << value.tm_year + 1900 << ',' << value.tm_mon + 1 << ',' << value.tm_mday << ',' + << value.tm_hour << ',' << value.tm_min << ',' << value.tm_sec; + return out.str(); +} + +} // namespace cp0::osinfo diff --git a/ext_components/cp0_lvgl/src/cp0_osinfo_codec.hpp b/ext_components/cp0_lvgl/src/cp0_osinfo_codec.hpp new file mode 100644 index 00000000..7f20ab6f --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_osinfo_codec.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include +#include + +namespace cp0::osinfo { + +void clear_eth_info(cp0_eth_info_t *info); +std::string encode_eth_info(const cp0_eth_info_t &info); +void decode_eth_info(const std::string &payload, cp0_eth_info_t *info); +bool decode_eth_info_strict(const std::string &payload, cp0_eth_info_t *info); + +std::string encode_account_info(const cp0_account_info_t &info); +void decode_account_info(const std::string &payload, cp0_account_info_t *info); +bool decode_account_info_strict(const std::string &payload, cp0_account_info_t *info); + +std::string encode_network_list(const cp0_netif_info_t *entries, int count); +std::string encode_local_time(const std::tm &value); + +} // namespace cp0::osinfo diff --git a/ext_components/cp0_lvgl/src/cp0_osinfo_contract.cpp b/ext_components/cp0_lvgl/src/cp0_osinfo_contract.cpp new file mode 100644 index 00000000..3738dba3 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_osinfo_contract.cpp @@ -0,0 +1,123 @@ +#include "cp0_osinfo_contract.hpp" + +#include "cp0_osinfo_codec.hpp" + +#include + +namespace cp0::osinfo { +namespace { + +constexpr int kMaxNetworkEntries = 64; + +int decimal_field(const std::string &value, size_t offset, size_t length) +{ + int result = 0; + for (size_t index = offset; index < offset + length; ++index) { + if (value[index] < '0' || value[index] > '9') + return -1; + result = result * 10 + value[index] - '0'; + } + return result; +} + +bool valid_timestamp(const std::string &value) +{ + if (value.size() != 19 || value[4] != '-' || value[7] != '-' || value[10] != ' ' || + value[13] != ':' || value[16] != ':') + return false; + const int year = decimal_field(value, 0, 4); + const int month = decimal_field(value, 5, 2); + const int day = decimal_field(value, 8, 2); + const int hour = decimal_field(value, 11, 2); + const int minute = decimal_field(value, 14, 2); + const int second = decimal_field(value, 17, 2); + if (year < 0 || month < 1 || month > 12 || hour < 0 || hour > 23 || + minute < 0 || minute > 59 || second < 0 || second > 59) + return false; + constexpr int days_per_month[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + int days = days_per_month[month - 1]; + if (month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) + ++days; + return day >= 1 && day <= days; +} + +std::string argument_at(const std::list &arguments, size_t index) +{ + auto iterator = arguments.begin(); + for (size_t current = 0; current < index && iterator != arguments.end(); ++current) + ++iterator; + return iterator == arguments.end() ? std::string() : *iterator; +} + +} // namespace + +Result dispatch(const std::list &arguments, const Operations &operations) +{ + try { + const std::string command = argument_at(arguments, 0); + if (command == "NetworkDefaultInfoRead" || command == "EthInfoRead") { + cp0_eth_info_t info{}; + if (!operations.read_eth_info) + return {}; + const int code = operations.read_eth_info(command == "NetworkDefaultInfoRead", &info); + return {code, encode_eth_info(info)}; + } + if (command == "NetworkList") { + if (!operations.list_networks) + return {}; + cp0_netif_info_t entries[kMaxNetworkEntries]{}; + int count = 0; + const int code = operations.list_networks(entries, kMaxNetworkEntries, &count); + if (code != 0) + return {code, {}}; + if (count < 0 || count > kMaxNetworkEntries) + return {-1, {}}; + return {0, encode_network_list(entries, count)}; + } + if (command == "AccountInfoRead") { + cp0_account_info_t info{}; + if (!operations.read_account_info) + return {}; + const int code = operations.read_account_info(&info); + return {code, encode_account_info(info)}; + } + if (command == "TimeSet") { + const std::string timestamp = argument_at(arguments, 1); + if (!valid_timestamp(timestamp)) + return {-1, {}}; + return operations.set_time ? Result{operations.set_time(timestamp), {}} : Result{}; + } + if (command == "LocalTime") { + if (!operations.read_local_time) + return {}; + std::tm value{}; + const int code = operations.read_local_time(&value); + return {code, code == 0 ? encode_local_time(value) : std::string()}; + } + if (command == "RandomU32") { + return operations.random_u32 ? Result{0, std::to_string(operations.random_u32())} : Result{}; + } + if (command == "NtpGet") { + return operations.get_ntp ? Result{operations.get_ntp(), {}} : Result{}; + } + if (command == "NtpSet") { + const std::string enabled = argument_at(arguments, 1); + if (enabled != "0" && enabled != "1") + return {-1, {}}; + return operations.set_ntp ? Result{operations.set_ntp(enabled == "1"), {}} : Result{}; + } + if (command == "AptUpdateBackground") { + return operations.apt_update_background ? Result{operations.apt_update_background(), {}} : Result{}; + } + if (command == "UpdateLauncherBackground") { + return operations.update_launcher_background + ? Result{operations.update_launcher_background(), {}} + : Result{}; + } + return {-1, "unknown osinfo api command"}; + } catch (...) { + return {-1, "osinfo backend failure"}; + } +} + +} // namespace cp0::osinfo diff --git a/ext_components/cp0_lvgl/src/cp0_osinfo_contract.hpp b/ext_components/cp0_lvgl/src/cp0_osinfo_contract.hpp new file mode 100644 index 00000000..499cf2ee --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_osinfo_contract.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include +#include +#include +#include +#include + +namespace cp0::osinfo { + +struct Result { + int code = -1; + std::string payload; +}; + +struct Operations { + std::function read_eth_info; + std::function list_networks; + std::function read_account_info; + std::function set_time; + std::function read_local_time; + std::function random_u32; + std::function get_ntp; + std::function set_ntp; + std::function apt_update_background; + std::function update_launcher_background; +}; + +Result dispatch(const std::list &arguments, const Operations &operations); + +} // namespace cp0::osinfo diff --git a/ext_components/cp0_lvgl/src/cp0_pointer_lifecycle.hpp b/ext_components/cp0_lvgl/src/cp0_pointer_lifecycle.hpp new file mode 100644 index 00000000..961d6992 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_pointer_lifecycle.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include + +namespace cp0 { + +template +bool can_mount_child(T *parent, T *current) +{ + return parent != nullptr && current == nullptr; +} + +template +bool display_references_screen(T *owned, T *active, T *loading, T *previous) +{ + return owned != nullptr && + (owned == active || owned == loading || owned == previous); +} + +template +bool clear_if_deleted(T *¤t, T *deleted) +{ + if (!deleted || current != deleted) return false; + current = nullptr; + return true; +} + +template +bool is_direct_event_target(T *target, T *current_target) +{ + return target != nullptr && target == current_target; +} + +constexpr int app_content_height(bool has_bottom_bar) +{ + return has_bottom_bar ? 130 : 150; +} + +template +bool input_type_uses_group(Type type, Type keypad, Type encoder) +{ + return type == keypad || type == encoder; +} + +template +class RollbackGuard +{ +public: + explicit RollbackGuard(Callback callback) + : callback_(std::move(callback)) + { + } + + ~RollbackGuard() noexcept + { + if (!active_) return; + try { + callback_(); + } catch (...) { + } + } + + RollbackGuard(const RollbackGuard &) = delete; + RollbackGuard &operator=(const RollbackGuard &) = delete; + RollbackGuard(RollbackGuard &&) = delete; + RollbackGuard &operator=(RollbackGuard &&) = delete; + + void dismiss() noexcept { active_ = false; } + +private: + Callback callback_; + bool active_ = true; +}; + +template +RollbackGuard> make_rollback_guard(Callback &&callback) +{ + return RollbackGuard>( + std::forward(callback)); +} + +} // namespace cp0 diff --git a/ext_components/cp0_lvgl/src/cp0_posix_filesystem.cpp b/ext_components/cp0_lvgl/src/cp0_posix_filesystem.cpp new file mode 100644 index 00000000..1fecf5f7 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_posix_filesystem.cpp @@ -0,0 +1,131 @@ +#include "cp0_posix_filesystem.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cp0_posix_filesystem { +namespace { + +bool is_dot_entry(const char *name) +{ + return name && name[0] == '.' && + (name[1] == '\0' || (name[1] == '.' && name[2] == '\0')); +} + +std::string join_path(const char *base, const char *name) +{ + std::string path = base ? base : ""; + if (path != "/" && !path.empty() && path.back() != '/') path.push_back('/'); + path += name ? name : ""; + return path; +} + +bool directory_entry_is_dir(const char *path, const dirent &entry) +{ + if (entry.d_type == DT_DIR) return true; + if (entry.d_type != DT_UNKNOWN) return false; + struct stat status{}; + return stat(join_path(path, entry.d_name).c_str(), &status) == 0 && S_ISDIR(status.st_mode); +} + +std::string encode_field(const char *value) +{ + static constexpr char hex[] = "0123456789ABCDEF"; + std::string encoded; + for (const unsigned char character : std::string(value ? value : "")) { + if (character == '%' || character == '\t' || character == '\n' || character == '\r') { + encoded.push_back('%'); + encoded.push_back(hex[character >> 4]); + encoded.push_back(hex[character & 0x0f]); + } else { + encoded.push_back(static_cast(character)); + } + } + return encoded; +} + +int open_failure(bool errno_on_open_failure) +{ + return errno_on_open_failure ? -errno : -1; +} + +} // namespace + +int list_directory(const char *path, cp0_dirent_t *entries, int max_entries, int *out_count, + bool errno_on_open_failure) +{ + if (out_count) *out_count = 0; + if (!path || !out_count) return -1; + if (!entries || max_entries <= 0) return 0; + + DIR *directory = opendir(path); + if (!directory) return open_failure(errno_on_open_failure); + while (dirent *entry = readdir(directory)) { + if (is_dot_entry(entry->d_name)) continue; + if (*out_count >= max_entries) break; + cp0_dirent_t &output = entries[*out_count]; + std::strncpy(output.name, entry->d_name, sizeof(output.name) - 1); + output.name[sizeof(output.name) - 1] = '\0'; + output.is_dir = directory_entry_is_dir(path, *entry) ? 1 : 0; + ++(*out_count); + } + closedir(directory); + return 0; +} + +int encode_directory(const char *path, bool include_size, bool errno_on_open_failure, + std::string &output) +{ + DIR *directory = opendir(path); + if (!directory) return open_failure(errno_on_open_failure); + try { + std::ostringstream encoded; + while (dirent *entry = readdir(directory)) { + if (is_dot_entry(entry->d_name)) continue; + const std::string full_path = join_path(path, entry->d_name); + struct stat status{}; + if (include_size) { + if (stat(full_path.c_str(), &status) != 0) continue; + } else { + status.st_mode = directory_entry_is_dir(path, *entry) ? S_IFDIR : S_IFREG; + } + encoded << (S_ISDIR(status.st_mode) ? 'D' : 'F') << '\t'; + if (include_size) encoded << static_cast(status.st_size) << '\t'; + encoded << encode_field(entry->d_name) << '\n'; + } + output = encoded.str(); + } catch (const std::bad_alloc &) { + closedir(directory); + return -ENOMEM; + } + closedir(directory); + return 0; +} + +int read_file_limited(const std::string &path, size_t max_bytes, std::string &output) +{ + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) return errno ? -errno : -EIO; + const std::streamoff length = file.tellg(); + if (length < 0) return -EIO; + if (static_cast(length) > max_bytes) return -EFBIG; + try { + output.resize(static_cast(length)); + } catch (const std::bad_alloc &) { + return -ENOMEM; + } + file.seekg(0); + if (length > 0 && !file.read(&output[0], length)) { + output.clear(); + return -EIO; + } + return 0; +} + +} // namespace cp0_posix_filesystem diff --git a/ext_components/cp0_lvgl/src/cp0_posix_filesystem.hpp b/ext_components/cp0_lvgl/src/cp0_posix_filesystem.hpp new file mode 100644 index 00000000..4b7c0e02 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_posix_filesystem.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include "cp0_lvgl_app.h" + +#include +#include + +namespace cp0_posix_filesystem { + +int list_directory(const char *path, cp0_dirent_t *entries, int max_entries, int *out_count, + bool errno_on_open_failure); +int encode_directory(const char *path, bool include_size, bool errno_on_open_failure, + std::string &output); +int read_file_limited(const std::string &path, size_t max_bytes, std::string &output); + +} // namespace cp0_posix_filesystem diff --git a/ext_components/cp0_lvgl/src/cp0_process_api_contract.hpp b/ext_components/cp0_lvgl/src/cp0_process_api_contract.hpp new file mode 100644 index 00000000..c193481b --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_process_api_contract.hpp @@ -0,0 +1,150 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cp0_process_api_contract { + +template +inline int invoke_c_api(Operation &&operation) noexcept +{ + try { + return std::forward(operation)(); + } catch (const std::bad_alloc &) { + return -ENOMEM; + } catch (...) { + return -EIO; + } +} + +template +inline void invoke_c_api_void(Operation &&operation) noexcept +{ + try { + std::forward(operation)(); + } catch (...) { + } +} + +inline constexpr int MAX_GRACE_MS = 300000; +inline constexpr int MAX_DELAY_MS = 300000; + +inline bool has_exact_arguments(const std::list &args, std::size_t count) +{ + return args.size() == count; +} + +inline bool has_at_least_arguments(const std::list &args, std::size_t count) +{ + return args.size() >= count; +} + +inline const std::string *argument_at(const std::list &args, std::size_t index) +{ + auto iterator = args.begin(); + while (index > 0 && iterator != args.end()) { + --index; + ++iterator; + } + return iterator == args.end() ? nullptr : &*iterator; +} + +inline std::vector arguments_from(const std::list &args, + std::size_t index) +{ + std::vector result; + auto iterator = args.begin(); + while (index > 0 && iterator != args.end()) { + --index; + ++iterator; + } + result.reserve(static_cast(std::distance(iterator, args.end()))); + for (; iterator != args.end(); ++iterator) result.push_back(*iterator); + return result; +} + +template +inline bool parse_integer(std::string_view text, Integer minimum, Integer maximum, Integer &value) +{ + if (text.empty()) return false; + Integer parsed = 0; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, parsed); + if (result.ec != std::errc{} || result.ptr != end || parsed < minimum || parsed > maximum) + return false; + value = parsed; + return true; +} + +inline bool parse_bool(std::string_view text, bool &value) +{ + int parsed = 0; + if (!parse_integer(text, 0, 1, parsed)) return false; + value = parsed != 0; + return true; +} + +inline bool parse_pid(std::string_view text, int &value) +{ + return parse_integer(text, 1, std::numeric_limits::max(), value); +} + +inline bool parse_grace_ms(std::string_view text, int &value) +{ + return parse_integer(text, 0, MAX_GRACE_MS, value); +} + +inline bool parse_delay_ms(std::string_view text, int &value) +{ + return parse_integer(text, 0, MAX_DELAY_MS, value); +} + +inline bool parse_pointer(std::string_view text, uintptr_t &value) +{ + return parse_integer(text, uintptr_t{0}, std::numeric_limits::max(), value); +} + +inline bool parse_spawn_response(std::string_view text, int &pid) +{ + return parse_pid(text, pid); +} + +inline bool parse_lock_holder_response(std::string_view text, int &pid) +{ + return parse_integer(text, 0, std::numeric_limits::max(), pid); +} + +inline void invoke_callback_safely( + const std::function &callback, + int code, + const std::string &data) noexcept +{ + if (!callback) return; + try { + callback(code, data); + } catch (...) { + } +} + +inline std::list make_run_sudo_request(const char *password, + const char *const *argv) +{ + std::list request = {"RunSudo", password ? password : ""}; + if (argv) { + for (int index = 0; argv[index]; ++index) + request.emplace_back(argv[index]); + } + return request; +} + +} // namespace cp0_process_api_contract diff --git a/ext_components/cp0_lvgl/src/cp0_pty_contract.cpp b/ext_components/cp0_lvgl/src/cp0_pty_contract.cpp new file mode 100644 index 00000000..8dd4c118 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_pty_contract.cpp @@ -0,0 +1,116 @@ +#include "cp0_pty_contract.hpp" + +#include "cp0_integer_codec.hpp" + +#include +#include +#include +#include + +namespace cp0::pty { +namespace { + +constexpr int kDefaultColumns = 80; +constexpr int kDefaultRows = 24; +constexpr std::size_t kDefaultRead = 4096; +constexpr std::size_t kMaximumRead = 1024 * 1024; + +std::string argument_at(const std::list &arguments, std::size_t index) +{ + if (index >= arguments.size()) return {}; + auto it = arguments.begin(); + std::advance(it, static_cast(index)); + return *it; +} + +bool parse_positive_int(const std::string &text, int &value) +{ + return cp0_integer_codec::parse_decimal(text, 1, static_cast(USHRT_MAX), value); +} + +bool parse_read_size(const std::string &text, std::size_t &value) +{ + return cp0_integer_codec::parse_decimal(text, std::size_t{1}, kMaximumRead, value); +} + +ParseResult failure(const std::string &message) +{ + ParseResult result; + result.error = message; + return result; +} + +bool parse_required_handle(const std::list &arguments, Request &request) +{ + return decode_handle(argument_at(arguments, 1), request.handle); +} + +} // namespace + +std::string encode_handle(std::uint64_t handle) +{ + return handle == 0 ? std::string() : std::to_string(handle); +} + +bool decode_handle(const std::string &text, std::uint64_t &handle) +{ + handle = 0; + return cp0_integer_codec::parse_decimal( + text, std::uint64_t{1}, std::numeric_limits::max(), handle); +} + +ParseResult parse_request(const std::list &arguments) +{ + if (arguments.empty()) return failure("empty pty api\n"); + + ParseResult result; + Request &request = result.request; + const std::string command = arguments.front(); + if (command == "Open") { + request.command = Command::open; + request.executable = argument_at(arguments, 1); + if (request.executable.empty()) return failure("empty pty command\n"); + if (!argument_at(arguments, 2).empty() && + !parse_positive_int(argument_at(arguments, 2), request.columns)) + return failure("invalid pty columns\n"); + if (!argument_at(arguments, 3).empty() && + !parse_positive_int(argument_at(arguments, 3), request.rows)) + return failure("invalid pty rows\n"); + if (argument_at(arguments, 2).empty()) request.columns = kDefaultColumns; + if (argument_at(arguments, 3).empty()) request.rows = kDefaultRows; + auto it = arguments.begin(); + std::advance(it, std::min(4, arguments.size())); + request.argv.assign(it, arguments.end()); + } else if (command == "Read") { + request.command = Command::read; + if (!parse_required_handle(arguments, request)) return failure("invalid pty handle\n"); + request.max_read = kDefaultRead; + if (!argument_at(arguments, 2).empty() && + !parse_read_size(argument_at(arguments, 2), request.max_read)) + return failure("invalid pty read size\n"); + } else if (command == "Write") { + request.command = Command::write; + if (!parse_required_handle(arguments, request)) return failure("invalid pty handle\n"); + if (arguments.size() < 3) return failure("missing pty write data\n"); + request.data = argument_at(arguments, 2); + } else if (command == "Resize") { + request.command = Command::resize; + if (!parse_required_handle(arguments, request)) return failure("invalid pty handle\n"); + if (!parse_positive_int(argument_at(arguments, 2), request.columns) || + !parse_positive_int(argument_at(arguments, 3), request.rows)) + return failure("invalid pty size\n"); + } else if (command == "CheckChild") { + request.command = Command::check_child; + if (!parse_required_handle(arguments, request)) return failure("invalid pty handle\n"); + } else if (command == "Close") { + request.command = Command::close; + if (!parse_required_handle(arguments, request)) return failure("invalid pty handle\n"); + } else { + return failure("unknown pty api: " + command + "\n"); + } + + result.ok = true; + return result; +} + +} // namespace cp0::pty diff --git a/ext_components/cp0_lvgl/src/cp0_pty_contract.hpp b/ext_components/cp0_lvgl/src/cp0_pty_contract.hpp new file mode 100644 index 00000000..4ee4550a --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_pty_contract.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace cp0::pty { + +enum class Command { + open, + read, + write, + resize, + check_child, + close, +}; + +struct Request { + Command command = Command::open; + std::uint64_t handle = 0; + std::string executable; + std::string data; + int columns = 80; + int rows = 24; + std::size_t max_read = 4096; + std::vector argv; +}; + +struct ParseResult { + bool ok = false; + Request request; + std::string error; +}; + +ParseResult parse_request(const std::list &arguments); +std::string encode_handle(std::uint64_t handle); +bool decode_handle(const std::string &text, std::uint64_t &handle); + +} // namespace cp0::pty diff --git a/ext_components/cp0_lvgl/src/cp0_pty_posix.inc b/ext_components/cp0_lvgl/src/cp0_pty_posix.inc new file mode 100644 index 00000000..a5d66e23 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_pty_posix.inc @@ -0,0 +1,427 @@ +#include "hal_lvgl_bsp.h" +#include "cp0_pty_contract.hpp" +#include "cp0_signal_registration.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#endif + +namespace { + +typedef void *pty_handle_t; + +struct cp0_pty_handle { + int master_fd; + pid_t child_pid; + bool child_reaped; + int exit_status; +}; + +class PtySystem { +public: + typedef std::function callback_t; + typedef std::list arg_t; + + ~PtySystem() + { + shutdown(); + } + + pty_handle_t open(const char *cmd, const char *const *args, int cols, int rows) + { +#if defined(__linux__) + if (!cmd || !cmd[0]) return NULL; + + int master_fd = -1; + struct winsize ws = {}; + ws.ws_col = cols; + ws.ws_row = rows; + std::string run_as_user = config_get_str("run_as_user", ""); + + pid_t pid = forkpty(&master_fd, NULL, NULL, &ws); + if (pid < 0) return NULL; + + if (pid == 0) { + setenv("TERM", "vt100", 1); + drop_root_user(run_as_user); + + if (args) + execvp(cmd, const_cast(args)); + else + execlp(cmd, cmd, static_cast(NULL)); + _exit(127); + } + + int flags = fcntl(master_fd, F_GETFL); + if (flags >= 0) fcntl(master_fd, F_SETFL, flags | O_NONBLOCK); + + cp0_pty_handle *pty = static_cast(std::malloc(sizeof(cp0_pty_handle))); + if (!pty) { + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + ::close(master_fd); + return NULL; + } + pty->master_fd = master_fd; + pty->child_pid = pid; + pty->child_reaped = false; + pty->exit_status = 0; + { + std::lock_guard lock(handles_mutex_); + if (!accepting_) { + cleanup(pty); + return NULL; + } + try { + handles_.emplace(reinterpret_cast(pty), pty); + } catch (...) { + cleanup(pty); + return NULL; + } + } + return pty; +#else + (void)cmd; + (void)args; + (void)cols; + (void)rows; + return NULL; +#endif + } + + int read(pty_handle_t pty, char *buf, size_t buf_size) + { +#if defined(__linux__) + std::lock_guard lock(handles_mutex_); + cp0_pty_handle *h = resolve(pty); + if (!h || !buf || buf_size == 0) return -1; + ssize_t n = ::read(h->master_fd, buf, buf_size); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return 0; + return -1; + } + return static_cast(n); +#else + (void)pty; + (void)buf; + (void)buf_size; + return -1; +#endif + } + + int write(pty_handle_t pty, const char *buf, size_t len) + { +#if defined(__linux__) + std::lock_guard lock(handles_mutex_); + cp0_pty_handle *h = resolve(pty); + if (!h || !buf) return -1; + return static_cast(::write(h->master_fd, buf, len)); +#else + (void)pty; + (void)buf; + (void)len; + return -1; +#endif + } + + int check_child(pty_handle_t pty, int *exit_status) + { +#if defined(__linux__) + std::lock_guard lock(handles_mutex_); + cp0_pty_handle *h = resolve(pty); + if (!h) return -1; + if (h->child_reaped) { + if (exit_status) *exit_status = h->exit_status; + return 1; + } + int status = 0; + pid_t r = waitpid(h->child_pid, &status, WNOHANG); + if (r == 0) return 0; + if (r > 0) { + h->child_reaped = true; + h->exit_status = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + if (exit_status) *exit_status = h->exit_status; + return 1; + } + return -1; +#else + (void)pty; + (void)exit_status; + return -1; +#endif + } + + int resize(pty_handle_t pty, int cols, int rows) + { +#if defined(__linux__) + std::lock_guard lock(handles_mutex_); + cp0_pty_handle *h = resolve(pty); + if (!h) return -1; + struct winsize ws = {}; + ws.ws_col = static_cast(cols); + ws.ws_row = static_cast(rows); + return ioctl(h->master_fd, TIOCSWINSZ, &ws); +#else + (void)pty; + (void)cols; + (void)rows; + return -1; +#endif + } + + bool close(pty_handle_t pty) + { +#if defined(__linux__) + cp0_pty_handle *handle = nullptr; + { + std::lock_guard lock(handles_mutex_); + auto it = handles_.find(reinterpret_cast(pty)); + if (it == handles_.end()) return false; + handle = it->second; + handles_.erase(it); + } + cleanup(handle); + return true; +#else + (void)pty; + return false; +#endif + } + + void shutdown() noexcept + { +#if defined(__linux__) + std::unordered_map handles; + try { + std::lock_guard lock(handles_mutex_); + accepting_ = false; + handles.swap(handles_); + } catch (...) { + return; + } + for (const auto &entry : handles) cleanup(entry.second); +#endif + } + + void api_call(arg_t arg, callback_t callback) + { + cp0::pty::ParseResult parsed = cp0::pty::parse_request(arg); + if (!parsed.ok) return report(callback, -1, parsed.error); + const cp0::pty::Request &request = parsed.request; + switch (request.command) { + case cp0::pty::Command::open: api_open(request, callback); break; + case cp0::pty::Command::read: api_read(request, callback); break; + case cp0::pty::Command::write: api_write(request, callback); break; + case cp0::pty::Command::resize: { + const int result = resize(to_handle(request.handle), request.columns, request.rows); + report(callback, result, result < 0 ? "resize pty failed\n" : ""); + break; + } + case cp0::pty::Command::check_child: api_check_child(request, callback); break; + case cp0::pty::Command::close: { + const bool closed = close(to_handle(request.handle)); + report(callback, closed ? 0 : -1, closed ? "" : "close pty failed\n"); + break; + } + } + } + +private: + static void cleanup(cp0_pty_handle *handle) noexcept + { +#if defined(__linux__) + if (!handle) return; + if (!handle->child_reaped) { + (void)kill(handle->child_pid, SIGKILL); + while (waitpid(handle->child_pid, NULL, 0) < 0 && errno == EINTR) { + } + } + if (handle->master_fd >= 0) (void)::close(handle->master_fd); + std::free(handle); +#else + (void)handle; +#endif + } + + static void report(callback_t callback, int code, const std::string &data) + { + if (callback) callback(code, data); + } + + cp0_pty_handle *resolve(pty_handle_t pty) + { + auto it = handles_.find(reinterpret_cast(pty)); + return it == handles_.end() ? nullptr : it->second; + } + + static pty_handle_t to_handle(std::uint64_t value) + { + if (value > static_cast(UINTPTR_MAX)) return nullptr; + return reinterpret_cast(static_cast(value)); + } + + static std::string config_get_str(const char *key, const char *default_val) + { + std::string value = default_val ? default_val : ""; + cp0_signal_config_api({"GetStr", key ? std::string(key) : std::string(), value}, + [&](int code, std::string data) { + if (code == 0) value = std::move(data); + }); + return value; + } + + static void drop_root_user(const std::string &configured_user) + { +#if defined(__linux__) + if (getuid() != 0) return; + + const char *username = configured_user.empty() ? NULL : configured_user.c_str(); + if (!username) { + struct passwd *p = NULL; + setpwent(); + while ((p = getpwent()) != NULL) { + if (p->pw_uid >= 1000 && p->pw_uid < 65534 && + p->pw_shell && p->pw_shell[0] && + !std::strstr(p->pw_shell, "nologin") && + !std::strstr(p->pw_shell, "/bin/false")) { + username = p->pw_name; + break; + } + } + endpwent(); + } + if (!username) username = "pi"; + + struct passwd *pw = getpwnam(username); + if (pw && std::strcmp(username, "root") != 0) { + const int initgroups_result = initgroups(pw->pw_name, pw->pw_gid); + const int setgid_result = setgid(pw->pw_gid); + const int setuid_result = setuid(pw->pw_uid); + const int home_result = setenv("HOME", pw->pw_dir, 1); + const int user_result = setenv("USER", pw->pw_name, 1); + const int logname_result = setenv("LOGNAME", pw->pw_name, 1); + const int shell_result = + setenv("SHELL", pw->pw_shell[0] ? pw->pw_shell : "/bin/bash", 1); + const int chdir_result = chdir(pw->pw_dir); + (void)initgroups_result; + (void)setgid_result; + (void)setuid_result; + (void)home_result; + (void)user_result; + (void)logname_result; + (void)shell_result; + (void)chdir_result; + } +#endif + } + + void api_open(const cp0::pty::Request &request, callback_t callback) + { + std::vector argv; + if (!request.argv.empty()) { + for (const std::string &item : request.argv) argv.push_back(item.c_str()); + argv.push_back(NULL); + } + + pty_handle_t pty = open(request.executable.c_str(), argv.empty() ? NULL : argv.data(), + request.columns, request.rows); + if (!pty) { + report(callback, -1, "open pty failed\n"); + return; + } + report(callback, 0, cp0::pty::encode_handle(reinterpret_cast(pty))); + } + + void api_read(const cp0::pty::Request &request, callback_t callback) + { + pty_handle_t pty = to_handle(request.handle); + std::string out(request.max_read, '\0'); + int n = read(pty, &out[0], out.size()); + if (n < 0) { + report(callback, -1, "read pty failed\n"); + return; + } + out.resize(static_cast(n)); + report(callback, n, out); + } + + void api_write(const cp0::pty::Request &request, callback_t callback) + { + int n = write(to_handle(request.handle), request.data.data(), request.data.size()); + report(callback, n, n < 0 ? "write pty failed\n" : ""); + } + + void api_check_child(const cp0::pty::Request &request, callback_t callback) + { + int status = 0; + int ret = check_child(to_handle(request.handle), &status); + report(callback, ret, std::to_string(status)); + } + + std::unordered_map handles_; + std::mutex handles_mutex_; + bool accepting_ = true; +}; + +std::mutex g_pty_mutex; +std::shared_ptr g_pty; +cp0::SignalRegistration g_pty_registration; + +} // namespace + +extern "C" void init_pty(void) +{ + try { + std::lock_guard lock(g_pty_mutex); + if (g_pty) return; + auto pty = std::make_shared(); + if (!g_pty_registration.replace( + cp0_signal_pty_api, + [pty](std::list arg, + std::function callback) { + pty->api_call(std::move(arg), std::move(callback)); + })) + return; + g_pty = std::move(pty); + } catch (...) { + } +} + +extern "C" void deinit_pty(void) noexcept +{ + try { + std::shared_ptr pty; + { + std::lock_guard lock(g_pty_mutex); + try { + g_pty_registration.reset(); + } catch (...) { + } + pty = std::move(g_pty); + } + if (pty) pty->shutdown(); + } catch (...) { + } +} diff --git a/ext_components/cp0_lvgl/src/cp0_resource_path_policy.cpp b/ext_components/cp0_lvgl/src/cp0_resource_path_policy.cpp new file mode 100644 index 00000000..9cba09e9 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_resource_path_policy.cpp @@ -0,0 +1,70 @@ +#include "cp0_resource_path_policy.hpp" + +#include +#include + +namespace cp0::filesystem { +namespace { + +bool starts_with(const std::string &value, const std::string &prefix) +{ + return value.compare(0, prefix.size(), prefix) == 0; +} + +std::string lower_extension(const std::string &path) +{ + const std::size_t slash = path.find_last_of("/\\"); + const std::size_t dot = path.find_last_of('.'); + if (dot == std::string::npos || (slash != std::string::npos && dot < slash) || + dot + 1 == path.size()) + return {}; + std::string extension = path.substr(dot + 1); + std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char value) { + return static_cast(std::tolower(value)); + }); + return extension; +} + +} // namespace + +ResourceKind classify_resource(const std::string &path) +{ + const std::string extension = lower_extension(path); + if (extension == "png" || extension == "gif" || extension == "jpg" || + extension == "jpeg" || extension == "svg") + return ResourceKind::image; + if (extension == "wav" || extension == "mp3" || extension == "ogg") + return ResourceKind::audio; + if (extension == "ttf" || extension == "otf") return ResourceKind::font; + return ResourceKind::none; +} + +bool has_lvgl_drive(const std::string &path) +{ + return path.size() >= 2 && std::isalpha(static_cast(path[0])) && + path[1] == ':'; +} + +bool has_parent_component(const std::string &path) +{ + std::size_t start = 0; + while (start <= path.size()) { + const std::size_t end = path.find_first_of("/\\", start); + const std::size_t length = (end == std::string::npos ? path.size() : end) - start; + if (length == 2 && path.compare(start, length, "..") == 0) return true; + if (end == std::string::npos) break; + start = end + 1; + } + return false; +} + +std::string strip_app_root_prefix(const std::string &path) +{ + static const std::string system_prefix = "/usr/share/APPLaunch/"; + static const std::string package_prefix = "APPLaunch/"; + if (starts_with(path, system_prefix)) return path.substr(system_prefix.size()); + if (starts_with(path, package_prefix)) return path.substr(package_prefix.size()); + return path; +} + +} // namespace cp0::filesystem diff --git a/ext_components/cp0_lvgl/src/cp0_resource_path_policy.hpp b/ext_components/cp0_lvgl/src/cp0_resource_path_policy.hpp new file mode 100644 index 00000000..95df5eed --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_resource_path_policy.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace cp0::filesystem { + +enum class ResourceKind { + none, + image, + audio, + font, +}; + +ResourceKind classify_resource(const std::string &path); +bool has_lvgl_drive(const std::string &path); +bool has_parent_component(const std::string &path); +std::string strip_app_root_prefix(const std::string &path); + +} // namespace cp0::filesystem diff --git a/ext_components/cp0_lvgl/src/cp0_rpc_runtime_contract.hpp b/ext_components/cp0_lvgl/src/cp0_rpc_runtime_contract.hpp new file mode 100644 index 00000000..0250f05a --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_rpc_runtime_contract.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include + +namespace cp0::rpc { + +class RuntimeState { +public: + enum class Phase { Idle, Running, Stopping }; + + bool can_start() const noexcept { return phase_ == Phase::Idle; } + bool running() const noexcept { return phase_ == Phase::Running; } + bool stopping() const noexcept { return phase_ == Phase::Stopping; } + void mark_started() noexcept { phase_ = Phase::Running; } + void mark_stopping() noexcept { phase_ = Phase::Stopping; } + void mark_stopped() noexcept { phase_ = Phase::Idle; } + +private: + Phase phase_ = Phase::Idle; +}; + +template +bool await_readiness(Future &future) noexcept +{ + try { + return future.get(); + } catch (...) { + return false; + } +} + +struct ShutdownResult { + bool context_shutdown = true; + bool subscriber_joined = true; + bool broker_joined = true; + + bool workers_joined() const noexcept + { + return subscriber_joined && broker_joined; + } +}; + +template +ShutdownResult shutdown_runtime(MarkStopping &&mark_stopping, + ShutdownContext &&shutdown_context, + JoinSubscriber &&join_subscriber, + JoinBroker &&join_broker) noexcept +{ + ShutdownResult result; + try { std::forward(mark_stopping)(); } catch (...) {} + try { std::forward(shutdown_context)(); } + catch (...) { result.context_shutdown = false; } + try { std::forward(join_subscriber)(); } + catch (...) { result.subscriber_joined = false; } + try { std::forward(join_broker)(); } + catch (...) { result.broker_joined = false; } + return result; +} + +} // namespace cp0::rpc diff --git a/ext_components/cp0_lvgl/src/cp0_runner_contract.hpp b/ext_components/cp0_lvgl/src/cp0_runner_contract.hpp new file mode 100644 index 00000000..e29833ce --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_runner_contract.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +namespace cp0 { + +class RunnerLifetime +{ +public: + bool claim() + { + State expected = State::Fresh; + return state_.compare_exchange_strong( + expected, State::Claimed, + std::memory_order_acq_rel, std::memory_order_acquire); + } + + void initialization_failed() + { + State expected = State::Claimed; + state_.compare_exchange_strong( + expected, State::Fresh, + std::memory_order_acq_rel, std::memory_order_acquire); + } + + void finish() + { + State expected = State::Claimed; + state_.compare_exchange_strong( + expected, State::Finished, + std::memory_order_acq_rel, std::memory_order_acquire); + } + +private: + enum class State { Fresh, Claimed, Finished }; + std::atomic state_{State::Fresh}; +}; + +template +void invoke_resume_callback(Operation &&operation) noexcept +{ + try { + std::forward(operation)(); + } catch (...) { + } +} + +} // namespace cp0 diff --git a/ext_components/cp0_lvgl/src/cp0_runner_shutdown.hpp b/ext_components/cp0_lvgl/src/cp0_runner_shutdown.hpp new file mode 100644 index 00000000..e8756654 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_runner_shutdown.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include + +extern "C" void deinit_battery(void); +extern "C" void deinit_audio(void) noexcept; +extern "C" void deinit_camera(void) noexcept; +extern "C" void deinit_input(void); +extern "C" void deinit_imu(void); +extern "C" void deinit_lora(void) noexcept; +extern "C" void deinit_pty(void) noexcept; +extern "C" void deinit_rpc(void) noexcept; +extern "C" void deinit_sudo(void) noexcept; +extern "C" void deinit_wifi(void); + +namespace cp0::runner { + +template +void shutdown_services(StopSudo &&stop_sudo, StopRpc &&stop_rpc, + StopCamera &&stop_camera, StopImu &&stop_imu, + StopAudio &&stop_audio, StopPty &&stop_pty, + StopInput &&stop_input, StopWifi &&stop_wifi, + StopLora &&stop_lora, StopBattery &&stop_battery, + DeinitializeLvgl &&deinitialize_lvgl) noexcept +{ + try { + std::forward(stop_sudo)(); + } catch (...) { + } + try { + std::forward(stop_rpc)(); + } catch (...) { + } + try { + std::forward(stop_camera)(); + } catch (...) { + } + try { + std::forward(stop_imu)(); + } catch (...) { + } + try { + std::forward(stop_audio)(); + } catch (...) { + } + try { + std::forward(stop_pty)(); + } catch (...) { + } + try { + std::forward(stop_input)(); + } catch (...) { + } + try { + std::forward(stop_wifi)(); + } catch (...) { + } + try { + std::forward(stop_lora)(); + } catch (...) { + } + try { + std::forward(stop_battery)(); + } catch (...) { + } + try { + std::forward(deinitialize_lvgl)(); + } catch (...) { + } +} + +} // namespace cp0::runner diff --git a/ext_components/cp0_lvgl/src/cp0_settings_policy.cpp b/ext_components/cp0_lvgl/src/cp0_settings_policy.cpp new file mode 100644 index 00000000..745aa955 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_settings_policy.cpp @@ -0,0 +1,128 @@ +#include "cp0_settings_policy.hpp" + +#include +#include + +namespace cp0::settings { + +Command command_from(const Arguments &arguments) +{ + if (arguments.empty()) + return Command::Unknown; + + const std::string &command = arguments.front(); + if (command == "BacklightRead") + return Command::BacklightRead; + if (command == "BacklightMax") + return Command::BacklightMax; + if (command == "BacklightWrite") + return Command::BacklightWrite; + if (command == "Log") + return Command::Log; + if (command == "TimeStr") + return Command::TimeStr; + if (command == "GpioSet") + return Command::GpioSet; + if (command == "GpioGet") + return Command::GpioGet; + return Command::Unknown; +} + +bool valid_arguments(const Arguments &arguments) +{ + const Command command = command_from(arguments); + if (command == Command::Unknown) return false; + if (command == Command::BacklightRead || command == Command::BacklightMax || + command == Command::TimeStr) + return arguments.size() == 1; + if (command == Command::Log) + return arguments.size() == 3; + if (command == Command::GpioGet) + return arguments.size() == 2 && + power_output_from_name(argument_at(arguments, 1)) != PowerOutput::Unknown; + + if (arguments.size() != (command == Command::BacklightWrite ? 2u : 3u)) + return false; + int parsed = 0; + if (!integer_argument(arguments, command == Command::BacklightWrite ? 1 : 2, + 0, std::numeric_limits::max(), parsed)) + return false; + if (command == Command::GpioSet) + return power_output_from_name(argument_at(arguments, 1)) != PowerOutput::Unknown && + (parsed == 0 || parsed == 1); + return parsed >= 0; +} + +std::string argument_at(const Arguments &arguments, std::size_t index) +{ + auto iterator = arguments.begin(); + for (std::size_t current = 0; current < index && iterator != arguments.end(); ++current) + ++iterator; + return iterator == arguments.end() ? std::string() : *iterator; +} + +bool parse_integer_response(std::string_view text, + int minimum, + int maximum, + int &value) +{ + if (text.empty()) return false; + int parsed = 0; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, parsed); + if (result.ec != std::errc{} || result.ptr != end || parsed < minimum || parsed > maximum) + return false; + value = parsed; + return true; +} + +bool integer_argument(const Arguments &arguments, + std::size_t index, + int minimum, + int maximum, + int &value) +{ + return parse_integer_response(argument_at(arguments, index), minimum, maximum, value); +} + +bool parse_integer_file_line(std::string_view text, + int minimum, + int maximum, + int &value) +{ + if (!text.empty() && text.back() == '\n') text.remove_suffix(1); + if (!text.empty() && text.back() == '\r') text.remove_suffix(1); + return parse_integer_response(text, minimum, maximum, value); +} + +bool parse_switch_response(std::string_view text, int &value) +{ + return parse_integer_response(text, 0, 1, value); +} + +PowerOutput power_output_from_name(const std::string &name) +{ + if (name == "GROVE5V" || name == "extport_usb") + return PowerOutput::Grove5V; + if (name == "EXT5V" || name == "extport_5vout") + return PowerOutput::Ext5V; + return PowerOutput::Unknown; +} + +int switch_value(int value) +{ + return value == 0 ? 0 : 1; +} + +int physical_line_value(int logical_value, bool active_low) +{ + return switch_value(logical_value) ^ (active_low ? 1 : 0); +} + +int logical_line_value(int physical_value, bool active_low) +{ + return switch_value(physical_value) ^ (active_low ? 1 : 0); +} + +} // namespace cp0::settings diff --git a/ext_components/cp0_lvgl/src/cp0_settings_policy.hpp b/ext_components/cp0_lvgl/src/cp0_settings_policy.hpp new file mode 100644 index 00000000..f04cde3a --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_settings_policy.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include + +namespace cp0::settings { + +using Arguments = std::list; + +enum class Command { + Unknown, + BacklightRead, + BacklightMax, + BacklightWrite, + Log, + TimeStr, + GpioSet, + GpioGet, +}; + +enum class PowerOutput { + Unknown, + Grove5V, + Ext5V, +}; + +Command command_from(const Arguments &arguments); +bool valid_arguments(const Arguments &arguments); +std::string argument_at(const Arguments &arguments, std::size_t index); +bool integer_argument(const Arguments &arguments, + std::size_t index, + int minimum, + int maximum, + int &value); +bool parse_integer_response(std::string_view text, + int minimum, + int maximum, + int &value); +bool parse_integer_file_line(std::string_view text, + int minimum, + int maximum, + int &value); +bool parse_switch_response(std::string_view text, int &value); + +PowerOutput power_output_from_name(const std::string &name); +int switch_value(int value); +int physical_line_value(int logical_value, bool active_low); +int logical_line_value(int physical_value, bool active_low); + +} // namespace cp0::settings diff --git a/ext_components/cp0_lvgl/src/cp0_signal_registration.hpp b/ext_components/cp0_lvgl/src/cp0_signal_registration.hpp new file mode 100644 index 00000000..d170c57d --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_signal_registration.hpp @@ -0,0 +1,183 @@ +#pragma once + +#include +#include +#include +#include + +namespace cp0 { + +template +class SignalRegistration { +public: + using Callback = typename CallbackList::Callback; + using Handle = typename CallbackList::Handle; + + SignalRegistration() = default; + ~SignalRegistration() { reset(); } + + SignalRegistration(const SignalRegistration &) = delete; + SignalRegistration &operator=(const SignalRegistration &) = delete; + + SignalRegistration(SignalRegistration &&other) noexcept + { + std::lock_guard lock(other.mutex_); + list_ = other.list_; + handle_ = std::move(other.handle_); + other.list_ = nullptr; + other.handle_ = Handle{}; + } + + SignalRegistration &operator=(SignalRegistration &&other) noexcept + { + if (this == &other) + return *this; + + CallbackList *old_list = nullptr; + Handle old_handle; + { + std::scoped_lock lock(mutex_, other.mutex_); + old_list = list_; + old_handle = std::move(handle_); + list_ = other.list_; + handle_ = std::move(other.handle_); + other.list_ = nullptr; + other.handle_ = Handle{}; + } + if (old_list && old_handle) + old_list->remove(old_handle); + return *this; + } + + bool replace(CallbackList &list, const Callback &callback) + { + Handle replacement = list.append(callback); + if (!replacement) + return false; + + CallbackList *old_list = nullptr; + Handle old_handle; + { + std::lock_guard lock(mutex_); + old_list = list_; + old_handle = std::move(handle_); + list_ = &list; + handle_ = std::move(replacement); + } + if (old_list && old_handle) + old_list->remove(old_handle); + return true; + } + + void reset() + { + CallbackList *list = nullptr; + Handle handle; + { + std::lock_guard lock(mutex_); + list = list_; + handle = std::move(handle_); + list_ = nullptr; + handle_ = Handle{}; + } + if (list && handle) + list->remove(handle); + } + + bool registered() const + { + std::lock_guard lock(mutex_); + return list_ != nullptr && static_cast(handle_); + } + +private: + mutable std::mutex mutex_; + CallbackList *list_ = nullptr; + Handle handle_; +}; + +template +struct SignalBinding { + CallbackList *list; + typename CallbackList::Callback callback; +}; + +template +SignalBinding bind_signal(CallbackList &list, Callback &&callback) +{ + return {&list, typename CallbackList::Callback(std::forward(callback))}; +} + +template +class SignalRegistrationBundle { +public: + SignalRegistrationBundle() = default; + ~SignalRegistrationBundle() { reset(); } + + SignalRegistrationBundle(const SignalRegistrationBundle &) = delete; + SignalRegistrationBundle &operator=(const SignalRegistrationBundle &) = delete; + SignalRegistrationBundle(SignalRegistrationBundle &&) = delete; + SignalRegistrationBundle &operator=(SignalRegistrationBundle &&) = delete; + + bool replace(SignalBinding... bindings) + { + const auto binding_tuple = std::make_tuple(std::move(bindings)...); + if (!bindings_valid(binding_tuple, + std::index_sequence_for{})) + return false; + + auto replacement = std::make_unique(); + if (!register_all(*replacement, binding_tuple, + std::index_sequence_for{})) + return false; + + std::unique_ptr previous; + { + std::lock_guard lock(mutex_); + previous = std::move(state_); + state_ = std::move(replacement); + } + return true; + } + + void reset() + { + std::unique_ptr previous; + { + std::lock_guard lock(mutex_); + previous = std::move(state_); + } + } + + bool registered() const + { + std::lock_guard lock(mutex_); + return state_ != nullptr; + } + +private: + struct State { + std::tuple...> registrations; + }; + + template + static bool bindings_valid(const Bindings &bindings, + std::index_sequence) + { + return ((std::get(bindings).list != nullptr) && ...); + } + + template + static bool register_all(State &state, const Bindings &bindings, + std::index_sequence) + { + return (std::get(state.registrations) + .replace(*std::get(bindings).list, + std::get(bindings).callback) && ...); + } + + mutable std::mutex mutex_; + std::unique_ptr state_; +}; + +} // namespace cp0 diff --git a/ext_components/cp0_lvgl/src/cp0_soundcard_contract.cpp b/ext_components/cp0_lvgl/src/cp0_soundcard_contract.cpp new file mode 100644 index 00000000..8c67f6b1 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_soundcard_contract.cpp @@ -0,0 +1,78 @@ +#include "cp0_soundcard_contract.hpp" + +#include "cp0_alsa_parser.hpp" + +#include +#include + +namespace cp0::soundcard { +namespace { + +std::string argument_at(const std::list &arguments, size_t index) +{ + auto iterator = arguments.begin(); + for (size_t current = 0; current < index && iterator != arguments.end(); ++current) + ++iterator; + return iterator == arguments.end() ? std::string() : *iterator; +} + +bool parse_card_index(const std::string &text, int &index) +{ + if (text.empty()) + return false; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto parsed = std::from_chars(begin, end, index); + return parsed.ec == std::errc{} && parsed.ptr == end && index >= 0; +} + +Result unavailable(const Operations &operations) +{ + return {-1, operations.unavailable_message}; +} + +} // namespace + +Result dispatch(const std::list &arguments, const Operations &operations) +{ + try { + const std::string command = argument_at(arguments, 0); + if (command == "ListCards") { + if (!operations.read_cards) + return unavailable(operations); + return {0, cp0_alsa_parser::encode_cards(operations.read_cards())}; + } + + int card_index = -1; + if (command == "ListControls") { + if (!parse_card_index(argument_at(arguments, 1), card_index)) + return {-1, {}}; + if (!operations.read_controls) + return unavailable(operations); + return {0, cp0_alsa_parser::encode_controls(operations.read_controls(card_index))}; + } + if (command == "GetControlDetail") { + const std::string control = argument_at(arguments, 2); + if (!parse_card_index(argument_at(arguments, 1), card_index) || control.empty()) + return {-1, {}}; + if (!operations.read_control_detail) + return unavailable(operations); + return {0, operations.read_control_detail(card_index, control)}; + } + if (command == "SetControl") { + const std::string control = argument_at(arguments, 2); + const std::string value = argument_at(arguments, 3); + if (!parse_card_index(argument_at(arguments, 1), card_index) || control.empty() || value.empty()) + return {-1, {}}; + if (!operations.set_control) + return unavailable(operations); + const int code = operations.set_control(card_index, control, value); + return {code, std::to_string(code)}; + } + return {-1, operations.unknown_command_message}; + } catch (...) { + return {-1, "soundcard backend failure"}; + } +} + +} // namespace cp0::soundcard diff --git a/ext_components/cp0_lvgl/src/cp0_soundcard_contract.hpp b/ext_components/cp0_lvgl/src/cp0_soundcard_contract.hpp new file mode 100644 index 00000000..0132f556 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_soundcard_contract.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +namespace cp0::soundcard { + +struct Result { + int code = -1; + std::string payload; +}; + +struct Operations { + std::function read_cards; + std::function read_controls; + std::function read_control_detail; + std::function set_control; + std::string unavailable_message = "not supported"; + std::string unknown_command_message = "unknown soundcard api command"; +}; + +Result dispatch(const std::list &arguments, const Operations &operations); + +} // namespace cp0::soundcard diff --git a/ext_components/cp0_lvgl/src/cp0_status_component_contract.hpp b/ext_components/cp0_lvgl/src/cp0_status_component_contract.hpp new file mode 100644 index 00000000..416dfe88 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_status_component_contract.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +namespace cp0::status { + +template +bool resources_ready(const Handle *resources, std::size_t count) +{ + if (!resources && count != 0) return false; + for (std::size_t index = 0; index < count; ++index) + if (!resources[index]) return false; + return true; +} + +template +void release_before_destroy(Deactivate &&deactivate, Unmount &&unmount) noexcept +{ + try { + std::forward(deactivate)(); + } catch (...) { + } + try { + std::forward(unmount)(); + } catch (...) { + } +} + +template +bool initialize_with_rollback(Initialize &&initialize, Ready &&ready, + Rollback &&rollback) noexcept +{ + bool initialized = false; + try { + std::forward(initialize)(); + initialized = std::forward(ready)(); + } catch (...) { + } + if (initialized) return true; + try { + std::forward(rollback)(); + } catch (...) { + } + return false; +} + +} // namespace cp0::status diff --git a/ext_components/cp0_lvgl/src/cp0_status_ethernet.c b/ext_components/cp0_lvgl/src/cp0_status_ethernet.c new file mode 100644 index 00000000..0276a45b --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_status_ethernet.c @@ -0,0 +1,55 @@ + +#if defined(LV_LVGL_H_INCLUDE_SIMPLE) +#include "lvgl.h" +#elif defined(LV_LVGL_H_INCLUDE_SYSTEM) +#include +#elif defined(LV_BUILD_TEST) +#include "../lvgl.h" +#else +#include "lvgl/lvgl.h" +#endif + +#ifndef LV_ATTRIBUTE_MEM_ALIGN +#define LV_ATTRIBUTE_MEM_ALIGN +#endif + +#ifndef LV_ATTRIBUTE_CP0_STATUS_ETHERNET +#define LV_ATTRIBUTE_CP0_STATUS_ETHERNET +#endif + +static const +LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST LV_ATTRIBUTE_CP0_STATUS_ETHERNET +uint8_t cp0_status_ethernet_map[] = { + + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x01,0x00,0x01,0x00,0x00,0x00,0x01,0x00,0x00,0x31,0xc9,0x31,0x86,0x33,0xcc,0x33,0x82,0x33,0xcc,0x33,0x0f,0x00,0xff,0x00,0x02,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0xff,0x00,0x02,0x36,0xc8,0x36,0x0e,0x33,0xcd,0x33,0x8b,0x31,0xca,0x31,0x87,0x00,0xff,0x00,0x02,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x01,0x7f,0xff,0x7f,0x02,0x3f,0xbf,0x3f,0x08,0x33,0xca,0x33,0x82,0x34,0xd1,0x34,0xff,0x31,0xcc,0x31,0xf5,0x33,0xcf,0x33,0x3b,0x00,0x00,0x00,0x00,0x55,0xaa,0x55,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x55,0xaa,0x55,0x03,0x00,0x00,0x00,0x00,0x33,0xd0,0x33,0x37,0x32,0xcb,0x32,0xfc,0x35,0xd3,0x35,0xff,0x31,0xc9,0x31,0x7c,0x3f,0xbf,0x3f,0x04,0x7f,0xff,0x7f,0x02,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x7f,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x32,0xca,0x32,0x83,0x35,0xd4,0x35,0xff,0x35,0xd0,0x35,0xff,0x33,0xcb,0x33,0x71,0x33,0xcc,0x33,0x0a,0x00,0xff,0x00,0x01,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x7f,0x00,0x02,0x3f,0xdf,0x3f,0x08,0x31,0xcb,0x31,0x76,0x35,0xd1,0x35,0xff,0x34,0xcf,0x34,0xfe,0x32,0xca,0x32,0x7f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7f,0x00,0x02,0x00,0x00,0x00,0x00, + 0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x31,0xc9,0x31,0x80,0x34,0xd0,0x34,0xfe,0x34,0xd2,0x34,0xfb,0x34,0xca,0x34,0x7a,0x00,0x7f,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x7f,0x00,0x02,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x02,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x01,0x32,0xca,0x32,0x75,0x34,0xd1,0x34,0xfb,0x34,0xd0,0x34,0xfe,0x32,0xca,0x32,0x70,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01, + 0x00,0x7f,0x00,0x02,0x55,0xaa,0x55,0x03,0x33,0xca,0x33,0x82,0x34,0xd1,0x34,0xff,0x34,0xd1,0x34,0xfd,0x33,0xcb,0x33,0x72,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x33,0xcc,0x33,0x05,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x3f,0xbf,0x3f,0x04,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x33,0xcc,0x33,0x73,0x34,0xd2,0x34,0xff,0x34,0xd0,0x34,0xff,0x33,0xca,0x33,0x82,0x55,0xaa,0x55,0x03,0x00,0x7f,0x00,0x02, + 0x00,0x00,0x00,0x00,0x34,0xca,0x34,0x7f,0x34,0xd2,0x34,0xfb,0x34,0xd0,0x34,0xfc,0x33,0xc9,0x33,0x73,0x00,0xaa,0x00,0x03,0x00,0x01,0x00,0x00,0x35,0xcc,0x35,0x64,0x34,0xd0,0x34,0xb4,0x32,0xca,0x32,0xa1,0x31,0xcd,0x31,0x1a,0x00,0x00,0x00,0x00,0x33,0xcb,0x33,0x40,0x34,0xcc,0x34,0xb0,0x33,0xcc,0x33,0xaf,0x33,0xcc,0x33,0x3c,0x00,0x00,0x00,0x00,0x31,0xcd,0x31,0x29,0x33,0xca,0x33,0xa4,0x35,0xcf,0x35,0xb2,0x33,0xcf,0x33,0x55,0x00,0x00,0x00,0x00,0x24,0xda,0x24,0x07,0x33,0xc9,0x33,0x73,0x33,0xcf,0x33,0xfc,0x34,0xd2,0x34,0xfb,0x34,0xca,0x34,0x7f,0x00,0x00,0x00,0x00, + 0x32,0xcc,0x32,0x7e,0x33,0xce,0x33,0xfd,0x33,0xcf,0x33,0xff,0x31,0xcb,0x31,0x71,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x2d,0xd2,0x2d,0x11,0x33,0xcb,0x33,0xf3,0x36,0xd7,0x36,0xff,0x37,0xdb,0x37,0xff,0x33,0xcd,0x33,0x80,0x33,0xcc,0x33,0x05,0x32,0xcb,0x32,0xbc,0x37,0xdb,0x37,0xff,0x37,0xde,0x37,0xff,0x31,0xcb,0x31,0xae,0x00,0x00,0x00,0x00,0x31,0xca,0x31,0xaa,0x37,0xdb,0x37,0xff,0x36,0xd8,0x36,0xff,0x33,0xcc,0x33,0xeb,0x33,0xe5,0x33,0x0a,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x32,0xca,0x32,0x79,0x34,0xce,0x34,0xff,0x33,0xce,0x33,0xfd,0x32,0xcc,0x32,0x7e, + 0x34,0xca,0x34,0x84,0x33,0xd1,0x33,0xfb,0x33,0xcf,0x33,0xfd,0x31,0xcb,0x31,0x67,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x02,0x35,0xd6,0x35,0x13,0x33,0xcb,0x33,0xf8,0x35,0xd4,0x35,0xff,0x37,0xd9,0x37,0xff,0x33,0xcc,0x33,0x87,0x3f,0xbf,0x3f,0x08,0x33,0xcc,0x33,0xc3,0x37,0xd9,0x37,0xff,0x37,0xdb,0x37,0xff,0x31,0xcd,0x31,0xb3,0x00,0x00,0x00,0x00,0x32,0xcc,0x32,0xb0,0x37,0xda,0x37,0xff,0x35,0xd5,0x35,0xff,0x32,0xcd,0x32,0xf1,0x2a,0xbf,0x2a,0x0c,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x33,0xcb,0x33,0x71,0x33,0xce,0x33,0xfd,0x34,0xd1,0x34,0xfb,0x34,0xcc,0x34,0x84, + 0x00,0x00,0x00,0x00,0x31,0xcb,0x31,0x81,0x35,0xd3,0x35,0xff,0x34,0xd0,0x34,0xfe,0x34,0xc8,0x34,0x70,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x33,0xcc,0x33,0x69,0x34,0xd0,0x34,0xc4,0x33,0xc9,0x33,0xad,0x36,0xc8,0x36,0x1c,0x00,0x00,0x00,0x00,0x32,0xcc,0x32,0x42,0x33,0xcd,0x33,0xbe,0x33,0xcc,0x33,0xbc,0x30,0xca,0x30,0x3f,0x00,0x00,0x00,0x00,0x35,0xc9,0x35,0x2b,0x32,0xca,0x32,0xb0,0x33,0xcf,0x33,0xc1,0x33,0xc9,0x33,0x5a,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x02,0x33,0xcb,0x33,0x71,0x34,0xce,0x34,0xfe,0x35,0xd4,0x35,0xff,0x33,0xcc,0x33,0x82,0x00,0x00,0x00,0x00, + 0x00,0x7f,0x00,0x02,0x24,0xb6,0x24,0x07,0x32,0xc8,0x32,0x84,0x34,0xd1,0x34,0xff,0x34,0xd1,0x34,0xf9,0x32,0xcc,0x32,0x6f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x2a,0xc6,0x2a,0x12,0x55,0xaa,0x55,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x2e,0xd0,0x2e,0x0b,0x33,0xcc,0x33,0x0a,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x3f,0xbf,0x3f,0x04,0x22,0xcc,0x22,0x0f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x33,0xcb,0x33,0x71,0x34,0xd2,0x34,0xfc,0x34,0xd0,0x34,0xff,0x33,0xc9,0x33,0x85,0x48,0xb6,0x48,0x07,0x00,0x7f,0x00,0x02, + 0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x33,0xca,0x33,0x8c,0x34,0xd0,0x34,0xfe,0x34,0xd1,0x34,0xfd,0x33,0xcb,0x33,0x6c,0x7f,0xff,0x7f,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x7f,0xff,0x7f,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x02,0x33,0xc8,0x33,0x67,0x33,0xd1,0x33,0xfc,0x34,0xd1,0x34,0xfe,0x32,0xca,0x32,0x7f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01, + 0x00,0x00,0x00,0x00,0x00,0x7f,0x00,0x02,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x33,0xcd,0x33,0x81,0x35,0xd4,0x35,0xff,0x35,0xd1,0x35,0xff,0x31,0xcb,0x31,0x71,0x33,0xcc,0x33,0x05,0x00,0xff,0x00,0x02,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0xff,0x00,0x02,0x3f,0xbf,0x3f,0x04,0x31,0xcb,0x31,0x77,0x35,0xd2,0x35,0xff,0x33,0xcf,0x33,0xfe,0x32,0xca,0x32,0x7f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x7f,0x00,0x02,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x01,0x55,0xaa,0x55,0x03,0x48,0xda,0x48,0x07,0x33,0xca,0x33,0x82,0x33,0xcf,0x33,0xfd,0x33,0xd0,0x33,0xfb,0x30,0xcc,0x30,0x5a,0x00,0x00,0x00,0x00,0x55,0xff,0x55,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x55,0xff,0x55,0x03,0x00,0x00,0x00,0x00,0x32,0xc9,0x32,0x56,0x34,0xcf,0x34,0xff,0x34,0xd2,0x34,0xfd,0x32,0xca,0x32,0x7f,0x3f,0xff,0x3f,0x04,0x7f,0xff,0x7f,0x02,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x33,0xca,0x33,0x87,0x33,0xcf,0x33,0xc6,0x2e,0xc9,0x2e,0x26,0x00,0x00,0x00,0x00,0x00,0x7f,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7f,0x00,0x02,0x00,0x00,0x00,0x00,0x34,0xc3,0x34,0x22,0x33,0xcc,0x33,0xd1,0x31,0xc7,0x31,0x8f,0x00,0xaa,0x00,0x03,0x00,0xff,0x00,0x01,0x00,0xff,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + +}; + +const lv_image_dsc_t cp0_status_ethernet = { + .header = { + .magic = LV_IMAGE_HEADER_MAGIC, + .cf = LV_COLOR_FORMAT_ARGB8888, + .flags = 0, + .w = 28, + .h = 14, + .stride = 112, + .reserved_2 = 0, + }, + .data_size = sizeof(cp0_status_ethernet_map), + .data = cp0_status_ethernet_map, + .reserved = NULL, +}; + diff --git a/ext_components/cp0_lvgl/src/cp0_status_lifecycle.cpp b/ext_components/cp0_lvgl/src/cp0_status_lifecycle.cpp new file mode 100644 index 00000000..0423815e --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_status_lifecycle.cpp @@ -0,0 +1,105 @@ +#include "cp0_status_lifecycle.hpp" + +#include + +namespace cp0::status { + +namespace { + +void release_reverse(std::vector> &releases) noexcept +{ + for (auto iterator = releases.rbegin(); iterator != releases.rend(); ++iterator) { + if (!*iterator) continue; + try { + (*iterator)(); + } catch (...) { + // Cleanup is best-effort, but one faulty resource must not retain the rest. + } + } + releases.clear(); +} + +} // namespace + +Lifecycle::~Lifecycle() +{ + stop(); +} + +bool Lifecycle::start(const std::vector &factories) +{ + std::unique_lock lock(mutex_); + condition_.wait(lock, [this] { return !starting_; }); + if (active_) + return true; + starting_ = true; + cancel_start_ = false; + lock.unlock(); + + std::vector> acquired; + acquired.reserve(factories.size()); + try { + for (const Factory &factory : factories) { + Resource resource = factory ? factory() : Resource{}; + if (!resource.valid) { + release_reverse(acquired); + lock.lock(); + starting_ = false; + cancel_start_ = false; + lock.unlock(); + condition_.notify_all(); + return false; + } + acquired.push_back(std::move(resource.release)); + } + } catch (...) { + release_reverse(acquired); + lock.lock(); + starting_ = false; + cancel_start_ = false; + lock.unlock(); + condition_.notify_all(); + return false; + } + + lock.lock(); + if (cancel_start_) { + starting_ = false; + cancel_start_ = false; + lock.unlock(); + release_reverse(acquired); + condition_.notify_all(); + return false; + } + releases_ = std::move(acquired); + active_ = true; + starting_ = false; + lock.unlock(); + condition_.notify_all(); + return true; +} + +void Lifecycle::stop() +{ + std::vector> releases; + { + std::lock_guard lock(mutex_); + if (starting_) { + cancel_start_ = true; + return; + } + if (!active_) + return; + active_ = false; + releases = std::move(releases_); + } + release_reverse(releases); +} + +bool Lifecycle::active() const +{ + std::lock_guard lock(mutex_); + return active_; +} + +} // namespace cp0::status diff --git a/ext_components/cp0_lvgl/src/cp0_status_lifecycle.hpp b/ext_components/cp0_lvgl/src/cp0_status_lifecycle.hpp new file mode 100644 index 00000000..539ce60b --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_status_lifecycle.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace cp0::status { + +struct Resource { + bool valid = false; + std::function release; +}; + +class Lifecycle { +public: + using Factory = std::function; + + ~Lifecycle(); + bool start(const std::vector &factories); + void stop(); + bool active() const; + +private: + mutable std::mutex mutex_; + std::condition_variable condition_; + bool active_ = false; + bool starting_ = false; + bool cancel_start_ = false; + std::vector> releases_; +}; + +} // namespace cp0::status diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_async.cpp b/ext_components/cp0_lvgl/src/cp0_sudo_async.cpp index b368e758..5518805c 100644 --- a/ext_components/cp0_lvgl/src/cp0_sudo_async.cpp +++ b/ext_components/cp0_lvgl/src/cp0_sudo_async.cpp @@ -1,15 +1,21 @@ #include "cp0_lvgl_app.h" -#include "compat/input_keys.h" +#include "input_keys.h" #include "hal_lvgl_bsp.h" #include "keyboard_input.h" #include "cp0_process_runner.hpp" #include "cp0_sudo_coordinator.hpp" +#include "cp0_callback_contract.hpp" +#include "cp0_sudo_request_factory.hpp" #include "cp0_dispatch_testable.hpp" +#include "cp0_pointer_lifecycle.hpp" +#include "cp0_signal_registration.hpp" +#include "cp0_dispatch_shutdown_gate.hpp" +#include "cp0_sudo_worker_registry.hpp" +#include "cp0_sudo_c_api_boundary.hpp" #include "lvgl/lvgl.h" #include -#include #include #include #include @@ -39,7 +45,49 @@ using cp0_sudo::Request; constexpr size_t kMaxPasswordBytes = 128; constexpr size_t kMaxCapturedOutputBytes = 64 * 1024; cp0_sudo::Coordinator g_coordinator; -std::atomic g_next_request_id{1}; +cp0_sudo::RequestFactory g_request_factory; +cp0::dispatch::ShutdownGate g_dispatch_gate; +std::shared_ptr g_workers; + +enum class SudoPhase { Stopped, Running, ShutdownRequested, Finalizing }; +std::mutex g_sudo_lifecycle_mutex; +SudoPhase g_sudo_phase = SudoPhase::Stopped; +std::thread::id g_sudo_owner_thread; +std::vector g_shutdown_actions; +thread_local unsigned g_sudo_dispatch_depth = 0; + +void secure_clear(std::string &value); + +struct DispatchScope { + DispatchScope() { ++g_sudo_dispatch_depth; } + ~DispatchScope() { --g_sudo_dispatch_depth; } +}; + +struct SecurePassword { + std::string value; + ~SecurePassword() { secure_clear(value); } +}; + +using SudoRegistration = cp0::SignalRegistrationBundle< + decltype(cp0_signal_sudo_argv_async), decltype(cp0_signal_sudo_cancel), + decltype(cp0_signal_system_admin_async)>; + +SudoRegistration &sudo_registration() +{ + static SudoRegistration registration; + return registration; +} + +bool sudo_running() +{ + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + return g_sudo_phase == SudoPhase::Running; +} + +bool valid_callback_thread(cp0_sudo_callback_thread_t thread) +{ + return thread == CP0_SUDO_CALLBACK_LVGL || thread == CP0_SUDO_CALLBACK_WORKER; +} struct SignalRequestContext { std::function complete; @@ -145,9 +193,11 @@ bool authentication_error(const std::string &output) #endif void key_event_cb(lv_event_t *event); +void prompt_object_delete_cb(lv_event_t *event); void execute_actions(std::vector actions); void submit_password(); void apply_queued_password(void *); +bool schedule_apply_queued_password(); void clear_queued_password() { @@ -175,10 +225,13 @@ void destroy_prompt() { if (g_key_hook) { lv_obj_remove_event_cb_with_user_data(g_key_hook, key_event_cb, nullptr); + lv_obj_remove_event_cb_with_user_data(g_key_hook, prompt_object_delete_cb, nullptr); g_key_hook = nullptr; } - lv_group_set_default(g_saved_group); - if (lv_indev_t *indev = lv_indev_get_next(nullptr)) lv_indev_set_group(indev, g_saved_group); + lv_group_t *saved_group = g_saved_group; + g_saved_group = nullptr; + lv_group_set_default(saved_group); + if (lv_indev_t *indev = lv_indev_get_next(nullptr)) lv_indev_set_group(indev, saved_group); if (g_prompt_group) { lv_group_delete(g_prompt_group); g_prompt_group = nullptr; } if (g_box) { lv_obj_del(g_box); g_box = nullptr; } if (g_overlay) { lv_obj_del(g_overlay); g_overlay = nullptr; } @@ -189,6 +242,23 @@ void destroy_prompt() clear_queued_password(); } +void prompt_object_delete_cb(lv_event_t *event) +{ + auto *deleted = static_cast(lv_event_get_target(event)); + if (cp0::clear_if_deleted(g_key_hook, deleted)) + g_saved_group = nullptr; + cp0::clear_if_deleted(g_overlay, deleted); + cp0::clear_if_deleted(g_box, deleted); + cp0::clear_if_deleted(g_password_label, deleted); + cp0::clear_if_deleted(g_hint_label, deleted); +} + +void fail_prompt_creation() +{ + destroy_prompt(); + execute_actions(g_coordinator.fail_all(-ENOMEM, now_ms())); +} + void update_password_label() { if (!g_password_label) return; @@ -199,16 +269,30 @@ void update_password_label() void create_prompt(const std::shared_ptr &request) { - if (g_overlay) return; + if (g_prompt_request) return; g_prompt_request = request; lv_obj_t *layer = lv_layer_top(); + if (!layer) { + fail_prompt_creation(); + return; + } g_overlay = lv_obj_create(layer); + if (!g_overlay) { + fail_prompt_creation(); + return; + } + lv_obj_add_event_cb(g_overlay, prompt_object_delete_cb, LV_EVENT_DELETE, nullptr); lv_obj_remove_style_all(g_overlay); lv_obj_set_size(g_overlay, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(g_overlay, lv_color_hex(0), 0); lv_obj_set_style_bg_opa(g_overlay, LV_OPA_70, 0); lv_obj_clear_flag(g_overlay, LV_OBJ_FLAG_SCROLLABLE); g_box = lv_obj_create(layer); + if (!g_box) { + fail_prompt_creation(); + return; + } + lv_obj_add_event_cb(g_box, prompt_object_delete_cb, LV_EVENT_DELETE, nullptr); lv_obj_remove_style_all(g_box); lv_obj_set_size(g_box, 240, 116); lv_obj_align(g_box, LV_ALIGN_CENTER, 0, 0); @@ -220,11 +304,20 @@ void create_prompt(const std::shared_ptr &request) lv_obj_set_style_pad_all(g_box, 0, 0); lv_obj_clear_flag(g_box, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t *title = lv_label_create(g_box); + if (!title) { + fail_prompt_creation(); + return; + } lv_label_set_text(title, "Sudo Password"); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14); lv_obj_set_style_text_color(title, lv_color_hex(0x4A9EFF), 0); lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); g_password_label = lv_label_create(g_box); + if (!g_password_label) { + fail_prompt_creation(); + return; + } + lv_obj_add_event_cb(g_password_label, prompt_object_delete_cb, LV_EVENT_DELETE, nullptr); lv_obj_set_width(g_password_label, 200); lv_obj_set_style_text_align(g_password_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(g_password_label, lv_color_hex(0xFFFFFF), 0); @@ -232,6 +325,11 @@ void create_prompt(const std::shared_ptr &request) lv_obj_align(g_password_label, LV_ALIGN_CENTER, 0, -1); update_password_label(); g_hint_label = lv_label_create(g_box); + if (!g_hint_label) { + fail_prompt_creation(); + return; + } + lv_obj_add_event_cb(g_hint_label, prompt_object_delete_cb, LV_EVENT_DELETE, nullptr); lv_label_set_text(g_hint_label, "Enter:OK ESC:Cancel"); lv_obj_set_width(g_hint_label, 220); lv_obj_set_style_text_align(g_hint_label, LV_TEXT_ALIGN_CENTER, 0); @@ -239,14 +337,23 @@ void create_prompt(const std::shared_ptr &request) lv_obj_set_style_text_font(g_hint_label, &lv_font_montserrat_10, 0); lv_obj_align(g_hint_label, LV_ALIGN_BOTTOM_MID, 0, -12); g_key_hook = lv_screen_active(); - if (g_key_hook) lv_obj_add_event_cb(g_key_hook, key_event_cb, - static_cast(LV_EVENT_KEYBOARD), nullptr); + if (g_key_hook) { + lv_obj_add_event_cb(g_key_hook, key_event_cb, + static_cast(LV_EVENT_KEYBOARD), nullptr); + lv_obj_add_event_cb(g_key_hook, prompt_object_delete_cb, LV_EVENT_DELETE, nullptr); + } g_saved_group = lv_group_get_default(); g_prompt_group = lv_group_create(); + if (!g_prompt_group) { + fail_prompt_creation(); + return; + } lv_group_add_obj(g_prompt_group, g_overlay); lv_group_set_default(g_prompt_group); if (lv_indev_t *indev = lv_indev_get_next(nullptr)) lv_indev_set_group(indev, g_prompt_group); - lv_async_call(apply_queued_password, nullptr); + const bool dispatched = schedule_apply_queued_password(); + cp0_testable::run_on_dispatch_failure( + dispatched, [] { apply_queued_password(nullptr); }); } void show_auth_error(const std::shared_ptr &request) @@ -262,17 +369,34 @@ void show_auth_error(const std::shared_ptr &request) update_password_label(); } -struct ActionHolder { std::vector actions; }; +struct ActionHolder { + std::vector actions; + cp0::dispatch::ShutdownGate::Reservation reservation; +}; +void coordinator_timer_cb(lv_timer_t *) +{ + DispatchScope dispatch_scope; + cp0::callback::invoke_direct([] { + execute_actions(g_coordinator.tick(now_ms(), {})); + }); +} + void action_cb(void *user) { std::unique_ptr holder(static_cast(user)); + auto lease = g_dispatch_gate.acquire(std::move(holder->reservation)); + if (!lease) return; + DispatchScope dispatch_scope; execute_actions(std::move(holder->actions)); } bool post_actions(std::vector actions) { if (actions.empty()) return true; - auto *holder = new (std::nothrow) ActionHolder{std::move(actions)}; + auto reservation = g_dispatch_gate.reserve(); + if (!reservation) return false; + auto *holder = new (std::nothrow) ActionHolder{ + std::move(actions), std::move(*reservation)}; if (!holder) return false; bool scheduled = cp0_testable::dispatch_task( [holder](auto callback) { return lv_async_call(callback, holder) == LV_RESULT_OK; }, @@ -307,6 +431,30 @@ void stream_output(const std::shared_ptr &request, const char *data, si } } +void invoke_worker_completion(const std::shared_ptr &request, + cp0_sudo_result_t result, int exit_code) noexcept +{ + if (request->callback_thread != CP0_SUDO_CALLBACK_WORKER) return; + auto callback = request->complete_cb; + request->complete_cb = nullptr; + if (!callback) return; + try { + callback(result, exit_code, request->user); + } catch (...) { + } +} + +void complete_worker(const std::shared_ptr &request, + cp0_sudo_result_t result, int exit_code) +{ + if (request->cancel_requested.load()) { + result = CP0_SUDO_RESULT_CANCELLED; + exit_code = -ECANCELED; + } + invoke_worker_completion(request, result, exit_code); + g_coordinator.worker_complete(request->id, result, exit_code); +} + void run_worker(std::shared_ptr request, std::string password) { cp0_sudo_result_t result = CP0_SUDO_RESULT_EXEC_FAILED; @@ -314,6 +462,9 @@ void run_worker(std::shared_ptr request, std::string password) #if defined(_WIN32) (void)request; #elif defined(HAL_PLATFORM_SDL) + auto authenticated = g_coordinator.worker_authenticated(request->id); + if (!authenticated.empty() && !post_actions(authenticated)) + g_coordinator.requeue_actions(std::move(authenticated)); std::vector command = request->argv; if (request->use_login_shell) { const char *shell = std::getenv("SHELL"); @@ -331,43 +482,66 @@ void run_worker(std::shared_ptr request, std::string password) if (!resolve_run_user(identity)) exit_code = -EPERM; else { std::string password_line = password + "\n"; - std::vector command; - if (request->use_login_shell) - command = {"sudo", "-k", "-S", "-p", "", "--", identity.shell, "-c", - request->argv.front()}; - else { - command = {"sudo", "-k", "-S", "-p", "", "--"}; - command.insert(command.end(), request->argv.begin(), request->argv.end()); - } - auto execution = cp0_runner::run(std::move(command), &password_line, - [request](const char *data, size_t size) { stream_output(request, data, size); }, - &request->cancel_requested, request->exec_timeout_ms, kMaxCapturedOutputBytes, + auto validation = cp0_runner::run({"sudo", "-k", "-S", "-p", "", "-v"}, + &password_line, nullptr, &request->cancel_requested, request->auth_timeout_ms, + kMaxCapturedOutputBytes, identity.uid, identity.gid, identity.name, identity.home, identity.shell); secure_clear(password_line); - exit_code = execution.exit_code; + exit_code = validation.exit_code; result = exit_code == -ECANCELED ? CP0_SUDO_RESULT_CANCELLED : exit_code == -ETIMEDOUT ? CP0_SUDO_RESULT_TIMED_OUT : - authentication_error(execution.output) ? CP0_SUDO_RESULT_AUTH_FAILED : + authentication_error(validation.output) ? CP0_SUDO_RESULT_AUTH_FAILED : exit_code == 0 ? CP0_SUDO_RESULT_SUCCESS : CP0_SUDO_RESULT_EXEC_FAILED; if (result == CP0_SUDO_RESULT_AUTH_FAILED) { + const bool terminal = request->cancel_requested.load() || request->auth_attempts >= 3; + if (terminal) { + if (request->cancel_requested.load()) { + result = CP0_SUDO_RESULT_CANCELLED; + exit_code = -ECANCELED; + } + invoke_worker_completion(request, result, exit_code); + } auto actions = g_coordinator.worker_auth_result(request->id, result, exit_code, now_ms()); secure_clear(password); if (!actions.empty() && !post_actions(actions)) g_coordinator.requeue_actions(std::move(actions)); return; } + if (result != CP0_SUDO_RESULT_SUCCESS) { + secure_clear(password); + complete_worker(request, result, exit_code); + return; + } + + auto authenticated = g_coordinator.worker_authenticated(request->id); + if (!authenticated.empty() && !post_actions(authenticated)) + g_coordinator.requeue_actions(std::move(authenticated)); + + std::vector command; + if (request->use_login_shell) + command = {"sudo", "-n", "--", identity.shell, "-c", request->argv.front()}; + else { + command = {"sudo", "-n", "--"}; + command.insert(command.end(), request->argv.begin(), request->argv.end()); + } + auto execution = cp0_runner::run(std::move(command), nullptr, + [request](const char *data, size_t size) { stream_output(request, data, size); }, + &request->cancel_requested, request->exec_timeout_ms, kMaxCapturedOutputBytes, + identity.uid, identity.gid, identity.name, identity.home, identity.shell); + exit_code = execution.exit_code; + result = exit_code == -ECANCELED ? CP0_SUDO_RESULT_CANCELLED : + exit_code == -ETIMEDOUT ? CP0_SUDO_RESULT_TIMED_OUT : + exit_code == 0 ? CP0_SUDO_RESULT_SUCCESS : CP0_SUDO_RESULT_EXEC_FAILED; } #endif secure_clear(password); - g_coordinator.worker_complete(request->id, result, exit_code); + complete_worker(request, result, exit_code); } void execute_actions(std::vector actions) { if (!g_timer) { - g_timer = lv_timer_create([](lv_timer_t *) { - execute_actions(g_coordinator.tick(now_ms(), {})); - }, 20, nullptr); + g_timer = lv_timer_create(coordinator_timer_cb, 20, nullptr); if (!g_timer) { actions = g_coordinator.fail_all(-ENOMEM, now_ms()); for (;;) { @@ -384,28 +558,43 @@ void execute_actions(std::vector actions) case ActionType::SHOW_AUTH_ERROR: show_auth_error(action.request); break; case ActionType::DESTROY_PROMPT: destroy_prompt(); break; case ActionType::START_WORKER: { - std::string password = g_password; + std::shared_ptr password; + try { + password = std::make_shared(); + password->value = g_password; + } catch (...) { + } secure_clear(g_password); update_password_label(); if (g_hint_label) lv_label_set_text(g_hint_label, "Authenticating..."); - std::thread(run_worker, action.request, std::move(password)).detach(); + if (!password || !g_workers || !g_workers->start( + [request = action.request] { request->cancel_requested.store(true); }, + [request = action.request, password]() mutable { + run_worker(std::move(request), std::move(password->value)); + })) { + invoke_worker_completion(action.request, + CP0_SUDO_RESULT_EXEC_FAILED, -EAGAIN); + g_coordinator.worker_complete(action.request->id, + CP0_SUDO_RESULT_EXEC_FAILED, -EAGAIN); + } break; } case ActionType::CALL_OUTPUT: - if (action.request->output_cb && !action.data.empty()) - action.request->output_cb(action.data.data(), action.data.size(), action.request->user); + try { + if (action.request->output_cb && !action.data.empty()) + action.request->output_cb(action.data.data(), action.data.size(), action.request->user); + } catch (...) { + } g_coordinator.output_delivered(action.request->id, action.data.size()); break; case ActionType::CALL_COMPLETE: { auto callback = action.request->complete_cb; action.request->complete_cb = nullptr; if (!callback) break; - if (action.request->callback_thread == CP0_SUDO_CALLBACK_WORKER) - std::thread([request = action.request, callback, result = action.result, - exit_code = action.exit_code]() { - callback(result, exit_code, request->user); - }).detach(); - else callback(action.result, action.exit_code, action.request->user); + try { + callback(action.result, action.exit_code, action.request->user); + } catch (...) { + } break; } } @@ -418,7 +607,7 @@ void submit_password() execute_actions(g_coordinator.submit_password(g_prompt_request->id)); } -void apply_queued_password(void *) +void apply_queued_password_impl() { if (!g_prompt_request || g_coordinator.state(g_prompt_request->id) != cp0_sudo::State::PROMPT) return; @@ -430,7 +619,37 @@ void apply_queued_password(void *) submit_password(); } -void key_event_cb(lv_event_t *event) +void apply_queued_password(void *) +{ + cp0::callback::invoke_direct(apply_queued_password_impl); +} + +struct QueuedPasswordHolder { + cp0::dispatch::ShutdownGate::Reservation reservation; +}; + +void apply_queued_password_dispatch(void *user) +{ + std::unique_ptr holder( + static_cast(user)); + auto lease = g_dispatch_gate.acquire(std::move(holder->reservation)); + if (!lease) return; + DispatchScope dispatch_scope; + apply_queued_password(nullptr); +} + +bool schedule_apply_queued_password() +{ + auto reservation = g_dispatch_gate.reserve(); + if (!reservation) return false; + auto *holder = new (std::nothrow) QueuedPasswordHolder{std::move(*reservation)}; + if (!holder) return false; + if (lv_async_call(apply_queued_password_dispatch, holder) == LV_RESULT_OK) return true; + delete holder; + return false; +} + +void key_event_impl(lv_event_t *event) { auto *key = static_cast(lv_event_get_param(event)); if (!key || key->key_state != KBD_KEY_RELEASED) return; @@ -459,22 +678,36 @@ void key_event_cb(lv_event_t *event) } } -bool valid_thread(cp0_sudo_callback_thread_t thread) +void key_event_cb(lv_event_t *event) { - return thread == CP0_SUDO_CALLBACK_LVGL || thread == CP0_SUDO_CALLBACK_WORKER; + DispatchScope dispatch_scope; + cp0::callback::invoke_direct(key_event_impl, event); } +struct EnqueueHolder { + std::shared_ptr request; + cp0::dispatch::ShutdownGate::Reservation reservation; +}; + void enqueue_cb(void *user) { - std::unique_ptr> holder( - static_cast *>(user)); - execute_actions(g_coordinator.commit_reserved((*holder)->id, now_ms())); + std::unique_ptr holder(static_cast(user)); + auto lease = g_dispatch_gate.acquire(std::move(holder->reservation)); + if (!lease) return; + DispatchScope dispatch_scope; + execute_actions(g_coordinator.commit_reserved(holder->request->id, now_ms())); } int enqueue(std::shared_ptr request, uint64_t *request_id) { if (!g_coordinator.reserve(request)) return -EEXIST; - auto *holder = new (std::nothrow) std::shared_ptr(request); + auto reservation = g_dispatch_gate.reserve(); + if (!reservation) { + g_coordinator.release_reserved(request->id, now_ms()); + return -ESHUTDOWN; + } + auto *holder = new (std::nothrow) EnqueueHolder{ + request, std::move(*reservation)}; if (!holder) { auto actions = g_coordinator.release_reserved(request->id, now_ms()); if (!actions.empty() && !post_actions(actions)) @@ -485,10 +718,10 @@ int enqueue(std::shared_ptr request, uint64_t *request_id) [holder](auto callback) { return lv_async_call(callback, holder) == LV_RESULT_OK; }, enqueue_cb); if (!scheduled) { - delete holder; auto actions = g_coordinator.release_reserved(request->id, now_ms()); if (!actions.empty() && !post_actions(actions)) g_coordinator.requeue_actions(std::move(actions)); + delete holder; return -EIO; } if (request_id) *request_id = request->id; @@ -502,17 +735,15 @@ extern "C" int cp0_sudo_run_argv_async_ex(const char *const *argv, cp0_sudo_complete_cb_t complete_cb, void *user, int auth_timeout_ms, int exec_timeout_ms, uint64_t *request_id) { - if (!argv || !argv[0] || !argv[0][0] || !valid_thread(callback_thread)) return -EINVAL; - auto request = std::make_shared(); - request->id = g_next_request_id.fetch_add(1); - request->callback_thread = callback_thread; - request->output_cb = output_cb; - request->complete_cb = complete_cb; - request->user = user; - request->auth_timeout_ms = auth_timeout_ms; - request->exec_timeout_ms = exec_timeout_ms; - for (size_t i = 0; argv[i]; ++i) request->argv.emplace_back(argv[i]); - return enqueue(std::move(request), request_id); + if (!argv || !argv[0] || !argv[0][0] || !valid_callback_thread(callback_thread)) + return -EINVAL; + return cp0_sudo::invoke_c_api([&] { + if (!sudo_running()) return -ESHUTDOWN; + auto built = g_request_factory.create_argv(argv, callback_thread, output_cb, + complete_cb, user, auth_timeout_ms, exec_timeout_ms); + if (built.error != 0) return built.error; + return enqueue(std::move(built.request), request_id); + }); } extern "C" int cp0_sudo_run_argv_async(const char *const *argv, @@ -527,25 +758,26 @@ extern "C" int cp0_sudo_run_shell_async(const char *command, cp0_sudo_callback_thread_t callback_thread, cp0_sudo_output_cb_t output_cb, cp0_sudo_complete_cb_t complete_cb, void *user) { - if (!command || !command[0] || !valid_thread(callback_thread)) return -EINVAL; - auto request = std::make_shared(); - request->id = g_next_request_id.fetch_add(1); - request->argv.emplace_back(command); - request->use_login_shell = true; - request->callback_thread = callback_thread; - request->output_cb = output_cb; - request->complete_cb = complete_cb; - request->user = user; - return enqueue(std::move(request), nullptr); + if (!command || !command[0] || !valid_callback_thread(callback_thread)) return -EINVAL; + return cp0_sudo::invoke_c_api([&] { + if (!sudo_running()) return -ESHUTDOWN; + auto built = g_request_factory.create_shell(command, callback_thread, output_cb, + complete_cb, user); + if (built.error != 0) return built.error; + return enqueue(std::move(built.request), nullptr); + }); } extern "C" int cp0_sudo_cancel(uint64_t request_id) { - std::vector actions; - int rc = g_coordinator.cancel(request_id, actions, now_ms()); - if (rc != 0) return rc; - if (!post_actions(actions)) g_coordinator.requeue_actions(std::move(actions)); - return 0; + return cp0_sudo::invoke_c_api([&] { + if (!sudo_running()) return -ESHUTDOWN; + std::vector actions; + int rc = g_coordinator.cancel(request_id, actions, now_ms()); + if (rc != 0) return rc; + if (!post_actions(actions)) g_coordinator.requeue_actions(std::move(actions)); + return 0; + }); } extern "C" int cp0_sudo_queue_password(const char *password) @@ -553,22 +785,46 @@ extern "C" int cp0_sudo_queue_password(const char *password) if (!password || !password[0]) return -EINVAL; const size_t length = std::strlen(password); if (length > kMaxPasswordBytes) return -E2BIG; - { - std::lock_guard lock(g_queued_password_mutex); - secure_clear(g_queued_password); - g_queued_password.assign(password, length); - g_queued_password_deadline_ms = now_ms() + 60000; - } - if (lv_async_call(apply_queued_password, nullptr) != LV_RESULT_OK) { - clear_queued_password(); - return -EIO; - } - return 0; + return cp0_sudo::invoke_c_api([&] { + if (!sudo_running()) return -ESHUTDOWN; + { + std::lock_guard lock(g_queued_password_mutex); + secure_clear(g_queued_password); + g_queued_password.assign(password, length); + g_queued_password_deadline_ms = now_ms() + 60000; + } + if (!schedule_apply_queued_password()) { + clear_queued_password(); + return -EIO; + } + return 0; + }); } extern "C" void init_sudo_signals(void) { - cp0_signal_sudo_argv_async.append([](std::list args, int auth_timeout_ms, + try { + { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + if (g_sudo_phase == SudoPhase::Running) { + if (g_sudo_owner_thread != std::this_thread::get_id()) return; + return; + } + if (g_sudo_phase != SudoPhase::Stopped) return; + g_sudo_phase = SudoPhase::Finalizing; + g_sudo_owner_thread = std::this_thread::get_id(); + } + if (!g_coordinator.accepting() && !g_coordinator.resume()) { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + g_sudo_owner_thread = {}; + g_sudo_phase = SudoPhase::Stopped; + return; + } + auto workers = std::make_shared(); + g_dispatch_gate.resume(); + if (!sudo_registration().replace( + cp0::bind_signal(cp0_signal_sudo_argv_async, + [](std::list args, int auth_timeout_ms, int exec_timeout_ms, std::function complete, std::function started) { std::vector storage(args.begin(), args.end()); @@ -586,12 +842,14 @@ extern "C" void init_sudo_signals(void) signal_request_complete, context, auth_timeout_ms, exec_timeout_ms, &request_id); if (ret != 0) delete context; if (started) started(ret, request_id); - }); - cp0_signal_sudo_cancel.append([](uint64_t request_id, std::function done) { + }), + cp0::bind_signal(cp0_signal_sudo_cancel, + [](uint64_t request_id, std::function done) { int ret = cp0_sudo_cancel(request_id); if (done) done(ret); - }); - cp0_signal_system_admin_async.append([](std::list args, int auth_timeout_ms, + }), + cp0::bind_signal(cp0_signal_system_admin_async, + [](std::list args, int auth_timeout_ms, int exec_timeout_ms, std::function complete, std::function started) { if (args.empty()) { @@ -602,7 +860,11 @@ extern "C" void init_sudo_signals(void) const std::string value = args.size() > 1 ? *std::next(args.begin()) : std::string(); std::list argv; if (command == "AdbSet") { - argv = {cp0_file_path("adb_helper"), value == "1" ? "enable" : "disable"}; + if (args.size() != 2 || (value != "0" && value != "1")) { + if (started) started(-EINVAL, 0); + return; + } + argv = {"systemctl", value == "1" ? "restart" : "stop", "adbd.service"}; } else if (command == "AdbAuthorize") { if (value.size() < 680 || value.size() > 2048 || value.find('\n') != std::string::npos || value.find('\r') != std::string::npos) { @@ -649,5 +911,133 @@ extern "C" void init_sudo_signals(void) } cp0_signal_sudo_argv_async(std::move(argv), auth_timeout_ms, exec_timeout_ms, std::move(complete), std::move(started)); - }); + }))) { + g_dispatch_gate.begin_shutdown(); + auto rollback = g_coordinator.begin_shutdown(-EIO, now_ms()); + workers->request_shutdown(); + (void)workers->join(); + execute_actions(std::move(rollback)); + for (;;) { + auto actions = g_coordinator.tick(now_ms(), {SIZE_MAX, SIZE_MAX}); + if (actions.empty()) break; + execute_actions(std::move(actions)); + } + while (g_dispatch_gate.pending() != 0) (void)lv_timer_handler(); + if (g_timer) { + lv_timer_delete(g_timer); + g_timer = nullptr; + } + destroy_prompt(); + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + g_sudo_owner_thread = {}; + g_sudo_phase = SudoPhase::Stopped; + return; + } + { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + g_workers = std::move(workers); + g_sudo_phase = SudoPhase::Running; + } + } catch (...) { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + if (g_sudo_phase == SudoPhase::Finalizing) { + g_sudo_owner_thread = {}; + g_sudo_phase = SudoPhase::Stopped; + } + } +} + +extern "C" void deinit_sudo(void) noexcept +{ + try { + std::shared_ptr workers; + std::thread::id owner_thread; + bool begin_shutdown = false; + { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + if (g_sudo_phase == SudoPhase::Stopped || + g_sudo_phase == SudoPhase::Finalizing) return; + workers = g_workers; + owner_thread = g_sudo_owner_thread; + if (g_sudo_phase == SudoPhase::Running) { + g_sudo_phase = SudoPhase::ShutdownRequested; + begin_shutdown = true; + } + } + + if (begin_shutdown) { + g_dispatch_gate.begin_shutdown(); + try { + sudo_registration().reset(); + } catch (...) { + } + auto actions = g_coordinator.begin_shutdown(-ESHUTDOWN, now_ms()); + { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + g_shutdown_actions = std::move(actions); + } + if (workers) workers->request_shutdown(); + } + + if (std::this_thread::get_id() != owner_thread || g_sudo_dispatch_depth != 0 || + (workers && workers->is_current_worker())) return; + { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + if (g_sudo_phase != SudoPhase::ShutdownRequested) return; + g_sudo_phase = SudoPhase::Finalizing; + } + if (workers && workers->join() != cp0_sudo::JoinResult::Joined) { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + g_sudo_phase = SudoPhase::ShutdownRequested; + return; + } + + std::vector shutdown_actions; + { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + shutdown_actions = std::move(g_shutdown_actions); + } + execute_actions(std::move(shutdown_actions)); + + for (;;) { + auto actions = g_coordinator.tick(now_ms(), {SIZE_MAX, SIZE_MAX}); + if (actions.empty()) break; + execute_actions(std::move(actions)); + } + std::size_t previous_pending = g_dispatch_gate.pending(); + unsigned stalled = 0; + while (previous_pending != 0) { + (void)lv_timer_handler(); + const std::size_t pending = g_dispatch_gate.pending(); + if (pending < previous_pending) stalled = 0; + else if (++stalled >= 1024) { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + g_sudo_phase = SudoPhase::ShutdownRequested; + return; + } + previous_pending = pending; + } + for (;;) { + auto actions = g_coordinator.tick(now_ms(), {SIZE_MAX, SIZE_MAX}); + if (actions.empty()) break; + execute_actions(std::move(actions)); + } + + if (g_timer) { + lv_timer_delete(g_timer); + g_timer = nullptr; + } + destroy_prompt(); + clear_queued_password(); + { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + if (g_workers == workers) g_workers.reset(); + g_sudo_owner_thread = {}; + g_sudo_phase = SudoPhase::Stopped; + } + } catch (...) { + std::lock_guard lifecycle_lock(g_sudo_lifecycle_mutex); + if (g_sudo_phase == SudoPhase::Finalizing) + g_sudo_phase = SudoPhase::ShutdownRequested; + } } diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_c_api_boundary.hpp b/ext_components/cp0_lvgl/src/cp0_sudo_c_api_boundary.hpp new file mode 100644 index 00000000..feb106fa --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_sudo_c_api_boundary.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +namespace cp0_sudo { + +template +int invoke_c_api(Operation &&operation) noexcept +{ + try { + return std::forward(operation)(); + } catch (const std::bad_alloc &) { + return -ENOMEM; + } catch (...) { + return -EIO; + } +} + +} // namespace cp0_sudo diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.cpp b/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.cpp index 7dfecb35..53e01ed1 100644 --- a/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.cpp +++ b/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.cpp @@ -11,7 +11,8 @@ Coordinator::Coordinator(size_t pending_limit, size_t terminal_limit) bool Coordinator::reserve(std::shared_ptr request) { std::lock_guard lock(mutex_); - if (!request || requests_.count(request->id) || terminals_.count(request->id)) return false; + if (!accepting_ || !request || requests_.count(request->id) || terminals_.count(request->id)) + return false; request->state = State::RESERVED; requests_[request->id] = request; reservations_.push_back(request); @@ -73,7 +74,7 @@ void Coordinator::remember_terminal_locked(uint64_t id) std::vector Coordinator::start_next_locked(int64_t now_ms) { std::vector actions; - while (!current_ && terminal_completions_pending_ == 0 && !queue_.empty()) { + while (accepting_ && !current_ && terminal_completions_pending_ == 0 && !queue_.empty()) { auto request = queue_.front(); queue_.pop_front(); if (request->cancel_requested.load()) { @@ -104,13 +105,51 @@ void Coordinator::terminal_locked(const std::shared_ptr &request, std::vector Coordinator::enqueue(std::shared_ptr request, int64_t now_ms) { std::lock_guard lock(mutex_); - if (!request || requests_.count(request->id) || terminals_.count(request->id)) return {}; + if (!accepting_ || !request || requests_.count(request->id) || terminals_.count(request->id)) + return {}; request->state = State::QUEUED; requests_[request->id] = request; queue_.push_back(request); return start_next_locked(now_ms); } +std::vector Coordinator::begin_shutdown(int error, int64_t now_ms) +{ + std::lock_guard lock(mutex_); + (void)now_ms; + if (!accepting_) return {}; + accepting_ = false; + + std::vector actions; + if (current_) { + auto request = current_; + request->cancel_requested.store(true); + if (request->state == State::RUNNING) { + if (!request->authentication_complete) { + request->authentication_complete = true; + actions.push_back({ActionType::DESTROY_PROMPT, request}); + } + } else { + actions.push_back({ActionType::DESTROY_PROMPT, request}); + terminal_locked(request, CP0_SUDO_RESULT_EXEC_FAILED, error); + } + } + while (!queue_.empty()) { + auto request = queue_.front(); + queue_.pop_front(); + request->cancel_requested.store(true); + terminal_locked(request, CP0_SUDO_RESULT_EXEC_FAILED, error); + } + while (!reservations_.empty()) { + auto request = reservations_.front(); + reservations_.pop_front(); + reservation_ready_.erase(request->id); + request->cancel_requested.store(true); + terminal_locked(request, CP0_SUDO_RESULT_EXEC_FAILED, error); + } + return actions; +} + std::vector Coordinator::fail_all(int error, int64_t now_ms) { std::lock_guard lock(mutex_); @@ -189,6 +228,17 @@ std::vector Coordinator::worker_auth_result(uint64_t id, cp0_sudo_result return {}; } +std::vector Coordinator::worker_authenticated(uint64_t id) +{ + std::lock_guard lock(mutex_); + auto it = requests_.find(id); + if (it == requests_.end() || it->second->state != State::RUNNING || + it->second->cancel_requested.load() || it->second->authentication_complete) + return {}; + it->second->authentication_complete = true; + return {{ActionType::DESTROY_PROMPT, it->second}}; +} + OutputResult Coordinator::worker_output(uint64_t id, std::string data) { std::lock_guard lock(mutex_); @@ -221,7 +271,8 @@ void Coordinator::worker_complete(uint64_t id, cp0_sudo_result_t result, int exi auto it = requests_.find(id); if (it == requests_.end() || it->second->state != State::RUNNING) return; if (it->second->cancel_requested.load()) { result = CP0_SUDO_RESULT_CANCELLED; exit_code = -ECANCELED; } - controls_.push_back({ActionType::DESTROY_PROMPT, it->second}); + if (!it->second->authentication_complete) + controls_.push_back({ActionType::DESTROY_PROMPT, it->second}); terminal_locked(it->second, result, exit_code); } @@ -279,4 +330,20 @@ State Coordinator::state(uint64_t id) const return it == requests_.end() ? State::TERMINAL : it->second->state; } +bool Coordinator::accepting() const +{ + std::lock_guard lock(mutex_); + return accepting_; +} + +bool Coordinator::resume() +{ + std::lock_guard lock(mutex_); + if (current_ || !requests_.empty() || !reservations_.empty() || !queue_.empty() || + !controls_.empty() || !callbacks_.empty() || terminal_completions_pending_ != 0) + return false; + accepting_ = true; + return true; +} + } // namespace cp0_sudo diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.hpp b/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.hpp index 5c510626..9331aa58 100644 --- a/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.hpp +++ b/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.hpp @@ -34,6 +34,7 @@ struct Request { int exec_timeout_ms = 0; int64_t deadline_ms = 0; std::atomic cancel_requested{false}; + bool authentication_complete = false; size_t pending_bytes = 0; }; @@ -63,6 +64,7 @@ class Coordinator { std::vector commit_reserved(uint64_t id, int64_t now_ms); std::vector release_reserved(uint64_t id, int64_t now_ms); std::vector enqueue(std::shared_ptr request, int64_t now_ms); + std::vector begin_shutdown(int error, int64_t now_ms); void requeue_actions(std::vector actions); std::vector fail_all(int error, int64_t now_ms); int cancel(uint64_t id, std::vector &actions, int64_t now_ms); @@ -70,11 +72,14 @@ class Coordinator { std::vector submit_password(uint64_t id); std::vector worker_auth_result(uint64_t id, cp0_sudo_result_t result, int exit_code, int64_t now_ms); + std::vector worker_authenticated(uint64_t id); OutputResult worker_output(uint64_t id, std::string data); void output_delivered(uint64_t id, size_t size); void worker_complete(uint64_t id, cp0_sudo_result_t result, int exit_code); std::shared_ptr find(uint64_t id) const; State state(uint64_t id) const; + bool accepting() const; + bool resume(); size_t max_output_chunk() const { return pending_limit_; } private: @@ -105,6 +110,7 @@ class Coordinator { size_t pending_limit_; size_t terminal_limit_; size_t terminal_completions_pending_ = 0; + bool accepting_ = true; }; } // namespace cp0_sudo diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_request_factory.cpp b/ext_components/cp0_lvgl/src/cp0_sudo_request_factory.cpp new file mode 100644 index 00000000..f53acf78 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_sudo_request_factory.cpp @@ -0,0 +1,54 @@ +#include "cp0_sudo_request_factory.hpp" + +#include + +namespace cp0_sudo { + +bool RequestFactory::valid_thread(cp0_sudo_callback_thread_t thread) +{ + return thread == CP0_SUDO_CALLBACK_LVGL || thread == CP0_SUDO_CALLBACK_WORKER; +} + +std::shared_ptr RequestFactory::create( + cp0_sudo_callback_thread_t callback_thread, cp0_sudo_output_cb_t output_cb, + cp0_sudo_complete_cb_t complete_cb, void *user) +{ + auto request = std::make_shared(); + request->id = next_id_.fetch_add(1); + request->callback_thread = callback_thread; + request->output_cb = output_cb; + request->complete_cb = complete_cb; + request->user = user; + return request; +} + +RequestBuildResult RequestFactory::create_argv( + const char *const *argv, cp0_sudo_callback_thread_t callback_thread, + cp0_sudo_output_cb_t output_cb, cp0_sudo_complete_cb_t complete_cb, + void *user, int auth_timeout_ms, int exec_timeout_ms) +{ + if (!argv || !argv[0] || !argv[0][0] || !valid_thread(callback_thread)) + return {-EINVAL, {}}; + + auto request = create(callback_thread, output_cb, complete_cb, user); + request->auth_timeout_ms = auth_timeout_ms; + request->exec_timeout_ms = exec_timeout_ms; + for (size_t i = 0; argv[i]; ++i) request->argv.emplace_back(argv[i]); + return {0, std::move(request)}; +} + +RequestBuildResult RequestFactory::create_shell( + const char *command, cp0_sudo_callback_thread_t callback_thread, + cp0_sudo_output_cb_t output_cb, cp0_sudo_complete_cb_t complete_cb, + void *user) +{ + if (!command || !command[0] || !valid_thread(callback_thread)) + return {-EINVAL, {}}; + + auto request = create(callback_thread, output_cb, complete_cb, user); + request->argv.emplace_back(command); + request->use_login_shell = true; + return {0, std::move(request)}; +} + +} // namespace cp0_sudo diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_request_factory.hpp b/ext_components/cp0_lvgl/src/cp0_sudo_request_factory.hpp new file mode 100644 index 00000000..7ad7a098 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_sudo_request_factory.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "cp0_sudo_coordinator.hpp" + +#include +#include +#include + +namespace cp0_sudo { + +struct RequestBuildResult { + int error = 0; + std::shared_ptr request; +}; + +class RequestFactory { +public: + explicit RequestFactory(uint64_t first_id = 1) : next_id_(first_id) {} + + RequestBuildResult create_argv(const char *const *argv, + cp0_sudo_callback_thread_t callback_thread, + cp0_sudo_output_cb_t output_cb, + cp0_sudo_complete_cb_t complete_cb, + void *user, int auth_timeout_ms, + int exec_timeout_ms); + RequestBuildResult create_shell(const char *command, + cp0_sudo_callback_thread_t callback_thread, + cp0_sudo_output_cb_t output_cb, + cp0_sudo_complete_cb_t complete_cb, + void *user); + +private: + static bool valid_thread(cp0_sudo_callback_thread_t thread); + std::shared_ptr create(cp0_sudo_callback_thread_t callback_thread, + cp0_sudo_output_cb_t output_cb, + cp0_sudo_complete_cb_t complete_cb, + void *user); + + std::atomic next_id_; +}; + +} // namespace cp0_sudo diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_worker_registry.hpp b/ext_components/cp0_lvgl/src/cp0_sudo_worker_registry.hpp new file mode 100644 index 00000000..29552bbc --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_sudo_worker_registry.hpp @@ -0,0 +1,157 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cp0_sudo { + +enum class JoinResult { + Joined, + SelfJoin, + AlreadyJoining, +}; + +class WorkerRegistry { +public: + WorkerRegistry() = default; + WorkerRegistry(const WorkerRegistry &) = delete; + WorkerRegistry &operator=(const WorkerRegistry &) = delete; + + ~WorkerRegistry() + { + request_shutdown(); + if (join() != JoinResult::Joined) std::terminate(); + } + + template + bool start(Cancel &&cancel, Worker &&worker) + { + auto entry = std::make_unique(); + try { + entry->cancel = std::forward(cancel); + } catch (...) { + return false; + } + + Entry *raw = entry.get(); + std::unique_lock lock(mutex_); + if (!accepting_ || joining_) return false; + ++active_; + entries_.push_back(std::move(entry)); + try { + raw->worker = std::thread( + [this, task = std::forward(worker)]() mutable noexcept { + current_registry_ = this; + try { + task(); + } catch (...) { + } + current_registry_ = nullptr; + worker_finished(); + }); + } catch (...) { + entries_.pop_back(); + --active_; + idle_.notify_all(); + return false; + } + return true; + } + + void request_shutdown() noexcept + { + std::vector> cancellations; + { + std::lock_guard lock(mutex_); + accepting_ = false; + if (shutdown_requested_) return; + shutdown_requested_ = true; + cancellations.reserve(entries_.size()); + for (const auto &entry : entries_) + if (entry->cancel) cancellations.push_back(entry->cancel); + } + for (auto &cancel : cancellations) { + try { + cancel(); + } catch (...) { + } + } + } + + JoinResult join() noexcept + { + std::vector workers; + { + std::unique_lock lock(mutex_); + accepting_ = false; + if (current_registry_ == this) return JoinResult::SelfJoin; + const std::thread::id caller = std::this_thread::get_id(); + for (const auto &entry : entries_) + if (entry->worker.joinable() && entry->worker.get_id() == caller) + return JoinResult::SelfJoin; + if (joined_) return JoinResult::Joined; + if (joining_) return JoinResult::AlreadyJoining; + joining_ = true; + idle_.wait(lock, [this] { return active_ == 0; }); + workers.reserve(entries_.size()); + for (auto &entry : entries_) + if (entry->worker.joinable()) workers.push_back(std::move(entry->worker)); + entries_.clear(); + } + for (auto &worker : workers) worker.join(); + { + std::lock_guard lock(mutex_); + joining_ = false; + joined_ = true; + } + return JoinResult::Joined; + } + + bool accepting() const noexcept + { + std::lock_guard lock(mutex_); + return accepting_; + } + + std::size_t active() const noexcept + { + std::lock_guard lock(mutex_); + return active_; + } + + bool is_current_worker() const noexcept + { + return current_registry_ == this; + } + +private: + struct Entry { + std::function cancel; + std::thread worker; + }; + + void worker_finished() noexcept + { + std::lock_guard lock(mutex_); + --active_; + idle_.notify_all(); + } + + mutable std::mutex mutex_; + std::condition_variable idle_; + std::vector> entries_; + std::size_t active_ = 0; + bool accepting_ = true; + bool shutdown_requested_ = false; + bool joining_ = false; + bool joined_ = false; + inline static thread_local WorkerRegistry *current_registry_ = nullptr; +}; + +} // namespace cp0_sudo diff --git a/ext_components/cp0_lvgl/src/cp0_sync_signal.hpp b/ext_components/cp0_lvgl/src/cp0_sync_signal.hpp new file mode 100644 index 00000000..445801d7 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_sync_signal.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace cp0::signal { + +template +bool invoke_noexcept(Operation &&operation) noexcept +{ + try { + std::forward(operation)(); + return true; + } catch (...) { + return false; + } +} + +} // namespace cp0::signal diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_bluetooth_settings.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_bluetooth_settings.cpp new file mode 100644 index 00000000..bdcc9ee5 --- /dev/null +++ b/ext_components/cp0_lvgl/src/sdl/sdl_bluetooth_settings.cpp @@ -0,0 +1,138 @@ +#include "sdl_bluetooth_settings.hpp" + +#include "cp0_lvgl_app.h" +#include "hal/hal_settings.h" +#include "hal_lvgl_bsp.h" + +#include "../cp0_app_internal_utils.h" +#include "../cp0_bluetooth_api_contract.hpp" +#include "../cp0_signal_registration.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sdl_bluetooth_settings { +namespace { + +class BluetoothSettings +{ +public: + using callback_t = std::function; + using arg_t = std::list; + + void api_call(const arg_t &args, const callback_t &callback) + { + cp0::bluetooth::Request request; + if (!cp0::bluetooth::parse_request(args, request)) { + cp0::bluetooth::invoke_callback(callback, -1, "invalid bt api request"); + return; + } + cp0::bluetooth::invoke_backend(callback, [&]() -> cp0::bluetooth::Reply { + using cp0::bluetooth::Command; + if (request.command == Command::Status) return {0, encode_status(status())}; + if (request.command == Command::Power) return {hal_bt_set_power(request.value), {}}; + if (request.command == Command::Alias || request.command == Command::Discoverable) + return {0, {}}; + if (request.command == Command::Scan || request.command == Command::List || + request.command == Command::ConnectedList) { + std::vector devices(static_cast(request.max_count)); + int count = scan(devices.empty() ? nullptr : devices.data(), + static_cast(devices.size())); + if (request.command == Command::ConnectedList) + count = filter_connected(devices.data(), count); + return {count, encode_devices(devices.data(), count)}; + } + if (request.command == Command::DiscoveryStart || + request.command == Command::DiscoveryStop) + return {0, {}}; + return {-1, {}}; + }); + } + +private: + static cp0_bt_status_t status() + { + const hal_bt_status_t source = hal_bt_get_status(); + cp0_bt_status_t result{}; + result.powered = source.powered; + result.discoverable = source.discoverable; + cp0_copy_string(result.address, sizeof(result.address), source.address); + cp0_copy_string(result.alias, sizeof(result.alias), source.alias); + return result; + } + + static int scan(cp0_bt_device_t *output, int max_devices) + { + if (!output || max_devices <= 0) return hal_bt_scan(nullptr, 0); + + std::vector source(static_cast(max_devices)); + int count = std::min(hal_bt_scan(source.data(), max_devices), max_devices); + for (int index = 0; index < count; ++index) { + cp0_copy_string(output[index].name, sizeof(output[index].name), source[index].name); + cp0_copy_string(output[index].address, sizeof(output[index].address), + source[index].address); + output[index].rssi = source[index].rssi; + output[index].connected = source[index].connected; + output[index].paired = source[index].paired; + output[index].trusted = source[index].trusted; + } + return count; + } + + static int filter_connected(cp0_bt_device_t *devices, int count) + { + if (!devices || count <= 0) return 0; + int output_count = 0; + for (int index = 0; index < count; ++index) { + if (!devices[index].connected) continue; + if (output_count != index) devices[output_count] = devices[index]; + ++output_count; + } + return output_count; + } + + static std::string encode_status(const cp0_bt_status_t &value) + { + std::ostringstream output; + output << value.powered << '\t' + << cp0::bluetooth::sanitize_wire_field(value.address) << '\t' + << value.discoverable << '\t' + << cp0::bluetooth::sanitize_wire_field(value.alias); + return output.str(); + } + + static std::string encode_devices(const cp0_bt_device_t *devices, int count) + { + std::ostringstream output; + for (int index = 0; devices && index < count; ++index) { + output << cp0::bluetooth::sanitize_wire_field(devices[index].address) << '\t' + << devices[index].rssi << '\t' + << devices[index].connected << '\t' << devices[index].paired << '\t' + << devices[index].trusted << '\t' + << cp0::bluetooth::sanitize_wire_field(devices[index].name) << '\n'; + } + return output.str(); + } +}; + +} // namespace + +void register_api() +{ + auto bluetooth = std::make_shared(); + static cp0::SignalRegistration registration; + registration.replace( + cp0_signal_bt_api, + [bluetooth](std::list args, + std::function callback) { + bluetooth->api_call(args, callback); + }); +} + +} // namespace sdl_bluetooth_settings diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_bluetooth_settings.hpp b/ext_components/cp0_lvgl/src/sdl/sdl_bluetooth_settings.hpp new file mode 100644 index 00000000..969b0be5 --- /dev/null +++ b/ext_components/cp0_lvgl/src/sdl/sdl_bluetooth_settings.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace sdl_bluetooth_settings { + +void register_api(); + +} // namespace sdl_bluetooth_settings diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_external_app_runner.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_external_app_runner.cpp new file mode 100644 index 00000000..277cb833 --- /dev/null +++ b/ext_components/cp0_lvgl/src/sdl/sdl_external_app_runner.cpp @@ -0,0 +1,83 @@ +#include "sdl_external_app_runner.hpp" + +#include "../cp0_external_process_group.hpp" + +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#endif + +namespace sdl_external_app_runner { + +int run(const char *command, volatile int *home_key_flag) +{ +#if defined(_WIN32) + (void)command; + (void)home_key_flag; + return -1; +#else + const bool subreaper = cp0_process_group::enable_subreaper(); + const pid_t pid = fork(); + if (pid < 0) return -1; + if (pid == 0) { + setpgid(0, 0); + execlp("/bin/sh", "sh", "-c", command, static_cast(nullptr)); + _exit(127); + } + + setpgid(pid, pid); + std::fprintf(stderr, "[process] external app leader=%d pgid=%d subreaper=%d\n", + static_cast(pid), static_cast(pid), subreaper ? 1 : 0); + + int status = 0; + bool leader_reaped = false; + int exit_stage = 0; + auto key_down_since = std::chrono::steady_clock::time_point{}; + auto terminate_since = std::chrono::steady_clock::time_point{}; + while (true) { + cp0_process_group::reap_available(pid, pid, status, leader_reaped); + if (!cp0_process_group::exists(pid)) break; + + if (home_key_flag) { + if (exit_stage == 0 && *home_key_flag) { + exit_stage = 1; + key_down_since = std::chrono::steady_clock::now(); + } else if (exit_stage == 1) { + if (!*home_key_flag) { + exit_stage = 0; + } else if (std::chrono::duration_cast( + std::chrono::steady_clock::now() - key_down_since) + .count() >= 3) { + exit_stage = 2; + terminate_since = std::chrono::steady_clock::now(); + std::fprintf(stderr, "[process] ESC timeout: SIGTERM pgid=%d\n", + static_cast(pid)); + killpg(pid, SIGTERM); + } + } else if (exit_stage == 2 && + std::chrono::duration_cast( + std::chrono::steady_clock::now() - terminate_since) + .count() >= 2) { + exit_stage = 3; + std::fprintf(stderr, "[process] grace timeout: SIGKILL pgid=%d\n", + static_cast(pid)); + killpg(pid, SIGKILL); + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + cp0_process_group::reap_available(pid, pid, status, leader_reaped); + std::fprintf(stderr, "[process] external app group drained pgid=%d leader_reaped=%d\n", + static_cast(pid), leader_reaped ? 1 : 0); + if (home_key_flag) *home_key_flag = 0; + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +#endif +} + +} // namespace sdl_external_app_runner diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_external_app_runner.hpp b/ext_components/cp0_lvgl/src/sdl/sdl_external_app_runner.hpp new file mode 100644 index 00000000..1981c3fc --- /dev/null +++ b/ext_components/cp0_lvgl/src/sdl/sdl_external_app_runner.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace sdl_external_app_runner { + +int run(const char *command, volatile int *home_key_flag); + +} // namespace sdl_external_app_runner diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_hal_settings.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_hal_settings.cpp new file mode 100644 index 00000000..4e859799 --- /dev/null +++ b/ext_components/cp0_lvgl/src/sdl/sdl_hal_settings.cpp @@ -0,0 +1,114 @@ +#include "cp0_lvgl_app.h" +#include "hal/hal_settings.h" + +#include +#include +#include +#include + +extern "C" time_t cp0_sdl_time_now(void); + +extern "C" int hal_backlight_read(void) { return 75; } +extern "C" int hal_backlight_max(void) { return 100; } + +extern "C" int hal_backlight_write(int value) +{ + return std::max(0, std::min(hal_backlight_max(), value)); +} + +extern "C" hal_battery_info_t hal_battery_read(void) +{ + const cp0_battery_info_t source = cp0_battery_read(); + hal_battery_info_t result{}; + result.voltage_mv = source.voltage_mv; + result.current_ma = source.current_ma; + result.temperature_c10 = source.temperature_c10; + result.soc = source.soc; + result.remain_mah = source.remain_mah; + result.full_mah = source.full_mah; + result.flags = source.flags; + result.avg_current_ma = source.avg_current_ma; + result.valid = source.valid; + return result; +} + +extern "C" int hal_volume_read(void) { return 39; } +extern "C" int hal_volume_write(int value) { return std::max(0, std::min(63, value)); } + +extern "C" hal_wifi_status_t hal_wifi_get_status(void) +{ + hal_wifi_status_t status{}; + status.connected = 1; + std::strncpy(status.ssid, "SimulatedWiFi", WIFI_SSID_MAX - 1); + std::strncpy(status.ip, "192.168.1.100", sizeof(status.ip) - 1); + status.signal = 80; + return status; +} + +extern "C" int hal_wifi_scan(hal_wifi_ap_t *output, int max_access_points) +{ + if (!output || max_access_points <= 0) return 0; + + const int count = std::min(max_access_points, 3); + std::memset(output, 0, sizeof(hal_wifi_ap_t) * static_cast(count)); + if (count > 0) { + std::strncpy(output[0].ssid, "SimulatedWiFi", WIFI_SSID_MAX - 1); + std::strncpy(output[0].security, "WPA2", sizeof(output[0].security) - 1); + output[0].signal = 80; + output[0].in_use = 1; + } + if (count > 1) { + std::strncpy(output[1].ssid, "Neighbor_5G", WIFI_SSID_MAX - 1); + std::strncpy(output[1].security, "WPA2", sizeof(output[1].security) - 1); + output[1].signal = 55; + } + if (count > 2) { + std::strncpy(output[2].ssid, "FreeWiFi", WIFI_SSID_MAX - 1); + std::strncpy(output[2].security, "Open", sizeof(output[2].security) - 1); + output[2].signal = 30; + } + return count; +} + +extern "C" int hal_wifi_connect(const char *ssid, const char *password) +{ + (void)ssid; + (void)password; + return 0; +} + +extern "C" int hal_wifi_disconnect(void) { return 0; } + +extern "C" hal_bt_status_t hal_bt_get_status(void) +{ + hal_bt_status_t status{}; + std::strncpy(status.address, "00:00:00:00:00:00", sizeof(status.address) - 1); + std::strncpy(status.alias, "CardputerZero", sizeof(status.alias) - 1); + return status; +} + +extern "C" int hal_bt_set_power(int on) +{ + (void)on; + return 0; +} + +extern "C" int hal_bt_scan(hal_bt_device_t *output, int max_devices) +{ + (void)output; + (void)max_devices; + return 0; +} + +extern "C" void hal_time_str(char *buffer, int buffer_size) +{ + if (!buffer || buffer_size <= 0) return; + const std::time_t now = cp0_sdl_time_now(); + const std::tm *time = std::localtime(&now); + if (!time) { + buffer[0] = '\0'; + return; + } + std::snprintf(buffer, static_cast(buffer_size), "%02d:%02d", time->tm_hour, + time->tm_min); +} diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c index a7cec137..88818ddc 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c @@ -8,29 +8,180 @@ #include #include "sdl_lvgl.h" #include "commount.h" +#include "../cp0_init_plan.h" +#include +static uint64_t enabled_features(void) +{ + uint64_t features = 0; +#ifdef CONFIG_CP0_LVGL_INIT_FILESYSTEM + features |= CP0_INIT_FEATURE_FILESYSTEM; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_CONFIG + features |= CP0_INIT_FEATURE_CONFIG; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_PTY + features |= CP0_INIT_FEATURE_PTY; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_AUDIO + features |= CP0_INIT_FEATURE_AUDIO; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_PROCESS + features |= CP0_INIT_FEATURE_PROCESS; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SUDO + features |= CP0_INIT_FEATURE_SUDO; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_OSINFO + features |= CP0_INIT_FEATURE_OSINFO; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SCREENSHOT + features |= CP0_INIT_FEATURE_SCREENSHOT; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_LORA + features |= CP0_INIT_FEATURE_LORA; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_WIFI + features |= CP0_INIT_FEATURE_WIFI; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SETTINGS + features |= CP0_INIT_FEATURE_SETTINGS; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_BQ27220 + features |= CP0_INIT_FEATURE_BQ27220; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_IMU + features |= CP0_INIT_FEATURE_IMU; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS + features |= CP0_INIT_FEATURE_SAVED_SETTINGS; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_BATTERY + features |= CP0_INIT_FEATURE_BATTERY; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_CAMERA + features |= CP0_INIT_FEATURE_CAMERA; +#endif +#ifdef CONFIG_CP0_LVGL_INIT_SOUNDCARD + features |= CP0_INIT_FEATURE_SOUNDCARD; +#endif + return features; +} + +static int run_init_step(cp0_init_step_t step) +{ + switch (step) { + case CP0_INIT_STEP_ENV: init_lvgl_env(); break; + case CP0_INIT_STEP_EVENT: init_lvgl_event(); break; + case CP0_INIT_STEP_FILESYSTEM: +#ifdef CONFIG_CP0_LVGL_INIT_FILESYSTEM + init_filesystem(); +#endif + break; + case CP0_INIT_STEP_CONFIG: +#ifdef CONFIG_CP0_LVGL_INIT_CONFIG + init_config(); +#endif + break; + case CP0_INIT_STEP_PTY: +#ifdef CONFIG_CP0_LVGL_INIT_PTY + init_pty(); +#endif + break; + case CP0_INIT_STEP_DISPLAY: + init_freambuffer_disp(); + return lv_display_get_default() != NULL; + case CP0_INIT_STEP_INPUT: init_input(); break; + case CP0_INIT_STEP_AUDIO: +#ifdef CONFIG_CP0_LVGL_INIT_AUDIO + init_audio(); +#endif + break; + case CP0_INIT_STEP_PROCESS: +#ifdef CONFIG_CP0_LVGL_INIT_PROCESS + init_process(); +#endif + break; + case CP0_INIT_STEP_SUDO: +#ifdef CONFIG_CP0_LVGL_INIT_SUDO + init_sudo_signals(); +#endif + break; + case CP0_INIT_STEP_OSINFO: +#ifdef CONFIG_CP0_LVGL_INIT_OSINFO + init_osinfo(); +#endif + break; + case CP0_INIT_STEP_SCREENSHOT: +#ifdef CONFIG_CP0_LVGL_INIT_SCREENSHOT + init_screenshot(); +#endif + break; + case CP0_INIT_STEP_LORA: +#ifdef CONFIG_CP0_LVGL_INIT_LORA + init_lora(); +#endif + break; + case CP0_INIT_STEP_WIFI: +#ifdef CONFIG_CP0_LVGL_INIT_WIFI + init_wifi(); +#endif + break; + case CP0_INIT_STEP_SETTINGS: +#ifdef CONFIG_CP0_LVGL_INIT_SETTINGS + init_settings(); +#endif + break; + case CP0_INIT_STEP_BQ27220: +#ifdef CONFIG_CP0_LVGL_INIT_BQ27220 + init_bq27220(); +#endif + break; + case CP0_INIT_STEP_IMU: +#ifdef CONFIG_CP0_LVGL_INIT_IMU + init_imu(); +#endif + break; + case CP0_INIT_STEP_SAVED_SETTINGS: +#ifdef CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS + init_lvgl_saved_settings(); +#endif + break; + case CP0_INIT_STEP_BATTERY: +#ifdef CONFIG_CP0_LVGL_INIT_BATTERY + init_battery(); +#endif + break; + case CP0_INIT_STEP_CAMERA: +#ifdef CONFIG_CP0_LVGL_INIT_CAMERA + init_camera(); +#endif + break; + case CP0_INIT_STEP_SOUNDCARD: +#ifdef CONFIG_CP0_LVGL_INIT_SOUNDCARD + init_soundcard(); +#endif + break; + case CP0_INIT_STEP_RPC: + case CP0_INIT_STEP_BLUETOOTH: + break; + } + return 1; +} void cp0_lvgl_init(void) { - init_lvgl_env(); - init_lvgl_event(); - init_filesystem(); - init_config(); - init_pty(); - init_freambuffer_disp(); - init_input(); - init_audio(); - init_process(); - init_sudo_signals(); - init_osinfo(); - init_screenshot(); - init_lora(); - init_wifi(); - init_settings(); - init_bq27220(); - init_imu(); - init_lvgl_saved_settings(); - init_battery(); - init_camera(); - init_soundcard(); + static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; + static size_t next_step = 0; + cp0_init_step_t plan[CP0_INIT_STEP_SOUNDCARD + 1]; + const size_t step_count = cp0_build_init_plan(enabled_features(), plan, + sizeof(plan) / sizeof(plan[0])); + + pthread_mutex_lock(&mutex); + while (next_step < step_count) { + if (!run_init_step(plan[next_step])) + break; + ++next_step; + } + pthread_mutex_unlock(&mutex); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.h b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.h index 1a6e1bfe..5cc6b8f7 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.h +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.h @@ -8,11 +8,22 @@ void init_sdl_disp(); void init_sdl_input(); void init_freambuffer_disp(); void init_input(); +void deinit_input(void); void init_lvgl_env(void); void init_filesystem(void); void init_audio(); +#ifdef __cplusplus +void deinit_audio(void) noexcept; +#else +void deinit_audio(void); +#endif void init_config(void); void init_pty(void); +#ifdef __cplusplus +void deinit_pty(void) noexcept; +#else +void deinit_pty(void); +#endif void init_process(void); void init_screenshot(void); void init_lora(void); @@ -22,8 +33,14 @@ void init_osinfo(void); void init_bq27220(void); void init_battery(); void init_imu(void); +void deinit_imu(void); void init_lvgl_saved_settings(void); void init_camera(void); +#ifdef __cplusplus +void deinit_camera(void) noexcept; +#else +void deinit_camera(void); +#endif void init_soundcard(void); #ifdef __cplusplus } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp index 4700e16f..fa3d5b2a 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp @@ -1,15 +1,18 @@ #include "hal_lvgl_bsp.h" +#include "../cp0_audio_api_contract.hpp" +#include "../cp0_audio_runtime_lifecycle.hpp" +#include "../cp0_signal_registration.hpp" #include #include #include #include -#include #include #include #include #include #include +#include #include #include @@ -37,19 +40,17 @@ class AudioSystem void setup(arg_t arg, callback_t callback) { - if (arg.empty()) + cp0::audio::SetupRequest request; + if (!cp0::audio::parse_setup_request(arg, request)) { + report(callback, -1, cp0::audio::invalid_setup_request_message()); return; - - const std::string &cmd = arg.front(); - if (cmd == "set_callback") { + } + if (request.command == cp0::audio::SetupCommand::SetCallback) { status_callback_ = std::move(callback); - } else if (cmd == "set_waveform" || cmd == "waveform") { - auto value = std::next(arg.begin()); - waveform_enabled_ = value == arg.end() || arg_is_enable(*value); - if (value != arg.end() && arg_is_disable(*value)) - waveform_enabled_ = false; + } else if (request.command == cp0::audio::SetupCommand::SetWaveform) { + waveform_enabled_ = request.waveform_enabled; emit_waveform_once(); - } else if (cmd == "stop_play") { + } else { playing_ = false; } } @@ -63,8 +64,9 @@ class AudioSystem void api_call(arg_t arg, callback_t callback) { - if (arg.empty()) { - report(callback, -1, "empty audio api\n"); + cp0::audio::ApiRequest request; + if (!cp0::audio::parse_api_request(arg, request)) { + report(callback, -1, cp0::audio::invalid_api_request_message()); return; } @@ -82,15 +84,25 @@ class AudioSystem map_fun(CapFileSave), map_fun(SetCallback), map_fun(VolumeRead), - map_fun(VolumeWrite), map_fun(MuteRead), map_fun(MuteToggle), map_fun(SetSystemSoundNames), - map_fun(SystemSoundPlay), - map_fun(SystemSoundEnable), }; #undef map_fun + if (request.command == cp0::audio::ApiCommand::VolumeWrite) { + VolumeWrite(request.value, callback); + return; + } + if (request.command == cp0::audio::ApiCommand::SystemSoundPlay) { + SystemSoundPlay(request.value, callback); + return; + } + if (request.command == cp0::audio::ApiCommand::SystemSoundEnable) { + SystemSoundEnable(request.enabled, callback); + return; + } + for (const auto &it : cmd_map) { if (it.first == arg.front()) { it.second(arg, callback); @@ -102,7 +114,7 @@ class AudioSystem private: static constexpr int kDefaultVolume = 39; - static constexpr int kMaxVolume = 63; + static constexpr int kMaxVolume = 100; static constexpr int kRecWaveformSize = 128; callback_t status_callback_; @@ -118,10 +130,7 @@ class AudioSystem void report(callback_t callback, int code, const std::string &data) { - if (callback) - callback(code, data); - else if (status_callback_) - status_callback_(code, data); + cp0::audio::invoke_callback(callback ? callback : status_callback_, code, data); } static std::string first_arg_after_command(const arg_t &arg) @@ -131,30 +140,12 @@ class AudioSystem return *std::next(arg.begin()); } - static bool arg_is_enable(const std::string &arg) - { - return arg == "1" || arg == "on" || arg == "true" || arg == "enable" || arg == "enabled"; - } - - static bool arg_is_disable(const std::string &arg) - { - return arg == "0" || arg == "off" || arg == "false" || arg == "disable" || arg == "disabled"; - } - - static int parse_volume_arg(const arg_t &arg) - { - std::string value = first_arg_after_command(arg); - if (value.empty()) - return 0; - return std::atoi(value.c_str()); - } - void emit_waveform_once() { if (!waveform_enabled_ || !status_callback_) return; std::string waveform(sizeof(float) * kRecWaveformSize, '\0'); - status_callback_(1, waveform); + cp0::audio::invoke_callback(status_callback_, 1, waveform); } static void put_u16_le(std::uint8_t *p, std::uint16_t v) @@ -315,9 +306,9 @@ class AudioSystem report(callback, 0, std::to_string(volume_)); } - void VolumeWrite(arg_t arg, callback_t callback) + void VolumeWrite(int value, callback_t callback) { - volume_ = std::max(0, std::min(kMaxVolume, parse_volume_arg(arg))); + volume_ = value; report(callback, 0, std::to_string(volume_)); } @@ -346,9 +337,8 @@ class AudioSystem report(callback, 0, "ok"); } - void SystemSoundPlay(arg_t arg, callback_t callback) + void SystemSoundPlay(int index, callback_t callback) { - int index = std::atoi(first_arg_after_command(arg).c_str()); if (index < 0 || index >= static_cast(system_sound_names_.size())) { report(callback, -1, "invalid system sound index\n"); return; @@ -361,48 +351,85 @@ class AudioSystem report(callback, 0, "system sound play\n"); } - void SystemSoundEnable(arg_t arg, callback_t callback) + void SystemSoundEnable(bool enabled, callback_t callback) { - std::string value = first_arg_after_command(arg); - if (arg_is_disable(value)) - system_sound_enabled_ = false; - else if (value.empty() || arg_is_enable(value)) - system_sound_enabled_ = true; - else - system_sound_enabled_ = std::atoi(value.c_str()) != 0; + system_sound_enabled_ = enabled; report(callback, 0, system_sound_enabled_ ? "1" : "0"); } }; +using AudioRegistrations = cp0::SignalRegistrationBundle< + decltype(cp0_signal_audio_play), decltype(cp0_signal_audio_cap), + decltype(cp0_signal_audio_setup), decltype(cp0_signal_audio_api), + decltype(cp0_signal_system_play)>; + +std::mutex g_audio_mutex; std::shared_ptr g_audio; +AudioRegistrations g_audio_registrations; +cp0::audio::RuntimeLifecycle g_audio_lifecycle; + +std::shared_ptr current_audio() +{ + std::lock_guard lock(g_audio_mutex); + return g_audio; +} } // namespace extern "C" void init_audio(void) { - if (g_audio) + std::lock_guard lock(g_audio_mutex); + if (!g_audio_lifecycle.begin_init()) return; + try { + auto audio = std::make_shared(); + if (!g_audio_registrations.replace( + cp0::bind_signal(cp0_signal_audio_play, + [audio](std::string wav) { audio->play(std::move(wav)); }), + cp0::bind_signal(cp0_signal_audio_cap, + [audio](bool enable) { audio->cap(enable); }), + cp0::bind_signal(cp0_signal_audio_setup, + [audio](std::list arg, + std::function callback) { + try { + audio->setup(std::move(arg), callback); + } catch (...) { + cp0::audio::invoke_callback( + callback, -1, "audio backend failure\n"); + } + }), + cp0::bind_signal(cp0_signal_audio_api, + [audio](std::list arg, + std::function callback) { + try { + audio->api_call(std::move(arg), callback); + } catch (...) { + cp0::audio::invoke_callback( + callback, -1, "audio backend failure\n"); + } + }), + cp0::bind_signal(cp0_signal_system_play, + [audio](std::string name) { audio->system_play(std::move(name)); }))) { + g_audio_lifecycle.rollback_init(); return; + } + g_audio = std::move(audio); + g_audio_lifecycle.commit_init(); + } catch (...) { + g_audio_registrations.reset(); + g_audio.reset(); + g_audio_lifecycle.rollback_init(); + } +} - g_audio = std::make_shared(); - cp0_signal_audio_play.append([](std::string wav) { - g_audio->play(wav); - }); - - cp0_signal_audio_cap.append([](bool enable) { - g_audio->cap(enable); - }); - - cp0_signal_audio_setup.append([](std::list arg, std::function callback) { - g_audio->setup(std::move(arg), std::move(callback)); - }); - - cp0_signal_audio_api.append([](std::list arg, std::function callback) { - g_audio->api_call(std::move(arg), std::move(callback)); - }); - - cp0_signal_system_play.append([](std::string name) { - g_audio->system_play(name); - }); +extern "C" void deinit_audio(void) noexcept +{ + try { + std::lock_guard lock(g_audio_mutex); + if (!g_audio_lifecycle.begin_shutdown()) return; + g_audio_registrations.reset(); + g_audio.reset(); + } catch (...) { + } } extern "C" void hal_audio_init(void) @@ -413,8 +440,8 @@ extern "C" void hal_audio_init(void) extern "C" void hal_audio_play(const char *path) { init_audio(); - if (g_audio) - g_audio->play(path ? path : ""); + const auto audio = current_audio(); + if (audio) audio->play(path ? path : ""); } extern "C" void hal_audio_play_sync(const char *path) @@ -424,11 +451,11 @@ extern "C" void hal_audio_play_sync(const char *path) extern "C" void hal_audio_stop(void) { - if (g_audio) - g_audio->api_call({"PlayEnd"}, nullptr); + const auto audio = current_audio(); + if (audio) audio->api_call({"PlayEnd"}, nullptr); } extern "C" void hal_audio_deinit(void) { - hal_audio_stop(); + deinit_audio(); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_battery.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_battery.cpp index b5f6780d..9ecbd37a 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_battery.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_battery.cpp @@ -1,40 +1,63 @@ #include "hal_lvgl_bsp.h" #include "lvgl/lvgl.h" #include "cp0_lvgl_app.h" +#include "../cp0_battery_lifecycle.hpp" +#include "../cp0_battery_publish_gate.hpp" +#include "../cp0_signal_registration.hpp" + #include #include -class BatterySystem -{ -public: - void pub() - { - if (lv_c_event[CP0_C_EVENT_BATTERY] == 0) - return; +namespace { +void publish_battery() +{ + static cp0::battery::PublishGate gate; + gate.run([] { + const uint32_t event_code = lv_c_event[CP0_C_EVENT_BATTERY]; + if (event_code == 0) return; cp0_battery_info_t info = cp0_battery_read(); - lv_obj_t *root = lv_display_get_screen_active(NULL); - if (root != NULL) - lv_obj_send_event(root, (lv_event_code_t)lv_c_event[CP0_C_EVENT_BATTERY], (void *)&info); - } -}; + lv_obj_t *root = lv_display_get_screen_active(nullptr); + if (root) + lv_obj_send_event(root, static_cast(event_code), &info); + }); +} -static void battery_timer_cb(lv_timer_t *timer) +void battery_timer_cb(lv_timer_t *) { - auto *battery = static_cast(lv_timer_get_user_data(timer)); - if (battery != nullptr) - battery->pub(); + publish_battery(); } +cp0::battery::Lifecycle &battery_lifecycle() +{ + static cp0::battery::Lifecycle lifecycle; + return lifecycle; +} + +} // namespace + extern "C" void init_battery() { - static std::shared_ptr battery; - if (battery) - return; - - battery = std::make_shared(); - BatterySystem *battery_ptr = battery.get(); - cp0_signal_battery_pub.append([battery_ptr](std::function fun) - { battery_ptr->pub(); }); - lv_timer_create(battery_timer_cb, 100, battery_ptr); + battery_lifecycle().start( + [] { + using Registration = cp0::SignalRegistration; + auto registration = std::make_shared(); + const bool registered = registration->replace( + cp0_signal_battery_pub, [](std::function) { publish_battery(); }); + return cp0::battery::LifecycleResource{ + registered, [registration] { registration->reset(); }}; + }, + [] { + lv_timer_t *timer = lv_timer_create(battery_timer_cb, 100, nullptr); + return cp0::battery::LifecycleResource{ + timer != nullptr, [timer] { + cp0::battery::release_timer_if_runtime_active( + timer, lv_is_initialized(), lv_timer_delete); + }}; + }); +} + +extern "C" void deinit_battery() +{ + battery_lifecycle().stop(); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_bq27220.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_bq27220.cpp index 8d44d537..84b720a0 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_bq27220.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_bq27220.cpp @@ -1,12 +1,17 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" +#include "../cp0_battery_api_contract.hpp" +#include "../cp0_callback_result.hpp" +#include "../cp0_battery_codec.hpp" +#include "../cp0_battery_lifecycle.hpp" +#include "../cp0_callback_contract.hpp" +#include "../cp0_signal_registration.hpp" -#include #include #include #include #include -#include +#include #include #include @@ -44,16 +49,17 @@ class Bq27220System void api_call(arg_t arg, callback_t callback) { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "Read") { - cp0_battery_info_t info = read(); - report(callback, info.valid ? 0 : -1, encode(info)); - } else if (cmd == "Calibrate") { - const int index = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : -1; - report(callback, calibrate(index), ""); - } else { - report(callback, -1, "unknown bq27220 api command"); - } + cp0::CallbackResult result(std::move(callback)); + result.guard(-1, "battery api failure", [&] { + cp0::battery::ApiReply reply; + { + std::lock_guard lock(mutex_); + reply = cp0::battery::dispatch_api_request( + arg, [this] { return read(); }, + [this](int index) { return calibrate(index); }); + } + result.complete(reply.code, reply.data); + }); } cp0_battery_info_t read() @@ -66,61 +72,11 @@ class Bq27220System return (command_index >= 0 && command_index < 4) ? 0 : -1; } - static bool decode_info(const std::string &data, cp0_battery_info_t *info) - { - if (!info) - return false; - cp0_battery_info_t parsed{}; - char comma; - std::istringstream is(data); - if (is >> parsed.voltage_mv >> comma && - is >> parsed.current_ma >> comma && - is >> parsed.temperature_c10 >> comma && - is >> parsed.soc >> comma && - is >> parsed.remain_mah >> comma && - is >> parsed.full_mah >> comma && - is >> parsed.flags >> comma && - is >> parsed.avg_current_ma >> comma && - is >> parsed.valid) { - *info = parsed; - return true; - } - return false; - } - private: - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) - callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - return it == arg.end() ? std::string() : *it; - } + std::mutex mutex_; - static std::string encode(const cp0_battery_info_t &info) - { - std::ostringstream os; - os << info.voltage_mv << ',' - << info.current_ma << ',' - << info.temperature_c10 << ',' - << info.soc << ',' - << info.remain_mah << ',' - << info.full_mah << ',' - << info.flags << ',' - << info.avg_current_ma << ',' - << info.valid; - return os.str(); - } }; -std::shared_ptr g_bq27220; - } // namespace extern "C" { @@ -128,32 +84,48 @@ extern "C" { cp0_battery_info_t cp0_battery_read(void) { cp0_battery_info_t info{}; - cp0_signal_bq27220_api({"Read"}, [&](int code, std::string data) { - if (code == 0) - Bq27220System::decode_info(data, &info); - }); + try { + cp0_signal_bq27220_api({"Read"}, [&](int code, std::string data) { + if (code == 0) cp0::battery::decode_info(data, &info); + }); + } catch (...) { + info = {}; + } return info; } int cp0_bq27220_calibrate(int command_index) { int ret = -1; - cp0_signal_bq27220_api({"Calibrate", std::to_string(command_index)}, [&](int code, std::string data) { - (void)data; - ret = code; - }); + try { + cp0_signal_bq27220_api({"Calibrate", std::to_string(command_index)}, [&](int code, std::string data) { + (void)data; + ret = code; + }); + } catch (...) { + ret = -1; + } return ret; } void init_bq27220(void) { - if (g_bq27220) - return; - - g_bq27220 = std::make_shared(); - cp0_signal_bq27220_api.append([](std::list arg, std::function callback) { - g_bq27220->api_call(std::move(arg), std::move(callback)); - }); + static cp0::battery::Lifecycle lifecycle; + lifecycle.start( + [] { + auto bq27220 = std::make_shared(); + using Registration = cp0::SignalRegistration; + auto registration = std::make_shared(); + const bool registered = registration->replace( + cp0_signal_bq27220_api, + [bq27220](std::list arg, + std::function callback) { + bq27220->api_call(std::move(arg), std::move(callback)); + }); + return cp0::battery::LifecycleResource{ + registered, [registration] { registration->reset(); }}; + }, + [] { return cp0::battery::LifecycleResource{true, {}}; }); } } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_camera.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_camera.cpp index b7efbd72..408b75f4 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_camera.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_camera.cpp @@ -1,10 +1,13 @@ #include "hal_lvgl_bsp.h" +#include "../cp0/cp0_camera_viewport.hpp" +#include "../cp0_camera_api_contract.hpp" +#include "../cp0_callback_contract.hpp" +#include "../cp0_signal_registration.hpp" #include #include #include #include -#include #include #include #include @@ -29,6 +32,14 @@ class CameraSystem close_camera(); } + void shutdown() + { + close_camera(); + std::lock_guard lock(mutex_); + status_callback_ = {}; + frame_callback_ = {}; + } + void api_call(arg_t arg, callback_t callback) { if (arg.empty()) { @@ -38,22 +49,28 @@ class CameraSystem const std::string command = arg.front(); if (command == "SetCallback") { - std::lock_guard lock(mutex_); - status_callback_ = std::move(callback); - report_locked(status_callback_, 0, "camera callback set\n"); + { + std::lock_guard lock(mutex_); + status_callback_ = callback; + } + report(std::move(callback), 0, "camera callback set\n"); return; } if (command == "SetFrameCallback") { - std::lock_guard lock(mutex_); - frame_callback_ = std::move(callback); - report_locked(frame_callback_, 0, "camera frame callback set\n"); + { + std::lock_guard lock(mutex_); + frame_callback_ = callback; + } + report(std::move(callback), 0, "camera frame callback set\n"); return; } if (command == "Open" || command == "Start") { - const int width = to_int(nth_arg(arg, 1), 320); - const int height = to_int(nth_arg(arg, 2), 150); - open_camera(width, height); - report(callback, 0, "SDL camera open\n"); + const int width = cp0_camera_api::parse_integer_argument( + nth_arg(arg, 1), cp0_camera_api::DEFAULT_WIDTH); + const int height = cp0_camera_api::parse_integer_argument( + nth_arg(arg, 2), cp0_camera_api::DEFAULT_HEIGHT); + const int result = open_camera(width, height); + report(callback, result, result == 0 ? "camera open\n" : "camera open failed\n"); return; } if (command == "Close" || command == "Stop") { @@ -63,8 +80,10 @@ class CameraSystem } if (command == "Capture" || command == "Photo") { const std::string path = nth_arg(arg, 1); - const int width = to_int(nth_arg(arg, 2), width_); - const int height = to_int(nth_arg(arg, 3), height_); + const int width = cp0_camera_api::parse_integer_argument( + nth_arg(arg, 2), cp0_camera_api::DEFAULT_WIDTH); + const int height = cp0_camera_api::parse_integer_argument( + nth_arg(arg, 3), cp0_camera_api::DEFAULT_HEIGHT); const int ret = capture(path, width, height); report(callback, ret, ret == 0 ? path + "\n" : "camera capture failed\n"); return; @@ -74,26 +93,35 @@ class CameraSystem return; } if (command == "ZoomIn") { - std::lock_guard lock(mutex_); - zoom_percent_ = zoom_percent_ < 250 ? 250 : 500; - report_locked(callback, 0, zoom_payload_locked()); + std::string payload; + { + std::lock_guard lock(mutex_); + viewport_.zoom_in(); + payload = viewport_.status_text(); + } + report(std::move(callback), 0, payload); return; } if (command == "ZoomOut") { - std::lock_guard lock(mutex_); - zoom_percent_ = zoom_percent_ > 250 ? 250 : 100; - if (zoom_percent_ == 100) - view_x_percent_ = view_y_percent_ = 50; - report_locked(callback, 0, zoom_payload_locked()); + std::string payload; + { + std::lock_guard lock(mutex_); + viewport_.zoom_out(); + payload = viewport_.status_text(); + } + report(std::move(callback), 0, payload); return; } if (command == "Pan") { - std::lock_guard lock(mutex_); - const int dx = to_int(nth_arg(arg, 1), 0); - const int dy = to_int(nth_arg(arg, 2), 0); - view_x_percent_ = clamp(view_x_percent_ + dx * 8, 0, 100); - view_y_percent_ = clamp(view_y_percent_ + dy * 8, 0, 100); - report_locked(callback, 0, zoom_payload_locked()); + const int dx = cp0_camera_api::parse_integer_argument(nth_arg(arg, 1), 0); + const int dy = cp0_camera_api::parse_integer_argument(nth_arg(arg, 2), 0); + std::string payload; + { + std::lock_guard lock(mutex_); + viewport_.pan(dx, dy); + payload = viewport_.status_text(); + } + report(std::move(callback), 0, payload); return; } @@ -106,18 +134,11 @@ class CameraSystem std::mutex mutex_; std::thread worker_; std::atomic running_{false}; - int width_ = 320; - int height_ = 150; - int zoom_percent_ = 100; - int view_x_percent_ = 50; - int view_y_percent_ = 50; + int width_ = cp0_camera_api::DEFAULT_WIDTH; + int height_ = cp0_camera_api::DEFAULT_HEIGHT; + cp0_camera::Viewport viewport_; uint32_t frame_index_ = 0; - static int clamp(int value, int lo, int hi) - { - return std::max(lo, std::min(hi, value)); - } - static std::string nth_arg(const arg_t &arg, size_t n) { if (arg.size() <= n) @@ -127,40 +148,21 @@ class CameraSystem return *it; } - static int to_int(const std::string &value, int fallback) - { - if (value.empty()) - return fallback; - char *end = nullptr; - long ret = std::strtol(value.c_str(), &end, 10); - return end && *end == '\0' ? static_cast(ret) : fallback; - } - static uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) { return static_cast(((r & 0xf8) << 8) | ((g & 0xfc) << 3) | (b >> 3)); } - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) - callback(code, data); - } - - static void report_locked(callback_t callback, int code, const std::string &data) + void report(callback_t callback, int code, const std::string &data) { - if (callback) - callback(code, data); - } - - std::string zoom_payload_locked() const - { - char buf[64]; - std::snprintf(buf, sizeof(buf), "ZOOM %d %d %d\n", zoom_percent_, view_x_percent_, view_y_percent_); - return buf; + if (!callback) { + std::lock_guard lock(mutex_); + callback = status_callback_; + } + cp0::callback::invoke(callback, code, data); } - void open_camera(int width, int height) + int open_camera(int width, int height) { close_camera(); { @@ -170,7 +172,13 @@ class CameraSystem frame_index_ = 0; } running_.store(true); - worker_ = std::thread([this]() { frame_loop(); }); + try { + worker_ = std::thread([this]() { frame_loop(); }); + } catch (...) { + running_.store(false); + return -2; + } + return 0; } void close_camera() @@ -191,8 +199,7 @@ class CameraSystem if (callback) payload = make_frame_payload_locked(); } - if (callback) - callback(0, payload); + cp0::callback::invoke(callback, 0, payload); std::this_thread::sleep_for(std::chrono::milliseconds(80)); } } @@ -201,12 +208,13 @@ class CameraSystem { std::vector pixels(static_cast(width_) * height_); const uint32_t tick = frame_index_++; - const int pan_x = (view_x_percent_ - 50) * 2; - const int pan_y = (view_y_percent_ - 50) * 2; + const int zoom_percent = viewport_.zoom_percent(); + const int pan_x = (viewport_.view_x_percent() - 50) * 2; + const int pan_y = (viewport_.view_y_percent() - 50) * 2; for (int y = 0; y < height_; ++y) { for (int x = 0; x < width_; ++x) { - int sx = (x * zoom_percent_) / 100 + pan_x + static_cast(tick); - int sy = (y * zoom_percent_) / 100 + pan_y; + int sx = (x * zoom_percent) / 100 + pan_x + static_cast(tick); + int sy = (y * zoom_percent) / 100 + pan_y; uint8_t r = static_cast((sx * 255) / std::max(1, width_)); uint8_t g = static_cast((sy * 255) / std::max(1, height_)); uint8_t b = static_cast((sx + sy + static_cast(tick) * 3) & 0xff); @@ -227,6 +235,11 @@ class CameraSystem { if (path.empty()) return -1; + uint32_t frame_index = 0; + { + std::lock_guard lock(mutex_); + frame_index = frame_index_; + } FILE *file = std::fopen(path.c_str(), "wb"); if (!file) return -1; @@ -239,7 +252,7 @@ class CameraSystem uint8_t rgb[3] = { static_cast((x * 255) / width), static_cast((y * 255) / height), - static_cast((x + y + frame_index_) & 0xff), + static_cast((x + y + frame_index) & 0xff), }; std::fwrite(rgb, 1, sizeof(rgb), file); } @@ -250,10 +263,45 @@ class CameraSystem } // namespace +namespace { + +std::shared_ptr &camera_system() +{ + static auto camera = std::make_shared(); + return camera; +} + +cp0::SignalRegistration &camera_registration() +{ + static cp0::SignalRegistration registration; + return registration; +} + +} // namespace + extern "C" void init_camera(void) { - std::shared_ptr camera = std::make_shared(); - cp0_signal_camera_api.append([camera](std::list arg, std::function callback) { - camera->api_call(std::move(arg), std::move(callback)); - }); + const auto camera = camera_system(); + camera_registration().replace(cp0_signal_camera_api, + [camera](std::list arg, + std::function callback) { + try { + camera->api_call(std::move(arg), callback); + } catch (...) { + cp0::callback::invoke( + callback, -1, "camera backend failure\n"); + } + }); +} + +extern "C" void deinit_camera(void) noexcept +{ + try { + camera_registration().reset(); + } catch (...) { + } + try { + camera_system()->shutdown(); + } catch (...) { + } } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_config.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_config.cpp index 37a9ea11..1c0f85b7 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_config.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_config.cpp @@ -2,70 +2,34 @@ #include "hal/hal_paths.h" #include "hal_lvgl_bsp.h" -#include "../cp0_config_json.h" +#include "cp0_config_json.h" +#include "../cp0_config_service.hpp" +#include "../cp0_signal_registration.hpp" #include -#include #include -#include -#include -#include +#include +#include #include -#include #include -#include #include #include -#include -#include namespace { -constexpr int kMaxEntries = 64; -constexpr int kKeyMax = 64; -constexpr int kValMax = 256; - -static void copy_cstr(char *dst, size_t dst_size, const char *src) -{ - if (!dst || dst_size == 0) - return; - if (!src) - src = ""; - std::strncpy(dst, src, dst_size - 1); - dst[dst_size - 1] = '\0'; -} - -static std::string join_path(const std::string &base, const char *leaf) +std::string join_path(const std::string &base, const char *leaf) { - std::string out = base.empty() ? std::string(".") : base; - if (!out.empty() && out.back() != '/') - out.push_back('/'); - out += leaf; - return out; + std::string path = base.empty() ? "." : base; + if (path.back() != '/') + path.push_back('/'); + return path + leaf; } -static int mkdir_p(const std::string &path) +std::string config_dir() { - if (path.empty()) - return -1; - char tmp[512]; - copy_cstr(tmp, sizeof(tmp), path.c_str()); - for (char *p = tmp + 1; *p; ++p) { - if (*p == '/') { - *p = '\0'; - if (mkdir(tmp, 0755) != 0 && errno != EEXIST) - return -1; - *p = '/'; - } - } - return (mkdir(tmp, 0755) == 0 || errno == EEXIST) ? 0 : -1; -} - -static std::string config_dir() -{ - const char *env = std::getenv("APPLAUNCH_SDL_CONFIG_DIR"); - if (env && env[0]) - return env; + const char *configured = std::getenv("APPLAUNCH_SDL_CONFIG_DIR"); + if (configured && configured[0]) + return configured; const char *xdg = std::getenv("XDG_CONFIG_HOME"); if (xdg && xdg[0]) return join_path(xdg, "applaunch-sdl"); @@ -75,222 +39,79 @@ static std::string config_dir() return join_path(hal_path_data_dir() ? hal_path_data_dir() : ".", "sdl_config"); } -class ConfigSystem { -public: - using callback_t = std::function; - using arg_t = std::list; - - ConfigSystem() { init(); } - - void init() - { - std::lock_guard lock(mutex_); - load_locked(); - } - - int get_int(const char *key, int default_val) - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - int idx = find_entry_locked(key); - return idx < 0 ? default_val : std::atoi(entries_[idx].val); - } - - void set_int(const char *key, int val) - { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%d", val); - set_str(key, buf); - } - - std::string get_str(const char *key, const char *default_val) - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - int idx = find_entry_locked(key); - return idx < 0 ? std::string(default_val ? default_val : "") : std::string(entries_[idx].val); - } - - void set_str(const char *key, const char *val) - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - int idx = get_or_create_entry_locked(key); - if (idx >= 0) - copy_cstr(entries_[idx].val, sizeof(entries_[idx].val), val); - } - - int save() - { - std::lock_guard lock(mutex_); - ensure_loaded_locked(); - const std::string dir = config_dir(); - if (mkdir_p(dir) != 0) - return -1; - std::vector> kv; - kv.reserve(count_); - for (int i = 0; i < count_; ++i) - kv.emplace_back(entries_[i].key, entries_[i].val); - const std::string json = cp0cfg::to_json(kv); - const std::string path = join_path(dir, "config.json"); - const std::string tmp_path = path + ".tmp"; - FILE *fp = std::fopen(tmp_path.c_str(), "w"); - if (!fp) - return -1; - size_t written = std::fwrite(json.data(), 1, json.size(), fp); - if (written != json.size()) { - std::fclose(fp); - std::remove(tmp_path.c_str()); - return -1; - } - if (std::fflush(fp) != 0 || fsync(fileno(fp)) != 0) { - std::fclose(fp); - std::remove(tmp_path.c_str()); - return -1; - } - std::fclose(fp); - if (std::rename(tmp_path.c_str(), path.c_str()) != 0) { - std::remove(tmp_path.c_str()); - return -1; - } - fsync_dir(dir.c_str()); - return 0; - } - - void api_call(arg_t arg, callback_t callback) - { - if (arg.empty()) { - report(callback, -1, "empty config api"); - return; - } - const std::string cmd = arg.front(); - if (cmd == "Init") { - init(); - report(callback, 0, "ok"); - } else if (cmd == "GetInt") { - report(callback, 0, std::to_string(get_int(nth_arg(arg, 1).c_str(), parse_int(nth_arg(arg, 2), 0)))); - } else if (cmd == "SetInt") { - set_int(nth_arg(arg, 1).c_str(), parse_int(nth_arg(arg, 2), 0)); - report(callback, 0, "ok"); - } else if (cmd == "GetStr") { - report(callback, 0, get_str(nth_arg(arg, 1).c_str(), nth_arg(arg, 2).c_str())); - } else if (cmd == "SetStr") { - set_str(nth_arg(arg, 1).c_str(), nth_arg(arg, 2).c_str()); - report(callback, 0, "ok"); - } else if (cmd == "Save") { - int ret = save(); - report(callback, ret, ret == 0 ? "ok" : "save failed"); - } else { - report(callback, -1, "unknown config api"); - } - } - -private: - struct Entry { - char key[kKeyMax]; - char val[kValMax]; - }; - - Entry entries_[kMaxEntries] = {}; - int count_ = 0; - bool loaded_ = false; - std::mutex mutex_; +bool make_directories(const std::string &path) +{ + if (path.empty()) + return false; + std::string partial; + partial.reserve(path.size()); + for (char character : path) { + partial.push_back(character); + if (character == '/' && partial.size() > 1 && + mkdir(partial.c_str(), 0755) != 0 && errno != EEXIST) + return false; + } + return mkdir(path.c_str(), 0755) == 0 || errno == EEXIST; +} - void ensure_loaded_locked() - { - if (!loaded_) - load_locked(); +void fsync_dir(const std::string &path) +{ +#ifdef O_DIRECTORY + const int fd = open(path.c_str(), O_RDONLY | O_DIRECTORY); +#else + const int fd = open(path.c_str(), O_RDONLY); +#endif + if (fd >= 0) { + fsync(fd); + close(fd); } +} - void load_locked() - { - count_ = 0; - loaded_ = true; - FILE *fp = std::fopen(join_path(config_dir(), "config.json").c_str(), "r"); - if (!fp) - return; - std::string text; - char buf[512]; - size_t n; - while ((n = std::fread(buf, 1, sizeof(buf), fp)) > 0) - text.append(buf, n); - std::fclose(fp); - - std::vector> kv; - if (!cp0cfg::from_json(text, kv)) - return; - for (const auto &e : kv) { - if (count_ >= kMaxEntries) - break; - copy_cstr(entries_[count_].key, sizeof(entries_[count_].key), e.first.c_str()); - copy_cstr(entries_[count_].val, sizeof(entries_[count_].val), e.second.c_str()); - ++count_; - } - } +bool load_config(cp0cfg::Entries &entries) +{ + FILE *fp = std::fopen(join_path(config_dir(), "config.json").c_str(), "r"); + if (!fp) + return false; + std::string text; + char buffer[512]; + size_t count = 0; + while ((count = std::fread(buffer, 1, sizeof(buffer), fp)) > 0) + text.append(buffer, count); + const bool read_ok = std::ferror(fp) == 0; + std::fclose(fp); + return read_ok && cp0cfg::from_json(text, entries); +} - int find_entry_locked(const char *key) const - { - if (!key || !key[0]) - return -1; - for (int i = 0; i < count_; ++i) { - if (std::strcmp(entries_[i].key, key) == 0) - return i; - } +int save_config(const cp0cfg::Entries &entries) +{ + const std::string dir = config_dir(); + if (!make_directories(dir)) + return -1; + const std::string path = join_path(dir, "config.json"); + const std::string temporary = path + ".tmp"; + const std::string json = cp0cfg::to_json(entries); + FILE *fp = std::fopen(temporary.c_str(), "w"); + if (!fp) + return -1; + const bool written = std::fwrite(json.data(), 1, json.size(), fp) == json.size(); + const bool flushed = written && std::fflush(fp) == 0 && fsync(fileno(fp)) == 0; + const bool closed = std::fclose(fp) == 0; + if (!flushed || !closed || std::rename(temporary.c_str(), path.c_str()) != 0) { + std::remove(temporary.c_str()); return -1; } - - int get_or_create_entry_locked(const char *key) - { - int idx = find_entry_locked(key); - if (idx >= 0) - return idx; - if (!key || !key[0] || count_ >= kMaxEntries) - return -1; - idx = count_++; - copy_cstr(entries_[idx].key, sizeof(entries_[idx].key), key); - entries_[idx].val[0] = '\0'; - return idx; - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - return it == arg.end() ? std::string() : *it; - } - - static int parse_int(const std::string &value, int fallback) - { - return value.empty() ? fallback : std::atoi(value.c_str()); - } - - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) - callback(code, data); - } - - static void fsync_dir(const char *path) - { -#ifdef O_DIRECTORY - int fd = open(path, O_RDONLY | O_DIRECTORY); -#else - int fd = open(path, O_RDONLY); -#endif - if (fd >= 0) { - fsync(fd); - close(fd); - } - } -}; + fsync_dir(dir); + return 0; +} } // namespace extern "C" void init_config(void) { - auto config = std::make_shared(); - cp0_signal_config_api.append([config](std::list arg, std::function callback) { - config->api_call(std::move(arg), std::move(callback)); + static cp0::SignalRegistration registration; + auto config = std::make_shared(load_config, save_config); + registration.replace(cp0_signal_config_api, [config](std::list arguments, + std::function callback) { + config->api_call(std::move(arguments), std::move(callback)); }); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_display.c b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_display.c index 00159370..f898b761 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_display.c +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_display.c @@ -1,6 +1,7 @@ #include "hal_lvgl_bsp.h" #include "lvgl/lvgl.h" #include "sdl_lvgl.h" +#include "../cp0_display_screenshot_contract.hpp" #include "lvgl/src/drivers/sdl/lv_sdl_mouse.h" @@ -8,6 +9,7 @@ #include "lvgl/src/drivers/sdl/lv_sdl_window.h" #include +#include #include #include #include @@ -21,20 +23,24 @@ static const char *getenv_default(const char *name, const char *dflt) void init_sdl_disp(void) { - static int display_initialized = 0; - if (display_initialized) + static pthread_mutex_t init_mutex = PTHREAD_MUTEX_INITIALIZER; + pthread_mutex_lock(&init_mutex); + if (lv_display_get_default() != NULL) { + pthread_mutex_unlock(&init_mutex); return; + } - int width = atoi(getenv_default("LV_SDL_VIDEO_WIDTH", "320")); - int height = atoi(getenv_default("LV_SDL_VIDEO_HEIGHT", "170")); + int width = cp0_display_dimension_or_default(getenv("LV_SDL_VIDEO_WIDTH"), 320); + int height = cp0_display_dimension_or_default(getenv("LV_SDL_VIDEO_HEIGHT"), 170); lv_display_t *disp = lv_sdl_window_create(width, height); if (disp == NULL) { fprintf(stderr, "cp0_lvgl: failed to create SDL display\n"); + pthread_mutex_unlock(&init_mutex); return; } lv_sdl_window_set_title(disp, getenv_default("LV_SDL_WINDOW_TITLE", "M5CardputerZero")); - display_initialized = 1; + pthread_mutex_unlock(&init_mutex); } void init_freambuffer_disp(void) diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_filesystem.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_filesystem.cpp index 04c46dd1..cd16f93f 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_filesystem.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_filesystem.cpp @@ -2,9 +2,12 @@ #include "cp0_lvgl_filesystem.hpp" #include "hal/hal_paths.h" #include "hal_lvgl_bsp.h" +#include "../cp0_filesystem_api.hpp" +#include "../cp0_posix_filesystem.hpp" +#include "../cp0_resource_path_policy.hpp" +#include "../cp0_c_api_boundary.hpp" +#include "../cp0_init_once.hpp" -#include -#include #include #include #include @@ -15,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -65,69 +69,17 @@ extern "C" const char *hal_path_audio_dir(void) { return s_audio_dir; } namespace { -static void copy_cstr(char *dst, size_t dst_size, const char *src) -{ - if (!dst || dst_size == 0) - return; - if (!src) - src = ""; - std::strncpy(dst, src, dst_size - 1); - dst[dst_size - 1] = '\0'; -} - static bool starts_with(const std::string &value, const char *prefix) { const std::string p(prefix ? prefix : ""); return value.compare(0, p.size(), p) == 0; } -static bool has_lvgl_drive(const std::string &file) -{ - return file.size() >= 2 && std::isalpha(static_cast(file[0])) && file[1] == ':'; -} - -static std::string lower_ext(const std::string &file) -{ - const size_t dot = file.find_last_of('.'); - if (dot == std::string::npos) - return ""; - std::string ext = file.substr(dot + 1); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return ext; -} - -static bool is_image_ext(const std::string &ext) -{ - return ext == "png" || ext == "gif" || ext == "jpg" || ext == "jpeg" || ext == "svg"; -} - -static bool is_audio_ext(const std::string &ext) -{ - return ext == "wav" || ext == "mp3" || ext == "ogg"; -} - -static bool is_font_ext(const std::string &ext) -{ - return ext == "ttf" || ext == "otf"; -} - -static std::string strip_app_root_prefix(const std::string &file) -{ - static constexpr const char *kAppRoot = "/usr/share/APPLaunch/"; - if (starts_with(file, kAppRoot)) - return file.substr(std::strlen(kAppRoot)); - if (starts_with(file, "APPLaunch/")) - return file.substr(std::strlen("APPLaunch/")); - return file; -} - static std::string join_path(const char *base, const std::string &rel) { if (rel.empty()) return base ? std::string(base) : std::string(); - if (rel.front() == '/' || has_lvgl_drive(rel)) + if (rel.front() == '/' || cp0::filesystem::has_lvgl_drive(rel)) return rel; std::string out = base ? std::string(base) : std::string("."); if (!out.empty() && out.back() != '/') @@ -138,8 +90,8 @@ static std::string join_path(const char *base, const std::string &rel) static std::string resource_path(const char *base, const char *prefix, const std::string &file) { - std::string rel = strip_app_root_prefix(file); - if (has_lvgl_drive(rel)) { + std::string rel = cp0::filesystem::strip_app_root_prefix(file); + if (cp0::filesystem::has_lvgl_drive(rel)) { rel = rel.substr(2); while (!rel.empty() && rel.front() == '/') rel.erase(rel.begin()); @@ -165,65 +117,13 @@ class SdlFilesystem { void api_call(arg_t arg, callback_t callback) { - auto report = [&](int code, const std::string &data) { - if (callback) - callback(code, data); - }; - - if (arg.empty()) { - report(-1, "missing command"); - return; - } - - const std::string cmd = arg.front(); - const std::string value = arg.size() >= 2 ? *std::next(arg.begin()) : std::string(); - if (cmd == "Path") { - report(0, resolve_path(value)); - } else if (cmd == "DirList") { - std::string data; - report(encode_dir_entries(value.c_str(), data), data); - } else if (cmd == "DirListDetail") { - std::string data; - report(encode_dir_entries_detail(value.c_str(), data), data); - } else if (cmd == "Exists") { - report(0, access(value.c_str(), R_OK) == 0 ? "1" : "0"); - } else if (cmd == "ReadFile") { - const auto max_it = arg.size() >= 3 ? std::next(arg.begin(), 2) : arg.end(); - const size_t max_bytes = max_it == arg.end() ? std::numeric_limits::max() - : static_cast(std::strtoull(max_it->c_str(), nullptr, 10)); - std::string data; - report(read_file_limited(value, max_bytes, data), data); - } else if (cmd == "EnsureDirForUser") { - int ret = mkdir(value.c_str(), 0755); - struct stat st{}; - report((ret == 0 || errno == EEXIST) && stat(value.c_str(), &st) == 0 && S_ISDIR(st.st_mode) ? 0 : -1, ""); - } else if (cmd == "Touch") { - const auto slash = value.find_last_of('/'); - if (slash != std::string::npos && slash > 0) { - const std::string parent = value.substr(0, slash); - struct stat st{}; - if ((mkdir(parent.c_str(), 0755) != 0 && errno != EEXIST) || - stat(parent.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) { - report(-1, ""); - return; - } - } - FILE *file = fopen(value.c_str(), "a"); - if (file) fclose(file); - report(file ? 0 : -1, ""); - } else if (cmd == "Remove") { - report(std::remove(value.c_str()) == 0 ? 0 : -1, ""); - } else if (cmd == "WatchStart") { - cp0_watcher_t watcher = watch_start(value.c_str()); - report(watcher ? 0 : -1, std::to_string(reinterpret_cast(watcher))); - } else if (cmd == "WatchPoll") { - report(0, std::to_string(watch_poll(parse_handle(value)))); - } else if (cmd == "WatchStop") { - watch_stop(parse_handle(value)); - report(0, ""); - } else { - report(-2, "unknown command: " + cmd); - } + cp0_filesystem_api::Operations operations; + operations.resolve_path = resolve_path; + operations.ensure_directory = ensure_directory; + operations.watch_start = [this](const char *path) { return dir_watch_start(path); }; + operations.watch_poll = [this](cp0_watcher_t watcher) { return dir_watch_poll(watcher); }; + operations.watch_stop = [this](cp0_watcher_t watcher) { return dir_watch_stop(watcher); }; + cp0_filesystem_api::dispatch(arg, operations, std::move(callback)); } std::string path(std::string file) { return resolve_path(file); } @@ -233,14 +133,28 @@ class SdlFilesystem { return list_dir(path, entries, max_entries, out_count); } - cp0_watcher_t dir_watch_start(const char *path) { return watch_start(path); } - int dir_watch_poll(cp0_watcher_t watcher) { return watch_poll(watcher); } - void dir_watch_stop(cp0_watcher_t watcher) { watch_stop(watcher); } + cp0_watcher_t dir_watch_start(const char *path) + { + return watchers_.add(native_watch_start(path)); + } + int dir_watch_poll(cp0_watcher_t watcher) + { + return watchers_.poll(watcher, native_watch_poll); + } + int dir_watch_stop(cp0_watcher_t watcher) + { + return watchers_.stop(watcher, native_watch_stop) ? 0 : -1; + } private: - static cp0_watcher_t parse_handle(const std::string &value) + static int ensure_directory(const std::string &path) { - return reinterpret_cast(static_cast(std::strtoull(value.c_str(), nullptr, 0))); + const int result = mkdir(path.c_str(), 0755); + struct stat status{}; + return (result == 0 || errno == EEXIST) && stat(path.c_str(), &status) == 0 && + S_ISDIR(status.st_mode) + ? 0 + : -1; } static std::string resolve_path(const std::string &file) @@ -272,14 +186,17 @@ class SdlFilesystem { if (file == "store_sync_cmd") return hal_path_store_sync_cmd(); - const std::string ext = lower_ext(file); - if (is_image_ext(ext)) + const cp0::filesystem::ResourceKind kind = cp0::filesystem::classify_resource(file); + if (kind != cp0::filesystem::ResourceKind::none && + cp0::filesystem::has_parent_component(file)) + return ""; + if (kind == cp0::filesystem::ResourceKind::image) return resource_path(hal_path_images_dir(), "share/images", file); - if (is_audio_ext(ext)) + if (kind == cp0::filesystem::ResourceKind::audio) return resource_path(hal_path_audio_dir(), "share/audio", file); - if (is_font_ext(ext)) + if (kind == cp0::filesystem::ResourceKind::font) return resource_path(hal_path_font_dir(), "share/font", file); - if (has_lvgl_drive(file)) { + if (cp0::filesystem::has_lvgl_drive(file)) { std::string rel = file.substr(2); while (!rel.empty() && rel.front() == '/') rel.erase(rel.begin()); @@ -290,126 +207,7 @@ class SdlFilesystem { static int list_dir(const char *path, cp0_dirent_t *entries, int max_entries, int *out_count) { - if (out_count) - *out_count = 0; - if (!path || !out_count) - return -1; - if (!entries || max_entries <= 0) - return 0; - - DIR *dir = opendir(path); - if (!dir) - return -errno; - - struct dirent *ent = nullptr; - while ((ent = readdir(dir)) != nullptr) { - if (is_dot_entry(ent->d_name)) - continue; - if (*out_count >= max_entries) - break; - cp0_dirent_t &entry = entries[*out_count]; - copy_cstr(entry.name, sizeof(entry.name), ent->d_name); - entry.is_dir = ent->d_type == DT_DIR ? 1 : 0; - if (ent->d_type == DT_UNKNOWN) { - std::string child = join_path(path, ent->d_name); - struct stat st; - entry.is_dir = stat(child.c_str(), &st) == 0 && S_ISDIR(st.st_mode) ? 1 : 0; - } - ++(*out_count); - } - closedir(dir); - return 0; - } - - static int encode_dir_entries(const char *path, std::string &data) - { - DIR *dir = opendir(path); - if (!dir) return -errno; - try { - std::ostringstream out; - struct dirent *ent = nullptr; - while ((ent = readdir(dir)) != nullptr) { - if (is_dot_entry(ent->d_name)) continue; - bool is_dir = ent->d_type == DT_DIR; - if (ent->d_type == DT_UNKNOWN) { - struct stat st{}; - is_dir = stat(join_path(path, ent->d_name).c_str(), &st) == 0 && S_ISDIR(st.st_mode); - } - out << (is_dir ? 'D' : 'F') << '\t' << encode_field(ent->d_name) << '\n'; - } - data = out.str(); - } catch (const std::bad_alloc &) { - closedir(dir); - return -ENOMEM; - } - closedir(dir); - return 0; - } - - static int read_file_limited(const std::string &path, size_t max_bytes, std::string &data) - { - std::ifstream file(path, std::ios::binary | std::ios::ate); - if (!file) return errno ? -errno : -EIO; - const std::streamoff length = file.tellg(); - if (length < 0) return -EIO; - if (static_cast(length) > max_bytes) return -EFBIG; - try { - data.resize(static_cast(length)); - } catch (const std::bad_alloc &) { - return -ENOMEM; - } - file.seekg(0); - if (length > 0 && !file.read(&data[0], length)) { - data.clear(); - return -EIO; - } - return 0; - } - - static int encode_dir_entries_detail(const char *path, std::string &data) - { - DIR *dir = opendir(path); - if (!dir) return -errno; - try { - std::ostringstream out; - struct dirent *ent = nullptr; - while ((ent = readdir(dir)) != nullptr) { - if (is_dot_entry(ent->d_name)) continue; - const std::string full = join_path(path, ent->d_name); - struct stat st; - if (stat(full.c_str(), &st) != 0) continue; - out << (S_ISDIR(st.st_mode) ? 'D' : 'F') << '\t' << static_cast(st.st_size) - << '\t' << encode_field(ent->d_name) << '\n'; - } - data = out.str(); - } catch (const std::bad_alloc &) { - closedir(dir); - return -ENOMEM; - } - closedir(dir); - return 0; - } - - static bool is_dot_entry(const char *name) - { - return name && name[0] == '.' && - (name[1] == '\0' || (name[1] == '.' && name[2] == '\0')); - } - - static std::string encode_field(const char *value) - { - static constexpr char hex[] = "0123456789ABCDEF"; - std::string encoded; - for (const unsigned char c : std::string(value ? value : "")) { - if (c == '%' || c == '\t' || c == '\n' || c == '\r') { - encoded.push_back('%'); - encoded.push_back(hex[c >> 4]); - encoded.push_back(hex[c & 0x0f]); - } else { - encoded.push_back(static_cast(c)); - } - } - return encoded; + return cp0_posix_filesystem::list_directory(path, entries, max_entries, out_count, true); } static void snapshot(SdlWatcher *watcher) @@ -427,7 +225,7 @@ class SdlFilesystem { } } - static cp0_watcher_t watch_start(const char *path) + static cp0_watcher_t native_watch_start(const char *path) { if (!path || !path[0]) return nullptr; @@ -437,7 +235,7 @@ class SdlFilesystem { return watcher; } - static int watch_poll(cp0_watcher_t handle) + static int native_watch_poll(cp0_watcher_t handle) { if (!handle) return -1; @@ -451,10 +249,12 @@ class SdlFilesystem { return changed ? 1 : 0; } - static void watch_stop(cp0_watcher_t watcher) + static void native_watch_stop(cp0_watcher_t watcher) { delete static_cast(watcher); } + + cp0_filesystem_api::WatcherRegistry watchers_; }; SdlFilesystem &filesystem() @@ -472,12 +272,14 @@ std::string cp0_file_path(std::string file) extern "C" const char *cp0_file_path_c(const char *file) { - static thread_local std::unordered_map paths; - std::string key = file ? file : ""; - auto it = paths.find(key); - if (it == paths.end()) - it = paths.emplace(key, cp0_file_path(key)).first; - return it->second.c_str(); + return cp0::invoke_c_api_or("", [file] { + static thread_local std::unordered_map paths; + std::string key = file ? file : ""; + auto it = paths.find(key); + if (it == paths.end()) + it = paths.emplace(key, cp0_file_path(key)).first; + return it->second.c_str(); + }); } extern "C" int cp0_dir_list(const char *path, cp0_dirent_t *entries, int max_entries, int *out_count) @@ -497,12 +299,17 @@ extern "C" int cp0_dir_watch_poll(cp0_watcher_t watcher) extern "C" void cp0_dir_watch_stop(cp0_watcher_t watcher) { - filesystem().dir_watch_stop(watcher); + (void)filesystem().dir_watch_stop(watcher); } extern "C" void init_filesystem(void) { - cp0_signal_filesystem_api.append([](std::list arg, std::function callback) { - filesystem().api_call(std::move(arg), std::move(callback)); + static cp0::InitOnce initialized; + initialized.run([] { + return static_cast(cp0_signal_filesystem_api.append( + [](std::list arg, + std::function callback) { + filesystem().api_call(std::move(arg), std::move(callback)); + })); }); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_imu.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_imu.cpp index a108b48f..e624b4ab 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_imu.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_imu.cpp @@ -1,10 +1,14 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" +#include "../cp0_callback_contract.hpp" +#include "../cp0_imu_codec.hpp" +#include "../cp0_signal_registration.hpp" #include #include #include #include +#include #include #include @@ -50,6 +54,7 @@ class ImuSystem void api_call(arg_t arg, callback_t callback) { + try { if (arg.empty()) { report(callback, -1, "empty imu api\n"); return; @@ -58,7 +63,7 @@ class ImuSystem if (arg.front() == "Read") { cp0_compass_info_t info{}; int ret = read_info(&info); - report(callback, ret, std::string(reinterpret_cast(&info), sizeof(info))); + report(callback, ret, cp0::imu::encode_info(info)); return; } @@ -68,6 +73,9 @@ class ImuSystem } report(callback, -1, "unknown imu api\n"); + } catch (...) { + report(callback, -1, "imu api failure\n"); + } } int read_info(cp0_compass_info_t *info) @@ -79,14 +87,25 @@ class ImuSystem } private: - static void report(callback_t callback, int code, const std::string &data) + static void report(const callback_t &callback, int code, const std::string &data) { - if (callback) - callback(code, data); + cp0::callback::invoke(callback, code, data); } }; -std::shared_ptr g_imu; +using ImuRegistration = cp0::SignalRegistration; + +struct ImuRuntime { + std::mutex mutex; + ImuRegistration registration; + std::shared_ptr service; +}; + +ImuRuntime &imu_runtime() +{ + static ImuRuntime runtime; + return runtime; +} } // namespace @@ -94,32 +113,58 @@ extern "C" int cp0_compass_read(cp0_compass_read_cb_t callback, void *user) { cp0_compass_info_t info{}; int ret = -1; - cp0_signal_imu_api({"Read"}, [&](int code, std::string data) { - ret = code; - if (data.size() == sizeof(info)) - std::memcpy(&info, data.data(), sizeof(info)); - }); - if (callback) - callback(ret, &info, user); + try { + cp0_signal_imu_api({"Read"}, [&](int code, std::string data) { + ret = code; + if (!cp0::imu::decode_info(data, info)) + ret = -1; + }); + } catch (...) { + ret = -1; + } + cp0::callback::invoke_direct(callback, ret, &info, user); return ret; } extern "C" int cp0_compass_calibrate(void) { int ret = -1; - cp0_signal_imu_api({"Calibrate"}, [&](int code, std::string) { - ret = code; - }); + try { + cp0_signal_imu_api({"Calibrate"}, [&](int code, std::string) { + ret = code; + }); + } catch (...) { + ret = -1; + } return ret; } extern "C" void init_imu(void) { - if (g_imu) - return; + try { + ImuRuntime &runtime = imu_runtime(); + std::lock_guard lock(runtime.mutex); + if (runtime.service) return; + auto imu = std::make_shared(); + if (!runtime.registration.replace( + cp0_signal_imu_api, + [imu](std::list arg, + std::function callback) { + imu->api_call(std::move(arg), std::move(callback)); + })) + return; + runtime.service = std::move(imu); + } catch (...) { + } +} - g_imu = std::make_shared(); - cp0_signal_imu_api.append([](std::list arg, std::function callback) { - g_imu->api_call(std::move(arg), std::move(callback)); - }); +extern "C" void deinit_imu(void) +{ + try { + ImuRuntime &runtime = imu_runtime(); + std::lock_guard lock(runtime.mutex); + runtime.registration.reset(); + runtime.service.reset(); + } catch (...) { + } } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c index 81ab5ce8..400f0b1c 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c @@ -3,6 +3,10 @@ #include "commount.h" #include "lvgl/lvgl.h" #include "sdl_lvgl.h" +#include "../cp0_keyboard_key_contract.h" +#include "../cp0_keyboard_input_lifecycle.h" +#include "../cp0_keyboard_queue.h" +#include "../cp0_keyboard_text.h" #include "lvgl/src/drivers/sdl/lv_sdl_mouse.h" @@ -25,6 +29,9 @@ typedef struct { static void cp0_sdl_keyboard_read(lv_indev_t *indev, lv_indev_data_t *data); static void cp0_sdl_keyboard_delete_cb(lv_event_t *event); +static cp0_keyboard_input_lifecycle_t keyboard_lifecycle; +static pthread_mutex_t keyboard_init_mutex = PTHREAD_MUTEX_INITIALIZER; +static int mouse_initialized; __attribute__((weak)) int ui_screensaver_filter_key(const struct key_item *elm) { @@ -37,23 +44,11 @@ __attribute__((weak)) void ui_global_hint_on_key(const struct key_item *elm) (void)elm; } -static uint32_t cp0_utf8_first_codepoint(const char *s) +static cp0_keyboard_key_handler_t global_key_handler; + +void cp0_keyboard_set_global_key_handler(cp0_keyboard_key_handler_t handler) { - const unsigned char *p = (const unsigned char *)s; - if (p[0] == '\0') - return 0; - if (p[0] < 0x80) - return p[0]; - if ((p[0] & 0xe0) == 0xc0 && (p[1] & 0xc0) == 0x80) - return ((uint32_t)(p[0] & 0x1f) << 6) | (uint32_t)(p[1] & 0x3f); - if ((p[0] & 0xf0) == 0xe0 && (p[1] & 0xc0) == 0x80 && (p[2] & 0xc0) == 0x80) - return ((uint32_t)(p[0] & 0x0f) << 12) | ((uint32_t)(p[1] & 0x3f) << 6) | - (uint32_t)(p[2] & 0x3f); - if ((p[0] & 0xf8) == 0xf0 && (p[1] & 0xc0) == 0x80 && (p[2] & 0xc0) == 0x80 && - (p[3] & 0xc0) == 0x80) - return ((uint32_t)(p[0] & 0x07) << 18) | ((uint32_t)(p[1] & 0x3f) << 12) | - ((uint32_t)(p[2] & 0x3f) << 6) | (uint32_t)(p[3] & 0x3f); - return 0; + global_key_handler = handler; } static uint32_t cp0_evdev_process_key(uint16_t code) @@ -127,65 +122,6 @@ static uint32_t cp0_sdl_ctrl_to_lv_key(SDL_Keycode key) } } -static const char *cp0_sdl_ctrl_utf8(SDL_Keycode key) -{ - switch (key) { - case SDLK_RETURN: - case SDLK_KP_ENTER: - return "\r"; - case SDLK_BACKSPACE: - return "\x7f"; - case SDLK_TAB: - return "\t"; - case SDLK_ESCAPE: - return "\x1b"; - case SDLK_UP: - return "\033[A"; - case SDLK_DOWN: - return "\033[B"; - case SDLK_RIGHT: - return "\033[C"; - case SDLK_LEFT: - return "\033[D"; - case SDLK_HOME: - return "\033[H"; - case SDLK_END: - return "\033[F"; - case SDLK_DELETE: - return "\033[3~"; - case SDLK_PAGEUP: - return "\033[5~"; - case SDLK_PAGEDOWN: - return "\033[6~"; - case SDLK_F1: - return "\033OP"; - case SDLK_F2: - return "\033OQ"; - case SDLK_F3: - return "\033OR"; - case SDLK_F4: - return "\033OS"; - case SDLK_F5: - return "\033[15~"; - case SDLK_F6: - return "\033[17~"; - case SDLK_F7: - return "\033[18~"; - case SDLK_F8: - return "\033[19~"; - case SDLK_F9: - return "\033[20~"; - case SDLK_F10: - return "\033[21~"; - case SDLK_F11: - return "\033[23~"; - case SDLK_F12: - return "\033[24~"; - default: - return NULL; - } -} - static bool cp0_sdl_ctrl_letter(const SDL_KeyboardEvent *event, char *out) { SDL_Keymod mods = SDL_GetModState(); @@ -275,6 +211,8 @@ static uint32_t cp0_sdl_scancode_to_linux_key(SDL_Scancode scancode) return KEY_0; case SDL_SCANCODE_RETURN: return KEY_ENTER; + case SDL_SCANCODE_KP_ENTER: + return KEY_KPENTER; case SDL_SCANCODE_ESCAPE: return KEY_ESC; case SDL_SCANCODE_BACKSPACE: @@ -401,34 +339,41 @@ const char *kbd_state_name(int state) } } -static bool cp0_sdl_queue_has_data(void) +static void cp0_sdl_enqueue_key(const struct key_item *event) { - bool has_data; - pthread_mutex_lock(&keyboard_mutex); - has_data = !STAILQ_EMPTY(&keyboard_queue); - pthread_mutex_unlock(&keyboard_mutex); - return has_data; + (void)cp0_keyboard_queue_push(event); } -static void cp0_sdl_enqueue_key(const struct key_item *event) +int cp0_keyboard_inject(uint32_t key_code, int key_state, uint32_t mods) { - struct key_item *elm = malloc(sizeof(*elm)); - if (elm == NULL) - return; - *elm = *event; - elm->flage = 0; - - if (elm->key_code == KEY_ESC) - LVGL_HOME_KEY_FLAG = elm->key_state; + if (key_state != KBD_KEY_RELEASED && key_state != KBD_KEY_PRESSED && + key_state != KBD_KEY_REPEATED) + return -1; + struct key_item item = {0}; + item.key_code = key_code; + item.key_state = key_state; + item.mods = mods; + const char *control = cp0_keyboard_control_utf8(key_code); + if (control) snprintf(item.utf8, sizeof(item.utf8), "%s", control); + snprintf(item.sym_name, sizeof(item.sym_name), "RPC_%u", key_code); + return cp0_keyboard_queue_push(&item); +} - if (LVGL_RUN_FLAGE) { - pthread_mutex_lock(&keyboard_mutex); - STAILQ_INSERT_TAIL(&keyboard_queue, elm, entries); - pthread_mutex_unlock(&keyboard_mutex); - } - else { - free(elm); +int cp0_keyboard_inject_text(const char *utf8) +{ + if (!utf8 || cp0_keyboard_utf8_validate(utf8) != 0) return -1; + const char *cursor = utf8; + while (*cursor) { + struct key_item item = {0}; + size_t length = 0; + if (cp0_keyboard_utf8_decode_one(cursor, &item.codepoint, &length) != 0) return -1; + item.key_state = KBD_KEY_RELEASED; + memcpy(item.utf8, cursor, length); + snprintf(item.sym_name, sizeof(item.sym_name), "RPC_TEXT"); + if (cp0_keyboard_queue_push(&item) != 0) return -1; + cursor += length; } + return 0; } static void cp0_sdl_fill_key_meta(cp0_sdl_keyboard_t *kbd, const SDL_KeyboardEvent *event) @@ -456,7 +401,7 @@ static void cp0_sdl_fill_key_meta(cp0_sdl_keyboard_t *kbd, const SDL_KeyboardEve if (name != NULL) snprintf(kbd->current.sym_name, sizeof(kbd->current.sym_name), "%s", name); - const char *ctrl_utf8 = cp0_sdl_ctrl_utf8(sym); + const char *ctrl_utf8 = cp0_keyboard_control_utf8(kbd->current.key_code); if (ctrl_utf8 != NULL) snprintf(kbd->current.utf8, sizeof(kbd->current.utf8), "%s", ctrl_utf8); @@ -471,11 +416,11 @@ static void cp0_sdl_set_text_key(cp0_sdl_keyboard_t *kbd, const char *utf8) kbd->current_valid = true; } - size_t len = strlen(utf8); - size_t n = len < sizeof(kbd->current.utf8) - 1 ? len : sizeof(kbd->current.utf8) - 1; - memcpy(kbd->current.utf8, utf8, n); - kbd->current.utf8[n] = '\0'; - kbd->current.codepoint = cp0_utf8_first_codepoint(kbd->current.utf8); + (void)cp0_keyboard_utf8_copy(kbd->current.utf8, sizeof(kbd->current.utf8), utf8); + size_t decoded_length = 0; + if (cp0_keyboard_utf8_decode_one( + kbd->current.utf8, &kbd->current.codepoint, &decoded_length) != 0) + kbd->current.codepoint = 0; } static void cp0_sdl_remember_active_key(cp0_sdl_keyboard_t *kbd, SDL_Scancode scancode, @@ -514,10 +459,8 @@ static void cp0_sdl_keyboard_read(lv_indev_t *indev, lv_indev_data_t *data) data->state = LV_INDEV_STATE_RELEASED; data->continue_reading = false; - pthread_mutex_lock(&keyboard_mutex); - if (!STAILQ_EMPTY(&keyboard_queue)) { - struct key_item *elm = STAILQ_FIRST(&keyboard_queue); - STAILQ_REMOVE_HEAD(&keyboard_queue, entries); + struct key_item *elm = cp0_keyboard_queue_pop(); + if (elm) { int swallowed = ui_screensaver_filter_key(elm); if (!swallowed) { @@ -525,18 +468,20 @@ static void cp0_sdl_keyboard_read(lv_indev_t *indev, lv_indev_data_t *data) if (root != NULL) lv_obj_send_event(root, (lv_event_code_t)LV_EVENT_KEYBOARD, elm); - ui_global_hint_on_key(elm); + if (global_key_handler) + global_key_handler(elm); + else + ui_global_hint_on_key(elm); data->key = cp0_evdev_process_key(elm->key_code); if (data->key) data->state = (lv_indev_state_t)elm->key_state; } if (data->key || swallowed) { - data->continue_reading = !STAILQ_EMPTY(&keyboard_queue); + data->continue_reading = cp0_keyboard_queue_has_data(); } free(elm); } - pthread_mutex_unlock(&keyboard_mutex); } static void cp0_sdl_keyboard_delete_cb(lv_event_t *event) @@ -547,11 +492,16 @@ static void cp0_sdl_keyboard_delete_cb(lv_event_t *event) lv_indev_set_driver_data(indev, NULL); lv_indev_set_read_cb(indev, NULL); free(kbd); + cp0_keyboard_queue_clear(); } + pthread_mutex_lock(&keyboard_init_mutex); + cp0_keyboard_input_handle_deleted(&keyboard_lifecycle, indev); + pthread_mutex_unlock(&keyboard_init_mutex); } void lv_sdl_keyboard_handler(SDL_Event *event) { + if (event == NULL) return; uint32_t win_id = UINT32_MAX; switch (event->type) { case SDL_KEYDOWN: @@ -578,6 +528,7 @@ void lv_sdl_keyboard_handler(SDL_Event *event) return; cp0_sdl_keyboard_t *kbd = lv_indev_get_driver_data(indev); + if (kbd == NULL) return; if (event->type == SDL_KEYDOWN) { cp0_sdl_fill_key_meta(kbd, &event->key); uint32_t ctrl_key = cp0_sdl_ctrl_to_lv_key(event->key.keysym.sym); @@ -616,29 +567,40 @@ void lv_sdl_keyboard_handler(SDL_Event *event) kbd->current_valid = false; } - while (cp0_sdl_queue_has_data()) + while (cp0_keyboard_queue_has_data()) lv_indev_read(indev); } void init_sdl_input(void) { - static int input_initialized = 0; - if (input_initialized) + pthread_mutex_lock(&keyboard_init_mutex); + if (!cp0_keyboard_input_begin_create(&keyboard_lifecycle)) { + pthread_mutex_unlock(&keyboard_init_mutex); return; + } - STAILQ_INIT(&keyboard_queue); + cp0_keyboard_queue_init(); if (LV_EVENT_KEYBOARD == 0) LV_EVENT_KEYBOARD = lv_event_register_id(); - lv_sdl_mouse_create(); - if (cp0_sdl_keyboard_create() == NULL) + if (!mouse_initialized) { + lv_sdl_mouse_create(); + mouse_initialized = 1; + } + lv_indev_t *indev = cp0_sdl_keyboard_create(); + cp0_keyboard_input_finish_create(&keyboard_lifecycle, indev); + if (indev == NULL) fprintf(stderr, "cp0_lvgl: failed to create SDL keyboard input\n"); - input_initialized = 1; + pthread_mutex_unlock(&keyboard_init_mutex); } void init_input(void) { init_sdl_input(); } + +void deinit_input(void) +{ +} diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_lara.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_lara.cpp index 2ceb46d4..e047e076 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_lara.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_lara.cpp @@ -1,11 +1,13 @@ #include "cp0_lvgl_app.h" +#include "../cp0_lora_contract.hpp" +#include "../cp0_callback_contract.hpp" +#include "../cp0_callback_result.hpp" +#include "../cp0_signal_registration.hpp" #include "hal_lvgl_bsp.h" -#include #include #include #include -#include #include #include #include @@ -14,29 +16,21 @@ class LoraSystem { public: - void api_call(std::list arg, std::function callback) + LoraSystem() { - if (arg.empty()) { - report(callback, -1, "missing lora api command\n"); - return; - } - - const std::string command = arg.front(); - if (command == "Init") { + cp0::lora::Operations operations; + operations.initialize = [this] { initialized_ = true; hw_ready_ = true; std::snprintf(diag_, sizeof(diag_), "SDL simulated LoRa ready"); - report(callback, 0, ""); - return; - } - if (command == "Poll" || command == "Info") { - cp0_lora_info_t info{}; - fill_info(&info, command == "Poll"); - report(callback, 0, std::string(reinterpret_cast(&info), sizeof(info))); - return; - } - if (command == "SendText") { - std::snprintf(last_tx_, sizeof(last_tx_), "%s", first_arg_after_command(arg).c_str()); + return true; + }; + operations.poll = [] {}; + operations.read_info = [this](cp0_lora_info_t *info, bool drain_events) { + fill_info(info, drain_events); + }; + operations.send_text = [this](const std::string &payload) { + std::snprintf(last_tx_, sizeof(last_tx_), "%s", payload.c_str()); std::snprintf(last_rx_, sizeof(last_rx_), "SDL echo: %.117s", last_tx_); has_sent_message_ = true; tx_event_ = true; @@ -44,31 +38,32 @@ class LoraSystem rssi_ = -42.0f; snr_ = 9.5f; std::snprintf(diag_, sizeof(diag_), "SDL simulated packet sent"); - report(callback, 0, ""); - return; - } - if (command == "StartReceive") { + return true; + }; + operations.start_receive = [this] { tx_mode_ = false; std::snprintf(diag_, sizeof(diag_), "SDL simulated receive mode"); - report(callback, 0, ""); - return; - } - if (command == "SetTxMode") { - tx_mode_ = std::atoi(first_arg_after_command(arg).c_str()) != 0; + }; + operations.set_tx_mode = [this](bool enabled) { + tx_mode_ = enabled; std::snprintf(diag_, sizeof(diag_), tx_mode_ ? "SDL simulated TX mode" : "SDL simulated RX mode"); - report(callback, 0, ""); - return; - } - if (command == "Shutdown") { + }; + operations.shutdown = [this] { initialized_ = false; hw_ready_ = false; tx_mode_ = false; std::snprintf(diag_, sizeof(diag_), "SDL simulated LoRa shutdown"); - report(callback, 0, ""); - return; - } + }; + service_ = std::make_unique(std::move(operations)); + } - report(callback, -1, "unknown lora api\n"); + void api_call(std::list arg, std::function callback) + { + cp0::CallbackResult completion(std::move(callback)); + completion.guard(-1, "lora api failure", [&] { + const cp0::lora::Result result = service_->call(arg); + completion.complete(result.code, result.payload); + }); } private: @@ -83,13 +78,7 @@ class LoraSystem char last_rx_[128] = "SDL simulated receive buffer"; char last_tx_[128] = "Hello from SDL LoRa"; char diag_[256] = "SDL simulated LoRa idle"; - - static std::string first_arg_after_command(const std::list& arg) - { - if (arg.size() < 2) - return ""; - return *std::next(arg.begin()); - } + std::unique_ptr service_; void fill_info(cp0_lora_info_t *info, bool drain_events) { @@ -117,16 +106,40 @@ class LoraSystem } } - static void report(std::function callback, int code, const std::string& data) - { - if (callback) callback(code, data); - } }; +namespace { + +std::shared_ptr &lora_system() +{ + static auto lora = std::make_shared(); + return lora; +} + +cp0::SignalRegistration &lora_registration() +{ + static cp0::SignalRegistration registration; + return registration; +} + +} // namespace + extern "C" void init_lora(void) { - std::shared_ptr lora = std::make_shared(); - cp0_signal_lora_api.append([lora](std::list arg, std::function callback) { - lora->api_call(arg, callback); + const auto active_lora = lora_system(); + lora_registration().replace(cp0_signal_lora_api, [active_lora](std::list arg, std::function callback) { + active_lora->api_call(arg, callback); }); } + +extern "C" void deinit_lora(void) noexcept +{ + try { + lora_registration().reset(); + } catch (...) { + } + try { + lora_system()->api_call({"Shutdown"}, nullptr); + } catch (...) { + } +} diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_network.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_network.cpp index 7e0f4a6a..565ed148 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_network.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_network.cpp @@ -2,17 +2,19 @@ #include "hal/hal_network.h" #include "hal/hal_settings.h" #include "hal_lvgl_bsp.h" +#include "../cp0_network_api_contract.hpp" +#include "../cp0_callback_result.hpp" +#include "../cp0_signal_registration.hpp" #include #include -#include #include #include #include #include #include +#include #include -#include #include #include #include @@ -37,48 +39,51 @@ class WifiSystem void api_call(arg_t arg, callback_t callback) { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "Status") { - report(callback, 0, encode_status(get_status())); - } else if (cmd == "Scan") { - int max_count = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : CP0_WIFI_AP_MAX; - std::vector aps(static_cast(std::max(0, max_count))); + cp0::CallbackResult result(std::move(callback)); + try { + cp0::network::ApiRequest request; + if (!cp0::network::parse_api_request(arg, request)) { + result.complete(-1, cp0::network::invalid_api_request_message()); + return; + } + switch (request.command) { + case cp0::network::ApiCommand::Status: + result.complete(0, cp0::network::encode_status_payload(get_status())); + break; + case cp0::network::ApiCommand::Scan: { + std::vector aps(static_cast(request.scan_limit)); int count = scan(aps.empty() ? nullptr : aps.data(), static_cast(aps.size())); - report(callback, count, encode_scan(aps.data(), count)); - } else if (cmd == "Connect") { - report(callback, hal_wifi_connect(nth_arg(arg, 1).c_str(), nth_arg(arg, 2).c_str()), ""); - } else if (cmd == "Disconnect" || cmd == "ProfileDisconnectActive") { - report(callback, hal_wifi_disconnect(), ""); - } else if (cmd == "ProfileForget") { - report(callback, 0, ""); - } else if (cmd == "ProfileExists") { - const std::string ssid = nth_arg(arg, 1); + result.complete(count, cp0::network::encode_scan_payload(aps.data(), count)); + break; + } + case cp0::network::ApiCommand::Connect: + result.complete(hal_wifi_connect(request.ssid.c_str(), request.password.c_str()), ""); + break; + case cp0::network::ApiCommand::Disconnect: + case cp0::network::ApiCommand::ProfileDisconnectActive: + result.complete(hal_wifi_disconnect(), ""); + break; + case cp0::network::ApiCommand::ProfileForget: + result.complete(0, ""); + break; + case cp0::network::ApiCommand::ProfileExists: { cp0_wifi_status_t st = get_status(); - report(callback, ssid == st.ssid ? 1 : 0, ""); - } else if (cmd == "RadioEnabled") { - report(callback, 1, ""); - } else if (cmd == "RadioSetEnabled") { - report(callback, 0, ""); - } else { - report(callback, -1, "unknown wifi api command"); + result.complete(request.ssid == st.ssid ? 1 : 0, ""); + break; + } + case cp0::network::ApiCommand::RadioEnabled: + result.complete(1, ""); + break; + case cp0::network::ApiCommand::RadioSetEnabled: + result.complete(0, ""); + break; + } + } catch (...) { + result.complete(-1, "network api failure"); } } private: - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) - callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - return it == arg.end() ? std::string() : *it; - } - static cp0_wifi_status_t get_status() { hal_wifi_status_t hal = hal_wifi_get_status(); @@ -107,24 +112,22 @@ class WifiSystem return count; } - static std::string encode_status(const cp0_wifi_status_t &st) - { - std::ostringstream oss; - oss << st.connected << ':' << st.ssid << ':' << st.ip << ':' << st.signal; - return oss.str(); - } - - static std::string encode_scan(const cp0_wifi_ap_t *aps, int count) - { - std::ostringstream oss; - for (int i = 0; aps && i < count; ++i) - oss << aps[i].ssid << ':' << aps[i].signal << ':' << aps[i].security << ':' << aps[i].in_use << '\n'; - return oss.str(); - } }; } // namespace +namespace { + +using WifiRegistration = cp0::SignalRegistration; + +WifiRegistration &wifi_registration() +{ + static WifiRegistration registration; + return registration; +} + +} // namespace + extern "C" int cp0_network_list(cp0_netif_info_t *entries, int max_entries, int *out_count) { if (!out_count) @@ -149,12 +152,23 @@ extern "C" int cp0_network_list(cp0_netif_info_t *entries, int max_entries, int extern "C" void init_wifi(void) { - auto wifi = std::make_shared(); - cp0_signal_wifi_api.append([wifi](std::list arg, std::function callback) { - wifi->api_call(std::move(arg), std::move(callback)); + static std::once_flag initialized; + std::call_once(initialized, []() { + auto wifi = std::make_shared(); + wifi_registration().replace( + cp0_signal_wifi_api, + [wifi](std::list arg, + std::function callback) { + wifi->api_call(std::move(arg), std::move(callback)); + }); }); } +extern "C" void deinit_wifi(void) +{ + wifi_registration().reset(); +} + extern "C" int hal_network_list(hal_netif_info_t *entries, int max_entries, int *out_count) { if (!out_count) diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_osinfo.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_osinfo.cpp index 6a51ee0c..e9e09863 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_osinfo.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_osinfo.cpp @@ -1,6 +1,11 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" #include "../cp0_app_internal_utils.h" +#include "../cp0_callback_contract.hpp" +#include "../cp0_osinfo_codec.hpp" +#include "../cp0_osinfo_contract.hpp" +#include "../cp0_signal_registration.hpp" +#include "../cp0_sync_signal.hpp" #include #include @@ -11,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -64,73 +68,45 @@ class SdlOsInfoSystem { using callback_t = std::function; using arg_t = std::list; + SdlOsInfoSystem() + { + operations_.read_eth_info = [](bool, cp0_eth_info_t *info) { + return network_default_info_read(info); + }; + operations_.list_networks = [](cp0_netif_info_t *entries, int capacity, int *count) { + return cp0_network_list(entries, capacity, count); + }; + operations_.read_account_info = account_info_read; + operations_.set_time = [](const std::string ×tamp) { return time_set(timestamp.c_str()); }; + operations_.read_local_time = read_local_time; + operations_.random_u32 = random_u32; + operations_.get_ntp = [] { return 0; }; + operations_.set_ntp = [](bool) { return 0; }; + operations_.apt_update_background = apt_update_background; + operations_.update_launcher_background = update_launcher_background; + } + void api_call(arg_t arg, callback_t callback) { - const std::string cmd = nth_arg(arg, 0); - if (cmd == "NetworkDefaultInfoRead" || cmd == "EthInfoRead") { - cp0_eth_info_t info{}; - int ret = network_default_info_read(&info); - report(callback, ret, encode_eth_info(info)); - } else if (cmd == "NetworkList") { - cp0_netif_info_t entries[64]{}; - int count = 0; - int ret = cp0_network_list(entries, 64, &count); - std::ostringstream out; - for (int i = 0; ret == 0 && i < count; ++i) - out << entries[i].iface << '\t' << entries[i].ipv4 << '\t' - << entries[i].netmask << '\t' << entries[i].is_up << '\n'; - report(callback, ret, out.str()); - } else if (cmd == "AccountInfoRead") { - cp0_account_info_t info{}; - int ret = account_info_read(&info); - report(callback, ret, encode_account_info(info)); - } else if (cmd == "TimeSet") { - report(callback, time_set(nth_arg(arg, 1).c_str()), ""); - } else if (cmd == "LocalTime") { - std::time_t now = std::time(nullptr); - struct tm value{}; -#ifdef _WIN32 - if (localtime_s(&value, &now) != 0) { - report(callback, -1, ""); - return; - } -#else - if (!localtime_r(&now, &value)) { - report(callback, errno ? -errno : -1, ""); - return; - } -#endif - std::ostringstream out; - out << value.tm_year + 1900 << ',' << value.tm_mon + 1 << ',' << value.tm_mday << ',' - << value.tm_hour << ',' << value.tm_min << ',' << value.tm_sec; - report(callback, 0, out.str()); - } else if (cmd == "RandomU32") { - report(callback, 0, std::to_string(random_u32())); - } else if (cmd == "NtpGet") { - report(callback, 0, ""); // emulator: NTP considered off so manual set is allowed - } else if (cmd == "NtpSet") { - report(callback, 0, ""); // emulator: no-op - } else if (cmd == "AptUpdateBackground") { - report(callback, apt_update_background(), ""); - } else if (cmd == "UpdateLauncherBackground") { - report(callback, update_launcher_background(), ""); - } else { - report(callback, -1, "unknown osinfo api command"); - } + const cp0::osinfo::Result result = cp0::osinfo::dispatch(arg, operations_); + cp0::callback::invoke(callback, result.code, result.payload); } static int api_simple(const arg_t &arg, std::string *out = nullptr) { int result = -1; - cp0_signal_osinfo_api(arg, [&](int code, std::string data) { + if (out) out->clear(); + cp0::signal::invoke_noexcept([&] { cp0_signal_osinfo_api(arg, [&](int code, std::string data) { result = code; if (out) *out = std::move(data); - }); + }); }); return result; } private: + cp0::osinfo::Operations operations_; + static uint32_t random_u32() noexcept { try { @@ -143,28 +119,16 @@ class SdlOsInfoSystem { } } - static void report(callback_t callback, int code, const std::string &data) + static int read_local_time(std::tm *value) { - if (callback) - callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - return it == arg.end() ? std::string() : *it; - } - - static void clear_net_info(cp0_eth_info_t *info) - { - if (!info) - return; - std::memset(info, 0, sizeof(*info)); - cp0_copy_cstr(info->ipv4, sizeof(info->ipv4), "N/A"); - cp0_copy_cstr(info->gateway, sizeof(info->gateway), "N/A"); - cp0_copy_cstr(info->mac, sizeof(info->mac), "N/A"); + if (!value) + return -1; + const std::time_t now = std::time(nullptr); +#ifdef _WIN32 + return localtime_s(value, &now) == 0 ? 0 : -1; +#else + return localtime_r(&now, value) ? 0 : (errno ? -errno : -1); +#endif } static std::string default_iface_from_route() @@ -218,7 +182,7 @@ class SdlOsInfoSystem { static int network_default_info_read(cp0_eth_info_t *info) { - clear_net_info(info); + cp0::osinfo::clear_eth_info(info); if (!info) return -1; @@ -286,39 +250,6 @@ class SdlOsInfoSystem { return 0; } - static std::string encode_eth_info(const cp0_eth_info_t &info) - { - return std::string(info.ipv4) + "\n" + info.gateway + "\n" + info.mac; - } - - static void decode_eth_info(const std::string &data, cp0_eth_info_t *info) - { - clear_net_info(info); - if (!info) - return; - std::istringstream lines(data); - std::string line; - if (std::getline(lines, line)) cp0_copy_string(info->ipv4, sizeof(info->ipv4), line); - if (std::getline(lines, line)) cp0_copy_string(info->gateway, sizeof(info->gateway), line); - if (std::getline(lines, line)) cp0_copy_string(info->mac, sizeof(info->mac), line); - } - - static std::string encode_account_info(const cp0_account_info_t &info) - { - return std::string(info.user) + "\n" + info.hostname; - } - - static void decode_account_info(const std::string &data, cp0_account_info_t *info) - { - if (!info) - return; - std::memset(info, 0, sizeof(*info)); - std::istringstream lines(data); - std::string line; - if (std::getline(lines, line)) cp0_copy_string(info->user, sizeof(info->user), line); - if (std::getline(lines, line)) cp0_copy_string(info->hostname, sizeof(info->hostname), line); - } - public: static int api_eth_info(const char *command, cp0_eth_info_t *info) { @@ -326,8 +257,8 @@ class SdlOsInfoSystem { return -1; std::string data; int ret = api_simple({command}, &data); - if (ret == 0) - decode_eth_info(data, info); + if (ret == 0 && !cp0::osinfo::decode_eth_info_strict(data, info)) + ret = -1; return ret; } @@ -337,8 +268,8 @@ class SdlOsInfoSystem { return -1; std::string data; int ret = api_simple({"AccountInfoRead"}, &data); - if (ret == 0) - decode_account_info(data, info); + if (ret == 0 && !cp0::osinfo::decode_account_info_strict(data, info)) + ret = -1; return ret; } }; @@ -352,8 +283,9 @@ extern "C" time_t cp0_sdl_time_now(void) extern "C" void init_osinfo(void) { + static cp0::SignalRegistration registration; auto osinfo = std::make_shared(); - cp0_signal_osinfo_api.append([osinfo](std::list arg, std::function callback) { + registration.replace(cp0_signal_osinfo_api, [osinfo](std::list arg, std::function callback) { osinfo->api_call(std::move(arg), std::move(callback)); }); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_process.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_process.cpp index 20405a23..54be3bc8 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_process.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_process.cpp @@ -1,48 +1,26 @@ #include "cp0_lvgl_app.h" #include "hal/hal_process.h" #include "hal_lvgl_bsp.h" + +#include "../cp0/cp0_desktop_exec_policy.hpp" +#include "../cp0/cp0_process_commands.hpp" +#include "../cp0/cp0_process_lifecycle.hpp" #include "../cp0_app_internal_utils.h" -#include "../cp0_external_process_group.hpp" +#include "../cp0_process_api_contract.hpp" +#include "../cp0_signal_registration.hpp" +#include "sdl_external_app_runner.hpp" -#include -#include #include #include #include -#include -#include -#include #include -#include +#include #include #include -#include -#include #include #include -#include #include - -#if !defined(_WIN32) -#include -#include -#include -#include -#include -#include -#endif - -#if !defined(HAL_PLATFORM_SDL) && !defined(_WIN32) -#include -#endif - -extern "C" { - extern void keyboard_pause(void); - extern void keyboard_resume(void); -} - -extern "C" void __attribute__((weak)) keyboard_pause(void) {} -extern "C" void __attribute__((weak)) keyboard_resume(void) {} +#include class ProcessSystem { @@ -50,743 +28,283 @@ class ProcessSystem using callback_t = std::function; using arg_t = std::list; - void api_call(arg_t arg, callback_t callback) + void api_call(arg_t args, callback_t callback) { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "ExecBlocking") { - const std::string exec_path = nth_arg(arg, 1); - volatile int *home_key_flag = decode_flag_ptr(nth_arg(arg, 2)); - int keep_root = std::atoi(nth_arg(arg, 3).c_str()); - report(callback, exec_blocking(exec_path.c_str(), home_key_flag, keep_root), ""); - } else if (cmd == "Spawn") { - const std::string exec_path = nth_arg(arg, 1); - int keep_root = std::atoi(nth_arg(arg, 2).c_str()); - cp0_pid_t pid = spawn(exec_path.c_str(), keep_root); + try { + const std::string command = args.empty() ? "" : args.front(); + if (command == "ExecBlocking") { + uintptr_t pointer = 0; + bool keep_root = false; + const auto *executable = cp0_process_api_contract::argument_at(args, 1); + const auto *pointer_text = cp0_process_api_contract::argument_at(args, 2); + const auto *keep_root_text = cp0_process_api_contract::argument_at(args, 3); + if (!cp0_process_api_contract::has_exact_arguments(args, 4) || !executable || + executable->empty() || !pointer_text || !keep_root_text || + !cp0_process_api_contract::parse_pointer(*pointer_text, pointer) || + !cp0_process_api_contract::parse_bool(*keep_root_text, keep_root)) + return invalid(callback); + (void)keep_root; + report(callback, sdl_external_app_runner::run( + executable->c_str(), reinterpret_cast(pointer)), ""); + } else if (command == "Spawn") { + bool keep_root = false; + const auto *executable = cp0_process_api_contract::argument_at(args, 1); + const auto *flag = cp0_process_api_contract::argument_at(args, 2); + if (!cp0_process_api_contract::has_exact_arguments(args, 3) || !executable || + executable->empty() || !flag || + !cp0_process_api_contract::parse_bool(*flag, keep_root)) + return invalid(callback); + const cp0_pid_t pid = cp0_process_lifecycle::spawn(executable->c_str(), keep_root); report(callback, pid < 0 ? -1 : 0, std::to_string(pid)); - } else if (cmd == "Stop") { - stop(static_cast(std::atoi(nth_arg(arg, 1).c_str()))); + } else if (command == "Stop") { + int pid = 0; + const auto *text = cp0_process_api_contract::argument_at(args, 1); + if (!cp0_process_api_contract::has_exact_arguments(args, 2) || !text || + !cp0_process_api_contract::parse_pid(*text, pid)) + return invalid(callback); + cp0_process_lifecycle::stop(static_cast(pid)); report(callback, 0, ""); - } else if (cmd == "CheckLock") { + } else if (command == "CheckLock") { + const auto *path = cp0_process_api_contract::argument_at(args, 1); + if (!cp0_process_api_contract::has_exact_arguments(args, 2) || !path || path->empty()) + return invalid(callback); int holder_pid = 0; - int ret = check_lock(nth_arg(arg, 1).c_str(), &holder_pid); - report(callback, ret, std::to_string(holder_pid)); - } else if (cmd == "Kill") { - int pid = std::atoi(nth_arg(arg, 1).c_str()); - int grace_ms = std::atoi(nth_arg(arg, 2).c_str()); - kill_process(pid, grace_ms); + const int result = cp0_process_lifecycle::check_lock(path->c_str(), &holder_pid); + report(callback, result, std::to_string(holder_pid)); + } else if (command == "Kill") { + int pid = 0; + int grace_ms = 0; + const auto *pid_text = cp0_process_api_contract::argument_at(args, 1); + const auto *grace_text = cp0_process_api_contract::argument_at(args, 2); + if (!cp0_process_api_contract::has_exact_arguments(args, 3) || !pid_text || + !grace_text || !cp0_process_api_contract::parse_pid(*pid_text, pid) || + !cp0_process_api_contract::parse_grace_ms(*grace_text, grace_ms)) + return invalid(callback); + cp0_process_lifecycle::kill(pid, grace_ms); report(callback, 0, ""); - } else if (cmd == "RunArgv") { - int background = std::atoi(nth_arg(arg, 1).c_str()); - std::vector argv = args_from(arg, 2); - report(callback, run_argv(argv, background), ""); - } else if (cmd == "CaptureArgv") { - std::vector argv = args_from(arg, 1); + } else if (command == "RunArgv") { + bool background = false; + const auto *flag = cp0_process_api_contract::argument_at(args, 1); + if (!cp0_process_api_contract::has_at_least_arguments(args, 3) || !flag || + !cp0_process_api_contract::parse_bool(*flag, background)) + return invalid(callback); + report(callback, cp0_process_commands::run_argv( + cp0_process_api_contract::arguments_from(args, 2), background), ""); + } else if (command == "RunSudo") { + if (!cp0_process_api_contract::has_at_least_arguments(args, 3)) + return invalid(callback); + report(callback, + cp0_process_commands::run_sudo( + *cp0_process_api_contract::argument_at(args, 1), + cp0_process_api_contract::arguments_from(args, 2)), + ""); + } else if (command == "CaptureArgv") { + if (!cp0_process_api_contract::has_at_least_arguments(args, 2)) + return invalid(callback); std::string output; - int ret = capture_argv(argv, output); - report(callback, ret, output); - } else if (cmd == "AdbStatus") { - std::string output; - report(callback, capture_argv({cp0_file_path("adb_helper"), "status"}, output), output); - } else if (cmd == "DesktopExecIsSafe") { + const int result = cp0_process_commands::capture_argv( + cp0_process_api_contract::arguments_from(args, 1), output); + report(callback, result, output); + } else if (command == "AdbStatus") { + if (!cp0_process_api_contract::has_exact_arguments(args, 1)) return invalid(callback); + report(callback, 0, "adbd=inactive\n"); + } else if (command == "DesktopExecIsSafe") { + const auto *exec = cp0_process_api_contract::argument_at(args, 1); + if (!cp0_process_api_contract::has_exact_arguments(args, 2) || !exec || exec->empty()) + return invalid(callback); char reason[128] = {}; - int safe = cp0_desktop_exec_is_safe(nth_arg(arg, 1).c_str(), reason, sizeof(reason)); + const int safe = cp0_desktop_exec_is_safe(exec->c_str(), reason, sizeof(reason)); report(callback, safe ? 0 : -1, reason); - } else if (cmd == "Shutdown") { + } else if (command == "Shutdown") { + if (!cp0_process_api_contract::has_exact_arguments(args, 1)) return invalid(callback); system_shutdown(); report(callback, 0, ""); - } else if (cmd == "Reboot") { + } else if (command == "Reboot") { + if (!cp0_process_api_contract::has_exact_arguments(args, 1)) return invalid(callback); system_reboot(); report(callback, 0, ""); - } else if (cmd == "DelayMs") { - std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, std::atoi(nth_arg(arg, 1).c_str())))); + } else if (command == "DelayMs") { + int delay_ms = 0; + const auto *text = cp0_process_api_contract::argument_at(args, 1); + if (!cp0_process_api_contract::has_exact_arguments(args, 2) || !text || + !cp0_process_api_contract::parse_delay_ms(*text, delay_ms)) + return invalid(callback); + std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); report(callback, 0, ""); } else { report(callback, -1, "unknown process api command"); } - } - - int exec_blocking(const char *exec_path, volatile int *home_key_flag, int keep_root) - { -#if defined(_WIN32) - (void)exec_path; - (void)home_key_flag; - (void)keep_root; - return -1; -#elif defined(HAL_PLATFORM_SDL) - return exec_blocking_sdl(exec_path, home_key_flag, keep_root); -#else - return exec_blocking_cp0(exec_path, home_key_flag, keep_root); -#endif - } - - cp0_pid_t spawn(const char *exec_path, int keep_root) - { -#if defined(_WIN32) - (void)exec_path; - (void)keep_root; - return -1; -#else - cp0_process_group::enable_subreaper(); - pid_t pid = fork(); - if (pid < 0) - return -1; - if (pid == 0) { - setpgid(0, 0); - if (keep_root) - execlp("/bin/sh", "sh", "-c", exec_path, static_cast(nullptr)); - else - exec_as_user(exec_path); - _exit(127); + } catch (...) { + report(callback, -1, "process api failure"); } - setpgid(pid, pid); - return static_cast(pid); -#endif } - void stop(cp0_pid_t pid) + static int api_simple(const arg_t &args, std::string *data = nullptr) { -#if !defined(_WIN32) - if (pid <= 0) - return; - if (!cp0_process_group::terminate_and_reap(static_cast(pid), - static_cast(pid))) - std::fprintf(stderr, "[process] failed to stop and reap pgid=%d\n", - static_cast(pid)); -#else - (void)pid; -#endif - } - - int check_lock(const char *lock_path, int *holder_pid) - { - if (holder_pid) - *holder_pid = 0; -#if defined(_WIN32) - (void)lock_path; - return 0; -#else - if (!lock_path || !holder_pid) - return -1; - int fd = open(lock_path, O_CREAT | O_RDWR, 0666); - if (fd < 0) - return -1; - struct flock fl; - std::memset(&fl, 0, sizeof(fl)); - fl.l_type = F_WRLCK; - fl.l_whence = SEEK_SET; - if (fcntl(fd, F_GETLK, &fl) == -1) { - close(fd); - return -1; - } - close(fd); - if (fl.l_type != F_UNLCK) { - *holder_pid = fl.l_pid; - return fl.l_pid; - } - return 0; -#endif + int result = -1; + cp0_signal_process_api(args, [&](int code, std::string output) { + result = code; + if (data) *data = std::move(output); + }); + return result; } - void kill_process(int pid, int grace_ms) +private: + static void report(const callback_t &callback, int code, const std::string &data) { -#if !defined(_WIN32) - if (pid <= 0) - return; - killpg(pid, SIGINT); - auto start = std::chrono::steady_clock::now(); - while (true) { - int status = 0; - if (waitpid(pid, &status, WNOHANG) != 0) - return; - auto now = std::chrono::steady_clock::now(); - if (std::chrono::duration_cast(now - start).count() >= grace_ms) { - killpg(pid, SIGKILL); - waitpid(pid, &status, 0); - return; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } -#else - (void)pid; - (void)grace_ms; -#endif + cp0_process_api_contract::invoke_callback_safely(callback, code, data); } - int run_argv(const std::vector &argv, int background) + static void invalid(const callback_t &callback) { -#if defined(_WIN32) - (void)argv; - (void)background; - return -1; -#else - if (argv.empty() || argv[0].empty()) - return -EINVAL; - - pid_t pid = fork(); - if (pid < 0) - return -errno; - - if (pid == 0) { - if (background) - redirect_to_devnull(); - auto raw = make_argv(argv); - execvp(raw[0], raw.data()); - _exit(127); - } - - if (background) - return 0; - - int status = 0; - while (waitpid(pid, &status, 0) < 0) { - if (errno != EINTR) - return -errno; - } - if (WIFEXITED(status)) - return WEXITSTATUS(status); - if (WIFSIGNALED(status)) - return 128 + WTERMSIG(status); - return -1; -#endif + report(callback, -1, "invalid process api arguments"); } - int capture_argv(const std::vector &argv, std::string &output) - { - output.clear(); -#if defined(_WIN32) - (void)argv; - return -1; -#else - if (argv.empty() || argv[0].empty()) - return -EINVAL; - - int pipefd[2]; - if (pipe(pipefd) != 0) - return -errno; - - pid_t pid = fork(); - if (pid < 0) { - close(pipefd[0]); - close(pipefd[1]); - return -errno; - } - - if (pid == 0) { - close(pipefd[0]); - dup2(pipefd[1], STDOUT_FILENO); - int devnull = open("/dev/null", O_WRONLY); - if (devnull >= 0) { - dup2(devnull, STDERR_FILENO); - if (devnull > STDERR_FILENO) - close(devnull); - } - if (pipefd[1] > STDERR_FILENO) - close(pipefd[1]); - auto raw = make_argv(argv); - execvp(raw[0], raw.data()); - _exit(127); - } - - close(pipefd[1]); - char buf[256]; - ssize_t n = 0; - while ((n = read(pipefd[0], buf, sizeof(buf))) > 0) - output.append(buf, static_cast(n)); - close(pipefd[0]); - - int status = 0; - while (waitpid(pid, &status, 0) < 0) { - if (errno != EINTR) - return -errno; - } - if (WIFEXITED(status)) - return WEXITSTATUS(status); - return -1; -#endif - } - - void system_shutdown() + static void system_shutdown() { std::printf("[SDL] shutdown requested (simulated no-op)\n"); } - void system_reboot() + static void system_reboot() { std::printf("[SDL] reboot requested (simulated no-op)\n"); } - -private: - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) - callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - return it == arg.end() ? std::string() : *it; - } - - static std::vector args_from(const arg_t &arg, size_t index) - { - std::vector out; - auto it = arg.begin(); - for (size_t i = 0; i < index && it != arg.end(); ++i) - ++it; - for (; it != arg.end(); ++it) - out.push_back(*it); - return out; - } - - static volatile int *decode_flag_ptr(const std::string &text) - { - if (text.empty() || text == "0") - return nullptr; - uintptr_t raw = static_cast(std::strtoull(text.c_str(), nullptr, 10)); - return reinterpret_cast(raw); - } - - static bool is_nologin_shell(const char *shell) - { - if (!shell || !shell[0]) - return true; - return std::strstr(shell, "nologin") != nullptr || std::strstr(shell, "/bin/false") != nullptr; - } - - static std::string config_get_str(const char *key, const char *default_val) - { - std::string value = default_val ? default_val : ""; - cp0_signal_config_api({"GetStr", key ? std::string(key) : std::string(), value}, - [&](int code, std::string data) { - if (code == 0) value = std::move(data); - }); - return value; - } - - static const char *get_run_user() - { - static thread_local std::string cfg; - cfg = config_get_str("run_as_user", nullptr); - if (!cfg.empty()) - return cfg.c_str(); - - struct passwd *pwd; - setpwent(); - while ((pwd = getpwent()) != nullptr) { - if (pwd->pw_uid >= 1000 && pwd->pw_uid < 65534 && !is_nologin_shell(pwd->pw_shell)) { - endpwent(); - return pwd->pw_name; - } - } - endpwent(); - return "pi"; - } - - static void exec_as_user(const char *exec_path) - { -#if defined(_WIN32) - (void)exec_path; -#else - const char *user = get_run_user(); - if (getuid() == 0 && std::strcmp(user, "root") != 0) { - struct passwd *pw = getpwnam(user); - if (pw) { - initgroups(pw->pw_name, pw->pw_gid); - setgid(pw->pw_gid); - setuid(pw->pw_uid); - setenv("HOME", pw->pw_dir, 1); - setenv("USER", pw->pw_name, 1); - setenv("LOGNAME", pw->pw_name, 1); - setenv("SHELL", pw->pw_shell[0] ? pw->pw_shell : "/bin/bash", 1); - chdir(pw->pw_dir); - } - } - execlp("/bin/sh", "sh", "-c", exec_path, static_cast(nullptr)); -#endif - } - - static std::vector make_argv(const std::vector &argv) - { - std::vector raw; - raw.reserve(argv.size() + 1); - for (const auto &arg : argv) - raw.push_back(const_cast(arg.c_str())); - raw.push_back(nullptr); - return raw; - } - - static void redirect_to_devnull() - { -#if !defined(_WIN32) - int fd = open("/dev/null", O_RDWR); - if (fd < 0) - return; - dup2(fd, STDIN_FILENO); - dup2(fd, STDOUT_FILENO); - dup2(fd, STDERR_FILENO); - if (fd > STDERR_FILENO) - close(fd); -#endif - } - - int exec_blocking_sdl(const char *exec_path, volatile int *home_key_flag, int keep_root) - { -#if defined(_WIN32) - (void)exec_path; - (void)home_key_flag; - (void)keep_root; - return -1; -#else - (void)keep_root; - const bool subreaper = cp0_process_group::enable_subreaper(); - pid_t pid = fork(); - if (pid < 0) - return -1; - if (pid == 0) { - setpgid(0, 0); - execlp("/bin/sh", "sh", "-c", exec_path, static_cast(nullptr)); - _exit(127); - } - setpgid(pid, pid); - std::fprintf(stderr, "[process] external app leader=%d pgid=%d subreaper=%d\n", - static_cast(pid), static_cast(pid), subreaper ? 1 : 0); - int status = 0; - bool leader_reaped = false; - int home_status = 0; - std::chrono::steady_clock::time_point home_start; - std::chrono::steady_clock::time_point term_start; - while (true) { - cp0_process_group::reap_available(pid, pid, status, leader_reaped); - if (!cp0_process_group::exists(pid)) - break; - - if (home_key_flag) { - if (home_status == 0 && *home_key_flag) { - home_status = 1; - home_start = std::chrono::steady_clock::now(); - } else if (home_status == 1) { - if (*home_key_flag) { - auto elapsed = std::chrono::steady_clock::now() - home_start; - if (std::chrono::duration_cast(elapsed).count() >= 3) { - home_status = 2; - term_start = std::chrono::steady_clock::now(); - std::fprintf(stderr, "[process] ESC timeout: SIGTERM pgid=%d\n", - static_cast(pid)); - killpg(pid, SIGTERM); - } - } else { - home_status = 0; - } - } else if (home_status == 2) { - auto elapsed = std::chrono::steady_clock::now() - term_start; - if (std::chrono::duration_cast(elapsed).count() >= 2) { - home_status = 3; - std::fprintf(stderr, "[process] grace timeout: SIGKILL pgid=%d\n", - static_cast(pid)); - killpg(pid, SIGKILL); - } - } - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - cp0_process_group::reap_available(pid, pid, status, leader_reaped); - std::fprintf(stderr, "[process] external app group drained pgid=%d leader_reaped=%d\n", - static_cast(pid), leader_reaped ? 1 : 0); - if (home_key_flag) - *home_key_flag = 0; - if (WIFEXITED(status)) - return WEXITSTATUS(status); - return -1; -#endif - } - - int exec_blocking_cp0(const char *exec_path, volatile int *home_key_flag, int keep_root) - { -#if defined(_WIN32) || defined(HAL_PLATFORM_SDL) - return exec_blocking_sdl(exec_path, home_key_flag, keep_root); -#else - keyboard_pause(); - const bool subreaper = cp0_process_group::enable_subreaper(); - - int evfd = open(get_kbd_device(), O_RDONLY | O_NONBLOCK); - if (evfd < 0) { - std::perror("[cp0] open evdev"); - keyboard_resume(); - return -1; - } - std::printf("[cp0] Opened evdev %s (no EVIOCGRAB; shared with child)\n", get_kbd_device()); - std::fflush(stdout); - - pid_t pid = fork(); - if (pid < 0) { - close(evfd); - keyboard_resume(); - return -1; - } - if (pid == 0) { - close(evfd); - setpgid(0, 0); - if (keep_root) - execlp("/bin/sh", "sh", "-c", exec_path, static_cast(nullptr)); - else - exec_as_user(exec_path); - _exit(127); - } - setpgid(pid, pid); - std::fprintf(stderr, "[process] external app leader=%d pgid=%d subreaper=%d\n", - static_cast(pid), static_cast(pid), subreaper ? 1 : 0); - - auto esc_down_since = std::chrono::steady_clock::time_point{}; - auto term_start = std::chrono::steady_clock::time_point{}; - bool esc_down = false; - bool raw_esc_down = false; - bool term_sent = false; - bool kill_sent = false; - bool leader_reaped = false; - int status = 0; - - while (true) { - cp0_process_group::reap_available(pid, pid, status, leader_reaped); - if (!cp0_process_group::exists(pid)) - break; - - struct input_event ev; - while (read(evfd, &ev, sizeof(ev)) == static_cast(sizeof(ev))) { - if (ev.type == EV_KEY && ev.code == KEY_ESC) { - if (ev.value == 1) { - raw_esc_down = true; - } else if (ev.value == 0) { - raw_esc_down = false; - } - } - } - - const bool esc_now = raw_esc_down || (home_key_flag && *home_key_flag); - if (esc_now && !esc_down) { - esc_down = true; - esc_down_since = std::chrono::steady_clock::now(); - } else if (!esc_now) { - esc_down = false; - } - - if (esc_down && !term_sent) { - auto held_ms = std::chrono::duration_cast( - std::chrono::steady_clock::now() - esc_down_since).count(); - if (held_ms >= 3000) { - term_sent = true; - term_start = std::chrono::steady_clock::now(); - std::fprintf(stderr, "[process] ESC timeout: SIGTERM pgid=%d\n", - static_cast(pid)); - killpg(pid, SIGTERM); - } - } - if (term_sent && !kill_sent && - std::chrono::duration_cast( - std::chrono::steady_clock::now() - term_start).count() >= 2) { - kill_sent = true; - std::fprintf(stderr, "[process] grace timeout: SIGKILL pgid=%d\n", - static_cast(pid)); - killpg(pid, SIGKILL); - } - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - cp0_process_group::reap_available(pid, pid, status, leader_reaped); - std::fprintf(stderr, "[process] external app group drained pgid=%d leader_reaped=%d\n", - static_cast(pid), leader_reaped ? 1 : 0); - - close(evfd); - keyboard_resume(); - std::printf("[cp0] Returned to launcher\n"); - std::fflush(stdout); - if (WIFEXITED(status)) - return WEXITSTATUS(status); - return -1; -#endif - } - - static const char *get_kbd_device() - { - const char *env = std::getenv("APPLAUNCH_LINUX_KEYBOARD_DEVICE"); - return env ? env : "/dev/input/by-path/platform-3f804000.i2c-event"; - } - -public: - static int api_simple(const arg_t &arg, std::string *data = nullptr) - { - int result = -1; - cp0_signal_process_api(arg, [&](int code, std::string out) { - result = code; - if (data) - *data = std::move(out); - }); - return result; - } }; -static bool contains_shell_meta(const char *s) -{ - if (!s) - return true; - static const char *kMeta = "|&;<>`$\\\n\r"; - return std::strpbrk(s, kMeta) != nullptr; -} - -static std::string first_token(const char *exec) -{ - std::istringstream iss(exec ? exec : ""); - std::string token; - iss >> token; - return token; -} - -static bool file_executable(const std::string &path) -{ -#if defined(_WIN32) - (void)path; - return false; -#else - struct stat st; - return stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode) && access(path.c_str(), X_OK) == 0; -#endif -} - extern "C" void init_process(void) { - auto process = std::make_shared(); - cp0_signal_process_api.append([process](std::list arg, std::function callback) { - process->api_call(std::move(arg), std::move(callback)); - }); + cp0_process_api_contract::invoke_c_api_void([] { + static auto process = std::make_shared(); + static cp0::SignalRegistration registration; + const auto active_process = process; + registration.replace(cp0_signal_process_api, + [active_process](std::list args, + std::function callback) { + active_process->api_call(std::move(args), std::move(callback)); + }); + }); } -extern "C" int cp0_process_exec_blocking(const char *exec_path, volatile int *home_key_flag, int keep_root) +extern "C" int cp0_process_exec_blocking(const char *exec_path, volatile int *home_key_flag, + int keep_root) { - return ProcessSystem::api_simple({"ExecBlocking", exec_path ? exec_path : "", - std::to_string(reinterpret_cast(home_key_flag)), - std::to_string(keep_root)}); + return cp0_process_api_contract::invoke_c_api([&] { + return ProcessSystem::api_simple( + {"ExecBlocking", exec_path ? exec_path : "", + std::to_string(reinterpret_cast(home_key_flag)), std::to_string(keep_root)}); + }); } extern "C" cp0_pid_t cp0_process_spawn(const char *exec_path, int keep_root) { - std::string data; - int ret = ProcessSystem::api_simple({"Spawn", exec_path ? exec_path : "", std::to_string(keep_root)}, &data); - return ret == 0 ? static_cast(std::atoi(data.c_str())) : -1; + const int result = cp0_process_api_contract::invoke_c_api([&] { + std::string data; + const int code = ProcessSystem::api_simple( + {"Spawn", exec_path ? exec_path : "", std::to_string(keep_root)}, &data); + int pid = 0; + return code == 0 && cp0_process_api_contract::parse_spawn_response(data, pid) ? pid : -1; + }); + return result < 0 ? static_cast(-1) : static_cast(result); } extern "C" void cp0_process_stop(cp0_pid_t pid) { - ProcessSystem::api_simple({"Stop", std::to_string(pid)}); + cp0_process_api_contract::invoke_c_api_void( + [&] { ProcessSystem::api_simple({"Stop", std::to_string(pid)}); }); } extern "C" int cp0_process_check_lock(const char *lock_path, int *holder_pid) { - std::string data; - int ret = ProcessSystem::api_simple({"CheckLock", lock_path ? lock_path : ""}, &data); - if (holder_pid) - *holder_pid = std::atoi(data.c_str()); - return ret; + if (holder_pid) *holder_pid = 0; + return cp0_process_api_contract::invoke_c_api([&] { + std::string data; + const int result = ProcessSystem::api_simple( + {"CheckLock", lock_path ? lock_path : ""}, &data); + int parsed_pid = 0; + if (holder_pid) { + if (!cp0_process_api_contract::parse_lock_holder_response(data, parsed_pid)) return -1; + *holder_pid = parsed_pid; + } + return result; + }); } extern "C" void cp0_process_kill(int pid, int grace_ms) { - ProcessSystem::api_simple({"Kill", std::to_string(pid), std::to_string(grace_ms)}); + cp0_process_api_contract::invoke_c_api_void([&] { + ProcessSystem::api_simple({"Kill", std::to_string(pid), std::to_string(grace_ms)}); + }); } extern "C" int cp0_process_run_argv(const char *const *argv, int background) { - std::list args = {"RunArgv", std::to_string(background)}; - if (argv) { - for (int i = 0; argv[i]; ++i) - args.push_back(argv[i]); - } - return ProcessSystem::api_simple(args); + return cp0_process_api_contract::invoke_c_api([&] { + std::list args = {"RunArgv", std::to_string(background)}; + if (argv) for (int index = 0; argv[index]; ++index) args.push_back(argv[index]); + return ProcessSystem::api_simple(args); + }); } extern "C" int cp0_process_capture_argv(const char *const *argv, char *out, int out_size) { - if (out && out_size > 0) - out[0] = '\0'; - std::list args = {"CaptureArgv"}; - if (argv) { - for (int i = 0; argv[i]; ++i) - args.push_back(argv[i]); - } - std::string data; - int ret = ProcessSystem::api_simple(args, &data); - if (out && out_size > 0) - cp0_copy_string(out, out_size, data); - return ret; + if (out && out_size > 0) out[0] = '\0'; + return cp0_process_api_contract::invoke_c_api([&] { + std::list args = {"CaptureArgv"}; + if (argv) for (int index = 0; argv[index]; ++index) args.push_back(argv[index]); + std::string data; + const int result = ProcessSystem::api_simple(args, &data); + if (out && out_size > 0) cp0_copy_string(out, out_size, data); + return result; + }); } extern "C" int cp0_process_run_sudo(const char *password, const char *const *argv) { - // sudo is not available on SDL/emulator builds; run the command directly. - (void)password; - return cp0_process_run_argv(argv, 0); + return cp0_process_api_contract::invoke_c_api([&] { + return ProcessSystem::api_simple( + cp0_process_api_contract::make_run_sudo_request(password, argv)); + }); } extern "C" int cp0_file_read_first_line(const char *path, char *out, int out_size) { - if (out && out_size > 0) - out[0] = '\0'; - if (!path || !out || out_size <= 0) - return -1; - std::ifstream file(path); - if (!file.is_open()) - return -1; - std::string line; - if (!std::getline(file, line)) - return -1; - if (!line.empty() && line.back() == '\r') - line.pop_back(); - cp0_copy_string(out, out_size, line); - return 0; + if (out && out_size > 0) out[0] = '\0'; + if (!path || !out || out_size <= 0) return -1; + + return cp0_process_api_contract::invoke_c_api([&] { + std::ifstream file(path); + if (!file.is_open()) return -1; + std::string line; + if (!std::getline(file, line)) return -1; + if (!line.empty() && line.back() == '\r') line.pop_back(); + cp0_copy_string(out, out_size, line); + return 0; + }); } extern "C" int cp0_desktop_exec_is_safe(const char *exec, char *reason, int reason_size) { - auto fail = [reason, reason_size](const char *msg) { - cp0_copy_cstr(reason, reason_size, msg ? msg : "unsafe Exec"); - return 0; - }; - - if (!exec || !exec[0]) - return fail("empty Exec"); - if (std::strlen(exec) > 512) - return fail("Exec too long"); - if (contains_shell_meta(exec)) - return fail("Exec contains shell metacharacters"); - - const std::string token = first_token(exec); - if (token.empty()) - return fail("missing executable"); - - if (token.find('/') != std::string::npos) { - if (!file_executable(token)) - return fail("executable path is not executable"); - return 1; - } - - static const char *kAllowedNames[] = {"bash", "python3", "vim", "vi", "nano", "sh"}; - if (std::find(std::begin(kAllowedNames), std::end(kAllowedNames), token) != std::end(kAllowedNames)) - return 1; - - return fail("executable name is not allowlisted"); + return cp0_process_api_contract::invoke_c_api([&] { + const cp0_desktop_exec_policy::Result result = cp0_desktop_exec_policy::evaluate(exec); + if (!result.allowed) cp0_copy_string(reason, reason_size, result.reason); + return result.allowed ? 1 : 0; + }); } extern "C" void cp0_system_shutdown(void) { - ProcessSystem::api_simple({"Shutdown"}); + cp0_process_api_contract::invoke_c_api_void( + [] { ProcessSystem::api_simple({"Shutdown"}); }); } extern "C" void cp0_system_reboot(void) { - ProcessSystem::api_simple({"Reboot"}); + cp0_process_api_contract::invoke_c_api_void( + [] { ProcessSystem::api_simple({"Reboot"}); }); } -extern "C" int hal_process_exec_blocking(const char *exec_path, volatile int *home_key_flag, int keep_root) +extern "C" int hal_process_exec_blocking(const char *exec_path, volatile int *home_key_flag, + int keep_root) { return cp0_process_exec_blocking(exec_path, home_key_flag, keep_root); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_pty.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_pty.cpp index 78aea037..ef4d963e 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_pty.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_pty.cpp @@ -1,336 +1 @@ -#include "hal_lvgl_bsp.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#if defined(__linux__) -#include -#endif - -namespace { - -typedef void *pty_handle_t; - -struct cp0_pty_handle { - int master_fd; - pid_t child_pid; -}; - -class PtySystem { -public: - typedef std::function callback_t; - typedef std::list arg_t; - - pty_handle_t open(const char *cmd, const char *const *args, int cols, int rows) - { -#if defined(__linux__) - if (!cmd || !cmd[0]) return NULL; - - int master_fd = -1; - struct winsize ws = {}; - ws.ws_col = cols; - ws.ws_row = rows; - std::string run_as_user = config_get_str("run_as_user", ""); - - pid_t pid = forkpty(&master_fd, NULL, NULL, &ws); - if (pid < 0) return NULL; - - if (pid == 0) { - setenv("TERM", "vt100", 1); - drop_root_user(run_as_user); - - if (args) - execvp(cmd, const_cast(args)); - else - execlp(cmd, cmd, static_cast(NULL)); - _exit(127); - } - - int flags = fcntl(master_fd, F_GETFL); - if (flags >= 0) fcntl(master_fd, F_SETFL, flags | O_NONBLOCK); - - cp0_pty_handle *pty = static_cast(std::malloc(sizeof(cp0_pty_handle))); - if (!pty) { - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); - ::close(master_fd); - return NULL; - } - pty->master_fd = master_fd; - pty->child_pid = pid; - return pty; -#else - (void)cmd; - (void)args; - (void)cols; - (void)rows; - return NULL; -#endif - } - - int read(pty_handle_t pty, char *buf, size_t buf_size) - { -#if defined(__linux__) - if (!pty || !buf || buf_size == 0) return -1; - cp0_pty_handle *h = static_cast(pty); - ssize_t n = ::read(h->master_fd, buf, buf_size); - if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) return 0; - return -1; - } - return static_cast(n); -#else - (void)pty; - (void)buf; - (void)buf_size; - return -1; -#endif - } - - int write(pty_handle_t pty, const char *buf, size_t len) - { -#if defined(__linux__) - if (!pty || !buf) return -1; - cp0_pty_handle *h = static_cast(pty); - return static_cast(::write(h->master_fd, buf, len)); -#else - (void)pty; - (void)buf; - (void)len; - return -1; -#endif - } - - int check_child(pty_handle_t pty, int *exit_status) - { -#if defined(__linux__) - if (!pty) return -1; - cp0_pty_handle *h = static_cast(pty); - int status = 0; - pid_t r = waitpid(h->child_pid, &status, WNOHANG); - if (r == 0) return 0; - if (r > 0) { - if (exit_status) *exit_status = WIFEXITED(status) ? WEXITSTATUS(status) : -1; - return 1; - } - return -1; -#else - (void)pty; - (void)exit_status; - return -1; -#endif - } - - void close(pty_handle_t pty) - { -#if defined(__linux__) - if (!pty) return; - cp0_pty_handle *h = static_cast(pty); - kill(h->child_pid, SIGKILL); - waitpid(h->child_pid, NULL, 0); - ::close(h->master_fd); - std::free(h); -#else - (void)pty; -#endif - } - - void api_call(arg_t arg, callback_t callback) - { - if (arg.empty()) { - report(callback, -1, "empty pty api\n"); - return; - } - - const std::string cmd = arg.front(); - if (cmd == "Open") { - api_open(arg, callback); - } else if (cmd == "Read") { - api_read(arg, callback); - } else if (cmd == "Write") { - api_write(arg, callback); - } else if (cmd == "CheckChild") { - api_check_child(arg, callback); - } else if (cmd == "Close") { - pty_handle_t pty = parse_handle(nth_arg(arg, 1)); - close(pty); - report(callback, 0, ""); - } else { - report(callback, -1, "unknown pty api: " + cmd + "\n"); - } - } - -private: - static void report(callback_t callback, int code, const std::string &data) - { - if (callback) callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - if (index >= arg.size()) return ""; - auto it = arg.begin(); - std::advance(it, index); - return *it; - } - - static std::string handle_to_string(pty_handle_t pty) - { - std::ostringstream os; - os << reinterpret_cast(pty); - return os.str(); - } - - static pty_handle_t parse_handle(const std::string &value) - { - if (value.empty()) return NULL; - char *end = NULL; - uintptr_t raw = static_cast(std::strtoull(value.c_str(), &end, 0)); - if (!end || *end != '\0') return NULL; - return reinterpret_cast(raw); - } - - static int parse_int(const std::string &value, int fallback) - { - if (value.empty()) return fallback; - char *end = NULL; - long parsed = std::strtol(value.c_str(), &end, 10); - return (end && *end == '\0') ? static_cast(parsed) : fallback; - } - - static std::string config_get_str(const char *key, const char *default_val) - { - std::string value = default_val ? default_val : ""; - cp0_signal_config_api({"GetStr", key ? std::string(key) : std::string(), value}, - [&](int code, std::string data) { - if (code == 0) value = std::move(data); - }); - return value; - } - - static void drop_root_user(const std::string &configured_user) - { -#if defined(__linux__) - if (getuid() != 0) return; - - const char *username = configured_user.empty() ? NULL : configured_user.c_str(); - if (!username) { - struct passwd *p = NULL; - setpwent(); - while ((p = getpwent()) != NULL) { - if (p->pw_uid >= 1000 && p->pw_uid < 65534 && - p->pw_shell && p->pw_shell[0] && - !std::strstr(p->pw_shell, "nologin") && - !std::strstr(p->pw_shell, "/bin/false")) { - username = p->pw_name; - break; - } - } - endpwent(); - } - if (!username) username = "pi"; - - struct passwd *pw = getpwnam(username); - if (pw && std::strcmp(username, "root") != 0) { - initgroups(pw->pw_name, pw->pw_gid); - setgid(pw->pw_gid); - setuid(pw->pw_uid); - setenv("HOME", pw->pw_dir, 1); - setenv("USER", pw->pw_name, 1); - setenv("LOGNAME", pw->pw_name, 1); - setenv("SHELL", pw->pw_shell[0] ? pw->pw_shell : "/bin/bash", 1); - chdir(pw->pw_dir); - } -#endif - } - - void api_open(const arg_t &arg, callback_t callback) - { - std::string exec = nth_arg(arg, 1); - int cols = parse_int(nth_arg(arg, 2), 80); - int rows = parse_int(nth_arg(arg, 3), 24); - if (exec.empty()) { - report(callback, -1, "empty pty command\n"); - return; - } - - std::vector argv_storage; - for (auto it = arg.begin(); it != arg.end(); ++it) { - size_t index = static_cast(std::distance(arg.begin(), it)); - if (index >= 4) argv_storage.push_back(*it); - } - - std::vector argv; - if (!argv_storage.empty()) { - for (const std::string &item : argv_storage) argv.push_back(item.c_str()); - argv.push_back(NULL); - } - - pty_handle_t pty = open(exec.c_str(), argv.empty() ? NULL : argv.data(), cols, rows); - if (!pty) { - report(callback, -1, "open pty failed\n"); - return; - } - report(callback, 0, handle_to_string(pty)); - } - - void api_read(const arg_t &arg, callback_t callback) - { - pty_handle_t pty = parse_handle(nth_arg(arg, 1)); - int max_len = parse_int(nth_arg(arg, 2), 4096); - if (max_len <= 0) max_len = 4096; - - std::string out(static_cast(max_len), '\0'); - int n = read(pty, &out[0], out.size()); - if (n < 0) { - report(callback, -1, "read pty failed\n"); - return; - } - out.resize(static_cast(n)); - report(callback, n, out); - } - - void api_write(const arg_t &arg, callback_t callback) - { - pty_handle_t pty = parse_handle(nth_arg(arg, 1)); - std::string data = nth_arg(arg, 2); - int n = write(pty, data.c_str(), data.size()); - report(callback, n, n < 0 ? "write pty failed\n" : ""); - } - - void api_check_child(const arg_t &arg, callback_t callback) - { - pty_handle_t pty = parse_handle(nth_arg(arg, 1)); - int status = 0; - int ret = check_child(pty, &status); - report(callback, ret, std::to_string(status)); - } -}; - -} // namespace - -extern "C" void init_pty(void) -{ - std::shared_ptr pty = std::make_shared(); - cp0_signal_pty_api.append([pty](std::list arg, std::function callback) { - pty->api_call(std::move(arg), std::move(callback)); - }); -} +#include "../cp0_pty_posix.inc" diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_screenshot.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_screenshot.cpp index f4b7ceb6..02c9ccf4 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_screenshot.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_screenshot.cpp @@ -1,17 +1,19 @@ #include "hal_lvgl_bsp.h" #include "lvgl/lvgl.h" #include "lvgl/src/drivers/sdl/lv_sdl_window.h" +#include "../cp0_callback_contract.hpp" +#include "../cp0_display_screenshot_contract.hpp" +#include "../cp0_init_once.hpp" #include -#include #include #include #include #include #include -#include #include #include +#include #include #include #include @@ -19,9 +21,6 @@ namespace { -static void write_le16(FILE *f, uint16_t v) { fwrite(&v, 2, 1, f); } -static void write_le32(FILE *f, uint32_t v) { fwrite(&v, 4, 1, f); } - static int mkdir_p(const char *dir) { if (!dir || !dir[0]) @@ -45,24 +44,19 @@ class ScreenshotSystem { void api_call(arg_t arg, callback_t callback) { - if (arg.empty()) { - report(callback, -1, "empty screenshot api"); - return; - } - if (arg.front() == "Save") { - const std::string dir = arg.size() >= 2 ? *std::next(arg.begin()) : std::string(); - int ret = save_to_bmp(dir.c_str()); - report(callback, ret, ret == 0 ? "screenshot saved\n" : "screenshot failed\n"); + cp0::screenshot::Request request; + if (!cp0::screenshot::parse_request(arg, request)) { + report(callback, -1, cp0::screenshot::invalid_request_message()); return; } - report(callback, -1, "unknown screenshot api"); + int ret = save_to_bmp(request.directory.c_str()); + report(callback, ret, ret == 0 ? "screenshot saved\n" : "screenshot failed\n"); } private: - static void report(callback_t callback, int code, const std::string &data) + static void report(const callback_t &callback, int code, const std::string &data) { - if (callback) - callback(code, data); + cp0::callback::invoke(callback, code, data); } static int save_to_bmp(const char *dir) @@ -81,7 +75,8 @@ class ScreenshotSystem { int w = 0; int h = 0; - if (SDL_GetRendererOutputSize(renderer, &w, &h) != 0 || w <= 0 || h <= 0) + if (SDL_GetRendererOutputSize(renderer, &w, &h) != 0 || + !cp0::screenshot::valid_dimensions(w, h)) return -4; SDL_Surface *surface = SDL_CreateRGBSurfaceWithFormat(0, w, h, 32, SDL_PIXELFORMAT_ARGB8888); @@ -93,18 +88,19 @@ class ScreenshotSystem { ret = -6; } else { std::time_t now = std::time(nullptr); - std::tm *t = std::localtime(&now); - if (!t) { + std::tm local{}; + if (!localtime_r(&now, &local)) { ret = -7; } else { - char filename[512]; - std::snprintf(filename, sizeof(filename), "%s/scr_%04d%02d%02d_%02d%02d%02d_sdl.bmp", - dir, t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, - t->tm_hour, t->tm_min, t->tm_sec); - if (SDL_SaveBMP(surface, filename) != 0) { + cp0::screenshot::Timestamp timestamp{local.tm_year + 1900, local.tm_mon + 1, + local.tm_mday, local.tm_hour, local.tm_min, local.tm_sec}; + std::string filename; + if (!cp0::screenshot::make_output_path(dir, timestamp, true, filename)) { + ret = -7; + } else if (SDL_SaveBMP(surface, filename.c_str()) != 0) { ret = -8; } else { - std::printf("[SDL SCREENSHOT] Saved screenshot: %s\n", filename); + std::printf("[SDL SCREENSHOT] Saved screenshot: %s\n", filename.c_str()); } } } @@ -118,8 +114,13 @@ class ScreenshotSystem { extern "C" void init_screenshot(void) { - auto screenshot = std::make_shared(); - cp0_signal_screenshot_api.append([screenshot](std::list arg, std::function callback) { - screenshot->api_call(std::move(arg), std::move(callback)); + static cp0::InitOnce initialized; + initialized.run([] { + auto screenshot = std::make_shared(); + return static_cast(cp0_signal_screenshot_api.append( + [screenshot](std::list arg, + std::function callback) { + screenshot->api_call(std::move(arg), std::move(callback)); + })); }); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_settings.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_settings.cpp index cd04e148..d3ad6fd7 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_settings.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_settings.cpp @@ -1,27 +1,19 @@ -#include "hal_lvgl_bsp.h" #include "cp0_lvgl_app.h" - -#ifdef HAL_PLATFORM_SDL #include "hal/hal_settings.h" -#endif +#include "hal_lvgl_bsp.h" + +#include "../cp0_app_internal_utils.h" +#include "../cp0_settings_policy.hpp" +#include "../cp0_signal_registration.hpp" +#include "../cp0_sync_signal.hpp" +#include "sdl_bluetooth_settings.hpp" -#include -#include -#include -#include -#include -#include #include -#include +#include #include #include -#include #include -#include #include -#include - -extern "C" time_t cp0_sdl_time_now(void); namespace { @@ -29,354 +21,92 @@ class SettingsSystem { public: using callback_t = std::function; - using arg_t = std::list; + using arg_t = cp0::settings::Arguments; - void api_call(arg_t arg, callback_t callback) + void api_call(const arg_t &args, const callback_t &callback) { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "BacklightRead") { - int val = backlight_read(); - report(callback, val < 0 ? -1 : 0, std::to_string(val)); - } else if (cmd == "BacklightMax") { - int val = backlight_max(); - report(callback, val < 0 ? -1 : 0, std::to_string(val)); - } else if (cmd == "BacklightWrite") { - int val = backlight_write(std::atoi(nth_arg(arg, 1).c_str())); - report(callback, val < 0 ? -1 : 0, std::to_string(val)); - } else if (cmd == "TimeStr") { - char buf[32] = {}; - time_str(buf, sizeof(buf)); - report(callback, 0, buf); - } else { - report(callback, -1, "unknown settings api command"); + try { + if (!cp0::settings::valid_arguments(args)) { + report(callback, -1, "invalid settings api request"); + return; } - } - - void bt_api_call(arg_t arg, callback_t callback) - { - const std::string cmd = arg.empty() ? "" : arg.front(); - if (cmd == "BtStatus") { - report(callback, 0, encode_bt_status(bt_get_status())); - } else if (cmd == "BtPower") { - report(callback, bt_set_power(std::atoi(nth_arg(arg, 1).c_str())), ""); - } else if (cmd == "BtAlias") { - report(callback, bt_set_alias(nth_arg(arg, 1).c_str()), ""); - } else if (cmd == "BtDiscoverable") { - report(callback, bt_set_discoverable(std::atoi(nth_arg(arg, 1).c_str())), ""); - } else if (cmd == "BtScan") { - int max_count = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : CP0_BT_DEVICE_MAX; - std::vector devices(std::max(0, max_count)); - int count = bt_scan(devices.empty() ? nullptr : devices.data(), static_cast(devices.size())); - report(callback, count, encode_bt_scan(devices.data(), count)); - } else if (cmd == "BtDiscoveryStart") { - report(callback, 0, ""); - } else if (cmd == "BtDiscoveryStop") { - report(callback, 0, ""); - } else if (cmd == "BtList") { - int max_count = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : CP0_BT_DEVICE_MAX; - std::vector devices(std::max(0, max_count)); - int count = bt_scan(devices.empty() ? nullptr : devices.data(), static_cast(devices.size())); - report(callback, count, encode_bt_scan(devices.data(), count)); - } else if (cmd == "BtConnectedList") { - int max_count = arg.size() >= 2 ? std::atoi(nth_arg(arg, 1).c_str()) : CP0_BT_DEVICE_MAX; - std::vector devices(std::max(0, max_count)); - int count = bt_scan(devices.empty() ? nullptr : devices.data(), static_cast(devices.size())); - count = filter_connected(devices.data(), count); - report(callback, count, encode_bt_scan(devices.data(), count)); - } else if (cmd == "BtPair" || cmd == "BtConnect" || cmd == "BtDisconnect" || cmd == "BtRemove") { - report(callback, -1, ""); - } else { - report(callback, -1, "unknown bt api command"); + switch (cp0::settings::command_from(args)) { + case cp0::settings::Command::BacklightRead: { + const int value = hal_backlight_read(); + report(callback, value < 0 ? -1 : 0, std::to_string(value)); + break; } - } - - static int api_int(const arg_t &arg, int default_value = -1) - { - int result = default_value; - cp0_signal_settings_api(arg, [&](int code, std::string data) { - if (code >= 0) - result = std::atoi(data.c_str()); - }); - return result; - } - - static int filter_connected(cp0_bt_device_t *devices, int count) - { - if (!devices || count <= 0) - return 0; - int out = 0; - for (int i = 0; i < count; ++i) { - if (!devices[i].connected) - continue; - if (out != i) - devices[out] = devices[i]; - ++out; + case cp0::settings::Command::BacklightMax: { + const int value = hal_backlight_max(); + report(callback, value <= 0 ? -1 : 0, std::to_string(value)); + break; } - return out; - } - - static void api_time_str(char *buf, int buf_size) - { - if (!buf || buf_size <= 0) - return; - buf[0] = '\0'; - cp0_signal_settings_api({"TimeStr"}, [&](int code, std::string data) { - if (code == 0) - copy_string(buf, static_cast(buf_size), data); - }); - if (buf[0] == '\0') - fallback_time_str(buf, buf_size); - } - -private: - void report(callback_t callback, int code, const std::string &data) - { - if (callback) - callback(code, data); - } - - static std::string nth_arg(const arg_t &arg, size_t index) - { - auto it = arg.begin(); - std::advance(it, std::min(index, arg.size())); - return it == arg.end() ? std::string() : *it; - } - - static void copy_string(char *dst, size_t dst_size, const std::string &src) - { - if (!dst || dst_size == 0) - return; - std::strncpy(dst, src.c_str(), dst_size - 1); - dst[dst_size - 1] = '\0'; - } - - static std::vector split_char(const std::string &line, char delimiter) - { - std::vector cols; - std::string item; - std::istringstream iss(line); - while (std::getline(iss, item, delimiter)) - cols.push_back(item); - return cols; - } - - static std::string encode_bt_status(const cp0_bt_status_t &st) - { - std::ostringstream oss; - oss << st.powered << '\t' << st.address << '\t' << st.discoverable << '\t' << st.alias; - return oss.str(); - } - - static std::string encode_bt_scan(const cp0_bt_device_t *devices, int count) - { - std::ostringstream oss; - for (int i = 0; devices && i < count; ++i) { - oss << devices[i].address << '\t' - << devices[i].rssi << '\t' - << devices[i].connected << '\t' - << devices[i].paired << '\t' - << devices[i].trusted << '\t' - << devices[i].name << '\n'; + case cp0::settings::Command::BacklightWrite: { + int requested = 0; + if (!cp0::settings::integer_argument( + args, 1, 0, std::numeric_limits::max(), requested)) { + report(callback, -1, "invalid settings api request"); + break; + } + const int value = hal_backlight_write(requested); + report(callback, value < 0 ? -1 : 0, std::to_string(value)); + break; } - return oss.str(); - } - - static void fallback_time_str(char *buf, int buf_size) - { - if (!buf || buf_size <= 0) - return; - std::time_t now = std::time(nullptr); - std::tm *t = std::localtime(&now); - if (!t) { - buf[0] = '\0'; - return; + case cp0::settings::Command::TimeStr: { + char buffer[32] = {}; + hal_time_str(buffer, sizeof(buffer)); + report(callback, 0, buffer); + break; } - std::snprintf(buf, static_cast(buf_size), "%02d:%02d", t->tm_hour, t->tm_min); - } - -#ifdef HAL_PLATFORM_SDL - int backlight_read() { return hal_backlight_read(); } - int backlight_max() { return hal_backlight_max(); } - int backlight_write(int val) { return hal_backlight_write(val); } - - cp0_bt_status_t bt_get_status() - { - hal_bt_status_t hal = hal_bt_get_status(); - cp0_bt_status_t st{}; - st.powered = hal.powered; - st.discoverable = hal.discoverable; - copy_string(st.address, sizeof(st.address), hal.address); - copy_string(st.alias, sizeof(st.alias), hal.alias); - return st; - } - - int bt_set_power(int on) { return hal_bt_set_power(on); } - int bt_set_alias(const char *alias) { (void)alias; return 0; } - int bt_set_discoverable(int on) { (void)on; return 0; } - - int bt_scan(cp0_bt_device_t *out, int max_devices) - { - if (!out || max_devices <= 0) - return hal_bt_scan(nullptr, 0); - - std::vector hal_devices(static_cast(max_devices)); - int count = hal_bt_scan(hal_devices.data(), max_devices); - count = std::min(count, max_devices); - for (int i = 0; i < count; ++i) { - copy_string(out[i].name, sizeof(out[i].name), hal_devices[static_cast(i)].name); - copy_string(out[i].address, sizeof(out[i].address), hal_devices[static_cast(i)].address); - out[i].rssi = hal_devices[static_cast(i)].rssi; - out[i].connected = hal_devices[static_cast(i)].connected; - out[i].paired = hal_devices[static_cast(i)].paired; - out[i].trusted = hal_devices[static_cast(i)].trusted; + case cp0::settings::Command::Log: + case cp0::settings::Command::GpioSet: + case cp0::settings::Command::GpioGet: + case cp0::settings::Command::Unknown: + default: + report(callback, -1, "unknown settings api command"); + break; } - return count; - } - - void time_str(char *buf, int buf_size) { hal_time_str(buf, buf_size); } -#else - static int read_int_file(const char *path, int default_value) - { - FILE *f = std::fopen(path, "r"); - if (!f) - return default_value; - int val = default_value; - if (std::fscanf(f, "%d", &val) != 1) - val = default_value; - std::fclose(f); - return val; - } - - int backlight_read() - { - return read_int_file("/sys/class/backlight/backlight/brightness", -1); - } - - int backlight_max() - { - return read_int_file("/sys/class/backlight/backlight/max_brightness", 100); - } - - int backlight_write(int val) - { - if (val < 0) - val = 0; - int mx = backlight_max(); - if (val > mx) - val = mx; - FILE *f = std::fopen("/sys/class/backlight/backlight/brightness", "w"); - if (!f) - return -1; - std::fprintf(f, "%d", val); - std::fclose(f); - return val; - } - - cp0_bt_status_t bt_get_status() - { - cp0_bt_status_t st{}; - char output[4096] = {}; - const char *argv[] = {"bluetoothctl", "show", nullptr}; - if (cp0_process_capture_argv(argv, output, sizeof(output)) != 0) - return st; - - std::istringstream lines(output); - std::string line; - while (std::getline(lines, line)) { - if (line.find("Powered:") != std::string::npos) - st.powered = line.find("yes") != std::string::npos ? 1 : 0; - if (line.find("Discoverable:") != std::string::npos) - st.discoverable = line.find("yes") != std::string::npos ? 1 : 0; - std::string alias_marker = "Alias: "; - size_t alias_pos = line.find(alias_marker); - if (alias_pos != std::string::npos) - copy_string(st.alias, sizeof(st.alias), line.substr(alias_pos + alias_marker.size())); - std::string marker = "Controller "; - size_t pos = line.find(marker); - if (pos != std::string::npos) { - std::string addr = line.substr(pos + marker.size()); - size_t sp = addr.find(' '); - if (sp != std::string::npos) - addr.resize(sp); - copy_string(st.address, sizeof(st.address), addr); - } + } catch (...) { + report(callback, -1, "settings api failure"); } - return st; } - int bt_set_power(int on) + static int api_int(const arg_t &args, + int default_value, + int minimum, + int maximum) { - const char *argv_on[] = {"bluetoothctl", "power", "on", nullptr}; - const char *argv_off[] = {"bluetoothctl", "power", "off", nullptr}; - char output[1024] = {}; - int ret = cp0_process_capture_argv(on ? argv_on : argv_off, output, sizeof(output)); - if (ret != 0) - return -1; - std::string data(output); - return (data.find("succeeded") != std::string::npos || data.find("Changing") != std::string::npos) ? 0 : -1; - } - - int bt_set_alias(const char *alias) - { - if (!alias || !alias[0]) - return -1; - const char *argv[] = {"bluetoothctl", "system-alias", alias, nullptr}; - char output[1024] = {}; - return cp0_process_capture_argv(argv, output, sizeof(output)) == 0 ? 0 : -1; + int result = default_value; + cp0::signal::invoke_noexcept([&] { cp0_signal_settings_api(args, [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && + cp0::settings::parse_integer_response(data, minimum, maximum, parsed)) + result = parsed; + }); }); + return result; } - int bt_set_discoverable(int on) + static void api_time_str(char *buffer, int buffer_size) { - const char *argv_on[] = {"bluetoothctl", "discoverable", "on", nullptr}; - const char *argv_off[] = {"bluetoothctl", "discoverable", "off", nullptr}; - char output[1024] = {}; - int ret = cp0_process_capture_argv(on ? argv_on : argv_off, output, sizeof(output)); - if (ret != 0) - return -1; - std::string data(output); - return (data.find("succeeded") != std::string::npos || data.find("Changing") != std::string::npos) ? 0 : -1; + if (!buffer || buffer_size <= 0) return; + buffer[0] = '\0'; + cp0::signal::invoke_noexcept([&] { cp0_signal_settings_api({"TimeStr"}, [&](int code, std::string data) { + if (code != 0) return; + cp0_copy_string(buffer, buffer_size, data); + }); }); + if (buffer[0] == '\0') hal_time_str(buffer, buffer_size); } - int bt_scan(cp0_bt_device_t *out, int max_devices) +private: + static void report(const callback_t &callback, int code, const std::string &data) { - const char *scan_on[] = {"bluetoothctl", "scan", "on", nullptr}; - const char *scan_off[] = {"bluetoothctl", "scan", "off", nullptr}; - cp0_process_run_argv(scan_on, 1); - std::this_thread::sleep_for(std::chrono::seconds(4)); - cp0_process_run_argv(scan_off, 0); - - char output[8192] = {}; - const char *devices[] = {"bluetoothctl", "devices", nullptr}; - if (cp0_process_capture_argv(devices, output, sizeof(output)) != 0) - return 0; - - int count = 0; - std::istringstream lines(output); - std::string line; - while (out && count < max_devices && std::getline(lines, line)) { - if (!line.empty() && line.back() == '\r') - line.pop_back(); - if (line.rfind("Device ", 0) != 0) - continue; - std::string rest = line.substr(7); - size_t sp = rest.find(' '); - if (sp == std::string::npos) - continue; - - cp0_bt_device_t dev{}; - std::string addr = rest.substr(0, sp); - std::string name = rest.substr(sp + 1); - copy_string(dev.address, sizeof(dev.address), addr); - copy_string(dev.name, sizeof(dev.name), name.empty() ? addr : name); - dev.rssi = 0; - dev.connected = 0; - out[count++] = dev; + if (!callback) return; + try { + callback(code, data); + } catch (...) { } - return count; } - void time_str(char *buf, int buf_size) { fallback_time_str(buf, buf_size); } -#endif }; } // namespace @@ -384,137 +114,37 @@ class SettingsSystem extern "C" void init_settings(void) { auto settings = std::make_shared(); - cp0_signal_settings_api.append([settings](std::list arg, std::function callback) { - settings->api_call(std::move(arg), std::move(callback)); - }); - cp0_signal_bt_api.append([settings](std::list arg, std::function callback) { - settings->bt_api_call(std::move(arg), std::move(callback)); - }); + static cp0::SignalRegistration registration; + registration.replace( + cp0_signal_settings_api, + [settings](std::list args, + std::function callback) { + settings->api_call(args, callback); + }); + sdl_bluetooth_settings::register_api(); } extern "C" int cp0_backlight_read(void) { - return SettingsSystem::api_int({"BacklightRead"}); + return SettingsSystem::api_int( + {"BacklightRead"}, -1, 0, std::numeric_limits::max()); } extern "C" int cp0_backlight_max(void) { - return SettingsSystem::api_int({"BacklightMax"}, 100); + return SettingsSystem::api_int( + {"BacklightMax"}, 100, 1, std::numeric_limits::max()); } -extern "C" int cp0_backlight_write(int val) +extern "C" int cp0_backlight_write(int value) { - return SettingsSystem::api_int({"BacklightWrite", std::to_string(val)}); + if (value < 0) return -1; + return SettingsSystem::api_int( + {"BacklightWrite", std::to_string(value)}, -1, 0, + std::numeric_limits::max()); } -extern "C" void cp0_time_str(char *buf, int buf_size) +extern "C" void cp0_time_str(char *buffer, int buffer_size) { - SettingsSystem::api_time_str(buf, buf_size); -} - -extern "C" int hal_backlight_read(void) { return 75; } -extern "C" int hal_backlight_max(void) { return 100; } -extern "C" int hal_backlight_write(int val) -{ - return std::max(0, std::min(hal_backlight_max(), val)); -} - -extern "C" hal_battery_info_t hal_battery_read(void) -{ - hal_battery_info_t info{}; - cp0_battery_info_t cp0 = cp0_battery_read(); - info.voltage_mv = cp0.voltage_mv; - info.current_ma = cp0.current_ma; - info.temperature_c10 = cp0.temperature_c10; - info.soc = cp0.soc; - info.remain_mah = cp0.remain_mah; - info.full_mah = cp0.full_mah; - info.flags = cp0.flags; - info.avg_current_ma = cp0.avg_current_ma; - info.valid = cp0.valid; - return info; -} - -extern "C" int hal_volume_read(void) { return 39; } -extern "C" int hal_volume_write(int val) { return std::max(0, std::min(63, val)); } - -extern "C" hal_wifi_status_t hal_wifi_get_status(void) -{ - hal_wifi_status_t st{}; - st.connected = 1; - std::strncpy(st.ssid, "SimulatedWiFi", WIFI_SSID_MAX - 1); - std::strncpy(st.ip, "192.168.1.100", sizeof(st.ip) - 1); - st.signal = 80; - return st; -} - -extern "C" int hal_wifi_scan(hal_wifi_ap_t *out, int max_aps) -{ - if (!out || max_aps <= 0) - return 0; - - const int count = std::min(max_aps, 3); - std::memset(out, 0, sizeof(hal_wifi_ap_t) * static_cast(count)); - if (count > 0) { - std::strncpy(out[0].ssid, "SimulatedWiFi", WIFI_SSID_MAX - 1); - std::strncpy(out[0].security, "WPA2", sizeof(out[0].security) - 1); - out[0].signal = 80; - out[0].in_use = 1; - } - if (count > 1) { - std::strncpy(out[1].ssid, "Neighbor_5G", WIFI_SSID_MAX - 1); - std::strncpy(out[1].security, "WPA2", sizeof(out[1].security) - 1); - out[1].signal = 55; - } - if (count > 2) { - std::strncpy(out[2].ssid, "FreeWiFi", WIFI_SSID_MAX - 1); - std::strncpy(out[2].security, "Open", sizeof(out[2].security) - 1); - out[2].signal = 30; - } - return count; -} - -extern "C" int hal_wifi_connect(const char *ssid, const char *password) -{ - (void)ssid; - (void)password; - return 0; -} - -extern "C" int hal_wifi_disconnect(void) { return 0; } - -extern "C" hal_bt_status_t hal_bt_get_status(void) -{ - hal_bt_status_t st{}; - st.powered = 0; - st.discoverable = 0; - std::strncpy(st.address, "00:00:00:00:00:00", sizeof(st.address) - 1); - std::strncpy(st.alias, "CardputerZero", sizeof(st.alias) - 1); - return st; -} - -extern "C" int hal_bt_set_power(int on) -{ - (void)on; - return 0; -} - -extern "C" int hal_bt_scan(hal_bt_device_t *out, int max_devices) -{ - (void)out; - (void)max_devices; - return 0; -} - -extern "C" void hal_time_str(char *buf, int buf_size) -{ - if (!buf || buf_size <= 0) - return; - std::time_t now = cp0_sdl_time_now(); - std::tm *t = std::localtime(&now); - if (!t) { - buf[0] = '\0'; - return; - } - std::snprintf(buf, static_cast(buf_size), "%02d:%02d", t->tm_hour, t->tm_min); + SettingsSystem::api_time_str(buffer, buffer_size); } diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_soundcard.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_soundcard.cpp index 1a4b6e1a..3b31df27 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_soundcard.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_soundcard.cpp @@ -1,5 +1,8 @@ #include "hal_lvgl_bsp.h" #include "cp0_lvgl_app.h" +#include "../cp0_callback_contract.hpp" +#include "../cp0_soundcard_contract.hpp" +#include "../cp0_signal_registration.hpp" #include #include @@ -14,18 +17,29 @@ class SoundcardSystem using callback_t = std::function; using arg_t = std::list; - void api_call(arg_t /*arg*/, callback_t callback) + SoundcardSystem() { - if (callback) callback(-1, "not supported on SDL"); + operations_.unavailable_message = "not supported on SDL"; + operations_.unknown_command_message = "not supported on SDL"; } + + void api_call(arg_t arg, callback_t callback) + { + const cp0::soundcard::Result result = cp0::soundcard::dispatch(arg, operations_); + cp0::callback::invoke(callback, result.code, result.payload); + } + +private: + cp0::soundcard::Operations operations_; }; } // namespace extern "C" void init_soundcard(void) { + static cp0::SignalRegistration registration; auto sc = std::make_shared(); - cp0_signal_soundcard_api.append( + registration.replace(cp0_signal_soundcard_api, [sc](std::list arg, std::function cb) { sc->api_call(std::move(arg), std::move(cb)); }); diff --git a/ext_components/cp0_lvgl/src/ui_app_page.cpp b/ext_components/cp0_lvgl/src/ui_app_page.cpp new file mode 100644 index 00000000..9b100e7a --- /dev/null +++ b/ext_components/cp0_lvgl/src/ui_app_page.cpp @@ -0,0 +1,1115 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "ui_app_page.hpp" +#include "cp0_callback_contract.hpp" +#include "cp0_pointer_lifecycle.hpp" +#include "cp0_status_lifecycle.hpp" +#include "cp0_status_component_contract.hpp" + +namespace { + +lv_obj_t *release_fallback_screen = nullptr; +lv_display_t *release_fallback_display = nullptr; + +void release_fallback_delete_cb(lv_event_t *event) +{ + auto *deleted = static_cast(lv_event_get_target(event)); + if (release_fallback_screen != deleted) return; + release_fallback_screen = nullptr; + release_fallback_display = nullptr; +} + +lv_obj_t *get_release_fallback_screen(lv_display_t *display) +{ + if (!display) return nullptr; + lv_obj_t *active = lv_display_get_screen_active(display); + lv_obj_t *loading = lv_display_get_screen_loading(display); + lv_obj_t *previous = lv_display_get_screen_prev(display); + if (release_fallback_screen && release_fallback_display == display && + !cp0::display_references_screen( + release_fallback_screen, active, loading, previous)) + return release_fallback_screen; + + lv_display_t *old_default = lv_display_get_default(); + lv_display_set_default(display); + release_fallback_screen = lv_obj_create(nullptr); + lv_display_set_default(old_default); + if (!release_fallback_screen) { + release_fallback_display = nullptr; + return nullptr; + } + release_fallback_display = display; + lv_obj_add_event_cb(release_fallback_screen, release_fallback_delete_cb, + LV_EVENT_DELETE, nullptr); + lv_obj_set_style_bg_color(release_fallback_screen, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(release_fallback_screen, LV_OPA_COVER, 0); + return release_fallback_screen; +} + +} // namespace + +UIAppContainer::UIAppContainer(int height) + : height_(height) +{ +} + +UIAppContainer::~UIAppContainer() +{ + if (container_) + lv_obj_remove_event_cb_with_user_data(container_, container_delete_cb, this); +} + +void UIAppContainer::set_height(int height) +{ + height_ = height; + if (container_) lv_obj_set_height(container_, height_); +} + +lv_obj_t *UIAppContainer::create(lv_obj_t *parent) +{ + if (!cp0::can_mount_child(parent, container_)) + return container_; + container_ = lv_obj_create(parent); + if (!container_) return nullptr; + lv_obj_add_event_cb(container_, container_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(container_); + lv_obj_set_width(container_, 320); + lv_obj_set_height(container_, height_); + lv_obj_set_pos(container_, 0, 20); + lv_obj_clear_flag(container_, + static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + return container_; +} + +void UIAppContainer::container_delete_cb(lv_event_t *event) +{ + if (!cp0::is_direct_event_target( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (self) self->container_ = nullptr; +} + +AppPageRoot::AppPageRoot() +{ + auto rollback = cp0::make_rollback_guard([this] { + top_bar_region_.reset(); + release_root_screen(); + if (input_group_) { + lv_group_delete(input_group_); + input_group_ = nullptr; + } + }); + create_base_ui(); + create_input_group(); + top_bar_region_ = std::make_unique(*this); + rollback.dismiss(); +} + +AppPageRoot::~AppPageRoot() +{ + top_bar_region_.reset(); + release_root_screen(); + if (input_group_) lv_group_delete(input_group_); +} + +void AppPageRoot::release_root_screen() +{ + if (!root_screen_) return; + + lv_display_t *display = lv_obj_get_display(root_screen_); + const bool display_owns_transition = display && cp0::display_references_screen( + root_screen_, lv_display_get_screen_active(display), + lv_display_get_screen_loading(display), + lv_display_get_screen_prev(display)); + if (display_owns_transition) { + lv_obj_t *fallback = get_release_fallback_screen(display); + if (fallback) { + lv_screen_load(fallback); + } else { + lv_obj_remove_event_cb_with_user_data( + root_screen_, screen_delete_cb, this); + root_screen_ = nullptr; + return; + } + + if (cp0::display_references_screen( + root_screen_, lv_display_get_screen_active(display), + lv_display_get_screen_loading(display), + lv_display_get_screen_prev(display))) { + lv_obj_remove_event_cb_with_user_data( + root_screen_, screen_delete_cb, this); + root_screen_ = nullptr; + return; + } + } + + if (root_screen_) lv_obj_delete(root_screen_); +} + +void AppPageRoot::enable_top_bar() { top_bar_region_->enable(); } +void AppPageRoot::disable_top_bar() { top_bar_region_->disable(); } +bool AppPageRoot::top_bar_enabled() const { return top_bar_region_->enabled(); } +void AppPageRoot::set_page_title(const std::string &title) +{ + page_title_ = title; + top_bar_region_->set_page_title(title); +} +void AppPageRoot::update_time() { top_bar_region_->update_time(); } +void AppPageRoot::update_status() { top_bar_region_->update_status(); } +void AppPageRoot::update_battery(const cp0_battery_info_t &battery) +{ + top_bar_region_->update_battery(battery); +} +AppTopBarComponent *AppPageRoot::add_top_bar_component( + std::unique_ptr component) +{ + return top_bar_region_->add_component(std::move(component)); +} +bool AppPageRoot::remove_top_bar_component(const std::string &id) +{ + return top_bar_region_->remove_component(id); +} +AppTopBarComponent *AppPageRoot::top_bar_component(const std::string &id) const +{ + return top_bar_region_->component(id); +} + +void AppPageRoot::clear_content() +{ + if (!root_screen_) return; + lv_obj_t *top_bar = top_bar_region_ ? top_bar_region_->container() : nullptr; + uint32_t index = 0; + while (index < lv_obj_get_child_count(root_screen_)) { + lv_obj_t *child = lv_obj_get_child(root_screen_, index); + if (child == top_bar) { + ++index; + } else { + lv_obj_delete(child); + } + } +} + +void AppPageRoot::create_base_ui() +{ + root_screen_ = lv_obj_create(nullptr); + if (!root_screen_) return; + lv_obj_add_event_cb(root_screen_, screen_delete_cb, LV_EVENT_DELETE, this); + lv_obj_clear_flag(root_screen_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_pad_all(root_screen_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(root_screen_, lv_color_hex(0x000000), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(root_screen_, LV_OPA_COVER, + LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void AppPageRoot::screen_delete_cb(lv_event_t *event) +{ + if (!cp0::is_direct_event_target( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) + return; + self->root_screen_ = nullptr; + if (self->top_bar_region_) + self->top_bar_region_->on_screen_deleted(); +} + +void AppPageRoot::create_input_group() +{ + if (!root_screen_) return; + input_group_ = lv_group_create(); + if (!input_group_) return; + lv_group_add_obj(input_group_, root_screen_); +} + +AppContentRegion::AppContentRegion() +{ + refresh(); +} + +AppContentRegion::~AppContentRegion() +{ + if (ui_APP_Container) + lv_obj_remove_event_cb_with_user_data(ui_APP_Container, container_delete_cb, this); +} + +void AppContentRegion::container_delete_cb(lv_event_t *event) +{ + if (!cp0::is_direct_event_target( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (self) self->ui_APP_Container = nullptr; +} + +void AppContentRegion::refresh() +{ + app_container_.set_height(cp0::app_content_height(has_bottom_bar_)); + if (ui_APP_Container || !root_screen_) return; + + ui_APP_Container = add_bar(app_container_); + if (ui_APP_Container) + lv_obj_add_event_cb(ui_APP_Container, container_delete_cb, + LV_EVENT_DELETE, this); +} + +AppTopBarComponent::AppTopBarComponent(std::string id, int recommended_width, int height) + : id_(std::move(id)), + recommended_width_(recommended_width > 0 ? recommended_width : kDefaultRecommendedWidth), + height_(height < 1 ? 1 : (height > kMaximumHeight ? kMaximumHeight : height)) +{ +} + +AppTopBarComponent::~AppTopBarComponent() +{ + unmount(); +} + +void AppTopBarComponent::unmount() +{ + if (obj_) lv_obj_delete(obj_); +} + +lv_obj_t *AppTopBarComponent::mount(lv_obj_t *parent) +{ + if (!cp0::can_mount_child(parent, obj_) || id_.empty()) return nullptr; + obj_ = lv_obj_create(parent); + if (!obj_) return nullptr; + lv_obj_add_event_cb(obj_, object_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(obj_); + lv_obj_set_size(obj_, recommended_width_, height_); + lv_obj_clear_flag(obj_, static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + if (!cp0::status::initialize_with_rollback( + [this] { on_create(obj_); }, + [this] { return resources_ready(); }, + [this] { + if (obj_) lv_obj_delete(obj_); + })) + return nullptr; + return obj_; +} + +void AppTopBarComponent::object_delete_cb(lv_event_t *event) +{ + auto *self = static_cast(lv_event_get_user_data(event)); + auto *object = static_cast(lv_event_get_target(event)); + if (self && self->obj_ == object) { + cp0::status::release_before_destroy( + [self] { self->deactivate(); }, + [self] { self->detach_from_deleted_parent(); }); + } +} + +void AppTopBarComponent::detach_from_deleted_parent() +{ + obj_ = nullptr; + on_object_deleted(); +} + +void AppTopBarComponent::set_visible(bool visible) +{ + if (!obj_) return; + if (visible) lv_obj_remove_flag(obj_, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(obj_, LV_OBJ_FLAG_HIDDEN); +} + +bool AppTopBarComponent::visible() const +{ + return obj_ && !lv_obj_has_flag(obj_, LV_OBJ_FLAG_HIDDEN); +} + +void AppTopBarComponent::set_width(int width) +{ + if (obj_) lv_obj_set_width(obj_, width > 0 ? width : recommended_width_); +} + +AppTopBarTitleComponent::AppTopBarTitleComponent() + : AppTopBarComponent("cp0.title", 150) +{ +} + +AppTopBarTitleComponent::~AppTopBarTitleComponent() +{ + unmount(); +} + +void AppTopBarTitleComponent::on_create(lv_obj_t *obj) +{ + label_ = lv_label_create(obj); + if (!label_) return; + lv_obj_add_event_cb(label_, child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(label_, LV_PCT(100), LV_PCT(100)); + lv_label_set_long_mode(label_, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_color(label_, lv_color_hex(0xCCAA00), 0); + lv_obj_set_style_text_font(label_, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_LEFT, 0); + lv_label_set_text(label_, title_.c_str()); +} + +void AppTopBarTitleComponent::child_delete_cb(lv_event_t *event) +{ + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (self) cp0::clear_if_deleted(self->label_, deleted); +} + +void AppTopBarTitleComponent::set_title(const std::string &title) +{ + title_ = title; + if (label_) lv_label_set_text(label_, title_.c_str()); +} + +void AppTopBarTitleComponent::on_object_deleted() +{ + label_ = nullptr; +} + +bool AppTopBarTitleComponent::resources_ready() const +{ + const lv_obj_t *resources[] = {label_}; + return cp0::status::resources_ready(resources, 1); +} + +namespace { + +void configure_status_component(lv_obj_t *obj, const lv_image_dsc_t *background) +{ + lv_obj_set_style_radius(obj, 0, 0); + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_image_src(obj, background, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_pad_all(obj, 0, 0); +} + +lv_obj_t *create_centered_status_label(lv_obj_t *obj) +{ + lv_obj_t *label = lv_label_create(obj); + if (!label) return nullptr; + lv_obj_center(label); + lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(label, LV_FONT_DEFAULT, 0); + lv_label_set_text(label, "--"); + return label; +} + +} // namespace + +AppTopBarTimeComponent::AppTopBarTimeComponent() + : AppTopBarComponent("cp0.time", 40) +{ +} + +AppTopBarTimeComponent::~AppTopBarTimeComponent() +{ + unmount(); +} + +void AppTopBarTimeComponent::on_create(lv_obj_t *obj) +{ + configure_status_component(obj, &cp0_status_time_background); + label_ = create_centered_status_label(obj); + if (label_) + lv_obj_add_event_cb(label_, child_delete_cb, LV_EVENT_DELETE, this); +} + +void AppTopBarTimeComponent::child_delete_cb(lv_event_t *event) +{ + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (self) cp0::clear_if_deleted(self->label_, deleted); +} + +void AppTopBarTimeComponent::refresh() +{ + if (!label_) return; + char text[16] = "--:--"; + cp0_time_str(text, sizeof(text)); + lv_label_set_text(label_, text[0] ? text : "--:--"); +} + +void AppTopBarTimeComponent::on_object_deleted() +{ + label_ = nullptr; +} + +bool AppTopBarTimeComponent::resources_ready() const +{ + const lv_obj_t *resources[] = {label_}; + return cp0::status::resources_ready(resources, 1); +} + +AppTopBarBatteryComponent::AppTopBarBatteryComponent() + : AppTopBarComponent("cp0.battery", 36) +{ +} + +AppTopBarBatteryComponent::~AppTopBarBatteryComponent() +{ + deactivate(); + unmount(); +} + +void AppTopBarBatteryComponent::on_create(lv_obj_t *obj) +{ + configure_status_component(obj, &cp0_status_battery_background); + + battery_bar_ = lv_bar_create(obj); + if (!battery_bar_) return; + lv_obj_add_event_cb(battery_bar_, child_delete_cb, LV_EVENT_DELETE, this); + lv_bar_set_value(battery_bar_, 96, LV_ANIM_OFF); + lv_bar_set_start_value(battery_bar_, 0, LV_ANIM_OFF); + lv_obj_set_size(battery_bar_, 33, 14); + lv_obj_set_align(battery_bar_, LV_ALIGN_CENTER); + lv_obj_set_style_radius(battery_bar_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(battery_bar_, lv_color_hex(0x484847), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(battery_bar_, LV_OPA_TRANSP, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(battery_bar_, 0, LV_PART_INDICATOR | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(battery_bar_, lv_color_hex(0x66CC33), + LV_PART_INDICATOR | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(battery_bar_, LV_OPA_TRANSP, + LV_PART_INDICATOR | LV_STATE_DEFAULT); + + charge_wave_ = lv_obj_create(obj); + if (!charge_wave_) return; + lv_obj_add_event_cb(charge_wave_, child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(charge_wave_, 8, 14); + lv_obj_set_pos(charge_wave_, -8, 1); + lv_obj_clear_flag(charge_wave_, static_cast( + LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + lv_obj_set_style_radius(charge_wave_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(charge_wave_, lv_color_hex(0xFFF176), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(charge_wave_, 190, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(charge_wave_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(charge_wave_, LV_OBJ_FLAG_HIDDEN); + + label_ = create_centered_status_label(obj); + if (label_) { + lv_obj_add_event_cb(label_, child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_move_foreground(label_); + } +} + +void AppTopBarBatteryComponent::child_delete_cb(lv_event_t *event) +{ + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (!self) return; + cp0::clear_if_deleted(self->battery_bar_, deleted); + cp0::clear_if_deleted(self->charge_wave_, deleted); + cp0::clear_if_deleted(self->label_, deleted); +} + +void AppTopBarBatteryComponent::update(const cp0_battery_info_t &battery) +{ + if (!battery.valid || !label_) return; + int charge = battery.soc < 0 ? 0 : (battery.soc > 100 ? 100 : battery.soc); + char text[16]; + std::snprintf(text, sizeof(text), "%d%%", charge); + lv_label_set_text(label_, text); + const bool charging = (battery.flags & 1) != 0; + set_charging(charging); + set_low_battery_blink(charge < 3); +} + +void AppTopBarBatteryComponent::deactivate() +{ + set_charging(false); + set_low_battery_blink(false); +} + +void AppTopBarBatteryComponent::blink_cb(void *object, int32_t opacity) +{ + lv_obj_set_style_opa(static_cast(object), static_cast(opacity), 0); +} + +void AppTopBarBatteryComponent::set_charging(bool charging) +{ + if (!obj() || !battery_bar_ || !label_ || !charge_wave_) return; + lv_obj_set_style_bg_opa(obj(), LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(battery_bar_, LV_OPA_TRANSP, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(battery_bar_, LV_OPA_TRANSP, + LV_PART_INDICATOR | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(label_, lv_color_hex(charging ? 0xFFF2A8 : 0xFFFFFF), + LV_PART_MAIN | LV_STATE_DEFAULT); + + if (charging == charging_) return; + charging_ = charging; + if (!charging) { + lv_anim_del(charge_wave_, nullptr); + lv_obj_set_x(charge_wave_, -8); + lv_obj_add_flag(charge_wave_, LV_OBJ_FLAG_HIDDEN); + return; + } + + lv_obj_clear_flag(charge_wave_, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(label_); + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, charge_wave_); + lv_anim_set_values(&animation, -8, 36); + lv_anim_set_time(&animation, 850); + lv_anim_set_repeat_count(&animation, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&animation, lv_anim_path_linear); + lv_anim_set_exec_cb(&animation, reinterpret_cast(lv_obj_set_x)); + lv_anim_start(&animation); +} + +void AppTopBarBatteryComponent::set_low_battery_blink(bool enabled) +{ + if (!obj() || enabled == low_battery_blink_) return; + low_battery_blink_ = enabled; + lv_anim_del(obj(), blink_cb); + if (!enabled) { + lv_obj_set_style_opa(obj(), LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + return; + } + + lv_obj_set_style_opa(obj(), LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, obj()); + lv_anim_set_values(&animation, LV_OPA_COVER, LV_OPA_20); + lv_anim_set_time(&animation, 450); + lv_anim_set_playback_time(&animation, 450); + lv_anim_set_repeat_count(&animation, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&animation, lv_anim_path_linear); + lv_anim_set_exec_cb(&animation, blink_cb); + lv_anim_start(&animation); +} + +void AppTopBarBatteryComponent::on_object_deleted() +{ + battery_bar_ = nullptr; + charge_wave_ = nullptr; + label_ = nullptr; + charging_ = false; + low_battery_blink_ = false; +} + +bool AppTopBarBatteryComponent::resources_ready() const +{ + const lv_obj_t *resources[] = {battery_bar_, charge_wave_, label_}; + return cp0::status::resources_ready(resources, 3); +} + +AppTopBarNetworkComponent::AppTopBarNetworkComponent() + : AppTopBarComponent("cp0.network", 28, 15) +{ +} + +AppTopBarNetworkComponent::~AppTopBarNetworkComponent() +{ + unmount(); +} + +void AppTopBarNetworkComponent::on_create(lv_obj_t *obj) +{ + lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_END, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(obj, 4, 0); + + ethernet_image_ = lv_image_create(obj); + if (!ethernet_image_) return; + lv_obj_add_event_cb(ethernet_image_, child_delete_cb, LV_EVENT_DELETE, this); + lv_image_set_src(ethernet_image_, &cp0_status_ethernet); + + static constexpr int HEIGHTS[] = {6, 9, 12, 15}; + wifi_panel_ = lv_obj_create(obj); + if (!wifi_panel_) return; + lv_obj_add_event_cb(wifi_panel_, child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(wifi_panel_); + lv_obj_set_size(wifi_panel_, 22, 15); + for (int index = 0; index < 4; ++index) { + wifi_bars_[index] = lv_obj_create(wifi_panel_); + if (!wifi_bars_[index]) return; + lv_obj_add_event_cb(wifi_bars_[index], child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(wifi_bars_[index], 4, HEIGHTS[index]); + lv_obj_set_pos(wifi_bars_[index], index * 6, 15 - HEIGHTS[index]); + lv_obj_set_style_radius(wifi_bars_[index], 2, 0); + lv_obj_set_style_bg_color(wifi_bars_[index], lv_color_hex(0x4D4D4D), 0); + lv_obj_set_style_border_width(wifi_bars_[index], 0, 0); + lv_obj_clear_flag(wifi_bars_[index], LV_OBJ_FLAG_SCROLLABLE); + } + set_visible(false); +} + +void AppTopBarNetworkComponent::child_delete_cb(lv_event_t *event) +{ + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (!self) return; + cp0::clear_if_deleted(self->ethernet_image_, deleted); + cp0::clear_if_deleted(self->wifi_panel_, deleted); + for (lv_obj_t *&bar : self->wifi_bars_) + cp0::clear_if_deleted(bar, deleted); +} + +void AppTopBarNetworkComponent::refresh() +{ + if (!obj() || !ethernet_image_ || !wifi_panel_) return; + for (lv_obj_t *bar : wifi_bars_) + if (!bar) return; + + cp0_wifi_status_t status{}; + if (cp0_wifi_status_read(&status) != 0) status = {}; + static constexpr int THRESHOLDS[] = {1, 30, 60, 80}; + for (int index = 0; index < 4; ++index) { + bool active = status.connected && status.signal >= THRESHOLDS[index]; + lv_obj_set_style_bg_color(wifi_bars_[index], + lv_color_hex(active ? 0x33CC33 : 0x4D4D4D), 0); + } + if (status.ethernet) lv_obj_remove_flag(ethernet_image_, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(ethernet_image_, LV_OBJ_FLAG_HIDDEN); + if (status.connected) lv_obj_remove_flag(wifi_panel_, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(wifi_panel_, LV_OBJ_FLAG_HIDDEN); + set_width(status.ethernet && status.connected ? 54 : + (status.ethernet ? 28 : recommended_width())); + set_visible(status.connected || status.ethernet); +} + +void AppTopBarNetworkComponent::on_object_deleted() +{ + ethernet_image_ = nullptr; + wifi_panel_ = nullptr; + for (lv_obj_t *&bar : wifi_bars_) bar = nullptr; +} + +bool AppTopBarNetworkComponent::resources_ready() const +{ + const lv_obj_t *resources[] = { + ethernet_image_, wifi_panel_, wifi_bars_[0], wifi_bars_[1], + wifi_bars_[2], wifi_bars_[3]}; + return cp0::status::resources_ready(resources, 6); +} + +UIAppTopBar::UIAppTopBar() + : title_(std::make_unique()), + network_(std::make_unique()), + time_(std::make_unique()), + battery_(std::make_unique()) +{ +} + +UIAppTopBar::~UIAppTopBar() +{ + for (auto &entry : components_) { + AppTopBarComponent *component = entry.second.get(); + cp0::status::release_before_destroy( + [component] { component->deactivate(); }, + [component] { component->unmount(); }); + } + components_.clear(); + set_active(false); + if (container_) + lv_obj_remove_event_cb_with_user_data(container_, container_delete_cb, this); + if (custom_container_) + lv_obj_remove_event_cb_with_user_data(custom_container_, custom_container_delete_cb, this); +} + +lv_obj_t *UIAppTopBar::create(lv_obj_t *parent) +{ + if (!cp0::can_mount_child(parent, container_)) return nullptr; + container_ = lv_obj_create(parent); + if (!container_) return nullptr; + lv_obj_add_event_cb(container_, container_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(container_); + lv_obj_set_size(container_, 320, height_); + lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(container_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_hor(container_, 5, 0); + lv_obj_set_style_pad_column(container_, 4, 0); + lv_obj_clear_flag(container_, static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + + if (!title_->mount(container_)) { + lv_obj_delete(container_); + return nullptr; + } + + lv_obj_t *spacer = lv_obj_create(container_); + if (!spacer) { + lv_obj_delete(container_); + return nullptr; + } + lv_obj_remove_style_all(spacer); + lv_obj_set_size(spacer, 0, height_); + lv_obj_set_flex_grow(spacer, 1); + + custom_container_ = lv_obj_create(container_); + if (!custom_container_) { + lv_obj_delete(container_); + return nullptr; + } + lv_obj_add_event_cb(custom_container_, custom_container_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(custom_container_); + lv_obj_set_size(custom_container_, LV_SIZE_CONTENT, height_); + lv_obj_set_flex_flow(custom_container_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(custom_container_, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(custom_container_, 4, 0); + lv_obj_clear_flag(custom_container_, static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + lv_obj_add_flag(custom_container_, LV_OBJ_FLAG_HIDDEN); + + if (!network_->mount(container_) || !time_->mount(container_) || + !battery_->mount(container_)) { + lv_obj_delete(container_); + return nullptr; + } + lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + return container_; +} + +void UIAppTopBar::container_delete_cb(lv_event_t *event) +{ + if (!cp0::is_direct_event_target( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (self) { + self->detach_from_deleted_parent(); + cp0::callback::invoke_direct(self->delete_callback_); + } +} + +void UIAppTopBar::custom_container_delete_cb(lv_event_t *event) +{ + if (!cp0::is_direct_event_target( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + for (auto &entry : self->components_) { + entry.second->deactivate(); + entry.second->detach_from_deleted_parent(); + } + self->custom_container_ = nullptr; +} + +void UIAppTopBar::detach_from_deleted_parent() +{ + auto detach = [](AppTopBarComponent *component) { + cp0::status::release_before_destroy( + [component] { component->deactivate(); }, + [component] { component->detach_from_deleted_parent(); }); + }; + detach(title_.get()); + detach(network_.get()); + detach(time_.get()); + detach(battery_.get()); + for (auto &entry : components_) detach(entry.second.get()); + container_ = nullptr; + custom_container_ = nullptr; + active_ = false; +} + +void UIAppTopBar::set_visible(bool visible) +{ + if (!container_) return; + if (visible) lv_obj_remove_flag(container_, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); +} + +bool UIAppTopBar::visible() const +{ + return container_ && !lv_obj_has_flag(container_, LV_OBJ_FLAG_HIDDEN); +} + +void UIAppTopBar::set_active(bool active) +{ + if (active_ == active) return; + active_ = active; + auto apply = [active](AppTopBarComponent *component) { + if (active) { + if (!component->obj()) return; + component->activate(); + component->refresh(); + } else { + component->deactivate(); + } + }; + apply(title_.get()); + apply(network_.get()); + apply(time_.get()); + apply(battery_.get()); + for (auto &entry : components_) apply(entry.second.get()); +} + +AppTopBarComponent *UIAppTopBar::add_component( + std::unique_ptr component) +{ + if (!component || component->id().empty() || !custom_container_) return nullptr; + const std::string id = component->id(); + if (id == title_->id() || id == network_->id() || + id == time_->id() || id == battery_->id()) return nullptr; + remove_component(id); + if (!component->mount(custom_container_)) return nullptr; + AppTopBarComponent *result = component.get(); + components_[id] = std::move(component); + if (active_) { + result->activate(); + result->refresh(); + } + lv_obj_remove_flag(custom_container_, LV_OBJ_FLAG_HIDDEN); + return result; +} + +bool UIAppTopBar::remove_component(const std::string &id) +{ + const auto found = components_.find(id); + if (found == components_.end()) return false; + AppTopBarComponent *component = found->second.get(); + cp0::status::release_before_destroy( + [component] { component->deactivate(); }, + [component] { component->unmount(); }); + components_.erase(found); + if (components_.empty() && custom_container_) + lv_obj_add_flag(custom_container_, LV_OBJ_FLAG_HIDDEN); + return true; +} + +AppTopBarComponent *UIAppTopBar::component(const std::string &id) const +{ + if (id == title_->id()) return title_.get(); + if (id == network_->id()) return network_.get(); + if (id == time_->id()) return time_.get(); + if (id == battery_->id()) return battery_.get(); + const auto found = components_.find(id); + return found == components_.end() ? nullptr : found->second.get(); +} + +void UIAppTopBar::set_title(const std::string &title) +{ + if (!container_) return; + title_->set_title(title); +} + +void UIAppTopBar::update_time() +{ + if (!container_) return; + time_->refresh(); +} + +void UIAppTopBar::update_network() +{ + if (!container_) return; + network_->refresh(); +} + +void UIAppTopBar::update_battery(const cp0_battery_info_t &battery) +{ + if (!container_) return; + battery_->update(battery); +} + +AppTopBarRegion::AppTopBarRegion(AppPageRoot &page) + : page_(page), lifecycle_(std::make_unique()) +{ + top_bar_.set_delete_callback([this] { on_top_bar_deleted(); }); + top_bar_.set_height(page_.top_bar_height_px_); + top_bar_.set_title(page_.page_title_); + top_bar_.create(page_.screen()); +} + +void AppTopBarRegion::on_top_bar_deleted() +{ + enabled_ = false; + lifecycle_->stop(); +} + +void AppTopBarRegion::enable() +{ + if (enabled_) return; + if (!page_.screen() || !top_bar_.container()) return; + top_bar_.set_visible(true); + top_bar_.set_active(true); + update_time(); + update_status(); + update_battery(cp0_battery_read()); + enabled_ = lifecycle_->start({ + [this] { + const uint32_t event_code = lv_c_event[CP0_C_EVENT_BATTERY]; + lv_obj_t *screen = page_.screen(); + if (event_code == 0) + return cp0::status::Resource{true, {}}; + if (!screen) + return cp0::status::Resource{}; + lv_obj_add_event_cb(screen, battery_event_cb, + static_cast(event_code), this); + return cp0::status::Resource{ + true, + [this, screen] { + if (page_.screen() == screen) + lv_obj_remove_event_cb_with_user_data(screen, battery_event_cb, this); + }}; + }, + [this] { + lv_timer_t *timer = lv_timer_create(time_timer_cb, 1000, this); + return cp0::status::Resource{ + timer != nullptr, [timer] { lv_timer_delete(timer); }}; + }, + [this] { + lv_timer_t *timer = lv_timer_create(status_timer_cb, 5000, this); + return cp0::status::Resource{ + timer != nullptr, [timer] { lv_timer_delete(timer); }}; + }, + }); + if (!enabled_) { + top_bar_.set_active(false); + top_bar_.set_visible(false); + } +} + +void AppTopBarRegion::disable() +{ + if (!enabled_) return; + enabled_ = false; + lifecycle_->stop(); + top_bar_.set_active(false); + top_bar_.set_visible(false); +} + +void AppTopBarRegion::on_screen_deleted() +{ + enabled_ = false; + cp0::status::release_before_destroy( + [this] { lifecycle_->stop(); }, + [this] { top_bar_.detach_from_deleted_parent(); }); +} + +AppTopBarRegion::~AppTopBarRegion() +{ + disable(); +} + +void AppTopBarRegion::set_page_title(const std::string &title) +{ + top_bar_.set_title(title); +} + +void AppTopBarRegion::update_time() { top_bar_.update_time(); } +void AppTopBarRegion::update_status() { top_bar_.update_network(); } +void AppTopBarRegion::update_battery(const cp0_battery_info_t &battery) +{ + top_bar_.update_battery(battery); +} + +AppTopBarComponent *AppTopBarRegion::add_component( + std::unique_ptr component) +{ + return top_bar_.add_component(std::move(component)); +} + +bool AppTopBarRegion::remove_component(const std::string &id) +{ + return top_bar_.remove_component(id); +} + +AppTopBarComponent *AppTopBarRegion::component(const std::string &id) const +{ + return top_bar_.component(id); +} + +void AppTopBarRegion::battery_event_cb(lv_event_t *event) +{ + auto *self = static_cast(lv_event_get_user_data(event)); + auto *battery = static_cast(lv_event_get_param(event)); + if (!battery) return; + cp0::callback::invoke_if_present( + self, [battery](AppTopBarRegion ®ion) { region.update_battery(*battery); }); +} + +void AppTopBarRegion::time_timer_cb(lv_timer_t *timer) +{ + auto *self = static_cast(lv_timer_get_user_data(timer)); + cp0::callback::invoke_if_present( + self, [](AppTopBarRegion ®ion) { region.update_time(); }); +} + +void AppTopBarRegion::status_timer_cb(lv_timer_t *timer) +{ + auto *self = static_cast(lv_timer_get_user_data(timer)); + cp0::callback::invoke_if_present( + self, [](AppTopBarRegion ®ion) { region.update_status(); }); +} + +AppBottomBarRegion::AppBottomBarRegion() +{ + has_bottom_bar_ = true; + refresh(); + if (!root_screen_) { + has_bottom_bar_ = false; + refresh(); + return; + } + ui_BOTTOM_Container = lv_obj_create(root_screen_); + if (!ui_BOTTOM_Container) { + has_bottom_bar_ = false; + refresh(); + return; + } + lv_obj_add_event_cb(ui_BOTTOM_Container, container_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(ui_BOTTOM_Container); + lv_obj_set_size(ui_BOTTOM_Container, 320, 20); + lv_obj_set_pos(ui_BOTTOM_Container, 0, 150); + lv_obj_clear_flag(ui_BOTTOM_Container, + static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); +} + +AppBottomBarRegion::~AppBottomBarRegion() +{ + if (ui_BOTTOM_Container) + lv_obj_remove_event_cb_with_user_data(ui_BOTTOM_Container, container_delete_cb, this); +} + +void AppBottomBarRegion::container_delete_cb(lv_event_t *event) +{ + if (!cp0::is_direct_event_target( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + self->ui_BOTTOM_Container = nullptr; + self->has_bottom_bar_ = false; + if (self->root_screen_) self->refresh(); +} + +AppPageLayout::AppPageLayout() + : AppPageRoot(), AppContentRegion() +{ + enable_top_bar(); +} + +AppPageWithBottomBarLayout::AppPageWithBottomBarLayout() + : AppPageRoot(), AppContentRegion(), AppBottomBarRegion() +{ + enable_top_bar(); +} + +void cp0_lvgl_start_app_page(AppPageRoot &page) +{ + if (!page.screen()) return; + lv_screen_load(page.screen()); + if (page.input_group()) { + lv_group_set_default(page.input_group()); + lv_indev_t *input = lv_indev_get_next(nullptr); + while (input) { + const lv_indev_type_t type = lv_indev_get_type(input); + if (cp0::input_type_uses_group( + type, LV_INDEV_TYPE_KEYPAD, LV_INDEV_TYPE_ENCODER)) + lv_indev_set_group(input, page.input_group()); + input = lv_indev_get_next(input); + } + } + lv_obj_invalidate(page.screen()); + lv_refr_now(nullptr); +} diff --git a/ext_components/cp0_lvgl/src/web/cp0_hal_stubs_web.c b/ext_components/cp0_lvgl/src/web/cp0_hal_stubs_web.c index e6276b26..cb2aaf3c 100644 --- a/ext_components/cp0_lvgl/src/web/cp0_hal_stubs_web.c +++ b/ext_components/cp0_lvgl/src/web/cp0_hal_stubs_web.c @@ -5,12 +5,20 @@ #include "hal/hal_process.h" #include "hal/hal_settings.h" #include "cp0_lvgl_app.h" +#include "keyboard_input.h" #include #include #include #include +static cp0_keyboard_key_handler_t global_key_handler; + +void cp0_keyboard_set_global_key_handler(cp0_keyboard_key_handler_t handler) +{ + global_key_handler = handler; +} + int cp0_process_run_sudo(const char *password, const char *const *argv) { (void)password; (void)argv; return -1; } diff --git a/ext_components/cp0_lvgl/src/win32/cp0_hal_stubs_win32.c b/ext_components/cp0_lvgl/src/win32/cp0_hal_stubs_win32.c index e6276b26..cb2aaf3c 100644 --- a/ext_components/cp0_lvgl/src/win32/cp0_hal_stubs_win32.c +++ b/ext_components/cp0_lvgl/src/win32/cp0_hal_stubs_win32.c @@ -5,12 +5,20 @@ #include "hal/hal_process.h" #include "hal/hal_settings.h" #include "cp0_lvgl_app.h" +#include "keyboard_input.h" #include #include #include #include +static cp0_keyboard_key_handler_t global_key_handler; + +void cp0_keyboard_set_global_key_handler(cp0_keyboard_key_handler_t handler) +{ + global_key_handler = handler; +} + int cp0_process_run_sudo(const char *password, const char *const *argv) { (void)password; (void)argv; return -1; } diff --git a/ext_components/cp0_lvgl/tests/run_tests.sh b/ext_components/cp0_lvgl/tests/run_tests.sh index 1e2205bd..18f59fb1 100755 --- a/ext_components/cp0_lvgl/tests/run_tests.sh +++ b/ext_components/cp0_lvgl/tests/run_tests.sh @@ -5,28 +5,408 @@ root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) binary="${TMPDIR:-/tmp}/cp0_lvgl_test_async_utils.$$" trap 'rm -f "$binary"' EXIT HUP INT TERM +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_c_api_boundary.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_audio_runtime_lifecycle.cpp" -o "$binary" +"$binary" + "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ -I"$root/include" "$root/tests/test_async_testable_utils.cpp" -o "$binary" "$binary" +"${CC:-cc}" -std=c11 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_keyboard_input_lifecycle.c" -o "$binary" +"$binary" + +"${CC:-cc}" -std=c11 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_keyboard_thread_lifecycle.c" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_sync_signal.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src/cp0" \ + "$root/src/cp0/cp0_audio_mixer_control.cpp" \ + "$root/tests/test_audio_mixer_control.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_integer_codec.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_bluetooth_api_contract.cpp" \ + "$root/tests/test_bluetooth_api_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_pointer_lifecycle.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_font_cache_policy.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" "$root/tests/test_runner_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_runner_resume_callback.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" "$root/tests/test_init_once.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" -I"$root/../../SDK/github_source/eventpp/include" \ + "$root/tests/test_signal_registration.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_init_plan.c" \ + "$root/tests/test_init_plan.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_display_screenshot_contract.cpp" \ + "$root/tests/test_display_screenshot_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_audio_api_contract.cpp" \ + "$root/tests/test_audio_api_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_camera_api_contract.cpp" \ + "$root/tests/test_camera_api_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + -I"$root/../../SDK/github_source/eventpp/include" \ + "$root/src/cp0_camera_api_contract.cpp" \ + "$root/src/cp0/cp0_camera_viewport.cpp" \ + "$root/src/sdl/sdl_lvgl_camera.cpp" \ + "$root/tests/test_sdl_camera_shutdown.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src/cp0" \ + "$root/src/cp0/cp0_framebuffer_codec.cpp" \ + "$root/tests/test_framebuffer_codec.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/src/cp0_alsa_parser.cpp" \ + "$root/tests/test_alsa_parser.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_alsa_parser.cpp" \ + "$root/src/cp0_soundcard_contract.cpp" \ + "$root/tests/test_soundcard_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src/cp0" \ + "$root/src/cp0/cp0_camera_viewport.cpp" \ + "$root/tests/test_camera_viewport.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src/cp0" \ + "$root/src/cp0/cp0_desktop_exec_policy.cpp" \ + "$root/tests/test_desktop_exec_policy.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/tests/test_process_api_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src/cp0" \ + "$root/src/cp0/cp0_lora_runtime_policy.cpp" \ + "$root/tests/test_lora_runtime_policy.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src/cp0" \ + "$root/tests/test_lora_gpio_offset_policy.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_lora_contract.cpp" \ + "$root/tests/test_lora_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + -I"$root/../../SDK/github_source/eventpp/include" \ + "$root/src/cp0_lora_contract.cpp" \ + "$root/src/sdl/sdl_lvgl_lara.cpp" \ + "$root/tests/test_sdl_lora_registration.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src/cp0" \ + "$root/src/cp0/cp0_network_policy.cpp" \ + "$root/tests/test_network_policy.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_network_api_contract.cpp" \ + "$root/tests/test_network_api_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_callback_result.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_callback_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_callback_owner_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_lvgl_callback_boundary.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_sudo_timer_callback_boundary.cpp" -o "$binary" +"$binary" + "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ -I"$root/src" "$root/tests/test_external_process_group.cpp" -o "$binary" "$binary" +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" \ + "$root/src/sdl/sdl_external_app_runner.cpp" \ + "$root/tests/test_sdl_external_app_runner.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_filesystem_api.cpp" \ + "$root/src/cp0_posix_filesystem.cpp" \ + "$root/tests/test_posix_filesystem.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_resource_path_policy.cpp" \ + "$root/tests/test_resource_path_policy.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + -I"$root/../../SDK/github_source/eventpp/include" \ + "$root/src/cp0_filesystem_api.cpp" \ + "$root/src/cp0_posix_filesystem.cpp" \ + "$root/src/cp0_resource_path_policy.cpp" \ + "$root/src/sdl/sdl_lvgl_filesystem.cpp" \ + "$root/tests/test_sdl_filesystem_api.cpp" -o "$binary" +"$binary" + "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ -I"$root/include" "$root/tests/test_dispatch_testable.cpp" -o "$binary" "$binary" +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/include" "$root/tests/test_dispatch_fallback.cpp" -o "$binary" +"$binary" + "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ -I"$root/include" "$root/tests/test_battery_testable.cpp" -o "$binary" "$binary" +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_battery_codec.cpp" \ + "$root/tests/test_battery_codec.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" \ + "$root/src/cp0_battery_lifecycle.cpp" \ + "$root/tests/test_battery_lifecycle.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_battery_timer_release.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_battery_runtime_shutdown.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_runner_service_shutdown.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" "$root/tests/test_imu_worker_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_rpc_runtime_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" "$root/tests/test_dispatch_shutdown_gate.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" "$root/src/cp0_battery_lifecycle.cpp" \ + "$root/tests/test_battery_init_failure.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" \ + "$root/src/cp0_status_lifecycle.cpp" \ + "$root/tests/test_status_lifecycle.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_status_component_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_owned_component_lifecycle.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_status_mount_rollback.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_component_delete_callback.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_screen_component_detach.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_screen_delete_cleanup.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_battery_codec.cpp" \ + "$root/src/cp0_battery_api_contract.cpp" \ + "$root/tests/test_battery_api_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + -I"$root/../../SDK/github_source/eventpp/include" \ + "$root/src/cp0_battery_codec.cpp" \ + "$root/src/cp0_battery_api_contract.cpp" \ + "$root/src/cp0_battery_lifecycle.cpp" \ + "$root/src/sdl/sdl_lvgl_bq27220.cpp" \ + "$root/tests/test_sdl_bq27220_api.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_osinfo_codec.cpp" \ + "$root/tests/test_osinfo_codec.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_osinfo_codec.cpp" \ + "$root/src/cp0_osinfo_contract.cpp" \ + "$root/tests/test_osinfo_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_settings_policy.cpp" \ + "$root/tests/test_settings_policy.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" \ + "$root/src/cp0_pty_contract.cpp" \ + "$root/tests/test_pty_contract.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + -I"$root/../../SDK/github_source/eventpp/include" \ + "$root/src/cp0_pty_contract.cpp" \ + "$root/src/sdl/sdl_lvgl_pty.cpp" \ + "$root/tests/test_pty_runtime.cpp" -lutil -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_imu_codec.cpp" \ + "$root/tests/test_imu_codec.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_config_service.cpp" \ + "$root/tests/test_config_service.cpp" -o "$binary" +"$binary" + "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ -I"$root/include" -I"$root/src" "$root/src/cp0_sudo_coordinator.cpp" \ "$root/tests/test_sudo_coordinator.cpp" -o "$binary" "$binary" +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_sudo_request_factory.cpp" \ + "$root/tests/test_sudo_request_factory.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" \ + "$root/tests/test_sudo_worker_registry.cpp" -o "$binary" +"$binary" + +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + -I"$root/src" "$root/tests/test_sudo_c_api_boundary.cpp" -o "$binary" +"$binary" + "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ -I"$root/include" -I"$root/src" "$root/src/cp0_process_runner.cpp" \ "$root/tests/test_process_runner.cpp" -o "$binary" "$binary" + +"${CC:-cc}" -std=c11 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src/cp0" \ + "$root/src/cp0/cp0_keyboard_keymap.c" \ + "$root/tests/test_keyboard_keymap.c" -lxkbcommon -o "$binary" +"$binary" + +"${CC:-cc}" -std=c11 -Wall -Wextra -Werror -pthread \ + -I"$root/include" -I"$root/src" \ + "$root/src/cp0_keyboard_text.c" \ + "$root/src/cp0_keyboard_queue.c" \ + "$root/tests/test_keyboard_contract.c" -o "$binary" +"$binary" diff --git a/ext_components/cp0_lvgl/tests/test_alsa_parser.cpp b/ext_components/cp0_lvgl/tests/test_alsa_parser.cpp new file mode 100644 index 00000000..534b7f49 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_alsa_parser.cpp @@ -0,0 +1,43 @@ +#include "../src/cp0_alsa_parser.hpp" + +#include +#include + +int main() +{ + const std::string cards = + " 0 [PCH ]: HDA-Intel - HDA Intel PCH\n" + " HDA Intel PCH at 0xf7e10000 irq 31\n" + " 2 [USB ]: USB-Audio - Headset\n" + "invalid line\n"; + assert(cp0_alsa_parser::encode_cards(cards) == + "0\tCard 0: HDA Intel PCH\n2\tCard 2: Headset\n"); + + const std::string controls = + "Simple mixer control 'Master',0\n" + " Capabilities: pvolume pswitch\n" + " Playback channels: Front Left - Front Right\n" + " Limits: Playback 0 - 100\n" + " Front Left: Playback 67 [67%] [-10.00dB] [on]\n" + " Front Right: Playback 67 [67%] [-10.00dB] [on]\n" + "Simple mixer control 'Input Source',0\n" + " Capabilities: enum\n" + " Items: 'Mic' 'Line'\n" + " Item0: 'Mic'\n"; + assert(cp0_alsa_parser::encode_controls(controls) == + "Master\tINTEGER\t0\t100\t1\tFront Left: Playback 67 [67%] [-10.00dB] [on]\t67\n" + "Input Source\tENUMERATED\t0\t0\t1\tItem0: 'Mic'\t0\n"); + + assert(cp0_alsa_parser::encode_controls("garbage\n") == ""); + + assert(cp0_alsa_parser::encode_cards( + "999999999999999999999999 [BAD]: Device - Overflow\n" + "1junk [BAD]: Device - Junk\n") + .empty()); + assert(cp0_alsa_parser::encode_controls( + "Simple mixer control 'Overflow',0\n" + " Capabilities: pvolume\n" + " Limits: Playback 0 - 999999999999999999999\n" + " Mono: Playback 999999999999999999999 [100%]\n") == + "Overflow\tINTEGER\t0\t0\t1\tMono: Playback 999999999999999999999 [100%]\t0\n"); +} diff --git a/ext_components/cp0_lvgl/tests/test_audio_api_contract.cpp b/ext_components/cp0_lvgl/tests/test_audio_api_contract.cpp new file mode 100644 index 00000000..216a1dbc --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_audio_api_contract.cpp @@ -0,0 +1,119 @@ +#include "cp0_audio_api_contract.hpp" + +#include +#include +#include + +namespace { + +using cp0::audio::ApiCommand; +using cp0::audio::ApiRequest; +using cp0::audio::SetupCommand; +using cp0::audio::SetupRequest; + +void test_path_and_no_argument_commands() +{ + ApiRequest request; + assert(!cp0::audio::parse_api_request({}, request)); + assert(!cp0::audio::parse_api_request({"Unknown"}, request)); + assert(!cp0::audio::parse_api_request({"Play"}, request)); + assert(!cp0::audio::parse_api_request({"Play", ""}, request)); + assert(!cp0::audio::parse_api_request({"Play", "tone.wav", "junk"}, request)); + assert(cp0::audio::parse_api_request({"Play", "tone.wav"}, request)); + assert(request.command == ApiCommand::Play && request.path == "tone.wav"); + assert(cp0::audio::parse_api_request({"PlayFile", "/tmp/tone.wav"}, request)); + assert(request.command == ApiCommand::PlayFile && request.path == "/tmp/tone.wav"); + assert(cp0::audio::parse_api_request({"CapFileSave", "/tmp/cap.wav"}, request)); + assert(request.command == ApiCommand::CapFileSave); + + assert(cp0::audio::parse_api_request({"PlayPause"}, request)); + assert(request.command == ApiCommand::PlayPause); + assert(!cp0::audio::parse_api_request({"PlayPause", "junk"}, request)); + assert(cp0::audio::parse_api_request({"CapEnd"}, request)); + assert(request.command == ApiCommand::CapEnd); + assert(cp0::audio::parse_api_request({"MuteToggle"}, request)); + assert(request.command == ApiCommand::MuteToggle); +} + +void test_numeric_and_boolean_commands() +{ + ApiRequest request; + assert(cp0::audio::parse_api_request({"VolumeWrite", "0"}, request)); + assert(request.command == ApiCommand::VolumeWrite && request.value == 0); + assert(cp0::audio::parse_api_request({"VolumeWrite", "100"}, request)); + assert(request.value == 100); + assert(!cp0::audio::parse_api_request({"VolumeWrite"}, request)); + assert(!cp0::audio::parse_api_request({"VolumeWrite", ""}, request)); + assert(!cp0::audio::parse_api_request({"VolumeWrite", "12junk"}, request)); + assert(!cp0::audio::parse_api_request({"VolumeWrite", "-1"}, request)); + assert(!cp0::audio::parse_api_request({"VolumeWrite", "101"}, request)); + assert(!cp0::audio::parse_api_request({"VolumeWrite", "99999999999999999999"}, request)); + assert(!cp0::audio::parse_api_request({"VolumeWrite", "50", "junk"}, request)); + + assert(cp0::audio::parse_api_request({"SystemSoundPlay", "0"}, request)); + assert(request.command == ApiCommand::SystemSoundPlay && request.value == 0); + assert(cp0::audio::parse_api_request({"SystemSoundPlay", "2"}, request)); + assert(!cp0::audio::parse_api_request({"SystemSoundPlay", "3"}, request)); + assert(!cp0::audio::parse_api_request({"SystemSoundPlay", "junk"}, request)); + + assert(cp0::audio::parse_api_request({"SystemSoundEnable"}, request)); + assert(request.enabled); + assert(cp0::audio::parse_api_request({"SystemSoundEnable", "disabled"}, request)); + assert(!request.enabled); + assert(cp0::audio::parse_api_request({"SystemSoundEnable", "true"}, request)); + assert(request.enabled); + assert(!cp0::audio::parse_api_request({"SystemSoundEnable", "yes"}, request)); +} + +void test_names_and_setup_commands() +{ + ApiRequest request; + assert(cp0::audio::parse_api_request({"SetSystemSoundNames"}, request)); + assert(request.names.empty()); + assert(cp0::audio::parse_api_request( + {"SetSystemSoundNames", "ding.wav", "switch.wav", "enter.wav"}, request)); + assert(request.names.size() == 3 && request.names[2] == "enter.wav"); + assert(!cp0::audio::parse_api_request( + {"SetSystemSoundNames", "1", "2", "3", "4"}, request)); + + SetupRequest setup; + assert(!cp0::audio::parse_setup_request({}, setup)); + assert(!cp0::audio::parse_setup_request({"unknown"}, setup)); + assert(cp0::audio::parse_setup_request({"set_callback"}, setup)); + assert(setup.command == SetupCommand::SetCallback); + assert(!cp0::audio::parse_setup_request({"set_callback", "junk"}, setup)); + assert(cp0::audio::parse_setup_request({"waveform"}, setup)); + assert(setup.command == SetupCommand::SetWaveform && setup.waveform_enabled); + assert(cp0::audio::parse_setup_request({"set_waveform", "off"}, setup)); + assert(!setup.waveform_enabled); + assert(!cp0::audio::parse_setup_request({"waveform", "junk"}, setup)); + assert(!cp0::audio::parse_setup_request({"waveform", "on", "junk"}, setup)); + assert(cp0::audio::parse_setup_request({"stop_play"}, setup)); + assert(setup.command == SetupCommand::StopPlay); + assert(std::string(cp0::audio::invalid_api_request_message()) == + "invalid audio api request\n"); +} + +void test_callback_exception_boundary() +{ + int calls = 0; + cp0::audio::invoke_callback( + [&](int code, std::string data) { + ++calls; + assert(code == 7 && data == "result"); + throw std::runtime_error("callback"); + }, + 7, "result"); + assert(calls == 1); + cp0::audio::invoke_callback({}, 0, "ignored"); +} + +} // namespace + +int main() +{ + test_path_and_no_argument_commands(); + test_numeric_and_boolean_commands(); + test_names_and_setup_commands(); + test_callback_exception_boundary(); +} diff --git a/ext_components/cp0_lvgl/tests/test_audio_mixer_control.cpp b/ext_components/cp0_lvgl/tests/test_audio_mixer_control.cpp new file mode 100644 index 00000000..4488d6eb --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_audio_mixer_control.cpp @@ -0,0 +1,18 @@ +#include "cp0_audio_mixer_control.hpp" + +#include + +int main() +{ + int value = 77; + assert(!Cp0AudioMixerControl::parse_volume_line(nullptr, value) && value == 77); + assert(!Cp0AudioMixerControl::parse_volume_line("", value) && value == 77); + assert(!Cp0AudioMixerControl::parse_volume_line("Volume: junk%", value) && value == 77); + assert(!Cp0AudioMixerControl::parse_volume_line("Volume: -1%", value) && value == 77); + assert(!Cp0AudioMixerControl::parse_volume_line("Volume: junk42%", value) && value == 77); + assert(!Cp0AudioMixerControl::parse_volume_line("Volume: 101%", value) && value == 77); + assert(!Cp0AudioMixerControl::parse_volume_line("Volume: 999999999999999999999%", value) && value == 77); + assert(Cp0AudioMixerControl::parse_volume_line("Volume: 0%", value) && value == 0); + assert(Cp0AudioMixerControl::parse_volume_line("Volume: 100%", value) && value == 100); + assert(Cp0AudioMixerControl::parse_volume_line("Volume: 42% / 42%", value) && value == 42); +} diff --git a/ext_components/cp0_lvgl/tests/test_audio_runtime_lifecycle.cpp b/ext_components/cp0_lvgl/tests/test_audio_runtime_lifecycle.cpp new file mode 100644 index 00000000..1f7734ed --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_audio_runtime_lifecycle.cpp @@ -0,0 +1,25 @@ +#include "cp0_audio_runtime_lifecycle.hpp" + +#include + +int main() +{ + cp0::audio::RuntimeLifecycle lifecycle; + assert(!lifecycle.active()); + assert(lifecycle.begin_init()); + assert(!lifecycle.begin_init()); + lifecycle.rollback_init(); + assert(!lifecycle.active()); + + assert(lifecycle.begin_init()); + lifecycle.commit_init(); + assert(lifecycle.active()); + assert(!lifecycle.begin_init()); + assert(lifecycle.begin_shutdown()); + assert(!lifecycle.active()); + assert(!lifecycle.begin_shutdown()); + + assert(lifecycle.begin_init()); + lifecycle.commit_init(); + assert(lifecycle.active()); +} diff --git a/ext_components/cp0_lvgl/tests/test_battery_api_contract.cpp b/ext_components/cp0_lvgl/tests/test_battery_api_contract.cpp new file mode 100644 index 00000000..c171b79f --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_battery_api_contract.cpp @@ -0,0 +1,78 @@ +#include "cp0_battery_api_contract.hpp" +#include "cp0_callback_contract.hpp" + +#include +#include + +int main() +{ + using cp0::battery::ApiCommand; + using cp0::battery::dispatch_api_request; + using cp0::battery::parse_api_request; + + auto read = parse_api_request({"Read"}); + assert(read.ok && read.request.command == ApiCommand::read); + assert(!parse_api_request({"Read", "extra"}).ok); + + for (int index = 0; index < 4; ++index) { + auto calibrate = parse_api_request({"Calibrate", std::to_string(index)}); + assert(calibrate.ok && calibrate.request.command == ApiCommand::calibrate); + assert(calibrate.request.calibration_index == index); + } + assert(!parse_api_request({}).ok); + assert(!parse_api_request({"Unknown"}).ok); + assert(!parse_api_request({"Calibrate"}).ok); + assert(!parse_api_request({"Calibrate", ""}).ok); + assert(!parse_api_request({"Calibrate", "-1"}).ok); + assert(!parse_api_request({"Calibrate", "+0"}).ok); + assert(!parse_api_request({"Calibrate", " 0"}).ok); + assert(!parse_api_request({"Calibrate", "4"}).ok); + assert(!parse_api_request({"Calibrate", "0junk"}).ok); + assert(!parse_api_request({"Calibrate", "999999999999999999999"}).ok); + assert(!parse_api_request({"Calibrate", "0", "extra"}).ok); + + int read_calls = 0; + int calibrate_calls = 0; + auto reader = [&] { + ++read_calls; + return cp0_battery_info_t{4100, -125, 273, 81, 2430, 3000, 4, -120, 1}; + }; + auto calibrator = [&](int index) { + ++calibrate_calls; + return index == 2 ? -17 : 0; + }; + + auto read_reply = dispatch_api_request({"Read"}, reader, calibrator); + assert(read_reply.code == 0); + assert(read_reply.data == "4100,-125,273,81,2430,3000,4,-120,1"); + assert(read_calls == 1 && calibrate_calls == 0); + + auto calibrate_reply = dispatch_api_request({"Calibrate", "2"}, reader, calibrator); + assert(calibrate_reply.code == -17 && calibrate_reply.data.empty()); + assert(read_calls == 1 && calibrate_calls == 1); + + auto invalid_reply = dispatch_api_request({"Calibrate", "2x"}, reader, calibrator); + assert(invalid_reply.code == -1 && calibrate_calls == 1); + + auto invalid_read = dispatch_api_request( + {"Read"}, [] { return cp0_battery_info_t{}; }, calibrator); + assert(invalid_read.code == -1 && invalid_read.data == "0,0,0,0,0,0,0,0,0"); + assert(dispatch_api_request({"Read"}, {}, calibrator).code == -1); + assert(dispatch_api_request({"Calibrate", "0"}, reader, {}).code == -1); + + auto throwing_read = dispatch_api_request( + {"Read"}, []() -> cp0_battery_info_t { throw std::runtime_error("read"); }, calibrator); + assert(throwing_read.code == -1 && throwing_read.data == "battery backend failure"); + auto throwing_calibrate = dispatch_api_request( + {"Calibrate", "1"}, reader, [](int) -> int { throw std::runtime_error("calibrate"); }); + assert(throwing_calibrate.code == -1 && throwing_calibrate.data == "battery backend failure"); + + int callback_calls = 0; + cp0::callback::invoke([&](int code, std::string data) { + ++callback_calls; + assert(code == -1 && data == "battery backend failure"); + throw std::runtime_error("callback"); + }, throwing_read.code, throwing_read.data); + assert(callback_calls == 1); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_battery_codec.cpp b/ext_components/cp0_lvgl/tests/test_battery_codec.cpp new file mode 100644 index 00000000..21971c89 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_battery_codec.cpp @@ -0,0 +1,65 @@ +#include "../src/cp0_battery_codec.hpp" + +#include + +int main() +{ + cp0_battery_info_t source{4100, -125, 273, 81, 2430, 3000, 4, -120, 1}; + const std::string encoded = cp0::battery::encode_info(source); + assert(encoded == "4100,-125,273,81,2430,3000,4,-120,1"); + + cp0_battery_info_t decoded{}; + assert(cp0::battery::decode_info(encoded, &decoded)); + assert(decoded.voltage_mv == source.voltage_mv); + assert(decoded.current_ma == source.current_ma); + assert(decoded.temperature_c10 == source.temperature_c10); + assert(decoded.soc == source.soc); + assert(decoded.remain_mah == source.remain_mah); + assert(decoded.full_mah == source.full_mah); + assert(decoded.flags == source.flags); + assert(decoded.avg_current_ma == source.avg_current_ma); + assert(decoded.valid == source.valid); + assert(!cp0::battery::decode_info("4100,-125,273", &decoded)); + assert(!cp0::battery::decode_info("4100;-125,273,81,2430,3000,4,-120,1", &decoded)); + assert(!cp0::battery::decode_info(encoded + ",extra", &decoded)); + assert(cp0::battery::decode_info(encoded + " \n", &decoded)); + assert(!cp0::battery::decode_info(encoded, nullptr)); + assert(!cp0::battery::decode_info("4100,-125,273,101,2430,3000,4,-120,1", &decoded)); + assert(!cp0::battery::decode_info("4100,-125,273,81,3001,3000,4,-120,1", &decoded)); + assert(!cp0::battery::decode_info("4100,-125,273,81,2430,3000,4,-120,2", &decoded)); + assert(!cp0::battery::decode_info("-1,-125,273,81,2430,3000,4,-120,1", &decoded)); + + const cp0_battery_info_t unavailable{}; + assert(cp0::battery::info_is_valid(unavailable)); + + const cp0_battery_info_t charging = cp0::battery::from_power_supply( + 1, 81, 4100123, 125500, 273, "Charging"); + assert(charging.valid == 1); + assert(charging.voltage_mv == 4100); + assert(charging.current_ma == 126); + assert(charging.avg_current_ma == 126); + assert(charging.temperature_c10 == 273); + assert(charging.soc == 81); + assert(charging.flags == 1); + + const cp0_battery_info_t discharging = cp0::battery::from_power_supply( + 1, 20, 3600000, -125500, 250, "Discharging"); + assert(discharging.valid == 1); + assert(discharging.current_ma == -126); + assert(discharging.flags == 0); + + assert(!cp0::battery::from_power_supply( + 0, 81, 4100000, 0, 273, "Charging").valid); + assert(!cp0::battery::from_power_supply( + 1, 101, 4100000, 0, 273, "Charging").valid); + assert(!cp0::battery::from_power_supply( + 1, 81, 4100000, 5000500, 273, "Charging").valid); + assert(!cp0::battery::from_power_supply( + 1, 81, -1, 0, 273, "Charging").valid); + assert(!cp0::battery::from_power_supply( + 1, 81, 20000001, 0, 273, "Charging").valid); + assert(!cp0::battery::from_power_supply( + 1, 81, 4100000, -5000500, 273, "Charging").valid); + assert(!cp0::battery::from_power_supply( + 1, 81, 4100000, 0, 273, "Unknown").valid); +} diff --git a/ext_components/cp0_lvgl/tests/test_battery_init_failure.cpp b/ext_components/cp0_lvgl/tests/test_battery_init_failure.cpp new file mode 100644 index 00000000..c2351578 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_battery_init_failure.cpp @@ -0,0 +1,32 @@ +#include "cp0_battery_lifecycle.hpp" + +#include +#include + +int main() +{ + int invalid_handler_releases = 0; + cp0::battery::Lifecycle invalid_handler; + assert(!invalid_handler.start( + [&] { + return cp0::battery::LifecycleResource{ + false, [&] { ++invalid_handler_releases; }}; + }, + [] { return cp0::battery::LifecycleResource{true, {}}; })); + assert(invalid_handler_releases == 1); + assert(!invalid_handler.active()); + + std::vector release_order; + cp0::battery::Lifecycle invalid_timer; + assert(!invalid_timer.start( + [&] { + return cp0::battery::LifecycleResource{ + true, [&] { release_order.push_back(2); }}; + }, + [&] { + return cp0::battery::LifecycleResource{ + false, [&] { release_order.push_back(1); }}; + })); + assert((release_order == std::vector{1, 2})); + assert(!invalid_timer.active()); +} diff --git a/ext_components/cp0_lvgl/tests/test_battery_lifecycle.cpp b/ext_components/cp0_lvgl/tests/test_battery_lifecycle.cpp new file mode 100644 index 00000000..25799cef --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_battery_lifecycle.cpp @@ -0,0 +1,91 @@ +#include "cp0_battery_lifecycle.hpp" +#include "cp0_battery_publish_gate.hpp" + +#include +#include +#include +#include +#include +#include + +int main() +{ + int handlers_created = 0; + int timers_created = 0; + std::vector release_order; + cp0::battery::Lifecycle lifecycle; + const auto handler_factory = [&] { + ++handlers_created; + return cp0::battery::LifecycleResource{ + true, [&] { release_order.push_back(2); }}; + }; + const auto timer_factory = [&] { + ++timers_created; + return cp0::battery::LifecycleResource{ + true, [&] { release_order.push_back(1); }}; + }; + + assert(lifecycle.start(handler_factory, timer_factory)); + assert(lifecycle.start(handler_factory, timer_factory)); + assert(lifecycle.active()); + assert(handlers_created == 1 && timers_created == 1); + lifecycle.stop(); + lifecycle.stop(); + assert(!lifecycle.active()); + assert((release_order == std::vector{1, 2})); + + int rollback_releases = 0; + cp0::battery::Lifecycle rollback; + assert(!rollback.start( + [&] { + return cp0::battery::LifecycleResource{ + true, [&] { ++rollback_releases; }}; + }, + [] { return cp0::battery::LifecycleResource{false, {}}; })); + assert(rollback_releases == 1 && !rollback.active()); + + cp0::battery::Lifecycle throwing; + int exception_rollbacks = 0; + assert(!throwing.start( + [&] { + return cp0::battery::LifecycleResource{ + true, [&] { ++exception_rollbacks; }}; + }, + []() -> cp0::battery::LifecycleResource { + throw std::runtime_error("timer factory"); + })); + assert(exception_rollbacks == 1 && !throwing.active()); + + cp0::battery::PublishGate gate; + int publishes = 0; + assert(gate.run([&] { + ++publishes; + assert(!gate.run([&] { ++publishes; })); + })); + assert(publishes == 1); + assert(gate.run([] { throw std::runtime_error("event callback"); })); + assert(gate.run([&] { ++publishes; })); + assert(publishes == 2); + + std::atomic concurrent_handlers{0}; + std::atomic concurrent_timers{0}; + cp0::battery::Lifecycle concurrent; + std::vector threads; + for (int index = 0; index < 12; ++index) { + threads.emplace_back([&] { + assert(concurrent.start( + [&] { + ++concurrent_handlers; + return cp0::battery::LifecycleResource{true, [] {}}; + }, + [&] { + ++concurrent_timers; + return cp0::battery::LifecycleResource{true, [] {}}; + })); + }); + } + for (auto &thread : threads) + thread.join(); + assert(concurrent_handlers.load() == 1); + assert(concurrent_timers.load() == 1); +} diff --git a/ext_components/cp0_lvgl/tests/test_battery_runtime_shutdown.cpp b/ext_components/cp0_lvgl/tests/test_battery_runtime_shutdown.cpp new file mode 100644 index 00000000..76c447a5 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_battery_runtime_shutdown.cpp @@ -0,0 +1,32 @@ +#include "cp0_battery_runtime.hpp" + +#include +#include +#include + +int main() +{ + std::vector order; + cp0::battery::shutdown_runtime( + [&] { order.push_back(1); }, + [&] { order.push_back(2); }); + assert((order == std::vector{1, 2})); + + order.clear(); + cp0::battery::shutdown_runtime( + [&] { + order.push_back(1); + throw std::runtime_error("stop battery"); + }, + [&] { order.push_back(2); }); + assert((order == std::vector{1, 2})); + + order.clear(); + cp0::battery::shutdown_runtime( + [&] { order.push_back(1); }, + [&] { + order.push_back(2); + throw std::runtime_error("deinit LVGL"); + }); + assert((order == std::vector{1, 2})); +} diff --git a/ext_components/cp0_lvgl/tests/test_battery_timer_release.cpp b/ext_components/cp0_lvgl/tests/test_battery_timer_release.cpp new file mode 100644 index 00000000..8b416561 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_battery_timer_release.cpp @@ -0,0 +1,25 @@ +#include "cp0_battery_lifecycle.hpp" + +#include +#include + +int main() +{ + int timer = 0; + int deletes = 0; + assert(cp0::battery::release_timer_if_runtime_active( + &timer, true, [&](int *handle) { + assert(handle == &timer); + ++deletes; + })); + assert(deletes == 1); + + assert(!cp0::battery::release_timer_if_runtime_active( + &timer, false, [&](int *) { ++deletes; })); + assert(!cp0::battery::release_timer_if_runtime_active( + static_cast(nullptr), true, [&](int *) { ++deletes; })); + assert(deletes == 1); + + assert(cp0::battery::release_timer_if_runtime_active( + &timer, true, [](int *) { throw std::runtime_error("delete"); })); +} diff --git a/ext_components/cp0_lvgl/tests/test_bluetooth_api_contract.cpp b/ext_components/cp0_lvgl/tests/test_bluetooth_api_contract.cpp new file mode 100644 index 00000000..eea43b3a --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_bluetooth_api_contract.cpp @@ -0,0 +1,76 @@ +#include "cp0_bluetooth_api_contract.hpp" + +#include +#include + +int main() +{ + cp0::bluetooth::Request request; + assert(cp0::bluetooth::parse_request({"BtStatus"}, request)); + assert(!cp0::bluetooth::parse_request({"BtStatus", "junk"}, request)); + assert(cp0::bluetooth::parse_request({"BtPower", "0"}, request)); + assert(request.value == 0); + assert(cp0::bluetooth::parse_request({"BtDiscoverable", "1"}, request)); + assert(request.value == 1); + assert(!cp0::bluetooth::parse_request({"BtPower"}, request)); + assert(!cp0::bluetooth::parse_request({"BtPower", "junk"}, request)); + assert(!cp0::bluetooth::parse_request({"BtPower", "-1"}, request)); + assert(!cp0::bluetooth::parse_request({"BtPower", "2"}, request)); + + assert(cp0::bluetooth::parse_request({"BtScan"}, request)); + assert(request.max_count == 16); + assert(cp0::bluetooth::parse_request({"BtList", "1"}, request)); + assert(request.max_count == 1); + assert(cp0::bluetooth::parse_request({"BtConnectedList", "16"}, request)); + assert(!cp0::bluetooth::parse_request({"BtScan", "0"}, request)); + assert(!cp0::bluetooth::parse_request({"BtScan", "-1"}, request)); + assert(!cp0::bluetooth::parse_request({"BtScan", "17"}, request)); + assert(!cp0::bluetooth::parse_request({"BtScan", "999999999999"}, request)); + assert(!cp0::bluetooth::parse_request({"BtScan", "2junk"}, request)); + + assert(cp0::bluetooth::parse_request({"BtAlias", "Cardputer"}, request)); + assert(request.text == "Cardputer"); + assert(!cp0::bluetooth::parse_request({"BtAlias"}, request)); + assert(!cp0::bluetooth::parse_request({"BtAlias", ""}, request)); + assert(!cp0::bluetooth::parse_request({"BtAlias", "bad\talias"}, request)); + assert(!cp0::bluetooth::parse_request( + {"BtAlias", std::string("bad\0alias", 9)}, request)); + assert(!cp0::bluetooth::parse_request({"BtAlias", std::string(64, 'a')}, request)); + assert(cp0::bluetooth::parse_request({"BtAlias", std::string(63, 'a')}, request)); + + const char *address = "AA:bb:01:23:45:67"; + assert(cp0::bluetooth::parse_request({"BtPair", address}, request)); + assert(cp0::bluetooth::parse_request({"BtConnect", address}, request)); + assert(cp0::bluetooth::parse_request({"BtDisconnect", address}, request)); + assert(cp0::bluetooth::parse_request({"BtRemove", address}, request)); + assert(!cp0::bluetooth::parse_request({"BtPair"}, request)); + assert(!cp0::bluetooth::parse_request({"BtPair", "not-an-address"}, request)); + assert(!cp0::bluetooth::parse_request({"BtPair", address, "junk"}, request)); + + assert(cp0::bluetooth::sanitize_wire_field("normal") == "normal"); + assert(cp0::bluetooth::sanitize_wire_field( + std::string("name\twith\rcontrols\n\0", 20)) == + std::string("name with controls ", 20)); + + int calls = 0; + cp0::bluetooth::invoke_callback([&](int code, std::string data) { + ++calls; + assert(code == 3 && data == "ok"); + throw std::runtime_error("callback"); + }, 3, "ok"); + assert(calls == 1); + + calls = 0; + cp0::bluetooth::invoke_backend([&](int code, std::string data) { + ++calls; + assert(code == -1 && data == "bluetooth backend failure"); + }, []() -> cp0::bluetooth::Reply { throw std::runtime_error("backend"); }); + assert(calls == 1); + + calls = 0; + cp0::bluetooth::invoke_backend([&](int code, std::string data) { + ++calls; + assert(code == 7 && data == "done"); + }, [] { return cp0::bluetooth::Reply{7, "done"}; }); + assert(calls == 1); +} diff --git a/ext_components/cp0_lvgl/tests/test_c_api_boundary.cpp b/ext_components/cp0_lvgl/tests/test_c_api_boundary.cpp new file mode 100644 index 00000000..a7ab55e5 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_c_api_boundary.cpp @@ -0,0 +1,24 @@ +#include "cp0_c_api_boundary.hpp" + +#include +#include + +namespace { +struct Operation { + int operator()() const { return 1; } +}; +} // namespace + +int main() +{ + static_assert(noexcept(cp0::invoke_c_api_or(0, Operation{}))); + assert(cp0::invoke_c_api_or(-1, [] { return 7; }) == 7); + assert(cp0::invoke_c_api_or(-1, []() -> int { throw std::bad_alloc(); }) == -1); + assert(cp0::invoke_c_api_or(-1, []() -> int { throw 1; }) == -1); + + const char *fallback = ""; + assert(cp0::invoke_c_api_or(fallback, []() -> const char * { + throw std::bad_alloc(); + }) == fallback); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_callback_contract.cpp b/ext_components/cp0_lvgl/tests/test_callback_contract.cpp new file mode 100644 index 00000000..21188233 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_callback_contract.cpp @@ -0,0 +1,50 @@ +#include "cp0_callback_contract.hpp" + +#include +#include +#include +#include + +namespace { + +struct CopyThrows +{ + explicit CopyThrows(int &calls) : calls(calls) {} + CopyThrows(const CopyThrows &other) : calls(other.calls) + { + throw std::runtime_error("copy"); + } + void operator()() const { ++calls; } + + int &calls; +}; + +} // namespace + +int main() +{ + int direct_calls = 0; + cp0::callback::invoke_direct([&] { ++direct_calls; }); + assert(direct_calls == 1); + + CopyThrows no_copy(direct_calls); + cp0::callback::invoke_direct(no_copy); + assert(direct_calls == 2); + + cp0::callback::invoke_direct([] { throw std::runtime_error("delete callback"); }); + std::function empty; + cp0::callback::invoke_direct(empty); + + int result_code = -1; + std::string result_payload; + cp0::callback::invoke([&](int code, std::string payload) { + result_code = code; + result_payload = std::move(payload); + }, 7, "ready"); + assert(result_code == 7); + assert(result_payload == "ready"); + + cp0::callback::invoke([](int, std::string) { + throw std::runtime_error("result callback"); + }, 0, "ignored"); +} diff --git a/ext_components/cp0_lvgl/tests/test_callback_owner_contract.cpp b/ext_components/cp0_lvgl/tests/test_callback_owner_contract.cpp new file mode 100644 index 00000000..72d7495d --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_callback_owner_contract.cpp @@ -0,0 +1,30 @@ +#include "cp0_callback_contract.hpp" + +#include +#include + +namespace { + +struct Owner +{ + int updates = 0; +}; + +} // namespace + +int main() +{ + Owner owner; + cp0::callback::invoke_if_present( + &owner, [](Owner ¤t) { ++current.updates; }); + assert(owner.updates == 1); + + cp0::callback::invoke_if_present( + static_cast(nullptr), + [](Owner &) { throw std::runtime_error("must not run"); }); + assert(owner.updates == 1); + + cp0::callback::invoke_if_present( + &owner, [](Owner &) { throw std::runtime_error("status update"); }); + assert(owner.updates == 1); +} diff --git a/ext_components/cp0_lvgl/tests/test_callback_result.cpp b/ext_components/cp0_lvgl/tests/test_callback_result.cpp new file mode 100644 index 00000000..561f5c0f --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_callback_result.cpp @@ -0,0 +1,53 @@ +#include "cp0_callback_result.hpp" + +#include +#include +#include +#include + +int main() +{ + int calls = 0; + int code = 0; + std::string data; + cp0::CallbackResult result([&](int value, std::string text) { + ++calls; + code = value; + data = std::move(text); + }); + assert(!result.completed()); + assert(result.complete(7, "ready")); + assert(result.completed()); + assert(calls == 1 && code == 7 && data == "ready"); + assert(!result.complete(-1, "duplicate")); + assert(calls == 1 && code == 7 && data == "ready"); + + cp0::CallbackResult throwing([](int, std::string) { + throw std::runtime_error("callback failure"); + }); + assert(throwing.complete(0, "ok")); + assert(throwing.completed()); + assert(!throwing.complete(-1, "retry")); + + cp0::CallbackResult empty({}); + assert(empty.complete(0, "ok")); + assert(empty.completed()); + + calls = 0; + cp0::CallbackResult failed_operation([&](int value, std::string text) { + ++calls; + code = value; + data = std::move(text); + }); + failed_operation.guard(-1, "api failure", [] { + throw std::runtime_error("backend failure"); + }); + assert(calls == 1 && code == -1 && data == "api failure"); + + cp0::CallbackResult completed_operation([&](int, std::string) { ++calls; }); + completed_operation.guard(-1, "must not retry", [&] { + completed_operation.complete(0, "ok"); + throw std::runtime_error("after completion"); + }); + assert(calls == 2); +} diff --git a/ext_components/cp0_lvgl/tests/test_camera_api_contract.cpp b/ext_components/cp0_lvgl/tests/test_camera_api_contract.cpp new file mode 100644 index 00000000..a6655ff6 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_camera_api_contract.cpp @@ -0,0 +1,23 @@ +#include "cp0_camera_api_contract.hpp" + +#include +#include + +int main() +{ + using cp0_camera_api::parse_integer_argument; + + assert(cp0_camera_api::DEFAULT_WIDTH == 320); + assert(cp0_camera_api::DEFAULT_HEIGHT == 150); + assert(parse_integer_argument("42", 7) == 42); + assert(parse_integer_argument("-3", 7) == -3); + assert(parse_integer_argument("", 7) == 7); + assert(parse_integer_argument("12px", 7) == 7); + assert(parse_integer_argument(" 12", 7) == 12); + assert(parse_integer_argument("\t+12", 7) == 12); + assert(parse_integer_argument("12 ", 7) == 7); + assert(parse_integer_argument(" ", 7) == 7); + assert(parse_integer_argument("999999999999999999999", 7) == 7); + assert(parse_integer_argument(std::to_string(INT_MAX), 7) == INT_MAX); + assert(parse_integer_argument(std::to_string(INT_MIN), 7) == INT_MIN); +} diff --git a/ext_components/cp0_lvgl/tests/test_camera_viewport.cpp b/ext_components/cp0_lvgl/tests/test_camera_viewport.cpp new file mode 100644 index 00000000..1450a58b --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_camera_viewport.cpp @@ -0,0 +1,58 @@ +#include "cp0_camera_viewport.hpp" + +#include +#include + +using cp0_camera::CropRectangle; +using cp0_camera::Viewport; + +int main() +{ + Viewport viewport; + assert(viewport.zoom_percent() == 100); + assert(viewport.view_x_percent() == 50); + assert(viewport.view_y_percent() == 50); + assert(viewport.status_text() == "ZOOM 100 50 50\n"); + + const CropRectangle bounds{10, 20, 1000, 800}; + CropRectangle crop = viewport.crop(bounds); + assert(crop.x == 10 && crop.y == 20 && crop.width == 1000 && crop.height == 800); + + viewport.pan(2, -2); + assert(viewport.view_x_percent() == 50); + assert(viewport.view_y_percent() == 50); + + viewport.zoom_in(); + assert(viewport.zoom_percent() == 250); + crop = viewport.crop(bounds); + assert(crop.x == 310 && crop.y == 260 && crop.width == 400 && crop.height == 320); + + viewport.pan(INT_MAX, INT_MIN); + assert(viewport.view_x_percent() == 100); + assert(viewport.view_y_percent() == 0); + crop = viewport.crop(bounds); + assert(crop.x == 610 && crop.y == 20); + + viewport.zoom_in(); + assert(viewport.zoom_percent() == 500); + crop = viewport.crop(bounds); + assert(crop.x == 810 && crop.y == 20 && crop.width == 200 && crop.height == 160); + + viewport.zoom_out(); + assert(viewport.zoom_percent() == 250); + assert(viewport.view_x_percent() == 100); + assert(viewport.view_y_percent() == 0); + viewport.zoom_out(); + assert(viewport.zoom_percent() == 100); + assert(viewport.view_x_percent() == 50); + assert(viewport.view_y_percent() == 50); + + const CropRectangle invalid{3, 4, 0, -1}; + crop = viewport.crop(invalid); + assert(crop.x == 3 && crop.y == 4 && crop.width == 0 && crop.height == -1); + + viewport.zoom_in(); + crop = viewport.crop({0, 0, INT_MAX, INT_MAX}); + assert(crop.width == INT_MAX * 2LL / 5); + assert(crop.x == (INT_MAX - crop.width) / 2); +} diff --git a/ext_components/cp0_lvgl/tests/test_component_delete_callback.cpp b/ext_components/cp0_lvgl/tests/test_component_delete_callback.cpp new file mode 100644 index 00000000..b27ca2ae --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_component_delete_callback.cpp @@ -0,0 +1,27 @@ +#include "cp0_status_component_contract.hpp" + +#include +#include + +int main() +{ + bool active = true; + bool attached = true; + cp0::status::release_before_destroy( + [&] { active = false; }, + [&] { attached = false; }); + assert(!active && !attached); + + active = true; + attached = true; + cp0::status::release_before_destroy( + [&] { + active = false; + throw std::runtime_error("deactivate"); + }, + [&] { attached = false; }); + assert(!active && !attached); + + bool retried_mount = !attached; + assert(retried_mount); +} diff --git a/ext_components/cp0_lvgl/tests/test_config_service.cpp b/ext_components/cp0_lvgl/tests/test_config_service.cpp new file mode 100644 index 00000000..1c380aab --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_config_service.cpp @@ -0,0 +1,162 @@ +#include "cp0_config_json.h" +#include "cp0_config_service.hpp" + +#include +#include +#include +#include + +namespace { + +std::pair call(cp0cfg::ConfigService &service, + cp0cfg::ConfigService::Arguments arguments) +{ + std::pair result{-99, "missing callback"}; + service.api_call(std::move(arguments), [&](int code, std::string data) { + result = {code, std::move(data)}; + }); + return result; +} + +} // namespace + +int main() +{ + assert(cp0cfg::parse_legacy_integer("", 7) == 7); + assert(cp0cfg::parse_legacy_integer("garbage", 7) == 0); + assert(cp0cfg::parse_legacy_integer("12tail", 7) == 12); + assert(cp0cfg::parse_legacy_integer(" \t+12tail", 7) == 12); + assert(cp0cfg::parse_legacy_integer("999999999999999999999", 0) == INT_MAX); + assert(cp0cfg::parse_legacy_integer("-999999999999999999999", 0) == INT_MIN); + + cp0cfg::Entries persisted{{"count", "4"}, {"empty", ""}}; + cp0cfg::Entries saved; + int save_result = 0; + cp0cfg::ConfigService service( + [&](cp0cfg::Entries &entries) { + entries = persisted; + return true; + }, + [&](const cp0cfg::Entries &entries) { + saved = entries; + return save_result; + }); + + assert(call(service, {"GetInt", "count", "9"}) == std::make_pair(0, std::string("4"))); + assert(call(service, {"GetInt", "missing", "9"}) == std::make_pair(0, std::string("9"))); + assert(call(service, {"GetInt", "empty", "9"}) == std::make_pair(0, std::string("0"))); + assert(call(service, {"SetInt", "count", "17junk"}).first == 0); + assert(call(service, {"SetStr", "label", "hello"}).first == 0); + assert(call(service, {"GetStr", "label", "fallback"}).second == "hello"); + bool reentered = false; + service.api_call({"GetStr", "label", "fallback"}, [&](int code, std::string) { + assert(code == 0); + reentered = call(service, {"GetInt", "count", "0"}).second == "17"; + }); + assert(reentered); + assert(call(service, {"Save"}) == std::make_pair(0, std::string("ok"))); + assert(saved.size() == 3); + assert(saved[0] == std::make_pair(std::string("count"), std::string("17"))); + + save_result = -1; + assert(call(service, {"Save"}) == std::make_pair(-1, std::string("save failed"))); + assert(call(service, {}).first == -1); + assert(call(service, {"Unknown"}).first == -1); + + persisted = {{"reloaded", "yes"}}; + assert(call(service, {"Init"}).first == 0); + assert(call(service, {"Init"}).first == 0); + assert(call(service, {"GetStr", "reloaded", "no"}).second == "yes"); + assert(call(service, {"GetStr", "label", "gone"}).second == "gone"); + + bool load_ok = false; + persisted = {{"must", "survive"}}; + assert(call(service, {"Init"}).first == 0); + cp0cfg::ConfigService *transactional_ptr = nullptr; + cp0cfg::ConfigService transactional( + [&](cp0cfg::Entries &entries) { + if (!load_ok) return false; + assert(transactional_ptr); + assert(call(*transactional_ptr, {"GetStr", "retained", "no"}).second == "yes"); + entries = {{"replacement", "yes"}}; + return true; + }, + [&](const cp0cfg::Entries &) { + assert(transactional_ptr); + assert(call(*transactional_ptr, {"GetStr", "replacement", "missing"}).second == "yes"); + return 0; + }); + transactional_ptr = &transactional; + assert(call(transactional, {"SetStr", "retained", "yes"}).first == 0); + assert(call(transactional, {"Init"}) == + std::make_pair(-1, std::string("load failed"))); + assert(call(transactional, {"GetStr", "retained", "no"}).second == "yes"); + load_ok = true; + assert(call(transactional, {"Init"}).first == 0); + assert(call(transactional, {"GetStr", "replacement", "no"}).second == "yes"); + assert(call(transactional, {"Save"}).first == 0); + + cp0cfg::ConfigService throwing( + [](cp0cfg::Entries &) -> bool { throw std::runtime_error("load"); }, + [](const cp0cfg::Entries &) -> int { throw std::runtime_error("save"); }); + assert(call(throwing, {"Init"}).first == -1); + assert(call(throwing, {"Save"}).first == -1); + throwing.api_call({"GetStr", "missing", "ok"}, + [](int, std::string) { throw std::runtime_error("callback"); }); + save_result = 0; + assert(call(service, {"Save"}).first == 0); + assert(saved == persisted); + + const std::string oversized_value(300, 'v'); + assert(call(service, {"SetStr", "bounded", oversized_value}).first == 0); + assert(call(service, {"GetStr", "bounded", ""}).second == std::string(255, 'v')); + + cp0cfg::Entries parsed{{"unchanged", "value"}}; + assert(!cp0cfg::from_json("{\"valid\": 1} trailing", parsed)); + assert(parsed == cp0cfg::Entries({{"unchanged", "value"}})); + assert(!cp0cfg::from_json("{\"valid\": 1, \"broken\":", parsed)); + assert(parsed == cp0cfg::Entries({{"unchanged", "value"}})); + assert(cp0cfg::from_json("{\"nested\": {\"value\": \"ok\"}}\n", parsed)); + assert(parsed == cp0cfg::Entries({{"nested.value", "ok"}})); + + const cp0cfg::Entries unchanged{{"unchanged", "value"}}; + const char *invalid_json[] = { + "{\"n\": 01}", + "{\"n\": 12tail}", + "{\"n\": 9223372036854775808}", + "{\"n\": -9223372036854775809}", + "{\"n\": 1.}", + "{\"n\": 1e}", + "{\"s\": \"bad\\q\"}", + "{\"s\": \"bad\\u12x4\"}", + "{\"s\": \"bad\\ud800\"}", + "{\"a\": [true false]}", + "{\"a\": [1,]}", + }; + for (const char *json : invalid_json) { + parsed = unchanged; + assert(!cp0cfg::from_json(json, parsed)); + assert(parsed == unchanged); + } + + assert(!cp0cfg::looks_numeric("9223372036854775808")); + assert(!cp0cfg::looks_numeric("12tail")); + assert(cp0cfg::looks_numeric("-9223372036854775808")); + assert(!cp0cfg::looks_numeric("007")); + + const cp0cfg::Entries round_trip{ + {"identity.leading_zero", "007"}, + {"identity.text", std::string("line one\nline two") + '\x01'}, + {"limits.maximum", "9223372036854775807"}, + {"limits.minimum", "-9223372036854775808"}, + }; + const std::string encoded = cp0cfg::to_json(round_trip); + cp0cfg::Entries decoded; + assert(cp0cfg::from_json(encoded, decoded)); + assert(decoded == round_trip); + assert(cp0cfg::to_json(decoded) == encoded); + assert(encoded.find("\\u0001") != std::string::npos); + + assert(cp0cfg::from_json("{\"unicode\": \"\\u4f60\\u597d \\ud83d\\ude00\"}", decoded)); + assert(decoded == cp0cfg::Entries({{"unicode", "\xe4\xbd\xa0\xe5\xa5\xbd \xf0\x9f\x98\x80"}})); +} diff --git a/ext_components/cp0_lvgl/tests/test_desktop_exec_policy.cpp b/ext_components/cp0_lvgl/tests/test_desktop_exec_policy.cpp new file mode 100644 index 00000000..0b01fb39 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_desktop_exec_policy.cpp @@ -0,0 +1,39 @@ +#include "../src/cp0/cp0_desktop_exec_policy.hpp" + +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#endif + +int main() +{ + using cp0_desktop_exec_policy::evaluate; + + assert(!evaluate(nullptr).allowed); + assert(evaluate(nullptr).reason == "empty Exec"); + assert(!evaluate("").allowed); + assert(evaluate("python3 app.py").allowed); + assert(evaluate("vim /tmp/file").allowed); + assert(!evaluate("unknown-app --flag").allowed); + assert(evaluate("unknown-app --flag").reason == "executable name is not allowlisted"); + assert(!evaluate("python3 app.py; reboot").allowed); + assert(evaluate("python3 app.py; reboot").reason == "Exec contains shell metacharacters"); + assert(!evaluate(std::string(513, 'a').c_str()).allowed); + +#if !defined(_WIN32) + char path[] = "/tmp/cp0-exec-policy-XXXXXX"; + const int fd = mkstemp(path); + assert(fd >= 0); + close(fd); + assert(!evaluate(path).allowed); + assert(chmod(path, 0700) == 0); + assert(evaluate(path).allowed); + unlink(path); +#endif + + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_dispatch_fallback.cpp b/ext_components/cp0_lvgl/tests/test_dispatch_fallback.cpp new file mode 100644 index 00000000..2ec2cb22 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_dispatch_fallback.cpp @@ -0,0 +1,22 @@ +#include "cp0_dispatch_testable.hpp" + +#include +#include + +int main() +{ + int fallbacks = 0; + assert(!cp0_testable::run_on_dispatch_failure( + true, [&] { ++fallbacks; })); + assert(fallbacks == 0); + + assert(cp0_testable::run_on_dispatch_failure( + false, [&] { ++fallbacks; })); + assert(fallbacks == 1); + + assert(cp0_testable::run_on_dispatch_failure(false, [&] { + ++fallbacks; + throw std::runtime_error("fallback"); + })); + assert(fallbacks == 2); +} diff --git a/ext_components/cp0_lvgl/tests/test_dispatch_shutdown_gate.cpp b/ext_components/cp0_lvgl/tests/test_dispatch_shutdown_gate.cpp new file mode 100644 index 00000000..b5de37bb --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_dispatch_shutdown_gate.cpp @@ -0,0 +1,79 @@ +#include "cp0_dispatch_shutdown_gate.hpp" + +#include +#include +#include +#include +#include + +int main() +{ + cp0::dispatch::ShutdownGate gate; + const uint64_t first_generation = gate.generation(); + + auto failed_enqueue = gate.reserve(); + assert(failed_enqueue && gate.pending() == 1); + failed_enqueue->release(); + assert(gate.pending() == 0); + + auto old_callback = gate.reserve(); + assert(old_callback); + assert(gate.begin_shutdown() == first_generation + 1); + assert(!gate.accepting()); + assert(!gate.reserve()); + { + auto lease = gate.acquire(std::move(*old_callback)); + assert(!lease); + assert(gate.pending() == 1); + } + gate.wait_for_drain(); + assert(gate.pending() == 0); + + gate.resume(); + auto current_callback = gate.reserve(); + assert(current_callback); + { + auto lease = gate.acquire(std::move(*current_callback)); + assert(lease); + } + assert(gate.pending() == 0); + + constexpr int kCallbacks = 32; + std::vector reservations; + reservations.reserve(kCallbacks); + for (int index = 0; index < kCallbacks; ++index) { + auto reservation = gate.reserve(); + assert(reservation); + reservations.push_back(std::move(*reservation)); + } + + std::atomic release_callbacks{false}; + std::atomic acquired{0}; + std::vector workers; + for (auto &reservation : reservations) { + workers.emplace_back([&gate, &release_callbacks, &acquired, + reservation = std::move(reservation)]() mutable { + auto lease = gate.acquire(std::move(reservation)); + if (lease) ++acquired; + while (!release_callbacks.load()) std::this_thread::yield(); + }); + } + while (acquired.load() != kCallbacks) std::this_thread::yield(); + + std::atomic drained{false}; + std::thread shutdown([&] { + gate.begin_shutdown(); + gate.wait_for_drain(); + drained.store(true); + }); + while (gate.accepting()) std::this_thread::yield(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + assert(!drained.load()); + assert(!gate.reserve()); + + release_callbacks.store(true); + for (auto &worker : workers) worker.join(); + shutdown.join(); + assert(drained.load()); + assert(gate.pending() == 0); +} diff --git a/ext_components/cp0_lvgl/tests/test_display_screenshot_contract.cpp b/ext_components/cp0_lvgl/tests/test_display_screenshot_contract.cpp new file mode 100644 index 00000000..b4cc61d1 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_display_screenshot_contract.cpp @@ -0,0 +1,72 @@ +#include "cp0_display_screenshot_contract.hpp" + +#include +#include +#include + +namespace { + +void test_display_dimensions() +{ + assert(cp0_display_dimension_or_default(nullptr, 320) == 320); + assert(cp0_display_dimension_or_default("", 320) == 320); + assert(cp0_display_dimension_or_default("1", 320) == 1); + assert(cp0_display_dimension_or_default("16384", 320) == 16384); + assert(cp0_display_dimension_or_default("0", 320) == 320); + assert(cp0_display_dimension_or_default("-1", 320) == 320); + assert(cp0_display_dimension_or_default("16385", 320) == 320); + assert(cp0_display_dimension_or_default("320junk", 170) == 170); + assert(cp0_display_dimension_or_default("999999999999999999", 170) == 170); +} + +void test_request_and_output_path() +{ + cp0::screenshot::Request request; + assert(!cp0::screenshot::parse_request({}, request)); + assert(!cp0::screenshot::parse_request({"Save"}, request)); + assert(!cp0::screenshot::parse_request({"Save", ""}, request)); + assert(!cp0::screenshot::parse_request({"Save", "/tmp", "junk"}, request)); + assert(!cp0::screenshot::parse_request({"Other", "/tmp"}, request)); + assert(cp0::screenshot::parse_request({"Save", "/tmp/screens"}, request)); + assert(request.directory == "/tmp/screens"); + assert(!cp0::screenshot::parse_request({"Save", std::string(512, 'x')}, request)); + + const cp0::screenshot::Timestamp timestamp{2026, 7, 22, 9, 8, 7}; + std::string path; + assert(cp0::screenshot::make_output_path("/tmp/screens", timestamp, false, path)); + assert(path == "/tmp/screens/scr_20260722_090807.bmp"); + assert(cp0::screenshot::make_output_path("/tmp/screens", timestamp, true, path)); + assert(path == "/tmp/screens/scr_20260722_090807_sdl.bmp"); + assert(!cp0::screenshot::make_output_path("", timestamp, false, path)); + assert(!cp0::screenshot::make_output_path("/tmp", {2026, 13, 1, 0, 0, 0}, false, path)); + assert(!cp0::screenshot::make_output_path(std::string(500, 'x'), timestamp, false, path)); +} + +void test_framebuffer_layout() +{ + cp0::screenshot::FramebufferLayout layout; + assert(cp0::screenshot::framebuffer_layout(320, 170, 16, 640, layout)); + assert(layout.mapped_size == 108800); + assert(layout.bmp_row_size == 960); + assert(layout.bmp_image_size == 163200); + assert(layout.bmp_file_size == 163254); + + assert(cp0::screenshot::framebuffer_layout(1, 1, 32, 4, layout)); + assert(layout.mapped_size == 4 && layout.bmp_row_size == 4); + assert(!cp0::screenshot::framebuffer_layout(0, 170, 16, 640, layout)); + assert(!cp0::screenshot::framebuffer_layout(320, -1, 16, 640, layout)); + assert(!cp0::screenshot::framebuffer_layout(320, 170, 24, 960, layout)); + assert(!cp0::screenshot::framebuffer_layout(320, 170, 16, 639, layout)); + assert(!cp0::screenshot::framebuffer_layout(16385, 1, 32, 65540, layout)); + assert(!cp0::screenshot::valid_dimensions(1, 0)); + assert(cp0::screenshot::valid_dimensions(16384, 16384)); +} + +} // namespace + +int main() +{ + test_display_dimensions(); + test_request_and_output_path(); + test_framebuffer_layout(); +} diff --git a/ext_components/cp0_lvgl/tests/test_font_cache_policy.cpp b/ext_components/cp0_lvgl/tests/test_font_cache_policy.cpp new file mode 100644 index 00000000..9ff19e3c --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_font_cache_policy.cpp @@ -0,0 +1,10 @@ +#include "cp0_font_cache_policy.hpp" + +#include + +int main() +{ + int font = 0; + assert(cp0::font::should_cache(&font)); + assert(!cp0::font::should_cache(static_cast(nullptr))); +} diff --git a/ext_components/cp0_lvgl/tests/test_framebuffer_codec.cpp b/ext_components/cp0_lvgl/tests/test_framebuffer_codec.cpp new file mode 100644 index 00000000..6da3bbff --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_framebuffer_codec.cpp @@ -0,0 +1,85 @@ +#include "cp0_framebuffer_codec.hpp" + +#include +#include +#include +#include + +namespace { + +using cp0::framebuffer::Layout; + +std::vector pixels(const std::vector &ppm) +{ + const std::string header = "P6\n2 1\n255\n"; + assert(ppm.size() >= header.size()); + assert(std::string(ppm.begin(), ppm.begin() + header.size()) == header); + return {ppm.begin() + header.size(), ppm.end()}; +} + +void test_rgb565_with_virtual_offset() +{ + const std::vector framebuffer = { + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0x00, 0xf8, 0xe0, 0x07, 0, 0, + }; + const Layout layout {2, 1, 1, 1, 16, 8, {}, {}, {}}; + std::vector ppm; + std::string error; + assert(cp0::framebuffer::encode_ppm(framebuffer.data(), framebuffer.size(), layout, + ppm, error)); + assert((pixels(ppm) == std::vector {255, 0, 0, 0, 255, 0})); + assert(error.empty()); +} + +void test_32_bit_channel_layout() +{ + const std::vector framebuffer = { + 0x00, 0x00, 0xff, 0x00, + 0xff, 0x00, 0x00, 0x00, + }; + const Layout layout {2, 1, 0, 0, 32, 8, {16, 8}, {8, 8}, {0, 8}}; + std::vector ppm; + std::string error; + assert(cp0::framebuffer::encode_ppm(framebuffer.data(), framebuffer.size(), layout, + ppm, error)); + assert((pixels(ppm) == std::vector {255, 0, 0, 0, 0, 255})); +} + +void test_rejects_invalid_layout_and_bounds() +{ + const std::vector framebuffer(8); + std::vector ppm {1, 2, 3}; + std::string error; + + assert(!cp0::framebuffer::encode_ppm(framebuffer.data(), framebuffer.size(), + Layout {0, 1, 0, 0, 16, 8, {}, {}, {}}, + ppm, error)); + assert(ppm.empty()); + assert(error == "unsupported framebuffer format"); + + assert(!cp0::framebuffer::encode_ppm(framebuffer.data(), framebuffer.size(), + Layout {2, 1, 1, 0, 16, 4, {}, {}, {}}, + ppm, error)); + assert(error == "framebuffer bounds mismatch"); + + assert(!cp0::framebuffer::encode_ppm(framebuffer.data(), framebuffer.size(), + Layout {2, 1, 0, 2, 16, 4, {}, {}, {}}, + ppm, error)); + assert(error == "framebuffer bounds mismatch"); + + assert(!cp0::framebuffer::encode_ppm( + framebuffer.data(), framebuffer.size(), + Layout {1, UINT32_MAX, 0, UINT32_MAX, 16, 2, {}, {}, {}}, ppm, error)); + assert(error == "framebuffer bounds mismatch"); +} + +} // namespace + +int main() +{ + test_rgb565_with_virtual_offset(); + test_32_bit_channel_layout(); + test_rejects_invalid_layout_and_bounds(); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_imu_codec.cpp b/ext_components/cp0_lvgl/tests/test_imu_codec.cpp new file mode 100644 index 00000000..0cbb3075 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_imu_codec.cpp @@ -0,0 +1,76 @@ +#include "cp0_imu_codec.hpp" +#include "cp0_callback_contract.hpp" + +#include +#include +#include +#include + +namespace { + +cp0_compass_info_t sample_info() +{ + cp0_compass_info_t info{}; + std::strcpy(info.status, "Sensor OK"); + info.yaw = 123.5f; + info.pitch = -4.25f; + info.roll = 8.0f; + info.acc_x = 1.0f; + info.acc_y = 2.0f; + info.acc_z = 3.0f; + info.gyr_x = 4.0f; + info.gyr_y = 5.0f; + info.gyr_z = 6.0f; + info.mag_x = 7.0f; + info.mag_y = 8.0f; + info.mag_z = 9.0f; + info.sensor_ready = 1; + return info; +} + +void test_round_trip_preserves_binary_payload() +{ + const cp0_compass_info_t expected = sample_info(); + const std::string payload = cp0::imu::encode_info(expected); + assert(payload.size() == sizeof(expected)); + + cp0_compass_info_t actual{}; + assert(cp0::imu::decode_info(payload, actual)); + assert(std::memcmp(&actual, &expected, sizeof(expected)) == 0); +} + +void test_decode_rejects_wrong_size_without_mutating_output() +{ + const cp0_compass_info_t unchanged = sample_info(); + cp0_compass_info_t output = unchanged; + + assert(!cp0::imu::decode_info({}, output)); + assert(std::memcmp(&output, &unchanged, sizeof(output)) == 0); + + const std::string payload = cp0::imu::encode_info(unchanged); + assert(!cp0::imu::decode_info(payload.substr(0, payload.size() - 1), output)); + assert(std::memcmp(&output, &unchanged, sizeof(output)) == 0); + + assert(!cp0::imu::decode_info(payload + '\0', output)); + assert(std::memcmp(&output, &unchanged, sizeof(output)) == 0); +} + +} // namespace + +int main() +{ + int callback_calls = 0; + cp0::callback::invoke_direct([&](int value) { + ++callback_calls; + assert(value == 7); + throw std::runtime_error("callback"); + }, 7); + assert(callback_calls == 1); + void (*empty_callback)(int) = nullptr; + cp0::callback::invoke_direct(empty_callback, 7); + assert(callback_calls == 1); + + test_round_trip_preserves_binary_payload(); + test_decode_rejects_wrong_size_without_mutating_output(); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_imu_worker_contract.cpp b/ext_components/cp0_lvgl/tests/test_imu_worker_contract.cpp new file mode 100644 index 00000000..66070503 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_imu_worker_contract.cpp @@ -0,0 +1,58 @@ +#include "cp0_imu_worker_contract.hpp" + +#include +#include +#include +#include +#include + +int main() +{ + std::mutex lifecycle_mutex; + bool report_reentered = false; + const auto outcome = cp0::imu::prepare_then_report( + [&] { + std::lock_guard lock(lifecycle_mutex); + return cp0::imu::StartOutcome::AlreadyRunning; + }, + [&](cp0::imu::StartOutcome reported) { + assert(reported == cp0::imu::StartOutcome::AlreadyRunning); + assert(lifecycle_mutex.try_lock()); + report_reentered = true; + lifecycle_mutex.unlock(); + }); + assert(outcome == cp0::imu::StartOutcome::AlreadyRunning); + assert(report_reentered); + + bool failure_reported = false; + assert(cp0::imu::prepare_then_report( + []() -> cp0::imu::StartOutcome { throw 1; }, + [&](cp0::imu::StartOutcome reported) { + failure_reported = + reported == cp0::imu::StartOutcome::ThreadCreationFailed; + throw 2; + }) == cp0::imu::StartOutcome::ThreadCreationFailed); + assert(failure_reported); + + std::atomic completed{false}; + std::thread natural([&] { completed.store(true); }); + while (!completed.load()) std::this_thread::yield(); + assert(cp0::imu::reap_completed_worker(natural, false)); + assert(!natural.joinable()); + + std::atomic cancelled{false}; + std::mutex mutex; + std::condition_variable wake; + std::thread blocked([&] { + std::unique_lock lock(mutex); + wake.wait(lock, [&] { return cancelled.load(); }); + }); + assert(cp0::imu::cancel_and_join_worker( + blocked, + [&] { cancelled.store(true); }, + [&] { wake.notify_all(); })); + assert(!blocked.joinable()); + + std::thread none; + assert(!cp0::imu::cancel_and_join_worker(none, [] {}, [] {})); +} diff --git a/ext_components/cp0_lvgl/tests/test_init_once.cpp b/ext_components/cp0_lvgl/tests/test_init_once.cpp new file mode 100644 index 00000000..d31bbcbd --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_init_once.cpp @@ -0,0 +1,69 @@ +#include "cp0_init_once.hpp" + +#include +#include +#include +#include +#include + +int main() +{ + cp0::InitOnce retryable; + int attempts = 0; + assert(!retryable.run([&] { ++attempts; return false; })); + assert(retryable.run([&] { ++attempts; return true; })); + assert(retryable.run([&] { ++attempts; return true; })); + assert(attempts == 2); + + cp0::InitOnce asynchronous; + std::atomic asynchronous_attempts{0}; + auto asynchronous_result = [&](bool success) { + return asynchronous.run([&] { + ++asynchronous_attempts; + std::promise ready; + auto readiness = ready.get_future(); + std::thread([ready = std::move(ready), success]() mutable { + ready.set_value(success); + }).detach(); + return readiness.get(); + }); + }; + assert(!asynchronous_result(false)); + assert(asynchronous_result(true)); + assert(asynchronous_result(true)); + assert(asynchronous_attempts.load() == 2); + + cp0::InitOnce concurrent; + std::atomic initializations{0}; + std::vector threads; + for (int index = 0; index < 16; ++index) { + threads.emplace_back([&] { + assert(concurrent.run([&] { ++initializations; return true; })); + }); + } + for (auto &thread : threads) thread.join(); + assert(initializations == 1); + + cp0::InitOnce concurrent_retry; + std::atomic retry_attempts{0}; + std::atomic successful_callers{0}; + threads.clear(); + for (int index = 0; index < 16; ++index) { + threads.emplace_back([&] { + if (concurrent_retry.run([&] { + const bool success = retry_attempts.fetch_add(1) != 0; + std::promise ready; + auto readiness = ready.get_future(); + std::thread([ready = std::move(ready), success]() mutable { + ready.set_value(success); + }).detach(); + return readiness.get(); + })) { + ++successful_callers; + } + }); + } + for (auto &thread : threads) thread.join(); + assert(retry_attempts.load() == 2); + assert(successful_callers.load() == 15); +} diff --git a/ext_components/cp0_lvgl/tests/test_init_plan.cpp b/ext_components/cp0_lvgl/tests/test_init_plan.cpp new file mode 100644 index 00000000..46fbba64 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_init_plan.cpp @@ -0,0 +1,83 @@ +#include "cp0_init_plan.h" + +#include +#include +#include +#include +#include + +namespace { + +std::vector build(std::uint64_t features) +{ + std::array storage{}; + const std::size_t count = cp0_build_init_plan(features, storage.data(), storage.size()); + assert(count <= storage.size()); + return {storage.begin(), storage.begin() + static_cast(count)}; +} + +std::size_t position(const std::vector &plan, cp0_init_step_t step) +{ + const auto found = std::find(plan.begin(), plan.end(), step); + assert(found != plan.end()); + return static_cast(found - plan.begin()); +} + +void test_core_plan() +{ + const auto plan = build(0); + assert((plan == std::vector{ + CP0_INIT_STEP_ENV, + CP0_INIT_STEP_EVENT, + CP0_INIT_STEP_DISPLAY, + CP0_INIT_STEP_INPUT, + })); +} + +void test_feature_order() +{ + const std::uint64_t all_features = + (CP0_INIT_FEATURE_SOUNDCARD << 1) - 1; + const auto plan = build(all_features); + assert(plan.size() == static_cast(CP0_INIT_STEP_SOUNDCARD + 1)); + assert(plan.front() == CP0_INIT_STEP_ENV); + assert(plan.back() == CP0_INIT_STEP_SOUNDCARD); + assert(position(plan, CP0_INIT_STEP_EVENT) < position(plan, CP0_INIT_STEP_FILESYSTEM)); + assert(position(plan, CP0_INIT_STEP_PTY) < position(plan, CP0_INIT_STEP_DISPLAY)); + assert(position(plan, CP0_INIT_STEP_DISPLAY) < position(plan, CP0_INIT_STEP_INPUT)); + assert(position(plan, CP0_INIT_STEP_INPUT) < position(plan, CP0_INIT_STEP_RPC)); + assert(position(plan, CP0_INIT_STEP_RPC) < position(plan, CP0_INIT_STEP_AUDIO)); + assert(position(plan, CP0_INIT_STEP_SCREENSHOT) < position(plan, CP0_INIT_STEP_LORA)); + assert(position(plan, CP0_INIT_STEP_IMU) < position(plan, CP0_INIT_STEP_SAVED_SETTINGS)); + assert(position(plan, CP0_INIT_STEP_BATTERY) < position(plan, CP0_INIT_STEP_CAMERA)); +} + +void test_selected_features_and_capacity() +{ + const auto plan = build(CP0_INIT_FEATURE_CONFIG | CP0_INIT_FEATURE_WIFI | + CP0_INIT_FEATURE_SCREENSHOT); + assert((plan == std::vector{ + CP0_INIT_STEP_ENV, + CP0_INIT_STEP_EVENT, + CP0_INIT_STEP_CONFIG, + CP0_INIT_STEP_DISPLAY, + CP0_INIT_STEP_INPUT, + CP0_INIT_STEP_SCREENSHOT, + CP0_INIT_STEP_WIFI, + })); + + cp0_init_step_t first_two[2]{}; + const std::size_t count = cp0_build_init_plan(CP0_INIT_FEATURE_AUDIO, first_two, 2); + assert(count == 5); + assert(first_two[0] == CP0_INIT_STEP_ENV && first_two[1] == CP0_INIT_STEP_EVENT); + assert(cp0_build_init_plan(CP0_INIT_FEATURE_AUDIO, nullptr, 0) == 5); +} + +} // namespace + +int main() +{ + test_core_plan(); + test_feature_order(); + test_selected_features_and_capacity(); +} diff --git a/ext_components/cp0_lvgl/tests/test_integer_codec.cpp b/ext_components/cp0_lvgl/tests/test_integer_codec.cpp new file mode 100644 index 00000000..76006ad8 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_integer_codec.cpp @@ -0,0 +1,68 @@ +#include "cp0_integer_codec.hpp" + +#include +#include +#include +#include + +int main() +{ + int value = 0; + assert(cp0_integer_codec::parse_int("0", value) && value == 0); + assert(cp0_integer_codec::parse_int("-1", value) && value == -1); + assert(cp0_integer_codec::parse_int(std::to_string(INT_MAX), value) && value == INT_MAX); + assert(cp0_integer_codec::parse_int(std::to_string(INT_MIN), value) && value == INT_MIN); + assert(!cp0_integer_codec::parse_int("", value)); + assert(!cp0_integer_codec::parse_int("12junk", value)); + assert(!cp0_integer_codec::parse_int(" 12", value)); + assert(!cp0_integer_codec::parse_int("2147483648", value)); + assert(!cp0_integer_codec::parse_int("-2147483649", value)); + + std::uint64_t unsigned_value = 17; + assert(cp0_integer_codec::parse_decimal("0", std::uint64_t{0}, + UINT64_MAX, unsigned_value)); + assert(unsigned_value == 0); + assert(cp0_integer_codec::parse_decimal("18446744073709551615", std::uint64_t{0}, + UINT64_MAX, unsigned_value)); + assert(unsigned_value == UINT64_MAX); + for (const char *invalid : {"18446744073709551616", "+1", " 1", "1 "}) { + unsigned_value = 17; + assert(!cp0_integer_codec::parse_decimal( + invalid, std::uint64_t{0}, UINT64_MAX, unsigned_value)); + assert(unsigned_value == 17); + } + assert(!cp0_integer_codec::parse_decimal( + "5", std::uint64_t{6}, std::uint64_t{4}, unsigned_value)); + assert(unsigned_value == 17); + + value = 91; + assert(cp0_integer_codec::parse_int_with_leading_whitespace(" \t+42", value)); + assert(value == 42); + assert(cp0_integer_codec::parse_int_with_leading_whitespace("\n-3", value)); + assert(value == -3); + assert(!cp0_integer_codec::parse_int_with_leading_whitespace("12tail", value)); + assert(value == -3); + assert(!cp0_integer_codec::parse_int_with_leading_whitespace(" ", value)); + assert(value == -3); + assert(!cp0_integer_codec::parse_int_with_leading_whitespace("2147483648", value)); + assert(value == -3); + + assert(cp0_integer_codec::parse_int_prefix_clamped("", 7) == 7); + assert(cp0_integer_codec::parse_int_prefix_clamped("garbage", 7) == 0); + assert(cp0_integer_codec::parse_int_prefix_clamped(" \t+12tail", 7) == 12); + assert(cp0_integer_codec::parse_int_prefix_clamped("2147483648", 7) == INT_MAX); + assert(cp0_integer_codec::parse_int_prefix_clamped("-2147483649", 7) == INT_MIN); + + std::uintmax_t identity = 0; + assert(cp0_integer_codec::parse_non_root_identity("1000", UINT32_MAX, identity)); + assert(identity == 1000); + assert(cp0_integer_codec::parse_non_root_identity("4294967294", UINT32_MAX, identity)); + assert(!cp0_integer_codec::parse_non_root_identity(nullptr, UINT32_MAX, identity)); + assert(!cp0_integer_codec::parse_non_root_identity("", UINT32_MAX, identity)); + assert(!cp0_integer_codec::parse_non_root_identity("0", UINT32_MAX, identity)); + assert(!cp0_integer_codec::parse_non_root_identity("-1", UINT32_MAX, identity)); + assert(!cp0_integer_codec::parse_non_root_identity("1000junk", UINT32_MAX, identity)); + assert(!cp0_integer_codec::parse_non_root_identity("4294967295", UINT32_MAX, identity)); + assert(!cp0_integer_codec::parse_non_root_identity("18446744073709551616", + UINT32_MAX, identity)); +} diff --git a/ext_components/cp0_lvgl/tests/test_keyboard_contract.c b/ext_components/cp0_lvgl/tests/test_keyboard_contract.c new file mode 100644 index 00000000..3128a570 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_keyboard_contract.c @@ -0,0 +1,79 @@ +#include "cp0_keyboard_key_contract.h" +#include "cp0_keyboard_queue.h" +#include "cp0_keyboard_text.h" + +#include "input_keys.h" + +#include +#include +#include + +struct keyboard_queue_t keyboard_queue; +pthread_mutex_t keyboard_mutex = PTHREAD_MUTEX_INITIALIZER; +volatile int LVGL_HOME_KEY_FLAG; +volatile int LVGL_RUN_FLAGE = 1; +volatile uint32_t LV_EVENT_KEYBOARD; + +int main(void) +{ + uint32_t codepoint = 0; + size_t length = 0; + assert(cp0_keyboard_utf8_decode_one("A", &codepoint, &length) == 0); + assert(codepoint == 'A' && length == 1); + assert(cp0_keyboard_utf8_decode_one("\xc2\xa2", &codepoint, &length) == 0); + assert(codepoint == 0xa2 && length == 2); + assert(cp0_keyboard_utf8_decode_one("\xe4\xb8\xad", &codepoint, &length) == 0); + assert(codepoint == 0x4e2d && length == 3); + assert(cp0_keyboard_utf8_decode_one("\xf0\x9f\x98\x80", &codepoint, &length) == 0); + assert(codepoint == 0x1f600 && length == 4); + assert(cp0_keyboard_utf8_validate("A\xe4\xb8\xad\xf0\x9f\x98\x80") == 0); + + assert(cp0_keyboard_utf8_validate(NULL) == -1); + assert(cp0_keyboard_utf8_decode_one("", &codepoint, &length) == -1); + assert(cp0_keyboard_utf8_validate("\x80") == -1); + assert(cp0_keyboard_utf8_validate("\xc0\x80") == -1); + assert(cp0_keyboard_utf8_validate("\xe0\x80\x80") == -1); + assert(cp0_keyboard_utf8_validate("\xed\xa0\x80") == -1); + assert(cp0_keyboard_utf8_validate("\xf4\x90\x80\x80") == -1); + assert(cp0_keyboard_utf8_validate("\xe4\xb8") == -1); + + char copied[6]; + assert(cp0_keyboard_utf8_copy(copied, sizeof(copied), "A\xf0\x9f\x98\x80Z") == 5); + assert(strcmp(copied, "A\xf0\x9f\x98\x80") == 0); + assert(cp0_keyboard_utf8_copy(copied, 4, "\xf0\x9f\x98\x80") == 0); + assert(copied[0] == '\0'); + assert(cp0_keyboard_utf8_copy(copied, sizeof(copied), "\xc0\x80") == 0); + + char encoded[8]; + assert(cp0_keyboard_utf32_to_utf8(0x1f600, encoded, sizeof(encoded)) == 4); + assert(strcmp(encoded, "\xf0\x9f\x98\x80") == 0); + assert(cp0_keyboard_utf32_to_utf8(0xd800, encoded, sizeof(encoded)) == -1); + assert(cp0_keyboard_utf32_to_utf8(0x110000, encoded, sizeof(encoded)) == -1); + assert(cp0_keyboard_utf32_to_utf8('A', encoded, 1) == -1); + + assert(strcmp(cp0_keyboard_control_utf8(KEY_INSERT), "\033[2~") == 0); + assert(strcmp(cp0_keyboard_control_utf8(KEY_KPENTER), "\r") == 0); + assert(cp0_keyboard_control_utf8(KEY_A) == NULL); + + cp0_keyboard_queue_init(); + cp0_keyboard_queue_init(); + struct key_item source = {0}; + source.key_code = KEY_ESC; + source.key_state = KBD_KEY_PRESSED; + strcpy(source.utf8, "\x1b"); + assert(cp0_keyboard_queue_push(&source) == 0); + assert(LVGL_HOME_KEY_FLAG == KBD_KEY_PRESSED); + assert(cp0_keyboard_queue_has_data()); + struct key_item *item = cp0_keyboard_queue_pop(); + assert(item && item != &source && item->key_code == KEY_ESC); + free(item); + assert(!cp0_keyboard_queue_has_data()); + + LVGL_RUN_FLAGE = 0; + assert(cp0_keyboard_queue_push(&source) == 0); + assert(!cp0_keyboard_queue_has_data()); + LVGL_RUN_FLAGE = 1; + assert(cp0_keyboard_queue_push(NULL) == -1); + cp0_keyboard_queue_clear(); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_keyboard_input_lifecycle.c b/ext_components/cp0_lvgl/tests/test_keyboard_input_lifecycle.c new file mode 100644 index 00000000..48ee6e94 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_keyboard_input_lifecycle.c @@ -0,0 +1,22 @@ +#include "cp0_keyboard_input_lifecycle.h" + +#include + +int main(void) +{ + cp0_keyboard_input_lifecycle_t state = {0}; + int first = 1; + int stale = 2; + + assert(cp0_keyboard_input_begin_create(&state)); + assert(!cp0_keyboard_input_begin_create(&state)); + cp0_keyboard_input_finish_create(&state, 0); + assert(cp0_keyboard_input_begin_create(&state)); + cp0_keyboard_input_finish_create(&state, &first); + assert(!cp0_keyboard_input_begin_create(&state)); + assert(!cp0_keyboard_input_handle_deleted(&state, &stale)); + assert(state.handle == &first); + assert(cp0_keyboard_input_handle_deleted(&state, &first)); + assert(state.handle == 0); + assert(cp0_keyboard_input_begin_create(&state)); +} diff --git a/ext_components/cp0_lvgl/tests/test_keyboard_keymap.c b/ext_components/cp0_lvgl/tests/test_keyboard_keymap.c new file mode 100644 index 00000000..426daaab --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_keyboard_keymap.c @@ -0,0 +1,21 @@ +#include "../src/cp0/cp0_keyboard_keymap.h" + +#include "input_keys.h" + +#include +#include + +int main(void) +{ + const struct cp0_keyboard_keymap_entry *mapped = cp0_keyboard_keymap_lookup(26); + assert(mapped != NULL); + assert(strcmp(mapped->sym_name, "exclam") == 0); + assert(strcmp(mapped->utf8, "!") == 0); + assert(cp0_keyboard_keymap_lookup(9999) == NULL); + + assert(strcmp(cp0_keyboard_control_utf8(KEY_ENTER), "\r") == 0); + assert(strcmp(cp0_keyboard_control_utf8(KEY_LEFT), "\033[D") == 0); + assert(strcmp(cp0_keyboard_control_utf8(KEY_F12), "\033[24~") == 0); + assert(cp0_keyboard_control_utf8(KEY_A) == NULL); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_keyboard_thread_lifecycle.c b/ext_components/cp0_lvgl/tests/test_keyboard_thread_lifecycle.c new file mode 100644 index 00000000..c9c98118 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_keyboard_thread_lifecycle.c @@ -0,0 +1,27 @@ +#include "cp0_keyboard_thread_lifecycle.h" + +#include + +int main(void) +{ + cp0_keyboard_thread_lifecycle_t state = {0}; + + assert(cp0_keyboard_thread_can_init(&state)); + cp0_keyboard_thread_mark_started(&state); + assert(!cp0_keyboard_thread_can_init(&state)); + + /* A naturally exited pthread remains joinable until deinit reaps it. */ + assert(cp0_keyboard_thread_begin_deinit(&state)); + assert(!cp0_keyboard_thread_can_init(&state)); + assert(!cp0_keyboard_thread_begin_deinit(&state)); + cp0_keyboard_thread_finish_deinit(&state); + + assert(cp0_keyboard_thread_can_init(&state)); + cp0_keyboard_thread_mark_started(&state); + assert(cp0_keyboard_thread_begin_deinit(&state)); + cp0_keyboard_thread_cancel_deinit(&state); + assert(!cp0_keyboard_thread_can_init(&state)); + assert(cp0_keyboard_thread_begin_deinit(&state)); + cp0_keyboard_thread_finish_deinit(&state); + assert(cp0_keyboard_thread_can_init(&state)); +} diff --git a/ext_components/cp0_lvgl/tests/test_lora_contract.cpp b/ext_components/cp0_lvgl/tests/test_lora_contract.cpp new file mode 100644 index 00000000..a9f81a0b --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_lora_contract.cpp @@ -0,0 +1,100 @@ +#include "cp0_lora_contract.hpp" + +#include +#include +#include +#include +#include +#include +#include + +int main() +{ + cp0::lora::Operations operations; + bool initialized = false; + bool drained = false; + bool tx_mode = false; + bool shutdown = false; + int polls = 0; + std::string sent; + operations.initialize = [&] { + initialized = true; + return true; + }; + operations.poll = [&] { ++polls; }; + operations.read_info = [&](cp0_lora_info_t *info, bool drain_events) { + info->initialized = initialized ? 1 : 0; + std::strcpy(info->last_rx, "received"); + drained = drain_events; + }; + operations.send_text = [&](const std::string &payload) { + sent = payload; + return true; + }; + operations.start_receive = [&] { tx_mode = false; }; + operations.set_tx_mode = [&](bool enabled) { tx_mode = enabled; }; + operations.shutdown = [&] { shutdown = true; }; + + cp0::lora::Service service(operations); + auto result = service.call({"Init"}); + assert(result.code == 0 && initialized && result.payload.empty()); + result = service.call({"Info"}); + assert(result.code == 0 && result.payload.size() == sizeof(cp0_lora_info_t)); + cp0_lora_info_t info{}; + assert(cp0::lora::decode_info(result.payload, &info)); + assert(info.initialized == 1 && std::strcmp(info.last_rx, "received") == 0); + assert(!drained && polls == 0); + result = service.call({"Poll"}); + assert(result.code == 0 && drained && polls == 1); + + const std::string maximum(cp0::lora::kMaxTextPayloadBytes, 'x'); + assert(service.call({"SendText", maximum}).code == 0 && sent == maximum); + assert(service.call({"SendText"}).code == -1); + assert(service.call({"SendText", ""}).code == -1); + assert(service.call({"SendText", std::string(128, 'x')}).code == -1); + assert(service.call({"SendText", std::string("ab\0cd", 5)}).code == -1); + + assert(service.call({"SetTxMode", "1"}).code == 0 && tx_mode); + assert(service.call({"SetTxMode", "0"}).code == 0 && !tx_mode); + for (const std::string invalid : {"", "-1", "+1", "2", "1junk", "999999999999999999"}) + assert(service.call({"SetTxMode", invalid}).code == -1); + tx_mode = true; + assert(service.call({"StartReceive"}).code == 0 && !tx_mode); + assert(service.call({"Shutdown"}).code == 0 && shutdown); + assert(service.call({}).payload == "missing lora api command\n"); + assert(service.call({"Unknown"}).payload == "unknown lora api\n"); + + assert(!cp0::lora::decode_info({}, &info)); + assert(!cp0::lora::decode_info(std::string(sizeof(info) - 1, '\0'), &info)); + assert(!cp0::lora::decode_info(result.payload, nullptr)); + + std::atomic active{0}; + std::atomic maximum_active{0}; + cp0::lora::Operations concurrent_operations; + concurrent_operations.set_tx_mode = [&](bool) { + const int now = active.fetch_add(1) + 1; + int observed = maximum_active.load(); + while (now > observed && !maximum_active.compare_exchange_weak(observed, now)) {} + for (int index = 0; index < 1000; ++index) + std::this_thread::yield(); + active.fetch_sub(1); + }; + cp0::lora::Service concurrent_service(concurrent_operations); + std::vector threads; + for (int index = 0; index < 8; ++index) + threads.emplace_back([&] { assert(concurrent_service.call({"SetTxMode", "1"}).code == 0); }); + for (auto &thread : threads) + thread.join(); + assert(maximum_active.load() == 1); + + cp0::lora::Operations throwing_operations; + throwing_operations.initialize = []() -> bool { throw std::runtime_error("init"); }; + throwing_operations.read_info = [](cp0_lora_info_t *, bool) { + throw std::runtime_error("info"); + }; + cp0::lora::Service throwing_service(throwing_operations); + result = throwing_service.call({"Init"}); + assert(result.code == -1 && result.payload == "lora backend failure\n"); + result = throwing_service.call({"Info"}); + assert(result.code == -1 && result.payload == "lora backend failure\n"); +} diff --git a/ext_components/cp0_lvgl/tests/test_lora_gpio_offset_policy.cpp b/ext_components/cp0_lvgl/tests/test_lora_gpio_offset_policy.cpp new file mode 100644 index 00000000..df7e8c7e --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_lora_gpio_offset_policy.cpp @@ -0,0 +1,21 @@ +#include "cp0_lora_gpio_offset_policy.hpp" + +#include + +int main() +{ + using namespace cp0_lora_gpio_offset_policy; + + auto result = resolve(nullptr, 5); + assert(result.valid() && !result.overridden() && result.offset == 5); + result = resolve("0", 5); + assert(result.valid() && result.overridden() && result.offset == MIN_OFFSET); + result = resolve("65535", 5); + assert(result.valid() && result.overridden() && result.offset == MAX_OFFSET); + + for (const char *invalid : {"", "-1", "+1", " 5", "5 ", "5junk", + "65536", "2147483648", "999999999999999999999"}) + assert(!resolve(invalid, 5).valid()); + assert(!resolve(nullptr, -1).valid()); + assert(!resolve(nullptr, MAX_OFFSET + 1).valid()); +} diff --git a/ext_components/cp0_lvgl/tests/test_lora_runtime_policy.cpp b/ext_components/cp0_lvgl/tests/test_lora_runtime_policy.cpp new file mode 100644 index 00000000..a47b21d7 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_lora_runtime_policy.cpp @@ -0,0 +1,25 @@ +#include "cp0_lora_runtime_policy.hpp" + +#include + +int main() +{ + using namespace cp0_lora_runtime_policy; + + assert(!should_timeout_transmit(false, true, true, 100, 4100)); + assert(!should_timeout_transmit(true, false, true, 100, 4100)); + assert(!should_timeout_transmit(true, true, false, 100, 4100)); + assert(!should_timeout_transmit(true, true, true, 0, 5000)); + assert(!should_timeout_transmit(true, true, true, 100, 4099)); + assert(should_timeout_transmit(true, true, true, 100, 4100)); + assert(!should_timeout_transmit(true, true, true, 4100, 100)); + + assert(!should_send_demo(false, true, false, 100, 2100)); + assert(!should_send_demo(true, false, false, 100, 2100)); + assert(!should_send_demo(true, true, true, 100, 2100)); + assert(!should_send_demo(true, true, false, 100, 2099)); + assert(should_send_demo(true, true, false, 100, 2100)); + assert(!should_send_demo(true, true, false, 2100, 100)); + + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_lvgl_callback_boundary.cpp b/ext_components/cp0_lvgl/tests/test_lvgl_callback_boundary.cpp new file mode 100644 index 00000000..ec086338 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_lvgl_callback_boundary.cpp @@ -0,0 +1,28 @@ +#include "cp0_callback_contract.hpp" + +#include +#include + +namespace { + +void throwing_event_handler(int *calls) +{ + ++*calls; + throw std::runtime_error("event handler"); +} + +} // namespace + +int main() +{ + int calls = 0; + cp0::callback::invoke_direct(throwing_event_handler, &calls); + assert(calls == 1); + + cp0::callback::invoke_direct([&] { ++calls; }); + assert(calls == 2); + + void (*empty_handler)(int *) = nullptr; + cp0::callback::invoke_direct(empty_handler, &calls); + assert(calls == 2); +} diff --git a/ext_components/cp0_lvgl/tests/test_network_api_contract.cpp b/ext_components/cp0_lvgl/tests/test_network_api_contract.cpp new file mode 100644 index 00000000..9d5ad30a --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_network_api_contract.cpp @@ -0,0 +1,125 @@ +#include "cp0_network_api_contract.hpp" + +#include +#include +#include +#include + +namespace { + +using cp0::network::ApiCommand; +using cp0::network::ApiRequest; + +void test_request_contract() +{ + ApiRequest request; + assert(!cp0::network::parse_api_request({}, request)); + assert(!cp0::network::parse_api_request({"Unknown"}, request)); + assert(!cp0::network::parse_api_request({"Status", "junk"}, request)); + assert(cp0::network::parse_api_request({"Status"}, request)); + assert(request.command == ApiCommand::Status); + + assert(cp0::network::parse_api_request({"Scan"}, request)); + assert(request.scan_limit == CP0_WIFI_AP_MAX); + assert(cp0::network::parse_api_request({"Scan", "0"}, request)); + assert(request.scan_limit == 0); + assert(cp0::network::parse_api_request({"Scan", "32"}, request)); + assert(request.scan_limit == CP0_WIFI_AP_MAX); + assert(!cp0::network::parse_api_request({"Scan", ""}, request)); + assert(!cp0::network::parse_api_request({"Scan", "12junk"}, request)); + assert(!cp0::network::parse_api_request({"Scan", "-1"}, request)); + assert(!cp0::network::parse_api_request({"Scan", "33"}, request)); + assert(!cp0::network::parse_api_request({"Scan", "999999999999999999999"}, request)); + assert(!cp0::network::parse_api_request({"Scan", "1", "junk"}, request)); + + assert(!cp0::network::parse_api_request({"Connect"}, request)); + assert(!cp0::network::parse_api_request({"Connect", ""}, request)); + assert(cp0::network::parse_api_request({"Connect", "office"}, request)); + assert(request.command == ApiCommand::Connect && request.ssid == "office" && request.password.empty()); + assert(cp0::network::parse_api_request({"Connect", "office", "secret"}, request)); + assert(request.password == "secret"); + assert(!cp0::network::parse_api_request({"Connect", "office", "secret", "junk"}, request)); + + assert(!cp0::network::parse_api_request({"ProfileForget", ""}, request)); + assert(!cp0::network::parse_api_request({"ProfileExists"}, request)); + assert(cp0::network::parse_api_request({"ProfileForget", "office"}, request)); + assert(request.command == ApiCommand::ProfileForget && request.ssid == "office"); + assert(cp0::network::parse_api_request({"ProfileExists", "office"}, request)); + assert(request.command == ApiCommand::ProfileExists); + + assert(cp0::network::parse_api_request({"RadioSetEnabled", "true"}, request)); + assert(request.radio_enabled); + assert(cp0::network::parse_api_request({"RadioSetEnabled", "off"}, request)); + assert(!request.radio_enabled); + assert(!cp0::network::parse_api_request({"RadioSetEnabled", "yes"}, request)); + assert(!cp0::network::parse_api_request({"RadioEnabled", "junk"}, request)); + assert(std::string(cp0::network::invalid_api_request_message()) == "invalid wifi api request"); +} + +void test_status_payload_round_trip_and_validation() +{ + cp0_wifi_status_t source{}; + source.connected = 1; + std::strcpy(source.ssid, "office:east\\floor\n2"); + std::strcpy(source.ip, "192.168.1.8"); + source.signal = 87; + source.ethernet = 1; + + const std::string payload = cp0::network::encode_status_payload(source); + cp0_wifi_status_t decoded{}; + assert(cp0::network::decode_status_payload(payload, decoded)); + assert(decoded.connected == 1 && decoded.signal == 87 && decoded.ethernet == 1); + assert(std::string(decoded.ssid) == source.ssid); + assert(std::string(decoded.ip) == source.ip); + + assert(!cp0::network::decode_status_payload("1:ssid:ip:50", decoded)); + assert(!cp0::network::decode_status_payload("1:ssid:ip:50:0:junk", decoded)); + assert(!cp0::network::decode_status_payload("2:ssid:ip:50:0", decoded)); + assert(!cp0::network::decode_status_payload("1:ssid:ip:101:0", decoded)); + assert(!cp0::network::decode_status_payload("1:ssid:ip:5x:0", decoded)); + assert(!cp0::network::decode_status_payload("1:bad\\q:ip:50:0", decoded)); +} + +void test_scan_payload_round_trip_and_validation() +{ + cp0_wifi_ap_t source[2]{}; + std::strcpy(source[0].ssid, "office:east"); + source[0].signal = 76; + std::strcpy(source[0].security, "WPA2\\WPA3"); + source[0].in_use = 1; + source[0].saved = 1; + std::strcpy(source[1].ssid, "guest\nlevel2"); + source[1].signal = 40; + std::strcpy(source[1].security, "OPEN"); + + const std::string payload = cp0::network::encode_scan_payload(source, 2); + cp0_wifi_ap_t decoded[4]{}; + assert(cp0::network::decode_scan_payload(payload, decoded, 4) == 2); + assert(std::string(decoded[0].ssid) == source[0].ssid); + assert(std::string(decoded[0].security) == source[0].security); + assert(decoded[0].signal == 76 && decoded[0].in_use == 1 && decoded[0].saved == 1); + assert(std::string(decoded[1].ssid) == source[1].ssid); + + const std::string malformed = + "valid:50:OPEN:0\n" + "junk:5x:OPEN:0\n" + "high:101:OPEN:0\n" + ":20:OPEN:0\n" + "extra:20:OPEN:0:junk\n" + "also-valid:0:OPEN:1\n"; + assert(cp0::network::decode_scan_payload(malformed, decoded, 4) == 2); + assert(std::string(decoded[0].ssid) == "valid"); + assert(std::string(decoded[1].ssid) == "also-valid"); + assert(cp0::network::decode_scan_payload(payload, nullptr, 4) == 0); + assert(cp0::network::decode_scan_payload(payload, decoded, 0) == 0); + assert(cp0::network::encode_scan_payload(nullptr, 2).empty()); +} + +} // namespace + +int main() +{ + test_request_contract(); + test_status_payload_round_trip_and_validation(); + test_scan_payload_round_trip_and_validation(); +} diff --git a/ext_components/cp0_lvgl/tests/test_network_policy.cpp b/ext_components/cp0_lvgl/tests/test_network_policy.cpp new file mode 100644 index 00000000..f586d527 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_network_policy.cpp @@ -0,0 +1,84 @@ +#include "cp0_network_policy.hpp" + +#include +#include + +using cp0::network::AccessPoint; +using cp0::network::Status; + +static void test_terse_fields_decode_escaped_delimiters() +{ + const auto fields = cp0::network::split_terse_fields(R"(office\:east:WPA2\\WPA3:*)"); + assert(fields.size() == 3); + assert(fields[0] == "office:east"); + assert(fields[1] == "WPA2\\WPA3"); + assert(fields[2] == "*"); +} + +static void test_scan_deduplicates_by_decoded_ssid() +{ + const auto access_points = cp0::network::parse_scan_output( + "office\\:east:31:WPA2: *\r\n" + "office\\:east:72:WPA2: \n" + ":99:OPEN: \n" + "junk:not-a-number:OPEN: \n" + "too-high:101:OPEN: \n" + "guest:54:OPEN: \n"); + + assert(access_points.size() == 2); + assert(access_points[0].ssid == "office:east"); + assert(access_points[0].signal == 72); + assert(access_points[0].in_use); + assert(access_points[1].ssid == "guest"); +} + +static void test_device_status_preserves_network_semantics() +{ + const Status status = cp0::network::parse_device_status( + "eth0:ethernet:connected:Wired connection 1\n" + "wlan0:wifi:connected (externally):--\n"); + assert(status.ethernet); + assert(status.connected); + assert(status.wifi_interface == "wlan0"); + assert(status.ssid.empty()); + + const Status netplan = cp0::network::parse_device_status( + "wlan0:wifi:connected:netplan-wlan0-home\\:lab\n"); + assert(netplan.connected); + assert(netplan.ssid == "home:lab"); +} + +static void test_active_wifi_fills_signal_and_missing_ssid() +{ + Status status; + cp0::network::apply_active_wifi(" :80:other\n*:67:home\\:lab\n", status); + assert(status.signal == 67); + assert(status.ssid == "home:lab"); + + status.ssid = "profile name"; + cp0::network::apply_active_wifi("*:52:ignored\n", status); + assert(status.ssid == "profile name"); + cp0::network::apply_active_wifi("*:5junk:broken\n*:101:also-broken\n", status); + assert(status.signal == 52); +} + +static void test_simple_command_output_parsers() +{ + assert(cp0::network::parse_ipv4_address("3: wlan0 inet 192.168.1.42/24 brd 192.168.1.255\n") == + "192.168.1.42"); + assert(cp0::network::parse_ipv4_address("no address here").empty()); + assert(cp0::network::parse_radio_enabled(" \tenabled\r\n")); + assert(!cp0::network::parse_radio_enabled("disabled\n")); + assert(cp0::network::first_active_connection("lo\ncoffee\\:shop\r\nbackup\n") == "coffee:shop"); +} + +int main() +{ + test_terse_fields_decode_escaped_delimiters(); + test_scan_deduplicates_by_decoded_ssid(); + test_device_status_preserves_network_semantics(); + test_active_wifi_fills_signal_and_missing_ssid(); + test_simple_command_output_parsers(); + std::cout << "network policy tests passed\n"; + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_osinfo_codec.cpp b/ext_components/cp0_lvgl/tests/test_osinfo_codec.cpp new file mode 100644 index 00000000..20c99f0e --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_osinfo_codec.cpp @@ -0,0 +1,130 @@ +#include "cp0_osinfo_codec.hpp" + +#include +#include +#include + +namespace { + +void test_eth_round_trip_and_field_order() +{ + cp0_eth_info_t source{}; + std::strcpy(source.ipv4, "192.0.2.4/24"); + std::strcpy(source.gateway, "192.0.2.1"); + std::strcpy(source.mac, "02:00:00:00:00:04"); + + const std::string payload = cp0::osinfo::encode_eth_info(source); + assert(payload == "192.0.2.4/24\n192.0.2.1\n02:00:00:00:00:04"); + + cp0_eth_info_t decoded{}; + cp0::osinfo::decode_eth_info(payload, &decoded); + assert(std::strcmp(decoded.ipv4, source.ipv4) == 0); + assert(std::strcmp(decoded.gateway, source.gateway) == 0); + assert(std::strcmp(decoded.mac, source.mac) == 0); +} + +void test_eth_missing_fields_use_legacy_defaults() +{ + cp0_eth_info_t info{}; + cp0::osinfo::decode_eth_info("198.51.100.8", &info); + assert(std::strcmp(info.ipv4, "198.51.100.8") == 0); + assert(std::strcmp(info.gateway, "N/A") == 0); + assert(std::strcmp(info.mac, "N/A") == 0); + + cp0::osinfo::decode_eth_info("\n\n", &info); + assert(info.ipv4[0] == '\0'); + assert(info.gateway[0] == '\0'); + assert(std::strcmp(info.mac, "N/A") == 0); + cp0::osinfo::decode_eth_info("ignored", nullptr); +} + +void test_decoders_truncate_and_terminate_fields() +{ + cp0_eth_info_t eth{}; + cp0::osinfo::decode_eth_info(std::string(200, 'i') + "\n" + std::string(200, 'g') + "\n" + + std::string(200, 'm'), + ð); + assert(std::strlen(eth.ipv4) == sizeof(eth.ipv4) - 1); + assert(std::strlen(eth.gateway) == sizeof(eth.gateway) - 1); + assert(std::strlen(eth.mac) == sizeof(eth.mac) - 1); + + cp0_account_info_t account{}; + cp0::osinfo::decode_account_info(std::string(200, 'u') + "\n" + std::string(300, 'h'), &account); + assert(std::strlen(account.user) == sizeof(account.user) - 1); + assert(std::strlen(account.hostname) == sizeof(account.hostname) - 1); +} + +void test_strict_decoders_reject_malformed_payloads() +{ + cp0_eth_info_t eth{}; + assert(cp0::osinfo::decode_eth_info_strict("ip\ngateway\nmac", ð)); + assert(std::strcmp(eth.mac, "mac") == 0); + assert(cp0::osinfo::decode_eth_info_strict("\n\n", ð)); + assert(!cp0::osinfo::decode_eth_info_strict("ip\ngateway", ð)); + assert(!cp0::osinfo::decode_eth_info_strict("ip\ngateway\nmac\njunk", ð)); + assert(!cp0::osinfo::decode_eth_info_strict("ip\ngateway\nmac", nullptr)); + + cp0_account_info_t account{}; + assert(cp0::osinfo::decode_account_info_strict("user\nhost", &account)); + assert(!cp0::osinfo::decode_account_info_strict("user", &account)); + assert(!cp0::osinfo::decode_account_info_strict("user\nhost\njunk", &account)); + assert(!cp0::osinfo::decode_account_info_strict("user\nhost", nullptr)); +} + +void test_account_payload_compatibility() +{ + cp0_account_info_t source{}; + std::strcpy(source.user, "operator"); + std::strcpy(source.hostname, "cardputer-zero"); + assert(cp0::osinfo::encode_account_info(source) == "operator\ncardputer-zero"); + + cp0_account_info_t decoded{}; + std::memset(&decoded, 0x7f, sizeof(decoded)); + cp0::osinfo::decode_account_info("operator", &decoded); + assert(std::strcmp(decoded.user, "operator") == 0); + assert(decoded.hostname[0] == '\0'); + cp0::osinfo::decode_account_info("ignored", nullptr); +} + +void test_network_list_payload_order() +{ + cp0_netif_info_t entries[2]{}; + std::strcpy(entries[0].iface, "eth0"); + std::strcpy(entries[0].ipv4, "192.0.2.4"); + std::strcpy(entries[0].netmask, "255.255.255.0"); + entries[0].is_up = 1; + std::strcpy(entries[1].iface, "wlan0"); + entries[1].is_up = 0; + + assert(cp0::osinfo::encode_network_list(entries, 2) == + "eth0\t192.0.2.4\t255.255.255.0\t1\n" + "wlan0\t\t\t0\n"); + assert(cp0::osinfo::encode_network_list(nullptr, 2).empty()); + assert(cp0::osinfo::encode_network_list(entries, 0).empty()); +} + +void test_local_time_payload_order() +{ + std::tm value{}; + value.tm_year = 126; + value.tm_mon = 6; + value.tm_mday = 22; + value.tm_hour = 9; + value.tm_min = 8; + value.tm_sec = 7; + assert(cp0::osinfo::encode_local_time(value) == "2026,7,22,9,8,7"); +} + +} // namespace + +int main() +{ + test_eth_round_trip_and_field_order(); + test_eth_missing_fields_use_legacy_defaults(); + test_decoders_truncate_and_terminate_fields(); + test_strict_decoders_reject_malformed_payloads(); + test_account_payload_compatibility(); + test_network_list_payload_order(); + test_local_time_payload_order(); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_osinfo_contract.cpp b/ext_components/cp0_lvgl/tests/test_osinfo_contract.cpp new file mode 100644 index 00000000..e5f72b34 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_osinfo_contract.cpp @@ -0,0 +1,106 @@ +#include "cp0_osinfo_contract.hpp" + +#include +#include +#include +#include + +int main() +{ + cp0::osinfo::Operations operations; + bool default_network = false; + operations.read_eth_info = [&](bool is_default, cp0_eth_info_t *info) { + default_network = is_default; + std::strcpy(info->ipv4, "192.0.2.10"); + std::strcpy(info->gateway, "192.0.2.1"); + std::strcpy(info->mac, "00:11:22:33:44:55"); + return 0; + }; + int network_count = 1; + operations.list_networks = [&](cp0_netif_info_t *entries, int capacity, int *count) { + assert(capacity == 64); + std::strcpy(entries[0].iface, "eth0"); + entries[0].is_up = 1; + *count = network_count; + return 0; + }; + operations.read_account_info = [](cp0_account_info_t *info) { + std::strcpy(info->user, "user"); + std::strcpy(info->hostname, "host"); + return 0; + }; + std::string timestamp; + operations.set_time = [&](const std::string &value) { + timestamp = value; + return 7; + }; + operations.read_local_time = [](std::tm *value) { + value->tm_year = 126; + value->tm_mon = 6; + value->tm_mday = 22; + return 0; + }; + operations.random_u32 = [] { return uint32_t{4294967295U}; }; + operations.get_ntp = [] { return 1; }; + bool ntp_enabled = false; + int ntp_calls = 0; + operations.set_ntp = [&](bool enabled) { + ntp_enabled = enabled; + ++ntp_calls; + return 0; + }; + operations.apt_update_background = [] { return 3; }; + operations.update_launcher_background = [] { return 4; }; + + auto result = cp0::osinfo::dispatch({"NetworkDefaultInfoRead"}, operations); + assert(result.code == 0 && default_network); + assert(result.payload == "192.0.2.10\n192.0.2.1\n00:11:22:33:44:55"); + result = cp0::osinfo::dispatch({"EthInfoRead"}, operations); + assert(result.code == 0 && !default_network); + result = cp0::osinfo::dispatch({"NetworkList"}, operations); + assert(result.code == 0 && result.payload == "eth0\t\t\t1\n"); + network_count = 65; + result = cp0::osinfo::dispatch({"NetworkList"}, operations); + assert(result.code == -1 && result.payload.empty()); + network_count = -1; + assert(cp0::osinfo::dispatch({"NetworkList"}, operations).code == -1); + + result = cp0::osinfo::dispatch({"AccountInfoRead"}, operations); + assert(result.code == 0 && result.payload == "user\nhost"); + result = cp0::osinfo::dispatch({"TimeSet", "2024-02-29 23:59:59"}, operations); + assert(result.code == 7 && timestamp == "2024-02-29 23:59:59"); + assert(cp0::osinfo::dispatch({"TimeSet", ""}, operations).code == -1); + assert(cp0::osinfo::dispatch({"TimeSet", "2023-02-29 00:00:00"}, operations).code == -1); + assert(cp0::osinfo::dispatch({"TimeSet", "2024-01-01 00:00:00junk"}, operations).code == -1); + assert(cp0::osinfo::dispatch({"TimeSet", "999999999999-01-01 00:00:00"}, operations).code == -1); + assert(cp0::osinfo::dispatch({"TimeSet", "2024-01-01 24:00:00"}, operations).code == -1); + + assert(cp0::osinfo::dispatch({"LocalTime"}, operations).payload == "2026,7,22,0,0,0"); + assert(cp0::osinfo::dispatch({"RandomU32"}, operations).payload == "4294967295"); + assert(cp0::osinfo::dispatch({"NtpGet"}, operations).code == 1); + assert(cp0::osinfo::dispatch({"NtpSet", "1"}, operations).code == 0); + assert(ntp_enabled && ntp_calls == 1); + assert(cp0::osinfo::dispatch({"NtpSet", "0"}, operations).code == 0); + assert(!ntp_enabled && ntp_calls == 2); + assert(cp0::osinfo::dispatch({"NtpSet"}, operations).code == -1); + assert(cp0::osinfo::dispatch({"NtpSet", "2"}, operations).code == -1); + assert(cp0::osinfo::dispatch({"NtpSet", "1junk"}, operations).code == -1); + assert(ntp_calls == 2); + + assert(cp0::osinfo::dispatch({"AptUpdateBackground"}, operations).code == 3); + assert(cp0::osinfo::dispatch({"UpdateLauncherBackground"}, operations).code == 4); + assert(cp0::osinfo::dispatch({}, operations).code == -1); + assert(cp0::osinfo::dispatch({"Unknown"}, operations).payload == "unknown osinfo api command"); + + cp0::osinfo::Operations missing; + assert(cp0::osinfo::dispatch({"RandomU32"}, missing).code == -1); + + operations.random_u32 = []() -> uint32_t { throw std::runtime_error("backend"); }; + result = cp0::osinfo::dispatch({"RandomU32"}, operations); + assert(result.code == -1 && result.payload == "osinfo backend failure"); + operations.list_networks = [](cp0_netif_info_t *, int, int *) -> int { + throw std::runtime_error("backend"); + }; + result = cp0::osinfo::dispatch({"NetworkList"}, operations); + assert(result.code == -1 && result.payload == "osinfo backend failure"); +} diff --git a/ext_components/cp0_lvgl/tests/test_owned_component_lifecycle.cpp b/ext_components/cp0_lvgl/tests/test_owned_component_lifecycle.cpp new file mode 100644 index 00000000..e4f1a944 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_owned_component_lifecycle.cpp @@ -0,0 +1,32 @@ +#include "cp0_status_component_contract.hpp" + +#include +#include +#include + +int main() +{ + std::vector order; + cp0::status::release_before_destroy( + [&] { order.push_back(1); }, + [&] { order.push_back(2); }); + assert((order == std::vector{1, 2})); + + order.clear(); + cp0::status::release_before_destroy( + [&] { + order.push_back(1); + throw std::runtime_error("deactivate"); + }, + [&] { order.push_back(2); }); + assert((order == std::vector{1, 2})); + + order.clear(); + cp0::status::release_before_destroy( + [&] { order.push_back(1); }, + [&] { + order.push_back(2); + throw std::runtime_error("unmount"); + }); + assert((order == std::vector{1, 2})); +} diff --git a/ext_components/cp0_lvgl/tests/test_pointer_lifecycle.cpp b/ext_components/cp0_lvgl/tests/test_pointer_lifecycle.cpp new file mode 100644 index 00000000..e0338162 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_pointer_lifecycle.cpp @@ -0,0 +1,79 @@ +#include "cp0_pointer_lifecycle.hpp" + +#include +#include + +int main() +{ + int first = 1; + int second = 2; + int *current = &first; + + assert(!cp0::can_mount_child(static_cast(nullptr), + static_cast(nullptr))); + assert(!cp0::can_mount_child(&first, &second)); + assert(cp0::can_mount_child(&first, static_cast(nullptr))); + + int *no_screen = nullptr; + assert(cp0::display_references_screen(&first, &first, no_screen, no_screen)); + assert(cp0::display_references_screen(&first, no_screen, &first, no_screen)); + assert(cp0::display_references_screen(&first, no_screen, no_screen, &first)); + assert(!cp0::display_references_screen(&first, &second, no_screen, no_screen)); + assert(!cp0::display_references_screen( + no_screen, no_screen, no_screen, no_screen)); + + assert(!cp0::clear_if_deleted(current, static_cast(nullptr))); + assert(!cp0::clear_if_deleted(current, &second)); + assert(current == &first); + assert(cp0::clear_if_deleted(current, &first)); + assert(current == nullptr); + + current = &second; + assert(!cp0::clear_if_deleted(current, &first)); + assert(current == &second); + + int *parent = &first; + int *child = &second; + assert(cp0::clear_if_deleted(child, &second)); + assert(parent == &first); + assert(child == nullptr); + assert(!cp0::clear_if_deleted(child, &second)); + assert(cp0::clear_if_deleted(parent, &first)); + assert(parent == nullptr); + + int *component_children[] = {&first, &second, &first}; + for (int *&owned : component_children) + cp0::clear_if_deleted(owned, &first); + assert(component_children[0] == nullptr); + assert(component_children[1] == &second); + assert(component_children[2] == nullptr); + + assert(cp0::is_direct_event_target(&first, &first)); + assert(!cp0::is_direct_event_target(&second, &first)); + assert(!cp0::is_direct_event_target( + static_cast(nullptr), static_cast(nullptr))); + static_assert(cp0::app_content_height(false) == 150); + static_assert(cp0::app_content_height(true) == 130); + assert(cp0::input_type_uses_group(1, 1, 2)); + assert(cp0::input_type_uses_group(2, 1, 2)); + assert(!cp0::input_type_uses_group(0, 1, 2)); + assert(!cp0::input_type_uses_group(3, 1, 2)); + + int rollbacks = 0; + { + auto guard = cp0::make_rollback_guard([&] { ++rollbacks; }); + } + assert(rollbacks == 1); + { + auto guard = cp0::make_rollback_guard([&] { ++rollbacks; }); + guard.dismiss(); + } + assert(rollbacks == 1); + { + auto guard = cp0::make_rollback_guard([&] { + ++rollbacks; + throw std::runtime_error("rollback"); + }); + } + assert(rollbacks == 2); +} diff --git a/ext_components/cp0_lvgl/tests/test_posix_filesystem.cpp b/ext_components/cp0_lvgl/tests/test_posix_filesystem.cpp new file mode 100644 index 00000000..d2c066a9 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_posix_filesystem.cpp @@ -0,0 +1,192 @@ +#include "../src/cp0_filesystem_api.hpp" +#include "../src/cp0_posix_filesystem.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +int main() +{ + char directory_template[] = "/tmp/cp0-posix-filesystem-XXXXXX"; + const char *directory = mkdtemp(directory_template); + assert(directory); + + const std::string file_path = std::string(directory) + "/a%\tb"; + FILE *file = std::fopen(file_path.c_str(), "wb"); + assert(file); + assert(std::fwrite("hello", 1, 5, file) == 5); + assert(std::fclose(file) == 0); + const std::string child_directory = std::string(directory) + "/child"; + assert(mkdir(child_directory.c_str(), 0700) == 0); + + cp0_dirent_t entries[4]{}; + int count = 0; + assert(cp0_posix_filesystem::list_directory(directory, entries, 4, &count, true) == 0); + assert(count == 2); + bool found_file = false; + bool found_directory = false; + for (int index = 0; index < count; ++index) { + if (std::strcmp(entries[index].name, "a%\tb") == 0) found_file = !entries[index].is_dir; + if (std::strcmp(entries[index].name, "child") == 0) + found_directory = entries[index].is_dir; + } + assert(found_file && found_directory); + + std::string encoded; + assert(cp0_posix_filesystem::encode_directory(directory, false, true, encoded) == 0); + assert(encoded.find("F\ta%25%09b\n") != std::string::npos); + assert(encoded.find("D\tchild\n") != std::string::npos); + assert(cp0_posix_filesystem::encode_directory(directory, true, true, encoded) == 0); + assert(encoded.find("F\t5\ta%25%09b\n") != std::string::npos); + + std::string contents; + assert(cp0_posix_filesystem::read_file_limited(file_path, 5, contents) == 0); + assert(contents == "hello"); + assert(cp0_posix_filesystem::read_file_limited(file_path, 4, contents) == -EFBIG); + + const std::string missing = std::string(directory) + "/missing"; + assert(cp0_posix_filesystem::list_directory(missing.c_str(), entries, 4, &count, false) == -1); + assert(cp0_posix_filesystem::list_directory(missing.c_str(), entries, 4, &count, true) == + -ENOENT); + + cp0_watcher_t watched = reinterpret_cast(static_cast(0x1234)); + bool watch_stopped = false; + cp0_filesystem_api::WatcherRegistry watchers; + cp0_filesystem_api::Operations operations; + operations.resolve_path = [](const std::string &value) { return "resolved/" + value; }; + operations.ensure_directory = [](const std::string &path) { + return mkdir(path.c_str(), 0700) == 0 ? 0 : -1; + }; + operations.watch_start = [&](const char *) { return watchers.add(watched); }; + operations.watch_poll = [&](cp0_watcher_t watcher) { + return watchers.poll(watcher, [watched](cp0_watcher_t active) { + return active == watched ? 1 : -1; + }); + }; + operations.watch_stop = [&](cp0_watcher_t watcher) { + return watchers.stop(watcher, [&](cp0_watcher_t active) { + watch_stopped = active == watched; + }) ? 0 : -1; + }; + + auto invoke = [&](std::list arguments, int &code, std::string &data) { + cp0_filesystem_api::dispatch(arguments, operations, [&](int result, std::string output) { + code = result; + data = std::move(output); + }); + }; + int code = -99; + std::string data; + invoke({"Path", "asset.png"}, code, data); + assert(code == 0 && data == "resolved/asset.png"); + invoke({"DirList", directory}, code, data); + assert(code == 0 && data.find("F\ta%25%09b\n") != std::string::npos); + invoke({"DirListDetail", directory}, code, data); + assert(code == 0 && data.find("F\t5\ta%25%09b\n") != std::string::npos); + invoke({"Exists", file_path}, code, data); + assert(code == 0 && data == "1"); + invoke({"ReadFile", file_path, "5"}, code, data); + assert(code == 0 && data == "hello"); + invoke({"ReadFile", file_path, "4"}, code, data); + assert(code == -EFBIG); + invoke({"ReadFile", file_path, "0"}, code, data); + assert(code == -EFBIG); + invoke({"ReadFile", file_path, "5junk"}, code, data); + assert(code == -1); + invoke({"ReadFile", file_path, "-1"}, code, data); + assert(code == -1); + invoke({"ReadFile", file_path, "184467440737095516160"}, code, data); + assert(code == -1); + + const std::string ensured = std::string(directory) + "/ensured"; + invoke({"EnsureDirForUser", ensured}, code, data); + assert(code == 0); + const std::string touched = std::string(directory) + "/touched"; + invoke({"Touch", touched}, code, data); + assert(code == 0 && access(touched.c_str(), F_OK) == 0); + invoke({"Remove", touched}, code, data); + assert(code == 0 && access(touched.c_str(), F_OK) != 0); + invoke({"WatchStart", directory}, code, data); + assert(code == 0 && !data.empty()); + const std::string watcher_handle = data; + invoke({"WatchPoll", watcher_handle}, code, data); + assert(code == 0 && data == "1"); + invoke({"WatchStop", watcher_handle}, code, data); + assert(code == 0 && watch_stopped); + invoke({"WatchPoll", watcher_handle}, code, data); + assert(code == -1 && data == "-1"); + invoke({"WatchStop", watcher_handle}, code, data); + assert(code == -1); + invoke({"WatchPoll", "0x1234"}, code, data); + assert(code == -1); + invoke({"WatchPoll", "4660junk"}, code, data); + assert(code == -1); + invoke({"WatchPoll", "184467440737095516160"}, code, data); + assert(code == -1); + + cp0_watcher_t decoded_watcher = nullptr; + assert(cp0_filesystem_api::decode_watcher_handle("4660", decoded_watcher)); + assert(decoded_watcher == watched); + assert(cp0_filesystem_api::encode_watcher_handle(watched) == "4660"); + assert(!cp0_filesystem_api::decode_watcher_handle("+4660", decoded_watcher)); + assert(!cp0_filesystem_api::decode_watcher_handle("4660 ", decoded_watcher)); + + invoke({"Exists"}, code, data); + assert(code == -1); + invoke({"Touch", ""}, code, data); + assert(code == -1); + invoke({"DirList", directory, "extra"}, code, data); + assert(code == -1); + invoke({"Path", "asset.png", "extra"}, code, data); + assert(code == -1); + invoke({"Exists", std::string("bad\0path", 8)}, code, data); + assert(code == -1); + invoke({"Unknown"}, code, data); + assert(code == -2 && data == "unknown command: Unknown"); + + int callback_calls = 0; + cp0_filesystem_api::Operations throwing_operations = operations; + throwing_operations.resolve_path = [](const std::string &) -> std::string { + throw std::runtime_error("resolve failed"); + }; + cp0_filesystem_api::dispatch({"Path", "asset.png"}, throwing_operations, + [&](int result, std::string output) { + ++callback_calls; + code = result; + data = std::move(output); + }); + assert(callback_calls == 1 && code == -1 && data == "filesystem operation failed"); + + throwing_operations = operations; + throwing_operations.watch_poll = [](cp0_watcher_t) -> int { + throw std::runtime_error("backend failed"); + }; + callback_calls = 0; + cp0_filesystem_api::dispatch({"WatchPoll", "4660"}, throwing_operations, + [&](int result, std::string output) { + ++callback_calls; + code = result; + data = std::move(output); + }); + assert(callback_calls == 1 && code == -1 && data == "filesystem operation failed"); + + callback_calls = 0; + cp0_filesystem_api::dispatch({"Exists", file_path}, operations, + [&](int, std::string) { + ++callback_calls; + throw std::runtime_error("user callback failed"); + }); + assert(callback_calls == 1); + + assert(unlink(file_path.c_str()) == 0); + assert(rmdir(ensured.c_str()) == 0); + assert(rmdir(child_directory.c_str()) == 0); + assert(rmdir(directory) == 0); +} diff --git a/ext_components/cp0_lvgl/tests/test_process_api_contract.cpp b/ext_components/cp0_lvgl/tests/test_process_api_contract.cpp new file mode 100644 index 00000000..c5d93a4b --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_process_api_contract.cpp @@ -0,0 +1,76 @@ +#include "cp0_process_api_contract.hpp" + +#include +#include +#include +#include +#include +#include + +struct ProcessOperation { + int operator()() const { return 0; } +}; + +int main() +{ + static_assert(noexcept(cp0_process_api_contract::invoke_c_api(ProcessOperation{}))); + assert(cp0_process_api_contract::invoke_c_api([] { return 7; }) == 7); + assert(cp0_process_api_contract::invoke_c_api( + []() -> int { throw std::bad_alloc(); }) == -ENOMEM); + assert(cp0_process_api_contract::invoke_c_api( + []() -> int { throw 1; }) == -EIO); + cp0_process_api_contract::invoke_c_api_void([] { throw 1; }); + const char *argv[] = {"apt-get", "install", "package with spaces", nullptr}; + assert(cp0_process_api_contract::make_run_sudo_request("secret", argv) == + (std::list{"RunSudo", "secret", "apt-get", "install", + "package with spaces"})); + + assert(cp0_process_api_contract::make_run_sudo_request(nullptr, nullptr) == + (std::list{"RunSudo", ""})); + + using namespace cp0_process_api_contract; + const std::list request = {"Kill", "42", "1000"}; + assert(has_exact_arguments(request, 3)); + assert(has_at_least_arguments(request, 2)); + assert(argument_at(request, 1) && *argument_at(request, 1) == "42"); + assert(argument_at(request, 3) == nullptr); + assert(arguments_from(request, 1) == (std::vector{"42", "1000"})); + + bool flag = false; + assert(parse_bool("1", flag) && flag); + assert(parse_bool("0", flag) && !flag); + assert(!parse_bool("2", flag)); + assert(!parse_bool("true", flag)); + + int value = 0; + assert(parse_pid("1", value) && value == 1); + assert(!parse_pid("0", value)); + assert(!parse_pid("12junk", value)); + assert(!parse_pid(" 12", value)); + assert(parse_grace_ms("300000", value)); + assert(!parse_grace_ms("300001", value)); + assert(!parse_grace_ms("-1", value)); + assert(parse_delay_ms("0", value)); + assert(!parse_delay_ms("99999999999999999999", value)); + + uintptr_t pointer = 0; + assert(parse_pointer("0", pointer) && pointer == 0); + assert(parse_pointer("123", pointer) && pointer == 123); + assert(!parse_pointer("0x10", pointer)); + + assert(parse_spawn_response("42", value) && value == 42); + assert(!parse_spawn_response("0", value)); + assert(!parse_spawn_response("42junk", value)); + assert(parse_lock_holder_response("0", value) && value == 0); + assert(!parse_lock_holder_response("-1", value)); + + bool callback_called = false; + invoke_callback_safely( + std::function([&](int code, std::string data) { + callback_called = code == 7 && data == "ok"; + throw 1; + }), + 7, "ok"); + assert(callback_called); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_pty_contract.cpp b/ext_components/cp0_lvgl/tests/test_pty_contract.cpp new file mode 100644 index 00000000..890fff53 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_pty_contract.cpp @@ -0,0 +1,59 @@ +#include "cp0_pty_contract.hpp" + +#include +#include +#include + +int main() +{ + using cp0::pty::Command; + using cp0::pty::parse_request; + + auto open = parse_request({"Open", "bash", "120", "40", "bash", "-i"}); + assert(open.ok && open.request.command == Command::open); + assert(open.request.executable == "bash" && open.request.columns == 120 && open.request.rows == 40); + assert(open.request.argv.size() == 2 && open.request.argv[0] == "bash" && open.request.argv[1] == "-i"); + + auto defaults = parse_request({"Open", "sh"}); + assert(defaults.ok && defaults.request.columns == 80 && defaults.request.rows == 24); + auto maximum_dimensions = parse_request({"Open", "sh", "65535", "65535"}); + assert(maximum_dimensions.ok && maximum_dimensions.request.columns == 65535 && + maximum_dimensions.request.rows == 65535); + assert(!parse_request({"Open", "sh", "0", "24"}).ok); + assert(!parse_request({"Open", "sh", "70000", "24"}).ok); + assert(!parse_request({"Open", "sh", "+80", "24"}).ok); + assert(!parse_request({"Open", ""}).ok); + + auto read = parse_request({"Read", "42"}); + assert(read.ok && read.request.command == Command::read && read.request.handle == 42); + assert(read.request.max_read == 4096); + assert(parse_request({"Read", "42", "1048576"}).ok); + assert(!parse_request({"Read", "42", "0"}).ok); + assert(!parse_request({"Read", "42", "1048577"}).ok); + assert(!parse_request({"Read", "42", "1048576junk"}).ok); + assert(!parse_request({"Read", "-1"}).ok); + assert(!parse_request({"Read", "0x2a"}).ok); + + auto write = parse_request({"Write", "42", std::string("a\0b", 3)}); + assert(write.ok && write.request.command == Command::write && write.request.data.size() == 3); + assert(!parse_request({"Write", "42"}).ok); + + auto resize = parse_request({"Resize", "42", "100", "30"}); + assert(resize.ok && resize.request.command == Command::resize); + assert(resize.request.columns == 100 && resize.request.rows == 30); + assert(!parse_request({"Resize", "42", "100", "bad"}).ok); + + assert(parse_request({"CheckChild", "42"}).request.command == Command::check_child); + assert(parse_request({"Close", "42"}).request.command == Command::close); + assert(!parse_request({}).ok && !parse_request({"Bogus"}).ok); + + const std::uint64_t largest = std::numeric_limits::max(); + const std::string encoded = cp0::pty::encode_handle(largest); + std::uint64_t decoded = 0; + assert(cp0::pty::decode_handle(encoded, decoded) && decoded == largest); + assert(cp0::pty::encode_handle(0).empty()); + assert(!cp0::pty::decode_handle("18446744073709551616", decoded)); + assert(decoded == 0); + assert(!cp0::pty::decode_handle("42 ", decoded)); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_pty_runtime.cpp b/ext_components/cp0_lvgl/tests/test_pty_runtime.cpp new file mode 100644 index 00000000..e569b001 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_pty_runtime.cpp @@ -0,0 +1,93 @@ +#include "hal_lvgl_bsp.h" + +#include +#include +#include +#include + +eventpp::CallbackList, std::function)> + cp0_signal_pty_api; +eventpp::CallbackList, std::function)> + cp0_signal_config_api; + +extern "C" void init_pty(void); +extern "C" void deinit_pty(void) noexcept; + +namespace { + +struct Reply { + int code = -999; + std::string data; + int callbacks = 0; +}; + +Reply call(std::list arguments) +{ + Reply reply; + cp0_signal_pty_api(std::move(arguments), [&](int code, std::string data) { + reply.code = code; + reply.data = std::move(data); + ++reply.callbacks; + }); + return reply; +} + +} // namespace + +int main() +{ + init_pty(); + init_pty(); + + Reply invalid = call({}); + assert(invalid.callbacks == 1 && invalid.code == -1); + + Reply opened = call({"Open", "/bin/sh", "80", "24", "/bin/sh", "-c", + "printf ready; sleep 0.05; exit 7"}); + assert(opened.callbacks == 1 && opened.code == 0 && !opened.data.empty()); + const std::string handle = opened.data; + + Reply resized = call({"Resize", handle, "100", "30"}); + assert(resized.code == 0); + + std::string output; + for (int attempt = 0; attempt < 200 && output.find("ready") == std::string::npos; ++attempt) { + Reply read = call({"Read", handle, "128"}); + assert(read.code >= 0); + output += read.data; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + assert(output.find("ready") != std::string::npos); + + Reply child; + for (int attempt = 0; attempt < 200; ++attempt) { + child = call({"CheckChild", handle}); + if (child.code == 1) break; + assert(child.code == 0); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + assert(child.code == 1 && child.data == "7"); + Reply repeated_check = call({"CheckChild", handle}); + assert(repeated_check.code == 1 && repeated_check.data == "7"); + + assert(call({"Close", handle}).code == 0); + assert(call({"Close", handle}).code == -1); + assert(call({"Read", "999999999", "16"}).code == -1); + + Reply active = call({"Open", "/bin/sh", "80", "24", "/bin/sh", "-c", "sleep 30"}); + assert(active.code == 0 && !active.data.empty()); + const std::string stale_handle = active.data; + deinit_pty(); + deinit_pty(); + assert(call({"Read", stale_handle, "16"}).callbacks == 0); + + init_pty(); + Reply stale = call({"Read", stale_handle, "16"}); + assert(stale.callbacks == 1 && stale.code == -1); + Reply reopened = call({"Open", "/bin/sh", "80", "24", "/bin/sh", "-c", "exit 0"}); + assert(reopened.callbacks == 1 && reopened.code == 0); + assert(call({"Close", reopened.data}).code == 0); + deinit_pty(); + assert(call({}).callbacks == 0); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_resource_path_policy.cpp b/ext_components/cp0_lvgl/tests/test_resource_path_policy.cpp new file mode 100644 index 00000000..3d4a428a --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_resource_path_policy.cpp @@ -0,0 +1,32 @@ +#include "cp0_resource_path_policy.hpp" + +#include + +int main() +{ + using cp0::filesystem::ResourceKind; + using cp0::filesystem::classify_resource; + + assert(classify_resource("icon.PNG") == ResourceKind::image); + assert(classify_resource("photo.JpEg") == ResourceKind::image); + assert(classify_resource("share/audio/chime.OGG") == ResourceKind::audio); + assert(classify_resource("font.otf") == ResourceKind::font); + assert(classify_resource("dir.with.dot/file") == ResourceKind::none); + assert(classify_resource("file.") == ResourceKind::none); + + assert(cp0::filesystem::has_lvgl_drive("A:/share/images/icon.png")); + assert(cp0::filesystem::has_lvgl_drive("z:value")); + assert(!cp0::filesystem::has_lvgl_drive("/tmp/a.png")); + + assert(cp0::filesystem::strip_app_root_prefix( + "/usr/share/APPLaunch/share/images/icon.png") == "share/images/icon.png"); + assert(cp0::filesystem::strip_app_root_prefix("APPLaunch/share/font/a.ttf") == + "share/font/a.ttf"); + assert(cp0::filesystem::strip_app_root_prefix("other/a.ttf") == "other/a.ttf"); + + assert(cp0::filesystem::has_parent_component("../icon.png")); + assert(cp0::filesystem::has_parent_component("share/images/../icon.png")); + assert(cp0::filesystem::has_parent_component("share\\..\\icon.png")); + assert(!cp0::filesystem::has_parent_component("share/images/.../icon.png")); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_rpc_runtime_contract.cpp b/ext_components/cp0_lvgl/tests/test_rpc_runtime_contract.cpp new file mode 100644 index 00000000..cf1701bd --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_rpc_runtime_contract.cpp @@ -0,0 +1,49 @@ +#include "cp0_rpc_runtime_contract.hpp" + +#include +#include +#include + +namespace { +struct ThrowingFuture { + bool get() { throw std::runtime_error("broken promise"); } +}; +} + +int main() +{ + cp0::rpc::RuntimeState state; + assert(state.can_start()); + state.mark_started(); + assert(state.running()); + state.mark_stopping(); + assert(state.stopping()); + assert(!state.can_start()); + state.mark_stopped(); + + ThrowingFuture broken; + assert(!cp0::rpc::await_readiness(broken)); + assert(state.can_start()); + state.mark_started(); + assert(state.running()); + state.mark_stopped(); + + std::vector order; + const auto successful = cp0::rpc::shutdown_runtime( + [&] { order.push_back(1); }, + [&] { order.push_back(2); }, + [&] { order.push_back(3); }, + [&] { order.push_back(4); }); + assert(successful.context_shutdown && successful.workers_joined()); + assert((order == std::vector{1, 2, 3, 4})); + + order.clear(); + const auto failed = cp0::rpc::shutdown_runtime( + [&] { order.push_back(1); throw std::runtime_error("stop"); }, + [&] { order.push_back(2); throw std::runtime_error("context"); }, + [&] { order.push_back(3); throw std::runtime_error("subscriber"); }, + [&] { order.push_back(4); throw std::runtime_error("broker"); }); + assert(!failed.context_shutdown); + assert(!failed.workers_joined()); + assert((order == std::vector{1, 2, 3, 4})); +} diff --git a/ext_components/cp0_lvgl/tests/test_runner_contract.cpp b/ext_components/cp0_lvgl/tests/test_runner_contract.cpp new file mode 100644 index 00000000..7db0b27a --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_runner_contract.cpp @@ -0,0 +1,37 @@ +#include "cp0_runner_contract.hpp" + +#include +#include +#include +#include + +int main() +{ + cp0::RunnerLifetime lifetime; + assert(lifetime.claim()); + assert(!lifetime.claim()); + + lifetime.initialization_failed(); + assert(lifetime.claim()); + lifetime.finish(); + assert(!lifetime.claim()); + + lifetime.initialization_failed(); + assert(!lifetime.claim()); + + cp0::RunnerLifetime concurrent; + std::atomic successful_claims{0}; + std::vector threads; + for (int index = 0; index < 32; ++index) { + threads.emplace_back([&] { + if (concurrent.claim()) ++successful_claims; + }); + } + for (auto &thread : threads) thread.join(); + assert(successful_claims.load() == 1); + + concurrent.initialization_failed(); + assert(concurrent.claim()); + concurrent.finish(); + assert(!concurrent.claim()); +} diff --git a/ext_components/cp0_lvgl/tests/test_runner_resume_callback.cpp b/ext_components/cp0_lvgl/tests/test_runner_resume_callback.cpp new file mode 100644 index 00000000..7a096252 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_runner_resume_callback.cpp @@ -0,0 +1,17 @@ +#include "cp0_runner_contract.hpp" + +#include +#include + +int main() +{ + int resumes = 0; + cp0::invoke_resume_callback([&] { ++resumes; }); + assert(resumes == 1); + + cp0::invoke_resume_callback([&] { + ++resumes; + throw std::runtime_error("mutex lock"); + }); + assert(resumes == 2); +} diff --git a/ext_components/cp0_lvgl/tests/test_runner_service_shutdown.cpp b/ext_components/cp0_lvgl/tests/test_runner_service_shutdown.cpp new file mode 100644 index 00000000..acd1637d --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_runner_service_shutdown.cpp @@ -0,0 +1,68 @@ +#include "cp0_runner_shutdown.hpp" + +#include +#include +#include + +int main() +{ + std::vector order; + cp0::runner::shutdown_services( + [&] { order.push_back(1); }, + [&] { order.push_back(2); }, + [&] { order.push_back(3); }, + [&] { order.push_back(4); }, + [&] { order.push_back(5); }, + [&] { order.push_back(6); }, + [&] { order.push_back(7); }, + [&] { order.push_back(8); }, + [&] { order.push_back(9); }, + [&] { order.push_back(10); }, + [&] { order.push_back(11); }); + assert((order == std::vector{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11})); + + order.clear(); + cp0::runner::shutdown_services( + [&] { + order.push_back(1); + throw std::runtime_error("sudo shutdown"); + }, + [&] { + order.push_back(2); + throw std::runtime_error("rpc shutdown"); + }, + [&] { + order.push_back(3); + throw std::runtime_error("camera shutdown"); + }, + [&] { + order.push_back(4); + throw std::runtime_error("imu shutdown"); + }, + [&] { + order.push_back(5); + throw std::runtime_error("input shutdown"); + }, + [&] { + order.push_back(6); + throw std::runtime_error("wifi shutdown"); + }, + [&] { + order.push_back(7); + throw std::runtime_error("lora shutdown"); + }, + [&] { + order.push_back(8); + throw std::runtime_error("lora shutdown"); + }, + [&] { + order.push_back(9); + throw std::runtime_error("battery shutdown"); + }, + [&] { + order.push_back(10); + throw std::runtime_error("last service shutdown"); + }, + [&] { order.push_back(11); }); + assert((order == std::vector{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11})); +} diff --git a/ext_components/cp0_lvgl/tests/test_screen_component_detach.cpp b/ext_components/cp0_lvgl/tests/test_screen_component_detach.cpp new file mode 100644 index 00000000..6022de1b --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_screen_component_detach.cpp @@ -0,0 +1,19 @@ +#include "cp0_status_component_contract.hpp" + +#include +#include + +int main() +{ + bool first_attached = true; + bool second_attached = true; + + cp0::status::release_before_destroy( + [] { throw std::runtime_error("first deactivate"); }, + [&] { first_attached = false; }); + cp0::status::release_before_destroy( + [] {}, [&] { second_attached = false; }); + + assert(!first_attached); + assert(!second_attached); +} diff --git a/ext_components/cp0_lvgl/tests/test_screen_delete_cleanup.cpp b/ext_components/cp0_lvgl/tests/test_screen_delete_cleanup.cpp new file mode 100644 index 00000000..501ddb9a --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_screen_delete_cleanup.cpp @@ -0,0 +1,20 @@ +#include "cp0_status_component_contract.hpp" + +#include +#include + +int main() +{ + bool lifecycle_active = true; + bool top_bar_attached = true; + + cp0::status::release_before_destroy( + [&] { + lifecycle_active = false; + throw std::runtime_error("lifecycle mutex"); + }, + [&] { top_bar_attached = false; }); + + assert(!lifecycle_active); + assert(!top_bar_attached); +} diff --git a/ext_components/cp0_lvgl/tests/test_sdl_bq27220_api.cpp b/ext_components/cp0_lvgl/tests/test_sdl_bq27220_api.cpp new file mode 100644 index 00000000..f9ae2dc3 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sdl_bq27220_api.cpp @@ -0,0 +1,39 @@ +#include "hal_lvgl_bsp.h" +#include "cp0_lvgl_app.h" + +#include +#include + +eventpp::CallbackList, std::function)> + cp0_signal_bq27220_api; + +extern "C" void init_bq27220(void); + +int main() +{ + init_bq27220(); + init_bq27220(); + + int callbacks = 0; + int code = 0; + cp0_signal_bq27220_api({}, [&](int result, std::string) { + ++callbacks; + code = result; + }); + assert(callbacks == 1 && code == -1); + + const cp0_battery_info_t info = cp0_battery_read(); + assert(info.valid == 1 && info.soc >= 55 && info.soc <= 96); + assert(cp0_bq27220_calibrate(0) == 0); + assert(cp0_bq27220_calibrate(3) == 0); + assert(cp0_bq27220_calibrate(-1) == -1); + assert(cp0_bq27220_calibrate(4) == -1); + + cp0_signal_bq27220_api.append( + [](std::list, std::function) { + throw std::runtime_error("signal"); + }); + assert(cp0_battery_read().valid == 0); + assert(cp0_bq27220_calibrate(0) == -1); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_sdl_camera_shutdown.cpp b/ext_components/cp0_lvgl/tests/test_sdl_camera_shutdown.cpp new file mode 100644 index 00000000..78875612 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sdl_camera_shutdown.cpp @@ -0,0 +1,46 @@ +#include "hal_lvgl_bsp.h" + +#include +#include +#include +#include +#include +#include +#include + +eventpp::CallbackList, std::function)> + cp0_signal_camera_api; + +extern "C" void init_camera(void); +extern "C" void deinit_camera(void) noexcept; + +int main() +{ + static_assert(noexcept(deinit_camera())); + init_camera(); + + std::atomic frames{0}; + cp0_signal_camera_api({"SetFrameCallback"}, + [&](int, std::string) { ++frames; }); + cp0_signal_camera_api({"Start", "8", "8"}, nullptr); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (frames.load() < 2 && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + assert(frames.load() >= 2); + + deinit_camera(); + const int stopped_frames = frames.load(); + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + assert(frames.load() == stopped_frames); + int callbacks = 0; + cp0_signal_camera_api({"Status"}, [&](int, std::string) { ++callbacks; }); + assert(callbacks == 0); + + init_camera(); + cp0_signal_camera_api({"Status"}, [&](int code, std::string) { + ++callbacks; + assert(code == 1); + }); + assert(callbacks == 1); + deinit_camera(); +} diff --git a/ext_components/cp0_lvgl/tests/test_sdl_external_app_runner.cpp b/ext_components/cp0_lvgl/tests/test_sdl_external_app_runner.cpp new file mode 100644 index 00000000..df6a1d7f --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sdl_external_app_runner.cpp @@ -0,0 +1,18 @@ +#include "../src/sdl/sdl_external_app_runner.hpp" + +#include +#include + +int main() +{ + assert(sdl_external_app_runner::run("exit 7", nullptr) == 7); + + volatile int exit_requested = 1; + const auto start = std::chrono::steady_clock::now(); + assert(sdl_external_app_runner::run("trap '' TERM; while :; do sleep 1; done", + &exit_requested) == -1); + const auto elapsed = std::chrono::steady_clock::now() - start; + assert(elapsed >= std::chrono::seconds(5)); + assert(elapsed < std::chrono::seconds(8)); + assert(exit_requested == 0); +} diff --git a/ext_components/cp0_lvgl/tests/test_sdl_filesystem_api.cpp b/ext_components/cp0_lvgl/tests/test_sdl_filesystem_api.cpp new file mode 100644 index 00000000..4cc94847 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sdl_filesystem_api.cpp @@ -0,0 +1,65 @@ +#include "cp0_lvgl_app.h" +#include "hal/hal_paths.h" +#include "hal_lvgl_bsp.h" + +#include +#include +#include + +eventpp::CallbackList, std::function)> + cp0_signal_filesystem_api; + +extern "C" void init_filesystem(void); + +namespace { + +struct Reply { + int code = -999; + std::string data; + int callbacks = 0; +}; + +Reply call(std::list arguments) +{ + Reply reply; + cp0_signal_filesystem_api(std::move(arguments), [&](int code, std::string data) { + reply.code = code; + reply.data = std::move(data); + ++reply.callbacks; + }); + return reply; +} + +} // namespace + +int main() +{ + hal_paths_init("/opt/test-data"); + init_filesystem(); + init_filesystem(); + + Reply missing = call({}); + assert(missing.callbacks == 1 && missing.code == -1); + assert(cp0_file_path("icon.PNG") == "/opt/test-data/APPLaunch/share/images/icon.PNG"); + assert(cp0_file_path("APPLaunch/share/font/a.ttf") == + "/opt/test-data/APPLaunch/share/font/a.ttf"); + assert(cp0_file_path("share/audio/tone.ogg") == + "/opt/test-data/APPLaunch/share/audio/tone.ogg"); + assert(cp0_file_path("share/images/../secret.png").empty()); + assert(cp0_file_path("plain.txt") == "plain.txt"); + + Reply started = call({"WatchStart", "/tmp"}); + assert(started.code == 0 && !started.data.empty()); + Reply polled = call({"WatchPoll", started.data}); + assert(polled.code == 0 && (polled.data == "0" || polled.data == "1")); + assert(call({"WatchPoll", "123456789"}).code == -1); + assert(call({"WatchStop", started.data}).code == 0); + assert(call({"WatchStop", started.data}).code == -1); + + cp0_watcher_t watcher = cp0_dir_watch_start("/tmp"); + assert(watcher != nullptr && cp0_dir_watch_poll(watcher) >= 0); + cp0_dir_watch_stop(watcher); + cp0_dir_watch_stop(watcher); + assert(cp0_dir_watch_poll(watcher) == -1); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_sdl_lora_registration.cpp b/ext_components/cp0_lvgl/tests/test_sdl_lora_registration.cpp new file mode 100644 index 00000000..606006e0 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sdl_lora_registration.cpp @@ -0,0 +1,66 @@ +#include "cp0_lora_contract.hpp" +#include "hal_lvgl_bsp.h" + +#include + +eventpp::CallbackList, std::function)> + cp0_signal_lora_api; + +extern "C" void init_lora(void); +extern "C" void deinit_lora(void) noexcept; + +int main() +{ + static_assert(noexcept(deinit_lora())); + init_lora(); + int callbacks = 0; + cp0_signal_lora_api({"Init"}, [&](int code, std::string) { + ++callbacks; + assert(code == 0); + }); + assert(callbacks == 1); + cp0_signal_lora_api({"SetTxMode", "1"}, [](int code, std::string) { + assert(code == 0); + }); + + init_lora(); + callbacks = 0; + cp0_lora_info_t info{}; + cp0_signal_lora_api({"Info"}, [&](int code, std::string payload) { + ++callbacks; + assert(code == 0); + assert(cp0::lora::decode_info(payload, &info)); + }); + assert(callbacks == 1); + assert(info.initialized == 1); + assert(info.tx_mode == 1); + + deinit_lora(); + callbacks = 0; + cp0_signal_lora_api({"Info"}, [&](int, std::string) { ++callbacks; }); + assert(callbacks == 0); + + init_lora(); + cp0_signal_lora_api({"Info"}, [&](int code, std::string payload) { + ++callbacks; + assert(code == 0); + assert(cp0::lora::decode_info(payload, &info)); + }); + assert(callbacks == 1); + assert(info.initialized == 0); + + callbacks = 0; + cp0_signal_lora_api({"Init"}, [&](int code, std::string) { + ++callbacks; + assert(code == 0); + }); + assert(callbacks == 1); + callbacks = 0; + cp0_signal_lora_api({"Info"}, [&](int code, std::string payload) { + ++callbacks; + assert(code == 0); + assert(cp0::lora::decode_info(payload, &info)); + }); + assert(callbacks == 1); + assert(info.initialized == 1); +} diff --git a/ext_components/cp0_lvgl/tests/test_settings_policy.cpp b/ext_components/cp0_lvgl/tests/test_settings_policy.cpp new file mode 100644 index 00000000..c5b9751c --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_settings_policy.cpp @@ -0,0 +1,74 @@ +#include "cp0_settings_policy.hpp" + +#include +#include + +using cp0::settings::Arguments; +using cp0::settings::Command; +using cp0::settings::PowerOutput; + +int main() +{ + assert(cp0::settings::command_from({}) == Command::Unknown); + assert(cp0::settings::command_from({"BacklightRead"}) == Command::BacklightRead); + assert(cp0::settings::command_from({"BacklightMax"}) == Command::BacklightMax); + assert(cp0::settings::command_from({"BacklightWrite", "50"}) == Command::BacklightWrite); + assert(cp0::settings::command_from({"Log"}) == Command::Log); + assert(cp0::settings::command_from({"TimeStr"}) == Command::TimeStr); + assert(cp0::settings::command_from({"GpioSet"}) == Command::GpioSet); + assert(cp0::settings::command_from({"GpioGet"}) == Command::GpioGet); + assert(cp0::settings::command_from({"backlightread"}) == Command::Unknown); + assert(cp0::settings::valid_arguments({"BacklightRead"})); + assert(!cp0::settings::valid_arguments({"BacklightRead", "junk"})); + assert(cp0::settings::valid_arguments({"BacklightWrite", "0"})); + assert(cp0::settings::valid_arguments({"BacklightWrite", "100"})); + assert(!cp0::settings::valid_arguments({"BacklightWrite"})); + assert(!cp0::settings::valid_arguments({"BacklightWrite", "12junk"})); + assert(!cp0::settings::valid_arguments({"BacklightWrite", "-1"})); + assert(cp0::settings::valid_arguments({"GpioSet", "GROVE5V", "1"})); + assert(cp0::settings::valid_arguments({"GpioSet", "EXT5V", "0"})); + assert(!cp0::settings::valid_arguments({"GpioSet", "EXT5V", "-12"})); + assert(!cp0::settings::valid_arguments({"GpioSet", "unknown", "1"})); + assert(cp0::settings::valid_arguments({"GpioGet", "GROVE5V"})); + assert(!cp0::settings::valid_arguments({"GpioGet", "unknown"})); + assert(cp0::settings::valid_arguments({"Log", "topic", "message"})); + assert(!cp0::settings::valid_arguments({"Log", "topic"})); + + const Arguments arguments{"GpioSet", "GROVE5V", "1"}; + assert(cp0::settings::argument_at(arguments, 0) == "GpioSet"); + assert(cp0::settings::argument_at(arguments, 1) == "GROVE5V"); + assert(cp0::settings::argument_at(arguments, 3).empty()); + int parsed = -1; + assert(cp0::settings::integer_argument(arguments, 2, 0, 1, parsed) && parsed == 1); + assert(!cp0::settings::integer_argument(arguments, 3, 0, 1, parsed)); + assert(!cp0::settings::integer_argument({"BacklightWrite", "invalid"}, 1, 0, 100, parsed)); + assert(cp0::settings::parse_integer_response("2147483647", 0, INT_MAX, parsed) && + parsed == INT_MAX); + assert(!cp0::settings::parse_integer_response("12junk", 0, INT_MAX, parsed)); + assert(!cp0::settings::parse_integer_response(" 12", 0, INT_MAX, parsed)); + assert(!cp0::settings::parse_integer_response("-1", 0, INT_MAX, parsed)); + assert(cp0::settings::parse_integer_file_line("12\n", 0, 100, parsed) && parsed == 12); + assert(cp0::settings::parse_integer_file_line("12\r\n", 0, 100, parsed) && parsed == 12); + assert(!cp0::settings::parse_integer_file_line("12junk\n", 0, 100, parsed)); + assert(cp0::settings::parse_switch_response("0", parsed) && parsed == 0); + assert(cp0::settings::parse_switch_response("1", parsed) && parsed == 1); + assert(!cp0::settings::parse_switch_response("2", parsed)); + + assert(cp0::settings::power_output_from_name("GROVE5V") == PowerOutput::Grove5V); + assert(cp0::settings::power_output_from_name("extport_usb") == PowerOutput::Grove5V); + assert(cp0::settings::power_output_from_name("EXT5V") == PowerOutput::Ext5V); + assert(cp0::settings::power_output_from_name("extport_5vout") == PowerOutput::Ext5V); + assert(cp0::settings::power_output_from_name("") == PowerOutput::Unknown); + assert(cp0::settings::power_output_from_name("ext5v") == PowerOutput::Unknown); + + assert(cp0::settings::switch_value(0) == 0); + assert(cp0::settings::switch_value(1) == 1); + assert(cp0::settings::switch_value(-1) == 1); + assert(cp0::settings::physical_line_value(0, false) == 0); + assert(cp0::settings::physical_line_value(8, false) == 1); + assert(cp0::settings::physical_line_value(0, true) == 1); + assert(cp0::settings::physical_line_value(8, true) == 0); + assert(cp0::settings::logical_line_value(0, true) == 1); + assert(cp0::settings::logical_line_value(1, true) == 0); + return 0; +} diff --git a/ext_components/cp0_lvgl/tests/test_signal_registration.cpp b/ext_components/cp0_lvgl/tests/test_signal_registration.cpp new file mode 100644 index 00000000..66b484da --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_signal_registration.cpp @@ -0,0 +1,211 @@ +#include "cp0_signal_registration.hpp" + +#include "eventpp/callbacklist.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class FailingList { +public: + using Callback = std::function; + + class Handle { + public: + Handle() = default; + explicit Handle(std::weak_ptr token) : token_(std::move(token)) {} + explicit operator bool() const { return !token_.expired(); } + std::weak_ptr token() const { return token_; } + + private: + std::weak_ptr token_; + }; + + Handle append(const Callback &) + { + if (fail_next) { + fail_next = false; + return {}; + } + token = std::make_shared(1); + tokens.push_back(token); + return Handle(token); + } + + bool remove(const Handle &handle) + { + const auto target = handle.token().lock(); + const auto found = std::find(tokens.begin(), tokens.end(), target); + if (!target || found == tokens.end()) + return false; + tokens.erase(found); + if (on_remove) + on_remove(); + return true; + } + + std::size_t active_count() const { return tokens.size(); } + + bool fail_next = false; + Callback on_remove; + std::shared_ptr token; + std::vector> tokens; +}; + +} // namespace + +int main() +{ + using List = eventpp::CallbackList; + using Registration = cp0::SignalRegistration; + + List signal; + int first_calls = 0; + int second_calls = 0; + auto first_instance = std::make_shared(1); + std::weak_ptr old_instance = first_instance; + { + Registration registration; + assert(!registration.registered()); + assert(registration.replace(signal, [instance = std::move(first_instance), &first_calls] { + assert(*instance == 1); + ++first_calls; + })); + signal(); + assert(first_calls == 1); + assert(registration.replace(signal, [&] { ++second_calls; })); + assert(old_instance.expired()); + signal(); + assert(first_calls == 1 && second_calls == 1); + + Registration moved(std::move(registration)); + assert(!registration.registered() && moved.registered()); + Registration assigned; + int discarded_calls = 0; + assert(assigned.replace(signal, [&] { ++discarded_calls; })); + assigned = std::move(moved); + assert(!moved.registered() && assigned.registered()); + signal(); + assert(second_calls == 2 && discarded_calls == 0); + assigned.reset(); + assigned.reset(); + assert(!assigned.registered()); + } + signal(); + assert(second_calls == 2); + + FailingList failing; + cp0::SignalRegistration failure_registration; + assert(failure_registration.replace(failing, [] {})); + failing.fail_next = true; + assert(!failure_registration.replace(failing, [] {})); + assert(failure_registration.registered()); + bool remove_callback_ran = false; + failing.on_remove = [&] { + (void)failure_registration.registered(); + remove_callback_ran = true; + }; + failure_registration.reset(); + assert(remove_callback_ran); + assert(!failure_registration.registered()); + + List concurrent_signal; + Registration concurrent; + std::atomic callback_calls{0}; + std::vector threads; + for (int index = 0; index < 16; ++index) { + threads.emplace_back([&] { + assert(concurrent.replace(concurrent_signal, [&] { ++callback_calls; })); + }); + } + for (auto &thread : threads) + thread.join(); + concurrent_signal(); + assert(callback_calls.load() == 1); + + using Bundle = cp0::SignalRegistrationBundle; + List first_signal; + List second_signal; + int old_first_calls = 0; + int old_second_calls = 0; + int new_first_calls = 0; + int new_second_calls = 0; + { + Bundle bundle; + assert(bundle.replace(cp0::bind_signal(first_signal, [&] { ++old_first_calls; }), + cp0::bind_signal(second_signal, [&] { ++old_second_calls; }))); + assert(bundle.registered()); + first_signal(); + second_signal(); + assert(old_first_calls == 1 && old_second_calls == 1); + + assert(bundle.replace(cp0::bind_signal(first_signal, [&] { ++new_first_calls; }), + cp0::bind_signal(second_signal, [&] { ++new_second_calls; }))); + first_signal(); + second_signal(); + assert(old_first_calls == 1 && old_second_calls == 1); + assert(new_first_calls == 1 && new_second_calls == 1); + } + first_signal(); + second_signal(); + assert(new_first_calls == 1 && new_second_calls == 1); + + FailingList first_failing; + FailingList second_failing; + cp0::SignalRegistrationBundle failing_bundle; + assert(failing_bundle.replace(cp0::bind_signal(first_failing, [] {}), + cp0::bind_signal(second_failing, [] {}))); + assert(first_failing.active_count() == 1 && second_failing.active_count() == 1); + second_failing.fail_next = true; + assert(!failing_bundle.replace(cp0::bind_signal(first_failing, [] {}), + cp0::bind_signal(second_failing, [] {}))); + assert(failing_bundle.registered()); + assert(first_failing.active_count() == 1 && second_failing.active_count() == 1); + failing_bundle.reset(); + assert(first_failing.active_count() == 0 && second_failing.active_count() == 0); + + FailingList null_binding_first; + FailingList null_binding_second; + cp0::SignalRegistrationBundle null_binding_bundle; + int retained_calls = 0; + assert(null_binding_bundle.replace( + cp0::bind_signal(null_binding_first, [&] { ++retained_calls; }), + cp0::bind_signal(null_binding_second, [&] { ++retained_calls; }))); + cp0::SignalBinding missing_owner{ + nullptr, FailingList::Callback([] {})}; + assert(!null_binding_bundle.replace( + std::move(missing_owner), + cp0::bind_signal(null_binding_second, [] {}))); + assert(null_binding_bundle.registered()); + assert(null_binding_first.active_count() == 1); + assert(null_binding_second.active_count() == 1); + null_binding_bundle.reset(); + assert(null_binding_first.active_count() == 0); + assert(null_binding_second.active_count() == 0); + + List concurrent_first; + List concurrent_second; + Bundle concurrent_bundle; + std::atomic concurrent_first_calls{0}; + std::atomic concurrent_second_calls{0}; + threads.clear(); + for (int index = 0; index < 16; ++index) { + threads.emplace_back([&] { + assert(concurrent_bundle.replace( + cp0::bind_signal(concurrent_first, [&] { ++concurrent_first_calls; }), + cp0::bind_signal(concurrent_second, [&] { ++concurrent_second_calls; }))); + }); + } + for (auto &thread : threads) + thread.join(); + concurrent_first(); + concurrent_second(); + assert(concurrent_first_calls.load() == 1); + assert(concurrent_second_calls.load() == 1); +} diff --git a/ext_components/cp0_lvgl/tests/test_soundcard_contract.cpp b/ext_components/cp0_lvgl/tests/test_soundcard_contract.cpp new file mode 100644 index 00000000..4e04e416 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_soundcard_contract.cpp @@ -0,0 +1,82 @@ +#include "cp0_soundcard_contract.hpp" +#include "cp0_callback_contract.hpp" + +#include +#include +#include +#include + +int main() +{ + cp0::soundcard::Operations operations; + int received_card = -1; + std::string received_control; + std::string received_value; + operations.read_cards = [] { + return std::string(" 0 [PCH ]: HDA-Intel - Built-in Audio\n"); + }; + operations.read_controls = [&](int card) { + received_card = card; + return std::string( + "Simple mixer control 'Master',0\n" + " Capabilities: pvolume\n" + " Limits: Playback 0 - 100\n" + " Mono: Playback 50 [50%]\n"); + }; + operations.read_control_detail = [&](int card, const std::string &control) { + received_card = card; + received_control = control; + return std::string("detail"); + }; + operations.set_control = [&](int card, const std::string &control, const std::string &value) { + received_card = card; + received_control = control; + received_value = value; + return 5; + }; + + auto result = cp0::soundcard::dispatch({"ListCards"}, operations); + assert(result.code == 0 && result.payload == "0\tCard 0: Built-in Audio\n"); + result = cp0::soundcard::dispatch({"ListControls", "12"}, operations); + assert(result.code == 0 && received_card == 12); + assert(result.payload == "Master\tINTEGER\t0\t100\t1\tMono: Playback 50 [50%]\t50\n"); + result = cp0::soundcard::dispatch({"GetControlDetail", "2", "Master"}, operations); + assert(result.code == 0 && result.payload == "detail"); + assert(received_card == 2 && received_control == "Master"); + result = cp0::soundcard::dispatch({"SetControl", "3", "Input Source", "Line"}, operations); + assert(result.code == 5 && result.payload == "5"); + assert(received_card == 3 && received_control == "Input Source" && received_value == "Line"); + + for (const std::string invalid : {"", "-1", "+1", "1junk", " 1", "2147483648", + "999999999999999999999999"}) { + assert(cp0::soundcard::dispatch({"ListControls", invalid}, operations).code == -1); + } + assert(cp0::soundcard::dispatch({"ListControls", std::to_string(INT_MAX)}, operations).code == 0); + assert(received_card == INT_MAX); + assert(cp0::soundcard::dispatch({"GetControlDetail", "0", ""}, operations).code == -1); + assert(cp0::soundcard::dispatch({"SetControl", "0", "Master", ""}, operations).code == -1); + assert(cp0::soundcard::dispatch({}, operations).payload == "unknown soundcard api command"); + assert(cp0::soundcard::dispatch({"Unknown"}, operations).code == -1); + + cp0::soundcard::Operations unavailable; + unavailable.unavailable_message = "not supported on SDL"; + unavailable.unknown_command_message = "not supported on SDL"; + result = cp0::soundcard::dispatch({"ListCards"}, unavailable); + assert(result.code == -1 && result.payload == "not supported on SDL"); + assert(cp0::soundcard::dispatch({"ListControls", "0"}, unavailable).payload == + "not supported on SDL"); + assert(cp0::soundcard::dispatch({"Unknown"}, unavailable).payload == "not supported on SDL"); + + operations.read_cards = []() -> std::string { throw std::runtime_error("backend"); }; + result = cp0::soundcard::dispatch({"ListCards"}, operations); + assert(result.code == -1 && result.payload == "soundcard backend failure"); + + int callback_calls = 0; + cp0::callback::invoke([&](int code, std::string payload) { + ++callback_calls; + assert(code == -1 && payload == "soundcard backend failure"); + throw std::runtime_error("callback"); + }, result.code, result.payload); + assert(callback_calls == 1); + cp0::callback::invoke({}, 0, "ignored"); +} diff --git a/ext_components/cp0_lvgl/tests/test_status_component_contract.cpp b/ext_components/cp0_lvgl/tests/test_status_component_contract.cpp new file mode 100644 index 00000000..2d75f174 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_status_component_contract.cpp @@ -0,0 +1,20 @@ +#include "cp0_status_component_contract.hpp" + +#include + +int main() +{ + int first = 1; + int second = 2; + int *complete[] = {&first, &second}; + int *partial[] = {&first, nullptr}; + int *single[] = {&first}; + + assert(cp0::status::resources_ready(nullptr, 0)); + assert(!cp0::status::resources_ready(nullptr, 1)); + assert(cp0::status::resources_ready(single, 1)); + single[0] = nullptr; + assert(!cp0::status::resources_ready(single, 1)); + assert(cp0::status::resources_ready(complete, 2)); + assert(!cp0::status::resources_ready(partial, 2)); +} diff --git a/ext_components/cp0_lvgl/tests/test_status_lifecycle.cpp b/ext_components/cp0_lvgl/tests/test_status_lifecycle.cpp new file mode 100644 index 00000000..16f3b453 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_status_lifecycle.cpp @@ -0,0 +1,106 @@ +#include "cp0_status_lifecycle.hpp" + +#include +#include +#include +#include +#include + +int main() +{ + cp0::status::Lifecycle lifecycle; + int creates = 0; + std::vector releases; + std::vector factories; + for (int id = 1; id <= 3; ++id) { + factories.push_back([&, id] { + ++creates; + return cp0::status::Resource{ + true, [&, id] { releases.push_back(id); }}; + }); + } + assert(lifecycle.start(factories)); + assert(lifecycle.start(factories)); + assert(lifecycle.active() && creates == 3); + lifecycle.stop(); + lifecycle.stop(); + assert(!lifecycle.active()); + assert((releases == std::vector{3, 2, 1})); + + cp0::status::Lifecycle rollback; + releases.clear(); + assert(!rollback.start({ + [&] { return cp0::status::Resource{true, [&] { releases.push_back(1); }}; }, + [&] { return cp0::status::Resource{true, [&] { releases.push_back(2); }}; }, + [] { return cp0::status::Resource{}; }, + })); + assert(!rollback.active()); + assert((releases == std::vector{2, 1})); + + cp0::status::Lifecycle throwing_factory; + releases.clear(); + assert(!throwing_factory.start({ + [&] { return cp0::status::Resource{true, [&] { + assert(!throwing_factory.active()); + releases.push_back(1); + }}; }, + []() -> cp0::status::Resource { throw std::runtime_error("factory"); }, + })); + assert(!throwing_factory.active()); + assert((releases == std::vector{1})); + + cp0::status::Lifecycle throwing_release; + releases.clear(); + assert(throwing_release.start({ + [&] { return cp0::status::Resource{true, [&] { releases.push_back(1); }}; }, + [] { return cp0::status::Resource{true, [] { throw std::runtime_error("release"); }}; }, + [&] { return cp0::status::Resource{true, [&] { releases.push_back(3); }}; }, + })); + throwing_release.stop(); + assert(!throwing_release.active()); + assert((releases == std::vector{3, 1})); + + std::atomic concurrent_creates{0}; + cp0::status::Lifecycle concurrent; + std::vector threads; + for (int index = 0; index < 12; ++index) { + threads.emplace_back([&] { + assert(concurrent.start({[&] { + ++concurrent_creates; + return cp0::status::Resource{true, [] {}}; + }})); + }); + } + for (auto &thread : threads) + thread.join(); + assert(concurrent_creates.load() == 1); + + cp0::status::Lifecycle reentrant_factory; + int reentrant_releases = 0; + assert(!reentrant_factory.start({[&] { + assert(!reentrant_factory.active()); + reentrant_factory.stop(); + return cp0::status::Resource{ + true, [&] { ++reentrant_releases; }}; + }})); + assert(!reentrant_factory.active()); + assert(reentrant_releases == 1); + + cp0::status::Lifecycle externally_deleted; + int timer_creates = 0; + int timer_releases = 0; + auto enable = [&] { + return externally_deleted.start({[&] { + ++timer_creates; + return cp0::status::Resource{ + true, [&] { ++timer_releases; }}; + }}); + }; + assert(enable()); + externally_deleted.stop(); + assert(!externally_deleted.active()); + assert(timer_creates == 1 && timer_releases == 1); + assert(enable()); + externally_deleted.stop(); + assert(timer_creates == 2 && timer_releases == 2); +} diff --git a/ext_components/cp0_lvgl/tests/test_status_mount_rollback.cpp b/ext_components/cp0_lvgl/tests/test_status_mount_rollback.cpp new file mode 100644 index 00000000..21067d67 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_status_mount_rollback.cpp @@ -0,0 +1,30 @@ +#include "cp0_status_component_contract.hpp" + +#include +#include + +int main() +{ + int rollbacks = 0; + assert(cp0::status::initialize_with_rollback( + [] {}, [] { return true; }, [&] { ++rollbacks; })); + assert(rollbacks == 0); + + assert(!cp0::status::initialize_with_rollback( + [] {}, [] { return false; }, [&] { ++rollbacks; })); + assert(rollbacks == 1); + + assert(!cp0::status::initialize_with_rollback( + [] { throw std::runtime_error("on_create"); }, + [] { return true; }, [&] { ++rollbacks; })); + assert(rollbacks == 2); + + assert(!cp0::status::initialize_with_rollback( + [] {}, []() -> bool { throw std::runtime_error("ready"); }, + [&] { ++rollbacks; })); + assert(rollbacks == 3); + + assert(!cp0::status::initialize_with_rollback( + [] {}, [] { return false; }, + [] { throw std::runtime_error("rollback"); })); +} diff --git a/ext_components/cp0_lvgl/tests/test_sudo_c_api_boundary.cpp b/ext_components/cp0_lvgl/tests/test_sudo_c_api_boundary.cpp new file mode 100644 index 00000000..4a83121c --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sudo_c_api_boundary.cpp @@ -0,0 +1,17 @@ +#include "cp0_sudo_c_api_boundary.hpp" + +#include +#include +#include + +struct Operation { + int operator()() const { return 0; } +}; + +int main() +{ + static_assert(noexcept(cp0_sudo::invoke_c_api(Operation{}))); + assert(cp0_sudo::invoke_c_api([] { return 17; }) == 17); + assert(cp0_sudo::invoke_c_api([]() -> int { throw std::bad_alloc(); }) == -ENOMEM); + assert(cp0_sudo::invoke_c_api([]() -> int { throw 1; }) == -EIO); +} diff --git a/ext_components/cp0_lvgl/tests/test_sudo_coordinator.cpp b/ext_components/cp0_lvgl/tests/test_sudo_coordinator.cpp index 52eec7e1..13c29611 100644 --- a/ext_components/cp0_lvgl/tests/test_sudo_coordinator.cpp +++ b/ext_components/cp0_lvgl/tests/test_sudo_coordinator.cpp @@ -4,6 +4,7 @@ #include #include #include +#include using namespace cp0_sudo; @@ -138,15 +139,18 @@ int main() Coordinator c(16, 2); c.enqueue(request(1), 0); c.submit_password(1); + auto authenticated = c.worker_authenticated(1); + assert(authenticated.size() == 1); + assert(authenticated[0].type == ActionType::DESTROY_PROMPT); + assert(c.worker_authenticated(1).empty()); assert(c.worker_output(1, "one") == OutputResult::ACCEPTED); assert(c.worker_output(1, "two") == OutputResult::ACCEPTED); c.worker_complete(1, CP0_SUDO_RESULT_SUCCESS, 0); auto all = c.tick(0, {}); - assert(all.size() == 4); - assert(all[0].type == ActionType::DESTROY_PROMPT); + assert(all.size() == 3); + assert(all[0].type == ActionType::CALL_OUTPUT); assert(all[1].type == ActionType::CALL_OUTPUT); - assert(all[2].type == ActionType::CALL_OUTPUT); - assert(all[3].type == ActionType::CALL_COMPLETE); + assert(all[2].type == ActionType::CALL_COMPLETE); c.output_delivered(1, 3); c.output_delivered(1, 3); c.worker_complete(1, CP0_SUDO_RESULT_SUCCESS, 0); @@ -200,4 +204,82 @@ int main() } assert(completes == 2); } + { + Coordinator c; + auto running = request(1); + auto queued = request(2); + auto reserved = request(3); + c.enqueue(running, 0); + c.enqueue(queued, 0); + assert(c.reserve(reserved)); + c.submit_password(1); + + auto shutdown = c.begin_shutdown(-ESHUTDOWN, 10); + assert(!c.accepting()); + assert(has(shutdown, ActionType::DESTROY_PROMPT, 1)); + assert(running->cancel_requested.load()); + assert(c.find(1) == running); + assert(c.find(2) == nullptr); + assert(c.find(3) == nullptr); + assert(!c.reserve(request(4))); + assert(c.enqueue(request(5), 10).empty()); + assert(c.begin_shutdown(-EIO, 11).empty()); + + auto early = c.tick(10, {}); + int early_completions = 0; + for (const auto &action : early) { + assert(action.type != ActionType::START_WORKER); + if (action.type == ActionType::CALL_COMPLETE) { + ++early_completions; + assert(action.request->id == 2 || action.request->id == 3); + assert(action.exit_code == -ESHUTDOWN); + } + } + assert(early_completions == 2); + + c.worker_complete(1, CP0_SUDO_RESULT_SUCCESS, 0); + c.worker_complete(1, CP0_SUDO_RESULT_SUCCESS, 0); + auto late = c.tick(12, {}); + int late_completions = 0; + for (const auto &action : late) { + if (action.type == ActionType::CALL_COMPLETE) { + ++late_completions; + assert(action.request->id == 1); + assert(action.result == CP0_SUDO_RESULT_CANCELLED); + assert(action.exit_code == -ECANCELED); + } + } + assert(late_completions == 1); + assert(c.tick(12, {}).empty()); + } + { + Coordinator c; + c.enqueue(request(1), 0); + std::vector first; + std::vector second; + std::thread one([&] { first = c.begin_shutdown(-ESHUTDOWN, 1); }); + std::thread two([&] { second = c.begin_shutdown(-ESHUTDOWN, 1); }); + one.join(); + two.join(); + assert(has(first, ActionType::DESTROY_PROMPT, 1) != + has(second, ActionType::DESTROY_PROMPT, 1)); + auto completed = c.tick(1, {}); + int completion_count = 0; + for (const auto &action : completed) + if (action.type == ActionType::CALL_COMPLETE && action.request->id == 1) + ++completion_count; + assert(completion_count == 1); + assert(c.tick(1, {}).empty()); + } + { + Coordinator c; + c.enqueue(request(1), 0); + auto shutdown = c.begin_shutdown(-ESHUTDOWN, 1); + assert(has(shutdown, ActionType::DESTROY_PROMPT, 1)); + auto completed = c.tick(1, {}); + assert(has(completed, ActionType::CALL_COMPLETE, 1)); + assert(c.resume()); + assert(c.accepting()); + assert(has(c.enqueue(request(2), 2), ActionType::SHOW_PROMPT, 2)); + } } diff --git a/ext_components/cp0_lvgl/tests/test_sudo_request_factory.cpp b/ext_components/cp0_lvgl/tests/test_sudo_request_factory.cpp new file mode 100644 index 00000000..862cd4cc --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sudo_request_factory.cpp @@ -0,0 +1,63 @@ +#include "../src/cp0_sudo_request_factory.hpp" + +#include +#include +#include +#include + +using cp0_sudo::RequestFactory; + +namespace { + +void output_callback(const char *, size_t, void *) {} +void complete_callback(cp0_sudo_result_t, int, void *) {} + +} // namespace + +int main() +{ + RequestFactory factory(41); + const char *argv[] = {"program", "", "argument", nullptr}; + int user = 7; + auto built = factory.create_argv(argv, CP0_SUDO_CALLBACK_WORKER, + output_callback, complete_callback, &user, 1200, 3400); + assert(built.error == 0); + assert(built.request); + assert(built.request->id == 41); + assert(built.request->argv.size() == 3); + assert(built.request->argv[1].empty()); + assert(built.request->callback_thread == CP0_SUDO_CALLBACK_WORKER); + assert(built.request->output_cb == output_callback); + assert(built.request->complete_cb == complete_callback); + assert(built.request->user == &user); + assert(built.request->auth_timeout_ms == 1200); + assert(built.request->exec_timeout_ms == 3400); + assert(!built.request->use_login_shell); + + auto shell = factory.create_shell("printf ok", CP0_SUDO_CALLBACK_LVGL, + nullptr, nullptr, nullptr); + assert(shell.error == 0); + assert(shell.request->id == 42); + assert(shell.request->argv == std::vector{"printf ok"}); + assert(shell.request->use_login_shell); + assert(shell.request->auth_timeout_ms == 0); + assert(shell.request->exec_timeout_ms == 0); + + const char *empty_argv[] = {"", nullptr}; + assert(factory.create_argv(nullptr, CP0_SUDO_CALLBACK_LVGL, nullptr, + nullptr, nullptr, 0, 0).error == -EINVAL); + assert(factory.create_argv(empty_argv, CP0_SUDO_CALLBACK_LVGL, nullptr, + nullptr, nullptr, 0, 0).error == -EINVAL); + assert(factory.create_argv(argv, static_cast(99), + nullptr, nullptr, nullptr, 0, 0).error == -EINVAL); + assert(factory.create_shell(nullptr, CP0_SUDO_CALLBACK_LVGL, nullptr, + nullptr, nullptr).error == -EINVAL); + assert(factory.create_shell("", CP0_SUDO_CALLBACK_LVGL, nullptr, + nullptr, nullptr).error == -EINVAL); + assert(factory.create_shell("true", static_cast(99), + nullptr, nullptr, nullptr).error == -EINVAL); + + auto next = factory.create_shell("true", CP0_SUDO_CALLBACK_LVGL, + nullptr, nullptr, nullptr); + assert(next.request->id == 43); +} diff --git a/ext_components/cp0_lvgl/tests/test_sudo_timer_callback_boundary.cpp b/ext_components/cp0_lvgl/tests/test_sudo_timer_callback_boundary.cpp new file mode 100644 index 00000000..cdda4039 --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sudo_timer_callback_boundary.cpp @@ -0,0 +1,18 @@ +#include "cp0_callback_contract.hpp" + +#include +#include +#include + +int main() +{ + std::vector actions; + cp0::callback::invoke_direct([&] { actions.push_back(1); }); + assert((actions == std::vector{1})); + + cp0::callback::invoke_direct([&] { + actions.push_back(2); + throw std::runtime_error("timer action"); + }); + assert((actions == std::vector{1, 2})); +} diff --git a/ext_components/cp0_lvgl/tests/test_sudo_worker_registry.cpp b/ext_components/cp0_lvgl/tests/test_sudo_worker_registry.cpp new file mode 100644 index 00000000..e041d86e --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sudo_worker_registry.cpp @@ -0,0 +1,72 @@ +#include "cp0_sudo_worker_registry.hpp" + +#include +#include +#include +#include +#include + +int main() +{ + using cp0_sudo::JoinResult; + using cp0_sudo::WorkerRegistry; + + WorkerRegistry registry; + std::mutex mutex; + std::condition_variable wake; + std::atomic cancellations{0}; + std::atomic completions{0}; + bool cancelled = false; + + for (int i = 0; i < 2; ++i) { + assert(registry.start( + [&] { + ++cancellations; + { + std::lock_guard lock(mutex); + cancelled = true; + } + wake.notify_all(); + }, + [&] { + std::unique_lock lock(mutex); + wake.wait(lock, [&] { return cancelled; }); + ++completions; + })); + } + assert(registry.active() > 0); + registry.request_shutdown(); + registry.request_shutdown(); + assert(!registry.accepting()); + assert(cancellations.load() == 2); + assert(!registry.start([] {}, [] {})); + assert(registry.join() == JoinResult::Joined); + assert(registry.active() == 0); + assert(completions.load() == 2); + + WorkerRegistry self_join_registry; + std::mutex self_mutex; + std::condition_variable self_wake; + bool allow_exit = false; + std::atomic self_result{JoinResult::Joined}; + std::atomic recognized_worker{false}; + assert(self_join_registry.start( + [&] { + { + std::lock_guard lock(self_mutex); + allow_exit = true; + } + self_wake.notify_all(); + }, + [&] { + recognized_worker.store(self_join_registry.is_current_worker()); + self_result.store(self_join_registry.join()); + std::unique_lock lock(self_mutex); + self_wake.wait(lock, [&] { return allow_exit; }); + })); + while (self_result.load() == JoinResult::Joined) std::this_thread::yield(); + assert(self_result.load() == JoinResult::SelfJoin); + assert(recognized_worker.load()); + self_join_registry.request_shutdown(); + assert(self_join_registry.join() == JoinResult::Joined); +} diff --git a/ext_components/cp0_lvgl/tests/test_sync_signal.cpp b/ext_components/cp0_lvgl/tests/test_sync_signal.cpp new file mode 100644 index 00000000..785c0efb --- /dev/null +++ b/ext_components/cp0_lvgl/tests/test_sync_signal.cpp @@ -0,0 +1,16 @@ +#include "cp0_sync_signal.hpp" + +#include +#include + +int main() +{ + int calls = 0; + assert(cp0::signal::invoke_noexcept([&] { ++calls; })); + assert(calls == 1); + assert(!cp0::signal::invoke_noexcept([&] { + ++calls; + throw std::runtime_error("signal"); + })); + assert(calls == 2); +} diff --git a/projects/APPLaunch/SConstruct b/projects/APPLaunch/SConstruct index e02185fb..5fbdb6bb 100644 --- a/projects/APPLaunch/SConstruct +++ b/projects/APPLaunch/SConstruct @@ -2,6 +2,7 @@ from pathlib import Path import os, sys import platform import subprocess +from build_support.config_default_file import sync_config_default, sync_config_tmp """ The default cross-compilation toolchain can be specified in a custom `config_defaults.mk` file and passed via the `CONFIG_DEFAULT_FILE` @@ -28,16 +29,19 @@ if os.environ.get("CONFIG_DEFAULT_FILE") == None: if platform.machine() == 'x86_64': os.environ["CONFIG_DEFAULT_FILE"] = "linux_x86_sdl2_config_defaults.mk" +config_dir = Path("build") / "config" +selected_config = os.environ.get("CONFIG_DEFAULT_FILE", "config_defaults.mk") +sync_config_default(config_dir, selected_config) + if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ''): cross_package_enabled = True - config_tmp_path = Path("build") / "config" / "config_tmp.mk" sysroot_path_str = static_lib_path.as_posix().replace('"', r'\"') config_tmp_content = ( f'CONFIG_TOOLCHAIN_SYSROOT="{sysroot_path_str}"\n' ) - if not config_tmp_path.exists() or config_tmp_path.read_text() != config_tmp_content: - config_tmp_path.parent.mkdir(parents=True, exist_ok=True) - config_tmp_path.write_text(config_tmp_content) + sync_config_tmp(config_dir, config_tmp_content) +else: + sync_config_tmp(config_dir, None) os.environ["SDK_PATH"] = str(sdk_path) os.environ["EXT_COMPONENTS_PATH"] = str(sdk_path.parent / "ext_components") diff --git a/projects/APPLaunch/build_support/config_default_file.py b/projects/APPLaunch/build_support/config_default_file.py new file mode 100644 index 00000000..4124f136 --- /dev/null +++ b/projects/APPLaunch/build_support/config_default_file.py @@ -0,0 +1,48 @@ +from pathlib import Path + + +GENERATED_CONFIG_FILES = ( + "global_config.mk", + "global_config.h", + "lvgl_config.h", +) + + +def config_default_identity(selected_config): + selected_path = Path(selected_config).resolve() + return str(selected_path) + "\n" + selected_path.read_text(encoding="utf-8") + + +def sync_config_default(config_dir, selected_config): + config_dir = Path(config_dir) + stamp_path = config_dir / "config_default_file" + selected_identity = config_default_identity(selected_config) + previous_identity = stamp_path.read_text(encoding="utf-8") if stamp_path.exists() else None + if previous_identity == selected_identity: + return () + + removed = [] + for filename in GENERATED_CONFIG_FILES: + generated_path = config_dir / filename + if generated_path.exists(): + generated_path.unlink() + removed.append(filename) + + config_dir.mkdir(parents=True, exist_ok=True) + stamp_path.write_text(selected_identity, encoding="utf-8") + return tuple(removed) + + +def sync_config_tmp(config_dir, content): + config_tmp_path = Path(config_dir) / "config_tmp.mk" + if content is None: + if config_tmp_path.exists(): + config_tmp_path.unlink() + return True + return False + + if config_tmp_path.exists() and config_tmp_path.read_text(encoding="utf-8") == content: + return False + config_tmp_path.parent.mkdir(parents=True, exist_ok=True) + config_tmp_path.write_text(content, encoding="utf-8") + return True diff --git a/projects/APPLaunch/config_defaults.mk b/projects/APPLaunch/config_defaults.mk index e071dc96..e628c576 100644 --- a/projects/APPLaunch/config_defaults.mk +++ b/projects/APPLaunch/config_defaults.mk @@ -92,6 +92,23 @@ CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_LORA=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y +CONFIG_CP0_LVGL_INIT_CONFIG=y +CONFIG_CP0_LVGL_INIT_PTY=y +CONFIG_CP0_LVGL_INIT_RPC=y +CONFIG_CP0_LVGL_INIT_AUDIO=y +CONFIG_CP0_LVGL_INIT_PROCESS=y +CONFIG_CP0_LVGL_INIT_SUDO=y +CONFIG_CP0_LVGL_INIT_OSINFO=y +CONFIG_CP0_LVGL_INIT_SCREENSHOT=y +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_BLUETOOTH=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_CP0_LVGL_INIT_SOUNDCARD=y CONFIG_EVENTPP_ENABLED=y CONFIG_RADIOLIB_COMPONENT_ENABLED=y CONFIG_APPLAUNCH_LINUX_CP0=y diff --git a/projects/APPLaunch/darwin_config_defaults.mk b/projects/APPLaunch/darwin_config_defaults.mk index 6a1d84ba..bf5fd18a 100644 --- a/projects/APPLaunch/darwin_config_defaults.mk +++ b/projects/APPLaunch/darwin_config_defaults.mk @@ -93,7 +93,24 @@ CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_LORA=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y +CONFIG_CP0_LVGL_INIT_CONFIG=y +CONFIG_CP0_LVGL_INIT_PTY=y +CONFIG_CP0_LVGL_INIT_RPC=y +CONFIG_CP0_LVGL_INIT_AUDIO=y +CONFIG_CP0_LVGL_INIT_PROCESS=y +CONFIG_CP0_LVGL_INIT_SUDO=y +CONFIG_CP0_LVGL_INIT_OSINFO=y +CONFIG_CP0_LVGL_INIT_SCREENSHOT=y +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_BLUETOOTH=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_CP0_LVGL_INIT_SOUNDCARD=y CONFIG_EVENTPP_ENABLED=y CONFIG_RADIOLIB_COMPONENT_ENABLED=y CONFIG_APPLAUNCH_DARWIN_SDL=y -CONFIG_UTILITIES_ENABLED=y \ No newline at end of file +CONFIG_UTILITIES_ENABLED=y diff --git a/projects/APPLaunch/linux_x86_cross_cp0_config_defaults.mk b/projects/APPLaunch/linux_x86_cross_cp0_config_defaults.mk index 12b1cb18..b39f5698 100644 --- a/projects/APPLaunch/linux_x86_cross_cp0_config_defaults.mk +++ b/projects/APPLaunch/linux_x86_cross_cp0_config_defaults.mk @@ -97,6 +97,23 @@ CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_LORA=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y +CONFIG_CP0_LVGL_INIT_CONFIG=y +CONFIG_CP0_LVGL_INIT_PTY=y +CONFIG_CP0_LVGL_INIT_RPC=y +CONFIG_CP0_LVGL_INIT_AUDIO=y +CONFIG_CP0_LVGL_INIT_PROCESS=y +CONFIG_CP0_LVGL_INIT_SUDO=y +CONFIG_CP0_LVGL_INIT_OSINFO=y +CONFIG_CP0_LVGL_INIT_SCREENSHOT=y +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_BLUETOOTH=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_CP0_LVGL_INIT_SOUNDCARD=y CONFIG_EVENTPP_ENABLED=y CONFIG_RADIOLIB_COMPONENT_ENABLED=y CONFIG_APPLAUNCH_LINUX_X86_CROSS_CP0=y diff --git a/projects/APPLaunch/linux_x86_sdl2_config_defaults.mk b/projects/APPLaunch/linux_x86_sdl2_config_defaults.mk index 37b33109..649d12b1 100644 --- a/projects/APPLaunch/linux_x86_sdl2_config_defaults.mk +++ b/projects/APPLaunch/linux_x86_sdl2_config_defaults.mk @@ -93,7 +93,24 @@ CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_LORA=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y +CONFIG_CP0_LVGL_INIT_CONFIG=y +CONFIG_CP0_LVGL_INIT_PTY=y +CONFIG_CP0_LVGL_INIT_RPC=y +CONFIG_CP0_LVGL_INIT_AUDIO=y +CONFIG_CP0_LVGL_INIT_PROCESS=y +CONFIG_CP0_LVGL_INIT_SUDO=y +CONFIG_CP0_LVGL_INIT_OSINFO=y +CONFIG_CP0_LVGL_INIT_SCREENSHOT=y +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_BLUETOOTH=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_CP0_LVGL_INIT_SOUNDCARD=y CONFIG_EVENTPP_ENABLED=y CONFIG_RADIOLIB_COMPONENT_ENABLED=y CONFIG_APPLAUNCH_LINUX_X86_SDL2=y -CONFIG_UTILITIES_ENABLED=y \ No newline at end of file +CONFIG_UTILITIES_ENABLED=y diff --git a/projects/APPLaunch/mac_cross_cp0_config_defaults.mk b/projects/APPLaunch/mac_cross_cp0_config_defaults.mk index 516bd593..c4d5c5f0 100644 --- a/projects/APPLaunch/mac_cross_cp0_config_defaults.mk +++ b/projects/APPLaunch/mac_cross_cp0_config_defaults.mk @@ -98,7 +98,24 @@ CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_LORA=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y +CONFIG_CP0_LVGL_INIT_CONFIG=y +CONFIG_CP0_LVGL_INIT_PTY=y +CONFIG_CP0_LVGL_INIT_RPC=y +CONFIG_CP0_LVGL_INIT_AUDIO=y +CONFIG_CP0_LVGL_INIT_PROCESS=y +CONFIG_CP0_LVGL_INIT_SUDO=y +CONFIG_CP0_LVGL_INIT_OSINFO=y +CONFIG_CP0_LVGL_INIT_SCREENSHOT=y +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_BLUETOOTH=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_CP0_LVGL_INIT_SOUNDCARD=y CONFIG_EVENTPP_ENABLED=y CONFIG_RADIOLIB_COMPONENT_ENABLED=y CONFIG_APPLAUNCH_MAC_CROSS_CP0=y -CONFIG_UTILITIES_ENABLED=y \ No newline at end of file +CONFIG_UTILITIES_ENABLED=y diff --git a/projects/APPLaunch/main/src/main.cpp b/projects/APPLaunch/main/src/main.cpp index b3a6b135..02af11f1 100644 --- a/projects/APPLaunch/main/src/main.cpp +++ b/projects/APPLaunch/main/src/main.cpp @@ -1,95 +1,34 @@ -#ifndef _GNU_SOURCE -#define _GNU_SOURCE -#endif - /* * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD * * SPDX-License-Identifier: MIT */ -#include "lvgl/lvgl.h" -#include "lvgl/demos/lv_demos.h" -#include -#include -#include -#include -#include -#include "ui/ui.h" -#include "ui/ui_screensaver.h" +#include "cp0_lvgl_app_runner.hpp" #include "keyboard_input.h" -#include "cp0_lvgl_app.h" -#include "cp0_lvgl_file.hpp" -#include "hal_lvgl_bsp.h" -#include "global_config.h" #include "sample_log.h" +#include "ui/ui.h" +#include "ui/ui_screensaver.h" + +#include + #if CONFIG_BACKWARD_CPP_ENABLED #define BACKWARD_HAS_DW 1 #include "backward.hpp" #include "backward.h" #endif -static sem_t lvgl_sem; - -static void lvgl_wait(uint32_t milliseconds) -{ - struct timespec deadline; -#if defined(__linux__) - clock_gettime(CLOCK_MONOTONIC, &deadline); -#else - clock_gettime(CLOCK_REALTIME, &deadline); -#endif - deadline.tv_nsec += (milliseconds % 1000) * 1000000; - deadline.tv_sec += milliseconds / 1000 + deadline.tv_nsec / 1000000000; - deadline.tv_nsec %= 1000000000; - - int result; - do { -#if defined(__linux__) - result = sem_clockwait(&lvgl_sem, CLOCK_MONOTONIC, &deadline); -#else - result = sem_timedwait(&lvgl_sem, &deadline); -#endif - } while (result != 0 && errno == EINTR); -} - -static void lvgl_resume_cb(void *data) { - sem_post(&lvgl_sem); -} - int main(void) { - sem_init(&lvgl_sem, 0, 0); - lv_init(); - SLOGI("[BOOT] lv_init() done"); - lv_timer_handler_set_resume_cb(lvgl_resume_cb, NULL); - - SLOGI("[BOOT] cp0_lvgl_init() starting..."); - cp0_lvgl_init(); - SLOGI("[BOOT] cp0_lvgl_init() done"); - - if (LV_EVENT_KEYBOARD == 0) - LV_EVENT_KEYBOARD = lv_event_register_id(); - - launcher_ui::init(); - ui_screensaver_init(); - - // Force full-screen refresh immediately after init - SLOGI("[BOOT] ui_init done, forcing full refresh..."); - lv_obj_invalidate(lv_scr_act()); - lv_refr_now(NULL); - SLOGI("[BOOT] First frame flushed to fb0."); - - /*Handle LVGL tasks*/ - SLOGI("Entering main loop (FULL render mode)..."); - while (1) { - uint32_t ms = lv_timer_handler(); - if (ms == LV_NO_TIMER_READY) { - sem_wait(&lvgl_sem); // 无定时器,阻塞等事件 - } else { - lvgl_wait(ms); // 定时唤醒或被事件提前唤醒,不受系统时间调整影响 - } - } - - return 0; + Cp0LvglRunOptions options; + options.setup = []() { + SLOGI("[BOOT] cp0_lvgl initialized"); + if (LV_EVENT_KEYBOARD == 0) + LV_EVENT_KEYBOARD = lv_event_register_id(); + launcher_ui::init(); + ui_screensaver_init(); + return true; + }; + options.teardown = []() { launcher_ui::deinit(); }; + return cp0_lvgl_run(std::move(options)); } diff --git a/projects/APPLaunch/main/ui/animation/animation.hpp b/projects/APPLaunch/main/ui/animation/animation.hpp index 027f96d9..6667e04a 100644 --- a/projects/APPLaunch/main/ui/animation/animation.hpp +++ b/projects/APPLaunch/main/ui/animation/animation.hpp @@ -36,10 +36,10 @@ class LvglAnimation { LvglAnimation *self = new LvglAnimation(time, type); self->callback_ = std::move(callback); self->finished_callback_ = std::move(finished); - self->launch(obj, obj, start_val, end_val, false); + if (!self->launch(obj, obj, start_val, end_val, false)) delete self; } - static void start_raw(int time, + static bool start_raw(int time, raw_callback_t callback, finished_callback_t finished = nullptr, Type type = overshoot) @@ -47,7 +47,11 @@ class LvglAnimation { LvglAnimation *self = new LvglAnimation(time, type); self->raw_callback_ = std::move(callback); self->finished_callback_ = std::move(finished); - self->launch(nullptr, self, 0, LV_BEZIER_VAL_MAX, true); + if (!self->launch(nullptr, self, 0, LV_BEZIER_VAL_MAX, true)) { + delete self; + return false; + } + return true; } int32_t Animation_map(int32_t start, int32_t end) @@ -85,7 +89,7 @@ class LvglAnimation { { } - void launch(void *obj, void *var, int32_t start_val, int32_t end_val, bool raw) + bool launch(void *obj, void *var, int32_t start_val, int32_t end_val, bool raw) { (void)obj; raw_ = raw; @@ -106,7 +110,7 @@ class LvglAnimation { lv_anim_set_path_cb(&anim, path_cb_); } - lv_anim_start(&anim); + return lv_anim_start(&anim) != nullptr; } static LvglAnimation *from_anim(lv_anim_t *anim) @@ -150,36 +154,48 @@ class LvglAnimation { } } - static void exec_cb(lv_anim_t *anim, int32_t value) + static void exec_cb(lv_anim_t *anim, int32_t value) noexcept { - LvglAnimation *self = from_anim(anim); - if (self && self->callback_) { - self->callback_(anim->var, value); + try { + if (!anim) return; + LvglAnimation *self = from_anim(anim); + if (self && self->callback_) { + self->callback_(anim->var, value); + } + } catch (...) { } } - static void exec_cb_raw(lv_anim_t *anim, int32_t value) + static void exec_cb_raw(lv_anim_t *anim, int32_t value) noexcept { - LvglAnimation *self = from_anim(anim); - if (!self) { - return; - } - - self->current_progress_ = value; - if (self->raw_callback_) { - self->raw_callback_(self); + try { + if (!anim) return; + LvglAnimation *self = from_anim(anim); + if (!self) { + return; + } + + self->current_progress_ = value; + if (self->raw_callback_) { + self->raw_callback_(self); + } + } catch (...) { } } - static void deleted_cb(lv_anim_t *anim) + static void deleted_cb(lv_anim_t *anim) noexcept { + if (!anim) return; LvglAnimation *self = from_anim(anim); if (!self) { return; } - if (self->finished_callback_) { - self->finished_callback_(self); + try { + if (self->finished_callback_) { + self->finished_callback_(self); + } + } catch (...) { } delete self; diff --git a/projects/APPLaunch/main/ui/animation/launcher_carousel_layout.h b/projects/APPLaunch/main/ui/animation/launcher_carousel_layout.h new file mode 100644 index 00000000..1ddb577a --- /dev/null +++ b/projects/APPLaunch/main/ui/animation/launcher_carousel_layout.h @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "lvgl/lvgl.h" + +#include +#include + +namespace launcher_carousel_layout { + +struct Slot +{ + lv_coord_t x; + lv_coord_t y; + lv_coord_t width; + lv_coord_t height; + bool hidden; +}; + +inline constexpr size_t kPanelCount = 5; +inline constexpr size_t kTitleOffset = kPanelCount; +inline constexpr size_t kElementCount = kPanelCount * 2; + +inline constexpr std::array kSlots = {{ + {-177, 4, 61, 61, true}, + {-99, -6, 80, 80, false}, + {0, -16, 100, 100, false}, + {99, -6, 80, 80, false}, + {177, 4, 61, 61, true}, + {-177, 50, 0, 0, true}, + {-99, 50, 0, 0, false}, + {0, 50, 0, 0, false}, + {99, 50, 0, 0, false}, + {177, 50, 0, 0, true}, +}}; + +static_assert(kTitleOffset + kPanelCount == kElementCount); + +} // namespace launcher_carousel_layout diff --git a/projects/APPLaunch/main/ui/animation/ui_launcher_animation.cpp b/projects/APPLaunch/main/ui/animation/ui_launcher_animation.cpp index a177acfa..7be998d6 100644 --- a/projects/APPLaunch/main/ui/animation/ui_launcher_animation.cpp +++ b/projects/APPLaunch/main/ui/animation/ui_launcher_animation.cpp @@ -7,43 +7,23 @@ #include "ui_launcher_animation.h" #include "animation.hpp" +#include "launcher_carousel_layout.h" +#include #include namespace { constexpr int kLauncherAnimationTimeMs = 200; -struct LauncherSlot { - lv_coord_t x; - lv_coord_t y; - lv_coord_t w; - lv_coord_t h; -}; - struct LauncherHomeAnimContext { - lv_obj_t *items[10]; + lv_obj_t *items[launcher_carousel_layout::kElementCount]; bool to_right; launcher_home_animation::ReadyCallback ready_cb; + launcher_home_animation::AliveCallback alive_cb; }; -constexpr LauncherSlot kPanelSlots[] = { - {-177, 4, 61, 61}, - {-99, -6, 80, 80}, - {0, -16, 100, 100}, - {99, -6, 80, 80}, - {177, 4, 61, 61}, -}; - -constexpr LauncherSlot kLabelSlots[] = { - {-177, 50, 0, 0}, - {-99, 50, 0, 0}, - {0, 50, 0, 0}, - {99, 50, 0, 0}, - {177, 50, 0, 0}, -}; - -void apply_panel_slot(lv_obj_t *obj, const LauncherSlot &slot) +void apply_panel_slot(lv_obj_t *obj, const launcher_carousel_layout::Slot &slot) { if (!obj) { return; @@ -51,11 +31,11 @@ void apply_panel_slot(lv_obj_t *obj, const LauncherSlot &slot) lv_obj_set_x(obj, slot.x); lv_obj_set_y(obj, slot.y); - lv_obj_set_width(obj, slot.w); - lv_obj_set_height(obj, slot.h); + lv_obj_set_width(obj, slot.width); + lv_obj_set_height(obj, slot.height); } -void apply_label_slot(lv_obj_t *obj, const LauncherSlot &slot) +void apply_label_slot(lv_obj_t *obj, const launcher_carousel_layout::Slot &slot) { if (!obj) { return; @@ -65,7 +45,8 @@ void apply_label_slot(lv_obj_t *obj, const LauncherSlot &slot) lv_obj_set_y(obj, slot.y); } -void animate_panel(lv_obj_t *obj, const LauncherSlot &from, const LauncherSlot &to, LvglAnimation *anim) +void animate_panel(lv_obj_t *obj, const launcher_carousel_layout::Slot &from, + const launcher_carousel_layout::Slot &to, LvglAnimation *anim) { if (!obj) { return; @@ -73,11 +54,12 @@ void animate_panel(lv_obj_t *obj, const LauncherSlot &from, const LauncherSlot & lv_obj_set_x(obj, anim->Animation_map(from.x, to.x)); lv_obj_set_y(obj, anim->Animation_map(from.y, to.y)); - lv_obj_set_width(obj, anim->Animation_map(from.w, to.w)); - lv_obj_set_height(obj, anim->Animation_map(from.h, to.h)); + lv_obj_set_width(obj, anim->Animation_map(from.width, to.width)); + lv_obj_set_height(obj, anim->Animation_map(from.height, to.height)); } -void animate_label(lv_obj_t *obj, const LauncherSlot &from, const LauncherSlot &to, LvglAnimation *anim) +void animate_label(lv_obj_t *obj, const launcher_carousel_layout::Slot &from, + const launcher_carousel_layout::Slot &to, LvglAnimation *anim) { if (!obj) { return; @@ -89,30 +71,39 @@ void animate_label(lv_obj_t *obj, const LauncherSlot &from, const LauncherSlot & void animate_home(LauncherHomeAnimContext *ctx, LvglAnimation *anim) { + if (ctx->alive_cb && !ctx->alive_cb()) + return; + constexpr size_t title_offset = launcher_carousel_layout::kTitleOffset; + const auto &slots = launcher_carousel_layout::kSlots; if (ctx->to_right) { - for (int i = 0; i < 4; ++i) { - animate_panel(ctx->items[i], kPanelSlots[i], kPanelSlots[i + 1], anim); - animate_label(ctx->items[i + 5], kLabelSlots[i], kLabelSlots[i + 1], anim); + for (size_t i = 0; i + 1 < launcher_carousel_layout::kPanelCount; ++i) { + animate_panel(ctx->items[i], slots[i], slots[i + 1], anim); + animate_label(ctx->items[i + title_offset], slots[i + title_offset], + slots[i + title_offset + 1], anim); } } else { - for (int i = 1; i < 5; ++i) { - animate_panel(ctx->items[i], kPanelSlots[i], kPanelSlots[i - 1], anim); - animate_label(ctx->items[i + 5], kLabelSlots[i], kLabelSlots[i - 1], anim); + for (size_t i = 1; i < launcher_carousel_layout::kPanelCount; ++i) { + animate_panel(ctx->items[i], slots[i], slots[i - 1], anim); + animate_label(ctx->items[i + title_offset], slots[i + title_offset], + slots[i + title_offset - 1], anim); } } } -void finish_home(LauncherHomeAnimContext *ctx) +void finish_home(const std::shared_ptr &ctx) { + if (ctx->alive_cb && !ctx->alive_cb()) return; + constexpr size_t title_offset = launcher_carousel_layout::kTitleOffset; + const auto &slots = launcher_carousel_layout::kSlots; if (ctx->to_right) { - for (int i = 0; i < 4; ++i) { - apply_panel_slot(ctx->items[i], kPanelSlots[i + 1]); - apply_label_slot(ctx->items[i + 5], kLabelSlots[i + 1]); + for (size_t i = 0; i + 1 < launcher_carousel_layout::kPanelCount; ++i) { + apply_panel_slot(ctx->items[i], slots[i + 1]); + apply_label_slot(ctx->items[i + title_offset], slots[i + title_offset + 1]); } } else { - for (int i = 1; i < 5; ++i) { - apply_panel_slot(ctx->items[i], kPanelSlots[i - 1]); - apply_label_slot(ctx->items[i + 5], kLabelSlots[i - 1]); + for (size_t i = 1; i < launcher_carousel_layout::kPanelCount; ++i) { + apply_panel_slot(ctx->items[i], slots[i - 1]); + apply_label_slot(ctx->items[i + title_offset], slots[i + title_offset - 1]); } } @@ -120,41 +111,45 @@ void finish_home(LauncherHomeAnimContext *ctx) ctx->ready_cb(); } - delete ctx; } -void launcher_home_animate(lv_obj_t **items, bool to_right, launcher_home_animation::ReadyCallback ready_cb) +void launcher_home_animate(lv_obj_t **items, bool to_right, + launcher_home_animation::ReadyCallback ready_cb, + launcher_home_animation::AliveCallback alive_cb) { - auto *ctx = new LauncherHomeAnimContext{}; + auto ctx = std::make_shared(); ctx->to_right = to_right; ctx->ready_cb = ready_cb; + ctx->alive_cb = alive_cb; - for (int i = 0; i < 10; ++i) { + for (size_t i = 0; i < launcher_carousel_layout::kElementCount; ++i) { ctx->items[i] = items[i]; } - LvglAnimation::start_raw( + if (!LvglAnimation::start_raw( kLauncherAnimationTimeMs, [ctx](LvglAnimation *anim) { - animate_home(ctx, anim); + animate_home(ctx.get(), anim); }, [ctx](LvglAnimation *) { finish_home(ctx); - }); + })) { + finish_home(ctx); + } } } // namespace namespace launcher_home_animation { -void animate_right(lv_obj_t **items, ReadyCallback ready_cb) +void animate_right(lv_obj_t **items, ReadyCallback ready_cb, AliveCallback alive_cb) { - launcher_home_animate(items, true, std::move(ready_cb)); + launcher_home_animate(items, true, std::move(ready_cb), std::move(alive_cb)); } -void animate_left(lv_obj_t **items, ReadyCallback ready_cb) +void animate_left(lv_obj_t **items, ReadyCallback ready_cb, AliveCallback alive_cb) { - launcher_home_animate(items, false, std::move(ready_cb)); + launcher_home_animate(items, false, std::move(ready_cb), std::move(alive_cb)); } } // namespace launcher_home_animation diff --git a/projects/APPLaunch/main/ui/animation/ui_launcher_animation.h b/projects/APPLaunch/main/ui/animation/ui_launcher_animation.h index 88b5f03f..c86728dd 100644 --- a/projects/APPLaunch/main/ui/animation/ui_launcher_animation.h +++ b/projects/APPLaunch/main/ui/animation/ui_launcher_animation.h @@ -13,8 +13,9 @@ namespace launcher_home_animation { using ReadyCallback = std::function; +using AliveCallback = std::function; -void animate_right(lv_obj_t **items, ReadyCallback ready_cb); -void animate_left(lv_obj_t **items, ReadyCallback ready_cb); +void animate_right(lv_obj_t **items, ReadyCallback ready_cb, AliveCallback alive_cb); +void animate_left(lv_obj_t **items, ReadyCallback ready_cb, AliveCallback alive_cb); } // namespace launcher_home_animation diff --git a/projects/APPLaunch/main/ui/app_directory_watcher.cpp b/projects/APPLaunch/main/ui/app_directory_watcher.cpp new file mode 100644 index 00000000..942522f7 --- /dev/null +++ b/projects/APPLaunch/main/ui/app_directory_watcher.cpp @@ -0,0 +1,86 @@ +#include "app_directory_watcher.h" + +#include "launcher_platform.hpp" +#include "model/app_directory_watcher_contract.hpp" + +#include +#include +#include +#include + +namespace { +constexpr uint32_t WATCH_POLL_PERIOD_MS = 3000; +} + +AppDirectoryWatcher::~AppDirectoryWatcher() +{ + stop(); +} + +void AppDirectoryWatcher::start(std::function on_changed) +{ + std::lock_guard lock(mutex_); + stop(); + on_changed_.set(std::move(on_changed)); + const std::string path = launcher_platform::path("applications"); + cp0_signal_filesystem_api({"WatchStart", path}, [&](int code, std::string data) { + uintptr_t handle = 0; + watcher_ = code == 0 && parse_app_watcher_handle(data, handle) + ? reinterpret_cast(handle) : nullptr; + }); + if (watcher_) { + timer_ = lv_timer_create(timer_cb, WATCH_POLL_PERIOD_MS, this); + if (!timer_) { + cp0_signal_filesystem_api( + {"WatchStop", std::to_string(reinterpret_cast(watcher_))}, nullptr); + watcher_ = nullptr; + } + } + if (!watcher_) + on_changed_.set(nullptr); +} + +void AppDirectoryWatcher::stop() noexcept +{ + try { + std::lock_guard lock(mutex_); + on_changed_.set(nullptr); + lv_timer_t *timer = timer_; + timer_ = nullptr; + if (timer) lv_timer_delete(timer); + cp0_watcher_t watcher = watcher_; + watcher_ = nullptr; + if (!watcher) return; + try { + cp0_signal_filesystem_api( + {"WatchStop", std::to_string(reinterpret_cast(watcher))}, nullptr); + } catch (...) { + } + } catch (...) { + } +} + +void AppDirectoryWatcher::poll(lv_timer_t *source_timer) +{ + std::lock_guard lock(mutex_); + if (!app_watcher_timer_is_current(source_timer, timer_) || !watcher_) return; + int changed = 0; + cp0_signal_filesystem_api( + {"WatchPoll", std::to_string(reinterpret_cast(watcher_))}, + [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && parse_app_watcher_change_count(data, parsed)) changed = parsed; + }); + if (changed > 0) on_changed_.notify(); +} + +void AppDirectoryWatcher::timer_cb(lv_timer_t *timer) noexcept +{ + auto *watcher = static_cast(lv_timer_get_user_data(timer)); + if (!watcher) return; + try { + watcher->poll(timer); + } catch (...) { + watcher->stop(); + } +} diff --git a/projects/APPLaunch/main/ui/app_directory_watcher.h b/projects/APPLaunch/main/ui/app_directory_watcher.h new file mode 100644 index 00000000..98c7bf49 --- /dev/null +++ b/projects/APPLaunch/main/ui/app_directory_watcher.h @@ -0,0 +1,26 @@ +#pragma once + +#include "cp0_lvgl_app.h" +#include "lvgl/lvgl.h" +#include "model/app_directory_watcher_contract.hpp" + +#include +#include + +class AppDirectoryWatcher +{ +public: + ~AppDirectoryWatcher(); + + void start(std::function on_changed); + void stop() noexcept; + +private: + void poll(lv_timer_t *source_timer); + static void timer_cb(lv_timer_t *timer) noexcept; + + cp0_watcher_t watcher_ = nullptr; + lv_timer_t *timer_ = nullptr; + AppDirectoryChangeCallbackSlot on_changed_; + std::recursive_mutex mutex_; +}; diff --git a/projects/APPLaunch/main/ui/app_registry.cpp b/projects/APPLaunch/main/ui/app_registry.cpp index 92ed6178..d2a9c952 100644 --- a/projects/APPLaunch/main/ui/app_registry.cpp +++ b/projects/APPLaunch/main/ui/app_registry.cpp @@ -8,33 +8,45 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" +#include "model/app_registry_callback.hpp" +#include #include #include namespace { -LauncherAppRegistryChangedCallback g_changed_callback = nullptr; -void *g_changed_user_data = nullptr; +AppRegistryCallbackSlot g_changed_callback; int config_get_int(const char *key, int default_val) { int val = default_val; cp0_signal_config_api({"GetInt", key ? std::string(key) : std::string(), std::to_string(default_val)}, [&](int code, std::string data) { - if (code == 0) val = std::atoi(data.c_str()); + int parsed = 0; + const char *begin = data.data(); + const char *end = begin + data.size(); + const auto result = std::from_chars(begin, end, parsed); + if (code == 0 && result.ec == std::errc{} && result.ptr == end) + val = parsed; }); return val; } -void config_set_int(const char *key, int val) +bool config_set_int(const char *key, int val) { - cp0_signal_config_api({"SetInt", key ? std::string(key) : std::string(), std::to_string(val)}, nullptr); + bool succeeded = false; + cp0_signal_config_api({"SetInt", key ? std::string(key) : std::string(), std::to_string(val)}, + [&](int code, std::string) { succeeded = code == 0; }); + return succeeded; } -void config_save() +bool config_save() { - cp0_signal_config_api({"Save"}, nullptr); + bool succeeded = false; + cp0_signal_config_api({"Save"}, + [&](int code, std::string) { succeeded = code == 0; }); + return succeeded; } } // namespace @@ -43,26 +55,35 @@ bool launcher_app_registry_is_enabled(const AppDescriptor &desc) { if (desc.always_on || !desc.configurable) return true; + if (!desc.config_key || !*desc.config_key) return true; return config_get_int(desc.config_key, 1) != 0; } -void launcher_app_registry_set_enabled(const AppDescriptor &desc, bool enabled) +bool launcher_app_registry_set_enabled(const AppDescriptor &desc, bool enabled) { - if (desc.always_on || !desc.configurable) - enabled = true; - config_set_int(desc.config_key, enabled ? 1 : 0); + if (desc.always_on || !desc.configurable) return true; + if (!desc.config_key || !*desc.config_key) return false; + const int previous = config_get_int(desc.config_key, 1) != 0 ? 1 : 0; + if (!config_set_int(desc.config_key, enabled ? 1 : 0)) return false; + if (config_save()) return true; + config_set_int(desc.config_key, previous); config_save(); + return false; } void launcher_app_registry_set_changed_callback(LauncherAppRegistryChangedCallback callback, void *user_data) { - g_changed_callback = callback; - g_changed_user_data = user_data; + g_changed_callback.set(callback, user_data); +} + +bool launcher_app_registry_clear_changed_callback(LauncherAppRegistryChangedCallback callback, + void *user_data) +{ + return g_changed_callback.clear_if_matches(callback, user_data); } void launcher_app_registry_notify_changed() { - if (g_changed_callback) - g_changed_callback(g_changed_user_data); + g_changed_callback.notify(); } diff --git a/projects/APPLaunch/main/ui/app_registry.h b/projects/APPLaunch/main/ui/app_registry.h index c98c461e..62bd45d0 100644 --- a/projects/APPLaunch/main/ui/app_registry.h +++ b/projects/APPLaunch/main/ui/app_registry.h @@ -18,9 +18,11 @@ struct AppDescriptor { const AppDescriptor *launcher_app_registry_entries(std::size_t *count); bool launcher_app_registry_is_enabled(const AppDescriptor &desc); -void launcher_app_registry_set_enabled(const AppDescriptor &desc, bool enabled); +bool launcher_app_registry_set_enabled(const AppDescriptor &desc, bool enabled); typedef void (*LauncherAppRegistryChangedCallback)(void *user_data); void launcher_app_registry_set_changed_callback(LauncherAppRegistryChangedCallback callback, void *user_data); +bool launcher_app_registry_clear_changed_callback(LauncherAppRegistryChangedCallback callback, + void *user_data); void launcher_app_registry_notify_changed(); diff --git a/projects/APPLaunch/main/ui/builtin_app_registry.cpp b/projects/APPLaunch/main/ui/builtin_app_registry.cpp new file mode 100644 index 00000000..ef7760e4 --- /dev/null +++ b/projects/APPLaunch/main/ui/builtin_app_registry.cpp @@ -0,0 +1,120 @@ +#include "builtin_app_registry.hpp" + +#include "app_registry.h" +#include "launch.h" +#include "ui.h" +#include "generated/page_app.h" +#include "launcher_platform.hpp" + +#include +#include +#include + +namespace { + +using BuiltinAppAppender = void (*)(std::list &apps, const AppDescriptor &desc); + +struct BuiltinAppRegistration { + AppDescriptor desc; + const char *exec; + bool terminal; + bool sysplause; + bool run_as_root; + BuiltinAppAppender append; +}; + +template +void append_page_app(std::list &apps, const AppDescriptor &desc) +{ + apps.emplace_back(desc.label, launcher_platform::path(desc.icon), page_v); +} + +std::string resolved_exec(const BuiltinAppRegistration ®istration) +{ + std::string exec = registration.exec ? registration.exec : ""; + if (!exec.empty() && exec.front() == '@') return launcher_platform::path(exec.substr(1)); + return exec; +} + +void append_builtin_app(std::list &apps, const BuiltinAppRegistration ®istration) +{ + if (registration.append) { + registration.append(apps, registration.desc); + return; + } + apps.emplace_back(registration.desc.label, + launcher_platform::path(registration.desc.icon), + resolved_exec(registration), + registration.terminal, + registration.sysplause, + registration.run_as_root); +} + +constexpr BuiltinAppRegistration BUILTIN_APPS[] = { + {{"Python", "python_100.png", "app_Python", false, true}, "python3", true, false, false, nullptr}, + {{"STORE", "store_100.png", "app_Store", false, true}, + "@appstore_exec", false, true, false, nullptr}, + {{"CLI", "cli_100.png", "app_CLI", false, true}, + nullptr, false, true, false, append_page_app}, + {{"GAME", "game_100.png", "app_Game", false, true}, + nullptr, false, true, false, append_page_app}, + {{"SETTING", "setting_100.png", "app_Setting", false, true}, + nullptr, false, true, false, append_page_app}, + {{"MATH", "math_100.png", "app_Math", true, false}, + "@calculator_exec", false, true, false, nullptr}, + {{"LORA", "lora_100.png", "app_LoRa", true, false}, + nullptr, false, true, false, append_page_app}, +#if defined(__linux__) && !defined(HAL_PLATFORM_SDL) + {{"IP_PANEL", "ip_panel_100.png", "app_IP_Panel", true, false}, + nullptr, false, true, false, append_page_app}, + {{"SSH", "ssh_100.png", "app_SSH", true, false}, + nullptr, false, true, false, append_page_app}, + {{"TANK", "tank_100.png", "app_Tank", true, false}, + nullptr, false, true, false, append_page_app}, +#endif +}; + +bool is_first_registration(std::size_t index) +{ + const char *id = BUILTIN_APPS[index].desc.config_key; + if (!id || !id[0]) return false; + for (std::size_t previous = 0; previous < index; ++previous) { + const char *previous_id = BUILTIN_APPS[previous].desc.config_key; + if (previous_id && std::strcmp(id, previous_id) == 0) return false; + } + return true; +} + +} // namespace + +const AppDescriptor *launcher_app_registry_entries(std::size_t *count) +{ + constexpr std::size_t descriptor_count = sizeof(BUILTIN_APPS) / sizeof(BUILTIN_APPS[0]); + static const auto descriptors = [] { + std::vector result; + result.reserve(descriptor_count); + for (std::size_t i = 0; i < descriptor_count; ++i) + if (is_first_registration(i)) result.push_back(BUILTIN_APPS[i].desc); + return result; + }(); + if (count) *count = descriptors.size(); + return descriptors.data(); +} + +void launcher_append_enabled_builtin_apps(std::list &apps) +{ + for (std::size_t index = 0; index < sizeof(BUILTIN_APPS) / sizeof(BUILTIN_APPS[0]); ++index) { + if (!is_first_registration(index)) continue; + const auto ®istration = BUILTIN_APPS[index]; + if (launcher_app_registry_is_enabled(registration.desc)) append_builtin_app(apps, registration); + } +} + +bool launcher_builtin_app_owns_exec(const std::string &exec) +{ + for (const auto ®istration : BUILTIN_APPS) { + const std::string builtin_exec = resolved_exec(registration); + if (!builtin_exec.empty() && exec == builtin_exec) return true; + } + return false; +} diff --git a/projects/APPLaunch/main/ui/builtin_app_registry.hpp b/projects/APPLaunch/main/ui/builtin_app_registry.hpp new file mode 100644 index 00000000..b5a7d2b8 --- /dev/null +++ b/projects/APPLaunch/main/ui/builtin_app_registry.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include +#include + +struct app; + +void launcher_append_enabled_builtin_apps(std::list &apps); +bool launcher_builtin_app_owns_exec(const std::string &exec); diff --git a/projects/APPLaunch/main/ui/desktop_app_loader.cpp b/projects/APPLaunch/main/ui/desktop_app_loader.cpp new file mode 100644 index 00000000..c9a1e4b1 --- /dev/null +++ b/projects/APPLaunch/main/ui/desktop_app_loader.cpp @@ -0,0 +1,103 @@ +#include "desktop_app_loader.hpp" + +#include "builtin_app_registry.hpp" +#include "cp0_lvgl_app.h" +#include "desktop_entry.h" +#include "launch.h" +#include "launcher_platform.hpp" + +#include +#include +#include +#include +#include + +namespace { + +bool contains_exec(const std::list &apps, const std::string &exec) +{ + for (const auto &item : apps) { + if (item.Exec == exec) return true; + } + return false; +} + +} // namespace + +void launcher_append_desktop_apps(std::list &apps) +{ + const std::size_t initial_size = apps.size(); + try { + const std::string app_dir = launcher_platform::path("applications"); + if (app_dir.empty()) return; + int list_code = -1; + std::string listing; + cp0_signal_filesystem_api({"DirList", app_dir}, [&](int code, std::string data) { + list_code = code; + listing = std::move(data); + }); + if (list_code != 0) return; + + std::istringstream lines(listing); + std::string line; + std::size_t appended = 0; + while (std::getline(lines, line)) { + if (appended >= LAUNCHER_MAX_DESKTOP_APPS) break; + if (line.size() < 3 || line[0] != 'F' || line[1] != '\t') continue; + + std::string name; + if (!launcher_platform::decode_field(line.substr(2), name) || + !desktop_entry_filename_valid(name)) + continue; + + const std::string path = app_dir + "/" + name; + int read_code = -1; + std::string desktop_data; + cp0_signal_filesystem_api({"ReadFile", path, "65536"}, [&](int code, std::string data) { + read_code = code; + desktop_data = std::move(data); + }); + if (read_code != 0) { + std::fprintf(stderr, "applications_load: cannot open %s\n", path.c_str()); + continue; + } + + const std::optional entry = parse_desktop_entry(desktop_data); + if (!entry) { + std::fprintf(stderr, "applications_load: skip %s (missing Name or Exec)\n", path.c_str()); + continue; + } + + int safe_code = -1; + std::string unsafe_reason; + cp0_signal_process_api({"DesktopExecIsSafe", entry->exec}, [&](int code, std::string data) { + safe_code = code; + unsafe_reason = std::move(data); + }); + if (safe_code != 0) { + std::fprintf(stderr, "applications_load: skip %s (unsafe Exec: %s)\n", + path.c_str(), unsafe_reason.c_str()); + continue; + } + if (contains_exec(apps, entry->exec)) { + std::fprintf(stderr, "applications_load: skip %s (duplicate Exec)\n", path.c_str()); + continue; + } + if (launcher_builtin_app_owns_exec(entry->exec)) { + std::fprintf(stderr, "applications_load: skip %s (shadows built-in app)\n", path.c_str()); + continue; + } + + const std::string icon_path = launcher_platform::path(entry->icon); + if (!entry->icon.empty() && icon_path.empty()) { + std::fprintf(stderr, "applications_load: skip %s (invalid Icon)\n", path.c_str()); + continue; + } + apps.emplace_back(entry->name, icon_path, entry->exec, + entry->terminal, entry->sysplause); + ++appended; + } + } catch (...) { + while (apps.size() > initial_size) apps.pop_back(); + } +} diff --git a/projects/APPLaunch/main/ui/desktop_app_loader.hpp b/projects/APPLaunch/main/ui/desktop_app_loader.hpp new file mode 100644 index 00000000..782d1181 --- /dev/null +++ b/projects/APPLaunch/main/ui/desktop_app_loader.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +struct app; + +inline constexpr std::size_t LAUNCHER_MAX_DESKTOP_APPS = 128; + +void launcher_append_desktop_apps(std::list &apps); diff --git a/projects/APPLaunch/main/ui/desktop_entry.cpp b/projects/APPLaunch/main/ui/desktop_entry.cpp new file mode 100644 index 00000000..b6ac4e57 --- /dev/null +++ b/projects/APPLaunch/main/ui/desktop_entry.cpp @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "desktop_entry.h" + +#include + +namespace { +void trim(std::string &value) +{ + const size_t first = value.find_first_not_of(" \t"); + if (first == std::string::npos) { + value.clear(); + return; + } + const size_t last = value.find_last_not_of(" \t"); + value = value.substr(first, last - first + 1); +} + +bool parse_bool(const std::string &value) +{ + return value == "true" || value == "True" || value == "1"; +} + +bool contains_nul(const std::string &value) +{ + return value.find('\0') != std::string::npos; +} + +bool contains_control(const std::string &value) +{ + for (const unsigned char character : value) { + if (character < 0x20 || character == 0x7F) return true; + } + return false; +} +} // namespace + +bool desktop_entry_filename_valid(std::string_view name) +{ + constexpr std::string_view suffix = ".desktop"; + if (name.size() <= suffix.size() || + name.substr(name.size() - suffix.size()) != suffix) + return false; + for (const unsigned char character : name) { + if (character < 0x20 || character == 0x7F || character == '/' || character == '\\') + return false; + } + return true; +} + +std::optional parse_desktop_entry(std::string_view contents) +{ + DesktopEntry entry; + bool in_desktop_entry = false; + bool hidden = false; + bool valid_type = true; + std::istringstream stream{std::string(contents)}; + std::string line; + while (std::getline(stream, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty() || line[0] == '#' || line[0] == ';') continue; + if (line[0] == '[') { + in_desktop_entry = line == "[Desktop Entry]"; + continue; + } + if (!in_desktop_entry) continue; + + const size_t separator = line.find('='); + if (separator == std::string::npos) continue; + std::string key = line.substr(0, separator); + std::string value = line.substr(separator + 1); + trim(key); + trim(value); + + if (key == "Name") entry.name = std::move(value); + else if (key == "Icon") entry.icon = std::move(value); + else if (key == "Exec") entry.exec = std::move(value); + else if (key == "Terminal") entry.terminal = parse_bool(value); + else if (key == "Sysplause") entry.sysplause = parse_bool(value); + else if (key == "Hidden" || key == "NoDisplay") hidden = hidden || parse_bool(value); + else if (key == "Type") valid_type = value == "Application"; + } + if (hidden || !valid_type || entry.name.empty() || entry.exec.empty() || + entry.name.size() > DESKTOP_ENTRY_MAX_NAME_BYTES || + entry.icon.size() > DESKTOP_ENTRY_MAX_ICON_BYTES || + entry.exec.size() > DESKTOP_ENTRY_MAX_EXEC_BYTES || + contains_control(entry.name) || contains_control(entry.icon) || contains_nul(entry.exec)) + return std::nullopt; + return entry; +} diff --git a/projects/APPLaunch/main/ui/desktop_entry.h b/projects/APPLaunch/main/ui/desktop_entry.h new file mode 100644 index 00000000..d6318a86 --- /dev/null +++ b/projects/APPLaunch/main/ui/desktop_entry.h @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include + +struct DesktopEntry +{ + std::string name; + std::string icon; + std::string exec; + bool terminal = false; + bool sysplause = true; +}; + +inline constexpr std::size_t DESKTOP_ENTRY_MAX_NAME_BYTES = 128; +inline constexpr std::size_t DESKTOP_ENTRY_MAX_ICON_BYTES = 512; +inline constexpr std::size_t DESKTOP_ENTRY_MAX_EXEC_BYTES = 512; + +std::optional parse_desktop_entry(std::string_view contents); +bool desktop_entry_filename_valid(std::string_view name); diff --git a/projects/APPLaunch/main/ui/esc_hold_hint_controller.cpp b/projects/APPLaunch/main/ui/esc_hold_hint_controller.cpp new file mode 100644 index 00000000..6bdd1943 --- /dev/null +++ b/projects/APPLaunch/main/ui/esc_hold_hint_controller.cpp @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "esc_hold_hint_controller.h" + +#include "cp0_lvgl_app.h" +#include "input_keys.h" +#include "keyboard_input.h" +#include "launcher_toast.h" + +namespace { + +constexpr uint32_t kPollPeriodMs = 100; + +} // namespace + +bool EscHoldHintController::handle(const key_item *item) +{ + if (!item || item->key_code != KEY_ESC) + return false; + + if (item->key_state == KBD_KEY_PRESSED) { + if (!model_.press(lv_tick_get())) return true; + if (!poll_timer_) + poll_timer_ = lv_timer_create(poll_timer_cb, kPollPeriodMs, this); + if (poll_timer_) { + lv_timer_set_period(poll_timer_, kPollPeriodMs); + lv_timer_reset(poll_timer_); + lv_timer_resume(poll_timer_); + } else { + model_.cancel(); + } + } else if (item->key_state == KBD_KEY_RELEASED) { + if (poll_timer_) + lv_timer_pause(poll_timer_); + if (model_.release()) launcher_toast().hide(); + } + return true; +} + +void EscHoldHintController::clear_hint_ownership() +{ + model_.clear_hint_ownership(); +} + +void EscHoldHintController::set_force_home_callback(ForceHomeCallback callback, void *user_data) +{ + force_home_callback_ = callback; + force_home_user_data_ = user_data; + if (!callback) { + if (poll_timer_) lv_timer_pause(poll_timer_); + if (model_.cancel()) launcher_toast().hide(); + } +} + +void EscHoldHintController::shutdown() +{ + if (poll_timer_) { + lv_timer_delete(poll_timer_); + poll_timer_ = nullptr; + } + if (model_.cancel()) launcher_toast().hide(); + force_home_callback_ = nullptr; + force_home_user_data_ = nullptr; +} + +void EscHoldHintController::poll(lv_timer_t *timer) +{ + if (!esc_hold_timer_is_current(timer, poll_timer_)) return; + const EscHoldPollDecision decision = model_.poll( + lv_tick_get(), LVGL_HOME_KEY_FLAG != 0, force_home_callback_ != nullptr); + if (decision.hide_hint) launcher_toast().hide(); + if (decision.show_hint) + launcher_toast().show("Hold ESC 3s to return home"); + if (decision.force_home) { + ForceHomeCallback callback = force_home_callback_; + void *user_data = force_home_user_data_; + if (callback) callback(user_data); + } + if (decision.pause_timer && esc_hold_timer_is_current(timer, poll_timer_)) + lv_timer_pause(timer); +} + +void EscHoldHintController::poll_timer_cb(lv_timer_t *timer) noexcept +{ + try { + auto *controller = static_cast(lv_timer_get_user_data(timer)); + if (controller && esc_hold_timer_is_current(timer, controller->poll_timer_)) + controller->poll(timer); + } catch (...) { + auto *controller = static_cast(lv_timer_get_user_data(timer)); + if (controller && esc_hold_timer_is_current(timer, controller->poll_timer_)) { + controller->model_.cancel(); + launcher_toast().hide(); + lv_timer_pause(timer); + } + } +} + +EscHoldHintController &esc_hold_hint_controller() +{ + static EscHoldHintController controller; + return controller; +} diff --git a/projects/APPLaunch/main/ui/esc_hold_hint_controller.h b/projects/APPLaunch/main/ui/esc_hold_hint_controller.h new file mode 100644 index 00000000..f8f4d975 --- /dev/null +++ b/projects/APPLaunch/main/ui/esc_hold_hint_controller.h @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "lvgl/lvgl.h" +#include "model/esc_hold_lifecycle_model.hpp" + +struct key_item; + +class EscHoldHintController +{ +public: + using ForceHomeCallback = void (*)(void *user_data); + + bool handle(const key_item *item); + void clear_hint_ownership(); + void set_force_home_callback(ForceHomeCallback callback, void *user_data); + void shutdown(); + +private: + void poll(lv_timer_t *timer); + static void poll_timer_cb(lv_timer_t *timer) noexcept; + + lv_timer_t *poll_timer_ = nullptr; + EscHoldLifecycleModel model_; + ForceHomeCallback force_home_callback_ = nullptr; + void *force_home_user_data_ = nullptr; +}; + +EscHoldHintController &esc_hold_hint_controller(); diff --git a/projects/APPLaunch/main/ui/keyboard_text_input.cpp b/projects/APPLaunch/main/ui/keyboard_text_input.cpp new file mode 100644 index 00000000..c6a8be4b --- /dev/null +++ b/projects/APPLaunch/main/ui/keyboard_text_input.cpp @@ -0,0 +1,55 @@ +#include "keyboard_text_input.hpp" + +#include "input_keys.h" +#include "keyboard_input.h" + +namespace launcher_ui::text_input { +namespace { + +char fallback_character(uint32_t key) +{ + if (key >= KEY_1 && key <= KEY_9) return static_cast('1' + key - KEY_1); + if (key == KEY_0) return '0'; + static constexpr char qwerty[] = "qwertyuiop"; + if (key >= KEY_Q && key <= KEY_P) return qwerty[key - KEY_Q]; + static constexpr char asdf[] = "asdfghjkl"; + if (key >= KEY_A && key <= KEY_L) return asdf[key - KEY_A]; + static constexpr char zxcv[] = "zxcvbnm"; + if (key >= KEY_Z && key <= KEY_M) return zxcv[key - KEY_Z]; + if (key == KEY_SPACE) return ' '; + if (key == KEY_DOT) return '.'; + if (key == KEY_MINUS) return '-'; + return 0; +} + +} // namespace + +std::string key_text(uint32_t key, const key_item *item) +{ + if (item && item->utf8[0] && static_cast(item->utf8[0]) >= 0x20) { + return item->utf8; + } + const char character = fallback_character(key); + return character ? std::string(1, character) : std::string(); +} + +bool append_limited(std::string &buffer, const std::string &text, size_t max_bytes) +{ + if (text.empty() || buffer.size() + text.size() > max_bytes) return false; + for (unsigned char character : text) { + if (character < 0x20 || character == 0x7f) return false; + } + buffer += text; + return true; +} + +bool erase_last_codepoint(std::string &buffer) +{ + if (buffer.empty()) return false; + size_t start = buffer.size() - 1; + while (start > 0 && (static_cast(buffer[start]) & 0xc0) == 0x80) --start; + buffer.erase(start); + return true; +} + +} // namespace launcher_ui::text_input diff --git a/projects/APPLaunch/main/ui/keyboard_text_input.hpp b/projects/APPLaunch/main/ui/keyboard_text_input.hpp new file mode 100644 index 00000000..2ff5c208 --- /dev/null +++ b/projects/APPLaunch/main/ui/keyboard_text_input.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +struct key_item; + +namespace launcher_ui::text_input { + +std::string key_text(uint32_t key, const key_item *item); +bool append_limited(std::string &buffer, const std::string &text, size_t max_bytes); +bool erase_last_codepoint(std::string &buffer); + +} // namespace launcher_ui::text_input diff --git a/projects/APPLaunch/main/ui/launch.cpp b/projects/APPLaunch/main/ui/launch.cpp index d4b6ed4c..825fbbf2 100644 --- a/projects/APPLaunch/main/ui/launch.cpp +++ b/projects/APPLaunch/main/ui/launch.cpp @@ -7,105 +7,25 @@ #include "launch.h" #include "app_registry.h" +#include "builtin_app_registry.hpp" +#include "desktop_app_loader.hpp" +#include "esc_hold_hint_controller.h" #include "ui.h" +#include "generated/page_app.h" #include "ui_launch_page.h" #include "ui_screensaver.h" #include "ui_loading.h" -#include "generated/page_app.h" #include "cp0_lvgl_app.h" -#include "cp0_lvgl_file.hpp" -#include "launcher_platform.hpp" #include "sample_log.h" -#include #include #include -#include #include -#include #include namespace { constexpr size_t kHomeCarouselSlotCount = 5; constexpr int kHomeCarouselCenterSlot = 2; -constexpr uint32_t kBuiltinEscForceCloseMs = 3000; -constexpr uint32_t kBuiltinEscPollMs = 100; - -using BuiltinAppAppender = void (*)(std::list &apps, const AppDescriptor &desc); - -struct BuiltinAppRegistration { - AppDescriptor desc; - const char *exec; - bool terminal; - bool sysplause; - bool run_as_root; - BuiltinAppAppender append; -}; - -template -void append_page_app(std::list &apps, const AppDescriptor &desc) -{ - apps.emplace_back(desc.label, launcher_platform::path(desc.icon), page_v); -} - -void append_builtin_app(std::list &apps, const BuiltinAppRegistration ®istration) -{ - const AppDescriptor &desc = registration.desc; - if (registration.append) { - registration.append(apps, desc); - return; - } - - std::string exec = registration.exec ? registration.exec : ""; - if (!exec.empty() && exec.front() == '@') exec = launcher_platform::path(exec.substr(1)); - apps.emplace_back(desc.label, - launcher_platform::path(desc.icon), - exec, - registration.terminal, - registration.sysplause, - registration.run_as_root); -} - -constexpr BuiltinAppRegistration kBuiltinApps[] = { - {{"Python", "python_100.png", "app_Python", false, true}, "python3", true, false, false, nullptr}, - {{"STORE", "store_100.png", "app_Store", false, true}, - "@appstore_exec", false, true, false, nullptr}, - {{"CLI", "cli_100.png", "app_CLI", false, true}, nullptr, false, true, false, append_page_app}, - {{"GAME", "game_100.png", "app_Game", false, true}, nullptr, false, true, false, append_page_app}, - {{"SETTING", "setting_100.png", "app_Setting", false, true}, nullptr, false, true, false, append_page_app}, - {{"MATH", "math_100.png", "app_Math", true, false}, - "@calculator_exec", false, true, false, nullptr}, - {{"LORA", "lora_100.png", "app_LoRa", true, false}, nullptr, false, true, false, append_page_app}, -#if defined(__linux__) && !defined(HAL_PLATFORM_SDL) - {{"IP_PANEL", "ip_panel_100.png", "app_IP_Panel", true, false}, - nullptr, false, true, false, append_page_app}, - // {{"FILE", "file_100.png", "app_File", true, false}, - // nullptr, false, true, false, append_page_app}, - {{"SSH", "ssh_100.png", "app_SSH", true, false}, - nullptr, false, true, false, append_page_app}, - // {{"MESH", "mesh_100.png", "app_Mesh", true, false}, - // nullptr, false, true, false, append_page_app}, - // {{"LORA", "lora_100.png", "app_LoRa", true, false}, - // nullptr, false, true, false, append_page_app}, - {{"TANK", "tank_100.png", "app_Tank", true, false}, - nullptr, false, true, false, append_page_app}, -#endif -}; -} - -const AppDescriptor *launcher_app_registry_entries(std::size_t *count) -{ - static AppDescriptor descriptors[sizeof(kBuiltinApps) / sizeof(kBuiltinApps[0])]; - static bool initialized = false; - if (!initialized) { - for (std::size_t i = 0; i < sizeof(kBuiltinApps) / sizeof(kBuiltinApps[0]); ++i) - descriptors[i] = kBuiltinApps[i].desc; - initialized = true; - } - - if (count) - *count = sizeof(kBuiltinApps) / sizeof(kBuiltinApps[0]); - return descriptors; } // ============================================================ @@ -113,130 +33,111 @@ const AppDescriptor *launcher_app_registry_entries(std::size_t *count) // ============================================================ void Launch::bind_ui() { - if (bound_) { - refresh_home_carousel(); - return; - } - bound_ = true; - - launcher_app_registry_set_changed_callback(app_registry_changed_cb, this); - rebuild_builtin_apps(); - - applications_load(); + if (bound_) { refresh_home_carousel(); + return; + } + bound_ = true; - // Initialize inotify and watch the applications directory - inotify_init_watch(); - - // Create a 3s LVGL timer to periodically check directory changes - release_watch_timer(); - watch_timer_ = lv_timer_create(app_dir_watch_cb, 3000, this); - if (!esc_hold_timer_) - esc_hold_timer_ = lv_timer_create(esc_hold_timer_cb, kBuiltinEscPollMs, this); + launcher_app_registry_set_changed_callback(app_registry_changed_cb, this); + rebuild_builtin_apps(); + applications_load(); + refresh_home_carousel(); - } + app_directory_watcher_.start([this] { applications_reload(); }); + esc_hold_hint_controller().set_force_home_callback(esc_force_home_cb, this); +} void Launch::launch_app() - { - const app *selected = app_at_index(current_app); - if (selected) - selected->launch(this); - } +{ + const app *selected = app_at_index(current_app); + if (selected) selected->launch(this); +} -void Launch::lv_go_back_home(void *arg) - { - auto self = (Launch *)arg; +void Launch::lv_go_back_home(void *arg) noexcept +{ + auto *self = static_cast(arg); + if (!self || !self->page_lifecycle_.complete_home()) return; + try { SLOGI("[HOME] lv_go_back_home executing (page=%p)", self->app_Page.get()); lv_timer_enable(true); - if (auto page = self->launch_page_.lock()) - page->show_home_screen(); - lv_refr_now(NULL); - if (self->app_Page) - self->app_Page.reset(); - self->esc_hold_active_ = false; - self->esc_hold_start_tick_ = 0; - self->force_home_pending_ = false; + if (auto page = self->launch_page_.lock()) page->show_home_screen(); + lv_refr_now(nullptr); + self->app_Page.reset(); SLOGI("[HOME] lv_go_back_home done, on launcher home"); + } catch (...) { + self->app_Page.reset(); } +} void Launch::go_back_home() - { - SLOGI("[HOME] go_back_home() requested, scheduling async call (page=%p)", app_Page.get()); - lv_async_call(lv_go_back_home, this); - } +{ + if (!page_lifecycle_.request_home()) return; + SLOGI("[HOME] go_back_home() requested, scheduling async call (page=%p)", app_Page.get()); + if (lv_async_call(lv_go_back_home, this) != LV_RESULT_OK) + page_lifecycle_.cancel_home_request(); +} + +bool Launch::begin_page_launch() +{ + return page_lifecycle_.begin_app(); +} - // Changed to accept std::string and no longer depend on app::Exec void Launch::launch_Exec_in_terminal(const std::string &exec, bool sysplause) - { - SLOGI("Launching terminal app: %s", exec.c_str()); - /* Instant visual feedback; paint before the (potentially slow) - * ST page construction so the user sees it right away. */ - ui_loading::show("Loading..."); - lv_refr_now(NULL); - auto p = std::make_shared(); - app_Page = p; - force_home_pending_ = false; - esc_hold_active_ = false; - lv_disp_load_scr(p->screen()); - lv_indev_set_group(lv_indev_get_next(NULL), p->input_group()); - p->navigate_home = std::bind(&Launch::go_back_home, this); - p->terminal_sysplause = sysplause; - /* ST page fully covers APP_Container; safe to hide now. - * The heavy exec() call below will still run while the terminal - * page is on-screen — no overlay needed at that point. */ - ui_loading::hide(); - p->exec(exec); - } +{ + if (!begin_page_launch()) return; + SLOGI("Launching terminal app: %s", exec.c_str()); + ui_loading::show("Loading..."); + lv_refr_now(nullptr); + auto p = std::make_shared(); + app_Page = p; + p->navigate_home = std::bind(&Launch::go_back_home, this); + p->terminal_sysplause = sysplause; + ui_loading::hide(); + cp0_lvgl_start_app_page(*p); + p->exec(exec); +} void Launch::launch_Exec(const std::string &exec, bool keep_root) - { - SLOGI("Launching external app: %s (keep_root=%d)", exec.c_str(), keep_root); - /* Show overlay BEFORE we tear down LVGL input/timers so the user - * gets immediate feedback when ENTER was pressed. The overlay - * stays drawn on the framebuffer right up until the child takes - * it over via the cp0 process callback. */ - ui_loading::show("Loading..."); - lv_disp_t *disp = lv_disp_get_default(); - lv_indev_t *indev = lv_indev_get_next(NULL); - ui_screensaver_set_foreground(0); - LVGL_RUN_FLAGE = 0; - if (indev) - lv_indev_set_group(indev, NULL); - lv_timer_enable(false); - lv_refr_now(disp); - - int ret = -1; - cp0_signal_process_api({"ExecBlocking", exec, - std::to_string(reinterpret_cast(&LVGL_HOME_KEY_FLAG)), - keep_root ? "1" : "0"}, - [&](int code, std::string) { ret = code; }); - SLOGI("App %s exited with code %d", exec.c_str(), ret); - - lv_timer_enable(true); - if (indev) - lv_indev_set_group(indev, UILaunchPage::home_input_group()); - if (auto page = launch_page_.lock()) - page->show_home_screen(); - ui_loading::hide(); - lv_obj_invalidate(lv_screen_active()); - lv_refr_now(disp); - LVGL_RUN_FLAGE = 1; - ui_screensaver_set_foreground(1); - } +{ + SLOGI("Launching external app: %s (keep_root=%d)", exec.c_str(), keep_root); + ui_loading::show("Loading..."); + lv_disp_t *disp = lv_disp_get_default(); + lv_indev_t *indev = lv_indev_get_next(nullptr); + ui_screensaver_set_foreground(0); + LVGL_RUN_FLAGE = 0; + if (indev) lv_indev_set_group(indev, nullptr); + lv_timer_enable(false); + lv_refr_now(disp); + + int ret = -1; + cp0_signal_process_api({"ExecBlocking", exec, + std::to_string(reinterpret_cast(&LVGL_HOME_KEY_FLAG)), + keep_root ? "1" : "0"}, + [&](int code, std::string) { ret = code; }); + SLOGI("App %s exited with code %d", exec.c_str(), ret); + + lv_timer_enable(true); + if (indev) lv_indev_set_group(indev, UILaunchPage::home_input_group()); + if (auto page = launch_page_.lock()) page->show_home_screen(); + ui_loading::hide(); + lv_obj_invalidate(lv_screen_active()); + lv_refr_now(disp); + LVGL_RUN_FLAGE = 1; + ui_screensaver_set_foreground(1); +} void Launch::select_next_app() - { - int next = normalized_app_index(current_app + 1); - if (next >= 0) - current_app = next; - } +{ + int next = normalized_app_index(current_app + 1); + if (next >= 0) current_app = next; +} void Launch::select_previous_app() - { - int previous = normalized_app_index(current_app - 1); - if (previous >= 0) - current_app = previous; - } +{ + int previous = normalized_app_index(current_app - 1); + if (previous >= 0) current_app = previous; +} const app *Launch::carousel_slot_app(size_t slot) const { @@ -246,228 +147,24 @@ const app *Launch::carousel_slot_app(size_t slot) const } void Launch::applications_load() - { - const std::string app_dir_path = launcher_platform::path("applications"); - int list_code = -1; - std::string listing; - cp0_signal_filesystem_api({"DirList", app_dir_path}, [&](int code, std::string data) { - list_code = code; - listing = std::move(data); - }); - if (list_code != 0) return; - std::istringstream list_stream(listing); - std::string list_line; - while (std::getline(list_stream, list_line)) - { - // Process only *.desktop files - if (list_line.size() < 3 || list_line[0] != 'F' || list_line[1] != '\t') continue; - std::string name; - if (!launcher_platform::decode_field(list_line.substr(2), name)) continue; - size_t len = name.size(); - if (len <= 8 || name.compare(len - 8, 8, ".desktop") != 0) - continue; - - std::string filepath = app_dir_path + "/" + name; - int read_code = -1; - std::string desktop_data; - cp0_signal_filesystem_api({"ReadFile", filepath, "65536"}, [&](int code, std::string data) { - read_code = code; - desktop_data = std::move(data); - }); - if (read_code != 0) - { - fprintf(stderr, "applications_load: cannot open %s\n", filepath.c_str()); - continue; - } - std::istringstream ifs(desktop_data); - - // Parse the INI file - std::string page_title, app_icon, app_exec; - bool app_terminal = false; - bool app_sysplause = true; - bool in_desktop_entry = false; - - std::string line; - while (std::getline(ifs, line)) - { - // Remove trailing \r (Windows newline) - if (!line.empty() && line.back() == '\r') - line.pop_back(); - - // Skip empty lines and comments - if (line.empty() || line[0] == '#' || line[0] == ';') - continue; - - // Detect section headers - if (line[0] == '[') - { - in_desktop_entry = (line == "[Desktop Entry]"); - continue; - } - - if (!in_desktop_entry) - continue; - - // Parse key=value - auto eq = line.find('='); - if (eq == std::string::npos) - continue; - - std::string key = line.substr(0, eq); - std::string value = line.substr(eq + 1); - - // Trim leading/trailing spaces from the key - auto ltrim = [](std::string &s) - { - size_t i = 0; - while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) - ++i; - s = s.substr(i); - }; - auto rtrim = [](std::string &s) - { - while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) - s.pop_back(); - }; - ltrim(key); - rtrim(key); - ltrim(value); - rtrim(value); - - if (key == "Name") - page_title = value; - else if (key == "Icon") - app_icon = value; - else if (key == "Exec") - app_exec = value; - else if (key == "Terminal") - app_terminal = (value == "true" || value == "True" || value == "1"); - else if (key == "Sysplause") - app_sysplause = (value == "true" || value == "True" || value == "1"); - } - - // Name and Exec are required for registration - if (page_title.empty() || app_exec.empty()) - { - fprintf(stderr, "applications_load: skip %s (missing Name or Exec)\n", filepath.c_str()); - continue; - } - int safe_code = -1; - std::string unsafe_reason; - cp0_signal_process_api({"DesktopExecIsSafe", app_exec}, [&](int code, std::string data) { - safe_code = code; - unsafe_reason = std::move(data); - }); - if (safe_code != 0) - { - fprintf(stderr, "applications_load: skip %s (unsafe Exec: %s)\n", - filepath.c_str(), unsafe_reason.c_str()); - continue; - } - bool in_list = false; - for (const auto &it : app_list) - { - if (it.Exec == app_exec) - { - in_list = true; - break; - } - } - if (in_list) - { - fprintf(stderr, "applications_load: skip %s (duplicate Exec)\n", filepath.c_str()); - continue; - } - - // Never let a third-party *.desktop shadow a built-in app: if the Exec - // matches a built-in, the built-in registry owns visibility. Otherwise a - // built-in the user hid (removed from app_list) would silently reappear - // here as a "third-party" entry (#59). - bool shadows_builtin = false; - for (const auto ®istration : kBuiltinApps) - { - std::string builtin_exec = registration.exec ? registration.exec : ""; - if (!builtin_exec.empty() && builtin_exec.front() == '@') - builtin_exec = launcher_platform::path(builtin_exec.substr(1)); - if (!builtin_exec.empty() && app_exec == builtin_exec) - { - shadows_builtin = true; - break; - } - } - if (shadows_builtin) - { - fprintf(stderr, "applications_load: skip %s (shadows built-in app)\n", filepath.c_str()); - continue; - } - - app_list.emplace_back(page_title, launcher_platform::path(app_icon), app_exec, app_terminal, app_sysplause); - } - } - - // ============================================================ - // Initialize inotify in non-blocking mode and watch the applications directory - // ============================================================ -void Launch::inotify_init_watch() - { - const std::string app_dir_path = launcher_platform::path("applications"); - release_dir_watcher(); - cp0_signal_filesystem_api({"WatchStart", app_dir_path}, [&](int code, std::string data) { - dir_watcher_ = code == 0 - ? reinterpret_cast(static_cast(std::strtoull(data.c_str(), nullptr, 0))) - : nullptr; - }); - } - -void Launch::release_dir_watcher() - { - if (dir_watcher_) { - cp0_signal_filesystem_api({"WatchStop", std::to_string(reinterpret_cast(dir_watcher_))}, nullptr); - dir_watcher_ = NULL; - } - } - -void Launch::release_watch_timer() - { - if (watch_timer_) { - lv_timer_delete(watch_timer_); - watch_timer_ = nullptr; - } - } - -void Launch::release_esc_hold_timer() - { - if (esc_hold_timer_) { - lv_timer_delete(esc_hold_timer_); - esc_hold_timer_ = nullptr; - } - esc_hold_active_ = false; - esc_hold_start_tick_ = 0; - force_home_pending_ = false; - } +{ + launcher_append_desktop_apps(app_list); +} - // ============================================================ - // Refresh home carousel slots from current_app - // ============================================================ void Launch::refresh_home_carousel() - { - int normalized = normalized_app_index(current_app); - if (normalized < 0) - return; - current_app = normalized; - if (auto page = launch_page_.lock()) - page->refresh_carousel(); - } +{ + int normalized = normalized_app_index(current_app); + if (normalized < 0) return; + current_app = normalized; + if (auto page = launch_page_.lock()) page->refresh_carousel(); +} - // ============================================================ - // Reload the dynamic app list (keep fixed entries and rescan applications directory) - // ============================================================ void Launch::applications_reload() - { - rebuild_builtin_apps(); - applications_load(); - refresh_home_carousel(); - } +{ + rebuild_builtin_apps(); + applications_load(); + refresh_home_carousel(); +} int Launch::normalized_app_index(int index) const { @@ -488,154 +185,25 @@ const app *Launch::app_at_index(int index) const return &*std::next(app_list.begin(), normalized); } - // ============================================================ - // LVGL timer callback: check inotify events and refresh the list on changes - // ============================================================ -void Launch::app_dir_watch_cb(lv_timer_t *timer) - { - auto *self = static_cast(lv_timer_get_user_data(timer)); - if (!self || !self->dir_watcher_) - return; - - int changed = 0; - cp0_signal_filesystem_api({"WatchPoll", std::to_string(reinterpret_cast(self->dir_watcher_))}, - [&](int code, std::string data) { - if (code == 0) changed = std::atoi(data.c_str()); - }); - if (changed > 0) - { - SLOGI("app_dir_watch_cb: applications dir changed, reloading..."); - self->applications_reload(); - } - } - -void Launch::esc_hold_timer_cb(lv_timer_t *timer) - { - auto *self = static_cast(lv_timer_get_user_data(timer)); - if (!self) - return; - - if (!self->app_Page || LVGL_RUN_FLAGE == 0) { - self->esc_hold_active_ = false; - self->esc_hold_start_tick_ = 0; - return; - } - - if (!LVGL_HOME_KEY_FLAG) { - self->esc_hold_active_ = false; - self->esc_hold_start_tick_ = 0; - return; - } - - if (!self->esc_hold_active_) { - self->esc_hold_active_ = true; - self->esc_hold_start_tick_ = lv_tick_get(); - if (self->esc_hold_start_tick_ == 0) - self->esc_hold_start_tick_ = 1; - return; - } - - if (self->force_home_pending_) - return; - - if (lv_tick_elaps(self->esc_hold_start_tick_) >= kBuiltinEscForceCloseMs) { - SLOGW("[HOME] ESC held for %u ms, forcing built-in page home", - (unsigned)kBuiltinEscForceCloseMs); - self->force_home_pending_ = true; - self->go_back_home(); - } +void Launch::esc_force_home_cb(void *user_data) noexcept +{ + auto *self = static_cast(user_data); + if (!self || !self->app_Page || LVGL_RUN_FLAGE == 0) return; + try { + SLOGW("[HOME] ESC held for 3000 ms, forcing built-in page home"); + self->go_back_home(); + } catch (...) { } - - -// ============================================================ -// app constructor implementation (placed after Launch definition) -// ============================================================ -inline app::app(std::string name, - std::string icon, - std::string exec, - bool terminal) - : Name(std::move(name)), Icon(std::move(icon)){ - Exec = exec; - launch = [exec = std::move(exec), terminal](Launch *ctx) - { - if (terminal) - ctx->launch_Exec_in_terminal(exec); - else - ctx->launch_Exec(exec); - }; } -inline app::app(std::string name, - std::string icon, - std::string exec, - bool terminal, - bool sysplause) - : Name(std::move(name)), Icon(std::move(icon)){ - Exec = exec; - launch = [exec = std::move(exec), terminal, sysplause](Launch *ctx) - { - if (terminal) - ctx->launch_Exec_in_terminal(exec, sysplause); - else - ctx->launch_Exec(exec); - }; -} - -inline app::app(std::string name, - std::string icon, - std::string exec, - bool terminal, - bool sysplause, - bool run_as_root) - : Name(std::move(name)), Icon(std::move(icon)){ - Exec = exec; - launch = [exec = std::move(exec), terminal, sysplause, run_as_root](Launch *ctx) - { - if (terminal) - ctx->launch_Exec_in_terminal(exec, sysplause); - else - ctx->launch_Exec(exec, run_as_root); - }; -} - -template -app::app(std::string name, - std::string icon, - page_t /*tag*/) - : Name(std::move(name)), Icon(std::move(icon)){ - launch = [](Launch *self) - { - /* Instant feedback: show the overlay, then force an immediate - * redraw so it actually paints BEFORE the (sometimes slow) page - * construction starts. Without lv_refr_now() the overlay would - * only hit the framebuffer after the constructor returns, which - * defeats the whole point. */ - ui_loading::show("Loading..."); - lv_refr_now(NULL); - auto p = std::make_shared(); - self->app_Page = p; - self->force_home_pending_ = false; - self->esc_hold_active_ = false; - lv_disp_load_scr(p->screen()); - lv_indev_set_group(lv_indev_get_next(NULL), - p->input_group()); - p->navigate_home = - std::bind(&Launch::go_back_home, self); - /* Page is now attached and drawable; hide the overlay. The - * next LVGL frame will paint the new page without it. */ - ui_loading::hide(); - }; -} -// ============================================================ -// Launch destructor implementation -// ============================================================ Launch::~Launch() { - launcher_app_registry_set_changed_callback(nullptr, nullptr); - release_esc_hold_timer(); - release_watch_timer(); - release_dir_watcher(); + launcher_app_registry_clear_changed_callback(app_registry_changed_cb, this); + esc_hold_hint_controller().set_force_home_callback(nullptr, nullptr); + app_directory_watcher_.stop(); + page_lifecycle_.stop(); + lv_async_call_cancel(lv_go_back_home, this); } Launch::Launch() = default; @@ -645,24 +213,22 @@ void Launch::set_launch_page(std::shared_ptr launch_page) launch_page_ = std::move(launch_page); } - void Launch::rebuild_builtin_apps() { app_list.clear(); - for (const auto ®istration : kBuiltinApps) { - if (!launcher_app_registry_is_enabled(registration.desc)) - continue; - append_builtin_app(app_list, registration); - } + launcher_append_enabled_builtin_apps(app_list); fixed_count = app_list.size(); current_app = normalized_app_index(current_app); } -void Launch::app_registry_changed_cb(void *user_data) +void Launch::app_registry_changed_cb(void *user_data) noexcept { auto *self = static_cast(user_data); - if (self) + if (!self) return; + try { self->applications_reload(); + } catch (...) { + } } diff --git a/projects/APPLaunch/main/ui/launch.h b/projects/APPLaunch/main/ui/launch.h index 71ead089..f96b7aa8 100644 --- a/projects/APPLaunch/main/ui/launch.h +++ b/projects/APPLaunch/main/ui/launch.h @@ -8,6 +8,9 @@ #include "lvgl/lvgl.h" #include "cp0_lvgl_app.h" +#include "app_directory_watcher.h" +#include "ui_loading.h" +#include "model/launcher_navigation_model.hpp" #include #include @@ -58,34 +61,42 @@ class Launch friend struct app; void go_back_home(); + bool begin_page_launch(); void launch_Exec_in_terminal(const std::string &exec, bool sysplause = true); void launch_Exec(const std::string &exec, bool keep_root = false); void applications_load(); - void inotify_init_watch(); - void release_dir_watcher(); - void release_watch_timer(); - void release_esc_hold_timer(); void refresh_home_carousel(); void applications_reload(); void rebuild_builtin_apps(); int normalized_app_index(int index) const; const app *app_at_index(int index) const; - static void lv_go_back_home(void *arg); - static void app_dir_watch_cb(lv_timer_t *timer); - static void esc_hold_timer_cb(lv_timer_t *timer); - static void app_registry_changed_cb(void *user_data); + static void lv_go_back_home(void *arg) noexcept; + static void esc_force_home_cb(void *user_data) noexcept; + static void app_registry_changed_cb(void *user_data) noexcept; std::weak_ptr launch_page_; int current_app = 2; - cp0_watcher_t dir_watcher_ = NULL; - lv_timer_t *watch_timer_ = nullptr; - lv_timer_t *esc_hold_timer_ = nullptr; - uint32_t esc_hold_start_tick_ = 0; - bool esc_hold_active_ = false; - bool force_home_pending_ = false; + AppDirectoryWatcher app_directory_watcher_; + LauncherPageLifecycleModel page_lifecycle_; int fixed_count = 0; bool bound_ = false; std::list app_list; std::shared_ptr app_Page; }; + +template +app::app(std::string name, std::string icon, page_t) + : Name(std::move(name)), Icon(std::move(icon)) +{ + launch = [](Launch *owner) { + if (!owner->begin_page_launch()) return; + ui_loading::show("Loading..."); + lv_refr_now(nullptr); + auto page = std::make_shared(); + owner->app_Page = page; + page->navigate_home = std::bind(&Launch::go_back_home, owner); + ui_loading::hide(); + cp0_lvgl_start_app_page(*page); + }; +} diff --git a/projects/APPLaunch/main/ui/launcher_app.cpp b/projects/APPLaunch/main/ui/launcher_app.cpp new file mode 100644 index 00000000..5057bee4 --- /dev/null +++ b/projects/APPLaunch/main/ui/launcher_app.cpp @@ -0,0 +1,38 @@ +#include "launch.h" + +#include + +app::app(std::string name, std::string icon, std::string exec, bool terminal) + : Name(std::move(name)), Icon(std::move(icon)), Exec(exec) +{ + launch = [exec = std::move(exec), terminal](Launch *owner) { + if (!owner || exec.empty()) return; + if (terminal) owner->launch_Exec_in_terminal(exec); + else owner->launch_Exec(exec); + }; +} + +app::app(std::string name, std::string icon, std::string exec, bool terminal, bool sysplause) + : Name(std::move(name)), Icon(std::move(icon)), Exec(exec) +{ + launch = [exec = std::move(exec), terminal, sysplause](Launch *owner) { + if (!owner || exec.empty()) return; + if (terminal) owner->launch_Exec_in_terminal(exec, sysplause); + else owner->launch_Exec(exec); + }; +} + +app::app(std::string name, + std::string icon, + std::string exec, + bool terminal, + bool sysplause, + bool run_as_root) + : Name(std::move(name)), Icon(std::move(icon)), Exec(exec) +{ + launch = [exec = std::move(exec), terminal, sysplause, run_as_root](Launch *owner) { + if (!owner || exec.empty()) return; + if (terminal) owner->launch_Exec_in_terminal(exec, sysplause); + else owner->launch_Exec(exec, run_as_root); + }; +} diff --git a/projects/APPLaunch/main/ui/launcher_media_controls.cpp b/projects/APPLaunch/main/ui/launcher_media_controls.cpp new file mode 100644 index 00000000..ce29045f --- /dev/null +++ b/projects/APPLaunch/main/ui/launcher_media_controls.cpp @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "launcher_media_controls.h" + +#include "hal_lvgl_bsp.h" +#include "model/launcher_media_model.hpp" + +#include + +namespace { + +LauncherMediaControlsModel model; + +int read_config_int(const char *key, int fallback) +{ + int value = fallback; + cp0_signal_config_api({"GetInt", key, std::to_string(fallback)}, + [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && LauncherMediaControlsModel::parse_int(data, parsed)) + value = parsed; + }); + return value; +} + +bool call_config(const std::list &arguments) +{ + bool succeeded = false; + cp0_signal_config_api(arguments, [&](int code, std::string) { succeeded = code == 0; }); + return succeeded; +} + +bool write_config_int(const char *key, int value, int fallback) +{ + const int previous = read_config_int(key, fallback); + if (!call_config({"SetInt", key, std::to_string(value)})) return false; + if (call_config({"Save"})) return true; + + if (call_config({"SetInt", key, std::to_string(previous)})) call_config({"Save"}); + return false; +} + +int read_volume() +{ + int volume = -1; + cp0_signal_audio_api({"VolumeRead"}, [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && LauncherMediaControlsModel::parse_percent(data, parsed)) volume = parsed; + }); + if (volume < 0) + volume = read_config_int("volume", model.volume_or(50)); + return LauncherMediaControlsModel::clamp_percent(volume); +} + +int write_volume(int previous, int percent) +{ + percent = LauncherMediaControlsModel::clamp_percent(percent); + int written = -1; + cp0_signal_audio_api({"VolumeWrite", std::to_string(percent)}, [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && LauncherMediaControlsModel::parse_percent(data, parsed)) written = parsed; + }); + if (written < 0) return previous; + if (!write_config_int("volume", written, previous)) { + cp0_signal_audio_api({"VolumeWrite", std::to_string(previous)}, nullptr); + return previous; + } + model.set_volume(written); + return written; +} + +int backlight_max() +{ + int maximum = 100; + cp0_signal_settings_api({"BacklightMax"}, [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && LauncherMediaControlsModel::parse_int(data, parsed) && parsed > 0) + maximum = parsed; + }); + return maximum > 0 ? maximum : 100; +} + +int read_brightness() +{ + int raw = -1; + const int maximum = backlight_max(); + cp0_signal_settings_api({"BacklightRead"}, [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && LauncherMediaControlsModel::parse_int(data, parsed) && parsed >= 0) + raw = parsed; + }); + if (raw < 0) { + const int fallback = LauncherMediaControlsModel::raw_from_percent( + model.brightness_or(100), maximum); + raw = read_config_int("brightness", fallback); + } + return LauncherMediaControlsModel::percent_from_raw(raw, maximum); +} + +int write_brightness(int previous_percent, int percent) +{ + percent = LauncherMediaControlsModel::clamp_percent(percent); + const int maximum = backlight_max(); + const int raw = LauncherMediaControlsModel::raw_from_percent(percent, maximum); + + int written = -1; + cp0_signal_settings_api({"BacklightWrite", std::to_string(raw)}, [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && LauncherMediaControlsModel::parse_int(data, parsed) && parsed >= 0) + written = parsed; + }); + if (written < 0) return previous_percent; + const int previous_raw = LauncherMediaControlsModel::raw_from_percent(previous_percent, maximum); + if (!write_config_int("brightness", written, previous_raw)) { + cp0_signal_settings_api({"BacklightWrite", std::to_string(previous_raw)}, nullptr); + return previous_percent; + } + model.set_brightness_from_raw(written, maximum); + return model.brightness_or(100); +} + +} // namespace + +namespace launcher_media_controls { + +int adjust_volume(int delta_percent) +{ + const int current = model.has_volume() ? model.volume_or(0) : read_volume(); + return write_volume(current, current + delta_percent); +} + +int adjust_brightness(int delta_percent) +{ + const int current = model.has_brightness() ? model.brightness_or(0) : read_brightness(); + return write_brightness(current, current + delta_percent); +} + +bool toggle_mute() +{ + const bool previous = model.muted(); + bool muted = previous; + cp0_signal_audio_api({"MuteToggle"}, [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && LauncherMediaControlsModel::parse_int(data, parsed) && + (parsed == 0 || parsed == 1)) { + muted = parsed != 0; + model.set_mute(muted); + } + }); + return muted; +} + +} // namespace launcher_media_controls diff --git a/projects/APPLaunch/main/ui/launcher_media_controls.h b/projects/APPLaunch/main/ui/launcher_media_controls.h new file mode 100644 index 00000000..b1257e1d --- /dev/null +++ b/projects/APPLaunch/main/ui/launcher_media_controls.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +namespace launcher_media_controls { + +int adjust_volume(int delta_percent); +int adjust_brightness(int delta_percent); +bool toggle_mute(); + +} // namespace launcher_media_controls diff --git a/projects/APPLaunch/main/ui/launcher_media_osd.cpp b/projects/APPLaunch/main/ui/launcher_media_osd.cpp new file mode 100644 index 00000000..0821e068 --- /dev/null +++ b/projects/APPLaunch/main/ui/launcher_media_osd.cpp @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "launcher_media_osd.h" + +#include "ui.h" + +namespace { + +constexpr lv_coord_t kWidth = 190; +constexpr lv_coord_t kHeight = 82; +constexpr uint32_t kBackgroundColor = 0x20242C; +constexpr uint32_t kBarBackgroundColor = 0x555C66; +constexpr uint32_t kAccentColor = 0xF2C94C; +constexpr uint32_t kTextColor = 0xFFFFFF; + +} // namespace + +bool LauncherMediaOsd::ensure_created() noexcept +{ + try { + if (launcher_media_osd_ui_ready(container_, icon_, title_, value_, bar_)) + return true; + if (container_) lv_obj_delete(container_); + + lv_obj_t *parent = lv_layer_top(); + if (!parent) + return false; + + container_ = lv_obj_create(parent); + if (!container_) + return false; + lv_obj_add_event_cb(container_, container_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(container_); + lv_obj_set_size(container_, kWidth, kHeight); + lv_obj_center(container_); + lv_obj_set_style_bg_color(container_, lv_color_hex(kBackgroundColor), 0); + lv_obj_set_style_bg_opa(container_, LV_OPA_90, 0); + lv_obj_set_style_radius(container_, 8, 0); + lv_obj_set_style_border_width(container_, 1, 0); + lv_obj_set_style_border_color(container_, lv_color_hex(0x3A404A), 0); + lv_obj_set_style_pad_all(container_, 0, 0); + lv_obj_set_style_shadow_width(container_, 0, 0); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(container_, LV_OBJ_FLAG_IGNORE_LAYOUT); + + icon_ = lv_label_create(container_); + if (!icon_) { + lv_obj_delete(container_); + return false; + } + lv_obj_add_event_cb(icon_, child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(icon_, 30, 24); + lv_obj_set_pos(icon_, 14, 10); + lv_obj_set_style_text_color(icon_, lv_color_hex(kAccentColor), 0); + lv_obj_set_style_text_font(icon_, LV_FONT_DEFAULT, 0); + lv_obj_set_style_text_align(icon_, LV_TEXT_ALIGN_CENTER, 0); + + lv_font_t *font = launcher_fonts().get( + "AlibabaPuHuiTi-3-55-Regular.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD); + title_ = lv_label_create(container_); + if (!title_) { + lv_obj_delete(container_); + return false; + } + lv_obj_add_event_cb(title_, child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(title_, 76, 18); + lv_obj_set_pos(title_, 48, 12); + lv_obj_set_style_text_color(title_, lv_color_hex(kTextColor), 0); + lv_obj_set_style_text_font(title_, font, 0); + lv_label_set_long_mode(title_, LV_LABEL_LONG_CLIP); + + value_ = lv_label_create(container_); + if (!value_) { + lv_obj_delete(container_); + return false; + } + lv_obj_add_event_cb(value_, child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(value_, 48, 18); + lv_obj_set_pos(value_, 124, 12); + lv_obj_set_style_text_color(value_, lv_color_hex(kTextColor), 0); + lv_obj_set_style_text_font(value_, font, 0); + lv_obj_set_style_text_align(value_, LV_TEXT_ALIGN_RIGHT, 0); + + bar_ = lv_bar_create(container_); + if (!bar_) { + lv_obj_delete(container_); + return false; + } + lv_obj_add_event_cb(bar_, child_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(bar_, 154, 10); + lv_obj_set_pos(bar_, 18, 50); + lv_bar_set_range(bar_, 0, 100); + lv_obj_set_style_radius(bar_, 5, LV_PART_MAIN); + lv_obj_set_style_radius(bar_, 5, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(bar_, lv_color_hex(kBarBackgroundColor), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bar_, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_color(bar_, lv_color_hex(kAccentColor), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(bar_, LV_OPA_COVER, LV_PART_INDICATOR); + + lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + return true; + } catch (...) { + if (container_) lv_obj_delete(container_); + container_ = nullptr; + icon_ = nullptr; + title_ = nullptr; + value_ = nullptr; + bar_ = nullptr; + model_.hide(); + stop_hide_timer(); + return false; + } +} + +void LauncherMediaOsd::show() noexcept +{ + try { + lv_obj_center(container_); + lv_obj_move_foreground(container_); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_HIDDEN); + if (!hide_timer_) + hide_timer_ = lv_timer_create(hide_timer_cb, LauncherMediaOsdModel::SHOW_DURATION_MS, this); + if (!hide_timer_) { + model_.hide(); + lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + return; + } + lv_timer_set_period(hide_timer_, LauncherMediaOsdModel::SHOW_DURATION_MS); + lv_timer_reset(hide_timer_); + lv_timer_resume(hide_timer_); + } catch (...) { + model_.hide(); + if (container_) lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + stop_hide_timer(); + } +} + +void LauncherMediaOsd::show_level(const char *title, const char *icon, int percent) noexcept +{ + try { + if (!ensure_created()) + return; + + model_.show_level(title ? title : "", icon ? icon : "", percent); + const auto &state = model_.state(); + lv_label_set_text(icon_, state.icon.c_str()); + lv_label_set_text(title_, state.title.c_str()); + lv_label_set_text_fmt(value_, "%d%%", state.percent); + lv_bar_set_value(bar_, state.percent, LV_ANIM_OFF); + lv_obj_set_size(icon_, 30, 24); + lv_obj_set_pos(icon_, 14, 10); + lv_obj_set_pos(title_, 48, 12); + lv_obj_set_pos(value_, 124, 12); + lv_obj_clear_flag(value_, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(bar_, LV_OBJ_FLAG_HIDDEN); + show(); + } catch (...) { + model_.hide(); + if (container_) lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + stop_hide_timer(); + } +} + +void LauncherMediaOsd::show_mute(bool muted) noexcept +{ + try { + if (!ensure_created()) + return; + + model_.show_mute(muted, LV_SYMBOL_MUTE, LV_SYMBOL_VOLUME_MAX); + const auto &state = model_.state(); + lv_label_set_text(icon_, state.icon.c_str()); + lv_label_set_text(title_, state.title.c_str()); + lv_obj_set_size(icon_, 60, 32); + lv_obj_set_pos(icon_, 65, 12); + lv_obj_set_pos(title_, 55, 50); + lv_obj_add_flag(value_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(bar_, LV_OBJ_FLAG_HIDDEN); + show(); + } catch (...) { + model_.hide(); + if (container_) lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + stop_hide_timer(); + } +} + +void LauncherMediaOsd::shutdown() +{ + model_.hide(); + stop_hide_timer(); + if (container_) + lv_obj_delete(container_); + container_ = nullptr; + icon_ = nullptr; + title_ = nullptr; + value_ = nullptr; + bar_ = nullptr; +} + +void LauncherMediaOsd::stop_hide_timer() +{ + if (!hide_timer_) return; + lv_timer_delete(hide_timer_); + hide_timer_ = nullptr; +} + +void LauncherMediaOsd::hide_timer_cb(lv_timer_t *timer) noexcept +{ + try { + auto *osd = static_cast(lv_timer_get_user_data(timer)); + if (!osd || !launcher_media_osd_timer_is_current(timer, osd->hide_timer_)) return; + osd->model_.hide(); + if (osd->container_) + lv_obj_add_flag(osd->container_, LV_OBJ_FLAG_HIDDEN); + lv_timer_pause(timer); + } catch (...) { + } +} + +void LauncherMediaOsd::container_delete_cb(lv_event_t *event) noexcept +{ + try { + auto *osd = static_cast(lv_event_get_user_data(event)); + if (!osd || lv_event_get_code(event) != LV_EVENT_DELETE || + lv_event_get_target(event) != lv_event_get_current_target(event) || + lv_event_get_target(event) != osd->container_) + return; + osd->container_ = nullptr; + osd->icon_ = nullptr; + osd->title_ = nullptr; + osd->value_ = nullptr; + osd->bar_ = nullptr; + osd->model_.hide(); + osd->stop_hide_timer(); + } catch (...) { + } +} + +void LauncherMediaOsd::child_delete_cb(lv_event_t *event) noexcept +{ + try { + auto *osd = static_cast(lv_event_get_user_data(event)); + auto *target = static_cast(lv_event_get_target(event)); + if (!osd || !target || target != lv_event_get_current_target(event)) return; + if (osd->icon_ == target) osd->icon_ = nullptr; + if (osd->title_ == target) osd->title_ = nullptr; + if (osd->value_ == target) osd->value_ = nullptr; + if (osd->bar_ == target) osd->bar_ = nullptr; + osd->model_.hide(); + if (osd->container_) lv_obj_add_flag(osd->container_, LV_OBJ_FLAG_HIDDEN); + osd->stop_hide_timer(); + } catch (...) { + } +} + +LauncherMediaOsd &launcher_media_osd() +{ + static LauncherMediaOsd osd; + return osd; +} diff --git a/projects/APPLaunch/main/ui/launcher_media_osd.h b/projects/APPLaunch/main/ui/launcher_media_osd.h new file mode 100644 index 00000000..83aa3cf2 --- /dev/null +++ b/projects/APPLaunch/main/ui/launcher_media_osd.h @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "model/launcher_media_model.hpp" +#include "model/launcher_media_osd_contract.hpp" +#include "lvgl/lvgl.h" + +class LauncherMediaOsd +{ +public: + void show_level(const char *title, const char *icon, int percent) noexcept; + void show_mute(bool muted) noexcept; + void shutdown(); + +private: + bool ensure_created() noexcept; + void show() noexcept; + void stop_hide_timer(); + static void hide_timer_cb(lv_timer_t *timer) noexcept; + static void container_delete_cb(lv_event_t *event) noexcept; + static void child_delete_cb(lv_event_t *event) noexcept; + + lv_obj_t *container_ = nullptr; + lv_obj_t *icon_ = nullptr; + lv_obj_t *title_ = nullptr; + lv_obj_t *value_ = nullptr; + lv_obj_t *bar_ = nullptr; + lv_timer_t *hide_timer_ = nullptr; + LauncherMediaOsdModel model_; +}; + +LauncherMediaOsd &launcher_media_osd(); diff --git a/projects/APPLaunch/main/ui/launcher_toast.cpp b/projects/APPLaunch/main/ui/launcher_toast.cpp new file mode 100644 index 00000000..42eedf9e --- /dev/null +++ b/projects/APPLaunch/main/ui/launcher_toast.cpp @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "launcher_toast.h" + +#include "ui.h" + +namespace { + +constexpr uint32_t kShowDurationMs = 1500; +constexpr uint32_t kBackgroundColor = 0x1F3A5F; +constexpr uint32_t kTextColor = 0xFFFFFF; +constexpr lv_coord_t kWidth = 280; +constexpr lv_coord_t kHeight = 22; +constexpr lv_coord_t kTopOffset = 4; + +} // namespace + +bool LauncherToast::ensure_created() noexcept +{ + try { + if (container_ && label_) return true; + if (container_) lv_obj_delete(container_); + + lv_obj_t *parent = lv_layer_top(); + if (!parent) + return false; + + container_ = lv_obj_create(parent); + if (!container_) + return false; + lv_obj_add_event_cb(container_, container_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(container_); + lv_obj_set_size(container_, kWidth, kHeight); + lv_obj_align(container_, LV_ALIGN_TOP_MID, 0, kTopOffset); + lv_obj_set_style_bg_color(container_, lv_color_hex(kBackgroundColor), 0); + lv_obj_set_style_bg_opa(container_, LV_OPA_80, 0); + lv_obj_set_style_radius(container_, 6, 0); + lv_obj_set_style_border_width(container_, 0, 0); + lv_obj_set_style_pad_all(container_, 0, 0); + lv_obj_set_style_shadow_width(container_, 0, 0); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(container_, LV_OBJ_FLAG_IGNORE_LAYOUT); + + label_ = lv_label_create(container_); + if (!label_) { + lv_obj_delete(container_); + return false; + } + lv_obj_add_event_cb(label_, label_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_style_text_color(label_, lv_color_hex(kTextColor), 0); + lv_obj_set_style_text_font( + label_, launcher_fonts().get("AlibabaPuHuiTi-3-55-Regular.ttf", 12, + LV_FREETYPE_FONT_STYLE_BOLD), 0); + lv_obj_center(label_); + lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + return true; + } catch (...) { + if (container_) lv_obj_delete(container_); + container_ = nullptr; + label_ = nullptr; + return false; + } +} + +void LauncherToast::show(const char *text) noexcept +{ + try { + if (!ensure_created()) + return; + + lv_label_set_text(label_, text ? text : ""); + lv_obj_align(container_, LV_ALIGN_TOP_MID, 0, kTopOffset); + lv_obj_move_foreground(container_); + lv_obj_clear_flag(container_, LV_OBJ_FLAG_HIDDEN); + if (!hide_timer_) + hide_timer_ = lv_timer_create(hide_timer_cb, kShowDurationMs, this); + if (!hide_timer_) { + lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + return; + } + lv_timer_set_period(hide_timer_, kShowDurationMs); + lv_timer_reset(hide_timer_); + lv_timer_resume(hide_timer_); + } catch (...) { + hide(); + } +} + +void LauncherToast::hide() +{ + if (container_) + lv_obj_add_flag(container_, LV_OBJ_FLAG_HIDDEN); + if (hide_timer_) + lv_timer_pause(hide_timer_); +} + +void LauncherToast::shutdown() +{ + if (container_) + lv_obj_delete(container_); + else if (hide_timer_) { + lv_timer_delete(hide_timer_); + hide_timer_ = nullptr; + } + container_ = nullptr; + label_ = nullptr; +} + +void LauncherToast::hide_timer_cb(lv_timer_t *timer) noexcept +{ + try { + auto *toast = static_cast(lv_timer_get_user_data(timer)); + if (toast && toast->hide_timer_ == timer) + toast->hide(); + } catch (...) { + } +} + +void LauncherToast::container_delete_cb(lv_event_t *event) noexcept +{ + try { + auto *toast = static_cast(lv_event_get_user_data(event)); + if (!toast || lv_event_get_code(event) != LV_EVENT_DELETE || + lv_event_get_target(event) != lv_event_get_current_target(event) || + lv_event_get_target(event) != toast->container_) + return; + toast->container_ = nullptr; + toast->label_ = nullptr; + if (toast->hide_timer_) { + lv_timer_delete(toast->hide_timer_); + toast->hide_timer_ = nullptr; + } + } catch (...) { + } +} + +void LauncherToast::label_delete_cb(lv_event_t *event) noexcept +{ + try { + if (lv_event_get_code(event) != LV_EVENT_DELETE || + lv_event_get_target(event) != lv_event_get_current_target(event)) return; + auto *toast = static_cast(lv_event_get_user_data(event)); + if (toast && lv_event_get_target(event) == toast->label_) { + toast->label_ = nullptr; + toast->hide(); + } + } catch (...) { + } +} + +LauncherToast &launcher_toast() +{ + static LauncherToast toast; + return toast; +} diff --git a/projects/APPLaunch/main/ui/launcher_toast.h b/projects/APPLaunch/main/ui/launcher_toast.h new file mode 100644 index 00000000..992a42ed --- /dev/null +++ b/projects/APPLaunch/main/ui/launcher_toast.h @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "lvgl/lvgl.h" + +class LauncherToast +{ +public: + void show(const char *text) noexcept; + void hide(); + void shutdown(); + +private: + bool ensure_created() noexcept; + static void hide_timer_cb(lv_timer_t *timer) noexcept; + static void container_delete_cb(lv_event_t *event) noexcept; + static void label_delete_cb(lv_event_t *event) noexcept; + + lv_obj_t *container_ = nullptr; + lv_obj_t *label_ = nullptr; + lv_timer_t *hide_timer_ = nullptr; +}; + +LauncherToast &launcher_toast(); diff --git a/projects/APPLaunch/main/ui/launcher_ui_app_page.hpp b/projects/APPLaunch/main/ui/launcher_ui_app_page.hpp new file mode 100644 index 00000000..9a3e58b8 --- /dev/null +++ b/projects/APPLaunch/main/ui/launcher_ui_app_page.hpp @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "ui.h" +#include "keyboard_input.h" +#include "input_keys.h" +#include "ui_app_page.hpp" + +// The launcher home screen now uses the same page shell as standalone apps. +// This compatibility type remains because UILaunchPage also owns carousel logic. +class home_base : public AppPage +{ +public: + home_base() + { + set_page_title("ZERO"); + } + + lv_obj_t *content_container() const { return ui_APP_Container; } + lv_obj_t *top_container() const { return root_screen_; } + void use_bold_home_title_font() {} +}; diff --git a/projects/APPLaunch/main/ui/launcher_ui_runtime.cpp b/projects/APPLaunch/main/ui/launcher_ui_runtime.cpp index 329b3f64..3f51809e 100644 --- a/projects/APPLaunch/main/ui/launcher_ui_runtime.cpp +++ b/projects/APPLaunch/main/ui/launcher_ui_runtime.cpp @@ -14,7 +14,6 @@ void LauncherUiRuntime::create_display() { - fonts_ = std::make_shared(); dispp_ = lv_disp_get_default(); theme_ = lv_theme_default_init(dispp_, lv_palette_main(LV_PALETTE_BLUE), lv_palette_main(LV_PALETTE_RED), diff --git a/projects/APPLaunch/main/ui/launcher_ui_runtime.h b/projects/APPLaunch/main/ui/launcher_ui_runtime.h index 34a57cc6..b12ca3ba 100644 --- a/projects/APPLaunch/main/ui/launcher_ui_runtime.h +++ b/projects/APPLaunch/main/ui/launcher_ui_runtime.h @@ -11,7 +11,6 @@ class Launch; class UILaunchPage; -class LauncherFonts; class LauncherUiRuntime { @@ -22,7 +21,6 @@ class LauncherUiRuntime void start(); private: - friend LauncherFonts &launcher_fonts(); void create_display(); void build_launcher_home(); void show_initial_screen(); @@ -30,6 +28,5 @@ class LauncherUiRuntime lv_disp_t *dispp_ = nullptr; lv_theme_t *theme_ = nullptr; std::shared_ptr launch_page_; - std::shared_ptr fonts_; std::unique_ptr launch_; }; diff --git a/projects/APPLaunch/main/ui/page_app/setting/adb_state.cpp b/projects/APPLaunch/main/ui/model/adb_state.cpp similarity index 73% rename from projects/APPLaunch/main/ui/page_app/setting/adb_state.cpp rename to projects/APPLaunch/main/ui/model/adb_state.cpp index f1fe344f..6cb38a1c 100644 --- a/projects/APPLaunch/main/ui/page_app/setting/adb_state.cpp +++ b/projects/APPLaunch/main/ui/model/adb_state.cpp @@ -1,8 +1,7 @@ #include "adb_state.hpp" +#include #include #include -#include -#include namespace setting { namespace { @@ -18,6 +17,29 @@ bool has_line(const char *output, const char *line) } return false; } + +bool parse_nonnegative_line_value(const char *output, const char *prefix, int &value) +{ + if (!output || !prefix) return false; + const std::size_t prefix_length = std::strlen(prefix); + const char *line = output; + while (*line) { + const char *end = line + std::strcspn(line, "\r\n"); + if (static_cast(end - line) > prefix_length && + std::strncmp(line, prefix, prefix_length) == 0) { + int parsed = 0; + const char *first = line + prefix_length; + const auto result = std::from_chars(first, end, parsed); + if (result.ec == std::errc{} && result.ptr == end && parsed >= 0) { + value = parsed; + return true; + } + } + line = end; + while (*line == '\r' || *line == '\n') ++line; + } + return false; +} } // namespace AdbStatus parse_adb_status(const char *output) @@ -26,9 +48,7 @@ AdbStatus parse_adb_status(const char *output) if (!output) return status; status.active = has_line(output, "adbd=active"); status.enabled = has_line(output, "enabled=enabled"); - const char *auth = std::strstr(output, "authorizations="); - if (auth && (auth == output || auth[-1] == '\n' || auth[-1] == '\r')) - status.authorizations = std::max(0, std::atoi(auth + std::strlen("authorizations="))); + (void)parse_nonnegative_line_value(output, "authorizations=", status.authorizations); status.valid = status.active || status.enabled || has_line(output, "adbd=inactive") || has_line(output, "adbd=failed") || has_line(output, "enabled=disabled"); return status; @@ -75,19 +95,8 @@ std::vector parse_adb_authorizations(const char *output) return result; } -bool adb_toggle_succeeded(cp0_sudo_result_t result, int exit_code) -{ - return result == CP0_SUDO_RESULT_SUCCESS || - (result == CP0_SUDO_RESULT_EXEC_FAILED && exit_code == 10); -} - -bool adb_reboot_required(cp0_sudo_result_t result, int exit_code) -{ - return result == CP0_SUDO_RESULT_EXEC_FAILED && exit_code == 10; -} - bool adb_state_after_failure(const AdbStatus &status, bool previous) { - return status.valid ? status.active || status.enabled : previous; + return status.valid ? status.active : previous; } } // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/adb_state.hpp b/projects/APPLaunch/main/ui/model/adb_state.hpp similarity index 80% rename from projects/APPLaunch/main/ui/page_app/setting/adb_state.hpp rename to projects/APPLaunch/main/ui/model/adb_state.hpp index 23fb3a2f..6250ea27 100644 --- a/projects/APPLaunch/main/ui/page_app/setting/adb_state.hpp +++ b/projects/APPLaunch/main/ui/model/adb_state.hpp @@ -19,8 +19,6 @@ struct AdbAuthorization { }; AdbStatus parse_adb_status(const char *output); -bool adb_toggle_succeeded(cp0_sudo_result_t result, int exit_code); -bool adb_reboot_required(cp0_sudo_result_t result, int exit_code); bool adb_state_after_failure(const AdbStatus &status, bool previous); bool adb_public_key_valid(const std::string &key); std::vector parse_adb_authorizations(const char *output); diff --git a/projects/APPLaunch/main/ui/model/app_directory_watcher_contract.hpp b/projects/APPLaunch/main/ui/model/app_directory_watcher_contract.hpp new file mode 100644 index 00000000..78f7857a --- /dev/null +++ b/projects/APPLaunch/main/ui/model/app_directory_watcher_contract.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include "integer_parse_policy.hpp" + +#include +#include +#include +#include +#include +#include + +class AppDirectoryChangeCallbackSlot +{ +public: + void set(std::function callback) + { + std::lock_guard lock(mutex_); + callback_ = std::move(callback); + } + + void notify() + { + std::lock_guard lock(mutex_); + if (!callback_) return; + auto callback = callback_; + callback(); + } + +private: + std::recursive_mutex mutex_; + std::function callback_; +}; + +inline bool parse_app_watcher_handle(const std::string &text, uintptr_t &handle) +{ + if (text.size() > 2 && text[0] == '0' && + (text[1] == 'x' || text[1] == 'X')) { + const std::string digits = text.substr(2); + return launcher_ui::integer_parse::complete( + digits, uintptr_t{1}, std::numeric_limits::max(), handle, + 16); + } + return launcher_ui::integer_parse::complete( + text, uintptr_t{1}, std::numeric_limits::max(), handle); +} + +inline bool parse_app_watcher_change_count(const std::string &text, int &count) +{ + return launcher_ui::integer_parse::complete( + text, 0, std::numeric_limits::max(), count); +} + +template +constexpr bool app_watcher_timer_is_current(CallbackTimer callback_timer, + ActiveTimer active_timer) +{ + return callback_timer && callback_timer == active_timer; +} diff --git a/projects/APPLaunch/main/ui/model/app_registry_callback.hpp b/projects/APPLaunch/main/ui/model/app_registry_callback.hpp new file mode 100644 index 00000000..8df44459 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/app_registry_callback.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +class AppRegistryCallbackSlot +{ +public: + using Callback = void (*)(void *); + + void set(Callback callback, void *user_data) + { + std::lock_guard lock(mutex_); + callback_ = callback; + user_data_ = user_data; + } + + void notify() + { + std::lock_guard lock(mutex_); + Callback callback = callback_; + void *user_data = user_data_; + if (callback) callback(user_data); + } + + bool clear_if_matches(Callback callback, void *user_data) + { + std::lock_guard lock(mutex_); + if (callback_ != callback || user_data_ != user_data) return false; + callback_ = nullptr; + user_data_ = nullptr; + return true; + } + +private: + std::recursive_mutex mutex_; + Callback callback_ = nullptr; + void *user_data_ = nullptr; +}; diff --git a/projects/APPLaunch/main/ui/model/app_registry_descriptor_contract.hpp b/projects/APPLaunch/main/ui/model/app_registry_descriptor_contract.hpp new file mode 100644 index 00000000..990c38ef --- /dev/null +++ b/projects/APPLaunch/main/ui/model/app_registry_descriptor_contract.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +template +bool app_registry_descriptor_ids_unique(const Descriptor *entries, std::size_t count) +{ + if (count > 0 && !entries) return false; + for (std::size_t i = 0; i < count; ++i) { + const char *id = entries[i].config_key; + if (!id || !id[0]) return false; + for (std::size_t j = i + 1; j < count; ++j) { + const char *other = entries[j].config_key; + if (!other || !other[0] || std::strcmp(id, other) == 0) return false; + } + } + return true; +} diff --git a/projects/APPLaunch/main/ui/model/async_operation_lifecycle.cpp b/projects/APPLaunch/main/ui/model/async_operation_lifecycle.cpp new file mode 100644 index 00000000..a833eaff --- /dev/null +++ b/projects/APPLaunch/main/ui/model/async_operation_lifecycle.cpp @@ -0,0 +1,89 @@ +#include "async_operation_lifecycle.hpp" + +namespace setting { + +AsyncOperationLifecycle::AsyncOperationLifecycle() : state_(std::make_shared()) {} + +AsyncOperationLifecycle::~AsyncOperationLifecycle() +{ + shutdown(); +} + +AsyncOperationLifecycle::Token AsyncOperationLifecycle::begin() +{ + std::lock_guard lock(state_->mutex); + if (!state_->alive || state_->active) + return {}; + state_->active = true; + state_->request_id = 0; + if (++state_->generation == 0) + ++state_->generation; + return Token(state_, state_->generation); +} + +bool AsyncOperationLifecycle::activate(const Token &token, uint64_t request_id) +{ + const auto state = token.state_.lock(); + if (!state || state.get() != state_.get()) + return false; + std::lock_guard lock(state->mutex); + if (!state->alive || !state->active || state->generation != token.generation_) + return false; + state->request_id = request_id; + return true; +} + +bool AsyncOperationLifecycle::abort(const Token &token) +{ + const auto state = token.state_.lock(); + if (!state || state.get() != state_.get()) + return false; + std::lock_guard lock(state->mutex); + if (!state->active || state->generation != token.generation_) + return false; + state->active = false; + state->request_id = 0; + if (++state->generation == 0) + ++state->generation; + return true; +} + +bool AsyncOperationLifecycle::active() const +{ + std::lock_guard lock(state_->mutex); + return state_->alive && state_->active; +} + +uint64_t AsyncOperationLifecycle::shutdown() +{ + std::lock_guard lock(state_->mutex); + const uint64_t request_id = state_->request_id; + state_->alive = false; + state_->active = false; + state_->request_id = 0; + return request_id; +} + +bool AsyncOperationLifecycle::Token::complete() const +{ + const auto state = state_.lock(); + if (!state) + return false; + std::lock_guard lock(state->mutex); + if (!state->alive || !state->active || state->generation != generation_) + return false; + state->active = false; + state->request_id = 0; + return true; +} + +bool AsyncOperationLifecycle::Token::current() const +{ + const auto state = state_.lock(); + if (!state) + return false; + std::lock_guard lock(state->mutex); + return state->alive && state->generation == generation_; +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/model/async_operation_lifecycle.hpp b/projects/APPLaunch/main/ui/model/async_operation_lifecycle.hpp new file mode 100644 index 00000000..da83846d --- /dev/null +++ b/projects/APPLaunch/main/ui/model/async_operation_lifecycle.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +namespace setting { + +class AsyncOperationLifecycle +{ +private: + struct State; + +public: + class Token + { + public: + Token() = default; + + explicit operator bool() const { return generation_ != 0 && !state_.expired(); } + bool current() const; + bool complete() const; + + private: + friend class AsyncOperationLifecycle; + Token(std::weak_ptr state, uint64_t generation) + : state_(std::move(state)), generation_(generation) + { + } + + std::weak_ptr state_; + uint64_t generation_ = 0; + }; + + AsyncOperationLifecycle(); + ~AsyncOperationLifecycle(); + + AsyncOperationLifecycle(const AsyncOperationLifecycle &) = delete; + AsyncOperationLifecycle &operator=(const AsyncOperationLifecycle &) = delete; + + Token begin(); + bool activate(const Token &token, uint64_t request_id); + bool abort(const Token &token); + bool active() const; + uint64_t shutdown(); + +private: + struct State + { + mutable std::mutex mutex; + bool alive = true; + bool active = false; + uint64_t generation = 0; + uint64_t request_id = 0; + }; + + std::shared_ptr state_; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/model/bluetooth_page_model.cpp b/projects/APPLaunch/main/ui/model/bluetooth_page_model.cpp new file mode 100644 index 00000000..cad1b767 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/bluetooth_page_model.cpp @@ -0,0 +1,221 @@ +#include "bluetooth_page_model.hpp" + +#include +#include +#include +#include +#include + +namespace { + +std::vector split_record(const std::string &record) +{ + std::vector columns; + std::string column; + std::istringstream input(record); + while (std::getline(input, column, '\t')) columns.push_back(column); + if (!record.empty() && record.back() == '\t') columns.emplace_back(); + return columns; +} + +bool parse_int(const std::string &text, int &value) +{ + if (text.empty()) return false; + int parsed = 0; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, parsed, 10); + if (result.ec != std::errc() || result.ptr != end) return false; + value = parsed; + return true; +} + +bool parse_bool(const std::string &text, bool &value) +{ + int parsed = 0; + if (!parse_int(text, parsed) || (parsed != 0 && parsed != 1)) return false; + value = parsed != 0; + return true; +} + +} // namespace + +bool BluetoothPageModel::decode_status_record(const std::string &record, + BluetoothWireStatus &status) +{ + const auto columns = split_record(record); + if (columns.size() != 4 || columns[1].empty()) return false; + BluetoothWireStatus parsed; + if (!parse_bool(columns[0], parsed.powered) || + !parse_bool(columns[2], parsed.discoverable)) + return false; + parsed.address = columns[1]; + parsed.alias = columns[3]; + status = std::move(parsed); + return true; +} + +bool BluetoothPageModel::decode_device_record(const std::string &record, + BluetoothWireDevice &device) +{ + const auto columns = split_record(record); + if (columns.size() != 6 || columns[0].empty()) return false; + BluetoothWireDevice parsed; + if (!parse_int(columns[1], parsed.rssi) || + !parse_bool(columns[2], parsed.connected) || + !parse_bool(columns[3], parsed.paired) || + !parse_bool(columns[4], parsed.trusted)) + return false; + parsed.address = columns[0]; + parsed.name = columns[5]; + device = std::move(parsed); + return true; +} + +void BluetoothPageModel::set_list_mode(BluetoothListMode mode) +{ + list_mode_ = mode; + clear_selection(); + cancel_feedback(); +} + +bool BluetoothPageModel::begin_feedback() +{ + if (feedback_pending_) return false; + feedback_pending_ = true; + return true; +} + +bool BluetoothPageModel::finish_feedback() +{ + if (!feedback_pending_) return false; + feedback_pending_ = false; + return true; +} + +void BluetoothPageModel::rebuild_rows(const std::vector &devices) +{ + rows_.clear(); + bool has_devices = false; + for (size_t index = 0; index < devices.size(); ++index) { + const BluetoothDeviceState &device = devices[index]; + if (should_hide_device(device) || + (list_mode_ == BluetoothListMode::MANAGED && !device.connected)) + continue; + if (!has_devices) { + rows_.push_back({-1, list_mode_ == BluetoothListMode::MANAGED + ? "Connected Devices" + : "Discovered Devices", + true}); + has_devices = true; + } + rows_.push_back({static_cast(index), nullptr, false}); + } + + if (list_selection_ >= static_cast(rows_.size())) + list_selection_ = rows_.empty() ? 0 : static_cast(rows_.size()) - 1; + if (!rows_.empty() && rows_[list_selection_].is_header) + select_next_device(1); +} + +void BluetoothPageModel::select_next_device(int direction) +{ + if (rows_.empty() || direction == 0) + return; + int index = list_selection_; + for (size_t steps = 0; steps < rows_.size(); ++steps) { + index += direction; + if (index < 0 || index >= static_cast(rows_.size())) + return; + if (!rows_[index].is_header) { + list_selection_ = index; + return; + } + } +} + +int BluetoothPageModel::selected_device_index() const +{ + if (list_selection_ < 0 || list_selection_ >= static_cast(rows_.size())) + return -1; + const BluetoothListRow &row = rows_[list_selection_]; + return row.is_header ? -1 : row.device_index; +} + +void BluetoothPageModel::set_alias(std::string alias) +{ + alias_ = alias.empty() ? DEFAULT_ALIAS : std::move(alias); +} + +void BluetoothPageModel::begin_alias_edit() +{ + alias_input_ = alias_.empty() ? DEFAULT_ALIAS : alias_; + if (alias_input_.size() > ALIAS_INPUT_LIMIT) + alias_input_.resize(ALIAS_INPUT_LIMIT); +} + +bool BluetoothPageModel::append_alias_text(const char *text) +{ + if (!text) + return false; + bool changed = false; + while (*text && alias_input_.size() < ALIAS_INPUT_LIMIT) { + const unsigned char character = static_cast(*text++); + if (alias_character_allowed(character)) { + alias_input_.push_back(static_cast(character)); + changed = true; + } + } + return changed; +} + +bool BluetoothPageModel::erase_alias_character() +{ + if (alias_input_.empty()) + return false; + alias_input_.pop_back(); + return true; +} + +std::string BluetoothPageModel::sanitized_alias() const +{ + std::string result; + result.reserve(alias_input_.size()); + for (unsigned char character : alias_input_) { + if (alias_character_allowed(character)) + result.push_back(static_cast(character)); + } + if (result.empty()) + result = DEFAULT_ALIAS; + if (result.size() > ALIAS_INPUT_LIMIT) + result.resize(ALIAS_INPUT_LIMIT); + return result; +} + +bool BluetoothPageModel::alias_character_allowed(unsigned char character) +{ + return std::isprint(character) && character != '\t' && character != '\n' && character != '\r'; +} + +std::string BluetoothPageModel::normalized_mac_text(const std::string &text) +{ + std::string result; + for (unsigned char character : text) { + if (std::isxdigit(character)) + result.push_back(static_cast(std::tolower(character))); + else if (character != ':' && character != '-' && character != '_' && character != ' ') + return {}; + } + return result; +} + +bool BluetoothPageModel::should_hide_device(const BluetoothDeviceState &device) const +{ + if (!named_only_) + return false; + if (device.name.empty()) + return true; + const std::string name_hex = normalized_mac_text(device.name); + const std::string address_hex = normalized_mac_text(device.address); + return !name_hex.empty() && (name_hex == address_hex || name_hex.size() == 12); +} diff --git a/projects/APPLaunch/main/ui/model/bluetooth_page_model.hpp b/projects/APPLaunch/main/ui/model/bluetooth_page_model.hpp new file mode 100644 index 00000000..d9e01f0b --- /dev/null +++ b/projects/APPLaunch/main/ui/model/bluetooth_page_model.hpp @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include + +enum class BluetoothListMode { MANAGED, SCAN }; + +struct BluetoothDeviceState +{ + std::string address; + std::string name; + bool paired = false; + bool connected = false; +}; + +struct BluetoothWireStatus +{ + bool powered = false; + std::string address; + bool discoverable = false; + std::string alias; +}; + +struct BluetoothWireDevice +{ + std::string address; + int rssi = 0; + bool connected = false; + bool paired = false; + bool trusted = false; + std::string name; +}; + +struct BluetoothListRow +{ + int device_index = -1; + const char *title = nullptr; + bool is_header = false; +}; + +class BluetoothPageModel +{ +public: + static constexpr size_t ALIAS_INPUT_LIMIT = 63; + static constexpr const char *DEFAULT_ALIAS = "CardputerZero"; + + static bool decode_status_record(const std::string &record, BluetoothWireStatus &status); + static bool decode_device_record(const std::string &record, BluetoothWireDevice &device); + + void set_list_mode(BluetoothListMode mode); + BluetoothListMode list_mode() const { return list_mode_; } + + void rebuild_rows(const std::vector &devices); + void clear_selection() { list_selection_ = 0; } + void select_next_device(int direction); + int selected_device_index() const; + int list_selection() const { return list_selection_; } + const std::vector &rows() const { return rows_; } + + void set_named_only(bool enabled) { named_only_ = enabled; } + bool named_only() const { return named_only_; } + + void set_discoverable(bool enabled) { discoverable_ = enabled; } + bool discoverable() const { return discoverable_; } + + bool begin_feedback(); + bool finish_feedback(); + void cancel_feedback() { feedback_pending_ = false; } + bool feedback_pending() const { return feedback_pending_; } + + void set_alias(std::string alias); + const std::string &alias() const { return alias_; } + void begin_alias_edit(); + bool append_alias_text(const char *text); + bool erase_alias_character(); + std::string sanitized_alias() const; + const std::string &alias_input() const { return alias_input_; } + +private: + static bool alias_character_allowed(unsigned char character); + static std::string normalized_mac_text(const std::string &text); + bool should_hide_device(const BluetoothDeviceState &device) const; + + BluetoothListMode list_mode_ = BluetoothListMode::MANAGED; + int list_selection_ = 0; + std::vector rows_; + bool named_only_ = true; + bool discoverable_ = false; + bool feedback_pending_ = false; + std::string alias_ = DEFAULT_ALIAS; + std::string alias_input_; +}; diff --git a/projects/APPLaunch/main/ui/model/bluetooth_scan_lifecycle_contract.hpp b/projects/APPLaunch/main/ui/model/bluetooth_scan_lifecycle_contract.hpp new file mode 100644 index 00000000..e32f4cb5 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/bluetooth_scan_lifecycle_contract.hpp @@ -0,0 +1,76 @@ +#pragma once + +template +constexpr bool bluetooth_scan_callback_ready(CallbackTimer callback_timer, + ActiveTimer active_timer, + PageHandle page) +{ + return callback_timer && callback_timer == active_timer && page; +} + +template +constexpr bool bluetooth_scan_callback_allowed(CallbackTimer callback_timer, + ActiveTimer active_timer, + PageHandle page, + bool callback_enabled) noexcept +{ + return callback_enabled && + bluetooth_scan_callback_ready(callback_timer, active_timer, page); +} + +template +constexpr bool bluetooth_feedback_callback_ready(CallbackTimer callback_timer, + ActiveTimer active_timer, + PageHandle page) +{ + return callback_timer && callback_timer == active_timer && page; +} + +template +constexpr bool bluetooth_feedback_callback_allowed(CallbackTimer callback_timer, + ActiveTimer active_timer, + PageHandle page, + bool feedback_pending) noexcept +{ + return feedback_pending && + bluetooth_feedback_callback_ready(callback_timer, active_timer, page); +} + +template +constexpr bool bluetooth_screen_delete_is_direct(ObjectHandle target, + ObjectHandle current_target) +{ + return target && target == current_target; +} + +template +constexpr bool bluetooth_feedback_screen_delete_matches( + EventTarget target, CurrentTarget current_target, + TrackedScreen tracked_screen) noexcept +{ + return target && target == current_target && target == tracked_screen; +} + +constexpr bool bluetooth_scan_action_needs_watchdog_recovery(bool action_busy, + bool operation_active) +{ + return action_busy && !operation_active; +} + +constexpr bool bluetooth_action_entry_allowed(bool action_busy, + bool operation_active) noexcept +{ + return !action_busy || !operation_active; +} + +template +constexpr bool bluetooth_async_completion_allowed(bool token_current, + PageHandle page) noexcept +{ + return token_current && page; +} + +constexpr bool bluetooth_scan_action_should_resume(bool from_scan, int result_code) +{ + return from_scan && result_code != 0; +} diff --git a/projects/APPLaunch/main/ui/model/boot_action_policy.cpp b/projects/APPLaunch/main/ui/model/boot_action_policy.cpp new file mode 100644 index 00000000..902cd711 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/boot_action_policy.cpp @@ -0,0 +1,51 @@ +#include "boot_action_policy.hpp" + +namespace boot_actions { +namespace { + +constexpr Presentation kReboot{"Reboot", "Reboot?"}; +constexpr Presentation kShutdown{"Shutdown", "Shutdown?"}; +constexpr Presentation kFactoryReset{"Factory Reset", {}}; +constexpr Presentation kRearmOobe{"Run Setup Again", {}}; + +} // namespace + +const Presentation &presentation(Action action) +{ + switch (action) { + case Action::Reboot: + return kReboot; + case Action::Shutdown: + return kShutdown; + case Action::FactoryReset: + return kFactoryReset; + case Action::RearmOobe: + return kRearmOobe; + } + return kReboot; +} + +std::vector operation_plan(Action action) +{ + switch (action) { + case Action::Reboot: + return {Operation::Reboot}; + case Action::Shutdown: + return {Operation::Shutdown}; + case Action::FactoryReset: + return {Operation::RemoveLauncherSettings, Operation::Reboot}; + case Action::RearmOobe: + return {Operation::TouchOobeMarker, Operation::Reboot}; + } + return {}; +} + +bool may_continue(Operation completed_operation, bool succeeded) +{ + if (completed_operation == Operation::Reboot || + completed_operation == Operation::Shutdown) + return false; + return succeeded; +} + +} // namespace boot_actions diff --git a/projects/APPLaunch/main/ui/model/boot_action_policy.hpp b/projects/APPLaunch/main/ui/model/boot_action_policy.hpp new file mode 100644 index 00000000..cab88220 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/boot_action_policy.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace boot_actions { + +enum class Action { + Reboot, + Shutdown, + FactoryReset, + RearmOobe, +}; + +enum class Operation { + Reboot, + Shutdown, + RemoveLauncherSettings, + TouchOobeMarker, +}; + +struct Presentation +{ + std::string_view label; + std::string_view confirmation_title; +}; + +const Presentation &presentation(Action action); +std::vector operation_plan(Action action); +bool may_continue(Operation completed_operation, bool succeeded); + +} // namespace boot_actions diff --git a/projects/APPLaunch/main/ui/model/developer_page_model.cpp b/projects/APPLaunch/main/ui/model/developer_page_model.cpp new file mode 100644 index 00000000..7e9bd0de --- /dev/null +++ b/projects/APPLaunch/main/ui/model/developer_page_model.cpp @@ -0,0 +1,108 @@ +#include "developer_page_model.hpp" + +#include + +namespace setting { +namespace { + +std::size_t utf8_character_start(const std::string &text) +{ + std::size_t pos = text.size() - 1; + while (pos > 0 && (static_cast(text[pos]) & 0xC0U) == 0x80U) --pos; + return pos; +} + +std::size_t preview_start(const std::string &text, std::size_t byte_limit) +{ + if (text.size() <= byte_limit) return 0; + std::size_t pos = text.size() - byte_limit; + while (pos < text.size() && (static_cast(text[pos]) & 0xC0U) == 0x80U) ++pos; + return pos; +} + +} // namespace + +bool DeveloperOverlayLifecycleModel::open() +{ + if (active_) return false; + active_ = true; + return true; +} + +bool DeveloperOverlayLifecycleModel::close() +{ + if (!active_) return false; + active_ = false; + return true; +} + +void DeveloperPageModel::begin_pairing(bool enable_after_pair) +{ + pair_key_.clear(); + enable_after_pair_ = enable_after_pair; +} + +void DeveloperPageModel::clear_pairing() +{ + pair_key_.clear(); + enable_after_pair_ = false; +} + +bool DeveloperPageModel::append_pairing_text(std::string_view text) +{ + if (text.empty() || text.size() > MAX_PAIR_KEY_BYTES - pair_key_.size()) return false; + pair_key_.append(text.data(), text.size()); + return true; +} + +bool DeveloperPageModel::erase_pairing_character() +{ + if (pair_key_.empty()) return false; + pair_key_.erase(utf8_character_start(pair_key_)); + return true; +} + +std::string DeveloperPageModel::pairing_preview() const +{ + const std::size_t start = preview_start(pair_key_, PAIR_PREVIEW_BYTES); + return (start == 0 ? std::string{} : std::string{"..."}) + pair_key_.substr(start) + "_"; +} + +void DeveloperPageModel::reset_authorization_selection(std::size_t count) +{ + authorization_selection_ = 0; + reconcile_authorization_count(count); +} + +void DeveloperPageModel::reconcile_authorization_count(std::size_t count) +{ + if (count == 0) + authorization_selection_ = 0; + else + authorization_selection_ = std::min(authorization_selection_, count - 1); +} + +bool DeveloperPageModel::select_previous_authorization() +{ + if (authorization_selection_ == 0) return false; + --authorization_selection_; + return true; +} + +bool DeveloperPageModel::select_next_authorization(std::size_t count) +{ + if (authorization_selection_ + 1 >= count) return false; + ++authorization_selection_; + return true; +} + +std::size_t DeveloperPageModel::authorization_window_start(std::size_t visible_rows) const +{ + if (visible_rows == 0) return authorization_selection_; + const std::size_t rows_before_selection = visible_rows / 2; + return authorization_selection_ > rows_before_selection + ? authorization_selection_ - rows_before_selection + : 0; +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/model/developer_page_model.hpp b/projects/APPLaunch/main/ui/model/developer_page_model.hpp new file mode 100644 index 00000000..f4f9bc7f --- /dev/null +++ b/projects/APPLaunch/main/ui/model/developer_page_model.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include + +namespace setting { + +template +constexpr bool developer_delete_callback_allowed(EventTarget event_target, + CurrentTarget current_target, + TrackedObject tracked_object) noexcept +{ + return event_target && event_target == current_target && + event_target == tracked_object; +} + +template +constexpr bool developer_async_completion_allowed(bool token_completed, + OwnerHandle owner, + PageHandle page) noexcept +{ + return token_completed && owner && page; +} + +class DeveloperPairViewBuildContract +{ +public: + static constexpr std::size_t REQUIRED_LABELS = 4; + + void label_created() { ++created_labels_; } + bool ready() const { return created_labels_ == REQUIRED_LABELS; } + +private: + std::size_t created_labels_ = 0; +}; + +class DeveloperOverlayLifecycleModel +{ +public: + bool open(); + bool close(); + bool active() const { return active_; } + +private: + bool active_ = false; +}; + +class DeveloperPageModel +{ +public: + static constexpr std::size_t MAX_PAIR_KEY_BYTES = 2048; + static constexpr std::size_t PAIR_PREVIEW_BYTES = 40; + + void begin_pairing(bool enable_after_pair); + void clear_pairing(); + bool append_pairing_text(std::string_view text); + bool erase_pairing_character(); + + const std::string &pair_key() const { return pair_key_; } + bool enable_after_pair() const { return enable_after_pair_; } + std::string pairing_preview() const; + + void reset_authorization_selection(std::size_t count); + void reconcile_authorization_count(std::size_t count); + bool select_previous_authorization(); + bool select_next_authorization(std::size_t count); + std::size_t authorization_selection() const { return authorization_selection_; } + std::size_t authorization_window_start(std::size_t visible_rows = 3) const; + +private: + std::string pair_key_; + bool enable_after_pair_ = false; + std::size_t authorization_selection_ = 0; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/model/esc_hold_lifecycle_model.cpp b/projects/APPLaunch/main/ui/model/esc_hold_lifecycle_model.cpp new file mode 100644 index 00000000..58d47807 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/esc_hold_lifecycle_model.cpp @@ -0,0 +1,67 @@ +#include "esc_hold_lifecycle_model.hpp" + +bool EscHoldLifecycleModel::press(uint32_t now) +{ + if (holding_) return false; + holding_ = true; + down_tick_ = now; + hint_shown_ = false; + hint_owned_ = false; + force_home_sent_ = false; + return true; +} + +bool EscHoldLifecycleModel::release() +{ + const bool hide_hint = hint_owned_; + reset(); + return hide_hint; +} + +bool EscHoldLifecycleModel::cancel() +{ + return release(); +} + +void EscHoldLifecycleModel::clear_hint_ownership() +{ + hint_owned_ = false; +} + +EscHoldPollDecision EscHoldLifecycleModel::poll(uint32_t now, bool hardware_key_down, + bool force_callback_available) +{ + EscHoldPollDecision decision; + if (!holding_) { + decision.pause_timer = true; + return decision; + } + if (!hardware_key_down) { + decision.hide_hint = release(); + decision.pause_timer = true; + return decision; + } + + const uint32_t elapsed = now - down_tick_; + if (elapsed >= HINT_DELAY_MS && !hint_shown_) { + hint_shown_ = true; + hint_owned_ = true; + decision.show_hint = true; + } + if (elapsed >= FORCE_HOME_DELAY_MS && !force_home_sent_ && + force_callback_available) { + force_home_sent_ = true; + decision.force_home = true; + decision.pause_timer = true; + } + return decision; +} + +void EscHoldLifecycleModel::reset() +{ + down_tick_ = 0; + holding_ = false; + hint_shown_ = false; + hint_owned_ = false; + force_home_sent_ = false; +} diff --git a/projects/APPLaunch/main/ui/model/esc_hold_lifecycle_model.hpp b/projects/APPLaunch/main/ui/model/esc_hold_lifecycle_model.hpp new file mode 100644 index 00000000..69b57621 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/esc_hold_lifecycle_model.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +struct EscHoldPollDecision +{ + bool show_hint = false; + bool hide_hint = false; + bool force_home = false; + bool pause_timer = false; +}; + +class EscHoldLifecycleModel +{ +public: + static constexpr uint32_t HINT_DELAY_MS = 1500; + static constexpr uint32_t FORCE_HOME_DELAY_MS = 3000; + + bool press(uint32_t now); + bool release(); + bool cancel(); + void clear_hint_ownership(); + EscHoldPollDecision poll(uint32_t now, bool hardware_key_down, + bool force_callback_available); + + bool holding() const { return holding_; } + +private: + void reset(); + + uint32_t down_tick_ = 0; + bool holding_ = false; + bool hint_shown_ = false; + bool hint_owned_ = false; + bool force_home_sent_ = false; +}; + +template +constexpr bool esc_hold_timer_is_current(CallbackTimer callback_timer, + ActiveTimer active_timer) +{ + return callback_timer && callback_timer == active_timer; +} diff --git a/projects/APPLaunch/main/ui/model/global_hint_policy.cpp b/projects/APPLaunch/main/ui/model/global_hint_policy.cpp new file mode 100644 index 00000000..f997bdeb --- /dev/null +++ b/projects/APPLaunch/main/ui/model/global_hint_policy.cpp @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "global_hint_policy.hpp" + +#include "input_keys.h" + +#include + +#ifndef KEY_RIGHTSHIFT +#define KEY_RIGHTSHIFT 54 +#endif + +#ifndef KEY_COMPOSE +#define KEY_COMPOSE 127 +#endif + +#ifndef KEY_FN +#define KEY_FN 0x1d0 +#endif + +#ifndef KEY_MUTE +#define KEY_MUTE 113 +#endif +#ifndef KEY_VOLUMEDOWN +#define KEY_VOLUMEDOWN 114 +#endif +#ifndef KEY_VOLUMEUP +#define KEY_VOLUMEUP 115 +#endif +#ifndef KEY_BRIGHTNESSDOWN +#define KEY_BRIGHTNESSDOWN 224 +#endif +#ifndef KEY_BRIGHTNESSUP +#define KEY_BRIGHTNESSUP 225 +#endif + +namespace { + +bool is_symbol_key_name(const char *name) +{ + if (!name || !name[0]) + return false; + return std::strcmp(name, "Multi_key") == 0 || std::strcmp(name, "Menu") == 0 || + std::strcmp(name, "Sym") == 0 || std::strcmp(name, "SYM") == 0; +} + +} // namespace + +GlobalHintAction GlobalHintPolicy::action_for(const GlobalHintKeyInput &input) const +{ + if (input.pressed || input.repeated) { + switch (input.key_code) { + case KEY_BRIGHTNESSUP: + return GlobalHintAction::BRIGHTNESS_UP; + case KEY_BRIGHTNESSDOWN: + return GlobalHintAction::BRIGHTNESS_DOWN; + case KEY_VOLUMEUP: + return GlobalHintAction::VOLUME_UP; + case KEY_VOLUMEDOWN: + return GlobalHintAction::VOLUME_DOWN; + case KEY_MUTE: + return input.pressed ? GlobalHintAction::TOGGLE_MUTE : GlobalHintAction::NONE; + default: + break; + } + } + + if (!input.pressed) + return GlobalHintAction::NONE; + + if (input.key_code == KEY_S && input.control && input.alt) + return GlobalHintAction::TAKE_SCREENSHOT; + + if (input.key_code == KEY_FN) + return GlobalHintAction::NONE; + + if (input.key_code == KEY_LEFTSHIFT || input.key_code == KEY_RIGHTSHIFT || + input.key_code == KEY_COMPOSE || is_symbol_key_name(input.symbol_name)) { + return GlobalHintAction::SHOW_LOCK_HINT; + } + + return GlobalHintAction::NONE; +} diff --git a/projects/APPLaunch/main/ui/model/global_hint_policy.hpp b/projects/APPLaunch/main/ui/model/global_hint_policy.hpp new file mode 100644 index 00000000..4f4aabf9 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/global_hint_policy.hpp @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include + +enum class GlobalHintAction { + NONE, + BRIGHTNESS_UP, + BRIGHTNESS_DOWN, + VOLUME_UP, + VOLUME_DOWN, + TOGGLE_MUTE, + TAKE_SCREENSHOT, + SHOW_LOCK_HINT, +}; + +struct GlobalHintKeyInput { + uint32_t key_code = 0; + const char *symbol_name = nullptr; + bool pressed = false; + bool repeated = false; + bool control = false; + bool alt = false; +}; + +class GlobalHintPolicy +{ +public: + GlobalHintAction action_for(const GlobalHintKeyInput &input) const; +}; + +class GlobalHintScreenshotPolicy +{ +public: + static bool should_save(int ensure_directory_code) + { + return ensure_directory_code == 0; + } + + static const char *result_message(int ensure_directory_code, int save_code) + { + return should_save(ensure_directory_code) && save_code == 0 + ? "Saved to ~/Screenshots" : "Screenshot failed"; + } +}; diff --git a/projects/APPLaunch/main/ui/model/global_overlay_model.hpp b/projects/APPLaunch/main/ui/model/global_overlay_model.hpp new file mode 100644 index 00000000..e9df6faf --- /dev/null +++ b/projects/APPLaunch/main/ui/model/global_overlay_model.hpp @@ -0,0 +1,26 @@ +#pragma once + +class GlobalOverlayModel +{ +public: + bool request_show() + { + visible_ = true; + return !created_; + } + + void mark_created() { created_ = true; } + void hide() { visible_ = false; } + void object_deleted() + { + created_ = false; + visible_ = false; + } + + bool created() const { return created_; } + bool visible() const { return visible_; } + +private: + bool created_ = false; + bool visible_ = false; +}; diff --git a/projects/APPLaunch/main/ui/model/integer_parse_policy.hpp b/projects/APPLaunch/main/ui/model/integer_parse_policy.hpp new file mode 100644 index 00000000..109edf2f --- /dev/null +++ b/projects/APPLaunch/main/ui/model/integer_parse_policy.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +namespace launcher_ui::integer_parse { + +template +bool complete(const std::string &text, Integer minimum, Integer maximum, + Integer &output, int base = 10) +{ + static_assert(std::is_integral::value, "Integer must be integral"); + if (text.empty()) return false; + + Integer parsed{}; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, parsed, base); + if (result.ec != std::errc{} || result.ptr != end || parsed < minimum || + parsed > maximum) + return false; + + output = parsed; + return true; +} + +} // namespace launcher_ui::integer_parse diff --git a/projects/APPLaunch/main/ui/model/ip_panel_model.cpp b/projects/APPLaunch/main/ui/model/ip_panel_model.cpp new file mode 100644 index 00000000..bc5a4f2f --- /dev/null +++ b/projects/APPLaunch/main/ui/model/ip_panel_model.cpp @@ -0,0 +1,57 @@ +#include "ip_panel_model.hpp" + +#include + +void IpPanelModel::replace(std::vector interfaces) +{ + const std::string selected_name = selected_index_ < interfaces_.size() + ? interfaces_[selected_index_].name + : std::string(); + std::sort(interfaces.begin(), interfaces.end(), + [](const IpInterfaceInfo &left, const IpInterfaceInfo &right) { + return left.name < right.name; + }); + interfaces_ = std::move(interfaces); + + const auto selected = std::find_if(interfaces_.begin(), interfaces_.end(), + [&](const IpInterfaceInfo &interface) { + return interface.name == selected_name; + }); + if (selected != interfaces_.end()) { + selected_index_ = static_cast(selected - interfaces_.begin()); + } else if (interfaces_.empty()) { + selected_index_ = 0; + } else if (selected_index_ >= interfaces_.size()) { + selected_index_ = interfaces_.size() - 1; + } +} + +bool IpPanelModel::apply_refresh(bool loaded, std::vector interfaces) +{ + if (!loaded) return false; + replace(std::move(interfaces)); + return true; +} + +bool IpPanelModel::select_previous() +{ + if (selected_index_ == 0) return false; + --selected_index_; + return true; +} + +bool IpPanelModel::select_next() +{ + if (selected_index_ + 1 >= interfaces_.size()) return false; + ++selected_index_; + return true; +} + +size_t IpPanelModel::visible_offset(size_t visible_count) const +{ + if (visible_count == 0 || interfaces_.size() <= visible_count) return 0; + const size_t centered = selected_index_ > visible_count / 2 + ? selected_index_ - visible_count / 2 + : 0; + return std::min(centered, interfaces_.size() - visible_count); +} diff --git a/projects/APPLaunch/main/ui/model/ip_panel_model.hpp b/projects/APPLaunch/main/ui/model/ip_panel_model.hpp new file mode 100644 index 00000000..8012127f --- /dev/null +++ b/projects/APPLaunch/main/ui/model/ip_panel_model.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +struct IpInterfaceInfo { + std::string name; + std::string address; + std::string netmask; + bool up = false; +}; + +constexpr bool ip_panel_refresh_callback_allowed(bool timer_is_current, + bool refresh_enabled) noexcept +{ + return timer_is_current && refresh_enabled; +} + +template +constexpr bool ip_panel_root_callback_allowed( + CurrentTarget current_target, TrackedRoot tracked_root) noexcept +{ + return current_target && current_target == tracked_root; +} + +template +constexpr bool ip_panel_view_ready(Background background, List list) noexcept +{ + return background && list; +} + +template +constexpr bool ip_panel_owned_delete_callback_allowed(EventTarget event_target, + CurrentTarget current_target) noexcept +{ + return event_target && event_target == current_target; +} + +class IpPanelModel +{ +public: + void replace(std::vector interfaces); + bool apply_refresh(bool loaded, std::vector interfaces); + bool select_previous(); + bool select_next(); + + const std::vector &interfaces() const { return interfaces_; } + size_t selected_index() const { return selected_index_; } + size_t visible_offset(size_t visible_count) const; + +private: + std::vector interfaces_; + size_t selected_index_ = 0; +}; diff --git a/projects/APPLaunch/main/ui/model/launcher_media_model.cpp b/projects/APPLaunch/main/ui/model/launcher_media_model.cpp new file mode 100644 index 00000000..fbaa0549 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/launcher_media_model.cpp @@ -0,0 +1,101 @@ +#include "launcher_media_model.hpp" + +#include +#include +#include +#include +#include + +bool LauncherMediaControlsModel::parse_int(const std::string &text, int &value) +{ + if (text.empty()) return false; + int parsed = 0; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, parsed, 10); + if (result.ec != std::errc() || result.ptr != end) return false; + value = parsed; + return true; +} + +bool LauncherMediaControlsModel::parse_percent(const std::string &text, int &value) +{ + int parsed = 0; + if (!parse_int(text, parsed) || parsed < 0 || parsed > 100) return false; + value = parsed; + return true; +} + +int LauncherMediaControlsModel::clamp_percent(int value) +{ + return std::clamp(value, 0, 100); +} + +int LauncherMediaControlsModel::percent_from_raw(int raw, int maximum) +{ + if (maximum <= 0) maximum = 100; + const int64_t percent = static_cast(raw) * 100 / maximum; + return clamp_percent(static_cast(std::clamp(percent, 0, 100))); +} + +int LauncherMediaControlsModel::raw_from_percent(int percent, int maximum) +{ + if (maximum <= 0) maximum = 100; + percent = clamp_percent(percent); + int raw = static_cast(static_cast(maximum) * percent / 100); + if (percent > 0) raw = std::max(raw, 1); + return raw; +} + +int LauncherMediaControlsModel::volume_or(int fallback) const +{ + return volume_ >= 0 ? volume_ : clamp_percent(fallback); +} + +int LauncherMediaControlsModel::brightness_or(int fallback) const +{ + return brightness_ >= 0 ? brightness_ : clamp_percent(fallback); +} + +void LauncherMediaControlsModel::set_volume(int percent) +{ + volume_ = clamp_percent(percent); +} + +void LauncherMediaControlsModel::set_brightness_from_raw(int raw, int maximum) +{ + brightness_ = percent_from_raw(raw, maximum); +} + +bool LauncherMediaControlsModel::toggle_mute() +{ + muted_ = !muted_; + return muted_; +} + +void LauncherMediaControlsModel::set_mute(bool muted) +{ + muted_ = muted; +} + +void LauncherMediaOsdModel::show_level(std::string title, std::string icon, int percent) +{ + state_.mode = LauncherMediaOsdMode::LEVEL; + state_.title = std::move(title); + state_.icon = std::move(icon); + state_.percent = LauncherMediaControlsModel::clamp_percent(percent); + state_.visible = true; +} + +void LauncherMediaOsdModel::show_mute(bool muted, std::string mute_icon, std::string volume_icon) +{ + state_.mode = LauncherMediaOsdMode::MUTE; + state_.title = muted ? "Muted" : "Sound On"; + state_.icon = muted ? std::move(mute_icon) : std::move(volume_icon); + state_.visible = true; +} + +void LauncherMediaOsdModel::hide() +{ + state_.visible = false; +} diff --git a/projects/APPLaunch/main/ui/model/launcher_media_model.hpp b/projects/APPLaunch/main/ui/model/launcher_media_model.hpp new file mode 100644 index 00000000..407dc8ad --- /dev/null +++ b/projects/APPLaunch/main/ui/model/launcher_media_model.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +class LauncherMediaControlsModel +{ +public: + static bool parse_int(const std::string &text, int &value); + static bool parse_percent(const std::string &text, int &value); + static int clamp_percent(int value); + static int percent_from_raw(int raw, int maximum); + static int raw_from_percent(int percent, int maximum); + + int volume_or(int fallback) const; + int brightness_or(int fallback) const; + bool has_volume() const { return volume_ >= 0; } + bool has_brightness() const { return brightness_ >= 0; } + bool muted() const { return muted_; } + void set_volume(int percent); + void set_brightness_from_raw(int raw, int maximum); + bool toggle_mute(); + void set_mute(bool muted); + +private: + int volume_ = -1; + int brightness_ = -1; + bool muted_ = false; +}; + +enum class LauncherMediaOsdMode +{ + LEVEL, + MUTE, +}; + +struct LauncherMediaOsdState +{ + LauncherMediaOsdMode mode = LauncherMediaOsdMode::LEVEL; + std::string title; + std::string icon; + int percent = 0; + bool visible = false; +}; + +class LauncherMediaOsdModel +{ +public: + static constexpr uint32_t SHOW_DURATION_MS = 1000; + + void show_level(std::string title, std::string icon, int percent); + void show_mute(bool muted, std::string mute_icon, std::string volume_icon); + void hide(); + + const LauncherMediaOsdState &state() const { return state_; } + +private: + LauncherMediaOsdState state_; +}; diff --git a/projects/APPLaunch/main/ui/model/launcher_media_osd_contract.hpp b/projects/APPLaunch/main/ui/model/launcher_media_osd_contract.hpp new file mode 100644 index 00000000..6fe14eca --- /dev/null +++ b/projects/APPLaunch/main/ui/model/launcher_media_osd_contract.hpp @@ -0,0 +1,18 @@ +#pragma once + +template +constexpr bool launcher_media_osd_ui_ready(Container container, + Icon icon, + Title title, + Value value, + Bar bar) +{ + return container && icon && title && value && bar; +} + +template +constexpr bool launcher_media_osd_timer_is_current(CallbackTimer callback_timer, + ActiveTimer active_timer) +{ + return callback_timer && callback_timer == active_timer; +} diff --git a/projects/APPLaunch/main/ui/model/launcher_navigation_model.cpp b/projects/APPLaunch/main/ui/model/launcher_navigation_model.cpp new file mode 100644 index 00000000..41c8da3f --- /dev/null +++ b/projects/APPLaunch/main/ui/model/launcher_navigation_model.cpp @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "launcher_navigation_model.hpp" + +bool LauncherStartupDelayModel::begin() +{ + if (state_ != LauncherStartupDelayState::IDLE) + return false; + state_ = LauncherStartupDelayState::WAITING; + return true; +} + +bool LauncherStartupDelayModel::timer_created(bool created) +{ + if (state_ != LauncherStartupDelayState::WAITING || created) + return false; + state_ = LauncherStartupDelayState::COMPLETE; + return true; +} + +bool LauncherStartupDelayModel::complete() +{ + if (state_ != LauncherStartupDelayState::WAITING) + return false; + state_ = LauncherStartupDelayState::COMPLETE; + return true; +} + +void LauncherStartupDelayModel::stop() +{ + state_ = LauncherStartupDelayState::STOPPED; +} + +bool LauncherPageLifecycleModel::begin_app() +{ + if (state_ != LauncherPageState::HOME) return false; + state_ = LauncherPageState::APP_ACTIVE; + return true; +} + +bool LauncherPageLifecycleModel::request_home() +{ + if (state_ != LauncherPageState::APP_ACTIVE) return false; + state_ = LauncherPageState::HOME_PENDING; + return true; +} + +bool LauncherPageLifecycleModel::complete_home() +{ + if (state_ != LauncherPageState::HOME_PENDING) return false; + state_ = LauncherPageState::HOME; + return true; +} + +void LauncherPageLifecycleModel::cancel_home_request() +{ + if (state_ == LauncherPageState::HOME_PENDING) + state_ = LauncherPageState::APP_ACTIVE; +} + +void LauncherPageLifecycleModel::stop() +{ + state_ = LauncherPageState::STOPPED; +} + +LauncherPageState LauncherPageLifecycleModel::state() const +{ + return state_; +} + +bool LauncherNavigationModel::begin_navigation(LauncherNavigationDirection direction) +{ + if (is_animating_) + return false; + + previous_page_ = selected_page_; + is_animating_ = true; + if (direction == LauncherNavigationDirection::PREVIOUS) + selected_page_ = selected_page_ == 0 ? PAGE_COUNT - 1 : selected_page_ - 1; + else + selected_page_ = (selected_page_ + 1) % PAGE_COUNT; + return true; +} + +void LauncherNavigationModel::finish_navigation() +{ + is_animating_ = false; + previous_page_ = selected_page_; +} + +bool LauncherNavigationModel::cancel_navigation() +{ + if (!is_animating_) + return false; + selected_page_ = previous_page_; + is_animating_ = false; + return true; +} + +size_t LauncherNavigationModel::selected_page() const +{ + return selected_page_; +} + +bool LauncherNavigationModel::is_animating() const +{ + return is_animating_; +} + +bool LauncherNavigationModel::keyboard_navigation_enabled() const +{ + return !is_animating_; +} diff --git a/projects/APPLaunch/main/ui/model/launcher_navigation_model.hpp b/projects/APPLaunch/main/ui/model/launcher_navigation_model.hpp new file mode 100644 index 00000000..0078f10b --- /dev/null +++ b/projects/APPLaunch/main/ui/model/launcher_navigation_model.hpp @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include + +enum class LauncherNavigationDirection +{ + PREVIOUS, + NEXT, +}; + +enum class LauncherPageState +{ + HOME, + APP_ACTIVE, + HOME_PENDING, + STOPPED, +}; + +class LauncherPageLifecycleModel +{ +public: + bool begin_app(); + bool request_home(); + bool complete_home(); + void cancel_home_request(); + void stop(); + + LauncherPageState state() const; + +private: + LauncherPageState state_ = LauncherPageState::HOME; +}; + +class LauncherNavigationModel +{ +public: + static constexpr size_t PAGE_COUNT = 5; + static constexpr size_t INITIAL_PAGE = PAGE_COUNT / 2; + + bool begin_navigation(LauncherNavigationDirection direction); + void finish_navigation(); + bool cancel_navigation(); + + size_t selected_page() const; + bool is_animating() const; + bool keyboard_navigation_enabled() const; + +private: + size_t selected_page_ = INITIAL_PAGE; + size_t previous_page_ = INITIAL_PAGE; + bool is_animating_ = false; +}; + +enum class LauncherStartupDelayState +{ + IDLE, + WAITING, + COMPLETE, + STOPPED, +}; + +class LauncherStartupDelayModel +{ +public: + bool begin(); + bool timer_created(bool created); + bool complete(); + void stop(); + LauncherStartupDelayState state() const { return state_; } + +private: + LauncherStartupDelayState state_ = LauncherStartupDelayState::IDLE; +}; diff --git a/projects/APPLaunch/main/ui/model/launcher_startup_contract.hpp b/projects/APPLaunch/main/ui/model/launcher_startup_contract.hpp new file mode 100644 index 00000000..67501d7a --- /dev/null +++ b/projects/APPLaunch/main/ui/model/launcher_startup_contract.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include + +class LauncherStartupGeneration +{ +public: + using Token = std::uint64_t; + + Token begin() + { + if (++generation_ == 0) ++generation_; + active_ = true; + return generation_; + } + + bool current(Token token) const + { + return active_ && token != 0 && token == generation_; + } + + bool stop(Token token) + { + if (!current(token)) return false; + active_ = false; + return true; + } + +private: + Token generation_ = 0; + bool active_ = false; +}; + +template +constexpr bool launcher_startup_timer_is_current(CallbackTimerHandle callback_timer, + ActiveTimerHandle active_timer) +{ + return callback_timer && callback_timer == active_timer; +} + +struct LauncherStartupOneShotDecision +{ + bool retire_timer = false; + bool release_context = false; + bool complete = false; +}; + +constexpr LauncherStartupOneShotDecision launcher_startup_one_shot_decision( + bool timer_is_current, bool context_is_owned, bool generation_is_current) +{ + if (!timer_is_current) return {}; + return { + true, + context_is_owned, + context_is_owned && generation_is_current, + }; +} + +constexpr bool launcher_startup_should_recover_deleted_gif(bool is_current_gif, + bool gif_already_done) +{ + return is_current_gif && !gif_already_done; +} diff --git a/projects/APPLaunch/main/ui/model/lora_page_contract.hpp b/projects/APPLaunch/main/ui/model/lora_page_contract.hpp new file mode 100644 index 00000000..b118a707 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/lora_page_contract.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include + +constexpr bool lora_info_response_valid(int code, std::size_t payload_size, + std::size_t expected_size) +{ + return code == 0 && payload_size == expected_size; +} + +constexpr bool lora_poll_callback_allowed(bool timer_is_current, + bool app_active) noexcept +{ + return timer_is_current && app_active; +} + +template +constexpr bool lora_animation_callback_allowed(Target target) noexcept +{ + return static_cast(target); +} + +template +constexpr bool lora_owned_delete_callback_allowed( + Target target, CurrentTarget current_target) noexcept +{ + return target && target == current_target; +} + +template +constexpr bool lora_send_action_callback_allowed( + CurrentTarget current_target, OwnedControl owned_control, + bool app_active, bool send_view_active) noexcept +{ + return current_target && current_target == owned_control && + app_active && send_view_active; +} + +template +constexpr bool lora_page_event_callback_allowed( + CurrentTarget current_target, PageRoot page_root, bool app_active) noexcept +{ + return current_target && current_target == page_root && app_active; +} + +template +bool lora_page_ui_ready(Handle page_root, Handle messages_view, Handle message_list, + Handle info_view, Handle send_view, Handle page_indicator) +{ + return page_root && messages_view && message_list && info_view && send_view && page_indicator; +} + +template +bool lora_page_controls_ready(Handles... handles) +{ + return (... && static_cast(handles)); +} diff --git a/projects/APPLaunch/main/ui/model/lora_page_model.cpp b/projects/APPLaunch/main/ui/model/lora_page_model.cpp new file mode 100644 index 00000000..d2780972 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/lora_page_model.cpp @@ -0,0 +1,56 @@ +#include "lora_page_model.hpp" + +#include + +void LoraPageModel::reset(bool hardware_ready) +{ + view_ = hardware_ready ? LoraView::MESSAGES : LoraView::INFO; + tx_input_.clear(); + send_status_.clear(); + messages_.clear(); +} + +void LoraPageModel::begin_send(char first_character) +{ + view_ = LoraView::SEND; + tx_input_.clear(); + send_status_.clear(); + if (first_character >= 0x20 && first_character <= 0x7e) tx_input_.push_back(first_character); +} + +void LoraPageModel::cancel_send() +{ + view_ = LoraView::MESSAGES; + tx_input_.clear(); + send_status_.clear(); +} + +bool LoraPageModel::append_character(char character) +{ + if (character < 0x20 || character > 0x7e || tx_input_.size() >= TX_INPUT_LIMIT) return false; + tx_input_.push_back(character); + send_status_.clear(); + return true; +} + +bool LoraPageModel::erase_character() +{ + if (tx_input_.empty()) return false; + tx_input_.pop_back(); + send_status_.clear(); + return true; +} + +void LoraPageModel::complete_send() +{ + view_ = LoraView::MESSAGES; + tx_input_.clear(); + send_status_.clear(); +} + +void LoraPageModel::append_message(std::string text, bool outgoing, float rssi, float snr) +{ + if (text.empty()) text = ""; + if (messages_.size() >= MESSAGE_HISTORY_LIMIT) messages_.pop_front(); + messages_.push_back({std::move(text), outgoing, rssi, snr}); +} diff --git a/projects/APPLaunch/main/ui/model/lora_page_model.hpp b/projects/APPLaunch/main/ui/model/lora_page_model.hpp new file mode 100644 index 00000000..ab066207 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/lora_page_model.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +enum class LoraView { MESSAGES, INFO, SEND }; + +struct LoraChatMessage { + std::string text; + bool outgoing = false; + float rssi = 0.0f; + float snr = 0.0f; +}; + +class LoraPageModel +{ +public: + static constexpr size_t MESSAGE_HISTORY_LIMIT = 64; + static constexpr size_t TX_INPUT_LIMIT = 127; + + void reset(bool hardware_ready); + void set_view(LoraView view) { view_ = view; } + LoraView view() const { return view_; } + + void begin_send(char first_character = 0); + void cancel_send(); + bool append_character(char character); + bool erase_character(); + void set_send_status(std::string status) { send_status_ = std::move(status); } + void complete_send(); + + void append_message(std::string text, bool outgoing, float rssi, float snr); + + const std::string &tx_input() const { return tx_input_; } + const std::string &send_status() const { return send_status_; } + const std::deque &messages() const { return messages_; } + +private: + LoraView view_ = LoraView::MESSAGES; + std::string tx_input_; + std::string send_status_; + std::deque messages_; +}; diff --git a/projects/APPLaunch/main/ui/model/mesh_page_contract.hpp b/projects/APPLaunch/main/ui/model/mesh_page_contract.hpp new file mode 100644 index 00000000..641299c5 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/mesh_page_contract.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "integer_parse_policy.hpp" + +#include +#include +#include +#include + +inline bool mesh_parse_random_u32(const std::string &text, uint32_t &value) +{ + return launcher_ui::integer_parse::complete( + text, 0, std::numeric_limits::max(), value); +} + +inline bool mesh_parse_local_time(const std::string &text, int &hour, int &minute, int &second) +{ + std::array values{}; + size_t begin = 0; + for (size_t index = 0; index < values.size(); ++index) { + const size_t end = text.find(',', begin); + if ((index + 1 < values.size() && end == std::string::npos) || + (index + 1 == values.size() && end != std::string::npos)) + return false; + const std::string field = text.substr(begin, end - begin); + if (!launcher_ui::integer_parse::complete( + field, 0, index == 0 ? 9999 : 59, values[index])) + return false; + begin = end == std::string::npos ? text.size() : end + 1; + } + if (values[1] < 1 || values[1] > 12 || values[2] < 1 || values[2] > 31 || + values[3] > 23) + return false; + hour = values[3]; + minute = values[4]; + second = values[5]; + return true; +} + +template +constexpr bool mesh_page_ui_ready(Handles... handles) +{ + return (... && static_cast(handles)); +} + +constexpr bool mesh_heartbeat_callback_allowed(bool timer_is_current, + bool heartbeat_enabled) noexcept +{ + return timer_is_current && heartbeat_enabled; +} + +template +constexpr bool mesh_owned_delete_callback_allowed( + Target target, CurrentTarget current_target) noexcept +{ + return target && target == current_target; +} + +template +constexpr bool mesh_root_event_callback_allowed(CurrentTarget current_target, + RootScreen root_screen) noexcept +{ + return current_target && current_target == root_screen; +} diff --git a/projects/APPLaunch/main/ui/model/mesh_page_model.cpp b/projects/APPLaunch/main/ui/model/mesh_page_model.cpp new file mode 100644 index 00000000..361ed685 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/mesh_page_model.cpp @@ -0,0 +1,62 @@ +#include "mesh_page_model.hpp" + +#include +#include +#include + +void MeshPageModel::initialize(uint32_t seed) +{ + random_.seed(seed ? seed : 1); + char node_id[16]; + std::snprintf(node_id, sizeof(node_id), "0x%04X", seed & 0xffff); + node_id_ = node_id; + neighbors_.clear(); + messages_.clear(); + heartbeat_count_ = 0; +} + +void MeshPageModel::refresh(const std::string ×tamp) +{ + neighbors_.clear(); + const int count = static_cast(next_random() % 4); + for (int index = 0; index < count; ++index) { + char node_id[16]; + std::snprintf(node_id, sizeof(node_id), "0x%04X", next_random() & 0xffff); + neighbors_.push_back({node_id, -(40 + static_cast(next_random() % 60)), + 1 + static_cast(next_random() % 3), timestamp}); + } + + char status[64]; + if (count > 0) { + std::snprintf(status, sizeof(status), "Scan: %d node(s) found", count); + } else { + std::snprintf(status, sizeof(status), "Scan: no nodes nearby"); + } + add_message(timestamp, "SYS", status); +} + +void MeshPageModel::heartbeat(const std::string ×tamp) +{ + ++heartbeat_count_; + if (heartbeat_count_ % 2 == 0 && !neighbors_.empty()) { + static constexpr const char *messages[] = {"PING", "ACK", "HELLO", "STATUS:OK", "HEARTBEAT"}; + const auto &neighbor = neighbors_[next_random() % neighbors_.size()]; + add_message(timestamp, neighbor.node_id, messages[next_random() % 5]); + } + for (auto &neighbor : neighbors_) { + neighbor.rssi += static_cast(next_random() % 7) - 3; + neighbor.rssi = std::max(-100, std::min(-30, neighbor.rssi)); + neighbor.last_seen = timestamp; + } +} + +void MeshPageModel::add_message(std::string timestamp, std::string sender, std::string text) +{ + messages_.push_back({std::move(timestamp), std::move(sender), std::move(text)}); + if (messages_.size() > MAX_MESSAGES) messages_.erase(messages_.begin()); +} + +uint32_t MeshPageModel::next_random() +{ + return random_(); +} diff --git a/projects/APPLaunch/main/ui/model/mesh_page_model.hpp b/projects/APPLaunch/main/ui/model/mesh_page_model.hpp new file mode 100644 index 00000000..37feed0f --- /dev/null +++ b/projects/APPLaunch/main/ui/model/mesh_page_model.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include + +struct MeshMessage { + std::string timestamp; + std::string sender; + std::string text; +}; + +struct MeshNeighbor { + std::string node_id; + int rssi = 0; + int hops = 0; + std::string last_seen; +}; + +class MeshPageModel +{ +public: + static constexpr size_t MAX_MESSAGES = 20; + + void initialize(uint32_t seed); + void refresh(const std::string ×tamp); + void heartbeat(const std::string ×tamp); + void add_message(std::string timestamp, std::string sender, std::string text); + + const std::string &node_id() const { return node_id_; } + const std::vector &neighbors() const { return neighbors_; } + const std::vector &messages() const { return messages_; } + int channel() const { return 7; } + int frequency_mhz() const { return 915; } + bool lora_detected() const { return false; } + +private: + uint32_t next_random(); + + std::string node_id_ = "0x0000"; + std::vector neighbors_; + std::vector messages_; + std::minstd_rand random_{1}; + int heartbeat_count_ = 0; +}; diff --git a/projects/APPLaunch/main/ui/model/page_timer_lifecycle.hpp b/projects/APPLaunch/main/ui/model/page_timer_lifecycle.hpp new file mode 100644 index 00000000..32a8d875 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/page_timer_lifecycle.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include + +template +class PageTimerLifecycle +{ +public: + using Deleter = std::function; + + PageTimerLifecycle() = default; + ~PageTimerLifecycle() noexcept { stop(); } + + PageTimerLifecycle(const PageTimerLifecycle &) = delete; + PageTimerLifecycle &operator=(const PageTimerLifecycle &) = delete; + + template + bool start(Factory &&factory, Deleter deleter) + { + std::lock_guard lock(mutex_); + if (handle_) + return true; + Handle handle = std::forward(factory)(); + if (!handle) + return false; + handle_ = handle; + deleter_ = std::move(deleter); + return true; + } + + void stop() noexcept + { + Handle handle{}; + Deleter deleter; + { + std::lock_guard lock(mutex_); + handle = handle_; + handle_ = Handle{}; + deleter = std::move(deleter_); + } + if (handle && deleter) { + try { + deleter(handle); + } catch (...) { + } + } + } + + bool current(Handle handle) const + { + std::lock_guard lock(mutex_); + return handle && handle_ == handle; + } + + bool active() const + { + std::lock_guard lock(mutex_); + return static_cast(handle_); + } + +private: + mutable std::mutex mutex_; + Handle handle_{}; + Deleter deleter_; +}; diff --git a/projects/APPLaunch/main/ui/model/rtc_state_model.cpp b/projects/APPLaunch/main/ui/model/rtc_state_model.cpp new file mode 100644 index 00000000..ae621664 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/rtc_state_model.cpp @@ -0,0 +1,210 @@ +#include "rtc_state_model.hpp" + +#include + +namespace setting { + +namespace { + +constexpr unsigned field_index(RtcField field) +{ + return static_cast(field); +} + +} // namespace + +RtcConfirmAction RtcWriteConfirmModel::handle(RtcConfirmInput input) +{ + switch (input) { + case RtcConfirmInput::SELECT_SAVE: + save_selected_ = true; + return RtcConfirmAction::NONE; + case RtcConfirmInput::SELECT_DISCARD: + save_selected_ = false; + return RtcConfirmAction::NONE; + case RtcConfirmInput::CONFIRM: + return save_selected_ ? RtcConfirmAction::SAVE : RtcConfirmAction::DISCARD; + case RtcConfirmInput::CANCEL: + return RtcConfirmAction::DISCARD; + } + return RtcConfirmAction::NONE; +} + +RtcOverlayLifecycleModel::Token RtcOverlayLifecycleModel::open() +{ + if (active_) return 0; + active_ = true; + if (++generation_ == 0) ++generation_; + return generation_; +} + +bool RtcOverlayLifecycleModel::close(Token token) +{ + if (!active_ || token == 0 || token != generation_) return false; + active_ = false; + return true; +} + +void RtcStateModel::set_ntp_status(int status) +{ + ntp_available_ = status >= 0; + if (ntp_available_) + ntp_on_ = status == 1; +} + +void RtcStateModel::rollback_ntp(bool previous) +{ + ntp_on_ = previous; +} + +NtpToggleEligibility RtcStateModel::ntp_toggle_eligibility(bool in_flight) const +{ + if (in_flight) return NtpToggleEligibility::IN_FLIGHT; + if (dirty_) return NtpToggleEligibility::DIRTY; + if (!ntp_available_) return NtpToggleEligibility::UNAVAILABLE; + return NtpToggleEligibility::ALLOWED; +} + +bool RtcStateModel::load_local_time(const std::string &csv) +{ + Values parsed{}; + char trailing = '\0'; + if (std::sscanf(csv.c_str(), "%d,%d,%d,%d,%d,%d %c", &parsed[0], &parsed[1], + &parsed[2], &parsed[3], &parsed[4], &parsed[5], &trailing) != 6 || !is_valid(parsed)) + return false; + + values_ = parsed; + dirty_ = false; + return true; +} + +int RtcStateModel::field_min(RtcField field) const +{ + static constexpr int minimums[] = {2000, 1, 1, 0, 0, 0}; + const unsigned index = field_index(field); + return index < field_index(RtcField::COUNT) ? minimums[index] : 0; +} + +int RtcStateModel::field_max(RtcField field) const +{ + static constexpr int maximums[] = {2099, 12, 31, 23, 59, 59}; + const unsigned index = field_index(field); + if (field == RtcField::DAY) + return days_in_month(values_[field_index(RtcField::YEAR)], values_[field_index(RtcField::MONTH)]); + return index < field_index(RtcField::COUNT) ? maximums[index] : 0; +} + +int RtcStateModel::field_value(RtcField field) const +{ + const unsigned index = field_index(field); + return index < values_.size() ? values_[index] : 0; +} + +bool RtcStateModel::edit_field(RtcField field, int value) +{ + const unsigned index = field_index(field); + if (index >= values_.size() || value < field_min(field) || value > field_max(field)) + return false; + + values_[index] = value; + if (field == RtcField::YEAR || field == RtcField::MONTH) { + const int maximum_day = field_max(RtcField::DAY); + if (values_[field_index(RtcField::DAY)] > maximum_day) + values_[field_index(RtcField::DAY)] = maximum_day; + } + dirty_ = true; + return true; +} + +std::vector RtcStateModel::field_options(RtcField field) const +{ + std::vector options; + if (field_index(field) >= field_index(RtcField::COUNT)) + return options; + const int minimum = field_min(field); + const int maximum = field_max(field); + if (maximum < minimum) + return options; + options.reserve(static_cast(maximum - minimum + 1)); + for (int value = minimum; value <= maximum; ++value) + options.push_back(std::to_string(value)); + return options; +} + +int RtcStateModel::field_selection_index(RtcField field) const +{ + const unsigned index = field_index(field); + if (index >= values_.size()) + return -1; + return field_value(field) - field_min(field); +} + +bool RtcStateModel::edit_field_selection(RtcField field, std::size_t selection) +{ + const int minimum = field_min(field); + const int maximum = field_max(field); + if (maximum < minimum || selection > static_cast(maximum - minimum)) + return false; + return edit_field(field, minimum + static_cast(selection)); +} + +std::string RtcStateModel::timestamp() const +{ + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), "%04d-%02d-%02d %02d:%02d:%02d", + values_[0], values_[1], values_[2], values_[3], values_[4], values_[5]); + return buffer; +} + +std::list RtcStateModel::commit_request() const +{ + return {"TimeSet", timestamp()}; +} + +std::string_view RtcStateModel::field_name(RtcField field) +{ + static constexpr std::string_view names[] = { + "Year", "Month", "Day", "Hour", "Minute", "Second", + }; + const unsigned index = field_index(field); + return index < field_index(RtcField::COUNT) ? names[index] : std::string_view{}; +} + +bool RtcStateModel::field_from_index(int index, RtcField &field) +{ + if (index < 0 || index >= static_cast(RtcField::COUNT)) + return false; + field = static_cast(index); + return true; +} + +int RtcStateModel::days_in_month(int year, int month) +{ + static constexpr int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month < 1 || month > 12) return 0; + if (month != 2) return days[month - 1]; + const bool leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + return leap ? 29 : 28; +} + +bool RtcStateModel::is_valid(const Values &values) +{ + if (values[0] < 2000 || values[0] > 2099 || values[1] < 1 || values[1] > 12) + return false; + return values[2] >= 1 && values[2] <= days_in_month(values[0], values[1]) && + values[3] >= 0 && values[3] <= 23 && values[4] >= 0 && values[4] <= 59 && + values[5] >= 0 && values[5] <= 59; +} + +PrivilegedResultKind classify_privileged_result(int result) +{ + switch (result) { + case 0: return PrivilegedResultKind::SUCCESS; + case 1: return PrivilegedResultKind::AUTH_FAILED; + case 3: return PrivilegedResultKind::CANCELLED; + case 4: return PrivilegedResultKind::TIMED_OUT; + default: return PrivilegedResultKind::EXEC_FAILED; + } +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/model/rtc_state_model.hpp b/projects/APPLaunch/main/ui/model/rtc_state_model.hpp new file mode 100644 index 00000000..9b0f1ac9 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/rtc_state_model.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace setting { + +template +constexpr bool rtc_overlay_delete_callback_allowed(EventTarget event_target, + CurrentTarget current_target, + TrackedOverlay tracked_overlay) noexcept +{ + return event_target && event_target == current_target && + event_target == tracked_overlay; +} + +enum class RtcField { YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, COUNT }; + +enum class NtpToggleEligibility { ALLOWED, IN_FLIGHT, DIRTY, UNAVAILABLE }; + +enum class PrivilegedResultKind { SUCCESS, AUTH_FAILED, CANCELLED, TIMED_OUT, EXEC_FAILED }; + +enum class RtcConfirmInput { SELECT_SAVE, SELECT_DISCARD, CONFIRM, CANCEL }; +enum class RtcConfirmAction { NONE, SAVE, DISCARD }; + +class RtcWriteConfirmModel +{ +public: + void reset() { save_selected_ = false; } + bool save_selected() const { return save_selected_; } + RtcConfirmAction handle(RtcConfirmInput input); + +private: + bool save_selected_ = false; +}; + +class RtcOverlayLifecycleModel +{ +public: + using Token = std::uint64_t; + + Token open(); + bool close(Token token); + bool active() const { return active_; } + +private: + bool active_ = false; + Token generation_ = 0; +}; + +class RtcStateModel +{ +public: + using Values = std::array(RtcField::COUNT)>; + + const Values &values() const { return values_; } + bool dirty() const { return dirty_; } + bool ntp_on() const { return ntp_on_; } + bool ntp_available() const { return ntp_available_; } + + void set_ntp_status(int status); + void rollback_ntp(bool previous); + NtpToggleEligibility ntp_toggle_eligibility(bool in_flight) const; + + bool load_local_time(const std::string &csv); + int field_min(RtcField field) const; + int field_max(RtcField field) const; + int field_value(RtcField field) const; + bool edit_field(RtcField field, int value); + std::vector field_options(RtcField field) const; + int field_selection_index(RtcField field) const; + bool edit_field_selection(RtcField field, std::size_t selection); + + void discard_edits() { dirty_ = false; } + void finish_commit(bool succeeded) { dirty_ = !succeeded; } + std::string timestamp() const; + std::list commit_request() const; + + static int days_in_month(int year, int month); + static std::string_view field_name(RtcField field); + static bool field_from_index(int index, RtcField &field); + +private: + static bool is_valid(const Values &values); + + Values values_ = {2026, 1, 1, 0, 0, 0}; + bool ntp_on_ = true; + bool ntp_available_ = true; + bool dirty_ = false; +}; + +PrivilegedResultKind classify_privileged_result(int result); + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/model/screensaver_model.cpp b/projects/APPLaunch/main/ui/model/screensaver_model.cpp new file mode 100644 index 00000000..968b6095 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/screensaver_model.cpp @@ -0,0 +1,102 @@ +#include "screensaver_model.hpp" + +#include + +namespace { + +constexpr int kVelocityX = 45; +constexpr int kVelocityY = 35; + +uint32_t elapsed_since(uint32_t now, uint32_t previous) +{ + return now - previous; +} + +} // namespace + +void ScreensaverModel::reset(uint32_t now) +{ + active_ = false; + swallow_active_ = false; + last_activity_tick_ = now; + last_frame_tick_ = 0; +} + +void ScreensaverModel::set_foreground(bool foreground, uint32_t now) +{ + foreground_ = foreground; + reset(now); +} + +bool ScreensaverModel::should_activate(uint32_t now, uint32_t timeout_ms, bool runtime_ready) const +{ + return foreground_ && runtime_ready && !active_ && timeout_ms > 0 && + elapsed_since(now, last_activity_tick_) >= timeout_ms; +} + +ScreensaverFrame ScreensaverModel::activate(int width, int height, uint32_t now) +{ + x_milli_ = std::max(0, (width - BLOCK_SIZE) / 4) * 1000; + y_milli_ = std::max(0, (height - BLOCK_SIZE) / 3) * 1000; + velocity_x_ = kVelocityX; + velocity_y_ = kVelocityY; + color_index_ = 0; + active_ = true; + last_frame_tick_ = now; + return frame(true); +} + +void ScreensaverModel::deactivate() +{ + active_ = false; + last_frame_tick_ = 0; +} + +ScreensaverFrame ScreensaverModel::advance(int width, int height, uint32_t now) +{ + if (!active_) return frame(); + + const uint32_t elapsed = std::min(elapsed_since(now, last_frame_tick_), 100); + last_frame_tick_ = now; + const int32_t max_x = std::max(0, width - BLOCK_SIZE) * 1000; + const int32_t max_y = std::max(0, height - BLOCK_SIZE) * 1000; + x_milli_ += velocity_x_ * static_cast(elapsed); + y_milli_ += velocity_y_ * static_cast(elapsed); + + bool collided = false; + if (x_milli_ <= 0 || x_milli_ >= max_x) { + x_milli_ = std::clamp(x_milli_, 0, max_x); + velocity_x_ = -velocity_x_; + collided = true; + } + if (y_milli_ <= 0 || y_milli_ >= max_y) { + y_milli_ = std::clamp(y_milli_, 0, max_y); + velocity_y_ = -velocity_y_; + collided = true; + } + if (collided) color_index_ = (color_index_ + 1) % COLOR_COUNT; + return frame(collided); +} + +bool ScreensaverModel::filter_key(uint32_t key_code, bool released, uint32_t now) +{ + last_activity_tick_ = now; + if (swallow_active_) { + if (key_code == swallowed_key_code_) { + if (released) swallow_active_ = false; + return true; + } + swallow_active_ = false; + } + + if (!active_) return false; + deactivate(); + swallowed_key_code_ = key_code; + swallow_active_ = !released; + return true; +} + +ScreensaverFrame ScreensaverModel::frame(bool color_changed) const +{ + return {x_milli_ / 1000, y_milli_ / 1000, color_index_, color_changed}; +} diff --git a/projects/APPLaunch/main/ui/model/screensaver_model.hpp b/projects/APPLaunch/main/ui/model/screensaver_model.hpp new file mode 100644 index 00000000..6c08c76e --- /dev/null +++ b/projects/APPLaunch/main/ui/model/screensaver_model.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +struct ScreensaverFrame +{ + int x = 0; + int y = 0; + size_t color_index = 0; + bool color_changed = false; +}; + +class ScreensaverModel +{ +public: + static constexpr int BLOCK_SIZE = 20; + static constexpr size_t COLOR_COUNT = 8; + + void reset(uint32_t now); + void set_foreground(bool foreground, uint32_t now); + bool should_activate(uint32_t now, uint32_t timeout_ms, bool runtime_ready) const; + ScreensaverFrame activate(int width, int height, uint32_t now); + void deactivate(); + ScreensaverFrame advance(int width, int height, uint32_t now); + bool filter_key(uint32_t key_code, bool released, uint32_t now); + + bool active() const { return active_; } + bool foreground() const { return foreground_; } + uint32_t last_activity_tick() const { return last_activity_tick_; } + +private: + ScreensaverFrame frame(bool color_changed = false) const; + + uint32_t last_activity_tick_ = 0; + uint32_t last_frame_tick_ = 0; + uint32_t swallowed_key_code_ = 0; + int32_t x_milli_ = 0; + int32_t y_milli_ = 0; + int velocity_x_ = 45; + int velocity_y_ = 35; + size_t color_index_ = 0; + bool active_ = false; + bool foreground_ = true; + bool swallow_active_ = false; +}; diff --git a/projects/APPLaunch/main/ui/model/screensaver_runtime_contract.hpp b/projects/APPLaunch/main/ui/model/screensaver_runtime_contract.hpp new file mode 100644 index 00000000..5429f459 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/screensaver_runtime_contract.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +template +constexpr bool screensaver_timer_is_current(CallbackTimer callback_timer, + ActiveTimer active_timer) +{ + return callback_timer && callback_timer == active_timer; +} + +template +constexpr bool screensaver_delete_is_tracked(Target target, + CurrentTarget current_target, + Owned owned) +{ + return target && target == current_target && target == owned; +} + +inline int screensaver_timeout_from_config(bool request_succeeded, + const std::string &text, + int default_seconds = 30) +{ + if (!request_succeeded || text.empty()) return default_seconds; + int value = 0; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, value); + if (result.ec != std::errc{} || result.ptr != end) return default_seconds; + switch (value) { + case 0: + case 10: + case 30: + case 60: + case 300: + return value; + default: + return default_seconds; + } +} diff --git a/projects/APPLaunch/main/ui/model/setup_info_model.cpp b/projects/APPLaunch/main/ui/model/setup_info_model.cpp new file mode 100644 index 00000000..b518e5ae --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_info_model.cpp @@ -0,0 +1,73 @@ +#include "setup_info_model.hpp" + +#include +#include +#include +#include + +namespace { + +bool parse_fields(const std::string &response, std::array &fields) +{ + std::size_t begin = 0; + for (std::size_t index = 0; index < fields.size(); ++index) { + const std::size_t end = index + 1 == fields.size() + ? response.size() + : response.find(',', begin); + if (end == std::string::npos || end == begin) return false; + const char *first = response.data() + begin; + const char *last = response.data() + end; + const auto parsed = std::from_chars(first, last, fields[index]); + if (parsed.ec != std::errc{} || parsed.ptr != last) return false; + begin = end + (index + 1 == fields.size() ? 0 : 1); + } + return begin == response.size(); +} + +} // namespace + +bool SetupInfoModel::update(int result_code, const std::string &response) +{ + std::array fields{}; + SetupBatterySnapshot parsed; + const bool decoded = result_code == 0 && parse_fields(response, fields); + if (!decoded) return false; + parsed.voltage_mv = fields[0]; + parsed.current_ma = fields[1]; + parsed.temperature_c10 = fields[2]; + parsed.soc = fields[3]; + const bool current_valid = parsed.current_ma == INT32_MIN || + (parsed.current_ma >= -5000 && parsed.current_ma <= 5000); + parsed.valid = fields[8] == 1 && parsed.voltage_mv >= 0 && + parsed.voltage_mv <= 20000 && current_valid && + parsed.temperature_c10 >= -400 && parsed.temperature_c10 <= 1000 && + parsed.soc >= 0 && parsed.soc <= 100 && fields[4] >= 0 && + fields[5] >= 0 && (fields[5] == 0 || fields[4] <= fields[5]) && + fields[6] >= 0 && fields[7] >= -5000 && fields[7] <= 5000; + if (!parsed.valid) return false; + snapshot_ = parsed; + rebuild_labels(); + return true; +} + +void SetupInfoModel::rebuild_labels() +{ + if (!snapshot_.valid) { + labels_ = {"Battery: --%", "Temp: --C", "Current: --mA", "Voltage: --V"}; + return; + } + + char text[64]; + std::snprintf(text, sizeof(text), "Battery: %d%%", snapshot_.soc); + labels_[0] = text; + std::snprintf(text, sizeof(text), "Temp: %.1fC", snapshot_.temperature_c10 / 10.0); + labels_[1] = text; + if (snapshot_.current_ma == INT32_MIN) + labels_[2] = "Current: --mA"; + else { + std::snprintf(text, sizeof(text), "Current: %dmA", snapshot_.current_ma); + labels_[2] = text; + } + std::snprintf(text, sizeof(text), "Voltage: %.2fV", snapshot_.voltage_mv / 1000.0); + labels_[3] = text; +} diff --git a/projects/APPLaunch/main/ui/model/setup_info_model.hpp b/projects/APPLaunch/main/ui/model/setup_info_model.hpp new file mode 100644 index 00000000..e92dbee8 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_info_model.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +struct SetupBatterySnapshot +{ + int voltage_mv = 0; + int current_ma = 0; + int temperature_c10 = 0; + int soc = 0; + bool valid = false; +}; + +class SetupInfoModel +{ +public: + bool update(int result_code, const std::string &response); + const SetupBatterySnapshot &snapshot() const { return snapshot_; } + const std::array &labels() const { return labels_; } + +private: + void rebuild_labels(); + + SetupBatterySnapshot snapshot_; + std::array labels_ = { + "Battery: --%", "Temp: --C", "Current: --mA", "Voltage: --V"}; +}; diff --git a/projects/APPLaunch/main/ui/model/setup_info_timer_contract.hpp b/projects/APPLaunch/main/ui/model/setup_info_timer_contract.hpp new file mode 100644 index 00000000..3a01a4d6 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_info_timer_contract.hpp @@ -0,0 +1,15 @@ +#pragma once + +template +constexpr bool setup_info_timer_callback_ready( + Timer callback_timer, bool timer_is_current, Page page) +{ + return callback_timer && timer_is_current && page; +} + +template +constexpr bool setup_info_label_delete_callback_allowed( + Target target, CurrentTarget current_target) noexcept +{ + return target && target == current_target; +} diff --git a/projects/APPLaunch/main/ui/model/setup_page_model.cpp b/projects/APPLaunch/main/ui/model/setup_page_model.cpp new file mode 100644 index 00000000..1b60f8e9 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_page_model.cpp @@ -0,0 +1,113 @@ +#include "setup_page_model.hpp" + +#include +#include + +void SetupConfirmController::begin(Action action) +{ + action_ = std::move(action); +} + +void SetupConfirmController::cancel() +{ + action_ = nullptr; +} + +bool SetupConfirmController::active() const +{ + return static_cast(action_); +} + +bool SetupConfirmController::resolve(bool confirmed) +{ + Action action = std::move(action_); + action_ = nullptr; + if (!confirmed || !action) return false; + action(); + return true; +} + +bool SetupPageLifecycle::begin_animation() +{ + if (!active_ || animating_) return false; + animating_ = true; + return true; +} + +void SetupPageLifecycle::finish_animation() +{ + if (active_) animating_ = false; +} + +void SetupPageLifecycle::cancel_animation() +{ + animating_ = false; +} + +void SetupPageLifecycle::root_deleted() +{ + active_ = false; + animating_ = false; +} + +int SetupPageModel::clamp_index(int index, int item_count) +{ + if (item_count <= 0) return 0; + return std::max(0, std::min(index, item_count - 1)); +} + +bool SetupPageModel::move_main(int direction, int item_count) +{ + int next = clamp_index(selected_index + direction, item_count); + if (next == selected_index) return false; + selected_index = next; + return true; +} + +void SetupPageModel::enter_sub(int item_count, int center_row) +{ + view = SetupViewState::SUB; + sub_selected_index = clamp_index(std::min(center_row, item_count - 1), item_count); +} + +bool SetupPageModel::move_sub(int direction, int item_count) +{ + int next = clamp_index(sub_selected_index + direction, item_count); + if (next == sub_selected_index) return false; + sub_selected_index = next; + return true; +} + +void SetupPageModel::enter_value(std::string title, std::vector options, int selected) +{ + value_title = std::move(title); + value_options = std::move(options); + value_selected_index = clamp_index(selected, static_cast(value_options.size())); + view = SetupViewState::VALUE_SELECT; +} + +bool SetupPageModel::enter_help() +{ + if (view != SetupViewState::SUB) return false; + view = SetupViewState::HELP; + return true; +} + +bool SetupPageModel::leave_help() +{ + if (view != SetupViewState::HELP) return false; + view = SetupViewState::SUB; + return true; +} + +bool SetupPageModel::move_value(int direction) +{ + int next = clamp_index(value_selected_index + direction, + static_cast(value_options.size())); + if (next == value_selected_index) return false; + value_selected_index = next; + return true; +} + +void SetupPageModel::leave_to_main() { view = SetupViewState::MAIN; } +void SetupPageModel::leave_to_sub() { view = SetupViewState::SUB; } diff --git a/projects/APPLaunch/main/ui/model/setup_page_model.hpp b/projects/APPLaunch/main/ui/model/setup_page_model.hpp new file mode 100644 index 00000000..f15fffa0 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_page_model.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +enum class SetupViewState { + MAIN, SUB, VALUE_SELECT, HELP, WIFI_LIST, WIFI_PW, WIFI_FORGET_CONFIRM, + BT_LIST, BT_ALIAS, + SOUNDCARD_CARDS, SOUNDCARD_CONTROLS, SOUNDCARD_DETAIL, + USB_GUIDE, ADB_PAIR, ADB_AUTHORIZATIONS, +}; + +class SetupConfirmController +{ +public: + using Action = std::function; + + void begin(Action action); + void cancel(); + bool active() const; + bool resolve(bool confirmed); + +private: + Action action_; +}; + +class SetupPageLifecycle +{ +public: + bool begin_animation(); + void finish_animation(); + void cancel_animation(); + void root_deleted(); + + bool active() const { return active_; } + bool animating() const { return animating_; } + +private: + bool active_ = true; + bool animating_ = false; +}; + +template +constexpr bool setup_root_callback_allowed( + CurrentTarget current_target, TrackedRoot tracked_root) noexcept +{ + return current_target && current_target == tracked_root; +} + +template +bool setup_teardown_step(Action &&action) noexcept +{ + try { + action(); + return true; + } catch (...) { + return false; + } +} + +class SetupPageModel +{ +public: + static constexpr int DEFAULT_MENU_INDEX = 2; + static constexpr int DEFAULT_CENTER_ROW = 3; + + int selected_index = DEFAULT_MENU_INDEX; + int sub_selected_index = 0; + SetupViewState view = SetupViewState::MAIN; + int value_selected_index = 0; + std::vector value_options; + std::string value_title; + + bool move_main(int direction, int item_count); + void enter_sub(int item_count, int center_row = DEFAULT_CENTER_ROW); + bool move_sub(int direction, int item_count); + void enter_value(std::string title, std::vector options, int selected_index); + bool enter_help(); + bool leave_help(); + bool move_value(int direction); + void leave_to_main(); + void leave_to_sub(); + +private: + static int clamp_index(int index, int item_count); +}; diff --git a/projects/APPLaunch/main/ui/model/setup_value_policy.cpp b/projects/APPLaunch/main/ui/model/setup_value_policy.cpp new file mode 100644 index 00000000..f2aedda4 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_value_policy.cpp @@ -0,0 +1,115 @@ +#include "setup_value_policy.hpp" + +#include +#include +#include + +namespace setup_values { +namespace { + +constexpr int kBrightnessPercentages[] = {100, 75, 50, 25}; +constexpr int kDarkTimes[] = {0, 10, 30, 60, 300}; +constexpr int kVolumePercentages[] = {100, 75, 50, 25, 0}; +constexpr CameraResolution kCameraResolutions[] = {{1280, 720}, {640, 480}}; + +template +constexpr int array_size(const T (&)[N]) +{ + return N; +} + +int clamp_index(int index, int count) +{ + return std::max(0, std::min(index, count - 1)); +} + +} // namespace + +int brightness_index(int value, int maximum) +{ + const int percent = maximum > 0 + ? static_cast(static_cast(value) * 100 / maximum) + : 100; + if (percent >= 87) return 0; + if (percent >= 62) return 1; + if (percent >= 37) return 2; + return 3; +} + +int brightness_value(int index, int maximum) +{ + const int safe_maximum = std::max(1, maximum); + const int percentage = kBrightnessPercentages[ + clamp_index(index, array_size(kBrightnessPercentages))]; + return std::max(1, static_cast( + static_cast(safe_maximum) * percentage / 100)); +} + +bool parse_nonnegative_int(std::string_view text, int &value) +{ + if (text.empty()) return false; + int parsed = 0; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, parsed); + if (result.ec != std::errc{} || result.ptr != end || parsed < 0) return false; + value = parsed; + return true; +} + +int dark_time_index(int seconds) +{ + for (int i = 0; i < array_size(kDarkTimes); ++i) { + if (kDarkTimes[i] == seconds) return i; + } + return 2; +} + +int dark_time_seconds(int index) +{ + return kDarkTimes[clamp_index(index, array_size(kDarkTimes))]; +} + +int volume_index(int percent) +{ + if (percent >= 87) return 0; + if (percent >= 62) return 1; + if (percent >= 37) return 2; + if (percent >= 12) return 3; + return 4; +} + +int volume_percent(int index) +{ + return kVolumePercentages[clamp_index(index, array_size(kVolumePercentages))]; +} + +bool volume_value_valid(int percent) +{ + return percent >= 0 && percent <= 100; +} + +int camera_resolution_index(int width, int height) +{ + return width == 640 && height == 480 ? 1 : 0; +} + +CameraResolution camera_resolution(int index) +{ + return kCameraResolutions[clamp_index(index, array_size(kCameraResolutions))]; +} + +bool camera_resolution_supported(int width, int height) +{ + for (const auto &resolution : kCameraResolutions) { + if (resolution.width == width && resolution.height == height) return true; + } + return false; +} + +bool camera_available_from_status(bool callback_received, int status_code) +{ + return callback_received && status_code >= 0; +} + +} // namespace setup_values diff --git a/projects/APPLaunch/main/ui/model/setup_value_policy.hpp b/projects/APPLaunch/main/ui/model/setup_value_policy.hpp new file mode 100644 index 00000000..bd7e4150 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_value_policy.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace setup_values { + +struct CameraResolution { + int width; + int height; +}; + +int brightness_index(int value, int maximum); +int brightness_value(int index, int maximum); +bool parse_nonnegative_int(std::string_view text, int &value); + +int dark_time_index(int seconds); +int dark_time_seconds(int index); + +int volume_index(int percent); +int volume_percent(int index); +bool volume_value_valid(int percent); + +int camera_resolution_index(int width, int height); +CameraResolution camera_resolution(int index); +bool camera_resolution_supported(int width, int height); +bool camera_available_from_status(bool callback_received, int status_code); + +} // namespace setup_values diff --git a/projects/APPLaunch/main/ui/model/setup_view_build_contract.hpp b/projects/APPLaunch/main/ui/model/setup_view_build_contract.hpp new file mode 100644 index 00000000..b65e14b2 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_view_build_contract.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +class SetupMainViewBuildContract +{ +public: + explicit constexpr SetupMainViewBuildContract(std::size_t required_rows) + : required_rows_(required_rows) + { + } + + constexpr void selection_created() { selection_created_ = true; } + constexpr void hint_created() { hint_created_ = true; } + constexpr void row_created() { ++created_rows_; } + + constexpr bool ready() const + { + return selection_created_ && hint_created_ && + created_rows_ == required_rows_; + } + +private: + std::size_t required_rows_; + std::size_t created_rows_ = 0; + bool selection_created_ = false; + bool hint_created_ = false; +}; diff --git a/projects/APPLaunch/main/ui/model/setup_wifi_model.cpp b/projects/APPLaunch/main/ui/model/setup_wifi_model.cpp new file mode 100644 index 00000000..dd65598e --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_wifi_model.cpp @@ -0,0 +1,212 @@ +#include "setup_wifi_model.hpp" + +#include +#include + +namespace { + +bool is_continuation(unsigned char value) +{ + return (value & 0xc0) == 0x80; +} + +bool is_valid_utf8_text(const std::string &text) +{ + std::size_t index = 0; + while (index < text.size()) { + const unsigned char lead = static_cast(text[index]); + std::size_t length = 0; + unsigned int codepoint = 0; + if (lead < 0x80) { + length = 1; + codepoint = lead; + } else if ((lead & 0xe0) == 0xc0) { + length = 2; + codepoint = lead & 0x1f; + } else if ((lead & 0xf0) == 0xe0) { + length = 3; + codepoint = lead & 0x0f; + } else if ((lead & 0xf8) == 0xf0) { + length = 4; + codepoint = lead & 0x07; + } else { + return false; + } + if (index + length > text.size()) return false; + for (std::size_t offset = 1; offset < length; ++offset) { + const unsigned char value = static_cast(text[index + offset]); + if (!is_continuation(value)) return false; + codepoint = (codepoint << 6) | (value & 0x3f); + } + if ((length == 2 && codepoint < 0x80) || + (length == 3 && codepoint < 0x800) || + (length == 4 && codepoint < 0x10000) || + (codepoint >= 0xd800 && codepoint <= 0xdfff) || codepoint > 0x10ffff || + codepoint < 0x20 || codepoint == 0x7f) + return false; + index += length; + } + return true; +} + +} // namespace + +void SetupWifiListModel::begin_scan() +{ + scanning_ = true; +} + +void SetupWifiListModel::apply_scan(std::vector access_points) +{ + std::string selected_ssid; + if (const auto *current = selected()) selected_ssid = current->ssid; + + access_points_ = std::move(access_points); + scanning_ = false; + if (access_points_.empty()) { + selected_index_ = 0; + return; + } + + const auto selected = std::find_if(access_points_.begin(), access_points_.end(), + [&](const SetupWifiAccessPoint &access_point) { + return !selected_ssid.empty() && access_point.ssid == selected_ssid; + }); + if (selected != access_points_.end()) + selected_index_ = static_cast(std::distance(access_points_.begin(), selected)); + else + selected_index_ = std::clamp(selected_index_, 0, static_cast(access_points_.size()) - 1); +} + +bool SetupWifiListModel::move_selection(int delta) +{ + if (access_points_.empty() || delta == 0) return false; + const int next = std::clamp(selected_index_ + delta, 0, + static_cast(access_points_.size()) - 1); + if (next == selected_index_) return false; + selected_index_ = next; + return true; +} + +const SetupWifiAccessPoint *SetupWifiListModel::selected() const +{ + return at(selected_index_); +} + +const SetupWifiAccessPoint *SetupWifiListModel::at(int index) const +{ + return index >= 0 && index < static_cast(access_points_.size()) + ? &access_points_[static_cast(index)] : nullptr; +} + +SetupWifiListSnapshot SetupWifiListViewModel::snapshot() const +{ + SetupWifiListSnapshot result; + const SetupWifiStatus &status = model_.status(); + result.title = status.connected + ? "Connected WiFi: " + status.ssid + " " + status.ip + : "WiFi: Not connected"; + result.empty_message = model_.scanning() + ? "Scanning for WiFi networks..." + : "No networks found. Press R to rescan."; + result.hint = model_.scanning() + ? "Scanning... OK:connect D:forget ESC:back" + : "OK:connect R:rescan D:forget ESC:back"; + + const int count = static_cast(model_.size()); + int offset = model_.selected_index() - VISIBLE_ROWS / 2; + offset = std::max(0, std::min(offset, count - VISIBLE_ROWS)); + for (int visible = 0; visible < VISIBLE_ROWS && offset + visible < count; ++visible) { + const int index = offset + visible; + const auto *access_point = model_.at(index); + if (!access_point) continue; + SetupWifiRowViewModel row; + row.ssid = access_point->ssid + (access_point->saved ? " *" : ""); + row.security = access_point->security.empty() ? "Open" : access_point->security; + row.signal = std::to_string(access_point->signal) + "%"; + row.selected = index == model_.selected_index(); + row.in_use = access_point->in_use; + result.rows.push_back(std::move(row)); + } + return result; +} + +SetupWifiPasswordModel::~SetupWifiPasswordModel() +{ + secure_clear_password(); +} + +void SetupWifiPasswordModel::begin(std::string ssid) +{ + ssid_ = std::move(ssid); + secure_clear_password(); +} + +void SetupWifiPasswordModel::reset() +{ + ssid_.clear(); + secure_clear_password(); +} + +bool SetupWifiPasswordModel::append(const std::string &text) +{ + if (text.empty() || password_.size() + text.size() > MAX_PASSWORD_BYTES) + return false; + if (!is_valid_utf8_text(text)) return false; + password_ += text; + return true; +} + +bool SetupWifiPasswordModel::erase_last() +{ + if (password_.empty()) return false; + std::size_t start = password_.size() - 1; + while (start > 0 && is_continuation(static_cast(password_[start]))) + --start; + volatile char *bytes = password_.data(); + for (std::size_t index = start; index < password_.size(); ++index) bytes[index] = '\0'; + password_.erase(start); + return true; +} + +void SetupWifiPasswordModel::clear_password() +{ + secure_clear_password(); +} + +void SetupWifiPasswordModel::secure_clear_password() +{ + volatile char *bytes = password_.data(); + for (std::size_t index = 0; index < password_.size(); ++index) bytes[index] = '\0'; + password_.clear(); +} + +std::string SetupWifiPasswordModel::masked_display() const +{ + std::size_t codepoints = 0; + for (unsigned char value : password_) + if (!is_continuation(value)) ++codepoints; + return std::string(codepoints, '*') + '_'; +} + +SetupWifiFeedbackModel::Token SetupWifiFeedbackModel::begin() +{ + if (pending_) return 0; + pending_ = true; + if (++generation_ == 0) ++generation_; + return generation_; +} + +bool SetupWifiFeedbackModel::complete(Token token) +{ + if (!pending_ || token == 0 || token != generation_) return false; + pending_ = false; + return true; +} + +bool SetupWifiFeedbackModel::cancel(Token token) +{ + if (!pending_ || token == 0 || token != generation_) return false; + pending_ = false; + return true; +} diff --git a/projects/APPLaunch/main/ui/model/setup_wifi_model.hpp b/projects/APPLaunch/main/ui/model/setup_wifi_model.hpp new file mode 100644 index 00000000..7cd5137d --- /dev/null +++ b/projects/APPLaunch/main/ui/model/setup_wifi_model.hpp @@ -0,0 +1,144 @@ +#pragma once + +#include +#include +#include +#include +#include + +struct SetupWifiAccessPoint +{ + std::string ssid; + std::string security; + int signal = 0; + bool in_use = false; + bool saved = false; +}; + +struct SetupWifiStatus +{ + bool connected = false; + std::string ssid; + std::string ip; +}; + +class SetupWifiListModel +{ +public: + void begin_scan(); + void cancel_scan() { scanning_ = false; } + void apply_scan(std::vector access_points); + void set_status(SetupWifiStatus status) { status_ = std::move(status); } + bool move_selection(int delta); + + bool scanning() const { return scanning_; } + int selected_index() const { return selected_index_; } + std::size_t size() const { return access_points_.size(); } + const std::vector &access_points() const { return access_points_; } + const SetupWifiAccessPoint *selected() const; + const SetupWifiAccessPoint *at(int index) const; + const SetupWifiStatus &status() const { return status_; } + +private: + std::vector access_points_; + int selected_index_ = 0; + bool scanning_ = false; + SetupWifiStatus status_; +}; + +struct SetupWifiRowViewModel +{ + std::string ssid; + std::string security; + std::string signal; + bool selected = false; + bool in_use = false; +}; + +struct SetupWifiListSnapshot +{ + std::string title; + std::string empty_message; + std::string hint; + std::vector rows; +}; + +class SetupWifiListViewModel +{ +public: + static constexpr int VISIBLE_ROWS = 5; + static constexpr std::uint32_t KEY_REPEAT_INTERVAL_MS = 60; + + void begin_scan() { model_.begin_scan(); } + void cancel_scan() { model_.cancel_scan(); } + void apply_scan(std::vector access_points) + { + model_.apply_scan(std::move(access_points)); + } + void set_status(SetupWifiStatus status) { model_.set_status(std::move(status)); } + bool move_selection(int delta) { return model_.move_selection(delta); } + SetupWifiListSnapshot snapshot() const; + + bool scanning() const { return model_.scanning(); } + int selected_index() const { return model_.selected_index(); } + std::size_t size() const { return model_.size(); } + const SetupWifiAccessPoint *selected() const { return model_.selected(); } + const SetupWifiAccessPoint *at(int index) const { return model_.at(index); } + +private: + SetupWifiListModel model_; +}; + +template +constexpr bool setup_wifi_feedback_timer_callback_ready( + CallbackTimer callback_timer, ActiveTimer active_timer, Token token) noexcept +{ + return callback_timer && callback_timer == active_timer && token != Token{}; +} + +template +constexpr bool setup_wifi_feedback_screen_delete_allowed( + Target target, CurrentTarget current_target, TrackedScreen tracked_screen) noexcept +{ + return target && target == current_target && target == tracked_screen; +} + +class SetupWifiPasswordModel +{ +public: + // WPA passphrases use up to 63 bytes; a raw hexadecimal PSK uses 64. + static constexpr std::size_t MAX_PASSWORD_BYTES = 64; + + ~SetupWifiPasswordModel(); + + void begin(std::string ssid); + void reset(); + bool append(const std::string &text); + bool erase_last(); + void clear_password(); + + bool can_submit() const { return !ssid_.empty() && !password_.empty(); } + const std::string &ssid() const { return ssid_; } + const std::string &password() const { return password_; } + std::string masked_display() const; + +private: + void secure_clear_password(); + std::string ssid_; + std::string password_; +}; + +class SetupWifiFeedbackModel +{ +public: + using Token = std::uint64_t; + + Token begin(); + bool complete(Token token); + bool cancel(Token token); + bool pending() const { return pending_; } + +private: + bool pending_ = false; + Token generation_ = 0; +}; diff --git a/projects/APPLaunch/main/ui/model/snake_game_model.cpp b/projects/APPLaunch/main/ui/model/snake_game_model.cpp new file mode 100644 index 00000000..c4b0f8ec --- /dev/null +++ b/projects/APPLaunch/main/ui/model/snake_game_model.cpp @@ -0,0 +1,82 @@ +#include "snake_game_model.hpp" + +#include + +SnakeGameModel::SnakeGameModel(uint32_t seed) : random_(seed) +{ + reset(); +} + +void SnakeGameModel::reset() +{ + const int center_x = GRID_COLS / 2; + const int center_y = GRID_ROWS / 2; + snake_ = {{center_x, center_y}, {center_x - 1, center_y}, {center_x - 2, center_y}}; + direction_ = Direction::RIGHT; + queued_direction_ = Direction::RIGHT; + score_ = 0; + spawn_food(); +} + +void SnakeGameModel::queue_direction(Direction direction) +{ + const bool opposite = + (direction_ == Direction::UP && direction == Direction::DOWN) || + (direction_ == Direction::DOWN && direction == Direction::UP) || + (direction_ == Direction::LEFT && direction == Direction::RIGHT) || + (direction_ == Direction::RIGHT && direction == Direction::LEFT); + if (!opposite) queued_direction_ = direction; +} + +bool SnakeGameModel::tick() +{ + direction_ = queued_direction_; + Point head = snake_.front(); + switch (direction_) { + case Direction::UP: --head.y; break; + case Direction::DOWN: ++head.y; break; + case Direction::LEFT: --head.x; break; + case Direction::RIGHT: ++head.x; break; + } + + if (head.x < 0 || head.x >= GRID_COLS || head.y < 0 || head.y >= GRID_ROWS || + occupied(head)) { + return false; + } + + snake_.push_front(head); + if (head == food_) { + score_ += 10; + spawn_food(); + } else { + snake_.pop_back(); + } + return true; +} + +bool SnakeGameModel::occupied(Point point) const +{ + return std::find(snake_.begin(), snake_.end(), point) != snake_.end(); +} + +void SnakeGameModel::spawn_food() +{ + constexpr int cell_count = GRID_COLS * GRID_ROWS; + for (int attempt = 0; attempt < cell_count * 2; ++attempt) { + const Point candidate{static_cast(random_() % GRID_COLS), + static_cast(random_() % GRID_ROWS)}; + if (!occupied(candidate)) { + food_ = candidate; + return; + } + } + + for (int y = 0; y < GRID_ROWS; ++y) { + for (int x = 0; x < GRID_COLS; ++x) { + if (!occupied({x, y})) { + food_ = {x, y}; + return; + } + } + } +} diff --git a/projects/APPLaunch/main/ui/model/snake_game_model.hpp b/projects/APPLaunch/main/ui/model/snake_game_model.hpp new file mode 100644 index 00000000..3d6b1e1d --- /dev/null +++ b/projects/APPLaunch/main/ui/model/snake_game_model.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +class SnakeGameModel +{ +public: + static constexpr int GRID_COLS = 40; + static constexpr int GRID_ROWS = 18; + + enum class Direction { UP, DOWN, LEFT, RIGHT }; + + struct Point { + int x = 0; + int y = 0; + + bool operator==(const Point &other) const { return x == other.x && y == other.y; } + }; + + explicit SnakeGameModel(uint32_t seed = 0x534E414B); + + void reset(); + void queue_direction(Direction direction); + bool tick(); + + const std::deque &snake() const { return snake_; } + Point food() const { return food_; } + Direction direction() const { return direction_; } + int score() const { return score_; } + +private: + bool occupied(Point point) const; + void spawn_food(); + + std::deque snake_; + Point food_; + Direction direction_ = Direction::RIGHT; + Direction queued_direction_ = Direction::RIGHT; + int score_ = 0; + std::minstd_rand random_; +}; diff --git a/projects/APPLaunch/main/ui/model/snake_view_contract.hpp b/projects/APPLaunch/main/ui/model/snake_view_contract.hpp new file mode 100644 index 00000000..6cda093d --- /dev/null +++ b/projects/APPLaunch/main/ui/model/snake_view_contract.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include + +template +constexpr bool snake_owned_delete_callback_allowed( + Target target, CurrentTarget current_target) noexcept +{ + return target && target == current_target; +} + +template +constexpr bool snake_tick_callback_allowed(bool callback_enabled, + bool timer_current, + bool game_playing, + GameArea game_area, + RenderLayer render_layer) noexcept +{ + return callback_enabled && timer_current && game_playing && + game_area && render_layer; +} + +template +constexpr bool snake_page_event_callback_allowed( + CurrentTarget current_target, RootScreen root_screen) noexcept +{ + return current_target && current_target == root_screen; +} + +class SnakeFrameBuildContract +{ +public: + explicit constexpr SnakeFrameBuildContract(std::size_t required_cells) + : required_cells_(required_cells) + { + } + + constexpr void cell_created() { ++created_cells_; } + constexpr bool ready() const { return created_cells_ == required_cells_; } + +private: + std::size_t required_cells_; + std::size_t created_cells_ = 0; +}; diff --git a/projects/APPLaunch/main/ui/model/sound_card_model.cpp b/projects/APPLaunch/main/ui/model/sound_card_model.cpp new file mode 100644 index 00000000..53d06547 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/sound_card_model.cpp @@ -0,0 +1,177 @@ +#include "sound_card_model.hpp" + +#include +#include +#include + +namespace setting { +namespace { + +std::string trim(const std::string &text) +{ + const size_t first = text.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const size_t last = text.find_last_not_of(" \t\r\n"); + return text.substr(first, last - first + 1); +} + +bool parse_integer(const std::string &text, int &value) +{ + if (text.empty()) return false; + const char *first = text.data(); + const char *last = first + text.size(); + const auto result = std::from_chars(first, last, value); + return result.ec == std::errc{} && result.ptr == last; +} + +bool parse_limits(const std::string &line, int &minimum, int &maximum) +{ + const size_t marker = line.find("Limits:"); + if (marker == std::string::npos) return false; + std::string values = trim(line.substr(marker + 7)); + for (const char *prefix : {"Playback ", "Capture "}) { + if (values.rfind(prefix, 0) == 0) { + values = values.substr(std::char_traits::length(prefix)); + break; + } + } + const size_t separator = values.find(" - "); + if (separator == std::string::npos) return false; + int parsed_minimum = 0; + int parsed_maximum = 0; + if (!parse_integer(trim(values.substr(0, separator)), parsed_minimum) || + !parse_integer(trim(values.substr(separator + 3)), parsed_maximum)) + return false; + minimum = parsed_minimum; + maximum = parsed_maximum; + return true; +} + +bool is_value_line(const std::string &line) +{ + static constexpr const char *prefixes[] = { + "Mono:", "Front Left:", "Front Right:", "Rear Left:", "Rear Right:", + "Center:", "LFE:", "Side Left:", "Side Right:", "Capture:", "Playback:", + "Item0:", + }; + for (const char *prefix : prefixes) { + if (line.rfind(prefix, 0) == 0) return true; + } + return false; +} + +std::string extract_value_text(const std::string &line) +{ + return trim(line); +} + +int parse_current_value(const std::string &line, int fallback) +{ + const size_t separator = line.find(": "); + if (separator == std::string::npos) return fallback; + const char *cursor = line.c_str() + separator + 2; + while (*cursor && *cursor != '-' && (*cursor < '0' || *cursor > '9')) ++cursor; + if (!*cursor) return fallback; + const char *end = cursor; + if (*end == '-') ++end; + while (*end >= '0' && *end <= '9') ++end; + int value = 0; + const auto result = std::from_chars(cursor, end, value); + return result.ec == std::errc{} && result.ptr == end ? value : fallback; +} + +} // namespace + +std::vector SoundCardModel::parse_cards(const std::string &data) +{ + std::vector cards; + std::istringstream lines(data); + std::string line; + while (std::getline(lines, line)) { + const size_t tab = line.find('\t'); + if (tab == std::string::npos) continue; + int index = 0; + if (!parse_integer(line.substr(0, tab), index) || index < 0) continue; + cards.push_back({index, line.substr(tab + 1)}); + } + return cards; +} + +std::vector SoundCardModel::parse_controls(const std::string &data) +{ + std::vector controls; + std::istringstream lines(data); + std::string line; + while (std::getline(lines, line)) { + std::vector columns; + std::istringstream row(line); + std::string column; + while (std::getline(row, column, '\t')) columns.push_back(column); + if (columns.size() < 7) continue; + int minimum = 0; + int maximum = 0; + int step = 0; + int current = 0; + if (!parse_integer(columns[2], minimum) || !parse_integer(columns[3], maximum) || + !parse_integer(columns[4], step) || !parse_integer(columns[6], current)) + continue; + controls.push_back({columns[0], columns[1], minimum, maximum, step, + columns[5], current}); + } + return controls; +} + +SoundControlInfo SoundCardModel::parse_detail(const std::string &data, + const SoundControlInfo &fallback) +{ + SoundControlInfo detail; + detail.name = fallback.name; + std::istringstream lines(data); + std::string line; + while (std::getline(lines, line)) { + const std::string text = trim(line); + if (text.rfind("Capabilities:", 0) == 0) { + detail.type = text.find("enum") != std::string::npos ? "ENUMERATED" : "INTEGER"; + } else if (text.rfind("Limits:", 0) == 0) { + parse_limits(text, detail.min_value, detail.max_value); + } else if (detail.current_text.empty() && is_value_line(text)) { + detail.current_text = extract_value_text(text); + detail.current_value = parse_current_value(text, detail.current_value); + } + } + if (detail.max_value == 0 && fallback.max_value != 0) { + detail.min_value = fallback.min_value; + detail.max_value = fallback.max_value; + } + return detail; +} + +int SoundCardModel::parse_value(const std::string &text, int fallback) +{ + int value = 0; + return parse_integer(text, value) ? value : fallback; +} + +int SoundCardModel::clamp_value(int value, const SoundControlInfo &control) +{ + if (control.max_value <= control.min_value) return value; + if (value < control.min_value) return control.min_value; + if (value > control.max_value) return control.max_value; + return value; +} + +bool SoundCardTransitionModel::begin() +{ + if (pending_) return false; + pending_ = true; + return true; +} + +bool SoundCardTransitionModel::complete() +{ + if (!pending_) return false; + pending_ = false; + return true; +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/model/sound_card_model.hpp b/projects/APPLaunch/main/ui/model/sound_card_model.hpp new file mode 100644 index 00000000..f31b98e2 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/sound_card_model.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include + +namespace setting { + +template +bool sound_card_commit_if_success(bool success, Value candidate, Value ¤t) +{ + if (!success) return false; + current = std::move(candidate); + return true; +} + +template +bool sound_card_invoke_backend(Operation &&operation) noexcept +{ + try { + std::forward(operation)(); + return true; + } catch (...) { + return false; + } +} + +template +constexpr bool sound_card_cursor_callback_ready(TimerHandle callback_timer, + TimerHandle current_timer, + LabelHandle input_label) +{ + return callback_timer && callback_timer == current_timer && input_label; +} + +template +constexpr bool sound_card_cursor_callback_allowed(TimerHandle callback_timer, + TimerHandle current_timer, + LabelHandle input_label, + bool callback_enabled) noexcept +{ + return callback_enabled && + sound_card_cursor_callback_ready(callback_timer, current_timer, input_label); +} + +template +constexpr bool sound_card_transition_timer_callback_allowed( + CallbackTimer callback_timer, CurrentTimer current_timer, + PageHandle page, bool transition_pending) noexcept +{ + return callback_timer && callback_timer == current_timer && + page && transition_pending; +} + +template +constexpr bool sound_card_screen_delete_is_direct(ObjectHandle target, + ObjectHandle current_target) +{ + return target && target == current_target; +} + +template +constexpr bool sound_card_transition_screen_delete_matches( + EventTarget target, CurrentTarget current_target, OwnedScreen owned_screen) noexcept +{ + return target && target == current_target && target == owned_screen; +} + +template +constexpr bool sound_card_owned_label_delete_matches(EventTarget target, + CurrentTarget current_target, + OwnedLabel owned_label) noexcept +{ + return target && target == current_target && target == owned_label; +} + +struct SoundCardInfo { + int index = 0; + std::string name; +}; + +struct SoundControlInfo { + std::string name; + std::string type; + int min_value = 0; + int max_value = 0; + int step = 1; + std::string current_text; + int current_value = 0; +}; + +class SoundCardModel +{ +public: + static std::vector parse_cards(const std::string &data); + static std::vector parse_controls(const std::string &data); + static SoundControlInfo parse_detail(const std::string &data, + const SoundControlInfo &fallback); + static int parse_value(const std::string &text, int fallback); + static int clamp_value(int value, const SoundControlInfo &control); +}; + +class SoundCardTransitionModel +{ +public: + bool begin(); + bool complete(); + void cancel() { pending_ = false; } + bool pending() const { return pending_; } + +private: + bool pending_ = false; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/model/ssh_connection_model.cpp b/projects/APPLaunch/main/ui/model/ssh_connection_model.cpp new file mode 100644 index 00000000..6518ed41 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/ssh_connection_model.cpp @@ -0,0 +1,80 @@ +#include "ssh_connection_model.hpp" +#include "../keyboard_text_input.hpp" +#include "integer_parse_policy.hpp" + +#include +#include + +SshConnectionModel::SshConnectionModel() + : values_{"192.168.1.1", "22", "pi"} +{ +} + +const char *SshConnectionModel::label(size_t index) const +{ + static constexpr const char *labels[FIELD_COUNT] = {"Host", "Port", "User"}; + return index < FIELD_COUNT ? labels[index] : ""; +} + +const std::string &SshConnectionModel::value(size_t index) const +{ + static const std::string empty; + return index < FIELD_COUNT ? values_[index] : empty; +} + +bool SshConnectionModel::select_previous() +{ + if (active_index_ == 0) return false; + --active_index_; + return true; +} + +bool SshConnectionModel::select_next() +{ + if (active_index_ + 1 >= FIELD_COUNT) return false; + ++active_index_; + return true; +} + +bool SshConnectionModel::append(const std::string &text) +{ + return launcher_ui::text_input::append_limited(values_[active_index_], text, + MAX_FIELD_BYTES); +} + +bool SshConnectionModel::erase_last() +{ + return launcher_ui::text_input::erase_last_codepoint(values_[active_index_]); +} + +bool SshConnectionModel::valid() const +{ + const std::string &host = values_[static_cast(Field::HOST)]; + const std::string &port = values_[static_cast(Field::PORT)]; + const std::string &user = values_[static_cast(Field::USER)]; + auto invalid_identity = [](const std::string &value) { + return (!value.empty() && value.front() == '-') || + std::any_of(value.begin(), value.end(), [](unsigned char character) { + return std::isspace(character) != 0; + }); + }; + if (host.empty() || invalid_identity(host) || invalid_identity(user)) return false; + if (port.empty()) return true; + unsigned int number = 0; + return launcher_ui::integer_parse::complete(port, 1U, 65535U, number); +} + +std::list SshConnectionModel::arguments() const +{ + if (!valid()) return {}; + const std::string &host = values_[static_cast(Field::HOST)]; + const std::string &port = values_[static_cast(Field::PORT)]; + const std::string &user = values_[static_cast(Field::USER)]; + std::list arguments = {"-o", "StrictHostKeyChecking=no"}; + if (!port.empty() && port != "22") { + arguments.push_back("-p"); + arguments.push_back(port); + } + arguments.push_back(user.empty() ? host : user + "@" + host); + return arguments; +} diff --git a/projects/APPLaunch/main/ui/model/ssh_connection_model.hpp b/projects/APPLaunch/main/ui/model/ssh_connection_model.hpp new file mode 100644 index 00000000..0494ad25 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/ssh_connection_model.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +class SshConnectionModel +{ +public: + enum class Field : size_t { HOST = 0, PORT = 1, USER = 2 }; + static constexpr size_t FIELD_COUNT = 3; + + SshConnectionModel(); + + const char *label(size_t index) const; + const std::string &value(size_t index) const; + size_t active_index() const { return active_index_; } + + bool select_previous(); + bool select_next(); + bool append(const std::string &text); + bool erase_last(); + + bool valid() const; + std::list arguments() const; + +private: + static constexpr size_t MAX_FIELD_BYTES = 128; + std::array values_; + size_t active_index_ = 0; +}; diff --git a/projects/APPLaunch/main/ui/model/ssh_view_build_contract.hpp b/projects/APPLaunch/main/ui/model/ssh_view_build_contract.hpp new file mode 100644 index 00000000..2afab0c9 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/ssh_view_build_contract.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include + +template +constexpr bool ssh_owned_delete_callback_allowed(EventTarget event_target, + CurrentTarget current_target) noexcept +{ + return event_target && event_target == current_target; +} + +template +constexpr bool ssh_page_event_callback_allowed(CurrentTarget current_target, + RootScreen root_screen) noexcept +{ + return current_target && current_target == root_screen; +} + +template +constexpr bool ssh_restore_completion_allowed(bool token_completed, + PageHandle page) noexcept +{ + return token_completed && page; +} + +class SshViewBuildContract +{ +public: + explicit constexpr SshViewBuildContract(std::size_t required_rows) + : required_rows_(required_rows) + { + } + + constexpr void row_completed() { ++completed_rows_; } + constexpr void hint_completed() { hint_completed_ = true; } + + constexpr bool ready() const + { + return completed_rows_ == required_rows_ && hint_completed_; + } + +private: + std::size_t required_rows_; + std::size_t completed_rows_ = 0; + bool hint_completed_ = false; +}; diff --git a/projects/APPLaunch/main/ui/model/st_key_encoder.cpp b/projects/APPLaunch/main/ui/model/st_key_encoder.cpp new file mode 100644 index 00000000..45c4078f --- /dev/null +++ b/projects/APPLaunch/main/ui/model/st_key_encoder.cpp @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "st_key_encoder.hpp" + +#include "input_keys.h" + +#include + +std::string STKeyEncoder::encode(uint32_t evdev_key, const char *utf8, + bool application_cursor_mode) +{ + switch (evdev_key) { + case KEY_ENTER: return "\r"; + case KEY_BACKSPACE: return std::string(1, static_cast(0x7f)); + case KEY_ESC: return "\x1b"; + case KEY_UP: return application_cursor_mode ? "\x1bOA" : "\x1b[A"; + case KEY_DOWN: return application_cursor_mode ? "\x1bOB" : "\x1b[B"; + case KEY_RIGHT: return application_cursor_mode ? "\x1bOC" : "\x1b[C"; + case KEY_LEFT: return application_cursor_mode ? "\x1bOD" : "\x1b[D"; + default: break; + } + + if (!utf8) return {}; + const size_t length = std::strlen(utf8); + if (length == 0 || length > MAX_TEXT_BYTES) return {}; + return std::string(utf8, length); +} diff --git a/projects/APPLaunch/main/ui/model/st_key_encoder.hpp b/projects/APPLaunch/main/ui/model/st_key_encoder.hpp new file mode 100644 index 00000000..22d606e3 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/st_key_encoder.hpp @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include +#include + +class STKeyEncoder +{ +public: + static constexpr size_t MAX_TEXT_BYTES = 15; + + static std::string encode(uint32_t evdev_key, const char *utf8, + bool application_cursor_mode); +}; diff --git a/projects/APPLaunch/main/ui/model/st_page_contract.hpp b/projects/APPLaunch/main/ui/model/st_page_contract.hpp new file mode 100644 index 00000000..d6924f5e --- /dev/null +++ b/projects/APPLaunch/main/ui/model/st_page_contract.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +inline bool st_parse_child_status(const std::string &text, int &status) +{ + if (text.empty()) return false; + int parsed = 0; + const char *begin = text.data(); + const char *end = begin + text.size(); + const auto result = std::from_chars(begin, end, parsed, 10); + if (result.ec != std::errc() || result.ptr != end) return false; + status = parsed; + return true; +} + +template +constexpr bool st_page_renderer_ready(Handles... handles) +{ + return (... && static_cast(handles)); +} + +struct STCallbackFailureState +{ + bool terminal_active; + bool waiting_key_to_exit; + int home_hold_status; +}; + +constexpr STCallbackFailureState st_callback_failure_state() noexcept +{ + return {false, true, 0}; +} diff --git a/projects/APPLaunch/main/ui/model/system_page_model.cpp b/projects/APPLaunch/main/ui/model/system_page_model.cpp new file mode 100644 index 00000000..6c5e929e --- /dev/null +++ b/projects/APPLaunch/main/ui/model/system_page_model.cpp @@ -0,0 +1,53 @@ +#include "system_page_model.hpp" + +#include + +namespace system_page { +namespace { + +std::string next_field(std::istringstream &lines) +{ + std::string value; + if (!std::getline(lines, value)) + return "--"; + if (!value.empty() && value.back() == '\r') + value.pop_back(); + return value.empty() ? "--" : value; +} + +} // namespace + +NetworkInfo parse_network_info(const std::string &payload) +{ + std::istringstream lines(payload); + return {next_field(lines), next_field(lines), next_field(lines)}; +} + +AccountInfo parse_account_info(const std::string &payload) +{ + std::istringstream lines(payload); + return {next_field(lines), next_field(lines)}; +} + +std::string version_label(const std::string &commit) +{ + return "Version: " + (commit.empty() ? std::string("--") : commit); +} + +const char *update_request(UpdateAction action) +{ + switch (action) { + case UpdateAction::CheckSystem: + return "AptUpdateBackground"; + case UpdateAction::UpdateLauncher: + return "UpdateLauncherBackground"; + } + return ""; +} + +bool extport_toggle_value(bool previous, bool desired, bool gpio_succeeded) +{ + return gpio_succeeded ? desired : previous; +} + +} // namespace system_page diff --git a/projects/APPLaunch/main/ui/model/system_page_model.hpp b/projects/APPLaunch/main/ui/model/system_page_model.hpp new file mode 100644 index 00000000..0f80f94c --- /dev/null +++ b/projects/APPLaunch/main/ui/model/system_page_model.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +namespace system_page { + +template +bool commit_if_success(bool success, Value candidate, Value ¤t) +{ + if (!success) return false; + current = std::move(candidate); + return true; +} + +struct NetworkInfo +{ + std::string ip; + std::string gateway; + std::string mac; +}; + +struct AccountInfo +{ + std::string username; + std::string hostname; +}; + +enum class UpdateAction +{ + CheckSystem, + UpdateLauncher, +}; + +NetworkInfo parse_network_info(const std::string &payload); +AccountInfo parse_account_info(const std::string &payload); +std::string version_label(const std::string &commit); +const char *update_request(UpdateAction action); +bool extport_toggle_value(bool previous, bool desired, bool gpio_succeeded); + +} // namespace system_page diff --git a/projects/APPLaunch/main/ui/model/tank_battle_model.cpp b/projects/APPLaunch/main/ui/model/tank_battle_model.cpp new file mode 100644 index 00000000..06454472 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/tank_battle_model.cpp @@ -0,0 +1,198 @@ +#include "tank_battle_model.hpp" + +#include + +TankBattleModel::TankBattleModel(uint32_t random_seed) : random_(random_seed) +{ + reset(); +} + +void TankBattleModel::reset() +{ + game_over_ = false; + won_ = false; + score_ = 0; + tick_count_ = 0; + + player_ = {}; + player_.x = GRID_COLUMNS / 2; + player_.y = GRID_ROWS - 1; + + enemies_.clear(); + for (int index = 0; index < ENEMY_COUNT; ++index) { + TankBattleTank enemy; + enemy.x = 2 + index * 3; + enemy.direction = TankDirection::DOWN; + enemy.fire_cooldown = index * 4; + enemies_.push_back(enemy); + } + + bullets_.assign(BULLET_CAPACITY, {}); +} + +bool TankBattleModel::move_player(TankDirection direction) +{ + if (game_over_) return false; + player_.direction = direction; + int dx = 0; + int dy = 0; + direction_step(direction, dx, dy); + const int next_x = player_.x + dx; + const int next_y = player_.y + dy; + if (!inside(next_x, next_y) || has_enemy_at(next_x, next_y)) return false; + player_.x = next_x; + player_.y = next_y; + return true; +} + +bool TankBattleModel::player_fire() +{ + if (game_over_ || player_.fire_cooldown > 0) return false; + int dx = 0; + int dy = 0; + direction_step(player_.direction, dx, dy); + const int x = player_.x + dx; + const int y = player_.y + dy; + if (!inside(x, y)) return false; + spawn_bullet(x, y, player_.direction, true); + player_.fire_cooldown = 4; + return true; +} + +void TankBattleModel::tick() +{ + if (game_over_) return; + ++tick_count_; + if (player_.fire_cooldown > 0) --player_.fire_cooldown; + for (auto &enemy : enemies_) { + if (enemy.alive && enemy.fire_cooldown > 0) --enemy.fire_cooldown; + } + update_enemies(); + move_bullets(); + update_end_state(); +} + +int TankBattleModel::alive_enemy_count() const +{ + return static_cast(std::count_if(enemies_.begin(), enemies_.end(), + [](const TankBattleTank &enemy) { return enemy.alive; })); +} + +bool TankBattleModel::inside(int x, int y) const +{ + return x >= 0 && x < GRID_COLUMNS && y >= 0 && y < GRID_ROWS; +} + +bool TankBattleModel::has_enemy_at(int x, int y, int skip_index) const +{ + for (size_t index = 0; index < enemies_.size(); ++index) { + if (static_cast(index) == skip_index) continue; + const auto &enemy = enemies_[index]; + if (enemy.alive && enemy.x == x && enemy.y == y) return true; + } + return false; +} + +void TankBattleModel::direction_step(TankDirection direction, int &dx, int &dy) +{ + dx = 0; + dy = 0; + switch (direction) { + case TankDirection::UP: dy = -1; break; + case TankDirection::DOWN: dy = 1; break; + case TankDirection::LEFT: dx = -1; break; + case TankDirection::RIGHT: dx = 1; break; + } +} + +void TankBattleModel::spawn_bullet(int x, int y, TankDirection direction, bool from_player) +{ + for (auto &bullet : bullets_) { + if (bullet.alive) continue; + bullet = {x, y, direction, from_player, true}; + return; + } +} + +void TankBattleModel::enemy_fire(TankBattleTank &enemy) +{ + if (!enemy.alive || enemy.fire_cooldown > 0) return; + int dx = 0; + int dy = 0; + direction_step(enemy.direction, dx, dy); + const int x = enemy.x + dx; + const int y = enemy.y + dy; + if (!inside(x, y)) return; + spawn_bullet(x, y, enemy.direction, false); + enemy.fire_cooldown = 8 + static_cast(random_() % 8); +} + +void TankBattleModel::update_enemies() +{ + for (size_t index = 0; index < enemies_.size(); ++index) { + auto &enemy = enemies_[index]; + if (!enemy.alive) continue; + if ((tick_count_ + static_cast(index)) % 6 == 0) { + const uint32_t next_direction = random_() % 5; + if (next_direction == 0) enemy.direction = TankDirection::LEFT; + else if (next_direction == 1) enemy.direction = TankDirection::RIGHT; + else if (next_direction == 2) enemy.direction = TankDirection::DOWN; + else if (next_direction == 3) enemy.direction = TankDirection::UP; + + int dx = 0; + int dy = 0; + direction_step(enemy.direction, dx, dy); + const int next_x = enemy.x + dx; + const int next_y = enemy.y + dy; + if (inside(next_x, next_y) && !has_enemy_at(next_x, next_y, static_cast(index)) && + !(player_.x == next_x && player_.y == next_y)) { + enemy.x = next_x; + enemy.y = next_y; + } + } + if ((random_() % 10) < 2) enemy_fire(enemy); + } +} + +void TankBattleModel::move_bullets() +{ + for (auto &bullet : bullets_) { + if (!bullet.alive) continue; + int dx = 0; + int dy = 0; + direction_step(bullet.direction, dx, dy); + bullet.x += dx; + bullet.y += dy; + if (!inside(bullet.x, bullet.y)) { + bullet.alive = false; + continue; + } + if (bullet.from_player) { + for (auto &enemy : enemies_) { + if (enemy.alive && enemy.x == bullet.x && enemy.y == bullet.y) { + enemy.alive = false; + bullet.alive = false; + score_ += 100; + break; + } + } + } else if (player_.alive && player_.x == bullet.x && player_.y == bullet.y) { + player_.alive = false; + bullet.alive = false; + game_over_ = true; + won_ = false; + } + } +} + +void TankBattleModel::update_end_state() +{ + if (game_over_) return; + if (!player_.alive) { + game_over_ = true; + won_ = false; + } else if (alive_enemy_count() == 0) { + game_over_ = true; + won_ = true; + } +} diff --git a/projects/APPLaunch/main/ui/model/tank_battle_model.hpp b/projects/APPLaunch/main/ui/model/tank_battle_model.hpp new file mode 100644 index 00000000..b4ae8abb --- /dev/null +++ b/projects/APPLaunch/main/ui/model/tank_battle_model.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include + +enum class TankDirection { UP, DOWN, LEFT, RIGHT }; + +struct TankBattleTank { + int x = 0; + int y = 0; + TankDirection direction = TankDirection::UP; + bool alive = true; + int fire_cooldown = 0; +}; + +struct TankBattleBullet { + int x = 0; + int y = 0; + TankDirection direction = TankDirection::UP; + bool from_player = true; + bool alive = false; +}; + +class TankBattleModel +{ +public: + static constexpr int GRID_COLUMNS = 18; + static constexpr int GRID_ROWS = 8; + static constexpr int ENEMY_COUNT = 5; + static constexpr int BULLET_CAPACITY = 24; + + explicit TankBattleModel(uint32_t random_seed = 0xC0FFEE); + + void reset(); + bool move_player(TankDirection direction); + bool player_fire(); + void tick(); + + const TankBattleTank &player() const { return player_; } + const std::vector &enemies() const { return enemies_; } + const std::vector &bullets() const { return bullets_; } + bool game_over() const { return game_over_; } + bool won() const { return won_; } + int score() const { return score_; } + int tick_count() const { return tick_count_; } + int alive_enemy_count() const; + +private: + bool inside(int x, int y) const; + bool has_enemy_at(int x, int y, int skip_index = -1) const; + static void direction_step(TankDirection direction, int &dx, int &dy); + void spawn_bullet(int x, int y, TankDirection direction, bool from_player); + void enemy_fire(TankBattleTank &enemy); + void update_enemies(); + void move_bullets(); + void update_end_state(); + + TankBattleTank player_; + std::vector enemies_; + std::vector bullets_; + bool game_over_ = false; + bool won_ = false; + int score_ = 0; + int tick_count_ = 0; + std::minstd_rand random_; +}; diff --git a/projects/APPLaunch/main/ui/model/tank_battle_page_contract.hpp b/projects/APPLaunch/main/ui/model/tank_battle_page_contract.hpp new file mode 100644 index 00000000..9945fc18 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/tank_battle_page_contract.hpp @@ -0,0 +1,34 @@ +#pragma once + +template +constexpr bool tank_battle_ui_ready(BackgroundHandle background, + ArenaHandle arena, + PlayerHandle player) +{ + return background && arena && player; +} + +template +constexpr bool tank_battle_tick_callback_allowed(bool callback_enabled, + bool timer_current, + BackgroundHandle background, + ArenaHandle arena, + PlayerHandle player) noexcept +{ + return callback_enabled && timer_current && + tank_battle_ui_ready(background, arena, player); +} + +template +constexpr bool tank_battle_root_event_callback_allowed( + CurrentTarget current_target, RootScreen root_screen) noexcept +{ + return current_target && current_target == root_screen; +} + +template +constexpr bool tank_battle_owned_delete_callback_allowed( + ObjectHandle deleted, ObjectHandle current_target) noexcept +{ + return deleted && deleted == current_target; +} diff --git a/projects/APPLaunch/main/ui/page_app/setting/basic.cpp b/projects/APPLaunch/main/ui/page_app/setting/basic.cpp index 6fb97918..d9fa8125 100644 --- a/projects/APPLaunch/main/ui/page_app/setting/basic.cpp +++ b/projects/APPLaunch/main/ui/page_app/setting/basic.cpp @@ -1,7 +1,75 @@ #define APP_PAGE_IMPLEMENTATION_UNIT #include "../ui_app_setup.hpp" +#include "../../model/boot_action_policy.hpp" +#include "../../model/setup_value_policy.hpp" +#include "setup_page_access.hpp" + +#include namespace setting { +namespace { + +void execute_boot_action(boot_actions::Action action) +{ + for (const auto operation : boot_actions::operation_plan(action)) { + bool succeeded = true; + switch (operation) { + case boot_actions::Operation::Reboot: + cp0_signal_process_api({"Reboot"}, nullptr); + break; + case boot_actions::Operation::Shutdown: + cp0_signal_process_api({"Shutdown"}, nullptr); + break; + case boot_actions::Operation::RemoveLauncherSettings: + succeeded = false; + cp0_signal_filesystem_api({"Remove", launcher_platform::path("launcher_settings")}, + [&](int code, std::string) { succeeded = code == 0; }); + break; + case boot_actions::Operation::TouchOobeMarker: + succeeded = false; + cp0_signal_filesystem_api({"Touch", launcher_platform::path("oobe_marker")}, + [&](int code, std::string) { succeeded = code == 0; }); + break; + } + if (!boot_actions::may_continue(operation, succeeded)) return; + } +} + +int read_config_int_strict(const char *key, int fallback) +{ + int value = fallback; + cp0_signal_config_api({"GetInt", key, std::to_string(fallback)}, + [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && setup_values::parse_nonnegative_int(data, parsed)) value = parsed; + }); + return value; +} + +bool write_config_int_checked(const char *key, int value) +{ + bool succeeded = false; + cp0_signal_config_api({"SetInt", key, std::to_string(value)}, + [&](int code, std::string) { succeeded = code == 0; }); + return succeeded; +} + +bool save_config_checked() +{ + bool succeeded = false; + cp0_signal_config_api({"Save"}, + [&](int code, std::string) { succeeded = code == 0; }); + return succeeded; +} + +void restore_camera_resolution(int width, int height) +{ + write_config_int_checked("camera.resolution.width", width); + write_config_int_checked("camera.resolution.height", height); + save_config_checked(); +} + +} // namespace void Launcher::append(UISetupPage &p, std::vector &menu) { @@ -23,24 +91,27 @@ void Launcher::append(UISetupPage &p, std::vector &menu) void Launcher::save_app_toggle(UISetupPage &page, const std::string &config_key) { - int launcher_idx = page.find_menu("Launcher"); - if (launcher_idx < 0) + SetupPageAccess access(page); + MenuItem *launcher_menu = access.find_menu("Launcher"); + if (!launcher_menu) return; - MenuItem &launcher_menu = page.menu_items_[launcher_idx]; std::size_t app_count = 0; const AppDescriptor *apps = launcher_app_registry_entries(&app_count); - int visible_idx = 0; + std::size_t visible_idx = 0; for (std::size_t i = 0; i < app_count; ++i) { const AppDescriptor &desc = apps[i]; if (!desc.configurable) continue; if (config_key == desc.config_key) { - if (visible_idx >= (int)launcher_menu.sub_items.size()) + if (visible_idx >= launcher_menu->sub_items.size()) + return; + bool enabled = launcher_menu->sub_items[visible_idx].toggle_state; + if (!launcher_app_registry_set_enabled(desc, enabled)) { + launcher_menu->sub_items[visible_idx].toggle_state = + launcher_app_registry_is_enabled(desc); return; - bool enabled = launcher_menu.sub_items[visible_idx].toggle_state; - launcher_app_registry_set_enabled(desc, enabled); - UISetupPage::config_save(); + } launcher_app_registry_notify_changed(); return; } @@ -50,40 +121,44 @@ void Launcher::save_app_toggle(UISetupPage &page, const std::string &config_key) void Boot::factory_reset() { - cp0_signal_filesystem_api({"Remove", launcher_platform::path("launcher_settings")}, nullptr); - cp0_signal_process_api({"Reboot"}, nullptr); + execute_boot_action(boot_actions::Action::FactoryReset); } void Boot::append(UISetupPage &p, std::vector &menu) { - UISetupPage *page = &p; + const auto make_item = [&p](boot_actions::Action action) { + const auto &view = boot_actions::presentation(action); + return SubItem{std::string(view.label), false, false, [&p, action]() { + const auto &selected = boot_actions::presentation(action); + SetupPageAccess(p).confirm(selected.confirmation_title.data(), + [action]() { execute_boot_action(action); }); + }}; + }; MenuItem m; m.label = "Boot"; m.sub_items = { - {"Reboot", false, false, [page]() { - page->enter_confirm_action("Reboot?", [page](){ cp0_signal_process_api({"Reboot"}, nullptr); }); - }}, - {"Shutdown", false, false, [page]() { - page->enter_confirm_action("Shutdown?", [page](){ cp0_signal_process_api({"Shutdown"}, nullptr); }); - }}, + make_item(boot_actions::Action::Reboot), + make_item(boot_actions::Action::Shutdown), }; menu.push_back(m); } void Boot::rearm_oobe_and_reboot() { - cp0_signal_filesystem_api({"Touch", launcher_platform::path("oobe_marker")}, nullptr); - cp0_signal_process_api({"Reboot"}, nullptr); + execute_boot_action(boot_actions::Action::RearmOobe); } void Screen::append(UISetupPage &p, std::vector &menu) { UISetupPage *page = &p; + Screen *controller = this; MenuItem m; m.label = "Screen"; m.sub_items = { - {"Brightness", false, false, [page]() { page->screen_.enter_brightness_adjust(*page); }}, - {"DarkTime", false, false, [page]() { page->screen_.enter_darktime_adjust(*page); }}, + {"Brightness", false, false, + [controller, page]() { controller->enter_brightness_adjust(*page); }}, + {"DarkTime", false, false, + [controller, page]() { controller->enter_darktime_adjust(*page); }}, }; menu.push_back(m); } @@ -92,7 +167,8 @@ int Screen::backlight_read() { int value = -1; cp0_signal_settings_api({"BacklightRead"}, [&](int code, std::string data) { - if (code == 0) value = std::atoi(data.c_str()); + int parsed = -1; + if (code == 0 && setup_values::parse_nonnegative_int(data, parsed)) value = parsed; }); return value; } @@ -101,121 +177,156 @@ int Screen::backlight_max() { int value = 100; cp0_signal_settings_api({"BacklightMax"}, [&](int code, std::string data) { - if (code == 0) value = std::atoi(data.c_str()); + int parsed = 0; + if (code == 0 && setup_values::parse_nonnegative_int(data, parsed) && parsed > 0) + value = parsed; }); return value; } void Screen::enter_brightness_adjust(UISetupPage &page) { - page.val_title_ = "Brightness"; - page.val_options_ = {"100%", "75%", "50%", "25%"}; + SetupPageAccess access(page); + const int maximum = backlight_max(); bright_val_ = backlight_read(); - int mx = backlight_max(); - int pct = mx > 0 ? bright_val_ * 100 / mx : 100; - if (pct >= 87) page.val_sel_idx_ = 0; - else if (pct >= 62) page.val_sel_idx_ = 1; - else if (pct >= 37) page.val_sel_idx_ = 2; - else page.val_sel_idx_ = 3; - page.view_state_ = UISetupPage::ViewState::VALUE_SELECT; - page.transition_enter_level(); + if (bright_val_ < 0 || bright_val_ > maximum) + bright_val_ = access.config_get_int("brightness", maximum); + bright_val_ = std::clamp(bright_val_, 1, maximum); + access.enter_value("Brightness", {"100%", "75%", "50%", "25%"}, + setup_values::brightness_index(bright_val_, maximum)); } void Screen::apply_value(UISetupPage &page) { - if (page.val_title_ == "DarkTime") { - static const int times[] = {0, 10, 30, 60, 300}; - UISetupPage::config_set_int("dark_time", times[page.val_sel_idx_]); - UISetupPage::config_save(); + SetupPageAccess access(page); + if (access.value_title() == "DarkTime") { + const int previous = access.config_get_int("dark_time", 30); + const int desired = setup_values::dark_time_seconds(access.value_selection()); + if (!access.config_set_int("dark_time", desired) || !access.config_save()) { + access.config_set_int("dark_time", previous); + access.config_save(); + } return; } + if (access.value_title() != "Brightness") return; - int mx = backlight_max(); - int pcts[] = {100, 75, 50, 25}; - int new_val = mx * pcts[page.val_sel_idx_] / 100; - if (new_val < 1) new_val = 1; - cp0_signal_settings_api({"BacklightWrite", std::to_string(new_val)}, nullptr); - UISetupPage::config_set_int("brightness", new_val); - UISetupPage::config_save(); + const int maximum = backlight_max(); + const int new_val = setup_values::brightness_value(access.value_selection(), maximum); + bool write_succeeded = false; + int applied_value = new_val; + cp0_signal_settings_api({"BacklightWrite", std::to_string(new_val)}, + [&](int code, std::string data) { + if (code != 0) return; + write_succeeded = true; + int parsed = 0; + if (setup_values::parse_nonnegative_int(data, parsed)) applied_value = parsed; + }); + if (!write_succeeded) return; + const int previous_brightness = bright_val_; + const int applied_brightness = std::clamp(applied_value, 1, maximum); + const int previous_config = std::clamp( + access.config_get_int("brightness", previous_brightness), 1, maximum); + if (!access.config_set_int("brightness", applied_brightness) || !access.config_save()) { + access.config_set_int("brightness", previous_config); + access.config_save(); + cp0_signal_settings_api( + {"BacklightWrite", std::to_string(previous_brightness)}, nullptr); + return; + } + bright_val_ = applied_brightness; } void Screen::enter_darktime_adjust(UISetupPage &page) { - static const int times[] = {0, 10, 30, 60, 300}; - page.val_title_ = "DarkTime"; - page.val_options_ = {"Never", "10S", "30S", "60S", "300S"}; - const int saved = UISetupPage::config_get_int("dark_time", 30); - page.val_sel_idx_ = 2; - for (size_t i = 0; i < sizeof(times) / sizeof(times[0]); ++i) { - if (times[i] == saved) { - page.val_sel_idx_ = static_cast(i); - break; - } - } - page.view_state_ = UISetupPage::ViewState::VALUE_SELECT; - page.transition_enter_level(); + SetupPageAccess access(page); + access.enter_value("DarkTime", {"Never", "10S", "30S", "60S", "300S"}, + setup_values::dark_time_index(access.config_get_int("dark_time", 30))); } void Speaker::append(UISetupPage &p, std::vector &menu) { UISetupPage *page = &p; + Speaker *controller = this; MenuItem m; m.label = "Speaker"; - m.sub_items = {{"Volume", false, false, [page]() { page->speaker_.enter_volume_adjust(*page); }}}; + m.sub_items = {{"Volume", false, false, + [controller, page]() { controller->enter_volume_adjust(*page); }}}; menu.push_back(m); } void Speaker::enter_volume_adjust(UISetupPage &page) { - page.val_title_ = "Volume"; - page.val_options_ = {"100%", "75%", "50%", "25%", "0%"}; - vol_val_ = UISetupPage::config_get_int("volume", UISetupPage::audio_volume_read()); - int pct = vol_val_; - if (pct >= 87) page.val_sel_idx_ = 0; - else if (pct >= 62) page.val_sel_idx_ = 1; - else if (pct >= 37) page.val_sel_idx_ = 2; - else if (pct >= 12) page.val_sel_idx_ = 3; - else page.val_sel_idx_ = 4; - page.view_state_ = UISetupPage::ViewState::VALUE_SELECT; - page.transition_enter_level(); + SetupPageAccess access(page); + vol_val_ = access.audio_volume_read(); + if (!setup_values::volume_value_valid(vol_val_)) + vol_val_ = read_config_int_strict("volume", 100); + vol_val_ = std::clamp(vol_val_, 0, 100); + access.enter_value("Volume", {"100%", "75%", "50%", "25%", "0%"}, + setup_values::volume_index(vol_val_)); } void Speaker::apply_value(UISetupPage &page) { - int pcts[] = {100, 75, 50, 25, 0}; - int new_val = pcts[page.val_sel_idx_]; - UISetupPage::audio_volume_write(new_val); - UISetupPage::config_set_int("volume", new_val); - UISetupPage::config_save(); + SetupPageAccess access(page); + if (access.value_title() != "Volume") return; + const int requested = setup_values::volume_percent(access.value_selection()); + const int previous_config = std::clamp( + read_config_int_strict("volume", vol_val_), 0, 100); + const int applied = access.audio_volume_write(requested); + if (!setup_values::volume_value_valid(applied)) return; + if (!write_config_int_checked("volume", applied) || !save_config_checked()) { + write_config_int_checked("volume", previous_config); + save_config_checked(); + access.audio_volume_write(vol_val_); + return; + } + vol_val_ = applied; } void Camera::append(UISetupPage &p, std::vector &menu) { + bool status_received = false; + int status_code = -1; + cp0_signal_camera_api({"Status"}, [&](int code, std::string) { + status_received = true; + status_code = code; + }); + if (!setup_values::camera_available_from_status(status_received, status_code)) return; + UISetupPage *page = &p; + Camera *controller = this; MenuItem m; m.label = "Camera"; - m.sub_items = {{"Resolution", false, false, [page]() { page->camera_.enter_resolution(*page); }}}; + m.sub_items = {{"Resolution", false, false, + [controller, page]() { controller->enter_resolution(*page); }}}; menu.push_back(m); } void Camera::enter_resolution(UISetupPage &page) { - page.val_title_ = "Resolution"; - page.val_options_ = {"1280x720", "640x480"}; - const int width = UISetupPage::config_get_int("camera.resolution.width", 1280); - const int height = UISetupPage::config_get_int("camera.resolution.height", 720); - page.val_sel_idx_ = (width == 640 && height == 480) ? 1 : 0; - page.view_state_ = UISetupPage::ViewState::VALUE_SELECT; - page.transition_enter_level(); + SetupPageAccess access(page); + int width = read_config_int_strict("camera.resolution.width", 1280); + int height = read_config_int_strict("camera.resolution.height", 720); + if (!setup_values::camera_resolution_supported(width, height)) { + width = 1280; + height = 720; + } + access.enter_value("Resolution", {"1280x720", "640x480"}, + setup_values::camera_resolution_index(width, height)); } void Camera::apply_value(UISetupPage &page) { - int width = 1280, height = 720; - if (page.val_sel_idx_ == 1) { width = 640; height = 480; } - UISetupPage::config_set_int("camera.resolution.width", width); - UISetupPage::config_set_int("camera.resolution.height", height); - UISetupPage::config_save(); + SetupPageAccess access(page); + if (access.value_title() != "Resolution") return; + const auto resolution = setup_values::camera_resolution(access.value_selection()); + const int previous_width = read_config_int_strict("camera.resolution.width", 1280); + const int previous_height = read_config_int_strict("camera.resolution.height", 720); + if (!write_config_int_checked("camera.resolution.width", resolution.width)) return; + if (!write_config_int_checked("camera.resolution.height", resolution.height) || + !save_config_checked()) { + restore_camera_resolution(previous_width, previous_height); + } } } // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/basic.hpp b/projects/APPLaunch/main/ui/page_app/setting/basic.hpp new file mode 100644 index 00000000..3a7a975d --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/basic.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "menu_types.hpp" + +#include +#include + +class UISetupPage; + +namespace setting { + +class Launcher +{ +public: + static void append(UISetupPage &page, std::vector &menu); + static void save_app_toggle(UISetupPage &page, const std::string &config_key); +}; + +class Boot +{ +public: + static void append(UISetupPage &page, std::vector &menu); + static void factory_reset(); + static void rearm_oobe_and_reboot(); +}; + +class Screen +{ +public: + void append(UISetupPage &page, std::vector &menu); + void enter_brightness_adjust(UISetupPage &page); + void enter_darktime_adjust(UISetupPage &page); + void apply_value(UISetupPage &page); + static int backlight_read(); + static int backlight_max(); + +private: + int bright_val_ = 75; +}; + +class Speaker +{ +public: + void append(UISetupPage &page, std::vector &menu); + void enter_volume_adjust(UISetupPage &page); + void apply_value(UISetupPage &page); + +private: + int vol_val_ = 39; +}; + +class Camera +{ +public: + void append(UISetupPage &page, std::vector &menu); + void enter_resolution(UISetupPage &page); + void apply_value(UISetupPage &page); +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/bluetooth.cpp b/projects/APPLaunch/main/ui/page_app/setting/bluetooth.cpp index 75c5c5cd..667135c8 100644 --- a/projects/APPLaunch/main/ui/page_app/setting/bluetooth.cpp +++ b/projects/APPLaunch/main/ui/page_app/setting/bluetooth.cpp @@ -1,44 +1,60 @@ #define APP_PAGE_IMPLEMENTATION_UNIT #include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +#include +#include +#include namespace setting { -Bluetooth::Bluetooth() : async_state_(std::make_shared()) {} +static_assert(BluetoothPageModel::ALIAS_INPUT_LIMIT == CP0_BT_NAME_MAX - 1, + "Bluetooth alias model must match the cp0 API buffer"); + +Bluetooth::Bluetooth() = default; Bluetooth::~Bluetooth() { - async_state_->alive = false; - if (async_state_->request_id) - cp0_signal_sudo_cancel(async_state_->request_id, nullptr); + shutdown(); +} + +void Bluetooth::shutdown() +{ + stop_scan_timer(); + stop_failure_feedback(); + action_operation_.shutdown(); + const uint64_t request_id = sudo_operation_.shutdown(); + if (request_id) + cp0_signal_sudo_cancel(request_id, nullptr); } void Bluetooth::append(UISetupPage &p, std::vector &menu) { UISetupPage *page = &p; - Bluetooth *bt = &page->bluetooth_; + SetupPageAccess access(*page); + Bluetooth *bt = &access.bluetooth(); MenuItem m; m.label = "Bluetooth"; - bt->named_only_ = UISetupPage::config_get_int("bt_named_only", 1) != 0; + bt->model_.set_named_only(access.config_get_int("bt_named_only", 1) != 0); m.sub_items = { {"Power", true, false, [bt, page]() { bt->toggle_power(*page); }}, {"Alias: CardputerZero", false, false, [bt, page]() { bt->enter_alias(*page); }}, {"Discoverable", true, false, [bt, page]() { bt->toggle_discoverable(*page); }}, - {"Named Only", true, bt->named_only_, [bt, page]() { bt->toggle_named_only(*page); }}, + {"Named Only", true, bt->model_.named_only(), [bt, page]() { bt->toggle_named_only(*page); }}, {"Connected", false, false, [bt, page]() { bt->enter_devices(*page); }}, {"Scan", false, false, [bt, page]() { bt->enter_scan(*page); }}, }; m.on_enter = [bt, page]() { bt->refresh_status(*page); }; menu.push_back(m); } - - - void Bluetooth::enter_devices(UISetupPage &page) { stop_scan_timer(); - list_mode_ = ListMode::Managed; - page.view_state_ = UISetupPage::ViewState::BT_LIST; - list_sel_ = 0; + stop_failure_feedback(); + action_operation_.abort(action_token_); + action_busy_ = false; + model_.set_list_mode(BluetoothListMode::MANAGED); + SetupPageAccess(page).set_view(SetupViewState::BT_LIST); refresh_devices(); build_list(page); } @@ -46,82 +62,28 @@ void Bluetooth::enter_devices(UISetupPage &page) void Bluetooth::enter_alias(UISetupPage &page) { stop_scan_timer(); + stop_failure_feedback(); + action_operation_.abort(action_token_); + action_busy_ = false; refresh_status(page); - alias_input_ = alias_.empty() ? "CardputerZero" : alias_; - page.view_state_ = UISetupPage::ViewState::BT_ALIAS; + model_.begin_alias_edit(); + SetupPageAccess(page).set_view(SetupViewState::BT_ALIAS); build_alias_view(page); } -bool Bluetooth::alias_char_allowed(unsigned char ch) -{ - return std::isprint(ch) && ch != '\t' && ch != '\n' && ch != '\r'; -} - -std::string Bluetooth::alias_sanitized() const -{ - std::string out; - out.reserve(alias_input_.size()); - for (unsigned char ch : alias_input_) { - if (alias_char_allowed(ch)) - out.push_back(static_cast(ch)); - } - if (out.empty()) - out = "CardputerZero"; - return out.substr(0, CP0_BT_NAME_MAX - 1); -} - -void Bluetooth::build_alias_view(UISetupPage &page) -{ - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - alias_input_lbl_ = nullptr; - alias_hint_lbl_ = nullptr; - - lv_obj_t *title = lv_label_create(cont); - lv_label_set_text(title, "Bluetooth Name"); - lv_obj_set_pos(title, 10, 10); - lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(title, &lv_font_montserrat_14, LV_PART_MAIN); - - lv_obj_t *label = lv_label_create(cont); - lv_label_set_text(label, "Name:"); - lv_obj_set_pos(label, 10, 38); - lv_obj_set_style_text_color(label, lv_color_hex(0xCCCCCC), LV_PART_MAIN); - lv_obj_set_style_text_font(label, &lv_font_montserrat_12, LV_PART_MAIN); - - alias_input_lbl_ = lv_label_create(cont); - lv_obj_set_pos(alias_input_lbl_, 64, 36); - lv_obj_set_width(alias_input_lbl_, 236); - lv_label_set_long_mode(alias_input_lbl_, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(alias_input_lbl_, lv_color_hex(0xFFFFFF), LV_PART_MAIN); - lv_obj_set_style_text_font(alias_input_lbl_, &lv_font_montserrat_14, LV_PART_MAIN); - alias_update_display(); - - alias_hint_lbl_ = lv_label_create(cont); - lv_label_set_text(alias_hint_lbl_, "OK:set BS:del ESC:cancel"); - lv_obj_set_pos(alias_hint_lbl_, 10, 70); - lv_obj_set_style_text_color(alias_hint_lbl_, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(alias_hint_lbl_, &lv_font_montserrat_10, LV_PART_MAIN); -} - -void Bluetooth::alias_update_display() -{ - if (!alias_input_lbl_) - return; - std::string display = alias_input_ + "_"; - lv_label_set_text(alias_input_lbl_, display.c_str()); -} - void Bluetooth::handle_alias_key(UISetupPage &page, uint32_t key) { if (key == KEY_ESC || key == KEY_LEFT) { - page.play_back(); - page.view_state_ = UISetupPage::ViewState::SUB; - page.build_sub_view(); + alias_input_lbl_ = nullptr; + alias_hint_lbl_ = nullptr; + SetupPageAccess access(page); + access.play_back(); + access.set_view(SetupViewState::SUB); + access.build_sub_view(); return; } if (key == KEY_ENTER || key == KEY_RIGHT) { - std::string alias = alias_sanitized(); + std::string alias = model_.sanitized_alias(); if (alias_hint_lbl_) { lv_label_set_text(alias_hint_lbl_, "Setting alias..."); lv_obj_set_style_text_color(alias_hint_lbl_, lv_color_hex(0xFFAA00), LV_PART_MAIN); @@ -129,10 +91,11 @@ void Bluetooth::handle_alias_key(UISetupPage &page, uint32_t key) } int ret = set_alias(alias); if (ret == 0) { - alias_ = alias; + model_.set_alias(alias); refresh_status(page); - page.view_state_ = UISetupPage::ViewState::SUB; - page.build_sub_view(); + SetupPageAccess access(page); + access.set_view(SetupViewState::SUB); + access.build_sub_view(); } else if (alias_hint_lbl_) { lv_label_set_text(alias_hint_lbl_, "Set failed"); lv_obj_set_style_text_color(alias_hint_lbl_, lv_color_hex(0xFF4444), LV_PART_MAIN); @@ -140,251 +103,50 @@ void Bluetooth::handle_alias_key(UISetupPage &page, uint32_t key) return; } if (key == KEY_BACKSPACE) { - if (!alias_input_.empty()) - alias_input_.pop_back(); + model_.erase_alias_character(); alias_update_display(); return; } - if (page.cur_elm_ && page.cur_elm_->utf8[0] && alias_input_.size() < CP0_BT_NAME_MAX - 1) { - const char *text = page.cur_elm_->utf8; - while (*text && alias_input_.size() < CP0_BT_NAME_MAX - 1) { - unsigned char ch = static_cast(*text++); - if (alias_char_allowed(ch)) - alias_input_ += static_cast(ch); - } + const std::string_view input = SetupPageAccess(page).current_utf8(); + if (!input.empty()) { + model_.append_alias_text(input.data()); alias_update_display(); } } void Bluetooth::enter_scan(UISetupPage &page) { - list_mode_ = ListMode::Scan; - page.view_state_ = UISetupPage::ViewState::BT_LIST; - list_sel_ = 0; + stop_failure_feedback(); + action_operation_.abort(action_token_); + action_busy_ = false; + model_.set_list_mode(BluetoothListMode::SCAN); + SetupPageAccess(page).set_view(SetupViewState::BT_LIST); start_scan_timer(page); } -void Bluetooth::build_list(UISetupPage &page) -{ - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - rebuild_rows(); - - cp0_bt_status_t st = get_status(); - char title_buf[96]; - snprintf(title_buf, sizeof(title_buf), "%s: %s %.24s", - list_mode_ == ListMode::Scan ? "Scan" : "Connected", - st.powered ? "On" : "Off", st.address[0] ? st.address : "--"); - lv_obj_t *title = lv_label_create(cont); - lv_label_set_text(title, title_buf); - lv_obj_set_pos(title, 8, 2); - lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(title, launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - UISetupPage::apply_overflow_handling(title, 8, UISetupPage::WIFI_TITLE_BOX_W, true); - - if (rows_.empty()) { - lv_obj_t *empty = lv_label_create(cont); - if (!st.powered) - lv_label_set_text(empty, "Bluetooth is off. Enable Power first."); - else if (list_mode_ == ListMode::Scan) - lv_label_set_text(empty, "Scanning..."); - else - lv_label_set_text(empty, "No connected devices."); - lv_obj_set_pos(empty, 8, 50); - lv_obj_set_width(empty, 300); - lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP); - lv_obj_set_style_text_color(empty, lv_color_hex(0x666666), LV_PART_MAIN); - lv_obj_set_style_text_font(empty, &lv_font_montserrat_12, LV_PART_MAIN); - } - - constexpr int list_y = 22; - constexpr int row_step = 20; - constexpr int hint_y = UISetupPage::LIST_H - 14; - constexpr int list_bottom_gap = 8; - int visible = (hint_y - list_bottom_gap - list_y) / row_step; - if (visible < 1) visible = 1; - int offset = list_sel_ - visible / 2; - if (offset < 0) offset = 0; - if (offset > (int)rows_.size() - visible) offset = (int)rows_.size() - visible; - if (offset < 0) offset = 0; - - for (int vi = 0; vi < visible && (vi + offset) < (int)rows_.size(); ++vi) { - int row_index = vi + offset; - const ListRow &row = rows_[row_index]; - int y = list_y + vi * row_step; - - if (row.is_header) { - lv_obj_t *header = lv_label_create(cont); - lv_label_set_text(header, row.title); - lv_obj_set_pos(header, 8, y + 3); - lv_obj_set_style_text_color(header, lv_color_hex(0x888888), LV_PART_MAIN); - lv_obj_set_style_text_font(header, launcher_fonts().get("Montserrat-Bold.ttf", 10, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - continue; - } - - bool sel = (row_index == list_sel_); - cp0_bt_device_t *dev = &devices_[row.device_index]; - if (sel) { - lv_obj_t *bg = lv_obj_create(cont); - lv_obj_set_size(bg, UISetupPage::SCREEN_W - 8, 20); - lv_obj_set_pos(bg, 4, y); - lv_obj_set_style_radius(bg, 2, LV_PART_MAIN); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x1F3A5F), LV_PART_MAIN); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - } - - uint32_t tc = dev->connected ? 0x58A6FF : (sel ? 0xFFFFFF : 0xCCCCCC); - lv_obj_t *name = lv_label_create(cont); - lv_label_set_text(name, dev->name[0] ? dev->name : dev->address); - lv_obj_set_pos(name, 8, y + 1); - lv_obj_set_width(name, 150); - lv_label_set_long_mode(name, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(name, lv_color_hex(tc), LV_PART_MAIN); - lv_obj_set_style_text_font(name, &lv_font_montserrat_12, LV_PART_MAIN); - - lv_obj_t *addr = lv_label_create(cont); - lv_label_set_text(addr, dev->address); - lv_obj_set_pos(addr, 8, y + 12); - lv_obj_set_width(addr, 190); - lv_label_set_long_mode(addr, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(addr, lv_color_hex(sel ? 0xBBBBBB : 0x777777), LV_PART_MAIN); - lv_obj_set_style_text_font(addr, &lv_font_montserrat_10, LV_PART_MAIN); - - char state_buf[32]; - if (dev->connected) - snprintf(state_buf, sizeof(state_buf), "Connected"); - else if (dev->paired) - snprintf(state_buf, sizeof(state_buf), "Paired"); - else - snprintf(state_buf, sizeof(state_buf), "%d", dev->rssi); - lv_obj_t *state = lv_label_create(cont); - lv_label_set_text(state, state_buf); - lv_obj_set_pos(state, 226, y + 4); - lv_obj_set_width(state, 88); - lv_label_set_long_mode(state, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_align(state, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN); - lv_obj_set_style_text_color(state, lv_color_hex(tc), LV_PART_MAIN); - lv_obj_set_style_text_font(state, &lv_font_montserrat_10, LV_PART_MAIN); - } - - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, list_mode_ == ListMode::Scan - ? "OK:act R:restart ESC:back" - : "OK:disconnect D:remove ESC:back"); - lv_obj_set_pos(hint, 8, hint_y); - lv_obj_set_width(hint, 304); - lv_label_set_long_mode(hint, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); -} - void Bluetooth::rebuild_rows() { - rows_.clear(); - if (list_mode_ == ListMode::Managed) { - bool has_connected = false; - for (int i = 0; i < device_count_; ++i) { - if (should_hide_device(devices_[i])) - continue; - if (devices_[i].connected) { - if (!has_connected) { - rows_.push_back({-1, "Connected Devices", true}); - has_connected = true; - } - rows_.push_back({i, nullptr, false}); - } - } - } else { - bool has_devices = false; - for (int i = 0; i < device_count_; ++i) { - if (should_hide_device(devices_[i])) - continue; - if (!has_devices) { - rows_.push_back({-1, "Discovered Devices", true}); - has_devices = true; - } - rows_.push_back({i, nullptr, false}); - } - } - if (list_sel_ >= (int)rows_.size()) - list_sel_ = rows_.empty() ? 0 : (int)rows_.size() - 1; - if (!rows_.empty() && rows_[list_sel_].is_header) - select_next_device(1); -} - -bool Bluetooth::should_hide_device(const cp0_bt_device_t &dev) const -{ - if (!named_only_) - return false; - if (!dev.name[0]) - return true; - std::string name_hex = normalized_mac_text(dev.name); - std::string addr_hex = normalized_mac_text(dev.address); - return !name_hex.empty() && (name_hex == addr_hex || name_hex.size() == 12); -} - -std::string Bluetooth::normalized_mac_text(const char *text) -{ - std::string out; - if (!text) - return out; - for (const unsigned char *p = reinterpret_cast(text); *p; ++p) { - if (std::isxdigit(*p)) - out.push_back((char)std::tolower(*p)); - else if (*p != ':' && *p != '-' && *p != '_' && *p != ' ') - return std::string(); + std::vector devices; + devices.reserve(device_count_); + for (int index = 0; index < device_count_; ++index) { + const cp0_bt_device_t &device = devices_[index]; + devices.push_back({device.address, device.name, device.paired != 0, device.connected != 0}); } - return out; -} - -int Bluetooth::selected_device_index() const -{ - if (list_sel_ < 0 || list_sel_ >= (int)rows_.size()) - return -1; - return rows_[list_sel_].is_header ? -1 : rows_[list_sel_].device_index; -} - -void Bluetooth::select_next_device(int direction) -{ - if (rows_.empty()) - return; - int idx = list_sel_; - for (int steps = 0; steps < (int)rows_.size(); ++steps) { - idx += direction; - if (idx < 0 || idx >= (int)rows_.size()) - return; - if (!rows_[idx].is_header) { - list_sel_ = idx; - return; - } - } -} - -void Bluetooth::show_action(UISetupPage &page, const char *msg, uint32_t color) -{ - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, msg); - lv_obj_set_pos(lbl, 8, 60); - lv_obj_set_style_text_color(lbl, lv_color_hex(color), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, LV_PART_MAIN); - lv_refr_now(NULL); + model_.rebuild_rows(devices); } void Bluetooth::activate_selected(UISetupPage &page) { - if (action_busy_) - return; - int dev_index = selected_device_index(); + const bool operation_active = action_busy_ && action_operation_.active(); + if (!bluetooth_action_entry_allowed(action_busy_, operation_active)) return; + if (action_busy_) action_busy_ = false; + int dev_index = model_.selected_device_index(); if (dev_index < 0) return; cp0_bt_device_t dev = devices_[dev_index]; - bool from_scan = list_mode_ == ListMode::Scan; + bool from_scan = model_.list_mode() == BluetoothListMode::SCAN; if (from_scan) - stop_scan_timer(); + suspend_scan_discovery(); action_busy_ = true; if (dev.connected) show_action(page, "Disconnecting..."); @@ -394,80 +156,190 @@ void Bluetooth::activate_selected(UISetupPage &page) show_action(page, "Pairing..."); struct BtActionResult { - Bluetooth *bt; + AsyncOperationLifecycle::Token token; UISetupPage *page; int ret; bool from_scan; }; - std::thread([this, &page, dev, from_scan]() { - int ret = -1; - if (dev.connected) { - ret = device_command("BtDisconnect", dev.address); - } else if (dev.paired) { - ret = device_command("BtConnect", dev.address); - } else { - ret = device_command("BtPair", dev.address); - if (ret == 0) + action_token_ = action_operation_.begin(); + AsyncOperationLifecycle::Token token = action_token_; + if (!token) { + action_busy_ = false; + if (from_scan) + resume_scan_discovery(); + return; + } + try { + std::thread([token, page = &page, dev, from_scan]() { + int ret = -1; + if (dev.connected) { + ret = device_command("BtDisconnect", dev.address); + } else if (dev.paired) { ret = device_command("BtConnect", dev.address); - } + } else { + ret = device_command("BtPair", dev.address); + if (ret == 0) + ret = device_command("BtConnect", dev.address); + } - BtActionResult *result = new BtActionResult{this, &page, ret, from_scan}; - lv_async_call([](void *user) { - BtActionResult *result = static_cast(user); - Bluetooth *bt = result->bt; - UISetupPage *page = result->page; - if (bt && page) { - bt->action_busy_ = false; - if (result->ret != 0) { - bt->show_action(*page, "Bluetooth action failed", 0xFF4444); - } else if (result->from_scan) { - bt->list_mode_ = ListMode::Managed; + auto result = std::unique_ptr( + new (std::nothrow) BtActionResult{token, page, ret, from_scan}); + if (!result) { + token.complete(); + return; + } + BtActionResult *queued = result.release(); + if (lv_async_call([](void *user) noexcept { + std::unique_ptr result(static_cast(user)); + try { + UISetupPage *page = result->page; + if (!bluetooth_async_completion_allowed( + result->token.complete(), page)) return; + Bluetooth *bt = &SetupPageAccess(*page).bluetooth(); + bt->action_busy_ = false; + if (result->ret != 0) { + bt->show_action(*page, "Bluetooth action failed", 0xFF4444); + if (bluetooth_scan_action_should_resume( + result->from_scan, result->ret)) + bt->resume_scan_discovery(); + } else if (result->from_scan) { + bt->model_.set_list_mode(BluetoothListMode::MANAGED); + bt->stop_scan_timer(); + } + bt->refresh_devices(); + if (SetupPageAccess(*page).is_view(SetupViewState::BT_LIST)) + bt->build_list(*page); + } catch (...) { } - bt->refresh_devices(); - if (page->view_state_ == UISetupPage::ViewState::BT_LIST) - bt->build_list(*page); + }, queued) != LV_RESULT_OK) { + queued->token.complete(); + delete queued; } - delete result; - }, result); - }).detach(); + }).detach(); + } catch (...) { + action_operation_.abort(token); + action_busy_ = false; + if (from_scan) + resume_scan_discovery(); + } } void Bluetooth::remove_selected(UISetupPage &page) { - int dev_index = selected_device_index(); + int dev_index = model_.selected_device_index(); if (dev_index < 0) return; show_action(page, "Removing..."); int ret = device_command("BtRemove", devices_[dev_index].address); if (ret != 0) { show_action(page, "Remove failed", 0xFF4444); - cp0_signal_process_api({"DelayMs", "1200"}, nullptr); + start_failure_feedback(page); + return; } refresh_devices(); build_list(page); } +void Bluetooth::start_failure_feedback(UISetupPage &page) +{ + stop_failure_feedback(); + if (!model_.begin_feedback()) return; + failure_feedback_page_ = &page; + failure_feedback_timer_ = lv_timer_create(failure_feedback_timer_cb, 1200, this); + if (failure_feedback_timer_) { + lv_timer_set_repeat_count(failure_feedback_timer_, 1); + if (page.screen()) + lv_obj_add_event_cb(page.screen(), failure_feedback_screen_delete_cb, + LV_EVENT_DELETE, this); + return; + } + + failure_feedback_page_ = nullptr; + model_.finish_feedback(); + refresh_devices(); + if (SetupPageAccess(page).is_view(SetupViewState::BT_LIST)) build_list(page); +} + +void Bluetooth::stop_failure_feedback() +{ + if (failure_feedback_page_ && failure_feedback_page_->screen()) + lv_obj_remove_event_cb_with_user_data( + failure_feedback_page_->screen(), failure_feedback_screen_delete_cb, this); + if (failure_feedback_timer_) { + lv_timer_delete(failure_feedback_timer_); + failure_feedback_timer_ = nullptr; + } + failure_feedback_page_ = nullptr; + model_.cancel_feedback(); +} + +void Bluetooth::failure_feedback_timer_cb(lv_timer_t *timer) noexcept +{ + auto *self = static_cast(lv_timer_get_user_data(timer)); + if (!self || !bluetooth_feedback_callback_allowed( + timer, self->failure_feedback_timer_, self->failure_feedback_page_, + self->model_.feedback_pending())) return; + try { + UISetupPage *page = self->failure_feedback_page_; + if (page->screen()) + lv_obj_remove_event_cb_with_user_data( + page->screen(), failure_feedback_screen_delete_cb, self); + self->failure_feedback_timer_ = nullptr; + self->failure_feedback_page_ = nullptr; + if (!self->model_.finish_feedback()) return; + if (!SetupPageAccess(*page).is_view(SetupViewState::BT_LIST)) return; + self->refresh_devices(); + self->build_list(*page); + } catch (...) { + self->failure_feedback_timer_ = nullptr; + self->failure_feedback_page_ = nullptr; + self->model_.cancel_feedback(); + } +} + +void Bluetooth::failure_feedback_screen_delete_cb(lv_event_t *event) noexcept +{ + try { + if (!event) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self || !self->failure_feedback_page_ || + !bluetooth_feedback_screen_delete_matches( + lv_event_get_target(event), lv_event_get_current_target(event), + self->failure_feedback_page_->screen())) return; + self->failure_feedback_page_ = nullptr; + if (self->failure_feedback_timer_) { + lv_timer_delete(self->failure_feedback_timer_); + self->failure_feedback_timer_ = nullptr; + } + self->model_.cancel_feedback(); + } catch (...) { + // Never let a C++ exception escape an LVGL C callback. + } +} + void Bluetooth::handle_list_key(UISetupPage &page, uint32_t key) { + if (model_.feedback_pending() && key != KEY_ESC && key != KEY_LEFT) + return; switch (key) { case KEY_UP: - select_next_device(-1); + model_.select_next_device(-1); build_list(page); break; case KEY_DOWN: - select_next_device(1); + model_.select_next_device(1); build_list(page); break; case KEY_ENTER: activate_selected(page); break; case KEY_D: - if (list_mode_ == ListMode::Managed) + if (model_.list_mode() == BluetoothListMode::MANAGED) remove_selected(page); break; case KEY_R: - if (list_mode_ == ListMode::Scan) { + if (model_.list_mode() == BluetoothListMode::SCAN) { start_scan_timer(page); } else { refresh_devices(); @@ -475,294 +347,19 @@ void Bluetooth::handle_list_key(UISetupPage &page, uint32_t key) } break; case KEY_ESC: - case KEY_LEFT: + case KEY_LEFT: { stop_scan_timer(); - page.view_state_ = UISetupPage::ViewState::SUB; - page.build_sub_view(); - break; - default: - break; - } -} - -void Bluetooth::copy_string(char *dst, size_t dst_size, const std::string &src) -{ - if (!dst || dst_size == 0) - return; - std::snprintf(dst, dst_size, "%s", src.c_str()); -} - -std::vector Bluetooth::split_char(const std::string &line, char delimiter) -{ - std::vector cols; - std::string item; - std::istringstream row(line); - while (std::getline(row, item, delimiter)) - cols.push_back(item); - return cols; -} - -bool Bluetooth::decode_status(const std::string &data, cp0_bt_status_t &st) -{ - auto cols = split_char(data, '\t'); - if (cols.size() < 3) - return false; - st.powered = std::atoi(cols[0].c_str()); - copy_string(st.address, sizeof(st.address), cols[1]); - st.discoverable = std::atoi(cols[2].c_str()); - if (cols.size() >= 4) - copy_string(st.alias, sizeof(st.alias), cols[3]); - return true; -} - -int Bluetooth::decode_devices(const std::string &data, cp0_bt_device_t *out, int max_devices) -{ - if (!out || max_devices <= 0) - return 0; - int count = 0; - std::istringstream lines(data); - std::string line; - while (count < max_devices && std::getline(lines, line)) { - if (line.empty()) - continue; - auto cols = split_char(line, '\t'); - if (cols.size() < 4) - continue; - cp0_bt_device_t dev{}; - copy_string(dev.address, sizeof(dev.address), cols[0]); - dev.paired = std::atoi(cols[1].c_str()); - dev.connected = std::atoi(cols[2].c_str()); - dev.rssi = std::atoi(cols[3].c_str()); - if (cols.size() >= 6) - copy_string(dev.name, sizeof(dev.name), cols[5]); - else if (cols.size() >= 4) - copy_string(dev.name, sizeof(dev.name), cols[3]); - out[count++] = dev; - } - return count; -} - -int Bluetooth::api_int(std::list args, int default_value) -{ - int ret = default_value; - cp0_signal_bt_api(std::move(args), [&](int code, std::string) { - ret = code; - }); - return ret; -} - -cp0_bt_status_t Bluetooth::get_status() -{ - cp0_bt_status_t st{}; - cp0_signal_bt_api({"BtStatus"}, [&](int code, std::string data) { - if (code == 0) - decode_status(data, st); - }); - return st; -} - -int Bluetooth::set_power(int on) -{ - return api_int({"BtPower", std::to_string(on)}); -} - -int Bluetooth::rfkill_blocked() -{ - const char *argv[] = { - "/usr/sbin/rfkill", "--noheadings", "--output", "TYPE,SOFT", nullptr - }; - char output[512] = {}; - if (cp0_process_capture_argv(argv, output, sizeof(output)) != 0) - return -1; - - std::istringstream lines(output); - std::string line; - while (std::getline(lines, line)) { - std::istringstream fields(line); - std::string type; - std::string soft; - fields >> type >> soft; - if (type == "bluetooth") - return soft == "blocked" ? 1 : 0; - } - return -1; -} - -int Bluetooth::set_alias(const std::string &alias) -{ - return api_int({"BtAlias", alias}); -} - -int Bluetooth::set_discoverable(int on) -{ - return api_int({"BtDiscoverable", std::to_string(on)}); -} - -int Bluetooth::device_command(const char *cmd, const char *address) -{ - return api_int({cmd ? std::string(cmd) : std::string(), - address ? std::string(address) : std::string()}); -} - -int Bluetooth::device_list(const char *cmd, cp0_bt_device_t *out, int max_devices) -{ - int count = 0; - cp0_signal_bt_api({cmd ? std::string(cmd) : std::string(), std::to_string(max_devices)}, - [&](int code, std::string data) { - count = out && max_devices > 0 ? decode_devices(data, out, max_devices) : code; - }); - return count; -} - -void Bluetooth::refresh_status(UISetupPage &page) -{ - cp0_bt_status_t st = get_status(); - for (auto &m : page.menu_items_) { - if (m.label != "Bluetooth") continue; - m.sub_items[0].toggle_state = st.powered != 0; - discoverable_ = st.discoverable != 0; - alias_ = st.alias[0] ? st.alias : "CardputerZero"; - m.sub_items[1].label = "Alias: " + alias_; - m.sub_items[2].toggle_state = discoverable_; - break; - } -} - -void Bluetooth::toggle_power(UISetupPage &page) -{ - for (auto &m : page.menu_items_) { - if (m.label != "Bluetooth") continue; - bool on = m.sub_items[0].toggle_state; - if (!on) { - stop_scan_timer(); - set_power(0); - refresh_status(page); - break; - } - - if (async_state_->request_id) { - refresh_status(page); - break; - } - - int blocked = rfkill_blocked(); - if (blocked <= 0) { - if (blocked < 0) - std::fprintf(stderr, "Bluetooth: unable to query /usr/sbin/rfkill; trying BlueZ power on\n"); - set_power(1); - refresh_status(page); - break; - } - - struct Context { - Bluetooth *bluetooth; - UISetupPage *page; - std::weak_ptr state; - }; - auto *ctx = new (std::nothrow) Context{this, &page, async_state_}; - if (!ctx) { - refresh_status(page); - break; - } - - uint64_t request_id = 0; - int rc = -1; - cp0_signal_sudo_argv_async({"/usr/sbin/rfkill", "unblock", "bluetooth"}, 60000, 30000, - [ctx](int result_code, int exit_code) { - std::unique_ptr owned(ctx); - auto state = ctx->state.lock(); - if (!state || !state->alive) return; - state->request_id = 0; - if (result_code == CP0_SUDO_RESULT_SUCCESS && exit_code == 0) { - if (ctx->bluetooth->set_power(1) != 0) - std::fprintf(stderr, "Bluetooth: BlueZ power on failed after rfkill unblock\n"); - } else { - std::fprintf(stderr, "Bluetooth: sudo /usr/sbin/rfkill unblock bluetooth failed\n"); - } - ctx->bluetooth->refresh_status(*ctx->page); - ctx->page->build_sub_view(); - }, [&](int code, uint64_t id) { rc = code; request_id = id; }); - if (rc != 0) { - delete ctx; - refresh_status(page); - } else { - async_state_->request_id = request_id; - } + stop_failure_feedback(); + action_operation_.abort(action_token_); + action_busy_ = false; + SetupPageAccess access(page); + access.set_view(SetupViewState::SUB); + access.build_sub_view(); break; } -} - -void Bluetooth::toggle_named_only(UISetupPage &page) -{ - for (auto &m : page.menu_items_) { - if (m.label != "Bluetooth") continue; - named_only_ = m.sub_items[3].toggle_state; - UISetupPage::config_set_int("bt_named_only", named_only_ ? 1 : 0); - UISetupPage::config_save(); - break; - } - if (page.view_state_ == UISetupPage::ViewState::BT_LIST) - build_list(page); -} - -void Bluetooth::toggle_discoverable(UISetupPage &page) -{ - for (auto &m : page.menu_items_) { - if (m.label != "Bluetooth") continue; - discoverable_ = m.sub_items[2].toggle_state; - if (set_discoverable(discoverable_ ? 1 : 0) != 0) { - m.sub_items[2].toggle_state = !discoverable_; - discoverable_ = m.sub_items[2].toggle_state; - } + default: break; } } -void Bluetooth::start_scan_timer(UISetupPage &page) -{ - stop_scan_timer(); - discovery_active_ = api_int({"BtDiscoveryStart"}, 0) == 0; - refresh_devices(); - build_list(page); - if (!discovery_active_) - return; - scan_timer_ = lv_timer_create([](lv_timer_t *t) { - UISetupPage *self = (UISetupPage *)lv_timer_get_user_data(t); - if (!self || self->view_state_ != UISetupPage::ViewState::BT_LIST || - self->bluetooth_.list_mode_ != ListMode::Scan) - return; - self->bluetooth_.refresh_devices(); - self->bluetooth_.build_list(*self); - }, 2500, &page); -} - -void Bluetooth::stop_scan_timer() -{ - if (scan_timer_) { - lv_timer_delete(scan_timer_); - scan_timer_ = nullptr; - } - if (discovery_active_) { - api_int({"BtDiscoveryStop"}, 0); - discovery_active_ = false; - } -} - -void Bluetooth::refresh_devices() -{ - if (list_mode_ == ListMode::Managed) - device_count_ = device_list("BtConnectedList", devices_, CP0_BT_DEVICE_MAX); - else - device_count_ = device_list("BtList", devices_, CP0_BT_DEVICE_MAX); - if (device_count_ < 0) - device_count_ = 0; - if (device_count_ == 0) - list_sel_ = 0; -} - -void Bluetooth::do_scan(UISetupPage &page) -{ - enter_scan(page); -} - } // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/bluetooth.hpp b/projects/APPLaunch/main/ui/page_app/setting/bluetooth.hpp new file mode 100644 index 00000000..7fd2e53f --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/bluetooth.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include "menu_types.hpp" +#include "../../model/async_operation_lifecycle.hpp" +#include "../../model/bluetooth_page_model.hpp" +#include "../../model/bluetooth_scan_lifecycle_contract.hpp" + +#include "cp0_lvgl_app.h" +#include + +#include +#include +#include +#include +#include + +class UISetupPage; + +namespace setting { + +class Bluetooth +{ +public: + Bluetooth(); + ~Bluetooth(); + void shutdown(); + + void append(UISetupPage &page, std::vector &menu); + void enter_devices(UISetupPage &page); + void enter_alias(UISetupPage &page); + void build_alias_view(UISetupPage &page); + void handle_alias_key(UISetupPage &page, uint32_t key); + void enter_scan(UISetupPage &page); + void build_list(UISetupPage &page); + void handle_list_key(UISetupPage &page, uint32_t key); + void refresh_status(UISetupPage &page); + void toggle_power(UISetupPage &page); + void toggle_named_only(UISetupPage &page); + void toggle_discoverable(UISetupPage &page); + void start_scan_timer(UISetupPage &page); + void stop_scan_timer(); + void refresh_devices(); + void do_scan(UISetupPage &page); + +private: + void alias_update_display(); + void rebuild_rows(); + void show_action(UISetupPage &page, const char *message, uint32_t color = 0x58A6FF); + void activate_selected(UISetupPage &page); + void remove_selected(UISetupPage &page); + void start_failure_feedback(UISetupPage &page); + void stop_failure_feedback(); + void suspend_scan_discovery(); + void resume_scan_discovery(); + static void scan_timer_cb(lv_timer_t *timer) noexcept; + static void failure_feedback_timer_cb(lv_timer_t *timer) noexcept; + static void failure_feedback_screen_delete_cb(lv_event_t *event) noexcept; + static void copy_string(char *destination, size_t destination_size, const std::string &source); + static bool decode_status(const std::string &data, cp0_bt_status_t &status); + static int decode_devices(const std::string &data, cp0_bt_device_t *out, int max_devices); + static int api_int(std::list args, int default_value = -1); + static cp0_bt_status_t get_status(); + static int set_power(int enabled); + static int set_alias(const std::string &alias); + static int set_discoverable(int enabled); + static int device_command(const char *command, const char *address); + static int device_list(const char *command, cp0_bt_device_t *out, int max_devices); + static int rfkill_blocked(); + + AsyncOperationLifecycle action_operation_; + AsyncOperationLifecycle sudo_operation_; + AsyncOperationLifecycle::Token action_token_; + cp0_bt_device_t devices_[CP0_BT_DEVICE_MAX]; + int device_count_ = 0; + BluetoothPageModel model_; + lv_timer_t *scan_timer_ = nullptr; + UISetupPage *scan_page_ = nullptr; + bool scan_callback_enabled_ = false; + lv_timer_t *failure_feedback_timer_ = nullptr; + UISetupPage *failure_feedback_page_ = nullptr; + bool discovery_active_ = false; + bool action_busy_ = false; + lv_obj_t *alias_input_lbl_ = nullptr; + lv_obj_t *alias_hint_lbl_ = nullptr; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/bluetooth_service.cpp b/projects/APPLaunch/main/ui/page_app/setting/bluetooth_service.cpp new file mode 100644 index 00000000..e500f0b3 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/bluetooth_service.cpp @@ -0,0 +1,347 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +#include +#include + +namespace setting { + +void Bluetooth::copy_string(char *destination, size_t destination_size, const std::string &source) +{ + if (!destination || destination_size == 0) + return; + std::snprintf(destination, destination_size, "%s", source.c_str()); +} + +bool Bluetooth::decode_status(const std::string &data, cp0_bt_status_t &status) +{ + BluetoothWireStatus decoded; + if (!BluetoothPageModel::decode_status_record(data, decoded)) return false; + cp0_bt_status_t parsed{}; + parsed.powered = decoded.powered ? 1 : 0; + copy_string(parsed.address, sizeof(parsed.address), decoded.address); + parsed.discoverable = decoded.discoverable ? 1 : 0; + copy_string(parsed.alias, sizeof(parsed.alias), decoded.alias); + status = parsed; + return true; +} + +int Bluetooth::decode_devices(const std::string &data, cp0_bt_device_t *out, int max_devices) +{ + if (!out || max_devices <= 0) + return 0; + int count = 0; + std::istringstream lines(data); + std::string line; + while (count < max_devices && std::getline(lines, line)) { + if (line.empty()) + continue; + BluetoothWireDevice decoded; + if (!BluetoothPageModel::decode_device_record(line, decoded)) continue; + cp0_bt_device_t device{}; + copy_string(device.address, sizeof(device.address), decoded.address); + device.rssi = decoded.rssi; + device.connected = decoded.connected ? 1 : 0; + device.paired = decoded.paired ? 1 : 0; + device.trusted = decoded.trusted ? 1 : 0; + copy_string(device.name, sizeof(device.name), decoded.name); + out[count++] = device; + } + return count; +} + +int Bluetooth::api_int(std::list args, int default_value) +{ + int result = default_value; + cp0_signal_bt_api(std::move(args), [&](int code, std::string) { result = code; }); + return result; +} + +cp0_bt_status_t Bluetooth::get_status() +{ + cp0_bt_status_t status{}; + cp0_signal_bt_api({"BtStatus"}, [&](int code, std::string data) { + if (code == 0) + decode_status(data, status); + }); + return status; +} + +int Bluetooth::set_power(int enabled) +{ + return api_int({"BtPower", std::to_string(enabled)}); +} + +int Bluetooth::rfkill_blocked() +{ + const char *argv[] = { + "/usr/sbin/rfkill", "--noheadings", "--output", "TYPE,SOFT", nullptr + }; + char output[512] = {}; + if (cp0_process_capture_argv(argv, output, sizeof(output)) != 0) + return -1; + + std::istringstream lines(output); + std::string line; + while (std::getline(lines, line)) { + std::istringstream fields(line); + std::string type; + std::string soft; + fields >> type >> soft; + if (type == "bluetooth") + return soft == "blocked" ? 1 : 0; + } + return -1; +} + +int Bluetooth::set_alias(const std::string &alias) +{ + return api_int({"BtAlias", alias}); +} + +int Bluetooth::set_discoverable(int enabled) +{ + return api_int({"BtDiscoverable", std::to_string(enabled)}); +} + +int Bluetooth::device_command(const char *command, const char *address) +{ + return api_int({command ? std::string(command) : std::string(), + address ? std::string(address) : std::string()}); +} + +int Bluetooth::device_list(const char *command, cp0_bt_device_t *out, int max_devices) +{ + int count = 0; + cp0_signal_bt_api({command ? std::string(command) : std::string(), std::to_string(max_devices)}, + [&](int code, std::string data) { + count = out && max_devices > 0 ? decode_devices(data, out, max_devices) : code; + }); + return count; +} + +void Bluetooth::refresh_status(UISetupPage &page) +{ + cp0_bt_status_t status = get_status(); + for (auto &menu : SetupPageAccess(page).menus()) { + if (menu.label != "Bluetooth") continue; + menu.sub_items[0].toggle_state = status.powered != 0; + model_.set_discoverable(status.discoverable != 0); + model_.set_alias(status.alias); + menu.sub_items[1].label = "Alias: " + model_.alias(); + menu.sub_items[2].toggle_state = model_.discoverable(); + break; + } +} + +void Bluetooth::toggle_power(UISetupPage &page) +{ + for (auto &menu : SetupPageAccess(page).menus()) { + if (menu.label != "Bluetooth") continue; + bool enabled = menu.sub_items[0].toggle_state; + if (!enabled) { + stop_scan_timer(); + set_power(0); + refresh_status(page); + break; + } + + if (sudo_operation_.active()) { + refresh_status(page); + break; + } + + int blocked = rfkill_blocked(); + if (blocked <= 0) { + if (blocked < 0) + std::fprintf(stderr, "Bluetooth: unable to query /usr/sbin/rfkill; trying BlueZ power on\n"); + set_power(1); + refresh_status(page); + break; + } + + struct Context { + UISetupPage *page; + AsyncOperationLifecycle::Token token; + }; + AsyncOperationLifecycle::Token token = sudo_operation_.begin(); + auto *context = new (std::nothrow) Context{&page, token}; + if (!context) { + sudo_operation_.abort(token); + refresh_status(page); + break; + } + + uint64_t request_id = 0; + int result = -1; + cp0_signal_sudo_argv_async({"/usr/sbin/rfkill", "unblock", "bluetooth"}, 60000, 30000, + [context](int result_code, int exit_code) { + std::unique_ptr owned(context); + if (!context->token.complete()) return; + if (result_code == CP0_SUDO_RESULT_SUCCESS && exit_code == 0) { + if (Bluetooth::set_power(1) != 0) + std::fprintf(stderr, "Bluetooth: BlueZ power on failed after rfkill unblock\n"); + } else { + std::fprintf(stderr, "Bluetooth: sudo /usr/sbin/rfkill unblock bluetooth failed\n"); + } + struct Completion { + UISetupPage *page; + AsyncOperationLifecycle::Token token; + }; + auto *completion = new (std::nothrow) Completion{context->page, context->token}; + if (!completion) + return; + if (lv_async_call([](void *user) noexcept { + std::unique_ptr completion(static_cast(user)); + try { + if (!bluetooth_async_completion_allowed( + completion->token.current(), completion->page)) return; + SetupPageAccess access(*completion->page); + access.bluetooth().refresh_status(*completion->page); + if (access.is_view(SetupViewState::SUB)) + access.build_sub_view(); + } catch (...) { + } + }, completion) != LV_RESULT_OK) { + delete completion; + } + }, [&](int code, uint64_t id) { result = code; request_id = id; }); + if (result != 0) { + delete context; + sudo_operation_.abort(token); + refresh_status(page); + } else { + sudo_operation_.activate(token, request_id); + } + break; + } +} + +void Bluetooth::toggle_named_only(UISetupPage &page) +{ + SetupPageAccess access(page); + for (auto &menu : access.menus()) { + if (menu.label != "Bluetooth") continue; + if (menu.sub_items.size() < 4) break; + const bool previous = access.config_get_int("bt_named_only", 1) != 0; + const bool desired = menu.sub_items[3].toggle_state; + if (!access.config_set_int("bt_named_only", desired ? 1 : 0) || + !access.config_save()) { + access.config_set_int("bt_named_only", previous ? 1 : 0); + access.config_save(); + menu.sub_items[3].toggle_state = previous; + model_.set_named_only(previous); + } else { + model_.set_named_only(desired); + } + break; + } + if (access.is_view(SetupViewState::BT_LIST)) + build_list(page); +} + +void Bluetooth::toggle_discoverable(UISetupPage &page) +{ + for (auto &menu : SetupPageAccess(page).menus()) { + if (menu.label != "Bluetooth") continue; + model_.set_discoverable(menu.sub_items[2].toggle_state); + if (set_discoverable(model_.discoverable() ? 1 : 0) != 0) { + model_.set_discoverable(!model_.discoverable()); + menu.sub_items[2].toggle_state = model_.discoverable(); + } + break; + } +} + +void Bluetooth::start_scan_timer(UISetupPage &page) +{ + stop_scan_timer(); + discovery_active_ = api_int({"BtDiscoveryStart"}, 0) == 0; + refresh_devices(); + build_list(page); + if (!discovery_active_) + return; + scan_page_ = &page; + scan_callback_enabled_ = true; + scan_timer_ = lv_timer_create(scan_timer_cb, 2500, this); + if (!scan_timer_) { + scan_callback_enabled_ = false; + scan_page_ = nullptr; + api_int({"BtDiscoveryStop"}, 0); + discovery_active_ = false; + } +} + +void Bluetooth::scan_timer_cb(lv_timer_t *timer) noexcept +{ + auto *bluetooth = static_cast(lv_timer_get_user_data(timer)); + if (!bluetooth || !bluetooth_scan_callback_allowed( + timer, bluetooth->scan_timer_, bluetooth->scan_page_, + bluetooth->scan_callback_enabled_)) + return; + try { + UISetupPage *page = bluetooth->scan_page_; + SetupPageAccess access(*page); + if (!access.is_view(SetupViewState::BT_LIST) || + bluetooth->model_.list_mode() != BluetoothListMode::SCAN) + return; + if (bluetooth->action_busy_) { + if (!bluetooth_scan_action_needs_watchdog_recovery( + bluetooth->action_busy_, bluetooth->action_operation_.active())) + return; + bluetooth->action_busy_ = false; + bluetooth->resume_scan_discovery(); + bluetooth->show_action(*page, "Bluetooth action failed", 0xFF4444); + } + bluetooth->refresh_devices(); + bluetooth->build_list(*page); + } catch (...) { + bluetooth->scan_callback_enabled_ = false; + } +} + +void Bluetooth::suspend_scan_discovery() +{ + if (!discovery_active_) + return; + api_int({"BtDiscoveryStop"}, 0); + discovery_active_ = false; +} + +void Bluetooth::resume_scan_discovery() +{ + if (discovery_active_ || !scan_timer_ || !scan_page_) + return; + discovery_active_ = api_int({"BtDiscoveryStart"}, 0) == 0; +} + +void Bluetooth::stop_scan_timer() +{ + scan_callback_enabled_ = false; + if (scan_timer_) { + lv_timer_delete(scan_timer_); + scan_timer_ = nullptr; + } + scan_page_ = nullptr; + suspend_scan_discovery(); +} + +void Bluetooth::refresh_devices() +{ + if (model_.list_mode() == BluetoothListMode::MANAGED) + device_count_ = device_list("BtConnectedList", devices_, CP0_BT_DEVICE_MAX); + else + device_count_ = device_list("BtList", devices_, CP0_BT_DEVICE_MAX); + if (device_count_ < 0) + device_count_ = 0; + if (device_count_ == 0) + model_.clear_selection(); +} + +void Bluetooth::do_scan(UISetupPage &page) +{ + enter_scan(page); +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/bluetooth_view.cpp b/projects/APPLaunch/main/ui/page_app/setting/bluetooth_view.cpp new file mode 100644 index 00000000..cbc84d6c --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/bluetooth_view.cpp @@ -0,0 +1,190 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +namespace setting { + +void Bluetooth::build_alias_view(UISetupPage &page) +{ + lv_obj_t *container = SetupPageAccess(page).content_container(); + lv_obj_clean(container); + alias_input_lbl_ = nullptr; + alias_hint_lbl_ = nullptr; + + lv_obj_t *title = lv_label_create(container); + lv_label_set_text(title, "Bluetooth Name"); + lv_obj_set_pos(title, 10, 10); + lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, LV_PART_MAIN); + + lv_obj_t *label = lv_label_create(container); + lv_label_set_text(label, "Name:"); + lv_obj_set_pos(label, 10, 38); + lv_obj_set_style_text_color(label, lv_color_hex(0xCCCCCC), LV_PART_MAIN); + lv_obj_set_style_text_font(label, &lv_font_montserrat_12, LV_PART_MAIN); + + alias_input_lbl_ = lv_label_create(container); + lv_obj_set_pos(alias_input_lbl_, 64, 36); + lv_obj_set_width(alias_input_lbl_, 236); + lv_label_set_long_mode(alias_input_lbl_, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(alias_input_lbl_, lv_color_hex(0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_font(alias_input_lbl_, &lv_font_montserrat_14, LV_PART_MAIN); + alias_update_display(); + + alias_hint_lbl_ = lv_label_create(container); + lv_label_set_text(alias_hint_lbl_, "OK:set BS:del ESC:cancel"); + lv_obj_set_pos(alias_hint_lbl_, 10, 70); + lv_obj_set_style_text_color(alias_hint_lbl_, lv_color_hex(0x555555), LV_PART_MAIN); + lv_obj_set_style_text_font(alias_hint_lbl_, &lv_font_montserrat_10, LV_PART_MAIN); +} + +void Bluetooth::alias_update_display() +{ + if (!alias_input_lbl_) return; + const std::string display = model_.alias_input() + "_"; + lv_label_set_text(alias_input_lbl_, display.c_str()); +} + +void Bluetooth::build_list(UISetupPage &page) +{ + SetupPageAccess access(page); + const SetupLayout layout = access.layout(); + lv_obj_t *container = access.content_container(); + lv_obj_clean(container); + rebuild_rows(); + + const cp0_bt_status_t status = get_status(); + char title_text[96]; + snprintf(title_text, sizeof(title_text), "%s: %s %.24s", + model_.list_mode() == BluetoothListMode::SCAN ? "Scan" : "Connected", + status.powered ? "On" : "Off", status.address[0] ? status.address : "--"); + lv_obj_t *title = lv_label_create(container); + lv_label_set_text(title, title_text); + lv_obj_set_pos(title, 8, 2); + lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); + lv_obj_set_style_text_font( + title, + launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD), + LV_PART_MAIN); + access.apply_overflow(title, 8, layout.wifi_title_width, true); + + const auto &rows = model_.rows(); + if (rows.empty()) { + lv_obj_t *empty = lv_label_create(container); + if (!status.powered) + lv_label_set_text(empty, "Bluetooth is off. Enable Power first."); + else if (model_.list_mode() == BluetoothListMode::SCAN) + lv_label_set_text(empty, "Scanning..."); + else + lv_label_set_text(empty, "No connected devices."); + lv_obj_set_pos(empty, 8, 50); + lv_obj_set_width(empty, 300); + lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_color(empty, lv_color_hex(0x666666), LV_PART_MAIN); + lv_obj_set_style_text_font(empty, &lv_font_montserrat_12, LV_PART_MAIN); + } + + constexpr int list_y = 22; + constexpr int row_step = 20; + const int hint_y = layout.content_height - 14; + constexpr int list_bottom_gap = 8; + int visible_count = (hint_y - list_bottom_gap - list_y) / row_step; + if (visible_count < 1) visible_count = 1; + int offset = model_.list_selection() - visible_count / 2; + if (offset < 0) offset = 0; + if (offset > static_cast(rows.size()) - visible_count) + offset = static_cast(rows.size()) - visible_count; + if (offset < 0) offset = 0; + + for (int visible_index = 0; + visible_index < visible_count && visible_index + offset < static_cast(rows.size()); + ++visible_index) { + const int row_index = visible_index + offset; + const BluetoothListRow &row = rows[row_index]; + const int y = list_y + visible_index * row_step; + + if (row.is_header) { + lv_obj_t *header = lv_label_create(container); + lv_label_set_text(header, row.title); + lv_obj_set_pos(header, 8, y + 3); + lv_obj_set_style_text_color(header, lv_color_hex(0x888888), LV_PART_MAIN); + lv_obj_set_style_text_font( + header, + launcher_fonts().get("Montserrat-Bold.ttf", 10, LV_FREETYPE_FONT_STYLE_BOLD), + LV_PART_MAIN); + continue; + } + + const bool selected = row_index == model_.list_selection(); + const cp0_bt_device_t &device = devices_[row.device_index]; + if (selected) { + lv_obj_t *background = lv_obj_create(container); + lv_obj_set_size(background, layout.screen_width - 8, 20); + lv_obj_set_pos(background, 4, y); + lv_obj_set_style_radius(background, 2, LV_PART_MAIN); + lv_obj_set_style_bg_color(background, lv_color_hex(0x1F3A5F), LV_PART_MAIN); + lv_obj_set_style_bg_opa(background, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(background, 0, LV_PART_MAIN); + lv_obj_clear_flag(background, LV_OBJ_FLAG_SCROLLABLE); + } + + const uint32_t text_color = + device.connected ? 0x58A6FF : (selected ? 0xFFFFFF : 0xCCCCCC); + lv_obj_t *name = lv_label_create(container); + lv_label_set_text(name, device.name[0] ? device.name : device.address); + lv_obj_set_pos(name, 8, y + 1); + lv_obj_set_width(name, 150); + lv_label_set_long_mode(name, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(name, lv_color_hex(text_color), LV_PART_MAIN); + lv_obj_set_style_text_font(name, &lv_font_montserrat_12, LV_PART_MAIN); + + lv_obj_t *address = lv_label_create(container); + lv_label_set_text(address, device.address); + lv_obj_set_pos(address, 8, y + 12); + lv_obj_set_width(address, 190); + lv_label_set_long_mode(address, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(address, lv_color_hex(selected ? 0xBBBBBB : 0x777777), + LV_PART_MAIN); + lv_obj_set_style_text_font(address, &lv_font_montserrat_10, LV_PART_MAIN); + + char state_text[32]; + if (device.connected) + snprintf(state_text, sizeof(state_text), "Connected"); + else if (device.paired) + snprintf(state_text, sizeof(state_text), "Paired"); + else + snprintf(state_text, sizeof(state_text), "%d", device.rssi); + lv_obj_t *state = lv_label_create(container); + lv_label_set_text(state, state_text); + lv_obj_set_pos(state, 226, y + 4); + lv_obj_set_width(state, 88); + lv_label_set_long_mode(state, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_align(state, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN); + lv_obj_set_style_text_color(state, lv_color_hex(text_color), LV_PART_MAIN); + lv_obj_set_style_text_font(state, &lv_font_montserrat_10, LV_PART_MAIN); + } + + lv_obj_t *hint = lv_label_create(container); + lv_label_set_text(hint, model_.list_mode() == BluetoothListMode::SCAN + ? "OK:act R:restart ESC:back" + : "OK:disconnect D:remove ESC:back"); + lv_obj_set_pos(hint, 8, hint_y); + lv_obj_set_width(hint, 304); + lv_label_set_long_mode(hint, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); +} + +void Bluetooth::show_action(UISetupPage &page, const char *message, uint32_t color) +{ + lv_obj_t *container = SetupPageAccess(page).content_container(); + lv_obj_clean(container); + lv_obj_t *label = lv_label_create(container); + lv_label_set_text(label, message); + lv_obj_set_pos(label, 8, 60); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_text_font(label, &lv_font_montserrat_14, LV_PART_MAIN); + lv_refr_now(nullptr); +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/developer.cpp b/projects/APPLaunch/main/ui/page_app/setting/developer.cpp new file mode 100644 index 00000000..66b3674f --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/developer.cpp @@ -0,0 +1,212 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +#include + +namespace setting { + +Developer::Developer() = default; + +Developer::~Developer() +{ + shutdown(); +} + +void Developer::shutdown() +{ + const uint64_t request_id = async_operation_.shutdown(); + if (request_id) cp0_signal_sudo_cancel(request_id, nullptr); + stop_usb_guide_anims(); + if (pair_input_label_) + lv_obj_remove_event_cb_with_user_data( + pair_input_label_, view_object_delete_cb, this); + if (pair_hint_label_) + lv_obj_remove_event_cb_with_user_data( + pair_hint_label_, view_object_delete_cb, this); + pair_input_label_ = nullptr; + pair_hint_label_ = nullptr; + close_status(); +} + +void Developer::append(UISetupPage &p, std::vector &menu) +{ + UISetupPage *page = &p; + Developer *controller = this; + MenuItem m; + m.label = "Developer"; + // Pairing and authorization views are retained but intentionally hidden for now. + m.sub_items = { + {"ADB", true, false, [controller, page]() { controller->toggle_adb(*page); }}, + }; + m.on_enter = [controller, page]() { controller->refresh_adb_status(*page); }; + menu.push_back(m); +} + +void Developer::toggle_adb(UISetupPage &page) +{ + SetupPageAccess access(page); + MenuItem *menu = access.find_menu("Developer"); + if (!menu || menu->sub_items.empty()) return; + if (async_operation_.active()) { + menu->sub_items[0].toggle_state = !menu->sub_items[0].toggle_state; + return; + } + bool want_on = menu->sub_items[0].toggle_state; + const bool previous = !want_on; + const auto operation = async_operation_.begin(); + if (!operation) + return; + show_status(want_on ? "Enabling ADB" : "Disabling ADB", "Please wait...", Modal::BUSY); + struct Context { + Developer *developer; + UISetupPage *page; + AsyncOperationLifecycle::Token operation; + bool desired; + bool previous; + }; + auto ctx = std::shared_ptr( + new (std::nothrow) Context{this, &page, operation, want_on, previous}); + if (!ctx) { + async_operation_.abort(operation); + update_toggle(page, previous); + show_status("ADB update failed", "Out of memory", Modal::ERROR); + return; + } + uint64_t request_id = 0; + int rc = -1; + cp0_signal_system_admin_async({"AdbSet", want_on ? "1" : "0"}, 60000, 300000, + [ctx](int result_code, int exit_code) noexcept { + try { + cp0_sudo_result_t result = static_cast(result_code); + if (!developer_async_completion_allowed( + ctx->operation.complete(), ctx->developer, ctx->page)) return; + if (result == CP0_SUDO_RESULT_SUCCESS) { + ctx->developer->update_toggle(*ctx->page, ctx->desired); + ctx->developer->close_status(); + SetupPageAccess(*ctx->page).build_sub_view(); + return; + } + if (!ctx->developer->refresh_adb_status(*ctx->page)) + ctx->developer->update_toggle(*ctx->page, ctx->previous); + if (result == CP0_SUDO_RESULT_CANCELLED) { + ctx->developer->close_status(); + SetupPageAccess(*ctx->page).build_sub_view(); + } else { + ctx->developer->show_result_error(result, exit_code); + } + } catch (...) { + } + }, [&](int code, uint64_t id) { rc = code; request_id = id; }); + if (rc != 0) { + async_operation_.abort(operation); + update_toggle(page, previous); + show_status("ADB update failed", "Unable to start request", Modal::ERROR); + return; + } + async_operation_.activate(operation, request_id); +} + +bool Developer::refresh_adb_status(UISetupPage &page) +{ + MenuItem *menu = SetupPageAccess(page).find_menu("Developer"); + if (!menu || menu->sub_items.empty()) return false; + int rc = -1; + std::string out; + cp0_signal_process_api({"AdbStatus"}, + [&](int code, std::string data) { rc = code; out = std::move(data); }); + if (rc != 0) return false; + AdbStatus status = parse_adb_status(out.c_str()); + if (!status.valid) return false; + update_toggle(page, adb_state_after_failure(status, menu->sub_items[0].toggle_state)); + return true; +} + +void Developer::submit_pairing(UISetupPage &page) +{ + if (!adb_public_key_valid(model_.pair_key())) { + if (pair_hint_label_) { + lv_label_set_text(pair_hint_label_, "Invalid adbkey.pub public key"); + lv_obj_set_style_text_color(pair_hint_label_, lv_color_hex(0xEB5F5F), 0); + } + return; + } + run_admin_action(page, {"AdbAuthorize", model_.pair_key()}, "Pairing ADB host", + "Authorizing this key...", model_.enable_after_pair()); +} + +void Developer::clear_authorizations(UISetupPage &page) +{ + run_admin_action(page, {"AdbClearAuthorizations"}, "Clearing ADB keys", + "Revoking every host...", false); +} + +void Developer::run_admin_action(UISetupPage &page, std::list args, + const char *title, const char *detail, bool enable_after) +{ + const auto operation = async_operation_.begin(); + if (!operation) + return; + show_status(title, detail, Modal::BUSY); + struct Context { Developer *self; UISetupPage *page; AsyncOperationLifecycle::Token operation; bool enable; }; + auto ctx = std::shared_ptr( + new (std::nothrow) Context{this, &page, operation, enable_after}); + if (!ctx) { + async_operation_.abort(operation); + show_status("ADB authorization failed", "Out of memory", Modal::ERROR); + return; + } + uint64_t request_id = 0; + int rc = -1; + cp0_signal_system_admin_async(std::move(args), 60000, 30000, + [ctx](int result_code, int exit_code) { + if (!ctx->operation.complete()) return; + auto result = static_cast(result_code); + if (result != CP0_SUDO_RESULT_SUCCESS) { + ctx->self->show_result_error(result, exit_code); + return; + } + ctx->self->model_.clear_pairing(); + ctx->self->close_status(); + SetupPageAccess access(*ctx->page); + access.set_view(SetupViewState::SUB); + ctx->self->refresh_adb_status(*ctx->page); + access.build_sub_view(); + if (ctx->enable) { + MenuItem *menu = access.find_menu("Developer"); + if (menu && !menu->sub_items.empty()) menu->sub_items[0].toggle_state = true; + ctx->self->toggle_adb(*ctx->page); + } + }, [&](int code, uint64_t id) { rc = code; request_id = id; }); + if (rc != 0) { + async_operation_.abort(operation); + show_status("ADB authorization failed", "Unable to start request", Modal::ERROR); + return; + } + async_operation_.activate(operation, request_id); +} + +void Developer::update_toggle(UISetupPage &page, bool enabled) +{ + SetupPageAccess access(page); + MenuItem *menu = access.find_menu("Developer"); + if (!menu || menu->sub_items.empty()) return; + menu->sub_items[0].toggle_state = enabled; +} + +void Developer::show_result_error(cp0_sudo_result_t result, int exit_code) +{ + const char *reason = "Command failed"; + switch (classify_privileged_result(static_cast(result))) { + case PrivilegedResultKind::AUTH_FAILED: reason = "Authentication failed"; break; + case PrivilegedResultKind::CANCELLED: reason = "Request cancelled"; break; + case PrivilegedResultKind::TIMED_OUT: reason = "Request timed out"; break; + case PrivilegedResultKind::EXEC_FAILED: + reason = exit_code < 0 ? "Unable to start command" : "Command returned an error"; + break; + default: break; + } + show_status("ADB update failed", reason, Modal::ERROR); +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/developer.hpp b/projects/APPLaunch/main/ui/page_app/setting/developer.hpp new file mode 100644 index 00000000..0f424415 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/developer.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include "../../model/adb_state.hpp" +#include "menu_types.hpp" +#include "../../model/developer_page_model.hpp" +#include "../../model/async_operation_lifecycle.hpp" + +#include "cp0_lvgl_app.h" +#include + +#include +#include +#include +#include + +class UISetupPage; + +namespace setting { + +class Developer +{ +public: + Developer(); + ~Developer(); + void shutdown(); + + void append(UISetupPage &page, std::vector &menu); + void toggle_adb(UISetupPage &page); + bool refresh_adb_status(UISetupPage &page); + void enter_usb_guide(UISetupPage &page, bool enabling); + void build_usb_guide_view(UISetupPage &page); + void stop_usb_guide_anims(); + void handle_usb_guide_key(UISetupPage &page, uint32_t key); + void handle_status_key(UISetupPage &page, uint32_t key); + void enter_pair_view(UISetupPage &page, bool enable_after_pair = false); + void enter_revoke_view(UISetupPage &page); + void build_authorizations_view(UISetupPage &page); + void handle_authorizations_key(UISetupPage &page, uint32_t key); + void handle_pair_key(UISetupPage &page, uint32_t key); + void clear_authorizations(UISetupPage &page); + bool status_active() const { return status_overlay_ != nullptr; } + +private: + enum class Modal + { + NONE, + BUSY, + ERROR + }; + + static lv_obj_t *guide_chip(lv_obj_t *parent, + int x, + int y, + int width, + int height, + uint32_t background, + uint32_t border, + int radius, + int border_width); + static lv_obj_t *guide_label(lv_obj_t *parent, + int x, + int y, + const char *text, + uint32_t color, + const lv_font_t *font); + void show_status(const char *title, const char *detail, Modal modal); + void close_status(); + static void status_overlay_delete_cb(lv_event_t *event) noexcept; + static void view_object_delete_cb(lv_event_t *event) noexcept; + void update_toggle(UISetupPage &page, bool enabled); + void show_result_error(cp0_sudo_result_t result, int exit_code); + void submit_pairing(UISetupPage &page); + void run_admin_action(UISetupPage &page, + std::list args, + const char *title, + const char *detail, + bool enable_after); + + AsyncOperationLifecycle async_operation_; + DeveloperOverlayLifecycleModel overlay_lifecycle_; + Modal modal_ = Modal::NONE; + lv_obj_t *status_overlay_ = nullptr; + bool usb_guide_enabling_ = true; + lv_obj_t *usb_guide_knob_ = nullptr; + DeveloperPageModel model_; + lv_obj_t *pair_input_label_ = nullptr; + lv_obj_t *pair_hint_label_ = nullptr; + std::vector authorizations_; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/developer_view.cpp b/projects/APPLaunch/main/ui/page_app/setting/developer_view.cpp new file mode 100644 index 00000000..8fe74634 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/developer_view.cpp @@ -0,0 +1,398 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +namespace setting { + +void Developer::enter_pair_view(UISetupPage &page, bool enable_after_pair) +{ + model_.begin_pairing(enable_after_pair); + SetupPageAccess access(page); + access.set_view(SetupViewState::ADB_PAIR); + lv_obj_t *cont = access.content_container(); + if (!cont) { + model_.clear_pairing(); + access.set_view(SetupViewState::SUB); + return; + } + lv_obj_clean(cont); + pair_input_label_ = nullptr; + pair_hint_label_ = nullptr; + DeveloperPairViewBuildContract build; + auto rollback = [&]() { + lv_obj_clean(cont); + pair_input_label_ = nullptr; + pair_hint_label_ = nullptr; + model_.clear_pairing(); + access.set_view(SetupViewState::SUB); + access.build_sub_view(); + }; + + if (!guide_label(cont, 8, 5, "Pair ADB host", 0xFFFFFF, + launcher_fonts().get("Montserrat-Bold.ttf", 14, + LV_FREETYPE_FONT_STYLE_BOLD))) { + rollback(); + return; + } + build.label_created(); + if (!guide_label(cont, 8, 28, "Paste the host's adbkey.pub:", 0xAAAAAA, + &lv_font_montserrat_10)) { + rollback(); + return; + } + build.label_created(); + pair_input_label_ = guide_label(cont, 8, 48, "_", 0xFFFFFF, &lv_font_montserrat_10); + if (!pair_input_label_) { + rollback(); + return; + } + build.label_created(); + lv_obj_add_event_cb(pair_input_label_, view_object_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_width(pair_input_label_, 304); + lv_label_set_long_mode(pair_input_label_, LV_LABEL_LONG_CLIP); + pair_hint_label_ = guide_label(cont, 8, 82, "OK: authorize ESC: cancel", 0x777777, + &lv_font_montserrat_10); + if (!pair_hint_label_) { + rollback(); + return; + } + build.label_created(); + lv_obj_add_event_cb(pair_hint_label_, view_object_delete_cb, LV_EVENT_DELETE, this); + if (!build.ready()) rollback(); +} + +void Developer::enter_revoke_view(UISetupPage &page) +{ + refresh_adb_status(page); + model_.reset_authorization_selection(authorizations_.size()); + SetupPageAccess(page).set_view(SetupViewState::ADB_AUTHORIZATIONS); + build_authorizations_view(page); +} + +void Developer::build_authorizations_view(UISetupPage &page) +{ + SetupPageAccess access(page); + lv_obj_t *cont = access.content_container(); + if (!cont) return; + lv_obj_clean(cont); + guide_label(cont, 8, 3, "Authorized ADB hosts", 0xFFFFFF, + launcher_fonts().get("Montserrat-Bold.ttf", 13, LV_FREETYPE_FONT_STYLE_BOLD)); + if (authorizations_.empty()) { + guide_label(cont, 8, 48, "No authorized hosts", 0x888888, &lv_font_montserrat_12); + } else { + const std::size_t selected = model_.authorization_selection(); + const std::size_t first = model_.authorization_window_start(); + const std::size_t last = std::min(authorizations_.size(), first + 3); + for (std::size_t i = first; i < last; ++i) { + const int y = 27 + static_cast(i - first) * 28; + if (i == selected) + guide_chip(cont, 4, y - 2, 312, 25, 0x2A2A2A, 0x444444, 2, 0); + std::string label = authorizations_[i].label; + if (label.size() > 25) label.resize(25); + guide_label(cont, 10, y, label.c_str(), + i == selected ? 0xFFFFFF : 0xAAAAAA, + &lv_font_montserrat_10); + std::string short_id = authorizations_[i].fingerprint.substr(0, 12); + guide_label(cont, 218, y, short_id.c_str(), 0x666666, &lv_font_montserrat_10); + } + } + guide_label(cont, 8, access.content_height() - 15, + authorizations_.empty() ? "ESC: back" : "OK: revoke ESC: back", + 0x777777, &lv_font_montserrat_10); +} + +void Developer::handle_authorizations_key(UISetupPage &page, uint32_t key) +{ + if (key == KEY_ESC || key == KEY_LEFT) { + SetupPageAccess access(page); + access.set_view(SetupViewState::SUB); + access.build_sub_view(); + return; + } + if (key == KEY_UP && model_.select_previous_authorization()) { + build_authorizations_view(page); + } else if (key == KEY_DOWN && model_.select_next_authorization(authorizations_.size())) { + build_authorizations_view(page); + } else if ((key == KEY_ENTER || key == KEY_RIGHT) && !authorizations_.empty()) { + const std::string fingerprint = + authorizations_[model_.authorization_selection()].fingerprint; + UISetupPage *page_ptr = &page; + SetupPageAccess(page).confirm("Revoke selected ADB host?", [this, page_ptr, fingerprint]() { + run_admin_action(*page_ptr, {"AdbRevoke", fingerprint}, "Revoking ADB host", + "Removing this key...", false); + }); + } +} + +void Developer::handle_pair_key(UISetupPage &page, uint32_t key) +{ + if (key == KEY_ESC || key == KEY_LEFT) { + model_.clear_pairing(); + SetupPageAccess access(page); + access.set_view(SetupViewState::SUB); + access.build_sub_view(); + return; + } + if (key == KEY_ENTER) { + submit_pairing(page); + return; + } + if (key == KEY_BACKSPACE) { + model_.erase_pairing_character(); + } else { + const std::string_view utf8 = SetupPageAccess(page).current_utf8(); + if (!utf8.empty()) model_.append_pairing_text(utf8); + } + if (pair_input_label_) { + const std::string shown = model_.pairing_preview(); + lv_label_set_text(pair_input_label_, shown.c_str()); + } +} + +void Developer::show_status(const char *title_text, const char *detail_text, Modal modal) +{ + close_status(); + lv_obj_t *layer = lv_layer_top(); + if (!layer) return; + status_overlay_ = lv_obj_create(layer); + if (!status_overlay_) return; + overlay_lifecycle_.open(); + lv_obj_add_event_cb(status_overlay_, status_overlay_delete_cb, LV_EVENT_DELETE, this); + modal_ = modal; + lv_obj_remove_style_all(status_overlay_); + const auto layout = SetupPageAccess::fixed_layout(); + lv_obj_set_size(status_overlay_, layout.screen_width, layout.screen_height + 20); + lv_obj_set_style_bg_color(status_overlay_, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(status_overlay_, LV_OPA_60, 0); + lv_obj_clear_flag(status_overlay_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t *box = guide_chip(status_overlay_, 35, 69, 250, 82, 0x1A1A2E, + modal == Modal::ERROR ? 0xCC5555 : 0x3A5A8A, 6, 1); + if (!box) { + close_status(); + return; + } + lv_obj_t *title = guide_label( + box, 10, 10, title_text, 0xFFFFFF, + launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD)); + if (!title) { + close_status(); + return; + } + lv_obj_set_width(title, 230); + lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_t *detail = guide_label(box, 10, 39, detail_text, 0xAAAAAA, &lv_font_montserrat_10); + if (!detail) { + close_status(); + return; + } + lv_obj_set_width(detail, 230); + lv_obj_set_style_text_align(detail, LV_TEXT_ALIGN_CENTER, 0); + if (modal == Modal::ERROR) { + lv_obj_t *hint = guide_label(box, 10, 64, "OK / ESC: close", 0x777777, + &lv_font_montserrat_10); + if (!hint) { + close_status(); + return; + } + lv_obj_set_width(hint, 230); + lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0); + } + lv_obj_move_foreground(status_overlay_); +} + +void Developer::close_status() +{ + if (status_overlay_) { + lv_obj_t *overlay = status_overlay_; + status_overlay_ = nullptr; + lv_obj_remove_event_cb_with_user_data( + overlay, status_overlay_delete_cb, this); + lv_obj_del(overlay); + } + modal_ = Modal::NONE; + overlay_lifecycle_.close(); +} + +void Developer::status_overlay_delete_cb(lv_event_t *event) noexcept +{ + try { + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (!self || !setting::developer_delete_callback_allowed( + deleted, lv_event_get_current_target(event), self->status_overlay_)) + return; + self->status_overlay_ = nullptr; + self->modal_ = Modal::NONE; + self->overlay_lifecycle_.close(); + } catch (...) { + } +} + +void Developer::view_object_delete_cb(lv_event_t *event) noexcept +{ + try { + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + auto *current = static_cast(lv_event_get_current_target(event)); + if (!self || !deleted || deleted != current) return; + if (setting::developer_delete_callback_allowed( + deleted, current, self->usb_guide_knob_)) + self->usb_guide_knob_ = nullptr; + if (setting::developer_delete_callback_allowed( + deleted, current, self->pair_input_label_)) + self->pair_input_label_ = nullptr; + if (setting::developer_delete_callback_allowed( + deleted, current, self->pair_hint_label_)) + self->pair_hint_label_ = nullptr; + } catch (...) { + } +} + +void Developer::handle_status_key(UISetupPage &page, uint32_t key) +{ + if (modal_ == Modal::BUSY) return; + if (key == KEY_ENTER || key == KEY_ESC || key == KEY_LEFT) { + close_status(); + SetupPageAccess(page).build_sub_view(); + } +} + +void Developer::enter_usb_guide(UISetupPage &page, bool enabling) +{ + usb_guide_enabling_ = enabling; + build_usb_guide_view(page); +} + +lv_obj_t *Developer::guide_chip(lv_obj_t *parent, int x, int y, int w, int h, + uint32_t bg, uint32_t border, int radius, int border_w) +{ + if (!parent) return nullptr; + lv_obj_t *o = lv_obj_create(parent); + if (!o) return nullptr; + lv_obj_set_pos(o, x, y); + lv_obj_set_size(o, w, h); + lv_obj_set_style_radius(o, radius, LV_PART_MAIN); + lv_obj_set_style_bg_color(o, lv_color_hex(bg), LV_PART_MAIN); + lv_obj_set_style_bg_opa(o, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_color(o, lv_color_hex(border), LV_PART_MAIN); + lv_obj_set_style_border_width(o, border_w, LV_PART_MAIN); + lv_obj_set_style_pad_all(o, 0, LV_PART_MAIN); + lv_obj_clear_flag(o, LV_OBJ_FLAG_SCROLLABLE); + return o; +} + +lv_obj_t *Developer::guide_label(lv_obj_t *parent, int x, int y, const char *txt, + uint32_t color, const lv_font_t *font) +{ + if (!parent) return nullptr; + lv_obj_t *label = lv_label_create(parent); + if (!label) return nullptr; + lv_label_set_text(label, txt); + lv_obj_set_pos(label, x, y); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_text_font(label, font, LV_PART_MAIN); + return label; +} + +void Developer::build_usb_guide_view(UISetupPage &page) +{ + SetupPageAccess access(page); + access.set_view(SetupViewState::USB_GUIDE); + stop_usb_guide_anims(); + lv_obj_t *cont = access.content_container(); + if (!cont) return; + lv_obj_clean(cont); + const bool en = usb_guide_enabling_; + const uint32_t C_GREEN = 0x46DC87, C_YEL = 0xF0C850, C_RED = 0xEB5F5F; + const uint32_t C_WHITE = 0xECECEC, C_GREY = 0x9A9AA0; + const lv_font_t *f_title = + launcher_fonts().get("Montserrat-Bold.ttf", 13, LV_FREETYPE_FONT_STYLE_BOLD); + const lv_font_t *f_msg = &lv_font_montserrat_10; + + guide_label(cont, 8, 2, + en ? "Enable ADB - switch USB to device" : "Disable ADB - switch USB to HUB", + C_WHITE, f_title ? f_title : &lv_font_montserrat_12); + guide_chip(cont, 86, 24, 146, 50, 0x282A30, 0x5A5C64, 6, 2); + guide_label(cont, 120, 28, "CardputerZero", C_GREY, f_msg); + guide_chip(cont, 218, 30, 12, 12, 0x101012, 0x5A5C64, 3, 2); + guide_chip(cont, 228, 32, 22, 8, 0xCDCDD2, 0xCDCDD2, 2, 0); + guide_chip(cont, 250, 34, 60, 4, 0x6A6C72, 0x6A6C72, 2, 0); + guide_label(cont, 232, 42, "USB-C", C_GREEN, f_msg); + guide_chip(cont, 24, 28, 32, 44, 0x1A1A1C, 0x5A5C64, 6, 2); + guide_chip(cont, 33, 33, 14, 34, 0x0E0E10, 0x0E0E10, 4, 0); + guide_label(cont, 26, 14, "HUB", en ? C_RED : C_GREEN, f_msg); + guide_label(cont, 28, 72, "USB", en ? C_GREEN : C_GREY, f_msg); + const int thumb_top = 34, thumb_bot = 54; + usb_guide_knob_ = + guide_chip(cont, 32, en ? thumb_top : thumb_bot, 16, 10, C_GREEN, 0x2A6F49, 3, 1); + if (usb_guide_knob_) + lv_obj_add_event_cb(usb_guide_knob_, view_object_delete_cb, LV_EVENT_DELETE, this); + + const int y = 80; + if (en) { + guide_label(cont, 8, y, "1 Slide LEFT switch HUB -> USB", C_WHITE, f_msg); + guide_label(cont, 8, y + 15, "2 USB hub & peripherals turn OFF", C_YEL, f_msg); + guide_label(cont, 8, y + 30, "3 Cable -> top-right USB-C port", C_GREEN, f_msg); + } else { + guide_label(cont, 8, y, "1 Slide LEFT switch USB -> HUB", C_WHITE, f_msg); + guide_label(cont, 8, y + 15, "2 USB hub & peripherals come back", C_GREEN, f_msg); + guide_label(cont, 8, y + 30, "3 Reboot to apply the change", C_YEL, f_msg); + } + guide_label(cont, 8, access.content_height() - 16, "OK: reboot now ESC: later", C_GREY, + &lv_font_montserrat_10); + + if (!usb_guide_knob_) return; + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, usb_guide_knob_); + lv_anim_set_values(&animation, en ? thumb_top : thumb_bot, en ? thumb_bot : thumb_top); + lv_anim_set_time(&animation, 650); + lv_anim_set_playback_time(&animation, 650); + lv_anim_set_repeat_count(&animation, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&animation, lv_anim_path_ease_in_out); + lv_anim_set_exec_cb(&animation, + [](void *var, int32_t value) { lv_obj_set_y((lv_obj_t *)var, value); }); + lv_anim_start(&animation); +} + +void Developer::stop_usb_guide_anims() +{ + if (usb_guide_knob_) { + lv_obj_t *knob = usb_guide_knob_; + usb_guide_knob_ = nullptr; + lv_anim_del(knob, nullptr); + lv_obj_remove_event_cb_with_user_data( + knob, view_object_delete_cb, this); + } +} + +void Developer::handle_usb_guide_key(UISetupPage &page, uint32_t key) +{ + switch (key) { + case KEY_ENTER: + case KEY_RIGHT: { + stop_usb_guide_anims(); + lv_obj_t *cont = SetupPageAccess(page).content_container(); + if (!cont) break; + lv_obj_clean(cont); + lv_obj_t *label = lv_label_create(cont); + if (!label) break; + lv_label_set_text(label, "Rebooting..."); + lv_obj_center(label); + cp0_signal_process_api({"Reboot"}, nullptr); + break; + } + case KEY_ESC: + case KEY_LEFT: { + stop_usb_guide_anims(); + SetupPageAccess access(page); + access.set_view(SetupViewState::SUB); + access.build_sub_view(); + break; + } + default: + break; + } +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/info.cpp b/projects/APPLaunch/main/ui/page_app/setting/info.cpp new file mode 100644 index 00000000..1d7f6e4a --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/info.cpp @@ -0,0 +1,163 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" +#include "../../model/setup_info_timer_contract.hpp" + +#include + +namespace setting { + +Info::~Info() +{ + stop_timer(); + reset_visible_labels(); +} + +void Info::append(UISetupPage &p, std::vector &menu) +{ + UISetupPage *page = &p; + Info *controller = this; + MenuItem m; + m.label = "Info"; + m.sub_items = { + {"Battery: --%", false, false, nullptr}, + {"Temp: --C", false, false, nullptr}, + {"Current: --mA", false, false, nullptr}, + {"Voltage: --V", false, false, nullptr}, + {"BQ Calibrate", false, false, [controller, page]() { controller->enter_bq_calibrate(*page); }}, + }; + m.on_enter = [controller, page]() { controller->refresh_values(*page); controller->start_timer(*page); }; + menu.push_back(m); +} + +void Info::refresh_values(UISetupPage &page) +{ + MenuItem *menu = SetupPageAccess(page).find_menu("Info"); + if (menu) { + int result_code = -1; + std::string response; + cp0_signal_bq27220_api({"Read"}, [&](int code, std::string data) { + result_code = code; + response = std::move(data); + }); + model_.update(result_code, response); + const auto &labels = model_.labels(); + const std::size_t count = std::min(labels.size(), menu->sub_items.size()); + for (std::size_t index = 0; index < count; ++index) + menu->sub_items[index].label = labels[index]; + } + if (SetupPageAccess(page).is_view(SetupViewState::SUB)) refresh_visible_labels(page); +} + +void Info::reset_visible_labels() +{ + for (int i = 0; i < 4; ++i) { + if (sub_labels_[i]) + lv_obj_remove_event_cb_with_user_data( + sub_labels_[i], visible_label_delete_cb, this); + sub_labels_[i] = nullptr; + sub_label_focused_[i] = false; + visible_text_[i].clear(); + } +} + +void Info::track_visible_label(int index, lv_obj_t *label, bool focused, const std::string &text) +{ + if (index < 0 || index >= 4) + return; + + if (sub_labels_[index]) + lv_obj_remove_event_cb_with_user_data( + sub_labels_[index], visible_label_delete_cb, this); + sub_labels_[index] = label; + if (label) + lv_obj_add_event_cb(label, visible_label_delete_cb, LV_EVENT_DELETE, this); + sub_label_focused_[index] = focused; + visible_text_[index] = text; +} + +void Info::refresh_visible_labels(UISetupPage &page) +{ + SetupPageAccess access(page); + MenuItem *item = access.selected_menu(); + if (!item || item->label != "Info") + return; + + for (int i = 0; i < 4 && i < static_cast(item->sub_items.size()); ++i) { + lv_obj_t *lbl = sub_labels_[i]; + if (!lbl) + continue; + + const char *new_text = item->sub_items[i].label.c_str(); + if (visible_text_[i] == new_text) + continue; + + lv_label_set_text(lbl, new_text); + visible_text_[i] = new_text; + const auto layout = access.layout(); + access.apply_overflow_centered(lbl, layout.sub_center_x, + sub_label_focused_[i] ? 80 : layout.value_box_width, sub_label_focused_[i]); + } +} + +void Info::start_timer(UISetupPage &page) +{ + stop_timer(); + timer_page_ = &page; + timer_.start( + [this] { + return lv_timer_create(timer_cb, 1000, this); + }, [](lv_timer_t *timer) { lv_timer_delete(timer); }); + if (!timer_.active()) timer_page_ = nullptr; +} + +void Info::timer_cb(lv_timer_t *timer) noexcept +{ + auto *info = static_cast(lv_timer_get_user_data(timer)); + if (!info || !setup_info_timer_callback_ready( + timer, info->timer_.current(timer), info->timer_page_)) return; + try { + SetupPageAccess access(*info->timer_page_); + if (access.is_view(SetupViewState::SUB)) + access.info().refresh_values(*info->timer_page_); + } catch (...) { + info->stop_timer(); + } +} + +void Info::stop_timer() +{ + timer_.stop(); + timer_page_ = nullptr; +} + +void Info::enter_bq_calibrate(UISetupPage &page) +{ + SetupPageAccess(page).enter_value( + "BQ Calib", {"Enter CAL", "CC Offset", "Board Offset", "Exit CAL"}, 0); +} + +void Info::apply_bq_calibrate(UISetupPage &page) +{ + const int selection = SetupPageAccess(page).value_selection(); + if (selection < 0 || selection > 3) return; + cp0_signal_bq27220_api( + {"Calibrate", std::to_string(selection)}, nullptr); +} + +void Info::visible_label_delete_cb(lv_event_t *event) noexcept +{ + if (!event || !setup_info_label_delete_callback_allowed( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + lv_obj_t *deleted = static_cast(lv_event_get_target(event)); + for (int index = 0; index < 4; ++index) { + if (self->sub_labels_[index] != deleted) continue; + self->sub_labels_[index] = nullptr; + self->sub_label_focused_[index] = false; + self->visible_text_[index].clear(); + } +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/info.hpp b/projects/APPLaunch/main/ui/page_app/setting/info.hpp new file mode 100644 index 00000000..8091838b --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/info.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "menu_types.hpp" +#include "../../model/setup_info_model.hpp" +#include "../../model/page_timer_lifecycle.hpp" + +#include + +#include +#include + +class UISetupPage; + +namespace setting { + +class Info +{ +public: + ~Info(); + void append(UISetupPage &page, std::vector &menu); + void refresh_values(UISetupPage &page); + void start_timer(UISetupPage &page); + void stop_timer(); + void enter_bq_calibrate(UISetupPage &page); + void apply_bq_calibrate(UISetupPage &page); + void reset_visible_labels(); + void track_visible_label(int index, lv_obj_t *label, bool focused, const std::string &text); + void refresh_visible_labels(UISetupPage &page); + +private: + static void timer_cb(lv_timer_t *timer) noexcept; + static void visible_label_delete_cb(lv_event_t *event) noexcept; + + PageTimerLifecycle timer_; + UISetupPage *timer_page_ = nullptr; + lv_obj_t *sub_labels_[4] = {}; + bool sub_label_focused_[4] = {}; + std::string visible_text_[4]; + SetupInfoModel model_; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/info_rtc.cpp b/projects/APPLaunch/main/ui/page_app/setting/info_rtc.cpp deleted file mode 100644 index 162e7fcb..00000000 --- a/projects/APPLaunch/main/ui/page_app/setting/info_rtc.cpp +++ /dev/null @@ -1,524 +0,0 @@ -#define APP_PAGE_IMPLEMENTATION_UNIT -#include "../ui_app_setup.hpp" - -namespace setting { - -void Info::append(UISetupPage &p, std::vector &menu) -{ - UISetupPage *page = &p; - MenuItem m; - m.label = "Info"; - m.sub_items = { - {"Battery: --%", false, false, nullptr}, - {"Temp: --C", false, false, nullptr}, - {"Current: --mA", false, false, nullptr}, - {"Voltage: --V", false, false, nullptr}, - {"BQ Calibrate", false, false, [page]() { page->info_.enter_bq_calibrate(*page); }}, - }; - m.on_enter = [page]() { page->info_.refresh_values(*page); page->info_.start_timer(*page); }; - menu.push_back(m); -} - -void Info::refresh_values(UISetupPage &page) -{ - for (auto &m : page.menu_items_) { - if (m.label != "Info") continue; - cp0_battery_info_t bat{}; - cp0_signal_bq27220_api({"Read"}, [&](int code, std::string data) { - cp0_battery_info_t parsed{}; - if (code == 0 && std::sscanf(data.c_str(), "%d,%d,%d,%d,%d,%d,%d,%d,%d", - &parsed.voltage_mv, &parsed.current_ma, &parsed.temperature_c10, &parsed.soc, - &parsed.remain_mah, &parsed.full_mah, &parsed.flags, &parsed.avg_current_ma, - &parsed.valid) == 9) - bat = parsed; - }); - char buf[64]; - snprintf(buf, sizeof(buf), "Battery: %d%%", bat.valid ? bat.soc : 0); - m.sub_items[0].label = buf; - snprintf(buf, sizeof(buf), "Temp: %.1fC", bat.valid ? bat.temperature_c10 / 10.0 : 0.0); - m.sub_items[1].label = buf; - if (bat.valid && bat.current_ma != INT32_MIN) - snprintf(buf, sizeof(buf), "Current: %dmA", bat.current_ma); - else - snprintf(buf, sizeof(buf), "Current: --mA"); - m.sub_items[2].label = buf; - snprintf(buf, sizeof(buf), "Voltage: %.2fV", bat.valid ? bat.voltage_mv / 1000.0 : 0.0); - m.sub_items[3].label = buf; - break; - } - if (page.view_state_ == UISetupPage::ViewState::SUB) refresh_visible_labels(page); -} - -void Info::reset_visible_labels() -{ - for (int i = 0; i < 4; ++i) { - sub_labels_[i] = nullptr; - sub_label_focused_[i] = false; - visible_text_[i].clear(); - } -} - -void Info::track_visible_label(int index, lv_obj_t *label, bool focused, const std::string &text) -{ - if (index < 0 || index >= 4) - return; - - sub_labels_[index] = label; - sub_label_focused_[index] = focused; - visible_text_[index] = text; -} - -void Info::refresh_visible_labels(UISetupPage &page) -{ - if (page.selected_idx_ < 0 || page.selected_idx_ >= (int)page.menu_items_.size()) - return; - - MenuItem &item = page.menu_items_[page.selected_idx_]; - if (item.label != "Info") - return; - - for (int i = 0; i < 4 && i < (int)item.sub_items.size(); ++i) { - lv_obj_t *lbl = sub_labels_[i]; - if (!lbl) - continue; - - const char *new_text = item.sub_items[i].label.c_str(); - if (visible_text_[i] == new_text) - continue; - - lv_label_set_text(lbl, new_text); - visible_text_[i] = new_text; - page.apply_overflow_centered(lbl, UISetupPage::SUB_CENTER_X, - sub_label_focused_[i] ? 80 : UISetupPage::VALUE_BOX_W, - sub_label_focused_[i]); - } -} - -void Info::start_timer(UISetupPage &page) -{ - stop_timer(); - timer_ = lv_timer_create([](lv_timer_t *t) { - UISetupPage *self = (UISetupPage *)lv_timer_get_user_data(t); - if (self && self->view_state_ == UISetupPage::ViewState::SUB) - self->info_.refresh_values(*self); - }, 1000, &page); -} - -void Info::stop_timer() -{ - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } -} - -void Info::enter_bq_calibrate(UISetupPage &page) -{ - page.val_title_ = "BQ Calib"; - page.val_options_ = {"Enter CAL", "CC Offset", "Board Offset", "Exit CAL"}; - page.val_sel_idx_ = 0; - page.view_state_ = UISetupPage::ViewState::VALUE_SELECT; - page.transition_enter_level(); -} - -void Info::apply_bq_calibrate(UISetupPage &page) -{ - cp0_signal_bq27220_api({"Calibrate", std::to_string(page.val_sel_idx_)}, nullptr); -} - -void RTC::append(UISetupPage &p, std::vector &menu) -{ - UISetupPage *page = &p; - MenuItem m; - m.label = "RTC"; - m.sub_items = { - {"NTP", true, true, [page]() { page->rtc_.toggle_ntp(*page); }}, - {"Year", false, false, [page]() { page->rtc_.enter_adjust(*page, 0); }}, - {"Month", false, false, [page]() { page->rtc_.enter_adjust(*page, 1); }}, - {"Day", false, false, [page]() { page->rtc_.enter_adjust(*page, 2); }}, - {"Hour", false, false, [page]() { page->rtc_.enter_adjust(*page, 3); }}, - {"Minute", false, false, [page]() { page->rtc_.enter_adjust(*page, 4); }}, - {"Second", false, false, [page]() { page->rtc_.enter_adjust(*page, 5); }}, - }; - m.on_enter = [page]() { page->rtc_.refresh_values(*page); }; - menu.push_back(m); -} - -RTC::RTC() : async_state_(std::make_shared()) {} - -RTC::~RTC() -{ - async_state_->alive = false; - if (async_state_->request_id) - cp0_signal_sudo_cancel(async_state_->request_id, nullptr); -} - -int RTC::days_in_month(int year, int month) -{ - return cp0_testable::days_in_month(year, month); -} - -void RTC::update_labels(UISetupPage &page) -{ - for (auto &m : page.menu_items_) { - if (m.label != "RTC") continue; - m.sub_items[0].toggle_state = ntp_on_; - char buf[32]; - const char *names[] = {"Year", "Month", "Day", "Hour", "Minute", "Second"}; - for (int i = 0; i < 6; ++i) { - snprintf(buf, sizeof(buf), "%s: %d", names[i], values_[i]); - m.sub_items[i + 1].label = buf; - } - break; - } -} - -void RTC::refresh_values(UISetupPage &page) -{ - int ntp = -1; - cp0_signal_osinfo_api({"NtpGet"}, [&](int code, std::string) { ntp = code; }); - ntp_available_ = ntp >= 0; - if (ntp_available_) - ntp_on_ = ntp == 1; - cp0_signal_osinfo_api({"LocalTime"}, [&](int code, std::string data) { - int parsed[6]{}; - if (code == 0 && std::sscanf(data.c_str(), "%d,%d,%d,%d,%d,%d", &parsed[0], &parsed[1], - &parsed[2], &parsed[3], &parsed[4], &parsed[5]) == 6) - std::copy(std::begin(parsed), std::end(parsed), std::begin(values_)); - }); - dirty_ = false; - update_labels(page); -} - -void RTC::toggle_ntp(UISetupPage &page) -{ - NtpToggleEligibility eligibility = ntp_toggle_eligibility( - async_state_->request_id != 0, dirty_, ntp_available_); - if (eligibility == NtpToggleEligibility::IN_FLIGHT) - return; - if (eligibility == NtpToggleEligibility::DIRTY) { - update_labels(page); - show_status("NTP unchanged", "Save or discard time edits first", Modal::ERROR); - return; - } - if (eligibility == NtpToggleEligibility::UNAVAILABLE) { - update_labels(page); - show_status("NTP unavailable", "Unable to read NTP status", Modal::ERROR); - return; - } - bool desired = ntp_on_; - for (auto &m : page.menu_items_) - if (m.label == "RTC") { desired = m.sub_items[0].toggle_state; break; } - ntp_previous_ = ntp_on_; - show_status("Updating NTP", "Please wait...", Modal::BUSY); - struct Context { RTC *rtc; UISetupPage *page; std::weak_ptr state; }; - auto *ctx = new (std::nothrow) Context{this, &page, async_state_}; - if (!ctx) { - ntp_on_ = ntp_rollback_value(ntp_previous_); - update_labels(page); - show_status("NTP failed", "Out of memory", Modal::ERROR); - return; - } - uint64_t request_id = 0; - int rc = -1; - cp0_signal_system_admin_async({"NtpSet", desired ? "1" : "0"}, 60000, 30000, - [ctx](int result_code, int exit_code) { - std::unique_ptr owned(ctx); - cp0_sudo_result_t result = static_cast(result_code); - auto state = ctx->state.lock(); - if (!state || !state->alive) return; - ctx->rtc->finish_request(); - if (result != CP0_SUDO_RESULT_SUCCESS) { - ctx->rtc->ntp_on_ = ntp_rollback_value(ctx->rtc->ntp_previous_); - ctx->rtc->update_labels(*ctx->page); - if (result == CP0_SUDO_RESULT_CANCELLED) { - ctx->rtc->close_write_confirm(); - ctx->page->build_sub_view(); - return; - } - ctx->rtc->show_result_error(result, exit_code, "NTP update"); - return; - } - int actual = -1; - cp0_signal_osinfo_api({"NtpGet"}, [&](int code, std::string) { actual = code; }); - if (actual < 0) { - ctx->rtc->ntp_available_ = false; - ctx->rtc->show_status("NTP unavailable", "Unable to read NTP status", Modal::ERROR); - } else { - ctx->rtc->ntp_available_ = true; - ctx->rtc->ntp_on_ = actual == 1; - ctx->rtc->close_write_confirm(); - } - ctx->rtc->update_labels(*ctx->page); - ctx->page->build_sub_view(); - }, [&](int code, uint64_t id) { rc = code; request_id = id; }); - if (rc != 0) { - delete ctx; - ntp_on_ = ntp_rollback_value(ntp_previous_); - update_labels(page); - show_status("NTP failed", "Unable to start request", Modal::ERROR); - } - else async_state_->request_id = request_id; -} - -void RTC::enter_adjust(UISetupPage &page, int field) -{ - if (ntp_on_) - return; - field_ = field; - const char *names[] = {"Year", "Month", "Day", "Hour", "Minute", "Second"}; - page.val_title_ = names[field]; - int cur = values_[field]; - int mins[] = {2000, 1, 1, 0, 0, 0}; - int maxs[] = {2099, 12, days_in_month(values_[0], values_[1]), 23, 59, 59}; - - page.val_options_.clear(); - for (int v = mins[field]; v <= maxs[field]; ++v) { - char buf[16]; - snprintf(buf, sizeof(buf), "%d", v); - page.val_options_.push_back(buf); - } - page.val_sel_idx_ = cur - mins[field]; - page.view_state_ = UISetupPage::ViewState::VALUE_SELECT; - page.transition_enter_level(); -} - -void RTC::apply_value(UISetupPage &page) -{ - int new_val = atoi(page.val_options_[page.val_sel_idx_].c_str()); - values_[field_] = new_val; - if ((field_ == 0 || field_ == 1) && values_[2] > days_in_month(values_[0], values_[1])) - values_[2] = days_in_month(values_[0], values_[1]); - dirty_ = true; - update_labels(page); -} - -void RTC::commit_to_hardware(UISetupPage &page) -{ - char timestamp[32]; - snprintf(timestamp, sizeof(timestamp), "%04d-%02d-%02d %02d:%02d:%02d", - values_[0], values_[1], values_[2], values_[3], values_[4], values_[5]); - show_status("Saving date & time", "Please wait...", Modal::BUSY); - struct Context { RTC *rtc; UISetupPage *page; std::weak_ptr state; }; - auto *ctx = new (std::nothrow) Context{this, &page, async_state_}; - if (!ctx) { show_status("Save failed", "Out of memory", Modal::ERROR); return; } - uint64_t request_id = 0; - int rc = -1; - cp0_signal_system_admin_async({"TimeSet", timestamp}, 60000, 30000, - [ctx](int result_code, int exit_code) { - std::unique_ptr owned(ctx); - cp0_sudo_result_t result = static_cast(result_code); - auto state = ctx->state.lock(); - if (!state || !state->alive) return; - ctx->rtc->finish_request(); - if (result == CP0_SUDO_RESULT_SUCCESS) { - ctx->rtc->dirty_ = false; - ctx->rtc->close_write_confirm(); - ctx->page->update_datetime_status(); - ctx->page->view_state_ = UISetupPage::ViewState::MAIN; - ctx->page->build_main_view(); - } else { - ctx->rtc->dirty_ = true; - if (result == CP0_SUDO_RESULT_CANCELLED) - ctx->rtc->close_write_confirm(); - else - ctx->rtc->show_result_error(result, exit_code, "Date/time save"); - } - }, [&](int code, uint64_t id) { rc = code; request_id = id; }); - if (rc != 0) { delete ctx; show_status("Save failed", "Unable to start request", Modal::ERROR); } - else async_state_->request_id = request_id; -} - -void RTC::finish_request() -{ - async_state_->request_id = 0; -} - -void RTC::show_result_error(cp0_sudo_result_t result, int exit_code, const char *operation) -{ - const char *reason = "Command failed"; - switch (classify_privileged_result(static_cast(result))) { - case PrivilegedResultKind::AUTH_FAILED: reason = "Authentication failed"; break; - case PrivilegedResultKind::CANCELLED: reason = "Request cancelled"; break; - case PrivilegedResultKind::TIMED_OUT: reason = "Request timed out"; break; - case PrivilegedResultKind::EXEC_FAILED: reason = exit_code ? "Command returned an error" : "Unable to start command"; break; - default: break; - } - char title[64]; - snprintf(title, sizeof(title), "%s failed", operation); - show_status(title, reason, Modal::ERROR); -} - -void RTC::show_status(const char *title_text, const char *detail_text, Modal modal) -{ - close_write_confirm(); - modal_ = modal; - confirm_overlay_ = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(confirm_overlay_); - lv_obj_set_size(confirm_overlay_, UISetupPage::SCREEN_W, UISetupPage::SCREEN_H + 20); - lv_obj_set_pos(confirm_overlay_, 0, 0); - lv_obj_set_style_bg_color(confirm_overlay_, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(confirm_overlay_, LV_OPA_60, 0); - lv_obj_clear_flag(confirm_overlay_, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *box = lv_obj_create(confirm_overlay_); - lv_obj_remove_style_all(box); - lv_obj_set_size(box, 250, 82); - lv_obj_align(box, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_bg_color(box, lv_color_hex(0x1A1A2E), 0); - lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); - lv_obj_set_style_radius(box, 6, 0); - lv_obj_set_style_border_color(box, lv_color_hex(modal == Modal::ERROR ? 0xCC5555 : 0x3A5A8A), 0); - lv_obj_set_style_border_width(box, 1, 0); - - lv_obj_t *title = lv_label_create(box); - lv_label_set_text(title, title_text); - lv_obj_set_width(title, 230); - lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_text_font(title, launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD), 0); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10); - - lv_obj_t *detail = lv_label_create(box); - lv_label_set_text(detail, detail_text); - lv_obj_set_width(detail, 230); - lv_obj_set_style_text_align(detail, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_color(detail, lv_color_hex(0xAAAAAA), 0); - lv_obj_set_style_text_font(detail, &lv_font_montserrat_10, 0); - lv_obj_align(detail, LV_ALIGN_CENTER, 0, 7); - if (modal == Modal::ERROR) { - lv_obj_t *hint = lv_label_create(box); - lv_label_set_text(hint, "OK / ESC: close"); - lv_obj_set_style_text_color(hint, lv_color_hex(0x777777), 0); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, 0); - lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -5); - } - lv_obj_move_foreground(confirm_overlay_); -} - -void RTC::show_write_confirm(UISetupPage &page) -{ - if (confirm_overlay_) - return; - - modal_ = Modal::CONFIRM; - confirm_sel_ = 1; - lv_obj_t *layer = lv_layer_top(); - - confirm_overlay_ = lv_obj_create(layer); - lv_obj_remove_style_all(confirm_overlay_); - lv_obj_set_size(confirm_overlay_, UISetupPage::SCREEN_W, UISetupPage::SCREEN_H + 20); - lv_obj_set_pos(confirm_overlay_, 0, 0); - lv_obj_set_style_bg_color(confirm_overlay_, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(confirm_overlay_, LV_OPA_60, 0); - lv_obj_clear_flag(confirm_overlay_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_clear_flag(confirm_overlay_, LV_OBJ_FLAG_CLICKABLE); - - lv_obj_t *box = lv_obj_create(confirm_overlay_); - lv_obj_remove_style_all(box); - lv_obj_set_size(box, 230, 86); - lv_obj_align(box, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_bg_color(box, lv_color_hex(0x1A1A2E), 0); - lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); - lv_obj_set_style_radius(box, 6, 0); - lv_obj_set_style_border_color(box, lv_color_hex(0x3A5A8A), 0); - lv_obj_set_style_border_width(box, 1, 0); - lv_obj_set_style_pad_all(box, 0, 0); - lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_clear_flag(box, LV_OBJ_FLAG_CLICKABLE); - - lv_obj_t *title = lv_label_create(box); - lv_label_set_text(title, "Write hardware RTC?"); - lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_text_font(title, launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD), 0); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10); - - lv_obj_t *hint = lv_label_create(box); - lv_label_set_text(hint, "OK:confirm ESC:no"); - lv_obj_set_style_text_color(hint, lv_color_hex(0x888888), 0); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, 0); - lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -7); - - confirm_yes_lbl_ = lv_label_create(box); - lv_label_set_text(confirm_yes_lbl_, "Yes"); - lv_obj_set_style_text_font(confirm_yes_lbl_, launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), 0); - lv_obj_align(confirm_yes_lbl_, LV_ALIGN_CENTER, -42, 8); - - confirm_no_lbl_ = lv_label_create(box); - lv_label_set_text(confirm_no_lbl_, "No"); - lv_obj_set_style_text_font(confirm_no_lbl_, launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), 0); - lv_obj_align(confirm_no_lbl_, LV_ALIGN_CENTER, 42, 8); - - update_write_confirm_buttons(); - lv_obj_move_foreground(confirm_overlay_); - lv_refr_now(NULL); -} - -void RTC::close_write_confirm() -{ - if (confirm_overlay_) { - lv_obj_del(confirm_overlay_); - confirm_overlay_ = nullptr; - } - confirm_yes_lbl_ = nullptr; - confirm_no_lbl_ = nullptr; - modal_ = Modal::NONE; -} - -void RTC::update_write_confirm_buttons() -{ - if (!confirm_yes_lbl_ || !confirm_no_lbl_) - return; - lv_obj_set_style_text_color(confirm_yes_lbl_, - lv_color_hex(confirm_sel_ == 0 ? 0x00CC66 : 0x888888), 0); - lv_obj_set_style_text_color(confirm_no_lbl_, - lv_color_hex(confirm_sel_ == 1 ? 0x00CC66 : 0x888888), 0); -} - -void RTC::handle_write_confirm_key(UISetupPage &page, uint32_t key) -{ - if (modal_ == Modal::BUSY) - return; - if (modal_ == Modal::ERROR) { - if (key == KEY_ENTER || key == KEY_ESC) { - page.play_back(); - close_write_confirm(); - if (page.view_state_ == UISetupPage::ViewState::SUB) - page.build_sub_view(); - } - return; - } - switch (key) { - case KEY_LEFT: - case KEY_UP: - confirm_sel_ = 0; - update_write_confirm_buttons(); - break; - case KEY_RIGHT: - case KEY_DOWN: - confirm_sel_ = 1; - update_write_confirm_buttons(); - break; - case KEY_ENTER: - page.play_enter(); - close_write_confirm(); - if (confirm_sel_ == 0) { - commit_to_hardware(page); - } else { - refresh_values(page); - page.view_state_ = UISetupPage::ViewState::MAIN; - page.build_main_view(); - } - break; - case KEY_ESC: - page.play_back(); - close_write_confirm(); - refresh_values(page); - dirty_ = false; - page.view_state_ = UISetupPage::ViewState::MAIN; - page.build_main_view(); - break; - default: - break; - } -} - - -} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/menu_builder.cpp b/projects/APPLaunch/main/ui/page_app/setting/menu_builder.cpp new file mode 100644 index 00000000..a859ea76 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/menu_builder.cpp @@ -0,0 +1,32 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +namespace setting { + +void build_menu(UISetupPage &page) +{ + SetupPageAccess access(page); + auto &menu = access.menus(); + std::vector candidate; + Launcher::append(page, candidate); + Boot::append(page, candidate); + access.screen().append(page, candidate); + access.wifi().append(page, candidate); + access.speaker().append(page, candidate); + access.camera().append(page, candidate); + access.info().append(page, candidate); + About::append(page, candidate); + Help::append(page, candidate); + ExtPort::append(page, candidate); + access.developer().append(page, candidate); + access.rtc().append(page, candidate); + access.bluetooth().append(page, candidate); + Ethernet::append(page, candidate); + Account::append(page, candidate); + Update::append(page, candidate); + access.soundcard().append(page, candidate); + menu.swap(candidate); +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/menu_types.hpp b/projects/APPLaunch/main/ui/page_app/setting/menu_types.hpp new file mode 100644 index 00000000..35558d13 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/menu_types.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include + +namespace setting { + +struct SubItem +{ + std::string label; + bool is_toggle; + bool toggle_state; + std::function action; +}; + +struct MenuItem +{ + std::string label; + std::vector sub_items; + std::function on_enter; + std::function custom_key_handler; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/rtc.cpp b/projects/APPLaunch/main/ui/page_app/setting/rtc.cpp new file mode 100644 index 00000000..ddfa6893 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/rtc.cpp @@ -0,0 +1,240 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +#include +#include + +namespace setting { + +void RTC::append(UISetupPage &p, std::vector &menu) +{ + UISetupPage *page = &p; + RTC *controller = this; + MenuItem m; + m.label = "RTC"; + m.sub_items = { + {"NTP", true, true, [controller, page]() { controller->toggle_ntp(*page); }}, + {"Year", false, false, [controller, page]() { controller->enter_adjust(*page, 0); }}, + {"Month", false, false, [controller, page]() { controller->enter_adjust(*page, 1); }}, + {"Day", false, false, [controller, page]() { controller->enter_adjust(*page, 2); }}, + {"Hour", false, false, [controller, page]() { controller->enter_adjust(*page, 3); }}, + {"Minute", false, false, [controller, page]() { controller->enter_adjust(*page, 4); }}, + {"Second", false, false, [controller, page]() { controller->enter_adjust(*page, 5); }}, + }; + m.on_enter = [controller, page]() { controller->refresh_values(*page); }; + menu.push_back(m); +} + +RTC::RTC() = default; + +RTC::~RTC() +{ + shutdown(); +} + +void RTC::shutdown() +{ + const uint64_t request_id = async_operation_.shutdown(); + if (request_id) + cp0_signal_sudo_cancel(request_id, nullptr); + close_write_confirm(); +} + +void RTC::update_labels(UISetupPage &page) +{ + MenuItem *menu = SetupPageAccess(page).find_menu("RTC"); + if (menu && menu->sub_items.size() >= 7) { + menu->sub_items[0].toggle_state = state_.ntp_on(); + char buf[32]; + for (int i = 0; i < 6; ++i) { + const auto field = static_cast(i); + snprintf(buf, sizeof(buf), "%.*s: %d", + static_cast(RtcStateModel::field_name(field).size()), + RtcStateModel::field_name(field).data(), state_.field_value(field)); + menu->sub_items[i + 1].label = buf; + } + } +} + +void RTC::refresh_values(UISetupPage &page) +{ + int ntp = -1; + try { + cp0_signal_osinfo_api({"NtpGet"}, [&](int code, std::string) { ntp = code; }); + } catch (...) { + ntp = -1; + } + state_.set_ntp_status(ntp); + RtcStateModel refreshed = state_; + bool time_loaded = false; + try { + cp0_signal_osinfo_api({"LocalTime"}, [&](int code, std::string data) { + if (code == 0) time_loaded = refreshed.load_local_time(data); + }); + } catch (...) { + time_loaded = false; + } + if (time_loaded) state_ = std::move(refreshed); + update_labels(page); +} + +void RTC::toggle_ntp(UISetupPage &page) +{ + NtpToggleEligibility eligibility = state_.ntp_toggle_eligibility(async_operation_.active()); + if (eligibility == NtpToggleEligibility::IN_FLIGHT) + return; + if (eligibility == NtpToggleEligibility::DIRTY) { + update_labels(page); + show_status("NTP unchanged", "Save or discard time edits first", Modal::ERROR); + return; + } + if (eligibility == NtpToggleEligibility::UNAVAILABLE) { + update_labels(page); + show_status("NTP unavailable", "Unable to read NTP status", Modal::ERROR); + return; + } + bool desired = state_.ntp_on(); + MenuItem *menu = SetupPageAccess(page).find_menu("RTC"); + if (menu && !menu->sub_items.empty()) desired = menu->sub_items[0].toggle_state; + ntp_previous_ = state_.ntp_on(); + const auto operation = async_operation_.begin(); + if (!operation) + return; + show_status("Updating NTP", "Please wait...", Modal::BUSY); + struct Context { RTC *rtc; UISetupPage *page; AsyncOperationLifecycle::Token operation; }; + auto ctx = std::shared_ptr(new (std::nothrow) Context{this, &page, operation}); + if (!ctx) { + async_operation_.abort(operation); + state_.rollback_ntp(ntp_previous_); + update_labels(page); + show_status("NTP failed", "Out of memory", Modal::ERROR); + return; + } + uint64_t request_id = 0; + int rc = -1; + try { + cp0_signal_system_admin_async({"NtpSet", desired ? "1" : "0"}, 60000, 30000, + [ctx](int result_code, int exit_code) { + cp0_sudo_result_t result = static_cast(result_code); + if (!ctx->operation.complete()) return; + if (result != CP0_SUDO_RESULT_SUCCESS) { + ctx->rtc->state_.rollback_ntp(ctx->rtc->ntp_previous_); + ctx->rtc->update_labels(*ctx->page); + if (result == CP0_SUDO_RESULT_CANCELLED) { + ctx->rtc->close_write_confirm(); + SetupPageAccess(*ctx->page).build_sub_view(); + return; + } + ctx->rtc->show_result_error(result, exit_code, "NTP update"); + return; + } + int actual = -1; + try { + cp0_signal_osinfo_api({"NtpGet"}, + [&](int code, std::string) { actual = code; }); + } catch (...) { + actual = -1; + } + if (actual < 0) { + ctx->rtc->state_.set_ntp_status(-1); + ctx->rtc->show_status("NTP unavailable", "Unable to read NTP status", Modal::ERROR); + } else { + ctx->rtc->state_.set_ntp_status(actual); + ctx->rtc->close_write_confirm(); + } + ctx->rtc->update_labels(*ctx->page); + SetupPageAccess(*ctx->page).build_sub_view(); + }, [&](int code, uint64_t id) { rc = code; request_id = id; }); + } catch (...) { + rc = -1; + } + if (rc != 0) { + async_operation_.abort(operation); + state_.rollback_ntp(ntp_previous_); + update_labels(page); + show_status("NTP failed", "Unable to start request", Modal::ERROR); + } + else async_operation_.activate(operation, request_id); +} + +void RTC::enter_adjust(UISetupPage &page, int field) +{ + if (state_.ntp_on()) + return; + if (!RtcStateModel::field_from_index(field, field_)) + return; + SetupPageAccess(page).enter_value(std::string(RtcStateModel::field_name(field_)), + state_.field_options(field_), state_.field_selection_index(field_)); +} + +void RTC::apply_value(UISetupPage &page) +{ + const int selection = SetupPageAccess(page).value_selection(); + if (selection < 0 || !state_.edit_field_selection(field_, static_cast(selection))) + return; + update_labels(page); +} + +void RTC::commit_to_hardware(UISetupPage &page) +{ + const auto request = state_.commit_request(); + const auto operation = async_operation_.begin(); + if (!operation) + return; + show_status("Saving date & time", "Please wait...", Modal::BUSY); + struct Context { RTC *rtc; UISetupPage *page; AsyncOperationLifecycle::Token operation; }; + auto ctx = std::shared_ptr(new (std::nothrow) Context{this, &page, operation}); + if (!ctx) { + async_operation_.abort(operation); + show_status("Save failed", "Out of memory", Modal::ERROR); + return; + } + uint64_t request_id = 0; + int rc = -1; + try { + cp0_signal_system_admin_async(request, 60000, 30000, + [ctx](int result_code, int exit_code) { + cp0_sudo_result_t result = static_cast(result_code); + if (!ctx->operation.complete()) return; + if (result == CP0_SUDO_RESULT_SUCCESS) { + ctx->rtc->state_.finish_commit(true); + ctx->rtc->close_write_confirm(); + ctx->page->update_datetime_status(); + SetupPageAccess access(*ctx->page); + access.set_view(SetupViewState::MAIN); + access.build_main_view(); + } else { + ctx->rtc->state_.finish_commit(false); + if (result == CP0_SUDO_RESULT_CANCELLED) + ctx->rtc->close_write_confirm(); + else + ctx->rtc->show_result_error(result, exit_code, "Date/time save"); + } + }, [&](int code, uint64_t id) { rc = code; request_id = id; }); + } catch (...) { + rc = -1; + } + if (rc != 0) { + async_operation_.abort(operation); + show_status("Save failed", "Unable to start request", Modal::ERROR); + } + else async_operation_.activate(operation, request_id); +} + +void RTC::show_result_error(cp0_sudo_result_t result, int exit_code, const char *operation) +{ + const char *reason = "Command failed"; + switch (classify_privileged_result(static_cast(result))) { + case PrivilegedResultKind::AUTH_FAILED: reason = "Authentication failed"; break; + case PrivilegedResultKind::CANCELLED: reason = "Request cancelled"; break; + case PrivilegedResultKind::TIMED_OUT: reason = "Request timed out"; break; + case PrivilegedResultKind::EXEC_FAILED: reason = exit_code ? "Command returned an error" : "Unable to start command"; break; + default: break; + } + char title[64]; + snprintf(title, sizeof(title), "%s failed", operation); + show_status(title, reason, Modal::ERROR); +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/rtc.hpp b/projects/APPLaunch/main/ui/page_app/setting/rtc.hpp new file mode 100644 index 00000000..6acaa3b7 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/rtc.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include "menu_types.hpp" +#include "../../model/rtc_state_model.hpp" +#include "../../model/async_operation_lifecycle.hpp" + +#include "cp0_lvgl_app.h" +#include + +#include +#include + +class UISetupPage; + +namespace setting { + +class RTC +{ +public: + RTC(); + ~RTC(); + void shutdown(); + + void append(UISetupPage &page, std::vector &menu); + void refresh_values(UISetupPage &page); + void toggle_ntp(UISetupPage &page); + void enter_adjust(UISetupPage &page, int field); + void apply_value(UISetupPage &page); + void commit_to_hardware(UISetupPage &page); + void show_write_confirm(UISetupPage &page); + void close_write_confirm(); + void handle_write_confirm_key(UISetupPage &page, uint32_t key); + + bool is_dirty() const { return state_.dirty(); } + bool ntp_on() const { return state_.ntp_on(); } + bool ntp_available() const { return state_.ntp_available(); } + bool write_confirm_active() const { return confirm_overlay_ != nullptr; } + void clear_dirty() { state_.discard_edits(); } + +private: + enum class Modal + { + NONE, + CONFIRM, + BUSY, + ERROR + }; + + void show_status(const char *title, const char *detail, Modal modal); + void show_result_error(cp0_sudo_result_t result, int exit_code, const char *operation); + void update_labels(UISetupPage &page); + void update_write_confirm_buttons(); + static void overlay_delete_cb(lv_event_t *event) noexcept; + + RtcStateModel state_; + RtcField field_ = RtcField::YEAR; + bool ntp_previous_ = true; + Modal modal_ = Modal::NONE; + AsyncOperationLifecycle async_operation_; + RtcWriteConfirmModel confirm_model_; + RtcOverlayLifecycleModel overlay_lifecycle_; + RtcOverlayLifecycleModel::Token overlay_token_ = 0; + lv_obj_t *confirm_overlay_ = nullptr; + lv_obj_t *confirm_yes_lbl_ = nullptr; + lv_obj_t *confirm_no_lbl_ = nullptr; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/rtc_ntp_state.cpp b/projects/APPLaunch/main/ui/page_app/setting/rtc_ntp_state.cpp deleted file mode 100644 index a95a5443..00000000 --- a/projects/APPLaunch/main/ui/page_app/setting/rtc_ntp_state.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "rtc_ntp_state.hpp" - -namespace setting { - -NtpToggleEligibility ntp_toggle_eligibility(bool in_flight, bool dirty, bool available) -{ - if (in_flight) return NtpToggleEligibility::IN_FLIGHT; - if (dirty) return NtpToggleEligibility::DIRTY; - if (!available) return NtpToggleEligibility::UNAVAILABLE; - return NtpToggleEligibility::ALLOWED; -} - -bool ntp_rollback_value(bool previous) -{ - return previous; -} - -PrivilegedResultKind classify_privileged_result(int result) -{ - switch (result) { - case 0: return PrivilegedResultKind::SUCCESS; - case 1: return PrivilegedResultKind::AUTH_FAILED; - case 2: return PrivilegedResultKind::EXEC_FAILED; - case 3: return PrivilegedResultKind::CANCELLED; - case 4: return PrivilegedResultKind::TIMED_OUT; - default: return PrivilegedResultKind::EXEC_FAILED; - } -} - -} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/rtc_ntp_state.hpp b/projects/APPLaunch/main/ui/page_app/setting/rtc_ntp_state.hpp deleted file mode 100644 index 6cc7160e..00000000 --- a/projects/APPLaunch/main/ui/page_app/setting/rtc_ntp_state.hpp +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -namespace setting { - -enum class NtpToggleEligibility { ALLOWED, IN_FLIGHT, DIRTY, UNAVAILABLE }; - -NtpToggleEligibility ntp_toggle_eligibility(bool in_flight, bool dirty, bool available); -bool ntp_rollback_value(bool previous); - -enum class PrivilegedResultKind { SUCCESS, AUTH_FAILED, CANCELLED, TIMED_OUT, EXEC_FAILED }; - -PrivilegedResultKind classify_privileged_result(int result); - -} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/rtc_view.cpp b/projects/APPLaunch/main/ui/page_app/setting/rtc_view.cpp new file mode 100644 index 00000000..99dad6ee --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/rtc_view.cpp @@ -0,0 +1,273 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +namespace setting { + +namespace { + +lv_obj_t *create_modal_overlay(int width, int height) +{ + lv_obj_t *layer = lv_layer_top(); + if (!layer) return nullptr; + lv_obj_t *overlay = lv_obj_create(layer); + if (!overlay) return nullptr; + lv_obj_remove_style_all(overlay); + lv_obj_set_size(overlay, width, height); + lv_obj_set_pos(overlay, 0, 0); + lv_obj_set_style_bg_color(overlay, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(overlay, LV_OPA_60, 0); + lv_obj_clear_flag(overlay, LV_OBJ_FLAG_SCROLLABLE); + return overlay; +} + +lv_obj_t *create_modal_box(lv_obj_t *overlay, int width, int height, uint32_t border_color) +{ + if (!overlay) return nullptr; + lv_obj_t *box = lv_obj_create(overlay); + if (!box) return nullptr; + lv_obj_remove_style_all(box); + lv_obj_set_size(box, width, height); + lv_obj_align(box, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_bg_color(box, lv_color_hex(0x1A1A2E), 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_radius(box, 6, 0); + lv_obj_set_style_border_color(box, lv_color_hex(border_color), 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_pad_all(box, 0, 0); + lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(box, LV_OBJ_FLAG_CLICKABLE); + return box; +} + +} // namespace + +void RTC::show_status(const char *title_text, const char *detail_text, Modal modal) +{ + const auto layout = SetupPageAccess::fixed_layout(); + lv_obj_t *overlay = create_modal_overlay(layout.screen_width, layout.screen_height + 20); + if (!overlay) return; + lv_obj_t *box = create_modal_box(overlay, 250, 82, + modal == Modal::ERROR ? 0xCC5555 : 0x3A5A8A); + if (!box) { + lv_obj_del(overlay); + return; + } + + lv_obj_t *title = lv_label_create(box); + if (!title) { + lv_obj_del(overlay); + return; + } + lv_label_set_text(title, title_text); + lv_obj_set_width(title, 230); + lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font( + title, + launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD), 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10); + + lv_obj_t *detail = lv_label_create(box); + if (!detail) { + lv_obj_del(overlay); + return; + } + lv_label_set_text(detail, detail_text); + lv_obj_set_width(detail, 230); + lv_obj_set_style_text_align(detail, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(detail, lv_color_hex(0xAAAAAA), 0); + lv_obj_set_style_text_font(detail, &lv_font_montserrat_10, 0); + lv_obj_align(detail, LV_ALIGN_CENTER, 0, 7); + if (modal == Modal::ERROR) { + lv_obj_t *hint = lv_label_create(box); + if (!hint) { + lv_obj_del(overlay); + return; + } + lv_label_set_text(hint, "OK / ESC: close"); + lv_obj_set_style_text_color(hint, lv_color_hex(0x777777), 0); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, 0); + lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -5); + } + close_write_confirm(); + overlay_token_ = overlay_lifecycle_.open(); + if (!overlay_token_) { + lv_obj_del(overlay); + return; + } + confirm_overlay_ = overlay; + modal_ = modal; + lv_obj_add_event_cb(confirm_overlay_, overlay_delete_cb, LV_EVENT_DELETE, this); + lv_obj_move_foreground(confirm_overlay_); +} + +void RTC::show_write_confirm(UISetupPage &page) +{ + (void)page; + if (confirm_overlay_) return; + + const auto layout = SetupPageAccess(page).layout(); + lv_obj_t *overlay = create_modal_overlay(layout.screen_width, layout.screen_height + 20); + if (!overlay) return; + lv_obj_clear_flag(overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_t *box = create_modal_box(overlay, 230, 86, 0x3A5A8A); + if (!box) { + lv_obj_del(overlay); + return; + } + + lv_obj_t *title = lv_label_create(box); + if (!title) { + lv_obj_del(overlay); + return; + } + lv_label_set_text(title, "Write hardware RTC?"); + lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font( + title, + launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD), 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10); + + lv_obj_t *hint = lv_label_create(box); + if (!hint) { + lv_obj_del(overlay); + return; + } + lv_label_set_text(hint, "OK:confirm ESC:no"); + lv_obj_set_style_text_color(hint, lv_color_hex(0x888888), 0); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, 0); + lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -7); + + lv_obj_t *yes_label = lv_label_create(box); + if (!yes_label) { + lv_obj_del(overlay); + return; + } + lv_label_set_text(yes_label, "Yes"); + lv_obj_set_style_text_font( + yes_label, + launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), 0); + lv_obj_align(yes_label, LV_ALIGN_CENTER, -42, 8); + + lv_obj_t *no_label = lv_label_create(box); + if (!no_label) { + lv_obj_del(overlay); + return; + } + lv_label_set_text(no_label, "No"); + lv_obj_set_style_text_font( + no_label, + launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), 0); + lv_obj_align(no_label, LV_ALIGN_CENTER, 42, 8); + + overlay_token_ = overlay_lifecycle_.open(); + if (!overlay_token_) { + lv_obj_del(overlay); + return; + } + confirm_overlay_ = overlay; + confirm_yes_lbl_ = yes_label; + confirm_no_lbl_ = no_label; + modal_ = Modal::CONFIRM; + confirm_model_.reset(); + lv_obj_add_event_cb(confirm_overlay_, overlay_delete_cb, LV_EVENT_DELETE, this); + update_write_confirm_buttons(); + lv_obj_move_foreground(confirm_overlay_); + lv_refr_now(nullptr); +} + +void RTC::close_write_confirm() +{ + if (confirm_overlay_) { + lv_obj_t *overlay = confirm_overlay_; + confirm_overlay_ = nullptr; + lv_obj_remove_event_cb_with_user_data(overlay, overlay_delete_cb, this); + lv_obj_del(overlay); + } + confirm_yes_lbl_ = nullptr; + confirm_no_lbl_ = nullptr; + modal_ = Modal::NONE; + overlay_lifecycle_.close(overlay_token_); + overlay_token_ = 0; +} + +void RTC::overlay_delete_cb(lv_event_t *event) noexcept +{ + try { + if (!event) return; + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (!self || !setting::rtc_overlay_delete_callback_allowed( + deleted, lv_event_get_current_target(event), self->confirm_overlay_)) + return; + self->confirm_overlay_ = nullptr; + self->confirm_yes_lbl_ = nullptr; + self->confirm_no_lbl_ = nullptr; + self->modal_ = Modal::NONE; + self->overlay_lifecycle_.close(self->overlay_token_); + self->overlay_token_ = 0; + } catch (...) { + } +} + +void RTC::update_write_confirm_buttons() +{ + if (!confirm_yes_lbl_ || !confirm_no_lbl_) return; + lv_obj_set_style_text_color(confirm_yes_lbl_, + lv_color_hex(confirm_model_.save_selected() ? 0x00CC66 : 0x888888), 0); + lv_obj_set_style_text_color(confirm_no_lbl_, + lv_color_hex(confirm_model_.save_selected() ? 0x888888 : 0x00CC66), 0); +} + +void RTC::handle_write_confirm_key(UISetupPage &page, uint32_t key) +{ + if (modal_ == Modal::BUSY) return; + if (modal_ == Modal::ERROR) { + if (key == KEY_ENTER || key == KEY_ESC) { + SetupPageAccess access(page); + access.play_back(); + close_write_confirm(); + if (access.is_view(SetupViewState::SUB)) access.build_sub_view(); + } + return; + } + switch (key) { + case KEY_LEFT: + case KEY_UP: + confirm_model_.handle(RtcConfirmInput::SELECT_SAVE); + update_write_confirm_buttons(); + break; + case KEY_RIGHT: + case KEY_DOWN: + confirm_model_.handle(RtcConfirmInput::SELECT_DISCARD); + update_write_confirm_buttons(); + break; + case KEY_ENTER: + SetupPageAccess(page).play_enter(); + close_write_confirm(); + if (confirm_model_.handle(RtcConfirmInput::CONFIRM) == RtcConfirmAction::SAVE) { + commit_to_hardware(page); + } else { + refresh_values(page); + SetupPageAccess access(page); + access.set_view(SetupViewState::MAIN); + access.build_main_view(); + } + break; + case KEY_ESC: { + SetupPageAccess(page).play_back(); + close_write_confirm(); + confirm_model_.handle(RtcConfirmInput::CANCEL); + refresh_values(page); + SetupPageAccess access(page); + access.set_view(SetupViewState::MAIN); + access.build_main_view(); + break; + } + default: + break; + } +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/setup_page_access.cpp b/projects/APPLaunch/main/ui/page_app/setting/setup_page_access.cpp new file mode 100644 index 00000000..1a194117 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/setup_page_access.cpp @@ -0,0 +1,169 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +#include + +namespace setting { + +std::vector &SetupPageAccess::menus() +{ + return page_.menu_items_; +} + +MenuItem *SetupPageAccess::find_menu(std::string_view label) +{ + return find_menu_item(page_.menu_items_, label); +} + +MenuItem *SetupPageAccess::selected_menu() +{ + return selected_menu_item(page_.menu_items_, page_.model_.selected_index); +} + +Screen &SetupPageAccess::screen() +{ + return page_.screen_; +} + +Speaker &SetupPageAccess::speaker() +{ + return page_.speaker_; +} + +Camera &SetupPageAccess::camera() +{ + return page_.camera_; +} + +WiFi &SetupPageAccess::wifi() { return page_.wifi_; } +Info &SetupPageAccess::info() { return page_.info_; } +Developer &SetupPageAccess::developer() { return page_.developer_; } +RTC &SetupPageAccess::rtc() { return page_.rtc_; } +Bluetooth &SetupPageAccess::bluetooth() { return page_.bluetooth_; } +SoundCard &SetupPageAccess::soundcard() { return page_.soundcard_; } + +void SetupPageAccess::confirm(const char *title, std::function action) +{ + page_.enter_confirm_action(title, std::move(action)); +} + +void SetupPageAccess::enter_value( + std::string title, std::vector options, int selected_index) +{ + page_.model_.enter_value(std::move(title), std::move(options), selected_index); + page_.transition_enter_level(); +} + +const std::string &SetupPageAccess::value_title() const +{ + return page_.model_.value_title; +} + +int SetupPageAccess::value_selection() const +{ + return page_.model_.value_selected_index; +} + +int SetupPageAccess::config_get_int(const char *key, int default_value) const +{ + return UISetupPage::config_get_int(key, default_value); +} + +bool SetupPageAccess::config_set_int(const char *key, int value) const +{ + return UISetupPage::config_set_int(key, value); +} + +bool SetupPageAccess::config_save() const +{ + return UISetupPage::config_save(); +} + +int SetupPageAccess::audio_volume_read() const +{ + return UISetupPage::audio_volume_read(); +} + +int SetupPageAccess::audio_volume_write(int value) const +{ + return UISetupPage::audio_volume_write(value); +} + +bool SetupPageAccess::gpio_set(const char *name, int value) const +{ + return UISetupPage::gpio_set(name, value); +} + +int SetupPageAccess::gpio_get(const char *name) const +{ + return UISetupPage::gpio_get(name); +} + +void SetupPageAccess::set_view(SetupViewState view) +{ + page_.model_.view = view; +} + +bool SetupPageAccess::enter_help() { return page_.model_.enter_help(); } +bool SetupPageAccess::leave_help() { return page_.model_.leave_help(); } + +SetupViewState SetupPageAccess::view() const { return page_.model_.view; } +bool SetupPageAccess::is_view(SetupViewState view) const { return page_.model_.view == view; } + +lv_obj_t *SetupPageAccess::content_container() +{ + const auto found = page_.ui_obj_.find("list_cont"); + return found == page_.ui_obj_.end() ? nullptr : found->second; +} + +int SetupPageAccess::content_height() const +{ + return UISetupPage::LIST_H; +} + +SetupLayout SetupPageAccess::layout() const +{ + return fixed_layout(); +} + +SetupLayout SetupPageAccess::fixed_layout() +{ + return {UISetupPage::SCREEN_W, UISetupPage::SCREEN_H, UISetupPage::LIST_H, + UISetupPage::WIFI_TITLE_BOX_W, UISetupPage::SUB_CENTER_X, UISetupPage::VALUE_BOX_W, + UISetupPage::SC_MARGIN_X, UISetupPage::SC_ROW_X, UISetupPage::SC_CARD_NAME_W, + UISetupPage::SC_CTRL_NAME_X, UISetupPage::SC_CTRL_NAME_W, UISetupPage::SC_CTRL_VALUE_X, + UISetupPage::SC_CTRL_VALUE_W, UISetupPage::SC_DETAIL_TEXT_W, UISetupPage::SC_INPUT_X, + UISetupPage::SC_INPUT_W, UISetupPage::SC_BOTTOM_HINT_W}; +} + +std::string_view SetupPageAccess::current_utf8() const +{ + return page_.cur_elm_ && page_.cur_elm_->utf8[0] ? std::string_view(page_.cur_elm_->utf8) : std::string_view{}; +} + +const std::string &SetupPageAccess::arrow_up_path() const { return page_.img_arrow_up_; } +const std::string &SetupPageAccess::arrow_down_path() const { return page_.img_arrow_down_; } +void SetupPageAccess::play_enter() { page_.play_enter(); } +void SetupPageAccess::play_back() { page_.play_back(); } +void SetupPageAccess::build_main_view() { page_.build_main_view(); } +void SetupPageAccess::build_sub_view() { page_.build_sub_view(); } +void SetupPageAccess::rebuild_view() { page_.rebuild_view(); } +void SetupPageAccess::transition_enter() { page_.transition_enter_level(); } +void SetupPageAccess::transition_back() { page_.transition_back_level(); } +void SetupPageAccess::apply_overflow(lv_obj_t *label, int left, int width, bool focused) const +{ + UISetupPage::apply_overflow_handling(label, left, width, focused); +} +void SetupPageAccess::apply_overflow_centered( + lv_obj_t *label, int center, int width, bool focused) const +{ + UISetupPage::apply_overflow_centered(label, center, width, focused); +} +void SetupPageAccess::apply_fixed_label_box( + lv_obj_t *label, int x, int y, int width, bool scroll) const +{ + UISetupPage::apply_fixed_label_box(label, x, y, width, scroll); +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/setup_page_access.hpp b/projects/APPLaunch/main/ui/page_app/setting/setup_page_access.hpp new file mode 100644 index 00000000..932edaaf --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/setup_page_access.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include "menu_types.hpp" +#include "../../model/setup_page_model.hpp" + +#include +#include +#include +#include + +class UISetupPage; +typedef struct _lv_obj_t lv_obj_t; + +namespace setting { + +class Screen; +class Speaker; +class Camera; +class WiFi; +class Info; +class Developer; +class RTC; +class Bluetooth; +class SoundCard; + +struct SetupLayout +{ + int screen_width; + int screen_height; + int content_height; + int wifi_title_width; + int sub_center_x; + int value_box_width; + int sc_margin_x; + int sc_row_x; + int sc_card_name_width; + int sc_control_name_x; + int sc_control_name_width; + int sc_control_value_x; + int sc_control_value_width; + int sc_detail_text_width; + int sc_input_x; + int sc_input_width; + int sc_bottom_hint_width; +}; + +MenuItem *find_menu_item(std::vector &menus, std::string_view label); +const MenuItem *find_menu_item(const std::vector &menus, std::string_view label); +MenuItem *selected_menu_item(std::vector &menus, int selected_index); +const MenuItem *selected_menu_item(const std::vector &menus, int selected_index); + +class SetupPageAccess +{ +public: + explicit SetupPageAccess(UISetupPage &page) : page_(page) {} + + std::vector &menus(); + MenuItem *find_menu(std::string_view label); + MenuItem *selected_menu(); + + Screen &screen(); + Speaker &speaker(); + Camera &camera(); + WiFi &wifi(); + Info &info(); + Developer &developer(); + RTC &rtc(); + Bluetooth &bluetooth(); + SoundCard &soundcard(); + + void confirm(const char *title, std::function action); + void enter_value(std::string title, std::vector options, int selected_index); + const std::string &value_title() const; + int value_selection() const; + + int config_get_int(const char *key, int default_value) const; + bool config_set_int(const char *key, int value) const; + bool config_save() const; + int audio_volume_read() const; + int audio_volume_write(int value) const; + bool gpio_set(const char *name, int value) const; + int gpio_get(const char *name) const; + + void set_view(SetupViewState view); + bool enter_help(); + bool leave_help(); + SetupViewState view() const; + bool is_view(SetupViewState view) const; + lv_obj_t *content_container(); + int content_height() const; + SetupLayout layout() const; + static SetupLayout fixed_layout(); + std::string_view current_utf8() const; + const std::string &arrow_up_path() const; + const std::string &arrow_down_path() const; + + void play_enter(); + void play_back(); + void build_main_view(); + void build_sub_view(); + void rebuild_view(); + void transition_enter(); + void transition_back(); + void apply_overflow(lv_obj_t *label, int left, int width, bool focused) const; + void apply_overflow_centered(lv_obj_t *label, int center, int width, bool focused) const; + void apply_fixed_label_box(lv_obj_t *label, int x, int y, int width, bool scroll) const; + +private: + UISetupPage &page_; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/setup_page_access_policy.cpp b/projects/APPLaunch/main/ui/page_app/setting/setup_page_access_policy.cpp new file mode 100644 index 00000000..05ccbbf7 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/setup_page_access_policy.cpp @@ -0,0 +1,36 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "setup_page_access.hpp" + +namespace setting { + +MenuItem *find_menu_item(std::vector &menus, std::string_view label) +{ + for (auto &menu : menus) + if (menu.label == label) + return &menu; + return nullptr; +} + +const MenuItem *find_menu_item(const std::vector &menus, std::string_view label) +{ + for (const auto &menu : menus) + if (menu.label == label) + return &menu; + return nullptr; +} + +MenuItem *selected_menu_item(std::vector &menus, int selected_index) +{ + if (selected_index < 0 || selected_index >= static_cast(menus.size())) + return nullptr; + return &menus[static_cast(selected_index)]; +} + +const MenuItem *selected_menu_item(const std::vector &menus, int selected_index) +{ + if (selected_index < 0 || selected_index >= static_cast(menus.size())) + return nullptr; + return &menus[static_cast(selected_index)]; +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/sound_card.cpp b/projects/APPLaunch/main/ui/page_app/setting/sound_card.cpp index 11f98b4a..f8f39ee7 100644 --- a/projects/APPLaunch/main/ui/page_app/setting/sound_card.cpp +++ b/projects/APPLaunch/main/ui/page_app/setting/sound_card.cpp @@ -1,433 +1,106 @@ #define APP_PAGE_IMPLEMENTATION_UNIT #include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" namespace setting { -std::string SoundCard::trim(const std::string &s) +SoundCard::~SoundCard() { - size_t a = s.find_first_not_of(" \t\r\n"); - if (a == std::string::npos) return {}; - size_t b = s.find_last_not_of(" \t\r\n"); - return s.substr(a, b - a + 1); + shutdown(); } -bool SoundCard::parse_limits(const std::string &line, int &mn, int &mx) +void SoundCard::shutdown() { - size_t p = line.find("Limits:"); - if (p == std::string::npos) return false; - std::string rest = line.substr(p + 7); - for (const char *pfx : {"Playback ", "Capture "}) { - if (rest.find(pfx) == 0) { rest = rest.substr(std::strlen(pfx)); break; } - } - int a = 0, b = 0; - if (std::sscanf(rest.c_str(), " %d - %d", &a, &b) == 2) { - mn = a; mx = b; return true; - } - return false; -} - -int SoundCard::parse_current_val(const std::string &line) -{ - size_t p = line.find(": "); - if (p == std::string::npos) return -1; - int v = 0; - if (std::sscanf(line.c_str() + p + 2, " %d", &v) == 1) return v; - return -1; -} - -std::string SoundCard::extract_value_str(const std::string &line) -{ - static const char *pfx[] = { - "Mono:", "Front Left:", "Front Right:", "Rear Left:", "Rear Right:", - "Center:", "LFE:", "Side Left:", "Side Right:", "Capture:", "Playback:", - nullptr - }; - for (int i = 0; pfx[i]; ++i) { - size_t p = line.find(pfx[i]); - if (p != std::string::npos) return trim(line.substr(p)); - } - return trim(line); -} - -bool SoundCard::is_value_line(const std::string &tl) -{ - static const char *pfx[] = { - "Mono:", "Front Left:", "Front Right:", "Rear Left:", "Rear Right:", - "Center:", "LFE:", "Side Left:", "Side Right:", "Capture:", "Playback:", - nullptr - }; - for (int i = 0; pfx[i]; ++i) - if (tl.rfind(pfx[i], 0) == 0) return true; - return false; + cursor_stop(); + stop_transition_timer(); + clear_detail_view(); } -// ==================================================================== -// Helpers -// ==================================================================== - void SoundCard::enter_cards(UISetupPage &page) { - cards_.clear(); - cp0_signal_soundcard_api({"ListCards"}, [this](int code, std::string data) { - if (code != 0) return; - std::istringstream lines(data); - std::string line; - while (std::getline(lines, line)) { - if (line.empty()) continue; - size_t tab = line.find('\t'); - if (tab == std::string::npos) continue; - SoundCard::Card c; - c.index = std::atoi(line.substr(0, tab).c_str()); - c.name = line.substr(tab + 1); - cards_.push_back(std::move(c)); - } - }); + stop_transition_timer(); + clear_detail_view(); + std::vector candidate; + bool loaded = false; + try { + cp0_signal_soundcard_api({"ListCards"}, [&](int code, std::string data) { + if (code != 0) return; + candidate = SoundCardModel::parse_cards(data); + loaded = true; + }); + } catch (...) { + loaded = false; + } + if (!sound_card_commit_if_success(loaded, std::move(candidate), cards_)) return; card_sel_ = 0; - page.view_state_ = UISetupPage::ViewState::SOUNDCARD_CARDS; - page.transition_enter_level(); + SetupPageAccess access(page); + access.set_view(SetupViewState::SOUNDCARD_CARDS); + access.transition_enter(); } void SoundCard::enter_controls(UISetupPage &page) { + stop_transition_timer(); + clear_detail_view(); if (cards_.empty()) return; - card_idx_ = cards_[card_sel_].index; - controls_.clear(); - cp0_signal_soundcard_api({"ListControls", std::to_string(card_idx_)}, - [this](int code, std::string data) { + const int candidate_card_idx = cards_[card_sel_].index; + std::vector candidate; + bool loaded = false; + try { + cp0_signal_soundcard_api({"ListControls", std::to_string(candidate_card_idx)}, + [&](int code, std::string data) { if (code != 0) return; - std::istringstream lines(data); - std::string line; - while (std::getline(lines, line)) { - if (line.empty()) continue; - std::vector cols; - std::string item; - std::istringstream row(line); - while (std::getline(row, item, '\t')) cols.push_back(item); - if (cols.size() < 7) continue; - SoundCard::Control c; - c.name = cols[0]; - c.type = cols[1]; - c.min_val = std::atoi(cols[2].c_str()); - c.max_val = std::atoi(cols[3].c_str()); - c.step = std::atoi(cols[4].c_str()); - c.current_str = cols[5]; - c.current_val = std::atoi(cols[6].c_str()); - controls_.push_back(std::move(c)); - } + candidate = SoundCardModel::parse_controls(data); + loaded = true; }); + } catch (...) { + loaded = false; + } + if (!sound_card_commit_if_success(loaded, std::move(candidate), controls_)) return; + card_idx_ = candidate_card_idx; ctrl_sel_ = 0; - page.view_state_ = UISetupPage::ViewState::SOUNDCARD_CONTROLS; - page.transition_enter_level(); + SetupPageAccess access(page); + access.set_view(SetupViewState::SOUNDCARD_CONTROLS); + access.transition_enter(); } void SoundCard::enter_detail(UISetupPage &page) { if (controls_.empty()) return; + stop_transition_timer(); + clear_detail_view(); const auto &ctrl = controls_[ctrl_sel_]; - detail_ = SoundCard::Control{}; - detail_.name = ctrl.name; - cp0_signal_soundcard_api({"GetControlDetail", std::to_string(card_idx_), ctrl.name}, - [this, &ctrl](int code, std::string data) { - if (code != 0) { detail_ = ctrl; return; } - std::istringstream ss(data); - std::string line; - while (std::getline(ss, line)) { - std::string tl = trim(line); - if (tl.rfind("Capabilities:", 0) == 0) - detail_.type = (tl.find("enum") != std::string::npos) ? "ENUMERATED" : "INTEGER"; - else if (tl.rfind("Limits:", 0) == 0) - parse_limits(tl, detail_.min_val, detail_.max_val); - else if (detail_.current_str.empty() && is_value_line(tl)) { - detail_.current_str = extract_value_str(tl); - int v = parse_current_val(tl); - if (v >= 0) detail_.current_val = v; - } - } + SoundControlInfo candidate = ctrl; + bool loaded = false; + try { + cp0_signal_soundcard_api({"GetControlDetail", std::to_string(card_idx_), ctrl.name}, + [&](int code, std::string data) { + if (code != 0) return; + candidate = SoundCardModel::parse_detail(data, ctrl); + loaded = true; }); - if (detail_.max_val == 0 && ctrl.max_val != 0) { - detail_.min_val = ctrl.min_val; - detail_.max_val = ctrl.max_val; + } catch (...) { + loaded = false; } + if (!sound_card_commit_if_success(loaded, std::move(candidate), detail_)) return; input_buf_.clear(); input_lbl_ = nullptr; hint_lbl_ = nullptr; - page.view_state_ = UISetupPage::ViewState::SOUNDCARD_DETAIL; - page.transition_enter_level(); + SetupPageAccess access(page); + access.set_view(SetupViewState::SOUNDCARD_DETAIL); + access.transition_enter(); } // ==================================================================== // Build: card list view // ==================================================================== -void SoundCard::build_cards_view(UISetupPage &page) -{ - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - - // Title - lv_obj_t *title = lv_label_create(cont); - lv_label_set_text(title, "Sound Cards"); - UISetupPage::apply_fixed_label_box(title, UISetupPage::SC_MARGIN_X, 4, UISetupPage::SC_DETAIL_TEXT_W, false); - lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(title, launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - - if (cards_.empty()) { - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, "No ALSA cards found."); - UISetupPage::apply_fixed_label_box(lbl, UISetupPage::SC_MARGIN_X, 40, UISetupPage::SC_DETAIL_TEXT_W, false); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x888888), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN); - - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, "ESC: back"); - UISetupPage::apply_fixed_label_box(hint, UISetupPage::SC_MARGIN_X, UISetupPage::LIST_H - 14, UISetupPage::SC_BOTTOM_HINT_W, false); - lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); - return; - } - - int visible = 5; - int total = (int)cards_.size(); - int offset = card_sel_ - visible / 2; - if (offset < 0) offset = 0; - if (total > visible && offset > total - visible) offset = total - visible; - - for (int vi = 0; vi < visible && (vi + offset) < total; ++vi) { - int ai = vi + offset; - bool sel = (ai == card_sel_); - int y = 22 + vi * 22; - - if (sel) { - lv_obj_t *bg = lv_obj_create(cont); - lv_obj_set_size(bg, UISetupPage::SCREEN_W - 8, 20); - lv_obj_set_pos(bg, 4, y); - lv_obj_set_style_radius(bg, 2, LV_PART_MAIN); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x1F3A5F), LV_PART_MAIN); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - } - - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, cards_[ai].name.c_str()); - UISetupPage::apply_fixed_label_box(lbl, UISetupPage::SC_ROW_X, y + 2, UISetupPage::SC_CARD_NAME_W, sel); - lv_obj_set_style_text_color(lbl, lv_color_hex(sel ? 0xFFFFFF : 0xCCCCCC), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN); - } - - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, "OK: open ESC: back"); - UISetupPage::apply_fixed_label_box(hint, UISetupPage::SC_MARGIN_X, UISetupPage::LIST_H - 14, UISetupPage::SC_BOTTOM_HINT_W, false); - lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); -} - -// ==================================================================== -// Build: control list view -// ==================================================================== -void SoundCard::build_controls_view(UISetupPage &page) -{ - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - - // Title: card name - char title_buf[80]; - if (!cards_.empty() && card_sel_ < (int)cards_.size()) - std::snprintf(title_buf, sizeof(title_buf), "%s", cards_[card_sel_].name.c_str()); - else - std::snprintf(title_buf, sizeof(title_buf), "Card %d", card_idx_); - - lv_obj_t *title = lv_label_create(cont); - lv_label_set_text(title, title_buf); - UISetupPage::apply_fixed_label_box(title, UISetupPage::SC_MARGIN_X, 4, UISetupPage::SC_DETAIL_TEXT_W, true); - lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(title, launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - - if (controls_.empty()) { - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, "No controls found."); - UISetupPage::apply_fixed_label_box(lbl, UISetupPage::SC_MARGIN_X, 40, UISetupPage::SC_DETAIL_TEXT_W, false); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x888888), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN); - - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, "ESC: back"); - UISetupPage::apply_fixed_label_box(hint, UISetupPage::SC_MARGIN_X, UISetupPage::LIST_H - 14, UISetupPage::SC_BOTTOM_HINT_W, false); - lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); - return; - } - - int visible = 5; - int total = (int)controls_.size(); - int offset = ctrl_sel_ - visible / 2; - if (offset < 0) offset = 0; - if (total > visible && offset > total - visible) offset = total - visible; - - for (int vi = 0; vi < visible && (vi + offset) < total; ++vi) { - int ai = vi + offset; - bool sel = (ai == ctrl_sel_); - int y = 20 + vi * 22; - - if (sel) { - lv_obj_t *bg = lv_obj_create(cont); - lv_obj_set_size(bg, UISetupPage::SCREEN_W - 8, 20); - lv_obj_set_pos(bg, 4, y); - lv_obj_set_style_radius(bg, 2, LV_PART_MAIN); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x1F3A5F), LV_PART_MAIN); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - } - - const auto &ctrl = controls_[ai]; - - // Left: control name - lv_obj_t *name_lbl = lv_label_create(cont); - lv_label_set_text(name_lbl, ctrl.name.c_str()); - UISetupPage::apply_fixed_label_box(name_lbl, UISetupPage::SC_CTRL_NAME_X, y + 2, UISetupPage::SC_CTRL_NAME_W, sel); - lv_obj_set_style_text_color(name_lbl, lv_color_hex(sel ? 0xFFFFFF : 0xCCCCCC), LV_PART_MAIN); - lv_obj_set_style_text_font(name_lbl, &lv_font_montserrat_12, LV_PART_MAIN); - - // Right: current value summary - if (!ctrl.current_str.empty()) { - lv_obj_t *val_lbl = lv_label_create(cont); - lv_label_set_text(val_lbl, ctrl.current_str.c_str()); - UISetupPage::apply_fixed_label_box(val_lbl, UISetupPage::SC_CTRL_VALUE_X, y + 2, UISetupPage::SC_CTRL_VALUE_W, sel); - lv_obj_set_style_text_color(val_lbl, lv_color_hex(sel ? 0xAADDFF : 0x888888), LV_PART_MAIN); - lv_obj_set_style_text_font(val_lbl, &lv_font_montserrat_10, LV_PART_MAIN); - } - } - - // Scroll arrows - if (ctrl_sel_ > 0) { - lv_obj_t *a = lv_img_create(cont); - lv_img_set_src(a, page.img_arrow_up_.c_str()); - lv_obj_set_pos(a, UISetupPage::SCREEN_W / 2 - 8, 2); - } - if (ctrl_sel_ < total - 1) { - lv_obj_t *a = lv_img_create(cont); - lv_img_set_src(a, page.img_arrow_down_.c_str()); - lv_obj_set_pos(a, UISetupPage::SCREEN_W / 2 - 8, UISetupPage::LIST_H - 28); - } - - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, "OK: edit ESC: back"); - UISetupPage::apply_fixed_label_box(hint, UISetupPage::SC_MARGIN_X, UISetupPage::LIST_H - 14, UISetupPage::SC_BOTTOM_HINT_W, false); - lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); -} - -// ==================================================================== -// Build: detail / input view -// ==================================================================== -void SoundCard::build_detail_view(UISetupPage &page) -{ - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - input_lbl_ = nullptr; - hint_lbl_ = nullptr; - - // Control name (header) - lv_obj_t *name_lbl = lv_label_create(cont); - lv_label_set_text(name_lbl, detail_.name.c_str()); - UISetupPage::apply_fixed_label_box(name_lbl, UISetupPage::SC_MARGIN_X, 4, UISetupPage::SC_DETAIL_TEXT_W, true); - lv_obj_set_style_text_color(name_lbl, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(name_lbl, launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - - // Info block: Limits + current value - char info_buf[128]; - std::snprintf(info_buf, sizeof(info_buf), - "Limits: %d - %d", detail_.min_val, detail_.max_val); - lv_obj_t *limits_lbl = lv_label_create(cont); - lv_label_set_text(limits_lbl, info_buf); - UISetupPage::apply_fixed_label_box(limits_lbl, UISetupPage::SC_MARGIN_X, 26, UISetupPage::SC_DETAIL_TEXT_W, false); - lv_obj_set_style_text_color(limits_lbl, lv_color_hex(0xAAAAAA), LV_PART_MAIN); - lv_obj_set_style_text_font(limits_lbl, &lv_font_montserrat_12, LV_PART_MAIN); - - if (!detail_.current_str.empty()) { - lv_obj_t *cur_lbl = lv_label_create(cont); - lv_label_set_text(cur_lbl, detail_.current_str.c_str()); - UISetupPage::apply_fixed_label_box(cur_lbl, UISetupPage::SC_MARGIN_X, 44, UISetupPage::SC_DETAIL_TEXT_W, true); - lv_obj_set_style_text_color(cur_lbl, lv_color_hex(0xCCCCCC), LV_PART_MAIN); - lv_obj_set_style_text_font(cur_lbl, &lv_font_montserrat_12, LV_PART_MAIN); - } - - // Separator line - lv_obj_t *sep = lv_obj_create(cont); - lv_obj_set_size(sep, UISetupPage::SCREEN_W - 16, 1); - lv_obj_set_pos(sep, 8, 64); - lv_obj_set_style_bg_color(sep, lv_color_hex(0x333333), LV_PART_MAIN); - lv_obj_set_style_bg_opa(sep, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(sep, 0, LV_PART_MAIN); - lv_obj_clear_flag(sep, LV_OBJ_FLAG_SCROLLABLE); - - // "New value:" label - lv_obj_t *new_lbl = lv_label_create(cont); - lv_label_set_text(new_lbl, "New value:"); - UISetupPage::apply_fixed_label_box(new_lbl, UISetupPage::SC_MARGIN_X, 72, UISetupPage::SC_INPUT_X - UISetupPage::SC_MARGIN_X - 4, false); - lv_obj_set_style_text_color(new_lbl, lv_color_hex(0xCCCCCC), LV_PART_MAIN); - lv_obj_set_style_text_font(new_lbl, &lv_font_montserrat_12, LV_PART_MAIN); - - // Input field (digits + cursor) - cursor_vis_ = true; - input_lbl_ = lv_label_create(cont); - input_update_display(); - UISetupPage::apply_fixed_label_box(input_lbl_, UISetupPage::SC_INPUT_X, 70, UISetupPage::SC_INPUT_W, false); - lv_obj_set_style_text_color(input_lbl_, lv_color_hex(0xFFFFFF), LV_PART_MAIN); - lv_obj_set_style_text_font(input_lbl_, &lv_font_montserrat_14, LV_PART_MAIN); - - // Blinking cursor timer (500 ms period) - cursor_timer_ = lv_timer_create([](lv_timer_t *timer) { - auto *self = static_cast(lv_timer_get_user_data(timer)); - self->cursor_vis_ = !self->cursor_vis_; - self->input_update_display(); - }, 500, this); - - // Range hint below input - char range_buf[64]; - std::snprintf(range_buf, sizeof(range_buf), "Range: %d ~ %d", - detail_.min_val, detail_.max_val); - hint_lbl_ = lv_label_create(cont); - lv_label_set_text(hint_lbl_, range_buf); - UISetupPage::apply_fixed_label_box(hint_lbl_, UISetupPage::SC_MARGIN_X, 92, UISetupPage::SC_DETAIL_TEXT_W, false); - lv_obj_set_style_text_color(hint_lbl_, lv_color_hex(0x666666), LV_PART_MAIN); - lv_obj_set_style_text_font(hint_lbl_, &lv_font_montserrat_10, LV_PART_MAIN); - - // Bottom hint - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, "0-9:type BS:del OK:apply ESC:back"); - UISetupPage::apply_fixed_label_box(hint, UISetupPage::SC_MARGIN_X, UISetupPage::LIST_H - 14, UISetupPage::SC_BOTTOM_HINT_W, true); - lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); -} - -void SoundCard::input_update_display() -{ - if (!input_lbl_) return; - std::string disp = input_buf_ + (cursor_vis_ ? "_" : " "); - lv_label_set_text(input_lbl_, disp.c_str()); -} - -void SoundCard::cursor_stop() -{ - if (cursor_timer_) { - lv_timer_del(cursor_timer_); - cursor_timer_ = nullptr; - } - cursor_vis_ = true; -} - // Apply the typed value via cp0_signal_soundcard_api void SoundCard::apply_value(UISetupPage &page) { if (input_buf_.empty()) return; - int new_val = std::atoi(input_buf_.c_str()); - // Clamp to declared limits when they are known - if (detail_.max_val > detail_.min_val) { - if (new_val < detail_.min_val) new_val = detail_.min_val; - if (new_val > detail_.max_val) new_val = detail_.max_val; - } + const int new_val = SoundCardModel::clamp_value( + SoundCardModel::parse_value(input_buf_, detail_.current_value), detail_); // Visual feedback while applying if (hint_lbl_) { @@ -437,9 +110,11 @@ void SoundCard::apply_value(UISetupPage &page) } int rc = -1; - cp0_signal_soundcard_api( - {"SetControl", std::to_string(card_idx_), detail_.name, std::to_string(new_val)}, - [&rc](int code, std::string) { rc = code; }); + sound_card_invoke_backend([&] { + cp0_signal_soundcard_api( + {"SetControl", std::to_string(card_idx_), detail_.name, std::to_string(new_val)}, + [&rc](int code, std::string) { rc = code; }); + }); if (hint_lbl_) { if (rc == 0) { @@ -456,21 +131,31 @@ void SoundCard::apply_value(UISetupPage &page) if (rc == 0 && ctrl_sel_ < (int)controls_.size()) { char val_str[32]; std::snprintf(val_str, sizeof(val_str), "%d", new_val); - controls_[ctrl_sel_].current_val = new_val; - controls_[ctrl_sel_].current_str = val_str; + controls_[ctrl_sel_].current_value = new_val; + controls_[ctrl_sel_].current_text = val_str; } // Go back to control list after a short pause - cursor_stop(); - input_lbl_ = nullptr; - hint_lbl_ = nullptr; - page.view_state_ = UISetupPage::ViewState::SOUNDCARD_CONTROLS; - lv_timer_t *t = lv_timer_create([](lv_timer_t *timer) { - auto *self = static_cast(lv_timer_get_user_data(timer)); - lv_timer_del(timer); - self->transition_back_level(); - }, 900, &page); - (void)t; + clear_detail_view(); + SetupPageAccess(page).set_view(SetupViewState::SOUNDCARD_CONTROLS); + stop_transition_timer(); + if (!transition_model_.begin()) return; + transition_page_ = &page; + transition_timer_ = lv_timer_create(transition_timer_cb, 900, this); + if (transition_timer_ && page.screen()) { + lv_timer_set_repeat_count(transition_timer_, 1); + lv_obj_add_event_cb(page.screen(), SoundCard::transition_screen_delete_cb, + LV_EVENT_DELETE, this); + } else { + if (transition_timer_) { + lv_timer_delete(transition_timer_); + transition_timer_ = nullptr; + } + transition_page_ = nullptr; + if (transition_model_.complete() && page.screen() && + SetupPageAccess(page).is_view(SetupViewState::SOUNDCARD_CONTROLS)) + SetupPageAccess(page).transition_back(); + } } // ==================================================================== @@ -488,13 +173,15 @@ void SoundCard::handle_cards_key(UISetupPage &page, uint32_t key) break; case KEY_ENTER: case KEY_RIGHT: - if (total > 0) { page.play_enter(); enter_controls(page); } + if (total > 0) { SetupPageAccess(page).play_enter(); enter_controls(page); } break; case KEY_ESC: case KEY_LEFT: - page.play_back(); - page.view_state_ = UISetupPage::ViewState::SUB; - page.transition_back_level(); + stop_transition_timer(); + clear_detail_view(); + SetupPageAccess(page).play_back(); + SetupPageAccess(page).set_view(SetupViewState::SUB); + SetupPageAccess(page).transition_back(); break; default: break; @@ -513,13 +200,15 @@ void SoundCard::handle_controls_key(UISetupPage &page, uint32_t key) break; case KEY_ENTER: case KEY_RIGHT: - if (total > 0) { page.play_enter(); enter_detail(page); } + if (total > 0) { SetupPageAccess(page).play_enter(); enter_detail(page); } break; case KEY_ESC: case KEY_LEFT: - page.play_back(); - page.view_state_ = UISetupPage::ViewState::SOUNDCARD_CARDS; - page.transition_back_level(); + stop_transition_timer(); + clear_detail_view(); + SetupPageAccess(page).play_back(); + SetupPageAccess(page).set_view(SetupViewState::SOUNDCARD_CARDS); + SetupPageAccess(page).transition_back(); break; default: break; @@ -555,16 +244,17 @@ void SoundCard::handle_detail_key(UISetupPage &page, uint32_t key) break; case KEY_ESC: case KEY_LEFT: - cursor_stop(); - page.play_back(); - page.view_state_ = UISetupPage::ViewState::SOUNDCARD_CONTROLS; - page.transition_back_level(); + clear_detail_view(); + SetupPageAccess(page).play_back(); + SetupPageAccess(page).set_view(SetupViewState::SOUNDCARD_CONTROLS); + SetupPageAccess(page).transition_back(); break; default: - // Also accept typed digit characters forwarded via page.cur_elm_->utf8 - if (page.cur_elm_ && page.cur_elm_->utf8[0] >= '0' && page.cur_elm_->utf8[0] <= '9') { + // Also accept typed digit characters forwarded by the input layer. + const std::string_view utf8 = SetupPageAccess(page).current_utf8(); + if (!utf8.empty() && utf8[0] >= '0' && utf8[0] <= '9') { if (input_buf_.size() < 8) { - input_buf_ += page.cur_elm_->utf8[0]; + input_buf_ += utf8[0]; input_update_display(); } } @@ -572,36 +262,102 @@ void SoundCard::handle_detail_key(UISetupPage &page, uint32_t key) } } +void SoundCard::clear_detail_view() +{ + cursor_stop(); + if (input_lbl_) + lv_obj_remove_event_cb_with_user_data( + input_lbl_, SoundCard::input_label_delete_cb, this); + if (hint_lbl_) + lv_obj_remove_event_cb_with_user_data( + hint_lbl_, SoundCard::hint_label_delete_cb, this); + input_lbl_ = nullptr; + hint_lbl_ = nullptr; +} + +void SoundCard::stop_transition_timer() +{ + if (transition_page_ && transition_page_->screen()) + lv_obj_remove_event_cb_with_user_data( + transition_page_->screen(), SoundCard::transition_screen_delete_cb, this); + if (transition_timer_) { + lv_timer_del(transition_timer_); + transition_timer_ = nullptr; + } + transition_page_ = nullptr; + transition_model_.cancel(); +} + +void SoundCard::input_label_delete_cb(lv_event_t *event) noexcept +{ + if (!event) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self || !sound_card_owned_label_delete_matches( + lv_event_get_target(event), lv_event_get_current_target(event), + self->input_lbl_)) return; + self->input_lbl_ = nullptr; + self->cursor_stop(); +} + +void SoundCard::hint_label_delete_cb(lv_event_t *event) noexcept +{ + if (!event) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self || !sound_card_owned_label_delete_matches( + lv_event_get_target(event), lv_event_get_current_target(event), + self->hint_lbl_)) return; + self->hint_lbl_ = nullptr; +} + +void SoundCard::transition_timer_cb(lv_timer_t *timer) noexcept +{ + auto *self = static_cast(lv_timer_get_user_data(timer)); + if (!self || !sound_card_transition_timer_callback_allowed( + timer, self->transition_timer_, self->transition_page_, + self->transition_model_.pending())) return; + try { + UISetupPage *page = self->transition_page_; + if (page->screen()) + lv_obj_remove_event_cb_with_user_data( + page->screen(), transition_screen_delete_cb, self); + self->transition_timer_ = nullptr; + self->transition_page_ = nullptr; + if (!self->transition_model_.complete()) return; + if (SetupPageAccess(*page).is_view(SetupViewState::SOUNDCARD_CONTROLS)) + SetupPageAccess(*page).transition_back(); + } catch (...) { + self->transition_timer_ = nullptr; + self->transition_page_ = nullptr; + self->transition_model_.cancel(); + } +} + +void SoundCard::transition_screen_delete_cb(lv_event_t *event) noexcept +{ + if (!event) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self || !self->transition_page_ || + !sound_card_transition_screen_delete_matches( + lv_event_get_target(event), lv_event_get_current_target(event), + self->transition_page_->screen())) return; + self->transition_page_ = nullptr; + if (self->transition_timer_) { + lv_timer_del(self->transition_timer_); + self->transition_timer_ = nullptr; + } + self->transition_model_.cancel(); +} + void SoundCard::append(UISetupPage &p, std::vector &menu) { UISetupPage *page = &p; + SoundCard *controller = this; MenuItem m; m.label = "SoundCard"; - m.sub_items = {{"Open Mixer", false, false, [page]() { page->soundcard_.enter_cards(*page); }}}; + m.sub_items = {{"Open Mixer", false, false, [page, controller]() { controller->enter_cards(*page); }}}; menu.push_back(m); } -void build_menu(UISetupPage &page) -{ - page.menu_items_.clear(); - Launcher::append(page, page.menu_items_); - Boot::append(page, page.menu_items_); - Screen::append(page, page.menu_items_); - WiFi::append(page, page.menu_items_); - Speaker::append(page, page.menu_items_); - Camera::append(page, page.menu_items_); - Info::append(page, page.menu_items_); - About::append(page, page.menu_items_); - Help::append(page, page.menu_items_); - ExtPort::append(page, page.menu_items_); - Developer::append(page, page.menu_items_); - RTC::append(page, page.menu_items_); - Bluetooth::append(page, page.menu_items_); - Ethernet::append(page, page.menu_items_); - Account::append(page, page.menu_items_); - Update::append(page, page.menu_items_); - SoundCard::append(page, page.menu_items_); -} } // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/sound_card.hpp b/projects/APPLaunch/main/ui/page_app/setting/sound_card.hpp new file mode 100644 index 00000000..07bcd641 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/sound_card.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "menu_types.hpp" +#include "../../model/sound_card_model.hpp" + +#include + +#include +#include +#include + +class UISetupPage; + +namespace setting { + +class SoundCard +{ +public: + SoundCard() = default; + ~SoundCard(); + void shutdown(); + + void append(UISetupPage &page, std::vector &menu); + void enter_cards(UISetupPage &page); + void enter_controls(UISetupPage &page); + void enter_detail(UISetupPage &page); + void build_cards_view(UISetupPage &page); + void build_controls_view(UISetupPage &page); + void build_detail_view(UISetupPage &page); + void handle_cards_key(UISetupPage &page, uint32_t key); + void handle_controls_key(UISetupPage &page, uint32_t key); + void handle_detail_key(UISetupPage &page, uint32_t key); + +private: + void input_update_display(); + void cursor_stop(); + void clear_detail_view(); + void stop_transition_timer(); + static void cursor_timer_cb(lv_timer_t *timer) noexcept; + static void input_label_delete_cb(lv_event_t *event) noexcept; + static void hint_label_delete_cb(lv_event_t *event) noexcept; + static void transition_timer_cb(lv_timer_t *timer) noexcept; + static void transition_screen_delete_cb(lv_event_t *event) noexcept; + void apply_value(UISetupPage &page); + + std::vector cards_; + std::vector controls_; + int card_sel_ = 0; + int ctrl_sel_ = 0; + int card_idx_ = -1; + SoundControlInfo detail_; + std::string input_buf_; + lv_obj_t *input_lbl_ = nullptr; + lv_obj_t *hint_lbl_ = nullptr; + lv_timer_t *cursor_timer_ = nullptr; + bool cursor_callback_enabled_ = false; + lv_timer_t *transition_timer_ = nullptr; + UISetupPage *transition_page_ = nullptr; + SoundCardTransitionModel transition_model_; + bool cursor_vis_ = true; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/sound_card_view.cpp b/projects/APPLaunch/main/ui/page_app/setting/sound_card_view.cpp new file mode 100644 index 00000000..fa52799a --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/sound_card_view.cpp @@ -0,0 +1,306 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +namespace setting { + +void SoundCard::build_cards_view(UISetupPage &page) +{ + clear_detail_view(); + SetupPageAccess access(page); + const SetupLayout layout = access.layout(); + lv_obj_t *cont = access.content_container(); + if (!cont) return; + lv_obj_clean(cont); + + // Title + lv_obj_t *title = lv_label_create(cont); + lv_label_set_text(title, "Sound Cards"); + access.apply_fixed_label_box(title, layout.sc_margin_x, 4, layout.sc_detail_text_width, false); + lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); + lv_obj_set_style_text_font(title, launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); + + if (cards_.empty()) { + lv_obj_t *lbl = lv_label_create(cont); + lv_label_set_text(lbl, "No ALSA cards found."); + access.apply_fixed_label_box(lbl, layout.sc_margin_x, 40, layout.sc_detail_text_width, false); + lv_obj_set_style_text_color(lbl, lv_color_hex(0x888888), LV_PART_MAIN); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN); + + lv_obj_t *hint = lv_label_create(cont); + lv_label_set_text(hint, "ESC: back"); + access.apply_fixed_label_box(hint, layout.sc_margin_x, layout.content_height - 14, layout.sc_bottom_hint_width, false); + lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); + return; + } + + int visible = 5; + int total = (int)cards_.size(); + int offset = card_sel_ - visible / 2; + if (offset < 0) offset = 0; + if (total > visible && offset > total - visible) offset = total - visible; + + for (int vi = 0; vi < visible && (vi + offset) < total; ++vi) { + int ai = vi + offset; + bool sel = (ai == card_sel_); + int y = 22 + vi * 22; + + if (sel) { + lv_obj_t *bg = lv_obj_create(cont); + lv_obj_set_size(bg, layout.screen_width - 8, 20); + lv_obj_set_pos(bg, 4, y); + lv_obj_set_style_radius(bg, 2, LV_PART_MAIN); + lv_obj_set_style_bg_color(bg, lv_color_hex(0x1F3A5F), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN); + lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); + lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); + } + + lv_obj_t *lbl = lv_label_create(cont); + lv_label_set_text(lbl, cards_[ai].name.c_str()); + access.apply_fixed_label_box(lbl, layout.sc_row_x, y + 2, layout.sc_card_name_width, sel); + lv_obj_set_style_text_color(lbl, lv_color_hex(sel ? 0xFFFFFF : 0xCCCCCC), LV_PART_MAIN); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN); + } + + lv_obj_t *hint = lv_label_create(cont); + lv_label_set_text(hint, "OK: open ESC: back"); + access.apply_fixed_label_box(hint, layout.sc_margin_x, layout.content_height - 14, layout.sc_bottom_hint_width, false); + lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); +} + +// ==================================================================== +// Build: control list view +// ==================================================================== +void SoundCard::build_controls_view(UISetupPage &page) +{ + clear_detail_view(); + SetupPageAccess access(page); + const SetupLayout layout = access.layout(); + lv_obj_t *cont = access.content_container(); + if (!cont) return; + lv_obj_clean(cont); + + // Title: card name + char title_buf[80]; + if (!cards_.empty() && card_sel_ < (int)cards_.size()) + std::snprintf(title_buf, sizeof(title_buf), "%s", cards_[card_sel_].name.c_str()); + else + std::snprintf(title_buf, sizeof(title_buf), "Card %d", card_idx_); + + lv_obj_t *title = lv_label_create(cont); + lv_label_set_text(title, title_buf); + access.apply_fixed_label_box(title, layout.sc_margin_x, 4, layout.sc_detail_text_width, true); + lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); + lv_obj_set_style_text_font(title, launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); + + if (controls_.empty()) { + lv_obj_t *lbl = lv_label_create(cont); + lv_label_set_text(lbl, "No controls found."); + access.apply_fixed_label_box(lbl, layout.sc_margin_x, 40, layout.sc_detail_text_width, false); + lv_obj_set_style_text_color(lbl, lv_color_hex(0x888888), LV_PART_MAIN); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN); + + lv_obj_t *hint = lv_label_create(cont); + lv_label_set_text(hint, "ESC: back"); + access.apply_fixed_label_box(hint, layout.sc_margin_x, layout.content_height - 14, layout.sc_bottom_hint_width, false); + lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); + return; + } + + int visible = 5; + int total = (int)controls_.size(); + int offset = ctrl_sel_ - visible / 2; + if (offset < 0) offset = 0; + if (total > visible && offset > total - visible) offset = total - visible; + + for (int vi = 0; vi < visible && (vi + offset) < total; ++vi) { + int ai = vi + offset; + bool sel = (ai == ctrl_sel_); + int y = 20 + vi * 22; + + if (sel) { + lv_obj_t *bg = lv_obj_create(cont); + lv_obj_set_size(bg, layout.screen_width - 8, 20); + lv_obj_set_pos(bg, 4, y); + lv_obj_set_style_radius(bg, 2, LV_PART_MAIN); + lv_obj_set_style_bg_color(bg, lv_color_hex(0x1F3A5F), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN); + lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); + lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); + } + + const auto &ctrl = controls_[ai]; + + // Left: control name + lv_obj_t *name_lbl = lv_label_create(cont); + lv_label_set_text(name_lbl, ctrl.name.c_str()); + access.apply_fixed_label_box(name_lbl, layout.sc_control_name_x, y + 2, layout.sc_control_name_width, sel); + lv_obj_set_style_text_color(name_lbl, lv_color_hex(sel ? 0xFFFFFF : 0xCCCCCC), LV_PART_MAIN); + lv_obj_set_style_text_font(name_lbl, &lv_font_montserrat_12, LV_PART_MAIN); + + // Right: current value summary + if (!ctrl.current_text.empty()) { + lv_obj_t *val_lbl = lv_label_create(cont); + lv_label_set_text(val_lbl, ctrl.current_text.c_str()); + access.apply_fixed_label_box(val_lbl, layout.sc_control_value_x, y + 2, layout.sc_control_value_width, sel); + lv_obj_set_style_text_color(val_lbl, lv_color_hex(sel ? 0xAADDFF : 0x888888), LV_PART_MAIN); + lv_obj_set_style_text_font(val_lbl, &lv_font_montserrat_10, LV_PART_MAIN); + } + } + + // Scroll arrows + if (ctrl_sel_ > 0) { + lv_obj_t *a = lv_img_create(cont); + lv_img_set_src(a, access.arrow_up_path().c_str()); + lv_obj_set_pos(a, layout.screen_width / 2 - 8, 2); + } + if (ctrl_sel_ < total - 1) { + lv_obj_t *a = lv_img_create(cont); + lv_img_set_src(a, access.arrow_down_path().c_str()); + lv_obj_set_pos(a, layout.screen_width / 2 - 8, layout.content_height - 28); + } + + lv_obj_t *hint = lv_label_create(cont); + lv_label_set_text(hint, "OK: edit ESC: back"); + access.apply_fixed_label_box(hint, layout.sc_margin_x, layout.content_height - 14, layout.sc_bottom_hint_width, false); + lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); +} + +// ==================================================================== +// Build: detail / input view +// ==================================================================== +void SoundCard::build_detail_view(UISetupPage &page) +{ + clear_detail_view(); + SetupPageAccess access(page); + const SetupLayout layout = access.layout(); + lv_obj_t *cont = access.content_container(); + if (!cont) return; + lv_obj_clean(cont); + + // Control name (header) + lv_obj_t *name_lbl = lv_label_create(cont); + lv_label_set_text(name_lbl, detail_.name.c_str()); + access.apply_fixed_label_box(name_lbl, layout.sc_margin_x, 4, layout.sc_detail_text_width, true); + lv_obj_set_style_text_color(name_lbl, lv_color_hex(0x58A6FF), LV_PART_MAIN); + lv_obj_set_style_text_font(name_lbl, launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); + + // Info block: Limits + current value + char info_buf[128]; + std::snprintf(info_buf, sizeof(info_buf), + "Limits: %d - %d", detail_.min_value, detail_.max_value); + lv_obj_t *limits_lbl = lv_label_create(cont); + lv_label_set_text(limits_lbl, info_buf); + access.apply_fixed_label_box(limits_lbl, layout.sc_margin_x, 26, layout.sc_detail_text_width, false); + lv_obj_set_style_text_color(limits_lbl, lv_color_hex(0xAAAAAA), LV_PART_MAIN); + lv_obj_set_style_text_font(limits_lbl, &lv_font_montserrat_12, LV_PART_MAIN); + + if (!detail_.current_text.empty()) { + lv_obj_t *cur_lbl = lv_label_create(cont); + lv_label_set_text(cur_lbl, detail_.current_text.c_str()); + access.apply_fixed_label_box(cur_lbl, layout.sc_margin_x, 44, layout.sc_detail_text_width, true); + lv_obj_set_style_text_color(cur_lbl, lv_color_hex(0xCCCCCC), LV_PART_MAIN); + lv_obj_set_style_text_font(cur_lbl, &lv_font_montserrat_12, LV_PART_MAIN); + } + + // Separator line + lv_obj_t *sep = lv_obj_create(cont); + lv_obj_set_size(sep, layout.screen_width - 16, 1); + lv_obj_set_pos(sep, 8, 64); + lv_obj_set_style_bg_color(sep, lv_color_hex(0x333333), LV_PART_MAIN); + lv_obj_set_style_bg_opa(sep, 255, LV_PART_MAIN); + lv_obj_set_style_border_width(sep, 0, LV_PART_MAIN); + lv_obj_clear_flag(sep, LV_OBJ_FLAG_SCROLLABLE); + + // "New value:" label + lv_obj_t *new_lbl = lv_label_create(cont); + lv_label_set_text(new_lbl, "New value:"); + access.apply_fixed_label_box(new_lbl, layout.sc_margin_x, 72, layout.sc_input_x - layout.sc_margin_x - 4, false); + lv_obj_set_style_text_color(new_lbl, lv_color_hex(0xCCCCCC), LV_PART_MAIN); + lv_obj_set_style_text_font(new_lbl, &lv_font_montserrat_12, LV_PART_MAIN); + + // Input field (digits + cursor) + cursor_vis_ = true; + lv_obj_t *candidate_input = lv_label_create(cont); + if (!candidate_input) return; + const std::string initial_display = input_buf_ + "_"; + lv_label_set_text(candidate_input, initial_display.c_str()); + access.apply_fixed_label_box(candidate_input, layout.sc_input_x, 70, layout.sc_input_width, false); + lv_obj_set_style_text_color(candidate_input, lv_color_hex(0xFFFFFF), LV_PART_MAIN); + lv_obj_set_style_text_font(candidate_input, &lv_font_montserrat_14, LV_PART_MAIN); + + // Range hint below input + char range_buf[64]; + std::snprintf(range_buf, sizeof(range_buf), "Range: %d ~ %d", + detail_.min_value, detail_.max_value); + lv_obj_t *candidate_hint = lv_label_create(cont); + if (!candidate_hint) { + lv_obj_delete(candidate_input); + return; + } + lv_label_set_text(candidate_hint, range_buf); + access.apply_fixed_label_box(candidate_hint, layout.sc_margin_x, 92, layout.sc_detail_text_width, false); + lv_obj_set_style_text_color(candidate_hint, lv_color_hex(0x666666), LV_PART_MAIN); + lv_obj_set_style_text_font(candidate_hint, &lv_font_montserrat_10, LV_PART_MAIN); + + lv_timer_t *candidate_timer = lv_timer_create(cursor_timer_cb, 500, this); + if (!candidate_timer) { + lv_obj_delete(candidate_hint); + lv_obj_delete(candidate_input); + return; + } + + input_lbl_ = candidate_input; + hint_lbl_ = candidate_hint; + cursor_timer_ = candidate_timer; + cursor_callback_enabled_ = true; + lv_obj_add_event_cb(input_lbl_, SoundCard::input_label_delete_cb, LV_EVENT_DELETE, this); + lv_obj_add_event_cb(hint_lbl_, SoundCard::hint_label_delete_cb, LV_EVENT_DELETE, this); + + // Bottom hint + lv_obj_t *hint = lv_label_create(cont); + lv_label_set_text(hint, "0-9:type BS:del OK:apply ESC:back"); + access.apply_fixed_label_box(hint, layout.sc_margin_x, layout.content_height - 14, layout.sc_bottom_hint_width, true); + lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); +} + +void SoundCard::input_update_display() +{ + if (!input_lbl_) return; + std::string disp = input_buf_ + (cursor_vis_ ? "_" : " "); + lv_label_set_text(input_lbl_, disp.c_str()); +} + +void SoundCard::cursor_timer_cb(lv_timer_t *timer) noexcept +{ + auto *self = static_cast(lv_timer_get_user_data(timer)); + if (!self || !self->cursor_callback_enabled_ || + !sound_card_cursor_callback_allowed( + timer, self->cursor_timer_, self->input_lbl_, + self->cursor_callback_enabled_)) return; + try { + self->cursor_vis_ = !self->cursor_vis_; + self->input_update_display(); + } catch (...) { + self->cursor_callback_enabled_ = false; + } +} + +void SoundCard::cursor_stop() +{ + cursor_callback_enabled_ = false; + if (cursor_timer_) { + lv_timer_del(cursor_timer_); + cursor_timer_ = nullptr; + } + cursor_vis_ = true; +} + + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/system.cpp b/projects/APPLaunch/main/ui/page_app/setting/system.cpp index 22292ee1..f72d42c1 100644 --- a/projects/APPLaunch/main/ui/page_app/setting/system.cpp +++ b/projects/APPLaunch/main/ui/page_app/setting/system.cpp @@ -1,663 +1,266 @@ #define APP_PAGE_IMPLEMENTATION_UNIT #include "../ui_app_setup.hpp" +#include "../../model/system_page_model.hpp" +#include "setup_page_access.hpp" namespace setting { -Developer::Developer() : async_state_(std::make_shared()) {} +namespace { -Developer::~Developer() +void apply_extport_toggle(UISetupPage &page, + std::size_t index, + const char *gpio_name) { - async_state_->alive = false; - if (async_state_->request_id) cp0_signal_sudo_cancel(async_state_->request_id, nullptr); - close_status(); -} - -void Developer::append(UISetupPage &p, std::vector &menu) -{ - UISetupPage *page = &p; - MenuItem m; - m.label = "Developer"; - bool adb_en = UISetupPage::config_get_int("adb_debug", 0) != 0; - m.sub_items = { - {"ADB", true, adb_en, [page]() { page->developer_.toggle_adb(*page); }}, - {"Pair host key", false, false, [page]() { page->developer_.enter_pair_view(*page); }}, - {"Authorizations: 0", false, false, nullptr}, - {"Manage authorized hosts", false, false, [page]() { page->developer_.enter_revoke_view(*page); }}, - {"Clear authorizations", false, false, [page]() { - page->enter_confirm_action("Clear ADB authorizations?", [page]() { - page->developer_.clear_authorizations(*page); - }); - }}, - }; - m.on_enter = [page]() { page->developer_.refresh_adb_status(*page); }; - menu.push_back(m); -} - -void Developer::toggle_adb(UISetupPage &page) -{ - int idx = page.find_menu("Developer"); - if (idx < 0) return; - if (async_state_->request_id) { - page.menu_items_[idx].sub_items[0].toggle_state = - !page.menu_items_[idx].sub_items[0].toggle_state; - return; - } - bool want_on = page.menu_items_[idx].sub_items[0].toggle_state; - const bool previous = !want_on; - if (want_on) { - refresh_adb_status(page); - if (page.menu_items_[idx].sub_items[2].label == "Authorizations: 0") { - page.menu_items_[idx].sub_items[0].toggle_state = false; - enter_pair_view(page, true); - return; - } - } - show_status(want_on ? "Enabling ADB" : "Disabling ADB", "Please wait...", Modal::BUSY); - struct Context { - Developer *developer; - UISetupPage *page; - std::weak_ptr state; - bool desired; - bool previous; - }; - auto *ctx = new (std::nothrow) Context{this, &page, async_state_, want_on, previous}; - if (!ctx) { - update_toggle(page, previous, false); - show_status("ADB update failed", "Out of memory", Modal::ERROR); - return; - } - uint64_t request_id = 0; - int rc = -1; - cp0_signal_system_admin_async({"AdbSet", want_on ? "1" : "0"}, 60000, 300000, - [ctx](int result_code, int exit_code) { - std::unique_ptr owned(ctx); - cp0_sudo_result_t result = static_cast(result_code); - auto state = ctx->state.lock(); - if (!state || !state->alive) return; - state->request_id = 0; - if (adb_toggle_succeeded(result, exit_code)) { - ctx->developer->update_toggle(*ctx->page, ctx->desired, true); - ctx->developer->close_status(); - if (adb_reboot_required(result, exit_code)) - ctx->developer->enter_usb_guide(*ctx->page, ctx->desired); - else - ctx->page->build_sub_view(); - return; - } - if (!ctx->developer->refresh_adb_status(*ctx->page)) - ctx->developer->update_toggle(*ctx->page, ctx->previous, false); - if (result == CP0_SUDO_RESULT_CANCELLED) { - ctx->developer->close_status(); - ctx->page->build_sub_view(); - } else { - ctx->developer->show_result_error(result, exit_code); - } - }, [&](int code, uint64_t id) { rc = code; request_id = id; }); - if (rc != 0) { - delete ctx; - update_toggle(page, previous, false); - show_status("ADB update failed", "Unable to start request", Modal::ERROR); - return; - } - async_state_->request_id = request_id; -} - -bool Developer::refresh_adb_status(UISetupPage &page) -{ - int idx = page.find_menu("Developer"); - if (idx < 0) return false; - int rc = -1; - std::string out; - cp0_signal_process_api({"AdbStatus"}, - [&](int code, std::string data) { rc = code; out = std::move(data); }); - if (rc != 0) return false; - AdbStatus status = parse_adb_status(out.c_str()); - if (!status.valid) return false; - update_toggle(page, adb_state_after_failure(status, false), true); - char count[32]; - snprintf(count, sizeof(count), "Authorizations: %d", status.authorizations); - page.menu_items_[idx].sub_items[2].label = count; - authorizations_ = parse_adb_authorizations(out.c_str()); - if (authorization_selected_ >= static_cast(authorizations_.size())) - authorization_selected_ = std::max(0, static_cast(authorizations_.size()) - 1); - return true; -} - -void Developer::enter_pair_view(UISetupPage &page, bool enable_after_pair) -{ - enable_after_pair_ = enable_after_pair; - pair_key_.clear(); - page.view_state_ = UISetupPage::ViewState::ADB_PAIR; - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - - guide_label(cont, 8, 5, "Pair ADB host", 0xFFFFFF, - launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD)); - guide_label(cont, 8, 28, "Paste the host's adbkey.pub:", 0xAAAAAA, &lv_font_montserrat_10); - pair_input_label_ = guide_label(cont, 8, 48, "_", 0xFFFFFF, &lv_font_montserrat_10); - lv_obj_set_width(pair_input_label_, 304); - lv_label_set_long_mode(pair_input_label_, LV_LABEL_LONG_CLIP); - pair_hint_label_ = guide_label(cont, 8, 82, "OK: authorize ESC: cancel", 0x777777, - &lv_font_montserrat_10); -} - -void Developer::enter_revoke_view(UISetupPage &page) -{ - refresh_adb_status(page); - authorization_selected_ = 0; - page.view_state_ = UISetupPage::ViewState::ADB_AUTHORIZATIONS; - build_authorizations_view(page); -} - -void Developer::build_authorizations_view(UISetupPage &page) -{ - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - guide_label(cont, 8, 3, "Authorized ADB hosts", 0xFFFFFF, - launcher_fonts().get("Montserrat-Bold.ttf", 13, LV_FREETYPE_FONT_STYLE_BOLD)); - if (authorizations_.empty()) { - guide_label(cont, 8, 48, "No authorized hosts", 0x888888, &lv_font_montserrat_12); - } else { - const int first = std::max(0, authorization_selected_ - 1); - const int last = std::min(static_cast(authorizations_.size()), first + 3); - for (int i = first; i < last; ++i) { - const int y = 27 + (i - first) * 28; - if (i == authorization_selected_) - guide_chip(cont, 4, y - 2, 312, 25, 0x2A2A2A, 0x444444, 2, 0); - std::string label = authorizations_[i].label; - if (label.size() > 25) label.resize(25); - guide_label(cont, 10, y, label.c_str(), i == authorization_selected_ ? 0xFFFFFF : 0xAAAAAA, - &lv_font_montserrat_10); - std::string short_id = authorizations_[i].fingerprint.substr(0, 12); - guide_label(cont, 218, y, short_id.c_str(), 0x666666, &lv_font_montserrat_10); - } - } - guide_label(cont, 8, UISetupPage::LIST_H - 15, - authorizations_.empty() ? "ESC: back" : "OK: revoke ESC: back", - 0x777777, &lv_font_montserrat_10); -} + SetupPageAccess access(page); + MenuItem *menu = access.find_menu("ExtPort"); + if (!menu || index >= menu->sub_items.size()) return; -void Developer::handle_authorizations_key(UISetupPage &page, uint32_t key) -{ - if (key == KEY_ESC || key == KEY_LEFT) { - page.view_state_ = UISetupPage::ViewState::SUB; - page.build_sub_view(); - return; - } - if (key == KEY_UP && authorization_selected_ > 0) { - --authorization_selected_; - build_authorizations_view(page); - } else if (key == KEY_DOWN && authorization_selected_ + 1 < static_cast(authorizations_.size())) { - ++authorization_selected_; - build_authorizations_view(page); - } else if ((key == KEY_ENTER || key == KEY_RIGHT) && !authorizations_.empty()) { - const std::string fingerprint = authorizations_[authorization_selected_].fingerprint; - UISetupPage *page_ptr = &page; - page.enter_confirm_action("Revoke selected ADB host?", [this, page_ptr, fingerprint]() { - run_admin_action(*page_ptr, {"AdbRevoke", fingerprint}, "Revoking ADB host", - "Removing this key...", false); - }); - } + SubItem &item = menu->sub_items[index]; + const bool desired = item.toggle_state; + const int current = access.gpio_get(gpio_name); + const bool previous = current >= 0 ? current != 0 : !desired; + const bool gpio_succeeded = access.gpio_set(gpio_name, desired ? 1 : 0); + item.toggle_state = system_page::extport_toggle_value(previous, desired, gpio_succeeded); } -void Developer::handle_pair_key(UISetupPage &page, uint32_t key) -{ - if (key == KEY_ESC || key == KEY_LEFT) { - pair_key_.clear(); - page.view_state_ = UISetupPage::ViewState::SUB; - page.build_sub_view(); - return; - } - if (key == KEY_ENTER) { - submit_pairing(page); - return; - } - if (key == KEY_BACKSPACE) { - if (!pair_key_.empty()) pair_key_.pop_back(); - } else if (page.cur_elm_ && page.cur_elm_->utf8[0] && pair_key_.size() < 2048) { - pair_key_ += page.cur_elm_->utf8; - } - if (pair_input_label_) { - std::string shown = pair_key_.size() > 40 ? "..." + pair_key_.substr(pair_key_.size() - 40) : pair_key_; - shown += "_"; - lv_label_set_text(pair_input_label_, shown.c_str()); - } -} +} // namespace -void Developer::submit_pairing(UISetupPage &page) +void About::append(UISetupPage &page, std::vector &menu) { - if (!adb_public_key_valid(pair_key_)) { - if (pair_hint_label_) { - lv_label_set_text(pair_hint_label_, "Invalid adbkey.pub public key"); - lv_obj_set_style_text_color(pair_hint_label_, lv_color_hex(0xEB5F5F), 0); - } - return; - } - run_admin_action(page, {"AdbAuthorize", pair_key_}, "Pairing ADB host", - "Authorizing this key...", enable_after_pair_); -} - -void Developer::clear_authorizations(UISetupPage &page) -{ - run_admin_action(page, {"AdbClearAuthorizations"}, "Clearing ADB keys", - "Revoking every host...", false); -} - -void Developer::run_admin_action(UISetupPage &page, std::list args, - const char *title, const char *detail, bool enable_after) -{ - show_status(title, detail, Modal::BUSY); - struct Context { Developer *self; UISetupPage *page; std::weak_ptr state; bool enable; }; - auto *ctx = new (std::nothrow) Context{this, &page, async_state_, enable_after}; - if (!ctx) { show_status("ADB authorization failed", "Out of memory", Modal::ERROR); return; } - uint64_t request_id = 0; - int rc = -1; - cp0_signal_system_admin_async(std::move(args), 60000, 30000, - [ctx](int result_code, int exit_code) { - std::unique_ptr owned(ctx); - auto state = ctx->state.lock(); - if (!state || !state->alive) return; - state->request_id = 0; - auto result = static_cast(result_code); - if (result != CP0_SUDO_RESULT_SUCCESS) { - ctx->self->show_result_error(result, exit_code); - return; - } - ctx->self->pair_key_.clear(); - ctx->self->close_status(); - ctx->page->view_state_ = UISetupPage::ViewState::SUB; - ctx->self->refresh_adb_status(*ctx->page); - ctx->page->build_sub_view(); - if (ctx->enable) { - int idx = ctx->page->find_menu("Developer"); - if (idx >= 0) ctx->page->menu_items_[idx].sub_items[0].toggle_state = true; - ctx->self->toggle_adb(*ctx->page); - } - }, [&](int code, uint64_t id) { rc = code; request_id = id; }); - if (rc != 0) { - delete ctx; - show_status("ADB authorization failed", "Unable to start request", Modal::ERROR); - return; - } - async_state_->request_id = request_id; -} - -void Developer::update_toggle(UISetupPage &page, bool enabled, bool save) -{ - int idx = page.find_menu("Developer"); - if (idx < 0) return; - page.menu_items_[idx].sub_items[0].toggle_state = enabled; - if (save) { - UISetupPage::config_set_int("adb_debug", enabled ? 1 : 0); - UISetupPage::config_save(); - } -} - -void Developer::show_result_error(cp0_sudo_result_t result, int exit_code) -{ - const char *reason = "Command failed"; - switch (classify_privileged_result(static_cast(result))) { - case PrivilegedResultKind::AUTH_FAILED: reason = "Authentication failed"; break; - case PrivilegedResultKind::CANCELLED: reason = "Request cancelled"; break; - case PrivilegedResultKind::TIMED_OUT: reason = "Request timed out"; break; - case PrivilegedResultKind::EXEC_FAILED: - reason = exit_code < 0 ? "Unable to start command" : "Command returned an error"; - break; - default: break; - } - show_status("ADB update failed", reason, Modal::ERROR); -} - -void Developer::show_status(const char *title_text, const char *detail_text, Modal modal) -{ - close_status(); - modal_ = modal; - status_overlay_ = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(status_overlay_); - lv_obj_set_size(status_overlay_, UISetupPage::SCREEN_W, UISetupPage::SCREEN_H + 20); - lv_obj_set_style_bg_color(status_overlay_, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(status_overlay_, LV_OPA_60, 0); - lv_obj_clear_flag(status_overlay_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_t *box = guide_chip(status_overlay_, 35, 69, 250, 82, 0x1A1A2E, - modal == Modal::ERROR ? 0xCC5555 : 0x3A5A8A, 6, 1); - lv_obj_t *title = guide_label(box, 10, 10, title_text, 0xFFFFFF, - launcher_fonts().get("Montserrat-Bold.ttf", 14, LV_FREETYPE_FONT_STYLE_BOLD)); - lv_obj_set_width(title, 230); - lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_t *detail = guide_label(box, 10, 39, detail_text, 0xAAAAAA, &lv_font_montserrat_10); - lv_obj_set_width(detail, 230); - lv_obj_set_style_text_align(detail, LV_TEXT_ALIGN_CENTER, 0); - if (modal == Modal::ERROR) { - lv_obj_t *hint = guide_label(box, 10, 64, "OK / ESC: close", 0x777777, - &lv_font_montserrat_10); - lv_obj_set_width(hint, 230); - lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0); - } - lv_obj_move_foreground(status_overlay_); -} - -void Developer::close_status() -{ - if (status_overlay_) { - lv_obj_del(status_overlay_); - status_overlay_ = nullptr; - } - modal_ = Modal::NONE; -} - -void Developer::handle_status_key(UISetupPage &page, uint32_t key) -{ - if (modal_ == Modal::BUSY) return; - if (key == KEY_ENTER || key == KEY_ESC || key == KEY_LEFT) { - close_status(); - page.build_sub_view(); - } -} - -void Developer::enter_usb_guide(UISetupPage &page, bool enabling) -{ - usb_guide_enabling_ = enabling; - build_usb_guide_view(page); -} - -lv_obj_t *Developer::guide_chip(lv_obj_t *parent, int x, int y, int w, int h, - uint32_t bg, uint32_t border, int radius, int border_w) -{ - lv_obj_t *o = lv_obj_create(parent); - lv_obj_set_pos(o, x, y); - lv_obj_set_size(o, w, h); - lv_obj_set_style_radius(o, radius, LV_PART_MAIN); - lv_obj_set_style_bg_color(o, lv_color_hex(bg), LV_PART_MAIN); - lv_obj_set_style_bg_opa(o, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_border_color(o, lv_color_hex(border), LV_PART_MAIN); - lv_obj_set_style_border_width(o, border_w, LV_PART_MAIN); - lv_obj_set_style_pad_all(o, 0, LV_PART_MAIN); - lv_obj_clear_flag(o, LV_OBJ_FLAG_SCROLLABLE); - return o; -} - -lv_obj_t *Developer::guide_label(lv_obj_t *parent, int x, int y, const char *txt, - uint32_t color, const lv_font_t *font) -{ - lv_obj_t *l = lv_label_create(parent); - lv_label_set_text(l, txt); - lv_obj_set_pos(l, x, y); - lv_obj_set_style_text_color(l, lv_color_hex(color), LV_PART_MAIN); - lv_obj_set_style_text_font(l, font, LV_PART_MAIN); - return l; -} - -void Developer::build_usb_guide_view(UISetupPage &page) -{ - page.view_state_ = UISetupPage::ViewState::USB_GUIDE; - usb_guide_knob_ = nullptr; - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - const bool en = usb_guide_enabling_; - const uint32_t C_GREEN = 0x46DC87, C_YEL = 0xF0C850, C_RED = 0xEB5F5F; - const uint32_t C_WHITE = 0xECECEC, C_GREY = 0x9A9AA0; - const lv_font_t *f_title = launcher_fonts().get("Montserrat-Bold.ttf", 13, LV_FREETYPE_FONT_STYLE_BOLD); - const lv_font_t *f_msg = &lv_font_montserrat_10; - - guide_label(cont, 8, 2, en ? "Enable ADB - switch USB to device" : "Disable ADB - switch USB to HUB", - C_WHITE, f_title ? f_title : &lv_font_montserrat_12); - guide_chip(cont, 86, 24, 146, 50, 0x282A30, 0x5A5C64, 6, 2); - guide_label(cont, 120, 28, "CardputerZero", C_GREY, f_msg); - guide_chip(cont, 218, 30, 12, 12, 0x101012, 0x5A5C64, 3, 2); - guide_chip(cont, 228, 32, 22, 8, 0xCDCDD2, 0xCDCDD2, 2, 0); - guide_chip(cont, 250, 34, 60, 4, 0x6A6C72, 0x6A6C72, 2, 0); - guide_label(cont, 232, 42, "USB-C", C_GREEN, f_msg); - guide_chip(cont, 24, 28, 32, 44, 0x1A1A1C, 0x5A5C64, 6, 2); - guide_chip(cont, 33, 33, 14, 34, 0x0E0E10, 0x0E0E10, 4, 0); - guide_label(cont, 26, 14, "HUB", en ? C_RED : C_GREEN, f_msg); - guide_label(cont, 28, 72, "USB", en ? C_GREEN : C_GREY, f_msg); - const int thumb_top = 34, thumb_bot = 54; - usb_guide_knob_ = guide_chip(cont, 32, en ? thumb_top : thumb_bot, 16, 10, C_GREEN, 0x2A6F49, 3, 1); - - int y = 80; - if (en) { - guide_label(cont, 8, y, "1 Slide LEFT switch HUB -> USB", C_WHITE, f_msg); - guide_label(cont, 8, y + 15, "2 USB hub & peripherals turn OFF", C_YEL, f_msg); - guide_label(cont, 8, y + 30, "3 Cable -> top-right USB-C port", C_GREEN, f_msg); - } else { - guide_label(cont, 8, y, "1 Slide LEFT switch USB -> HUB", C_WHITE, f_msg); - guide_label(cont, 8, y + 15, "2 USB hub & peripherals come back", C_GREEN, f_msg); - guide_label(cont, 8, y + 30, "3 Reboot to apply the change", C_YEL, f_msg); - } - guide_label(cont, 8, UISetupPage::LIST_H - 16, "OK: reboot now ESC: later", C_GREY, &lv_font_montserrat_10); - - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, usb_guide_knob_); - lv_anim_set_values(&a, en ? thumb_top : thumb_bot, en ? thumb_bot : thumb_top); - lv_anim_set_time(&a, 650); - lv_anim_set_playback_time(&a, 650); - lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); - lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); - lv_anim_set_exec_cb(&a, [](void *var, int32_t v) { lv_obj_set_y((lv_obj_t *)var, v); }); - lv_anim_start(&a); -} - -void Developer::stop_usb_guide_anims() -{ - if (usb_guide_knob_) lv_anim_del(usb_guide_knob_, nullptr); - usb_guide_knob_ = nullptr; -} - -void Developer::handle_usb_guide_key(UISetupPage &page, uint32_t key) -{ - switch (key) { - case KEY_ENTER: - case KEY_RIGHT: { - stop_usb_guide_anims(); - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, "Rebooting..."); - lv_obj_center(lbl); - cp0_signal_process_api({"Reboot"}, nullptr); - break; - } - case KEY_ESC: - case KEY_LEFT: - stop_usb_guide_anims(); - page.view_state_ = UISetupPage::ViewState::SUB; - page.build_sub_view(); - break; - default: - break; - } -} - -void About::append(UISetupPage &p, std::vector &menu) -{ - UISetupPage *page = &p; - MenuItem m; - m.label = "About"; - m.sub_items = { + UISetupPage *page_ptr = &page; + MenuItem item; + item.label = "About"; + item.sub_items = { {"CardputerZero", false, false, nullptr}, {"LVGL 9.x", false, false, nullptr}, {"", false, false, nullptr}, {"", false, false, nullptr}, }; - m.on_enter = [page]() { About::refresh_info(*page); }; - menu.push_back(m); + item.on_enter = [page_ptr]() { About::refresh_info(*page_ptr); }; + menu.push_back(item); } void About::refresh_info(UISetupPage &page) { - for (auto &m : page.menu_items_) { - if (m.label != "About") continue; - m.sub_items[0].label = "M5CardputerZero"; - m.sub_items[1].label = "LVGL: 9.x"; - char buf[64]; - snprintf(buf, sizeof(buf), "Build: %s", __DATE__); - m.sub_items[2].label = buf; - snprintf(buf, sizeof(buf), "Commit: %s", LAUNCHER_GIT_COMMIT); - m.sub_items[3].label = buf; - break; - } + MenuItem *item = SetupPageAccess(page).find_menu("About"); + if (!item || item->sub_items.size() < 4) + return; + item->sub_items[0].label = "M5CardputerZero"; + item->sub_items[1].label = "LVGL: 9.x"; + char text[64]; + snprintf(text, sizeof(text), "Build: %s", __DATE__); + item->sub_items[2].label = text; + snprintf(text, sizeof(text), "Commit: %s", LAUNCHER_GIT_COMMIT); + item->sub_items[3].label = text; } -void Help::append(UISetupPage &p, std::vector &menu) +void Help::append(UISetupPage &page, std::vector &menu) { - UISetupPage *page = &p; - MenuItem m; - m.label = "Help"; - m.sub_items = {{"View Help", false, false, [page]() { Help::enter_page(*page); }}}; - menu.push_back(m); + UISetupPage *page_ptr = &page; + MenuItem item; + item.label = "Help"; + item.sub_items = {{"View Help", false, false, [page_ptr]() { Help::enter_page(*page_ptr); }}}; + menu.push_back(item); } void Help::enter_page(UISetupPage &page) { - page.view_state_ = UISetupPage::ViewState::WIFI_LIST; - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); + SetupPageAccess access(page); + if (build_page(page)) access.enter_help(); +} + +bool Help::build_page(UISetupPage &page) +{ + SetupPageAccess access(page); + lv_obj_t *container = access.content_container(); + if (!container) + return false; + lv_obj_t *candidate = lv_obj_create(container); + if (!candidate) return false; + lv_obj_set_size(candidate, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(candidate, 0, 0); + lv_obj_set_style_bg_opa(candidate, 0, LV_PART_MAIN); + lv_obj_set_style_border_width(candidate, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(candidate, 0, LV_PART_MAIN); + lv_obj_clear_flag(candidate, LV_OBJ_FLAG_SCROLLABLE); int y = 4; auto add_line = [&](const char *text, uint32_t color, const lv_font_t *font) { - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, text); - lv_obj_set_pos(lbl, 8, y); - lv_obj_set_width(lbl, 300); - lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); - lv_obj_set_style_text_color(lbl, lv_color_hex(color), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, font, LV_PART_MAIN); - lv_obj_update_layout(lbl); - y += lv_obj_get_height(lbl) + 3; + lv_obj_t *label = lv_label_create(candidate); + if (!label) return false; + lv_label_set_text(label, text); + lv_obj_set_pos(label, 8, y); + lv_obj_set_width(label, 300); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_text_font(label, font, LV_PART_MAIN); + lv_obj_update_layout(label); + y += lv_obj_get_height(label) + 3; + return true; }; - add_line("Help", 0x58A6FF, launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD)); - add_line("Screenshot: Ctrl+Alt+S", 0xCCCCCC, &lv_font_montserrat_12); - add_line(" Saved to ~/Screenshots", 0x888888, &lv_font_montserrat_10); - add_line("Home: Hold ESC 3s", 0xCCCCCC, &lv_font_montserrat_12); - add_line("Navigate: Arrow keys / OK / ESC", 0xCCCCCC, &lv_font_montserrat_12); - add_line("WiFi: Setting > WiFi > Scan", 0xCCCCCC, &lv_font_montserrat_12); - - lv_obj_t *hint = lv_label_create(cont); + if (!add_line("Help", 0x58A6FF, launcher_fonts().get( + "Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD)) || + !add_line("Screenshot: Ctrl+Alt+S", 0xCCCCCC, &lv_font_montserrat_12) || + !add_line(" Saved to ~/Screenshots", 0x888888, &lv_font_montserrat_10) || + !add_line("Home: Hold ESC 3s", 0xCCCCCC, &lv_font_montserrat_12) || + !add_line("Navigate: Arrow keys / OK / ESC", 0xCCCCCC, &lv_font_montserrat_12) || + !add_line("WiFi: Setting > WiFi > Scan", 0xCCCCCC, &lv_font_montserrat_12)) + { lv_obj_delete(candidate); return false; } + + lv_obj_t *hint = lv_label_create(candidate); + if (!hint) { lv_obj_delete(candidate); return false; } lv_label_set_text(hint, "ESC: back"); - lv_obj_set_pos(hint, 8, UISetupPage::LIST_H - 14); + lv_obj_set_pos(hint, 8, access.content_height() - 14); lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); + + lv_obj_t *child = lv_obj_get_child(container, 0); + while (child) { + if (child == candidate) { + child = lv_obj_get_child(container, 1); + } else { + lv_obj_delete(child); + child = lv_obj_get_child(container, 0); + } + } + while ((child = lv_obj_get_child(candidate, 0)) != nullptr) + lv_obj_set_parent(child, container); + lv_obj_delete(candidate); + return true; } -void ExtPort::append(UISetupPage &p, std::vector &menu) +void Help::handle_key(UISetupPage &page, uint32_t key) { - UISetupPage *page = &p; - MenuItem m; - m.label = "ExtPort"; - bool usb_en = UISetupPage::config_get_int("extport_usb", 1) != 0; - bool vout_en = UISetupPage::config_get_int("extport_5vout", 1) != 0; - m.sub_items = { - {"GROVE5V", true, usb_en, [page]() { - bool en = page->menu_items_[page->selected_idx_].sub_items[0].toggle_state; - UISetupPage::config_set_int("extport_usb", en ? 1 : 0); - UISetupPage::gpio_set("GROVE5V", en ? 1 : 0); - UISetupPage::config_save(); + if (key != KEY_ESC && key != KEY_LEFT) return; + SetupPageAccess access(page); + if (!access.leave_help()) return; + access.play_back(); + if (access.content_container()) access.build_sub_view(); +} + +void ExtPort::append(UISetupPage &page, std::vector &menu) +{ + UISetupPage *page_ptr = &page; + MenuItem item; + item.label = "ExtPort"; + SetupPageAccess access(page); + bool usb_enabled = access.gpio_get("GROVE5V") == 1; + bool output_enabled = access.gpio_get("EXT5V") == 1; + item.sub_items = { + {"GROVE5V", true, usb_enabled, [page_ptr]() { + apply_extport_toggle(*page_ptr, 0, "GROVE5V"); }}, - {"EXT5V", true, vout_en, [page]() { - bool en = page->menu_items_[page->selected_idx_].sub_items[1].toggle_state; - UISetupPage::config_set_int("extport_5vout", en ? 1 : 0); - UISetupPage::gpio_set("EXT5V", en ? 1 : 0); - UISetupPage::config_save(); + {"EXT5V", true, output_enabled, [page_ptr]() { + apply_extport_toggle(*page_ptr, 1, "EXT5V"); }}, }; - menu.push_back(m); + menu.push_back(item); } -void Ethernet::append(UISetupPage &p, std::vector &menu) +void Ethernet::append(UISetupPage &page, std::vector &menu) { - UISetupPage *page = &p; - MenuItem m; - m.label = "Ethernet"; - m.sub_items = { + UISetupPage *page_ptr = &page; + MenuItem item; + item.label = "Ethernet"; + item.sub_items = { {"IP: --", false, false, nullptr}, {"Gateway: --", false, false, nullptr}, {"MAC: --", false, false, nullptr}, }; - m.on_enter = [page]() { Ethernet::refresh_info(*page); }; - menu.push_back(m); + item.on_enter = [page_ptr]() { Ethernet::refresh_info(*page_ptr); }; + menu.push_back(item); } void Ethernet::refresh_info(UISetupPage &page) { - for (auto &m : page.menu_items_) { - if (m.label != "Ethernet") continue; - std::string data; + MenuItem *item = SetupPageAccess(page).find_menu("Ethernet"); + if (!item || item->sub_items.size() < 3) + return; + std::string data; + bool loaded = false; + try { cp0_signal_osinfo_api({"NetworkDefaultInfoRead"}, - [&](int code, std::string value) { if (code == 0) data = std::move(value); }); - std::istringstream lines(data); - std::string ip, gateway, mac; - std::getline(lines, ip); std::getline(lines, gateway); std::getline(lines, mac); - m.sub_items[0].label = "IP: " + ip; - m.sub_items[1].label = "GW: " + gateway; - m.sub_items[2].label = "MAC: " + mac; - break; + [&](int code, std::string value) { + if (code == 0) { data = std::move(value); loaded = true; } + }); + } catch (...) { + loaded = false; } + if (!loaded) return; + const auto info = system_page::parse_network_info(data); + item->sub_items[0].label = "IP: " + info.ip; + item->sub_items[1].label = "GW: " + info.gateway; + item->sub_items[2].label = "MAC: " + info.mac; } -void Account::append(UISetupPage &p, std::vector &menu) +void Account::append(UISetupPage &page, std::vector &menu) { - UISetupPage *page = &p; - MenuItem m; - m.label = "Account"; - m.sub_items = { + UISetupPage *page_ptr = &page; + MenuItem item; + item.label = "Account"; + item.sub_items = { {"Username", false, false, nullptr}, {"Password", false, false, nullptr}, {"Hostname", false, false, nullptr}, }; - m.on_enter = [page]() { Account::refresh_info(*page); }; - menu.push_back(m); + item.on_enter = [page_ptr]() { Account::refresh_info(*page_ptr); }; + menu.push_back(item); } void Account::refresh_info(UISetupPage &page) { - for (auto &m : page.menu_items_) { - if (m.label != "Account") continue; - std::string data; + MenuItem *item = SetupPageAccess(page).find_menu("Account"); + if (!item || item->sub_items.size() < 3) + return; + std::string data; + bool loaded = false; + try { cp0_signal_osinfo_api({"AccountInfoRead"}, - [&](int code, std::string value) { if (code == 0) data = std::move(value); }); - std::istringstream lines(data); - std::string user, hostname; - std::getline(lines, user); std::getline(lines, hostname); - m.sub_items[0].label = "User: " + user; - m.sub_items[1].label = "Password: ****"; - m.sub_items[2].label = "Host: " + hostname; - break; + [&](int code, std::string value) { + if (code == 0) { data = std::move(value); loaded = true; } + }); + } catch (...) { + loaded = false; } + if (!loaded) return; + const auto info = system_page::parse_account_info(data); + item->sub_items[0].label = "User: " + info.username; + item->sub_items[1].label = "Password: ****"; + item->sub_items[2].label = "Host: " + info.hostname; } -void Update::append(UISetupPage &p, std::vector &menu) +void Update::append(UISetupPage &page, std::vector &menu) { - UISetupPage *page = &p; - MenuItem m; - m.label = "Update"; - m.sub_items = { + UISetupPage *page_ptr = &page; + MenuItem item; + item.label = "Update"; + item.sub_items = { {"Check System", false, false, []() { Update::check_system_update(); }}, {"Update Launcher", false, false, []() { Update::update_launcher(); }}, {"Version: --", false, false, nullptr}, }; - m.on_enter = [page]() { Update::refresh_version_info(*page); }; - menu.push_back(m); + item.on_enter = [page_ptr]() { Update::refresh_version_info(*page_ptr); }; + menu.push_back(item); } void Update::refresh_version_info(UISetupPage &page) { - for (auto &m : page.menu_items_) { - if (m.label != "Update") continue; - m.sub_items[2].label = std::string("Version: ") + LAUNCHER_GIT_COMMIT; - break; - } + MenuItem *item = SetupPageAccess(page).find_menu("Update"); + if (item && item->sub_items.size() >= 3) + item->sub_items[2].label = system_page::version_label(LAUNCHER_GIT_COMMIT); } void Update::check_system_update() { - cp0_signal_osinfo_api({"AptUpdateBackground"}, nullptr); + cp0_signal_osinfo_api( + {system_page::update_request(system_page::UpdateAction::CheckSystem)}, nullptr); } void Update::update_launcher() { - cp0_signal_osinfo_api({"UpdateLauncherBackground"}, nullptr); + cp0_signal_osinfo_api( + {system_page::update_request(system_page::UpdateAction::UpdateLauncher)}, nullptr); } } // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/system.hpp b/projects/APPLaunch/main/ui/page_app/setting/system.hpp new file mode 100644 index 00000000..fc22ee39 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/system.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "menu_types.hpp" + +#include +#include + +class UISetupPage; + +namespace setting { + +class About +{ +public: + static void append(UISetupPage &page, std::vector &menu); + static void refresh_info(UISetupPage &page); +}; + +class Help +{ +public: + static void append(UISetupPage &page, std::vector &menu); + static void enter_page(UISetupPage &page); + static bool build_page(UISetupPage &page); + static void handle_key(UISetupPage &page, uint32_t key); +}; + +class ExtPort +{ +public: + static void append(UISetupPage &page, std::vector &menu); +}; + +class Ethernet +{ +public: + static void append(UISetupPage &page, std::vector &menu); + static void refresh_info(UISetupPage &page); +}; + +class Account +{ +public: + static void append(UISetupPage &page, std::vector &menu); + static void refresh_info(UISetupPage &page); +}; + +class Update +{ +public: + static void append(UISetupPage &page, std::vector &menu); + static void refresh_version_info(UISetupPage &page); + static void check_system_update(); + static void update_launcher(); +}; + +void build_menu(UISetupPage &page); + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/wifi.cpp b/projects/APPLaunch/main/ui/page_app/setting/wifi.cpp index 13b28c36..fe8eeddf 100644 --- a/projects/APPLaunch/main/ui/page_app/setting/wifi.cpp +++ b/projects/APPLaunch/main/ui/page_app/setting/wifi.cpp @@ -1,187 +1,236 @@ #define APP_PAGE_IMPLEMENTATION_UNIT #include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +#include +#include +#include +#include +#include +#include +#include namespace setting { +struct WiFi::ScanState { + std::mutex mutex; + std::condition_variable wake; + WiFi *owner = nullptr; + UISetupPage *page = nullptr; + bool stop = false; + bool requested = false; +}; + +struct WiFi::ScanResult { + std::shared_ptr state; + UISetupPage *page = nullptr; + cp0_wifi_ap_t aps[CP0_WIFI_AP_MAX]{}; + int count = 0; +}; + +namespace { + +constexpr auto kWifiScanPeriod = std::chrono::seconds(8); + +} // namespace + +WiFi::~WiFi() +{ + shutdown(); + stop_connection_failure_feedback(); + clear_password_view(); + password_model_.reset(); + forget_ssid_.clear(); + forget_active_ = false; +} + void WiFi::append(UISetupPage &p, std::vector &menu) { UISetupPage *page = &p; + WiFi *controller = this; MenuItem m; m.label = "WiFi"; m.sub_items = { - {"Power", true, false, [page]() { page->wifi_.toggle_enable(*page); }}, - {"Scan", false, false, [page]() { page->wifi_.enter_scan(*page); }}, + {"Power", true, false, [controller, page]() { controller->toggle_enable(*page); }}, + {"Scan", false, false, [controller, page]() { controller->enter_scan(*page); }}, }; - m.on_enter = [page]() { page->wifi_.refresh_radio(*page); }; + m.on_enter = [controller, page]() { controller->refresh_radio(*page); }; menu.push_back(m); } -void WiFi::do_scan() +void WiFi::enter_scan(UISetupPage &page) { - ap_count_ = launcher_wifi::scan(aps_, CP0_WIFI_AP_MAX); + stop_connection_failure_feedback(); + clear_password_view(); + SetupPageAccess(page).set_view(SetupViewState::WIFI_LIST); + refresh_list_status(); + start_scan(page); + build_list(page); } -void WiFi::enter_scan(UISetupPage &page) +void WiFi::start_scan(UISetupPage &page) { - do_scan(); - list_sel_ = 0; - page.view_state_ = UISetupPage::ViewState::WIFI_LIST; - build_list(page); + stop_scan(); + auto state = std::make_shared(); + state->owner = this; + state->page = &page; + scan_state_ = state; + list_view_model_.begin_scan(); + scan_threads_.emplace_back([state] { + for (;;) { + { + std::lock_guard lock(state->mutex); + if (state->stop) break; + state->requested = false; + } + + auto result = std::make_unique(); + result->state = state; + result->count = cp0_wifi_scan(result->aps, CP0_WIFI_AP_MAX); + + { + std::lock_guard lock(state->mutex); + if (state->stop) break; + result->page = state->page; + } + ScanResult *raw = result.release(); + if (lv_async_call(scan_result_cb, raw) != LV_RESULT_OK) + delete raw; + + std::unique_lock lock(state->mutex); + state->wake.wait_for(lock, kWifiScanPeriod, [&] { + return state->stop || state->requested; + }); + if (state->stop) break; + } + }); } -void WiFi::refresh_radio(UISetupPage &page) +void WiFi::stop_scan() { - for (auto &m : page.menu_items_) { - if (m.label != "WiFi") continue; - m.sub_items[0].toggle_state = launcher_wifi::radio_enabled() != 0; - break; + auto state = scan_state_; + if (state) { + { + std::lock_guard lock(state->mutex); + state->stop = true; + state->owner = nullptr; + state->page = nullptr; + } + state->wake.notify_all(); } + scan_state_.reset(); + list_view_model_.cancel_scan(); } -void WiFi::toggle_enable(UISetupPage &page) +void WiFi::shutdown() { - for (auto &m : page.menu_items_) { - if (m.label != "WiFi") continue; - bool on = m.sub_items[0].toggle_state; - launcher_wifi::radio_set_enabled(on); - m.sub_items[0].toggle_state = launcher_wifi::radio_enabled() != 0; - break; + stop_scan(); + for (auto &thread : scan_threads_) { + if (thread.joinable()) thread.join(); } + scan_threads_.clear(); } -void WiFi::build_list(UISetupPage &page) +void WiFi::request_scan() { - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - - lv_obj_t *title = lv_label_create(cont); + auto state = scan_state_; + if (!state) return; { - cp0_wifi_status_t ws = launcher_wifi::get_status(); - static char title_buf[128]; - if (ws.connected) - snprintf(title_buf, sizeof(title_buf), "Connected WiFi: %.64s %.40s", ws.ssid, ws.ip); - else - snprintf(title_buf, sizeof(title_buf), "WiFi: Not connected"); - lv_label_set_text(title, title_buf); - } - lv_obj_set_pos(title, 8, 2); - lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(title, launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - UISetupPage::apply_overflow_handling(title, 8, UISetupPage::WIFI_TITLE_BOX_W, true); - - lv_obj_t *h1 = lv_label_create(cont); - lv_label_set_text(h1, "SSID"); - lv_obj_set_pos(h1, 8, 18); - lv_obj_set_style_text_color(h1, lv_color_hex(0x888888), LV_PART_MAIN); - lv_obj_set_style_text_font(h1, &lv_font_montserrat_10, LV_PART_MAIN); - - lv_obj_t *h2 = lv_label_create(cont); - lv_label_set_text(h2, "Security"); - lv_obj_set_pos(h2, 180, 18); - lv_obj_set_style_text_color(h2, lv_color_hex(0x888888), LV_PART_MAIN); - lv_obj_set_style_text_font(h2, &lv_font_montserrat_10, LV_PART_MAIN); - - lv_obj_t *h3 = lv_label_create(cont); - lv_label_set_text(h3, "Signal"); - lv_obj_set_pos(h3, 270, 18); - lv_obj_set_style_text_color(h3, lv_color_hex(0x888888), LV_PART_MAIN); - lv_obj_set_style_text_font(h3, &lv_font_montserrat_10, LV_PART_MAIN); - - if (ap_count_ == 0) { - lv_obj_t *empty = lv_label_create(cont); - lv_label_set_text(empty, "No networks found. Press R to rescan."); - lv_obj_set_pos(empty, 8, 50); - lv_obj_set_style_text_color(empty, lv_color_hex(0x666666), LV_PART_MAIN); - lv_obj_set_style_text_font(empty, &lv_font_montserrat_12, LV_PART_MAIN); - return; + std::lock_guard lock(state->mutex); + if (state->stop) return; + state->requested = true; } + list_view_model_.begin_scan(); + state->wake.notify_all(); +} - int visible = 5; - int offset = list_sel_ - visible / 2; - if (offset < 0) offset = 0; - if (offset > ap_count_ - visible) offset = ap_count_ - visible; - if (offset < 0) offset = 0; - - for (int vi = 0; vi < visible && (vi + offset) < ap_count_; ++vi) { - int ai = vi + offset; - bool sel = (ai == list_sel_); - cp0_wifi_ap_t *ap = &aps_[ai]; - int y = 30 + vi * 22; - - if (sel) { - lv_obj_t *bg = lv_obj_create(cont); - lv_obj_set_size(bg, UISetupPage::SCREEN_W - 8, 20); - lv_obj_set_pos(bg, 4, y); - lv_obj_set_style_radius(bg, 2, LV_PART_MAIN); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x1F3A5F), LV_PART_MAIN); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); +void WiFi::scan_result_cb(void *user) noexcept +{ + std::unique_ptr result(static_cast(user)); + if (!result) return; + try { + WiFi *owner = nullptr; + { + std::lock_guard lock(result->state->mutex); + if (result->state->stop || result->state->page != result->page) return; + owner = result->state->owner; } + if (!owner || !result->page || + !SetupPageAccess(*result->page).is_view(SetupViewState::WIFI_LIST)) return; + owner->apply_scan_result(*result->page, result->aps, result->count); + } catch (...) { + } +} - uint32_t tc = sel ? 0xFFFFFF : 0xCCCCCC; - if (ap->in_use) tc = 0x58A6FF; - - lv_obj_t *ssid_lbl = lv_label_create(cont); - static char ssid_buf[CP0_WIFI_SSID_MAX + 4]; - if (has_saved_profile(ap->ssid)) - snprintf(ssid_buf, sizeof(ssid_buf), "%s *", ap->ssid); - else - snprintf(ssid_buf, sizeof(ssid_buf), "%s", ap->ssid); - lv_label_set_text(ssid_lbl, ssid_buf); - lv_obj_set_pos(ssid_lbl, 8, y + 2); - lv_obj_set_style_text_color(ssid_lbl, lv_color_hex(tc), LV_PART_MAIN); - lv_obj_set_style_text_font(ssid_lbl, &lv_font_montserrat_12, LV_PART_MAIN); - lv_obj_set_width(ssid_lbl, 165); - lv_label_set_long_mode(ssid_lbl, LV_LABEL_LONG_CLIP); - - lv_obj_t *sec = lv_label_create(cont); - lv_label_set_text(sec, ap->security[0] ? ap->security : "Open"); - lv_obj_set_pos(sec, 180, y + 2); - lv_obj_set_style_text_color(sec, lv_color_hex(tc), LV_PART_MAIN); - lv_obj_set_style_text_font(sec, &lv_font_montserrat_10, LV_PART_MAIN); - - char sig_buf[16]; - snprintf(sig_buf, sizeof(sig_buf), "%d%%", ap->signal); - lv_obj_t *sig = lv_label_create(cont); - lv_label_set_text(sig, sig_buf); - lv_obj_set_pos(sig, 275, y + 2); - lv_obj_set_style_text_color(sig, lv_color_hex(tc), LV_PART_MAIN); - lv_obj_set_style_text_font(sig, &lv_font_montserrat_10, LV_PART_MAIN); +void WiFi::apply_scan_result(UISetupPage &page, const cp0_wifi_ap_t *aps, int count) +{ + std::vector access_points; + const int safe_count = std::clamp(count, 0, CP0_WIFI_AP_MAX); + access_points.reserve(static_cast(safe_count)); + for (int index = 0; aps && index < safe_count; ++index) { + const auto &source = aps[index]; + access_points.push_back({source.ssid, source.security, source.signal, + source.in_use != 0, source.saved != 0}); } + list_view_model_.apply_scan(std::move(access_points)); + refresh_list_status(); + build_list(page); +} + +void WiFi::refresh_list_status() +{ + cp0_wifi_status_t status{}; + cp0_wifi_status_read(&status); + list_view_model_.set_status({status.connected != 0, status.ssid, status.ip}); +} + +void WiFi::refresh_radio(UISetupPage &page) +{ + MenuItem *menu = SetupPageAccess(page).find_menu("WiFi"); + if (menu && !menu->sub_items.empty()) + menu->sub_items[0].toggle_state = cp0_wifi_radio_enabled() != 0; +} - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, "OK:connect R:rescan D:forget ESC:back"); - lv_obj_set_pos(hint, 8, UISetupPage::LIST_H - 14); - lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); +void WiFi::toggle_enable(UISetupPage &page) +{ + MenuItem *menu = SetupPageAccess(page).find_menu("WiFi"); + if (menu && !menu->sub_items.empty()) { + bool on = menu->sub_items[0].toggle_state; + cp0_wifi_radio_set_enabled(on); + menu->sub_items[0].toggle_state = cp0_wifi_radio_enabled() != 0; + } } void WiFi::handle_list_key(UISetupPage &page, uint32_t key) { + if (feedback_model_.pending() && key != KEY_ESC && key != KEY_LEFT) + return; switch (key) { case KEY_UP: - if (list_sel_ > 0) { --list_sel_; build_list(page); } + if (list_view_model_.move_selection(-1)) build_list(page); break; case KEY_DOWN: - if (list_sel_ < ap_count_ - 1) { ++list_sel_; build_list(page); } + if (list_view_model_.move_selection(1)) build_list(page); break; case KEY_ENTER: - if (ap_count_ > 0) try_connect(page, list_sel_); + if (list_view_model_.selected()) try_connect(page, list_view_model_.selected_index()); break; case KEY_R: - do_scan(); - list_sel_ = 0; + request_scan(); build_list(page); break; case KEY_D: - if (ap_count_ > 0) forget_selected(page); + if (list_view_model_.selected()) forget_selected(page); break; case KEY_ESC: - case KEY_LEFT: - page.view_state_ = UISetupPage::ViewState::SUB; - page.build_sub_view(); + case KEY_LEFT: { + stop_connection_failure_feedback(); + stop_scan(); + SetupPageAccess access(page); + access.set_view(SetupViewState::SUB); + access.build_sub_view(); break; + } default: break; } @@ -189,196 +238,228 @@ void WiFi::handle_list_key(UISetupPage &page, uint32_t key) void WiFi::try_connect(UISetupPage &page, int idx) { - if (idx < 0 || idx >= ap_count_) return; - cp0_wifi_ap_t *ap = &aps_[idx]; - if (ap->in_use) return; + const SetupWifiAccessPoint *selected = list_view_model_.at(idx); + if (!selected || selected->in_use) return; + const SetupWifiAccessPoint ap = *selected; + stop_scan(); bool needs_password = false; int ret = -1; - if (strcmp(ap->security, "Open") == 0 || ap->security[0] == 0) { - show_connecting(page, ap->ssid); - ret = launcher_wifi::connect(ap->ssid, NULL); - } else if (has_saved_profile(ap->ssid)) { - show_connecting(page, ap->ssid); - ret = launcher_wifi::connect(ap->ssid, NULL); + if (ap.security == "Open" || ap.security.empty()) { + show_connecting(page, ap.ssid.c_str()); + ret = cp0_wifi_connect(ap.ssid.c_str(), NULL); + } else if (ap.saved) { + show_connecting(page, ap.ssid.c_str()); + ret = cp0_wifi_connect(ap.ssid.c_str(), NULL); if (ret != 0) { needs_password = true; - pw_ssid_ = ap->ssid; - pw_buf_.clear(); - show_pw_input(page); + password_model_.begin(ap.ssid); + if (!password_view_.show(page, password_model_.ssid())) { + password_model_.reset(); + start_scan(page); + build_list(page); + } } } else { needs_password = true; - pw_ssid_ = ap->ssid; - pw_buf_.clear(); - show_pw_input(page); + password_model_.begin(ap.ssid); + if (!password_view_.show(page, password_model_.ssid())) { + password_model_.reset(); + start_scan(page); + build_list(page); + } } if (!needs_password) { - if (ret != 0) + if (ret != 0) { show_error(page, "Connection failed"); - do_scan(); + start_connection_failure_feedback(page); + return; + } + start_scan(page); + build_list(page); + } +} + +void WiFi::start_connection_failure_feedback(UISetupPage &page) +{ + stop_connection_failure_feedback(); + feedback_token_ = feedback_model_.begin(); + if (!feedback_token_) return; + feedback_page_ = &page; + feedback_timer_ = lv_timer_create(connection_feedback_timer_cb, 2000, this); + if (feedback_timer_) { + lv_timer_set_repeat_count(feedback_timer_, 1); + if (page.screen()) + lv_obj_add_event_cb( + page.screen(), feedback_screen_delete_cb, LV_EVENT_DELETE, this); + return; + } + + feedback_page_ = nullptr; + feedback_model_.complete(feedback_token_); + feedback_token_ = 0; + if (SetupPageAccess(page).is_view(SetupViewState::WIFI_LIST)) { + start_scan(page); build_list(page); } } -void WiFi::show_connecting(UISetupPage &page, const char *ssid) +void WiFi::stop_connection_failure_feedback() +{ + if (feedback_page_ && feedback_page_->screen()) + lv_obj_remove_event_cb_with_user_data( + feedback_page_->screen(), feedback_screen_delete_cb, this); + if (feedback_timer_) { + lv_timer_delete(feedback_timer_); + feedback_timer_ = nullptr; + } + feedback_page_ = nullptr; + feedback_model_.cancel(feedback_token_); + feedback_token_ = 0; +} + +void WiFi::connection_feedback_timer_cb(lv_timer_t *timer) noexcept { - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - static char msg[128]; - snprintf(msg, sizeof(msg), "Connecting to %s...", ssid); - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, msg); - lv_obj_set_pos(lbl, 8, 60); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, LV_PART_MAIN); - lv_refr_now(NULL); + auto *self = static_cast(lv_timer_get_user_data(timer)); + if (!self || !setup_wifi_feedback_timer_callback_ready( + timer, self->feedback_timer_, self->feedback_token_)) return; + const auto token = self->feedback_token_; + try { + UISetupPage *page = self->feedback_page_; + if (page && page->screen()) + lv_obj_remove_event_cb_with_user_data( + page->screen(), feedback_screen_delete_cb, self); + self->feedback_timer_ = nullptr; + self->feedback_page_ = nullptr; + self->feedback_token_ = 0; + if (!self->feedback_model_.complete(token) || !page) return; + if (!SetupPageAccess(*page).is_view(SetupViewState::WIFI_LIST)) return; + self->start_scan(*page); + self->build_list(*page); + } catch (...) { + self->feedback_timer_ = nullptr; + self->feedback_page_ = nullptr; + self->feedback_token_ = 0; + self->feedback_model_.cancel(token); + } } -void WiFi::show_error(UISetupPage &page, const char *msg) +void WiFi::feedback_screen_delete_cb(lv_event_t *event) noexcept { - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, msg); - lv_obj_set_pos(lbl, 8, 60); - lv_obj_set_style_text_color(lbl, lv_color_hex(0xFF4444), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, LV_PART_MAIN); - lv_refr_now(NULL); - cp0_signal_process_api({"DelayMs", "2000"}, nullptr); + if (!event) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + lv_obj_t *tracked_screen = self->feedback_page_ + ? self->feedback_page_->screen() : nullptr; + if (!setup_wifi_feedback_screen_delete_allowed( + lv_event_get_target(event), lv_event_get_current_target(event), + tracked_screen)) return; + + lv_timer_t *timer = self->feedback_timer_; + self->feedback_page_ = nullptr; + self->feedback_timer_ = nullptr; + self->feedback_model_.cancel(self->feedback_token_); + self->feedback_token_ = 0; + if (timer) { + try { + lv_timer_delete(timer); + } catch (...) { + } + } } void WiFi::forget_selected(UISetupPage &page) { - if (list_sel_ < 0 || list_sel_ >= ap_count_) return; - cp0_wifi_ap_t *ap = &aps_[list_sel_]; + const SetupWifiAccessPoint *selected = list_view_model_.selected(); + if (!selected) return; + const SetupWifiAccessPoint ap = *selected; - if (!has_saved_profile(ap->ssid)) { + if (!ap.saved) { + stop_scan(); show_error(page, "No saved password for this network"); - do_scan(); - build_list(page); + start_connection_failure_feedback(page); return; } - char msg[128]; - snprintf(msg, sizeof(msg), "Forget '%s'?", ap->ssid); - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, msg); - lv_obj_set_pos(lbl, 8, 50); - lv_obj_set_style_text_color(lbl, lv_color_hex(0xFFAA00), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, LV_PART_MAIN); - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, "OK:confirm ESC:cancel"); - lv_obj_set_pos(hint, 8, 75); - lv_obj_set_style_text_color(hint, lv_color_hex(0x888888), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN); - lv_refr_now(NULL); - - pw_ssid_ = ap->ssid; - page.view_state_ = UISetupPage::ViewState::WIFI_PW; - launcher_wifi::profile_forget(ap->ssid); - if (ap->in_use) - launcher_wifi::profile_disconnect_active(); - - lv_obj_clean(cont); - lbl = lv_label_create(cont); - snprintf(msg, sizeof(msg), "Forgot '%s'", ap->ssid); - lv_label_set_text(lbl, msg); - lv_obj_set_pos(lbl, 8, 60); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x33CC33), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, LV_PART_MAIN); - lv_refr_now(NULL); - cp0_signal_process_api({"DelayMs", "1500"}, nullptr); - - page.view_state_ = UISetupPage::ViewState::WIFI_LIST; - do_scan(); - build_list(page); -} + const std::string ssid = ap.ssid; + const bool active = ap.in_use; + stop_scan(); + if (!show_forget_confirmation(page, ssid)) return; -bool WiFi::has_saved_profile(const char *ssid) -{ - return launcher_wifi::profile_exists(ssid) != 0; + forget_ssid_ = ssid; + forget_active_ = active; + SetupPageAccess(page).set_view(SetupViewState::WIFI_FORGET_CONFIRM); } -void WiFi::show_pw_input(UISetupPage &page) +void WiFi::handle_forget_key(UISetupPage &page, uint32_t key) { - page.view_state_ = UISetupPage::ViewState::WIFI_PW; - lv_obj_t *cont = page.ui_obj_["list_cont"]; - lv_obj_clean(cont); - - lv_obj_t *title = lv_label_create(cont); - char buf[128]; - snprintf(buf, sizeof(buf), "Connect: %s", pw_ssid_.c_str()); - lv_label_set_text(title, buf); - lv_obj_set_pos(title, 10, 10); - lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(title, &lv_font_montserrat_12, LV_PART_MAIN); - - lv_obj_t *pw_label = lv_label_create(cont); - lv_label_set_text(pw_label, "Password:"); - lv_obj_set_pos(pw_label, 10, 35); - lv_obj_set_style_text_color(pw_label, lv_color_hex(0xCCCCCC), LV_PART_MAIN); - lv_obj_set_style_text_font(pw_label, &lv_font_montserrat_12, LV_PART_MAIN); - - pw_input_lbl_ = lv_label_create(cont); - lv_label_set_text(pw_input_lbl_, "_"); - lv_obj_set_pos(pw_input_lbl_, 90, 35); - lv_obj_set_width(pw_input_lbl_, 200); - lv_label_set_long_mode(pw_input_lbl_, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(pw_input_lbl_, lv_color_hex(0xFFFFFF), LV_PART_MAIN); - lv_obj_set_style_text_font(pw_input_lbl_, &lv_font_montserrat_14, LV_PART_MAIN); - - pw_hint_lbl_ = lv_label_create(cont); - lv_label_set_text(pw_hint_lbl_, "Type pw, OK:connect, ESC:cancel"); - lv_obj_set_pos(pw_hint_lbl_, 10, 65); - lv_obj_set_style_text_color(pw_hint_lbl_, lv_color_hex(0x555555), LV_PART_MAIN); - lv_obj_set_style_text_font(pw_hint_lbl_, &lv_font_montserrat_10, LV_PART_MAIN); + if (key != KEY_ENTER && key != KEY_ESC && key != KEY_LEFT) return; + + if (key == KEY_ENTER && !forget_ssid_.empty()) { + cp0_wifi_profile_forget(forget_ssid_.c_str()); + if (forget_active_) cp0_wifi_disconnect_active(); + } + forget_ssid_.clear(); + forget_active_ = false; + SetupPageAccess access(page); + access.set_view(SetupViewState::WIFI_LIST); + start_scan(page); + access.rebuild_view(); } void WiFi::handle_pw_key(UISetupPage &page, uint32_t key) { if (key == KEY_ESC) { - page.view_state_ = UISetupPage::ViewState::WIFI_LIST; - page.rebuild_view(); + clear_password_view(); + password_model_.reset(); + SetupPageAccess access(page); + access.set_view(SetupViewState::WIFI_LIST); + start_scan(page); + access.rebuild_view(); return; } if (key == KEY_ENTER) { - if (pw_hint_lbl_) lv_label_set_text(pw_hint_lbl_, "Connecting..."); + if (!password_model_.can_submit()) { + password_view_.set_hint("Password required"); + return; + } + password_view_.set_hint("Connecting..."); lv_refr_now(NULL); - int ret = launcher_wifi::connect(pw_ssid_.c_str(), pw_buf_.c_str()); + int ret = cp0_wifi_connect( + password_model_.ssid().c_str(), password_model_.password().c_str()); if (ret != 0) { - launcher_wifi::profile_forget(pw_ssid_.c_str()); - if (pw_hint_lbl_) { - lv_label_set_text(pw_hint_lbl_, "Failed! Wrong password? Try again."); - lv_obj_set_style_text_color(pw_hint_lbl_, lv_color_hex(0xFF4444), LV_PART_MAIN); - } - pw_buf_.clear(); - pw_update_display(); + cp0_wifi_profile_forget(password_model_.ssid().c_str()); + password_view_.set_hint("Failed! Wrong password? Try again.", 0xFF4444); + password_model_.clear_password(); + password_view_.update_password(password_model_.masked_display()); return; } - page.view_state_ = UISetupPage::ViewState::WIFI_LIST; - do_scan(); - page.rebuild_view(); + clear_password_view(); + password_model_.reset(); + SetupPageAccess access(page); + access.set_view(SetupViewState::WIFI_LIST); + start_scan(page); + access.rebuild_view(); return; } if (key == KEY_BACKSPACE) { - if (!pw_buf_.empty()) pw_buf_.pop_back(); - pw_update_display(); + password_model_.erase_last(); + password_view_.update_password(password_model_.masked_display()); return; } - if (page.cur_elm_ && page.cur_elm_->utf8[0]) { - pw_buf_ += page.cur_elm_->utf8; - pw_update_display(); + const std::string_view input = SetupPageAccess(page).current_utf8(); + if (!input.empty()) { + if (!password_model_.append(std::string(input))) { + password_view_.set_hint("Password too long"); + return; + } + password_view_.update_password(password_model_.masked_display()); } } -void WiFi::pw_update_display() +void WiFi::clear_password_view() { - if (!pw_input_lbl_) return; - std::string display = pw_buf_ + "_"; - lv_label_set_text(pw_input_lbl_, display.c_str()); + password_view_.unmount(); } } // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/wifi.hpp b/projects/APPLaunch/main/ui/page_app/setting/wifi.hpp new file mode 100644 index 00000000..eefaed7b --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/wifi.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include "menu_types.hpp" +#include "../../model/setup_wifi_model.hpp" + +#include "cp0_lvgl_app.h" +#include + +#include +#include +#include +#include +#include + +class UISetupPage; + +namespace setting { + +class WiFiListView +{ +public: + bool mount(UISetupPage &page); + bool render(UISetupPage &page, const SetupWifiListSnapshot &snapshot); + void unmount(); + bool mounted() const { return root_ != nullptr; } + +private: + struct RowObjects { + lv_obj_t *background = nullptr; + lv_obj_t *ssid = nullptr; + lv_obj_t *security = nullptr; + lv_obj_t *signal = nullptr; + std::string ssid_text; + std::string security_text; + std::string signal_text; + uint32_t color = 0; + bool selected = false; + bool visible = false; + }; + + static void root_delete_cb(lv_event_t *event) noexcept; + void reset_objects(); + + lv_obj_t *root_ = nullptr; + lv_obj_t *title_ = nullptr; + lv_obj_t *empty_ = nullptr; + lv_obj_t *hint_ = nullptr; + std::string title_text_; + std::string empty_text_; + std::string hint_text_; + RowObjects rows_[SetupWifiListViewModel::VISIBLE_ROWS]{}; +}; + +class WiFiPasswordView +{ +public: + bool show(UISetupPage &page, const std::string &ssid); + void update_password(const std::string &display); + void set_hint(const char *text, uint32_t color = 0x555555); + void unmount(); + +private: + static void root_delete_cb(lv_event_t *event) noexcept; + void reset_objects(); + + lv_obj_t *root_ = nullptr; + lv_obj_t *input_ = nullptr; + lv_obj_t *hint_ = nullptr; +}; + +class WiFi +{ +public: + ~WiFi(); + void append(UISetupPage &page, std::vector &menu); + void enter_scan(UISetupPage &page); + bool build_list(UISetupPage &page); + void handle_list_key(UISetupPage &page, uint32_t key); + void refresh_radio(UISetupPage &page); + void toggle_enable(UISetupPage &page); + void try_connect(UISetupPage &page, int index); + bool show_connecting(UISetupPage &page, const char *ssid); + bool show_error(UISetupPage &page, const char *message); + bool show_forget_confirmation(UISetupPage &page, const std::string &ssid); + void forget_selected(UISetupPage &page); + void handle_forget_key(UISetupPage &page, uint32_t key); + void handle_pw_key(UISetupPage &page, uint32_t key); + void shutdown(); + +private: + struct ScanState; + struct ScanResult; + void start_scan(UISetupPage &page); + void stop_scan(); + void request_scan(); + void refresh_list_status(); + void apply_scan_result(UISetupPage &page, const cp0_wifi_ap_t *aps, int count); + static void scan_result_cb(void *user) noexcept; + void clear_password_view(); + void start_connection_failure_feedback(UISetupPage &page); + void stop_connection_failure_feedback(); + static void connection_feedback_timer_cb(lv_timer_t *timer) noexcept; + static void feedback_screen_delete_cb(lv_event_t *event) noexcept; + + SetupWifiListViewModel list_view_model_; + WiFiListView list_view_; + WiFiPasswordView password_view_; + SetupWifiPasswordModel password_model_; + SetupWifiFeedbackModel feedback_model_; + SetupWifiFeedbackModel::Token feedback_token_ = 0; + lv_timer_t *feedback_timer_ = nullptr; + UISetupPage *feedback_page_ = nullptr; + std::string forget_ssid_; + bool forget_active_ = false; + std::shared_ptr scan_state_; + std::vector scan_threads_; +}; + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/setting/wifi_view.cpp b/projects/APPLaunch/main/ui/page_app/setting/wifi_view.cpp new file mode 100644 index 00000000..54c78064 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/setting/wifi_view.cpp @@ -0,0 +1,325 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "../ui_app_setup.hpp" +#include "setup_page_access.hpp" + +namespace setting { + +namespace { + +lv_obj_t *create_label(lv_obj_t *parent, const char *text, int x, int y, uint32_t color, + const lv_font_t *font) +{ + if (!parent) return nullptr; + lv_obj_t *label = lv_label_create(parent); + if (!label) return nullptr; + lv_label_set_text(label, text); + lv_obj_set_pos(label, x, y); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_text_font(label, font, LV_PART_MAIN); + return label; +} + +lv_obj_t *begin_view(lv_obj_t *container) +{ + if (!container) return nullptr; + lv_obj_t *view = lv_obj_create(container); + if (!view) return nullptr; + lv_obj_set_size(view, lv_pct(100), lv_pct(100)); + lv_obj_set_pos(view, 0, 0); + lv_obj_set_style_radius(view, 0, LV_PART_MAIN); + lv_obj_set_style_border_width(view, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(view, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(view, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_clear_flag(view, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(view, LV_OBJ_FLAG_HIDDEN); + return view; +} + +void commit_view(lv_obj_t *container, lv_obj_t *view) +{ + while (lv_obj_get_child_count(container) > 1) { + lv_obj_t *child = lv_obj_get_child(container, 0); + if (child == view) child = lv_obj_get_child(container, 1); + lv_obj_delete(child); + } + lv_obj_remove_flag(view, LV_OBJ_FLAG_HIDDEN); +} + +} // namespace + +void WiFiListView::reset_objects() +{ + root_ = nullptr; + title_ = nullptr; + empty_ = nullptr; + hint_ = nullptr; + title_text_.clear(); + empty_text_.clear(); + hint_text_.clear(); + for (auto &row : rows_) row = {}; +} + +void WiFiListView::root_delete_cb(lv_event_t *event) noexcept +{ + if (!event || lv_event_get_target(event) != lv_event_get_current_target(event)) return; + auto *view = static_cast(lv_event_get_user_data(event)); + if (view && lv_event_get_target(event) == view->root_) view->reset_objects(); +} + +void WiFiListView::unmount() +{ + if (root_) lv_obj_delete(root_); + reset_objects(); +} + +bool WiFiListView::mount(UISetupPage &page) +{ + if (mounted()) return true; + SetupPageAccess access(page); + lv_obj_t *container = access.content_container(); + lv_obj_t *view = begin_view(container); + if (!view) return false; + root_ = view; + lv_obj_add_event_cb(view, root_delete_cb, LV_EVENT_DELETE, this); + auto fail = [&] { + if (root_) lv_obj_delete(root_); + reset_objects(); + return false; + }; + + title_ = create_label( + view, "", 8, 2, 0x58A6FF, + launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD)); + if (!title_) return fail(); + + if (!create_label(view, "SSID", 8, 18, 0x888888, &lv_font_montserrat_10) || + !create_label(view, "Security", 180, 18, 0x888888, &lv_font_montserrat_10) || + !create_label(view, "Signal", 270, 18, 0x888888, &lv_font_montserrat_10)) + return fail(); + + empty_ = create_label(view, "", 8, 50, 0x666666, &lv_font_montserrat_12); + if (!empty_) return fail(); + + for (int visible_index = 0; visible_index < SetupWifiListViewModel::VISIBLE_ROWS; + ++visible_index) { + auto &row = rows_[visible_index]; + const int y = 30 + visible_index * 22; + row.background = lv_obj_create(view); + if (!row.background) return fail(); + lv_obj_set_size(row.background, access.layout().screen_width - 8, 20); + lv_obj_set_pos(row.background, 4, y); + lv_obj_set_style_radius(row.background, 2, LV_PART_MAIN); + lv_obj_set_style_bg_color(row.background, lv_color_hex(0x1F3A5F), LV_PART_MAIN); + lv_obj_set_style_bg_opa(row.background, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(row.background, 0, LV_PART_MAIN); + lv_obj_clear_flag(row.background, LV_OBJ_FLAG_SCROLLABLE); + + row.ssid = create_label(view, "", 8, y + 2, 0xCCCCCC, &lv_font_montserrat_12); + row.security = create_label(view, "", 180, y + 2, 0xCCCCCC, &lv_font_montserrat_10); + row.signal = create_label(view, "", 275, y + 2, 0xCCCCCC, &lv_font_montserrat_10); + if (!row.ssid || !row.security || !row.signal) return fail(); + lv_obj_set_width(row.ssid, 165); + lv_label_set_long_mode(row.ssid, LV_LABEL_LONG_CLIP); + lv_obj_add_flag(row.background, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(row.ssid, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(row.security, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(row.signal, LV_OBJ_FLAG_HIDDEN); + } + + hint_ = create_label(view, "", 8, access.content_height() - 14, 0x555555, + &lv_font_montserrat_10); + if (!hint_) return fail(); + commit_view(container, view); + return true; +} + +bool WiFiListView::render(UISetupPage &page, const SetupWifiListSnapshot &snapshot) +{ + if (!mount(page)) return false; + SetupPageAccess access(page); + if (title_text_ != snapshot.title) { + title_text_ = snapshot.title; + lv_label_set_text(title_, title_text_.c_str()); + access.apply_overflow(title_, 8, access.layout().wifi_title_width, true); + } + if (empty_text_ != snapshot.empty_message) { + empty_text_ = snapshot.empty_message; + lv_label_set_text(empty_, empty_text_.c_str()); + } + if (snapshot.rows.empty()) lv_obj_remove_flag(empty_, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(empty_, LV_OBJ_FLAG_HIDDEN); + if (hint_text_ != snapshot.hint) { + hint_text_ = snapshot.hint; + lv_label_set_text(hint_, hint_text_.c_str()); + } + + for (int index = 0; index < SetupWifiListViewModel::VISIBLE_ROWS; ++index) { + auto &objects = rows_[index]; + if (index >= static_cast(snapshot.rows.size())) { + if (objects.visible) { + lv_obj_add_flag(objects.background, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.ssid, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.security, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.signal, LV_OBJ_FLAG_HIDDEN); + objects.visible = false; + } + continue; + } + const auto &row = snapshot.rows[static_cast(index)]; + const uint32_t color = row.in_use ? 0x58A6FF : (row.selected ? 0xFFFFFF : 0xCCCCCC); + if (objects.ssid_text != row.ssid) { + objects.ssid_text = row.ssid; + lv_label_set_text(objects.ssid, objects.ssid_text.c_str()); + } + if (objects.security_text != row.security) { + objects.security_text = row.security; + lv_label_set_text(objects.security, objects.security_text.c_str()); + } + if (objects.signal_text != row.signal) { + objects.signal_text = row.signal; + lv_label_set_text(objects.signal, objects.signal_text.c_str()); + } + if (!objects.visible || objects.color != color) { + objects.color = color; + lv_obj_set_style_text_color(objects.ssid, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_text_color(objects.security, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_text_color(objects.signal, lv_color_hex(color), LV_PART_MAIN); + } + if (!objects.visible || objects.selected != row.selected) { + objects.selected = row.selected; + if (row.selected) lv_obj_remove_flag(objects.background, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(objects.background, LV_OBJ_FLAG_HIDDEN); + } + if (!objects.visible) { + lv_obj_remove_flag(objects.ssid, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(objects.security, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(objects.signal, LV_OBJ_FLAG_HIDDEN); + objects.visible = true; + } + } + return true; +} + +bool WiFi::build_list(UISetupPage &page) +{ + return list_view_.render(page, list_view_model_.snapshot()); +} + +bool WiFi::show_connecting(UISetupPage &page, const char *ssid) +{ + lv_obj_t *container = SetupPageAccess(page).content_container(); + lv_obj_t *view = begin_view(container); + if (!view) return false; + char message[128]; + snprintf(message, sizeof(message), "Connecting to %s...", ssid); + if (!create_label(view, message, 8, 60, 0x58A6FF, &lv_font_montserrat_14)) { + lv_obj_delete(view); + return false; + } + commit_view(container, view); + lv_refr_now(nullptr); + return true; +} + +bool WiFi::show_error(UISetupPage &page, const char *message) +{ + lv_obj_t *container = SetupPageAccess(page).content_container(); + lv_obj_t *view = begin_view(container); + if (!view) return false; + if (!create_label(view, message, 8, 60, 0xFF4444, &lv_font_montserrat_14)) { + lv_obj_delete(view); + return false; + } + commit_view(container, view); + lv_refr_now(nullptr); + return true; +} + +bool WiFi::show_forget_confirmation(UISetupPage &page, const std::string &ssid) +{ + lv_obj_t *container = SetupPageAccess(page).content_container(); + lv_obj_t *view = begin_view(container); + if (!view) return false; + char message[128]; + snprintf(message, sizeof(message), "Forget '%s'?", ssid.c_str()); + if (!create_label(view, message, 8, 50, 0xFFAA00, &lv_font_montserrat_14) || + !create_label(view, "OK:confirm ESC:cancel", 8, 75, 0x888888, + &lv_font_montserrat_10)) { + lv_obj_delete(view); + return false; + } + commit_view(container, view); + lv_refr_now(nullptr); + return true; +} + +void WiFiPasswordView::reset_objects() +{ + root_ = nullptr; + input_ = nullptr; + hint_ = nullptr; +} + +void WiFiPasswordView::root_delete_cb(lv_event_t *event) noexcept +{ + if (!event || lv_event_get_target(event) != lv_event_get_current_target(event)) return; + auto *view = static_cast(lv_event_get_user_data(event)); + if (view && lv_event_get_target(event) == view->root_) view->reset_objects(); +} + +void WiFiPasswordView::unmount() +{ + if (root_) lv_obj_delete(root_); + reset_objects(); +} + +bool WiFiPasswordView::show(UISetupPage &page, const std::string &ssid) +{ + unmount(); + SetupPageAccess access(page); + lv_obj_t *container = access.content_container(); + lv_obj_t *view = begin_view(container); + if (!view) return false; + root_ = view; + lv_obj_add_event_cb(view, root_delete_cb, LV_EVENT_DELETE, this); + auto fail = [&] { + if (root_) lv_obj_delete(root_); + reset_objects(); + return false; + }; + + char title_text[128]; + snprintf(title_text, sizeof(title_text), "Connect: %s", ssid.c_str()); + if (!create_label(view, title_text, 10, 10, 0x58A6FF, &lv_font_montserrat_12) || + !create_label(view, "Password:", 10, 35, 0xCCCCCC, &lv_font_montserrat_12)) + return fail(); + + lv_obj_t *input = create_label(view, "_", 90, 35, 0xFFFFFF, &lv_font_montserrat_14); + if (!input) return fail(); + lv_obj_set_width(input, 200); + lv_label_set_long_mode(input, LV_LABEL_LONG_CLIP); + + lv_obj_t *hint = create_label(view, "Type pw, OK:connect, ESC:cancel", 10, 65, + 0x555555, &lv_font_montserrat_10); + if (!hint) return fail(); + + commit_view(container, view); + input_ = input; + hint_ = hint; + access.set_view(SetupViewState::WIFI_PW); + return true; +} + +void WiFiPasswordView::update_password(const std::string &display) +{ + if (input_) lv_label_set_text(input_, display.c_str()); +} + +void WiFiPasswordView::set_hint(const char *text, uint32_t color) +{ + if (!hint_) return; + lv_label_set_text(hint_, text ? text : ""); + lv_obj_set_style_text_color(hint_, lv_color_hex(color), LV_PART_MAIN); +} + +} // namespace setting diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_file.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_file.hpp deleted file mode 100644 index 9fb3f5ff..00000000 --- a/projects/APPLaunch/main/ui/page_app/ui_app_file.hpp +++ /dev/null @@ -1,437 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD - * - * SPDX-License-Identifier: MIT - */ - -#pragma once -#include "sample_log.h" -#include "../ui_app_page.hpp" -#include "compat/input_keys.h" -#include -#include -#include -#include -#include -#include - -// ============================================================ -// File Browser UIFilePage -// Screen: 320 x 170 (top bar 20px, ui_APP_Container 320x150) -// -// Features: -// - Browse filesystem starting at "/" -// - Directories first (sorted), then files (sorted) -// - Navigate with UP/DOWN, enter directory with RIGHT/ENTER -// - LEFT or ESC goes to parent (ESC at root goes home) -// ============================================================ - -class UIFilePage : public AppPage -{ - struct FileEntry - { - std::string name; - bool is_dir; - off_t size; // file size in bytes (0 for dirs) - }; - -public: - UIFilePage() : AppPage() - { - set_page_title("FILES"); - current_path_ = "/"; - creat_UI(); - event_handler_init(); - } - ~UIFilePage() {} - -private: - // ==================== data members ==================== - std::unordered_map ui_obj_; - std::string current_path_; - std::vector entries_; - int selected_idx_ = 0; - - // layout constants (content area 320x150, title bar 22px, list area 128px) - static constexpr int ITEM_H = 30; - static constexpr int VISIBLE_ROWS = 4; - static constexpr int TITLE_H = 22; - static constexpr int LIST_H = 128; - - // ==================== path utilities ==================== - static std::string path_parent(const std::string &path) - { - if (path.empty()) return "/"; - std::vector buf(path.begin(), path.end()); - buf.push_back('\0'); - char *p = dirname(buf.data()); - return std::string(p ? p : "/"); - } - - static std::string path_join(const std::string &base, const std::string &name) - { - if (base.empty()) return name; - if (name.empty()) return base; - if (base == "/") return "/" + name; - if (!base.empty() && base.back() == '/') return base + name; - return base + "/" + name; - } - - // ==================== format file size ==================== - static void format_size(off_t size, char *buf, size_t buflen) - { - if (size < 1024) - snprintf(buf, buflen, "%dB", (int)size); - else if (size < 1024 * 1024) - snprintf(buf, buflen, "%.1fKB", size / 1024.0); - else - snprintf(buf, buflen, "%.1fMB", size / (1024.0 * 1024.0)); - } - - static bool decode_list_field(const std::string &encoded, std::string &decoded) - { - auto hex_value = [](char c) -> int { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - return -1; - }; - decoded.clear(); - for (size_t i = 0; i < encoded.size(); ++i) { - if (encoded[i] != '%') { - decoded.push_back(encoded[i]); - continue; - } - if (i + 2 >= encoded.size()) return false; - const int high = hex_value(encoded[i + 1]); - const int low = hex_value(encoded[i + 2]); - if (high < 0 || low < 0) return false; - decoded.push_back(static_cast((high << 4) | low)); - i += 2; - } - return true; - } - - // ==================== read directory ==================== - void read_directory() - { - entries_.clear(); - - int code = -1; - std::string data; - cp0_signal_filesystem_api({"DirListDetail", current_path_}, [&](int c, std::string d) { - code = c; - data = std::move(d); - }); - if (code != 0) { - SLOGI("[File] directory list failed: %s", current_path_.c_str()); - return; - } - std::istringstream stream(data); - std::string line; - while (std::getline(stream, line)) { - const size_t first = line.find('\t'); - const size_t second = first == std::string::npos ? first : line.find('\t', first + 1); - if (first == std::string::npos || second == std::string::npos) continue; - FileEntry fe; - if (line[0] != 'D' && line[0] != 'F') continue; - char *size_end = nullptr; - const long long parsed_size = std::strtoll(line.c_str() + first + 1, &size_end, 10); - if (size_end != line.c_str() + second || parsed_size < 0) continue; - fe.is_dir = line[0] == 'D'; - fe.size = fe.is_dir ? 0 : static_cast(parsed_size); - if (!decode_list_field(line.substr(second + 1), fe.name)) continue; - entries_.push_back(std::move(fe)); - } - - // sort: directories first (alphabetically), then files (alphabetically) - std::sort(entries_.begin(), entries_.end(), - [](const FileEntry &a, const FileEntry &b) { - if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir; - return a.name < b.name; - }); - - // clamp selected index - if (selected_idx_ >= (int)entries_.size()) - selected_idx_ = entries_.empty() ? 0 : (int)entries_.size() - 1; - } - - // ==================== UI construction ==================== - void creat_UI() - { - // ---- background ---- - lv_obj_t *bg = lv_obj_create(ui_APP_Container); - lv_obj_set_size(bg, 320, 150); - lv_obj_set_pos(bg, 0, 0); - lv_obj_set_style_radius(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x0D1117), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["bg"] = bg; - - // ---- title bar ---- - lv_obj_t *title_bar = lv_obj_create(bg); - lv_obj_set_size(title_bar, 320, TITLE_H); - lv_obj_set_pos(title_bar, 0, 0); - lv_obj_set_style_radius(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(title_bar, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(title_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(title_bar, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - - // path label (left side of title bar) - lv_obj_t *lbl_path = lv_label_create(title_bar); - lv_label_set_text(lbl_path, "/"); - lv_obj_set_align(lbl_path, LV_ALIGN_LEFT_MID); - lv_obj_set_width(lbl_path, 220); - lv_label_set_long_mode(lbl_path, LV_LABEL_LONG_SCROLL_CIRCULAR); - lv_obj_set_style_text_color(lbl_path, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_path, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - ui_obj_["title_path"] = lbl_path; - - // hint (right side of title bar) - lv_obj_t *lbl_hint = lv_label_create(title_bar); - lv_label_set_text(lbl_hint, "ESC:back"); - lv_obj_set_align(lbl_hint, LV_ALIGN_RIGHT_MID); - lv_obj_set_x(lbl_hint, -4); - lv_obj_set_style_text_color(lbl_hint, lv_color_hex(0x7EA8D8), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_hint, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - - // ---- list container ---- - lv_obj_t *list_cont = lv_obj_create(bg); - lv_obj_set_size(list_cont, 320, LIST_H); - lv_obj_set_pos(list_cont, 0, TITLE_H); - lv_obj_set_style_radius(list_cont, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(list_cont, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(list_cont, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(list_cont, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(list_cont, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["list_cont"] = list_cont; - - // initial read and render - read_directory(); - build_list_rows(); - } - - // ==================== build list rows ==================== - void build_list_rows() - { - lv_obj_t *list_cont = ui_obj_["list_cont"]; - lv_obj_clean(list_cont); - - // update title path - if (ui_obj_.count("title_path") && ui_obj_["title_path"]) - lv_label_set_text(ui_obj_["title_path"], current_path_.c_str()); - - if (entries_.empty()) - { - lv_obj_t *lbl = lv_label_create(list_cont); - lv_label_set_text(lbl, "(empty directory)"); - lv_obj_set_pos(lbl, 10, 50); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x555555), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - return; - } - - int item_count = (int)entries_.size(); - int visible = LIST_H / ITEM_H; - - // scroll offset to center selected item - int offset = selected_idx_ - visible / 2; - if (offset < 0) offset = 0; - if (offset > item_count - visible) offset = item_count - visible; - if (offset < 0) offset = 0; - - for (int vi = 0; vi < visible && (vi + offset) < item_count; ++vi) - { - int mi = vi + offset; - bool sel = (mi == selected_idx_); - create_row(list_cont, vi, mi, sel); - } - } - - // ==================== create single row ==================== - void create_row(lv_obj_t *parent, int visual_row, int idx, bool selected) - { - const FileEntry &fe = entries_[idx]; - - // row background - lv_obj_t *row = lv_obj_create(parent); - lv_obj_set_size(row, 318, ITEM_H - 2); - lv_obj_set_pos(row, 1, visual_row * ITEM_H + 1); - lv_obj_set_style_radius(row, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); - - if (selected) - { - lv_obj_set_style_bg_color(row, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(row, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - // left highlight bar - lv_obj_t *sel_bar = lv_obj_create(row); - lv_obj_set_size(sel_bar, 3, ITEM_H - 8); - lv_obj_set_pos(sel_bar, 2, 3); - lv_obj_set_style_radius(sel_bar, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(sel_bar, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(sel_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(sel_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(sel_bar, LV_OBJ_FLAG_SCROLLABLE); - } - else - { - lv_obj_set_style_bg_color(row, lv_color_hex(0x161B22), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(row, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - // divider line - if (idx < (int)entries_.size() - 1) - { - lv_obj_t *div = lv_obj_create(parent); - lv_obj_set_size(div, 310, 1); - lv_obj_set_pos(div, 5, (visual_row + 1) * ITEM_H); - lv_obj_set_style_radius(div, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(div, lv_color_hex(0x21262D), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(div, 200, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(div, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(div, LV_OBJ_FLAG_SCROLLABLE); - } - - // icon indicator (folder or file) - lv_obj_t *lbl_icon = lv_label_create(row); - lv_label_set_text(lbl_icon, fe.is_dir ? LV_SYMBOL_DIRECTORY : LV_SYMBOL_FILE); - lv_obj_set_pos(lbl_icon, 8, (ITEM_H - 2 - 14) / 2); - lv_obj_set_style_text_color(lbl_icon, - fe.is_dir ? lv_color_hex(0x58A6FF) : lv_color_hex(0x7EA8D8), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_icon, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - - // file/directory name - char name_buf[256]; - if (fe.is_dir) - snprintf(name_buf, sizeof(name_buf), "%s/", fe.name.c_str()); - else - snprintf(name_buf, sizeof(name_buf), "%s", fe.name.c_str()); - - lv_obj_t *lbl_name = lv_label_create(row); - lv_label_set_text(lbl_name, name_buf); - lv_obj_set_pos(lbl_name, 28, (ITEM_H - 2 - 14) / 2); - lv_obj_set_width(lbl_name, fe.is_dir ? 270 : 210); - lv_label_set_long_mode(lbl_name, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(lbl_name, - selected ? lv_color_hex(0xFFFFFF) : lv_color_hex(0xE6EDF3), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_name, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - - // file size (only for files) - if (!fe.is_dir) - { - char size_buf[32]; - format_size(fe.size, size_buf, sizeof(size_buf)); - - lv_obj_t *lbl_size = lv_label_create(row); - lv_label_set_text(lbl_size, size_buf); - lv_obj_set_pos(lbl_size, 260, (ITEM_H - 2 - 12) / 2); - lv_obj_set_width(lbl_size, 56); - lv_label_set_long_mode(lbl_size, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(lbl_size, lv_color_hex(0x555555), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_size, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - // arrow indicator for directories - if (fe.is_dir) - { - lv_obj_t *lbl_arrow = lv_label_create(row); - lv_label_set_text(lbl_arrow, LV_SYMBOL_RIGHT); - lv_obj_set_pos(lbl_arrow, 300, (ITEM_H - 2 - 12) / 2); - lv_obj_set_style_text_color(lbl_arrow, - selected ? lv_color_hex(0x58A6FF) : lv_color_hex(0x3A4A5A), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_arrow, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - } - } - - // ==================== navigation ==================== - void navigate_into(int idx) - { - if (idx < 0 || idx >= (int)entries_.size()) return; - if (!entries_[idx].is_dir) return; - - current_path_ = path_join(current_path_, entries_[idx].name); - selected_idx_ = 0; - read_directory(); - build_list_rows(); - } - - void navigate_parent() - { - if (current_path_ == "/") return; - current_path_ = path_parent(current_path_); - selected_idx_ = 0; - read_directory(); - build_list_rows(); - } - - // ==================== event binding ==================== - void event_handler_init() - { - lv_obj_add_event_cb(root_screen_, UIFilePage::static_lvgl_handler, LV_EVENT_ALL, this); - } - - static void static_lvgl_handler(lv_event_t *e) - { - UIFilePage *self = static_cast(lv_event_get_user_data(e)); - if (self) self->event_handler(e); - } - - void event_handler(lv_event_t *e) - { - if (launcher_ui::events::is_key_released(e)) - { - uint32_t key = launcher_ui::events::keyboard_key(e); - int count = (int)entries_.size(); - - switch (key) - { - case KEY_UP: - if (selected_idx_ > 0) - { - --selected_idx_; - build_list_rows(); - } - break; - - case KEY_DOWN: - if (selected_idx_ < count - 1) - { - ++selected_idx_; - build_list_rows(); - } - break; - - case KEY_RIGHT: - case KEY_ENTER: - if (count > 0 && entries_[selected_idx_].is_dir) - navigate_into(selected_idx_); - break; - - case KEY_LEFT: - navigate_parent(); - break; - - case KEY_ESC: - if (current_path_ != "/") - navigate_parent(); - else if (navigate_home) - navigate_home(); - break; - - default: - break; - } - } - } -}; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_game.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_game.cpp new file mode 100644 index 00000000..6c75cd09 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_game.cpp @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_game.hpp" +#include "../model/snake_view_contract.hpp" + +#include "input_keys.h" + +#include + +UIGamePage::UIGamePage() : AppPageRoot() +{ + + create_ui(); + event_handler_init(); +} + +UIGamePage::~UIGamePage() +{ + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UIGamePage::static_lvgl_handler, this); + game_timer_.stop(); + detach_ui_callbacks(); +} + +void UIGamePage::game_reset() +{ + model_.reset(); + update_score_label(); +} + +void UIGamePage::game_start() +{ + if (!game_area_) return; + state_ = STATE_PLAYING; + clear_overlay(); + game_reset(); + if (!render_game()) { + state_ = STATE_READY; + show_overlay("Unable to render game"); + return; + } + + game_tick_callback_enabled_ = game_timer_.start( + [this] { return lv_timer_create(UIGamePage::static_game_tick, 200, this); }, + [](lv_timer_t *timer) { lv_timer_delete(timer); }); + if (!game_tick_callback_enabled_) { + state_ = STATE_READY; + show_overlay("Unable to start game"); + } +} + +void UIGamePage::game_over() +{ + state_ = STATE_GAME_OVER; + game_timer_.stop(); + + char buf[80]; + snprintf(buf, sizeof(buf), "Game Over! Score: %d\nOK: Restart ESC: Quit", model_.score()); + show_overlay(buf); +} + +void UIGamePage::game_tick() +{ + if (state_ != STATE_PLAYING) return; + + const int previous_score = model_.score(); + if (!model_.tick()) { + game_over(); + return; + } + if (model_.score() != previous_score) update_score_label(); + if (!render_game()) { + game_timer_.stop(); + state_ = STATE_READY; + show_overlay("Unable to render game"); + } +} + +void UIGamePage::static_game_tick(lv_timer_t *t) noexcept +{ + UIGamePage *self = nullptr; + try { + self = static_cast(lv_timer_get_user_data(t)); + if (!self || !self->game_tick_callback_enabled_) return; + if (!snake_tick_callback_allowed( + self->game_tick_callback_enabled_, self->game_timer_.current(t), + self->state_ == STATE_PLAYING, self->game_area_, + self->render_layer_)) return; + self->game_tick(); + } catch (...) { + if (self) { + self->game_tick_callback_enabled_ = false; + self->state_ = STATE_READY; + } + } +} + +void UIGamePage::event_handler_init() +{ + if (root_screen_) + lv_obj_add_event_cb(root_screen_, UIGamePage::static_lvgl_handler, LV_EVENT_ALL, this); +} + +void UIGamePage::static_lvgl_handler(lv_event_t *e) noexcept +{ + UIGamePage *self = nullptr; + try { + if (!e) return; + self = static_cast(lv_event_get_user_data(e)); + if (!self || !snake_page_event_callback_allowed( + lv_event_get_current_target(e), self->root_screen_)) return; + self->event_handler(e); + } catch (...) { + if (self) { + self->game_tick_callback_enabled_ = false; + self->state_ = STATE_READY; + } + } +} + +void UIGamePage::event_handler(lv_event_t *e) +{ + if (lv_event_get_code(e) == LV_EVENT_DELETE && + lv_event_get_target(e) == lv_event_get_current_target(e)) { + game_timer_.stop(); + bg_ = nullptr; + title_bar_ = nullptr; + score_label_ = nullptr; + game_area_ = nullptr; + render_layer_ = nullptr; + overlay_lbl_ = nullptr; + return; + } + if (launcher_ui::events::is_key_released(e)) + { + uint32_t key = launcher_ui::events::keyboard_key(e); + + switch (state_) + { + case STATE_READY: + handle_ready_key(key); + break; + case STATE_PLAYING: + handle_playing_key(key); + break; + case STATE_GAME_OVER: + handle_gameover_key(key); + break; + } + } +} + +void UIGamePage::handle_ready_key(uint32_t key) +{ + switch (key) { + case KEY_ENTER: + game_start(); + break; + case KEY_ESC: + if (navigate_home) navigate_home(); + break; + default: + break; + } +} + +void UIGamePage::handle_playing_key(uint32_t key) +{ + switch (key) { + case KEY_UP: + model_.queue_direction(SnakeGameModel::Direction::UP); + break; + case KEY_DOWN: + model_.queue_direction(SnakeGameModel::Direction::DOWN); + break; + case KEY_LEFT: + model_.queue_direction(SnakeGameModel::Direction::LEFT); + break; + case KEY_RIGHT: + model_.queue_direction(SnakeGameModel::Direction::RIGHT); + break; + case KEY_ESC: + // Pause and quit + game_timer_.stop(); + state_ = STATE_READY; + if (game_area_) lv_obj_clean(game_area_); + render_layer_ = nullptr; + overlay_lbl_ = nullptr; + show_overlay("Press OK to Start"); + model_.reset(); + update_score_label(); + break; + default: + break; + } +} + +void UIGamePage::handle_gameover_key(uint32_t key) +{ + switch (key) { + case KEY_ENTER: + clear_overlay(); + game_start(); + break; + case KEY_ESC: + if (navigate_home) navigate_home(); + break; + default: + break; + } +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_game.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_game.hpp index 511cfb1e..41d1baf0 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_game.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_game.hpp @@ -5,12 +5,9 @@ */ #pragma once -#include "../ui_app_page.hpp" -#include -#include -#include -#include -#include "compat/input_keys.h" +#include "../launcher_ui_app_page.hpp" +#include "../model/snake_game_model.hpp" +#include "../model/page_timer_lifecycle.hpp" // ============================================================ // Snake Game UIGamePage @@ -38,8 +35,6 @@ class UIGamePage : public AppPageRoot // ---- Grid constants ---- static constexpr int CELL_SIZE = 8; - static constexpr int GRID_COLS = 40; // 320 / 8 - static constexpr int GRID_ROWS = 18; // 144 / 8 static constexpr int GAME_AREA_W = 320; static constexpr int GAME_AREA_H = 148; // 170 - 22 static constexpr int TITLE_BAR_H = 22; @@ -54,392 +49,73 @@ class UIGamePage : public AppPageRoot static constexpr uint32_t COLOR_TEXT = 0xE6EDF3; static constexpr uint32_t COLOR_TEXT_DIM = 0x7EA8D8; - // ---- Direction enum ---- - enum Direction { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT }; - // ---- Game state enum ---- enum GameState { STATE_READY, STATE_PLAYING, STATE_GAME_OVER }; - // ---- Coordinate pair ---- - struct Point { - int x, y; - bool operator==(const Point &o) const { return x == o.x && y == o.y; } - }; - // ---- UI objects ---- lv_obj_t *bg_ = nullptr; lv_obj_t *title_bar_ = nullptr; lv_obj_t *score_label_ = nullptr; lv_obj_t *game_area_ = nullptr; + lv_obj_t *render_layer_ = nullptr; lv_obj_t *overlay_lbl_ = nullptr; // centered text for READY / GAME_OVER // ---- Game timer ---- - lv_timer_t *game_timer_ = nullptr; + PageTimerLifecycle game_timer_; + bool game_tick_callback_enabled_ = false; // ---- Game data ---- - std::deque snake_; - Point food_; - Direction dir_ = DIR_RIGHT; - Direction next_dir_ = DIR_RIGHT; - GameState state_ = STATE_READY; - int score_ = 0; + SnakeGameModel model_; + GameState state_ = STATE_READY; public: - UIGamePage() : AppPageRoot() - { - - creat_UI(); - event_handler_init(); - } + UIGamePage(); - ~UIGamePage() - { - if (game_timer_) { - lv_timer_delete(game_timer_); - game_timer_ = nullptr; - } - } + ~UIGamePage(); private: // ==================== UI construction ==================== - void creat_UI() - { - // -- Background panel -- - bg_ = lv_obj_create(root_screen_); - lv_obj_set_size(bg_, SCREEN_W, SCREEN_H); - lv_obj_set_pos(bg_, 0, 0); - lv_obj_set_style_radius(bg_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(bg_, lv_color_hex(COLOR_BG), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(bg_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(bg_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(bg_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(bg_, LV_OBJ_FLAG_SCROLLABLE); - - // -- Title bar -- - title_bar_ = lv_obj_create(bg_); - lv_obj_set_size(title_bar_, SCREEN_W, TITLE_BAR_H); - lv_obj_set_pos(title_bar_, 0, 0); - lv_obj_set_style_radius(title_bar_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(title_bar_, lv_color_hex(COLOR_TITLE_BAR), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(title_bar_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(title_bar_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(title_bar_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(title_bar_, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *lbl_title = lv_label_create(title_bar_); - lv_label_set_text(lbl_title, "GAME - Snake"); - lv_obj_set_align(lbl_title, LV_ALIGN_LEFT_MID); - lv_obj_set_style_text_color(lbl_title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - - score_label_ = lv_label_create(title_bar_); - lv_label_set_text(score_label_, "Score: 0"); - lv_obj_set_align(score_label_, LV_ALIGN_RIGHT_MID); - lv_obj_set_x(score_label_, -8); - lv_obj_set_style_text_color(score_label_, lv_color_hex(COLOR_TEXT_DIM), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(score_label_, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - - // -- Game area container -- - game_area_ = lv_obj_create(bg_); - lv_obj_set_size(game_area_, GAME_AREA_W, GAME_AREA_H); - lv_obj_set_pos(game_area_, 0, TITLE_BAR_H); - lv_obj_set_style_radius(game_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(game_area_, lv_color_hex(COLOR_BG), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(game_area_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(game_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(game_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(game_area_, LV_OBJ_FLAG_SCROLLABLE); - - // -- Show ready screen -- - show_overlay("Press OK to Start"); - } + void create_ui(); + void detach_ui_callbacks(); + static void owned_obj_delete_cb(lv_event_t *event) noexcept; // ==================== Overlay text (centered) ==================== - void show_overlay(const char *text) - { - clear_overlay(); - overlay_lbl_ = lv_label_create(game_area_); - lv_label_set_text(overlay_lbl_, text); - lv_obj_set_align(overlay_lbl_, LV_ALIGN_CENTER); - lv_obj_set_style_text_color(overlay_lbl_, lv_color_hex(COLOR_TEXT), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(overlay_lbl_, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(overlay_lbl_, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - } + bool show_overlay(const char *text); - void clear_overlay() - { - if (overlay_lbl_) { - lv_obj_del(overlay_lbl_); - overlay_lbl_ = nullptr; - } - } + void clear_overlay(); // ==================== Game logic ==================== - void game_reset() - { - snake_.clear(); - // Start in the center of the grid, length 3, moving RIGHT - int cx = GRID_COLS / 2; - int cy = GRID_ROWS / 2; - snake_.push_front({cx, cy}); - snake_.push_back({cx - 1, cy}); - snake_.push_back({cx - 2, cy}); - - dir_ = DIR_RIGHT; - next_dir_ = DIR_RIGHT; - score_ = 0; - update_score_label(); - - spawn_food(); - } - - void game_start() - { - state_ = STATE_PLAYING; - clear_overlay(); - game_reset(); - render_game(); - - if (!game_timer_) - game_timer_ = lv_timer_create(UIGamePage::static_game_tick, 200, this); - } + void game_reset(); - void game_over() - { - state_ = STATE_GAME_OVER; - if (game_timer_) { - lv_timer_delete(game_timer_); - game_timer_ = nullptr; - } + void game_start(); - char buf[80]; - snprintf(buf, sizeof(buf), "Game Over! Score: %d\nOK: Restart ESC: Quit", score_); - show_overlay(buf); - } + void game_over(); - void spawn_food() - { - // Find a random position not occupied by the snake - int attempts = 0; - while (attempts < GRID_COLS * GRID_ROWS * 2) { - Point p = { rand() % GRID_COLS, rand() % GRID_ROWS }; - bool on_snake = false; - for (const auto &s : snake_) { - if (s == p) { on_snake = true; break; } - } - if (!on_snake) { - food_ = p; - return; - } - attempts++; - } - // Fallback: iterate grid to find any free cell - for (int y = 0; y < GRID_ROWS; y++) { - for (int x = 0; x < GRID_COLS; x++) { - Point p = {x, y}; - bool on_snake = false; - for (const auto &s : snake_) { - if (s == p) { on_snake = true; break; } - } - if (!on_snake) { - food_ = p; - return; - } - } - } - } - - void game_tick() - { - if (state_ != STATE_PLAYING) return; - - // Apply queued direction - dir_ = next_dir_; - - // Calculate new head position - Point head = snake_.front(); - switch (dir_) { - case DIR_UP: head.y--; break; - case DIR_DOWN: head.y++; break; - case DIR_LEFT: head.x--; break; - case DIR_RIGHT: head.x++; break; - } - - // Check wall collision - if (head.x < 0 || head.x >= GRID_COLS || head.y < 0 || head.y >= GRID_ROWS) { - game_over(); - return; - } - - // Check self collision - for (const auto &s : snake_) { - if (s == head) { - game_over(); - return; - } - } - - // Move: add new head - snake_.push_front(head); - - // Check food - if (head == food_) { - score_ += 10; - update_score_label(); - spawn_food(); - // Do NOT remove tail -- snake grows - } else { - // Remove tail - snake_.pop_back(); - } - - render_game(); - } + void game_tick(); // ==================== Rendering ==================== - void render_game() - { - // Clear all children of game_area (but keep overlay_lbl_ null during play) - lv_obj_clean(game_area_); - overlay_lbl_ = nullptr; // cleaned by lv_obj_clean - - // Draw food - create_cell(food_.x, food_.y, COLOR_FOOD); + bool render_game(); - // Draw snake body (tail to head-1) - for (size_t i = 1; i < snake_.size(); i++) { - create_cell(snake_[i].x, snake_[i].y, COLOR_SNAKE_BODY); - } + bool create_cell(lv_obj_t *parent, int gx, int gy, uint32_t color); - // Draw snake head - if (!snake_.empty()) { - create_cell(snake_.front().x, snake_.front().y, COLOR_SNAKE_HEAD); - } - } - - void create_cell(int gx, int gy, uint32_t color) - { - lv_obj_t *cell = lv_obj_create(game_area_); - lv_obj_set_size(cell, 7, 7); - lv_obj_set_pos(cell, gx * CELL_SIZE, gy * CELL_SIZE); - lv_obj_set_style_radius(cell, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(cell, lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(cell, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(cell, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(cell, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(cell, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - } - - void update_score_label() - { - if (!score_label_) return; - char buf[32]; - snprintf(buf, sizeof(buf), "Score: %d", score_); - lv_label_set_text(score_label_, buf); - } + void update_score_label(); // ==================== Timer callback ==================== - static void static_game_tick(lv_timer_t *t) - { - UIGamePage *self = static_cast(lv_timer_get_user_data(t)); - if (self) self->game_tick(); - } + static void static_game_tick(lv_timer_t *t) noexcept; // ==================== Event handling ==================== - void event_handler_init() - { - lv_obj_add_event_cb(root_screen_, UIGamePage::static_lvgl_handler, LV_EVENT_ALL, this); - } - - static void static_lvgl_handler(lv_event_t *e) - { - UIGamePage *self = static_cast(lv_event_get_user_data(e)); - if (self) self->event_handler(e); - } + void event_handler_init(); - void event_handler(lv_event_t *e) - { - if (launcher_ui::events::is_key_released(e)) - { - uint32_t key = launcher_ui::events::keyboard_key(e); + static void static_lvgl_handler(lv_event_t *e) noexcept; - switch (state_) - { - case STATE_READY: - handle_ready_key(key); - break; - case STATE_PLAYING: - handle_playing_key(key); - break; - case STATE_GAME_OVER: - handle_gameover_key(key); - break; - } - } - } + void event_handler(lv_event_t *e); // ---- READY state keys ---- - void handle_ready_key(uint32_t key) - { - switch (key) { - case KEY_ENTER: - game_start(); - break; - case KEY_ESC: - if (navigate_home) navigate_home(); - break; - default: - break; - } - } + void handle_ready_key(uint32_t key); // ---- PLAYING state keys ---- - void handle_playing_key(uint32_t key) - { - switch (key) { - case KEY_UP: - if (dir_ != DIR_DOWN) next_dir_ = DIR_UP; - break; - case KEY_DOWN: - if (dir_ != DIR_UP) next_dir_ = DIR_DOWN; - break; - case KEY_LEFT: - if (dir_ != DIR_RIGHT) next_dir_ = DIR_LEFT; - break; - case KEY_RIGHT: - if (dir_ != DIR_LEFT) next_dir_ = DIR_RIGHT; - break; - case KEY_ESC: - // Pause and quit - if (game_timer_) { - lv_timer_delete(game_timer_); - game_timer_ = nullptr; - } - state_ = STATE_READY; - lv_obj_clean(game_area_); - overlay_lbl_ = nullptr; - show_overlay("Press OK to Start"); - score_ = 0; - update_score_label(); - break; - default: - break; - } - } + void handle_playing_key(uint32_t key); // ---- GAME_OVER state keys ---- - void handle_gameover_key(uint32_t key) - { - switch (key) { - case KEY_ENTER: - clear_overlay(); - game_start(); - break; - case KEY_ESC: - if (navigate_home) navigate_home(); - break; - default: - break; - } - } + void handle_gameover_key(uint32_t key); }; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_game_view.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_game_view.cpp new file mode 100644 index 00000000..0887551c --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_game_view.cpp @@ -0,0 +1,236 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_game.hpp" +#include "../model/snake_view_contract.hpp" + +#include + +void UIGamePage::create_ui() +{ + if (!root_screen_) return; + bg_ = lv_obj_create(root_screen_); + if (!bg_) return; + auto rollback = [this]() { + lv_obj_t *background = bg_; + bg_ = nullptr; + title_bar_ = nullptr; + score_label_ = nullptr; + game_area_ = nullptr; + render_layer_ = nullptr; + overlay_lbl_ = nullptr; + if (background) lv_obj_delete(background); + }; + lv_obj_add_event_cb(bg_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(bg_, SCREEN_W, SCREEN_H); + lv_obj_set_pos(bg_, 0, 0); + lv_obj_set_style_radius(bg_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(bg_, lv_color_hex(COLOR_BG), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(bg_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(bg_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(bg_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(bg_, LV_OBJ_FLAG_SCROLLABLE); + + title_bar_ = lv_obj_create(bg_); + if (!title_bar_) { + rollback(); + return; + } + lv_obj_add_event_cb(title_bar_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + { + lv_obj_set_size(title_bar_, SCREEN_W, TITLE_BAR_H); + lv_obj_set_pos(title_bar_, 0, 0); + lv_obj_set_style_radius(title_bar_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(title_bar_, lv_color_hex(COLOR_TITLE_BAR), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(title_bar_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(title_bar_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(title_bar_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(title_bar_, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title = lv_label_create(title_bar_); + if (!title) { + rollback(); + return; + } + { + lv_label_set_text(title, "GAME - Snake"); + lv_obj_set_align(title, LV_ALIGN_LEFT_MID); + lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, + LV_PART_MAIN | LV_STATE_DEFAULT); + } + + score_label_ = lv_label_create(title_bar_); + if (!score_label_) { + rollback(); + return; + } + { + lv_obj_add_event_cb(score_label_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_label_set_text(score_label_, "Score: 0"); + lv_obj_set_align(score_label_, LV_ALIGN_RIGHT_MID); + lv_obj_set_x(score_label_, -8); + lv_obj_set_style_text_color(score_label_, lv_color_hex(COLOR_TEXT_DIM), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(score_label_, &lv_font_montserrat_12, + LV_PART_MAIN | LV_STATE_DEFAULT); + } + } + + game_area_ = lv_obj_create(bg_); + if (!game_area_) { + rollback(); + return; + } + lv_obj_add_event_cb(game_area_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(game_area_, GAME_AREA_W, GAME_AREA_H); + lv_obj_set_pos(game_area_, 0, TITLE_BAR_H); + lv_obj_set_style_radius(game_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(game_area_, lv_color_hex(COLOR_BG), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(game_area_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(game_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(game_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(game_area_, LV_OBJ_FLAG_SCROLLABLE); + if (!show_overlay("Press OK to Start")) rollback(); +} + +bool UIGamePage::show_overlay(const char *text) +{ + if (!game_area_) return false; + clear_overlay(); + overlay_lbl_ = lv_label_create(game_area_); + if (!overlay_lbl_) return false; + lv_obj_add_event_cb(overlay_lbl_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_label_set_text(overlay_lbl_, text); + lv_obj_set_align(overlay_lbl_, LV_ALIGN_CENTER); + lv_obj_set_style_text_color(overlay_lbl_, lv_color_hex(COLOR_TEXT), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(overlay_lbl_, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(overlay_lbl_, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + return true; +} + +void UIGamePage::clear_overlay() +{ + if (!overlay_lbl_) return; + lv_obj_delete(overlay_lbl_); + overlay_lbl_ = nullptr; +} + +bool UIGamePage::render_game() +{ + if (!game_area_) { + game_timer_.stop(); + state_ = STATE_READY; + return false; + } + lv_obj_t *new_layer = lv_obj_create(game_area_); + if (!new_layer) return false; + lv_obj_remove_style_all(new_layer); + lv_obj_add_event_cb(new_layer, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(new_layer, GAME_AREA_W, GAME_AREA_H); + lv_obj_set_pos(new_layer, 0, 0); + lv_obj_clear_flag(new_layer, LV_OBJ_FLAG_SCROLLABLE); + + const auto food = model_.food(); + const auto &snake = model_.snake(); + SnakeFrameBuildContract build(snake.size() + 1); + if (!create_cell(new_layer, food.x, food.y, COLOR_FOOD)) { + lv_obj_delete(new_layer); + return false; + } + build.cell_created(); + for (size_t index = 1; index < snake.size(); ++index) { + if (!create_cell(new_layer, snake[index].x, snake[index].y, COLOR_SNAKE_BODY)) { + lv_obj_delete(new_layer); + return false; + } + build.cell_created(); + } + if (!snake.empty()) { + if (!create_cell(new_layer, snake.front().x, snake.front().y, COLOR_SNAKE_HEAD)) { + lv_obj_delete(new_layer); + return false; + } + build.cell_created(); + } + if (!build.ready()) { + lv_obj_delete(new_layer); + return false; + } + clear_overlay(); + if (render_layer_) lv_obj_delete(render_layer_); + render_layer_ = new_layer; + return true; +} + +bool UIGamePage::create_cell(lv_obj_t *parent, int grid_x, int grid_y, uint32_t color) +{ + if (!parent) return false; + lv_obj_t *cell = lv_obj_create(parent); + if (!cell) return false; + lv_obj_set_size(cell, 7, 7); + lv_obj_set_pos(cell, grid_x * CELL_SIZE, grid_y * CELL_SIZE); + lv_obj_set_style_radius(cell, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(cell, lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(cell, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(cell, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(cell, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(cell, static_cast(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + return true; +} + +void UIGamePage::detach_ui_callbacks() +{ + lv_obj_t *objects[] = { + overlay_lbl_, render_layer_, score_label_, game_area_, title_bar_, bg_}; + for (lv_obj_t *object : objects) + if (object) + lv_obj_remove_event_cb_with_user_data(object, owned_obj_delete_cb, this); +} + +void UIGamePage::owned_obj_delete_cb(lv_event_t *event) noexcept +{ + try { + if (!event || !snake_owned_delete_callback_allowed( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (!self || !deleted) return; + if (self->overlay_lbl_ == deleted) self->overlay_lbl_ = nullptr; + if (self->render_layer_ == deleted) self->render_layer_ = nullptr; + if (self->score_label_ == deleted) self->score_label_ = nullptr; + if (self->game_area_ == deleted) { + self->game_area_ = nullptr; + self->render_layer_ = nullptr; + self->overlay_lbl_ = nullptr; + self->game_timer_.stop(); + self->state_ = STATE_READY; + } + if (self->title_bar_ == deleted) { + self->title_bar_ = nullptr; + self->score_label_ = nullptr; + } + if (self->bg_ == deleted) { + self->bg_ = nullptr; + self->title_bar_ = nullptr; + self->score_label_ = nullptr; + self->game_area_ = nullptr; + self->render_layer_ = nullptr; + self->overlay_lbl_ = nullptr; + self->game_timer_.stop(); + self->state_ = STATE_READY; + } + } catch (...) { + auto *self = event + ? static_cast(lv_event_get_user_data(event)) : nullptr; + if (self) self->state_ = STATE_READY; + } +} + +void UIGamePage::update_score_label() +{ + if (!score_label_) return; + char text[32]; + std::snprintf(text, sizeof(text), "Score: %d", model_.score()); + lv_label_set_text(score_label_, text); +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel.cpp new file mode 100644 index 00000000..df9c34f0 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel.cpp @@ -0,0 +1,165 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_ip_panel.hpp" + +#include "cp0_lvgl_app.h" +#include "input_keys.h" + +#include + +UIIpPanelPage::UIIpPanelPage() : AppPage() +{ + try { + set_page_title("IP INFO"); + create_ui(); + if (root_screen_ && ip_panel_view_ready(background_, list_container_)) + event_handler_init(); + } catch (...) { + refresh_enabled_ = false; + try { + refresh_timer_.stop(); + } catch (...) { + } + lv_obj_t *background = background_; + detach_delete_callbacks(); + if (background) lv_obj_delete(background); + } +} + +UIIpPanelPage::~UIIpPanelPage() +{ + refresh_timer_.stop(); + detach_delete_callbacks(); + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UIIpPanelPage::static_lvgl_handler, this); +} + +bool UIIpPanelPage::fetch_iface_list() +{ + cp0_netif_info_t entries[32]{}; + int count = 0; + if (cp0_network_list(entries, 32, &count) != 0) { + return false; + } + if (count < 0) count = 0; + if (count > 32) count = 32; + + std::vector interfaces; + interfaces.reserve(static_cast(count)); + for (int index = 0; index < count; ++index) { + interfaces.push_back({entries[index].iface, entries[index].ipv4, + entries[index].netmask, entries[index].is_up != 0}); + } + return model_.apply_refresh(true, std::move(interfaces)); +} + +void UIIpPanelPage::static_timer_cb(lv_timer_t *timer) noexcept +{ + auto *page = static_cast(lv_timer_get_user_data(timer)); + if (!page) return; + try { + if (ip_panel_refresh_callback_allowed( + page->refresh_timer_.current(timer), page->refresh_enabled_)) + page->on_timer_refresh(); + } catch (...) { + page->refresh_enabled_ = false; + } +} + +void UIIpPanelPage::on_timer_refresh() +{ + if (fetch_iface_list()) build_list_rows(); +} + +void UIIpPanelPage::event_handler_init() +{ + if (!root_screen_) return; + lv_obj_add_event_cb(root_screen_, UIIpPanelPage::static_lvgl_handler, LV_EVENT_ALL, this); +} + +void UIIpPanelPage::static_lvgl_handler(lv_event_t *event) noexcept +{ + if (!event) return; + auto *page = static_cast(lv_event_get_user_data(event)); + if (!page || !ip_panel_root_callback_allowed( + lv_event_get_current_target(event), page->root_screen_)) return; + try { + page->event_handler(event); + } catch (...) { + page->refresh_enabled_ = false; + page->refresh_timer_.stop(); + } +} + +uint32_t UIIpPanelPage::fzxc_to_lv_arrow(uint32_t key) +{ + switch (key) { + case KEY_F: return LV_KEY_UP; + case KEY_X: return LV_KEY_DOWN; + case KEY_Z: return LV_KEY_LEFT; + case KEY_C: return LV_KEY_RIGHT; + default: return key; + } +} + +void UIIpPanelPage::event_handler(lv_event_t *event) +{ + if (lv_event_get_code(event) == LV_EVENT_DELETE && + lv_event_get_target(event) == lv_event_get_current_target(event)) { + refresh_timer_.stop(); + list_container_ = nullptr; + return; + } + if (lv_event_get_code(event) != LV_EVENT_KEY) return; + switch (fzxc_to_lv_arrow(lv_event_get_key(event))) { + case LV_KEY_UP: + if (model_.select_previous()) build_list_rows(); + break; + case LV_KEY_DOWN: + if (model_.select_next()) build_list_rows(); + break; + case LV_KEY_ESC: + refresh_timer_.stop(); + if (navigate_home) navigate_home(); + break; + default: + break; + } +} + +void UIIpPanelPage::static_owned_obj_delete_cb(lv_event_t *event) noexcept +{ + UIIpPanelPage *self = nullptr; + try { + if (!event || !ip_panel_owned_delete_callback_allowed( + lv_event_get_target(event), lv_event_get_current_target(event))) + return; + self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + lv_obj_t *deleted = static_cast(lv_event_get_target(event)); + if (deleted == self->background_) { + self->background_ = nullptr; + self->list_container_ = nullptr; + self->refresh_enabled_ = false; + self->refresh_timer_.stop(); + } else if (deleted == self->list_container_) { + self->list_container_ = nullptr; + self->refresh_enabled_ = false; + self->refresh_timer_.stop(); + } + } catch (...) { + if (self) self->refresh_enabled_ = false; + } +} + +void UIIpPanelPage::detach_delete_callbacks() +{ + if (list_container_) + lv_obj_remove_event_cb_with_user_data( + list_container_, static_owned_obj_delete_cb, this); + if (background_) + lv_obj_remove_event_cb_with_user_data( + background_, static_owned_obj_delete_cb, this); + list_container_ = nullptr; + background_ = nullptr; +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel.hpp index 65f6f021..71eec786 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel.hpp @@ -5,14 +5,9 @@ */ #pragma once -#include "../ui_app_page.hpp" -#include "compat/input_keys.h" -#include -#include -#include -#include -#include -#include "cp0_lvgl_app.h" +#include "../launcher_ui_app_page.hpp" +#include "../model/ip_panel_model.hpp" +#include "../model/page_timer_lifecycle.hpp" // ============================================================ // IP panel screen UIIpPanelPage @@ -24,29 +19,15 @@ class UIIpPanelPage : public AppPage { - // ==================== Single network interface info ==================== - struct NetIfInfo - { - std::string iface; // interface name, e.g. eth0 / wlan0 - std::string ip; // IPv4 address, or "N/A" when absent - std::string mask; // subnet mask - bool up; // whether it is UP - }; - public: - UIIpPanelPage() : AppPage() - { - set_page_title("IP INFO"); - creat_UI(); - event_handler_init(); - } - ~UIIpPanelPage() {} + UIIpPanelPage(); + ~UIIpPanelPage() override; private: // ==================== Data members ==================== - std::unordered_map ui_obj_; - std::vector iface_list_; // current network interface list - int selected_idx_ = 0; // currently highlighted row + IpPanelModel model_; + lv_obj_t *background_ = nullptr; + lv_obj_t *list_container_ = nullptr; // Layout constants (content area 320x150, title bar 22px, remaining 128px) static constexpr int ITEM_H = 32; // row height @@ -55,309 +36,31 @@ class UIIpPanelPage : public AppPage static constexpr int LIST_H = 128; // list area height // timer handle - lv_timer_t *refresh_timer_ = nullptr; + PageTimerLifecycle refresh_timer_; + bool refresh_enabled_ = true; // ==================== Read system network interface information ==================== - void fetch_iface_list() - { - iface_list_.clear(); - - int code = -1; - std::string data; - cp0_signal_osinfo_api({"NetworkList"}, [&](int c, std::string value) { - code = c; - data = std::move(value); - }); - if (code != 0) - return; - std::istringstream lines(data); - std::string line; - while (std::getline(lines, line)) { - std::istringstream fields(line); - NetIfInfo info; - std::string up; - if (!std::getline(fields, info.iface, '\t') || !std::getline(fields, info.ip, '\t') || - !std::getline(fields, info.mask, '\t') || !std::getline(fields, up)) continue; - info.up = std::atoi(up.c_str()) != 0; - iface_list_.push_back(info); - } - - // Sort by interface name for stable ordering - std::sort(iface_list_.begin(), iface_list_.end(), - [](const NetIfInfo &a, const NetIfInfo &b) { - return a.iface < b.iface; - }); - - // Out-of-range protection for selected index - if (selected_idx_ >= (int)iface_list_.size()) - selected_idx_ = iface_list_.empty() ? 0 : (int)iface_list_.size() - 1; - } + bool fetch_iface_list(); // ==================== UI construction ==================== - void creat_UI() - { - // ---- Background ---- - lv_obj_t *bg = lv_obj_create(ui_APP_Container); - lv_obj_set_size(bg, 320, 150); - lv_obj_set_pos(bg, 0, 0); - lv_obj_set_style_radius(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x0D1117), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["bg"] = bg; - - // ---- Title bar ---- - lv_obj_t *title_bar = lv_obj_create(bg); - lv_obj_set_size(title_bar, 320, TITLE_H); - lv_obj_set_pos(title_bar, 0, 0); - lv_obj_set_style_radius(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(title_bar, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(title_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(title_bar, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - - // Title text - lv_obj_t *lbl_title = lv_label_create(title_bar); - lv_label_set_text(lbl_title, LV_SYMBOL_WIFI " IP Panel"); - lv_obj_set_align(lbl_title, LV_ALIGN_LEFT_MID); - lv_obj_set_style_text_color(lbl_title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - - // right-side control hint - lv_obj_t *lbl_hint = lv_label_create(title_bar); - lv_label_set_text(lbl_hint, "UP/DN:select ESC:back"); - lv_obj_set_align(lbl_hint, LV_ALIGN_RIGHT_MID); - lv_obj_set_x(lbl_hint, -4); - lv_obj_set_style_text_color(lbl_hint, lv_color_hex(0x7EA8D8), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_hint, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - - // ---- list container ---- - lv_obj_t *list_cont = lv_obj_create(bg); - lv_obj_set_size(list_cont, 320, LIST_H); - lv_obj_set_pos(list_cont, 0, TITLE_H); - lv_obj_set_style_radius(list_cont, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(list_cont, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(list_cont, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(list_cont, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(list_cont, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["list_cont"] = list_cont; - - // Initial refresh and render - fetch_iface_list(); - build_list_rows(); - - // ---- Timer for automatic refresh every second ---- - refresh_timer_ = lv_timer_create(UIIpPanelPage::static_timer_cb, 1000, this); - } + void create_ui(); // ==================== Build list rows ==================== - void build_list_rows() - { - lv_obj_t *list_cont = ui_obj_["list_cont"]; - lv_obj_clean(list_cont); - - if (iface_list_.empty()) - { - // Show a hint when there are no interfaces - lv_obj_t *lbl = lv_label_create(list_cont); - lv_label_set_text(lbl, "No network interface found."); - lv_obj_set_pos(lbl, 10, 50); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x555555), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - return; - } - - int item_count = (int)iface_list_.size(); - int visible = LIST_H / ITEM_H; - - // Calculate the scroll offset to keep the selected item centered when possible - int offset = selected_idx_ - visible / 2; - if (offset < 0) offset = 0; - if (offset > item_count - visible) offset = item_count - visible; - if (offset < 0) offset = 0; - - for (int vi = 0; vi < visible && (vi + offset) < item_count; ++vi) - { - int mi = vi + offset; - bool sel = (mi == selected_idx_); - create_row(list_cont, vi, mi, sel); - } - } + void build_list_rows(); // ==================== Create one row ==================== - void create_row(lv_obj_t *parent, int visual_row, int idx, bool selected) - { - const NetIfInfo &info = iface_list_[idx]; - - // Row background - lv_obj_t *row = lv_obj_create(parent); - lv_obj_set_size(row, 318, ITEM_H - 2); - lv_obj_set_pos(row, 1, visual_row * ITEM_H + 1); - lv_obj_set_style_radius(row, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); - - if (selected) - { - lv_obj_set_style_bg_color(row, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(row, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - // left highlight bar - lv_obj_t *sel_bar = lv_obj_create(row); - lv_obj_set_size(sel_bar, 3, ITEM_H - 8); - lv_obj_set_pos(sel_bar, 2, 3); - lv_obj_set_style_radius(sel_bar, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(sel_bar, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(sel_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(sel_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(sel_bar, LV_OBJ_FLAG_SCROLLABLE); - } - else - { - lv_obj_set_style_bg_color(row, lv_color_hex(0x161B22), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(row, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - // separator line (except the last visible row) - if (idx < (int)iface_list_.size() - 1) - { - lv_obj_t *div = lv_obj_create(parent); - lv_obj_set_size(div, 310, 1); - lv_obj_set_pos(div, 5, (visual_row + 1) * ITEM_H); - lv_obj_set_style_radius(div, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(div, lv_color_hex(0x21262D), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(div, 200, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(div, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(div, LV_OBJ_FLAG_SCROLLABLE); - } - - // status dot (green=UP, gray=DOWN) - lv_obj_t *dot = lv_obj_create(row); - lv_obj_set_size(dot, 6, 6); - lv_obj_set_pos(dot, 8, (ITEM_H - 2 - 6) / 2); - lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(dot, - info.up ? lv_color_hex(0x2ECC71) : lv_color_hex(0x444444), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(dot, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(dot, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(dot, LV_OBJ_FLAG_SCROLLABLE); - - // interface name (left) - lv_obj_t *lbl_iface = lv_label_create(row); - lv_label_set_text(lbl_iface, info.iface.c_str()); - lv_obj_set_pos(lbl_iface, 20, 2); - lv_obj_set_width(lbl_iface, 72); - lv_label_set_long_mode(lbl_iface, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(lbl_iface, - selected ? lv_color_hex(0x58A6FF) : lv_color_hex(0x4A7ABF), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_iface, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - - // IP address (middle) - lv_obj_t *lbl_ip = lv_label_create(row); - lv_label_set_text(lbl_ip, info.ip.c_str()); - lv_obj_set_pos(lbl_ip, 96, 2); - lv_obj_set_width(lbl_ip, 130); - lv_label_set_long_mode(lbl_ip, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(lbl_ip, - selected ? lv_color_hex(0xFFFFFF) : lv_color_hex(0xCCCCCC), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_ip, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - - // subnet mask (right, small text) - lv_obj_t *lbl_mask = lv_label_create(row); - lv_label_set_text(lbl_mask, info.mask.c_str()); - lv_obj_set_pos(lbl_mask, 96, 17); - lv_obj_set_width(lbl_mask, 130); - lv_label_set_long_mode(lbl_mask, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(lbl_mask, - lv_color_hex(0x555555), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_mask, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - - // UP/DOWN status text (right) - lv_obj_t *lbl_state = lv_label_create(row); - lv_label_set_text(lbl_state, info.up ? "UP" : "DOWN"); - lv_obj_set_pos(lbl_state, 266, 2); - lv_obj_set_style_text_color(lbl_state, - info.up ? lv_color_hex(0x2ECC71) : lv_color_hex(0x555555), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_state, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - } + void create_row(lv_obj_t *parent, int visual_row, size_t index, bool selected); // ==================== Periodic refresh callback ==================== - static void static_timer_cb(lv_timer_t *timer) - { - UIIpPanelPage *self = static_cast(lv_timer_get_user_data(timer)); - if (self) self->on_timer_refresh(); - } - void on_timer_refresh() - { - fetch_iface_list(); - build_list_rows(); - } + static void static_timer_cb(lv_timer_t *timer) noexcept; + void on_timer_refresh(); // ==================== Event binding ==================== - void event_handler_init() - { - lv_obj_add_event_cb(root_screen_, UIIpPanelPage::static_lvgl_handler, LV_EVENT_ALL, this); - } - static void static_lvgl_handler(lv_event_t *e) - { - UIIpPanelPage *self = static_cast(lv_event_get_user_data(e)); - if (self) self->event_handler(e); - } - static uint32_t fzxc_to_lv_arrow(uint32_t key) - { - switch (key) { - case KEY_F: return LV_KEY_UP; - case KEY_X: return LV_KEY_DOWN; - case KEY_Z: return LV_KEY_LEFT; - case KEY_C: return LV_KEY_RIGHT; - default: return key; - } - } - - void event_handler(lv_event_t *e) - { - if (lv_event_get_code(e) != LV_EVENT_KEY) return; - uint32_t key = fzxc_to_lv_arrow(lv_event_get_key(e)); - - int count = (int)iface_list_.size(); - switch (key) - { - case LV_KEY_UP: - if (selected_idx_ > 0) - { - --selected_idx_; - build_list_rows(); - } - break; - - case LV_KEY_DOWN: - if (selected_idx_ < count - 1) - { - ++selected_idx_; - build_list_rows(); - } - break; - - case LV_KEY_ESC: - // Destroy the timer and return to the home screen - if (refresh_timer_) - { - lv_timer_del(refresh_timer_); - refresh_timer_ = nullptr; - } - if (navigate_home) navigate_home(); - break; + void event_handler_init(); + static void static_lvgl_handler(lv_event_t *e) noexcept; + static void static_owned_obj_delete_cb(lv_event_t *event) noexcept; + static uint32_t fzxc_to_lv_arrow(uint32_t key); - default: - break; - } - } + void event_handler(lv_event_t *e); + void detach_delete_callbacks(); }; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel_view.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel_view.cpp new file mode 100644 index 00000000..59b43006 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_ip_panel_view.cpp @@ -0,0 +1,185 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_ip_panel.hpp" + +void UIIpPanelPage::create_ui() +{ + if (!ui_APP_Container) return; + lv_obj_t *background = lv_obj_create(ui_APP_Container); + if (!background) return; + lv_obj_set_size(background, 320, 150); + lv_obj_set_pos(background, 0, 0); + lv_obj_set_style_radius(background, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(background, lv_color_hex(0x0D1117), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(background, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(background, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(background, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(background, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title_bar = lv_obj_create(background); + if (!title_bar) { lv_obj_delete(background); return; } + lv_obj_set_size(title_bar, 320, TITLE_H); + lv_obj_set_pos(title_bar, 0, 0); + lv_obj_set_style_radius(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(title_bar, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(title_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(title_bar, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title = lv_label_create(title_bar); + if (!title) { lv_obj_delete(background); return; } + lv_label_set_text(title, LV_SYMBOL_WIFI " IP Panel"); + lv_obj_set_align(title, LV_ALIGN_LEFT_MID); + lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *hint = lv_label_create(title_bar); + if (!hint) { lv_obj_delete(background); return; } + lv_label_set_text(hint, "UP/DN:select ESC:back"); + lv_obj_set_align(hint, LV_ALIGN_RIGHT_MID); + lv_obj_set_x(hint, -4); + lv_obj_set_style_text_color(hint, lv_color_hex(0x7EA8D8), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *list_container = lv_obj_create(background); + if (!list_container) { lv_obj_delete(background); return; } + background_ = background; + list_container_ = list_container; + lv_obj_add_event_cb( + list_container_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_add_event_cb( + background_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(list_container_, 320, LIST_H); + lv_obj_set_pos(list_container_, 0, TITLE_H); + lv_obj_set_style_radius(list_container_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(list_container_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(list_container_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(list_container_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(list_container_, LV_OBJ_FLAG_SCROLLABLE); + + (void)fetch_iface_list(); + build_list_rows(); + refresh_timer_.start( + [this] { return lv_timer_create(UIIpPanelPage::static_timer_cb, 1000, this); }, + [](lv_timer_t *timer) { lv_timer_delete(timer); }); +} + +void UIIpPanelPage::build_list_rows() +{ + if (!list_container_) return; + lv_obj_clean(list_container_); + const auto &interfaces = model_.interfaces(); + if (interfaces.empty()) { + lv_obj_t *label = lv_label_create(list_container_); + if (!label) return; + lv_label_set_text(label, "No network interface found."); + lv_obj_set_pos(label, 10, 50); + lv_obj_set_style_text_color(label, lv_color_hex(0x555555), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(label, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); + return; + } + + const size_t offset = model_.visible_offset(VISIBLE_ROWS); + for (size_t row = 0; row < VISIBLE_ROWS && row + offset < interfaces.size(); ++row) { + const size_t index = row + offset; + create_row(list_container_, static_cast(row), index, + index == model_.selected_index()); + } +} + +void UIIpPanelPage::create_row(lv_obj_t *parent, int visual_row, size_t index, bool selected) +{ + const auto &interfaces = model_.interfaces(); + if (!parent || index >= interfaces.size()) return; + const IpInterfaceInfo &info = interfaces[index]; + lv_obj_t *row = lv_obj_create(parent); + if (!row) return; + lv_obj_set_size(row, 318, ITEM_H - 2); + lv_obj_set_pos(row, 1, visual_row * ITEM_H + 1); + lv_obj_set_style_radius(row, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(row, lv_color_hex(selected ? 0x1F3A5F : 0x161B22), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(row, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + + if (selected) { + lv_obj_t *bar = lv_obj_create(row); + if (bar) { + lv_obj_set_size(bar, 3, ITEM_H - 8); + lv_obj_set_pos(bar, 2, 3); + lv_obj_set_style_radius(bar, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(bar, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + } + } + + if (index + 1 < interfaces.size()) { + lv_obj_t *divider = lv_obj_create(parent); + if (divider) { + lv_obj_set_size(divider, 310, 1); + lv_obj_set_pos(divider, 5, (visual_row + 1) * ITEM_H); + lv_obj_set_style_radius(divider, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(divider, lv_color_hex(0x21262D), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(divider, 200, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(divider, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(divider, LV_OBJ_FLAG_SCROLLABLE); + } + } + + lv_obj_t *status_dot = lv_obj_create(row); + if (status_dot) { + lv_obj_set_size(status_dot, 6, 6); + lv_obj_set_pos(status_dot, 8, (ITEM_H - 8) / 2); + lv_obj_set_style_radius(status_dot, LV_RADIUS_CIRCLE, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(status_dot, lv_color_hex(info.up ? 0x2ECC71 : 0x444444), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(status_dot, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(status_dot, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(status_dot, LV_OBJ_FLAG_SCROLLABLE); + } + + lv_obj_t *name = lv_label_create(row); + if (name) { + lv_label_set_text(name, info.name.c_str()); + lv_obj_set_pos(name, 20, 2); + lv_obj_set_width(name, 72); + lv_label_set_long_mode(name, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(name, lv_color_hex(selected ? 0x58A6FF : 0x4A7ABF), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(name, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_t *address = lv_label_create(row); + if (address) { + lv_label_set_text(address, info.address.c_str()); + lv_obj_set_pos(address, 96, 2); + lv_obj_set_width(address, 130); + lv_label_set_long_mode(address, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(address, lv_color_hex(selected ? 0xFFFFFF : 0xCCCCCC), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(address, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_t *netmask = lv_label_create(row); + if (netmask) { + lv_label_set_text(netmask, info.netmask.c_str()); + lv_obj_set_pos(netmask, 96, 17); + lv_obj_set_width(netmask, 130); + lv_label_set_long_mode(netmask, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(netmask, lv_color_hex(0x555555), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(netmask, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_t *state = lv_label_create(row); + if (state) { + lv_label_set_text(state, info.up ? "UP" : "DOWN"); + lv_obj_set_pos(state, 266, 2); + lv_obj_set_style_text_color(state, lv_color_hex(info.up ? 0x2ECC71 : 0x555555), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(state, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + } +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_lora.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_lora.cpp new file mode 100644 index 00000000..394f6892 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_lora.cpp @@ -0,0 +1,328 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_lora.hpp" + +namespace lora_app_detail { + +constexpr uint32_t kPollIntervalMs = 300; +constexpr int32_t kMessageScrollStep = 36; + +static bool is_printable_ascii(uint32_t key) +{ + return key >= 0x20 && key <= 0x7e; +} + +static char key_to_ascii(uint32_t key) +{ + return is_printable_ascii(key) ? static_cast(key) : '\0'; +} + +static int call_lora_api(const std::list &args, cp0_lora_info_t *info = nullptr) +{ + int result = -1; + cp0_lora_info_t response{}; + bool response_valid = false; + cp0_signal_lora_api(args, [&](int code, std::string data) { + result = code; + if (info && lora_info_response_valid(code, data.size(), sizeof(response))) { + std::memcpy(&response, data.data(), sizeof(response)); + response_valid = true; + } + }); + if (info && response_valid) *info = response; + if (info && result == 0 && !response_valid) return -1; + return result; +} + +static bool is_menu_prev_key(uint32_t key) +{ + return key == LV_KEY_LEFT || key == LV_KEY_PREV || key == 'z' || key == 'Z'; +} + +static bool is_menu_next_key(uint32_t key) +{ + return key == LV_KEY_RIGHT || key == LV_KEY_NEXT || key == 'c' || key == 'C'; +} + +} // namespace lora_app_detail + +UILoraPage::UILoraPage() + : AppPage() +{ + set_page_title("LORA"); + create_ui(); + if (!ui_ready()) { + if (page_root_) lv_obj_delete(page_root_); + return; + } + bind_events(); + init_lora(); +} + +UILoraPage::~UILoraPage() +{ + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UILoraPage::static_key_event_cb, this); + cancel_view_animations(); + app_active_ = false; + poll_timer_.stop(); + detach_delete_callbacks(); +} + + +void UILoraPage::init_lora() +{ + if (!ui_ready()) + return; + app_active_ = true; + scroll_to_latest_pending_ = false; + lv_obj_clean(message_list_); + last_message_row_ = nullptr; + set_visible(empty_message_label_, true); + set_visible(empty_message_hint_label_, true); + + (void)lora_app_detail::call_lora_api({"Init"}); + refresh_lora_info(true); + lora_info_.rx_event = 0; + lora_info_.tx_event = 0; + model_.reset(lora_info_.hw_ready); + render_current_view(); + if (lora_info_.hw_ready) (void)lora_app_detail::call_lora_api({"StartReceive"}); + poll_timer_.start( + [this] { + return lv_timer_create(&UILoraPage::static_poll_timer_cb, + lora_app_detail::kPollIntervalMs, this); + }, + [](lv_timer_t *timer) { lv_timer_delete(timer); }); +} + +bool UILoraPage::refresh_lora_info(bool poll) +{ + return lora_app_detail::call_lora_api({poll ? "Poll" : "Info"}, &lora_info_) == 0; +} + +void UILoraPage::append_chat_message(const char *text, bool outgoing, float rssi, float snr) +{ + if (!message_list_) return; + const bool history_full = model_.messages().size() >= LoraPageModel::MESSAGE_HISTORY_LIMIT; + if (history_full) { + lv_obj_t *oldest_row = lv_obj_get_child(message_list_, 0); + if (oldest_row) lv_obj_delete(oldest_row); + } + model_.append_message(text ? text : "", outgoing, rssi, snr); + last_message_row_ = append_message_row(model_.messages().back()); + set_visible(empty_message_label_, false); + set_visible(empty_message_hint_label_, false); + if (model_.view() == LoraView::MESSAGES) scroll_to_latest(LV_ANIM_ON); + else scroll_to_latest_pending_ = true; +} + +void UILoraPage::open_send_view(uint32_t first_key) +{ + model_.begin_send(lora_app_detail::key_to_ascii(first_key)); + render_current_view(); +} + +void UILoraPage::scroll_messages(int32_t amount) +{ + if (message_list_) lv_obj_scroll_by_bounded(message_list_, 0, amount, LV_ANIM_ON); +} + +void UILoraPage::cancel_send() +{ + model_.cancel_send(); + render_current_view(); +} + +bool UILoraPage::handle_send_key(uint32_t key) +{ + if (key == LV_KEY_ESC) { + cancel_send(); + } else if (key == LV_KEY_BACKSPACE || key == LV_KEY_DEL) { + model_.erase_character(); + update_send_content(); + } else if (key == LV_KEY_ENTER) { + send_current_text(); + } else if (lora_app_detail::is_printable_ascii(key)) { + append_text_key(key); + update_send_content(); + } + return true; +} + +bool UILoraPage::handle_navigation_key(uint32_t key) +{ + if (lora_app_detail::is_menu_prev_key(key)) { + model_.set_view(LoraView::MESSAGES); + render_current_view(); + return true; + } + if (lora_app_detail::is_menu_next_key(key)) { + model_.set_view(LoraView::INFO); + render_current_view(); + return true; + } + if (model_.view() == LoraView::MESSAGES && (key == LV_KEY_UP || key == LV_KEY_DOWN)) { + scroll_messages(key == LV_KEY_UP ? lora_app_detail::kMessageScrollStep + : -lora_app_detail::kMessageScrollStep); + return true; + } + if (model_.view() != LoraView::MESSAGES && (key == LV_KEY_UP || key == LV_KEY_DOWN)) { + model_.set_view(key == LV_KEY_UP ? LoraView::MESSAGES : LoraView::INFO); + render_current_view(); + return true; + } + if (key == LV_KEY_ENTER) { + open_send_view(0); + return true; + } + if (lora_app_detail::is_printable_ascii(key) && key != 'z' && key != 'Z' && + key != 'c' && key != 'C') { + open_send_view(key); + return true; + } + return false; +} + +bool UILoraPage::handle_key(uint32_t key) +{ + if (model_.view() == LoraView::SEND) return handle_send_key(key); + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE || key == LV_KEY_DEL) { + if (navigate_home) navigate_home(); + return true; + } + return handle_navigation_key(key); +} + +void UILoraPage::append_text_key(uint32_t key) +{ + model_.append_character(lora_app_detail::key_to_ascii(key)); +} + +void UILoraPage::send_current_text() +{ + if (model_.tx_input().empty()) { + model_.set_send_status("Message is empty :("); + update_send_content(); + return; + } + std::string sent_text(model_.tx_input()); + if (lora_app_detail::call_lora_api({"SendText", sent_text}) == 0) { + refresh_lora_info(false); + append_chat_message(sent_text.c_str(), true, 0.0f, 0.0f); + model_.complete_send(); + render_current_view(); + } else { + model_.set_send_status("Send failed"); + update_send_content(); + } +} + +uint32_t UILoraPage::normalize_key(const key_item *key_event) const +{ + uint32_t key = key_event->key_code; + if (model_.view() != LoraView::SEND) { + if (key == KEY_F) return LV_KEY_UP; + if (key == KEY_X) return LV_KEY_DOWN; + } + if (lora_app_detail::is_printable_ascii(key_event->codepoint)) return key_event->codepoint; + if (key == KEY_UP) return LV_KEY_UP; + if (key == KEY_DOWN) return LV_KEY_DOWN; + if (key == KEY_LEFT) return LV_KEY_LEFT; + if (key == KEY_RIGHT) return LV_KEY_RIGHT; + if (key == KEY_ENTER || key == KEY_KPENTER) return LV_KEY_ENTER; + if (key == KEY_ESC) return LV_KEY_ESC; + if (key == KEY_BACKSPACE) return LV_KEY_BACKSPACE; + if (key == KEY_DELETE) return LV_KEY_DEL; + return key; +} + +void UILoraPage::on_key_event(lv_event_t *event) +{ + auto *key_event = static_cast(lv_event_get_param(event)); + if (!key_event || key_event->key_state == KBD_KEY_RELEASED) return; + (void)handle_key(normalize_key(key_event)); +} + +void UILoraPage::on_poll_timer() +{ + if (!app_active_ || !page_root_) return; + if (!refresh_lora_info(true)) return; + if (lora_info_.rx_event) + append_chat_message(lora_info_.last_rx, false, lora_info_.rssi, lora_info_.snr); + if (model_.view() == LoraView::INFO) update_info_content(); +} + +void UILoraPage::static_cancel_button_cb(lv_event_t *event) noexcept +{ + UILoraPage *self = nullptr; + try { + if (!event) return; + self = static_cast(lv_event_get_user_data(event)); + if (!self || !lora_send_action_callback_allowed( + lv_event_get_current_target(event), self->send_cancel_button_, + self->app_active_, self->model_.view() == LoraView::SEND)) return; + self->cancel_send(); + } catch (...) { + if (self) self->app_active_ = false; + } +} + +void UILoraPage::static_send_button_cb(lv_event_t *event) noexcept +{ + UILoraPage *self = nullptr; + try { + if (!event) return; + self = static_cast(lv_event_get_user_data(event)); + if (!self || !lora_send_action_callback_allowed( + lv_event_get_current_target(event), self->send_confirm_button_, + self->app_active_, self->model_.view() == LoraView::SEND)) return; + self->send_current_text(); + } catch (...) { + if (self) self->app_active_ = false; + } +} + +void UILoraPage::static_key_event_cb(lv_event_t *event) noexcept +{ + UILoraPage *self = nullptr; + try { + if (!event) return; + self = static_cast(lv_event_get_user_data(event)); + if (lv_event_get_code(event) == LV_EVENT_DELETE && + lv_event_get_target(event) == lv_event_get_current_target(event)) { + if (self && lv_event_get_current_target(event) == self->root_screen_) { + self->app_active_ = false; + self->poll_timer_.stop(); + self->cancel_view_animations(); + } + return; + } + if (lv_event_get_code(event) != static_cast(LV_EVENT_KEYBOARD) || + !self || !lora_page_event_callback_allowed( + lv_event_get_current_target(event), self->root_screen_, + self->app_active_)) return; + self->on_key_event(event); + } catch (...) { + if (self) self->app_active_ = false; + } +} + +void UILoraPage::static_poll_timer_cb(lv_timer_t *timer) noexcept +{ + auto *self = static_cast(lv_timer_get_user_data(timer)); + if (!self) return; + try { + if (lora_poll_callback_allowed( + self->poll_timer_.current(timer), self->app_active_)) + self->on_poll_timer(); + } catch (...) { + self->app_active_ = false; + } +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_lora.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_lora.hpp index 03d1ba20..e853d3e9 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_lora.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_lora.hpp @@ -6,8 +6,11 @@ #pragma once -#include "../ui_app_page.hpp" -#include "compat/input_keys.h" +#include "../launcher_ui_app_page.hpp" +#include "../model/lora_page_model.hpp" +#include "../model/lora_page_contract.hpp" +#include "../model/page_timer_lifecycle.hpp" +#include "input_keys.h" #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" #include "keyboard_input.h" @@ -24,104 +27,18 @@ #include #include -namespace lora_app_detail { - -constexpr uint32_t kPollIntervalMs = 300; -constexpr lv_coord_t kScreenWidth = 320; -constexpr lv_coord_t kContentHeight = 150; -constexpr std::size_t kMessageHistoryLimit = 64; -constexpr int32_t kMessageScrollStep = 36; -constexpr uint32_t kViewTransitionMs = 150; -constexpr lv_coord_t kBubbleTailWidth = 6; -constexpr lv_coord_t kBubbleTailDrop = 3; - -inline const char *safe_text(const char *text, const char *fallback = "") -{ - return text && text[0] ? text : fallback; -} - -inline bool is_printable_ascii(uint32_t key) -{ - return key >= 0x20 && key <= 0x7e; -} - -inline char key_to_ascii(uint32_t key) -{ - return is_printable_ascii(key) ? static_cast(key) : '\0'; -} - -inline int call_lora_api(const std::list &args, cp0_lora_info_t *info = nullptr) -{ - int result = -1; - cp0_signal_lora_api(args, [&](int code, std::string data) { - result = code; - if (info && data.size() == sizeof(*info)) { - std::memcpy(info, data.data(), sizeof(*info)); - } - }); - return result; -} - -inline bool is_menu_prev_key(uint32_t key) -{ - return key == LV_KEY_LEFT || key == LV_KEY_PREV || key == 'z' || key == 'Z'; -} - -inline bool is_menu_next_key(uint32_t key) -{ - return key == LV_KEY_RIGHT || key == LV_KEY_NEXT || key == 'c' || key == 'C'; -} - -} // namespace lora_app_detail - class UILoraPage : public AppPage { public: - UILoraPage() : AppPage() - { - set_page_title("LORA"); - create_ui(); - bind_events(); - init_lora(); - } - - ~UILoraPage() override - { - app_active_ = false; - cancel_view_animations(); - if (poll_timer_) { - lv_timer_delete(poll_timer_); - poll_timer_ = nullptr; - } - } + UILoraPage(); + ~UILoraPage() override; private: - enum class View { - Messages, - Info, - Send, - }; - - struct ChatMessage { - ChatMessage(std::string value, bool is_outgoing, float message_rssi, float message_snr) - : text(std::move(value)), outgoing(is_outgoing), rssi(message_rssi), snr(message_snr) - { - } - - std::string text; - bool outgoing; - float rssi; - float snr; - }; - - View current_view_ = View::Messages; + LoraPageModel model_; bool app_active_ = false; bool scroll_to_latest_pending_ = false; - char tx_input_[128] = ""; - char send_status_[64] = ""; cp0_lora_info_t lora_info_{}; - std::deque messages_; - lv_timer_t *poll_timer_ = nullptr; + PageTimerLifecycle poll_timer_; lv_obj_t *page_root_ = nullptr; lv_obj_t *messages_view_ = nullptr; lv_obj_t *message_list_ = nullptr; @@ -146,685 +63,69 @@ class UILoraPage : public AppPage { lv_obj_t *page_dots_[2] = {nullptr, nullptr}; lv_obj_t *active_view_ = nullptr; - static void set_visible(lv_obj_t *object, bool visible) - { - if (!object) return; - if (visible) - lv_obj_clear_flag(object, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(object, LV_OBJ_FLAG_HIDDEN); - } + static void set_visible(lv_obj_t *object, bool visible); static lv_obj_t *make_panel(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, lv_coord_t width, lv_coord_t height, - lv_color_t color, lv_opa_t opacity, lv_coord_t radius) - { - lv_obj_t *panel = lv_obj_create(parent); - lv_obj_set_pos(panel, x, y); - lv_obj_set_size(panel, width, height); - lv_obj_set_style_bg_color(panel, color, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(panel, opacity, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_radius(panel, radius, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_shadow_width(panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); - return panel; - } + lv_color_t color, lv_opa_t opacity, lv_coord_t radius); static lv_obj_t *make_plain_container(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, lv_coord_t width, - lv_coord_t height) - { - return make_panel(parent, x, y, width, height, lv_color_hex(0x000000), LV_OPA_TRANSP, 0); - } + lv_coord_t height); static lv_obj_t *make_label(lv_obj_t *parent, const char *text, lv_coord_t x, lv_coord_t y, lv_coord_t width, - lv_coord_t height, const lv_font_t *font, lv_color_t color, lv_text_align_t align) - { - lv_obj_t *label = lv_label_create(parent); - lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); - lv_label_set_text(label, text ? text : ""); - lv_obj_set_pos(label, x, y); - lv_obj_set_size(label, width, height); - lv_obj_set_style_text_font(label, font ? font : &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(label, color, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(label, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(label, align, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(label, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(label, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - return label; - } - - static lv_obj_t *make_divider(lv_obj_t *parent, lv_coord_t y) - { - return make_panel(parent, 8, y, 304, 1, lv_color_hex(0x25272B), LV_OPA_COVER, 0); - } - - static void bubble_tail_draw_cb(lv_event_t *event) - { - lv_obj_t *row = lv_event_get_target_obj(event); - lv_layer_t *layer = lv_event_get_layer(event); - if (!row || !layer) return; - - lv_obj_t *bubble = lv_obj_get_child(row, 0); - if (!bubble) return; - - lv_area_t area; - lv_obj_get_coords(bubble, &area); - - lv_draw_triangle_dsc_t draw_dsc; - lv_draw_triangle_dsc_init(&draw_dsc); - draw_dsc.color = lv_obj_get_style_bg_color(bubble, LV_PART_MAIN); - draw_dsc.opa = lv_obj_get_style_bg_opa(bubble, LV_PART_MAIN); - lv_opa_t recursive_opa = lv_obj_get_style_opa_recursive(bubble, LV_PART_MAIN); - if (recursive_opa < LV_OPA_MAX) draw_dsc.opa = LV_OPA_MIX2(draw_dsc.opa, recursive_opa); - - constexpr lv_coord_t kTailRise = 9; - constexpr lv_coord_t kTailShoulder = 10; - bool outgoing = lv_obj_has_flag(row, LV_OBJ_FLAG_USER_1); - lv_coord_t side_x = outgoing ? area.x2 : area.x1; - lv_coord_t shoulder_x = outgoing ? area.x2 - kTailShoulder : area.x1 + kTailShoulder; - lv_coord_t tip_x = - outgoing ? area.x2 + lora_app_detail::kBubbleTailWidth : area.x1 - lora_app_detail::kBubbleTailWidth; - draw_dsc.p[0].x = static_cast(side_x); - draw_dsc.p[0].y = static_cast(area.y2 - kTailRise); - draw_dsc.p[1].x = static_cast(shoulder_x); - draw_dsc.p[1].y = static_cast(area.y2); - draw_dsc.p[2].x = static_cast(tip_x); - draw_dsc.p[2].y = static_cast(area.y2 + lora_app_detail::kBubbleTailDrop); - lv_draw_triangle(layer, &draw_dsc); - } - - void create_ui() - { - page_root_ = make_panel(ui_APP_Container, 0, 0, lora_app_detail::kScreenWidth, lora_app_detail::kContentHeight, - lv_color_hex(0x0B0C0E), LV_OPA_COVER, 0); - create_messages_view(); - create_info_view(); - create_send_view(); - create_page_indicator(); - } - - void create_messages_view() - { - messages_view_ = make_plain_container(page_root_, 0, 0, 320, 150); - message_list_ = make_plain_container(messages_view_, 0, 0, 320, 150); - lv_obj_set_flex_flow(message_list_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(message_list_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_left(message_list_, 10, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(message_list_, 10, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(message_list_, 20, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(message_list_, 10, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_row(message_list_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_add_flag(message_list_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scroll_dir(message_list_, LV_DIR_VER); - lv_obj_set_scrollbar_mode(message_list_, LV_SCROLLBAR_MODE_OFF); - - empty_message_label_ = make_label(messages_view_, "No messages yet", 0, 50, 320, 16, &lv_font_montserrat_12, - lv_color_hex(0xB2B2B2), LV_TEXT_ALIGN_CENTER); - empty_message_hint_label_ = make_label(messages_view_, "Type anything to send", 0, 68, 320, 14, - &lv_font_montserrat_12, lv_color_hex(0x5FE492), LV_TEXT_ALIGN_CENTER); - - lv_obj_t *title_notch = make_panel(messages_view_, 108, -8, 104, 28, lv_color_hex(0x0B0C0E), LV_OPA_COVER, 8); - make_label(title_notch, "Messages", 0, 8, 104, 18, &lv_font_montserrat_14, lv_color_hex(0xE4E4E4), - LV_TEXT_ALIGN_CENTER); - } + lv_coord_t height, const lv_font_t *font, lv_color_t color, + lv_text_align_t align); - void create_info_view() - { - info_view_ = make_plain_container(page_root_, 0, 0, 320, 150); - make_label(info_view_, "LoRa Info", 0, 0, 320, 18, &lv_font_montserrat_14, lv_color_hex(0xE4E4E4), - LV_TEXT_ALIGN_CENTER); + static lv_obj_t *make_divider(lv_obj_t *parent, lv_coord_t y); - info_status_dot_ = make_panel(info_view_, 8, 23, 6, 6, lv_color_hex(0x69AD80), LV_OPA_COVER, LV_RADIUS_CIRCLE); - info_status_label_ = make_label(info_view_, "", 20, 20, 180, 16, &lv_font_montserrat_10, lv_color_hex(0xAEB2B8), - LV_TEXT_ALIGN_LEFT); - make_label(info_view_, "CLIENT", 240, 20, 72, 16, &lv_font_montserrat_10, lv_color_hex(0x777B82), - LV_TEXT_ALIGN_RIGHT); - make_divider(info_view_, 41); + static void bubble_tail_draw_cb(lv_event_t *event) noexcept; - make_label(info_view_, "DEVICE", 8, 48, 148, 11, &lv_font_montserrat_10, lv_color_hex(0x777B82), - LV_TEXT_ALIGN_LEFT); - make_label(info_view_, "RSSI", 166, 48, 66, 11, &lv_font_montserrat_10, lv_color_hex(0x777B82), - LV_TEXT_ALIGN_LEFT); - make_label(info_view_, "SNR", 240, 48, 72, 11, &lv_font_montserrat_10, lv_color_hex(0x777B82), - LV_TEXT_ALIGN_LEFT); - - info_device_value_ = make_label(info_view_, "", 8, 60, 148, 17, &lv_font_montserrat_12, lv_color_hex(0xDDE0E4), - LV_TEXT_ALIGN_LEFT); - info_rssi_value_ = make_label(info_view_, "", 166, 60, 66, 17, &lv_font_montserrat_12, lv_color_hex(0xDDE0E4), - LV_TEXT_ALIGN_LEFT); - info_snr_value_ = make_label(info_view_, "", 240, 60, 72, 17, &lv_font_montserrat_12, lv_color_hex(0xDDE0E4), - LV_TEXT_ALIGN_LEFT); - lv_label_set_long_mode(info_device_value_, LV_LABEL_LONG_DOT); - - make_divider(info_view_, 81); - make_label(info_view_, "LINK", 8, 88, 304, 11, &lv_font_montserrat_10, lv_color_hex(0x777B82), - LV_TEXT_ALIGN_LEFT); - info_link_value_ = make_label(info_view_, "", 8, 100, 304, 16, &lv_font_montserrat_12, lv_color_hex(0xDDE0E4), - LV_TEXT_ALIGN_LEFT); - info_diag_value_ = make_label(info_view_, "", 8, 120, 304, 13, &lv_font_montserrat_10, lv_color_hex(0x777B82), - LV_TEXT_ALIGN_LEFT); - lv_label_set_long_mode(info_link_value_, LV_LABEL_LONG_DOT); - lv_label_set_long_mode(info_diag_value_, LV_LABEL_LONG_DOT); - } - - void create_send_view() - { - send_view_ = make_plain_container(page_root_, 0, 0, 320, 150); - make_label(send_view_, "New Message", 0, 0, 320, 18, &lv_font_montserrat_14, lv_color_hex(0xE4E4E4), - LV_TEXT_ALIGN_CENTER); - - send_input_bubble_ = make_panel(send_view_, 0, 0, 286, 80, lv_color_hex(0x555555), LV_OPA_COVER, 8); - lv_obj_align(send_input_bubble_, LV_ALIGN_CENTER, 0, -12); - send_input_label_ = make_label(send_input_bubble_, "", 10, 8, 266, 64, &lv_font_montserrat_14, - lv_color_hex(0xFFFFFF), LV_TEXT_ALIGN_LEFT); - - send_status_label_ = make_label(send_input_bubble_, "", 10, 54, 266, 16, &lv_font_montserrat_14, - lv_color_hex(0xFED40D), LV_TEXT_ALIGN_RIGHT); - set_visible(send_status_label_, false); - - send_cancel_button_ = make_action_button(send_view_, 83, 113, 110, "ESC: Cancel", lv_color_hex(0x6D6D6D), - lv_color_hex(0xF3F3F3), &UILoraPage::static_cancel_button_cb); - send_confirm_button_ = make_action_button(send_view_, 203, 113, 100, "Enter: Send", lv_color_hex(0xFED40D), - lv_color_hex(0x5E4D00), &UILoraPage::static_send_button_cb); - } + void create_ui(); + void create_messages_view(); + void create_info_view(); + void create_send_view(); lv_obj_t *make_action_button(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, lv_coord_t width, const char *text, - lv_color_t background, lv_color_t foreground, lv_event_cb_t callback) - { - lv_obj_t *button = make_panel(parent, x, y, width, 26, background, LV_OPA_COVER, 5); - lv_obj_add_flag(button, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(button, callback, LV_EVENT_CLICKED, this); - lv_obj_t *label = make_label(button, text, 0, 0, LV_SIZE_CONTENT, LV_SIZE_CONTENT, &lv_font_montserrat_14, - foreground, LV_TEXT_ALIGN_CENTER); - lv_obj_center(label); - return button; - } - - void create_page_indicator() - { - page_indicator_ = make_panel(page_root_, 141, 137, 38, 24, lv_color_hex(0x0B0C0E), LV_OPA_COVER, 7); - page_dots_[0] = - make_panel(page_indicator_, 11, 3, 5, 5, lv_color_hex(0xE4E4E4), LV_OPA_COVER, LV_RADIUS_CIRCLE); - page_dots_[1] = - make_panel(page_indicator_, 22, 3, 5, 5, lv_color_hex(0x4E5157), LV_OPA_COVER, LV_RADIUS_CIRCLE); - } - - void bind_events() - { - lv_obj_add_event_cb(root_screen_, &UILoraPage::static_key_event_cb, - static_cast(LV_EVENT_KEYBOARD), this); - } - - void init_lora() - { - app_active_ = true; - current_view_ = View::Messages; - scroll_to_latest_pending_ = false; - tx_input_[0] = '\0'; - send_status_[0] = '\0'; - messages_.clear(); - lv_obj_clean(message_list_); - last_message_row_ = nullptr; - set_visible(empty_message_label_, true); - set_visible(empty_message_hint_label_, true); - - (void)lora_app_detail::call_lora_api({"Init"}); - refresh_lora_info(true); - lora_info_.rx_event = 0; - lora_info_.tx_event = 0; - current_view_ = lora_info_.hw_ready ? View::Messages : View::Info; - render_current_view(); - if (lora_info_.hw_ready) { - (void)lora_app_detail::call_lora_api({"StartReceive"}); - } - poll_timer_ = lv_timer_create(&UILoraPage::static_poll_timer_cb, lora_app_detail::kPollIntervalMs, this); - } - - void refresh_lora_info(bool poll) - { - (void)lora_app_detail::call_lora_api({poll ? "Poll" : "Info"}, &lora_info_); - } - - void update_page_indicator() - { - bool messages_active = current_view_ == View::Messages; - lv_obj_set_style_bg_color(page_dots_[0], lv_color_hex(messages_active ? 0xE4E4E4 : 0x4E5157), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(page_dots_[1], lv_color_hex(messages_active ? 0x4E5157 : 0xE4E4E4), - LV_PART_MAIN | LV_STATE_DEFAULT); - } - - void update_info_content() - { - const char *state_text = "RECEIVING"; - uint32_t state_color = 0x69AD80; - if (!lora_info_.hw_ready) { - state_text = "RADIO OFF"; - state_color = 0xD96C6C; - } else if (lora_info_.tx_in_progress) { - state_text = "SENDING"; - state_color = 0xC9A45C; - } else if (lora_info_.tx_mode) { - state_text = "TX MODE"; - state_color = 0xC9A45C; - } - lv_label_set_text(info_status_label_, state_text); - lv_obj_set_style_bg_color(info_status_dot_, lv_color_hex(state_color), LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_label_set_text(info_device_value_, lora_app_detail::safe_text(lora_info_.spi_device, "Unavailable")); - - char value[64]; - std::snprintf(value, sizeof(value), "%.0f dBm", lora_info_.rssi); - lv_label_set_text(info_rssi_value_, value); - std::snprintf(value, sizeof(value), "%.1f dB", lora_info_.snr); - lv_label_set_text(info_snr_value_, value); - - lv_label_set_text(info_link_value_, - lora_app_detail::safe_text(lora_info_.probe_display, "Link configuration unavailable")); - lv_label_set_text(info_diag_value_, lora_app_detail::safe_text(lora_info_.diag, "No diagnostics")); - lv_obj_set_style_text_color(info_diag_value_, lv_color_hex(lora_info_.hw_ready ? 0x777B82 : 0xD96C6C), - LV_PART_MAIN | LV_STATE_DEFAULT); - } - - void update_send_content() - { - char display[sizeof(tx_input_) + 2]; - std::snprintf(display, sizeof(display), "%s|", tx_input_); - lv_label_set_text(send_input_label_, display); - lv_obj_set_style_text_color(send_input_label_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - - bool has_status = send_status_[0] != '\0'; - lv_label_set_text(send_status_label_, has_status ? send_status_ : ""); - set_visible(send_status_label_, has_status); - } - - void scroll_to_latest(lv_anim_enable_t animation) - { - if (!last_message_row_) { - scroll_to_latest_pending_ = false; - return; - } - lv_obj_update_layout(message_list_); - lv_obj_scroll_to_view(last_message_row_, animation); - scroll_to_latest_pending_ = false; - } - - static void view_opa_exec_cb(void *object, int32_t opacity) - { - lv_obj_set_style_opa(static_cast(object), static_cast(opacity), - LV_PART_MAIN | LV_STATE_DEFAULT); - } - - static void hide_view_after_fade_cb(lv_anim_t *animation) - { - auto *view = static_cast(lv_anim_get_user_data(animation)); - if (!view) return; - set_visible(view, false); - lv_obj_set_style_opa(view, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - static void cancel_view_animation(lv_obj_t *view) - { - if (view) lv_anim_del(view, view_opa_exec_cb); - } - - void cancel_view_animations() - { - cancel_view_animation(messages_view_); - cancel_view_animation(info_view_); - cancel_view_animation(send_view_); - } - - static void animate_view_opacity(lv_obj_t *view, lv_opa_t start, lv_opa_t end, bool hide_after_fade) - { - if (!view) return; - cancel_view_animation(view); - set_visible(view, true); - view_opa_exec_cb(view, start); - - lv_anim_t animation; - lv_anim_init(&animation); - lv_anim_set_var(&animation, view); - lv_anim_set_values(&animation, start, end); - lv_anim_set_time(&animation, lora_app_detail::kViewTransitionMs); - lv_anim_set_path_cb(&animation, lv_anim_path_ease_in_out); - lv_anim_set_exec_cb(&animation, view_opa_exec_cb); - if (hide_after_fade) { - lv_anim_set_user_data(&animation, view); - lv_anim_set_completed_cb(&animation, hide_view_after_fade_cb); - } - lv_anim_start(&animation); - } - - void transition_to_view(lv_obj_t *target) - { - if (!target || target == active_view_) return; - - if (!active_view_) { - lv_obj_t *views[] = {messages_view_, info_view_, send_view_}; - for (lv_obj_t *view : views) { - cancel_view_animation(view); - lv_obj_set_style_opa(view, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - set_visible(view, view == target); - } - active_view_ = target; - return; - } - - lv_obj_t *outgoing = active_view_; - lv_opa_t outgoing_start = lv_obj_get_style_opa(outgoing, LV_PART_MAIN); - lv_opa_t incoming_start = - lv_obj_has_flag(target, LV_OBJ_FLAG_HIDDEN) ? LV_OPA_TRANSP : lv_obj_get_style_opa(target, LV_PART_MAIN); - animate_view_opacity(outgoing, outgoing_start, LV_OPA_TRANSP, true); - animate_view_opacity(target, incoming_start, LV_OPA_COVER, false); - active_view_ = target; - } - - void render_current_view() - { - bool show_messages = current_view_ == View::Messages; - bool show_info = current_view_ == View::Info; - bool show_send = current_view_ == View::Send; - - if (show_info) update_info_content(); - if (show_send) update_send_content(); - transition_to_view(show_messages ? messages_view_ : (show_info ? info_view_ : send_view_)); - set_visible(page_indicator_, !show_send); - if (!show_send) update_page_indicator(); - if (show_messages && scroll_to_latest_pending_) { - scroll_to_latest(LV_ANIM_OFF); - } - } - - lv_obj_t *append_message_row(const ChatMessage &message) - { - char metadata[64] = ""; - if (!message.outgoing) { - std::snprintf(metadata, sizeof(metadata), "%.0f dBm / %.1f dB", message.rssi, message.snr); - } - - constexpr int32_t kHorizontalPadding = 10; - constexpr int32_t kMaxTextWidth = 224; - constexpr int32_t kMaxBubbleWidth = 244; - lv_point_t text_size{}; - lv_text_get_size(&text_size, message.text.c_str(), &lv_font_montserrat_12, 0, 0, kMaxTextWidth, - LV_TEXT_FLAG_NONE); - int32_t content_width = text_size.x; - if (metadata[0]) { - lv_point_t metadata_size{}; - lv_text_get_size(&metadata_size, metadata, &lv_font_montserrat_10, 0, 0, kMaxTextWidth, LV_TEXT_FLAG_NONE); - content_width = std::max(content_width, metadata_size.x); - } - int32_t bubble_width = - std::max(64, std::min(kMaxBubbleWidth, content_width + kHorizontalPadding * 2)); - - lv_obj_t *row = make_plain_container(message_list_, 0, 0, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, message.outgoing ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, - LV_FLEX_ALIGN_START); - if (message.outgoing) lv_obj_add_flag(row, LV_OBJ_FLAG_USER_1); - lv_obj_add_event_cb(row, bubble_tail_draw_cb, LV_EVENT_DRAW_MAIN_END, nullptr); - - lv_color_t bubble_color = lv_color_hex(message.outgoing ? 0x3FCC75 : 0xCCCCCC); - lv_obj_t *bubble = make_panel(row, 0, 0, bubble_width, LV_SIZE_CONTENT, bubble_color, LV_OPA_COVER, 8); - lv_obj_set_style_margin_left(bubble, message.outgoing ? 0 : lora_app_detail::kBubbleTailWidth, - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_margin_right(bubble, message.outgoing ? lora_app_detail::kBubbleTailWidth : 0, - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_margin_bottom(bubble, lora_app_detail::kBubbleTailDrop, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_flex_flow(bubble, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(bubble, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_left(bubble, kHorizontalPadding, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(bubble, kHorizontalPadding, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(bubble, 7, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(bubble, 7, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_row(bubble, 3, LV_PART_MAIN | LV_STATE_DEFAULT); - - make_label(bubble, message.text.c_str(), 0, 0, bubble_width - kHorizontalPadding * 2, LV_SIZE_CONTENT, - &lv_font_montserrat_12, lv_color_hex(0x000000), LV_TEXT_ALIGN_LEFT); - if (metadata[0]) { - make_label(bubble, metadata, 0, 0, bubble_width - kHorizontalPadding * 2, LV_SIZE_CONTENT, - &lv_font_montserrat_10, lv_color_hex(0x7E7E7E), LV_TEXT_ALIGN_RIGHT); - } - return row; - } - - void append_chat_message(const char *text, bool outgoing, float rssi, float snr) - { - const char *display_text = text && text[0] ? text : ""; - if (messages_.size() >= lora_app_detail::kMessageHistoryLimit) { - messages_.pop_front(); - lv_obj_t *oldest_row = lv_obj_get_child(message_list_, 0); - if (oldest_row) lv_obj_delete(oldest_row); - } - - messages_.emplace_back(display_text, outgoing, rssi, snr); - last_message_row_ = append_message_row(messages_.back()); - set_visible(empty_message_label_, false); - set_visible(empty_message_hint_label_, false); - - if (current_view_ == View::Messages) { - scroll_to_latest(LV_ANIM_ON); - } else { - scroll_to_latest_pending_ = true; - } - } - - void open_send_view(uint32_t first_key) - { - current_view_ = View::Send; - send_status_[0] = '\0'; - tx_input_[0] = '\0'; - - char ch = lora_app_detail::key_to_ascii(first_key); - if (ch != '\0') { - tx_input_[0] = ch; - tx_input_[1] = '\0'; - } - render_current_view(); - } - - void scroll_messages(int32_t amount) - { - if (message_list_) { - lv_obj_scroll_by_bounded(message_list_, 0, amount, LV_ANIM_ON); - } - } - - void cancel_send() - { - current_view_ = View::Messages; - tx_input_[0] = '\0'; - send_status_[0] = '\0'; - render_current_view(); - } - - bool handle_send_key(uint32_t key) - { - if (key == LV_KEY_ESC) { - cancel_send(); - return true; - } - - if (key == LV_KEY_BACKSPACE || key == LV_KEY_DEL) { - std::size_t length = std::strlen(tx_input_); - if (length > 0) tx_input_[length - 1] = '\0'; - send_status_[0] = '\0'; - update_send_content(); - return true; - } - - if (key == LV_KEY_ENTER) { - send_current_text(); - return true; - } - - if (lora_app_detail::is_printable_ascii(key)) { - append_text_key(key); - update_send_content(); - return true; - } - - return true; - } - - bool handle_navigation_key(uint32_t key) - { - if (lora_app_detail::is_menu_prev_key(key)) { - current_view_ = View::Messages; - render_current_view(); - return true; - } - if (lora_app_detail::is_menu_next_key(key)) { - current_view_ = View::Info; - render_current_view(); - return true; - } - - if (current_view_ == View::Messages) { - if (key == LV_KEY_UP) { - scroll_messages(lora_app_detail::kMessageScrollStep); - return true; - } - if (key == LV_KEY_DOWN) { - scroll_messages(-lora_app_detail::kMessageScrollStep); - return true; - } - } else { - if (key == LV_KEY_UP) { - current_view_ = View::Messages; - render_current_view(); - return true; - } - if (key == LV_KEY_DOWN) { - current_view_ = View::Info; - render_current_view(); - return true; - } - } - - if (key == LV_KEY_ENTER) { - open_send_view(0); - return true; - } - if (lora_app_detail::is_printable_ascii(key) && key != 'z' && key != 'Z' && key != 'c' && key != 'C') { - open_send_view(key); - return true; - } - return false; - } - - bool handle_key(uint32_t key) - { - if (current_view_ == View::Send) { - return handle_send_key(key); - } - - if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE || key == LV_KEY_DEL) { - if (navigate_home) navigate_home(); - return true; - } - - return handle_navigation_key(key); - } - - void append_text_key(uint32_t key) - { - std::size_t length = std::strlen(tx_input_); - if (length + 1 < sizeof(tx_input_)) { - tx_input_[length] = lora_app_detail::key_to_ascii(key); - tx_input_[length + 1] = '\0'; - } - send_status_[0] = '\0'; - } - - void send_current_text() - { - if (!tx_input_[0]) { - std::snprintf(send_status_, sizeof(send_status_), "Message is empty :("); - update_send_content(); - return; - } - - std::string sent_text(tx_input_); - if (lora_app_detail::call_lora_api({"SendText", sent_text}) == 0) { - refresh_lora_info(false); - append_chat_message(sent_text.c_str(), true, 0.0f, 0.0f); - current_view_ = View::Messages; - tx_input_[0] = '\0'; - send_status_[0] = '\0'; - render_current_view(); - } else { - std::snprintf(send_status_, sizeof(send_status_), "Send failed"); - update_send_content(); - } - } - - uint32_t normalize_key(const key_item *key_event) const - { - uint32_t key = key_event->key_code; - uint32_t codepoint = key_event->codepoint; - - if (current_view_ != View::Send) { - if (key == KEY_F) return LV_KEY_UP; - if (key == KEY_X) return LV_KEY_DOWN; - } - if (lora_app_detail::is_printable_ascii(codepoint)) return codepoint; - - if (key == KEY_UP) return LV_KEY_UP; - if (key == KEY_DOWN) return LV_KEY_DOWN; - if (key == KEY_LEFT) return LV_KEY_LEFT; - if (key == KEY_RIGHT) return LV_KEY_RIGHT; - if (key == KEY_ENTER || key == KEY_KPENTER) return LV_KEY_ENTER; - if (key == KEY_ESC) return LV_KEY_ESC; - if (key == KEY_BACKSPACE) return LV_KEY_BACKSPACE; - if (key == KEY_DELETE) return LV_KEY_DEL; - return key; - } - - void on_key_event(lv_event_t *event) - { - auto *key_event = static_cast(lv_event_get_param(event)); - if (!key_event || key_event->key_state == KBD_KEY_RELEASED) return; - (void)handle_key(normalize_key(key_event)); - } - - void on_poll_timer() - { - if (!app_active_) return; - - refresh_lora_info(true); - if (lora_info_.rx_event) { - append_chat_message(lora_info_.last_rx, false, lora_info_.rssi, lora_info_.snr); - } - if (current_view_ == View::Info) { - update_info_content(); - } - } - - static void static_cancel_button_cb(lv_event_t *event) - { - auto *self = static_cast(lv_event_get_user_data(event)); - if (self) self->cancel_send(); - } - - static void static_send_button_cb(lv_event_t *event) - { - auto *self = static_cast(lv_event_get_user_data(event)); - if (self) self->send_current_text(); - } - - static void static_key_event_cb(lv_event_t *event) - { - if (lv_event_get_code(event) != static_cast(LV_EVENT_KEYBOARD)) return; - auto *self = static_cast(lv_event_get_user_data(event)); - if (self) self->on_key_event(event); - } - - static void static_poll_timer_cb(lv_timer_t *timer) - { - auto *self = static_cast(lv_timer_get_user_data(timer)); - if (self) self->on_poll_timer(); - } + lv_color_t background, lv_color_t foreground, + lv_event_cb_t callback); + void create_page_indicator(); + void bind_events(); + void track_owned_handle(lv_obj_t *object); + bool ui_ready() const; + void detach_delete_callbacks(); + void clear_deleted_handles(lv_obj_t *deleted); + static void static_owned_obj_delete_cb(lv_event_t *event) noexcept; + + void init_lora(); + bool refresh_lora_info(bool poll); + void update_page_indicator(); + void update_info_content(); + void update_send_content(); + void scroll_to_latest(lv_anim_enable_t animation); + static void view_opa_exec_cb(void *object, int32_t opacity) noexcept; + static void hide_view_after_fade_cb(lv_anim_t *animation) noexcept; + static void cancel_view_animation(lv_obj_t *view); + void cancel_view_animations(); + static void animate_view_opacity(lv_obj_t *view, lv_opa_t start, lv_opa_t end, + bool hide_after_fade); + void transition_to_view(lv_obj_t *target); + void render_current_view(); + + lv_obj_t *append_message_row(const LoraChatMessage &message); + + void append_chat_message(const char *text, bool outgoing, float rssi, float snr); + void open_send_view(uint32_t first_key); + void scroll_messages(int32_t amount); + void cancel_send(); + bool handle_send_key(uint32_t key); + bool handle_navigation_key(uint32_t key); + bool handle_key(uint32_t key); + void append_text_key(uint32_t key); + void send_current_text(); + uint32_t normalize_key(const key_item *key_event) const; + void on_key_event(lv_event_t *event); + void on_poll_timer(); + static void static_cancel_button_cb(lv_event_t *event) noexcept; + static void static_send_button_cb(lv_event_t *event) noexcept; + static void static_key_event_cb(lv_event_t *event) noexcept; + static void static_poll_timer_cb(lv_timer_t *timer) noexcept; }; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_lora_view.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_lora_view.cpp new file mode 100644 index 00000000..9eb39036 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_lora_view.cpp @@ -0,0 +1,626 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_lora.hpp" + +namespace lora_app_detail { + +constexpr lv_coord_t kScreenWidth = 320; +constexpr lv_coord_t kContentHeight = 150; +constexpr uint32_t kViewTransitionMs = 150; +constexpr lv_coord_t kBubbleTailWidth = 6; +constexpr lv_coord_t kBubbleTailDrop = 3; + +static const char *safe_text(const char *text, const char *fallback = "") +{ + return text && text[0] ? text : fallback; +} + +} // namespace lora_app_detail + +void UILoraPage::set_visible(lv_obj_t *object, bool visible) +{ + if (!object) return; + if (visible) lv_obj_clear_flag(object, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(object, LV_OBJ_FLAG_HIDDEN); +} + +lv_obj_t *UILoraPage::make_panel(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, + lv_coord_t width, lv_coord_t height, lv_color_t color, + lv_opa_t opacity, lv_coord_t radius) +{ + if (!parent) return nullptr; + lv_obj_t *panel = lv_obj_create(parent); + if (!panel) return nullptr; + lv_obj_set_pos(panel, x, y); + lv_obj_set_size(panel, width, height); + lv_obj_set_style_bg_color(panel, color, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(panel, opacity, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(panel, radius, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_width(panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + return panel; +} + +lv_obj_t *UILoraPage::make_plain_container(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, + lv_coord_t width, lv_coord_t height) +{ + return make_panel(parent, x, y, width, height, lv_color_hex(0x000000), LV_OPA_TRANSP, 0); +} + +lv_obj_t *UILoraPage::make_label(lv_obj_t *parent, const char *text, lv_coord_t x, + lv_coord_t y, lv_coord_t width, lv_coord_t height, + const lv_font_t *font, lv_color_t color, + lv_text_align_t align) +{ + if (!parent) return nullptr; + lv_obj_t *label = lv_label_create(parent); + if (!label) return nullptr; + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_label_set_text(label, text ? text : ""); + lv_obj_set_pos(label, x, y); + lv_obj_set_size(label, width, height); + lv_obj_set_style_text_font(label, font ? font : &lv_font_montserrat_12, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(label, color, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_opa(label, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(label, align, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(label, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(label, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + return label; +} + +lv_obj_t *UILoraPage::make_divider(lv_obj_t *parent, lv_coord_t y) +{ + return make_panel(parent, 8, y, 304, 1, lv_color_hex(0x25272B), LV_OPA_COVER, 0); +} + +void UILoraPage::bubble_tail_draw_cb(lv_event_t *event) noexcept +{ + if (!event) return; + lv_obj_t *row = lv_event_get_target_obj(event); + lv_layer_t *layer = lv_event_get_layer(event); + if (!row || !layer) return; + lv_obj_t *bubble = lv_obj_get_child(row, 0); + if (!bubble) return; + + lv_area_t area; + lv_obj_get_coords(bubble, &area); + lv_draw_triangle_dsc_t draw_dsc; + lv_draw_triangle_dsc_init(&draw_dsc); + draw_dsc.color = lv_obj_get_style_bg_color(bubble, LV_PART_MAIN); + draw_dsc.opa = lv_obj_get_style_bg_opa(bubble, LV_PART_MAIN); + lv_opa_t recursive_opacity = lv_obj_get_style_opa_recursive(bubble, LV_PART_MAIN); + if (recursive_opacity < LV_OPA_MAX) + draw_dsc.opa = LV_OPA_MIX2(draw_dsc.opa, recursive_opacity); + + static constexpr lv_coord_t TAIL_RISE = 9; + static constexpr lv_coord_t TAIL_SHOULDER = 10; + bool outgoing = lv_obj_has_flag(row, LV_OBJ_FLAG_USER_1); + lv_coord_t side_x = outgoing ? area.x2 : area.x1; + lv_coord_t shoulder_x = outgoing ? area.x2 - TAIL_SHOULDER : area.x1 + TAIL_SHOULDER; + lv_coord_t tip_x = outgoing ? area.x2 + lora_app_detail::kBubbleTailWidth + : area.x1 - lora_app_detail::kBubbleTailWidth; + draw_dsc.p[0] = {static_cast(side_x), + static_cast(area.y2 - TAIL_RISE)}; + draw_dsc.p[1] = {static_cast(shoulder_x), + static_cast(area.y2)}; + draw_dsc.p[2] = {static_cast(tip_x), + static_cast(area.y2 + lora_app_detail::kBubbleTailDrop)}; + lv_draw_triangle(layer, &draw_dsc); +} + +void UILoraPage::update_page_indicator() +{ + if (!page_dots_[0] || !page_dots_[1]) return; + bool messages_active = model_.view() == LoraView::MESSAGES; + lv_obj_set_style_bg_color(page_dots_[0], + lv_color_hex(messages_active ? 0xE4E4E4 : 0x4E5157), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(page_dots_[1], + lv_color_hex(messages_active ? 0x4E5157 : 0xE4E4E4), + LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void UILoraPage::update_info_content() +{ + if (!info_status_label_ || !info_status_dot_ || !info_device_value_ || + !info_rssi_value_ || !info_snr_value_ || !info_link_value_ || !info_diag_value_) + return; + const char *state_text = "RECEIVING"; + uint32_t state_color = 0x69AD80; + if (!lora_info_.hw_ready) { + state_text = "RADIO OFF"; + state_color = 0xD96C6C; + } else if (lora_info_.tx_in_progress || lora_info_.tx_mode) { + state_text = lora_info_.tx_in_progress ? "SENDING" : "TX MODE"; + state_color = 0xC9A45C; + } + lv_label_set_text(info_status_label_, state_text); + lv_obj_set_style_bg_color(info_status_dot_, lv_color_hex(state_color), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(info_device_value_, + lora_app_detail::safe_text(lora_info_.spi_device, "Unavailable")); + + char value[64]; + std::snprintf(value, sizeof(value), "%.0f dBm", lora_info_.rssi); + lv_label_set_text(info_rssi_value_, value); + std::snprintf(value, sizeof(value), "%.1f dB", lora_info_.snr); + lv_label_set_text(info_snr_value_, value); + lv_label_set_text(info_link_value_, + lora_app_detail::safe_text(lora_info_.probe_display, + "Link configuration unavailable")); + lv_label_set_text(info_diag_value_, + lora_app_detail::safe_text(lora_info_.diag, "No diagnostics")); + lv_obj_set_style_text_color(info_diag_value_, + lv_color_hex(lora_info_.hw_ready ? 0x777B82 : 0xD96C6C), + LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void UILoraPage::update_send_content() +{ + if (!send_input_label_ || !send_status_label_) return; + std::string display = model_.tx_input() + "|"; + lv_label_set_text(send_input_label_, display.c_str()); + lv_obj_set_style_text_color(send_input_label_, lv_color_hex(0xFFFFFF), + LV_PART_MAIN | LV_STATE_DEFAULT); + bool has_status = !model_.send_status().empty(); + lv_label_set_text(send_status_label_, has_status ? model_.send_status().c_str() : ""); + set_visible(send_status_label_, has_status); +} + +void UILoraPage::scroll_to_latest(lv_anim_enable_t animation) +{ + if (!message_list_ || !last_message_row_) { + scroll_to_latest_pending_ = false; + return; + } + lv_obj_update_layout(message_list_); + lv_obj_scroll_to_view(last_message_row_, animation); + scroll_to_latest_pending_ = false; +} + +void UILoraPage::view_opa_exec_cb(void *object, int32_t opacity) noexcept +{ + if (!lora_animation_callback_allowed(object)) return; + lv_obj_set_style_opa(static_cast(object), static_cast(opacity), + LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void UILoraPage::hide_view_after_fade_cb(lv_anim_t *animation) noexcept +{ + if (!animation) return; + auto *view = static_cast(lv_anim_get_user_data(animation)); + if (!lora_animation_callback_allowed(view)) return; + set_visible(view, false); + lv_obj_set_style_opa(view, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void UILoraPage::cancel_view_animation(lv_obj_t *view) +{ + if (view) lv_anim_del(view, view_opa_exec_cb); +} + +void UILoraPage::cancel_view_animations() +{ + cancel_view_animation(messages_view_); + cancel_view_animation(info_view_); + cancel_view_animation(send_view_); +} + +void UILoraPage::animate_view_opacity(lv_obj_t *view, lv_opa_t start, lv_opa_t end, + bool hide_after_fade) +{ + if (!view) return; + cancel_view_animation(view); + set_visible(view, true); + view_opa_exec_cb(view, start); + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, view); + lv_anim_set_values(&animation, start, end); + lv_anim_set_time(&animation, lora_app_detail::kViewTransitionMs); + lv_anim_set_path_cb(&animation, lv_anim_path_ease_in_out); + lv_anim_set_exec_cb(&animation, view_opa_exec_cb); + if (hide_after_fade) { + lv_anim_set_user_data(&animation, view); + lv_anim_set_completed_cb(&animation, hide_view_after_fade_cb); + } + if (!lv_anim_start(&animation)) { + view_opa_exec_cb(view, end); + if (hide_after_fade) { + set_visible(view, false); + view_opa_exec_cb(view, LV_OPA_COVER); + } + } +} + +void UILoraPage::transition_to_view(lv_obj_t *target) +{ + if (!target || target == active_view_) return; + if (!active_view_) { + lv_obj_t *views[] = {messages_view_, info_view_, send_view_}; + for (lv_obj_t *view : views) { + cancel_view_animation(view); + lv_obj_set_style_opa(view, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + set_visible(view, view == target); + } + active_view_ = target; + return; + } + + lv_obj_t *outgoing = active_view_; + lv_opa_t outgoing_start = lv_obj_get_style_opa(outgoing, LV_PART_MAIN); + lv_opa_t incoming_start = lv_obj_has_flag(target, LV_OBJ_FLAG_HIDDEN) + ? LV_OPA_TRANSP : lv_obj_get_style_opa(target, LV_PART_MAIN); + animate_view_opacity(outgoing, outgoing_start, LV_OPA_TRANSP, true); + animate_view_opacity(target, incoming_start, LV_OPA_COVER, false); + active_view_ = target; +} + +void UILoraPage::render_current_view() +{ + bool show_messages = model_.view() == LoraView::MESSAGES; + bool show_info = model_.view() == LoraView::INFO; + bool show_send = model_.view() == LoraView::SEND; + if (show_info) update_info_content(); + if (show_send) update_send_content(); + transition_to_view(show_messages ? messages_view_ : (show_info ? info_view_ : send_view_)); + set_visible(page_indicator_, !show_send); + if (!show_send) update_page_indicator(); + if (show_messages && scroll_to_latest_pending_) scroll_to_latest(LV_ANIM_OFF); +} + +void UILoraPage::create_ui() +{ + page_root_ = make_panel(ui_APP_Container, 0, 0, lora_app_detail::kScreenWidth, + lora_app_detail::kContentHeight, lv_color_hex(0x0B0C0E), + LV_OPA_COVER, 0); + if (!page_root_) return; + lv_obj_add_event_cb(page_root_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + create_messages_view(); + create_info_view(); + create_send_view(); + create_page_indicator(); + lv_obj_t *persistent_children[] = { + empty_message_label_, empty_message_hint_label_, info_status_dot_, + info_status_label_, info_device_value_, info_rssi_value_, info_snr_value_, + info_link_value_, info_diag_value_, send_input_bubble_, send_input_label_, + send_status_label_, send_cancel_button_, send_confirm_button_, page_dots_[0], + page_dots_[1]}; + for (lv_obj_t *object : persistent_children) track_owned_handle(object); +} + +void UILoraPage::create_messages_view() +{ + messages_view_ = make_plain_container(page_root_, 0, 0, 320, 150); + if (!messages_view_) return; + lv_obj_add_event_cb(messages_view_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + message_list_ = make_plain_container(messages_view_, 0, 0, 320, 150); + if (!message_list_) return; + lv_obj_add_event_cb(message_list_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_flex_flow(message_list_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(message_list_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_left(message_list_, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(message_list_, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(message_list_, 20, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(message_list_, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(message_list_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(message_list_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(message_list_, LV_DIR_VER); + lv_obj_set_scrollbar_mode(message_list_, LV_SCROLLBAR_MODE_OFF); + + empty_message_label_ = make_label(messages_view_, "No messages yet", 0, 50, 320, 16, + &lv_font_montserrat_12, lv_color_hex(0xB2B2B2), + LV_TEXT_ALIGN_CENTER); + empty_message_hint_label_ = make_label(messages_view_, "Type anything to send", 0, 68, + 320, 14, &lv_font_montserrat_12, + lv_color_hex(0x5FE492), LV_TEXT_ALIGN_CENTER); + lv_obj_t *title = make_panel(messages_view_, 108, -8, 104, 28, + lv_color_hex(0x0B0C0E), LV_OPA_COVER, 8); + if (title) + make_label(title, "Messages", 0, 8, 104, 18, &lv_font_montserrat_14, + lv_color_hex(0xE4E4E4), LV_TEXT_ALIGN_CENTER); +} + +void UILoraPage::create_info_view() +{ + info_view_ = make_plain_container(page_root_, 0, 0, 320, 150); + if (!info_view_) return; + lv_obj_add_event_cb(info_view_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + make_label(info_view_, "LoRa Info", 0, 0, 320, 18, &lv_font_montserrat_14, + lv_color_hex(0xE4E4E4), LV_TEXT_ALIGN_CENTER); + info_status_dot_ = make_panel(info_view_, 8, 23, 6, 6, lv_color_hex(0x69AD80), + LV_OPA_COVER, LV_RADIUS_CIRCLE); + info_status_label_ = make_label(info_view_, "", 20, 20, 180, 16, &lv_font_montserrat_10, + lv_color_hex(0xAEB2B8), LV_TEXT_ALIGN_LEFT); + make_label(info_view_, "CLIENT", 240, 20, 72, 16, &lv_font_montserrat_10, + lv_color_hex(0x777B82), LV_TEXT_ALIGN_RIGHT); + make_divider(info_view_, 41); + make_label(info_view_, "DEVICE", 8, 48, 148, 11, &lv_font_montserrat_10, + lv_color_hex(0x777B82), LV_TEXT_ALIGN_LEFT); + make_label(info_view_, "RSSI", 166, 48, 66, 11, &lv_font_montserrat_10, + lv_color_hex(0x777B82), LV_TEXT_ALIGN_LEFT); + make_label(info_view_, "SNR", 240, 48, 72, 11, &lv_font_montserrat_10, + lv_color_hex(0x777B82), LV_TEXT_ALIGN_LEFT); + info_device_value_ = make_label(info_view_, "", 8, 60, 148, 17, &lv_font_montserrat_12, + lv_color_hex(0xDDE0E4), LV_TEXT_ALIGN_LEFT); + info_rssi_value_ = make_label(info_view_, "", 166, 60, 66, 17, &lv_font_montserrat_12, + lv_color_hex(0xDDE0E4), LV_TEXT_ALIGN_LEFT); + info_snr_value_ = make_label(info_view_, "", 240, 60, 72, 17, &lv_font_montserrat_12, + lv_color_hex(0xDDE0E4), LV_TEXT_ALIGN_LEFT); + if (info_device_value_) lv_label_set_long_mode(info_device_value_, LV_LABEL_LONG_DOT); + make_divider(info_view_, 81); + make_label(info_view_, "LINK", 8, 88, 304, 11, &lv_font_montserrat_10, + lv_color_hex(0x777B82), LV_TEXT_ALIGN_LEFT); + info_link_value_ = make_label(info_view_, "", 8, 100, 304, 16, &lv_font_montserrat_12, + lv_color_hex(0xDDE0E4), LV_TEXT_ALIGN_LEFT); + info_diag_value_ = make_label(info_view_, "", 8, 120, 304, 13, &lv_font_montserrat_10, + lv_color_hex(0x777B82), LV_TEXT_ALIGN_LEFT); + if (info_link_value_) lv_label_set_long_mode(info_link_value_, LV_LABEL_LONG_DOT); + if (info_diag_value_) lv_label_set_long_mode(info_diag_value_, LV_LABEL_LONG_DOT); +} + +void UILoraPage::create_send_view() +{ + send_view_ = make_plain_container(page_root_, 0, 0, 320, 150); + if (!send_view_) return; + lv_obj_add_event_cb(send_view_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + make_label(send_view_, "New Message", 0, 0, 320, 18, &lv_font_montserrat_14, + lv_color_hex(0xE4E4E4), LV_TEXT_ALIGN_CENTER); + send_input_bubble_ = make_panel(send_view_, 0, 0, 286, 80, lv_color_hex(0x555555), + LV_OPA_COVER, 8); + if (send_input_bubble_) lv_obj_align(send_input_bubble_, LV_ALIGN_CENTER, 0, -12); + send_input_label_ = make_label(send_input_bubble_, "", 10, 8, 266, 64, + &lv_font_montserrat_14, lv_color_hex(0xFFFFFF), + LV_TEXT_ALIGN_LEFT); + send_status_label_ = make_label(send_input_bubble_, "", 10, 54, 266, 16, + &lv_font_montserrat_14, lv_color_hex(0xFED40D), + LV_TEXT_ALIGN_RIGHT); + set_visible(send_status_label_, false); + send_cancel_button_ = make_action_button(send_view_, 83, 113, 110, "ESC: Cancel", + lv_color_hex(0x6D6D6D), lv_color_hex(0xF3F3F3), + &UILoraPage::static_cancel_button_cb); + send_confirm_button_ = make_action_button(send_view_, 203, 113, 100, "Enter: Send", + lv_color_hex(0xFED40D), lv_color_hex(0x5E4D00), + &UILoraPage::static_send_button_cb); +} + +lv_obj_t *UILoraPage::make_action_button(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, + lv_coord_t width, const char *text, + lv_color_t background, lv_color_t foreground, + lv_event_cb_t callback) +{ + lv_obj_t *button = make_panel(parent, x, y, width, 26, background, LV_OPA_COVER, 5); + if (!button) return nullptr; + lv_obj_add_flag(button, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(button, callback, LV_EVENT_CLICKED, this); + lv_obj_t *label = make_label(button, text, 0, 0, LV_SIZE_CONTENT, LV_SIZE_CONTENT, + &lv_font_montserrat_14, foreground, LV_TEXT_ALIGN_CENTER); + if (label) lv_obj_center(label); + return button; +} + +void UILoraPage::create_page_indicator() +{ + page_indicator_ = make_panel(page_root_, 141, 137, 38, 24, lv_color_hex(0x0B0C0E), + LV_OPA_COVER, 7); + if (!page_indicator_) return; + lv_obj_add_event_cb(page_indicator_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + page_dots_[0] = make_panel(page_indicator_, 11, 3, 5, 5, lv_color_hex(0xE4E4E4), + LV_OPA_COVER, LV_RADIUS_CIRCLE); + page_dots_[1] = make_panel(page_indicator_, 22, 3, 5, 5, lv_color_hex(0x4E5157), + LV_OPA_COVER, LV_RADIUS_CIRCLE); +} + +void UILoraPage::bind_events() +{ + if (root_screen_) + lv_obj_add_event_cb(root_screen_, &UILoraPage::static_key_event_cb, LV_EVENT_ALL, this); +} + +void UILoraPage::track_owned_handle(lv_obj_t *object) +{ + if (object) + lv_obj_add_event_cb(object, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); +} + +bool UILoraPage::ui_ready() const +{ + return lora_page_controls_ready( + page_root_, messages_view_, message_list_, empty_message_label_, + empty_message_hint_label_, info_view_, info_status_dot_, info_status_label_, + info_device_value_, info_rssi_value_, info_snr_value_, info_link_value_, + info_diag_value_, send_view_, send_input_bubble_, send_input_label_, + send_status_label_, send_cancel_button_, send_confirm_button_, page_indicator_, + page_dots_[0], page_dots_[1]); +} + +void UILoraPage::detach_delete_callbacks() +{ + lv_obj_t *objects[] = { + message_list_, messages_view_, empty_message_label_, empty_message_hint_label_, + info_view_, info_status_dot_, info_status_label_, info_device_value_, + info_rssi_value_, info_snr_value_, info_link_value_, info_diag_value_, + send_view_, send_input_bubble_, send_input_label_, send_status_label_, + send_cancel_button_, send_confirm_button_, page_indicator_, page_dots_[0], + page_dots_[1], page_root_}; + for (lv_obj_t *object : objects) + if (object) + lv_obj_remove_event_cb_with_user_data( + object, static_owned_obj_delete_cb, this); +} + +void UILoraPage::clear_deleted_handles(lv_obj_t *deleted) +{ + if (!deleted) return; + if (deleted == empty_message_label_) empty_message_label_ = nullptr; + if (deleted == empty_message_hint_label_) empty_message_hint_label_ = nullptr; + if (deleted == info_status_dot_) info_status_dot_ = nullptr; + if (deleted == info_status_label_) info_status_label_ = nullptr; + if (deleted == info_device_value_) info_device_value_ = nullptr; + if (deleted == info_rssi_value_) info_rssi_value_ = nullptr; + if (deleted == info_snr_value_) info_snr_value_ = nullptr; + if (deleted == info_link_value_) info_link_value_ = nullptr; + if (deleted == info_diag_value_) info_diag_value_ = nullptr; + if (deleted == send_input_bubble_) send_input_bubble_ = nullptr; + if (deleted == send_input_label_) send_input_label_ = nullptr; + if (deleted == send_status_label_) send_status_label_ = nullptr; + if (deleted == send_cancel_button_) send_cancel_button_ = nullptr; + if (deleted == send_confirm_button_) send_confirm_button_ = nullptr; + if (deleted == page_dots_[0]) page_dots_[0] = nullptr; + if (deleted == page_dots_[1]) page_dots_[1] = nullptr; + if (deleted == message_list_) { + message_list_ = nullptr; + last_message_row_ = nullptr; + } + if (deleted == messages_view_) { + messages_view_ = nullptr; + message_list_ = nullptr; + empty_message_label_ = nullptr; + empty_message_hint_label_ = nullptr; + last_message_row_ = nullptr; + } + if (deleted == info_view_) { + info_view_ = nullptr; + info_status_dot_ = nullptr; + info_status_label_ = nullptr; + info_device_value_ = nullptr; + info_rssi_value_ = nullptr; + info_snr_value_ = nullptr; + info_link_value_ = nullptr; + info_diag_value_ = nullptr; + } + if (deleted == send_view_) { + send_view_ = nullptr; + send_input_bubble_ = nullptr; + send_input_label_ = nullptr; + send_status_label_ = nullptr; + send_cancel_button_ = nullptr; + send_confirm_button_ = nullptr; + } + if (deleted == page_indicator_) { + page_indicator_ = nullptr; + page_dots_[0] = nullptr; + page_dots_[1] = nullptr; + } + if (active_view_ == deleted) active_view_ = nullptr; + if (deleted == page_root_) { + page_root_ = nullptr; + messages_view_ = nullptr; + message_list_ = nullptr; + empty_message_label_ = nullptr; + empty_message_hint_label_ = nullptr; + last_message_row_ = nullptr; + info_view_ = nullptr; + info_status_dot_ = nullptr; + info_status_label_ = nullptr; + info_device_value_ = nullptr; + info_rssi_value_ = nullptr; + info_snr_value_ = nullptr; + info_link_value_ = nullptr; + info_diag_value_ = nullptr; + send_view_ = nullptr; + send_input_bubble_ = nullptr; + send_input_label_ = nullptr; + send_status_label_ = nullptr; + send_cancel_button_ = nullptr; + send_confirm_button_ = nullptr; + page_indicator_ = nullptr; + page_dots_[0] = nullptr; + page_dots_[1] = nullptr; + active_view_ = nullptr; + app_active_ = false; + poll_timer_.stop(); + } + if (!ui_ready()) { + app_active_ = false; + poll_timer_.stop(); + } +} + +void UILoraPage::static_owned_obj_delete_cb(lv_event_t *event) noexcept +{ + try { + if (!event || !lora_owned_delete_callback_allowed( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (self) self->clear_deleted_handles(deleted); + } catch (...) { + auto *self = event + ? static_cast(lv_event_get_user_data(event)) : nullptr; + if (self) self->app_active_ = false; + } +} + +lv_obj_t *UILoraPage::append_message_row(const LoraChatMessage &message) +{ + if (!message_list_) return nullptr; + char metadata[64] = ""; + if (!message.outgoing) + std::snprintf(metadata, sizeof(metadata), "%.0f dBm / %.1f dB", + message.rssi, message.snr); + + static constexpr int32_t HORIZONTAL_PADDING = 10; + static constexpr int32_t MAX_TEXT_WIDTH = 224; + static constexpr int32_t MAX_BUBBLE_WIDTH = 244; + lv_point_t text_size{}; + lv_text_get_size(&text_size, message.text.c_str(), &lv_font_montserrat_12, 0, 0, + MAX_TEXT_WIDTH, LV_TEXT_FLAG_NONE); + int32_t content_width = text_size.x; + if (metadata[0]) { + lv_point_t metadata_size{}; + lv_text_get_size(&metadata_size, metadata, &lv_font_montserrat_10, 0, 0, + MAX_TEXT_WIDTH, LV_TEXT_FLAG_NONE); + content_width = std::max(content_width, metadata_size.x); + } + int32_t bubble_width = std::max( + 64, std::min(MAX_BUBBLE_WIDTH, content_width + HORIZONTAL_PADDING * 2)); + + lv_obj_t *row = make_plain_container(message_list_, 0, 0, LV_PCT(100), LV_SIZE_CONTENT); + if (!row) return nullptr; + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, + message.outgoing ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + if (message.outgoing) lv_obj_add_flag(row, LV_OBJ_FLAG_USER_1); + lv_obj_add_event_cb(row, bubble_tail_draw_cb, LV_EVENT_DRAW_MAIN_END, nullptr); + + lv_obj_t *bubble = make_panel(row, 0, 0, bubble_width, LV_SIZE_CONTENT, + lv_color_hex(message.outgoing ? 0x3FCC75 : 0xCCCCCC), + LV_OPA_COVER, 8); + if (!bubble) return row; + lv_obj_set_style_margin_left(bubble, + message.outgoing ? 0 : lora_app_detail::kBubbleTailWidth, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_margin_right(bubble, + message.outgoing ? lora_app_detail::kBubbleTailWidth : 0, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_margin_bottom(bubble, lora_app_detail::kBubbleTailDrop, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_flex_flow(bubble, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(bubble, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_left(bubble, HORIZONTAL_PADDING, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(bubble, HORIZONTAL_PADDING, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(bubble, 7, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(bubble, 7, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(bubble, 3, LV_PART_MAIN | LV_STATE_DEFAULT); + + make_label(bubble, message.text.c_str(), 0, 0, + bubble_width - HORIZONTAL_PADDING * 2, LV_SIZE_CONTENT, + &lv_font_montserrat_12, lv_color_hex(0x000000), LV_TEXT_ALIGN_LEFT); + if (metadata[0]) + make_label(bubble, metadata, 0, 0, bubble_width - HORIZONTAL_PADDING * 2, + LV_SIZE_CONTENT, &lv_font_montserrat_10, lv_color_hex(0x7E7E7E), + LV_TEXT_ALIGN_RIGHT); + return row; +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_mesh.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_mesh.cpp new file mode 100644 index 00000000..27cc23d0 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_mesh.cpp @@ -0,0 +1,278 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_mesh.hpp" + +#include "input_keys.h" +#include "../keyboard_text_input.hpp" + +#include +#include + +UIMeshPage::UIMeshPage() : AppPage() +{ + set_page_title("MESH"); + generate_node_id(); + create_ui(); + const auto bg = ui_obj_.find("bg"); + if (!ui_ready()) { + if (bg != ui_obj_.end() && bg->second) lv_obj_delete(bg->second); + return; + } + event_handler_init(); + heartbeat_timer_.start( + [this] { return lv_timer_create(heartbeat_timer_cb, 8000, this); }, + [](lv_timer_t *timer) { lv_timer_delete(timer); }); +} + +UIMeshPage::~UIMeshPage() +{ + heartbeat_timer_.stop(); + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UIMeshPage::static_lvgl_handler, this); + detach_ui_callbacks(); +} + +void UIMeshPage::generate_node_id() +{ + uint32_t r = 0; + cp0_signal_osinfo_api({"RandomU32"}, [&](int code, std::string data) { + uint32_t parsed = 0; + if (code == 0 && mesh_parse_random_u32(data, parsed)) r = parsed; + }); + model_.initialize(r); +} + +std::string UIMeshPage::current_time() +{ + int hour = 0; + int minute = 0; + int second = 0; + bool valid = false; + cp0_signal_osinfo_api({"LocalTime"}, [&](int code, std::string data) { + valid = code == 0 && mesh_parse_local_time(data, hour, minute, second); + }); + if (!valid) return "--:--:--"; + char timestamp[16]; + snprintf(timestamp, sizeof(timestamp), "%02d:%02d:%02d", hour, minute, second); + return timestamp; +} + +void UIMeshPage::show_input_overlay() +{ + const auto bg_entry = ui_obj_.find("bg"); + if (bg_entry == ui_obj_.end() || !bg_entry->second || input_overlay_) return; + lv_obj_t *bg = bg_entry->second; + input_overlay_ = lv_obj_create(bg); + if (!input_overlay_) return; + lv_obj_add_event_cb(input_overlay_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + view_state_ = ViewState::INPUT; + msg_input_buf_.clear(); + lv_obj_set_size(input_overlay_, 280, 50); + lv_obj_set_pos(input_overlay_, 20, 50); + lv_obj_set_style_radius(input_overlay_, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(input_overlay_, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(input_overlay_, 240, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(input_overlay_, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(input_overlay_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(input_overlay_, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(input_overlay_, LV_OBJ_FLAG_SCROLLABLE); + make_label(input_overlay_, "Send MESH Message:", 0, 0, 0x58A6FF, &lv_font_montserrat_10); + msg_input_lbl_ = make_label(input_overlay_, "_", 0, 14, 0xFFFFFF, &lv_font_montserrat_12); + if (!msg_input_lbl_) { + close_input_overlay(); + return; + } + lv_obj_add_event_cb(msg_input_lbl_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_width(msg_input_lbl_, 260); + lv_label_set_long_mode(msg_input_lbl_, LV_LABEL_LONG_CLIP); + make_label(input_overlay_, "OK:send ESC:cancel", 0, 30, 0x888888, &lv_font_montserrat_10); +} + +void UIMeshPage::close_input_overlay() +{ + if (input_overlay_) { + lv_obj_del(input_overlay_); + input_overlay_ = nullptr; + } + msg_input_lbl_ = nullptr; + view_state_ = ViewState::MAIN; +} + +void UIMeshPage::update_input_display() +{ + if (!msg_input_lbl_) return; + std::string display = msg_input_buf_ + "_"; + lv_label_set_text(msg_input_lbl_, display.c_str()); +} + +void UIMeshPage::do_refresh() +{ + model_.refresh(current_time()); + build_neighbor_list(); + build_message_list(); +} + +void UIMeshPage::heartbeat_timer_cb(lv_timer_t *timer) noexcept +{ + UIMeshPage *self = static_cast(lv_timer_get_user_data(timer)); + if (!self) return; + try { + if (mesh_heartbeat_callback_allowed( + self->heartbeat_timer_.current(timer), self->heartbeat_enabled_)) + self->on_heartbeat(); + } catch (...) { + self->heartbeat_enabled_ = false; + } +} + +void UIMeshPage::on_heartbeat() +{ + model_.heartbeat(current_time()); + build_neighbor_list(); + build_message_list(); +} + +void UIMeshPage::event_handler_init() +{ + if (!root_screen_) return; + lv_obj_add_event_cb(root_screen_, UIMeshPage::static_lvgl_handler, LV_EVENT_ALL, this); +} + +bool UIMeshPage::ui_ready() const +{ + const auto bg = ui_obj_.find("bg"); + return bg != ui_obj_.end() && mesh_page_ui_ready( + bg->second, neighbor_area_, msg_area_, status_lbl_, hint_lbl_); +} + +void UIMeshPage::detach_ui_callbacks() +{ + lv_obj_t *objects[] = {msg_input_lbl_, input_overlay_, neighbor_area_, msg_area_, + status_lbl_, hint_lbl_}; + for (lv_obj_t *object : objects) + if (object) + lv_obj_remove_event_cb_with_user_data(object, owned_obj_delete_cb, this); + const auto bg = ui_obj_.find("bg"); + if (bg != ui_obj_.end() && bg->second) + lv_obj_remove_event_cb_with_user_data(bg->second, owned_obj_delete_cb, this); +} + +void UIMeshPage::owned_obj_delete_cb(lv_event_t *event) noexcept +{ + if (!event || !mesh_owned_delete_callback_allowed( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + try { + auto *deleted = static_cast(lv_event_get_target(event)); + + if (self->input_overlay_ == deleted) { + self->input_overlay_ = nullptr; + self->msg_input_lbl_ = nullptr; + self->msg_input_buf_.clear(); + self->view_state_ = ViewState::MAIN; + } + if (self->msg_input_lbl_ == deleted) self->msg_input_lbl_ = nullptr; + if (self->status_lbl_ == deleted) self->status_lbl_ = nullptr; + if (self->hint_lbl_ == deleted) self->hint_lbl_ = nullptr; + if (self->neighbor_area_ == deleted) { + self->neighbor_area_ = nullptr; + self->heartbeat_timer_.stop(); + } + if (self->msg_area_ == deleted) { + self->msg_area_ = nullptr; + self->heartbeat_timer_.stop(); + } + const auto bg = self->ui_obj_.find("bg"); + if (bg != self->ui_obj_.end() && bg->second == deleted) { + self->heartbeat_timer_.stop(); + self->input_overlay_ = nullptr; + self->msg_input_lbl_ = nullptr; + self->msg_area_ = nullptr; + self->neighbor_area_ = nullptr; + self->status_lbl_ = nullptr; + self->hint_lbl_ = nullptr; + self->ui_obj_.clear(); + self->msg_input_buf_.clear(); + self->view_state_ = ViewState::MAIN; + } + if (!self->ui_ready()) self->heartbeat_timer_.stop(); + } catch (...) { + self->heartbeat_enabled_ = false; + } +} + +void UIMeshPage::static_lvgl_handler(lv_event_t *e) noexcept +{ + UIMeshPage *self = nullptr; + try { + if (!e) return; + self = static_cast(lv_event_get_user_data(e)); + if (!self || !mesh_root_event_callback_allowed( + lv_event_get_current_target(e), self->root_screen_)) + return; + self->event_handler(e); + } catch (...) { + if (self) self->heartbeat_enabled_ = false; + } +} + +void UIMeshPage::event_handler(lv_event_t *e) +{ + if (lv_event_get_code(e) == LV_EVENT_DELETE && + lv_event_get_target(e) == lv_event_get_current_target(e)) { + heartbeat_timer_.stop(); + input_overlay_ = nullptr; + msg_input_lbl_ = nullptr; + msg_area_ = nullptr; + neighbor_area_ = nullptr; + return; + } + if (!launcher_ui::events::is_key_released(e)) return; + uint32_t key = launcher_ui::events::keyboard_key(e); + switch (view_state_) { + case ViewState::MAIN: handle_main_key(key); break; + case ViewState::INPUT: handle_input_key(key, launcher_ui::events::keyboard_item(e)); break; + } +} + +void UIMeshPage::handle_main_key(uint32_t key) +{ + switch (key) { + case KEY_S: show_input_overlay(); break; + case KEY_R: do_refresh(); break; + case KEY_UP: + if (msg_area_) lv_obj_scroll_by(msg_area_, 0, 20, LV_ANIM_ON); + break; + case KEY_DOWN: + if (msg_area_) lv_obj_scroll_by(msg_area_, 0, -20, LV_ANIM_ON); + break; + case KEY_ESC: + heartbeat_timer_.stop(); + if (navigate_home) navigate_home(); + break; + default: break; + } +} + +void UIMeshPage::handle_input_key(uint32_t key, const struct key_item *elm) +{ + if (key == KEY_ESC) { + close_input_overlay(); + return; + } + if (key == KEY_ENTER) { + if (!msg_input_buf_.empty()) add_message("ME", msg_input_buf_.c_str()); + close_input_overlay(); + return; + } + if (key == KEY_BACKSPACE) { + launcher_ui::text_input::erase_last_codepoint(msg_input_buf_); + update_input_display(); + return; + } + if (launcher_ui::text_input::append_limited( + msg_input_buf_, launcher_ui::text_input::key_text(key, elm), 40)) { + update_input_display(); + } +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_mesh.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_mesh.hpp index c66648eb..95a510f5 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_mesh.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_mesh.hpp @@ -5,551 +5,79 @@ */ #pragma once -#include "../ui_app_page.hpp" -#include + +#include "../launcher_ui_app_page.hpp" +#include "../model/mesh_page_model.hpp" +#include "../model/mesh_page_contract.hpp" +#include "../model/page_timer_lifecycle.hpp" + +#include #include +#include #include -#include -#include -#include -#include -#include -#include "compat/input_keys.h" -// ============================================================ -// LoRa MESH UIMeshPage -// Screen: 320 x 170 (top bar 20px, ui_APP_Container 320x150) -// -// View states: -// VIEW_MAIN - Main view with status, neighbors, messages -// VIEW_INPUT - Message input overlay -// -// This is a UI placeholder/demo for LoRa MESH networking. -// Actual LoRa hardware integration requires specific drivers. -// ============================================================ +struct key_item; class UIMeshPage : public AppPage { - enum class ViewState { MAIN, INPUT }; - - struct MeshMessage - { - std::string timestamp; - std::string sender; - std::string text; - }; - - struct MeshNeighbor +private: + enum class ViewState { - std::string node_id; - int rssi; - int hops; - std::string last_seen; + MAIN, + INPUT }; public: - UIMeshPage() : AppPage() - { - set_page_title("MESH"); - generate_node_id(); - creat_UI(); - event_handler_init(); - // Periodic heartbeat timer (every 8 seconds) - heartbeat_timer_ = lv_timer_create(heartbeat_timer_cb, 8000, this); - } - ~UIMeshPage() - { - if (heartbeat_timer_) lv_timer_delete(heartbeat_timer_); - } + UIMeshPage(); + ~UIMeshPage(); private: std::unordered_map ui_obj_; ViewState view_state_ = ViewState::MAIN; - // Node info - char node_id_[16] = {}; - int channel_ = 7; - int frequency_mhz_ = 915; - bool lora_detected_ = false; + MeshPageModel model_; - // Neighbors - std::vector neighbors_; - - // Messages - std::vector messages_; - static constexpr int MAX_MESSAGES = 20; - - // Message input std::string msg_input_buf_; - lv_obj_t *msg_input_lbl_ = nullptr; - lv_obj_t *input_overlay_ = nullptr; - - // Message display area - lv_obj_t *msg_area_ = nullptr; - lv_obj_t *neighbor_area_ = nullptr; - lv_obj_t *status_lbl_ = nullptr; - lv_obj_t *hint_lbl_ = nullptr; - - // Heartbeat timer - lv_timer_t *heartbeat_timer_ = nullptr; - int heartbeat_count_ = 0; - - // ==================== helper: styled label ==================== - static lv_obj_t *make_label(lv_obj_t *parent, const char *text, - int x, int y, uint32_t color = 0xE6EDF3, - const lv_font_t *font = &lv_font_montserrat_12) - { - lv_obj_t *lbl = lv_label_create(parent); - lv_label_set_text(lbl, text); - lv_obj_set_pos(lbl, x, y); - lv_obj_set_style_text_color(lbl, lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl, font, LV_PART_MAIN | LV_STATE_DEFAULT); - return lbl; - } - - // ==================== keycode to char ==================== - static char keycode_to_char(uint32_t key) - { - if (key >= KEY_1 && key <= KEY_9) return '1' + (key - KEY_1); - if (key == KEY_0) return '0'; - static const char qwerty[] = "qwertyuiop"; - if (key >= KEY_Q && key <= KEY_P) return qwerty[key - KEY_Q]; - static const char asdf[] = "asdfghjkl"; - if (key >= KEY_A && key <= KEY_L) return asdf[key - KEY_A]; - static const char zxcv[] = "zxcvbnm"; - if (key >= KEY_Z && key <= KEY_M) return zxcv[key - KEY_Z]; - if (key == KEY_SPACE) return ' '; - if (key == 52) return '.'; // KEY_DOT - return 0; - } - - // ==================== generate random node ID ==================== - void generate_node_id() - { - uint32_t r = 0; - cp0_signal_osinfo_api({"RandomU32"}, [&](int code, std::string data) { - if (code == 0) r = static_cast(std::strtoul(data.c_str(), nullptr, 10)); - }); - snprintf(node_id_, sizeof(node_id_), "0x%04X", r & 0xFFFF); - } - - // ==================== get current time string ==================== - static void get_time_str(char *buf, int sz) - { - int values[6] = {}; - bool valid = false; - cp0_signal_osinfo_api({"LocalTime"}, [&](int code, std::string data) { - valid = code == 0 && std::sscanf(data.c_str(), "%d,%d,%d,%d,%d,%d", &values[0], &values[1], - &values[2], &values[3], &values[4], &values[5]) == 6; - }); - if (valid) snprintf(buf, sz, "%02d:%02d:%02d", values[3], values[4], values[5]); - else snprintf(buf, sz, "--:--:--"); - } - - // ==================== UI build ==================== - void creat_UI() - { - // ---- Background ---- - lv_obj_t *bg = lv_obj_create(ui_APP_Container); - lv_obj_set_size(bg, 320, 150); - lv_obj_set_pos(bg, 0, 0); - lv_obj_set_style_radius(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x0D1117), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["bg"] = bg; - - // ---- Title bar ---- - lv_obj_t *title_bar = lv_obj_create(bg); - lv_obj_set_size(title_bar, 320, 22); - lv_obj_set_pos(title_bar, 0, 0); - lv_obj_set_style_radius(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(title_bar, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(title_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(title_bar, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *lbl_title = lv_label_create(title_bar); - lv_label_set_text(lbl_title, LV_SYMBOL_WIFI " LoRa MESH"); - lv_obj_set_align(lbl_title, LV_ALIGN_LEFT_MID); - lv_obj_set_style_text_color(lbl_title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_t *lbl_hint = lv_label_create(title_bar); - lv_label_set_text(lbl_hint, "S:Send R:Refresh ESC:Back"); - lv_obj_set_align(lbl_hint, LV_ALIGN_RIGHT_MID); - lv_obj_set_x(lbl_hint, -4); - lv_obj_set_style_text_color(lbl_hint, lv_color_hex(0x7EA8D8), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_hint, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - - // ---- Left panel: Status + Neighbors (width 158) ---- - lv_obj_t *left_panel = lv_obj_create(bg); - lv_obj_set_size(left_panel, 158, 116); - lv_obj_set_pos(left_panel, 2, 24); - lv_obj_set_style_radius(left_panel, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(left_panel, lv_color_hex(0x161B22), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(left_panel, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(left_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(left_panel, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(left_panel, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["left_panel"] = left_panel; - - // Status - char status_buf[64]; - snprintf(status_buf, sizeof(status_buf), "LoRa: %s", - lora_detected_ ? "Ready" : "Not Detected"); - status_lbl_ = make_label(left_panel, status_buf, 0, 0, - lora_detected_ ? 0x2ECC71 : 0xE74C3C, - &lv_font_montserrat_10); - - // Node info - char node_buf[64]; - snprintf(node_buf, sizeof(node_buf), "Node: %s", node_id_); - make_label(left_panel, node_buf, 0, 13, 0x58A6FF, &lv_font_montserrat_10); - - char freq_buf[64]; - snprintf(freq_buf, sizeof(freq_buf), "CH:%d %dMHz", channel_, frequency_mhz_); - make_label(left_panel, freq_buf, 0, 26, 0x888888, &lv_font_montserrat_10); - - // Separator - lv_obj_t *sep = lv_obj_create(left_panel); - lv_obj_set_size(sep, 146, 1); - lv_obj_set_pos(sep, 0, 39); - lv_obj_set_style_bg_color(sep, lv_color_hex(0x21262D), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(sep, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(sep, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(sep, LV_OBJ_FLAG_SCROLLABLE); - - make_label(left_panel, "Neighbors:", 0, 43, 0x7EA8D8, &lv_font_montserrat_10); - - // Neighbor list container - neighbor_area_ = lv_obj_create(left_panel); - lv_obj_set_size(neighbor_area_, 148, 56); - lv_obj_set_pos(neighbor_area_, 0, 55); - lv_obj_set_style_bg_opa(neighbor_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(neighbor_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(neighbor_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(neighbor_area_, LV_OBJ_FLAG_SCROLLABLE); - - build_neighbor_list(); - - // ---- Right panel: Messages (width 154) ---- - lv_obj_t *right_panel = lv_obj_create(bg); - lv_obj_set_size(right_panel, 156, 116); - lv_obj_set_pos(right_panel, 162, 24); - lv_obj_set_style_radius(right_panel, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(right_panel, lv_color_hex(0x161B22), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(right_panel, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(right_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(right_panel, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(right_panel, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["right_panel"] = right_panel; - - make_label(right_panel, "Messages:", 0, 0, 0x7EA8D8, &lv_font_montserrat_10); - - msg_area_ = lv_obj_create(right_panel); - lv_obj_set_size(msg_area_, 146, 96); - lv_obj_set_pos(msg_area_, 0, 12); - lv_obj_set_style_bg_opa(msg_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(msg_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(msg_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_scroll_dir(msg_area_, LV_DIR_VER); - lv_obj_add_flag(msg_area_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(msg_area_, LV_SCROLLBAR_MODE_AUTO); - lv_obj_set_style_width(msg_area_, 2, LV_PART_SCROLLBAR | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(msg_area_, lv_color_hex(0x1F6FEB), LV_PART_SCROLLBAR | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(msg_area_, 200, LV_PART_SCROLLBAR | LV_STATE_DEFAULT); - - build_message_list(); - - // ---- Bottom hint bar ---- - hint_lbl_ = make_label(bg, "S:Send R:Refresh ESC:Back", 6, 142, 0x555555, &lv_font_montserrat_10); - } - - // ==================== build neighbor list ==================== - void build_neighbor_list() - { - if (!neighbor_area_) return; - lv_obj_clean(neighbor_area_); - - if (neighbors_.empty()) { - make_label(neighbor_area_, "No neighbors found", 0, 4, 0x555555, &lv_font_montserrat_10); - return; - } + lv_obj_t *msg_input_lbl_ = nullptr; + lv_obj_t *input_overlay_ = nullptr; - int y = 0; - for (size_t i = 0; i < neighbors_.size() && i < 4; ++i) { - char buf[64]; - snprintf(buf, sizeof(buf), "%s %ddBm %dhop", - neighbors_[i].node_id.c_str(), - neighbors_[i].rssi, - neighbors_[i].hops); - make_label(neighbor_area_, buf, 0, y, 0xCCCCCC, &lv_font_montserrat_10); - y += 13; - } - } + lv_obj_t *msg_area_ = nullptr; + lv_obj_t *neighbor_area_ = nullptr; + lv_obj_t *status_lbl_ = nullptr; + lv_obj_t *hint_lbl_ = nullptr; - // ==================== build message list ==================== - void build_message_list() - { - if (!msg_area_) return; - lv_obj_clean(msg_area_); - - if (messages_.empty()) { - make_label(msg_area_, "MESH messages will", 0, 4, 0x555555, &lv_font_montserrat_10); - make_label(msg_area_, "appear here", 0, 17, 0x555555, &lv_font_montserrat_10); - return; - } - - int y = 0; - // Show most recent messages (bottom of list) - int start = (int)messages_.size() > 7 ? (int)messages_.size() - 7 : 0; - for (int i = start; i < (int)messages_.size(); ++i) { - // Timestamp + sender - char header[64]; - snprintf(header, sizeof(header), "[%s] %s:", - messages_[i].timestamp.c_str(), - messages_[i].sender.c_str()); - - uint32_t hdr_color = (messages_[i].sender == "ME") ? 0x58A6FF : 0x2ECC71; - make_label(msg_area_, header, 0, y, hdr_color, &lv_font_montserrat_10); - y += 12; - - // Message text (truncate) - std::string text = messages_[i].text; - if (text.size() > 22) - text = text.substr(0, 22) + ".."; - make_label(msg_area_, text.c_str(), 4, y, 0xE6EDF3, &lv_font_montserrat_10); - y += 14; - } - } - - // ==================== add a message ==================== - void add_message(const char *sender, const char *text) - { - MeshMessage msg; - char ts[16]; - get_time_str(ts, sizeof(ts)); - msg.timestamp = ts; - msg.sender = sender; - msg.text = text; - messages_.push_back(msg); - if ((int)messages_.size() > MAX_MESSAGES) - messages_.erase(messages_.begin()); - build_message_list(); - } - - // ==================== show message input overlay ==================== - void show_input_overlay() - { - view_state_ = ViewState::INPUT; - msg_input_buf_.clear(); - - lv_obj_t *bg = ui_obj_["bg"]; - - input_overlay_ = lv_obj_create(bg); - lv_obj_set_size(input_overlay_, 280, 50); - lv_obj_set_pos(input_overlay_, 20, 50); - lv_obj_set_style_radius(input_overlay_, 6, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(input_overlay_, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(input_overlay_, 240, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(input_overlay_, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(input_overlay_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(input_overlay_, 6, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(input_overlay_, LV_OBJ_FLAG_SCROLLABLE); - - make_label(input_overlay_, "Send MESH Message:", 0, 0, 0x58A6FF, &lv_font_montserrat_10); - - msg_input_lbl_ = make_label(input_overlay_, "_", 0, 14, 0xFFFFFF, &lv_font_montserrat_12); - lv_obj_set_width(msg_input_lbl_, 260); - lv_label_set_long_mode(msg_input_lbl_, LV_LABEL_LONG_CLIP); - - make_label(input_overlay_, "OK:send ESC:cancel", 0, 30, 0x888888, &lv_font_montserrat_10); - } - - // ==================== close input overlay ==================== - void close_input_overlay() - { - if (input_overlay_) { - lv_obj_del(input_overlay_); - input_overlay_ = nullptr; - } - msg_input_lbl_ = nullptr; - view_state_ = ViewState::MAIN; - } - - // ==================== update input display ==================== - void update_input_display() - { - if (!msg_input_lbl_) return; - std::string display = msg_input_buf_ + "_"; - lv_label_set_text(msg_input_lbl_, display.c_str()); - } - - // ==================== simulate refresh ==================== - void do_refresh() - { - // Simulate scanning for neighbors with random data - neighbors_.clear(); - int n = rand() % 4; // 0-3 neighbors - for (int i = 0; i < n; ++i) { - MeshNeighbor nb; - char id[16]; - snprintf(id, sizeof(id), "0x%04X", (uint32_t)(rand() & 0xFFFF)); - nb.node_id = id; - nb.rssi = -(40 + rand() % 60); // -40 to -99 dBm - nb.hops = 1 + rand() % 3; - char ts[16]; - get_time_str(ts, sizeof(ts)); - nb.last_seen = ts; - neighbors_.push_back(nb); - } - build_neighbor_list(); - - // Add a system message - if (n > 0) { - char buf[64]; - snprintf(buf, sizeof(buf), "Scan: %d node(s) found", n); - add_message("SYS", buf); - } else { - add_message("SYS", "Scan: no nodes nearby"); - } - } - - // ==================== heartbeat timer ==================== - static void heartbeat_timer_cb(lv_timer_t *timer) - { - UIMeshPage *self = static_cast(lv_timer_get_user_data(timer)); - if (self) self->on_heartbeat(); - } - - void on_heartbeat() - { - heartbeat_count_++; - - // Every other heartbeat, simulate a received message or status - if (heartbeat_count_ % 2 == 0 && !neighbors_.empty()) { - int idx = rand() % (int)neighbors_.size(); - const char *msgs[] = { - "PING", - "ACK", - "HELLO", - "STATUS:OK", - "HEARTBEAT" - }; - int mi = rand() % 5; - add_message(neighbors_[idx].node_id.c_str(), msgs[mi]); - } - - // Periodically update RSSI of neighbors - for (auto &nb : neighbors_) { - nb.rssi += (rand() % 7) - 3; // fluctuate +/- 3 - if (nb.rssi > -30) nb.rssi = -30; - if (nb.rssi < -100) nb.rssi = -100; - } - if (!neighbors_.empty()) - build_neighbor_list(); - } - - // ==================== event binding ==================== - void event_handler_init() - { - lv_obj_add_event_cb(root_screen_, UIMeshPage::static_lvgl_handler, LV_EVENT_ALL, this); - } - static void static_lvgl_handler(lv_event_t *e) - { - UIMeshPage *self = static_cast(lv_event_get_user_data(e)); - if (self) self->event_handler(e); - } - void event_handler(lv_event_t *e) - { - if (launcher_ui::events::is_key_released(e)) - { - uint32_t key = launcher_ui::events::keyboard_key(e); - switch (view_state_) - { - case ViewState::MAIN: handle_main_key(key); break; - case ViewState::INPUT: handle_input_key(key, launcher_ui::events::keyboard_item(e)); break; - } - } - } - - // ==================== main view keys ==================== - void handle_main_key(uint32_t key) - { - switch (key) - { - case KEY_S: - show_input_overlay(); - break; + PageTimerLifecycle heartbeat_timer_; + bool heartbeat_enabled_ = true; - case KEY_R: - do_refresh(); - break; - - case KEY_UP: - if (msg_area_) - lv_obj_scroll_by(msg_area_, 0, 20, LV_ANIM_ON); - break; - - case KEY_DOWN: - if (msg_area_) - lv_obj_scroll_by(msg_area_, 0, -20, LV_ANIM_ON); - break; - - case KEY_ESC: - if (heartbeat_timer_) { - lv_timer_delete(heartbeat_timer_); - heartbeat_timer_ = nullptr; - } - if (navigate_home) navigate_home(); - break; - - default: - break; - } - } - - // ==================== input overlay keys ==================== - void handle_input_key(uint32_t key, const struct key_item *elm) - { - if (key == KEY_ESC) { - close_input_overlay(); - return; - } - - if (key == KEY_ENTER) { - if (!msg_input_buf_.empty()) { - add_message("ME", msg_input_buf_.c_str()); - } - close_input_overlay(); - return; - } - - if (key == KEY_BACKSPACE) { - if (!msg_input_buf_.empty()) - msg_input_buf_.pop_back(); - update_input_display(); - return; - } - - if (elm && elm->utf8[0] && (unsigned char)elm->utf8[0] >= 0x20) { - if (msg_input_buf_.size() + strlen(elm->utf8) <= 40) { - msg_input_buf_ += elm->utf8; - update_input_display(); - } - return; - } - - char ch = keycode_to_char(key); - if (ch) { - if (msg_input_buf_.size() < 40) { - msg_input_buf_ += ch; - update_input_display(); - } - } - } +private: + static lv_obj_t *make_label(lv_obj_t *parent, + const char *text, + int x, + int y, + uint32_t color = 0xE6EDF3, + const lv_font_t *font = &lv_font_montserrat_12); + static std::string current_time(); + + void generate_node_id(); + void create_ui(); + bool ui_ready() const; + void build_neighbor_list(); + void build_message_list(); + void add_message(const char *sender, const char *text); + + void show_input_overlay(); + void close_input_overlay(); + void update_input_display(); + void do_refresh(); + + static void heartbeat_timer_cb(lv_timer_t *timer) noexcept; + void on_heartbeat(); + + void event_handler_init(); + void detach_ui_callbacks(); + static void owned_obj_delete_cb(lv_event_t *event) noexcept; + static void static_lvgl_handler(lv_event_t *e) noexcept; + void event_handler(lv_event_t *e); + void handle_main_key(uint32_t key); + void handle_input_key(uint32_t key, const key_item *item); }; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_mesh_view.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_mesh_view.cpp new file mode 100644 index 00000000..f3302c4b --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_mesh_view.cpp @@ -0,0 +1,217 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_mesh.hpp" + +#include + +lv_obj_t *UIMeshPage::make_label(lv_obj_t *parent, + const char *text, + int x, + int y, + uint32_t color, + const lv_font_t *font) +{ + if (!parent) return nullptr; + lv_obj_t *label = lv_label_create(parent); + if (!label) return nullptr; + lv_label_set_text(label, text ? text : ""); + lv_obj_set_pos(label, x, y); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(label, font, LV_PART_MAIN | LV_STATE_DEFAULT); + return label; +} + +void UIMeshPage::create_ui() +{ + if (!ui_APP_Container) return; + lv_obj_t *bg = lv_obj_create(ui_APP_Container); + if (!bg) return; + lv_obj_add_event_cb(bg, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(bg, 320, 150); + lv_obj_set_pos(bg, 0, 0); + lv_obj_set_style_radius(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(bg, lv_color_hex(0x0D1117), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); + ui_obj_["bg"] = bg; + + lv_obj_t *title_bar = lv_obj_create(bg); + if (!title_bar) return; + lv_obj_set_size(title_bar, 320, 22); + lv_obj_set_pos(title_bar, 0, 0); + lv_obj_set_style_radius(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(title_bar, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(title_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(title_bar, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title = lv_label_create(title_bar); + if (title) { + lv_label_set_text(title, LV_SYMBOL_WIFI " LoRa MESH"); + lv_obj_set_align(title, LV_ALIGN_LEFT_MID); + lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_t *title_hint = lv_label_create(title_bar); + if (title_hint) { + lv_label_set_text(title_hint, "S:Send R:Refresh ESC:Back"); + lv_obj_set_align(title_hint, LV_ALIGN_RIGHT_MID); + lv_obj_set_x(title_hint, -4); + lv_obj_set_style_text_color(title_hint, lv_color_hex(0x7EA8D8), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(title_hint, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_t *left_panel = lv_obj_create(bg); + if (!left_panel) return; + lv_obj_set_size(left_panel, 158, 116); + lv_obj_set_pos(left_panel, 2, 24); + lv_obj_set_style_radius(left_panel, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(left_panel, lv_color_hex(0x161B22), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(left_panel, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(left_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(left_panel, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(left_panel, LV_OBJ_FLAG_SCROLLABLE); + ui_obj_["left_panel"] = left_panel; + + char status_buf[64]; + std::snprintf(status_buf, sizeof(status_buf), "LoRa: %s", + model_.lora_detected() ? "Ready" : "Not Detected"); + status_lbl_ = make_label(left_panel, + status_buf, + 0, + 0, + model_.lora_detected() ? 0x2ECC71 : 0xE74C3C, + &lv_font_montserrat_10); + if (status_lbl_) + lv_obj_add_event_cb(status_lbl_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + + char node_buf[64]; + std::snprintf(node_buf, sizeof(node_buf), "Node: %s", model_.node_id().c_str()); + make_label(left_panel, node_buf, 0, 13, 0x58A6FF, &lv_font_montserrat_10); + + char frequency_buf[64]; + std::snprintf(frequency_buf, sizeof(frequency_buf), "CH:%d %dMHz", + model_.channel(), model_.frequency_mhz()); + make_label(left_panel, frequency_buf, 0, 26, 0x888888, &lv_font_montserrat_10); + + lv_obj_t *separator = lv_obj_create(left_panel); + if (separator) { + lv_obj_set_size(separator, 146, 1); + lv_obj_set_pos(separator, 0, 39); + lv_obj_set_style_bg_color(separator, lv_color_hex(0x21262D), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(separator, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(separator, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(separator, LV_OBJ_FLAG_SCROLLABLE); + } + + make_label(left_panel, "Neighbors:", 0, 43, 0x7EA8D8, &lv_font_montserrat_10); + neighbor_area_ = lv_obj_create(left_panel); + if (!neighbor_area_) return; + lv_obj_add_event_cb(neighbor_area_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(neighbor_area_, 148, 56); + lv_obj_set_pos(neighbor_area_, 0, 55); + lv_obj_set_style_bg_opa(neighbor_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(neighbor_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(neighbor_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(neighbor_area_, LV_OBJ_FLAG_SCROLLABLE); + build_neighbor_list(); + + lv_obj_t *right_panel = lv_obj_create(bg); + if (!right_panel) return; + lv_obj_set_size(right_panel, 156, 116); + lv_obj_set_pos(right_panel, 162, 24); + lv_obj_set_style_radius(right_panel, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(right_panel, lv_color_hex(0x161B22), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(right_panel, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(right_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(right_panel, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(right_panel, LV_OBJ_FLAG_SCROLLABLE); + ui_obj_["right_panel"] = right_panel; + + make_label(right_panel, "Messages:", 0, 0, 0x7EA8D8, &lv_font_montserrat_10); + msg_area_ = lv_obj_create(right_panel); + if (!msg_area_) return; + lv_obj_add_event_cb(msg_area_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(msg_area_, 146, 96); + lv_obj_set_pos(msg_area_, 0, 12); + lv_obj_set_style_bg_opa(msg_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(msg_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(msg_area_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_scroll_dir(msg_area_, LV_DIR_VER); + lv_obj_add_flag(msg_area_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(msg_area_, LV_SCROLLBAR_MODE_AUTO); + lv_obj_set_style_width(msg_area_, 2, LV_PART_SCROLLBAR | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(msg_area_, lv_color_hex(0x1F6FEB), LV_PART_SCROLLBAR | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(msg_area_, 200, LV_PART_SCROLLBAR | LV_STATE_DEFAULT); + build_message_list(); + + hint_lbl_ = make_label(bg, "S:Send R:Refresh ESC:Back", 6, 142, 0x555555, &lv_font_montserrat_10); + if (hint_lbl_) + lv_obj_add_event_cb(hint_lbl_, owned_obj_delete_cb, LV_EVENT_DELETE, this); +} + +void UIMeshPage::build_neighbor_list() +{ + if (!neighbor_area_) return; + lv_obj_clean(neighbor_area_); + + const auto &neighbors = model_.neighbors(); + if (neighbors.empty()) { + make_label(neighbor_area_, "No neighbors found", 0, 4, 0x555555, &lv_font_montserrat_10); + return; + } + + int y = 0; + for (size_t i = 0; i < neighbors.size() && i < 4; ++i) { + char text[64]; + std::snprintf(text, + sizeof(text), + "%s %ddBm %dhop", + neighbors[i].node_id.c_str(), + neighbors[i].rssi, + neighbors[i].hops); + make_label(neighbor_area_, text, 0, y, 0xCCCCCC, &lv_font_montserrat_10); + y += 13; + } +} + +void UIMeshPage::build_message_list() +{ + if (!msg_area_) return; + lv_obj_clean(msg_area_); + + const auto &messages = model_.messages(); + if (messages.empty()) { + make_label(msg_area_, "MESH messages will", 0, 4, 0x555555, &lv_font_montserrat_10); + make_label(msg_area_, "appear here", 0, 17, 0x555555, &lv_font_montserrat_10); + return; + } + + int y = 0; + const size_t start = messages.size() > 7 ? messages.size() - 7 : 0; + for (size_t i = start; i < messages.size(); ++i) { + char header[64]; + std::snprintf(header, + sizeof(header), + "[%s] %s:", + messages[i].timestamp.c_str(), + messages[i].sender.c_str()); + const uint32_t color = messages[i].sender == "ME" ? 0x58A6FF : 0x2ECC71; + make_label(msg_area_, header, 0, y, color, &lv_font_montserrat_10); + y += 12; + + std::string text = messages[i].text; + if (text.size() > 22) text = text.substr(0, 22) + ".."; + make_label(msg_area_, text.c_str(), 4, y, 0xE6EDF3, &lv_font_montserrat_10); + y += 14; + } +} + +void UIMeshPage::add_message(const char *sender, const char *text) +{ + model_.add_message(current_time(), sender, text); + build_message_list(); +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_setup.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_setup.cpp new file mode 100644 index 00000000..80bbdc3c --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_setup.cpp @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_setup.hpp" +#include "../model/setup_value_policy.hpp" + +UISetupPage::UISetupPage() + : AppPage() +{ + set_page_title("SETTING"); + cache_image_paths(); + menu_init(); + create_ui(); + if (root_screen_ && ui_obj_["list_cont"]) event_handler_init(); +} + +UISetupPage::~UISetupPage() noexcept +{ + if (!setup_teardown_step([this] { on_root_deleted(); })) + lifecycle_.root_deleted(); + setup_teardown_step([this] { + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UISetupPage::static_handler, this); + }); +} + +void UISetupPage::play_enter() +{ + cp0_signal_audio_api({"SystemSoundPlay", "2"}, nullptr); +} + +void UISetupPage::play_back() +{ + play_audio_file(snd_back_); +} + +int UISetupPage::config_get_int(const char *key, int default_val) +{ + if (!key || !*key) return default_val; + int value = default_val; + cp0_signal_config_api({"GetInt", key ? std::string(key) : std::string(), + std::to_string(default_val)}, + [&](int code, std::string data) { + int parsed = 0; + if (code == 0 && + setup_values::parse_nonnegative_int(data, parsed)) + value = parsed; + }); + return value; +} + +bool UISetupPage::config_set_int(const char *key, int val) +{ + if (!key || !*key) return false; + bool succeeded = false; + cp0_signal_config_api({"SetInt", std::string(key), std::to_string(val)}, + [&](int code, std::string) { succeeded = code == 0; }); + return succeeded; +} + +bool UISetupPage::config_save() +{ + bool succeeded = false; + cp0_signal_config_api({"Save"}, + [&](int code, std::string) { succeeded = code == 0; }); + return succeeded; +} + +bool UISetupPage::gpio_set(const char *name, int val) +{ + if (!name || !*name || (val != 0 && val != 1)) return false; + bool succeeded = false; + cp0_signal_settings_api({"GpioSet", std::string(name), std::to_string(val)}, + [&](int code, std::string) { succeeded = code == 0; }); + return succeeded; +} + +int UISetupPage::gpio_get(const char *name) +{ + if (!name || !*name) return -1; + int value = -1; + cp0_signal_settings_api({"GpioGet", std::string(name)}, + [&](int code, std::string data) { + int parsed = -1; + if (code == 0 && setup_values::parse_nonnegative_int(data, parsed) && + (parsed == 0 || parsed == 1)) + value = parsed; + }); + return value; +} + +int UISetupPage::audio_volume_read() +{ + int volume = -1; + cp0_signal_audio_api({"VolumeRead"}, [&](int code, std::string data) { + int parsed = -1; + if (code == 0 && setup_values::parse_nonnegative_int(data, parsed) && + setup_values::volume_value_valid(parsed)) + volume = parsed; + }); + return volume; +} + +int UISetupPage::audio_volume_write(int val) +{ + if (!setup_values::volume_value_valid(val)) return -1; + int volume = -1; + cp0_signal_audio_api({"VolumeWrite", std::to_string(val)}, + [&](int code, std::string data) { + int parsed = -1; + if (code == 0 && setup_values::parse_nonnegative_int(data, parsed) && + setup_values::volume_value_valid(parsed)) + volume = parsed; + }); + return volume; +} + +void UISetupPage::play_audio_file(const std::string &path) +{ + if (!path.empty()) cp0_signal_audio_api({"PlayFile", path}, nullptr); +} + +void UISetupPage::cache_image_paths() +{ + img_arrow_up_ = launcher_platform::path("setting_red_up.png"); + img_arrow_down_ = launcher_platform::path("setting_red_down.png"); + img_right_arrow_ = launcher_platform::path("setting_right_arrow.png"); + img_ok_ = launcher_platform::path("setting_ok.png"); + img_cross_ = launcher_platform::path("setting_cross.png"); + snd_enter_ = launcher_platform::path("key_enter.wav"); + snd_back_ = launcher_platform::path("key_back.wav"); +} + +void UISetupPage::menu_init() +{ + setting::build_menu(*this); +} + +void UISetupPage::stop_power_timer() +{ + if (!pwr_timer_) return; + lv_timer_delete(pwr_timer_); + pwr_timer_ = nullptr; +} + +int UISetupPage::find_menu(const char *label) +{ + for (size_t i = 0; i < menu_items_.size(); ++i) { + if (menu_items_[i].label == label) return static_cast(i); + } + return -1; +} + +void UISetupPage::enter_confirm_action(const char *title, std::function action) +{ + confirm_controller_.begin(std::move(action)); + model_.enter_value(title ? title : "", {"Yes", "No"}, 1); + transition_enter_level(); +} + +void UISetupPage::apply_value_selection() +{ + if (val_title_ == "Brightness" || val_title_ == "DarkTime") + screen_.apply_value(*this); + else if (val_title_ == "Volume") + speaker_.apply_value(*this); + else if (val_title_ == "Resolution") + camera_.apply_value(*this); + else if (val_title_ == "BQ Calib") + info_.apply_bq_calibrate(*this); + else if (val_title_ == "Reboot?" || val_title_ == "Shutdown?" || val_title_ == "Run Setup?") { + confirm_controller_.resolve(val_sel_idx_ == 0); + } else if (confirm_controller_.active()) { + confirm_controller_.resolve(val_sel_idx_ == 0); + } else if (val_title_ == "Year" || val_title_ == "Month" || val_title_ == "Day" || + val_title_ == "Hour" || val_title_ == "Minute" || val_title_ == "Second") { + rtc_.apply_value(*this); + } +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_setup.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_setup.hpp index ed794e30..c475cf81 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_setup.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_setup.hpp @@ -5,8 +5,17 @@ */ #pragma once -#include "setting/rtc_ntp_state.hpp" -#include "setting/adb_state.hpp" +#include "setting/basic.hpp" +#include "../model/rtc_state_model.hpp" +#include "../model/adb_state.hpp" +#include "setting/bluetooth.hpp" +#include "setting/developer.hpp" +#include "setting/info.hpp" +#include "setting/menu_types.hpp" +#include "setting/rtc.hpp" +#include "setting/sound_card.hpp" +#include "setting/system.hpp" +#include "setting/wifi.hpp" // Keep this page platform-neutral: system and hardware operations go through // cp0_lvgl's cp0_* service interfaces so the SDL build can compile the page. #define _STRINGIFY(x) #x @@ -15,7 +24,8 @@ #define LAUNCHER_GIT_COMMIT_RAW unknown #endif #define LAUNCHER_GIT_COMMIT STRINGIFY(LAUNCHER_GIT_COMMIT_RAW) -#include "../ui_app_page.hpp" +#include "../launcher_ui_app_page.hpp" +#include "../model/setup_page_model.hpp" #include #include #include @@ -39,361 +49,7 @@ class UISetupPage; - -namespace setting { - -struct SubItem { - std::string label; - bool is_toggle; - bool toggle_state; - std::function action; -}; - -struct MenuItem { - std::string label; - std::vector sub_items; - std::function on_enter; - std::function custom_key_handler; -}; - -class Launcher { -public: - static void append(UISetupPage &p, std::vector &menu); - static void save_app_toggle(UISetupPage &page, const std::string &config_key); -}; - -class Boot { -public: - static void append(UISetupPage &p, std::vector &menu); - static void factory_reset(); - static void rearm_oobe_and_reboot(); -}; - -class Screen { -public: - static void append(UISetupPage &p, std::vector &menu); - void enter_brightness_adjust(UISetupPage &page); - void enter_darktime_adjust(UISetupPage &page); - void apply_value(UISetupPage &page); - static int backlight_read(); - static int backlight_max(); -private: - int bright_val_ = 75; -}; - -class WiFi { -public: - static void append(UISetupPage &p, std::vector &menu); - void enter_scan(UISetupPage &page); - void build_list(UISetupPage &page); - void handle_list_key(UISetupPage &page, uint32_t key); - void refresh_radio(UISetupPage &page); - void toggle_enable(UISetupPage &page); - void try_connect(UISetupPage &page, int idx); - void show_connecting(UISetupPage &page, const char *ssid); - void show_error(UISetupPage &page, const char *msg); - void forget_selected(UISetupPage &page); - bool has_saved_profile(const char *ssid); - void show_pw_input(UISetupPage &page); - void handle_pw_key(UISetupPage &page, uint32_t key); - void pw_update_display(); - void do_scan(); -private: - cp0_wifi_ap_t aps_[CP0_WIFI_AP_MAX]; - int ap_count_ = 0; - int list_sel_ = 0; - std::string pw_ssid_; - std::string pw_buf_; - lv_obj_t *pw_input_lbl_ = nullptr; - lv_obj_t *pw_hint_lbl_ = nullptr; -}; - -class Speaker { -public: - static void append(UISetupPage &p, std::vector &menu); - void enter_volume_adjust(UISetupPage &page); - void apply_value(UISetupPage &page); -private: - int vol_val_ = 39; -}; - -class Camera { -public: - static void append(UISetupPage &p, std::vector &menu); - void enter_resolution(UISetupPage &page); - void apply_value(UISetupPage &page); -}; - -class Info { -public: - static void append(UISetupPage &p, std::vector &menu); - void refresh_values(UISetupPage &page); - void start_timer(UISetupPage &page); - void stop_timer(); - void enter_bq_calibrate(UISetupPage &page); - void apply_bq_calibrate(UISetupPage &page); - void reset_visible_labels(); - void track_visible_label(int index, lv_obj_t *label, bool focused, const std::string &text); - void refresh_visible_labels(UISetupPage &page); -private: - lv_timer_t *timer_ = nullptr; - lv_obj_t *sub_labels_[4] = {}; - bool sub_label_focused_[4] = {}; - std::string visible_text_[4]; -}; - -class Developer { -public: - Developer(); - ~Developer(); - static void append(UISetupPage &p, std::vector &menu); - void toggle_adb(UISetupPage &page); - bool refresh_adb_status(UISetupPage &page); - void enter_usb_guide(UISetupPage &page, bool enabling); - void build_usb_guide_view(UISetupPage &page); - void stop_usb_guide_anims(); - void handle_usb_guide_key(UISetupPage &page, uint32_t key); - void handle_status_key(UISetupPage &page, uint32_t key); - void enter_pair_view(UISetupPage &page, bool enable_after_pair = false); - void enter_revoke_view(UISetupPage &page); - void build_authorizations_view(UISetupPage &page); - void handle_authorizations_key(UISetupPage &page, uint32_t key); - void handle_pair_key(UISetupPage &page, uint32_t key); - void clear_authorizations(UISetupPage &page); - bool status_active() const { return status_overlay_ != nullptr; } -private: - struct AsyncState { bool alive = true; uint64_t request_id = 0; }; - enum class Modal { NONE, BUSY, ERROR }; - static lv_obj_t *guide_chip(lv_obj_t *parent, int x, int y, int w, int h, - uint32_t bg, uint32_t border, int radius, int border_w); - static lv_obj_t *guide_label(lv_obj_t *parent, int x, int y, const char *txt, - uint32_t color, const lv_font_t *font); - void show_status(const char *title, const char *detail, Modal modal); - void close_status(); - void update_toggle(UISetupPage &page, bool enabled, bool save); - void show_result_error(cp0_sudo_result_t result, int exit_code); - void submit_pairing(UISetupPage &page); - void run_admin_action(UISetupPage &page, std::list args, - const char *title, const char *detail, bool enable_after); - std::shared_ptr async_state_; - Modal modal_ = Modal::NONE; - lv_obj_t *status_overlay_ = nullptr; - bool usb_guide_enabling_ = true; - lv_obj_t *usb_guide_knob_ = nullptr; - std::string pair_key_; - lv_obj_t *pair_input_label_ = nullptr; - lv_obj_t *pair_hint_label_ = nullptr; - bool enable_after_pair_ = false; - std::vector authorizations_; - int authorization_selected_ = 0; -}; - -class RTC { -public: - RTC(); - ~RTC(); - static void append(UISetupPage &p, std::vector &menu); - void refresh_values(UISetupPage &page); - void toggle_ntp(UISetupPage &page); - void enter_adjust(UISetupPage &page, int field); - void apply_value(UISetupPage &page); - void commit_to_hardware(UISetupPage &page); - void show_write_confirm(UISetupPage &page); - void close_write_confirm(); - void handle_write_confirm_key(UISetupPage &page, uint32_t key); - bool is_dirty() const { return dirty_; } - bool ntp_on() const { return ntp_on_; } - bool ntp_available() const { return ntp_available_; } - bool write_confirm_active() const { return confirm_overlay_ != nullptr; } - void clear_dirty() { dirty_ = false; } -private: - struct AsyncState { - bool alive = true; - uint64_t request_id = 0; - }; - enum class Modal { NONE, CONFIRM, BUSY, ERROR }; - static int days_in_month(int year, int month); - void show_status(const char *title, const char *detail, Modal modal); - void finish_request(); - void show_result_error(cp0_sudo_result_t result, int exit_code, const char *operation); - void update_labels(UISetupPage &page); - void update_write_confirm_buttons(); - int values_[6] = {2026, 1, 1, 0, 0, 0}; - int field_ = 0; - bool ntp_on_ = true; - bool dirty_ = false; - bool ntp_available_ = true; - bool ntp_previous_ = true; - Modal modal_ = Modal::NONE; - std::shared_ptr async_state_; - int confirm_sel_ = 1; - lv_obj_t *confirm_overlay_ = nullptr; - lv_obj_t *confirm_yes_lbl_ = nullptr; - lv_obj_t *confirm_no_lbl_ = nullptr; -}; - -class About { -public: - static void append(UISetupPage &p, std::vector &menu); - static void refresh_info(UISetupPage &page); -}; - -class Help { -public: - static void append(UISetupPage &p, std::vector &menu); - static void enter_page(UISetupPage &page); -}; - -class ExtPort { -public: - static void append(UISetupPage &p, std::vector &menu); -}; - -class Ethernet { -public: - static void append(UISetupPage &p, std::vector &menu); - static void refresh_info(UISetupPage &page); -}; - -class Account { -public: - static void append(UISetupPage &p, std::vector &menu); - static void refresh_info(UISetupPage &page); -}; - -class Update { -public: - static void append(UISetupPage &p, std::vector &menu); - static void refresh_version_info(UISetupPage &page); - static void check_system_update(); - static void update_launcher(); -}; - -class Bluetooth { - struct AsyncState { bool alive = true; uint64_t request_id = 0; }; - struct ListRow { - int device_index; - const char *title; - bool is_header; - }; - enum class ListMode { Managed, Scan }; - -public: - Bluetooth(); - ~Bluetooth(); - static void append(UISetupPage &p, std::vector &menu); - void enter_devices(UISetupPage &page); - void enter_alias(UISetupPage &page); - void build_alias_view(UISetupPage &page); - void handle_alias_key(UISetupPage &page, uint32_t key); - void enter_scan(UISetupPage &page); - void build_list(UISetupPage &page); - void handle_list_key(UISetupPage &page, uint32_t key); - void refresh_status(UISetupPage &page); - void toggle_power(UISetupPage &page); - void toggle_named_only(UISetupPage &page); - void toggle_discoverable(UISetupPage &page); - void start_scan_timer(UISetupPage &page); - void stop_scan_timer(); - void refresh_devices(); - void do_scan(UISetupPage &page); - -private: - static bool alias_char_allowed(unsigned char ch); - std::string alias_sanitized() const; - void alias_update_display(); - void rebuild_rows(); - bool should_hide_device(const cp0_bt_device_t &dev) const; - static std::string normalized_mac_text(const char *text); - int selected_device_index() const; - void select_next_device(int direction); - void show_action(UISetupPage &page, const char *msg, uint32_t color = 0x58A6FF); - void activate_selected(UISetupPage &page); - void remove_selected(UISetupPage &page); - static void copy_string(char *dst, size_t dst_size, const std::string &src); - static std::vector split_char(const std::string &line, char delimiter); - static bool decode_status(const std::string &data, cp0_bt_status_t &st); - static int decode_devices(const std::string &data, cp0_bt_device_t *out, int max_devices); - static int api_int(std::list args, int default_value = -1); - static cp0_bt_status_t get_status(); - static int set_power(int on); - static int set_alias(const std::string &alias); - static int set_discoverable(int on); - static int device_command(const char *cmd, const char *address); - static int device_list(const char *cmd, cp0_bt_device_t *out, int max_devices); - static int rfkill_blocked(); - - std::shared_ptr async_state_; - cp0_bt_device_t devices_[CP0_BT_DEVICE_MAX]; - int device_count_ = 0; - int list_sel_ = 0; - std::vector rows_; - ListMode list_mode_ = ListMode::Managed; - lv_timer_t *scan_timer_ = nullptr; - bool discovery_active_ = false; - bool named_only_ = true; - bool action_busy_ = false; - std::string alias_ = "CardputerZero"; - bool discoverable_ = false; - std::string alias_input_; - lv_obj_t *alias_input_lbl_ = nullptr; - lv_obj_t *alias_hint_lbl_ = nullptr; -}; - -class SoundCard { - struct Card { - int index = 0; - std::string name; - }; - - struct Control { - std::string name; - std::string type; - int min_val = 0; - int max_val = 0; - int step = 1; - std::string current_str; - int current_val = 0; - }; - -public: - static void append(UISetupPage &p, std::vector &menu); - void enter_cards(UISetupPage &page); - void enter_controls(UISetupPage &page); - void enter_detail(UISetupPage &page); - void build_cards_view(UISetupPage &page); - void build_controls_view(UISetupPage &page); - void build_detail_view(UISetupPage &page); - void handle_cards_key(UISetupPage &page, uint32_t key); - void handle_controls_key(UISetupPage &page, uint32_t key); - void handle_detail_key(UISetupPage &page, uint32_t key); - -private: - static std::string trim(const std::string &s); - static bool parse_limits(const std::string &line, int &mn, int &mx); - static int parse_current_val(const std::string &line); - static std::string extract_value_str(const std::string &line); - static bool is_value_line(const std::string &tl); - void input_update_display(); - void cursor_stop(); - void apply_value(UISetupPage &page); - - std::vector cards_; - std::vector controls_; - int card_sel_ = 0; - int ctrl_sel_ = 0; - int card_idx_ = -1; - Control detail_; - std::string input_buf_; - lv_obj_t *input_lbl_ = nullptr; - lv_obj_t *hint_lbl_ = nullptr; - lv_timer_t *cursor_timer_ = nullptr; - bool cursor_vis_ = true; -}; - -void build_menu(UISetupPage &page); - -} // namespace setting +namespace setting { class SetupPageAccess; } // ============================================================ // System settings screen UISetupPage (Carousel Design) @@ -405,50 +61,18 @@ void build_menu(UISetupPage &page); class UISetupPage : public AppPage { - enum class ViewState { MAIN, SUB, VALUE_SELECT, WIFI_LIST, WIFI_PW, BT_LIST, BT_ALIAS, - SOUNDCARD_CARDS, SOUNDCARD_CONTROLS, SOUNDCARD_DETAIL, - USB_GUIDE, ADB_PAIR, ADB_AUTHORIZATIONS }; + using ViewState = SetupViewState; using SubItem = setting::SubItem; using MenuItem = setting::MenuItem; public: - UISetupPage() : AppPage() - { - set_page_title("SETTING"); - cache_image_paths(); - menu_init(); - create_ui(); - event_handler_init(); - } - ~UISetupPage() - { - stop_power_timer(); - info_.stop_timer(); - bluetooth_.stop_scan_timer(); - rtc_.close_write_confirm(); - } + UISetupPage(); + ~UISetupPage() noexcept; private: std::vector menu_items_; - friend class setting::Launcher; - friend class setting::Boot; - friend class setting::Screen; - friend class setting::WiFi; - friend class setting::Speaker; - friend class setting::Camera; - friend class setting::Info; - friend class setting::Developer; - friend class setting::RTC; - friend class setting::About; - friend class setting::Help; - friend class setting::ExtPort; - friend class setting::Ethernet; - friend class setting::Account; - friend class setting::Update; - friend class setting::Bluetooth; - friend class setting::SoundCard; - friend void setting::build_menu(UISetupPage &page); + friend class setting::SetupPageAccess; setting::Screen screen_; setting::WiFi wifi_; @@ -460,9 +84,11 @@ class UISetupPage : public AppPage setting::Bluetooth bluetooth_; setting::SoundCard soundcard_; - int selected_idx_ = 2; - int sub_selected_idx_ = 0; - ViewState view_state_ = ViewState::MAIN; + SetupPageModel model_; + SetupPageLifecycle lifecycle_; + int &selected_idx_ = model_.selected_index; + int &sub_selected_idx_ = model_.sub_selected_index; + ViewState &view_state_ = model_.view; std::unordered_map ui_obj_; // Image paths @@ -475,9 +101,9 @@ class UISetupPage : public AppPage struct key_item *cur_elm_ = nullptr; // Value select (3rd level) - int val_sel_idx_ = 0; - std::vector val_options_; - std::string val_title_; + int &val_sel_idx_ = model_.value_selected_index; + std::vector &val_options_ = model_.value_options; + std::string &val_title_ = model_.value_title; // Power timer lv_timer_t *pwr_timer_ = nullptr; @@ -492,170 +118,45 @@ class UISetupPage : public AppPage std::string snd_enter_; std::string snd_back_; - void play_enter() { cp0_signal_audio_api({"SystemSoundPlay", "2"}, nullptr); } - void play_back() { play_audio_file(snd_back_); } - - static int config_get_int(const char *key, int default_val) - { - int val = default_val; - cp0_signal_config_api({"GetInt", key ? std::string(key) : std::string(), std::to_string(default_val)}, - [&](int code, std::string data) { - if (code == 0) val = std::atoi(data.c_str()); - }); - return val; - } - - static void config_set_int(const char *key, int val) - { - cp0_signal_config_api({"SetInt", key ? std::string(key) : std::string(), std::to_string(val)}, nullptr); - } - - static void config_save() - { - cp0_signal_config_api({"Save"}, nullptr); - } - - static void gpio_set(const char *name, int val) - { - cp0_signal_settings_api({"GpioSet", name ? std::string(name) : std::string(), std::to_string(val)}, nullptr); - } - - static int audio_volume_read() - { - int volume = -1; - cp0_signal_audio_api({"VolumeRead"}, [&](int code, std::string data) { - if (code == 0) - volume = std::atoi(data.c_str()); - }); - return volume; - } - - static int audio_volume_write(int val) - { - int volume = -1; - cp0_signal_audio_api({"VolumeWrite", std::to_string(val)}, [&](int code, std::string data) { - if (code == 0) - volume = std::atoi(data.c_str()); - }); - return volume; - } - - void play_audio_file(const std::string &path) - { - if (!path.empty()) cp0_signal_audio_api({"PlayFile", path}, nullptr); - } - - void cache_image_paths() - { - img_arrow_up_ = launcher_platform::path("setting_red_up.png"); - img_arrow_down_ = launcher_platform::path("setting_red_down.png"); - img_right_arrow_ = launcher_platform::path("setting_right_arrow.png"); - img_ok_ = launcher_platform::path("setting_ok.png"); - img_cross_ = launcher_platform::path("setting_cross.png"); - snd_enter_ = launcher_platform::path("key_enter.wav"); - snd_back_ = launcher_platform::path("key_back.wav"); - } + void play_enter(); + void play_back(); + static int config_get_int(const char *key, int default_val); + static bool config_set_int(const char *key, int val); + static bool config_save(); + static bool gpio_set(const char *name, int val); + static int gpio_get(const char *name); + static int audio_volume_read(); + static int audio_volume_write(int val); + void play_audio_file(const std::string &path); + void cache_image_paths(); // ==================== Menu init ==================== - void menu_init() - { - setting::build_menu(*this); - } + void menu_init(); - int find_menu(const char *label) - { - for (size_t i = 0; i < menu_items_.size(); ++i) - if (menu_items_[i].label == label) return (int)i; - return -1; - } + int find_menu(const char *label); // ==================== Confirm action (Reboot/Shutdown) ==================== - std::function confirm_action_; + SetupConfirmController confirm_controller_; - void enter_confirm_action(const char *title, std::function action) - { - confirm_action_ = action; - val_title_ = title; - val_options_ = {"Yes", "No"}; - val_sel_idx_ = 1; // default to No - view_state_ = ViewState::VALUE_SELECT; - transition_enter_level(); - } - - void apply_value_selection() - { - if (val_title_ == "Brightness") { - screen_.apply_value(*this); - } else if (val_title_ == "DarkTime") { - screen_.apply_value(*this); - } else if (val_title_ == "Volume") { - speaker_.apply_value(*this); - } else if (val_title_ == "Resolution") { - camera_.apply_value(*this); - } else if (val_title_ == "BQ Calib") { - info_.apply_bq_calibrate(*this); - } else if (val_title_ == "Reboot?" || val_title_ == "Shutdown?" || val_title_ == "Run Setup?") { - if (val_sel_idx_ == 0 && confirm_action_) confirm_action_(); - confirm_action_ = nullptr; - } else if (confirm_action_) { - auto action = std::move(confirm_action_); - confirm_action_ = nullptr; - if (val_sel_idx_ == 0) action(); - } else if (val_title_ == "Year" || val_title_ == "Month" || val_title_ == "Day" || - val_title_ == "Hour" || val_title_ == "Minute" || val_title_ == "Second") { - rtc_.apply_value(*this); - } - } + void enter_confirm_action(const char *title, std::function action); + void apply_value_selection(); // ==================== Power timer ==================== - void stop_power_timer() - { - if (pwr_timer_) { lv_timer_delete(pwr_timer_); pwr_timer_ = nullptr; } - } + void stop_power_timer(); // ==================== UI ==================== - void create_ui() - { - lv_obj_t *bg = lv_obj_create(ui_APP_Container); - lv_obj_set_size(bg, SCREEN_W, SCREEN_H); - lv_obj_set_pos(bg, 0, 0); - lv_obj_set_style_radius(bg, 0, LV_PART_MAIN); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x000000), LV_PART_MAIN); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); - lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["bg"] = bg; - - // List container (full area — title is handled by system status bar) - lv_obj_t *list_cont = lv_obj_create(bg); - lv_obj_set_size(list_cont, SCREEN_W, LIST_H); - lv_obj_set_pos(list_cont, 0, 0); - lv_obj_set_style_radius(list_cont, 0, LV_PART_MAIN); - lv_obj_set_style_bg_opa(list_cont, 0, LV_PART_MAIN); - lv_obj_set_style_border_width(list_cont, 0, LV_PART_MAIN); - lv_obj_set_style_pad_all(list_cont, 0, LV_PART_MAIN); - lv_obj_clear_flag(list_cont, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["list_cont"] = list_cont; - - build_main_view(); - } + void create_ui(); // ==================== Animation config ==================== static constexpr int ANIM_TIME = 200; - bool is_animating_ = false; lv_obj_t *row_labels_[ROWS_VISIBLE] = {}; lv_obj_t *sel_bg_ = nullptr; lv_obj_t *hint_lbl_ = nullptr; lv_obj_t *arrow_up_obj_ = nullptr; lv_obj_t *arrow_down_obj_ = nullptr; - int row_h() { return LIST_H / ROWS_VISIBLE; } - int row_y(int vi) { - // Center row (ROW_CENTER) must be vertically centered in the 150px area - int center_y = (LIST_H - row_h()) / 2; - return center_y + (vi - ROW_CENTER) * row_h(); - } + int row_h() const; + int row_y(int vi) const; struct RowStyle { const lv_font_t *font; @@ -701,16 +202,7 @@ class UISetupPage : public AppPage static constexpr int SUB_ARROW_X = 100; static constexpr int SUB_CENTER_X = 160; - RowStyle style_for_slot(int vi) { - int dist = vi > ROW_CENTER ? vi - ROW_CENTER : ROW_CENTER - vi; - if (dist == 0) - return {launcher_fonts().get("Montserrat-Bold.ttf", 18, LV_FREETYPE_FONT_STYLE_BOLD), 0xFFFFFF, MENU_X, 255}; - if (dist == 1) - return {launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), 0xAAAAAA, MENU_X, 220}; - if (dist == 2) - return {launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD), 0x777777, MENU_X, 170}; - return {&lv_font_montserrat_12, 0x555555, MENU_X, 130}; - } + RowStyle style_for_slot(int vi); // ==================== Shared: create a styled carousel label ==================== // vi = visual slot (0..ROWS_VISIBLE-1), center_vi = which slot is "selected" @@ -718,62 +210,7 @@ class UISetupPage : public AppPage // text = label string, hide if empty // smaller = true for sub-menu columns (one font size smaller) lv_obj_t *create_carousel_label(lv_obj_t *parent, int vi, int center_vi, - const char *text, int center_x, bool smaller = false) - { - int dist = vi > center_vi ? vi - center_vi : center_vi - vi; - const lv_font_t *font; - uint32_t color; - int opa; - if (!smaller) { - if (dist == 0) { - font = launcher_fonts().get("Montserrat-Bold.ttf", 18, LV_FREETYPE_FONT_STYLE_BOLD); - color = 0xFFFFFF; opa = 255; - } else if (dist == 1) { - font = launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD); - color = 0xAAAAAA; opa = 220; - } else if (dist == 2) { - font = launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD); - color = 0x777777; opa = 170; - } else { - font = &lv_font_montserrat_12; - color = 0x555555; opa = 130; - } - } else { - // Smaller variant for sub-menu / right column - if (dist == 0) { - font = launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD); - color = 0xFFFFFF; opa = 255; - } else if (dist == 1) { - font = launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD); - color = 0xAAAAAA; opa = 220; - } else if (dist == 2) { - font = &lv_font_montserrat_12; - color = 0x777777; opa = 170; - } else { - font = &lv_font_montserrat_10; - color = 0x555555; opa = 130; - } - } - - lv_obj_t *lbl = lv_label_create(parent); - lv_label_set_text(lbl, text ? text : ""); - lv_obj_set_style_text_color(lbl, lv_color_hex(color), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, font, LV_PART_MAIN); - lv_obj_set_style_opa(lbl, opa, LV_PART_MAIN); - - lv_obj_update_layout(lbl); - int tw = lv_obj_get_width(lbl); - int lx = center_x - tw / 2; - if (lx < 4) lx = 4; - int font_h = lv_font_get_line_height(font); - int ly = row_y(vi) + (row_h() - font_h) / 2; - if (smaller) ly += 1; - lv_obj_set_pos(lbl, lx, ly); - - if (!text || !text[0]) - lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); - return lbl; - } + const char *text, int center_x, bool smaller = false); static constexpr int SUB_RIGHT_ARROW_X = SUB_ARROW_X; static constexpr int ARROW_SRC = 19; // setting_right_arrow.png is 19x19 @@ -783,759 +220,74 @@ class UISetupPage : public AppPage // column. It sits just left of the right text with a guaranteed gap, is // scaled down slightly, and is drawn *behind* the row text (but above the // highlight bar) so a wide left label can never be occluded by it. (plan A) - void place_blue_arrow(lv_obj_t *parent, lv_obj_t *left_lbl, int right_min_x) - { - if (!left_lbl || right_min_x <= 0) return; - lv_obj_update_layout(left_lbl); - - const int vis = ARROW_SRC * ARROW_SCALE / 256; // scaled width/height (square) - int left_right_edge = lv_obj_get_x(left_lbl) + lv_obj_get_width(left_lbl); - - static constexpr int SAFE_GAP = 4; - int arrow_x = right_min_x - SAFE_GAP - vis; - if (arrow_x < left_right_edge + 1) arrow_x = left_right_edge + 1; - - // Vertically center the (scaled) arrow within the focused row. - int arrow_y = row_y(ROW_CENTER) + (row_h() - vis) / 2; - - lv_obj_t *arrow = lv_img_create(parent); - lv_img_set_src(arrow, img_right_arrow_.c_str()); - lv_image_set_pivot(arrow, 0, 0); - lv_image_set_scale(arrow, ARROW_SCALE); - lv_obj_set_pos(arrow, arrow_x, arrow_y); - // Behind the row text, above the highlight bar (child index 0). - lv_obj_move_to_index(arrow, 1); - } - - void place_fixed_sub_arrow(lv_obj_t *parent) - { - static constexpr int ARROW_H = 19; - lv_obj_t *arrow = lv_img_create(parent); - lv_img_set_src(arrow, img_right_arrow_.c_str()); - lv_obj_set_pos(arrow, SUB_RIGHT_ARROW_X, row_y(ROW_CENTER) + (row_h() - ARROW_H) / 2); - } + void place_blue_arrow(lv_obj_t *parent, lv_obj_t *left_lbl, int right_min_x); + void place_fixed_sub_arrow(lv_obj_t *parent); // Convenience: create a main-menu label at slot vi - lv_obj_t *create_menu_label(lv_obj_t *parent, int vi, int item_idx, int count) - { - const char *text = (item_idx >= 0 && item_idx < count) - ? menu_items_[item_idx].label.c_str() : ""; - lv_obj_t *lbl = create_carousel_label(parent, vi, ROW_CENTER, text, MENU_X); - if (item_idx < 0 || item_idx >= count) - lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); - return lbl; - } + lv_obj_t *create_menu_label(lv_obj_t *parent, int vi, int item_idx, int count); // If a (carousel) label's text is wider than box_w, clamp it into [box_left, box_left+box_w] // and either marquee-scroll it (focused/center row) or ellipsize it (other rows). Labels // that already fit keep their original centered auto-width layout. Generic so any long value // (MAC / Commit / IP / hostname / version / long option) is handled, not just one screen. - static void apply_overflow_handling(lv_obj_t *lbl, int box_left, int box_w, bool focused) - { - if (!lbl || box_w <= 0) - return; - lv_obj_update_layout(lbl); - if (lv_obj_get_width(lbl) <= box_w) - return; // fits — keep default centered behavior - lv_obj_set_width(lbl, box_w); - lv_obj_set_x(lbl, box_left); - // focused row marquee-scrolls (single line); others clip (single line, no wrap). - // Do NOT use LV_LABEL_LONG_DOT here: with auto height it wraps to multiple lines - // before adding dots, which looks like an unwanted line break. - lv_label_set_long_mode(lbl, focused ? LV_LABEL_LONG_SCROLL_CIRCULAR : LV_LABEL_LONG_CLIP); - } - - static void apply_fixed_label_box(lv_obj_t *lbl, int x, int y, int w, bool scroll) - { - if (!lbl || w <= 0) - return; - lv_obj_set_pos(lbl, x, y); - lv_obj_set_width(lbl, w); - lv_label_set_long_mode(lbl, scroll ? LV_LABEL_LONG_SCROLL_CIRCULAR : LV_LABEL_LONG_CLIP); - } - - static void clamp_label_box(lv_obj_t *lbl, int x, int w, bool scroll) - { - if (!lbl || w <= 0) - return; - lv_obj_set_x(lbl, x); - lv_obj_set_width(lbl, w); - lv_label_set_long_mode(lbl, scroll ? LV_LABEL_LONG_SCROLL_CIRCULAR : LV_LABEL_LONG_CLIP); - } + static void apply_overflow_handling(lv_obj_t *lbl, int box_left, int box_w, bool focused); + static void apply_fixed_label_box(lv_obj_t *lbl, int x, int y, int w, bool scroll); + static void clamp_label_box(lv_obj_t *lbl, int x, int w, bool scroll); // Same as apply_overflow_handling but keeps the clamped box centered on // center_x, so an overflowing (marquee) value stays visually centered // instead of drifting to a fixed left edge. - static void apply_overflow_centered(lv_obj_t *lbl, int center_x, int box_w, bool focused) - { - apply_overflow_handling(lbl, center_x - box_w / 2, box_w, focused); - } + static void apply_overflow_centered(lv_obj_t *lbl, int center_x, int box_w, bool focused); // ==================== Main carousel view ==================== - void build_main_view() - { - lv_obj_t *cont = ui_obj_["list_cont"]; - lv_obj_clean(cont); - - int count = (int)menu_items_.size(); - - // Selected item background (312px wide, 22px tall, gray, no radius) - static constexpr int SEL_BAR_H = 23; - static constexpr int SEL_BAR_W = 312; - sel_bg_ = lv_obj_create(cont); - lv_obj_set_size(sel_bg_, SEL_BAR_W, SEL_BAR_H); - lv_obj_set_pos(sel_bg_, (SCREEN_W - SEL_BAR_W) / 2, row_y(ROW_CENTER) + (row_h() - SEL_BAR_H) / 2); - lv_obj_set_style_radius(sel_bg_, 0, LV_PART_MAIN); - lv_obj_set_style_bg_color(sel_bg_, lv_color_hex(0x2a2a2a), LV_PART_MAIN); - lv_obj_set_style_bg_opa(sel_bg_, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(sel_bg_, 0, LV_PART_MAIN); - lv_obj_clear_flag(sel_bg_, LV_OBJ_FLAG_SCROLLABLE); - - // Hint label (right-aligned, 6px from right edge, smaller font) - hint_lbl_ = lv_label_create(cont); - lv_label_set_text(hint_lbl_, "ok:enter"); - lv_obj_set_style_text_color(hint_lbl_, lv_color_hex(0x00CC66), LV_PART_MAIN); - lv_obj_set_style_text_font(hint_lbl_, launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - lv_obj_update_layout(hint_lbl_); - int hint_w = lv_obj_get_width(hint_lbl_); - int hint_h = lv_obj_get_height(hint_lbl_); - lv_obj_set_pos(hint_lbl_, SCREEN_W - 6 - hint_w, row_y(ROW_CENTER) + (row_h() - hint_h) / 2); - - // Row labels - for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { - int item_idx = selected_idx_ - ROW_CENTER + vi; - row_labels_[vi] = create_menu_label(cont, vi, item_idx, count); - } - - // Arrows created last (on top), centered at x=MENU_X (arrow 16px wide) - int arrow_x = MENU_X - 8; - arrow_up_obj_ = lv_img_create(cont); - lv_img_set_src(arrow_up_obj_, img_arrow_up_.c_str()); - lv_obj_set_pos(arrow_up_obj_, arrow_x, 2); - - arrow_down_obj_ = lv_img_create(cont); - lv_img_set_src(arrow_down_obj_, img_arrow_down_.c_str()); - lv_obj_set_pos(arrow_down_obj_, arrow_x, LIST_H - 14); - - is_animating_ = false; - } + void build_main_view(); // Animate scroll: direction = -1 (up) or +1 (down) - void animate_scroll(int direction) - { - int count = (int)menu_items_.size(); - int new_idx = selected_idx_ + direction; - - // Always bounce the arrow (even at boundary) - if (direction < 0) bounce_arrow(arrow_up_obj_, -1); - else bounce_arrow(arrow_down_obj_, 1); - - if (new_idx < 0 || new_idx >= count) return; - if (is_animating_) return; - - is_animating_ = true; - selected_idx_ = new_idx; - - // Arrows always visible — bounce only when not at boundary - // (animate_scroll already blocks at boundaries) - - // Update text content for each slot - for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { - int item_idx = selected_idx_ - ROW_CENTER + vi; - if (item_idx >= 0 && item_idx < count) { - lv_label_set_text(row_labels_[vi], menu_items_[item_idx].label.c_str()); - lv_obj_clear_flag(row_labels_[vi], LV_OBJ_FLAG_HIDDEN); - } else { - lv_label_set_text(row_labels_[vi], ""); - lv_obj_add_flag(row_labels_[vi], LV_OBJ_FLAG_HIDDEN); - } - } - - // Animate each label's Y position - int rh = row_h(); - int offset = direction * rh; - for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { - RowStyle s = style_for_slot(vi); - int font_h = lv_font_get_line_height(s.font); - int target_y = row_y(vi) + (rh - font_h) / 2; - int start_y = target_y + offset; - - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, row_labels_[vi]); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_y); - lv_anim_set_values(&a, start_y, target_y); - lv_anim_set_time(&a, ANIM_TIME); - lv_anim_set_path_cb(&a, lv_anim_path_ease_out); - if (vi == ROW_CENTER) { - lv_anim_set_completed_cb(&a, anim_done_cb); - lv_anim_set_user_data(&a, this); - } - lv_anim_start(&a); - - // Update style (font/color/opacity) and re-center X - lv_obj_set_style_text_color(row_labels_[vi], lv_color_hex(s.color), LV_PART_MAIN); - lv_obj_set_style_text_font(row_labels_[vi], s.font, LV_PART_MAIN); - lv_obj_set_style_opa(row_labels_[vi], s.opa, LV_PART_MAIN); - lv_obj_update_layout(row_labels_[vi]); - int tw = lv_obj_get_width(row_labels_[vi]); - int lx = MENU_X - tw / 2; - if (lx < 4) lx = 4; - lv_obj_set_x(row_labels_[vi], lx); - } - } - - static void anim_done_cb(lv_anim_t *a) - { - UISetupPage *self = (UISetupPage *)lv_anim_get_user_data(a); - if (self) self->is_animating_ = false; - } + void animate_scroll(int direction); + void cancel_scroll_animations(); + static void anim_done_cb(lv_anim_t *a) noexcept; // ==================== Sub view ==================== - void build_sub_view() - { - lv_obj_t *cont = ui_obj_["list_cont"]; - info_.reset_visible_labels(); - lv_obj_clean(cont); - - MenuItem &item = menu_items_[selected_idx_]; - int sub_count = (int)item.sub_items.size(); - int count = (int)menu_items_.size(); - - // Gray highlight bar (same as main view, behind everything) - static constexpr int SEL_BAR_H = 23; - static constexpr int SEL_BAR_W = 312; - lv_obj_t *bar = lv_obj_create(cont); - lv_obj_set_size(bar, SEL_BAR_W, SEL_BAR_H); - lv_obj_set_pos(bar, (SCREEN_W - SEL_BAR_W) / 2, row_y(ROW_CENTER) + (row_h() - SEL_BAR_H) / 2); - lv_obj_set_style_radius(bar, 0, LV_PART_MAIN); - lv_obj_set_style_bg_color(bar, lv_color_hex(0x2a2a2a), LV_PART_MAIN); - lv_obj_set_style_bg_opa(bar, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(bar, 0, LV_PART_MAIN); - lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); - - // Left column: reuse shared label creation - lv_obj_t *left_center_lbl = nullptr; - for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { - int item_idx = selected_idx_ - ROW_CENTER + vi; - if (item_idx < 0 || item_idx >= count) continue; - - lv_obj_t *lbl = create_menu_label(cont, vi, item_idx, count); - clamp_label_box(lbl, SUB_LEFT_BOX_X, SUB_LEFT_BOX_W, vi == ROW_CENTER); - if (vi == ROW_CENTER) left_center_lbl = lbl; - } - - // Right column: sub items (same carousel style, centered at x=160) - - if (sub_count == 0) { - create_carousel_label(cont, ROW_CENTER, ROW_CENTER, "(no options)", SUB_CENTER_X, true); - return; - } - - // Default sub_selected to position ~3 if enough items - int sub_center_vi = ROW_CENTER; - - // Sub items — two passes: create labels, then place indicators aligned - struct SubLabelInfo { lv_obj_t *lbl; int si; int right_edge; }; - SubLabelInfo sub_labels[ROWS_VISIBLE] = {}; - int sub_label_count = 0; - int right_min_x = SCREEN_W; - - for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { - int si = sub_selected_idx_ - sub_center_vi + vi; - if (si < 0 || si >= sub_count) continue; - - SubItem &sub = item.sub_items[si]; - lv_obj_t *lbl = create_carousel_label(cont, vi, sub_center_vi, - sub.label.c_str(), SUB_CENTER_X, true); - bool focused_row = (vi == sub_center_vi); - apply_overflow_centered(lbl, SUB_CENTER_X, focused_row ? 80 : VALUE_BOX_W, focused_row); - if (item.label == "Info" && si >= 0 && si < 4) { - info_.track_visible_label(si, lbl, focused_row, sub.label); - } - lv_obj_update_layout(lbl); - int lx = lv_obj_get_x(lbl); - int tw = lv_obj_get_width(lbl); - if (lx < right_min_x) right_min_x = lx; - - sub_labels[sub_label_count++] = {lbl, si, lx + tw}; - } - - // Place toggle indicators at fixed x=220 - int indicator_x = 220; - for (int i = 0; i < sub_label_count; ++i) { - SubItem &sub = item.sub_items[sub_labels[i].si]; - if (!sub.is_toggle) continue; - - lv_obj_t *lbl = sub_labels[i].lbl; - lv_obj_t *ind = lv_img_create(cont); - lv_img_set_src(ind, sub.toggle_state ? img_ok_.c_str() : img_cross_.c_str()); - // Vertically center indicator with the label - lv_obj_update_layout(ind); - int ind_h = lv_obj_get_height(ind); - int lbl_y = lv_obj_get_y(lbl); - int lbl_h = lv_obj_get_height(lbl); - int x_offset = sub.toggle_state ? 0 : 1; - lv_obj_set_pos(ind, indicator_x + x_offset, lbl_y + (lbl_h - ind_h) / 2); - } - - // Keep the sub-view pointer in a fixed slot. Dynamic placement based on text - // widths breaks when long menu names like "SoundCard" overlap the value column. - if (sub_count > 0) - place_fixed_sub_arrow(cont); - - // Up/down arrows for sub (centered at SUB_CENTER_X) - int sub_arrow_x = SUB_CENTER_X - 8; - if (sub_selected_idx_ > 0) { - lv_obj_t *a = lv_img_create(cont); - lv_img_set_src(a, img_arrow_up_.c_str()); - lv_obj_set_pos(a, sub_arrow_x, 2); - } - if (sub_selected_idx_ < sub_count - 1) { - lv_obj_t *a = lv_img_create(cont); - lv_img_set_src(a, img_arrow_down_.c_str()); - lv_obj_set_pos(a, sub_arrow_x, LIST_H - 14); - } - - // Hint for selected sub item (right-aligned, 6px from right edge, smaller font) - SubItem &cur_sub = item.sub_items[sub_selected_idx_]; - lv_obj_t *hint = lv_label_create(cont); - if (cur_sub.is_toggle && item.label == "RTC" && cur_sub.label == "NTP") - lv_label_set_text(hint, rtc_.ntp_available() - ? (cur_sub.toggle_state ? "ok:disable" : "ok:enable") - : "unavailable"); - else if (cur_sub.is_toggle && item.label == "WiFi" && cur_sub.label == "Power") - lv_label_set_text(hint, cur_sub.toggle_state ? "ok:disable" : "ok:enable"); - else if (cur_sub.is_toggle && item.label == "Bluetooth" && cur_sub.label == "Named Only") - lv_label_set_text(hint, cur_sub.toggle_state ? "ok:show all" : "ok:named"); - else if (cur_sub.is_toggle) - lv_label_set_text(hint, cur_sub.toggle_state ? "ok:hide" : "ok:show"); - else if (item.label == "RTC" && rtc_.ntp_on()) - lv_label_set_text(hint, "ntp on"); - else - lv_label_set_text(hint, "ok:enter"); - lv_obj_set_style_text_color(hint, lv_color_hex(0x00CC66), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - apply_right_hint_overflow(hint, row_y(sub_center_vi)); - } + void build_sub_view(); // Position the far-right hint (ok:xxx). If it is wider than RIGHT_HINT_BOX_W it is // clamped into a right-edge box and marquee-scrolled (so long hints like "ok:disable" // don't overlap the toggle indicator); otherwise it keeps its natural right-aligned pos. - void apply_right_hint_overflow(lv_obj_t *hint, int row_top_y) - { - lv_obj_update_layout(hint); - int hw = lv_obj_get_width(hint); - int hh = lv_obj_get_height(hint); - int y = row_top_y + (row_h() - hh) / 2; - if (hw > RIGHT_HINT_BOX_W) { - lv_obj_set_width(hint, RIGHT_HINT_BOX_W); - lv_obj_set_pos(hint, SCREEN_W - 6 - RIGHT_HINT_BOX_W, y); - lv_label_set_long_mode(hint, LV_LABEL_LONG_SCROLL_CIRCULAR); - } else { - lv_obj_set_pos(hint, SCREEN_W - 6 - hw, y); - } - } + void apply_right_hint_overflow(lv_obj_t *hint, int row_top_y); // ==================== Value select view (3rd level) ==================== - void build_value_view() - { - lv_obj_t *cont = ui_obj_["list_cont"]; - lv_obj_clean(cont); - - int count = (int)menu_items_[selected_idx_].sub_items.size(); - int val_count = (int)val_options_.size(); - - // Gray highlight bar - static constexpr int SEL_BAR_H2 = 22; - static constexpr int SEL_BAR_W2 = 312; - lv_obj_t *bar = lv_obj_create(cont); - lv_obj_set_size(bar, SEL_BAR_W2, SEL_BAR_H2); - lv_obj_set_pos(bar, (SCREEN_W - SEL_BAR_W2) / 2, row_y(ROW_CENTER) + (row_h() - SEL_BAR_H2) / 2); - lv_obj_set_style_radius(bar, 0, LV_PART_MAIN); - lv_obj_set_style_bg_color(bar, lv_color_hex(0x2a2a2a), LV_PART_MAIN); - lv_obj_set_style_bg_opa(bar, 255, LV_PART_MAIN); - lv_obj_set_style_border_width(bar, 0, LV_PART_MAIN); - lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); - - // Left column: sub-item name (e.g. "Resolution") as carousel at MENU_X. - // Long names are clamped + marquee-scrolled so they can't run into the - // arrow / value column (kept centered on MENU_X). - static constexpr int VAL_LEFT_BOX_W = 84; - lv_obj_t *val_left_lbl = nullptr; - for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { - int si = sub_selected_idx_ - ROW_CENTER + vi; - if (si < 0 || si >= count) continue; - const char *text = menu_items_[selected_idx_].sub_items[si].label.c_str(); - lv_obj_t *lbl = create_carousel_label(cont, vi, ROW_CENTER, text, MENU_X); - apply_overflow_centered(lbl, MENU_X, VAL_LEFT_BOX_W, vi == ROW_CENTER); - if (vi == ROW_CENTER) val_left_lbl = lbl; - } + void build_value_view(); - // Right column: value options — track min X for stable arrow - static constexpr int VAL_CENTER_X = 160; - int val_right_min_x = SCREEN_W; - for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { - int val_i = val_sel_idx_ - ROW_CENTER + vi; - if (val_i < 0 || val_i >= val_count) continue; - lv_obj_t *lbl = create_carousel_label(cont, vi, ROW_CENTER, - val_options_[val_i].c_str(), VAL_CENTER_X, true); - apply_overflow_centered(lbl, VAL_CENTER_X, VALUE_BOX_W, vi == ROW_CENTER); - lv_obj_update_layout(lbl); - int lx = lv_obj_get_x(lbl); - if (lx < val_right_min_x) val_right_min_x = lx; - } - - // Blue arrow centered between left and right columns - if (val_left_lbl && val_count > 0) - place_blue_arrow(cont, val_left_lbl, val_right_min_x); - - // Arrows for value column - int val_arrow_x = VAL_CENTER_X - 8; - if (val_sel_idx_ > 0) { - lv_obj_t *a = lv_img_create(cont); - lv_img_set_src(a, img_arrow_up_.c_str()); - lv_obj_set_pos(a, val_arrow_x, 2); - } - if (val_sel_idx_ < val_count - 1) { - lv_obj_t *a = lv_img_create(cont); - lv_img_set_src(a, img_arrow_down_.c_str()); - lv_obj_set_pos(a, val_arrow_x, LIST_H - 14); - } - - // Hint (right-aligned, smaller font) - lv_obj_t *hint = lv_label_create(cont); - lv_label_set_text(hint, "ok:set"); - lv_obj_set_style_text_color(hint, lv_color_hex(0x00CC66), LV_PART_MAIN); - lv_obj_set_style_text_font(hint, launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN); - lv_obj_update_layout(hint); - int val_hint_w = lv_obj_get_width(hint); - int val_hint_h = lv_obj_get_height(hint); - lv_obj_set_pos(hint, SCREEN_W - 6 - val_hint_w, row_y(ROW_CENTER) + (row_h() - val_hint_h) / 2); - } - - void rebuild_view() - { - if (view_state_ == ViewState::MAIN) build_main_view(); - else if (view_state_ == ViewState::SUB) build_sub_view(); - else if (view_state_ == ViewState::VALUE_SELECT) build_value_view(); - else if (view_state_ == ViewState::WIFI_LIST) wifi_.build_list(*this); - else if (view_state_ == ViewState::BT_LIST) bluetooth_.build_list(*this); - else if (view_state_ == ViewState::BT_ALIAS) bluetooth_.build_alias_view(*this); - else if (view_state_ == ViewState::SOUNDCARD_CARDS) soundcard_.build_cards_view(*this); - else if (view_state_ == ViewState::SOUNDCARD_CONTROLS) soundcard_.build_controls_view(*this); - else if (view_state_ == ViewState::SOUNDCARD_DETAIL) soundcard_.build_detail_view(*this); - else if (view_state_ == ViewState::ADB_PAIR) developer_.enter_pair_view(*this); - else if (view_state_ == ViewState::ADB_AUTHORIZATIONS) - developer_.build_authorizations_view(*this); - } + void rebuild_view(); // Bounce animation for orange arrows (small Y displacement + return) - void bounce_arrow(lv_obj_t *arrow, int direction) - { - if (!arrow) return; - int cur_y = lv_obj_get_y(arrow); - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, arrow); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_y); - lv_anim_set_values(&a, cur_y + direction * 4, cur_y); - lv_anim_set_time(&a, 150); - lv_anim_set_path_cb(&a, lv_anim_path_ease_out); - lv_anim_start(&a); - } + void bounce_arrow(lv_obj_t *arrow, int direction); // Dual-container slide transition // direction: +1 = enter deeper (old slides left, new slides in from right) // -1 = go back (old slides right, new slides in from left) - void slide_transition(int direction) - { - lv_obj_t *bg = ui_obj_["bg"]; - lv_obj_t *old_cont = ui_obj_["list_cont"]; - if (!bg || !old_cont) { rebuild_view(); return; } - - // Create new container for the new content - lv_obj_t *new_cont = lv_obj_create(bg); - lv_obj_set_size(new_cont, SCREEN_W, LIST_H); - lv_obj_set_pos(new_cont, direction * SCREEN_W, 0); - lv_obj_set_style_radius(new_cont, 0, LV_PART_MAIN); - lv_obj_set_style_bg_opa(new_cont, 0, LV_PART_MAIN); - lv_obj_set_style_border_width(new_cont, 0, LV_PART_MAIN); - lv_obj_set_style_pad_all(new_cont, 0, LV_PART_MAIN); - lv_obj_clear_flag(new_cont, LV_OBJ_FLAG_SCROLLABLE); - - // Switch list_cont to new container and rebuild - ui_obj_["list_cont"] = new_cont; - rebuild_view(); - - // Animate old container sliding out - lv_anim_t a_old; - lv_anim_init(&a_old); - lv_anim_set_var(&a_old, old_cont); - lv_anim_set_exec_cb(&a_old, (lv_anim_exec_xcb_t)lv_obj_set_x); - lv_anim_set_values(&a_old, 0, -direction * SCREEN_W); - lv_anim_set_time(&a_old, 200); - lv_anim_set_path_cb(&a_old, lv_anim_path_ease_out); - lv_anim_set_completed_cb(&a_old, slide_old_done_cb); - lv_anim_set_user_data(&a_old, old_cont); - lv_anim_start(&a_old); - - // Animate new container sliding in - lv_anim_t a_new; - lv_anim_init(&a_new); - lv_anim_set_var(&a_new, new_cont); - lv_anim_set_exec_cb(&a_new, (lv_anim_exec_xcb_t)lv_obj_set_x); - lv_anim_set_values(&a_new, direction * SCREEN_W, 0); - lv_anim_set_time(&a_new, 200); - lv_anim_set_path_cb(&a_new, lv_anim_path_ease_out); - lv_anim_start(&a_new); - } - - static void slide_old_done_cb(lv_anim_t *a) - { - lv_obj_t *old_cont = (lv_obj_t *)lv_anim_get_user_data(a); - if (old_cont) lv_obj_del(old_cont); - } + void slide_transition(int direction); + static void slide_old_done_cb(lv_anim_t *a) noexcept; // Enter deeper level (old slides left, new from right) - void transition_enter_level() - { - slide_transition(1); - } + void transition_enter_level(); // Return to shallower level (old slides right, new from left) - void transition_back_level() - { - slide_transition(-1); - } + void transition_back_level(); // ==================== Events ==================== - void event_handler_init() - { - lv_obj_add_event_cb(root_screen_, UISetupPage::static_handler, LV_EVENT_ALL, this); - } - static void static_handler(lv_event_t *e) - { - UISetupPage *self = static_cast(lv_event_get_user_data(e)); - if (self) self->on_event(e); - } + void event_handler_init(); + static void static_handler(lv_event_t *e) noexcept; uint32_t last_repeat_tick_ = 0; static constexpr uint32_t REPEAT_INTERVAL_MS = 300; - void on_event(lv_event_t *e) - { - bool released = launcher_ui::events::is_key_released(e); - bool pressed = launcher_ui::events::is_key_pressed(e); - if (!released && !pressed) return; - - struct key_item *elm = (struct key_item *)lv_event_get_param(e); - cur_elm_ = elm; - uint32_t key = elm->key_code; - key = remap_fzxc(key); - - if (developer_.status_active()) { - if (released) developer_.handle_status_key(*this, key); - return; - } - - if (rtc_.write_confirm_active()) { - if (released) - rtc_.handle_write_confirm_key(*this, key); - return; - } - - // For held keys (pressed), only handle UP/DOWN with throttle - if (pressed) { - if (key != KEY_UP && key != KEY_DOWN) return; - uint32_t now = lv_tick_get(); - if (now - last_repeat_tick_ < REPEAT_INTERVAL_MS) return; - last_repeat_tick_ = now; - } else { - // On release, also throttle to prevent double-trigger - uint32_t now = lv_tick_get(); - if (key == KEY_UP || key == KEY_DOWN) { - if (now - last_repeat_tick_ < 300) return; - last_repeat_tick_ = now; - } - } - - switch (view_state_) { - case ViewState::MAIN: handle_main_key(key); break; - case ViewState::SUB: handle_sub_key(key); break; - case ViewState::VALUE_SELECT: handle_value_key(key); break; - case ViewState::WIFI_LIST: - // UP/DOWN: only on pressed (throttled above). Other keys: only on released. - if (pressed && (key == KEY_UP || key == KEY_DOWN)) - wifi_.handle_list_key(*this, key); - else if (released && key != KEY_UP && key != KEY_DOWN) - wifi_.handle_list_key(*this, key); - break; - case ViewState::WIFI_PW: - if (released) wifi_.handle_pw_key(*this, key); - break; - case ViewState::BT_LIST: - if (pressed && (key == KEY_UP || key == KEY_DOWN)) - bluetooth_.handle_list_key(*this, key); - else if (released && key != KEY_UP && key != KEY_DOWN) - bluetooth_.handle_list_key(*this, key); - break; - case ViewState::BT_ALIAS: - if (released) bluetooth_.handle_alias_key(*this, key); - break; - case ViewState::SOUNDCARD_CARDS: - if (pressed && (key == KEY_UP || key == KEY_DOWN)) - soundcard_.handle_cards_key(*this, key); - else if (released && key != KEY_UP && key != KEY_DOWN) - soundcard_.handle_cards_key(*this, key); - break; - case ViewState::SOUNDCARD_CONTROLS: - if (pressed && (key == KEY_UP || key == KEY_DOWN)) - soundcard_.handle_controls_key(*this, key); - else if (released && key != KEY_UP && key != KEY_DOWN) - soundcard_.handle_controls_key(*this, key); - break; - case ViewState::SOUNDCARD_DETAIL: - if (released) soundcard_.handle_detail_key(*this, key); - break; - case ViewState::USB_GUIDE: - if (released) developer_.handle_usb_guide_key(*this, key); - break; - case ViewState::ADB_PAIR: - if (released) developer_.handle_pair_key(*this, key); - break; - case ViewState::ADB_AUTHORIZATIONS: - if (released) developer_.handle_authorizations_key(*this, key); - break; - } - } - - static uint32_t remap_fzxc(uint32_t key) - { - switch (key) { - case KEY_F: return KEY_UP; - case KEY_X: return KEY_DOWN; - case KEY_Z: return KEY_LEFT; - case KEY_C: return KEY_RIGHT; - default: return key; - } - } - - void handle_main_key(uint32_t key) - { - switch (key) { - case KEY_UP: - animate_scroll(-1); - break; - case KEY_DOWN: - animate_scroll(1); - break; - case KEY_ENTER: - case KEY_RIGHT: { - play_enter(); - MenuItem &m = menu_items_[selected_idx_]; - if (m.on_enter) m.on_enter(); - if (!m.sub_items.empty()) { - view_state_ = ViewState::SUB; - int sc = (int)m.sub_items.size(); - sub_selected_idx_ = sc > ROW_CENTER ? ROW_CENTER : sc - 1; - build_sub_view(); - } - break; - } - case KEY_ESC: - play_back(); - if (navigate_home) navigate_home(); - break; - default: - break; - } - } - - void handle_sub_key(uint32_t key) - { - MenuItem &item = menu_items_[selected_idx_]; - int sub_count = (int)item.sub_items.size(); - - switch (key) { - case KEY_UP: - if (sub_selected_idx_ > 0) { --sub_selected_idx_; build_sub_view(); } - break; - case KEY_DOWN: - if (sub_selected_idx_ < sub_count - 1) { ++sub_selected_idx_; build_sub_view(); } - break; - case KEY_ENTER: - case KEY_RIGHT: { - play_enter(); - if (sub_selected_idx_ < sub_count) { - SubItem &sub = item.sub_items[sub_selected_idx_]; - if (sub.is_toggle) { - sub.toggle_state = !sub.toggle_state; - if (sub.action) sub.action(); - build_sub_view(); - } else if (sub.action) { - sub.action(); - } - } - break; - } - case KEY_ESC: - case KEY_LEFT: - play_back(); - info_.stop_timer(); - if (item.label == "RTC" && rtc_.is_dirty()) { - rtc_.show_write_confirm(*this); - break; - } - view_state_ = ViewState::MAIN; - build_main_view(); - break; - default: - if (item.custom_key_handler) item.custom_key_handler(key); - break; - } - } - - void handle_value_key(uint32_t key) - { - int val_count = (int)val_options_.size(); - switch (key) { - case KEY_UP: - if (val_sel_idx_ > 0) { --val_sel_idx_; build_value_view(); } - break; - case KEY_DOWN: - if (val_sel_idx_ < val_count - 1) { ++val_sel_idx_; build_value_view(); } - break; - case KEY_ENTER: - case KEY_RIGHT: { - apply_value_selection(); - // After reboot/shutdown, don't animate back — the system is going down. - if (val_title_ == "Reboot?" || val_title_ == "Shutdown?" || val_title_ == "Run Setup?") { - // Show a brief message, then let the system halt/reboot. - lv_obj_t *cont = ui_obj_["list_cont"]; - lv_obj_clean(cont); - lv_obj_t *lbl = lv_label_create(cont); - lv_label_set_text(lbl, val_title_ == "Shutdown?" ? "Shutting down..." : "Rebooting..."); - lv_obj_center(lbl); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x58A6FF), LV_PART_MAIN); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, LV_PART_MAIN); - lv_refr_now(NULL); - break; - } - view_state_ = ViewState::SUB; - transition_back_level(); - break; - } - case KEY_ESC: - case KEY_LEFT: - confirm_action_ = nullptr; - view_state_ = ViewState::SUB; - transition_back_level(); - break; - default: - break; - } - } + void on_event(lv_event_t *e); + void on_root_deleted(); + static uint32_t remap_fzxc(uint32_t key); + void handle_main_key(uint32_t key); + void handle_sub_key(uint32_t key); + void handle_value_key(uint32_t key); }; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_setup_input.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_setup_input.cpp new file mode 100644 index 00000000..b812628e --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_setup_input.cpp @@ -0,0 +1,289 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_setup.hpp" + +void UISetupPage::event_handler_init() +{ + lv_obj_add_event_cb(root_screen_, UISetupPage::static_handler, LV_EVENT_ALL, this); +} + +void UISetupPage::static_handler(lv_event_t *event) noexcept +{ + if (!event) return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self || !setup_root_callback_allowed( + lv_event_get_current_target(event), self->root_screen_)) return; + try { + self->on_event(event); + } catch (...) { + try { + self->on_root_deleted(); + } catch (...) { + self->lifecycle_.root_deleted(); + } + } +} + +void UISetupPage::on_event(lv_event_t *event) +{ + if (lv_event_get_code(event) == LV_EVENT_DELETE && + lv_event_get_target(event) == lv_event_get_current_target(event)) { + on_root_deleted(); + return; + } + if (!lifecycle_.active()) return; + + bool released = launcher_ui::events::is_key_released(event); + bool pressed = launcher_ui::events::is_key_pressed(event); + if (!released && !pressed) return; + + auto *item = static_cast(lv_event_get_param(event)); + if (!item) return; + cur_elm_ = item; + uint32_t key = remap_fzxc(item->key_code); + + if (developer_.status_active()) { + if (released) developer_.handle_status_key(*this, key); + return; + } + if (rtc_.write_confirm_active()) { + if (released) rtc_.handle_write_confirm_key(*this, key); + return; + } + + if (pressed) { + if (key != KEY_UP && key != KEY_DOWN) return; + uint32_t now = lv_tick_get(); + const bool wifi_repeat = view_state_ == ViewState::WIFI_LIST && + item->key_state == KBD_KEY_REPEATED; + const uint32_t repeat_interval = view_state_ == ViewState::WIFI_LIST + ? SetupWifiListViewModel::KEY_REPEAT_INTERVAL_MS : REPEAT_INTERVAL_MS; + if ((view_state_ != ViewState::WIFI_LIST || wifi_repeat) && + now - last_repeat_tick_ < repeat_interval) return; + last_repeat_tick_ = now; + } else if (key == KEY_UP || key == KEY_DOWN) { + // WiFi navigation is handled on press/repeat. A release must not delay + // the next physical press. + if (view_state_ == ViewState::WIFI_LIST) return; + uint32_t now = lv_tick_get(); + if (now - last_repeat_tick_ < REPEAT_INTERVAL_MS) return; + last_repeat_tick_ = now; + } + + switch (view_state_) { + case ViewState::MAIN: + handle_main_key(key); + break; + case ViewState::SUB: + handle_sub_key(key); + break; + case ViewState::VALUE_SELECT: + handle_value_key(key); + break; + case ViewState::HELP: + if (released) setting::Help::handle_key(*this, key); + break; + case ViewState::WIFI_LIST: + if (pressed && (key == KEY_UP || key == KEY_DOWN)) + wifi_.handle_list_key(*this, key); + else if (released && key != KEY_UP && key != KEY_DOWN) + wifi_.handle_list_key(*this, key); + break; + case ViewState::WIFI_PW: + if (released) wifi_.handle_pw_key(*this, key); + break; + case ViewState::WIFI_FORGET_CONFIRM: + if (released) wifi_.handle_forget_key(*this, key); + break; + case ViewState::BT_LIST: + if (pressed && (key == KEY_UP || key == KEY_DOWN)) + bluetooth_.handle_list_key(*this, key); + else if (released && key != KEY_UP && key != KEY_DOWN) + bluetooth_.handle_list_key(*this, key); + break; + case ViewState::BT_ALIAS: + if (released) bluetooth_.handle_alias_key(*this, key); + break; + case ViewState::SOUNDCARD_CARDS: + if (pressed && (key == KEY_UP || key == KEY_DOWN)) + soundcard_.handle_cards_key(*this, key); + else if (released && key != KEY_UP && key != KEY_DOWN) + soundcard_.handle_cards_key(*this, key); + break; + case ViewState::SOUNDCARD_CONTROLS: + if (pressed && (key == KEY_UP || key == KEY_DOWN)) + soundcard_.handle_controls_key(*this, key); + else if (released && key != KEY_UP && key != KEY_DOWN) + soundcard_.handle_controls_key(*this, key); + break; + case ViewState::SOUNDCARD_DETAIL: + if (released) soundcard_.handle_detail_key(*this, key); + break; + case ViewState::USB_GUIDE: + if (released) developer_.handle_usb_guide_key(*this, key); + break; + case ViewState::ADB_PAIR: + if (released) developer_.handle_pair_key(*this, key); + break; + case ViewState::ADB_AUTHORIZATIONS: + if (released) developer_.handle_authorizations_key(*this, key); + break; + } +} + +void UISetupPage::on_root_deleted() +{ + if (!lifecycle_.active()) return; + cancel_scroll_animations(); + lifecycle_.root_deleted(); + stop_power_timer(); + wifi_.shutdown(); + info_.stop_timer(); + bluetooth_.shutdown(); + developer_.shutdown(); + rtc_.shutdown(); + soundcard_.shutdown(); + confirm_controller_.cancel(); + + ui_obj_.clear(); + for (auto *&label : row_labels_) label = nullptr; + sel_bg_ = nullptr; + hint_lbl_ = nullptr; + arrow_up_obj_ = nullptr; + arrow_down_obj_ = nullptr; + cur_elm_ = nullptr; +} + +uint32_t UISetupPage::remap_fzxc(uint32_t key) +{ + switch (key) { + case KEY_F: return KEY_UP; + case KEY_X: return KEY_DOWN; + case KEY_Z: return KEY_LEFT; + case KEY_C: return KEY_RIGHT; + default: return key; + } +} + +void UISetupPage::handle_main_key(uint32_t key) +{ + switch (key) { + case KEY_UP: + animate_scroll(-1); + break; + case KEY_DOWN: + animate_scroll(1); + break; + case KEY_ENTER: + case KEY_RIGHT: { + play_enter(); + MenuItem &item = menu_items_[selected_idx_]; + if (item.on_enter) item.on_enter(); + if (!item.sub_items.empty()) { + int sub_count = static_cast(item.sub_items.size()); + model_.enter_sub(sub_count, ROW_CENTER); + build_sub_view(); + } + break; + } + case KEY_ESC: + play_back(); + if (navigate_home) navigate_home(); + break; + default: + break; + } +} + +void UISetupPage::handle_sub_key(uint32_t key) +{ + MenuItem &item = menu_items_[selected_idx_]; + int sub_count = static_cast(item.sub_items.size()); + switch (key) { + case KEY_UP: + if (model_.move_sub(-1, sub_count)) { + build_sub_view(); + } + break; + case KEY_DOWN: + if (model_.move_sub(1, sub_count)) { + build_sub_view(); + } + break; + case KEY_ENTER: + case KEY_RIGHT: + play_enter(); + if (sub_selected_idx_ < sub_count) { + SubItem &sub = item.sub_items[sub_selected_idx_]; + if (sub.is_toggle) { + sub.toggle_state = !sub.toggle_state; + if (sub.action) sub.action(); + build_sub_view(); + } else if (sub.action) { + sub.action(); + } + } + break; + case KEY_ESC: + case KEY_LEFT: + play_back(); + info_.stop_timer(); + if (item.label == "RTC" && rtc_.is_dirty()) { + rtc_.show_write_confirm(*this); + break; + } + model_.leave_to_main(); + build_main_view(); + break; + default: + if (item.custom_key_handler) item.custom_key_handler(key); + break; + } +} + +void UISetupPage::handle_value_key(uint32_t key) +{ + switch (key) { + case KEY_UP: + if (model_.move_value(-1)) { + build_value_view(); + } + break; + case KEY_DOWN: + if (model_.move_value(1)) { + build_value_view(); + } + break; + case KEY_ENTER: + case KEY_RIGHT: + apply_value_selection(); + if (val_title_ == "Reboot?" || val_title_ == "Shutdown?" || val_title_ == "Run Setup?") { + lv_obj_t *container = ui_obj_["list_cont"]; + lv_obj_clean(container); + lv_obj_t *label = lv_label_create(container); + lv_label_set_text(label, + val_title_ == "Shutdown?" ? "Shutting down..." : "Rebooting..."); + lv_obj_center(label); + lv_obj_set_style_text_color(label, lv_color_hex(0x58A6FF), LV_PART_MAIN); + lv_obj_set_style_text_font(label, &lv_font_montserrat_14, LV_PART_MAIN); + lv_refr_now(nullptr); + break; + } + model_.leave_to_sub(); + transition_back_level(); + break; + case KEY_ESC: + case KEY_LEFT: + confirm_controller_.cancel(); + model_.leave_to_sub(); + transition_back_level(); + break; + default: + break; + } +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_setup_layout.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_setup_layout.cpp new file mode 100644 index 00000000..e8ad126a --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_setup_layout.cpp @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_setup.hpp" + +void UISetupPage::create_ui() +{ + if (!ui_APP_Container) return; + lv_obj_t *bg = lv_obj_create(ui_APP_Container); + if (!bg) return; + lv_obj_set_size(bg, SCREEN_W, SCREEN_H); + lv_obj_set_pos(bg, 0, 0); + lv_obj_set_style_radius(bg, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(bg, lv_color_hex(0x000000), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bg, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN); + lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); + ui_obj_["bg"] = bg; + + lv_obj_t *list = lv_obj_create(bg); + if (!list) { + lv_obj_del(bg); + ui_obj_.erase("bg"); + return; + } + lv_obj_set_size(list, SCREEN_W, LIST_H); + lv_obj_set_pos(list, 0, 0); + lv_obj_set_style_radius(list, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(list, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); + lv_obj_clear_flag(list, LV_OBJ_FLAG_SCROLLABLE); + ui_obj_["list_cont"] = list; + build_main_view(); +} + +int UISetupPage::row_h() const +{ + return LIST_H / ROWS_VISIBLE; +} + +int UISetupPage::row_y(int visual_index) const +{ + const int center_y = (LIST_H - row_h()) / 2; + return center_y + (visual_index - ROW_CENTER) * row_h(); +} + +UISetupPage::RowStyle UISetupPage::style_for_slot(int visual_index) +{ + const int distance = visual_index > ROW_CENTER + ? visual_index - ROW_CENTER + : ROW_CENTER - visual_index; + if (distance == 0) + return {launcher_fonts().get("Montserrat-Bold.ttf", 18, LV_FREETYPE_FONT_STYLE_BOLD), 0xFFFFFF, MENU_X, 255}; + if (distance == 1) + return {launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), 0xAAAAAA, MENU_X, 220}; + if (distance == 2) + return {launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD), 0x777777, MENU_X, 170}; + return {&lv_font_montserrat_12, 0x555555, MENU_X, 130}; +} + +lv_obj_t *UISetupPage::create_carousel_label(lv_obj_t *parent, int vi, int center_vi, + const char *text, int center_x, bool smaller) +{ + if (!parent) return nullptr; + int dist = vi > center_vi ? vi - center_vi : center_vi - vi; + const lv_font_t *font; + uint32_t color; + int opa; + if (!smaller) { + if (dist == 0) { + font = launcher_fonts().get("Montserrat-Bold.ttf", 18, LV_FREETYPE_FONT_STYLE_BOLD); + color = 0xFFFFFF; opa = 255; + } else if (dist == 1) { + font = launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD); + color = 0xAAAAAA; opa = 220; + } else if (dist == 2) { + font = launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD); + color = 0x777777; opa = 170; + } else { + font = &lv_font_montserrat_12; + color = 0x555555; opa = 130; + } + } else { + if (dist == 0) { + font = launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD); + color = 0xFFFFFF; opa = 255; + } else if (dist == 1) { + font = launcher_fonts().get("Montserrat-Bold.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD); + color = 0xAAAAAA; opa = 220; + } else if (dist == 2) { + font = &lv_font_montserrat_12; + color = 0x777777; opa = 170; + } else { + font = &lv_font_montserrat_10; + color = 0x555555; opa = 130; + } + } + + lv_obj_t *lbl = lv_label_create(parent); + if (!lbl) return nullptr; + lv_label_set_text(lbl, text ? text : ""); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_text_font(lbl, font, LV_PART_MAIN); + lv_obj_set_style_opa(lbl, opa, LV_PART_MAIN); + + lv_obj_update_layout(lbl); + int tw = lv_obj_get_width(lbl); + int lx = center_x - tw / 2; + if (lx < 4) lx = 4; + int font_h = lv_font_get_line_height(font); + int ly = row_y(vi) + (row_h() - font_h) / 2; + if (smaller) ly += 1; + lv_obj_set_pos(lbl, lx, ly); + + if (!text || !text[0]) lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); + return lbl; +} + +void UISetupPage::place_blue_arrow(lv_obj_t *parent, lv_obj_t *left_lbl, int right_min_x) +{ + if (!left_lbl || right_min_x <= 0) return; + lv_obj_update_layout(left_lbl); + + const int vis = ARROW_SRC * ARROW_SCALE / 256; + int left_right_edge = lv_obj_get_x(left_lbl) + lv_obj_get_width(left_lbl); + static constexpr int SAFE_GAP = 4; + int arrow_x = right_min_x - SAFE_GAP - vis; + if (arrow_x < left_right_edge + 1) arrow_x = left_right_edge + 1; + int arrow_y = row_y(ROW_CENTER) + (row_h() - vis) / 2; + + lv_obj_t *arrow = lv_img_create(parent); + if (!arrow) return; + lv_img_set_src(arrow, img_right_arrow_.c_str()); + lv_image_set_pivot(arrow, 0, 0); + lv_image_set_scale(arrow, ARROW_SCALE); + lv_obj_set_pos(arrow, arrow_x, arrow_y); + lv_obj_move_to_index(arrow, 1); +} + +void UISetupPage::place_fixed_sub_arrow(lv_obj_t *parent) +{ + static constexpr int ARROW_H = 19; + lv_obj_t *arrow = lv_img_create(parent); + if (!arrow) return; + lv_img_set_src(arrow, img_right_arrow_.c_str()); + lv_obj_set_pos(arrow, SUB_RIGHT_ARROW_X, row_y(ROW_CENTER) + (row_h() - ARROW_H) / 2); +} + +lv_obj_t *UISetupPage::create_menu_label(lv_obj_t *parent, int vi, int item_idx, int count) +{ + const char *text = (item_idx >= 0 && item_idx < count) + ? menu_items_[item_idx].label.c_str() : ""; + lv_obj_t *lbl = create_carousel_label(parent, vi, ROW_CENTER, text, MENU_X); + if (!lbl) return nullptr; + if (item_idx < 0 || item_idx >= count) lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); + return lbl; +} + +void UISetupPage::apply_overflow_handling(lv_obj_t *lbl, int box_left, int box_w, bool focused) +{ + if (!lbl || box_w <= 0) return; + lv_obj_update_layout(lbl); + if (lv_obj_get_width(lbl) <= box_w) return; + lv_obj_set_width(lbl, box_w); + lv_obj_set_x(lbl, box_left); + lv_label_set_long_mode(lbl, focused ? LV_LABEL_LONG_SCROLL_CIRCULAR : LV_LABEL_LONG_CLIP); +} + +void UISetupPage::apply_fixed_label_box(lv_obj_t *lbl, int x, int y, int w, bool scroll) +{ + if (!lbl || w <= 0) return; + lv_obj_set_pos(lbl, x, y); + lv_obj_set_width(lbl, w); + lv_label_set_long_mode(lbl, scroll ? LV_LABEL_LONG_SCROLL_CIRCULAR : LV_LABEL_LONG_CLIP); +} + +void UISetupPage::clamp_label_box(lv_obj_t *lbl, int x, int w, bool scroll) +{ + if (!lbl || w <= 0) return; + lv_obj_set_x(lbl, x); + lv_obj_set_width(lbl, w); + lv_label_set_long_mode(lbl, scroll ? LV_LABEL_LONG_SCROLL_CIRCULAR : LV_LABEL_LONG_CLIP); +} + +void UISetupPage::apply_overflow_centered(lv_obj_t *lbl, int center_x, int box_w, bool focused) +{ + apply_overflow_handling(lbl, center_x - box_w / 2, box_w, focused); +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_setup_views.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_setup_views.cpp new file mode 100644 index 00000000..72654c52 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_setup_views.cpp @@ -0,0 +1,476 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_setup.hpp" +#include "../model/setup_view_build_contract.hpp" + +void UISetupPage::build_main_view() +{ + lv_obj_t *cont = ui_obj_["list_cont"]; + if (!cont) return; + lv_obj_clean(cont); + sel_bg_ = nullptr; + hint_lbl_ = nullptr; + arrow_up_obj_ = nullptr; + arrow_down_obj_ = nullptr; + for (auto &label : row_labels_) label = nullptr; + + SetupMainViewBuildContract build(ROWS_VISIBLE); + auto rollback = [&]() { + lv_obj_clean(cont); + sel_bg_ = nullptr; + hint_lbl_ = nullptr; + arrow_up_obj_ = nullptr; + arrow_down_obj_ = nullptr; + for (auto &label : row_labels_) label = nullptr; + lifecycle_.finish_animation(); + }; + + int count = (int)menu_items_.size(); + static constexpr int SEL_BAR_H = 23; + static constexpr int SEL_BAR_W = 312; + sel_bg_ = lv_obj_create(cont); + if (!sel_bg_) { + rollback(); + return; + } + build.selection_created(); + lv_obj_set_size(sel_bg_, SEL_BAR_W, SEL_BAR_H); + lv_obj_set_pos(sel_bg_, (SCREEN_W - SEL_BAR_W) / 2, + row_y(ROW_CENTER) + (row_h() - SEL_BAR_H) / 2); + lv_obj_set_style_radius(sel_bg_, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(sel_bg_, lv_color_hex(0x2a2a2a), LV_PART_MAIN); + lv_obj_set_style_bg_opa(sel_bg_, 255, LV_PART_MAIN); + lv_obj_set_style_border_width(sel_bg_, 0, LV_PART_MAIN); + lv_obj_clear_flag(sel_bg_, LV_OBJ_FLAG_SCROLLABLE); + + hint_lbl_ = lv_label_create(cont); + if (!hint_lbl_) { + rollback(); + return; + } + build.hint_created(); + lv_label_set_text(hint_lbl_, "ok:enter"); + lv_obj_set_style_text_color(hint_lbl_, lv_color_hex(0x00CC66), LV_PART_MAIN); + lv_obj_set_style_text_font(hint_lbl_, + launcher_fonts().get("Montserrat-Bold.ttf", 16, + LV_FREETYPE_FONT_STYLE_BOLD), + LV_PART_MAIN); + lv_obj_update_layout(hint_lbl_); + int hint_w = lv_obj_get_width(hint_lbl_); + int hint_h = lv_obj_get_height(hint_lbl_); + lv_obj_set_pos(hint_lbl_, SCREEN_W - 6 - hint_w, + row_y(ROW_CENTER) + (row_h() - hint_h) / 2); + + for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { + int item_idx = selected_idx_ - ROW_CENTER + vi; + row_labels_[vi] = create_menu_label(cont, vi, item_idx, count); + if (!row_labels_[vi]) { + rollback(); + return; + } + build.row_created(); + } + + if (!build.ready()) { + rollback(); + return; + } + + int arrow_x = MENU_X - 8; + arrow_up_obj_ = lv_img_create(cont); + if (arrow_up_obj_) { + lv_img_set_src(arrow_up_obj_, img_arrow_up_.c_str()); + lv_obj_set_pos(arrow_up_obj_, arrow_x, 2); + } + + arrow_down_obj_ = lv_img_create(cont); + if (arrow_down_obj_) { + lv_img_set_src(arrow_down_obj_, img_arrow_down_.c_str()); + lv_obj_set_pos(arrow_down_obj_, arrow_x, LIST_H - 14); + } + lifecycle_.finish_animation(); +} + +void UISetupPage::animate_scroll(int direction) +{ + int count = (int)menu_items_.size(); + if (direction < 0) bounce_arrow(arrow_up_obj_, -1); + else bounce_arrow(arrow_down_obj_, 1); + + if (lifecycle_.animating() || !model_.move_main(direction, count)) return; + if (!lifecycle_.begin_animation()) return; + + for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { + if (!row_labels_[vi]) continue; + int item_idx = selected_idx_ - ROW_CENTER + vi; + if (item_idx >= 0 && item_idx < count) { + lv_label_set_text(row_labels_[vi], menu_items_[item_idx].label.c_str()); + lv_obj_clear_flag(row_labels_[vi], LV_OBJ_FLAG_HIDDEN); + } else { + lv_label_set_text(row_labels_[vi], ""); + lv_obj_add_flag(row_labels_[vi], LV_OBJ_FLAG_HIDDEN); + } + } + + int rh = row_h(); + int offset = direction * rh; + bool completion_scheduled = false; + for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { + if (!row_labels_[vi]) continue; + RowStyle style = style_for_slot(vi); + int font_h = lv_font_get_line_height(style.font); + int target_y = row_y(vi) + (rh - font_h) / 2; + + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, row_labels_[vi]); + lv_anim_set_exec_cb(&animation, (lv_anim_exec_xcb_t)lv_obj_set_y); + lv_anim_set_values(&animation, target_y + offset, target_y); + lv_anim_set_time(&animation, ANIM_TIME); + lv_anim_set_path_cb(&animation, lv_anim_path_ease_out); + if (vi == ROW_CENTER) { + lv_anim_set_completed_cb(&animation, anim_done_cb); + lv_anim_set_user_data(&animation, this); + } + lv_anim_t *started = lv_anim_start(&animation); + if (vi == ROW_CENTER) completion_scheduled = started != nullptr; + + lv_obj_set_style_text_color(row_labels_[vi], lv_color_hex(style.color), LV_PART_MAIN); + lv_obj_set_style_text_font(row_labels_[vi], style.font, LV_PART_MAIN); + lv_obj_set_style_opa(row_labels_[vi], style.opa, LV_PART_MAIN); + lv_obj_update_layout(row_labels_[vi]); + int label_x = MENU_X - lv_obj_get_width(row_labels_[vi]) / 2; + lv_obj_set_x(row_labels_[vi], label_x < 4 ? 4 : label_x); + } + if (!completion_scheduled) lifecycle_.finish_animation(); +} + +void UISetupPage::cancel_scroll_animations() +{ + lifecycle_.cancel_animation(); + for (auto *label : row_labels_) { + if (label) lv_anim_del(label, nullptr); + } +} + +void UISetupPage::anim_done_cb(lv_anim_t *animation) noexcept +{ + auto *self = static_cast(lv_anim_get_user_data(animation)); + if (self) self->lifecycle_.finish_animation(); +} + +void UISetupPage::rebuild_view() +{ + if (view_state_ == ViewState::MAIN) build_main_view(); + else if (view_state_ == ViewState::SUB) build_sub_view(); + else if (view_state_ == ViewState::VALUE_SELECT) build_value_view(); + else if (view_state_ == ViewState::HELP) setting::Help::build_page(*this); + else if (view_state_ == ViewState::WIFI_LIST) wifi_.build_list(*this); + else if (view_state_ == ViewState::BT_LIST) bluetooth_.build_list(*this); + else if (view_state_ == ViewState::BT_ALIAS) bluetooth_.build_alias_view(*this); + else if (view_state_ == ViewState::SOUNDCARD_CARDS) soundcard_.build_cards_view(*this); + else if (view_state_ == ViewState::SOUNDCARD_CONTROLS) soundcard_.build_controls_view(*this); + else if (view_state_ == ViewState::SOUNDCARD_DETAIL) soundcard_.build_detail_view(*this); + else if (view_state_ == ViewState::ADB_PAIR) developer_.enter_pair_view(*this); + else if (view_state_ == ViewState::ADB_AUTHORIZATIONS) + developer_.build_authorizations_view(*this); +} + +void UISetupPage::bounce_arrow(lv_obj_t *arrow, int direction) +{ + if (!arrow) return; + int current_y = lv_obj_get_y(arrow); + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, arrow); + lv_anim_set_exec_cb(&animation, (lv_anim_exec_xcb_t)lv_obj_set_y); + lv_anim_set_values(&animation, current_y + direction * 4, current_y); + lv_anim_set_time(&animation, 150); + lv_anim_set_path_cb(&animation, lv_anim_path_ease_out); + lv_anim_start(&animation); +} + +void UISetupPage::slide_transition(int direction) +{ + lv_obj_t *background = ui_obj_["bg"]; + lv_obj_t *old_container = ui_obj_["list_cont"]; + if (!background || !old_container) { + rebuild_view(); + return; + } + + lv_obj_t *new_container = lv_obj_create(background); + if (!new_container) { + rebuild_view(); + return; + } + lv_obj_set_size(new_container, SCREEN_W, LIST_H); + lv_obj_set_pos(new_container, direction * SCREEN_W, 0); + lv_obj_set_style_radius(new_container, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(new_container, 0, LV_PART_MAIN); + lv_obj_set_style_border_width(new_container, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(new_container, 0, LV_PART_MAIN); + lv_obj_clear_flag(new_container, LV_OBJ_FLAG_SCROLLABLE); + + ui_obj_["list_cont"] = new_container; + rebuild_view(); + + lv_anim_t old_animation; + lv_anim_init(&old_animation); + lv_anim_set_var(&old_animation, old_container); + lv_anim_set_exec_cb(&old_animation, (lv_anim_exec_xcb_t)lv_obj_set_x); + lv_anim_set_values(&old_animation, 0, -direction * SCREEN_W); + lv_anim_set_time(&old_animation, 200); + lv_anim_set_path_cb(&old_animation, lv_anim_path_ease_out); + lv_anim_set_completed_cb(&old_animation, slide_old_done_cb); + lv_anim_set_user_data(&old_animation, old_container); + if (!lv_anim_start(&old_animation)) + lv_obj_del(old_container); + + lv_anim_t new_animation; + lv_anim_init(&new_animation); + lv_anim_set_var(&new_animation, new_container); + lv_anim_set_exec_cb(&new_animation, (lv_anim_exec_xcb_t)lv_obj_set_x); + lv_anim_set_values(&new_animation, direction * SCREEN_W, 0); + lv_anim_set_time(&new_animation, 200); + lv_anim_set_path_cb(&new_animation, lv_anim_path_ease_out); + if (!lv_anim_start(&new_animation)) + lv_obj_set_x(new_container, 0); +} + +void UISetupPage::slide_old_done_cb(lv_anim_t *animation) noexcept +{ + auto *old_container = static_cast(lv_anim_get_user_data(animation)); + if (old_container) lv_obj_del(old_container); +} + +void UISetupPage::transition_enter_level() +{ + slide_transition(1); +} + +void UISetupPage::transition_back_level() +{ + slide_transition(-1); +} + +void UISetupPage::build_sub_view() +{ + lv_obj_t *container = ui_obj_["list_cont"]; + if (!container || selected_idx_ < 0 || + selected_idx_ >= static_cast(menu_items_.size())) return; + info_.reset_visible_labels(); + lv_obj_clean(container); + + MenuItem &item = menu_items_[selected_idx_]; + int sub_count = static_cast(item.sub_items.size()); + int menu_count = static_cast(menu_items_.size()); + + static constexpr int SEL_BAR_H = 23; + static constexpr int SEL_BAR_W = 312; + lv_obj_t *bar = lv_obj_create(container); + if (!bar) return; + lv_obj_set_size(bar, SEL_BAR_W, SEL_BAR_H); + lv_obj_set_pos(bar, (SCREEN_W - SEL_BAR_W) / 2, + row_y(ROW_CENTER) + (row_h() - SEL_BAR_H) / 2); + lv_obj_set_style_radius(bar, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(bar, lv_color_hex(0x2a2a2a), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bar, 255, LV_PART_MAIN); + lv_obj_set_style_border_width(bar, 0, LV_PART_MAIN); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + + for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { + int item_index = selected_idx_ - ROW_CENTER + vi; + if (item_index < 0 || item_index >= menu_count) continue; + lv_obj_t *label = create_menu_label(container, vi, item_index, menu_count); + clamp_label_box(label, SUB_LEFT_BOX_X, SUB_LEFT_BOX_W, vi == ROW_CENTER); + } + + if (sub_count == 0) { + create_carousel_label(container, ROW_CENTER, ROW_CENTER, + "(no options)", SUB_CENTER_X, true); + return; + } + if (sub_selected_idx_ < 0 || sub_selected_idx_ >= sub_count) return; + + struct SubLabelInfo { + lv_obj_t *label; + int sub_index; + }; + SubLabelInfo sub_labels[ROWS_VISIBLE] = {}; + int sub_label_count = 0; + + for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { + int sub_index = sub_selected_idx_ - ROW_CENTER + vi; + if (sub_index < 0 || sub_index >= sub_count) continue; + + SubItem &sub = item.sub_items[sub_index]; + lv_obj_t *label = create_carousel_label(container, vi, ROW_CENTER, + sub.label.c_str(), SUB_CENTER_X, true); + if (!label) continue; + bool focused = vi == ROW_CENTER; + apply_overflow_centered(label, SUB_CENTER_X, focused ? 80 : VALUE_BOX_W, focused); + if (item.label == "Info" && sub_index < 4) + info_.track_visible_label(sub_index, label, focused, sub.label); + sub_labels[sub_label_count++] = {label, sub_index}; + } + + static constexpr int INDICATOR_X = 220; + for (int i = 0; i < sub_label_count; ++i) { + SubItem &sub = item.sub_items[sub_labels[i].sub_index]; + if (!sub.is_toggle) continue; + + lv_obj_t *label = sub_labels[i].label; + if (!label) continue; + lv_obj_t *indicator = lv_img_create(container); + if (!indicator) continue; + lv_img_set_src(indicator, sub.toggle_state ? img_ok_.c_str() : img_cross_.c_str()); + lv_obj_update_layout(indicator); + int x_offset = sub.toggle_state ? 0 : 1; + int indicator_y = lv_obj_get_y(label) + + (lv_obj_get_height(label) - lv_obj_get_height(indicator)) / 2; + lv_obj_set_pos(indicator, INDICATOR_X + x_offset, indicator_y); + } + + place_fixed_sub_arrow(container); + + int arrow_x = SUB_CENTER_X - 8; + if (sub_selected_idx_ > 0) { + lv_obj_t *arrow = lv_img_create(container); + if (arrow) { + lv_img_set_src(arrow, img_arrow_up_.c_str()); + lv_obj_set_pos(arrow, arrow_x, 2); + } + } + if (sub_selected_idx_ < sub_count - 1) { + lv_obj_t *arrow = lv_img_create(container); + if (arrow) { + lv_img_set_src(arrow, img_arrow_down_.c_str()); + lv_obj_set_pos(arrow, arrow_x, LIST_H - 14); + } + } + + SubItem ¤t_sub = item.sub_items[sub_selected_idx_]; + lv_obj_t *hint = lv_label_create(container); + if (!hint) return; + if (current_sub.is_toggle && item.label == "RTC" && current_sub.label == "NTP") + lv_label_set_text(hint, rtc_.ntp_available() + ? (current_sub.toggle_state ? "ok:disable" : "ok:enable") + : "unavailable"); + else if (current_sub.is_toggle && item.label == "WiFi" && current_sub.label == "Power") + lv_label_set_text(hint, current_sub.toggle_state ? "ok:disable" : "ok:enable"); + else if (current_sub.is_toggle && item.label == "Bluetooth" && + current_sub.label == "Named Only") + lv_label_set_text(hint, current_sub.toggle_state ? "ok:show all" : "ok:named"); + else if (current_sub.is_toggle) + lv_label_set_text(hint, current_sub.toggle_state ? "ok:hide" : "ok:show"); + else if (item.label == "RTC" && rtc_.ntp_on()) + lv_label_set_text(hint, "ntp on"); + else + lv_label_set_text(hint, "ok:enter"); + lv_obj_set_style_text_color(hint, lv_color_hex(0x00CC66), LV_PART_MAIN); + lv_obj_set_style_text_font(hint, + launcher_fonts().get("Montserrat-Bold.ttf", 16, + LV_FREETYPE_FONT_STYLE_BOLD), + LV_PART_MAIN); + apply_right_hint_overflow(hint, row_y(ROW_CENTER)); +} + +void UISetupPage::apply_right_hint_overflow(lv_obj_t *hint, int row_top_y) +{ + if (!hint) return; + lv_obj_update_layout(hint); + int hint_width = lv_obj_get_width(hint); + int y = row_top_y + (row_h() - lv_obj_get_height(hint)) / 2; + if (hint_width > RIGHT_HINT_BOX_W) { + lv_obj_set_width(hint, RIGHT_HINT_BOX_W); + lv_obj_set_pos(hint, SCREEN_W - 6 - RIGHT_HINT_BOX_W, y); + lv_label_set_long_mode(hint, LV_LABEL_LONG_SCROLL_CIRCULAR); + } else { + lv_obj_set_pos(hint, SCREEN_W - 6 - hint_width, y); + } +} + +void UISetupPage::build_value_view() +{ + lv_obj_t *container = ui_obj_["list_cont"]; + if (!container || selected_idx_ < 0 || + selected_idx_ >= static_cast(menu_items_.size())) return; + lv_obj_clean(container); + + int sub_count = static_cast(menu_items_[selected_idx_].sub_items.size()); + int value_count = static_cast(val_options_.size()); + static constexpr int SEL_BAR_H = 22; + static constexpr int SEL_BAR_W = 312; + lv_obj_t *bar = lv_obj_create(container); + if (!bar) return; + lv_obj_set_size(bar, SEL_BAR_W, SEL_BAR_H); + lv_obj_set_pos(bar, (SCREEN_W - SEL_BAR_W) / 2, + row_y(ROW_CENTER) + (row_h() - SEL_BAR_H) / 2); + lv_obj_set_style_radius(bar, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(bar, lv_color_hex(0x2a2a2a), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bar, 255, LV_PART_MAIN); + lv_obj_set_style_border_width(bar, 0, LV_PART_MAIN); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + + static constexpr int LEFT_BOX_W = 84; + lv_obj_t *center_label = nullptr; + for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { + int sub_index = sub_selected_idx_ - ROW_CENTER + vi; + if (sub_index < 0 || sub_index >= sub_count) continue; + const char *text = menu_items_[selected_idx_].sub_items[sub_index].label.c_str(); + lv_obj_t *label = create_carousel_label(container, vi, ROW_CENTER, text, MENU_X); + apply_overflow_centered(label, MENU_X, LEFT_BOX_W, vi == ROW_CENTER); + if (vi == ROW_CENTER) center_label = label; + } + + static constexpr int VALUE_CENTER_X = 160; + int right_min_x = SCREEN_W; + for (int vi = 0; vi < ROWS_VISIBLE; ++vi) { + int value_index = val_sel_idx_ - ROW_CENTER + vi; + if (value_index < 0 || value_index >= value_count) continue; + lv_obj_t *label = create_carousel_label(container, vi, ROW_CENTER, + val_options_[value_index].c_str(), + VALUE_CENTER_X, true); + if (!label) continue; + apply_overflow_centered(label, VALUE_CENTER_X, VALUE_BOX_W, vi == ROW_CENTER); + lv_obj_update_layout(label); + if (lv_obj_get_x(label) < right_min_x) right_min_x = lv_obj_get_x(label); + } + + if (center_label && value_count > 0) + place_blue_arrow(container, center_label, right_min_x); + + int arrow_x = VALUE_CENTER_X - 8; + if (val_sel_idx_ > 0) { + lv_obj_t *arrow = lv_img_create(container); + if (arrow) { + lv_img_set_src(arrow, img_arrow_up_.c_str()); + lv_obj_set_pos(arrow, arrow_x, 2); + } + } + if (val_sel_idx_ < value_count - 1) { + lv_obj_t *arrow = lv_img_create(container); + if (arrow) { + lv_img_set_src(arrow, img_arrow_down_.c_str()); + lv_obj_set_pos(arrow, arrow_x, LIST_H - 14); + } + } + + lv_obj_t *hint = lv_label_create(container); + if (!hint) return; + lv_label_set_text(hint, "ok:set"); + lv_obj_set_style_text_color(hint, lv_color_hex(0x00CC66), LV_PART_MAIN); + lv_obj_set_style_text_font(hint, + launcher_fonts().get("Montserrat-Bold.ttf", 16, + LV_FREETYPE_FONT_STYLE_BOLD), + LV_PART_MAIN); + lv_obj_update_layout(hint); + lv_obj_set_pos(hint, SCREEN_W - 6 - lv_obj_get_width(hint), + row_y(ROW_CENTER) + (row_h() - lv_obj_get_height(hint)) / 2); +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_ssh.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_ssh.cpp new file mode 100644 index 00000000..99431857 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_ssh.cpp @@ -0,0 +1,215 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_ssh.hpp" + +#include "input_keys.h" +#include "../keyboard_text_input.hpp" +#include "sample_log.h" +#include "ui_app_st.hpp" + +#include +#include + +UISSHPage::UISSHPage() : AppPage() +{ + set_page_title("SSH"); + create_ui(); + if (root_screen_ && form_container_) event_handler_init(); +} + +UISSHPage::~UISSHPage() +{ + restore_operation_.shutdown(); + detach_delete_callbacks(); + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UISSHPage::static_lvgl_handler, this); + if (terminal_page_) + terminal_page_->navigate_home = nullptr; + terminal_page_.reset(); +} + +void UISSHPage::do_connect() +{ + const auto arguments = model_.arguments(); + if (arguments.empty()) return; + SLOGI("[SSH] Launching structured ssh request"); + + restore_operation_.abort(restore_token_); + restore_token_ = restore_operation_.begin(); + if (!restore_token_) + return; + + try { + terminal_page_ = std::make_shared(); + } catch (...) { + restore_operation_.abort(restore_token_); + return; + } + if (!terminal_page_ || !terminal_page_->screen()) { + terminal_page_.reset(); + restore_operation_.abort(restore_token_); + return; + } + terminal_page_->navigate_home = [page = this, token = restore_token_]() { + if (!token.current()) return; + UISSHPage *self = page; + if (self->terminal_return_pending_) return; + self->terminal_return_pending_ = true; + struct RestoreContext { + UISSHPage *page; + setting::AsyncOperationLifecycle::Token token; + }; + auto *context = new (std::nothrow) RestoreContext{self, token}; + if (!context) { + self->terminal_return_pending_ = false; + return; + } + if (lv_async_call([](void *user) noexcept { + std::unique_ptr context(static_cast(user)); + bool owner_confirmed = false; + try { + owner_confirmed = ssh_restore_completion_allowed( + context->token.complete(), context->page); + if (!owner_confirmed) return; + context->page->restore_input_view(); + } catch (...) { + if (owner_confirmed) + context->page->terminal_return_pending_ = false; + } + }, context) != LV_RESULT_OK) { + delete context; + self->terminal_return_pending_ = false; + } + }; + + try { + terminal_page_->exec("ssh", arguments); + } catch (...) { + terminal_page_->navigate_home = nullptr; + terminal_page_.reset(); + restore_operation_.abort(restore_token_); + return; + } + + view_state_ = ViewState::TERMINAL; + terminal_return_pending_ = false; + lv_disp_load_scr(terminal_page_->screen()); + if (lv_indev_t *input = lv_indev_get_next(nullptr)) { + lv_indev_set_group(input, terminal_page_->input_group()); + } +} + +void UISSHPage::restore_input_view() +{ + if (screen()) lv_disp_load_scr(screen()); + if (lv_indev_t *input = lv_indev_get_next(nullptr)) { + lv_indev_set_group(input, input_group()); + } + if (terminal_page_) + terminal_page_->navigate_home = nullptr; + terminal_page_.reset(); + view_state_ = ViewState::INPUT; + terminal_return_pending_ = false; +} + +void UISSHPage::event_handler_init() +{ + if (!root_screen_) return; + lv_obj_add_event_cb(root_screen_, UISSHPage::static_lvgl_handler, LV_EVENT_ALL, this); +} + +void UISSHPage::static_lvgl_handler(lv_event_t *event) noexcept +{ + try { + if (!event) return; + auto *page = static_cast(lv_event_get_user_data(event)); + if (!page || !ssh_page_event_callback_allowed( + lv_event_get_current_target(event), page->root_screen_)) + return; + page->event_handler(event); + } catch (...) { + } +} + +void UISSHPage::rebuild_or_restore(SshConnectionModel previous) noexcept +{ + try { + if (build_input_fields()) return; + } catch (...) { + } + model_ = std::move(previous); +} + +void UISSHPage::event_handler(lv_event_t *event) +{ + if (lv_event_get_code(event) == LV_EVENT_DELETE && + lv_event_get_target(event) == lv_event_get_current_target(event)) { + restore_operation_.shutdown(); + if (terminal_page_) + terminal_page_->navigate_home = nullptr; + return; + } + if (view_state_ != ViewState::INPUT || !launcher_ui::events::is_key_released(event)) return; + const uint32_t key = launcher_ui::events::keyboard_key(event); + switch (key) { + case KEY_UP: { + SshConnectionModel previous = model_; + if (model_.select_previous()) rebuild_or_restore(std::move(previous)); + break; + } + case KEY_DOWN: { + SshConnectionModel previous = model_; + if (model_.select_next()) rebuild_or_restore(std::move(previous)); + break; + } + case KEY_ENTER: + do_connect(); + break; + case KEY_ESC: + if (navigate_home) navigate_home(); + break; + case KEY_BACKSPACE: { + SshConnectionModel previous = model_; + if (model_.erase_last()) rebuild_or_restore(std::move(previous)); + break; + } + default: { + SshConnectionModel previous = model_; + const struct key_item *item = launcher_ui::events::keyboard_item(event); + if (model_.append(launcher_ui::text_input::key_text(key, item))) + rebuild_or_restore(std::move(previous)); + break; + } + } +} + +void UISSHPage::static_owned_obj_delete_cb(lv_event_t *event) noexcept +{ + try { + if (!event || !ssh_owned_delete_callback_allowed( + lv_event_get_target(event), lv_event_get_current_target(event))) + return; + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + lv_obj_t *deleted = static_cast(lv_event_get_target(event)); + if (deleted == self->background_) { + self->background_ = nullptr; + self->form_container_ = nullptr; + } else if (deleted == self->form_container_) { + self->form_container_ = nullptr; + } + } catch (...) { + } +} + +void UISSHPage::detach_delete_callbacks() +{ + if (form_container_) + lv_obj_remove_event_cb_with_user_data( + form_container_, static_owned_obj_delete_cb, this); + if (background_) + lv_obj_remove_event_cb_with_user_data( + background_, static_owned_obj_delete_cb, this); + form_container_ = nullptr; + background_ = nullptr; +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_ssh.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_ssh.hpp index 4413fc7a..3baeb0ac 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_ssh.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_ssh.hpp @@ -5,15 +5,13 @@ */ #pragma once -#include "sample_log.h" -#include "../ui_app_page.hpp" -#include "ui_app_st.hpp" -#include "compat/input_keys.h" -#include -#include -#include +#include "../launcher_ui_app_page.hpp" +#include "../model/async_operation_lifecycle.hpp" +#include "../model/ssh_connection_model.hpp" +#include "../model/ssh_view_build_contract.hpp" #include -#include + +class UISTPage; // ============================================================ // SSH Client UISSHPage @@ -28,322 +26,39 @@ class UISSHPage : public AppPage { enum class ViewState { INPUT, TERMINAL }; - struct InputField - { - const char *label; - std::string value; - }; - public: - UISSHPage() : AppPage() - { - set_page_title("SSH"); - fields_.resize(3); - fields_[0] = {"Host", "192.168.1.1"}; - fields_[1] = {"Port", "22"}; - fields_[2] = {"User", "pi"}; - creat_UI(); - event_handler_init(); - } + UISSHPage(); - ~UISSHPage() - { - terminal_page_.reset(); - } + ~UISSHPage(); private: // ==================== data members ==================== - std::unordered_map ui_obj_; - std::vector fields_; - int active_field_ = 0; + SshConnectionModel model_; + lv_obj_t *background_ = nullptr; + lv_obj_t *form_container_ = nullptr; ViewState view_state_ = ViewState::INPUT; std::shared_ptr terminal_page_; bool terminal_return_pending_ = false; - - // ==================== keycode to char ==================== - static char keycode_to_char(uint32_t key) - { - if (key >= KEY_1 && key <= KEY_9) return '1' + (key - KEY_1); - if (key == KEY_0) return '0'; - static const char qwerty[] = "qwertyuiop"; - if (key >= KEY_Q && key <= KEY_P) return qwerty[key - KEY_Q]; - static const char asdf[] = "asdfghjkl"; - if (key >= KEY_A && key <= KEY_L) return asdf[key - KEY_A]; - static const char zxcv[] = "zxcvbnm"; - if (key >= KEY_Z && key <= KEY_M) return zxcv[key - KEY_Z]; - if (key == KEY_SPACE) return ' '; - // common symbols needed for IP addresses and hostnames - if (key == 52) return '.'; // KEY_DOT - if (key == 12) return '-'; // KEY_MINUS - return 0; - } + setting::AsyncOperationLifecycle restore_operation_; + setting::AsyncOperationLifecycle::Token restore_token_; // ==================== UI construction (input view) ==================== - void creat_UI() - { - // ---- background ---- - lv_obj_t *bg = lv_obj_create(ui_APP_Container); - lv_obj_set_size(bg, 320, 150); - lv_obj_set_pos(bg, 0, 0); - lv_obj_set_style_radius(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x0D1117), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["bg"] = bg; - - // ---- title bar ---- - lv_obj_t *title_bar = lv_obj_create(bg); - lv_obj_set_size(title_bar, 320, 22); - lv_obj_set_pos(title_bar, 0, 0); - lv_obj_set_style_radius(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(title_bar, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(title_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(title_bar, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *lbl_title = lv_label_create(title_bar); - lv_label_set_text(lbl_title, "SSH Client"); - lv_obj_set_align(lbl_title, LV_ALIGN_LEFT_MID); - lv_obj_set_style_text_color(lbl_title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_t *lbl_hint_title = lv_label_create(title_bar); - lv_label_set_text(lbl_hint_title, "OK:Connect ESC:Back"); - lv_obj_set_align(lbl_hint_title, LV_ALIGN_RIGHT_MID); - lv_obj_set_x(lbl_hint_title, -4); - lv_obj_set_style_text_color(lbl_hint_title, lv_color_hex(0x7EA8D8), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_hint_title, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - - // ---- input fields ---- - build_input_fields(); - } - - void build_input_fields() - { - lv_obj_t *bg = ui_obj_["bg"]; - - // remove old field objects if they exist - for (int i = 0; i < 3; ++i) - { - std::string key_row = "field_row_" + std::to_string(i); - if (ui_obj_.count(key_row) && ui_obj_[key_row]) - { - lv_obj_del(ui_obj_[key_row]); - ui_obj_[key_row] = nullptr; - } - } - if (ui_obj_.count("hint_lbl") && ui_obj_["hint_lbl"]) - { - lv_obj_del(ui_obj_["hint_lbl"]); - ui_obj_["hint_lbl"] = nullptr; - } - - int start_y = 30; - int field_h = 30; - int gap = 4; - - for (int i = 0; i < 3; ++i) - { - bool active = (i == active_field_); - int y = start_y + i * (field_h + gap); - - // field container - lv_obj_t *row = lv_obj_create(bg); - lv_obj_set_size(row, 300, field_h); - lv_obj_set_pos(row, 10, y); - lv_obj_set_style_radius(row, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, active ? 1 : 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(row, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); - - uint32_t bg_color = active ? 0x1F3A5F : 0x161B22; - lv_obj_set_style_bg_color(row, lv_color_hex(bg_color), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(row, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - // active indicator bar - if (active) - { - lv_obj_t *bar = lv_obj_create(row); - lv_obj_set_size(bar, 3, field_h - 8); - lv_obj_set_pos(bar, 2, 3); - lv_obj_set_style_radius(bar, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(bar, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); - } - - // label (field name) - lv_obj_t *lbl = lv_label_create(row); - lv_label_set_text(lbl, fields_[i].label); - lv_obj_set_pos(lbl, 10, (field_h - 14) / 2); - lv_obj_set_style_text_color(lbl, - active ? lv_color_hex(0x58A6FF) : lv_color_hex(0x7EA8D8), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - - // value (editable text) - lv_obj_t *val_lbl = lv_label_create(row); - std::string display = fields_[i].value; - if (active) display += "_"; - lv_label_set_text(val_lbl, display.c_str()); - lv_obj_set_pos(val_lbl, 60, (field_h - 14) / 2); - lv_obj_set_width(val_lbl, 228); - lv_label_set_long_mode(val_lbl, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(val_lbl, - active ? lv_color_hex(0xFFFFFF) : lv_color_hex(0xCCCCCC), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(val_lbl, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - - std::string key_row = "field_row_" + std::to_string(i); - ui_obj_[key_row] = row; - } + void create_ui(); - // hint at bottom - lv_obj_t *lbl_bottom = lv_label_create(bg); - lv_label_set_text(lbl_bottom, "UP/DN:field Type to edit OK:Connect ESC:Back"); - lv_obj_set_pos(lbl_bottom, 10, 135); - lv_obj_set_width(lbl_bottom, 300); - lv_label_set_long_mode(lbl_bottom, LV_LABEL_LONG_CLIP); - lv_obj_set_style_text_color(lbl_bottom, lv_color_hex(0x555555), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_bottom, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - ui_obj_["hint_lbl"] = lbl_bottom; - } + bool build_input_fields(); + void rebuild_or_restore(SshConnectionModel previous) noexcept; // ==================== connect via SSH ==================== - void do_connect() - { - std::string host = fields_[0].value; - std::string port = fields_[1].value; - std::string user = fields_[2].value; + void do_connect(); - if (host.empty()) return; - - // Build ssh command - std::string cmd = "ssh -o StrictHostKeyChecking=no " + user + "@" + host; - if (!port.empty() && port != "22") - cmd += " -p " + port; - - SLOGI("[SSH] Launching: %s", cmd.c_str()); - - // Create terminal page - terminal_page_ = std::make_shared(); - - // Restore the SSH input view when the embedded terminal exits. - terminal_page_->navigate_home = [this]() { - if (terminal_return_pending_) - return; - terminal_return_pending_ = true; - lv_async_call(UISSHPage::static_restore_input_view, this); - }; - - // Switch to terminal screen - view_state_ = ViewState::TERMINAL; - terminal_return_pending_ = false; - lv_disp_load_scr(terminal_page_->screen()); - lv_indev_set_group(lv_indev_get_next(NULL), terminal_page_->input_group()); - - // Launch ssh command - terminal_page_->exec(cmd); - } - - static void static_restore_input_view(void *user) - { - auto *self = static_cast(user); - if (self) - self->restore_input_view(); - } - - void restore_input_view() - { - lv_disp_load_scr(this->screen()); - lv_indev_t *indev = lv_indev_get_next(NULL); - if (indev) - lv_indev_set_group(indev, this->input_group()); - - terminal_page_.reset(); - view_state_ = ViewState::INPUT; - terminal_return_pending_ = false; - } + void restore_input_view(); // ==================== event binding ==================== - void event_handler_init() - { - lv_obj_add_event_cb(root_screen_, UISSHPage::static_lvgl_handler, LV_EVENT_ALL, this); - } - - static void static_lvgl_handler(lv_event_t *e) - { - UISSHPage *self = static_cast(lv_event_get_user_data(e)); - if (self) self->event_handler(e); - } - - void event_handler(lv_event_t *e) - { - // Only handle input view events; terminal view is handled by UISTPage - if (view_state_ != ViewState::INPUT) return; - - if (launcher_ui::events::is_key_released(e)) - { - uint32_t key = launcher_ui::events::keyboard_key(e); - - switch (key) - { - case KEY_UP: - if (active_field_ > 0) - { - --active_field_; - build_input_fields(); - } - break; - - case KEY_DOWN: - if (active_field_ < 2) - { - ++active_field_; - build_input_fields(); - } - break; - - case KEY_ENTER: - do_connect(); - break; - - case KEY_ESC: - if (navigate_home) navigate_home(); - break; - - case KEY_BACKSPACE: - if (!fields_[active_field_].value.empty()) - { - fields_[active_field_].value.pop_back(); - build_input_fields(); - } - break; + void event_handler_init(); - default: - { - const struct key_item *elm = launcher_ui::events::keyboard_item(e); - if (elm && elm->utf8[0] && (unsigned char)elm->utf8[0] >= 0x20) - { - fields_[active_field_].value += elm->utf8; - build_input_fields(); - break; - } + static void static_lvgl_handler(lv_event_t *e) noexcept; + static void static_owned_obj_delete_cb(lv_event_t *event) noexcept; - char ch = keycode_to_char(key); - if (ch) - { - fields_[active_field_].value += ch; - build_input_fields(); - } - break; - } - } - } - } + void event_handler(lv_event_t *e); + void detach_delete_callbacks(); }; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_ssh_view.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_ssh_view.cpp new file mode 100644 index 00000000..026e83d6 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_ssh_view.cpp @@ -0,0 +1,172 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_ssh.hpp" +#include "../model/ssh_view_build_contract.hpp" + +void UISSHPage::create_ui() +{ + if (!ui_APP_Container) return; + auto rollback = [this]() { + if (background_) lv_obj_delete(background_); + background_ = nullptr; + form_container_ = nullptr; + }; + background_ = lv_obj_create(ui_APP_Container); + if (!background_) return; + lv_obj_add_event_cb( + background_, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(background_, 320, 150); + lv_obj_set_pos(background_, 0, 0); + lv_obj_set_style_radius(background_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(background_, lv_color_hex(0x0D1117), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(background_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(background_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(background_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(background_, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title_bar = lv_obj_create(background_); + if (!title_bar) { + rollback(); + return; + } + lv_obj_set_size(title_bar, 320, 22); + lv_obj_set_pos(title_bar, 0, 0); + lv_obj_set_style_radius(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(title_bar, lv_color_hex(0x1F3A5F), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(title_bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(title_bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(title_bar, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title = lv_label_create(title_bar); + if (!title) { + rollback(); + return; + } + lv_label_set_text(title, "SSH Client"); + lv_obj_set_align(title, LV_ALIGN_LEFT_MID); + lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *hint = lv_label_create(title_bar); + if (!hint) { + rollback(); + return; + } + lv_label_set_text(hint, "OK:Connect ESC:Back"); + lv_obj_set_align(hint, LV_ALIGN_RIGHT_MID); + lv_obj_set_x(hint, -4); + lv_obj_set_style_text_color(hint, lv_color_hex(0x7EA8D8), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + + if (!build_input_fields()) { + rollback(); + return; + } +} + +bool UISSHPage::build_input_fields() +{ + if (!background_) return false; + lv_obj_t *candidate = lv_obj_create(background_); + if (!candidate) return false; + try { + lv_obj_set_size(candidate, 320, 128); + lv_obj_set_pos(candidate, 0, 22); + lv_obj_set_style_bg_opa(candidate, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(candidate, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(candidate, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(candidate, LV_OBJ_FLAG_SCROLLABLE); + + SshViewBuildContract build(SshConnectionModel::FIELD_COUNT); + constexpr int row_height = 30; + constexpr int row_gap = 4; + for (size_t index = 0; index < SshConnectionModel::FIELD_COUNT; ++index) { + const bool active = index == model_.active_index(); + const int y = 8 + static_cast(index) * (row_height + row_gap); + lv_obj_t *row = lv_obj_create(candidate); + if (!row) { + lv_obj_delete(candidate); + return false; + } + lv_obj_set_size(row, 300, row_height); + lv_obj_set_pos(row, 10, y); + lv_obj_set_style_radius(row, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, active ? 1 : 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(row, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(row, lv_color_hex(active ? 0x1F3A5F : 0x161B22), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(row, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + if (active) { + lv_obj_t *bar = lv_obj_create(row); + if (!bar) { + lv_obj_delete(candidate); + return false; + } + lv_obj_set_size(bar, 3, row_height - 8); + lv_obj_set_pos(bar, 2, 3); + lv_obj_set_style_radius(bar, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(bar, lv_color_hex(0x1F6FEB), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(bar, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(bar, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + } + + lv_obj_t *label = lv_label_create(row); + if (!label) { + lv_obj_delete(candidate); + return false; + } + lv_label_set_text(label, model_.label(index)); + lv_obj_set_pos(label, 10, 8); + lv_obj_set_style_text_color(label, lv_color_hex(active ? 0x58A6FF : 0x7EA8D8), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(label, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); + + std::string display = model_.value(index); + if (active) display += "_"; + lv_obj_t *value = lv_label_create(row); + if (!value) { + lv_obj_delete(candidate); + return false; + } + lv_label_set_text(value, display.c_str()); + lv_obj_set_pos(value, 60, 7); + lv_obj_set_width(value, 228); + lv_label_set_long_mode(value, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(value, lv_color_hex(active ? 0xFFFFFF : 0xCCCCCC), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(value, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + build.row_completed(); + } + + lv_obj_t *hint = lv_label_create(candidate); + if (!hint) { + lv_obj_delete(candidate); + return false; + } + lv_label_set_text(hint, "UP/DN:field Type to edit OK:Connect ESC:Back"); + lv_obj_set_pos(hint, 10, 113); + lv_obj_set_width(hint, 300); + lv_label_set_long_mode(hint, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_color(hint, lv_color_hex(0x555555), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + build.hint_completed(); + if (!build.ready()) { + lv_obj_delete(candidate); + return false; + } + + lv_obj_add_event_cb( + candidate, static_owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_t *old = form_container_; + form_container_ = candidate; + if (old) lv_obj_delete(old); + return true; + } catch (...) { + lv_obj_delete(candidate); + return false; + } +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_st.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_st.cpp new file mode 100644 index 00000000..fad215d8 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_st.cpp @@ -0,0 +1,234 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_st.hpp" + +#include + +#include +#include +#include + +UISTPage::UISTPage() + : AppPage() +{ + set_page_title("CLI"); + reset_terminal(); + create_ui(); + if (!renderer_ready()) { + if (terminal_container_) lv_obj_delete(terminal_container_); + return; + } + bind_events(); + start_shell(); +} +UISTPage::~UISTPage() +{ + terminal_active_ = false; + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UISTPage::static_event_cb, this); + stop_timers(); + stop_pty(); + detach_renderer_callbacks(); +} + +void UISTPage::exec(std::string command) +{ + std::vector tokens; + std::istringstream stream(command); + std::string token; + while (stream >> token) tokens.push_back(token); + std::list arguments; + for (size_t index = 1; index < tokens.size(); ++index) arguments.push_back(tokens[index]); + exec(tokens.empty() ? std::string() : tokens.front(), arguments); +} + +void UISTPage::exec(const std::string &command, const std::list &arguments) +{ + stop_timers(); + stop_pty(); + + terminal_active_ = true; + waiting_key_to_exit_ = false; + big_mode_ = false; + term_cols_ = NORMAL_COLS; + term_rows_ = NORMAL_ROWS; + viewport_x_ = 0; + viewport_y_ = 0; + big_view_locked_ = false; + reset_terminal(); + update_big_mode_ui(); + render_all(); + + if (command.empty()) { + static constexpr char ERROR_MESSAGE[] = "Error: empty command\r\n"; + process_bytes(ERROR_MESSAGE, sizeof(ERROR_MESSAGE) - 1); + render_all(); + terminal_active_ = false; + waiting_key_to_exit_ = true; + return; + } + + start_command(command, arguments, command.c_str(), "Error: openpty/fork failed\r\n"); +} + +int UISTPage::clamp(int value, int low, int high) +{ + if (value < low) return low; + if (value > high) return high; + return value; +} + +char UISTPage::printable(uint32_t codepoint) +{ + if (codepoint < 32 || codepoint == 127) return ' '; + if (codepoint > 126) return '?'; + return static_cast(codepoint); +} + +lv_color_t UISTPage::palette(uint32_t color) +{ + static constexpr uint32_t COLORS[] = { + 0x0D1117, 0xFF5F56, 0x27C93F, 0xFFBD2E, + 0x2F81F7, 0xBC8CFF, 0x39C5CF, 0xF0F6FC, + 0x6E7681, 0xFFA198, 0x56D364, 0xE3B341, + 0x79C0FF, 0xD2A8FF, 0x56D4DD, 0xFFFFFF, + }; + return lv_color_hex(COLORS[color < 16 ? color : DEFAULT_FG]); +} + +const lv_font_t *UISTPage::terminal_font() +{ + return launcher_fonts().get("LiberationMono-Regular.ttf", 11); +} + +uint32_t UISTPage::xterm256_to_palette(int color) +{ + color = clamp(color, 0, 255); + if (color < 16) return static_cast(color); + if (color >= 232) return color >= 244 ? 15 : 8; + + int index = color - 16; + int red = index / 36; + int green = (index / 6) % 6; + int blue = index % 6; + if (red >= green && red >= blue) return red >= 3 ? 9 : 1; + if (green >= red && green >= blue) return green >= 3 ? 10 : 2; + return blue >= 3 ? 12 : 4; +} + +uint32_t UISTPage::rgb_to_palette(int red, int green, int blue) +{ + red = clamp(red, 0, 255); + green = clamp(green, 0, 255); + blue = clamp(blue, 0, 255); + int maximum = std::max(red, std::max(green, blue)); + int minimum = std::min(red, std::min(green, blue)); + if (maximum < 80) return 0; + if (maximum - minimum < 35) return maximum > 180 ? 15 : 8; + if (red >= green && red >= blue) return red > 180 ? 9 : 1; + if (green >= red && green >= blue) return green > 180 ? 10 : 2; + return blue > 180 ? 12 : 4; +} + +UISTPage::Glyph UISTPage::blank_glyph() const +{ + Glyph glyph; + glyph.u = ' '; + glyph.attr = cursor_.attr.attr; + glyph.fg = cursor_.attr.fg; + glyph.bg = cursor_.attr.bg; + return glyph; +} + +void UISTPage::dirty_row(int row) +{ + if (big_mode_) { + int view_row = row - viewport_y_; + if (view_row >= 0 && view_row < visible_rows()) dirty_[view_row] = true; + return; + } + if (row >= 0 && row < ROWS) dirty_[row] = true; +} + +void UISTPage::dirty_all() +{ + dirty_.fill(true); + for (auto &segments : row_segments_) { + for (auto &segment : segments) { + segment.text.clear(); + segment.fg = UINT32_MAX; + segment.bg = UINT32_MAX; + segment.hidden = true; + if (segment.label) lv_obj_add_flag(segment.label, LV_OBJ_FLAG_HIDDEN); + } + } +} + +int UISTPage::visible_cols() const +{ + return NORMAL_COLS; +} + +int UISTPage::visible_rows() const +{ + return big_mode_ ? BIG_VIEW_ROWS : NORMAL_ROWS; +} + +int UISTPage::visible_h() const +{ + return visible_rows() * CHAR_H; +} + +int UISTPage::max_viewport_x() const +{ + return std::max(0, term_cols_ - visible_cols()); +} + +int UISTPage::max_viewport_y() const +{ + return std::max(0, term_rows_ - visible_rows()); +} + +void UISTPage::append_scrollback_row(const std::array &row) +{ + scrollback_.push_back(row); + if (static_cast(scrollback_.size()) <= SCROLLBACK_MAX_ROWS) return; + + int drop_count = static_cast(scrollback_.size()) - SCROLLBACK_MAX_ROWS; + scrollback_.erase(scrollback_.begin(), scrollback_.begin() + drop_count); + scrollback_offset_ = std::max(0, scrollback_offset_ - drop_count); +} + +const std::array &UISTPage::display_row(int row) const +{ + static const std::array EMPTY_ROW{}; + if (big_mode_) { + int screen_row = clamp(viewport_y_ + row, 0, term_rows_ - 1); + return screen_[static_cast(screen_row)]; + } + + int history_rows = static_cast(scrollback_.size()); + int total_rows = history_rows + term_rows_; + int index = total_rows - term_rows_ - scrollback_offset_ + row; + if (index < 0 || index >= total_rows) return EMPTY_ROW; + if (index < history_rows) return scrollback_[static_cast(index)]; + return screen_[static_cast(index - history_rows)]; +} + +void UISTPage::scrollback_page(int direction) +{ + int old_offset = scrollback_offset_; + int page_size = std::max(1, visible_rows() - 1); + if (direction > 0) + scrollback_offset_ = std::min(static_cast(scrollback_.size()), + scrollback_offset_ + page_size); + else + scrollback_offset_ = std::max(0, scrollback_offset_ - page_size); + if (scrollback_offset_ != old_offset) dirty_all(); +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_st.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_st.hpp index 76ea734e..e5ccf829 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_st.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_st.hpp @@ -7,9 +7,11 @@ #pragma once #include "sample_log.h" -#include "../ui_app_page.hpp" +#include "../launcher_ui_app_page.hpp" +#include "../model/page_timer_lifecycle.hpp" +#include "../model/st_page_contract.hpp" #include "cp0_lvgl_app.h" -#include "compat/input_keys.h" +#include "input_keys.h" #include #include #include @@ -19,14 +21,9 @@ #include #include #include -#include #include #include -extern "C" { -LV_FONT_DECLARE(ui_font_liberation_mono_11) -} - // ============================================================ // ST terminal UISTPage // Screen resolution: 320 x 170 (top bar 20px, ui_APP_Container 320x150) @@ -118,60 +115,11 @@ class UISTPage : public AppPage public: bool terminal_sysplause = true; - UISTPage() : AppPage() - { - set_page_title("CLI"); - reset_terminal(); - create_ui(); - bind_events(); - start_shell(); - } - - ~UISTPage() - { - terminal_active_ = false; - stop_timers(); - stop_pty(); - } - - void exec(std::string cmd) - { - stop_timers(); - stop_pty(); - - terminal_active_ = true; - waiting_key_to_exit_ = false; - big_mode_ = false; - term_cols_ = NORMAL_COLS; - term_rows_ = NORMAL_ROWS; - viewport_x_ = 0; - viewport_y_ = 0; - big_view_locked_ = false; - reset_terminal(); - update_big_mode_ui(); - render_all(); - - std::vector tokens; - std::istringstream iss(cmd); - std::string token; - while (iss >> token) - tokens.push_back(token); + UISTPage(); + ~UISTPage(); - if (tokens.empty()) { - const char *err = "Error: empty command\r\n"; - process_bytes(err, (int)strlen(err)); - render_all(); - terminal_active_ = false; - waiting_key_to_exit_ = true; - return; - } - - std::list args; - for (size_t i = 1; i < tokens.size(); ++i) - args.push_back(tokens[i]); - - start_command(tokens[0], args, tokens[0].c_str(), "Error: openpty/fork failed\r\n"); - } + void exec(std::string cmd); + void exec(const std::string &command, const std::list &arguments); private: std::array, MAX_ROWS> screen_{}; @@ -208,8 +156,8 @@ class UISTPage : public AppPage std::array bottom_indicators_{}; const lv_font_t *mono_font_ = nullptr; lv_obj_t *cursor_label_ = nullptr; - lv_timer_t *poll_timer_ = nullptr; - lv_timer_t *cursor_timer_ = nullptr; + PageTimerLifecycle poll_timer_; + PageTimerLifecycle cursor_timer_; std::string pty_handle_; bool terminal_active_ = false; @@ -221,1483 +169,89 @@ class UISTPage : public AppPage int home_hold_status_ = 0; std::chrono::time_point home_hold_start_{}; - static int clamp(int v, int lo, int hi) - { - if (v < lo) - return lo; - if (v > hi) - return hi; - return v; - } - - static char printable(uint32_t u) - { - if (u < 32 || u == 127) - return ' '; - if (u > 126) - return '?'; - return (char)u; - } - - static lv_color_t palette(uint32_t color) - { - static const uint32_t colors[] = { - 0x0D1117, 0xFF5F56, 0x27C93F, 0xFFBD2E, - 0x2F81F7, 0xBC8CFF, 0x39C5CF, 0xF0F6FC, - 0x6E7681, 0xFFA198, 0x56D364, 0xE3B341, - 0x79C0FF, 0xD2A8FF, 0x56D4DD, 0xFFFFFF, - }; - return lv_color_hex(colors[color < 16 ? color : DEFAULT_FG]); - } - - static const lv_font_t *terminal_font() - { - return &ui_font_liberation_mono_11; - } - - static uint32_t xterm256_to_palette(int color) - { - color = clamp(color, 0, 255); - if (color < 16) - return (uint32_t)color; - if (color >= 232) - return color >= 244 ? 15 : 8; - - int idx = color - 16; - int r = idx / 36; - int g = (idx / 6) % 6; - int b = idx % 6; - if (r >= g && r >= b) - return r >= 3 ? 9 : 1; - if (g >= r && g >= b) - return g >= 3 ? 10 : 2; - return b >= 3 ? 12 : 4; - } - - static uint32_t rgb_to_palette(int r, int g, int b) - { - r = clamp(r, 0, 255); - g = clamp(g, 0, 255); - b = clamp(b, 0, 255); - int maxc = std::max(r, std::max(g, b)); - int minc = std::min(r, std::min(g, b)); - if (maxc < 80) - return 0; - if (maxc - minc < 35) - return maxc > 180 ? 15 : 8; - if (r >= g && r >= b) - return r > 180 ? 9 : 1; - if (g >= r && g >= b) - return g > 180 ? 10 : 2; - return b > 180 ? 12 : 4; - } - - Glyph blank_glyph() const - { - Glyph g; - g.u = ' '; - g.attr = cursor_.attr.attr; - g.fg = cursor_.attr.fg; - g.bg = cursor_.attr.bg; - return g; - } - - void dirty_row(int row) - { - if (big_mode_) { - int view_row = row - viewport_y_; - if (view_row >= 0 && view_row < visible_rows()) - dirty_[view_row] = true; - return; - } - if (row >= 0 && row < ROWS) - dirty_[row] = true; - } - - void dirty_all() - { - dirty_.fill(true); - for (auto &segments : row_segments_) { - for (auto &segment : segments) { - segment.text.clear(); - segment.fg = UINT32_MAX; - segment.bg = UINT32_MAX; - segment.hidden = true; - if (segment.label) - lv_obj_add_flag(segment.label, LV_OBJ_FLAG_HIDDEN); - } - } - } - - int visible_cols() const - { - return NORMAL_COLS; - } - - int visible_rows() const - { - return big_mode_ ? BIG_VIEW_ROWS : NORMAL_ROWS; - } - - int visible_h() const - { - return visible_rows() * CHAR_H; - } - - int max_viewport_x() const - { - return std::max(0, term_cols_ - visible_cols()); - } - - int max_viewport_y() const - { - return std::max(0, term_rows_ - visible_rows()); - } - - void append_scrollback_row(const std::array &row) - { - scrollback_.push_back(row); - if ((int)scrollback_.size() > SCROLLBACK_MAX_ROWS) { - int drop = (int)scrollback_.size() - SCROLLBACK_MAX_ROWS; - scrollback_.erase(scrollback_.begin(), scrollback_.begin() + drop); - scrollback_offset_ = std::max(0, scrollback_offset_ - drop); - } - } - - const std::array &display_row(int r) const - { - static const std::array empty_row{}; - if (big_mode_) { - int y = clamp(viewport_y_ + r, 0, term_rows_ - 1); - return screen_[(size_t)y]; - } - int history_rows = (int)scrollback_.size(); - int total_rows = history_rows + term_rows_; - int top = total_rows - term_rows_ - scrollback_offset_; - int idx = top + r; - if (idx < 0 || idx >= total_rows) - return empty_row; - if (idx < history_rows) - return scrollback_[(size_t)idx]; - return screen_[(size_t)(idx - history_rows)]; - } - - void scrollback_page(int direction) - { - int old_offset = scrollback_offset_; - int page = std::max(1, visible_rows() - 1); - if (direction > 0) - scrollback_offset_ = std::min((int)scrollback_.size(), scrollback_offset_ + page); - else - scrollback_offset_ = std::max(0, scrollback_offset_ - page); - if (scrollback_offset_ != old_offset) - dirty_all(); - } - - void update_scrollbar() - { - if (!scrollbar_track_ || !scrollbar_thumb_) - return; - - if (big_mode_) { - int max_y = max_viewport_y(); - lv_obj_clear_flag(scrollbar_track_, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(scrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); - lv_obj_set_size(scrollbar_track_, SCROLLBAR_W, visible_h()); - lv_obj_set_pos(scrollbar_track_, TERM_W - SCROLLBAR_W - 1, 0); - - int thumb_h = std::max(8, visible_h() * visible_rows() / std::max(term_rows_, 1)); - thumb_h = std::min(visible_h(), thumb_h); - int range = visible_h() - thumb_h; - int thumb_y = max_y > 0 ? viewport_y_ * range / max_y : 0; - lv_obj_set_size(scrollbar_thumb_, SCROLLBAR_W, thumb_h); - lv_obj_set_pos(scrollbar_thumb_, TERM_W - SCROLLBAR_W - 1, thumb_y); - lv_obj_move_foreground(scrollbar_track_); - lv_obj_move_foreground(scrollbar_thumb_); - return; - } - - int history_rows = (int)scrollback_.size(); - if (history_rows <= 0) { - lv_obj_add_flag(scrollbar_track_, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(scrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); - return; - } - - lv_obj_clear_flag(scrollbar_track_, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(scrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); - lv_obj_set_size(scrollbar_track_, SCROLLBAR_W, TERM_H); - lv_obj_set_pos(scrollbar_track_, TERM_W - SCROLLBAR_W - 1, 0); - - int total_rows = history_rows + term_rows_; - int thumb_h = std::max(8, TERM_H * term_rows_ / std::max(total_rows, 1)); - thumb_h = std::min(TERM_H, thumb_h); - int range = TERM_H - thumb_h; - int max_offset = std::max(1, history_rows); - int thumb_y = range - (scrollback_offset_ * range / max_offset); - - lv_obj_set_size(scrollbar_thumb_, SCROLLBAR_W, thumb_h); - lv_obj_set_pos(scrollbar_thumb_, TERM_W - SCROLLBAR_W - 1, thumb_y); - lv_obj_move_foreground(scrollbar_track_); - lv_obj_move_foreground(scrollbar_thumb_); - } - - void update_hscrollbar() - { - if (!hscrollbar_track_ || !hscrollbar_thumb_) - return; - if (!big_mode_) { - lv_obj_add_flag(hscrollbar_track_, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(hscrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); - return; - } - - lv_obj_clear_flag(hscrollbar_track_, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(hscrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); - int y = visible_h(); - int width = TERM_W - SCROLLBAR_W - 2; - lv_obj_set_size(hscrollbar_track_, width, 3); - lv_obj_set_pos(hscrollbar_track_, 0, y); - - int max_x = max_viewport_x(); - int thumb_w = std::max(18, width * visible_cols() / std::max(term_cols_, 1)); - thumb_w = std::min(width, thumb_w); - int range = width - thumb_w; - int thumb_x = max_x > 0 ? viewport_x_ * range / max_x : 0; - lv_obj_set_size(hscrollbar_thumb_, thumb_w, 3); - lv_obj_set_pos(hscrollbar_thumb_, thumb_x, y); - lv_obj_move_foreground(hscrollbar_track_); - lv_obj_move_foreground(hscrollbar_thumb_); - } - - void set_bottom_label(int idx, const char *text) - { - if (idx < 0 || idx >= BOTTOM_BAR_SLOTS || !bottom_labels_[(size_t)idx]) - return; - lv_label_set_text(bottom_labels_[(size_t)idx], text); - } - - void update_big_mode_ui() - { - bool show = big_mode_; - for (auto *label : bottom_labels_) { - if (!label) - continue; - if (show) - lv_obj_clear_flag(label, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(label, LV_OBJ_FLAG_HIDDEN); - } - for (auto *label : bottom_indicators_) { - if (!label) - continue; - if (show) - lv_obj_clear_flag(label, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(label, LV_OBJ_FLAG_HIDDEN); - } - update_hscrollbar(); - update_scrollbar(); - } - - void switch_big_mode(bool enable) - { - if (big_mode_ == enable) - return; - - terminal_active_ = false; - stop_timers(); - stop_pty(); - big_mode_ = enable; - term_cols_ = big_mode_ ? BIG_COLS : NORMAL_COLS; - term_rows_ = big_mode_ ? BIG_ROWS : NORMAL_ROWS; - viewport_x_ = 0; - viewport_y_ = 0; - big_view_locked_ = false; - reset_terminal(); - update_big_mode_ui(); - start_shell(); - } - - void leave_scrollback() - { - if (scrollback_offset_ == 0) - return; - scrollback_offset_ = 0; - dirty_all(); - } - - void pan_big_view(int dx, int dy) - { - if (!big_mode_) - return; - big_view_locked_ = true; - int old_x = viewport_x_; - int old_y = viewport_y_; - viewport_x_ = clamp(viewport_x_ + dx, 0, max_viewport_x()); - viewport_y_ = clamp(viewport_y_ + dy, 0, max_viewport_y()); - if (viewport_x_ != old_x || viewport_y_ != old_y) - dirty_all(); - } - - void follow_cursor_in_big_mode() - { - if (!big_mode_ || big_view_locked_) - return; - int old_x = viewport_x_; - int old_y = viewport_y_; - if (cursor_.x < viewport_x_) - viewport_x_ = cursor_.x; - else if (cursor_.x >= viewport_x_ + visible_cols()) - viewport_x_ = cursor_.x - visible_cols() + 1; - - if (cursor_.y < viewport_y_) - viewport_y_ = cursor_.y; - else if (cursor_.y >= viewport_y_ + visible_rows()) - viewport_y_ = cursor_.y - visible_rows() + 1; - - viewport_x_ = clamp(viewport_x_, 0, max_viewport_x()); - viewport_y_ = clamp(viewport_y_, 0, max_viewport_y()); - if (viewport_x_ != old_x || viewport_y_ != old_y) - dirty_all(); - } - - void reset_terminal() - { - cursor_ = Cursor{}; - cursor_.attr.fg = DEFAULT_FG; - cursor_.attr.bg = DEFAULT_BG; - saved_cursor_ = cursor_; - scroll_top_ = 0; - scroll_bot_ = term_rows_ - 1; - scrollback_.clear(); - scrollback_offset_ = 0; - mode_ = MODE_WRAP; - parse_state_ = ParseState::Normal; - cursor_visible_mode_ = true; - csi_reset(); - for (auto &row : screen_) - for (auto &cell : row) - cell = blank_glyph(); - dirty_all(); - } - - void clear_region(int x1, int y1, int x2, int y2) - { - x1 = clamp(x1, 0, term_cols_ - 1); - x2 = clamp(x2, 0, term_cols_ - 1); - y1 = clamp(y1, 0, term_rows_ - 1); - y2 = clamp(y2, 0, term_rows_ - 1); - if (x1 > x2) - std::swap(x1, x2); - if (y1 > y2) - std::swap(y1, y2); - - Glyph blank = blank_glyph(); - for (int y = y1; y <= y2; ++y) { - for (int x = x1; x <= x2; ++x) - screen_[y][x] = blank; - dirty_row(y); - } - } - - void move_to(int x, int y) - { - cursor_.x = clamp(x, 0, term_cols_ - 1); - cursor_.y = clamp(y, 0, term_rows_ - 1); - } - - void scroll_up(int top, int bot, int n) - { - top = clamp(top, 0, term_rows_ - 1); - bot = clamp(bot, 0, term_rows_ - 1); - if (top > bot || n <= 0) - return; - n = std::min(n, bot - top + 1); - if (!big_mode_ && top == 0 && bot == term_rows_ - 1) { - for (int y = 0; y < n; ++y) - append_scrollback_row(screen_[y]); - } - for (int y = top; y <= bot - n; ++y) { - screen_[y] = screen_[y + n]; - dirty_row(y); - } - Glyph blank = blank_glyph(); - for (int y = bot - n + 1; y <= bot; ++y) { - for (auto &cell : screen_[y]) - cell = blank; - dirty_row(y); - } - } - - void scroll_down(int top, int bot, int n) - { - top = clamp(top, 0, term_rows_ - 1); - bot = clamp(bot, 0, term_rows_ - 1); - if (top > bot || n <= 0) - return; - n = std::min(n, bot - top + 1); - for (int y = bot; y >= top + n; --y) { - screen_[y] = screen_[y - n]; - dirty_row(y); - } - Glyph blank = blank_glyph(); - for (int y = top; y < top + n; ++y) { - for (auto &cell : screen_[y]) - cell = blank; - dirty_row(y); - } - } - - void newline(bool first_col) - { - if (first_col) - cursor_.x = 0; - if (cursor_.y == scroll_bot_) - scroll_up(scroll_top_, scroll_bot_, 1); - else - cursor_.y = clamp(cursor_.y + 1, 0, term_rows_ - 1); - } - - void put_tab() - { - int next = ((cursor_.x / 8) + 1) * 8; - cursor_.x = clamp(next, 0, term_cols_ - 1); - } - - void insert_blank(int n) - { - n = std::max(n, 1); - n = std::min(n, term_cols_ - cursor_.x); - auto &line = screen_[cursor_.y]; - for (int x = term_cols_ - 1; x >= cursor_.x + n; --x) - line[x] = line[x - n]; - Glyph blank = blank_glyph(); - for (int x = cursor_.x; x < cursor_.x + n; ++x) - line[x] = blank; - dirty_row(cursor_.y); - } - - void delete_chars(int n) - { - n = std::max(n, 1); - n = std::min(n, term_cols_ - cursor_.x); - auto &line = screen_[cursor_.y]; - for (int x = cursor_.x; x < term_cols_ - n; ++x) - line[x] = line[x + n]; - Glyph blank = blank_glyph(); - for (int x = term_cols_ - n; x < term_cols_; ++x) - line[x] = blank; - dirty_row(cursor_.y); - } - - void put_rune(uint32_t rune) - { - if (mode_ & MODE_INSERT) - insert_blank(1); - - if (cursor_.x >= term_cols_) { - if (mode_ & MODE_WRAP) { - cursor_.x = 0; - newline(false); - } else { - cursor_.x = term_cols_ - 1; - } - } - - Glyph g = cursor_.attr; - g.u = rune; - screen_[cursor_.y][cursor_.x] = g; - dirty_row(cursor_.y); - cursor_.x++; - } - - void control_code(uint8_t c) - { - switch (c) { - case '\t': - put_tab(); - break; - case '\b': - cursor_.x = std::max(0, cursor_.x - 1); - break; - case '\r': - cursor_.x = 0; - break; - case '\n': - case '\v': - case '\f': - newline(true); - break; - case 0x0e: - case 0x0f: - break; - default: - break; - } - } - - int param(int index, int def) const - { - if (index >= csi_param_count_) - return def; - return csi_params_[index] == 0 ? def : csi_params_[index]; - } - - void csi_reset() - { - csi_private_ = false; - csi_secondary_ = false; - csi_param_count_ = 0; - csi_param_value_ = 0; - csi_have_value_ = false; - memset(csi_params_, 0, sizeof(csi_params_)); - } - - void csi_push_param() - { - if (csi_param_count_ >= (int)(sizeof(csi_params_) / sizeof(csi_params_[0]))) - return; - csi_params_[csi_param_count_++] = csi_have_value_ ? csi_param_value_ : 0; - csi_param_value_ = 0; - csi_have_value_ = false; - } - - void set_sgr() - { - if (csi_param_count_ == 0) { - cursor_.attr.attr = ATTR_NULL; - cursor_.attr.fg = DEFAULT_FG; - cursor_.attr.bg = DEFAULT_BG; - return; - } - - for (int i = 0; i < csi_param_count_; ++i) { - int val = csi_params_[i]; - if (val == 0) { - cursor_.attr.attr = ATTR_NULL; - cursor_.attr.fg = DEFAULT_FG; - cursor_.attr.bg = DEFAULT_BG; - } else if (val == 1) { - cursor_.attr.attr |= ATTR_BOLD; - } else if (val == 2) { - cursor_.attr.attr |= ATTR_FAINT; - } else if (val == 4) { - cursor_.attr.attr |= ATTR_UNDERLINE; - } else if (val == 5) { - cursor_.attr.attr |= ATTR_BLINK; - } else if (val == 7) { - cursor_.attr.attr |= ATTR_REVERSE; - } else if (val == 8) { - cursor_.attr.attr |= ATTR_INVISIBLE; - } else if (val == 22) { - cursor_.attr.attr &= ~(ATTR_BOLD | ATTR_FAINT); - } else if (val == 24) { - cursor_.attr.attr &= ~ATTR_UNDERLINE; - } else if (val == 25) { - cursor_.attr.attr &= ~ATTR_BLINK; - } else if (val == 27) { - cursor_.attr.attr &= ~ATTR_REVERSE; - } else if (val == 28) { - cursor_.attr.attr &= ~ATTR_INVISIBLE; - } else if (val >= 30 && val <= 37) { - cursor_.attr.fg = (uint32_t)(val - 30); - } else if (val >= 40 && val <= 47) { - cursor_.attr.bg = (uint32_t)(val - 40); - } else if (val >= 90 && val <= 97) { - cursor_.attr.fg = (uint32_t)(val - 90 + 8); - } else if (val >= 100 && val <= 107) { - cursor_.attr.bg = (uint32_t)(val - 100 + 8); - } else if ((val == 38 || val == 48) && i + 2 < csi_param_count_ && csi_params_[i + 1] == 5) { - uint32_t mapped = xterm256_to_palette(csi_params_[i + 2]); - if (val == 38) - cursor_.attr.fg = mapped; - else - cursor_.attr.bg = mapped; - i += 2; - } else if ((val == 38 || val == 48) && i + 4 < csi_param_count_ && csi_params_[i + 1] == 2) { - uint32_t mapped = rgb_to_palette(csi_params_[i + 2], csi_params_[i + 3], csi_params_[i + 4]); - if (val == 38) - cursor_.attr.fg = mapped; - else - cursor_.attr.bg = mapped; - i += 4; - } else if (val == 39) { - cursor_.attr.fg = DEFAULT_FG; - } else if (val == 49) { - cursor_.attr.bg = DEFAULT_BG; - } - } - } - - void handle_private_mode(char final) - { - bool set = final == 'h'; - for (int i = 0; i < csi_param_count_; ++i) { - switch (csi_params_[i]) { - case 1: - if (set) - mode_ |= MODE_APPCURSOR; - else - mode_ &= ~MODE_APPCURSOR; - break; - case 7: - if (set) - mode_ |= MODE_WRAP; - else - mode_ &= ~MODE_WRAP; - break; - case 25: - cursor_visible_mode_ = set; - break; - case 1049: - if (set) - clear_region(0, 0, term_cols_ - 1, term_rows_ - 1); - break; - default: - break; - } - } - } - - void handle_csi(char final) - { - if (csi_secondary_) { - if (final == 'c') - pty_write("\033[>0;115;0c", 11); - return; - } - - if (csi_private_ && (final == 'h' || final == 'l')) { - handle_private_mode(final); - return; - } - - switch (final) { - case '@': - insert_blank(param(0, 1)); - break; - case 'A': - move_to(cursor_.x, cursor_.y - param(0, 1)); - break; - case 'B': - move_to(cursor_.x, cursor_.y + param(0, 1)); - break; - case 'C': - move_to(cursor_.x + param(0, 1), cursor_.y); - break; - case 'D': - move_to(cursor_.x - param(0, 1), cursor_.y); - break; - case 'G': - move_to(param(0, 1) - 1, cursor_.y); - break; - case 'H': - case 'f': - move_to(param(1, 1) - 1, param(0, 1) - 1); - break; - case 'J': - if (param(0, 0) == 0) - clear_region(cursor_.x, cursor_.y, term_cols_ - 1, term_rows_ - 1); - else if (param(0, 0) == 1) - clear_region(0, 0, cursor_.x, cursor_.y); - else - clear_region(0, 0, term_cols_ - 1, term_rows_ - 1); - break; - case 'K': - if (param(0, 0) == 0) - clear_region(cursor_.x, cursor_.y, term_cols_ - 1, cursor_.y); - else if (param(0, 0) == 1) - clear_region(0, cursor_.y, cursor_.x, cursor_.y); - else - clear_region(0, cursor_.y, term_cols_ - 1, cursor_.y); - break; - case 'L': - scroll_down(cursor_.y, scroll_bot_, param(0, 1)); - break; - case 'M': - scroll_up(cursor_.y, scroll_bot_, param(0, 1)); - break; - case 'P': - delete_chars(param(0, 1)); - break; - case 'd': - move_to(cursor_.x, param(0, 1) - 1); - break; - case 'h': - if (param(0, 0) == 4) - mode_ |= MODE_INSERT; - break; - case 'l': - if (param(0, 0) == 4) - mode_ &= ~MODE_INSERT; - break; - case 'm': - set_sgr(); - break; - case 'n': - if (param(0, 0) == 5) { - pty_write("\033[0n", 4); - } else if (param(0, 0) == 6) { - char reply[32]; - int len = snprintf(reply, sizeof(reply), "\033[%d;%dR", cursor_.y + 1, cursor_.x + 1); - pty_write(reply, (size_t)len); - } - break; - case 'r': - scroll_top_ = clamp(param(0, 1) - 1, 0, term_rows_ - 1); - scroll_bot_ = clamp(param(1, term_rows_) - 1, 0, term_rows_ - 1); - if (scroll_top_ >= scroll_bot_) { - scroll_top_ = 0; - scroll_bot_ = term_rows_ - 1; - } - move_to(0, 0); - break; - case 's': - saved_cursor_ = cursor_; - break; - case 'u': - cursor_ = saved_cursor_; - move_to(cursor_.x, cursor_.y); - break; - case 'c': - pty_write("\033[?1;2c", 7); - break; - default: - break; - } - } - - void handle_esc(uint8_t c) - { - switch (c) { - case '[': - csi_reset(); - parse_state_ = ParseState::Csi; - return; - case ']': - parse_state_ = ParseState::Osc; - return; - case '(': - case ')': - case '*': - case '+': - parse_state_ = ParseState::Charset; - return; - case '7': - saved_cursor_ = cursor_; - break; - case '8': - cursor_ = saved_cursor_; - move_to(cursor_.x, cursor_.y); - break; - case 'D': - newline(false); - break; - case 'E': - newline(true); - break; - case 'M': - if (cursor_.y == scroll_top_) - scroll_down(scroll_top_, scroll_bot_, 1); - else - move_to(cursor_.x, cursor_.y - 1); - break; - case 'c': - reset_terminal(); - break; - default: - break; - } - parse_state_ = ParseState::Normal; - } - - void process_bytes(const char *data, int len) - { - for (int i = 0; i < len; ++i) { - uint8_t c = (uint8_t)data[i]; - - if (parse_state_ == ParseState::Osc) { - if (c == 0x07) - parse_state_ = ParseState::Normal; - else if (c == 0x1b && i + 1 < len && data[i + 1] == '\\') { - ++i; - parse_state_ = ParseState::Normal; - } - continue; - } - - if (parse_state_ == ParseState::Charset) { - parse_state_ = ParseState::Normal; - continue; - } - - if (parse_state_ == ParseState::Esc) { - handle_esc(c); - continue; - } - - if (parse_state_ == ParseState::Csi) { - if (c == '?') { - csi_private_ = true; - continue; - } - if (c == '>') { - csi_secondary_ = true; - continue; - } - if (isdigit(c)) { - csi_param_value_ = csi_param_value_ * 10 + (c - '0'); - csi_have_value_ = true; - continue; - } - if (c == ';') { - csi_push_param(); - continue; - } - if (c >= 0x20 && c <= 0x2f) - continue; - csi_push_param(); - handle_csi((char)c); - parse_state_ = ParseState::Normal; - continue; - } - - if (c == 0x1b) { - parse_state_ = ParseState::Esc; - } else if (c < 0x20 || c == 0x7f) { - control_code(c); - } else { - put_rune(c); - } - } - } - - void create_ui() - { - terminal_container_ = lv_obj_create(ui_APP_Container); - lv_obj_remove_style_all(terminal_container_); - lv_obj_set_size(terminal_container_, TERM_W, TERM_H); - lv_obj_set_pos(terminal_container_, 0, 0); - lv_obj_set_style_bg_color(terminal_container_, palette(DEFAULT_BG), 0); - lv_obj_set_style_bg_opa(terminal_container_, LV_OPA_COVER, 0); - lv_obj_clear_flag(terminal_container_, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - - term_canvas_ = lv_obj_create(terminal_container_); - lv_obj_set_size(term_canvas_, TERM_W, TERM_H); - lv_obj_set_pos(term_canvas_, 0, 0); - lv_obj_set_style_bg_color(term_canvas_, palette(DEFAULT_BG), 0); - lv_obj_set_style_bg_opa(term_canvas_, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(term_canvas_, 0, 0); - lv_obj_set_style_pad_all(term_canvas_, 0, 0); - lv_obj_set_style_radius(term_canvas_, 0, 0); - lv_obj_clear_flag(term_canvas_, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - - scrollbar_track_ = lv_obj_create(terminal_container_); - lv_obj_remove_style_all(scrollbar_track_); - lv_obj_set_size(scrollbar_track_, SCROLLBAR_W, TERM_H); - lv_obj_set_pos(scrollbar_track_, TERM_W - SCROLLBAR_W - 1, 0); - lv_obj_set_style_bg_color(scrollbar_track_, lv_color_hex(0x30363D), 0); - lv_obj_set_style_bg_opa(scrollbar_track_, LV_OPA_COVER, 0); - lv_obj_clear_flag(scrollbar_track_, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_add_flag(scrollbar_track_, LV_OBJ_FLAG_HIDDEN); - - scrollbar_thumb_ = lv_obj_create(terminal_container_); - lv_obj_remove_style_all(scrollbar_thumb_); - lv_obj_set_size(scrollbar_thumb_, SCROLLBAR_W, 8); - lv_obj_set_pos(scrollbar_thumb_, TERM_W - SCROLLBAR_W - 1, TERM_H - 8); - lv_obj_set_style_bg_color(scrollbar_thumb_, lv_color_hex(0x8B949E), 0); - lv_obj_set_style_bg_opa(scrollbar_thumb_, LV_OPA_COVER, 0); - lv_obj_clear_flag(scrollbar_thumb_, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_add_flag(scrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); - - hscrollbar_track_ = lv_obj_create(terminal_container_); - lv_obj_remove_style_all(hscrollbar_track_); - lv_obj_set_size(hscrollbar_track_, TERM_W - SCROLLBAR_W - 2, 3); - lv_obj_set_pos(hscrollbar_track_, 0, BIG_VIEW_ROWS * CHAR_H); - lv_obj_set_style_bg_color(hscrollbar_track_, lv_color_hex(0x30363D), 0); - lv_obj_set_style_bg_opa(hscrollbar_track_, LV_OPA_COVER, 0); - lv_obj_clear_flag(hscrollbar_track_, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_add_flag(hscrollbar_track_, LV_OBJ_FLAG_HIDDEN); - - hscrollbar_thumb_ = lv_obj_create(terminal_container_); - lv_obj_remove_style_all(hscrollbar_thumb_); - lv_obj_set_size(hscrollbar_thumb_, 18, 3); - lv_obj_set_pos(hscrollbar_thumb_, 0, BIG_VIEW_ROWS * CHAR_H); - lv_obj_set_style_bg_color(hscrollbar_thumb_, lv_color_hex(0x8B949E), 0); - lv_obj_set_style_bg_opa(hscrollbar_thumb_, LV_OPA_COVER, 0); - lv_obj_clear_flag(hscrollbar_thumb_, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_add_flag(hscrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); - - static const char *bottom_text[BOTTOM_BAR_SLOTS] = {"F4 <", "F5 up", "F6 normal", "F7 down", "F8 >"}; - constexpr int slot_w = TERM_W / BOTTOM_BAR_SLOTS; - for (int i = 0; i < BOTTOM_BAR_SLOTS; ++i) { - bottom_labels_[(size_t)i] = lv_label_create(terminal_container_); - lv_obj_set_pos(bottom_labels_[(size_t)i], i * slot_w, TERM_H - BIG_BOTTOM_H); - lv_obj_set_size(bottom_labels_[(size_t)i], slot_w, BIG_BOTTOM_H); - lv_obj_set_style_text_font(bottom_labels_[(size_t)i], &lv_font_montserrat_12, 0); - lv_obj_set_style_text_color(bottom_labels_[(size_t)i], lv_color_hex(0xF0F6FC), 0); - lv_obj_set_style_text_align(bottom_labels_[(size_t)i], LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_pad_top(bottom_labels_[(size_t)i], 1, 0); - lv_label_set_text(bottom_labels_[(size_t)i], bottom_text[i]); - lv_obj_add_flag(bottom_labels_[(size_t)i], LV_OBJ_FLAG_HIDDEN); - - bottom_indicators_[(size_t)i] = lv_label_create(terminal_container_); - lv_obj_set_pos(bottom_indicators_[(size_t)i], i * slot_w, TERM_H - 4); - lv_obj_set_size(bottom_indicators_[(size_t)i], slot_w, 4); - lv_obj_set_style_text_font(bottom_indicators_[(size_t)i], &lv_font_montserrat_12, 0); - lv_obj_set_style_text_color(bottom_indicators_[(size_t)i], lv_color_hex(0x8B949E), 0); - lv_obj_set_style_text_align(bottom_indicators_[(size_t)i], LV_TEXT_ALIGN_CENTER, 0); - lv_label_set_text(bottom_indicators_[(size_t)i], "|"); - lv_obj_add_flag(bottom_indicators_[(size_t)i], LV_OBJ_FLAG_HIDDEN); - } - - mono_font_ = launcher_fonts().get_mono("LiberationMono-Regular.ttf", 11, LV_FREETYPE_FONT_STYLE_NORMAL); - - cursor_label_ = lv_label_create(term_canvas_); - lv_obj_set_style_text_font(cursor_label_, mono_font_, 0); - lv_obj_set_style_text_color(cursor_label_, palette(DEFAULT_BG), 0); - lv_obj_set_style_bg_color(cursor_label_, palette(DEFAULT_FG), 0); - lv_obj_set_style_bg_opa(cursor_label_, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(cursor_label_, 0, 0); - lv_obj_set_style_pad_top(cursor_label_, TEXT_Y_PAD, 0); - lv_obj_set_style_text_letter_space(cursor_label_, 0, 0); - lv_label_set_long_mode(cursor_label_, LV_LABEL_LONG_CLIP); - lv_obj_set_size(cursor_label_, CHAR_W, CHAR_H); - lv_label_set_text(cursor_label_, " "); - lv_obj_add_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); - } - - void bind_events() - { - lv_obj_add_event_cb(root_screen_, UISTPage::static_event_cb, LV_EVENT_ALL, this); - } - - static void static_event_cb(lv_event_t *e) - { - auto *self = static_cast(lv_event_get_user_data(e)); - if (self) - self->event_cb(e); - } - - void event_cb(lv_event_t *e) - { - if (lv_event_get_code(e) != LV_EVENT_KEYBOARD) - return; - struct key_item *elm = (struct key_item *)lv_event_get_param(e); - if (!elm) - return; - - if (waiting_key_to_exit_) { - if (elm->key_state == 0) { - if (terminal_sysplause) { - terminal_sysplause = false; - } else { - waiting_key_to_exit_ = false; - if (navigate_home) - navigate_home(); - } - } - return; - } - - if (elm->key_code == KEY_LEFTSHIFT || elm->key_code == KEY_RIGHTSHIFT) { - shift_down_ = elm->key_state != KBD_KEY_RELEASED; - return; - } - - if (elm->key_state && elm->key_code == KEY_F6) { - switch_big_mode(!big_mode_); - return; - } - if (elm->key_state && big_mode_) { - switch (elm->key_code) { - case KEY_F4: - pan_big_view(-8, 0); - render_all(); - return; - case KEY_F8: - pan_big_view(8, 0); - render_all(); - return; - case KEY_F5: - pan_big_view(0, -4); - render_all(); - return; - case KEY_F7: - pan_big_view(0, 4); - render_all(); - return; - default: - break; - } - } - - bool shift = shift_down_ || ((elm->mods & KBD_MOD_SHIFT) != 0); - if (elm->key_state && shift && elm->key_code == KEY_PAGEUP) { - scrollback_page(1); - render_all(); - return; - } - if (elm->key_state && shift && elm->key_code == KEY_PAGEDOWN) { - scrollback_page(-1); - render_all(); - return; - } - - if (terminal_active_ && !pty_handle_.empty() && elm->key_state) { - leave_scrollback(); - follow_cursor_in_big_mode(); - write_key(elm->key_code, elm->utf8); - } - } - - static void effective_colors(const Glyph &g, uint32_t *fg, uint32_t *bg) - { - uint32_t out_fg = g.fg; - uint32_t out_bg = g.bg; - if (g.attr & ATTR_REVERSE) - std::swap(out_fg, out_bg); - if (g.attr & ATTR_BOLD) - out_fg = out_fg < 8 ? out_fg + 8 : out_fg; - if (fg) - *fg = out_fg; - if (bg) - *bg = out_bg; - } - - bool meaningful_cell(const Glyph &g) const - { - uint32_t fg = DEFAULT_FG; - uint32_t bg = DEFAULT_BG; - effective_colors(g, &fg, &bg); - char ch = (g.attr & ATTR_INVISIBLE) ? ' ' : printable(g.u); - return ch != ' ' || fg != DEFAULT_FG || bg != DEFAULT_BG; - } - - lv_obj_t *create_segment_label() - { - lv_obj_t *lbl = lv_label_create(term_canvas_); - lv_obj_set_style_text_font(lbl, mono_font_, 0); - lv_obj_set_style_text_color(lbl, palette(DEFAULT_FG), 0); - lv_obj_set_style_bg_color(lbl, palette(DEFAULT_BG), 0); - lv_obj_set_style_bg_opa(lbl, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(lbl, 0, 0); - lv_obj_set_style_pad_top(lbl, TEXT_Y_PAD, 0); - lv_obj_set_style_text_letter_space(lbl, 0, 0); - lv_obj_set_style_text_line_space(lbl, 0, 0); - lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); - lv_obj_set_size(lbl, CHAR_W, CHAR_H); - lv_label_set_text(lbl, " "); - lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); - return lbl; - } - - std::vector build_row_segments(int r) - { - std::vector out; - const auto &line = display_row(r); - int last = -1; - int first_col = big_mode_ ? viewport_x_ : 0; - int cols = visible_cols(); - for (int c = cols - 1; c >= 0; --c) { - if (meaningful_cell(line[(size_t)(first_col + c)])) { - last = c; - break; - } - } - if (last < 0) - return out; - - SegmentData current; - bool has_current = false; - for (int c = 0; c <= last; ++c) { - const Glyph &g = line[(size_t)(first_col + c)]; - uint32_t fg = DEFAULT_FG; - uint32_t bg = DEFAULT_BG; - effective_colors(g, &fg, &bg); - char ch = (g.attr & ATTR_INVISIBLE) ? ' ' : printable(g.u); - - if (!has_current || current.fg != fg || current.bg != bg) { - if (has_current) - out.push_back(current); - current = SegmentData{}; - current.x = c; - current.fg = fg; - current.bg = bg; - has_current = true; - } - current.text.push_back(ch); - } - if (has_current) - out.push_back(current); - return out; - } - - void render_row(int r) - { - std::vector desired = build_row_segments(r); - std::vector &rendered = row_segments_[r]; - if (rendered.size() < desired.size()) - rendered.resize(desired.size()); - - for (size_t i = 0; i < desired.size(); ++i) { - SegmentData &want = desired[i]; - RenderSegment &have = rendered[i]; - if (!have.label) - have.label = create_segment_label(); - - int width = (int)want.text.size() * CHAR_W; - bool changed = have.hidden || have.x != want.x || have.width != width || - have.fg != want.fg || have.bg != want.bg || have.text != want.text; - if (!changed) - continue; - - have.hidden = false; - have.x = want.x; - have.width = width; - have.fg = want.fg; - have.bg = want.bg; - have.text = want.text; - - lv_obj_clear_flag(have.label, LV_OBJ_FLAG_HIDDEN); - lv_obj_set_pos(have.label, want.x * CHAR_W, r * CHAR_H); - lv_obj_set_size(have.label, width, CHAR_H); - lv_obj_set_style_text_color(have.label, palette(want.fg), 0); - lv_obj_set_style_bg_color(have.label, palette(want.bg), 0); - lv_label_set_text(have.label, want.text.c_str()); - } - - for (size_t i = desired.size(); i < rendered.size(); ++i) { - RenderSegment &segment = rendered[i]; - if (!segment.hidden && segment.label) { - lv_obj_add_flag(segment.label, LV_OBJ_FLAG_HIDDEN); - segment.hidden = true; - segment.text.clear(); - } - } - } - - void render_all() - { - int rows = visible_rows(); - for (int r = 0; r < ROWS; ++r) { - if (r >= rows) { - if (dirty_[r]) { - for (auto &segment : row_segments_[r]) { - if (segment.label) - lv_obj_add_flag(segment.label, LV_OBJ_FLAG_HIDDEN); - segment.hidden = true; - } - dirty_[r] = false; - } - continue; - } - if (dirty_[r]) { - render_row(r); - dirty_[r] = false; - } - } - update_cursor(); - update_hscrollbar(); - update_scrollbar(); - } - - void update_cursor() - { - if (!cursor_label_) - return; - if (scrollback_offset_ > 0) { - lv_obj_add_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); - cursor_blink_visible_ = false; - return; - } - int x = clamp(cursor_.x - (big_mode_ ? viewport_x_ : 0), 0, visible_cols() - 1); - int y = clamp(cursor_.y - (big_mode_ ? viewport_y_ : 0), 0, visible_rows() - 1); - if (big_mode_ && (cursor_.x < viewport_x_ || cursor_.x >= viewport_x_ + visible_cols() || - cursor_.y < viewport_y_ || cursor_.y >= viewport_y_ + visible_rows())) { - lv_obj_add_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); - cursor_blink_visible_ = false; - return; - } - const Glyph &g = screen_[cursor_.y][cursor_.x]; - uint32_t fg = DEFAULT_FG; - uint32_t bg = DEFAULT_BG; - effective_colors(g, &fg, &bg); - char under = (g.attr & ATTR_INVISIBLE) ? ' ' : printable(g.u); - char text[2] = {under, '\0'}; - lv_label_set_text(cursor_label_, text); - lv_obj_set_style_text_color(cursor_label_, palette(bg), 0); - lv_obj_set_style_bg_color(cursor_label_, palette(fg), 0); - lv_obj_set_pos(cursor_label_, x * CHAR_W, y * CHAR_H); - lv_obj_move_foreground(cursor_label_); - } - - void show_cursor(bool show) - { - if (!cursor_label_) - return; - if (scrollback_offset_ > 0) - show = false; - if (big_mode_ && (cursor_.x < viewport_x_ || cursor_.x >= viewport_x_ + visible_cols() || - cursor_.y < viewport_y_ || cursor_.y >= viewport_y_ + visible_rows())) - show = false; - if (show) - lv_obj_clear_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); - cursor_blink_visible_ = show; - } - - std::string pty_open(const std::string &cmd, const std::list &args) - { - int code = -1; - std::string handle; - std::list api_args = { - "Open", - cmd, - std::to_string(term_cols_), - std::to_string(term_rows_), - cmd, - }; - for (const auto &arg : args) - api_args.push_back(arg); - - cp0_signal_pty_api(std::move(api_args), [&](int c, std::string data) { - code = c; - if (code == 0) - handle = std::move(data); - }); - return handle; - } - - void stop_timers() - { - if (poll_timer_) { - lv_timer_delete(poll_timer_); - poll_timer_ = nullptr; - } - if (cursor_timer_) { - lv_timer_delete(cursor_timer_); - cursor_timer_ = nullptr; - } - } - - void start_timers() - { - if (!poll_timer_) - poll_timer_ = lv_timer_create(UISTPage::static_poll_cb, 30, this); - if (!cursor_timer_) - cursor_timer_ = lv_timer_create(UISTPage::static_cursor_cb, 500, this); - } - - void start_command(const std::string &cmd, const std::list &args, - const char *title, const char *err_msg) - { - stop_pty(); - pty_handle_ = pty_open(cmd, args); - terminal_active_ = !pty_handle_.empty(); - if (!terminal_active_) { - process_bytes(err_msg, (int)strlen(err_msg)); - render_all(); - waiting_key_to_exit_ = true; - return; - } - if (title && title[0]) - set_page_title(title); - start_timers(); - render_all(); - } - - void start_shell() - { - start_command("bash", { - "-c", - "cd ~ && " - "if [ -r ~/.bashrc ]; then " - "exec env TERM=st-256color COLORTERM=truecolor bash --rcfile ~/.bashrc -i; " - "else " - "exec env TERM=st-256color COLORTERM=truecolor bash -i; " - "fi" - }, "CLI", "cli: failed to open PTY\r\n"); - } - - void stop_pty() - { - if (!pty_handle_.empty()) { - cp0_signal_pty_api({"Close", pty_handle_}, nullptr); - pty_handle_.clear(); - } - } - - int pty_read(char *buf, size_t buf_size) - { - int code = -1; - std::string data; - cp0_signal_pty_api({"Read", pty_handle_, std::to_string(buf_size)}, [&](int c, std::string d) { - code = c; - data = std::move(d); - }); - if (code < 0) - return -1; - size_t n = std::min(data.size(), buf_size); - if (n > 0) - memcpy(buf, data.data(), n); - return (int)n; - } - - int pty_write(const char *buf, size_t len) - { - if (pty_handle_.empty() || !buf || len == 0) - return -1; - int code = -1; - cp0_signal_pty_api({"Write", pty_handle_, std::string(buf, len)}, [&](int c, std::string) { - code = c; - }); - return code; - } - - int pty_check_child(int *status) - { - int code = -1; - std::string data; - cp0_signal_pty_api({"CheckChild", pty_handle_}, [&](int c, std::string d) { - code = c; - data = std::move(d); - }); - if (status) - *status = atoi(data.c_str()); - return code; - } - - static void static_poll_cb(lv_timer_t *timer) - { - auto *self = static_cast(lv_timer_get_user_data(timer)); - if (self) - self->poll_cb(); - } - - void poll_cb() - { - if (!terminal_active_ || pty_handle_.empty()) - return; - - char buf[1024]; - int n = 0; - bool changed = false; - while ((n = pty_read(buf, sizeof(buf))) > 0) { - process_bytes(buf, n); - changed = true; - } - - if (changed) { - if (scrollback_offset_ > 0) - dirty_all(); - follow_cursor_in_big_mode(); - if (big_mode_) - dirty_all(); - render_all(); - } - - bool child_exited = n < 0; - if (!child_exited && !pty_handle_.empty()) { - int status = 0; - child_exited = pty_check_child(&status) == 1; - } - if (child_exited) { - terminal_active_ = false; - stop_pty(); - const char *hint = "\r\n-- Press any key to exit --"; - process_bytes(hint, (int)strlen(hint)); - render_all(); - waiting_key_to_exit_ = true; - } - } - - static void static_cursor_cb(lv_timer_t *timer) - { - auto *self = static_cast(lv_timer_get_user_data(timer)); - if (self) - self->cursor_cb(); - } - - void cursor_cb() - { - handle_home_hold_exit(); - update_cursor(); - if (!terminal_active_ || !cursor_visible_mode_) { - show_cursor(false); - return; - } - show_cursor(!cursor_blink_visible_); - } - - void handle_home_hold_exit() - { - if (home_hold_status_ == 0) { - if (LVGL_HOME_KEY_FLAG) { - home_hold_status_ = 1; - home_hold_start_ = std::chrono::steady_clock::now(); - } - return; - } - - if (!LVGL_HOME_KEY_FLAG) { - home_hold_status_ = 0; - return; - } - - auto now = std::chrono::steady_clock::now(); - if (std::chrono::duration_cast(now - home_hold_start_).count() < 5) - return; - - home_hold_status_ = 0; - stop_pty(); - terminal_active_ = false; - if (navigate_home) - navigate_home(); - } - - void write_key(uint32_t evdev_key, const char *utf8) - { - char buf[16]; - int len = 0; - - switch (evdev_key) { - case 28: - buf[0] = '\r'; - len = 1; - break; - case 14: - buf[0] = 0x7f; - len = 1; - break; - case 1: - buf[0] = 0x1b; - len = 1; - break; - case 103: - len = snprintf(buf, sizeof(buf), "%s", (mode_ & MODE_APPCURSOR) ? "\033OA" : "\033[A"); - break; - case 108: - len = snprintf(buf, sizeof(buf), "%s", (mode_ & MODE_APPCURSOR) ? "\033OB" : "\033[B"); - break; - case 106: - len = snprintf(buf, sizeof(buf), "%s", (mode_ & MODE_APPCURSOR) ? "\033OC" : "\033[C"); - break; - case 105: - len = snprintf(buf, sizeof(buf), "%s", (mode_ & MODE_APPCURSOR) ? "\033OD" : "\033[D"); - break; - default: - len = utf8 ? (int)strlen(utf8) : 0; - if (len > 0 && len < (int)sizeof(buf)) - memcpy(buf, utf8, (size_t)len); - else - len = 0; - break; - } - - if (len > 0) - pty_write(buf, (size_t)len); - } + static int clamp(int v, int lo, int hi); + static char printable(uint32_t u); + static lv_color_t palette(uint32_t color); + static const lv_font_t *terminal_font(); + static uint32_t xterm256_to_palette(int color); + static uint32_t rgb_to_palette(int r, int g, int b); + + Glyph blank_glyph() const; + void dirty_row(int row); + void dirty_all(); + int visible_cols() const; + int visible_rows() const; + int visible_h() const; + int max_viewport_x() const; + int max_viewport_y() const; + void append_scrollback_row(const std::array &row); + const std::array &display_row(int row) const; + void scrollback_page(int direction); + + void update_scrollbar(); + void update_hscrollbar(); + void set_bottom_label(int index, const char *text); + void update_big_mode_ui(); + void switch_big_mode(bool enable); + void leave_scrollback(); + void pan_big_view(int dx, int dy); + void follow_cursor_in_big_mode(); + + void reset_terminal(); + void clear_region(int x1, int y1, int x2, int y2); + void move_to(int x, int y); + void scroll_up(int top, int bottom, int count); + void scroll_down(int top, int bottom, int count); + void newline(bool first_col); + void put_tab(); + void insert_blank(int count); + void delete_chars(int count); + void put_rune(uint32_t rune); + void control_code(uint8_t code); + + int param(int index, int default_value) const; + void csi_reset(); + void csi_push_param(); + void set_sgr(); + void handle_private_mode(char final); + + void handle_csi(char final); + + void handle_esc(uint8_t code); + void process_bytes(const char *data, int length); + + void create_ui(); + bool renderer_ready() const; + void bind_events(); + void detach_renderer_callbacks(); + static void static_renderer_delete_cb(lv_event_t *event) noexcept; + static void static_event_cb(lv_event_t *event) noexcept; + void event_cb(lv_event_t *event); + void recover_callback_failure() noexcept; + + static void effective_colors(const Glyph &glyph, uint32_t *foreground, uint32_t *background); + bool meaningful_cell(const Glyph &glyph) const; + lv_obj_t *create_segment_label(); + std::vector build_row_segments(int row); + void render_row(int row); + void render_all(); + void update_cursor(); + void show_cursor(bool show); + + std::string pty_open(const std::string &command, const std::list &args); + void stop_timers(); + bool start_timers(); + void start_command(const std::string &command, const std::list &args, + const char *title, const char *error_message); + void start_shell(); + void stop_pty(); + int pty_read(char *buffer, size_t buffer_size); + int pty_write(const char *buffer, size_t length); + int pty_check_child(int *status); + static void static_poll_cb(lv_timer_t *timer) noexcept; + void poll_cb(); + static void static_cursor_cb(lv_timer_t *timer) noexcept; + void cursor_cb(); + void handle_home_hold_exit(); + void write_key(uint32_t evdev_key, const char *utf8); }; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_st_parser.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_st_parser.cpp new file mode 100644 index 00000000..13a8da87 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_st_parser.cpp @@ -0,0 +1,441 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_st.hpp" + +#include +#include + +void UISTPage::reset_terminal() +{ + cursor_ = Cursor{}; + cursor_.attr.fg = DEFAULT_FG; + cursor_.attr.bg = DEFAULT_BG; + saved_cursor_ = cursor_; + scroll_top_ = 0; + scroll_bot_ = term_rows_ - 1; + scrollback_.clear(); + scrollback_offset_ = 0; + mode_ = MODE_WRAP; + parse_state_ = ParseState::Normal; + cursor_visible_mode_ = true; + csi_reset(); + for (auto &row : screen_) + for (auto &cell : row) cell = blank_glyph(); + dirty_all(); +} + +void UISTPage::clear_region(int x1, int y1, int x2, int y2) +{ + x1 = clamp(x1, 0, term_cols_ - 1); + x2 = clamp(x2, 0, term_cols_ - 1); + y1 = clamp(y1, 0, term_rows_ - 1); + y2 = clamp(y2, 0, term_rows_ - 1); + if (x1 > x2) std::swap(x1, x2); + if (y1 > y2) std::swap(y1, y2); + + Glyph blank = blank_glyph(); + for (int y = y1; y <= y2; ++y) { + for (int x = x1; x <= x2; ++x) screen_[y][x] = blank; + dirty_row(y); + } +} + +void UISTPage::move_to(int x, int y) +{ + cursor_.x = clamp(x, 0, term_cols_ - 1); + cursor_.y = clamp(y, 0, term_rows_ - 1); +} + +void UISTPage::scroll_up(int top, int bottom, int count) +{ + top = clamp(top, 0, term_rows_ - 1); + bottom = clamp(bottom, 0, term_rows_ - 1); + if (top > bottom || count <= 0) return; + count = std::min(count, bottom - top + 1); + if (!big_mode_ && top == 0 && bottom == term_rows_ - 1) + for (int y = 0; y < count; ++y) append_scrollback_row(screen_[y]); + for (int y = top; y <= bottom - count; ++y) { + screen_[y] = screen_[y + count]; + dirty_row(y); + } + Glyph blank = blank_glyph(); + for (int y = bottom - count + 1; y <= bottom; ++y) { + for (auto &cell : screen_[y]) cell = blank; + dirty_row(y); + } +} + +void UISTPage::scroll_down(int top, int bottom, int count) +{ + top = clamp(top, 0, term_rows_ - 1); + bottom = clamp(bottom, 0, term_rows_ - 1); + if (top > bottom || count <= 0) return; + count = std::min(count, bottom - top + 1); + for (int y = bottom; y >= top + count; --y) { + screen_[y] = screen_[y - count]; + dirty_row(y); + } + Glyph blank = blank_glyph(); + for (int y = top; y < top + count; ++y) { + for (auto &cell : screen_[y]) cell = blank; + dirty_row(y); + } +} + +void UISTPage::newline(bool first_col) +{ + if (first_col) cursor_.x = 0; + if (cursor_.y == scroll_bot_) + scroll_up(scroll_top_, scroll_bot_, 1); + else + cursor_.y = clamp(cursor_.y + 1, 0, term_rows_ - 1); +} + +void UISTPage::put_tab() +{ + int next_tab = ((cursor_.x / 8) + 1) * 8; + cursor_.x = clamp(next_tab, 0, term_cols_ - 1); +} + +void UISTPage::insert_blank(int count) +{ + count = std::min(std::max(count, 1), term_cols_ - cursor_.x); + auto &line = screen_[cursor_.y]; + for (int x = term_cols_ - 1; x >= cursor_.x + count; --x) + line[x] = line[x - count]; + Glyph blank = blank_glyph(); + for (int x = cursor_.x; x < cursor_.x + count; ++x) line[x] = blank; + dirty_row(cursor_.y); +} + +void UISTPage::delete_chars(int count) +{ + count = std::min(std::max(count, 1), term_cols_ - cursor_.x); + auto &line = screen_[cursor_.y]; + for (int x = cursor_.x; x < term_cols_ - count; ++x) line[x] = line[x + count]; + Glyph blank = blank_glyph(); + for (int x = term_cols_ - count; x < term_cols_; ++x) line[x] = blank; + dirty_row(cursor_.y); +} + +void UISTPage::put_rune(uint32_t rune) +{ + if (mode_ & MODE_INSERT) insert_blank(1); + if (cursor_.x >= term_cols_) { + if (mode_ & MODE_WRAP) { + cursor_.x = 0; + newline(false); + } else { + cursor_.x = term_cols_ - 1; + } + } + + Glyph glyph = cursor_.attr; + glyph.u = rune; + screen_[cursor_.y][cursor_.x] = glyph; + dirty_row(cursor_.y); + ++cursor_.x; +} + +void UISTPage::control_code(uint8_t code) +{ + switch (code) { + case '\t': put_tab(); break; + case '\b': cursor_.x = std::max(0, cursor_.x - 1); break; + case '\r': cursor_.x = 0; break; + case '\n': + case '\v': + case '\f': newline(true); break; + case 0x0e: + case 0x0f: + default: break; + } +} + +int UISTPage::param(int index, int default_value) const +{ + if (index >= csi_param_count_) return default_value; + return csi_params_[index] == 0 ? default_value : csi_params_[index]; +} + +void UISTPage::csi_reset() +{ + csi_private_ = false; + csi_secondary_ = false; + csi_param_count_ = 0; + csi_param_value_ = 0; + csi_have_value_ = false; + std::fill(std::begin(csi_params_), std::end(csi_params_), 0); +} + +void UISTPage::csi_push_param() +{ + if (csi_param_count_ >= static_cast(std::size(csi_params_))) return; + csi_params_[csi_param_count_++] = csi_have_value_ ? csi_param_value_ : 0; + csi_param_value_ = 0; + csi_have_value_ = false; +} + +void UISTPage::set_sgr() +{ + if (csi_param_count_ == 0) { + cursor_.attr.attr = ATTR_NULL; + cursor_.attr.fg = DEFAULT_FG; + cursor_.attr.bg = DEFAULT_BG; + return; + } + + for (int i = 0; i < csi_param_count_; ++i) { + int value = csi_params_[i]; + if (value == 0) { + cursor_.attr.attr = ATTR_NULL; + cursor_.attr.fg = DEFAULT_FG; + cursor_.attr.bg = DEFAULT_BG; + } else if (value == 1) { + cursor_.attr.attr |= ATTR_BOLD; + } else if (value == 2) { + cursor_.attr.attr |= ATTR_FAINT; + } else if (value == 4) { + cursor_.attr.attr |= ATTR_UNDERLINE; + } else if (value == 5) { + cursor_.attr.attr |= ATTR_BLINK; + } else if (value == 7) { + cursor_.attr.attr |= ATTR_REVERSE; + } else if (value == 8) { + cursor_.attr.attr |= ATTR_INVISIBLE; + } else if (value == 22) { + cursor_.attr.attr &= ~(ATTR_BOLD | ATTR_FAINT); + } else if (value == 24) { + cursor_.attr.attr &= ~ATTR_UNDERLINE; + } else if (value == 25) { + cursor_.attr.attr &= ~ATTR_BLINK; + } else if (value == 27) { + cursor_.attr.attr &= ~ATTR_REVERSE; + } else if (value == 28) { + cursor_.attr.attr &= ~ATTR_INVISIBLE; + } else if (value >= 30 && value <= 37) { + cursor_.attr.fg = static_cast(value - 30); + } else if (value >= 40 && value <= 47) { + cursor_.attr.bg = static_cast(value - 40); + } else if (value >= 90 && value <= 97) { + cursor_.attr.fg = static_cast(value - 90 + 8); + } else if (value >= 100 && value <= 107) { + cursor_.attr.bg = static_cast(value - 100 + 8); + } else if ((value == 38 || value == 48) && i + 2 < csi_param_count_ && + csi_params_[i + 1] == 5) { + uint32_t mapped = xterm256_to_palette(csi_params_[i + 2]); + (value == 38 ? cursor_.attr.fg : cursor_.attr.bg) = mapped; + i += 2; + } else if ((value == 38 || value == 48) && i + 4 < csi_param_count_ && + csi_params_[i + 1] == 2) { + uint32_t mapped = rgb_to_palette(csi_params_[i + 2], csi_params_[i + 3], + csi_params_[i + 4]); + (value == 38 ? cursor_.attr.fg : cursor_.attr.bg) = mapped; + i += 4; + } else if (value == 39) { + cursor_.attr.fg = DEFAULT_FG; + } else if (value == 49) { + cursor_.attr.bg = DEFAULT_BG; + } + } +} + +void UISTPage::handle_private_mode(char final) +{ + bool set = final == 'h'; + for (int i = 0; i < csi_param_count_; ++i) { + switch (csi_params_[i]) { + case 1: + if (set) mode_ |= MODE_APPCURSOR; + else mode_ &= ~MODE_APPCURSOR; + break; + case 7: + if (set) mode_ |= MODE_WRAP; + else mode_ &= ~MODE_WRAP; + break; + case 25: + cursor_visible_mode_ = set; + break; + case 1049: + if (set) clear_region(0, 0, term_cols_ - 1, term_rows_ - 1); + break; + default: + break; + } + } +} + +void UISTPage::handle_csi(char final) +{ + if (csi_secondary_) { + if (final == 'c') pty_write("\033[>0;115;0c", 11); + return; + } + if (csi_private_ && (final == 'h' || final == 'l')) { + handle_private_mode(final); + return; + } + + switch (final) { + case '@': insert_blank(param(0, 1)); break; + case 'A': move_to(cursor_.x, cursor_.y - param(0, 1)); break; + case 'B': move_to(cursor_.x, cursor_.y + param(0, 1)); break; + case 'C': move_to(cursor_.x + param(0, 1), cursor_.y); break; + case 'D': move_to(cursor_.x - param(0, 1), cursor_.y); break; + case 'G': move_to(param(0, 1) - 1, cursor_.y); break; + case 'H': + case 'f': move_to(param(1, 1) - 1, param(0, 1) - 1); break; + case 'J': + if (param(0, 0) == 0) + clear_region(cursor_.x, cursor_.y, term_cols_ - 1, term_rows_ - 1); + else if (param(0, 0) == 1) + clear_region(0, 0, cursor_.x, cursor_.y); + else + clear_region(0, 0, term_cols_ - 1, term_rows_ - 1); + break; + case 'K': + if (param(0, 0) == 0) + clear_region(cursor_.x, cursor_.y, term_cols_ - 1, cursor_.y); + else if (param(0, 0) == 1) + clear_region(0, cursor_.y, cursor_.x, cursor_.y); + else + clear_region(0, cursor_.y, term_cols_ - 1, cursor_.y); + break; + case 'L': scroll_down(cursor_.y, scroll_bot_, param(0, 1)); break; + case 'M': scroll_up(cursor_.y, scroll_bot_, param(0, 1)); break; + case 'P': delete_chars(param(0, 1)); break; + case 'd': move_to(cursor_.x, param(0, 1) - 1); break; + case 'h': + if (param(0, 0) == 4) mode_ |= MODE_INSERT; + break; + case 'l': + if (param(0, 0) == 4) mode_ &= ~MODE_INSERT; + break; + case 'm': set_sgr(); break; + case 'n': + if (param(0, 0) == 5) { + pty_write("\033[0n", 4); + } else if (param(0, 0) == 6) { + char reply[32]; + int length = snprintf(reply, sizeof(reply), "\033[%d;%dR", + cursor_.y + 1, cursor_.x + 1); + pty_write(reply, static_cast(length)); + } + break; + case 'r': + scroll_top_ = clamp(param(0, 1) - 1, 0, term_rows_ - 1); + scroll_bot_ = clamp(param(1, term_rows_) - 1, 0, term_rows_ - 1); + if (scroll_top_ >= scroll_bot_) { + scroll_top_ = 0; + scroll_bot_ = term_rows_ - 1; + } + move_to(0, 0); + break; + case 's': saved_cursor_ = cursor_; break; + case 'u': + cursor_ = saved_cursor_; + move_to(cursor_.x, cursor_.y); + break; + case 'c': pty_write("\033[?1;2c", 7); break; + default: break; + } +} + +void UISTPage::handle_esc(uint8_t code) +{ + switch (code) { + case '[': + csi_reset(); + parse_state_ = ParseState::Csi; + return; + case ']': + parse_state_ = ParseState::Osc; + return; + case '(': + case ')': + case '*': + case '+': + parse_state_ = ParseState::Charset; + return; + case '7': + saved_cursor_ = cursor_; + break; + case '8': + cursor_ = saved_cursor_; + move_to(cursor_.x, cursor_.y); + break; + case 'D': newline(false); break; + case 'E': newline(true); break; + case 'M': + if (cursor_.y == scroll_top_) + scroll_down(scroll_top_, scroll_bot_, 1); + else + move_to(cursor_.x, cursor_.y - 1); + break; + case 'c': reset_terminal(); break; + default: break; + } + parse_state_ = ParseState::Normal; +} + +void UISTPage::process_bytes(const char *data, int length) +{ + for (int index = 0; index < length; ++index) { + uint8_t code = static_cast(data[index]); + if (parse_state_ == ParseState::Osc) { + if (code == 0x07) { + parse_state_ = ParseState::Normal; + } else if (code == 0x1b && index + 1 < length && data[index + 1] == '\\') { + ++index; + parse_state_ = ParseState::Normal; + } + continue; + } + if (parse_state_ == ParseState::Charset) { + parse_state_ = ParseState::Normal; + continue; + } + if (parse_state_ == ParseState::Esc) { + handle_esc(code); + continue; + } + if (parse_state_ == ParseState::Csi) { + if (code == '?') { + csi_private_ = true; + continue; + } + if (code == '>') { + csi_secondary_ = true; + continue; + } + if (std::isdigit(code)) { + csi_param_value_ = csi_param_value_ * 10 + (code - '0'); + csi_have_value_ = true; + continue; + } + if (code == ';') { + csi_push_param(); + continue; + } + if (code >= 0x20 && code <= 0x2f) continue; + csi_push_param(); + handle_csi(static_cast(code)); + parse_state_ = ParseState::Normal; + continue; + } + + if (code == 0x1b) + parse_state_ = ParseState::Esc; + else if (code < 0x20 || code == 0x7f) + control_code(code); + else + put_rune(code); + } +} + + diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_st_renderer.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_st_renderer.cpp new file mode 100644 index 00000000..4c8c5f47 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_st_renderer.cpp @@ -0,0 +1,480 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_st.hpp" + +void UISTPage::create_ui() +{ + if (!ui_APP_Container) return; + terminal_container_ = lv_obj_create(ui_APP_Container); + if (!terminal_container_) return; + lv_obj_add_event_cb(terminal_container_, static_renderer_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(terminal_container_); + lv_obj_set_size(terminal_container_, TERM_W, TERM_H); + lv_obj_set_pos(terminal_container_, 0, 0); + lv_obj_set_style_bg_color(terminal_container_, palette(DEFAULT_BG), 0); + lv_obj_set_style_bg_opa(terminal_container_, LV_OPA_COVER, 0); + lv_obj_clear_flag(terminal_container_, + static_cast(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + + term_canvas_ = lv_obj_create(terminal_container_); + if (!term_canvas_) return; + lv_obj_add_event_cb(term_canvas_, static_renderer_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(term_canvas_, TERM_W, TERM_H); + lv_obj_set_pos(term_canvas_, 0, 0); + lv_obj_set_style_bg_color(term_canvas_, palette(DEFAULT_BG), 0); + lv_obj_set_style_bg_opa(term_canvas_, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(term_canvas_, 0, 0); + lv_obj_set_style_pad_all(term_canvas_, 0, 0); + lv_obj_set_style_radius(term_canvas_, 0, 0); + lv_obj_clear_flag(term_canvas_, + static_cast(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + + auto create_scrollbar = [&](lv_obj_t **object, int width, int height, int x, int y, + uint32_t color) { + *object = lv_obj_create(terminal_container_); + if (!*object) return; + lv_obj_add_event_cb(*object, static_renderer_delete_cb, LV_EVENT_DELETE, this); + lv_obj_remove_style_all(*object); + lv_obj_set_size(*object, width, height); + lv_obj_set_pos(*object, x, y); + lv_obj_set_style_bg_color(*object, lv_color_hex(color), 0); + lv_obj_set_style_bg_opa(*object, LV_OPA_COVER, 0); + lv_obj_clear_flag(*object, + static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + lv_obj_add_flag(*object, LV_OBJ_FLAG_HIDDEN); + }; + create_scrollbar(&scrollbar_track_, SCROLLBAR_W, TERM_H, + TERM_W - SCROLLBAR_W - 1, 0, 0x30363D); + create_scrollbar(&scrollbar_thumb_, SCROLLBAR_W, 8, + TERM_W - SCROLLBAR_W - 1, TERM_H - 8, 0x8B949E); + create_scrollbar(&hscrollbar_track_, TERM_W - SCROLLBAR_W - 2, 3, + 0, BIG_VIEW_ROWS * CHAR_H, 0x30363D); + create_scrollbar(&hscrollbar_thumb_, 18, 3, + 0, BIG_VIEW_ROWS * CHAR_H, 0x8B949E); + + static constexpr const char *BOTTOM_TEXT[BOTTOM_BAR_SLOTS] = { + "F4 <", "F5 up", "F6 normal", "F7 down", "F8 >", + }; + constexpr int SLOT_WIDTH = TERM_W / BOTTOM_BAR_SLOTS; + for (int index = 0; index < BOTTOM_BAR_SLOTS; ++index) { + size_t slot = static_cast(index); + bottom_labels_[slot] = lv_label_create(terminal_container_); + if (bottom_labels_[slot]) { + lv_obj_add_event_cb(bottom_labels_[slot], static_renderer_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_pos(bottom_labels_[slot], index * SLOT_WIDTH, TERM_H - BIG_BOTTOM_H); + lv_obj_set_size(bottom_labels_[slot], SLOT_WIDTH, BIG_BOTTOM_H); + lv_obj_set_style_text_font(bottom_labels_[slot], &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(bottom_labels_[slot], lv_color_hex(0xF0F6FC), 0); + lv_obj_set_style_text_align(bottom_labels_[slot], LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_pad_top(bottom_labels_[slot], 1, 0); + lv_label_set_text(bottom_labels_[slot], BOTTOM_TEXT[index]); + lv_obj_add_flag(bottom_labels_[slot], LV_OBJ_FLAG_HIDDEN); + } + + bottom_indicators_[slot] = lv_label_create(terminal_container_); + if (bottom_indicators_[slot]) { + lv_obj_add_event_cb(bottom_indicators_[slot], static_renderer_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_pos(bottom_indicators_[slot], index * SLOT_WIDTH, TERM_H - 4); + lv_obj_set_size(bottom_indicators_[slot], SLOT_WIDTH, 4); + lv_obj_set_style_text_font(bottom_indicators_[slot], &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(bottom_indicators_[slot], lv_color_hex(0x8B949E), 0); + lv_obj_set_style_text_align(bottom_indicators_[slot], LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(bottom_indicators_[slot], "|"); + lv_obj_add_flag(bottom_indicators_[slot], LV_OBJ_FLAG_HIDDEN); + } + } + + mono_font_ = launcher_fonts().get_mono("LiberationMono-Regular.ttf", 11, + LV_FREETYPE_FONT_STYLE_NORMAL); + cursor_label_ = lv_label_create(term_canvas_); + if (!cursor_label_) return; + lv_obj_add_event_cb(cursor_label_, static_renderer_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_style_text_font(cursor_label_, mono_font_, 0); + lv_obj_set_style_text_color(cursor_label_, palette(DEFAULT_BG), 0); + lv_obj_set_style_bg_color(cursor_label_, palette(DEFAULT_FG), 0); + lv_obj_set_style_bg_opa(cursor_label_, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(cursor_label_, 0, 0); + lv_obj_set_style_pad_top(cursor_label_, TEXT_Y_PAD, 0); + lv_obj_set_style_text_letter_space(cursor_label_, 0, 0); + lv_label_set_long_mode(cursor_label_, LV_LABEL_LONG_CLIP); + lv_obj_set_size(cursor_label_, CHAR_W, CHAR_H); + lv_label_set_text(cursor_label_, " "); + lv_obj_add_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); +} + +void UISTPage::bind_events() +{ + if (!root_screen_) return; + lv_obj_add_event_cb(root_screen_, UISTPage::static_event_cb, LV_EVENT_ALL, this); +} + +bool UISTPage::renderer_ready() const +{ + if (!st_page_renderer_ready(terminal_container_, term_canvas_, scrollbar_track_, + scrollbar_thumb_, hscrollbar_track_, hscrollbar_thumb_, + cursor_label_)) + return false; + for (lv_obj_t *label : bottom_labels_) + if (!label) return false; + for (lv_obj_t *indicator : bottom_indicators_) + if (!indicator) return false; + return true; +} + +void UISTPage::detach_renderer_callbacks() +{ + std::vector objects = { + terminal_container_, term_canvas_, scrollbar_track_, scrollbar_thumb_, + hscrollbar_track_, hscrollbar_thumb_, cursor_label_, + }; + objects.insert(objects.end(), bottom_labels_.begin(), bottom_labels_.end()); + objects.insert(objects.end(), bottom_indicators_.begin(), bottom_indicators_.end()); + for (const auto &row : row_segments_) + for (const auto &segment : row) + objects.push_back(segment.label); + for (lv_obj_t *object : objects) + if (object) + lv_obj_remove_event_cb_with_user_data(object, static_renderer_delete_cb, this); +} + +void UISTPage::static_renderer_delete_cb(lv_event_t *event) noexcept +{ + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + try { + auto *deleted = static_cast(lv_event_get_target(event)); + if (!deleted || deleted != lv_event_get_current_target(event)) return; + + if (self->cursor_label_ == deleted) self->cursor_label_ = nullptr; + if (self->scrollbar_track_ == deleted) self->scrollbar_track_ = nullptr; + if (self->scrollbar_thumb_ == deleted) self->scrollbar_thumb_ = nullptr; + if (self->hscrollbar_track_ == deleted) self->hscrollbar_track_ = nullptr; + if (self->hscrollbar_thumb_ == deleted) self->hscrollbar_thumb_ = nullptr; + for (lv_obj_t *&label : self->bottom_labels_) + if (label == deleted) label = nullptr; + for (lv_obj_t *&indicator : self->bottom_indicators_) + if (indicator == deleted) indicator = nullptr; + for (auto &row : self->row_segments_) + for (auto &segment : row) + if (segment.label == deleted) { + segment.label = nullptr; + segment.hidden = true; + segment.text.clear(); + } + if (self->term_canvas_ == deleted) { + self->terminal_active_ = false; + self->stop_timers(); + self->stop_pty(); + self->term_canvas_ = nullptr; + self->cursor_label_ = nullptr; + for (auto &row : self->row_segments_) + for (auto &segment : row) + segment.label = nullptr; + } + if (self->terminal_container_ == deleted) { + self->terminal_active_ = false; + self->stop_timers(); + self->stop_pty(); + self->terminal_container_ = nullptr; + self->term_canvas_ = nullptr; + self->scrollbar_track_ = nullptr; + self->scrollbar_thumb_ = nullptr; + self->hscrollbar_track_ = nullptr; + self->hscrollbar_thumb_ = nullptr; + self->cursor_label_ = nullptr; + self->bottom_labels_.fill(nullptr); + self->bottom_indicators_.fill(nullptr); + for (auto &row : self->row_segments_) + for (auto &segment : row) + segment.label = nullptr; + } + if (!self->renderer_ready()) { + self->terminal_active_ = false; + self->stop_timers(); + self->stop_pty(); + } + } catch (...) { + self->recover_callback_failure(); + } +} + +void UISTPage::static_event_cb(lv_event_t *event) noexcept +{ + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self) return; + try { + self->event_cb(event); + } catch (...) { + self->recover_callback_failure(); + } +} + +void UISTPage::recover_callback_failure() noexcept +{ + const auto state = st_callback_failure_state(); + terminal_active_ = state.terminal_active; + waiting_key_to_exit_ = state.waiting_key_to_exit; + home_hold_status_ = state.home_hold_status; +} + +void UISTPage::event_cb(lv_event_t *event) +{ + if (lv_event_get_code(event) == LV_EVENT_DELETE && + lv_event_get_target(event) == lv_event_get_current_target(event)) { + terminal_active_ = false; + stop_timers(); + stop_pty(); + terminal_container_ = nullptr; + term_canvas_ = nullptr; + scrollbar_track_ = nullptr; + scrollbar_thumb_ = nullptr; + hscrollbar_track_ = nullptr; + hscrollbar_thumb_ = nullptr; + cursor_label_ = nullptr; + bottom_labels_.fill(nullptr); + bottom_indicators_.fill(nullptr); + for (auto &row : row_segments_) + for (auto &segment : row) + segment.label = nullptr; + return; + } + if (lv_event_get_code(event) != LV_EVENT_KEYBOARD) return; + auto *key = static_cast(lv_event_get_param(event)); + if (!key) return; + + if (waiting_key_to_exit_) { + if (key->key_state == 0) { + if (terminal_sysplause) + terminal_sysplause = false; + else { + waiting_key_to_exit_ = false; + if (navigate_home) navigate_home(); + } + } + return; + } + if (key->key_code == KEY_LEFTSHIFT || key->key_code == KEY_RIGHTSHIFT) { + shift_down_ = key->key_state != KBD_KEY_RELEASED; + return; + } + if (key->key_state && key->key_code == KEY_F6) { + switch_big_mode(!big_mode_); + return; + } + if (key->key_state && big_mode_) { + switch (key->key_code) { + case KEY_F4: pan_big_view(-8, 0); render_all(); return; + case KEY_F8: pan_big_view(8, 0); render_all(); return; + case KEY_F5: pan_big_view(0, -4); render_all(); return; + case KEY_F7: pan_big_view(0, 4); render_all(); return; + default: break; + } + } + + bool shift = shift_down_ || (key->mods & KBD_MOD_SHIFT) != 0; + if (key->key_state && shift && key->key_code == KEY_PAGEUP) { + scrollback_page(1); + render_all(); + return; + } + if (key->key_state && shift && key->key_code == KEY_PAGEDOWN) { + scrollback_page(-1); + render_all(); + return; + } + if (terminal_active_ && !pty_handle_.empty() && key->key_state) { + leave_scrollback(); + follow_cursor_in_big_mode(); + write_key(key->key_code, key->utf8); + } +} + +void UISTPage::effective_colors(const Glyph &glyph, uint32_t *foreground, + uint32_t *background) +{ + uint32_t output_foreground = glyph.fg; + uint32_t output_background = glyph.bg; + if (glyph.attr & ATTR_REVERSE) std::swap(output_foreground, output_background); + if (glyph.attr & ATTR_BOLD) + output_foreground = output_foreground < 8 ? output_foreground + 8 : output_foreground; + if (foreground) *foreground = output_foreground; + if (background) *background = output_background; +} + +bool UISTPage::meaningful_cell(const Glyph &glyph) const +{ + uint32_t foreground = DEFAULT_FG; + uint32_t background = DEFAULT_BG; + effective_colors(glyph, &foreground, &background); + char character = glyph.attr & ATTR_INVISIBLE ? ' ' : printable(glyph.u); + return character != ' ' || foreground != DEFAULT_FG || background != DEFAULT_BG; +} + +lv_obj_t *UISTPage::create_segment_label() +{ + if (!term_canvas_) return nullptr; + lv_obj_t *label = lv_label_create(term_canvas_); + if (!label) return nullptr; + lv_obj_add_event_cb(label, static_renderer_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_style_text_font(label, mono_font_, 0); + lv_obj_set_style_text_color(label, palette(DEFAULT_FG), 0); + lv_obj_set_style_bg_color(label, palette(DEFAULT_BG), 0); + lv_obj_set_style_bg_opa(label, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(label, 0, 0); + lv_obj_set_style_pad_top(label, TEXT_Y_PAD, 0); + lv_obj_set_style_text_letter_space(label, 0, 0); + lv_obj_set_style_text_line_space(label, 0, 0); + lv_label_set_long_mode(label, LV_LABEL_LONG_CLIP); + lv_obj_set_size(label, CHAR_W, CHAR_H); + lv_label_set_text(label, " "); + lv_obj_add_flag(label, LV_OBJ_FLAG_HIDDEN); + return label; +} + +std::vector UISTPage::build_row_segments(int row) +{ + std::vector result; + const auto &line = display_row(row); + int first_column = big_mode_ ? viewport_x_ : 0; + int last_column = -1; + for (int column = visible_cols() - 1; column >= 0; --column) { + if (meaningful_cell(line[static_cast(first_column + column)])) { + last_column = column; + break; + } + } + if (last_column < 0) return result; + + SegmentData current; + bool has_current = false; + for (int column = 0; column <= last_column; ++column) { + const Glyph &glyph = line[static_cast(first_column + column)]; + uint32_t foreground = DEFAULT_FG; + uint32_t background = DEFAULT_BG; + effective_colors(glyph, &foreground, &background); + char character = glyph.attr & ATTR_INVISIBLE ? ' ' : printable(glyph.u); + if (!has_current || current.fg != foreground || current.bg != background) { + if (has_current) result.push_back(current); + current = SegmentData{}; + current.x = column; + current.fg = foreground; + current.bg = background; + has_current = true; + } + current.text.push_back(character); + } + if (has_current) result.push_back(current); + return result; +} + +void UISTPage::render_row(int row) +{ + std::vector desired = build_row_segments(row); + std::vector &rendered = row_segments_[row]; + if (rendered.size() < desired.size()) rendered.resize(desired.size()); + + for (size_t index = 0; index < desired.size(); ++index) { + SegmentData &wanted = desired[index]; + RenderSegment ¤t = rendered[index]; + if (!current.label) current.label = create_segment_label(); + if (!current.label) continue; + int width = static_cast(wanted.text.size()) * CHAR_W; + bool changed = current.hidden || current.x != wanted.x || current.width != width || + current.fg != wanted.fg || current.bg != wanted.bg || + current.text != wanted.text; + if (!changed) continue; + + current.hidden = false; + current.x = wanted.x; + current.width = width; + current.fg = wanted.fg; + current.bg = wanted.bg; + current.text = wanted.text; + lv_obj_clear_flag(current.label, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_pos(current.label, wanted.x * CHAR_W, row * CHAR_H); + lv_obj_set_size(current.label, width, CHAR_H); + lv_obj_set_style_text_color(current.label, palette(wanted.fg), 0); + lv_obj_set_style_bg_color(current.label, palette(wanted.bg), 0); + lv_label_set_text(current.label, wanted.text.c_str()); + } + for (size_t index = desired.size(); index < rendered.size(); ++index) { + RenderSegment &segment = rendered[index]; + if (!segment.hidden && segment.label) { + lv_obj_add_flag(segment.label, LV_OBJ_FLAG_HIDDEN); + segment.hidden = true; + segment.text.clear(); + } + } +} + +void UISTPage::render_all() +{ + int visible_row_count = visible_rows(); + for (int row = 0; row < ROWS; ++row) { + if (row >= visible_row_count) { + if (dirty_[row]) { + for (auto &segment : row_segments_[row]) { + if (segment.label) lv_obj_add_flag(segment.label, LV_OBJ_FLAG_HIDDEN); + segment.hidden = true; + } + dirty_[row] = false; + } + } else if (dirty_[row]) { + render_row(row); + dirty_[row] = false; + } + } + update_cursor(); + update_hscrollbar(); + update_scrollbar(); +} + +void UISTPage::update_cursor() +{ + if (!cursor_label_) return; + if (scrollback_offset_ > 0) { + lv_obj_add_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); + cursor_blink_visible_ = false; + return; + } + int x = clamp(cursor_.x - (big_mode_ ? viewport_x_ : 0), 0, visible_cols() - 1); + int y = clamp(cursor_.y - (big_mode_ ? viewport_y_ : 0), 0, visible_rows() - 1); + bool outside_view = big_mode_ && + (cursor_.x < viewport_x_ || cursor_.x >= viewport_x_ + visible_cols() || + cursor_.y < viewport_y_ || cursor_.y >= viewport_y_ + visible_rows()); + if (outside_view) { + lv_obj_add_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); + cursor_blink_visible_ = false; + return; + } + + const Glyph &glyph = screen_[cursor_.y][cursor_.x]; + uint32_t foreground = DEFAULT_FG; + uint32_t background = DEFAULT_BG; + effective_colors(glyph, &foreground, &background); + char text[2] = {glyph.attr & ATTR_INVISIBLE ? ' ' : printable(glyph.u), '\0'}; + lv_label_set_text(cursor_label_, text); + lv_obj_set_style_text_color(cursor_label_, palette(background), 0); + lv_obj_set_style_bg_color(cursor_label_, palette(foreground), 0); + lv_obj_set_pos(cursor_label_, x * CHAR_W, y * CHAR_H); + lv_obj_move_foreground(cursor_label_); +} + +void UISTPage::show_cursor(bool show) +{ + if (!cursor_label_) return; + if (scrollback_offset_ > 0) show = false; + if (big_mode_ && + (cursor_.x < viewport_x_ || cursor_.x >= viewport_x_ + visible_cols() || + cursor_.y < viewport_y_ || cursor_.y >= viewport_y_ + visible_rows())) + show = false; + if (show) lv_obj_clear_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(cursor_label_, LV_OBJ_FLAG_HIDDEN); + cursor_blink_visible_ = show; +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_st_session.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_st_session.cpp new file mode 100644 index 00000000..7aacf395 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_st_session.cpp @@ -0,0 +1,230 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_st.hpp" +#include "../model/st_key_encoder.hpp" + +#include +#include + +std::string UISTPage::pty_open(const std::string &command, + const std::list &args) +{ + int result = -1; + std::string handle; + std::list api_args = { + "Open", command, std::to_string(term_cols_), std::to_string(term_rows_), command, + }; + for (const auto &arg : args) api_args.push_back(arg); + cp0_signal_pty_api(std::move(api_args), [&](int code, std::string data) { + result = code; + if (result == 0) handle = std::move(data); + }); + return handle; +} + +void UISTPage::stop_timers() +{ + poll_timer_.stop(); + cursor_timer_.stop(); +} + +bool UISTPage::start_timers() +{ + const bool poll_started = poll_timer_.start( + [this] { return lv_timer_create(UISTPage::static_poll_cb, 30, this); }, + [](lv_timer_t *timer) { lv_timer_delete(timer); }); + const bool cursor_started = cursor_timer_.start( + [this] { return lv_timer_create(UISTPage::static_cursor_cb, 500, this); }, + [](lv_timer_t *timer) { lv_timer_delete(timer); }); + if (poll_started && cursor_started) return true; + stop_timers(); + return false; +} + +void UISTPage::start_command(const std::string &command, + const std::list &args, + const char *title, const char *error_message) +{ + stop_pty(); + pty_handle_ = pty_open(command, args); + terminal_active_ = !pty_handle_.empty(); + if (!terminal_active_) { + process_bytes(error_message, static_cast(std::strlen(error_message))); + render_all(); + waiting_key_to_exit_ = true; + return; + } + if (title && title[0]) set_page_title(title); + if (!start_timers()) { + stop_pty(); + terminal_active_ = false; + static constexpr char TIMER_ERROR[] = + "Error: failed to start terminal timers\r\n"; + process_bytes(TIMER_ERROR, sizeof(TIMER_ERROR) - 1); + render_all(); + waiting_key_to_exit_ = true; + return; + } + render_all(); +} + +void UISTPage::start_shell() +{ + start_command("bash", { + "-c", + "cd ~ && " + "if [ -r ~/.bashrc ]; then " + "exec env TERM=st-256color COLORTERM=truecolor bash --rcfile ~/.bashrc -i; " + "else " + "exec env TERM=st-256color COLORTERM=truecolor bash -i; " + "fi" + }, "CLI", "cli: failed to open PTY\r\n"); +} + +void UISTPage::stop_pty() +{ + if (pty_handle_.empty()) return; + cp0_signal_pty_api({"Close", pty_handle_}, nullptr); + pty_handle_.clear(); +} + +int UISTPage::pty_read(char *buffer, size_t buffer_size) +{ + int result = -1; + std::string data; + cp0_signal_pty_api({"Read", pty_handle_, std::to_string(buffer_size)}, + [&](int code, std::string response) { + result = code; + data = std::move(response); + }); + if (result < 0) return -1; + size_t length = std::min(data.size(), buffer_size); + if (length > 0) std::memcpy(buffer, data.data(), length); + return static_cast(length); +} + +int UISTPage::pty_write(const char *buffer, size_t length) +{ + if (pty_handle_.empty() || !buffer || length == 0) return -1; + int result = -1; + cp0_signal_pty_api({"Write", pty_handle_, std::string(buffer, length)}, + [&](int code, std::string) { result = code; }); + return result; +} + +int UISTPage::pty_check_child(int *status) +{ + int result = -1; + std::string data; + cp0_signal_pty_api({"CheckChild", pty_handle_}, [&](int code, std::string response) { + result = code; + data = std::move(response); + }); + if (result == 1) { + int parsed = 0; + if (!st_parse_child_status(data, parsed)) return -1; + if (status) *status = parsed; + } + return result; +} + +void UISTPage::static_poll_cb(lv_timer_t *timer) noexcept +{ + auto *self = static_cast(lv_timer_get_user_data(timer)); + if (!self) return; + try { + if (self->poll_timer_.current(timer)) self->poll_cb(); + } catch (...) { + self->recover_callback_failure(); + } +} + +void UISTPage::poll_cb() +{ + if (!terminal_active_ || pty_handle_.empty()) return; + + char buffer[1024]; + int length = 0; + bool changed = false; + while ((length = pty_read(buffer, sizeof(buffer))) > 0) { + process_bytes(buffer, length); + changed = true; + } + if (changed) { + if (scrollback_offset_ > 0) dirty_all(); + follow_cursor_in_big_mode(); + if (big_mode_) dirty_all(); + render_all(); + } + + bool child_exited = length < 0; + if (!child_exited && !pty_handle_.empty()) { + int status = 0; + child_exited = pty_check_child(&status) == 1; + } + if (!child_exited) return; + + terminal_active_ = false; + stop_pty(); + static constexpr char EXIT_HINT[] = "\r\n-- Press any key to exit --"; + process_bytes(EXIT_HINT, sizeof(EXIT_HINT) - 1); + render_all(); + waiting_key_to_exit_ = true; +} + +void UISTPage::static_cursor_cb(lv_timer_t *timer) noexcept +{ + auto *self = static_cast(lv_timer_get_user_data(timer)); + if (!self) return; + try { + if (self->cursor_timer_.current(timer)) self->cursor_cb(); + } catch (...) { + self->recover_callback_failure(); + } +} + +void UISTPage::cursor_cb() +{ + handle_home_hold_exit(); + update_cursor(); + if (!terminal_active_ || !cursor_visible_mode_) { + show_cursor(false); + return; + } + show_cursor(!cursor_blink_visible_); +} + +void UISTPage::handle_home_hold_exit() +{ + if (home_hold_status_ == 0) { + if (LVGL_HOME_KEY_FLAG) { + home_hold_status_ = 1; + home_hold_start_ = std::chrono::steady_clock::now(); + } + return; + } + if (!LVGL_HOME_KEY_FLAG) { + home_hold_status_ = 0; + return; + } + + auto now = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(now - home_hold_start_).count() < 5) + return; + home_hold_status_ = 0; + stop_pty(); + terminal_active_ = false; + if (navigate_home) navigate_home(); +} + +void UISTPage::write_key(uint32_t evdev_key, const char *utf8) +{ + const std::string encoded = + STKeyEncoder::encode(evdev_key, utf8, (mode_ & MODE_APPCURSOR) != 0); + if (!encoded.empty()) pty_write(encoded.data(), encoded.size()); +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_st_viewport.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_st_viewport.cpp new file mode 100644 index 00000000..5c0881d9 --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_st_viewport.cpp @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_st.hpp" + +void UISTPage::update_scrollbar() +{ + if (!scrollbar_track_ || !scrollbar_thumb_) return; + if (big_mode_) { + int max_y = max_viewport_y(); + lv_obj_clear_flag(scrollbar_track_, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(scrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_size(scrollbar_track_, SCROLLBAR_W, visible_h()); + lv_obj_set_pos(scrollbar_track_, TERM_W - SCROLLBAR_W - 1, 0); + + int thumb_height = std::max(8, visible_h() * visible_rows() / std::max(term_rows_, 1)); + thumb_height = std::min(visible_h(), thumb_height); + int range = visible_h() - thumb_height; + int thumb_y = max_y > 0 ? viewport_y_ * range / max_y : 0; + lv_obj_set_size(scrollbar_thumb_, SCROLLBAR_W, thumb_height); + lv_obj_set_pos(scrollbar_thumb_, TERM_W - SCROLLBAR_W - 1, thumb_y); + lv_obj_move_foreground(scrollbar_track_); + lv_obj_move_foreground(scrollbar_thumb_); + return; + } + + int history_rows = static_cast(scrollback_.size()); + if (history_rows <= 0) { + lv_obj_add_flag(scrollbar_track_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(scrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); + return; + } + + lv_obj_clear_flag(scrollbar_track_, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(scrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_size(scrollbar_track_, SCROLLBAR_W, TERM_H); + lv_obj_set_pos(scrollbar_track_, TERM_W - SCROLLBAR_W - 1, 0); + int total_rows = history_rows + term_rows_; + int thumb_height = std::max(8, TERM_H * term_rows_ / std::max(total_rows, 1)); + thumb_height = std::min(TERM_H, thumb_height); + int range = TERM_H - thumb_height; + int thumb_y = range - scrollback_offset_ * range / std::max(1, history_rows); + lv_obj_set_size(scrollbar_thumb_, SCROLLBAR_W, thumb_height); + lv_obj_set_pos(scrollbar_thumb_, TERM_W - SCROLLBAR_W - 1, thumb_y); + lv_obj_move_foreground(scrollbar_track_); + lv_obj_move_foreground(scrollbar_thumb_); +} + +void UISTPage::update_hscrollbar() +{ + if (!hscrollbar_track_ || !hscrollbar_thumb_) return; + if (!big_mode_) { + lv_obj_add_flag(hscrollbar_track_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(hscrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); + return; + } + + lv_obj_clear_flag(hscrollbar_track_, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(hscrollbar_thumb_, LV_OBJ_FLAG_HIDDEN); + int y = visible_h(); + int width = TERM_W - SCROLLBAR_W - 2; + lv_obj_set_size(hscrollbar_track_, width, 3); + lv_obj_set_pos(hscrollbar_track_, 0, y); + int max_x = max_viewport_x(); + int thumb_width = std::max(18, width * visible_cols() / std::max(term_cols_, 1)); + thumb_width = std::min(width, thumb_width); + int range = width - thumb_width; + int thumb_x = max_x > 0 ? viewport_x_ * range / max_x : 0; + lv_obj_set_size(hscrollbar_thumb_, thumb_width, 3); + lv_obj_set_pos(hscrollbar_thumb_, thumb_x, y); + lv_obj_move_foreground(hscrollbar_track_); + lv_obj_move_foreground(hscrollbar_thumb_); +} + +void UISTPage::set_bottom_label(int index, const char *text) +{ + if (index < 0 || index >= BOTTOM_BAR_SLOTS || !bottom_labels_[static_cast(index)]) + return; + lv_label_set_text(bottom_labels_[static_cast(index)], text); +} + +void UISTPage::update_big_mode_ui() +{ + for (auto *label : bottom_labels_) { + if (!label) continue; + if (big_mode_) lv_obj_clear_flag(label, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(label, LV_OBJ_FLAG_HIDDEN); + } + for (auto *indicator : bottom_indicators_) { + if (!indicator) continue; + if (big_mode_) lv_obj_clear_flag(indicator, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(indicator, LV_OBJ_FLAG_HIDDEN); + } + update_hscrollbar(); + update_scrollbar(); +} + +void UISTPage::switch_big_mode(bool enable) +{ + if (big_mode_ == enable) return; + terminal_active_ = false; + stop_timers(); + stop_pty(); + big_mode_ = enable; + term_cols_ = big_mode_ ? BIG_COLS : NORMAL_COLS; + term_rows_ = big_mode_ ? BIG_ROWS : NORMAL_ROWS; + viewport_x_ = 0; + viewport_y_ = 0; + big_view_locked_ = false; + reset_terminal(); + update_big_mode_ui(); + start_shell(); +} + +void UISTPage::leave_scrollback() +{ + if (scrollback_offset_ == 0) return; + scrollback_offset_ = 0; + dirty_all(); +} + +void UISTPage::pan_big_view(int dx, int dy) +{ + if (!big_mode_) return; + big_view_locked_ = true; + int old_x = viewport_x_; + int old_y = viewport_y_; + viewport_x_ = clamp(viewport_x_ + dx, 0, max_viewport_x()); + viewport_y_ = clamp(viewport_y_ + dy, 0, max_viewport_y()); + if (viewport_x_ != old_x || viewport_y_ != old_y) dirty_all(); +} + +void UISTPage::follow_cursor_in_big_mode() +{ + if (!big_mode_ || big_view_locked_) return; + int old_x = viewport_x_; + int old_y = viewport_y_; + if (cursor_.x < viewport_x_) + viewport_x_ = cursor_.x; + else if (cursor_.x >= viewport_x_ + visible_cols()) + viewport_x_ = cursor_.x - visible_cols() + 1; + if (cursor_.y < viewport_y_) + viewport_y_ = cursor_.y; + else if (cursor_.y >= viewport_y_ + visible_rows()) + viewport_y_ = cursor_.y - visible_rows() + 1; + + viewport_x_ = clamp(viewport_x_, 0, max_viewport_x()); + viewport_y_ = clamp(viewport_y_, 0, max_viewport_y()); + if (viewport_x_ != old_x || viewport_y_ != old_y) dirty_all(); +} + + diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle.cpp new file mode 100644 index 00000000..251d60ab --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle.cpp @@ -0,0 +1,260 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_tank_battle.hpp" + +#include "input_keys.h" + +#include + +UITankBattlePage::UITankBattlePage() : AppPageRoot() +{ + init_game_state(); + creat_UI(); + event_handler_init(); + if (tank_battle_ui_ready(background_, arena_, player_obj_)) { + tick_callback_enabled_ = tick_timer_.start( + [this] { return lv_timer_create(UITankBattlePage::static_tick_cb, 80, this); }, + [](lv_timer_t *timer) { lv_timer_delete(timer); }); + } +} + +UITankBattlePage::~UITankBattlePage() +{ + tick_timer_.stop(); + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UITankBattlePage::static_lvgl_handler, this); + detach_ui_callbacks(); +} + +void UITankBattlePage::init_game_state() +{ + model_.reset(); +} + +void UITankBattlePage::event_handler_init() +{ + if (!root_screen_) return; + lv_obj_add_event_cb(root_screen_, UITankBattlePage::static_lvgl_handler, LV_EVENT_ALL, this); +} + +void UITankBattlePage::detach_ui_callbacks() +{ + std::vector objects = {background_, arena_, status_label_, player_obj_, + game_msg_panel_, game_msg_label_}; + objects.insert(objects.end(), enemy_objs_.begin(), enemy_objs_.end()); + objects.insert(objects.end(), bullet_objs_.begin(), bullet_objs_.end()); + for (lv_obj_t *object : objects) + if (object) + lv_obj_remove_event_cb_with_user_data(object, owned_obj_delete_cb, this); +} + +void UITankBattlePage::owned_obj_delete_cb(lv_event_t *event) noexcept +{ + try { + if (!event || !tank_battle_owned_delete_callback_allowed( + lv_event_get_target(event), lv_event_get_current_target(event))) return; + auto *self = static_cast(lv_event_get_user_data(event)); + auto *deleted = static_cast(lv_event_get_target(event)); + if (!self) return; + + if (self->game_msg_label_ == deleted) self->game_msg_label_ = nullptr; + if (self->game_msg_panel_ == deleted) { + self->game_msg_panel_ = nullptr; + self->game_msg_label_ = nullptr; + } + if (self->player_obj_ == deleted) { + self->player_obj_ = nullptr; + self->tick_timer_.stop(); + } + if (self->status_label_ == deleted) { + self->status_label_ = nullptr; + self->ui_obj_.erase("status"); + } + for (lv_obj_t *&object : self->enemy_objs_) + if (object == deleted) object = nullptr; + for (lv_obj_t *&object : self->bullet_objs_) + if (object == deleted) object = nullptr; + if (self->arena_ == deleted) { + self->tick_timer_.stop(); + self->arena_ = nullptr; + self->player_obj_ = nullptr; + self->enemy_objs_.clear(); + self->bullet_objs_.clear(); + self->game_msg_panel_ = nullptr; + self->game_msg_label_ = nullptr; + self->ui_obj_.erase("arena"); + } + if (self->background_ == deleted) { + self->tick_timer_.stop(); + self->background_ = nullptr; + self->arena_ = nullptr; + self->status_label_ = nullptr; + self->player_obj_ = nullptr; + self->enemy_objs_.clear(); + self->bullet_objs_.clear(); + self->game_msg_panel_ = nullptr; + self->game_msg_label_ = nullptr; + self->ui_obj_.clear(); + } + } catch (...) { + // LVGL callbacks must not propagate C++ exceptions across the C boundary. + } +} + +void UITankBattlePage::static_lvgl_handler(lv_event_t *e) noexcept +{ + UITankBattlePage *self = nullptr; + try { + if (!e) return; + self = static_cast(lv_event_get_user_data(e)); + if (!self || !tank_battle_root_event_callback_allowed( + lv_event_get_current_target(e), self->root_screen_)) return; + self->event_handler(e); + } catch (...) { + if (self) self->tick_callback_enabled_ = false; + } +} + +void UITankBattlePage::event_handler(lv_event_t *e) +{ + if (lv_event_get_code(e) == LV_EVENT_DELETE && + lv_event_get_target(e) == lv_event_get_current_target(e)) { + tick_timer_.stop(); + player_obj_ = nullptr; + enemy_objs_.clear(); + bullet_objs_.clear(); + game_msg_panel_ = nullptr; + game_msg_label_ = nullptr; + return; + } + if (!launcher_ui::events::is_key_pressed(e) && !launcher_ui::events::is_key_released(e)) return; + + uint32_t key = launcher_ui::events::keyboard_key(e); + if (key == KEY_ESC) { + tick_timer_.stop(); + if (navigate_home) navigate_home(); + return; + } + if (!launcher_ui::events::is_key_pressed(e)) return; + + if (model_.game_over()) { + if (key == KEY_FIRE) { + init_game_state(); + sync_scene(); + } + return; + } + + switch (key) { + case KEY_MOVE_UP: + model_.move_player(TankDirection::UP); + break; + case KEY_MOVE_DOWN: + model_.move_player(TankDirection::DOWN); + break; + case KEY_MOVE_LEFT: + model_.move_player(TankDirection::LEFT); + break; + case KEY_MOVE_RIGHT: + model_.move_player(TankDirection::RIGHT); + break; + case KEY_FIRE: + model_.player_fire(); + break; + default: + break; + } + sync_scene(); +} + +void UITankBattlePage::static_tick_cb(lv_timer_t *timer) noexcept +{ + UITankBattlePage *self = nullptr; + try { + self = static_cast(lv_timer_get_user_data(timer)); + if (!self || !self->tick_callback_enabled_) return; + if (!tank_battle_tick_callback_allowed( + self->tick_callback_enabled_, self->tick_timer_.current(timer), + self->background_, self->arena_, self->player_obj_)) return; + self->tick(); + } catch (...) { + if (self) self->tick_callback_enabled_ = false; + } +} + +void UITankBattlePage::tick() +{ + if (model_.game_over()) return; + model_.tick(); + sync_scene(); +} + +void UITankBattlePage::place_grid_obj(lv_obj_t *obj, int gx, int gy, int w, int h) +{ + const int px = GRID_OX + gx * CELL + (CELL - w) / 2; + const int py = GRID_OY + gy * CELL + (CELL - h) / 2; + lv_obj_set_pos(obj, px, py); +} + +void UITankBattlePage::sync_scene() +{ + const auto &player = model_.player(); + const auto &enemies = model_.enemies(); + const auto &bullets = model_.bullets(); + if (player_obj_) { + place_grid_obj(player_obj_, player.x, player.y, CELL - 2, CELL - 2); + player.alive ? lv_obj_clear_flag(player_obj_, LV_OBJ_FLAG_HIDDEN) + : lv_obj_add_flag(player_obj_, LV_OBJ_FLAG_HIDDEN); + } + + for (size_t i = 0; i < enemy_objs_.size() && i < enemies.size(); ++i) { + lv_obj_t *obj = enemy_objs_[i]; + if (!obj) continue; + if (enemies[i].alive) { + place_grid_obj(obj, enemies[i].x, enemies[i].y, CELL - 2, CELL - 2); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + } + } + + for (size_t i = 0; i < bullet_objs_.size() && i < bullets.size(); ++i) { + lv_obj_t *obj = bullet_objs_[i]; + if (!obj) continue; + const TankBattleBullet &bullet = bullets[i]; + if (bullet.alive) { + place_grid_obj(obj, bullet.x, bullet.y, 4, 4); + lv_obj_set_style_bg_color(obj, + bullet.from_player ? lv_color_hex(0xF4D03F) + : lv_color_hex(0xFF8C42), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + } + } + + char status_buf[96]; + lv_snprintf(status_buf, sizeof(status_buf), "Score:%d Enemy:%d", model_.score(), + model_.alive_enemy_count()); + if (status_label_) lv_label_set_text(status_label_, status_buf); + sync_game_message(); +} + +void UITankBattlePage::sync_game_message() +{ + if (!game_msg_panel_ || !game_msg_label_) return; + if (!model_.game_over()) { + lv_obj_add_flag(game_msg_panel_, LV_OBJ_FLAG_HIDDEN); + return; + } + + lv_label_set_text(game_msg_label_, model_.won() ? "YOU WIN\nPress Space restart" + : "GAME OVER\nPress Space restart"); + lv_obj_set_style_border_color(game_msg_panel_, + lv_color_hex(model_.won() ? 0x2ECC71 : 0xE74C3C), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_center(game_msg_label_); + lv_obj_clear_flag(game_msg_panel_, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(game_msg_panel_); +} diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle.hpp index d55276f7..b798bfc4 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle.hpp @@ -6,48 +6,18 @@ #pragma once -#include "ui/ui.h" -#include "../ui_app_page.hpp" -#include "compat/input_keys.h" -#include +#include "../launcher_ui_app_page.hpp" +#include "../model/tank_battle_model.hpp" +#include "../model/tank_battle_page_contract.hpp" +#include "../model/page_timer_lifecycle.hpp" -#include #include -#include -#include #include #include #include class UITankBattlePage : public AppPageRoot { -private: - enum class Dir - { - UP, - DOWN, - LEFT, - RIGHT - }; - - struct Tank - { - int x = 0; - int y = 0; - Dir dir = Dir::UP; - bool alive = true; - int fire_cd = 0; - }; - - struct Bullet - { - int x = 0; - int y = 0; - Dir dir = Dir::UP; - bool from_player = true; - bool alive = false; - }; - private: static constexpr uint32_t KEY_MOVE_UP = 33; static constexpr uint32_t KEY_MOVE_DOWN = 45; @@ -58,8 +28,8 @@ class UITankBattlePage : public AppPageRoot static constexpr int SCREEN_W = 320; static constexpr int SCREEN_H = 170; - static constexpr int GRID_COLS = 18; - static constexpr int GRID_ROWS = 8; + static constexpr int GRID_COLS = TankBattleModel::GRID_COLUMNS; + static constexpr int GRID_ROWS = TankBattleModel::GRID_ROWS; static constexpr int CELL = 14; static constexpr int ARENA_X = 8; @@ -73,677 +43,46 @@ class UITankBattlePage : public AppPageRoot static constexpr int GRID_OY = (ARENA_H - GRID_H) / 2; public: - UITankBattlePage() : AppPageRoot() - { - init_game_state(); - creat_UI(); - event_handler_init(); - - tick_timer_ = lv_timer_create(UITankBattlePage::static_tick_cb, 80, this); - } - - ~UITankBattlePage() - { - delete_timer(tick_timer_); - } + UITankBattlePage(); + ~UITankBattlePage(); private: std::unordered_map ui_obj_; - lv_timer_t *tick_timer_ = nullptr; - - Tank player_; - std::vector enemies_; - std::vector bullets_; + PageTimerLifecycle tick_timer_; + bool tick_callback_enabled_ = false; + TankBattleModel model_; lv_obj_t *player_obj_ = nullptr; + lv_obj_t *background_ = nullptr; + lv_obj_t *arena_ = nullptr; + lv_obj_t *status_label_ = nullptr; std::vector enemy_objs_; std::vector bullet_objs_; lv_obj_t *game_msg_panel_ = nullptr; lv_obj_t *game_msg_label_ = nullptr; - bool game_over_ = false; - bool win_ = false; - - int score_ = 0; - int tick_count_ = 0; - - std::minstd_rand rng_{0xC0FFEE}; - private: - static void delete_timer(lv_timer_t *&timer) - { - if (timer) { - lv_timer_delete(timer); - timer = nullptr; - } - } + void init_game_state(); private: - void init_game_state() - { - game_over_ = false; - win_ = false; - score_ = 0; - tick_count_ = 0; - - player_ = Tank{}; - player_.x = GRID_COLS / 2; - player_.y = GRID_ROWS - 1; - player_.dir = Dir::UP; - player_.alive = true; - - enemies_.clear(); - - for (int i = 0; i < 5; ++i) { - Tank e; - e.x = 2 + i * 3; - e.y = 0; - e.dir = Dir::DOWN; - e.alive = true; - e.fire_cd = i * 4; - enemies_.push_back(e); - } - - bullets_.clear(); - bullets_.resize(24); - } + void creat_UI(); + void create_game_message_panel(lv_obj_t *arena); private: - void creat_UI() - { - lv_obj_t *bg = lv_obj_create(root_screen_); - lv_obj_set_size(bg, SCREEN_W, SCREEN_H); - lv_obj_set_pos(bg, 0, 0); - lv_obj_set_style_radius(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(bg, lv_color_hex(0x10151C), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["bg"] = bg; - - lv_obj_t *title = lv_obj_create(bg); - lv_obj_set_size(title, SCREEN_W, 24); - lv_obj_set_pos(title, 0, 0); - lv_obj_set_style_radius(title, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(title, lv_color_hex(0x2A4D69), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(title, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(title, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(title, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(title, 6, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(title, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *lbl_title = lv_label_create(title); - lv_label_set_text(lbl_title, "Tank Game"); - lv_obj_set_align(lbl_title, LV_ALIGN_LEFT_MID); - lv_obj_set_style_text_color(lbl_title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_t *lbl_hint = lv_label_create(title); - lv_label_set_text(lbl_hint, "33/45/44/46 move 57 fire"); - lv_obj_set_align(lbl_hint, LV_ALIGN_RIGHT_MID); - lv_obj_set_x(lbl_hint, -4); - lv_obj_set_style_text_color(lbl_hint, lv_color_hex(0xB7D1E6), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(lbl_hint, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_t *status = lv_label_create(bg); - lv_label_set_text(status, "Score:0 Enemy:5"); - lv_obj_set_pos(status, 8, 26); - lv_obj_set_width(status, 304); - lv_label_set_long_mode(status, LV_LABEL_LONG_DOT); - lv_obj_set_style_text_color(status, lv_color_hex(0xDDE6ED), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(status, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); - ui_obj_["status"] = status; - - lv_obj_t *arena = lv_obj_create(bg); - lv_obj_set_size(arena, ARENA_W, ARENA_H); - lv_obj_set_pos(arena, ARENA_X, ARENA_Y); - lv_obj_set_style_radius(arena, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(arena, lv_color_hex(0x0B0F14), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(arena, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(arena, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(arena, lv_color_hex(0x2F3F4F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(arena, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(arena, LV_OBJ_FLAG_SCROLLABLE); - ui_obj_["arena"] = arena; - - for (int x = 1; x < GRID_COLS; ++x) { - lv_obj_t *line = lv_obj_create(arena); - lv_obj_set_size(line, 1, GRID_H); - lv_obj_set_pos(line, GRID_OX + x * CELL, GRID_OY); - lv_obj_set_style_radius(line, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(line, lv_color_hex(0x18222D), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(line, 120, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(line, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(line, LV_OBJ_FLAG_SCROLLABLE); - } - - for (int y = 1; y < GRID_ROWS; ++y) { - lv_obj_t *line = lv_obj_create(arena); - lv_obj_set_size(line, GRID_W, 1); - lv_obj_set_pos(line, GRID_OX, GRID_OY + y * CELL); - lv_obj_set_style_radius(line, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(line, lv_color_hex(0x18222D), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(line, 120, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(line, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(line, LV_OBJ_FLAG_SCROLLABLE); - } - - player_obj_ = lv_obj_create(arena); - lv_obj_set_size(player_obj_, CELL - 2, CELL - 2); - lv_obj_set_style_radius(player_obj_, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(player_obj_, lv_color_hex(0x2ECC71), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(player_obj_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(player_obj_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(player_obj_, LV_OBJ_FLAG_SCROLLABLE); - - enemy_objs_.clear(); - - for (size_t i = 0; i < enemies_.size(); ++i) { - lv_obj_t *obj = lv_obj_create(arena); - lv_obj_set_size(obj, CELL - 2, CELL - 2); - lv_obj_set_style_radius(obj, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(obj, lv_color_hex(0xE74C3C), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - enemy_objs_.push_back(obj); - } - - bullet_objs_.clear(); - - for (int i = 0; i < 24; ++i) { - lv_obj_t *obj = lv_obj_create(arena); - lv_obj_set_size(obj, 4, 4); - lv_obj_set_style_radius(obj, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(obj, lv_color_hex(0xF4D03F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - bullet_objs_.push_back(obj); - } - - create_game_message_panel(arena); - - sync_scene(); - } - - void create_game_message_panel(lv_obj_t *arena) - { - game_msg_panel_ = lv_obj_create(arena); - lv_obj_set_size(game_msg_panel_, 224, 66); - lv_obj_set_pos(game_msg_panel_, (ARENA_W - 224) / 2, (ARENA_H - 66) / 2); - lv_obj_set_style_radius(game_msg_panel_, 10, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(game_msg_panel_, lv_color_hex(0x111827), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(game_msg_panel_, 235, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(game_msg_panel_, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(game_msg_panel_, lv_color_hex(0xF4D03F), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(game_msg_panel_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(game_msg_panel_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(game_msg_panel_, LV_OBJ_FLAG_HIDDEN); - - game_msg_label_ = lv_label_create(game_msg_panel_); - lv_obj_set_width(game_msg_label_, 210); - lv_label_set_long_mode(game_msg_label_, LV_LABEL_LONG_WRAP); - lv_label_set_text(game_msg_label_, ""); - lv_obj_set_style_text_align(game_msg_label_, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(game_msg_label_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(game_msg_label_, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_center(game_msg_label_); - } + void event_handler_init(); + void detach_ui_callbacks(); + static void owned_obj_delete_cb(lv_event_t *event) noexcept; + static void static_lvgl_handler(lv_event_t *e) noexcept; + void event_handler(lv_event_t *e); private: - void event_handler_init() - { - lv_obj_add_event_cb(root_screen_, UITankBattlePage::static_lvgl_handler, LV_EVENT_ALL, this); - } - - static void static_lvgl_handler(lv_event_t *e) - { - UITankBattlePage *self = static_cast(lv_event_get_user_data(e)); - - if (self) { - self->event_handler(e); - } - } - - void event_handler(lv_event_t *e) - { - if (!launcher_ui::events::is_key_pressed(e) && !launcher_ui::events::is_key_released(e)) { - return; - } - - uint32_t key = launcher_ui::events::keyboard_key(e); - - if (key == KEY_ESC) { - if (navigate_home) { - navigate_home(); - } - return; - } - - if (!launcher_ui::events::is_key_pressed(e)) { - return; - } - - if (game_over_) { - if (key == KEY_FIRE) { - init_game_state(); - sync_scene(); - } - return; - } - - switch (key) { - case KEY_MOVE_UP: - player_.dir = Dir::UP; - move_player(0, -1); - break; - - case KEY_MOVE_DOWN: - player_.dir = Dir::DOWN; - move_player(0, 1); - break; - - case KEY_MOVE_LEFT: - player_.dir = Dir::LEFT; - move_player(-1, 0); - break; - - case KEY_MOVE_RIGHT: - player_.dir = Dir::RIGHT; - move_player(1, 0); - break; - - case KEY_FIRE: - player_fire(); - break; - - default: - break; - } - - sync_scene(); - } + static void static_tick_cb(lv_timer_t *t) noexcept; + void tick(); private: - static void static_tick_cb(lv_timer_t *t) - { - UITankBattlePage *self = static_cast(lv_timer_get_user_data(t)); - - if (self) { - self->tick(); - } - } - - void tick() - { - if (game_over_) { - return; - } - - ++tick_count_; - - if (player_.fire_cd > 0) { - --player_.fire_cd; - } - - for (auto &e : enemies_) { - if (e.alive && e.fire_cd > 0) { - --e.fire_cd; - } - } - - enemy_ai(); - move_bullets(); - check_end_state(); - sync_scene(); - } - -private: - bool inside(int x, int y) const - { - return x >= 0 && x < GRID_COLS && y >= 0 && y < GRID_ROWS; - } - - bool has_enemy_at(int x, int y, int skip_idx = -1) const - { - for (size_t i = 0; i < enemies_.size(); ++i) { - if ((int)i == skip_idx) { - continue; - } - - const auto &e = enemies_[i]; - - if (e.alive && e.x == x && e.y == y) { - return true; - } - } - - return false; - } - - void move_player(int dx, int dy) - { - int nx = player_.x + dx; - int ny = player_.y + dy; - - if (!inside(nx, ny)) { - return; - } - - if (has_enemy_at(nx, ny)) { - return; - } - - player_.x = nx; - player_.y = ny; - } - - void dir_step(Dir dir, int &dx, int &dy) const - { - dx = 0; - dy = 0; - - switch (dir) { - case Dir::UP: - dy = -1; - break; - - case Dir::DOWN: - dy = 1; - break; - - case Dir::LEFT: - dx = -1; - break; - - case Dir::RIGHT: - dx = 1; - break; - } - } - - void spawn_bullet(int x, int y, Dir dir, bool from_player) - { - for (auto &b : bullets_) { - if (!b.alive) { - b.x = x; - b.y = y; - b.dir = dir; - b.from_player = from_player; - b.alive = true; - return; - } - } - } - - void player_fire() - { - if (player_.fire_cd > 0) { - return; - } - - int dx = 0; - int dy = 0; - dir_step(player_.dir, dx, dy); - - int sx = player_.x + dx; - int sy = player_.y + dy; - - if (!inside(sx, sy)) { - return; - } - - spawn_bullet(sx, sy, player_.dir, true); - player_.fire_cd = 4; - } - - void enemy_fire(Tank &e) - { - if (!e.alive || e.fire_cd > 0) { - return; - } - - int dx = 0; - int dy = 0; - dir_step(e.dir, dx, dy); - - int sx = e.x + dx; - int sy = e.y + dy; - - if (!inside(sx, sy)) { - return; - } - - spawn_bullet(sx, sy, e.dir, false); - e.fire_cd = 8 + (int)(rng_() % 8); - } - - void enemy_ai() - { - for (size_t i = 0; i < enemies_.size(); ++i) { - auto &e = enemies_[i]; - - if (!e.alive) { - continue; - } - - if ((tick_count_ + (int)i) % 6 == 0) { - uint32_t r = rng_() % 5; - - if (r == 0) { - e.dir = Dir::LEFT; - } else if (r == 1) { - e.dir = Dir::RIGHT; - } else if (r == 2) { - e.dir = Dir::DOWN; - } else if (r == 3) { - e.dir = Dir::UP; - } - - int dx = 0; - int dy = 0; - dir_step(e.dir, dx, dy); - - int nx = e.x + dx; - int ny = e.y + dy; - - if (inside(nx, ny) && - !has_enemy_at(nx, ny, (int)i) && - !(player_.x == nx && player_.y == ny)) { - e.x = nx; - e.y = ny; - } - } - - if ((rng_() % 10) < 2) { - enemy_fire(e); - } - } - } - - void move_bullets() - { - for (auto &b : bullets_) { - if (!b.alive) { - continue; - } - - int dx = 0; - int dy = 0; - dir_step(b.dir, dx, dy); - - b.x += dx; - b.y += dy; - - if (!inside(b.x, b.y)) { - b.alive = false; - continue; - } - - if (b.from_player) { - for (auto &e : enemies_) { - if (e.alive && e.x == b.x && e.y == b.y) { - e.alive = false; - b.alive = false; - score_ += 100; - break; - } - } - } else { - if (player_.alive && player_.x == b.x && player_.y == b.y) { - player_.alive = false; - b.alive = false; - game_over_ = true; - win_ = false; - } - } - } - } - - void check_end_state() - { - if (game_over_) { - return; - } - - int alive_enemy = 0; - - for (const auto &e : enemies_) { - if (e.alive) { - ++alive_enemy; - } - } - - if (!player_.alive) { - game_over_ = true; - win_ = false; - return; - } - - if (alive_enemy == 0) { - game_over_ = true; - win_ = true; - } - } - -private: - void place_grid_obj(lv_obj_t *obj, int gx, int gy, int w, int h) - { - int px = GRID_OX + gx * CELL + (CELL - w) / 2; - int py = GRID_OY + gy * CELL + (CELL - h) / 2; - - lv_obj_set_pos(obj, px, py); - } - - int alive_enemy_count() const - { - int c = 0; - - for (const auto &e : enemies_) { - if (e.alive) { - ++c; - } - } - - return c; - } - - void sync_scene() - { - if (player_obj_) { - place_grid_obj(player_obj_, player_.x, player_.y, CELL - 2, CELL - 2); - - if (player_.alive) { - lv_obj_clear_flag(player_obj_, LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(player_obj_, LV_OBJ_FLAG_HIDDEN); - } - } - - for (size_t i = 0; i < enemy_objs_.size() && i < enemies_.size(); ++i) { - lv_obj_t *obj = enemy_objs_[i]; - - if (enemies_[i].alive) { - place_grid_obj(obj, enemies_[i].x, enemies_[i].y, CELL - 2, CELL - 2); - lv_obj_clear_flag(obj, LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); - } - } - - for (size_t i = 0; i < bullet_objs_.size() && i < bullets_.size(); ++i) { - lv_obj_t *obj = bullet_objs_[i]; - auto &b = bullets_[i]; - - if (b.alive) { - place_grid_obj(obj, b.x, b.y, 4, 4); - - lv_obj_set_style_bg_color( - obj, - b.from_player ? lv_color_hex(0xF4D03F) : lv_color_hex(0xFF8C42), - LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_clear_flag(obj, LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); - } - } - - char status_buf[96]; - - if (!game_over_) { - lv_snprintf(status_buf, - sizeof(status_buf), - "Score:%d Enemy:%d", - score_, - alive_enemy_count()); - } else { - lv_snprintf(status_buf, - sizeof(status_buf), - "Score:%d Enemy:%d", - score_, - alive_enemy_count()); - } - - auto it = ui_obj_.find("status"); - - if (it != ui_obj_.end() && it->second) { - lv_label_set_text(it->second, status_buf); - } - - sync_game_message(); - } - - void sync_game_message() - { - if (!game_msg_panel_ || !game_msg_label_) { - return; - } - - if (!game_over_) { - lv_obj_add_flag(game_msg_panel_, LV_OBJ_FLAG_HIDDEN); - return; - } - - if (win_) { - lv_label_set_text(game_msg_label_, "YOU WIN\nPress Space restart"); - lv_obj_set_style_border_color(game_msg_panel_, - lv_color_hex(0x2ECC71), - LV_PART_MAIN | LV_STATE_DEFAULT); - } else { - lv_label_set_text(game_msg_label_, "GAME OVER\nPress Space restart"); - lv_obj_set_style_border_color(game_msg_panel_, - lv_color_hex(0xE74C3C), - LV_PART_MAIN | LV_STATE_DEFAULT); - } - - lv_obj_center(game_msg_label_); - lv_obj_clear_flag(game_msg_panel_, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_foreground(game_msg_panel_); - } -}; \ No newline at end of file + void place_grid_obj(lv_obj_t *obj, int gx, int gy, int w, int h); + void sync_scene(); + void sync_game_message(); +}; diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle_view.cpp b/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle_view.cpp new file mode 100644 index 00000000..b0dd9a2f --- /dev/null +++ b/projects/APPLaunch/main/ui/page_app/ui_app_tank_battle_view.cpp @@ -0,0 +1,177 @@ +#define APP_PAGE_IMPLEMENTATION_UNIT +#include "ui_app_tank_battle.hpp" + +void UITankBattlePage::creat_UI() +{ + if (!root_screen_) return; + lv_obj_t *bg = lv_obj_create(root_screen_); + if (!bg) return; + background_ = bg; + lv_obj_add_event_cb(bg, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(bg, SCREEN_W, SCREEN_H); + lv_obj_set_pos(bg, 0, 0); + lv_obj_set_style_radius(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(bg, lv_color_hex(0x10151C), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(bg, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); + ui_obj_["bg"] = bg; + + lv_obj_t *title = lv_obj_create(bg); + if (!title) return; + lv_obj_set_size(title, SCREEN_W, 24); + lv_obj_set_pos(title, 0, 0); + lv_obj_set_style_radius(title, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(title, lv_color_hex(0x2A4D69), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(title, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(title, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(title, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(title, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(title, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title_label = lv_label_create(title); + if (title_label) { + lv_label_set_text(title_label, "Tank Game"); + lv_obj_set_align(title_label, LV_ALIGN_LEFT_MID); + lv_obj_set_style_text_color(title_label, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(title_label, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_t *hint_label = lv_label_create(title); + if (hint_label) { + lv_label_set_text(hint_label, "33/45/44/46 move 57 fire"); + lv_obj_set_align(hint_label, LV_ALIGN_RIGHT_MID); + lv_obj_set_x(hint_label, -4); + lv_obj_set_style_text_color(hint_label, lv_color_hex(0xB7D1E6), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(hint_label, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_t *status = lv_label_create(bg); + if (!status) return; + status_label_ = status; + lv_obj_add_event_cb(status, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_label_set_text(status, "Score:0 Enemy:5"); + lv_obj_set_pos(status, 8, 26); + lv_obj_set_width(status, 304); + lv_label_set_long_mode(status, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_color(status, lv_color_hex(0xDDE6ED), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(status, &lv_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); + ui_obj_["status"] = status; + + lv_obj_t *arena = lv_obj_create(bg); + if (!arena) return; + arena_ = arena; + lv_obj_add_event_cb(arena, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(arena, ARENA_W, ARENA_H); + lv_obj_set_pos(arena, ARENA_X, ARENA_Y); + lv_obj_set_style_radius(arena, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(arena, lv_color_hex(0x0B0F14), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(arena, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(arena, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(arena, lv_color_hex(0x2F3F4F), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(arena, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(arena, LV_OBJ_FLAG_SCROLLABLE); + ui_obj_["arena"] = arena; + + for (int x = 1; x < GRID_COLS; ++x) { + lv_obj_t *line = lv_obj_create(arena); + if (!line) continue; + lv_obj_set_size(line, 1, GRID_H); + lv_obj_set_pos(line, GRID_OX + x * CELL, GRID_OY); + lv_obj_set_style_radius(line, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(line, lv_color_hex(0x18222D), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(line, 120, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(line, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(line, LV_OBJ_FLAG_SCROLLABLE); + } + + for (int y = 1; y < GRID_ROWS; ++y) { + lv_obj_t *line = lv_obj_create(arena); + if (!line) continue; + lv_obj_set_size(line, GRID_W, 1); + lv_obj_set_pos(line, GRID_OX, GRID_OY + y * CELL); + lv_obj_set_style_radius(line, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(line, lv_color_hex(0x18222D), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(line, 120, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(line, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(line, LV_OBJ_FLAG_SCROLLABLE); + } + + player_obj_ = lv_obj_create(arena); + if (!player_obj_) return; + lv_obj_add_event_cb(player_obj_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(player_obj_, CELL - 2, CELL - 2); + lv_obj_set_style_radius(player_obj_, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(player_obj_, lv_color_hex(0x2ECC71), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(player_obj_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(player_obj_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(player_obj_, LV_OBJ_FLAG_SCROLLABLE); + + enemy_objs_.clear(); + for (size_t i = 0; i < model_.enemies().size(); ++i) { + lv_obj_t *obj = lv_obj_create(arena); + if (!obj) { + enemy_objs_.push_back(nullptr); + continue; + } + lv_obj_add_event_cb(obj, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(obj, CELL - 2, CELL - 2); + lv_obj_set_style_radius(obj, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(obj, lv_color_hex(0xE74C3C), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + enemy_objs_.push_back(obj); + } + + bullet_objs_.clear(); + for (size_t i = 0; i < model_.bullets().size(); ++i) { + lv_obj_t *obj = lv_obj_create(arena); + if (!obj) { + bullet_objs_.push_back(nullptr); + continue; + } + lv_obj_add_event_cb(obj, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(obj, 4, 4); + lv_obj_set_style_radius(obj, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(obj, lv_color_hex(0xF4D03F), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + bullet_objs_.push_back(obj); + } + + create_game_message_panel(arena); + sync_scene(); +} + +void UITankBattlePage::create_game_message_panel(lv_obj_t *arena) +{ + if (!arena) return; + game_msg_panel_ = lv_obj_create(arena); + if (!game_msg_panel_) return; + lv_obj_add_event_cb(game_msg_panel_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_size(game_msg_panel_, 224, 66); + lv_obj_set_pos(game_msg_panel_, (ARENA_W - 224) / 2, (ARENA_H - 66) / 2); + lv_obj_set_style_radius(game_msg_panel_, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(game_msg_panel_, lv_color_hex(0x111827), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(game_msg_panel_, 235, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(game_msg_panel_, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(game_msg_panel_, lv_color_hex(0xF4D03F), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(game_msg_panel_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(game_msg_panel_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(game_msg_panel_, LV_OBJ_FLAG_HIDDEN); + + game_msg_label_ = lv_label_create(game_msg_panel_); + if (!game_msg_label_) return; + lv_obj_add_event_cb(game_msg_label_, owned_obj_delete_cb, LV_EVENT_DELETE, this); + lv_obj_set_width(game_msg_label_, 210); + lv_label_set_long_mode(game_msg_label_, LV_LABEL_LONG_WRAP); + lv_label_set_text(game_msg_label_, ""); + lv_obj_set_style_text_align(game_msg_label_, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(game_msg_label_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(game_msg_label_, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_center(game_msg_label_); +} diff --git a/projects/APPLaunch/main/ui/ui.cpp b/projects/APPLaunch/main/ui/ui.cpp index c0be37c1..e9020adc 100644 --- a/projects/APPLaunch/main/ui/ui.cpp +++ b/projects/APPLaunch/main/ui/ui.cpp @@ -10,8 +10,11 @@ // Project name: zero #include "ui.h" -#include "ui_app_page.hpp" +#include "launcher_ui_app_page.hpp" #include "ui_low_battery.h" +#include "ui_global_hint.h" +#include "ui_loading.h" +#include "ui_screensaver.h" #include #include #include "lvgl/src/widgets/gif/lv_gif.h" @@ -32,17 +35,22 @@ namespace launcher_ui { void init() { + if (home) + return; + cp0_keyboard_set_global_key_handler(ui_global_hint::on_key); home = std::make_unique(); home->start(); launcher_battery_ui::init_warning(); } -} // namespace launcher_ui - -LauncherFonts &launcher_fonts() +void deinit() { - if (!home) { - std::abort(); - } - return *home->fonts_; + cp0_keyboard_set_global_key_handler(nullptr); + ui_global_hint::shutdown(); + launcher_battery_ui::shutdown_warning(); + ui_loading::shutdown(); + ui_screensaver_deinit(); + home.reset(); } + +} // namespace launcher_ui diff --git a/projects/APPLaunch/main/ui/ui.h b/projects/APPLaunch/main/ui/ui.h index 1a03a6fa..729c9651 100644 --- a/projects/APPLaunch/main/ui/ui.h +++ b/projects/APPLaunch/main/ui/ui.h @@ -25,6 +25,7 @@ #include "keyboard_input.h" #include "cp0_lvgl_app.h" +#include "cp0_font_service.hpp" // Launcher layout constants #define BORDER_COLOR_CENTER 0x444444 @@ -81,29 +82,10 @@ inline const cp0_battery_info_t *battery_info(lv_event_t *event) } // namespace events void init(); +void deinit(); } -class LauncherFonts -{ -public: - LauncherFonts() = default; - ~LauncherFonts(); - - lv_font_t *get(const char *ttf_name, uint16_t size, lv_freetype_font_style_t style); - lv_font_t *get(const char *ttf_name, uint16_t size, lv_freetype_font_style_t style, - lv_freetype_font_render_mode_t render_mode); - lv_font_t *get_mono(const char *ttf_name, uint16_t size, lv_freetype_font_style_t style); - -private: - void release(); - lv_font_t *fallback(uint16_t size) const; - static std::string key(const char *ttf_name, uint16_t size, lv_freetype_font_style_t style, - lv_freetype_font_render_mode_t render_mode); - - std::unordered_map fonts_; -}; - -LauncherFonts &launcher_fonts(); +inline Cp0FontService &launcher_fonts() { return cp0_fonts(); } #ifndef APP_PAGE_IMPLEMENTATION_UNIT #include "launch.h" #include "ui_launch_page.h" diff --git a/projects/APPLaunch/main/ui/ui_app_page.hpp b/projects/APPLaunch/main/ui/ui_app_page.hpp deleted file mode 100644 index 399059e5..00000000 --- a/projects/APPLaunch/main/ui/ui_app_page.hpp +++ /dev/null @@ -1,1088 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD - * - * SPDX-License-Identifier: MIT - */ - -#pragma once -#include "ui.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "cp0_lvgl_app.h" -#include "hal_lvgl_bsp.h" -#include "launcher_platform.hpp" -#include "cp0_lvgl_file.hpp" -#include "ui_low_battery.h" -#define APP_CONSOLE_EXIT_EVENT (lv_event_code_t)(LV_EVENT_LAST + 1) - -namespace launcher_wifi { - -inline std::vector split_colon(const std::string &line) -{ - std::vector cols; - std::string current; - for (char ch : line) { - if (ch == ':') { - cols.push_back(current); - current.clear(); - } else { - current.push_back(ch); - } - } - cols.push_back(current); - return cols; -} - -inline void copy_string(char *dst, size_t dst_size, const std::string &src) -{ - if (!dst || dst_size == 0) - return; - std::snprintf(dst, dst_size, "%s", src.c_str()); -} - -inline cp0_wifi_status_t get_status() -{ - cp0_wifi_status_t st{}; - cp0_signal_wifi_api({"Status"}, [&](int code, std::string data) { - if (code != 0) - return; - auto cols = split_colon(data); - if (cols.size() < 4) - return; - st.connected = std::atoi(cols[0].c_str()); - copy_string(st.ssid, sizeof(st.ssid), cols[1]); - copy_string(st.ip, sizeof(st.ip), cols[2]); - st.signal = std::atoi(cols[3].c_str()); - if (cols.size() >= 5) - st.ethernet = std::atoi(cols[4].c_str()); - }); - return st; -} - -inline int scan(cp0_wifi_ap_t *out, int max_aps) -{ - int count = 0; - cp0_signal_wifi_api({"Scan", std::to_string(max_aps)}, [&](int code, std::string data) { - if (!out || max_aps <= 0) { - count = code; - return; - } - std::istringstream lines(data); - std::string line; - while (count < max_aps && std::getline(lines, line)) { - if (!line.empty() && line.back() == '\r') - line.pop_back(); - auto cols = split_colon(line); - if (cols.size() < 4 || cols[0].empty()) - continue; - cp0_wifi_ap_t ap{}; - copy_string(ap.ssid, sizeof(ap.ssid), cols[0]); - ap.signal = std::atoi(cols[1].c_str()); - copy_string(ap.security, sizeof(ap.security), cols[2]); - ap.in_use = std::atoi(cols[3].c_str()); - out[count++] = ap; - } - }); - return count; -} - -inline int simple(const std::list &args) -{ - int result = -1; - cp0_signal_wifi_api(args, [&](int code, std::string) { result = code; }); - return result; -} - -inline int connect(const char *ssid, const char *password) -{ - if (!ssid || !ssid[0]) - return -1; - if (password && password[0]) - return simple({"Connect", ssid, password}); - return simple({"Connect", ssid}); -} - -inline int profile_forget(const char *ssid) -{ - return (!ssid || !ssid[0]) ? -1 : simple({"ProfileForget", ssid}); -} - -inline int profile_exists(const char *ssid) -{ - return (!ssid || !ssid[0]) ? 0 : simple({"ProfileExists", ssid}); -} - -inline int profile_disconnect_active() -{ - return simple({"ProfileDisconnectActive"}); -} - -inline int radio_enabled() -{ - return simple({"RadioEnabled"}); -} - -inline int radio_set_enabled(bool enabled) -{ - return simple({"RadioSetEnabled", enabled ? "on" : "off"}); -} - -} // namespace launcher_wifi - -namespace launcher_battery_ui { - -constexpr int kLowBatteryBlinkThreshold = 3; -constexpr uint32_t kLowBatteryBlinkMs = 450; - -inline bool should_blink(int soc) -{ - return soc < kLowBatteryBlinkThreshold; -} - -inline void blink_opa_exec_cb(void *obj, int32_t opa) -{ - lv_obj_set_style_opa(static_cast(obj), static_cast(opa), - LV_PART_MAIN | LV_STATE_DEFAULT); -} - -inline void set_blink(lv_obj_t *battery_panel, bool enabled, bool &active) -{ - if (!battery_panel || enabled == active) - return; - - active = enabled; - lv_anim_del(battery_panel, blink_opa_exec_cb); - if (enabled) { - lv_obj_set_style_opa(battery_panel, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, battery_panel); - lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_20); - lv_anim_set_time(&a, kLowBatteryBlinkMs); - lv_anim_set_playback_time(&a, kLowBatteryBlinkMs); - lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); - lv_anim_set_path_cb(&a, lv_anim_path_linear); - lv_anim_set_exec_cb(&a, blink_opa_exec_cb); - lv_anim_start(&a); - } else { - lv_obj_set_style_opa(battery_panel, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - } -} - -} // namespace launcher_battery_ui - -class UIAppTopBar -{ -public: - UIAppTopBar() = default; - explicit UIAppTopBar(std::string title) : title_(std::move(title)) {} - - lv_obj_t *create(lv_obj_t *parent) - { - return create(parent, title_); - } - - void set_height(int height) - { - height_ = height; - if (ui_TOP_Container) - lv_obj_set_height(ui_TOP_Container, height_); - if (title_label_) - lv_obj_set_height(title_label_, height_); - } - - lv_obj_t *create(lv_obj_t *parent, const std::string &title) - { - title_ = title; - ui_TOP_Container = lv_obj_create(parent); - lv_obj_remove_style_all(ui_TOP_Container); - lv_obj_set_size(ui_TOP_Container, 320, height_); - lv_obj_set_pos(ui_TOP_Container, 0, 0); - lv_obj_clear_flag(ui_TOP_Container, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_set_flex_flow(ui_TOP_Container, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(ui_TOP_Container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_left(ui_TOP_Container, 5, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(ui_TOP_Container, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(ui_TOP_Container, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(ui_TOP_Container, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(ui_TOP_Container, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - - create_title(ui_TOP_Container); - create_spacer(ui_TOP_Container); - create_ethernet(ui_TOP_Container); - create_wifi(ui_TOP_Container); - create_time(ui_TOP_Container); - create_battery(ui_TOP_Container); - return ui_TOP_Container; - } - - void set_title(const std::string &title) - { - title_ = title; - if (title_label_) - lv_label_set_text(title_label_, title.c_str()); - } - - void update_time() - { - if (!time_label_) - return; - std::string time_text = "--:--"; - cp0_signal_settings_api({"TimeStr"}, [&](int code, std::string data) { - if (code == 0) time_text = std::move(data); - }); - lv_label_set_text(time_label_, time_text.c_str()); - } - - void update_wifi() - { - cp0_wifi_status_t ws = launcher_wifi::get_status(); - set_wifi_signal(ws.connected ? ws.signal : 0); - if (eth_icon_) - { - if (ws.ethernet) - lv_obj_clear_flag(eth_icon_, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(eth_icon_, LV_OBJ_FLAG_HIDDEN); - } - if (!wifi_panel_) - return; - if (ws.connected) - lv_obj_clear_flag(wifi_panel_, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(wifi_panel_, LV_OBJ_FLAG_HIDDEN); - } - - void update_battery(const cp0_battery_info_t &bat) - { - if (!bat.valid || !power_label_) - return; - int soc = bat.soc; - if (soc > 100) - soc = 100; - if (soc < 0) - soc = 0; - - char pwr_buf[16]; - snprintf(pwr_buf, sizeof(pwr_buf), "%d%%", soc); - lv_label_set_text(power_label_, pwr_buf); - if (battery_bar_) - lv_bar_set_value(battery_bar_, soc, LV_ANIM_OFF); - set_battery_charging((bat.flags & 1) != 0); - set_battery_low_blink(launcher_battery_ui::should_blink(soc)); - } - - void update_status() - { - update_time(); - update_wifi(); - } - -private: - std::string title_ = "APP"; - lv_obj_t *ui_TOP_Container = nullptr; - lv_obj_t *title_label_ = nullptr; - lv_obj_t *battery_panel_ = nullptr; - lv_obj_t *battery_bar_ = nullptr; - lv_obj_t *battery_charge_wave_ = nullptr; - lv_obj_t *power_label_ = nullptr; - lv_obj_t *time_panel_ = nullptr; - lv_obj_t *time_label_ = nullptr; - lv_obj_t *wifi_panel_ = nullptr; - lv_obj_t *wifi_bars_[4] = {}; - lv_obj_t *eth_icon_ = nullptr; - int height_ = 20; - bool battery_charging_ = false; - bool battery_low_blink_ = false; - - static lv_font_t *top_title_font() - { - return launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD); - } - - - static void clear_status_panel_style(lv_obj_t *obj) - { - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(obj, lv_color_hex(0x000000), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - void create_title(lv_obj_t *parent) - { - title_label_ = lv_label_create(parent); - lv_label_set_long_mode(title_label_, LV_LABEL_LONG_DOT); - lv_label_set_text(title_label_, title_.c_str()); - lv_obj_set_width(title_label_, 150); - lv_obj_set_height(title_label_, height_); - lv_obj_set_style_text_font(title_label_, top_title_font(), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(title_label_, lv_color_hex(0xCCAA00), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(title_label_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(title_label_, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - void create_spacer(lv_obj_t *parent) - { - lv_obj_t *spacer = lv_obj_create(parent); - lv_obj_remove_style_all(spacer); - lv_obj_set_size(spacer, 0, height_); - lv_obj_set_flex_grow(spacer, 1); - lv_obj_clear_flag(spacer, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - } - - void create_ethernet(lv_obj_t *parent) - { - eth_icon_ = lv_img_create(parent); - lv_img_set_src(eth_icon_, launcher_platform::path_c("status_ethernet.png")); - lv_obj_clear_flag(eth_icon_, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_add_flag(eth_icon_, LV_OBJ_FLAG_HIDDEN); - } - - void create_wifi(lv_obj_t *parent) - { - static const int bar_heights[4] = {6, 9, 12, 15}; - - wifi_panel_ = lv_obj_create(parent); - lv_obj_set_size(wifi_panel_, 22, 15); - clear_status_panel_style(wifi_panel_); - lv_obj_set_flex_flow(wifi_panel_, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(wifi_panel_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); - lv_obj_set_style_pad_column(wifi_panel_, 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_add_flag(wifi_panel_, LV_OBJ_FLAG_HIDDEN); - - for (int i = 0; i < 4; ++i) - { - wifi_bars_[i] = lv_obj_create(wifi_panel_); - lv_obj_set_size(wifi_bars_[i], 4, bar_heights[i]); - lv_obj_clear_flag(wifi_bars_[i], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(wifi_bars_[i], 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(wifi_bars_[i], lv_color_hex(0x4D4D4D), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(wifi_bars_[i], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(wifi_bars_[i], 0, LV_PART_MAIN | LV_STATE_DEFAULT); - } - } - - void create_time(lv_obj_t *parent) - { - time_panel_ = lv_obj_create(parent); - lv_obj_set_size(time_panel_, 40, 16); - clear_status_panel_style(time_panel_); - lv_obj_set_style_bg_img_src(time_panel_, launcher_platform::path_c("status_time_background.png"), LV_PART_MAIN | LV_STATE_DEFAULT); - - time_label_ = lv_label_create(time_panel_); - lv_obj_set_align(time_label_, LV_ALIGN_CENTER); - lv_label_set_text(time_label_, "15:21"); - lv_obj_set_style_text_color(time_label_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(time_label_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - void create_battery(lv_obj_t *parent) - { - battery_panel_ = lv_obj_create(parent); - lv_obj_set_size(battery_panel_, 36, 16); - clear_status_panel_style(battery_panel_); - lv_obj_set_style_bg_img_src(battery_panel_, launcher_platform::path_c("status_battery_background.png"), LV_PART_MAIN | LV_STATE_DEFAULT); - - battery_bar_ = lv_bar_create(battery_panel_); - lv_bar_set_value(battery_bar_, 96, LV_ANIM_OFF); - lv_bar_set_start_value(battery_bar_, 0, LV_ANIM_OFF); - lv_obj_set_size(battery_bar_, 33, 14); - lv_obj_set_align(battery_bar_, LV_ALIGN_CENTER); - lv_obj_set_style_radius(battery_bar_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(battery_bar_, lv_color_hex(0x484847), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(battery_bar_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_radius(battery_bar_, 0, LV_PART_INDICATOR | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(battery_bar_, lv_color_hex(0x66CC33), LV_PART_INDICATOR | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(battery_bar_, 0, LV_PART_INDICATOR | LV_STATE_DEFAULT); - - battery_charge_wave_ = lv_obj_create(battery_panel_); - lv_obj_set_size(battery_charge_wave_, 8, 14); - lv_obj_set_pos(battery_charge_wave_, -8, 1); - lv_obj_clear_flag(battery_charge_wave_, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_set_style_radius(battery_charge_wave_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(battery_charge_wave_, lv_color_hex(0xFFF176), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(battery_charge_wave_, 190, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(battery_charge_wave_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_add_flag(battery_charge_wave_, LV_OBJ_FLAG_HIDDEN); - - power_label_ = lv_label_create(battery_panel_); - lv_obj_set_align(power_label_, LV_ALIGN_CENTER); - lv_label_set_text(power_label_, "--"); - lv_obj_set_style_text_color(power_label_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(power_label_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - void set_battery_low_blink(bool enabled) - { - launcher_battery_ui::set_blink(battery_panel_, enabled, battery_low_blink_); - } - - void set_battery_charging(bool charging) - { - if (!battery_panel_ || !battery_bar_ || !power_label_ || !battery_charge_wave_) - return; - // No progress fill: the battery level is shown by the rounded background - // image + "%" label only. Keep the bar fully transparent in all states. - lv_obj_set_style_bg_opa(battery_panel_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(battery_bar_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(battery_bar_, 0, LV_PART_INDICATOR | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(power_label_, lv_color_hex(charging ? 0xFFF2A8 : 0xFFFFFF), - LV_PART_MAIN | LV_STATE_DEFAULT); - - if (charging == battery_charging_) - return; - battery_charging_ = charging; - if (charging) { - lv_obj_clear_flag(battery_charge_wave_, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_foreground(power_label_); - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, battery_charge_wave_); - lv_anim_set_values(&a, -8, 36); - lv_anim_set_time(&a, 850); - lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); - lv_anim_set_path_cb(&a, lv_anim_path_linear); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_x); - lv_anim_start(&a); - } else { - lv_anim_del(battery_charge_wave_, nullptr); - lv_obj_set_x(battery_charge_wave_, -8); - lv_obj_add_flag(battery_charge_wave_, LV_OBJ_FLAG_HIDDEN); - } - } - - void set_wifi_signal(int signal) - { - static const int thresholds[4] = {1, 30, 60, 80}; - const uint32_t on_color = 0x33CC33; - const uint32_t off_color = 0x4D4D4D; - - for (int i = 0; i < 4; ++i) - { - if (!wifi_bars_[i]) - continue; - lv_obj_set_style_bg_color(wifi_bars_[i], - lv_color_hex(signal >= thresholds[i] ? on_color : off_color), - LV_PART_MAIN | LV_STATE_DEFAULT); - } - } -}; -class UIAppContainer -{ -public: - UIAppContainer() = default; - explicit UIAppContainer(int height) : height_(height) {} - - void set_height(int height) - { - height_ = height; - if (ui_APP_Container) - lv_obj_set_height(ui_APP_Container, height_); - } - - lv_obj_t *create(lv_obj_t *parent) - { - ui_APP_Container = lv_obj_create(parent); - lv_obj_remove_style_all(ui_APP_Container); - lv_obj_set_width(ui_APP_Container, 320); - lv_obj_set_height(ui_APP_Container, height_); - lv_obj_set_pos(ui_APP_Container, 0, 20); - lv_obj_clear_flag(ui_APP_Container, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - return ui_APP_Container; - } - - lv_obj_t *get() const - { - return ui_APP_Container; - } - -private: - int height_ = 150; - lv_obj_t *ui_APP_Container = nullptr; -}; - - -class AppPageRoot -{ - -public: - std::string page_title_ = "APP"; - lv_group_t *input_group_ = nullptr; - lv_obj_t *root_screen_ = nullptr; - lv_obj_t *screen() { return root_screen_; } - lv_group_t *input_group() { return input_group_; } - std::function navigate_home; - bool has_bottom_bar_ = false; - int top_bar_height_px_ = 20; -public: - AppPageRoot() - { - creat_base_UI(); - creat_input_group(); - } - virtual ~AppPageRoot() - { - if (root_screen_) - lv_obj_del(root_screen_); - if (input_group_) - lv_group_delete(input_group_); - } - - template - lv_obj_t *add_bar(Component &&component) - { - return component.create(root_screen_); - } - -private: - /* ================================================================== */ - /* UI construction */ - /* ================================================================== */ - void creat_base_UI() - { - root_screen_ = lv_obj_create(NULL); - lv_obj_clear_flag(root_screen_, LV_OBJ_FLAG_SCROLLABLE); /// Flags - lv_obj_set_style_pad_all(root_screen_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(root_screen_, lv_color_hex(0x000000), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(root_screen_, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - void creat_input_group() - { - input_group_ = lv_group_create(); - lv_group_add_obj(input_group_, root_screen_); - } -}; - -class AppTopBarRegion : virtual public AppPageRoot -{ -public: - AppTopBarRegion() - { - top_bar_.set_title(page_title_); - top_bar_.set_height(top_bar_height_px_); - add_bar(top_bar_); - UI_bind_event(); - update_datetime_status(); - update_status_bar(); - cp0_signal_bq27220_api({"Read"}, [&](int code, std::string data) { - cp0_battery_info_t bat{}; - if (code == 0 && std::sscanf(data.c_str(), "%d,%d,%d,%d,%d,%d,%d,%d,%d", - &bat.voltage_mv, &bat.current_ma, &bat.temperature_c10, &bat.soc, - &bat.remain_mah, &bat.full_mah, &bat.flags, &bat.avg_current_ma, &bat.valid) == 9) - update_battery_status(bat); - }); - time_timer_ = lv_timer_create(app_time_timer_cb, 1000, this); - status_timer_ = lv_timer_create(app_status_timer_cb, 5000, this); - } - - virtual ~AppTopBarRegion() - { - if (time_timer_) - lv_timer_delete(time_timer_); - if (status_timer_) - lv_timer_delete(status_timer_); - } - - void update_status_bar() - { - top_bar_.update_wifi(); - } - - void update_datetime_status() - { - top_bar_.update_time(); - } - - void update_battery_status(const cp0_battery_info_t &bat) - { - top_bar_.update_battery(bat); - launcher_battery_ui::update_warning(bat); - } - - void set_page_title(const std::string &title) - { - page_title_ = title; - top_bar_.set_title(title); - } - -private: - UIAppTopBar top_bar_; - lv_timer_t *time_timer_ = nullptr; - lv_timer_t *status_timer_ = nullptr; - - static void app_battery_event_cb(lv_event_t *e) - { - AppTopBarRegion *self = static_cast(lv_event_get_user_data(e)); - if (!self || lv_event_get_code(e) != launcher_ui::events::battery_event()) - return; - const cp0_battery_info_t *bat = launcher_ui::events::battery_info(e); - if (bat) - self->update_battery_status(*bat); - } - - static void app_status_timer_cb(lv_timer_t *timer) - { - AppTopBarRegion *self = static_cast(lv_timer_get_user_data(timer)); - if (self) - self->update_status_bar(); - } - - static void app_time_timer_cb(lv_timer_t *timer) - { - AppTopBarRegion *self = static_cast(lv_timer_get_user_data(timer)); - if (self) - self->update_datetime_status(); - } - - void UI_bind_event() - { - lv_obj_add_event_cb(root_screen_, app_battery_event_cb, launcher_ui::events::battery_event(), this); - } -}; - -class AppContentRegion : virtual public AppPageRoot -{ -public: - AppContentRegion() - { - refresh(); - ui_APP_Container = add_bar(app_container_); - } - - void refresh() - { - app_container_.set_height(has_bottom_bar_ ? 130 : 150); - } - - void refash() - { - refresh(); - } - - virtual ~AppContentRegion() = default; - - lv_obj_t *ui_APP_Container = nullptr; - -private: - UIAppContainer app_container_; -}; - -class AppBottomBarRegion : virtual public AppPageRoot, virtual public AppContentRegion -{ -public: - AppBottomBarRegion() - { - has_bottom_bar_ = true; - refresh(); - - ui_BOTTOM_Container = lv_obj_create(root_screen_); - lv_obj_remove_style_all(ui_BOTTOM_Container); - lv_obj_set_width(ui_BOTTOM_Container, 320); - lv_obj_set_height(ui_BOTTOM_Container, 20); - lv_obj_set_pos(ui_BOTTOM_Container, 0, 150); - lv_obj_clear_flag(ui_BOTTOM_Container, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - } - - virtual ~AppBottomBarRegion() = default; - - lv_obj_t *ui_BOTTOM_Container = nullptr; -}; - -class AppPageLayout : virtual public AppTopBarRegion, virtual public AppContentRegion -{ -public: - AppPageLayout() : AppPageRoot(), AppTopBarRegion(), AppContentRegion() - { - } - - virtual ~AppPageLayout() = default; -}; - -class AppPageWithBottomBarLayout : virtual public AppTopBarRegion, virtual public AppContentRegion, virtual public AppBottomBarRegion -{ -public: - AppPageWithBottomBarLayout() : AppPageRoot(), AppTopBarRegion(), AppContentRegion(), AppBottomBarRegion() - { - } - - virtual ~AppPageWithBottomBarLayout() = default; -}; - -class home_base : public AppPageRoot -{ -private: - lv_obj_t *ui_TOP_Container = nullptr; - lv_obj_t *ui_TOP_logo = nullptr; - lv_obj_t *ui_TOP_wifiPanel = nullptr; - lv_obj_t *ui_TOP_wifiBars[4] = {}; - lv_obj_t *ui_TOP_eth = nullptr; - lv_obj_t *ui_TOP_time = nullptr; - lv_obj_t *ui_TOP_time_Label = nullptr; - lv_obj_t *ui_TOP_battery_panel = nullptr; - lv_obj_t *ui_TOP_Power = nullptr; - lv_obj_t *ui_TOP_charge_wave = nullptr; - lv_obj_t *ui_TOP_power_Label = nullptr; - lv_timer_t *time_timer_ = nullptr; - lv_timer_t *status_timer_ = nullptr; - bool ui_TOP_charging = false; - bool ui_TOP_battery_low_blink = false; - -public: - lv_obj_t *ui_APP_Container = nullptr; - -public: - home_base() : AppPageRoot() - { - creat_Top_UI(); - UI_bind_event(); - update_time_status(); - update_status_bar(); - time_timer_ = lv_timer_create(home_time_timer_cb, 1000, this); - status_timer_ = lv_timer_create(home_status_timer_cb, 5000, this); - } - ~home_base() - { - if (time_timer_) - lv_timer_delete(time_timer_); - if (status_timer_) - lv_timer_delete(status_timer_); - } - - static void home_battery_event_cb(lv_event_t *e) - { - home_base *self = static_cast(lv_event_get_user_data(e)); - if (!self || lv_event_get_code(e) != launcher_ui::events::battery_event()) - return; - const cp0_battery_info_t *bat = launcher_ui::events::battery_info(e); - if (bat) - self->update_battery_status(*bat); - } - - static void home_status_timer_cb(lv_timer_t *timer) - { - home_base *self = static_cast(lv_timer_get_user_data(timer)); - if (self) - self->update_status_bar(); - } - - static void home_time_timer_cb(lv_timer_t *timer) - { - home_base *self = static_cast(lv_timer_get_user_data(timer)); - if (self) - self->update_time_status(); - } - - void update_status_bar() - { - update_wifi_status(); - } - - void use_bold_home_title_font() - { -#ifndef APPLAUNCH_LOGO_USE_PNG - if (!ui_TOP_logo) - return; - lv_obj_set_style_text_font(ui_TOP_logo, - launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), - LV_PART_MAIN | LV_STATE_DEFAULT); -#endif - } - - void update_battery_status(const cp0_battery_info_t &bat) - { - launcher_battery_ui::update_warning(bat); - if (bat.valid) - { - int soc = bat.soc; - if (soc > 100) - soc = 100; - if (soc < 0) - soc = 0; - lv_bar_set_value(ui_TOP_Power, soc, LV_ANIM_ON); - char pwr_buf[16]; - snprintf(pwr_buf, sizeof(pwr_buf), "%d%%", soc); - lv_label_set_text(ui_TOP_power_Label, pwr_buf); - lv_obj_set_style_text_font(ui_TOP_power_Label, LV_FONT_DEFAULT, LV_PART_MAIN | LV_STATE_DEFAULT); - const bool charging = (bat.flags & 1) != 0; - set_home_battery_charging(charging); - set_home_battery_low_blink(launcher_battery_ui::should_blink(soc)); - - uint32_t color = 0x66CC33; - if (soc <= 20) - color = 0xE74C3C; - else if (soc <= 50) - color = 0xF39C12; - if (!charging) { - lv_obj_set_style_bg_color(ui_TOP_Power, lv_color_hex(color), - LV_PART_INDICATOR | LV_STATE_DEFAULT); - } - } - } - - lv_obj_t *content_container() const - { - return ui_APP_Container; - } - - lv_obj_t *top_container() const - { - return ui_TOP_Container; - } - -private: - /* ================================================================== */ - /* UI construction */ - /* ================================================================== */ - void creat_Top_UI() - { - ui_TOP_Container = lv_obj_create(root_screen_); - lv_obj_remove_style_all(ui_TOP_Container); - lv_obj_set_size(ui_TOP_Container, 320, 20); - lv_obj_set_pos(ui_TOP_Container, 0, 0); - lv_obj_clear_flag(ui_TOP_Container, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_set_flex_flow(ui_TOP_Container, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(ui_TOP_Container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_left(ui_TOP_Container, 5, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(ui_TOP_Container, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(ui_TOP_Container, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(ui_TOP_Container, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(ui_TOP_Container, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - -#ifdef APPLAUNCH_LOGO_USE_PNG - ui_TOP_logo = lv_img_create(ui_TOP_Container); - lv_img_set_src(ui_TOP_logo, launcher_platform::path_c("launcher_brand_logo.png")); - lv_obj_set_size(ui_TOP_logo, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_add_flag(ui_TOP_logo, LV_OBJ_FLAG_ADV_HITTEST); - lv_obj_clear_flag(ui_TOP_logo, LV_OBJ_FLAG_SCROLLABLE); -#else - ui_TOP_logo = lv_label_create(ui_TOP_Container); - lv_label_set_text(ui_TOP_logo, "ZERO"); - lv_obj_set_style_text_font(ui_TOP_logo, &lv_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(ui_TOP_logo, lv_color_hex(0xCCAA00), LV_PART_MAIN | LV_STATE_DEFAULT); -#endif - - // Spacer: pushes right-side icons to the far right - lv_obj_t *spacer = lv_obj_create(ui_TOP_Container); - lv_obj_remove_style_all(spacer); - lv_obj_set_size(spacer, 0, 20); - lv_obj_set_flex_grow(spacer, 1); - lv_obj_clear_flag(spacer, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - - ui_TOP_eth = lv_img_create(ui_TOP_Container); - lv_img_set_src(ui_TOP_eth, launcher_platform::path_c("status_ethernet.png")); - lv_obj_clear_flag(ui_TOP_eth, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_add_flag(ui_TOP_eth, LV_OBJ_FLAG_HIDDEN); - - create_wifi_status(ui_TOP_Container); - - ui_TOP_time = lv_obj_create(ui_TOP_Container); - lv_obj_set_width(ui_TOP_time, 40); - lv_obj_set_height(ui_TOP_time, 16); - lv_obj_clear_flag(ui_TOP_time, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(ui_TOP_time, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(ui_TOP_time, lv_color_hex(0x000000), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_time, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_img_src(ui_TOP_time, launcher_platform::path_c("status_time_background.png"), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ui_TOP_time, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - ui_TOP_time_Label = lv_label_create(ui_TOP_time); - lv_obj_set_width(ui_TOP_time_Label, LV_SIZE_CONTENT); - lv_obj_set_height(ui_TOP_time_Label, LV_SIZE_CONTENT); - lv_obj_set_align(ui_TOP_time_Label, LV_ALIGN_CENTER); - lv_label_set_text(ui_TOP_time_Label, "15:21"); - lv_obj_set_style_text_color(ui_TOP_time_Label, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(ui_TOP_time_Label, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - ui_TOP_battery_panel = lv_obj_create(ui_TOP_Container); - lv_obj_set_width(ui_TOP_battery_panel, 36); - lv_obj_set_height(ui_TOP_battery_panel, 16); - lv_obj_clear_flag(ui_TOP_battery_panel, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(ui_TOP_battery_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(ui_TOP_battery_panel, lv_color_hex(0x000000), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_battery_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_img_src(ui_TOP_battery_panel, launcher_platform::path_c("status_battery_background.png"), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ui_TOP_battery_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(ui_TOP_battery_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - - ui_TOP_Power = lv_bar_create(ui_TOP_battery_panel); - lv_bar_set_value(ui_TOP_Power, 96, LV_ANIM_OFF); - lv_bar_set_start_value(ui_TOP_Power, 0, LV_ANIM_OFF); - lv_obj_set_width(ui_TOP_Power, 33); - lv_obj_set_height(ui_TOP_Power, 14); - lv_obj_set_align(ui_TOP_Power, LV_ALIGN_CENTER); - lv_obj_set_style_radius(ui_TOP_Power, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(ui_TOP_Power, lv_color_hex(0x484847), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_Power, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_set_style_radius(ui_TOP_Power, 0, LV_PART_INDICATOR | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(ui_TOP_Power, lv_color_hex(0x66CC33), LV_PART_INDICATOR | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_Power, 0, LV_PART_INDICATOR | LV_STATE_DEFAULT); - - ui_TOP_charge_wave = lv_obj_create(ui_TOP_Power); - lv_obj_set_width(ui_TOP_charge_wave, 8); - lv_obj_set_height(ui_TOP_charge_wave, 14); - lv_obj_set_pos(ui_TOP_charge_wave, -8, 1); - lv_obj_clear_flag(ui_TOP_charge_wave, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - lv_obj_set_style_radius(ui_TOP_charge_wave, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(ui_TOP_charge_wave, lv_color_hex(0xFFF176), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_charge_wave, 190, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ui_TOP_charge_wave, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_add_flag(ui_TOP_charge_wave, LV_OBJ_FLAG_HIDDEN); - - ui_TOP_power_Label = lv_label_create(ui_TOP_Power); - lv_obj_set_width(ui_TOP_power_Label, LV_SIZE_CONTENT); - lv_obj_set_height(ui_TOP_power_Label, LV_SIZE_CONTENT); - lv_obj_set_align(ui_TOP_power_Label, LV_ALIGN_CENTER); - lv_label_set_text(ui_TOP_power_Label, "--"); - lv_obj_set_style_text_color(ui_TOP_power_Label, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(ui_TOP_power_Label, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - ui_APP_Container = lv_obj_create(root_screen_); - lv_obj_remove_style_all(ui_APP_Container); - lv_obj_set_size(ui_APP_Container, 320, 150); - lv_obj_set_pos(ui_APP_Container, 0, 20); - lv_obj_clear_flag(ui_APP_Container, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); /// Flags - } - - void set_home_battery_low_blink(bool enabled) - { - launcher_battery_ui::set_blink(ui_TOP_battery_panel, enabled, ui_TOP_battery_low_blink); - } - - void set_home_battery_charging(bool charging) - { - if (!ui_TOP_battery_panel || !ui_TOP_Power || !ui_TOP_power_Label || !ui_TOP_charge_wave) - return; - // No progress fill: the battery level is shown by the rounded background - // image + "%" label only. Keep the bar fully transparent in all states. - lv_obj_set_style_bg_opa(ui_TOP_battery_panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_Power, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_Power, 0, LV_PART_INDICATOR | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(ui_TOP_power_Label, lv_color_hex(charging ? 0xFFF2A8 : 0xFFFFFF), - LV_PART_MAIN | LV_STATE_DEFAULT); - - if (charging == ui_TOP_charging) - return; - ui_TOP_charging = charging; - if (charging) { - lv_obj_clear_flag(ui_TOP_charge_wave, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_foreground(ui_TOP_power_Label); - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, ui_TOP_charge_wave); - lv_anim_set_values(&a, -8, 36); - lv_anim_set_time(&a, 850); - lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); - lv_anim_set_path_cb(&a, lv_anim_path_linear); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_x); - lv_anim_start(&a); - } else { - lv_anim_del(ui_TOP_charge_wave, nullptr); - lv_obj_set_x(ui_TOP_charge_wave, -8); - lv_obj_add_flag(ui_TOP_charge_wave, LV_OBJ_FLAG_HIDDEN); - } - } - - void create_wifi_status(lv_obj_t *parent) - { - static const int bar_heights[4] = {6, 9, 12, 15}; - - ui_TOP_wifiPanel = lv_obj_create(parent); - lv_obj_set_width(ui_TOP_wifiPanel, 24); - lv_obj_set_height(ui_TOP_wifiPanel, 15); - lv_obj_clear_flag(ui_TOP_wifiPanel, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(ui_TOP_wifiPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_wifiPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ui_TOP_wifiPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(ui_TOP_wifiPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_add_flag(ui_TOP_wifiPanel, LV_OBJ_FLAG_HIDDEN); - - for (int i = 0; i < 4; ++i) - { - ui_TOP_wifiBars[i] = lv_obj_create(ui_TOP_wifiPanel); - lv_obj_set_width(ui_TOP_wifiBars[i], 4); - lv_obj_set_height(ui_TOP_wifiBars[i], bar_heights[i]); - lv_obj_set_align(ui_TOP_wifiBars[i], LV_ALIGN_BOTTOM_LEFT); - lv_obj_set_x(ui_TOP_wifiBars[i], i * 6); - lv_obj_clear_flag(ui_TOP_wifiBars[i], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(ui_TOP_wifiBars[i], 2, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(ui_TOP_wifiBars[i], lv_color_hex(0x4D4D4D), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(ui_TOP_wifiBars[i], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ui_TOP_wifiBars[i], 0, LV_PART_MAIN | LV_STATE_DEFAULT); - } - } - - void update_time_status() - { - if (!ui_TOP_time_Label) - return; - - std::string time_text = "--:--"; - cp0_signal_settings_api({"TimeStr"}, [&](int code, std::string data) { - if (code == 0) time_text = std::move(data); - }); - lv_label_set_text(ui_TOP_time_Label, time_text.c_str()); - } - - void update_wifi_status() - { - if (!ui_TOP_wifiPanel) - return; - - cp0_wifi_status_t ws = launcher_wifi::get_status(); - static const int thresholds[4] = {1, 30, 60, 80}; - - for (int i = 0; i < 4; ++i) - { - if (!ui_TOP_wifiBars[i]) - continue; - lv_obj_set_style_bg_color(ui_TOP_wifiBars[i], - lv_color_hex(ws.connected && ws.signal >= thresholds[i] ? 0x33CC33 : 0x4D4D4D), - LV_PART_MAIN | LV_STATE_DEFAULT); - } - - if (ws.connected) - lv_obj_clear_flag(ui_TOP_wifiPanel, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(ui_TOP_wifiPanel, LV_OBJ_FLAG_HIDDEN); - - if (ui_TOP_eth) - { - if (ws.ethernet) - lv_obj_clear_flag(ui_TOP_eth, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(ui_TOP_eth, LV_OBJ_FLAG_HIDDEN); - } - } - - void UI_bind_event() - { - lv_obj_add_event_cb(root_screen_, home_battery_event_cb, launcher_ui::events::battery_event(), this); - } -}; - -class AppPage : public AppPageLayout -{ -public: - AppPage() : AppPageLayout() - { - } - - ~AppPage() override = default; -}; diff --git a/projects/APPLaunch/main/ui/ui_global_hint.cpp b/projects/APPLaunch/main/ui/ui_global_hint.cpp index f1b48c90..49236524 100644 --- a/projects/APPLaunch/main/ui/ui_global_hint.cpp +++ b/projects/APPLaunch/main/ui/ui_global_hint.cpp @@ -7,7 +7,7 @@ /* * ui_global_hint.cpp * - * Transient on-screen hint/toast overlay. + * Global shortcut dispatcher and transient on-screen hints. * * Behavior: * (a) ESC held continuously for >= 1.5s -> show @@ -28,562 +28,113 @@ */ #include "ui_global_hint.h" -#include "ui.h" #include "keyboard_input.h" #include "lvgl/lvgl.h" #include "hal_lvgl_bsp.h" -#include "cp0_lvgl_app.h" #include "launcher_platform.hpp" +#include "launcher_media_controls.h" +#include "launcher_media_osd.h" +#include "launcher_toast.h" +#include "esc_hold_hint_controller.h" +#include "model/global_hint_policy.hpp" -#include "compat/input_keys.h" +#include "input_keys.h" -#include -#include -#include #include -/* KEY_RIGHTSHIFT / KEY_COMPOSE exist in but the - * project's non-Linux compat/input_keys.h does not define them. - * Provide reasonable fallbacks so the file builds on Darwin / SDL too. */ -#ifndef KEY_RIGHTSHIFT -#define KEY_RIGHTSHIFT 54 -#endif - -/* Standard Linux evdev code for the Fn key. Defined here to avoid - * relying on any particular having it. */ -#ifndef KEY_FN -#define KEY_FN 0x1d0 -#endif - -/* Fallback: KEY_COMPOSE is the most common evdev code for a physical - * "SYM" / "Menu" style key; include it alongside LEFTSHIFT / RIGHTSHIFT - * so the hint fires regardless of which exact code the TCA8418 driver - * chose for the SYM key on this board. */ -#ifndef KEY_COMPOSE -#define KEY_COMPOSE 127 -#endif - -#define HINT_SHOW_MS 1500 -#define HINT_ESC_HOLD_MS 1500 /* how long ESC must be held before the - * "long-press 3s to return home" toast - * appears. Short taps stay silent. */ -#define HINT_ESC_POLL_MS 100 /* how often we re-check "is ESC still - * held and past the threshold?". */ -#define HINT_BG_COLOR 0x1F3A5F -#define HINT_BG_OPA LV_OPA_80 -#define HINT_TEXT_COLOR 0xFFFFFF -#define HINT_WIDTH 280 -#define HINT_HEIGHT 22 -#define HINT_Y_OFFSET 4 /* px below top of screen */ - -#define MEDIA_OSD_SHOW_MS 1000 #define MEDIA_OSD_STEP 5 -#define MEDIA_OSD_WIDTH 190 -#define MEDIA_OSD_HEIGHT 82 -#define MEDIA_OSD_BG_COLOR 0x20242C -#define MEDIA_OSD_BAR_BG 0x555C66 -#define MEDIA_OSD_BAR_FG 0xF2C94C -#define MEDIA_OSD_TEXT 0xFFFFFF - -#ifndef KEY_MUTE -#define KEY_MUTE 113 -#endif -#ifndef KEY_VOLUMEDOWN -#define KEY_VOLUMEDOWN 114 -#endif -#ifndef KEY_VOLUMEUP -#define KEY_VOLUMEUP 115 -#endif -#ifndef KEY_BRIGHTNESSDOWN -#define KEY_BRIGHTNESSDOWN 224 -#endif -#ifndef KEY_BRIGHTNESSUP -#define KEY_BRIGHTNESSUP 225 -#endif - -static lv_obj_t *s_hint_obj = NULL; -static lv_obj_t *s_hint_label = NULL; -static lv_timer_t *s_hint_timer = NULL; - -static lv_obj_t *s_media_obj = NULL; -static lv_obj_t *s_media_icon = NULL; -static lv_obj_t *s_media_title = NULL; -static lv_obj_t *s_media_value = NULL; -static lv_obj_t *s_media_bar = NULL; -static lv_timer_t *s_media_timer = NULL; -static int s_brightness_pct = -1; -static int s_volume_pct = -1; -static bool s_muted = false; - -/* ESC-hold tracking. We do NOT fire the ESC hint on key-down anymore; - * instead we record the down-tick and let a poll timer decide. */ -static lv_timer_t *s_esc_poll_timer = NULL; -static uint32_t s_esc_down_tick = 0; /* lv_tick_get() snapshot at - * key-down; 0 == not held */ -static bool s_esc_hint_shown = false; /* true iff the currently - * visible toast is the ESC - * one (so release can hide - * only its own hint). */ - -static void hint_timer_cb(lv_timer_t *t) -{ - /* One-shot: hide the toast and pause the timer. We keep the timer - * alive (never let its repeat_count hit zero + auto-delete) so - * subsequent triggers can just reset it without worrying about - * dangling pointers. */ - if (s_hint_obj) { - lv_obj_add_flag(s_hint_obj, LV_OBJ_FLAG_HIDDEN); - } - s_esc_hint_shown = false; - if (t) lv_timer_pause(t); -} - -static int clamp_percent(int value) -{ - if (value < 0) return 0; - if (value > 100) return 100; - return value; -} - -static int read_config_int(const char *key, int default_val) -{ - int val = default_val; - cp0_signal_config_api({"GetInt", key ? std::string(key) : std::string(), std::to_string(default_val)}, - [&](int code, std::string data) { - if (code == 0) val = std::atoi(data.c_str()); - }); - return val; -} - -static void write_config_int(const char *key, int val) -{ - cp0_signal_config_api({"SetInt", key ? std::string(key) : std::string(), std::to_string(val)}, nullptr); - cp0_signal_config_api({"Save"}, nullptr); -} - -static int read_volume_percent(void) -{ - int volume = -1; - cp0_signal_audio_api({"VolumeRead"}, [&](int code, std::string data) { - if (code == 0) volume = std::atoi(data.c_str()); - }); - if (volume < 0) volume = read_config_int("volume", s_volume_pct >= 0 ? s_volume_pct : 50); - return clamp_percent(volume); -} - -static int write_volume_percent(int pct) -{ - pct = clamp_percent(pct); - int written = pct; - cp0_signal_audio_api({"VolumeWrite", std::to_string(pct)}, [&](int code, std::string data) { - if (code == 0) written = clamp_percent(std::atoi(data.c_str())); - }); - s_volume_pct = written; - write_config_int("volume", written); - return written; -} - -static int read_brightness_percent(void) -{ - int raw = -1; - int mx = 100; - cp0_signal_settings_api({"BacklightRead"}, [&](int code, std::string data) { - if (code == 0) raw = std::atoi(data.c_str()); - }); - cp0_signal_settings_api({"BacklightMax"}, [&](int code, std::string data) { - if (code == 0) mx = std::atoi(data.c_str()); - }); - if (mx <= 0) mx = 100; - if (raw < 0) raw = read_config_int("brightness", s_brightness_pct >= 0 ? mx * s_brightness_pct / 100 : mx); - return clamp_percent(raw * 100 / mx); -} - -static int write_brightness_percent(int pct) -{ - pct = clamp_percent(pct); - int mx = 100; - cp0_signal_settings_api({"BacklightMax"}, [&](int code, std::string data) { - if (code == 0) mx = std::atoi(data.c_str()); - }); - if (mx <= 0) mx = 100; - int raw = mx * pct / 100; - if (pct > 0 && raw < 1) raw = 1; - int written = -1; - cp0_signal_settings_api({"BacklightWrite", std::to_string(raw)}, [&](int code, std::string data) { - if (code == 0) written = std::atoi(data.c_str()); - }); - if (written < 0) written = raw; - s_brightness_pct = clamp_percent(written * 100 / mx); - write_config_int("brightness", written); - return s_brightness_pct; -} - -static bool read_system_mute(bool fallback) -{ - bool muted = fallback; - cp0_signal_audio_api({"MuteRead"}, [&](int code, std::string data) { - if (code == 0) muted = std::atoi(data.c_str()) != 0; - }); - return muted; -} - -static bool toggle_system_mute(void) -{ - bool fallback = !s_muted; - s_muted = fallback; - cp0_signal_audio_api({"MuteToggle"}, [&](int code, std::string data) { - if (code == 0) s_muted = std::atoi(data.c_str()) != 0; - }); - return s_muted; -} - -static void media_timer_cb(lv_timer_t *t) -{ - if (s_media_obj) lv_obj_add_flag(s_media_obj, LV_OBJ_FLAG_HIDDEN); - if (t) lv_timer_pause(t); -} - -/* Forward decl: the poll timer shows the hint, which is defined below. */ -static void show_hint(const char *text); - -/* Poll while ESC is held. Fires every HINT_ESC_POLL_MS. If ESC is - * released (LVGL_HOME_KEY_FLAG == 0) we just pause ourselves — the - * release path also explicitly pauses, this is belt-and-suspenders. - * If ESC is still down and has been held >= HINT_ESC_HOLD_MS, show - * the toast once and pause until the next fresh ESC press. */ -static void esc_poll_timer_cb(lv_timer_t *t) -{ - /* ESC no longer held — nothing to do until a new key-down. */ - if (LVGL_HOME_KEY_FLAG == 0 || s_esc_down_tick == 0) { - if (t) lv_timer_pause(t); - return; - } - - uint32_t elapsed = lv_tick_elaps(s_esc_down_tick); - if (elapsed >= HINT_ESC_HOLD_MS) { - show_hint("Hold ESC 3s to return home"); - s_esc_hint_shown = true; - /* One-shot per hold: don't keep re-triggering show_hint every - * 100ms while the user continues to hold ESC past 1.5s. The - * auto-hide timer (HINT_SHOW_MS) will take the toast down. */ - if (t) lv_timer_pause(t); - } -} - -static void ensure_hint_created(void) -{ - if (s_hint_obj != NULL) return; - - lv_obj_t *parent = lv_layer_top(); - if (parent == NULL) return; - - s_hint_obj = lv_obj_create(parent); - lv_obj_remove_style_all(s_hint_obj); - lv_obj_set_size(s_hint_obj, HINT_WIDTH, HINT_HEIGHT); - lv_obj_align(s_hint_obj, LV_ALIGN_TOP_MID, 0, HINT_Y_OFFSET); - - lv_obj_set_style_bg_color(s_hint_obj, lv_color_hex(HINT_BG_COLOR), 0); - lv_obj_set_style_bg_opa(s_hint_obj, HINT_BG_OPA, 0); - lv_obj_set_style_radius(s_hint_obj, 6, 0); - lv_obj_set_style_border_width(s_hint_obj, 0, 0); - lv_obj_set_style_pad_all(s_hint_obj, 0, 0); - lv_obj_set_style_shadow_width(s_hint_obj, 0, 0); - /* Block user interaction — this is purely visual. */ - lv_obj_clear_flag(s_hint_obj, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(s_hint_obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(s_hint_obj, LV_OBJ_FLAG_IGNORE_LAYOUT); - - s_hint_label = lv_label_create(s_hint_obj); - lv_obj_set_style_text_color(s_hint_label, lv_color_hex(HINT_TEXT_COLOR), 0); - /* Prefer the project's Chinese-capable 12pt font; it already falls - * back to lv_font_montserrat_12 inside ui.c if freetype init failed. */ - lv_font_t *font = launcher_fonts().get("AlibabaPuHuiTi-3-55-Regular.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD); - lv_obj_set_style_text_font(s_hint_label, font, 0); - lv_label_set_text(s_hint_label, ""); - lv_obj_center(s_hint_label); - - lv_obj_add_flag(s_hint_obj, LV_OBJ_FLAG_HIDDEN); -} - -static void ensure_media_created(void) +static void show_hint(const char *text) { - if (s_media_obj != NULL) return; - - lv_obj_t *parent = lv_layer_top(); - if (parent == NULL) return; - - s_media_obj = lv_obj_create(parent); - lv_obj_remove_style_all(s_media_obj); - lv_obj_set_size(s_media_obj, MEDIA_OSD_WIDTH, MEDIA_OSD_HEIGHT); - lv_obj_center(s_media_obj); - lv_obj_set_style_bg_color(s_media_obj, lv_color_hex(MEDIA_OSD_BG_COLOR), 0); - lv_obj_set_style_bg_opa(s_media_obj, LV_OPA_90, 0); - lv_obj_set_style_radius(s_media_obj, 8, 0); - lv_obj_set_style_border_width(s_media_obj, 1, 0); - lv_obj_set_style_border_color(s_media_obj, lv_color_hex(0x3A404A), 0); - lv_obj_set_style_pad_all(s_media_obj, 0, 0); - lv_obj_set_style_shadow_width(s_media_obj, 0, 0); - lv_obj_clear_flag(s_media_obj, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(s_media_obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(s_media_obj, LV_OBJ_FLAG_IGNORE_LAYOUT); - - s_media_icon = lv_label_create(s_media_obj); - lv_obj_set_size(s_media_icon, 30, 24); - lv_obj_set_pos(s_media_icon, 14, 10); - lv_obj_set_style_text_color(s_media_icon, lv_color_hex(MEDIA_OSD_BAR_FG), 0); - lv_obj_set_style_text_font(s_media_icon, LV_FONT_DEFAULT, 0); - lv_obj_set_style_text_align(s_media_icon, LV_TEXT_ALIGN_CENTER, 0); - lv_label_set_text(s_media_icon, LV_SYMBOL_VOLUME_MAX); - - lv_font_t *font = launcher_fonts().get("AlibabaPuHuiTi-3-55-Regular.ttf", 12, LV_FREETYPE_FONT_STYLE_BOLD); - - s_media_title = lv_label_create(s_media_obj); - lv_obj_set_size(s_media_title, 76, 18); - lv_obj_set_pos(s_media_title, 48, 12); - lv_obj_set_style_text_color(s_media_title, lv_color_hex(MEDIA_OSD_TEXT), 0); - lv_obj_set_style_text_font(s_media_title, font, 0); - lv_label_set_long_mode(s_media_title, LV_LABEL_LONG_CLIP); - lv_label_set_text(s_media_title, ""); - - s_media_value = lv_label_create(s_media_obj); - lv_obj_set_size(s_media_value, 48, 18); - lv_obj_set_pos(s_media_value, 124, 12); - lv_obj_set_style_text_color(s_media_value, lv_color_hex(MEDIA_OSD_TEXT), 0); - lv_obj_set_style_text_font(s_media_value, font, 0); - lv_obj_set_style_text_align(s_media_value, LV_TEXT_ALIGN_RIGHT, 0); - lv_label_set_text(s_media_value, ""); - - s_media_bar = lv_bar_create(s_media_obj); - lv_obj_set_size(s_media_bar, 154, 10); - lv_obj_set_pos(s_media_bar, 18, 50); - lv_bar_set_range(s_media_bar, 0, 100); - lv_obj_set_style_radius(s_media_bar, 5, LV_PART_MAIN); - lv_obj_set_style_radius(s_media_bar, 5, LV_PART_INDICATOR); - lv_obj_set_style_bg_color(s_media_bar, lv_color_hex(MEDIA_OSD_BAR_BG), LV_PART_MAIN); - lv_obj_set_style_bg_opa(s_media_bar, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_bg_color(s_media_bar, lv_color_hex(MEDIA_OSD_BAR_FG), LV_PART_INDICATOR); - lv_obj_set_style_bg_opa(s_media_bar, LV_OPA_COVER, LV_PART_INDICATOR); - - lv_obj_add_flag(s_media_obj, LV_OBJ_FLAG_HIDDEN); + esc_hold_hint_controller().clear_hint_ownership(); + launcher_toast().show(text); } -static void show_media_bar(const char *title, const char *icon, int pct) +static int ensure_screenshot_dir(const char *scr_dir) { - ensure_media_created(); - if (!s_media_obj || !s_media_icon || !s_media_title || !s_media_value || !s_media_bar) return; - - pct = clamp_percent(pct); - lv_label_set_text(s_media_icon, icon); - lv_label_set_text(s_media_title, title); - lv_label_set_text_fmt(s_media_value, "%d%%", pct); - lv_bar_set_value(s_media_bar, pct, LV_ANIM_OFF); - - lv_obj_set_size(s_media_icon, 30, 24); - lv_obj_set_pos(s_media_icon, 14, 10); - lv_obj_set_pos(s_media_title, 48, 12); - lv_obj_set_pos(s_media_value, 124, 12); - lv_obj_clear_flag(s_media_value, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(s_media_bar, LV_OBJ_FLAG_HIDDEN); - - lv_obj_center(s_media_obj); - lv_obj_move_foreground(s_media_obj); - lv_obj_clear_flag(s_media_obj, LV_OBJ_FLAG_HIDDEN); - - if (s_media_timer == NULL) { - s_media_timer = lv_timer_create(media_timer_cb, MEDIA_OSD_SHOW_MS, NULL); - } - if (s_media_timer) { - lv_timer_set_period(s_media_timer, MEDIA_OSD_SHOW_MS); - lv_timer_reset(s_media_timer); - lv_timer_resume(s_media_timer); + int result = -1; + try { + cp0_signal_filesystem_api( + {"EnsureDirForUser", scr_dir ? scr_dir : ""}, + [&](int code, std::string) { result = code; }); + } catch (...) { } + return result; } -static void show_mute_osd(bool muted) -{ - ensure_media_created(); - if (!s_media_obj || !s_media_icon || !s_media_title || !s_media_value || !s_media_bar) return; - - lv_label_set_text(s_media_icon, muted ? LV_SYMBOL_MUTE : LV_SYMBOL_VOLUME_MAX); - lv_label_set_text(s_media_title, muted ? "Muted" : "Sound On"); - lv_obj_set_size(s_media_icon, 60, 32); - lv_obj_set_pos(s_media_icon, 65, 12); - lv_obj_set_pos(s_media_title, 55, 50); - lv_obj_add_flag(s_media_value, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(s_media_bar, LV_OBJ_FLAG_HIDDEN); - - lv_obj_center(s_media_obj); - lv_obj_move_foreground(s_media_obj); - lv_obj_clear_flag(s_media_obj, LV_OBJ_FLAG_HIDDEN); - - if (s_media_timer == NULL) { - s_media_timer = lv_timer_create(media_timer_cb, MEDIA_OSD_SHOW_MS, NULL); - } - if (s_media_timer) { - lv_timer_set_period(s_media_timer, MEDIA_OSD_SHOW_MS); - lv_timer_reset(s_media_timer); - lv_timer_resume(s_media_timer); - } -} - -static void show_hint(const char *text) -{ - ensure_hint_created(); - if (s_hint_obj == NULL || s_hint_label == NULL) return; - - /* Any call to show_hint replaces whatever toast was up. Clear the - * "visible toast is the ESC one" flag; the ESC path re-sets it right - * after calling us, so this only affects SHIFT/SYM callers — which - * means releasing ESC after a SHIFT toast took over won't mistakenly - * hide the SHIFT toast. */ - s_esc_hint_shown = false; - - lv_label_set_text(s_hint_label, text); - lv_obj_align(s_hint_obj, LV_ALIGN_TOP_MID, 0, HINT_Y_OFFSET); - lv_obj_clear_flag(s_hint_obj, LV_OBJ_FLAG_HIDDEN); - - if (s_hint_timer == NULL) { - s_hint_timer = lv_timer_create(hint_timer_cb, HINT_SHOW_MS, NULL); - } - /* Keep the timer alive indefinitely; the callback pauses it after - * one firing. Resetting here restarts the countdown from zero, so - * successive hints extend the visible window each time. */ - if (s_hint_timer) { - lv_timer_set_period(s_hint_timer, HINT_SHOW_MS); - lv_timer_reset(s_hint_timer); - lv_timer_resume(s_hint_timer); - } -} +namespace ui_global_hint { -static void ensure_screenshot_dir(const char *scr_dir) +void shutdown() { - cp0_signal_filesystem_api({"EnsureDirForUser", scr_dir ? scr_dir : ""}, nullptr); + esc_hold_hint_controller().shutdown(); + launcher_toast().shutdown(); + launcher_media_osd().shutdown(); } -namespace ui_global_hint { - -void on_key(const struct key_item *elm) +void on_key(const struct key_item *elm) noexcept { + try { if (elm == NULL) return; - const uint32_t code = elm->key_code; - - /* ESC has its own gated behavior: arm/disarm on every press/release - * edge; do not bail on non-PRESSED states like the other keys. */ - if (code == KEY_ESC) { - if (elm->key_state == KBD_KEY_PRESSED) { - /* Arm the hold timer. Don't show anything yet — a quick tap - * (common "go back" gesture) should stay silent. */ - s_esc_down_tick = lv_tick_get(); - if (s_esc_down_tick == 0) s_esc_down_tick = 1; /* sentinel */ - s_esc_hint_shown = false; - - if (s_esc_poll_timer == NULL) { - s_esc_poll_timer = lv_timer_create(esc_poll_timer_cb, - HINT_ESC_POLL_MS, - NULL); - } - if (s_esc_poll_timer) { - lv_timer_set_period(s_esc_poll_timer, HINT_ESC_POLL_MS); - lv_timer_reset(s_esc_poll_timer); - lv_timer_resume(s_esc_poll_timer); - } - } else if (elm->key_state == KBD_KEY_RELEASED) { - /* Released before the threshold -> hint never armed; pause - * the poll timer. If the hint is currently visible because - * the user did hold past 1.5s, hide it immediately on release - * rather than waiting for the auto-hide 1.5s. */ - s_esc_down_tick = 0; - if (s_esc_poll_timer) lv_timer_pause(s_esc_poll_timer); - if (s_esc_hint_shown) { - /* Reuse hint_timer_cb to do the hide + pause bookkeeping - * in one place. Passing s_hint_timer keeps the pause-in- - * callback behavior consistent. */ - hint_timer_cb(s_hint_timer); - } - } - /* state == KBD_KEY_REPEATED: ignore — the poll timer handles - * the hold detection regardless of repeat delivery. */ + if (esc_hold_hint_controller().handle(elm)) return; - } - - if (elm->key_state == KBD_KEY_PRESSED || elm->key_state == KBD_KEY_REPEATED) { - switch (code) { - case KEY_BRIGHTNESSUP: - case KEY_BRIGHTNESSDOWN: { - int pct = s_brightness_pct >= 0 ? s_brightness_pct : read_brightness_percent(); - pct += (code == KEY_BRIGHTNESSUP) ? MEDIA_OSD_STEP : -MEDIA_OSD_STEP; - pct = write_brightness_percent(pct); - show_media_bar("Brightness", LV_SYMBOL_TINT, pct); - return; - } - - case KEY_VOLUMEUP: - case KEY_VOLUMEDOWN: { - int pct = s_volume_pct >= 0 ? s_volume_pct : read_volume_percent(); - pct += (code == KEY_VOLUMEUP) ? MEDIA_OSD_STEP : -MEDIA_OSD_STEP; - pct = write_volume_percent(pct); - show_media_bar("Volume", pct == 0 ? LV_SYMBOL_MUTE : LV_SYMBOL_VOLUME_MAX, pct); - return; - } - case KEY_MUTE: - if (elm->key_state == KBD_KEY_PRESSED) { - show_mute_osd(toggle_system_mute()); - } - return; + static const GlobalHintPolicy policy; + const GlobalHintAction action = policy.action_for({ + elm->key_code, + elm->sym_name, + elm->key_state == KBD_KEY_PRESSED, + elm->key_state == KBD_KEY_REPEATED, + (elm->mods & KBD_MOD_CTRL) != 0, + (elm->mods & KBD_MOD_ALT) != 0, + }); - default: - break; + switch (action) { + case GlobalHintAction::BRIGHTNESS_UP: + case GlobalHintAction::BRIGHTNESS_DOWN: { + const int delta = action == GlobalHintAction::BRIGHTNESS_UP ? MEDIA_OSD_STEP + : -MEDIA_OSD_STEP; + const int pct = launcher_media_controls::adjust_brightness(delta); + launcher_media_osd().show_level("Brightness", LV_SYMBOL_TINT, pct); + return; } - } - - /* All non-media keys: only fire on the initial key-down edge. */ - if (elm->key_state != KBD_KEY_PRESSED) return; - - /* Ctrl+Alt+S: global screenshot → ~/Screenshots */ - if (code == KEY_S && (elm->mods & KBD_MOD_CTRL) && (elm->mods & KBD_MOD_ALT)) { - std::string scr_dir = launcher_platform::path("home_dir") + "/Screenshots"; - ensure_screenshot_dir(scr_dir.c_str()); - int ret = -1; - cp0_signal_screenshot_api({"Save", scr_dir}, [&](int code, std::string) { - ret = code; - }); - show_hint(ret == 0 ? "Saved to ~/Screenshots" : "Screenshot failed"); - return; - } - - /* Explicitly skip Fn — no lock feature attached to it. */ - if (code == KEY_FN) return; - - switch (code) { - case KEY_LEFTSHIFT: - case KEY_RIGHTSHIFT: - case KEY_COMPOSE: + case GlobalHintAction::VOLUME_UP: + case GlobalHintAction::VOLUME_DOWN: { + const int delta = action == GlobalHintAction::VOLUME_UP ? MEDIA_OSD_STEP + : -MEDIA_OSD_STEP; + const int pct = launcher_media_controls::adjust_volume(delta); + launcher_media_osd().show_level( + "Volume", pct == 0 ? LV_SYMBOL_MUTE : LV_SYMBOL_VOLUME_MAX, pct); + return; + } + case GlobalHintAction::TOGGLE_MUTE: + launcher_media_osd().show_mute(launcher_media_controls::toggle_mute()); + return; + case GlobalHintAction::TAKE_SCREENSHOT: { + const std::string scr_dir = launcher_platform::path("home_dir") + "/Screenshots"; + const int ensure_result = ensure_screenshot_dir(scr_dir.c_str()); + int result = -1; + if (GlobalHintScreenshotPolicy::should_save(ensure_result)) + cp0_signal_screenshot_api({"Save", scr_dir}, [&](int code, std::string) { + result = code; + }); + show_hint(GlobalHintScreenshotPolicy::result_message(ensure_result, result)); + return; + } + case GlobalHintAction::SHOW_LOCK_HINT: show_hint("Double-tap to lock"); return; - + case GlobalHintAction::NONE: default: - break; + return; } - - /* Secondary best-effort match for the SYM key: some TCA8418 keymaps - * tag it with sym_name "Multi_key" / "Menu" / "Sym". Match by name - * too so we don't miss it if the raw code differs from our fallbacks. */ - if (elm->sym_name[0]) { - if (strcmp(elm->sym_name, "Multi_key") == 0 || - strcmp(elm->sym_name, "Menu") == 0 || - strcmp(elm->sym_name, "Sym") == 0 || - strcmp(elm->sym_name, "SYM") == 0) { - show_hint("Double-tap to lock"); - } + } catch (...) { } } } // namespace ui_global_hint -extern "C" void ui_global_hint_on_key(const struct key_item *elm) +extern "C" void ui_global_hint_on_key(const struct key_item *elm) noexcept { ui_global_hint::on_key(elm); } diff --git a/projects/APPLaunch/main/ui/ui_global_hint.h b/projects/APPLaunch/main/ui/ui_global_hint.h index c054e46d..9f45772a 100644 --- a/projects/APPLaunch/main/ui/ui_global_hint.h +++ b/projects/APPLaunch/main/ui/ui_global_hint.h @@ -7,9 +7,9 @@ /* * ui_global_hint.h * - * Global on-screen hint/toast overlay for the launcher and any sub-app - * page. Shows a short, transient banner near the top of the active - * screen when specific keys are pressed. + * Global launcher shortcut dispatcher for the launcher and sub-app pages. + * Key classification lives in GlobalHintPolicy; this module executes the + * resulting media, screenshot, and transient hint actions. * * Hooked from the cp0_lvgl keyboard dispatch after LV_EVENT_KEYBOARD * has been sent to the active screen. @@ -22,17 +22,21 @@ struct key_item; #ifdef __cplusplus namespace ui_global_hint { -void on_key(const struct key_item *elm); +void on_key(const struct key_item *elm) noexcept; +void shutdown(); } extern "C" { #endif /* Call on every key_item dequeued from the keyboard queue. - * Decides whether to show a transient toast hint; a no-op for - * keys that don't match the rules. + * Executes a matching global shortcut; a no-op for unrelated keys. */ +#ifdef __cplusplus +void ui_global_hint_on_key(const struct key_item *elm) noexcept; +#else void ui_global_hint_on_key(const struct key_item *elm); +#endif #ifdef __cplusplus } diff --git a/projects/APPLaunch/main/ui/ui_launch_page.cpp b/projects/APPLaunch/main/ui/ui_launch_page.cpp index 249ec3bc..e59e39a2 100644 --- a/projects/APPLaunch/main/ui/ui_launch_page.cpp +++ b/projects/APPLaunch/main/ui/ui_launch_page.cpp @@ -7,20 +7,9 @@ #include "ui_launch_page.h" #include "launch.h" -#include "hal_lvgl_bsp.h" -#include "lvgl/src/widgets/gif/lv_gif.h" -#include "sample_log.h" -#include "launcher_platform.hpp" -#include "compat/input_keys.h" - -#undef SLOGI -#define SLOGI(...) do { } while (0) - -#include -#include -#include #include "animation/ui_launcher_animation.h" +#include "animation/launcher_carousel_layout.h" #include @@ -36,134 +25,18 @@ void UILaunchPage::rotate_carousel_right(size_t start, size_t end) std::rotate(items.begin() + start, items.begin() + end, items.begin() + end + 1); } -// ============================================================ -// switch panel style -// ============================================================ - -void UILaunchPage::switchpanleEnable(int obj_index, int enable) -{ - lv_obj_t *obj = carousel_elements_[obj_index]; - - if (enable) - { - lv_obj_set_width(obj, 10); - lv_obj_set_height(obj, 10); - lv_obj_set_align(obj, LV_ALIGN_CENTER); - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_set_style_bg_color(obj, lv_color_hex(0xCCCC33), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_color(obj, lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(obj, lv_color_hex(0xCCCC33), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } - else - { - lv_obj_set_width(obj, 5); - lv_obj_set_height(obj, 5); - lv_obj_set_align(obj, LV_ALIGN_CENTER); - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_set_style_bg_color(obj, lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_color(obj, lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(obj, lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(obj, 255, LV_PART_MAIN | LV_STATE_DEFAULT); - } -} - - -void UILaunchPage::switchpanleEnableClick(int obj_index, int enable) -{ - lv_obj_t *obj = carousel_elements_[obj_index]; - - if (enable) - { - lv_obj_add_flag(obj, LV_OBJ_FLAG_CLICKABLE); - } - else - { - lv_obj_clear_flag(obj, LV_OBJ_FLAG_CLICKABLE); - } -} - -void UILaunchPage::set_panel_icon(lv_obj_t *panel, const char *src) -{ - if (!panel) - return; - - const char *icon_src = src ? src : ""; - if (icon_src[0] == '\0') { - SLOGW("[LAUNCHER] set panel icon with empty path"); - } else { - bool exists = false; - cp0_signal_filesystem_api({"Exists", icon_src}, [&](int code, std::string data) { - exists = code == 0 && data == "1"; - }); - if (exists) { - SLOGI("[LAUNCHER] set panel icon: %s", icon_src); - } else { - SLOGW("[LAUNCHER] set panel icon missing/unreadable: %s", icon_src); - } - } - - lv_obj_set_style_pad_all(panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_t *img = lv_obj_get_child(panel, 0); - if (!img || !lv_obj_check_type(img, &lv_image_class)) { - img = lv_image_create(panel); - lv_obj_set_size(img, LV_PCT(100), LV_PCT(100)); - lv_obj_set_align(img, LV_ALIGN_CENTER); - lv_image_set_inner_align(img, LV_IMAGE_ALIGN_STRETCH); - } - lv_image_set_src(img, icon_src); -} - - - namespace { UILaunchPage *active_launch_page = nullptr; lv_group_t *home_input_group = nullptr; -constexpr int kStartupSoundRetryMax = 10; -constexpr uint32_t kStartupSoundRetryMs = 500; - -// ==================== standard layout for carousel slots ==================== - -struct CarouselSlot { - lv_coord_t x; - lv_coord_t y; - lv_coord_t width; - lv_coord_t height; - bool hidden; -}; - -static const CarouselSlot CAROUSEL_SLOTS[] = { - {-177, 4, 61, 61, true}, - {-99, -6, 80, 80, false}, - {0, -16, 100, 100, false}, - {99, -6, 80, 80, false}, - {177, 4, 61, 61, true}, - {-177, LABEL_Y_SIDE, 0, 0, true}, - {-99, LABEL_Y_SIDE, 0, 0, false}, - {0, LABEL_Y_CENTER, 0, 0, false}, - {99, LABEL_Y_SIDE, 0, 0, false}, - {177, LABEL_Y_SIDE, 0, 0, true}, -}; - -// ============================================================ -// audio -// ============================================================ - -static void audio_play_switch(void) -{ - cp0_signal_audio_api({"SystemSoundPlay", "1"}, nullptr); -} +using launcher_carousel_layout::Slot; +using launcher_carousel_layout::kSlots; +static_assert(UILaunchPage::kPageDot0 == launcher_carousel_layout::kElementCount); +static_assert(UILaunchPage::kLauncherCarouselElementCount == + launcher_carousel_layout::kElementCount + launcher_carousel_layout::kPanelCount); +static_assert(LauncherNavigationModel::PAGE_COUNT == + UILaunchPage::kPageDot4 - UILaunchPage::kPageDot0 + 1); -static void audio_play_enter(void) -{ - cp0_signal_audio_api({"SystemSoundPlay", "2"}, nullptr); -} // ============================================================ // Force the panel to the specified slot @@ -171,7 +44,7 @@ static void audio_play_enter(void) static void snap_panel_to_slot(lv_obj_t *panel, int slot) { - const CarouselSlot &layout = CAROUSEL_SLOTS[slot]; + const Slot &layout = kSlots[slot]; lv_obj_set_x(panel, layout.x); lv_obj_set_y(panel, layout.y); lv_obj_set_width(panel, layout.width); @@ -194,13 +67,13 @@ static void snap_panel_to_slot(lv_obj_t *panel, int slot) static void snap_label_to_slot(lv_obj_t *label, int slot) { - const CarouselSlot &layout = CAROUSEL_SLOTS[slot]; + const Slot &layout = kSlots[slot]; lv_obj_set_x(label, layout.x); lv_obj_set_y(label, layout.y); // Constrain label width to the matching card width so long names never overflow. // Label slots are 5..9; corresponding card slots are 0..4. - const int card_w = CAROUSEL_SLOTS[slot - 5].width; + const int card_w = kSlots[slot - launcher_carousel_layout::kTitleOffset].width; lv_obj_set_width(label, card_w > 0 ? card_w : LV_SIZE_CONTENT); lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); @@ -215,94 +88,10 @@ static void snap_label_to_slot(lv_obj_t *label, int slot) } } -static uint32_t fzxc_to_arrow(uint32_t key) -{ - switch (key) - { - case KEY_F: - return KEY_UP; - - case KEY_X: - return KEY_DOWN; - - case KEY_Z: - return KEY_LEFT; - - case KEY_C: - return KEY_RIGHT; - - default: - return key; - } -} - -static UILaunchPage *page_from_event(lv_event_t *event) -{ - return event ? static_cast(lv_event_get_user_data(event)) : nullptr; -} } // namespace -LauncherFonts::~LauncherFonts() -{ - release(); -} - -lv_font_t *LauncherFonts::get(const char *ttf_name, uint16_t size, lv_freetype_font_style_t style) -{ - return get(ttf_name, size, style, LV_FREETYPE_FONT_RENDER_MODE_BITMAP); -} - -lv_font_t *LauncherFonts::get_mono(const char *ttf_name, uint16_t size, lv_freetype_font_style_t style) -{ - return get(ttf_name, size, style, LV_FREETYPE_FONT_RENDER_MODE_BITMAP_MONO); -} - -lv_font_t *LauncherFonts::get(const char *ttf_name, uint16_t size, lv_freetype_font_style_t style, - lv_freetype_font_render_mode_t render_mode) -{ - const std::string font_key = key(ttf_name, size, style, render_mode); - auto it = fonts_.find(font_key); - if (it != fonts_.end()) { - return it->second ? it->second : fallback(size); - } - - const std::string font_path = launcher_platform::path(ttf_name); - lv_font_t *font = lv_freetype_font_create(font_path.c_str(), render_mode, size, style); - fonts_[font_key] = font; - return font ? font : fallback(size); -} - -void LauncherFonts::release() -{ - for (auto &item : fonts_) { - if (item.second) { - lv_freetype_font_delete(item.second); - item.second = nullptr; - } - } - fonts_.clear(); -} - -lv_font_t *LauncherFonts::fallback(uint16_t size) const -{ - if (size >= 18) { - return (lv_font_t *)&lv_font_montserrat_20; - } - if (size >= 14) { - return (lv_font_t *)&lv_font_montserrat_14; - } - return (lv_font_t *)&lv_font_montserrat_12; -} - -std::string LauncherFonts::key(const char *ttf_name, uint16_t size, lv_freetype_font_style_t style, - lv_freetype_font_render_mode_t render_mode) -{ - return std::string(ttf_name ? ttf_name : "") + "#" + std::to_string(size) + "#" + - std::to_string(static_cast(style)) + "#" + std::to_string(static_cast(render_mode)); -} - lv_group_t *UILaunchPage::home_input_group() { if (active_launch_page) @@ -334,96 +123,151 @@ void UILaunchPage::init_input_group() bind_home_input_group(); } -void UILaunchPage::show_home_screen() -{ - SLOGI("[HOME] show_home_screen() - loading launcher home screen"); - use_bold_home_title_font(); - lv_disp_load_scr(screen()); - UILaunchPage::bind_home_input_group(); -} - -void UILaunchPage::load_home_screen() -{ - play_startup_sound_with_retry(); - - // Show logo_lcd.png as background during the 1-second sound delay - snprintf(startup_logo_path_.data(), startup_logo_path_.size(), "%s", launcher_platform::path("logo_lcd.png").c_str()); - startup_logo_scr_ = lv_obj_create(nullptr); - lv_obj_set_style_bg_color(startup_logo_scr_, lv_color_hex(0x000000), LV_PART_MAIN); - lv_obj_set_style_bg_opa(startup_logo_scr_, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_t *logo_img = lv_img_create(startup_logo_scr_); - lv_img_set_src(logo_img, startup_logo_path_.data()); - lv_obj_center(logo_img); - lv_disp_load_scr(startup_logo_scr_); - - if (!startup_delay_timer_) { - startup_delay_timer_ = lv_timer_create(startup_delay_timer_cb, 1000, this); - lv_timer_set_repeat_count(startup_delay_timer_, 1); - } -} - -void UILaunchPage::play_startup_sound_with_retry() -{ - int play_result = -1; - cp0_signal_audio_api({"SystemSoundPlay", "0"}, [&](int code, std::string) { - play_result = code; - }); - - if (play_result == 0) { - stop_startup_sound_timer(); - startup_sound_retry_count_ = 0; - return; - } - - if (startup_sound_timer_) - return; - - startup_sound_retry_count_ = 0; - startup_sound_timer_ = lv_timer_create(startup_sound_timer_cb, kStartupSoundRetryMs, this); -} - -void UILaunchPage::stop_startup_sound_timer() -{ - if (startup_sound_timer_) { - lv_timer_delete(startup_sound_timer_); - startup_sound_timer_ = nullptr; - } -} - -void UILaunchPage::start_startup_gif() -{ - snprintf(startup_gif_path_.data(), startup_gif_path_.size(), "%s", launcher_platform::path("logo_output.gif").c_str()); - startup_gif_done_ = false; - startup_gif_ = lv_gif_create(nullptr); - lv_gif_set_src(startup_gif_, startup_gif_path_.data()); - lv_obj_center(startup_gif_); - lv_obj_add_event_cb(startup_gif_, on_startup_gif_event, LV_EVENT_ALL, this); - lv_disp_load_scr(startup_gif_); -} - UILaunchPage::UILaunchPage(Launch *launch) : home_base(), launch_(launch) { active_launch_page = this; + if (root_screen_) + lv_obj_add_event_cb(root_screen_, UILaunchPage::on_root_deleted, + LV_EVENT_DELETE, this); } UILaunchPage::~UILaunchPage() { + *carousel_alive_ = false; + navigation_.cancel_navigation(); + if (carousel_container_) { + lv_obj_remove_event_cb_with_user_data( + carousel_container_, UILaunchPage::on_owned_obj_deleted, this); + for (lv_obj_t *element : carousel_elements_) { + if (element) + lv_obj_remove_event_cb_with_user_data( + element, UILaunchPage::on_owned_obj_deleted, this); + } + if (left_arrow_button_) + lv_obj_remove_event_cb_with_user_data( + left_arrow_button_, UILaunchPage::on_owned_obj_deleted, this); + if (right_arrow_button_) + lv_obj_remove_event_cb_with_user_data( + right_arrow_button_, UILaunchPage::on_owned_obj_deleted, this); + lv_obj_delete(carousel_container_); + carousel_container_ = nullptr; + carousel_elements_.fill(nullptr); + left_arrow_button_ = nullptr; + right_arrow_button_ = nullptr; + } + if (home_key_registered_ && root_screen_) { + lv_obj_remove_event_cb_with_user_data( + root_screen_, UILaunchPage::on_home_key, this); + home_key_registered_ = false; + } + if (root_screen_) + lv_obj_remove_event_cb_with_user_data( + root_screen_, UILaunchPage::on_root_deleted, this); + stop_startup_delay(); stop_startup_sound_timer(); - if (startup_delay_timer_) { - lv_timer_delete(startup_delay_timer_); - startup_delay_timer_ = nullptr; - } - if (startup_logo_scr_) { - lv_obj_del(startup_logo_scr_); - startup_logo_scr_ = nullptr; - } - if (green_bg_) { - lv_obj_del(green_bg_); - green_bg_ = nullptr; - } + release_startup_screen(startup_logo_scr_); + release_startup_screen(startup_gif_); if (active_launch_page == this) active_launch_page = nullptr; + if (::home_input_group == input_group()) + ::home_input_group = nullptr; +} + +void UILaunchPage::release_startup_screen(lv_obj_t *&screen) +{ + if (!screen) return; + + lv_display_t *display = lv_display_get_default(); + auto display_references = [display](lv_obj_t *candidate) { + return display && candidate && + (lv_display_get_screen_active(display) == candidate || + lv_display_get_screen_loading(display) == candidate || + lv_display_get_screen_prev(display) == candidate); + }; + auto detach_from_page = [this](lv_obj_t *object) { + lv_obj_remove_event_cb_with_user_data( + object, UILaunchPage::on_owned_obj_deleted, this); + lv_obj_remove_event_cb_with_user_data( + object, UILaunchPage::on_startup_gif_event, this); + }; + + const bool display_references_screen = display_references(screen); + if (display_references_screen) { + if (root_screen_ && lv_display_get_screen_active(display) != root_screen_) { + lv_screen_load(root_screen_); + } else { + detach_from_page(screen); + screen = nullptr; + return; + } + if (display_references(screen)) { + detach_from_page(screen); + screen = nullptr; + return; + } + } + + if (screen) lv_obj_delete(screen); + screen = nullptr; +} + +void UILaunchPage::on_root_deleted(lv_event_t *event) noexcept +{ + try { + auto *self = static_cast(lv_event_get_user_data(event)); + if (!self || lv_event_get_target(event) != lv_event_get_current_target(event) || + lv_event_get_target(event) != self->root_screen_) + return; + self->stop_startup_delay(); + self->stop_startup_sound_timer(); + *self->carousel_alive_ = false; + self->carousel_elements_.fill(nullptr); + self->carousel_container_ = nullptr; + self->left_arrow_button_ = nullptr; + self->right_arrow_button_ = nullptr; + self->home_key_registered_ = false; + self->navigation_.cancel_navigation(); + } catch (...) { + } +} + +void UILaunchPage::on_owned_obj_deleted(lv_event_t *event) noexcept +{ + try { + auto *self = static_cast(lv_event_get_user_data(event)); + lv_obj_t *target = static_cast(lv_event_get_target(event)); + if (!self || !target || target != lv_event_get_current_target(event)) return; + const bool recover_gif = launcher_startup_should_recover_deleted_gif( + self->startup_gif_ == target, self->startup_gif_done_); + if (self->startup_logo_scr_ == target) self->startup_logo_scr_ = nullptr; + if (self->startup_gif_ == target) self->startup_gif_ = nullptr; + bool carousel_object_deleted = self->carousel_container_ == target || + self->left_arrow_button_ == target || self->right_arrow_button_ == target; + for (lv_obj_t *&element : self->carousel_elements_) { + if (element == target) { + element = nullptr; + carousel_object_deleted = true; + } + } + if (self->left_arrow_button_ == target) self->left_arrow_button_ = nullptr; + if (self->right_arrow_button_ == target) self->right_arrow_button_ = nullptr; + if (self->carousel_container_ == target) { + self->carousel_container_ = nullptr; + self->carousel_elements_.fill(nullptr); + self->left_arrow_button_ = nullptr; + self->right_arrow_button_ = nullptr; + } + if (carousel_object_deleted) { + *self->carousel_alive_ = false; + self->cancel_switch_animation(); + } + if (recover_gif) { + self->startup_gif_done_ = true; + self->load_home_screen(); + } + } catch (...) { + } } void UILaunchPage::fill_right_entering_slot(lv_obj_t *panel, lv_obj_t *label) @@ -451,24 +295,12 @@ void UILaunchPage::refresh_carousel() if (!launch_) return; - for (size_t slot = 0; slot < 5; ++slot) { + for (size_t slot = 0; slot < launcher_carousel_layout::kPanelCount; ++slot) { if (const app *item = launch_->carousel_slot_app(slot)) update_carousel_slot(slot, item->Name.c_str(), item->Icon.c_str()); } } -void UILaunchPage::update_carousel_slot(size_t slot, const char *title, const char *icon) -{ - update_carousel_item(panel(slot), label(slot), title, icon); -} - -void UILaunchPage::update_carousel_item(lv_obj_t *panel, lv_obj_t *label, const char *title, const char *icon) -{ - if (label) - lv_label_set_text(label, title ? title : ""); - set_panel_icon(panel, icon); -} - void UILaunchPage::launch_selected_app() { if (launch_) @@ -477,39 +309,71 @@ void UILaunchPage::launch_selected_app() void UILaunchPage::finish_switch_animation() { - for (int i = 0; i < 5; i++) + if (std::any_of(carousel_elements_.begin(), carousel_elements_.begin() + + launcher_carousel_layout::kElementCount, + [](lv_obj_t *element) { return element == nullptr; })) { + cancel_switch_animation(); + return; + } + for (size_t i = 0; i < launcher_carousel_layout::kPanelCount; ++i) { snap_panel_to_slot(carousel_elements_[i], i); } - for (int i = 5; i < 10; i++) + for (size_t i = launcher_carousel_layout::kTitleOffset; + i < launcher_carousel_layout::kElementCount; ++i) { snap_label_to_slot(carousel_elements_[i], i); } - is_animating_ = false; + navigation_.finish_navigation(); - for (int i = 0; i < 5; i++) { - uint32_t color = (i == 2) ? BORDER_COLOR_CENTER : BORDER_COLOR_SIDE; + for (size_t i = 0; i < launcher_carousel_layout::kPanelCount; ++i) { + uint32_t color = (i == kCardCenter) ? BORDER_COLOR_CENTER : BORDER_COLOR_SIDE; lv_obj_set_style_border_color(carousel_elements_[i], lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); } - for (int i = 5; i < 10; i++) { + for (size_t i = launcher_carousel_layout::kTitleOffset; + i < launcher_carousel_layout::kElementCount; ++i) { lv_obj_set_style_text_font(carousel_elements_[i], launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN | LV_STATE_DEFAULT); } } +void UILaunchPage::cancel_switch_animation() +{ + if (!navigation_.cancel_navigation()) return; + if (launch_) { + if (active_navigation_moves_next_) + launch_->select_previous_app(); + else + launch_->select_next_app(); + } +} + void UILaunchPage::switch_right() { - if (is_animating_) + if (std::any_of(carousel_elements_.begin(), carousel_elements_.begin() + + launcher_carousel_layout::kElementCount, + [](lv_obj_t *element) { return element == nullptr; })) return; + const size_t previous_page = navigation_.selected_page(); + if (!navigation_.begin_navigation(LauncherNavigationDirection::PREVIOUS)) return; - - is_animating_ = true; + active_navigation_moves_next_ = false; lv_obj_clear_flag(carousel_elements_[0], LV_OBJ_FLAG_HIDDEN); - launcher_home_animation::animate_right(carousel_elements_.data(), [this]() { finish_switch_animation(); }); + std::weak_ptr alive = carousel_alive_; + launcher_home_animation::animate_right( + carousel_elements_.data(), + [this, alive]() { + auto state = alive.lock(); + if (state && *state) finish_switch_animation(); + }, + [alive]() { + auto state = alive.lock(); + return state && *state; + }); snap_panel_to_slot(carousel_elements_[4], 0); @@ -519,29 +383,39 @@ void UILaunchPage::switch_right() fill_left_entering_slot(carousel_elements_[4], carousel_elements_[9]); - switchpanleEnableClick(2, 0); + set_carousel_element_clickable(kCardCenter, false); rotate_carousel_right(0, 4); - switchpanleEnableClick(2, 1); + set_carousel_element_clickable(kCardCenter, true); rotate_carousel_right(5, 9); - switchpanleEnable(switch_current_pos_, 0); - - switch_current_pos_ = switch_current_pos_ == UILaunchPage::kPageDot0 ? UILaunchPage::kPageDot4 : switch_current_pos_ - 1; - - switchpanleEnable(switch_current_pos_, 1); + set_page_dot_selected(kPageDot0 + previous_page, false); + set_page_dot_selected(kPageDot0 + navigation_.selected_page(), true); } void UILaunchPage::switch_left() { - if (is_animating_) + if (std::any_of(carousel_elements_.begin(), carousel_elements_.begin() + + launcher_carousel_layout::kElementCount, + [](lv_obj_t *element) { return element == nullptr; })) return; + const size_t previous_page = navigation_.selected_page(); + if (!navigation_.begin_navigation(LauncherNavigationDirection::NEXT)) return; - - is_animating_ = true; + active_navigation_moves_next_ = true; lv_obj_clear_flag(carousel_elements_[4], LV_OBJ_FLAG_HIDDEN); - launcher_home_animation::animate_left(carousel_elements_.data(), [this]() { finish_switch_animation(); }); + std::weak_ptr alive = carousel_alive_; + launcher_home_animation::animate_left( + carousel_elements_.data(), + [this, alive]() { + auto state = alive.lock(); + if (state && *state) finish_switch_animation(); + }, + [alive]() { + auto state = alive.lock(); + return state && *state; + }); snap_panel_to_slot(carousel_elements_[0], 4); @@ -551,433 +425,12 @@ void UILaunchPage::switch_left() fill_right_entering_slot(carousel_elements_[0], carousel_elements_[5]); - switchpanleEnableClick(2, 0); + set_carousel_element_clickable(kCardCenter, false); rotate_carousel_left(0, 4); - switchpanleEnableClick(2, 1); + set_carousel_element_clickable(kCardCenter, true); rotate_carousel_left(5, 9); - switchpanleEnable(switch_current_pos_, 0); - - switch_current_pos_ = switch_current_pos_ == UILaunchPage::kPageDot4 ? UILaunchPage::kPageDot0 : switch_current_pos_ + 1; - - switchpanleEnable(switch_current_pos_, 1); -} - -void UILaunchPage::handle_home_key(lv_event_t *event) -{ - if (!event) - return; - - struct key_item *elm = static_cast(lv_event_get_param(event)); - if (!elm) - return; - - uint32_t code = fzxc_to_arrow(elm->key_code); - - SLOGI("[LAUNCHER] main_key_switch raw=%u->code=%u state=%s sym=%s", - elm->key_code, - code, - kbd_state_name(elm->key_state), - elm->sym_name); - - if (elm->key_state) - { - switch (code) - { - case KEY_UP: - break; - - case KEY_DOWN: - break; - - case KEY_LEFT: - { - if (!lvping_lock_ && !is_animating_) - { - audio_play_switch(); - switch_right(); - } - } - break; - - case KEY_RIGHT: - { - if (!lvping_lock_ && !is_animating_) - { - audio_play_switch(); - switch_left(); - } - } - break; - - default: - break; - } - } - else if (code == KEY_ENTER) - { - audio_play_enter(); - launch_selected_app(); - } - else if (code == KEY_F12) - { - if (lvping_lock_ == 0) - { - lvping_lock_ = 1; - green_bg_ = lv_obj_create(lv_scr_act()); - lv_obj_set_size(green_bg_, 320, 170); - lv_obj_align(green_bg_, LV_ALIGN_TOP_LEFT, 0, 0); - - lv_obj_set_style_bg_color(green_bg_, lv_color_hex(0x00FF00), LV_PART_MAIN); - lv_obj_set_style_bg_opa(green_bg_, LV_OPA_COVER, LV_PART_MAIN); - - lv_obj_set_style_border_width(green_bg_, 0, LV_PART_MAIN); - lv_obj_set_style_radius(green_bg_, 0, LV_PART_MAIN); - lv_obj_set_style_shadow_width(green_bg_, 0, LV_PART_MAIN); - lv_obj_set_style_pad_all(green_bg_, 0, LV_PART_MAIN); - } - else - { - lvping_lock_ = 0; - if (green_bg_) { - lv_obj_del(green_bg_); - green_bg_ = nullptr; - } - } - } -} - -void UILaunchPage::handle_startup_gif_event(lv_event_t *event) -{ - if (!event || lv_event_get_code(event) != LV_EVENT_READY || startup_gif_done_) - return; - - startup_gif_done_ = true; - SLOGI("[GIF] first LV_EVENT_READY -> pause + home_screen_load()"); - if (startup_gif_) - lv_gif_pause(startup_gif_); - - load_home_screen(); -} - -void UILaunchPage::on_left_arrow_clicked(lv_event_t *event) -{ - if (UILaunchPage *self = page_from_event(event)) - self->switch_right(); -} - -void UILaunchPage::on_right_arrow_clicked(lv_event_t *event) -{ - if (UILaunchPage *self = page_from_event(event)) - self->switch_left(); -} - -void UILaunchPage::on_app_clicked(lv_event_t *event) -{ - if (UILaunchPage *self = page_from_event(event)) - self->launch_selected_app(); -} - -void UILaunchPage::on_home_key(lv_event_t *event) -{ - if (UILaunchPage *self = page_from_event(event)) - self->handle_home_key(event); -} - -void UILaunchPage::on_startup_gif_event(lv_event_t *event) -{ - if (UILaunchPage *self = page_from_event(event)) - self->handle_startup_gif_event(event); -} - -void UILaunchPage::startup_sound_timer_cb(lv_timer_t *timer) -{ - UILaunchPage *self = static_cast(lv_timer_get_user_data(timer)); - if (!self) - return; - - ++self->startup_sound_retry_count_; - if (self->startup_sound_retry_count_ > kStartupSoundRetryMax) { - self->stop_startup_sound_timer(); - return; - } - - self->play_startup_sound_with_retry(); -} - -void UILaunchPage::startup_delay_timer_cb(lv_timer_t *timer) -{ - UILaunchPage *self = static_cast(lv_timer_get_user_data(timer)); - if (!self) - return; - - self->startup_delay_timer_ = nullptr; - self->show_home_screen(); - - if (self->startup_logo_scr_) { - lv_obj_del(self->startup_logo_scr_); - self->startup_logo_scr_ = nullptr; - } -} - -void UILaunchPage::create_screen() -{ - if (carousel_elements_[kCardCenter]) - return; - - create_app_container(content_container()); - -} - - -void UILaunchPage::create_app_container(lv_obj_t *parent) -{ - lv_obj_t *app_container = parent; - if (!app_container) - return; - - lv_obj_set_size(app_container, 320, 150); - lv_obj_clear_flag(app_container, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - - carousel_elements_[kPageDot0] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kPageDot0], 5); - lv_obj_set_height(carousel_elements_[kPageDot0], 5); - lv_obj_set_x(carousel_elements_[kPageDot0], -20); - lv_obj_set_y(carousel_elements_[kPageDot0], 70); - lv_obj_set_align(carousel_elements_[kPageDot0], LV_ALIGN_CENTER); - lv_obj_clear_flag(carousel_elements_[kPageDot0], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_color(carousel_elements_[kPageDot0], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kPageDot0], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_color(carousel_elements_[kPageDot0], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kPageDot0], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kPageDot0], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kPageDot1] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kPageDot1], 5); - lv_obj_set_height(carousel_elements_[kPageDot1], 5); - lv_obj_set_x(carousel_elements_[kPageDot1], -10); - lv_obj_set_y(carousel_elements_[kPageDot1], 70); - lv_obj_set_align(carousel_elements_[kPageDot1], LV_ALIGN_CENTER); - lv_obj_clear_flag(carousel_elements_[kPageDot1], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_color(carousel_elements_[kPageDot1], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kPageDot1], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_color(carousel_elements_[kPageDot1], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kPageDot1], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kPageDot1], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kPageDot2] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kPageDot2], 10); - lv_obj_set_height(carousel_elements_[kPageDot2], 10); - lv_obj_set_x(carousel_elements_[kPageDot2], 0); - lv_obj_set_y(carousel_elements_[kPageDot2], 70); - lv_obj_set_align(carousel_elements_[kPageDot2], LV_ALIGN_CENTER); - lv_obj_clear_flag(carousel_elements_[kPageDot2], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_color(carousel_elements_[kPageDot2], lv_color_hex(0xCCCC33), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kPageDot2], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_color(carousel_elements_[kPageDot2], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kPageDot2], lv_color_hex(0xCCCC33), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kPageDot2], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kPageDot3] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kPageDot3], 5); - lv_obj_set_height(carousel_elements_[kPageDot3], 5); - lv_obj_set_x(carousel_elements_[kPageDot3], 10); - lv_obj_set_y(carousel_elements_[kPageDot3], 70); - lv_obj_set_align(carousel_elements_[kPageDot3], LV_ALIGN_CENTER); - lv_obj_clear_flag(carousel_elements_[kPageDot3], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_color(carousel_elements_[kPageDot3], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kPageDot3], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_color(carousel_elements_[kPageDot3], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kPageDot3], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kPageDot3], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kPageDot4] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kPageDot4], 5); - lv_obj_set_height(carousel_elements_[kPageDot4], 5); - lv_obj_set_x(carousel_elements_[kPageDot4], 20); - lv_obj_set_y(carousel_elements_[kPageDot4], 70); - lv_obj_set_align(carousel_elements_[kPageDot4], LV_ALIGN_CENTER); - lv_obj_clear_flag(carousel_elements_[kPageDot4], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_color(carousel_elements_[kPageDot4], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kPageDot4], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_color(carousel_elements_[kPageDot4], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kPageDot4], lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kPageDot4], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kTitleCenter] = lv_label_create(app_container); - lv_obj_set_width(carousel_elements_[kTitleCenter], 100); - lv_obj_set_height(carousel_elements_[kTitleCenter], LV_SIZE_CONTENT); /// 1 - lv_obj_set_x(carousel_elements_[kTitleCenter], 0); - lv_obj_set_y(carousel_elements_[kTitleCenter], LABEL_Y_CENTER); - lv_obj_set_align(carousel_elements_[kTitleCenter], LV_ALIGN_CENTER); - lv_label_set_long_mode(carousel_elements_[kTitleCenter], LV_LABEL_LONG_SCROLL_CIRCULAR); - lv_obj_set_style_text_align(carousel_elements_[kTitleCenter], LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text(carousel_elements_[kTitleCenter], "CLI"); - lv_obj_set_style_text_font(carousel_elements_[kTitleCenter], launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(carousel_elements_[kTitleCenter], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(carousel_elements_[kTitleCenter], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kTitleRight] = lv_label_create(app_container); - lv_obj_set_width(carousel_elements_[kTitleRight], 80); - lv_obj_set_height(carousel_elements_[kTitleRight], LV_SIZE_CONTENT); /// 1 - lv_obj_set_x(carousel_elements_[kTitleRight], 99); - lv_obj_set_y(carousel_elements_[kTitleRight], LABEL_Y_SIDE); - lv_obj_set_align(carousel_elements_[kTitleRight], LV_ALIGN_CENTER); - lv_label_set_long_mode(carousel_elements_[kTitleRight], LV_LABEL_LONG_SCROLL_CIRCULAR); - lv_obj_set_style_text_align(carousel_elements_[kTitleRight], LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text(carousel_elements_[kTitleRight], "GAME"); - lv_obj_set_style_text_color(carousel_elements_[kTitleRight], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(carousel_elements_[kTitleRight], launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(carousel_elements_[kTitleRight], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kTitleLeft] = lv_label_create(app_container); - lv_obj_set_width(carousel_elements_[kTitleLeft], 80); - lv_obj_set_height(carousel_elements_[kTitleLeft], LV_SIZE_CONTENT); /// 1 - lv_obj_set_x(carousel_elements_[kTitleLeft], -99); - lv_obj_set_y(carousel_elements_[kTitleLeft], LABEL_Y_SIDE); - lv_obj_set_align(carousel_elements_[kTitleLeft], LV_ALIGN_CENTER); - lv_label_set_long_mode(carousel_elements_[kTitleLeft], LV_LABEL_LONG_SCROLL_CIRCULAR); - lv_obj_set_style_text_align(carousel_elements_[kTitleLeft], LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text(carousel_elements_[kTitleLeft], "STORE"); - lv_obj_set_style_text_color(carousel_elements_[kTitleLeft], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(carousel_elements_[kTitleLeft], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(carousel_elements_[kTitleLeft], launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kCardLeft] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kCardLeft], 80); - lv_obj_set_height(carousel_elements_[kCardLeft], 80); - lv_obj_set_x(carousel_elements_[kCardLeft], -99); - lv_obj_set_y(carousel_elements_[kCardLeft], -6); - lv_obj_set_align(carousel_elements_[kCardLeft], LV_ALIGN_CENTER); - lv_obj_clear_flag(carousel_elements_[kCardLeft], (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); /// Flags - lv_obj_set_style_radius(carousel_elements_[kCardLeft], 17, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(carousel_elements_[kCardLeft], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kCardLeft], 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kCardLeft], lv_color_hex(0x222222), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kCardLeft], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kCardCenter] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kCardCenter], 100); - lv_obj_set_height(carousel_elements_[kCardCenter], 100); - lv_obj_set_x(carousel_elements_[kCardCenter], 0); - lv_obj_set_y(carousel_elements_[kCardCenter], -16); - lv_obj_set_align(carousel_elements_[kCardCenter], LV_ALIGN_CENTER); - lv_obj_clear_flag(carousel_elements_[kCardCenter], LV_OBJ_FLAG_SCROLLABLE); /// Flags - lv_obj_set_style_radius(carousel_elements_[kCardCenter], 22, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(carousel_elements_[kCardCenter], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kCardCenter], 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kCardCenter], lv_color_hex(0x444444), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kCardCenter], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(carousel_elements_[kCardCenter], 2, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kCardRight] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kCardRight], 80); - lv_obj_set_height(carousel_elements_[kCardRight], 80); - lv_obj_set_x(carousel_elements_[kCardRight], 99); - lv_obj_set_y(carousel_elements_[kCardRight], -6); - lv_obj_set_align(carousel_elements_[kCardRight], LV_ALIGN_CENTER); - lv_obj_clear_flag(carousel_elements_[kCardRight], (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); /// Flags - lv_obj_set_style_radius(carousel_elements_[kCardRight], 17, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(carousel_elements_[kCardRight], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kCardRight], 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kCardRight], lv_color_hex(0x222222), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kCardRight], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kCardFarRight] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kCardFarRight], 61); - lv_obj_set_height(carousel_elements_[kCardFarRight], 61); - lv_obj_set_x(carousel_elements_[kCardFarRight], 177); - lv_obj_set_y(carousel_elements_[kCardFarRight], 4); - lv_obj_set_align(carousel_elements_[kCardFarRight], LV_ALIGN_CENTER); - lv_obj_add_flag(carousel_elements_[kCardFarRight], LV_OBJ_FLAG_HIDDEN); /// Flags - lv_obj_clear_flag(carousel_elements_[kCardFarRight], (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); /// Flags - lv_obj_set_style_radius(carousel_elements_[kCardFarRight], 17, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(carousel_elements_[kCardFarRight], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kCardFarRight], 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kCardFarRight], lv_color_hex(0x333333), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kCardFarRight], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - left_arrow_button_ = lv_btn_create(app_container); - lv_obj_set_width(left_arrow_button_, 17); - lv_obj_set_height(left_arrow_button_, 23); - lv_obj_set_x(left_arrow_button_, -151); - lv_obj_set_y(left_arrow_button_, -4); - lv_obj_set_align(left_arrow_button_, LV_ALIGN_CENTER); - lv_obj_add_flag(left_arrow_button_, LV_OBJ_FLAG_SCROLL_ON_FOCUS); /// Flags - lv_obj_clear_flag(left_arrow_button_, LV_OBJ_FLAG_SCROLLABLE); /// Flags - lv_obj_set_style_radius(left_arrow_button_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(left_arrow_button_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(left_arrow_button_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_img_src(left_arrow_button_, launcher_platform::path_c("carousel_left_arrow.png"), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_shadow_color(left_arrow_button_, lv_color_hex(0x000000), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_shadow_opa(left_arrow_button_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - - right_arrow_button_ = lv_btn_create(app_container); - lv_obj_set_width(right_arrow_button_, 17); - lv_obj_set_height(right_arrow_button_, 23); - lv_obj_set_x(right_arrow_button_, 150); - lv_obj_set_y(right_arrow_button_, -4); - lv_obj_set_align(right_arrow_button_, LV_ALIGN_CENTER); - lv_obj_add_flag(right_arrow_button_, LV_OBJ_FLAG_SCROLL_ON_FOCUS); /// Flags - lv_obj_clear_flag(right_arrow_button_, LV_OBJ_FLAG_SCROLLABLE); /// Flags - lv_obj_set_style_radius(right_arrow_button_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(right_arrow_button_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(right_arrow_button_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_img_src(right_arrow_button_, launcher_platform::path_c("carousel_right_arrow.png"), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_shadow_color(right_arrow_button_, lv_color_hex(0x000000), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_shadow_opa(right_arrow_button_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kCardFarLeft] = lv_obj_create(app_container); - lv_obj_set_width(carousel_elements_[kCardFarLeft], 61); - lv_obj_set_height(carousel_elements_[kCardFarLeft], 61); - lv_obj_set_x(carousel_elements_[kCardFarLeft], -177); - lv_obj_set_y(carousel_elements_[kCardFarLeft], 4); - lv_obj_set_align(carousel_elements_[kCardFarLeft], LV_ALIGN_CENTER); - lv_obj_add_flag(carousel_elements_[kCardFarLeft], LV_OBJ_FLAG_HIDDEN); /// Flags - lv_obj_clear_flag(carousel_elements_[kCardFarLeft], (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); /// Flags - lv_obj_set_style_radius(carousel_elements_[kCardFarLeft], 17, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(carousel_elements_[kCardFarLeft], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(carousel_elements_[kCardFarLeft], 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(carousel_elements_[kCardFarLeft], lv_color_hex(0x333333), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_opa(carousel_elements_[kCardFarLeft], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kTitleFarLeft] = lv_label_create(app_container); - lv_obj_set_width(carousel_elements_[kTitleFarLeft], 61); - lv_obj_set_height(carousel_elements_[kTitleFarLeft], LV_SIZE_CONTENT); /// 1 - lv_obj_set_x(carousel_elements_[kTitleFarLeft], -177); - lv_obj_set_y(carousel_elements_[kTitleFarLeft], LABEL_Y_SIDE); - lv_obj_set_align(carousel_elements_[kTitleFarLeft], LV_ALIGN_CENTER); - lv_label_set_long_mode(carousel_elements_[kTitleFarLeft], LV_LABEL_LONG_SCROLL_CIRCULAR); - lv_obj_set_style_text_align(carousel_elements_[kTitleFarLeft], LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text(carousel_elements_[kTitleFarLeft], "one"); - lv_obj_add_flag(carousel_elements_[kTitleFarLeft], LV_OBJ_FLAG_HIDDEN); /// Flags - lv_obj_set_style_text_font(carousel_elements_[kTitleFarLeft], launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(carousel_elements_[kTitleFarLeft], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(carousel_elements_[kTitleFarLeft], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - carousel_elements_[kTitleFarRight] = lv_label_create(app_container); - lv_obj_set_width(carousel_elements_[kTitleFarRight], 61); - lv_obj_set_height(carousel_elements_[kTitleFarRight], LV_SIZE_CONTENT); /// 1 - lv_obj_set_x(carousel_elements_[kTitleFarRight], 177); - lv_obj_set_y(carousel_elements_[kTitleFarRight], LABEL_Y_SIDE); - lv_obj_set_align(carousel_elements_[kTitleFarRight], LV_ALIGN_CENTER); - lv_label_set_long_mode(carousel_elements_[kTitleFarRight], LV_LABEL_LONG_SCROLL_CIRCULAR); - lv_obj_set_style_text_align(carousel_elements_[kTitleFarRight], LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text(carousel_elements_[kTitleFarRight], "three"); - lv_obj_add_flag(carousel_elements_[kTitleFarRight], LV_OBJ_FLAG_HIDDEN); /// Flags - lv_obj_set_style_text_font(carousel_elements_[kTitleFarRight], launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(carousel_elements_[kTitleFarRight], lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(carousel_elements_[kTitleFarRight], 255, LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_add_event_cb(carousel_elements_[kCardLeft], on_app_clicked, LV_EVENT_CLICKED, this); - lv_obj_add_event_cb(carousel_elements_[kCardCenter], on_app_clicked, LV_EVENT_CLICKED, this); - lv_obj_add_event_cb(carousel_elements_[kCardRight], on_app_clicked, LV_EVENT_CLICKED, this); - lv_obj_add_event_cb(carousel_elements_[kCardFarRight], on_app_clicked, LV_EVENT_CLICKED, this); - lv_obj_add_event_cb(left_arrow_button_, on_left_arrow_clicked, LV_EVENT_CLICKED, this); - lv_obj_add_event_cb(right_arrow_button_, on_right_arrow_clicked, LV_EVENT_CLICKED, this); - lv_obj_add_event_cb(carousel_elements_[kCardFarLeft], on_app_clicked, LV_EVENT_CLICKED, this); - lv_obj_add_event_cb(screen(), on_home_key, (lv_event_code_t)LV_EVENT_KEYBOARD, this); - - + set_page_dot_selected(kPageDot0 + previous_page, false); + set_page_dot_selected(kPageDot0 + navigation_.selected_page(), true); } diff --git a/projects/APPLaunch/main/ui/ui_launch_page.h b/projects/APPLaunch/main/ui/ui_launch_page.h index d9fde1cc..cbc9f1b8 100644 --- a/projects/APPLaunch/main/ui/ui_launch_page.h +++ b/projects/APPLaunch/main/ui/ui_launch_page.h @@ -6,7 +6,9 @@ #pragma once -#include "ui_app_page.hpp" +#include "launcher_ui_app_page.hpp" +#include "model/launcher_navigation_model.hpp" +#include "model/launcher_startup_contract.hpp" #include #include @@ -56,43 +58,61 @@ class UILaunchPage : public home_base std::array carousel_elements_ = {}; private: + struct StartupTimerContext + { + UILaunchPage *page = nullptr; + LauncherStartupGeneration::Token generation = 0; + }; + void create_app_container(lv_obj_t *parent); void switch_left(); void switch_right(); void fill_left_entering_slot(lv_obj_t *panel, lv_obj_t *label); void fill_right_entering_slot(lv_obj_t *panel, lv_obj_t *label); void finish_switch_animation(); + void cancel_switch_animation(); void handle_home_key(lv_event_t *event); void handle_startup_gif_event(lv_event_t *event); void play_startup_sound_with_retry(); void stop_startup_sound_timer(); + void stop_startup_delay(); + void finish_startup_delay(); + void release_startup_screen(lv_obj_t *&screen); void rotate_carousel_left(size_t start, size_t end); void rotate_carousel_right(size_t start, size_t end); - void switchpanleEnable(int obj_index, int enable); - void switchpanleEnableClick(int obj_index, int enable); + void set_page_dot_selected(size_t element, bool selected); + void set_carousel_element_clickable(size_t element, bool clickable); static void set_panel_icon(lv_obj_t *panel, const char *src); - static void on_left_arrow_clicked(lv_event_t *event); - static void on_right_arrow_clicked(lv_event_t *event); - static void on_app_clicked(lv_event_t *event); - static void on_home_key(lv_event_t *event); - static void on_startup_gif_event(lv_event_t *event); - static void startup_sound_timer_cb(lv_timer_t *timer); - static void startup_delay_timer_cb(lv_timer_t *timer); + static void on_left_arrow_clicked(lv_event_t *event) noexcept; + static void on_right_arrow_clicked(lv_event_t *event) noexcept; + static void on_app_clicked(lv_event_t *event) noexcept; + static void on_home_key(lv_event_t *event) noexcept; + static void on_startup_gif_event(lv_event_t *event) noexcept; + static void on_owned_obj_deleted(lv_event_t *event) noexcept; + static void on_root_deleted(lv_event_t *event) noexcept; + static void startup_sound_timer_cb(lv_timer_t *timer) noexcept; + static void startup_delay_timer_cb(lv_timer_t *timer) noexcept; Launch *launch_ = nullptr; lv_obj_t *startup_gif_ = nullptr; lv_obj_t *left_arrow_button_ = nullptr; lv_obj_t *right_arrow_button_ = nullptr; - lv_obj_t *green_bg_ = nullptr; + lv_obj_t *carousel_container_ = nullptr; lv_timer_t *startup_sound_timer_ = nullptr; lv_timer_t *startup_delay_timer_ = nullptr; + StartupTimerContext *startup_sound_context_ = nullptr; + StartupTimerContext *startup_delay_context_ = nullptr; lv_obj_t *startup_logo_scr_ = nullptr; std::array startup_gif_path_ = {}; std::array startup_logo_path_ = {}; - bool is_animating_ = false; bool startup_gif_done_ = false; int startup_sound_retry_count_ = 0; - int lvping_lock_ = 0; - int switch_current_pos_ = kPageDot2; + LauncherNavigationModel navigation_; + LauncherStartupDelayModel startup_delay_model_; + LauncherStartupGeneration startup_sound_generation_; + LauncherStartupGeneration startup_delay_generation_; + std::shared_ptr carousel_alive_ = std::make_shared(true); + bool home_key_registered_ = false; + bool active_navigation_moves_next_ = false; }; diff --git a/projects/APPLaunch/main/ui/ui_launch_page_carousel_view.cpp b/projects/APPLaunch/main/ui/ui_launch_page_carousel_view.cpp new file mode 100644 index 00000000..2a646292 --- /dev/null +++ b/projects/APPLaunch/main/ui/ui_launch_page_carousel_view.cpp @@ -0,0 +1,253 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "ui_launch_page.h" + +#include "animation/launcher_carousel_layout.h" +#include "launcher_platform.hpp" +#include "sample_log.h" + +#include +#include + +namespace { + +using launcher_carousel_layout::Slot; +using launcher_carousel_layout::kSlots; + +static_assert(UILaunchPage::kPageDot0 == launcher_carousel_layout::kElementCount); +static_assert(UILaunchPage::kLauncherCarouselElementCount == + launcher_carousel_layout::kElementCount + launcher_carousel_layout::kPanelCount); + +lv_obj_t *create_page_dot(lv_obj_t *parent, lv_coord_t x, bool selected) +{ + lv_obj_t *dot = lv_obj_create(parent); + if (!dot) return nullptr; + const lv_coord_t size = selected ? 10 : 5; + const lv_color_t color = lv_color_hex(selected ? 0xCCCC33 : 0x4A4C4A); + lv_obj_set_size(dot, size, size); + lv_obj_set_pos(dot, x, 70); + lv_obj_set_align(dot, LV_ALIGN_CENTER); + lv_obj_clear_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(dot, color, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_grad_color(dot, lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(dot, color, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_opa(dot, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + return dot; +} + +lv_obj_t *create_carousel_card(lv_obj_t *parent, const Slot &slot, + lv_coord_t radius, uint32_t border_color, + bool clickable) +{ + lv_obj_t *card = lv_obj_create(parent); + if (!card) return nullptr; + lv_obj_set_size(card, slot.width, slot.height); + lv_obj_set_pos(card, slot.x, slot.y); + lv_obj_set_align(card, LV_ALIGN_CENTER); + lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); + if (!clickable) + lv_obj_clear_flag(card, LV_OBJ_FLAG_CLICKABLE); + if (slot.hidden) + lv_obj_add_flag(card, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_radius(card, radius, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(card, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(card, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(card, lv_color_hex(border_color), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_opa(card, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + return card; +} + +lv_obj_t *create_carousel_title(lv_obj_t *parent, const Slot &slot, + lv_coord_t width, const char *text) +{ + lv_obj_t *title = lv_label_create(parent); + if (!title) return nullptr; + lv_obj_set_size(title, width, LV_SIZE_CONTENT); + lv_obj_set_pos(title, slot.x, slot.y); + lv_obj_set_align(title, LV_ALIGN_CENTER); + lv_label_set_long_mode(title, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_label_set_text(title, text); + lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font( + title, + launcher_fonts().get("Montserrat-Bold.ttf", 16, LV_FREETYPE_FONT_STYLE_BOLD), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_opa(title, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + if (slot.hidden) + lv_obj_add_flag(title, LV_OBJ_FLAG_HIDDEN); + return title; +} + +lv_obj_t *create_carousel_arrow(lv_obj_t *parent, lv_coord_t x, const char *asset) +{ + lv_obj_t *button = lv_btn_create(parent); + if (!button) return nullptr; + lv_obj_set_size(button, 17, 23); + lv_obj_set_pos(button, x, -4); + lv_obj_set_align(button, LV_ALIGN_CENTER); + lv_obj_add_flag(button, LV_OBJ_FLAG_SCROLL_ON_FOCUS); + lv_obj_clear_flag(button, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(button, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(button, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(button, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_src(button, launcher_platform::path_c(asset), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_color(button, lv_color_hex(0x000000), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_opa(button, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + return button; +} + +} // namespace + +void UILaunchPage::set_page_dot_selected(size_t element, bool selected) +{ + if (element >= carousel_elements_.size() || !carousel_elements_[element]) return; + lv_obj_t *dot = carousel_elements_[element]; + const lv_coord_t size = selected ? 10 : 5; + const lv_color_t color = lv_color_hex(selected ? 0xCCCC33 : 0x4A4C4A); + lv_obj_set_size(dot, size, size); + lv_obj_set_align(dot, LV_ALIGN_CENTER); + lv_obj_clear_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(dot, color, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_grad_color(dot, lv_color_hex(0x4A4C4A), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(dot, color, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_opa(dot, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void UILaunchPage::set_carousel_element_clickable(size_t element, bool clickable) +{ + if (element >= carousel_elements_.size() || !carousel_elements_[element]) return; + if (clickable) + lv_obj_add_flag(carousel_elements_[element], LV_OBJ_FLAG_CLICKABLE); + else + lv_obj_clear_flag(carousel_elements_[element], LV_OBJ_FLAG_CLICKABLE); +} + +void UILaunchPage::set_panel_icon(lv_obj_t *panel, const char *src) +{ + if (!panel) + return; + + const char *icon_src = src ? src : ""; + if (icon_src[0] == '\0') { + SLOGW("[LAUNCHER] set panel icon with empty path"); + } else { + bool exists = false; + cp0_signal_filesystem_api({"Exists", icon_src}, [&](int code, std::string data) { + exists = code == 0 && data == "1"; + }); + if (!exists) + SLOGW("[LAUNCHER] set panel icon missing/unreadable: %s", icon_src); + } + + lv_obj_set_style_pad_all(panel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_t *image = lv_obj_get_child(panel, 0); + if (!image || !lv_obj_check_type(image, &lv_image_class)) { + image = lv_image_create(panel); + if (!image) return; + lv_obj_set_size(image, LV_PCT(100), LV_PCT(100)); + lv_obj_set_align(image, LV_ALIGN_CENTER); + lv_image_set_inner_align(image, LV_IMAGE_ALIGN_STRETCH); + } + lv_image_set_src(image, icon_src); +} + +void UILaunchPage::update_carousel_slot(size_t slot, const char *title, const char *icon) +{ + update_carousel_item(panel(slot), label(slot), title, icon); +} + +void UILaunchPage::update_carousel_item(lv_obj_t *panel, lv_obj_t *label, + const char *title, const char *icon) +{ + if (label) + lv_label_set_text(label, title ? title : ""); + set_panel_icon(panel, icon); +} + +void UILaunchPage::create_screen() +{ + const bool complete = carousel_container_ && left_arrow_button_ && right_arrow_button_ && + std::all_of(carousel_elements_.begin(), carousel_elements_.end(), + [](lv_obj_t *element) { return element != nullptr; }); + if (!complete) + create_app_container(content_container()); +} + +void UILaunchPage::create_app_container(lv_obj_t *parent) +{ + if (!parent) + return; + + lv_obj_t *container = lv_obj_create(parent); + if (!container) return; + lv_obj_set_size(container, 320, 150); + lv_obj_set_align(container, LV_ALIGN_CENTER); + lv_obj_clear_flag(container, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + lv_obj_set_style_bg_opa(container, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(container, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(container, 0, LV_PART_MAIN); + + std::array elements = {}; + + elements[kPageDot0] = create_page_dot(container, -20, navigation_.selected_page() == 0); + elements[kPageDot1] = create_page_dot(container, -10, navigation_.selected_page() == 1); + elements[kPageDot2] = create_page_dot(container, 0, navigation_.selected_page() == 2); + elements[kPageDot3] = create_page_dot(container, 10, navigation_.selected_page() == 3); + elements[kPageDot4] = create_page_dot(container, 20, navigation_.selected_page() == 4); + + elements[kTitleCenter] = create_carousel_title(container, kSlots[kTitleCenter], 100, "CLI"); + elements[kTitleRight] = create_carousel_title(container, kSlots[kTitleRight], 80, "GAME"); + elements[kTitleLeft] = create_carousel_title(container, kSlots[kTitleLeft], 80, "STORE"); + + elements[kCardLeft] = create_carousel_card(container, kSlots[kCardLeft], 17, 0x222222, false); + elements[kCardCenter] = create_carousel_card(container, kSlots[kCardCenter], 22, 0x444444, true); + elements[kCardRight] = create_carousel_card(container, kSlots[kCardRight], 17, 0x222222, false); + elements[kCardFarRight] = create_carousel_card(container, kSlots[kCardFarRight], 17, 0x333333, false); + elements[kCardFarLeft] = create_carousel_card(container, kSlots[kCardFarLeft], 17, 0x333333, false); + + lv_obj_t *left_arrow = create_carousel_arrow(container, -151, "carousel_left_arrow.png"); + lv_obj_t *right_arrow = create_carousel_arrow(container, 150, "carousel_right_arrow.png"); + + elements[kTitleFarLeft] = create_carousel_title(container, kSlots[kTitleFarLeft], 61, "one"); + elements[kTitleFarRight] = create_carousel_title(container, kSlots[kTitleFarRight], 61, "three"); + + const bool complete = left_arrow && right_arrow && + std::all_of(elements.begin(), elements.end(), [](lv_obj_t *element) { return element != nullptr; }); + if (!complete) { + lv_obj_delete(container); + return; + } + lv_obj_set_style_border_width(elements[kCardCenter], 2, LV_PART_MAIN | LV_STATE_DEFAULT); + + *carousel_alive_ = false; + if (carousel_container_) lv_obj_delete(carousel_container_); + carousel_alive_ = std::make_shared(true); + carousel_container_ = container; + carousel_elements_ = elements; + left_arrow_button_ = left_arrow; + right_arrow_button_ = right_arrow; + + lv_obj_add_event_cb(carousel_elements_[kCardLeft], on_app_clicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(carousel_elements_[kCardCenter], on_app_clicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(carousel_elements_[kCardRight], on_app_clicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(carousel_elements_[kCardFarRight], on_app_clicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(carousel_elements_[kCardFarLeft], on_app_clicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(left_arrow_button_, on_left_arrow_clicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(right_arrow_button_, on_right_arrow_clicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(carousel_container_, on_owned_obj_deleted, LV_EVENT_DELETE, this); + for (lv_obj_t *element : carousel_elements_) + lv_obj_add_event_cb(element, on_owned_obj_deleted, LV_EVENT_DELETE, this); + lv_obj_add_event_cb(left_arrow_button_, on_owned_obj_deleted, LV_EVENT_DELETE, this); + lv_obj_add_event_cb(right_arrow_button_, on_owned_obj_deleted, LV_EVENT_DELETE, this); + if (!home_key_registered_ && screen()) { + lv_obj_add_event_cb(screen(), on_home_key, (lv_event_code_t)LV_EVENT_KEYBOARD, this); + home_key_registered_ = true; + } +} diff --git a/projects/APPLaunch/main/ui/ui_launch_page_input.cpp b/projects/APPLaunch/main/ui/ui_launch_page_input.cpp new file mode 100644 index 00000000..d0da70e9 --- /dev/null +++ b/projects/APPLaunch/main/ui/ui_launch_page_input.cpp @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "ui_launch_page.h" + +#include "hal_lvgl_bsp.h" +#include "input_keys.h" +#include "keyboard_input.h" +#include "sample_log.h" + +#undef SLOGI +#define SLOGI(...) do { } while (0) + +namespace { + +// ============================================================ +// audio +// ============================================================ + +static void audio_play_switch(void) +{ + cp0_signal_audio_api({"SystemSoundPlay", "1"}, nullptr); +} + +static void audio_play_enter(void) +{ + cp0_signal_audio_api({"SystemSoundPlay", "2"}, nullptr); +} + +static uint32_t fzxc_to_arrow(uint32_t key) +{ + switch (key) + { + case KEY_F: + return KEY_UP; + + case KEY_X: + return KEY_DOWN; + + case KEY_Z: + return KEY_LEFT; + + case KEY_C: + return KEY_RIGHT; + + default: + return key; + } +} + +static UILaunchPage *page_from_event(lv_event_t *event) +{ + return event ? static_cast(lv_event_get_user_data(event)) : nullptr; +} + +} // namespace + +void UILaunchPage::handle_home_key(lv_event_t *event) +{ + if (!event) + return; + + struct key_item *elm = static_cast(lv_event_get_param(event)); + if (!elm) + return; + + uint32_t code = fzxc_to_arrow(elm->key_code); + + SLOGI("[LAUNCHER] main_key_switch raw=%u->code=%u state=%s sym=%s", + elm->key_code, + code, + kbd_state_name(elm->key_state), + elm->sym_name); + + if (elm->key_state) + { + switch (code) + { + case KEY_UP: + break; + + case KEY_DOWN: + break; + + case KEY_LEFT: + { + if (navigation_.keyboard_navigation_enabled()) + { + audio_play_switch(); + switch_right(); + } + } + break; + + case KEY_RIGHT: + { + if (navigation_.keyboard_navigation_enabled()) + { + audio_play_switch(); + switch_left(); + } + } + break; + + default: + break; + } + } + else if (code == KEY_ENTER) + { + audio_play_enter(); + launch_selected_app(); + } +} + +void UILaunchPage::on_left_arrow_clicked(lv_event_t *event) noexcept +{ + try { + if (UILaunchPage *self = page_from_event(event)) self->switch_right(); + } catch (...) { + } +} + +void UILaunchPage::on_right_arrow_clicked(lv_event_t *event) noexcept +{ + try { + if (UILaunchPage *self = page_from_event(event)) self->switch_left(); + } catch (...) { + } +} + +void UILaunchPage::on_app_clicked(lv_event_t *event) noexcept +{ + try { + if (UILaunchPage *self = page_from_event(event)) self->launch_selected_app(); + } catch (...) { + } +} + +void UILaunchPage::on_home_key(lv_event_t *event) noexcept +{ + try { + if (UILaunchPage *self = page_from_event(event)) self->handle_home_key(event); + } catch (...) { + } +} diff --git a/projects/APPLaunch/main/ui/ui_launch_page_startup.cpp b/projects/APPLaunch/main/ui/ui_launch_page_startup.cpp new file mode 100644 index 00000000..1d328ddf --- /dev/null +++ b/projects/APPLaunch/main/ui/ui_launch_page_startup.cpp @@ -0,0 +1,239 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "ui_launch_page.h" + +#include "launcher_platform.hpp" +#include "lvgl/src/widgets/gif/lv_gif.h" +#include "sample_log.h" + +#include +#include +#include + +namespace { + +constexpr int kStartupSoundRetryMax = 10; +constexpr uint32_t kStartupSoundRetryMs = 500; +constexpr uint32_t kStartupLogoDelayMs = 1000; + +UILaunchPage *page_from_event(lv_event_t *event) +{ + return event ? static_cast(lv_event_get_user_data(event)) : nullptr; +} + +} // namespace + +void UILaunchPage::show_home_screen() +{ + if (!screen()) return; + SLOGI("[HOME] loading launcher home screen"); + use_bold_home_title_font(); + lv_disp_load_scr(screen()); + bind_home_input_group(); +} + +void UILaunchPage::load_home_screen() +{ + if (!startup_delay_model_.begin()) + return; + play_startup_sound_with_retry(); + + const std::string logo_path = launcher_platform::path("logo_lcd.png"); + std::snprintf(startup_logo_path_.data(), startup_logo_path_.size(), "%s", logo_path.c_str()); + startup_logo_scr_ = lv_obj_create(nullptr); + if (!startup_logo_scr_) { + stop_startup_delay(); + show_home_screen(); + return; + } + lv_obj_add_event_cb(startup_logo_scr_, on_owned_obj_deleted, LV_EVENT_DELETE, this); + lv_obj_set_style_bg_color(startup_logo_scr_, lv_color_hex(0x000000), LV_PART_MAIN); + lv_obj_set_style_bg_opa(startup_logo_scr_, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_t *logo = lv_img_create(startup_logo_scr_); + if (!logo) { + release_startup_screen(startup_logo_scr_); + stop_startup_delay(); + show_home_screen(); + return; + } + lv_img_set_src(logo, startup_logo_path_.data()); + lv_obj_center(logo); + lv_disp_load_scr(startup_logo_scr_); + + if (!startup_delay_timer_) { + auto *context = new (std::nothrow) StartupTimerContext{ + this, startup_delay_generation_.begin()}; + if (context) + startup_delay_timer_ = + lv_timer_create(startup_delay_timer_cb, kStartupLogoDelayMs, context); + if (startup_delay_timer_) { + startup_delay_context_ = context; + lv_timer_set_repeat_count(startup_delay_timer_, 1); + startup_delay_model_.timer_created(true); + } else if (startup_delay_model_.timer_created(false)) { + if (context) { + startup_delay_generation_.stop(context->generation); + delete context; + } + finish_startup_delay(); + } + } +} + +void UILaunchPage::finish_startup_delay() +{ + show_home_screen(); + release_startup_screen(startup_logo_scr_); + release_startup_screen(startup_gif_); +} + +void UILaunchPage::play_startup_sound_with_retry() +{ + int play_result = -1; + try { + cp0_signal_audio_api({"SystemSoundPlay", "0"}, [&](int code, std::string) { + play_result = code; + }); + } catch (...) { + play_result = -1; + } + + if (play_result == 0) { + stop_startup_sound_timer(); + startup_sound_retry_count_ = 0; + return; + } + if (startup_sound_timer_) + return; + + startup_sound_retry_count_ = 0; + auto *context = new (std::nothrow) StartupTimerContext{ + this, startup_sound_generation_.begin()}; + if (!context) return; + startup_sound_timer_ = lv_timer_create(startup_sound_timer_cb, kStartupSoundRetryMs, context); + if (startup_sound_timer_) + startup_sound_context_ = context; + else { + startup_sound_generation_.stop(context->generation); + delete context; + } +} + +void UILaunchPage::stop_startup_sound_timer() +{ + if (!startup_sound_timer_) + return; + lv_timer_delete(startup_sound_timer_); + startup_sound_timer_ = nullptr; + if (startup_sound_context_) { + startup_sound_generation_.stop(startup_sound_context_->generation); + delete startup_sound_context_; + startup_sound_context_ = nullptr; + } +} + +void UILaunchPage::stop_startup_delay() +{ + startup_delay_model_.stop(); + if (startup_delay_timer_) { + lv_timer_delete(startup_delay_timer_); + startup_delay_timer_ = nullptr; + } + if (startup_delay_context_) { + startup_delay_generation_.stop(startup_delay_context_->generation); + delete startup_delay_context_; + startup_delay_context_ = nullptr; + } +} + +void UILaunchPage::start_startup_gif() +{ + if (startup_gif_) return; + const std::string gif_path = launcher_platform::path("logo_output.gif"); + std::snprintf(startup_gif_path_.data(), startup_gif_path_.size(), "%s", gif_path.c_str()); + startup_gif_done_ = false; + startup_gif_ = lv_gif_create(nullptr); + if (!startup_gif_) { + load_home_screen(); + return; + } + lv_obj_add_event_cb(startup_gif_, on_owned_obj_deleted, LV_EVENT_DELETE, this); + lv_gif_set_src(startup_gif_, startup_gif_path_.data()); + lv_obj_center(startup_gif_); + lv_obj_add_event_cb(startup_gif_, on_startup_gif_event, LV_EVENT_ALL, this); + lv_disp_load_scr(startup_gif_); +} + +void UILaunchPage::handle_startup_gif_event(lv_event_t *event) +{ + if (!event || lv_event_get_code(event) != LV_EVENT_READY || startup_gif_done_ || + lv_event_get_target(event) != startup_gif_ || + lv_event_get_current_target(event) != startup_gif_) + return; + + startup_gif_done_ = true; + if (startup_gif_) + lv_gif_pause(startup_gif_); + load_home_screen(); +} + +void UILaunchPage::on_startup_gif_event(lv_event_t *event) noexcept +{ + try { + if (UILaunchPage *self = page_from_event(event)) + self->handle_startup_gif_event(event); + } catch (...) { + } +} + +void UILaunchPage::startup_sound_timer_cb(lv_timer_t *timer) noexcept +{ + UILaunchPage *self = nullptr; + try { + auto *context = static_cast(lv_timer_get_user_data(timer)); + self = context ? context->page : nullptr; + if (!self || context != self->startup_sound_context_ || + !self->startup_sound_generation_.current(context->generation) || + !launcher_startup_timer_is_current(timer, self->startup_sound_timer_)) + return; + + ++self->startup_sound_retry_count_; + if (self->startup_sound_retry_count_ > kStartupSoundRetryMax) { + self->stop_startup_sound_timer(); + return; + } + self->play_startup_sound_with_retry(); + } catch (...) { + if (self) self->stop_startup_sound_timer(); + } +} + +void UILaunchPage::startup_delay_timer_cb(lv_timer_t *timer) noexcept +{ + try { + auto *context = static_cast(lv_timer_get_user_data(timer)); + UILaunchPage *self = context ? context->page : nullptr; + if (!self) return; + + const bool context_is_owned = context == self->startup_delay_context_; + const auto decision = launcher_startup_one_shot_decision( + launcher_startup_timer_is_current(timer, self->startup_delay_timer_), + context_is_owned, + context_is_owned && self->startup_delay_generation_.current(context->generation)); + if (!decision.retire_timer) return; + + self->startup_delay_timer_ = nullptr; + if (decision.release_context) { + self->startup_delay_context_ = nullptr; + self->startup_delay_generation_.stop(context->generation); + delete context; + } + if (decision.complete && self->startup_delay_model_.complete()) + self->finish_startup_delay(); + } catch (...) { + } +} diff --git a/projects/APPLaunch/main/ui/ui_loading.cpp b/projects/APPLaunch/main/ui/ui_loading.cpp index 5cd993fe..db68e4c3 100644 --- a/projects/APPLaunch/main/ui/ui_loading.cpp +++ b/projects/APPLaunch/main/ui/ui_loading.cpp @@ -31,6 +31,7 @@ #include "ui_loading.h" #include "ui.h" #include "lvgl/lvgl.h" +#include "model/global_overlay_model.hpp" #define LOADING_BG_COLOR 0x1F3A5F #define LOADING_BG_OPA LV_OPA_80 @@ -42,15 +43,59 @@ static lv_obj_t *s_loading_obj = NULL; static lv_obj_t *s_loading_spinner = NULL; static lv_obj_t *s_loading_label = NULL; +static GlobalOverlayModel s_loading_state; -static void ensure_loading_created(void) +static void loading_deleted_cb(lv_event_t *event) noexcept { - if (s_loading_obj != NULL) return; + try { + if (lv_event_get_code(event) != LV_EVENT_DELETE || + lv_event_get_target(event) != lv_event_get_current_target(event) || + lv_event_get_target(event) != s_loading_obj) + return; + s_loading_obj = NULL; + s_loading_spinner = NULL; + s_loading_label = NULL; + s_loading_state.object_deleted(); + } catch (...) { + s_loading_obj = NULL; + s_loading_spinner = NULL; + s_loading_label = NULL; + s_loading_state.object_deleted(); + } +} + +static void loading_child_deleted_cb(lv_event_t *event) noexcept +{ + try { + if (lv_event_get_code(event) != LV_EVENT_DELETE || + lv_event_get_target(event) != lv_event_get_current_target(event)) return; + lv_obj_t *deleted = static_cast(lv_event_get_target(event)); + if (deleted != s_loading_spinner && deleted != s_loading_label) return; + if (deleted == s_loading_spinner) s_loading_spinner = NULL; + if (deleted == s_loading_label) s_loading_label = NULL; + if (s_loading_obj != NULL) + lv_obj_add_flag(s_loading_obj, LV_OBJ_FLAG_HIDDEN); + s_loading_state.object_deleted(); + } catch (...) { + s_loading_spinner = NULL; + s_loading_label = NULL; + s_loading_state.object_deleted(); + } +} + +static void ensure_loading_created(void) noexcept +{ + try { + if (s_loading_obj != NULL && s_loading_spinner != NULL && + s_loading_label != NULL) return; + if (s_loading_obj != NULL) lv_obj_delete(s_loading_obj); lv_obj_t *parent = lv_layer_top(); if (parent == NULL) return; s_loading_obj = lv_obj_create(parent); + if (s_loading_obj == NULL) return; + lv_obj_add_event_cb(s_loading_obj, loading_deleted_cb, LV_EVENT_DELETE, NULL); lv_obj_remove_style_all(s_loading_obj); lv_obj_set_size(s_loading_obj, LOADING_WIDTH, LOADING_HEIGHT); lv_obj_center(s_loading_obj); @@ -77,6 +122,12 @@ static void ensure_loading_created(void) /* Spinner — 1s arc, 60 degrees long. */ s_loading_spinner = lv_spinner_create(s_loading_obj); + if (s_loading_spinner == NULL) { + lv_obj_delete(s_loading_obj); + return; + } + lv_obj_add_event_cb( + s_loading_spinner, loading_child_deleted_cb, LV_EVENT_DELETE, NULL); lv_obj_set_size(s_loading_spinner, LOADING_SPINNER_SZ, LOADING_SPINNER_SZ); lv_spinner_set_anim_params(s_loading_spinner, 1000, 60); lv_obj_set_style_arc_width(s_loading_spinner, 3, LV_PART_MAIN); @@ -88,6 +139,12 @@ static void ensure_loading_created(void) LV_PART_INDICATOR); s_loading_label = lv_label_create(s_loading_obj); + if (s_loading_label == NULL) { + lv_obj_delete(s_loading_obj); + return; + } + lv_obj_add_event_cb( + s_loading_label, loading_child_deleted_cb, LV_EVENT_DELETE, NULL); lv_obj_set_style_text_color(s_loading_label, lv_color_hex(LOADING_TEXT_COLOR), 0); /* Use the project's 14pt font with montserrat fallback — matches @@ -97,13 +154,24 @@ static void ensure_loading_created(void) lv_label_set_text(s_loading_label, ""); lv_obj_add_flag(s_loading_obj, LV_OBJ_FLAG_HIDDEN); + s_loading_state.mark_created(); + } catch (...) { + if (s_loading_obj != NULL) + lv_obj_delete(s_loading_obj); + s_loading_obj = NULL; + s_loading_spinner = NULL; + s_loading_label = NULL; + s_loading_state.object_deleted(); + } } namespace ui_loading { void show(const char *label) { - ensure_loading_created(); + try { + if (s_loading_state.request_show()) + ensure_loading_created(); if (s_loading_obj == NULL || s_loading_label == NULL) return; lv_label_set_text(s_loading_label, @@ -112,12 +180,29 @@ void show(const char *label) * children (e.g. the ui_global_hint toast). */ lv_obj_move_foreground(s_loading_obj); lv_obj_clear_flag(s_loading_obj, LV_OBJ_FLAG_HIDDEN); + } catch (...) { + if (s_loading_obj != NULL) + lv_obj_add_flag(s_loading_obj, LV_OBJ_FLAG_HIDDEN); + s_loading_state.hide(); + } } void hide() { + s_loading_state.hide(); if (s_loading_obj == NULL) return; lv_obj_add_flag(s_loading_obj, LV_OBJ_FLAG_HIDDEN); } +void shutdown() +{ + s_loading_state.hide(); + if (s_loading_obj != NULL) + lv_obj_delete(s_loading_obj); + s_loading_obj = NULL; + s_loading_spinner = NULL; + s_loading_label = NULL; + s_loading_state.object_deleted(); +} + } // namespace ui_loading diff --git a/projects/APPLaunch/main/ui/ui_loading.h b/projects/APPLaunch/main/ui/ui_loading.h index e89dc5c4..868ee3b1 100644 --- a/projects/APPLaunch/main/ui/ui_loading.h +++ b/projects/APPLaunch/main/ui/ui_loading.h @@ -37,6 +37,7 @@ void show(const char *label); /* Hide the loading overlay. Safe to call when not shown (no-op). */ void hide(); +void shutdown(); } // namespace ui_loading diff --git a/projects/APPLaunch/main/ui/ui_low_battery.cpp b/projects/APPLaunch/main/ui/ui_low_battery.cpp index 030a7b75..4792d21a 100644 --- a/projects/APPLaunch/main/ui/ui_low_battery.cpp +++ b/projects/APPLaunch/main/ui/ui_low_battery.cpp @@ -21,12 +21,58 @@ lv_timer_t *warning_timer = nullptr; uint32_t flash_tick = 0; uint32_t rendered_seconds = 0; +void overlay_deleted_cb(lv_event_t *event) noexcept +{ + try { + if (lv_event_get_code(event) != LV_EVENT_DELETE || + lv_event_get_target(event) != lv_event_get_current_target(event) || + lv_event_get_target(event) != overlay) + return; + overlay = nullptr; + tint = nullptr; + countdown_label = nullptr; + rendered_warning = LowBatteryWarning::None; + rendered_seconds = 0; + } catch (...) { + overlay = nullptr; + tint = nullptr; + countdown_label = nullptr; + rendered_warning = LowBatteryWarning::None; + rendered_seconds = 0; + } +} + +void critical_child_deleted_cb(lv_event_t *event) noexcept +{ + try { + if (lv_event_get_code(event) != LV_EVENT_DELETE || + lv_event_get_target(event) != lv_event_get_current_target(event)) return; + lv_obj_t *deleted = static_cast(lv_event_get_target(event)); + if (deleted != tint && deleted != countdown_label) return; + if (deleted == tint) tint = nullptr; + if (deleted == countdown_label) countdown_label = nullptr; + if (overlay) + lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); + rendered_warning = LowBatteryWarning::None; + rendered_seconds = 0; + } catch (...) { + tint = nullptr; + countdown_label = nullptr; + rendered_warning = LowBatteryWarning::None; + rendered_seconds = 0; + } +} + void create_overlay() { - if (overlay || !lv_layer_top()) - return; + if (overlay && tint && countdown_label) return; + if (overlay) lv_obj_delete(overlay); + if (!lv_layer_top()) return; overlay = lv_obj_create(lv_layer_top()); + if (!overlay) + return; + lv_obj_add_event_cb(overlay, overlay_deleted_cb, LV_EVENT_DELETE, nullptr); lv_obj_remove_style_all(overlay); lv_obj_set_pos(overlay, 0, 0); lv_obj_set_size(overlay, kScreenWidth, kScreenHeight); @@ -34,6 +80,11 @@ void create_overlay() lv_obj_add_flag(overlay, LV_OBJ_FLAG_IGNORE_LAYOUT); tint = lv_obj_create(overlay); + if (!tint) { + lv_obj_delete(overlay); + return; + } + lv_obj_add_event_cb(tint, critical_child_deleted_cb, LV_EVENT_DELETE, nullptr); lv_obj_remove_style_all(tint); lv_obj_set_size(tint, kScreenWidth, kScreenHeight); lv_obj_set_style_bg_color(tint, lv_color_hex(0xFF0000), 0); @@ -41,6 +92,10 @@ void create_overlay() lv_obj_clear_flag(tint, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); lv_obj_t *panel = lv_obj_create(overlay); + if (!panel) { + lv_obj_delete(overlay); + return; + } lv_obj_remove_style_all(panel); lv_obj_set_pos(panel, 18, 73); lv_obj_set_size(panel, 284, 94); @@ -50,6 +105,10 @@ void create_overlay() lv_obj_set_style_border_width(panel, 2, 0); lv_obj_t *title = lv_label_create(panel); + if (!title) { + lv_obj_delete(overlay); + return; + } lv_label_set_text(title, "LOW BATTERY"); lv_obj_set_width(title, 260); lv_obj_set_pos(title, 12, 10); @@ -58,6 +117,12 @@ void create_overlay() lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); countdown_label = lv_label_create(panel); + if (!countdown_label) { + lv_obj_delete(overlay); + return; + } + lv_obj_add_event_cb( + countdown_label, critical_child_deleted_cb, LV_EVENT_DELETE, nullptr); lv_obj_set_width(countdown_label, 260); lv_obj_set_pos(countdown_label, 12, 36); lv_obj_set_style_text_align(countdown_label, LV_TEXT_ALIGN_CENTER, 0); @@ -65,6 +130,10 @@ void create_overlay() lv_obj_set_style_text_font(countdown_label, &lv_font_montserrat_14, 0); lv_obj_t *message = lv_label_create(panel); + if (!message) { + lv_obj_delete(overlay); + return; + } lv_label_set_text(message, "Shut down now or connect a charger."); lv_obj_set_width(message, 260); lv_obj_set_pos(message, 12, 64); @@ -88,7 +157,7 @@ void render(uint32_t now, bool force = false) } create_overlay(); - if (!overlay) + if (!overlay || !tint || !countdown_label) return; lv_obj_move_foreground(overlay); lv_obj_clear_flag(overlay, LV_OBJ_FLAG_HIDDEN); @@ -109,8 +178,10 @@ void render(uint32_t now, bool force = false) } } -void timer_cb(lv_timer_t *) +void timer_cb(lv_timer_t *timer) noexcept { + try { + if (timer != warning_timer) return; const uint32_t now = lv_tick_get(); render(now); if (overlay && flow.warning() != LowBatteryWarning::None && @@ -127,24 +198,59 @@ void timer_cb(lv_timer_t *) render(now, true); if (flow.confirm_shutdown(confirmation.valid != 0, (confirmation.flags & 1) != 0, now)) cp0_system_shutdown(); + } catch (...) { + if (overlay) + lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); + } } } // namespace void init_warning() { + try { if (warning_timer) return; flow.reset(); warning_timer = lv_timer_create(timer_cb, 250, nullptr); update_warning(cp0_battery_read()); + } catch (...) { + if (warning_timer) { + lv_timer_delete(warning_timer); + warning_timer = nullptr; + } + if (overlay) + lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); + flow.reset(); + } } void update_warning(const cp0_battery_info_t &info) { + try { const uint32_t now = lv_tick_get(); flow.update(info.valid != 0, info.soc, (info.flags & 1) != 0, now); render(now, true); + } catch (...) { + if (overlay) + lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); + } +} + +void shutdown_warning() +{ + if (warning_timer) { + lv_timer_delete(warning_timer); + warning_timer = nullptr; + } + if (overlay) + lv_obj_delete(overlay); + overlay = nullptr; + tint = nullptr; + countdown_label = nullptr; + flow.reset(); + rendered_warning = LowBatteryWarning::None; + rendered_seconds = 0; } } // namespace launcher_battery_ui diff --git a/projects/APPLaunch/main/ui/ui_low_battery.h b/projects/APPLaunch/main/ui/ui_low_battery.h index 22aa3916..ad376e0e 100644 --- a/projects/APPLaunch/main/ui/ui_low_battery.h +++ b/projects/APPLaunch/main/ui/ui_low_battery.h @@ -6,5 +6,6 @@ namespace launcher_battery_ui { void init_warning(); void update_warning(const cp0_battery_info_t &info); +void shutdown_warning(); } // namespace launcher_battery_ui diff --git a/projects/APPLaunch/main/ui/ui_screensaver.cpp b/projects/APPLaunch/main/ui/ui_screensaver.cpp index 49dbe762..3d11c369 100644 --- a/projects/APPLaunch/main/ui/ui_screensaver.cpp +++ b/projects/APPLaunch/main/ui/ui_screensaver.cpp @@ -10,89 +10,126 @@ #include "hal_lvgl_bsp.h" #include "keyboard_input.h" #include "lvgl/lvgl.h" +#include "model/screensaver_model.hpp" +#include "model/screensaver_runtime_contract.hpp" -#include #include #include +#include namespace { -constexpr int kBlockSize = 20; constexpr uint32_t kIdleCheckMs = 500; constexpr uint32_t kAnimationFrameMs = 40; -constexpr int kVelocityX = 45; -constexpr int kVelocityY = 35; constexpr uint32_t kColors[] = { 0x00E5FF, 0xFFEA00, 0xFF3D71, 0x69F0AE, 0xFF9100, 0xD500F9, 0x76FF03, 0xFFFFFF, }; +static_assert(sizeof(kColors) / sizeof(kColors[0]) == ScreensaverModel::COLOR_COUNT); lv_obj_t *s_overlay = nullptr; lv_obj_t *s_block = nullptr; lv_timer_t *s_timer = nullptr; -uint32_t s_last_activity_tick = 0; -uint32_t s_last_frame_tick = 0; -uint32_t s_swallow_code = 0; -int32_t s_x_milli = 0; -int32_t s_y_milli = 0; -int s_velocity_x = kVelocityX; -int s_velocity_y = kVelocityY; -size_t s_color_index = 0; -bool s_active = false; -bool s_foreground = true; -bool s_swallow_active = false; - -int config_get_int(const char *key, int default_value) +ScreensaverModel s_model; + +void reset_animation_period() { - int value = default_value; - cp0_signal_config_api({"GetInt", key, std::to_string(default_value)}, - [&](int code, std::string data) { - if (code == 0) - value = std::atoi(data.c_str()); - }); - return value; + if (s_timer) lv_timer_set_period(s_timer, kIdleCheckMs); } -int timeout_seconds() +void block_delete_cb(lv_event_t *event) noexcept { - const int value = config_get_int("dark_time", 30); - switch (value) { - case 0: - case 10: - case 30: - case 60: - case 300: - return value; - default: - return 30; + try { + if (!event || !screensaver_delete_is_tracked( + lv_event_get_target(event), lv_event_get_current_target(event), s_block)) + return; + s_block = nullptr; + if (s_overlay) lv_obj_add_flag(s_overlay, LV_OBJ_FLAG_HIDDEN); + s_model.deactivate(); + reset_animation_period(); + } catch (...) { + s_block = nullptr; + reset_animation_period(); + try { + s_model.deactivate(); + } catch (...) { + } } } -void create_objects() +void overlay_delete_cb(lv_event_t *event) noexcept { - if (s_overlay) - return; + try { + if (!event || !screensaver_delete_is_tracked( + lv_event_get_target(event), lv_event_get_current_target(event), s_overlay)) + return; + s_overlay = nullptr; + s_block = nullptr; + s_model.deactivate(); + reset_animation_period(); + } catch (...) { + s_overlay = nullptr; + s_block = nullptr; + reset_animation_period(); + try { + s_model.deactivate(); + } catch (...) { + } + } +} +int timeout_seconds() noexcept +{ + try { + bool succeeded = false; + std::string response; + cp0_signal_config_api({"GetInt", "dark_time", "30"}, + [&](int code, std::string data) { + succeeded = code == 0; + response = std::move(data); + }); + return screensaver_timeout_from_config(succeeded, response); + } catch (...) { + return screensaver_timeout_from_config(false, {}); + } +} + +void create_objects() +{ lv_display_t *display = lv_display_get_default(); if (!display) return; - s_overlay = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(s_overlay); - lv_obj_set_pos(s_overlay, 0, 0); - lv_obj_set_size(s_overlay, - lv_display_get_horizontal_resolution(display), - lv_display_get_vertical_resolution(display)); - lv_obj_set_style_bg_color(s_overlay, lv_color_black(), 0); - lv_obj_set_style_bg_opa(s_overlay, LV_OPA_COVER, 0); - lv_obj_clear_flag(s_overlay, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(s_overlay, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(s_overlay, LV_OBJ_FLAG_IGNORE_LAYOUT); + if (!s_overlay) { + lv_obj_t *parent = lv_layer_top(); + if (!parent) + return; + s_overlay = lv_obj_create(parent); + if (!s_overlay) + return; + lv_obj_add_event_cb(s_overlay, overlay_delete_cb, LV_EVENT_DELETE, nullptr); + lv_obj_remove_style_all(s_overlay); + lv_obj_set_pos(s_overlay, 0, 0); + lv_obj_set_size(s_overlay, + lv_display_get_horizontal_resolution(display), + lv_display_get_vertical_resolution(display)); + lv_obj_set_style_bg_color(s_overlay, lv_color_black(), 0); + lv_obj_set_style_bg_opa(s_overlay, LV_OPA_COVER, 0); + lv_obj_clear_flag(s_overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(s_overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(s_overlay, LV_OBJ_FLAG_IGNORE_LAYOUT); + } + if (s_block) return; s_block = lv_obj_create(s_overlay); + if (!s_block) { + lv_obj_delete(s_overlay); + return; + } + lv_obj_add_event_cb(s_block, block_delete_cb, LV_EVENT_DELETE, nullptr); lv_obj_remove_style_all(s_block); - lv_obj_set_size(s_block, kBlockSize, kBlockSize); + lv_obj_set_size(s_block, ScreensaverModel::BLOCK_SIZE, ScreensaverModel::BLOCK_SIZE); lv_obj_set_style_bg_color(s_block, lv_color_hex(kColors[0]), 0); lv_obj_set_style_bg_opa(s_block, LV_OPA_COVER, 0); lv_obj_set_style_radius(s_block, 0, 0); @@ -102,15 +139,15 @@ void create_objects() lv_obj_add_flag(s_overlay, LV_OBJ_FLAG_HIDDEN); } -void stop_screensaver() +void stop_screensaver(bool was_active = false) { if (s_overlay) lv_obj_add_flag(s_overlay, LV_OBJ_FLAG_HIDDEN); - if (s_active) - lv_obj_invalidate(lv_screen_active()); + if (was_active || s_model.active()) + if (lv_obj_t *active_screen = lv_screen_active()) + lv_obj_invalidate(active_screen); - s_active = false; - s_last_frame_tick = 0; + s_model.deactivate(); if (s_timer) lv_timer_set_period(s_timer, kIdleCheckMs); } @@ -122,22 +159,17 @@ void start_screensaver() return; lv_display_t *display = lv_display_get_default(); + if (!display) return; const int width = lv_display_get_horizontal_resolution(display); const int height = lv_display_get_vertical_resolution(display); lv_obj_set_size(s_overlay, width, height); - s_x_milli = std::max(0, (width - kBlockSize) / 4) * 1000; - s_y_milli = std::max(0, (height - kBlockSize) / 3) * 1000; - s_velocity_x = kVelocityX; - s_velocity_y = kVelocityY; - s_color_index = 0; - lv_obj_set_pos(s_block, s_x_milli / 1000, s_y_milli / 1000); - lv_obj_set_style_bg_color(s_block, lv_color_hex(kColors[s_color_index]), 0); + const ScreensaverFrame frame = s_model.activate(width, height, lv_tick_get()); + lv_obj_set_pos(s_block, frame.x, frame.y); + lv_obj_set_style_bg_color(s_block, lv_color_hex(kColors[frame.color_index]), 0); lv_obj_move_foreground(s_overlay); lv_obj_clear_flag(s_overlay, LV_OBJ_FLAG_HIDDEN); - s_active = true; - s_last_frame_tick = lv_tick_get(); lv_timer_set_period(s_timer, kAnimationFrameMs); lv_timer_reset(s_timer); } @@ -148,89 +180,91 @@ void animate(uint32_t now) if (!display || !s_block) return; - uint32_t elapsed = lv_tick_elaps(s_last_frame_tick); - s_last_frame_tick = now; - elapsed = std::min(elapsed, 100); - - const int32_t max_x = std::max(0, lv_display_get_horizontal_resolution(display) - kBlockSize) * 1000; - const int32_t max_y = std::max(0, lv_display_get_vertical_resolution(display) - kBlockSize) * 1000; - s_x_milli += s_velocity_x * static_cast(elapsed); - s_y_milli += s_velocity_y * static_cast(elapsed); - - bool collided = false; - if (s_x_milli <= 0 || s_x_milli >= max_x) { - s_x_milli = std::clamp(s_x_milli, 0, max_x); - s_velocity_x = -s_velocity_x; - collided = true; - } - if (s_y_milli <= 0 || s_y_milli >= max_y) { - s_y_milli = std::clamp(s_y_milli, 0, max_y); - s_velocity_y = -s_velocity_y; - collided = true; - } - - if (collided) { - s_color_index = (s_color_index + 1) % (sizeof(kColors) / sizeof(kColors[0])); - lv_obj_set_style_bg_color(s_block, lv_color_hex(kColors[s_color_index]), 0); - } - lv_obj_set_pos(s_block, s_x_milli / 1000, s_y_milli / 1000); + const ScreensaverFrame frame = s_model.advance( + lv_display_get_horizontal_resolution(display), lv_display_get_vertical_resolution(display), now); + if (frame.color_changed) + lv_obj_set_style_bg_color(s_block, lv_color_hex(kColors[frame.color_index]), 0); + lv_obj_set_pos(s_block, frame.x, frame.y); } -void timer_cb(lv_timer_t *) +void timer_cb(lv_timer_t *timer) noexcept { - if (!s_foreground || LVGL_RUN_FLAGE != 1) + try { + if (!screensaver_timer_is_current(timer, s_timer)) return; + if (!s_model.foreground() || LVGL_RUN_FLAGE != 1) return; const uint32_t now = lv_tick_get(); - if (s_active) { + if (s_model.active()) { animate(now); return; } const int seconds = timeout_seconds(); - if (seconds > 0 && lv_tick_elaps(s_last_activity_tick) >= static_cast(seconds) * 1000u) + if (s_model.should_activate(now, static_cast(seconds) * 1000u, true)) start_screensaver(); + } catch (...) { + stop_screensaver(); + } } } // namespace extern "C" void ui_screensaver_init(void) { + try { if (s_timer) return; create_objects(); - s_last_activity_tick = lv_tick_get(); + s_model.set_foreground(true, lv_tick_get()); s_timer = lv_timer_create(timer_cb, kIdleCheckMs, nullptr); + } catch (...) { + if (s_timer) { + lv_timer_delete(s_timer); + s_timer = nullptr; + } + s_model.set_foreground(false, 0); + } +} + +extern "C" void ui_screensaver_deinit(void) +{ + if (s_timer) { + lv_timer_delete(s_timer); + s_timer = nullptr; + } + if (s_overlay) + lv_obj_delete(s_overlay); + s_overlay = nullptr; + s_block = nullptr; + s_model.reset(0); + s_model.set_foreground(false, 0); } extern "C" int ui_screensaver_filter_key(const struct key_item *item) { + try { if (!item) return 0; - s_last_activity_tick = lv_tick_get(); - if (s_swallow_active) { - if (item->key_code == s_swallow_code) { - if (item->key_state == KBD_KEY_RELEASED) - s_swallow_active = false; - return 1; - } - s_swallow_active = false; - } - - if (!s_active) + const bool was_active = s_model.active(); + const bool consumed = s_model.filter_key( + item->key_code, item->key_state == KBD_KEY_RELEASED, lv_tick_get()); + if (was_active && consumed) stop_screensaver(true); + return consumed ? 1 : 0; + } catch (...) { + stop_screensaver(); return 0; - - stop_screensaver(); - s_swallow_code = item->key_code; - s_swallow_active = item->key_state != KBD_KEY_RELEASED; - return 1; + } } extern "C" void ui_screensaver_set_foreground(int foreground) { - s_foreground = foreground != 0; - s_swallow_active = false; + try { stop_screensaver(); - s_last_activity_tick = lv_tick_get(); + s_model.set_foreground(foreground != 0, lv_tick_get()); + } catch (...) { + stop_screensaver(); + s_model.set_foreground(false, 0); + } } diff --git a/projects/APPLaunch/main/ui/ui_screensaver.h b/projects/APPLaunch/main/ui/ui_screensaver.h index e3b64c67..0a4035d6 100644 --- a/projects/APPLaunch/main/ui/ui_screensaver.h +++ b/projects/APPLaunch/main/ui/ui_screensaver.h @@ -13,6 +13,7 @@ extern "C" { struct key_item; void ui_screensaver_init(void); +void ui_screensaver_deinit(void); int ui_screensaver_filter_key(const struct key_item *item); void ui_screensaver_set_foreground(int foreground); diff --git a/projects/APPLaunch/tests/run_tests.sh b/projects/APPLaunch/tests/run_tests.sh index 190953f4..56a02bb2 100755 --- a/projects/APPLaunch/tests/run_tests.sh +++ b/projects/APPLaunch/tests/run_tests.sh @@ -1,6 +1,8 @@ #!/bin/sh set -eu python3 "$(dirname "$0")/test_store_cache_sync.py" +PYTHONPATH="$(dirname "$0")/..${PYTHONPATH:+:$PYTHONPATH}" \ + python3 "$(dirname "$0")/test_config_default_file.py" build_dir="${TMPDIR:-/tmp}/applaunch-tests" mkdir -p "$build_dir" ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ @@ -8,16 +10,40 @@ ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ -o "$build_dir/test_low_battery_flow" "$build_dir/test_low_battery_flow" ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ - "$(dirname "$0")/test_rtc_ntp_state.cpp" \ - "$(dirname "$0")/../main/ui/page_app/setting/rtc_ntp_state.cpp" \ - -o "$build_dir/test_rtc_ntp_state" -"$build_dir/test_rtc_ntp_state" + "$(dirname "$0")/test_rtc_state_model.cpp" \ + "$(dirname "$0")/../main/ui/model/rtc_state_model.cpp" \ + -o "$build_dir/test_rtc_state_model" +"$build_dir/test_rtc_state_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror -pthread \ + "$(dirname "$0")/test_async_operation_lifecycle.cpp" \ + "$(dirname "$0")/../main/ui/model/async_operation_lifecycle.cpp" \ + -o "$build_dir/test_async_operation_lifecycle" +"$build_dir/test_async_operation_lifecycle" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror -pthread \ + "$(dirname "$0")/test_page_timer_lifecycle.cpp" \ + -o "$build_dir/test_page_timer_lifecycle" +"$build_dir/test_page_timer_lifecycle" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_setup_info_model.cpp" \ + "$(dirname "$0")/../main/ui/model/setup_info_model.cpp" \ + -o "$build_dir/test_setup_info_model" +"$build_dir/test_setup_info_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_setup_wifi_model.cpp" \ + "$(dirname "$0")/../main/ui/model/setup_wifi_model.cpp" \ + -o "$build_dir/test_setup_wifi_model" +"$build_dir/test_setup_wifi_model" ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ -I"$(dirname "$0")/../../../ext_components/cp0_lvgl/include" \ "$(dirname "$0")/test_adb_state.cpp" \ - "$(dirname "$0")/../main/ui/page_app/setting/adb_state.cpp" \ + "$(dirname "$0")/../main/ui/model/adb_state.cpp" \ -o "$build_dir/test_adb_state" "$build_dir/test_adb_state" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_developer_page_model.cpp" \ + "$(dirname "$0")/../main/ui/model/developer_page_model.cpp" \ + -o "$build_dir/test_developer_page_model" +"$build_dir/test_developer_page_model" "$(dirname "$0")/test_cardputer_adb.sh" python3 "$(dirname "$0")/test_adb_packaging.py" ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ @@ -27,3 +53,136 @@ ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ "$(dirname "$0")/test_launcher_platform.cpp" \ -o "$build_dir/test_launcher_platform" "$build_dir/test_launcher_platform" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + -I"$(dirname "$0")/../main/ui" \ + "$(dirname "$0")/test_desktop_entry.cpp" \ + "$(dirname "$0")/../main/ui/desktop_entry.cpp" \ + -o "$build_dir/test_desktop_entry" +"$build_dir/test_desktop_entry" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_snake_game_model.cpp" \ + "$(dirname "$0")/../main/ui/model/snake_game_model.cpp" \ + -o "$build_dir/test_snake_game_model" +"$build_dir/test_snake_game_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_snake_view_contract.cpp" \ + -o "$build_dir/test_snake_view_contract" +"$build_dir/test_snake_view_contract" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_sound_card_model.cpp" \ + "$(dirname "$0")/../main/ui/model/sound_card_model.cpp" \ + -o "$build_dir/test_sound_card_model" +"$build_dir/test_sound_card_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_ip_panel_model.cpp" \ + "$(dirname "$0")/../main/ui/model/ip_panel_model.cpp" \ + -o "$build_dir/test_ip_panel_model" +"$build_dir/test_ip_panel_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + -I"$(dirname "$0")/../../../ext_components/cp0_lvgl/include" \ + "$(dirname "$0")/test_ssh_connection_model.cpp" \ + "$(dirname "$0")/../main/ui/model/ssh_connection_model.cpp" \ + "$(dirname "$0")/../main/ui/keyboard_text_input.cpp" \ + -o "$build_dir/test_ssh_connection_model" +"$build_dir/test_ssh_connection_model" + +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_ssh_view_build_contract.cpp" \ + -o "$build_dir/test_ssh_view_build_contract" +"$build_dir/test_ssh_view_build_contract" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + -I"$(dirname "$0")/../../../ext_components/cp0_lvgl/include" \ + "$(dirname "$0")/test_keyboard_text_input.cpp" \ + "$(dirname "$0")/../main/ui/keyboard_text_input.cpp" \ + -o "$build_dir/test_keyboard_text_input" +"$build_dir/test_keyboard_text_input" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_mesh_page_model.cpp" \ + "$(dirname "$0")/../main/ui/model/mesh_page_model.cpp" \ + -o "$build_dir/test_mesh_page_model" +"$build_dir/test_mesh_page_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_lora_page_model.cpp" \ + "$(dirname "$0")/../main/ui/model/lora_page_model.cpp" \ + -o "$build_dir/test_lora_page_model" +"$build_dir/test_lora_page_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_tank_battle_model.cpp" \ + "$(dirname "$0")/../main/ui/model/tank_battle_model.cpp" \ + -o "$build_dir/test_tank_battle_model" +"$build_dir/test_tank_battle_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_setup_page_model.cpp" \ + "$(dirname "$0")/../main/ui/model/setup_page_model.cpp" \ + -o "$build_dir/test_setup_page_model" +"$build_dir/test_setup_page_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_setup_view_build_contract.cpp" \ + -o "$build_dir/test_setup_view_build_contract" +"$build_dir/test_setup_view_build_contract" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_setup_page_access_policy.cpp" \ + "$(dirname "$0")/../main/ui/page_app/setting/setup_page_access_policy.cpp" \ + -o "$build_dir/test_setup_page_access_policy" +"$build_dir/test_setup_page_access_policy" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_setup_value_policy.cpp" \ + "$(dirname "$0")/../main/ui/model/setup_value_policy.cpp" \ + -o "$build_dir/test_setup_value_policy" +"$build_dir/test_setup_value_policy" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_boot_action_policy.cpp" \ + "$(dirname "$0")/../main/ui/model/boot_action_policy.cpp" \ + -o "$build_dir/test_boot_action_policy" +"$build_dir/test_boot_action_policy" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_system_page_model.cpp" \ + "$(dirname "$0")/../main/ui/model/system_page_model.cpp" \ + -o "$build_dir/test_system_page_model" +"$build_dir/test_system_page_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_screensaver_model.cpp" \ + "$(dirname "$0")/../main/ui/model/screensaver_model.cpp" \ + -o "$build_dir/test_screensaver_model" +"$build_dir/test_screensaver_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_esc_hold_lifecycle_model.cpp" \ + "$(dirname "$0")/../main/ui/model/esc_hold_lifecycle_model.cpp" \ + -o "$build_dir/test_esc_hold_lifecycle_model" +"$build_dir/test_esc_hold_lifecycle_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_launcher_navigation_model.cpp" \ + "$(dirname "$0")/../main/ui/model/launcher_navigation_model.cpp" \ + -o "$build_dir/test_launcher_navigation_model" +"$build_dir/test_launcher_navigation_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror -pthread \ + "$(dirname "$0")/test_app_registry_callback.cpp" \ + -o "$build_dir/test_app_registry_callback" +"$build_dir/test_app_registry_callback" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_launcher_media_model.cpp" \ + "$(dirname "$0")/../main/ui/model/launcher_media_model.cpp" \ + -o "$build_dir/test_launcher_media_model" +"$build_dir/test_launcher_media_model" + +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + -I"$(dirname "$0")/../../../ext_components/cp0_lvgl/include" \ + "$(dirname "$0")/test_global_hint_policy.cpp" \ + "$(dirname "$0")/../main/ui/model/global_hint_policy.cpp" \ + -o "$build_dir/test_global_hint_policy" +"$build_dir/test_global_hint_policy" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_global_overlay_model.cpp" \ + -o "$build_dir/test_global_overlay_model" +"$build_dir/test_global_overlay_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$(dirname "$0")/test_bluetooth_page_model.cpp" \ + "$(dirname "$0")/../main/ui/model/bluetooth_page_model.cpp" \ + -o "$build_dir/test_bluetooth_page_model" +"$build_dir/test_bluetooth_page_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + -I"$(dirname "$0")/../../../ext_components/cp0_lvgl/include" \ + "$(dirname "$0")/test_st_key_encoder.cpp" \ + "$(dirname "$0")/../main/ui/model/st_key_encoder.cpp" \ + -o "$build_dir/test_st_key_encoder" +"$build_dir/test_st_key_encoder" diff --git a/projects/APPLaunch/tests/test_adb_state.cpp b/projects/APPLaunch/tests/test_adb_state.cpp index e4661feb..e678d570 100644 --- a/projects/APPLaunch/tests/test_adb_state.cpp +++ b/projects/APPLaunch/tests/test_adb_state.cpp @@ -1,5 +1,6 @@ -#include "../main/ui/page_app/setting/adb_state.hpp" +#include "../main/ui/model/adb_state.hpp" #include +#include int main() { @@ -12,19 +13,25 @@ int main() assert(off.valid && !off.active && !off.enabled); assert(!parse_adb_status("unrelated=active\n").valid); assert(!parse_adb_status(nullptr).valid); - assert(adb_toggle_succeeded(CP0_SUDO_RESULT_SUCCESS, 0)); - assert(adb_toggle_succeeded(CP0_SUDO_RESULT_EXEC_FAILED, 10)); - assert(!adb_toggle_succeeded(CP0_SUDO_RESULT_EXEC_FAILED, 1)); - assert(!adb_toggle_succeeded(CP0_SUDO_RESULT_AUTH_FAILED, 10)); - assert(adb_reboot_required(CP0_SUDO_RESULT_EXEC_FAILED, 10)); - assert(!adb_reboot_required(CP0_SUDO_RESULT_SUCCESS, 0)); assert(adb_state_after_failure(active, false)); - assert(adb_state_after_failure(pending, false)); + assert(!adb_state_after_failure(pending, true)); assert(!adb_state_after_failure(off, true)); assert(adb_state_after_failure(AdbStatus{}, true)); assert(!adb_state_after_failure(AdbStatus{}, false)); AdbStatus paired = parse_adb_status("adbd=inactive\nenabled=disabled\nauthorizations=2\n"); assert(paired.valid && paired.authorizations == 2); + assert(parse_adb_status("adbd=active\nauthorizations=0\n").authorizations == 0); + assert(parse_adb_status( + ("adbd=active\nauthorizations=" + + std::to_string(std::numeric_limits::max()) + "\n").c_str()) + .authorizations == std::numeric_limits::max()); + assert(parse_adb_status("adbd=active\nauthorizations=-1\n").authorizations == 0); + assert(parse_adb_status("adbd=active\nauthorizations=+2\n").authorizations == 0); + assert(parse_adb_status("adbd=active\nauthorizations=2junk\n").authorizations == 0); + assert(parse_adb_status("adbd=active\nauthorizations=2147483648\n").authorizations == 0); + assert(parse_adb_status("adbd=active\nxauthorizations=9\n").authorizations == 0); + assert(parse_adb_status("adbd=active\nauthorizations=bad\nauthorizations=3\n") + .authorizations == 3); const std::string blob = "QAAAA" + std::string(694, 'A') + "="; assert(adb_public_key_valid(blob + " workstation@user")); assert(!adb_public_key_valid(blob)); diff --git a/projects/APPLaunch/tests/test_app_registry_callback.cpp b/projects/APPLaunch/tests/test_app_registry_callback.cpp new file mode 100644 index 00000000..25950f1a --- /dev/null +++ b/projects/APPLaunch/tests/test_app_registry_callback.cpp @@ -0,0 +1,187 @@ +#include "../main/ui/model/app_registry_callback.hpp" +#include "../main/ui/model/app_directory_watcher_contract.hpp" +#include "../main/ui/model/app_registry_descriptor_contract.hpp" +#include "../main/ui/app_registry.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +struct BlockingCallback { + AppRegistryCallbackSlot *slot; + std::atomic entered{false}; + std::atomic release{false}; + int calls = 0; +}; + +void block(void *user_data) +{ + auto &state = *static_cast(user_data); + state.entered = true; + while (!state.release) std::this_thread::yield(); + ++state.calls; +} + +void unregister_self(void *user_data) +{ + auto *slot = static_cast(user_data); + slot->set(nullptr, nullptr); +} + +struct ReplacementState { + AppRegistryCallbackSlot *slot; + int old_calls = 0; + int new_calls = 0; +}; + +void replacement_callback(void *user_data) +{ + ++static_cast(user_data)->new_calls; +} + +void count_int(void *user_data) +{ + ++*static_cast(user_data); +} + +void replace_self(void *user_data) +{ + auto &state = *static_cast(user_data); + ++state.old_calls; + state.slot->set(replacement_callback, &state); +} + +} // namespace + +int main() +{ + const AppDescriptor unique_descriptors[] = { + {"One", "one.png", "app_One", true, false}, + {"Two", "two.png", "app_Two", true, false}, + }; + const AppDescriptor duplicate_descriptors[] = { + {"One", "one.png", "app_Duplicate", true, false}, + {"Two", "two.png", "app_Duplicate", true, false}, + }; + assert(app_registry_descriptor_ids_unique(unique_descriptors, 2)); + assert(!app_registry_descriptor_ids_unique(duplicate_descriptors, 2)); + assert(!app_registry_descriptor_ids_unique(nullptr, 1)); + + uintptr_t handle = 0; + assert(parse_app_watcher_handle("0x1234", handle) && handle == 0x1234); + assert(parse_app_watcher_handle("0Xffff", handle) && handle == 0xffff); + assert(parse_app_watcher_handle("1", handle) && handle == 1); + assert(!parse_app_watcher_handle("", handle)); + assert(!parse_app_watcher_handle("0", handle)); + handle = 77; + assert(!parse_app_watcher_handle("0x", handle) && handle == 77); + assert(!parse_app_watcher_handle("0x0", handle) && handle == 77); + assert(!parse_app_watcher_handle("+1", handle) && handle == 77); + assert(!parse_app_watcher_handle(" 1", handle) && handle == 77); + assert(!parse_app_watcher_handle("12garbage", handle)); + assert(handle == 77); + assert(!parse_app_watcher_handle("184467440737095516160", handle)); + assert(handle == 77); + int changes = -1; + assert(parse_app_watcher_change_count("0", changes) && changes == 0); + assert(parse_app_watcher_change_count("17", changes) && changes == 17); + assert(parse_app_watcher_change_count( + std::to_string(std::numeric_limits::max()), changes)); + assert(changes == std::numeric_limits::max()); + changes = 23; + assert(!parse_app_watcher_change_count("-1", changes)); + assert(changes == 23); + assert(!parse_app_watcher_change_count("+1", changes)); + assert(changes == 23); + assert(!parse_app_watcher_change_count("1 ", changes)); + assert(changes == 23); + assert(!parse_app_watcher_change_count("2147483648", changes)); + assert(changes == 23); + assert(!parse_app_watcher_change_count("changed", changes)); + assert(changes == 23); + int timer = 0; + int stale_timer = 0; + assert(app_watcher_timer_is_current(&timer, &timer)); + assert(!app_watcher_timer_is_current(&stale_timer, &timer)); + assert(!app_watcher_timer_is_current(nullptr, &timer)); + + AppDirectoryChangeCallbackSlot directory_callback; + int directory_calls = 0; + directory_callback.set([&]() { + ++directory_calls; + directory_callback.set(nullptr); + }); + directory_callback.notify(); + directory_callback.notify(); + assert(directory_calls == 1); + + std::atomic directory_entered{false}; + std::atomic directory_release{false}; + directory_callback.set([&]() { + directory_entered = true; + while (!directory_release) std::this_thread::yield(); + }); + std::thread directory_notifier([&]() { directory_callback.notify(); }); + while (!directory_entered) std::this_thread::yield(); + std::atomic directory_cleared{false}; + std::thread directory_clearer([&]() { + directory_callback.set(nullptr); + directory_cleared = true; + }); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + assert(!directory_cleared); + directory_release = true; + directory_notifier.join(); + directory_clearer.join(); + assert(directory_cleared); + + AppRegistryCallbackSlot slot; + BlockingCallback state{&slot}; + slot.set(block, &state); + + std::thread notifier([&slot]() { slot.notify(); }); + while (!state.entered) std::this_thread::yield(); + + std::atomic unregistered{false}; + std::thread unregister_thread([&]() { + slot.set(nullptr, nullptr); + unregistered = true; + }); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + assert(!unregistered); + + state.release = true; + notifier.join(); + unregister_thread.join(); + assert(unregistered); + assert(state.calls == 1); + slot.notify(); + assert(state.calls == 1); + + slot.set(unregister_self, &slot); + slot.notify(); + slot.notify(); + + ReplacementState replacement{&slot}; + slot.set(replace_self, &replacement); + slot.notify(); + assert(replacement.old_calls == 1 && replacement.new_calls == 0); + slot.notify(); + assert(replacement.old_calls == 1 && replacement.new_calls == 1); + + int old_owner = 0; + int new_owner = 0; + slot.set(count_int, &old_owner); + slot.set(count_int, &new_owner); + assert(!slot.clear_if_matches(count_int, &old_owner)); + slot.notify(); + assert(new_owner == 1); + assert(slot.clear_if_matches(count_int, &new_owner)); + slot.notify(); + assert(new_owner == 1); +} diff --git a/projects/APPLaunch/tests/test_async_operation_lifecycle.cpp b/projects/APPLaunch/tests/test_async_operation_lifecycle.cpp new file mode 100644 index 00000000..b091f28c --- /dev/null +++ b/projects/APPLaunch/tests/test_async_operation_lifecycle.cpp @@ -0,0 +1,89 @@ +#include "../main/ui/model/async_operation_lifecycle.hpp" + +#include +#include +#include +#include +#include + +using setting::AsyncOperationLifecycle; + +int main() +{ + AsyncOperationLifecycle lifecycle; + const auto first = lifecycle.begin(); + assert(first); + assert(!lifecycle.begin()); + assert(lifecycle.activate(first, 42)); + assert(first.current()); + assert(lifecycle.active()); + assert(first.complete()); + assert(first.current()); + assert(!first.complete()); + assert(!lifecycle.active()); + + const auto stale = lifecycle.begin(); + assert(stale); + assert(lifecycle.abort(stale)); + assert(!stale.current()); + const auto current = lifecycle.begin(); + assert(current); + assert(!first.current()); + assert(lifecycle.activate(current, 77)); + assert(!stale.complete()); + assert(lifecycle.active()); + assert(current.complete()); + + const auto reentrant = lifecycle.begin(); + assert(reentrant.complete()); + assert(reentrant.current()); + assert(!lifecycle.activate(reentrant, 88)); + assert(!lifecycle.active()); + + const auto next_generation = lifecycle.begin(); + assert(next_generation); + assert(!reentrant.current()); + assert(!reentrant.complete()); + assert(next_generation.current()); + assert(next_generation.complete()); + + AsyncOperationLifecycle concurrent; + std::atomic winners{0}; + std::vector threads; + for (int index = 0; index < 16; ++index) { + threads.emplace_back([&] { + const auto token = concurrent.begin(); + if (token) + ++winners; + }); + } + for (auto &thread : threads) + thread.join(); + assert(winners.load() == 1); + assert(concurrent.active()); + + AsyncOperationLifecycle cancellable; + const auto cancel_token = cancellable.begin(); + assert(cancellable.activate(cancel_token, 1234)); + assert(cancellable.shutdown() == 1234); + assert(cancellable.shutdown() == 0); + assert(!cancel_token.complete()); + assert(!cancel_token.current()); + assert(!cancellable.begin()); + + AsyncOperationLifecycle destroyed_before_completion; + const auto queued_completion = destroyed_before_completion.begin(); + assert(queued_completion); + destroyed_before_completion.shutdown(); + assert(!queued_completion.current()); + assert(!queued_completion.complete()); + + AsyncOperationLifecycle::Token late; + { + auto owner = std::make_unique(); + late = owner->begin(); + assert(owner->activate(late, 9)); + } + assert(!late.complete()); + assert(!late.current()); +} diff --git a/projects/APPLaunch/tests/test_bluetooth_page_model.cpp b/projects/APPLaunch/tests/test_bluetooth_page_model.cpp new file mode 100644 index 00000000..9906db3d --- /dev/null +++ b/projects/APPLaunch/tests/test_bluetooth_page_model.cpp @@ -0,0 +1,165 @@ +#include "../main/ui/model/bluetooth_page_model.hpp" +#include "../main/ui/model/bluetooth_scan_lifecycle_contract.hpp" + +#include +#include +#include + +int main() +{ + BluetoothWireStatus wire_status; + assert(BluetoothPageModel::decode_status_record( + "1\tAA:BB:CC:DD:EE:FF\t0\tCardputerZero", wire_status)); + assert(wire_status.powered); + assert(!wire_status.discoverable); + assert(wire_status.alias == "CardputerZero"); + const BluetoothWireStatus unchanged_status = wire_status; + assert(!BluetoothPageModel::decode_status_record( + "yes\tAA:BB:CC:DD:EE:FF\t0\tBroken", wire_status)); + assert(wire_status.alias == unchanged_status.alias); + assert(!BluetoothPageModel::decode_status_record("1\t\t0\tAlias", wire_status)); + + BluetoothWireDevice wire_device; + assert(BluetoothPageModel::decode_device_record( + "AA:BB:CC:DD:EE:01\t-67\t1\t0\t1\tKeyboard", wire_device)); + assert(wire_device.rssi == -67); + assert(wire_device.connected); + assert(!wire_device.paired); + assert(wire_device.trusted); + assert(wire_device.name == "Keyboard"); + const BluetoothWireDevice unchanged_device = wire_device; + assert(!BluetoothPageModel::decode_device_record( + "AA:BB:CC:DD:EE:01\t-67dBm\t1\t0\t1\tKeyboard", wire_device)); + assert(wire_device.rssi == unchanged_device.rssi); + assert(!BluetoothPageModel::decode_device_record( + "AA:BB:CC:DD:EE:01\t-67\t2\t0\t1\tKeyboard", wire_device)); + assert(!BluetoothPageModel::decode_device_record( + "AA:BB:CC:DD:EE:01\t-67\t1\t0\tKeyboard", wire_device)); + + int timer = 0; + int stale_timer = 0; + int page = 0; + int screen = 0; + int child = 0; + static_assert(noexcept(bluetooth_scan_callback_allowed( + static_cast(nullptr), static_cast(nullptr), + static_cast(nullptr), false))); + assert(bluetooth_scan_callback_allowed(&timer, &timer, &page, true)); + assert(!bluetooth_scan_callback_allowed(&stale_timer, &timer, &page, true)); + assert(!bluetooth_scan_callback_allowed(&timer, &timer, nullptr, true)); + assert(!bluetooth_scan_callback_allowed(&timer, &timer, &page, false)); + assert(bluetooth_scan_callback_ready(&timer, &timer, &page)); + assert(!bluetooth_scan_callback_ready(&stale_timer, &timer, &page)); + assert(!bluetooth_scan_callback_ready(nullptr, &timer, &page)); + assert(!bluetooth_scan_callback_ready(&timer, &timer, nullptr)); + assert(bluetooth_feedback_callback_ready(&timer, &timer, &page)); + assert(!bluetooth_feedback_callback_ready(&stale_timer, &timer, &page)); + assert(!bluetooth_feedback_callback_ready(&timer, &timer, nullptr)); + static_assert(noexcept(bluetooth_feedback_callback_allowed( + static_cast(nullptr), static_cast(nullptr), + static_cast(nullptr), false))); + assert(bluetooth_feedback_callback_allowed(&timer, &timer, &page, true)); + assert(!bluetooth_feedback_callback_allowed(&stale_timer, &timer, &page, true)); + assert(!bluetooth_feedback_callback_allowed(&timer, &timer, nullptr, true)); + assert(!bluetooth_feedback_callback_allowed(&timer, &timer, &page, false)); + assert(bluetooth_screen_delete_is_direct(&screen, &screen)); + assert(!bluetooth_screen_delete_is_direct(&child, &screen)); + assert(!bluetooth_screen_delete_is_direct(static_cast(nullptr), &screen)); + int stale_screen = 0; + static_assert(noexcept(bluetooth_feedback_screen_delete_matches( + static_cast(nullptr), static_cast(nullptr), + static_cast(nullptr)))); + assert(bluetooth_feedback_screen_delete_matches(&screen, &screen, &screen)); + assert(!bluetooth_feedback_screen_delete_matches(&child, &screen, &screen)); + assert(!bluetooth_feedback_screen_delete_matches( + &stale_screen, &stale_screen, &screen)); + assert(!bluetooth_feedback_screen_delete_matches( + &screen, &screen, static_cast(nullptr))); + assert(bluetooth_scan_action_needs_watchdog_recovery(true, false)); + assert(!bluetooth_scan_action_needs_watchdog_recovery(true, true)); + assert(!bluetooth_scan_action_needs_watchdog_recovery(false, false)); + static_assert(noexcept(bluetooth_action_entry_allowed(false, false))); + assert(bluetooth_action_entry_allowed(true, false)); + assert(!bluetooth_action_entry_allowed(true, true)); + assert(bluetooth_action_entry_allowed(false, false)); + assert(bluetooth_action_entry_allowed(false, true)); + static_assert(noexcept(bluetooth_async_completion_allowed( + false, static_cast(nullptr)))); + assert(bluetooth_async_completion_allowed(true, &page)); + assert(!bluetooth_async_completion_allowed(false, &page)); + assert(!bluetooth_async_completion_allowed( + true, static_cast(nullptr))); + assert(bluetooth_scan_action_should_resume(true, -1)); + assert(!bluetooth_scan_action_should_resume(true, 0)); + assert(!bluetooth_scan_action_should_resume(false, -1)); + + BluetoothPageModel model; + assert(model.named_only()); + assert(model.alias() == BluetoothPageModel::DEFAULT_ALIAS); + + model.set_alias(""); + model.begin_alias_edit(); + assert(model.alias_input() == BluetoothPageModel::DEFAULT_ALIAS); + assert(model.erase_alias_character()); + assert(model.append_alias_text("X\nY\tZ")); + assert(model.alias_input().find('\n') == std::string::npos); + assert(model.alias_input().find('\t') == std::string::npos); + + model.set_alias("A"); + model.begin_alias_edit(); + const std::string long_text(BluetoothPageModel::ALIAS_INPUT_LIMIT + 10, 'x'); + assert(model.append_alias_text(long_text.c_str())); + assert(model.alias_input().size() == BluetoothPageModel::ALIAS_INPUT_LIMIT); + assert(!model.append_alias_text("y")); + + model.set_alias(""); + model.begin_alias_edit(); + while (model.erase_alias_character()) {} + assert(model.sanitized_alias() == BluetoothPageModel::DEFAULT_ALIAS); + + const std::vector devices = { + {"AA:BB:CC:DD:EE:01", "Speaker", true, true}, + {"AA:BB:CC:DD:EE:02", "", false, false}, + {"AA:BB:CC:DD:EE:03", "AA-BB-CC-DD-EE-03", false, false}, + {"AA:BB:CC:DD:EE:04", "Keyboard", true, false}, + }; + + model.set_list_mode(BluetoothListMode::MANAGED); + model.rebuild_rows(devices); + assert(model.rows().size() == 2); + assert(model.rows().front().is_header); + assert(model.selected_device_index() == 0); + model.select_next_device(-1); + assert(model.selected_device_index() == 0); + + model.set_list_mode(BluetoothListMode::SCAN); + model.rebuild_rows(devices); + assert(model.rows().size() == 3); + assert(model.selected_device_index() == 0); + model.select_next_device(1); + assert(model.selected_device_index() == 3); + model.select_next_device(1); + assert(model.selected_device_index() == 3); + + model.set_named_only(false); + model.rebuild_rows(devices); + assert(model.rows().size() == devices.size() + 1); + assert(model.selected_device_index() == 1); + model.select_next_device(1); + assert(model.selected_device_index() == 2); + + model.set_discoverable(true); + assert(model.discoverable()); + + assert(!model.feedback_pending()); + assert(model.begin_feedback()); + assert(model.feedback_pending()); + assert(!model.begin_feedback()); + assert(model.finish_feedback()); + assert(!model.feedback_pending()); + assert(!model.finish_feedback()); + assert(model.begin_feedback()); + model.set_list_mode(BluetoothListMode::MANAGED); + assert(!model.feedback_pending()); + model.cancel_feedback(); +} diff --git a/projects/APPLaunch/tests/test_boot_action_policy.cpp b/projects/APPLaunch/tests/test_boot_action_policy.cpp new file mode 100644 index 00000000..a113b5a3 --- /dev/null +++ b/projects/APPLaunch/tests/test_boot_action_policy.cpp @@ -0,0 +1,43 @@ +#include "../main/ui/model/boot_action_policy.hpp" + +#include +#include + +using boot_actions::Action; +using boot_actions::Operation; + +int main() +{ + const auto &reboot = boot_actions::presentation(Action::Reboot); + assert(reboot.label == "Reboot"); + assert(reboot.confirmation_title == "Reboot?"); + assert(boot_actions::operation_plan(Action::Reboot) == std::vector{Operation::Reboot}); + + const auto &shutdown = boot_actions::presentation(Action::Shutdown); + assert(shutdown.label == "Shutdown"); + assert(shutdown.confirmation_title == "Shutdown?"); + assert(boot_actions::operation_plan(Action::Shutdown) == std::vector{Operation::Shutdown}); + + const auto &factory_reset = boot_actions::presentation(Action::FactoryReset); + assert(factory_reset.confirmation_title.empty()); + const std::vector factory_plan = { + Operation::RemoveLauncherSettings, + Operation::Reboot, + }; + assert(boot_actions::operation_plan(Action::FactoryReset) == factory_plan); + + const auto &rearm_oobe = boot_actions::presentation(Action::RearmOobe); + assert(rearm_oobe.confirmation_title.empty()); + const std::vector oobe_plan = { + Operation::TouchOobeMarker, + Operation::Reboot, + }; + assert(boot_actions::operation_plan(Action::RearmOobe) == oobe_plan); + + assert(boot_actions::may_continue(Operation::RemoveLauncherSettings, true)); + assert(!boot_actions::may_continue(Operation::RemoveLauncherSettings, false)); + assert(boot_actions::may_continue(Operation::TouchOobeMarker, true)); + assert(!boot_actions::may_continue(Operation::TouchOobeMarker, false)); + assert(!boot_actions::may_continue(Operation::Reboot, true)); + assert(!boot_actions::may_continue(Operation::Shutdown, true)); +} diff --git a/projects/APPLaunch/tests/test_config_default_file.py b/projects/APPLaunch/tests/test_config_default_file.py new file mode 100644 index 00000000..d66846ac --- /dev/null +++ b/projects/APPLaunch/tests/test_config_default_file.py @@ -0,0 +1,75 @@ +import tempfile +import unittest +from pathlib import Path + +from build_support.config_default_file import GENERATED_CONFIG_FILES +from build_support.config_default_file import sync_config_default, sync_config_tmp + + +class ConfigDefaultFileTest(unittest.TestCase): + def test_selection_change_invalidates_generated_config_once(self): + with tempfile.TemporaryDirectory() as temporary_directory: + config_dir = Path(temporary_directory) + sdl_config = config_dir / "sdl.mk" + cross_config = config_dir / "cross.mk" + sdl_config.write_text("MODE=sdl\n", encoding="utf-8") + cross_config.write_text("MODE=cross\n", encoding="utf-8") + self.assertEqual(sync_config_default(config_dir, sdl_config), ()) + stamp = config_dir / "config_default_file" + stamp_mtime = stamp.stat().st_mtime_ns + + for filename in GENERATED_CONFIG_FILES: + (config_dir / filename).write_text("generated\n", encoding="utf-8") + + self.assertEqual(sync_config_default(config_dir, sdl_config), ()) + self.assertEqual(stamp.stat().st_mtime_ns, stamp_mtime) + for filename in GENERATED_CONFIG_FILES: + self.assertTrue((config_dir / filename).exists()) + + self.assertEqual( + sync_config_default(config_dir, cross_config), + GENERATED_CONFIG_FILES, + ) + self.assertEqual( + stamp.read_text(encoding="utf-8"), + str(cross_config.resolve()) + "\nMODE=cross\n", + ) + for filename in GENERATED_CONFIG_FILES: + self.assertFalse((config_dir / filename).exists()) + + def test_content_change_invalidates_same_selection(self): + with tempfile.TemporaryDirectory() as temporary_directory: + config_dir = Path(temporary_directory) + selected_config = config_dir / "defaults.mk" + selected_config.write_text("VALUE=1\n", encoding="utf-8") + self.assertEqual(sync_config_default(config_dir, selected_config), ()) + + for filename in GENERATED_CONFIG_FILES: + (config_dir / filename).write_text("generated\n", encoding="utf-8") + selected_config.write_text("VALUE=2\n", encoding="utf-8") + + self.assertEqual( + sync_config_default(config_dir, selected_config), + GENERATED_CONFIG_FILES, + ) + self.assertIn( + "VALUE=2\n", + (config_dir / "config_default_file").read_text(encoding="utf-8"), + ) + + def test_cross_config_tmp_is_content_stable_and_removed_for_sdl(self): + with tempfile.TemporaryDirectory() as temporary_directory: + config_dir = Path(temporary_directory) + content = 'CONFIG_TOOLCHAIN_SYSROOT="/sdk"\n' + self.assertTrue(sync_config_tmp(config_dir, content)) + config_tmp = config_dir / "config_tmp.mk" + config_tmp_mtime = config_tmp.stat().st_mtime_ns + self.assertFalse(sync_config_tmp(config_dir, content)) + self.assertEqual(config_tmp.stat().st_mtime_ns, config_tmp_mtime) + self.assertTrue(sync_config_tmp(config_dir, None)) + self.assertFalse(config_tmp.exists()) + self.assertFalse(sync_config_tmp(config_dir, None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/projects/APPLaunch/tests/test_desktop_entry.cpp b/projects/APPLaunch/tests/test_desktop_entry.cpp new file mode 100644 index 00000000..964ca0f1 --- /dev/null +++ b/projects/APPLaunch/tests/test_desktop_entry.cpp @@ -0,0 +1,44 @@ +#include "desktop_entry.h" + +#include +#include + +int main() +{ + const auto entry = parse_desktop_entry( + "[Other]\nName=Ignored\n[Desktop Entry]\r\n" + " Name = Demo App \r\nIcon = demo.png\n" + "Exec=/usr/bin/demo --flag=value\nTerminal=True\nSysplause=0\n"); + assert(entry); + assert(entry->name == "Demo App"); + assert(entry->icon == "demo.png"); + assert(entry->exec == "/usr/bin/demo --flag=value"); + assert(entry->terminal); + assert(!entry->sysplause); + assert(!parse_desktop_entry("[Desktop Entry]\nName=Missing Exec\n")); + assert(!parse_desktop_entry("[Desktop Entry]\nExec=missing-name\n")); + + const auto defaults = parse_desktop_entry("[Desktop Entry]\nName=A\nExec=a\n"); + assert(defaults && !defaults->terminal && defaults->sysplause); + + assert(desktop_entry_filename_valid("demo.desktop")); + assert(!desktop_entry_filename_valid(".desktop")); + assert(!desktop_entry_filename_valid("../demo.desktop")); + assert(!desktop_entry_filename_valid("sub\\demo.desktop")); + assert(!desktop_entry_filename_valid("bad\n.desktop")); + + assert(!parse_desktop_entry( + "[Desktop Entry]\nType=Link\nName=A\nExec=python3\n")); + assert(!parse_desktop_entry( + "[Desktop Entry]\nHidden=true\nName=A\nExec=python3\n")); + assert(!parse_desktop_entry( + "[Desktop Entry]\nNoDisplay=1\nName=A\nExec=python3\n")); + std::string nul_exec = "[Desktop Entry]\nName=A\nExec=python3"; + nul_exec.push_back('\0'); + nul_exec += " --unsafe\n"; + assert(!parse_desktop_entry(nul_exec)); + assert(!parse_desktop_entry("[Desktop Entry]\nName=Bad\tName\nExec=python3\n")); + assert(!parse_desktop_entry( + "[Desktop Entry]\nName=" + + std::string(DESKTOP_ENTRY_MAX_NAME_BYTES + 1, 'a') + "\nExec=python3\n")); +} diff --git a/projects/APPLaunch/tests/test_developer_page_model.cpp b/projects/APPLaunch/tests/test_developer_page_model.cpp new file mode 100644 index 00000000..e3460a18 --- /dev/null +++ b/projects/APPLaunch/tests/test_developer_page_model.cpp @@ -0,0 +1,94 @@ +#include "../main/ui/model/developer_page_model.hpp" + +#include +#include + +using setting::DeveloperPageModel; +using setting::DeveloperOverlayLifecycleModel; +using setting::DeveloperPairViewBuildContract; + +int main() +{ + int tracked = 0; + int stale = 0; + static_assert(noexcept(setting::developer_delete_callback_allowed( + &tracked, &tracked, &tracked))); + assert(setting::developer_delete_callback_allowed(&tracked, &tracked, &tracked)); + assert(!setting::developer_delete_callback_allowed(&tracked, &stale, &tracked)); + assert(!setting::developer_delete_callback_allowed(&stale, &stale, &tracked)); + assert(!setting::developer_delete_callback_allowed( + static_cast(nullptr), &tracked, &tracked)); + static_assert(noexcept(setting::developer_async_completion_allowed( + false, static_cast(nullptr), static_cast(nullptr)))); + assert(setting::developer_async_completion_allowed( + true, &tracked, &tracked)); + assert(!setting::developer_async_completion_allowed( + false, &tracked, &tracked)); + assert(!setting::developer_async_completion_allowed( + true, static_cast(nullptr), &tracked)); + assert(!setting::developer_async_completion_allowed( + true, &tracked, static_cast(nullptr))); + + DeveloperPairViewBuildContract pair_build; + assert(!pair_build.ready()); + for (std::size_t index = 0; + index < DeveloperPairViewBuildContract::REQUIRED_LABELS - 1; ++index) + pair_build.label_created(); + assert(!pair_build.ready()); + pair_build.label_created(); + assert(pair_build.ready()); + pair_build.label_created(); + assert(!pair_build.ready()); + + DeveloperPageModel model; + model.begin_pairing(true); + assert(model.enable_after_pair()); + assert(model.pairing_preview() == "_"); + assert(model.append_pairing_text("QAAAA")); + assert(model.pairing_preview() == "QAAAA_"); + + const std::string utf8 = "\xE4\xB8\xAD"; + assert(model.append_pairing_text(utf8)); + assert(model.erase_pairing_character()); + assert(model.pair_key() == "QAAAA"); + + model.clear_pairing(); + assert(model.pair_key().empty()); + assert(!model.enable_after_pair()); + assert(!model.erase_pairing_character()); + + assert(model.append_pairing_text(std::string(DeveloperPageModel::MAX_PAIR_KEY_BYTES, 'a'))); + assert(!model.append_pairing_text("b")); + const std::string preview = model.pairing_preview(); + assert(preview == "..." + std::string(DeveloperPageModel::PAIR_PREVIEW_BYTES, 'a') + "_"); + + model.reset_authorization_selection(5); + assert(model.authorization_selection() == 0); + assert(!model.select_previous_authorization()); + assert(model.select_next_authorization(5)); + assert(model.select_next_authorization(5)); + assert(model.authorization_selection() == 2); + assert(model.authorization_window_start() == 1); + assert(model.select_next_authorization(5)); + assert(model.select_next_authorization(5)); + assert(!model.select_next_authorization(5)); + assert(model.authorization_window_start() == 3); + + model.reconcile_authorization_count(2); + assert(model.authorization_selection() == 1); + model.reconcile_authorization_count(0); + assert(model.authorization_selection() == 0); + assert(!model.select_next_authorization(0)); + + DeveloperOverlayLifecycleModel overlay; + assert(!overlay.active()); + assert(overlay.open()); + assert(overlay.active()); + assert(!overlay.open()); + assert(overlay.close()); + assert(!overlay.active()); + assert(!overlay.close()); + assert(overlay.open()); + assert(overlay.close()); + return 0; +} diff --git a/projects/APPLaunch/tests/test_esc_hold_lifecycle_model.cpp b/projects/APPLaunch/tests/test_esc_hold_lifecycle_model.cpp new file mode 100644 index 00000000..3a5db663 --- /dev/null +++ b/projects/APPLaunch/tests/test_esc_hold_lifecycle_model.cpp @@ -0,0 +1,44 @@ +#include "../main/ui/model/esc_hold_lifecycle_model.hpp" + +#include +#include + +int main() +{ + EscHoldLifecycleModel model; + assert(model.press(1000)); + assert(!model.press(1400)); + auto decision = model.poll(2499, true, true); + assert(!decision.show_hint && !decision.force_home); + decision = model.poll(2500, true, true); + assert(decision.show_hint && !decision.force_home); + model.clear_hint_ownership(); + decision = model.poll(2600, true, true); + assert(!decision.show_hint); + assert(!model.release()); + + assert(model.press(5000)); + decision = model.poll(8000, true, true); + assert(decision.show_hint && decision.force_home && decision.pause_timer); + decision = model.poll(9000, true, true); + assert(!decision.force_home); + assert(model.release()); + + assert(model.press(10000)); + decision = model.poll(10100, false, true); + assert(decision.pause_timer && !model.holding()); + assert(model.press(10200)); + + model.cancel(); + assert(model.press(UINT32_MAX - 1000)); + decision = model.poll(499, true, true); + assert(decision.show_hint && !decision.force_home); + decision = model.poll(1999, true, true); + assert(decision.force_home); + + int timer = 0; + int stale_timer = 0; + assert(esc_hold_timer_is_current(&timer, &timer)); + assert(!esc_hold_timer_is_current(&stale_timer, &timer)); + assert(!esc_hold_timer_is_current(nullptr, &timer)); +} diff --git a/projects/APPLaunch/tests/test_global_hint_policy.cpp b/projects/APPLaunch/tests/test_global_hint_policy.cpp new file mode 100644 index 00000000..3d5014af --- /dev/null +++ b/projects/APPLaunch/tests/test_global_hint_policy.cpp @@ -0,0 +1,66 @@ +#include "../main/ui/model/global_hint_policy.hpp" + +#include "input_keys.h" + +#include +#include + +#ifndef KEY_RIGHTSHIFT +#define KEY_RIGHTSHIFT 54 +#endif +#ifndef KEY_COMPOSE +#define KEY_COMPOSE 127 +#endif +#ifndef KEY_FN +#define KEY_FN 0x1d0 +#endif +#ifndef KEY_MUTE +#define KEY_MUTE 113 +#endif +#ifndef KEY_VOLUMEUP +#define KEY_VOLUMEUP 115 +#endif +#ifndef KEY_BRIGHTNESSDOWN +#define KEY_BRIGHTNESSDOWN 224 +#endif + +int main() +{ + const GlobalHintPolicy policy; + + assert(policy.action_for({KEY_BRIGHTNESSDOWN, nullptr, true}) == + GlobalHintAction::BRIGHTNESS_DOWN); + assert(policy.action_for({KEY_VOLUMEUP, nullptr, false, true}) == + GlobalHintAction::VOLUME_UP); + assert(policy.action_for({KEY_MUTE, nullptr, false, true}) == GlobalHintAction::NONE); + assert(policy.action_for({KEY_MUTE, nullptr, true}) == GlobalHintAction::TOGGLE_MUTE); + + assert(policy.action_for({KEY_S, nullptr, true, false, true, true}) == + GlobalHintAction::TAKE_SCREENSHOT); + assert(policy.action_for({KEY_S, nullptr, true, false, true, false}) == + GlobalHintAction::NONE); + assert(policy.action_for({KEY_S, nullptr, false, true, true, true}) == + GlobalHintAction::NONE); + + assert(policy.action_for({KEY_LEFTSHIFT, nullptr, true}) == + GlobalHintAction::SHOW_LOCK_HINT); + assert(policy.action_for({KEY_RIGHTSHIFT, nullptr, true}) == + GlobalHintAction::SHOW_LOCK_HINT); + assert(policy.action_for({KEY_COMPOSE, nullptr, true}) == + GlobalHintAction::SHOW_LOCK_HINT); + assert(policy.action_for({999, "Multi_key", true}) == GlobalHintAction::SHOW_LOCK_HINT); + assert(policy.action_for({999, "SYM", true}) == GlobalHintAction::SHOW_LOCK_HINT); + assert(policy.action_for({999, "sym", true}) == GlobalHintAction::NONE); + assert(policy.action_for({999, "SYM", false}) == GlobalHintAction::NONE); + assert(policy.action_for({KEY_FN, "SYM", true}) == GlobalHintAction::NONE); + assert(policy.action_for({999, nullptr, true}) == GlobalHintAction::NONE); + + assert(GlobalHintScreenshotPolicy::should_save(0)); + assert(!GlobalHintScreenshotPolicy::should_save(-1)); + assert(std::string(GlobalHintScreenshotPolicy::result_message(0, 0)) == + "Saved to ~/Screenshots"); + assert(std::string(GlobalHintScreenshotPolicy::result_message(-1, 0)) == + "Screenshot failed"); + assert(std::string(GlobalHintScreenshotPolicy::result_message(0, -1)) == + "Screenshot failed"); +} diff --git a/projects/APPLaunch/tests/test_global_overlay_model.cpp b/projects/APPLaunch/tests/test_global_overlay_model.cpp new file mode 100644 index 00000000..7ff277b3 --- /dev/null +++ b/projects/APPLaunch/tests/test_global_overlay_model.cpp @@ -0,0 +1,22 @@ +#include "../main/ui/model/global_overlay_model.hpp" + +#include + +int main() +{ + GlobalOverlayModel model; + assert(model.request_show()); + model.mark_created(); + assert(model.created() && model.visible()); + assert(!model.request_show()); + + model.hide(); + model.hide(); + assert(model.created() && !model.visible()); + + model.object_deleted(); + assert(!model.created() && !model.visible()); + assert(model.request_show()); + model.mark_created(); + assert(model.created() && model.visible()); +} diff --git a/projects/APPLaunch/tests/test_ip_panel_model.cpp b/projects/APPLaunch/tests/test_ip_panel_model.cpp new file mode 100644 index 00000000..410b9ca9 --- /dev/null +++ b/projects/APPLaunch/tests/test_ip_panel_model.cpp @@ -0,0 +1,77 @@ +#include "../main/ui/model/ip_panel_model.hpp" + +#include + +namespace { + +IpInterfaceInfo interface(const char *name) +{ + return {name, "192.0.2.1", "255.255.255.0", true}; +} + +} // namespace + +int main() +{ + static_assert(noexcept(ip_panel_refresh_callback_allowed(true, true))); + static_assert(ip_panel_refresh_callback_allowed(true, true)); + static_assert(!ip_panel_refresh_callback_allowed(false, true)); + static_assert(!ip_panel_refresh_callback_allowed(true, false)); + + int root = 0; + int stale_root = 0; + static_assert(noexcept(ip_panel_root_callback_allowed(&root, &root))); + assert(ip_panel_root_callback_allowed(&root, &root)); + assert(!ip_panel_root_callback_allowed(&stale_root, &root)); + assert(!ip_panel_root_callback_allowed(static_cast(nullptr), &root)); + + int background = 1; + int list = 2; + static_assert(noexcept(ip_panel_view_ready(&background, &list))); + assert(ip_panel_view_ready(&background, &list)); + assert(!ip_panel_view_ready(static_cast(nullptr), &list)); + assert(!ip_panel_view_ready(&background, static_cast(nullptr))); + + static_assert(noexcept(ip_panel_owned_delete_callback_allowed( + &background, &background))); + assert(ip_panel_owned_delete_callback_allowed(&background, &background)); + assert(!ip_panel_owned_delete_callback_allowed(&background, &list)); + assert(!ip_panel_owned_delete_callback_allowed( + static_cast(nullptr), &background)); + + IpPanelModel model; + model.replace({interface("wlan0"), interface("eth0"), interface("lo"), + interface("usb0"), interface("docker0"), interface("br0")}); + assert(model.interfaces().size() == 6); + assert(model.interfaces()[0].name == "br0"); + assert(model.interfaces()[5].name == "wlan0"); + assert(model.selected_index() == 0); + assert(!model.select_previous()); + + assert(model.select_next()); + assert(model.select_next()); + assert(model.select_next()); + assert(model.interfaces()[model.selected_index()].name == "lo"); + assert(model.visible_offset(4) == 1); + + model.replace({interface("new0"), interface("lo"), interface("eth0"), + interface("wlan0"), interface("br0")}); + assert(model.interfaces()[model.selected_index()].name == "lo"); + assert(model.visible_offset(4) == 0); + + model.replace({interface("eth0")}); + assert(model.selected_index() == 0); + assert(!model.select_next()); + + model.replace({}); + assert(model.selected_index() == 0); + assert(model.visible_offset(4) == 0); + + model.replace({interface("eth0"), interface("wlan0")}); + assert(model.select_next()); + assert(!model.apply_refresh(false, {interface("failed0")})); + assert(model.interfaces().size() == 2); + assert(model.interfaces()[model.selected_index()].name == "wlan0"); + assert(model.apply_refresh(true, {interface("usb0"), interface("wlan0")})); + assert(model.interfaces()[model.selected_index()].name == "wlan0"); +} diff --git a/projects/APPLaunch/tests/test_keyboard_text_input.cpp b/projects/APPLaunch/tests/test_keyboard_text_input.cpp new file mode 100644 index 00000000..20b0df6a --- /dev/null +++ b/projects/APPLaunch/tests/test_keyboard_text_input.cpp @@ -0,0 +1,39 @@ +#include "../main/ui/keyboard_text_input.hpp" + +#include "input_keys.h" +#include "keyboard_input.h" + +#include +#include + +int main() +{ + using launcher_ui::text_input::append_limited; + using launcher_ui::text_input::erase_last_codepoint; + using launcher_ui::text_input::key_text; + + assert(key_text(KEY_A, nullptr) == "a"); + assert(key_text(KEY_7, nullptr) == "7"); + assert(key_text(KEY_DOT, nullptr) == "."); + assert(key_text(KEY_MINUS, nullptr) == "-"); + assert(key_text(KEY_ENTER, nullptr).empty()); + + key_item item{}; + std::strcpy(item.utf8, "A"); + assert(key_text(KEY_A, &item) == "A"); + std::strcpy(item.utf8, "\xE7\x95\x8C"); + assert(key_text(0, &item) == "\xE7\x95\x8C"); + item.utf8[0] = '\n'; + item.utf8[1] = '\0'; + assert(key_text(0, &item).empty()); + + std::string buffer = "abc"; + assert(append_limited(buffer, "de", 5) && buffer == "abcde"); + assert(!append_limited(buffer, "f", 5)); + assert(!append_limited(buffer, "\n", 8)); + assert(append_limited(buffer, "\xE7\x95\x8C", 8)); + assert(erase_last_codepoint(buffer) && buffer == "abcde"); + while (erase_last_codepoint(buffer)) { + } + assert(buffer.empty() && !erase_last_codepoint(buffer)); +} diff --git a/projects/APPLaunch/tests/test_launcher_media_model.cpp b/projects/APPLaunch/tests/test_launcher_media_model.cpp new file mode 100644 index 00000000..5517180a --- /dev/null +++ b/projects/APPLaunch/tests/test_launcher_media_model.cpp @@ -0,0 +1,86 @@ +#include "../main/ui/model/launcher_media_model.hpp" +#include "../main/ui/model/launcher_media_osd_contract.hpp" + +#include +#include + +int main() +{ + int parsed = 7; + assert(LauncherMediaControlsModel::parse_int("0", parsed) && parsed == 0); + assert(LauncherMediaControlsModel::parse_int("-42", parsed) && parsed == -42); + assert(!LauncherMediaControlsModel::parse_int("", parsed)); + assert(!LauncherMediaControlsModel::parse_int("12px", parsed)); + assert(!LauncherMediaControlsModel::parse_int(" 12", parsed)); + assert(!LauncherMediaControlsModel::parse_int("999999999999999999999", parsed)); + assert(LauncherMediaControlsModel::parse_percent("100", parsed) && parsed == 100); + assert(!LauncherMediaControlsModel::parse_percent("-1", parsed)); + assert(!LauncherMediaControlsModel::parse_percent("101", parsed)); + + int container = 0; + int icon = 0; + int title = 0; + int value = 0; + int bar = 0; + assert(launcher_media_osd_ui_ready(&container, &icon, &title, &value, &bar)); + assert(!launcher_media_osd_ui_ready(nullptr, &icon, &title, &value, &bar)); + assert(!launcher_media_osd_ui_ready(&container, nullptr, &title, &value, &bar)); + assert(!launcher_media_osd_ui_ready(&container, &icon, &title, &value, nullptr)); + int timer = 0; + int stale_timer = 0; + assert(launcher_media_osd_timer_is_current(&timer, &timer)); + assert(!launcher_media_osd_timer_is_current(&stale_timer, &timer)); + assert(!launcher_media_osd_timer_is_current(nullptr, &timer)); + + LauncherMediaControlsModel controls; + assert(!controls.has_volume()); + assert(!controls.has_brightness()); + assert(controls.volume_or(50) == 50); + controls.set_volume(110); + assert(controls.has_volume()); + assert(controls.volume_or(0) == 100); + controls.set_volume(-10); + assert(controls.volume_or(50) == 0); + + assert(LauncherMediaControlsModel::percent_from_raw(25, 50) == 50); + assert(LauncherMediaControlsModel::percent_from_raw(200, 50) == 100); + assert(LauncherMediaControlsModel::percent_from_raw(INT_MAX, INT_MAX) == 100); + assert(LauncherMediaControlsModel::percent_from_raw(INT_MIN, INT_MAX) == 0); + assert(LauncherMediaControlsModel::raw_from_percent(1, 40) == 1); + assert(LauncherMediaControlsModel::raw_from_percent(50, 255) == 127); + assert(LauncherMediaControlsModel::raw_from_percent(100, INT_MAX) == INT_MAX); + controls.set_brightness_from_raw(64, 255); + assert(controls.has_brightness()); + assert(controls.brightness_or(0) == 25); + assert(controls.toggle_mute()); + assert(controls.muted()); + assert(!controls.toggle_mute()); + assert(!controls.muted()); + controls.set_mute(true); + assert(!controls.toggle_mute()); + + LauncherMediaOsdModel osd; + assert(!osd.state().visible); + osd.show_level("Volume", "V", 120); + assert(osd.state().visible); + assert(osd.state().mode == LauncherMediaOsdMode::LEVEL); + assert(osd.state().title == "Volume"); + assert(osd.state().percent == 100); + osd.hide(); + assert(!osd.state().visible); + osd.show_mute(true, "M", "V"); + assert(osd.state().mode == LauncherMediaOsdMode::MUTE); + assert(osd.state().title == "Muted"); + assert(osd.state().icon == "M"); + osd.show_mute(false, "M", "V"); + assert(osd.state().visible); + assert(osd.state().title == "Sound On"); + assert(osd.state().icon == "V"); + osd.hide(); + osd.hide(); + assert(!osd.state().visible); + osd.show_level("Brightness", "B", 25); + assert(osd.state().visible); + assert(osd.state().percent == 25); + return 0; +} diff --git a/projects/APPLaunch/tests/test_launcher_navigation_model.cpp b/projects/APPLaunch/tests/test_launcher_navigation_model.cpp new file mode 100644 index 00000000..443885fb --- /dev/null +++ b/projects/APPLaunch/tests/test_launcher_navigation_model.cpp @@ -0,0 +1,119 @@ +#include "../main/ui/model/launcher_navigation_model.hpp" +#include "../main/ui/model/launcher_startup_contract.hpp" + +#include + +int main() +{ + int timer = 0; + int stale_timer = 0; + assert(launcher_startup_timer_is_current(&timer, &timer)); + assert(!launcher_startup_timer_is_current(&stale_timer, &timer)); + assert(!launcher_startup_timer_is_current(nullptr, &timer)); + assert(!launcher_startup_timer_is_current(&timer, static_cast(nullptr))); + + auto one_shot = launcher_startup_one_shot_decision(true, true, true); + assert(one_shot.retire_timer); + assert(one_shot.release_context); + assert(one_shot.complete); + + one_shot = launcher_startup_one_shot_decision(true, true, false); + assert(one_shot.retire_timer); + assert(one_shot.release_context); + assert(!one_shot.complete); + + one_shot = launcher_startup_one_shot_decision(true, false, false); + assert(one_shot.retire_timer); + assert(!one_shot.release_context); + assert(!one_shot.complete); + + one_shot = launcher_startup_one_shot_decision(false, true, true); + assert(!one_shot.retire_timer); + assert(!one_shot.release_context); + assert(!one_shot.complete); + + assert(launcher_startup_should_recover_deleted_gif(true, false)); + assert(!launcher_startup_should_recover_deleted_gif(true, true)); + assert(!launcher_startup_should_recover_deleted_gif(false, false)); + + LauncherStartupGeneration generation; + const auto first_generation = generation.begin(); + assert(generation.current(first_generation)); + assert(generation.stop(first_generation)); + const auto second_generation = generation.begin(); + assert(second_generation != first_generation); + assert(!generation.current(first_generation)); + assert(!generation.stop(first_generation)); + assert(generation.current(second_generation)); + + LauncherStartupDelayModel startup_delay; + assert(startup_delay.state() == LauncherStartupDelayState::IDLE); + assert(startup_delay.begin()); + assert(!startup_delay.begin()); + assert(!startup_delay.timer_created(true)); + assert(startup_delay.state() == LauncherStartupDelayState::WAITING); + assert(startup_delay.complete()); + assert(!startup_delay.complete()); + + LauncherStartupDelayModel creation_failure; + assert(creation_failure.begin()); + assert(creation_failure.timer_created(false)); + assert(creation_failure.state() == LauncherStartupDelayState::COMPLETE); + assert(!creation_failure.complete()); + + LauncherStartupDelayModel stopped; + assert(stopped.begin()); + stopped.stop(); + assert(stopped.state() == LauncherStartupDelayState::STOPPED); + assert(!stopped.complete()); + assert(!stopped.timer_created(false)); + + LauncherPageLifecycleModel lifecycle; + assert(lifecycle.state() == LauncherPageState::HOME); + assert(!lifecycle.request_home()); + assert(lifecycle.begin_app()); + assert(!lifecycle.begin_app()); + assert(lifecycle.request_home()); + assert(!lifecycle.request_home()); + lifecycle.cancel_home_request(); + assert(lifecycle.state() == LauncherPageState::APP_ACTIVE); + assert(lifecycle.request_home()); + assert(lifecycle.complete_home()); + assert(!lifecycle.complete_home()); + assert(lifecycle.state() == LauncherPageState::HOME); + assert(lifecycle.begin_app()); + lifecycle.stop(); + assert(lifecycle.state() == LauncherPageState::STOPPED); + assert(!lifecycle.request_home()); + assert(!lifecycle.begin_app()); + + LauncherNavigationModel model; + assert(model.selected_page() == LauncherNavigationModel::INITIAL_PAGE); + assert(model.keyboard_navigation_enabled()); + + assert(model.begin_navigation(LauncherNavigationDirection::PREVIOUS)); + assert(model.selected_page() == 1); + assert(model.is_animating()); + assert(!model.keyboard_navigation_enabled()); + assert(!model.begin_navigation(LauncherNavigationDirection::NEXT)); + assert(model.selected_page() == 1); + + assert(model.cancel_navigation()); + assert(model.selected_page() == LauncherNavigationModel::INITIAL_PAGE); + assert(!model.is_animating()); + assert(!model.cancel_navigation()); + + assert(model.begin_navigation(LauncherNavigationDirection::PREVIOUS)); + model.finish_navigation(); + assert(model.begin_navigation(LauncherNavigationDirection::PREVIOUS)); + model.finish_navigation(); + assert(model.begin_navigation(LauncherNavigationDirection::PREVIOUS)); + assert(model.selected_page() == LauncherNavigationModel::PAGE_COUNT - 1); + model.finish_navigation(); + + assert(model.begin_navigation(LauncherNavigationDirection::NEXT)); + assert(model.selected_page() == 0); + model.finish_navigation(); + + return 0; +} diff --git a/projects/APPLaunch/tests/test_lora_page_model.cpp b/projects/APPLaunch/tests/test_lora_page_model.cpp new file mode 100644 index 00000000..421d8f26 --- /dev/null +++ b/projects/APPLaunch/tests/test_lora_page_model.cpp @@ -0,0 +1,100 @@ +#include "../main/ui/model/lora_page_model.hpp" +#include "../main/ui/model/lora_page_contract.hpp" + +#include +#include + +int main() +{ + static_assert(noexcept(lora_poll_callback_allowed(true, true))); + static_assert(lora_poll_callback_allowed(true, true)); + static_assert(!lora_poll_callback_allowed(false, true)); + static_assert(!lora_poll_callback_allowed(true, false)); + + int animation_target = 1; + static_assert(noexcept(lora_animation_callback_allowed(&animation_target))); + assert(lora_animation_callback_allowed(&animation_target)); + assert(!lora_animation_callback_allowed(static_cast(nullptr))); + + int delete_target = 1; + int bubbled_target = 2; + assert(lora_owned_delete_callback_allowed(&delete_target, &delete_target)); + assert(!lora_owned_delete_callback_allowed(&delete_target, &bubbled_target)); + assert(!lora_owned_delete_callback_allowed( + static_cast(nullptr), &delete_target)); + + int action_button = 3; + int stale_button = 4; + static_assert(noexcept(lora_send_action_callback_allowed( + static_cast(nullptr), static_cast(nullptr), false, false))); + assert(lora_send_action_callback_allowed( + &action_button, &action_button, true, true)); + assert(!lora_send_action_callback_allowed( + &stale_button, &action_button, true, true)); + assert(!lora_send_action_callback_allowed( + &action_button, &action_button, false, true)); + assert(!lora_send_action_callback_allowed( + &action_button, &action_button, true, false)); + + int page_root = 5; + int stale_page = 6; + static_assert(noexcept(lora_page_event_callback_allowed( + static_cast(nullptr), static_cast(nullptr), false))); + assert(lora_page_event_callback_allowed(&page_root, &page_root, true)); + assert(!lora_page_event_callback_allowed(&stale_page, &page_root, true)); + assert(!lora_page_event_callback_allowed(&page_root, &page_root, false)); + + assert(lora_info_response_valid(0, 32, 32)); + assert(!lora_info_response_valid(-1, 32, 32)); + assert(!lora_info_response_valid(0, 31, 32)); + + int handle = 1; + int *present = &handle; + int *missing = nullptr; + assert(lora_page_ui_ready(present, present, present, present, present, present)); + assert(!lora_page_ui_ready(missing, present, present, present, present, present)); + assert(!lora_page_ui_ready(present, present, missing, present, present, present)); + assert(!lora_page_ui_ready(present, present, present, present, present, missing)); + assert(lora_page_controls_ready(present, present, present)); + assert(!lora_page_controls_ready(present, missing, present)); + + LoraPageModel model; + model.reset(true); + assert(model.view() == LoraView::MESSAGES); + assert(model.messages().empty()); + + model.reset(false); + assert(model.view() == LoraView::INFO); + + model.begin_send('A'); + assert(model.view() == LoraView::SEND); + assert(model.tx_input() == "A"); + assert(!model.append_character('\n')); + assert(model.tx_input() == "A"); + assert(model.append_character('B')); + assert(model.erase_character()); + assert(model.tx_input() == "A"); + model.cancel_send(); + assert(model.view() == LoraView::MESSAGES); + assert(model.tx_input().empty()); + + model.begin_send(); + for (size_t index = 0; index < LoraPageModel::TX_INPUT_LIMIT; ++index) + assert(model.append_character('x')); + assert(!model.append_character('y')); + assert(model.tx_input().size() == LoraPageModel::TX_INPUT_LIMIT); + + model.set_send_status("sending"); + model.complete_send(); + assert(model.view() == LoraView::MESSAGES); + assert(model.tx_input().empty()); + assert(model.send_status().empty()); + + model.append_message("", false, -81.0f, 7.5f); + assert(model.messages().back().text == ""); + for (size_t index = 0; index < LoraPageModel::MESSAGE_HISTORY_LIMIT + 3; ++index) + model.append_message(std::to_string(index), true, 0.0f, 0.0f); + assert(model.messages().size() == LoraPageModel::MESSAGE_HISTORY_LIMIT); + assert(model.messages().front().text == "3"); + assert(model.messages().back().text == "66"); +} diff --git a/projects/APPLaunch/tests/test_mesh_page_model.cpp b/projects/APPLaunch/tests/test_mesh_page_model.cpp new file mode 100644 index 00000000..738a3213 --- /dev/null +++ b/projects/APPLaunch/tests/test_mesh_page_model.cpp @@ -0,0 +1,92 @@ +#include "../main/ui/model/mesh_page_model.hpp" +#include "../main/ui/model/mesh_page_contract.hpp" + +#include +#include + +int main() +{ + static_assert(noexcept(mesh_heartbeat_callback_allowed(true, true))); + static_assert(mesh_heartbeat_callback_allowed(true, true)); + static_assert(!mesh_heartbeat_callback_allowed(false, true)); + static_assert(!mesh_heartbeat_callback_allowed(true, false)); + + int delete_target = 0; + int bubbled_target = 0; + static_assert(noexcept(mesh_owned_delete_callback_allowed( + &delete_target, &delete_target))); + assert(mesh_owned_delete_callback_allowed(&delete_target, &delete_target)); + assert(!mesh_owned_delete_callback_allowed(&delete_target, &bubbled_target)); + assert(!mesh_owned_delete_callback_allowed( + static_cast(nullptr), &delete_target)); + + int root_screen = 0; + int stale_root = 0; + static_assert(noexcept(mesh_root_event_callback_allowed( + &root_screen, &root_screen))); + assert(mesh_root_event_callback_allowed(&root_screen, &root_screen)); + assert(!mesh_root_event_callback_allowed(&stale_root, &root_screen)); + assert(!mesh_root_event_callback_allowed( + static_cast(nullptr), &root_screen)); + + uint32_t random_value = 7; + assert(mesh_parse_random_u32("4294967295", random_value)); + assert(random_value == UINT32_MAX); + assert(!mesh_parse_random_u32("4294967296", random_value)); + assert(!mesh_parse_random_u32("12junk", random_value)); + assert(random_value == UINT32_MAX); + int hour = -1; + int minute = -1; + int second = -1; + assert(mesh_parse_local_time("2026,7,22,9,8,7", hour, minute, second)); + assert(hour == 9 && minute == 8 && second == 7); + assert(!mesh_parse_local_time("2026,13,22,9,8,7", hour, minute, second)); + assert(!mesh_parse_local_time("2026,7,22,24,8,7", hour, minute, second)); + assert(!mesh_parse_local_time("2026,7,22,9,8,7,extra", hour, minute, second)); + + int background = 0; + int neighbor_area = 0; + int message_area = 0; + assert(mesh_page_ui_ready(&background, &neighbor_area, &message_area)); + assert(!mesh_page_ui_ready(nullptr, &neighbor_area, &message_area)); + assert(!mesh_page_ui_ready(&background, nullptr, &message_area)); + assert(!mesh_page_ui_ready(&background, &neighbor_area, nullptr)); + assert(mesh_page_ui_ready(&background, &neighbor_area, &message_area, &hour)); + assert(!mesh_page_ui_ready(&background, &neighbor_area, &message_area, nullptr)); + + MeshPageModel model; + model.initialize(0x12345); + assert(model.node_id() == "0x2345"); + assert(model.neighbors().empty() && model.messages().empty()); + + uint32_t seed = 1; + do { + model.initialize(seed++); + model.refresh("10:00:00"); + } while (model.neighbors().empty() && seed < 100); + assert(!model.neighbors().empty() && model.neighbors().size() <= 3); + assert(model.messages().size() == 1); + assert(model.messages().back().sender == "SYS"); + for (const auto &neighbor : model.neighbors()) { + assert(neighbor.rssi >= -99 && neighbor.rssi <= -40); + assert(neighbor.hops >= 1 && neighbor.hops <= 3); + assert(neighbor.last_seen == "10:00:00"); + } + + model.heartbeat("10:00:01"); + assert(model.messages().size() == 1); + model.heartbeat("10:00:02"); + assert(model.messages().size() == 2); + for (const auto &neighbor : model.neighbors()) { + assert(neighbor.rssi >= -100 && neighbor.rssi <= -30); + assert(neighbor.last_seen == "10:00:02"); + } + + model.initialize(9); + for (int index = 0; index < 25; ++index) { + model.add_message("now", "ME", std::to_string(index)); + } + assert(model.messages().size() == MeshPageModel::MAX_MESSAGES); + assert(model.messages().front().text == "5"); + assert(model.messages().back().text == "24"); +} diff --git a/projects/APPLaunch/tests/test_page_timer_lifecycle.cpp b/projects/APPLaunch/tests/test_page_timer_lifecycle.cpp new file mode 100644 index 00000000..d3bf3085 --- /dev/null +++ b/projects/APPLaunch/tests/test_page_timer_lifecycle.cpp @@ -0,0 +1,94 @@ +#include "../main/ui/model/page_timer_lifecycle.hpp" + +#include +#include +#include +#include +#include + +int main() +{ + using Lifecycle = PageTimerLifecycle; + int timer = 1; + int deleted = 0; + int creates = 0; + Lifecycle lifecycle; + assert(!lifecycle.start([] { return static_cast(nullptr); }, + [&](int *) { ++deleted; })); + assert(!lifecycle.active()); + assert(lifecycle.start([&] { ++creates; return &timer; }, + [&](int *) { ++deleted; })); + assert(lifecycle.active()); + lifecycle.stop(); + + creates = 0; + deleted = 0; + assert(lifecycle.start([&] { ++creates; return &timer; }, + [&](int *handle) { + assert(handle == &timer); + assert(!lifecycle.current(handle)); + assert(!lifecycle.active()); + lifecycle.stop(); + ++deleted; + })); + assert(lifecycle.start([&] { ++creates; return &timer; }, [&](int *) { ++deleted; })); + assert(creates == 1); + assert(lifecycle.current(&timer)); + lifecycle.stop(); + lifecycle.stop(); + assert(deleted == 1); + + int destructor_deletes = 0; + { + Lifecycle scoped; + assert(scoped.start([&] { return &timer; }, + [&](int *) { ++destructor_deletes; })); + } + assert(destructor_deletes == 1); + + int throwing_deletes = 0; + assert(lifecycle.start([&] { return &timer; }, [&](int *) { + ++throwing_deletes; + throw std::runtime_error("timer delete failed"); + })); + static_assert(noexcept(lifecycle.stop())); + lifecycle.stop(); + assert(throwing_deletes == 1); + assert(!lifecycle.active()); + assert(lifecycle.start([&] { return &timer; }, [](int *) {})); + lifecycle.stop(); + + { + Lifecycle scoped; + assert(scoped.start([&] { return &timer; }, [&](int *) { + ++throwing_deletes; + throw std::runtime_error("destructor delete failed"); + })); + } + assert(throwing_deletes == 2); + + Lifecycle concurrent; + std::atomic concurrent_creates{0}; + std::vector threads; + for (int index = 0; index < 16; ++index) { + threads.emplace_back([&] { + assert(concurrent.start([&] { + ++concurrent_creates; + return &timer; + }, [](int *) {})); + }); + } + for (auto &thread : threads) + thread.join(); + assert(concurrent_creates.load() == 1); + + Lifecycle poll; + Lifecycle cursor; + int poll_timer = 2; + assert(poll.start([&] { return &poll_timer; }, [](int *) {})); + assert(!cursor.start([] { return static_cast(nullptr); }, [](int *) {})); + assert(poll.active()); + assert(!cursor.active()); + poll.stop(); + cursor.stop(); +} diff --git a/projects/APPLaunch/tests/test_rtc_ntp_state.cpp b/projects/APPLaunch/tests/test_rtc_ntp_state.cpp deleted file mode 100644 index 0af5eb28..00000000 --- a/projects/APPLaunch/tests/test_rtc_ntp_state.cpp +++ /dev/null @@ -1,16 +0,0 @@ -#include "../main/ui/page_app/setting/rtc_ntp_state.hpp" -#include -int main() -{ - using namespace setting; - assert(ntp_toggle_eligibility(true, false, true) == NtpToggleEligibility::IN_FLIGHT); - assert(ntp_toggle_eligibility(false, true, true) == NtpToggleEligibility::DIRTY); - assert(ntp_toggle_eligibility(false, false, false) == NtpToggleEligibility::UNAVAILABLE); - assert(ntp_toggle_eligibility(false, false, true) == NtpToggleEligibility::ALLOWED); - assert(ntp_rollback_value(true) && !ntp_rollback_value(false)); - assert(classify_privileged_result(0) == PrivilegedResultKind::SUCCESS); - assert(classify_privileged_result(1) == PrivilegedResultKind::AUTH_FAILED); - assert(classify_privileged_result(2) == PrivilegedResultKind::EXEC_FAILED); - assert(classify_privileged_result(3) == PrivilegedResultKind::CANCELLED); - assert(classify_privileged_result(4) == PrivilegedResultKind::TIMED_OUT); -} diff --git a/projects/APPLaunch/tests/test_rtc_state_model.cpp b/projects/APPLaunch/tests/test_rtc_state_model.cpp new file mode 100644 index 00000000..4b11096b --- /dev/null +++ b/projects/APPLaunch/tests/test_rtc_state_model.cpp @@ -0,0 +1,129 @@ +#include "../main/ui/model/rtc_state_model.hpp" + +#include +#include +#include + +int main() +{ + using namespace setting; + + int tracked_overlay_handle = 0; + int stale_overlay_handle = 0; + static_assert(noexcept(rtc_overlay_delete_callback_allowed( + &tracked_overlay_handle, &tracked_overlay_handle, &tracked_overlay_handle))); + assert(rtc_overlay_delete_callback_allowed( + &tracked_overlay_handle, &tracked_overlay_handle, &tracked_overlay_handle)); + assert(!rtc_overlay_delete_callback_allowed( + &tracked_overlay_handle, &stale_overlay_handle, &tracked_overlay_handle)); + assert(!rtc_overlay_delete_callback_allowed( + &stale_overlay_handle, &stale_overlay_handle, &tracked_overlay_handle)); + assert(!rtc_overlay_delete_callback_allowed( + static_cast(nullptr), &tracked_overlay_handle, &tracked_overlay_handle)); + + RtcStateModel model; + assert(model.timestamp() == "2026-01-01 00:00:00"); + assert(!model.dirty()); + + assert(RtcStateModel::days_in_month(2000, 2) == 29); + assert(RtcStateModel::days_in_month(2024, 2) == 29); + assert(RtcStateModel::days_in_month(2100, 2) == 28); + assert(RtcStateModel::days_in_month(2025, 2) == 28); + assert(RtcStateModel::days_in_month(2025, 4) == 30); + assert(RtcStateModel::days_in_month(2025, 13) == 0); + + assert(model.load_local_time("2024,2,29,23,59,58")); + assert(model.field_value(RtcField::DAY) == 29); + assert(model.field_max(RtcField::DAY) == 29); + assert(!model.load_local_time("2023,2,29,12,0,0")); + assert(!model.load_local_time("2024,1,1,24,0,0")); + assert(!model.load_local_time("2024,1,1,0,0,0 trailing")); + assert(model.timestamp() == "2024-02-29 23:59:58"); + assert(RtcStateModel::field_name(RtcField::YEAR) == "Year"); + assert(RtcStateModel::field_name(RtcField::SECOND) == "Second"); + assert(RtcStateModel::field_name(RtcField::COUNT).empty()); + assert(model.field_options(RtcField::COUNT).empty()); + assert(model.field_selection_index(RtcField::COUNT) == -1); + assert(!model.edit_field_selection(RtcField::COUNT, 0)); + RtcField parsed_field = RtcField::YEAR; + assert(RtcStateModel::field_from_index(5, parsed_field)); + assert(parsed_field == RtcField::SECOND); + assert(!RtcStateModel::field_from_index(-1, parsed_field)); + assert(!RtcStateModel::field_from_index(6, parsed_field)); + + const auto days = model.field_options(RtcField::DAY); + assert(days.size() == 29); + assert(days.front() == "1" && days.back() == "29"); + assert(model.field_selection_index(RtcField::DAY) == 28); + assert(!model.edit_field_selection(RtcField::DAY, days.size())); + assert(model.edit_field_selection(RtcField::DAY, 0)); + assert(model.field_value(RtcField::DAY) == 1); + assert(model.edit_field_selection(RtcField::DAY, 28)); + assert(model.field_value(RtcField::DAY) == 29); + + assert(model.edit_field(RtcField::YEAR, 2023)); + assert(model.field_value(RtcField::DAY) == 28); + assert(model.dirty()); + assert(!model.edit_field(RtcField::MONTH, 0)); + assert(!model.edit_field(RtcField::HOUR, 24)); + assert(model.field_value(RtcField::MONTH) == 2); + assert(model.edit_field(RtcField::MONTH, 4)); + assert(model.edit_field(RtcField::DAY, 30)); + assert(!model.edit_field(RtcField::DAY, 31)); + assert(model.edit_field(RtcField::SECOND, 0)); + assert(model.timestamp() == "2023-04-30 23:59:00"); + assert(!model.load_local_time("invalid")); + assert(model.dirty()); + assert(model.timestamp() == "2023-04-30 23:59:00"); + assert(model.commit_request() == + (std::list{"TimeSet", "2023-04-30 23:59:00"})); + + model.finish_commit(false); + assert(model.dirty()); + model.finish_commit(true); + assert(!model.dirty()); + + model.set_ntp_status(-1); + assert(!model.ntp_available()); + assert(model.ntp_on()); + assert(model.ntp_toggle_eligibility(false) == NtpToggleEligibility::UNAVAILABLE); + model.set_ntp_status(0); + assert(model.ntp_available() && !model.ntp_on()); + assert(model.ntp_toggle_eligibility(false) == NtpToggleEligibility::ALLOWED); + assert(model.ntp_toggle_eligibility(true) == NtpToggleEligibility::IN_FLIGHT); + assert(model.edit_field(RtcField::MINUTE, 1)); + assert(model.ntp_toggle_eligibility(false) == NtpToggleEligibility::DIRTY); + model.rollback_ntp(true); + assert(model.ntp_on()); + + assert(classify_privileged_result(0) == PrivilegedResultKind::SUCCESS); + assert(classify_privileged_result(1) == PrivilegedResultKind::AUTH_FAILED); + assert(classify_privileged_result(2) == PrivilegedResultKind::EXEC_FAILED); + assert(classify_privileged_result(3) == PrivilegedResultKind::CANCELLED); + assert(classify_privileged_result(4) == PrivilegedResultKind::TIMED_OUT); + assert(classify_privileged_result(99) == PrivilegedResultKind::EXEC_FAILED); + + RtcWriteConfirmModel confirm; + assert(!confirm.save_selected()); + assert(confirm.handle(RtcConfirmInput::CONFIRM) == RtcConfirmAction::DISCARD); + assert(confirm.handle(RtcConfirmInput::SELECT_SAVE) == RtcConfirmAction::NONE); + assert(confirm.save_selected()); + assert(confirm.handle(RtcConfirmInput::CONFIRM) == RtcConfirmAction::SAVE); + confirm.reset(); + assert(confirm.handle(RtcConfirmInput::CANCEL) == RtcConfirmAction::DISCARD); + + RtcOverlayLifecycleModel overlay; + assert(!overlay.active()); + const auto first_overlay = overlay.open(); + assert(first_overlay); + assert(overlay.active()); + assert(!overlay.open()); + assert(overlay.close(first_overlay)); + assert(!overlay.active()); + assert(!overlay.close(first_overlay)); + const auto second_overlay = overlay.open(); + assert(second_overlay && second_overlay != first_overlay); + assert(!overlay.close(first_overlay)); + assert(overlay.active()); + assert(overlay.close(second_overlay)); +} diff --git a/projects/APPLaunch/tests/test_screensaver_model.cpp b/projects/APPLaunch/tests/test_screensaver_model.cpp new file mode 100644 index 00000000..65b4c975 --- /dev/null +++ b/projects/APPLaunch/tests/test_screensaver_model.cpp @@ -0,0 +1,74 @@ +#include "../main/ui/model/screensaver_model.hpp" +#include "../main/ui/model/screensaver_runtime_contract.hpp" + +#include +#include + +int main() +{ + int timer = 0; + int stale_timer = 0; + assert(screensaver_timer_is_current(&timer, &timer)); + assert(!screensaver_timer_is_current(&stale_timer, &timer)); + assert(!screensaver_timer_is_current(nullptr, &timer)); + assert(screensaver_delete_is_tracked(&timer, &timer, &timer)); + assert(!screensaver_delete_is_tracked(&stale_timer, &timer, &timer)); + assert(!screensaver_delete_is_tracked(&timer, &stale_timer, &timer)); + assert(!screensaver_delete_is_tracked(&timer, &timer, &stale_timer)); + assert(!screensaver_delete_is_tracked(nullptr, &timer, &timer)); + assert(screensaver_timeout_from_config(true, "0") == 0); + assert(screensaver_timeout_from_config(true, "10") == 10); + assert(screensaver_timeout_from_config(true, "300") == 300); + assert(screensaver_timeout_from_config(true, "garbage") == 30); + assert(screensaver_timeout_from_config(true, "10junk") == 30); + assert(screensaver_timeout_from_config(true, " 10") == 30); + assert(screensaver_timeout_from_config(true, "+10") == 30); + assert(screensaver_timeout_from_config(true, "99999999999999999999") == 30); + assert(screensaver_timeout_from_config(true, "-1") == 30); + assert(screensaver_timeout_from_config(false, "0") == 30); + + ScreensaverModel model; + model.reset(1000); + assert(!model.active()); + assert(!model.should_activate(30999, 30000, true)); + assert(model.should_activate(31000, 30000, true)); + assert(!model.should_activate(31000, 0, true)); + assert(!model.should_activate(31000, 30000, false)); + + ScreensaverFrame frame = model.activate(240, 135, 31000); + assert(model.active()); + assert(frame.x == 55); + assert(frame.y == 38); + assert(frame.color_index == 0); + + frame = model.advance(240, 135, 31040); + assert(frame.x == 56); + assert(frame.y == 39); + assert(!frame.color_changed); + + for (int index = 0; index < 200 && !frame.color_changed; ++index) + frame = model.advance(240, 135, 31080 + static_cast(index) * 40); + assert(frame.color_changed); + assert(frame.color_index == 1); + + assert(model.filter_key(42, false, 40000)); + assert(!model.active()); + assert(model.filter_key(42, true, 40010)); + assert(!model.filter_key(42, false, 40020)); + assert(model.last_activity_tick() == 40020); + + model.activate(10, 10, 50000); + frame = model.advance(10, 10, 50040); + assert(frame.x == 0 && frame.y == 0); + + model.set_foreground(false, 60000); + assert(!model.foreground()); + assert(!model.active()); + assert(!model.should_activate(90000, 30000, true)); + model.set_foreground(true, 90000); + assert(!model.should_activate(119999, 30000, true)); + assert(model.should_activate(120000, 30000, true)); + + model.reset(UINT32_MAX - 10); + assert(model.should_activate(9, 20, true)); +} diff --git a/projects/APPLaunch/tests/test_setup_info_model.cpp b/projects/APPLaunch/tests/test_setup_info_model.cpp new file mode 100644 index 00000000..cc2bf6f7 --- /dev/null +++ b/projects/APPLaunch/tests/test_setup_info_model.cpp @@ -0,0 +1,63 @@ +#include "../main/ui/model/setup_info_model.hpp" +#include "../main/ui/model/setup_info_timer_contract.hpp" + +#include +#include + +int main() +{ + int timer = 1; + int page = 3; + assert(setup_info_timer_callback_ready(&timer, true, &page)); + assert(!setup_info_timer_callback_ready(&timer, false, &page)); + assert(!setup_info_timer_callback_ready( + &timer, true, static_cast(nullptr))); + + int visible_label = 1; + int bubbled_label = 2; + static_assert(noexcept(setup_info_label_delete_callback_allowed( + &visible_label, &visible_label))); + assert(setup_info_label_delete_callback_allowed( + &visible_label, &visible_label)); + assert(!setup_info_label_delete_callback_allowed( + &visible_label, &bubbled_label)); + assert(!setup_info_label_delete_callback_allowed( + static_cast(nullptr), &visible_label)); + + SetupInfoModel model; + assert(!model.update(-1, "")); + assert(model.labels()[0] == "Battery: --%"); + assert(!model.update(0, "broken")); + assert(!model.update(0, "4012,0,253,87,1000,1200,0,-100,1junk")); + assert(!model.update(0, "4012,0,253,87,1000,1200,0,-100,1,trailing")); + assert(!model.update(0, " 4012,0,253,87,1000,1200,0,-100,1")); + assert(!model.update(0, "4000,10,250,101,1,2,3,4,1")); + assert(!model.update(0, "4000,10,250,90,1,2,3,4,2")); + assert(!model.update(0, "4000,10,1001,90,1,2,3,4,1")); + assert(!model.update(0, "4000,5001,250,90,1,2,3,4,1")); + assert(!model.update(0, "4000,10,250,90,3,2,3,4,1")); + assert(!model.update(0, "999999999999999999999,10,250,90,1,2,3,4,1")); + + assert(model.update(0, "4012,-125,253,87,1000,1200,0,-100,1")); + assert(model.labels()[0] == "Battery: 87%"); + assert(model.labels()[1] == "Temp: 25.3C"); + assert(model.labels()[2] == "Current: -125mA"); + assert(model.labels()[3] == "Voltage: 4.01V"); + + assert(model.update(0, "4012,-2147483648,253,87,1000,1200,0,-100,1")); + assert(model.labels()[2] == "Current: --mA"); + const auto stable_snapshot = model.snapshot(); + const auto stable_labels = model.labels(); + assert(!model.update(-1, "")); + assert(!model.update(0, "broken")); + assert(!model.update(0, "4012,0,253,87,1000,1200,0,-100,0")); + assert(model.snapshot().valid); + assert(model.snapshot().voltage_mv == stable_snapshot.voltage_mv); + assert(model.snapshot().current_ma == stable_snapshot.current_ma); + assert(model.labels() == stable_labels); + assert(model.labels()[0] == "Battery: 87%"); + assert(model.labels()[1] == "Temp: 25.3C"); + assert(model.labels()[2] == "Current: --mA"); + assert(model.labels()[3] == "Voltage: 4.01V"); + return 0; +} diff --git a/projects/APPLaunch/tests/test_setup_page_access_policy.cpp b/projects/APPLaunch/tests/test_setup_page_access_policy.cpp new file mode 100644 index 00000000..7410120c --- /dev/null +++ b/projects/APPLaunch/tests/test_setup_page_access_policy.cpp @@ -0,0 +1,30 @@ +#include "../main/ui/page_app/setting/setup_page_access.hpp" + +#include +#include + +int main() +{ + std::vector menus = { + {"Screen", {{"Brightness", false, false, {}}}, {}, {}}, + {"Speaker", {{"Volume", false, false, {}}}, {}, {}}, + {"Screen", {{"Duplicate", false, false, {}}}, {}, {}}, + }; + + setting::MenuItem *screen = setting::find_menu_item(menus, "Screen"); + assert(screen == &menus[0]); + assert(screen->sub_items[0].label == "Brightness"); + screen->sub_items[0].toggle_state = true; + assert(menus[0].sub_items[0].toggle_state); + assert(setting::find_menu_item(menus, "Missing") == nullptr); + + const auto &const_menus = menus; + const setting::MenuItem *speaker = setting::find_menu_item(const_menus, "Speaker"); + assert(speaker == &menus[1]); + assert(speaker->sub_items[0].label == "Volume"); + + assert(setting::selected_menu_item(menus, -1) == nullptr); + assert(setting::selected_menu_item(menus, 3) == nullptr); + assert(setting::selected_menu_item(menus, 1) == &menus[1]); + assert(setting::selected_menu_item(const_menus, 2) == &menus[2]); +} diff --git a/projects/APPLaunch/tests/test_setup_page_model.cpp b/projects/APPLaunch/tests/test_setup_page_model.cpp new file mode 100644 index 00000000..17d6d966 --- /dev/null +++ b/projects/APPLaunch/tests/test_setup_page_model.cpp @@ -0,0 +1,114 @@ +#include "../main/ui/model/setup_page_model.hpp" + +#include + +int main() +{ + int root = 0; + int stale_root = 0; + static_assert(noexcept(setup_root_callback_allowed(&root, &root))); + assert(setup_root_callback_allowed(&root, &root)); + assert(!setup_root_callback_allowed(&stale_root, &root)); + assert(!setup_root_callback_allowed(static_cast(nullptr), &root)); + + int teardown_calls = 0; + auto teardown_action = [] {}; + static_assert(noexcept(setup_teardown_step(teardown_action))); + assert(setup_teardown_step([&] { ++teardown_calls; })); + assert(!setup_teardown_step([&] { + ++teardown_calls; + throw 1; + })); + assert(setup_teardown_step([&] { ++teardown_calls; })); + assert(teardown_calls == 3); + + SetupPageLifecycle lifecycle; + assert(lifecycle.active()); + assert(lifecycle.begin_animation()); + assert(lifecycle.animating()); + assert(!lifecycle.begin_animation()); + lifecycle.finish_animation(); + assert(!lifecycle.animating()); + + assert(lifecycle.begin_animation()); + lifecycle.cancel_animation(); + assert(lifecycle.active()); + assert(!lifecycle.animating()); + lifecycle.cancel_animation(); + assert(!lifecycle.animating()); + + assert(lifecycle.begin_animation()); + lifecycle.root_deleted(); + assert(!lifecycle.active()); + assert(!lifecycle.animating()); + lifecycle.finish_animation(); + assert(!lifecycle.animating()); + assert(!lifecycle.begin_animation()); + lifecycle.root_deleted(); + + SetupConfirmController confirmation; + int calls = 0; + assert(!confirmation.active()); + assert(!confirmation.resolve(true)); + + confirmation.begin([&calls]() { ++calls; }); + assert(confirmation.active()); + assert(!confirmation.resolve(false)); + assert(!confirmation.active()); + assert(calls == 0); + + confirmation.begin([&calls]() { ++calls; }); + assert(confirmation.resolve(true)); + assert(!confirmation.active()); + assert(calls == 1); + + confirmation.begin([&calls]() { ++calls; }); + confirmation.cancel(); + assert(!confirmation.resolve(true)); + assert(calls == 1); + + SetupPageModel model; + assert(model.view == SetupViewState::MAIN); + assert(model.selected_index == SetupPageModel::DEFAULT_MENU_INDEX); + assert(!model.enter_help()); + model.view = SetupViewState::SUB; + assert(model.enter_help()); + assert(model.view == SetupViewState::HELP); + assert(model.view != SetupViewState::WIFI_LIST); + assert(!model.enter_help()); + assert(model.leave_help()); + assert(model.view == SetupViewState::SUB); + assert(!model.leave_help()); + model.view = SetupViewState::MAIN; + + assert(model.move_main(-1, 5)); + assert(model.move_main(-1, 5)); + assert(!model.move_main(-1, 5)); + assert(model.selected_index == 0); + + model.enter_sub(8); + assert(model.view == SetupViewState::SUB); + assert(model.sub_selected_index == SetupPageModel::DEFAULT_CENTER_ROW); + assert(model.move_sub(1, 8)); + assert(model.sub_selected_index == 4); + + model.enter_sub(2); + assert(model.sub_selected_index == 1); + assert(!model.move_sub(1, 2)); + model.leave_to_main(); + assert(model.view == SetupViewState::MAIN); + + model.enter_value("Volume", {"100%", "50%", "0%"}, 99); + assert(model.view == SetupViewState::VALUE_SELECT); + assert(model.value_title == "Volume"); + assert(model.value_selected_index == 2); + assert(!model.move_value(1)); + assert(model.move_value(-1)); + assert(model.value_selected_index == 1); + model.leave_to_sub(); + assert(model.view == SetupViewState::SUB); + + model.enter_value("Empty", {}, 4); + assert(model.value_selected_index == 0); + assert(!model.move_value(-1)); +} diff --git a/projects/APPLaunch/tests/test_setup_value_policy.cpp b/projects/APPLaunch/tests/test_setup_value_policy.cpp new file mode 100644 index 00000000..b25d46f5 --- /dev/null +++ b/projects/APPLaunch/tests/test_setup_value_policy.cpp @@ -0,0 +1,62 @@ +#include "../main/ui/model/setup_value_policy.hpp" + +#include +#include + +int main() +{ + assert(setup_values::brightness_index(100, 100) == 0); + assert(setup_values::brightness_index(86, 100) == 1); + assert(setup_values::brightness_index(61, 100) == 2); + assert(setup_values::brightness_index(36, 100) == 3); + assert(setup_values::brightness_index(20, 0) == 0); + assert(setup_values::brightness_value(0, 200) == 200); + assert(setup_values::brightness_value(2, 200) == 100); + assert(setup_values::brightness_value(99, 3) == 1); + assert(setup_values::brightness_value(-1, 0) == 1); + assert(setup_values::brightness_index(INT_MAX, INT_MAX) == 0); + assert(setup_values::brightness_value(2, INT_MAX) == INT_MAX / 2); + + int parsed = -1; + assert(setup_values::parse_nonnegative_int("0", parsed) && parsed == 0); + assert(setup_values::parse_nonnegative_int("2147483647", parsed) && parsed == INT_MAX); + assert(!setup_values::parse_nonnegative_int("", parsed)); + assert(!setup_values::parse_nonnegative_int("12junk", parsed)); + assert(!setup_values::parse_nonnegative_int(" 12", parsed)); + assert(!setup_values::parse_nonnegative_int("+12", parsed)); + assert(!setup_values::parse_nonnegative_int("-1", parsed)); + assert(!setup_values::parse_nonnegative_int("2147483648", parsed)); + + assert(setup_values::dark_time_index(0) == 0); + assert(setup_values::dark_time_index(300) == 4); + assert(setup_values::dark_time_index(17) == 2); + assert(setup_values::dark_time_seconds(-1) == 0); + assert(setup_values::dark_time_seconds(99) == 300); + + assert(setup_values::volume_index(100) == 0); + assert(setup_values::volume_index(86) == 1); + assert(setup_values::volume_index(61) == 2); + assert(setup_values::volume_index(36) == 3); + assert(setup_values::volume_index(11) == 4); + assert(setup_values::volume_percent(-1) == 100); + assert(setup_values::volume_percent(99) == 0); + assert(setup_values::volume_value_valid(0)); + assert(setup_values::volume_value_valid(100)); + assert(!setup_values::volume_value_valid(-1)); + assert(!setup_values::volume_value_valid(101)); + + assert(setup_values::camera_resolution_index(640, 480) == 1); + assert(setup_values::camera_resolution_index(1280, 720) == 0); + assert(setup_values::camera_resolution_index(640, 720) == 0); + assert(setup_values::camera_resolution_supported(1280, 720)); + assert(setup_values::camera_resolution_supported(640, 480)); + assert(!setup_values::camera_resolution_supported(640, 720)); + assert(setup_values::camera_available_from_status(true, 0)); + assert(setup_values::camera_available_from_status(true, 1)); + assert(!setup_values::camera_available_from_status(true, -10)); + assert(!setup_values::camera_available_from_status(false, 0)); + const auto hd = setup_values::camera_resolution(-1); + assert(hd.width == 1280 && hd.height == 720); + const auto vga = setup_values::camera_resolution(99); + assert(vga.width == 640 && vga.height == 480); +} diff --git a/projects/APPLaunch/tests/test_setup_view_build_contract.cpp b/projects/APPLaunch/tests/test_setup_view_build_contract.cpp new file mode 100644 index 00000000..da2157aa --- /dev/null +++ b/projects/APPLaunch/tests/test_setup_view_build_contract.cpp @@ -0,0 +1,31 @@ +#include "../main/ui/model/setup_view_build_contract.hpp" + +#include + +int main() +{ + SetupMainViewBuildContract build(7); + assert(!build.ready()); + + build.selection_created(); + assert(!build.ready()); + build.hint_created(); + assert(!build.ready()); + + for (int row = 0; row < 6; ++row) build.row_created(); + assert(!build.ready()); + build.row_created(); + assert(build.ready()); + + SetupMainViewBuildContract missing_selection(0); + missing_selection.hint_created(); + assert(!missing_selection.ready()); + + SetupMainViewBuildContract too_many_rows(1); + too_many_rows.selection_created(); + too_many_rows.hint_created(); + too_many_rows.row_created(); + assert(too_many_rows.ready()); + too_many_rows.row_created(); + assert(!too_many_rows.ready()); +} diff --git a/projects/APPLaunch/tests/test_setup_wifi_model.cpp b/projects/APPLaunch/tests/test_setup_wifi_model.cpp new file mode 100644 index 00000000..900c110a --- /dev/null +++ b/projects/APPLaunch/tests/test_setup_wifi_model.cpp @@ -0,0 +1,125 @@ +#include "../main/ui/model/setup_wifi_model.hpp" + +#include +#include +#include + +int main() +{ + SetupWifiListViewModel list; + static_assert(SetupWifiListViewModel::KEY_REPEAT_INTERVAL_MS < 100); + list.begin_scan(); + assert(list.scanning()); + auto scanning = list.snapshot(); + assert(scanning.rows.empty()); + assert(scanning.empty_message == "Scanning for WiFi networks..."); + + std::vector networks; + for (int index = 0; index < 7; ++index) { + networks.push_back({"wifi-" + std::to_string(index), "WPA2", 90 - index, + index == 0, index == 3}); + } + list.apply_scan(networks); + assert(!list.scanning()); + assert(list.size() == 7); + assert(list.selected() && list.selected()->ssid == "wifi-0"); + assert(!list.move_selection(-1)); + assert(list.move_selection(1)); + assert(list.move_selection(1)); + assert(list.move_selection(1)); + list.set_status({true, "wifi-0", "192.168.1.2"}); + auto selected = list.snapshot(); + assert(selected.title == "Connected WiFi: wifi-0 192.168.1.2"); + assert(selected.rows.size() == 5); + assert(selected.rows[2].selected); + assert(selected.rows[2].ssid == "wifi-3 *"); + + list.begin_scan(); + std::vector reordered = networks; + std::rotate(reordered.begin(), reordered.begin() + 3, reordered.end()); + list.apply_scan(std::move(reordered)); + assert(list.selected() && list.selected()->ssid == "wifi-3"); + list.begin_scan(); + list.cancel_scan(); + assert(!list.scanning()); + list.apply_scan({}); + assert(!list.selected()); + assert(list.selected_index() == 0); + + int timer = 0; + int stale_timer = 0; + static_assert(noexcept(setup_wifi_feedback_timer_callback_ready( + static_cast(nullptr), static_cast(nullptr), + SetupWifiFeedbackModel::Token{}))); + assert(setup_wifi_feedback_timer_callback_ready( + &timer, &timer, SetupWifiFeedbackModel::Token{1})); + assert(!setup_wifi_feedback_timer_callback_ready( + &stale_timer, &timer, SetupWifiFeedbackModel::Token{1})); + assert(!setup_wifi_feedback_timer_callback_ready( + static_cast(nullptr), &timer, SetupWifiFeedbackModel::Token{1})); + assert(!setup_wifi_feedback_timer_callback_ready( + &timer, &timer, SetupWifiFeedbackModel::Token{})); + + int feedback_screen = 0; + int stale_screen = 0; + static_assert(noexcept(setup_wifi_feedback_screen_delete_allowed( + &feedback_screen, &feedback_screen, &feedback_screen))); + assert(setup_wifi_feedback_screen_delete_allowed( + &feedback_screen, &feedback_screen, &feedback_screen)); + assert(!setup_wifi_feedback_screen_delete_allowed( + &feedback_screen, &stale_screen, &feedback_screen)); + assert(!setup_wifi_feedback_screen_delete_allowed( + &stale_screen, &stale_screen, &feedback_screen)); + assert(!setup_wifi_feedback_screen_delete_allowed( + static_cast(nullptr), &feedback_screen, &feedback_screen)); + + SetupWifiPasswordModel model; + assert(!model.can_submit()); + assert(model.masked_display() == "_"); + + model.begin("Office WiFi"); + assert(!model.can_submit()); + assert(model.append("secret")); + assert(model.can_submit()); + assert(model.password() == "secret"); + assert(model.masked_display() == "******_"); + assert(model.masked_display().find("secret") == std::string::npos); + assert(!model.append("\n")); + assert(!model.append("\x80")); + assert(!model.append("\xc3")); + assert(!model.append("\xc0\x80")); + assert(!model.append("\xed\xa0\x80")); + + assert(model.append("\xc3\xa9")); + assert(model.masked_display() == "*******_"); + assert(model.erase_last()); + assert(model.password() == "secret"); + + model.clear_password(); + assert(model.append(std::string(62, 'x'))); + assert(!model.append("\xe2\x82\xac")); + assert(model.password().size() == 62); + model.clear_password(); + assert(model.append(std::string(SetupWifiPasswordModel::MAX_PASSWORD_BYTES, 'x'))); + assert(!model.append("y")); + model.reset(); + assert(model.ssid().empty()); + assert(model.password().empty()); + assert(!model.can_submit()); + + SetupWifiFeedbackModel feedback; + assert(!feedback.pending()); + const auto first_feedback = feedback.begin(); + assert(first_feedback); + assert(feedback.pending()); + assert(!feedback.begin()); + assert(feedback.complete(first_feedback)); + assert(!feedback.pending()); + assert(!feedback.complete(first_feedback)); + const auto second_feedback = feedback.begin(); + assert(second_feedback && second_feedback != first_feedback); + assert(!feedback.complete(first_feedback)); + assert(feedback.pending()); + assert(feedback.cancel(second_feedback)); + assert(!feedback.pending()); +} diff --git a/projects/APPLaunch/tests/test_snake_game_model.cpp b/projects/APPLaunch/tests/test_snake_game_model.cpp new file mode 100644 index 00000000..95073b4a --- /dev/null +++ b/projects/APPLaunch/tests/test_snake_game_model.cpp @@ -0,0 +1,33 @@ +#include "../main/ui/model/snake_game_model.hpp" + +#include +#include + +int main() +{ + SnakeGameModel model(7); + assert(model.snake().size() == 3); + assert((model.snake().front() == SnakeGameModel::Point{20, 9})); + assert(model.direction() == SnakeGameModel::Direction::RIGHT); + assert(model.score() == 0); + assert(std::find(model.snake().begin(), model.snake().end(), model.food()) == model.snake().end()); + + model.queue_direction(SnakeGameModel::Direction::LEFT); + assert(model.tick()); + assert((model.snake().front() == SnakeGameModel::Point{21, 9})); + assert(model.direction() == SnakeGameModel::Direction::RIGHT); + + model.queue_direction(SnakeGameModel::Direction::UP); + assert(model.tick()); + assert((model.snake().front() == SnakeGameModel::Point{21, 8})); + assert(model.direction() == SnakeGameModel::Direction::UP); + + model.reset(); + bool alive = true; + for (int step = 0; step < 20 && alive; ++step) alive = model.tick(); + assert(!alive); + + model.reset(); + assert((model.snake().front() == SnakeGameModel::Point{20, 9})); + assert(model.score() == 0); +} diff --git a/projects/APPLaunch/tests/test_snake_view_contract.cpp b/projects/APPLaunch/tests/test_snake_view_contract.cpp new file mode 100644 index 00000000..2d71cfb9 --- /dev/null +++ b/projects/APPLaunch/tests/test_snake_view_contract.cpp @@ -0,0 +1,50 @@ +#include "../main/ui/model/snake_view_contract.hpp" + +#include + +int main() +{ + int target = 1; + int bubbled_target = 2; + assert(snake_owned_delete_callback_allowed(&target, &target)); + assert(!snake_owned_delete_callback_allowed(&target, &bubbled_target)); + assert(!snake_owned_delete_callback_allowed( + static_cast(nullptr), &target)); + + int game_area = 3; + int render_layer = 4; + static_assert(noexcept(snake_tick_callback_allowed( + false, false, false, static_cast(nullptr), + static_cast(nullptr)))); + assert(snake_tick_callback_allowed( + true, true, true, &game_area, &render_layer)); + assert(!snake_tick_callback_allowed( + false, true, true, &game_area, &render_layer)); + assert(!snake_tick_callback_allowed( + true, false, true, &game_area, &render_layer)); + assert(!snake_tick_callback_allowed( + true, true, false, &game_area, &render_layer)); + assert(!snake_tick_callback_allowed( + true, true, true, &game_area, static_cast(nullptr))); + + int root_screen = 5; + int stale_root = 6; + static_assert(noexcept(snake_page_event_callback_allowed( + static_cast(nullptr), static_cast(nullptr)))); + assert(snake_page_event_callback_allowed(&root_screen, &root_screen)); + assert(!snake_page_event_callback_allowed(&stale_root, &root_screen)); + assert(!snake_page_event_callback_allowed( + static_cast(nullptr), &root_screen)); + + SnakeFrameBuildContract empty(0); + assert(empty.ready()); + + SnakeFrameBuildContract frame(4); + assert(!frame.ready()); + for (int cell = 0; cell < 3; ++cell) frame.cell_created(); + assert(!frame.ready()); + frame.cell_created(); + assert(frame.ready()); + frame.cell_created(); + assert(!frame.ready()); +} diff --git a/projects/APPLaunch/tests/test_sound_card_model.cpp b/projects/APPLaunch/tests/test_sound_card_model.cpp new file mode 100644 index 00000000..03a45c57 --- /dev/null +++ b/projects/APPLaunch/tests/test_sound_card_model.cpp @@ -0,0 +1,162 @@ +#include "../main/ui/model/sound_card_model.hpp" + +#include +#include +#include + +int main() +{ + int timer = 0; + int stale_timer = 0; + int input_label = 0; + int hint_label = 0; + int screen = 0; + int stale_screen = 0; + int child = 0; + assert(setting::sound_card_cursor_callback_ready(&timer, &timer, &input_label)); + assert(!setting::sound_card_cursor_callback_ready(&stale_timer, &timer, &input_label)); + assert(!setting::sound_card_cursor_callback_ready(&timer, &timer, nullptr)); + static_assert(noexcept(setting::sound_card_cursor_callback_allowed( + static_cast(nullptr), static_cast(nullptr), + static_cast(nullptr), false))); + assert(setting::sound_card_cursor_callback_allowed( + &timer, &timer, &input_label, true)); + assert(!setting::sound_card_cursor_callback_allowed( + &stale_timer, &timer, &input_label, true)); + assert(!setting::sound_card_cursor_callback_allowed( + &timer, &timer, nullptr, true)); + assert(!setting::sound_card_cursor_callback_allowed( + &timer, &timer, &input_label, false)); + static_assert(noexcept(setting::sound_card_transition_timer_callback_allowed( + static_cast(nullptr), static_cast(nullptr), + static_cast(nullptr), false))); + assert(setting::sound_card_transition_timer_callback_allowed( + &timer, &timer, &screen, true)); + assert(!setting::sound_card_transition_timer_callback_allowed( + &stale_timer, &timer, &screen, true)); + assert(!setting::sound_card_transition_timer_callback_allowed( + &timer, &timer, static_cast(nullptr), true)); + assert(!setting::sound_card_transition_timer_callback_allowed( + &timer, &timer, &screen, false)); + assert(setting::sound_card_screen_delete_is_direct(&screen, &screen)); + assert(!setting::sound_card_screen_delete_is_direct(&child, &screen)); + assert(!setting::sound_card_screen_delete_is_direct( + static_cast(nullptr), &screen)); + static_assert(noexcept(setting::sound_card_transition_screen_delete_matches( + static_cast(nullptr), static_cast(nullptr), + static_cast(nullptr)))); + assert(setting::sound_card_transition_screen_delete_matches( + &screen, &screen, &screen)); + assert(!setting::sound_card_transition_screen_delete_matches( + &child, &screen, &screen)); + assert(!setting::sound_card_transition_screen_delete_matches( + &stale_screen, &stale_screen, &screen)); + assert(!setting::sound_card_transition_screen_delete_matches( + &screen, &screen, static_cast(nullptr))); + static_assert(noexcept(setting::sound_card_owned_label_delete_matches( + static_cast(nullptr), static_cast(nullptr), + static_cast(nullptr)))); + assert(setting::sound_card_owned_label_delete_matches( + &input_label, &input_label, &input_label)); + assert(!setting::sound_card_owned_label_delete_matches( + &child, &input_label, &input_label)); + assert(!setting::sound_card_owned_label_delete_matches( + &input_label, &input_label, &child)); + assert(!setting::sound_card_owned_label_delete_matches( + static_cast(nullptr), &input_label, &input_label)); + assert(setting::sound_card_owned_label_delete_matches( + &hint_label, &hint_label, &hint_label)); + assert(!setting::sound_card_owned_label_delete_matches( + &child, &hint_label, &hint_label)); + assert(!setting::sound_card_owned_label_delete_matches( + &hint_label, &hint_label, &input_label)); + + int backend_calls = 0; + assert(setting::sound_card_invoke_backend([&] { ++backend_calls; })); + assert(backend_calls == 1); + assert(!setting::sound_card_invoke_backend([] { throw std::runtime_error("backend"); })); + + std::vector current{1, 2}; + assert(!setting::sound_card_commit_if_success(false, std::vector{3}, current)); + assert((current == std::vector{1, 2})); + assert(setting::sound_card_commit_if_success(true, std::vector{4, 5}, current)); + assert((current == std::vector{4, 5})); + + using setting::SoundCardModel; + using setting::SoundCardTransitionModel; + using setting::SoundControlInfo; + + const auto cards = SoundCardModel::parse_cards( + "0\tCard 0: Built-in Audio\ninvalid\n2\tCard 2: USB Audio\n"); + assert(cards.size() == 2); + assert(cards[0].index == 0 && cards[0].name == "Card 0: Built-in Audio"); + assert(cards[1].index == 2 && cards[1].name == "Card 2: USB Audio"); + const auto bounded_cards = SoundCardModel::parse_cards( + "2junk\tbad\n-1\tnegative\n2147483648\toverflow\n2147483647\tvalid\n"); + assert(bounded_cards.size() == 1); + assert(bounded_cards[0].index == std::numeric_limits::max()); + + const auto controls = SoundCardModel::parse_controls( + "Master\tINTEGER\t0\t100\t1\tPlayback: 45 [45%]\t45\n" + "bad\trow\n" + "Input Source\tENUMERATED\t0\t0\t1\tItem0: 'Mic'\t0\n"); + assert(controls.size() == 2); + assert(controls[0].name == "Master"); + assert(controls[0].min_value == 0 && controls[0].max_value == 100); + assert(controls[0].current_value == 45); + assert(controls[1].current_text == "Item0: 'Mic'"); + const auto bounded_controls = SoundCardModel::parse_controls( + "bad-min\tINTEGER\t0junk\t100\t1\tPlayback: 1\t1\n" + "bad-max\tINTEGER\t0\t2147483648\t1\tPlayback: 1\t1\n" + "bad-step\tINTEGER\t0\t100\t1x\tPlayback: 1\t1\n" + "bad-current\tINTEGER\t0\t100\t1\tPlayback: 1\t999999999999999999\n"); + assert(bounded_controls.empty()); + + const SoundControlInfo fallback{"Master", "INTEGER", 5, 95, 1, + "Playback: 20 [20%]", 20}; + const auto detail = SoundCardModel::parse_detail( + " Capabilities: pvolume\n" + " Limits: Playback 0 - 100\n" + " Front Left: Playback 67 [67%]\n", + fallback); + assert(detail.name == "Master" && detail.type == "INTEGER"); + assert(detail.min_value == 0 && detail.max_value == 100); + assert(detail.current_text == "Front Left: Playback 67 [67%]"); + assert(detail.current_value == 67); + + const auto overflow_detail = SoundCardModel::parse_detail( + "Capabilities: pvolume\n" + "Limits: Playback -2147483649 - 2147483648\n" + "Front Left: Playback 999999999999999999 [100%]\n", + fallback); + assert(overflow_detail.min_value == fallback.min_value); + assert(overflow_detail.max_value == fallback.max_value); + assert(overflow_detail.current_value == 0); + + const auto enum_detail = SoundCardModel::parse_detail( + "Capabilities: enum\nItem0: 'Headphones'\n", controls[1]); + assert(enum_detail.type == "ENUMERATED"); + assert(enum_detail.current_text == "Item0: 'Headphones'"); + + assert(SoundCardModel::clamp_value(-1, fallback) == 5); + assert(SoundCardModel::clamp_value(50, fallback) == 50); + assert(SoundCardModel::clamp_value(120, fallback) == 95); + assert(SoundCardModel::clamp_value(120, controls[1]) == 120); + assert(SoundCardModel::parse_value("-2147483648", 7) == std::numeric_limits::min()); + assert(SoundCardModel::parse_value("2147483647", 7) == std::numeric_limits::max()); + assert(SoundCardModel::parse_value("2147483648", 7) == 7); + assert(SoundCardModel::parse_value("12junk", 7) == 7); + assert(SoundCardModel::parse_value("+12", 7) == 7); + + SoundCardTransitionModel transition; + assert(!transition.pending()); + assert(transition.begin()); + assert(transition.pending()); + assert(!transition.begin()); + assert(transition.complete()); + assert(!transition.pending()); + assert(!transition.complete()); + assert(transition.begin()); + transition.cancel(); + assert(!transition.pending()); +} diff --git a/projects/APPLaunch/tests/test_ssh_connection_model.cpp b/projects/APPLaunch/tests/test_ssh_connection_model.cpp new file mode 100644 index 00000000..2bd043c8 --- /dev/null +++ b/projects/APPLaunch/tests/test_ssh_connection_model.cpp @@ -0,0 +1,73 @@ +#include "../main/ui/model/ssh_connection_model.hpp" + +#include +#include + +namespace { + +void clear_active(SshConnectionModel &model) +{ + while (model.erase_last()) { + } +} + +} // namespace + +int main() +{ + SshConnectionModel model; + assert(model.label(0) == std::string("Host")); + assert(model.value(0) == "192.168.1.1"); + assert(model.valid()); + + auto arguments = model.arguments(); + assert(arguments.size() == 3); + auto argument = arguments.begin(); + assert(*argument++ == "-o"); + assert(*argument++ == "StrictHostKeyChecking=no"); + assert(*argument == "pi@192.168.1.1"); + + assert(!model.select_previous()); + assert(model.select_next()); + clear_active(model); + assert(model.append("2222")); + arguments = model.arguments(); + argument = arguments.begin(); + std::advance(argument, 2); + assert(*argument++ == "-p"); + assert(*argument++ == "2222"); + assert(*argument == "pi@192.168.1.1"); + + clear_active(model); + assert(model.append("70000")); + assert(!model.valid() && model.arguments().empty()); + clear_active(model); + assert(model.append("65535")); + assert(model.valid()); + clear_active(model); + assert(model.append("0")); + assert(!model.valid() && model.arguments().empty()); + clear_active(model); + assert(model.append("+22")); + assert(!model.valid()); + clear_active(model); + assert(model.append("22 ")); + assert(!model.valid()); + clear_active(model); + assert(model.append("999999999999999999999999999999")); + assert(!model.valid()); + clear_active(model); + assert(model.append("22")); + + assert(model.select_previous()); + clear_active(model); + assert(model.append("host")); + assert(model.append("\xE7\x95\x8C")); + assert(model.erase_last()); + assert(model.value(0) == "host"); + assert(!model.append("\n")); + + clear_active(model); + assert(model.append("-oProxyCommand=bad")); + assert(!model.valid()); +} diff --git a/projects/APPLaunch/tests/test_ssh_view_build_contract.cpp b/projects/APPLaunch/tests/test_ssh_view_build_contract.cpp new file mode 100644 index 00000000..8ab43afe --- /dev/null +++ b/projects/APPLaunch/tests/test_ssh_view_build_contract.cpp @@ -0,0 +1,50 @@ +#include "../main/ui/model/ssh_view_build_contract.hpp" + +#include + +int main() +{ + int background = 0; + int child = 0; + static_assert(noexcept(ssh_owned_delete_callback_allowed( + &background, &background))); + assert(ssh_owned_delete_callback_allowed(&background, &background)); + assert(!ssh_owned_delete_callback_allowed(&child, &background)); + assert(!ssh_owned_delete_callback_allowed( + static_cast(nullptr), &background)); + + int root_screen = 0; + int stale_root = 0; + static_assert(noexcept(ssh_page_event_callback_allowed( + &root_screen, &root_screen))); + assert(ssh_page_event_callback_allowed(&root_screen, &root_screen)); + assert(!ssh_page_event_callback_allowed(&stale_root, &root_screen)); + assert(!ssh_page_event_callback_allowed( + static_cast(nullptr), &root_screen)); + static_assert(noexcept(ssh_restore_completion_allowed( + false, static_cast(nullptr)))); + assert(ssh_restore_completion_allowed(true, &root_screen)); + assert(!ssh_restore_completion_allowed(false, &root_screen)); + assert(!ssh_restore_completion_allowed( + true, static_cast(nullptr))); + + SshViewBuildContract build(3); + assert(!build.ready()); + build.row_completed(); + build.row_completed(); + build.hint_completed(); + assert(!build.ready()); + build.row_completed(); + assert(build.ready()); + + SshViewBuildContract missing_hint(1); + missing_hint.row_completed(); + assert(!missing_hint.ready()); + + SshViewBuildContract too_many_rows(1); + too_many_rows.row_completed(); + too_many_rows.hint_completed(); + assert(too_many_rows.ready()); + too_many_rows.row_completed(); + assert(!too_many_rows.ready()); +} diff --git a/projects/APPLaunch/tests/test_st_key_encoder.cpp b/projects/APPLaunch/tests/test_st_key_encoder.cpp new file mode 100644 index 00000000..4366ccd3 --- /dev/null +++ b/projects/APPLaunch/tests/test_st_key_encoder.cpp @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "../main/ui/model/st_key_encoder.hpp" +#include "../main/ui/model/st_page_contract.hpp" + +#include "input_keys.h" + +#include +#include + +int main() +{ + static_assert(noexcept(st_callback_failure_state())); + constexpr auto callback_failure = st_callback_failure_state(); + static_assert(!callback_failure.terminal_active); + static_assert(callback_failure.waiting_key_to_exit); + static_assert(callback_failure.home_hold_status == 0); + + int child_status = 7; + assert(st_parse_child_status("0", child_status) && child_status == 0); + assert(st_parse_child_status("-15", child_status) && child_status == -15); + assert(!st_parse_child_status("", child_status)); + assert(!st_parse_child_status("12 exited", child_status)); + assert(!st_parse_child_status(" 12", child_status)); + assert(!st_parse_child_status("99999999999999999999", child_status)); + + int container = 0; + int canvas = 0; + int cursor = 0; + int renderer_part = 0; + assert(st_page_renderer_ready(&container, &canvas, &cursor)); + assert(!st_page_renderer_ready(nullptr, &canvas, &cursor)); + assert(!st_page_renderer_ready(&container, nullptr, &cursor)); + assert(!st_page_renderer_ready(&container, &canvas, nullptr)); + assert(st_page_renderer_ready(&container, &canvas, &cursor, &renderer_part)); + assert(!st_page_renderer_ready(&container, &canvas, &cursor, nullptr)); + + assert(STKeyEncoder::encode(KEY_ENTER, nullptr, false) == "\r"); + assert(STKeyEncoder::encode(KEY_BACKSPACE, nullptr, false) == + std::string(1, static_cast(0x7f))); + assert(STKeyEncoder::encode(KEY_ESC, nullptr, false) == "\x1b"); + + assert(STKeyEncoder::encode(KEY_UP, nullptr, false) == "\x1b[A"); + assert(STKeyEncoder::encode(KEY_DOWN, nullptr, false) == "\x1b[B"); + assert(STKeyEncoder::encode(KEY_RIGHT, nullptr, false) == "\x1b[C"); + assert(STKeyEncoder::encode(KEY_LEFT, nullptr, false) == "\x1b[D"); + assert(STKeyEncoder::encode(KEY_UP, nullptr, true) == "\x1bOA"); + assert(STKeyEncoder::encode(KEY_DOWN, nullptr, true) == "\x1bOB"); + assert(STKeyEncoder::encode(KEY_RIGHT, nullptr, true) == "\x1bOC"); + assert(STKeyEncoder::encode(KEY_LEFT, nullptr, true) == "\x1bOD"); + + assert(STKeyEncoder::encode(KEY_A, "a", false) == "a"); + assert(STKeyEncoder::encode(KEY_A, "\xe4\xbd\xa0", false) == "\xe4\xbd\xa0"); + assert(STKeyEncoder::encode(KEY_A, nullptr, false).empty()); + assert(STKeyEncoder::encode(KEY_A, "", false).empty()); + + const std::string maximum(STKeyEncoder::MAX_TEXT_BYTES, 'x'); + const std::string too_long(STKeyEncoder::MAX_TEXT_BYTES + 1, 'x'); + assert(STKeyEncoder::encode(KEY_A, maximum.c_str(), false) == maximum); + assert(STKeyEncoder::encode(KEY_A, too_long.c_str(), false).empty()); + + // A recognized control key never falls through to the supplied text. + assert(STKeyEncoder::encode(KEY_ENTER, "ignored", false) == "\r"); + return 0; +} diff --git a/projects/APPLaunch/tests/test_system_page_model.cpp b/projects/APPLaunch/tests/test_system_page_model.cpp new file mode 100644 index 00000000..41a2b3b8 --- /dev/null +++ b/projects/APPLaunch/tests/test_system_page_model.cpp @@ -0,0 +1,44 @@ +#include "../main/ui/model/system_page_model.hpp" + +#include +#include + +int main() +{ + using namespace system_page; + + std::string current = "stable"; + assert(!commit_if_success(false, std::string("partial"), current)); + assert(current == "stable"); + assert(commit_if_success(true, std::string("committed"), current)); + assert(current == "committed"); + + NetworkInfo network = parse_network_info("192.0.2.5\n192.0.2.1\naa:bb:cc:dd:ee:ff\n"); + assert(network.ip == "192.0.2.5"); + assert(network.gateway == "192.0.2.1"); + assert(network.mac == "aa:bb:cc:dd:ee:ff"); + + network = parse_network_info("10.0.0.2\r\n\r\n"); + assert(network.ip == "10.0.0.2"); + assert(network.gateway == "--"); + assert(network.mac == "--"); + + AccountInfo account = parse_account_info("alice\r\ncardputer\r\n"); + assert(account.username == "alice"); + assert(account.hostname == "cardputer"); + + account = parse_account_info(""); + assert(account.username == "--"); + assert(account.hostname == "--"); + + assert(version_label("abc123") == "Version: abc123"); + assert(version_label("") == "Version: --"); + assert(std::string(update_request(UpdateAction::CheckSystem)) == "AptUpdateBackground"); + assert(std::string(update_request(UpdateAction::UpdateLauncher)) == + "UpdateLauncherBackground"); + + assert(extport_toggle_value(false, true, true)); + assert(!extport_toggle_value(true, false, true)); + assert(extport_toggle_value(true, false, false)); + assert(!extport_toggle_value(false, true, false)); +} diff --git a/projects/APPLaunch/tests/test_tank_battle_model.cpp b/projects/APPLaunch/tests/test_tank_battle_model.cpp new file mode 100644 index 00000000..4adcd4bd --- /dev/null +++ b/projects/APPLaunch/tests/test_tank_battle_model.cpp @@ -0,0 +1,109 @@ +#include "../main/ui/model/tank_battle_model.hpp" +#include "../main/ui/model/tank_battle_page_contract.hpp" + +#include +#include + +static int active_bullet_count(const TankBattleModel &model) +{ + return static_cast(std::count_if(model.bullets().begin(), model.bullets().end(), + [](const TankBattleBullet &bullet) { return bullet.alive; })); +} + +static void assert_same_state(const TankBattleModel &left, const TankBattleModel &right) +{ + assert(left.player().x == right.player().x); + assert(left.player().y == right.player().y); + assert(left.player().alive == right.player().alive); + assert(left.score() == right.score()); + assert(left.game_over() == right.game_over()); + assert(left.won() == right.won()); + assert(left.enemies().size() == right.enemies().size()); + assert(left.bullets().size() == right.bullets().size()); + for (size_t index = 0; index < left.enemies().size(); ++index) { + assert(left.enemies()[index].x == right.enemies()[index].x); + assert(left.enemies()[index].y == right.enemies()[index].y); + assert(left.enemies()[index].alive == right.enemies()[index].alive); + } + for (size_t index = 0; index < left.bullets().size(); ++index) { + assert(left.bullets()[index].x == right.bullets()[index].x); + assert(left.bullets()[index].y == right.bullets()[index].y); + assert(left.bullets()[index].alive == right.bullets()[index].alive); + } +} + +int main() +{ + int background = 0; + int arena = 0; + int player = 0; + assert(tank_battle_ui_ready(&background, &arena, &player)); + assert(!tank_battle_ui_ready(nullptr, &arena, &player)); + assert(!tank_battle_ui_ready(&background, nullptr, &player)); + assert(!tank_battle_ui_ready(&background, &arena, nullptr)); + static_assert(noexcept(tank_battle_tick_callback_allowed( + false, false, static_cast(nullptr), static_cast(nullptr), + static_cast(nullptr)))); + assert(tank_battle_tick_callback_allowed( + true, true, &background, &arena, &player)); + assert(!tank_battle_tick_callback_allowed( + false, true, &background, &arena, &player)); + assert(!tank_battle_tick_callback_allowed( + true, false, &background, &arena, &player)); + assert(!tank_battle_tick_callback_allowed( + true, true, &background, &arena, static_cast(nullptr))); + int root_screen = 0; + int stale_root = 0; + static_assert(noexcept(tank_battle_root_event_callback_allowed( + static_cast(nullptr), static_cast(nullptr)))); + assert(tank_battle_root_event_callback_allowed(&root_screen, &root_screen)); + assert(!tank_battle_root_event_callback_allowed(&stale_root, &root_screen)); + assert(!tank_battle_root_event_callback_allowed( + static_cast(nullptr), &root_screen)); + int child = 0; + static_assert(noexcept(tank_battle_owned_delete_callback_allowed( + static_cast(nullptr), static_cast(nullptr)))); + assert(tank_battle_owned_delete_callback_allowed(&arena, &arena)); + assert(!tank_battle_owned_delete_callback_allowed(&child, &arena)); + assert(!tank_battle_owned_delete_callback_allowed( + static_cast(nullptr), &arena)); + + TankBattleModel model; + assert(model.player().x == TankBattleModel::GRID_COLUMNS / 2); + assert(model.player().y == TankBattleModel::GRID_ROWS - 1); + assert(model.alive_enemy_count() == TankBattleModel::ENEMY_COUNT); + assert(model.bullets().size() == TankBattleModel::BULLET_CAPACITY); + + assert(!model.move_player(TankDirection::DOWN)); + for (int column = model.player().x; column > 0; --column) + assert(model.move_player(TankDirection::LEFT)); + assert(model.player().x == 0); + assert(!model.move_player(TankDirection::LEFT)); + assert(model.player().direction == TankDirection::LEFT); + + assert(model.move_player(TankDirection::RIGHT)); + assert(model.player_fire()); + assert(!model.player_fire()); + assert(active_bullet_count(model) == 1); + for (int tick = 0; tick < 4; ++tick) model.tick(); + assert(model.player().fire_cooldown == 0); + assert(model.player_fire()); + + model.reset(); + assert(model.score() == 0); + assert(model.tick_count() == 0); + assert(!model.game_over()); + assert(active_bullet_count(model) == 0); + + TankBattleModel replay_a(1234); + TankBattleModel replay_b(1234); + replay_a.move_player(TankDirection::LEFT); + replay_b.move_player(TankDirection::LEFT); + replay_a.player_fire(); + replay_b.player_fire(); + for (int tick = 0; tick < 40; ++tick) { + replay_a.tick(); + replay_b.tick(); + } + assert_same_state(replay_a, replay_b); +} diff --git a/projects/APPLaunch/win_x86_cross_config_defaults.mk b/projects/APPLaunch/win_x86_cross_config_defaults.mk index 276f0450..f06df599 100644 --- a/projects/APPLaunch/win_x86_cross_config_defaults.mk +++ b/projects/APPLaunch/win_x86_cross_config_defaults.mk @@ -103,8 +103,25 @@ CONFIG_V9_5_LV_DRAW_THREAD_STACK_SIZE=65536 CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y -CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_LORA=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y +CONFIG_CP0_LVGL_INIT_CONFIG=y +CONFIG_CP0_LVGL_INIT_PTY=y +CONFIG_CP0_LVGL_INIT_RPC=y +CONFIG_CP0_LVGL_INIT_AUDIO=y +CONFIG_CP0_LVGL_INIT_PROCESS=y +CONFIG_CP0_LVGL_INIT_SUDO=y +CONFIG_CP0_LVGL_INIT_OSINFO=y +CONFIG_CP0_LVGL_INIT_SCREENSHOT=y +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_BLUETOOTH=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_CP0_LVGL_INIT_SOUNDCARD=y CONFIG_EVENTPP_ENABLED=y CONFIG_RADIOLIB_COMPONENT_ENABLED=y CONFIG_APPLAUNCH_WIN_X86_CROSS_CP0=y -CONFIG_UTILITIES_ENABLED=y \ No newline at end of file +CONFIG_UTILITIES_ENABLED=y diff --git a/projects/APPLaunch/win_x86_sdl2_config_defaults.mk b/projects/APPLaunch/win_x86_sdl2_config_defaults.mk index b9f73c18..4bfc30ce 100644 --- a/projects/APPLaunch/win_x86_sdl2_config_defaults.mk +++ b/projects/APPLaunch/win_x86_sdl2_config_defaults.mk @@ -97,8 +97,25 @@ CONFIG_V9_5_LV_DRAW_THREAD_STACK_SIZE=65536 CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y -CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_LORA=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y +CONFIG_CP0_LVGL_INIT_CONFIG=y +CONFIG_CP0_LVGL_INIT_PTY=y +CONFIG_CP0_LVGL_INIT_RPC=y +CONFIG_CP0_LVGL_INIT_AUDIO=y +CONFIG_CP0_LVGL_INIT_PROCESS=y +CONFIG_CP0_LVGL_INIT_SUDO=y +CONFIG_CP0_LVGL_INIT_OSINFO=y +CONFIG_CP0_LVGL_INIT_SCREENSHOT=y +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_BLUETOOTH=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_SAVED_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_CP0_LVGL_INIT_SOUNDCARD=y CONFIG_EVENTPP_ENABLED=y CONFIG_RADIOLIB_COMPONENT_ENABLED=y CONFIG_APPLAUNCH_WIN_X86_SDL2=y -CONFIG_UTILITIES_ENABLED=y \ No newline at end of file +CONFIG_UTILITIES_ENABLED=y diff --git a/projects/AppStore b/projects/AppStore index dc99ea12..98ac425a 160000 --- a/projects/AppStore +++ b/projects/AppStore @@ -1 +1 @@ -Subproject commit dc99ea1268f316066e77bc7f316f010634447776 +Subproject commit 98ac425a6d2bdc4823eb0d5fcb0721fc2eb40e5a diff --git a/projects/CardputerZero-Emulator/CMakeLists.txt b/projects/CardputerZero-Emulator/CMakeLists.txt index 75246345..e3b58524 100644 --- a/projects/CardputerZero-Emulator/CMakeLists.txt +++ b/projects/CardputerZero-Emulator/CMakeLists.txt @@ -147,30 +147,44 @@ endmacro() # =========================================================================== # Native-only targets (skipped for Emscripten) # =========================================================================== +set(CP0_LVGL_APP_COMMON_SRCS + ${CP0_LVGL}/src/commount.c + ${CP0_LVGL}/src/commount.cpp + ${CP0_LVGL}/src/cp0_font_service.cpp + ${CP0_LVGL}/src/cp0_lvgl_app_page_assets.c + ${CP0_LVGL}/src/cp0_network_api_contract.cpp + ${CP0_LVGL}/src/cp0_status_ethernet.c + ${CP0_LVGL}/src/cp0_status_lifecycle.cpp + ${CP0_LVGL}/src/ui_app_page.cpp +) + if(NOT EMSCRIPTEN) option(EMU_SKIP_APPLAUNCH "Skip APPLaunch (Unix-only deps)" OFF) -file(GLOB_RECURSE APPLAUNCH_UI_C ${APPLAUNCH}/ui/*.c) -file(GLOB_RECURSE APPLAUNCH_UI_CPP ${APPLAUNCH}/ui/*.cpp) +file(GLOB_RECURSE APPLAUNCH_UI_C CONFIGURE_DEPENDS ${APPLAUNCH}/ui/*.c) +file(GLOB_RECURSE APPLAUNCH_UI_CPP CONFIGURE_DEPENDS ${APPLAUNCH}/ui/*.cpp) list(FILTER APPLAUNCH_UI_C EXCLUDE REGEX ".*/ui/lvgl/.*") list(FILTER APPLAUNCH_UI_CPP EXCLUDE REGEX ".*/ui/lvgl/.*") -file(GLOB APPLAUNCH_KBD ${APPLAUNCH}/hal/keyboard_input.c) -file(GLOB APPLAUNCH_HAL_SDL_C ${APPLAUNCH}/hal/sdl/*.c) -file(GLOB APPLAUNCH_HAL_SDL_CPP ${APPLAUNCH}/hal/sdl/*.cpp) +file(GLOB APPLAUNCH_KBD CONFIGURE_DEPENDS ${APPLAUNCH}/hal/keyboard_input.c) +file(GLOB APPLAUNCH_HAL_SDL_C CONFIGURE_DEPENDS ${APPLAUNCH}/hal/sdl/*.c) +file(GLOB APPLAUNCH_HAL_SDL_CPP CONFIGURE_DEPENDS ${APPLAUNCH}/hal/sdl/*.cpp) +file(GLOB CP0_LVGL_COMMON_SRCS CONFIGURE_DEPENDS + ${CP0_LVGL}/src/*.c + ${CP0_LVGL}/src/*.cpp +) file(GLOB CP0_LVGL_SDL_SRCS CONFIGURE_DEPENDS - ${CP0_LVGL}/src/commount.c - ${CP0_LVGL}/src/commount.cpp - ${CP0_LVGL}/src/cp0_process_runner.cpp - ${CP0_LVGL}/src/cp0_sudo_async.cpp - ${CP0_LVGL}/src/cp0_sudo_coordinator.cpp + ${CP0_LVGL_COMMON_SRCS} + ${CP0_LVGL}/src/cp0/cp0_camera_viewport.cpp + ${CP0_LVGL}/src/cp0/cp0_desktop_exec_policy.cpp + ${CP0_LVGL}/src/cp0/cp0_process_commands.cpp + ${CP0_LVGL}/src/cp0/cp0_process_lifecycle.cpp ${CP0_LVGL}/src/sdl/*.c ${CP0_LVGL}/src/sdl/*.cpp ) set(CP0_LVGL_WIN32_SRCS src/cp0_web_runtime.cpp - ${CP0_LVGL}/src/commount.c - ${CP0_LVGL}/src/commount.cpp + ${CP0_LVGL_APP_COMMON_SRCS} ${CP0_LVGL}/src/win32/cp0_hal_stubs_win32.c ) @@ -409,8 +423,9 @@ add_custom_command(TARGET cardputer-zero-emu POST_BUILD COMMENT "Copying apps and resources" ) if(TARGET APPLaunch) - add_custom_command(TARGET cardputer-zero-emu POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy $ $/apps/ + add_custom_command(TARGET APPLaunch POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/apps + COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_CURRENT_BINARY_DIR}/apps/ COMMENT "Copying APPLaunch" ) endif() @@ -428,8 +443,7 @@ if(EMSCRIPTEN) file(GLOB APPLAUNCH_HAL_SDL_CPP_WEB ${APPLAUNCH}/hal/sdl/*.cpp) set(CP0_LVGL_WEB_SRCS src/cp0_web_runtime.cpp - ${CP0_LVGL}/src/commount.c - ${CP0_LVGL}/src/commount.cpp + ${CP0_LVGL_APP_COMMON_SRCS} ${CP0_LVGL}/src/web/cp0_hal_stubs_web.c ) diff --git a/projects/CardputerZero-Emulator/src/app_launch_entry.cpp b/projects/CardputerZero-Emulator/src/app_launch_entry.cpp index 5184a5b3..e0ce3611 100644 --- a/projects/CardputerZero-Emulator/src/app_launch_entry.cpp +++ b/projects/CardputerZero-Emulator/src/app_launch_entry.cpp @@ -1,12 +1,20 @@ #include "lvgl/lvgl.h" +#include "ui_screensaver.h" namespace launcher_ui { void init(); +void deinit(); } extern "C" void ui_init(void) { launcher_ui::init(); + ui_screensaver_init(); lv_obj_invalidate(lv_scr_act()); lv_refr_now(NULL); } + +extern "C" void ui_deinit(void) +{ + launcher_ui::deinit(); +} diff --git a/projects/CardputerZero-Emulator/src/main.cpp b/projects/CardputerZero-Emulator/src/main.cpp index e12cd882..fff680be 100644 --- a/projects/CardputerZero-Emulator/src/main.cpp +++ b/projects/CardputerZero-Emulator/src/main.cpp @@ -249,6 +249,7 @@ static void render() #endif typedef void (*ui_init_fn)(void); +typedef void (*ui_deinit_fn)(void); #if !defined(_WIN32) && !defined(__APPLE__) extern "C" { @@ -306,6 +307,7 @@ static void emu_init_cp0_runtime() #ifdef EMU_STATIC_APP extern "C" { void ui_init(void); + void ui_deinit(void); void lv_sdl_keyboard_handler(SDL_Event *event); } // APPLaunch's src/main.cpp defines LV_EVENT_BATTERY, but the Windows @@ -337,10 +339,28 @@ static void set_exe_dir() // Linux: usually launched from the right dir, or use /proc/self/exe } +static unsigned emu_test_frame(const char *name) +{ + const char *value = getenv(name); + if (!value || !*value) return 0; + + char *end = nullptr; + unsigned long frame = strtoul(value, &end, 10); + if (*end != '\0' || frame == 0) { + fprintf(stderr, "[EMU] Ignoring invalid %s=%s\n", name, value); + return 0; + } + return static_cast(frame); +} + int main(int argc, char *argv[]) { set_exe_dir(); + const unsigned test_reset_frame = emu_test_frame("EMU_TEST_RESET_FRAME"); + const unsigned test_quit_frame = emu_test_frame("EMU_TEST_QUIT_FRAME"); + const unsigned test_navigate_frame = emu_test_frame("EMU_TEST_NAVIGATE_FRAME"); + #ifdef EMU_STATIC_APP // Windows: app statically linked, no dlopen const char *app_path = "(static-linked UserDemo)"; @@ -361,7 +381,10 @@ int main(int argc, char *argv[]) printf(" Modifiers: click Aa/fn/ctrl to toggle\n"); printf("========================================\n"); - SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO); + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) != 0) { + fprintf(stderr, "SDL init: %s\n", SDL_GetError()); + return 1; + } IMG_Init(IMG_INIT_PNG); int win_w = (int)(SKIN_W * SCALE), win_h = (int)(SKIN_H * SCALE); @@ -369,14 +392,28 @@ int main(int argc, char *argv[]) g_win = SDL_CreateWindow("M5CardputerZero Emulator", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, win_w, win_h, SDL_WINDOW_SHOWN | SDL_WINDOW_ALLOW_HIGHDPI); + if (!g_win) { + fprintf(stderr, "window: %s\n", SDL_GetError()); + return 1; + } g_ren = SDL_CreateRenderer(g_win, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); + if (!g_ren) + g_ren = SDL_CreateRenderer(g_win, -1, SDL_RENDERER_SOFTWARE); + if (!g_ren) { + fprintf(stderr, "renderer: %s\n", SDL_GetError()); + return 1; + } SDL_RenderSetLogicalSize(g_ren, SKIN_W, SKIN_H); // On Retina, renderer output is 2x the window size - int render_w, render_h; - SDL_GetRendererOutputSize(g_ren, &render_w, &render_h); - g_dpi_scale = (float)render_w / (float)win_w; + int render_w = win_w, render_h = win_h; + if (SDL_GetRendererOutputSize(g_ren, &render_w, &render_h) != 0 || + render_w <= 0 || render_h <= 0) { + render_w = win_w; + render_h = win_h; + } + g_dpi_scale = static_cast(render_w) / static_cast(win_w); printf("[EMU] Window: %dx%d Renderer: %dx%d DPI scale: %.1f\n", win_w, win_h, render_w, render_h, g_dpi_scale); @@ -434,10 +471,16 @@ int main(int argc, char *argv[]) ui_init_fn app_init = (ui_init_fn)emu_dlsym(app, "ui_init"); if (!app_init) { fprintf(stderr, "[EMU] ui_init missing\n"); return 1; } + ui_deinit_fn app_deinit = (ui_deinit_fn)emu_dlsym(app, "ui_deinit"); + if (!app_deinit) + printf("[EMU] ui_deinit unavailable; legacy app compatibility mode\n"); app_init(); #endif printf("[EMU] Running.\n"); + unsigned frame_count = 0; + bool test_reset_done = false; + bool test_navigate_done = false; while (true) { SDL_Event ev; while (SDL_PollEvent(&ev)) { @@ -465,10 +508,17 @@ int main(int argc, char *argv[]) printf("[EMU] POWER — resetting\n"); memset(g_lcd_buf, 0, LCD_W * LCD_H * sizeof(uint32_t)); #ifdef EMU_STATIC_APP + ui_deinit(); + printf("[EMU] App deinitialized\n"); ui_init(); #else + if (app_deinit) { + app_deinit(); + printf("[EMU] App deinitialized\n"); + } app_init(); #endif + printf("[EMU] App initialized\n"); } g_side_pr = -1; } else if (side >= 0) { @@ -501,10 +551,47 @@ int main(int argc, char *argv[]) lv_tick_inc(5); lv_timer_handler(); render(); + ++frame_count; + if (!test_navigate_done && test_navigate_frame && frame_count >= test_navigate_frame) { + printf("[EMU] TEST navigate at frame %u\n", frame_count); + inject_sdl_key(SDLK_RIGHT, true); + inject_sdl_key(SDLK_RIGHT, false); + test_navigate_done = true; + } + if (!test_reset_done && test_reset_frame && frame_count >= test_reset_frame) { + printf("[EMU] TEST reset at frame %u\n", frame_count); + memset(g_lcd_buf, 0, LCD_W * LCD_H * sizeof(uint32_t)); +#ifdef EMU_STATIC_APP + ui_deinit(); + printf("[EMU] App deinitialized\n"); + ui_init(); +#else + if (app_deinit) { + app_deinit(); + printf("[EMU] App deinitialized\n"); + } + app_init(); +#endif + printf("[EMU] App initialized\n"); + test_reset_done = true; + } + if (test_quit_frame && frame_count >= test_quit_frame) { + printf("[EMU] TEST graceful quit at frame %u\n", frame_count); + goto done; + } SDL_Delay(5); } done: +#ifdef EMU_STATIC_APP + ui_deinit(); + printf("[EMU] App deinitialized\n"); +#else + if (app_deinit) { + app_deinit(); + printf("[EMU] App deinitialized\n"); + } +#endif free(g_lcd_buf); #ifndef EMU_STATIC_APP emu_dlclose(app); diff --git a/projects/CardputerZero-Emulator/src/main_web.cpp b/projects/CardputerZero-Emulator/src/main_web.cpp index cca37868..4a1430f2 100644 --- a/projects/CardputerZero-Emulator/src/main_web.cpp +++ b/projects/CardputerZero-Emulator/src/main_web.cpp @@ -12,7 +12,10 @@ #include #include -extern "C" { void ui_init(void); } +extern "C" { +void ui_init(void); +void ui_deinit(void); +} // APPLaunch's main.cpp defines LV_EVENT_BATTERY, but the web target // uses this main_web.cpp instead, so the symbol would be undefined. @@ -180,6 +183,7 @@ static void main_loop() { if(confirmed){ printf("[EMU] POWER — reset\n"); memset(g_lcd_buf,0,LCD_W*LCD_H*sizeof(uint32_t)); + ui_deinit(); ui_init(); } g_side_pr=-1; diff --git a/projects/HelloWorld/SConstruct b/projects/HelloWorld/SConstruct index e02185fb..144e415b 100644 --- a/projects/HelloWorld/SConstruct +++ b/projects/HelloWorld/SConstruct @@ -28,6 +28,20 @@ if os.environ.get("CONFIG_DEFAULT_FILE") == None: if platform.machine() == 'x86_64': os.environ["CONFIG_DEFAULT_FILE"] = "linux_x86_sdl2_config_defaults.mk" +# The SDK only generates Kconfig output when global_config.mk is absent. Invalidate +# generated configuration when callers switch between SDL and cross defaults. +selected_config_path = Path(os.environ["CONFIG_DEFAULT_FILE"]).resolve() +selected_config = str(selected_config_path) + "\n" + selected_config_path.read_text() +config_selection_path = Path("build") / "config" / "selected-defaults.txt" +previous_config = config_selection_path.read_text() if config_selection_path.exists() else "" +if previous_config != selected_config: + for generated_name in ("global_config.mk", "global_config.h", "lvgl_config.h"): + generated_path = Path("build") / "config" / generated_name + if generated_path.exists(): + generated_path.unlink() + config_selection_path.parent.mkdir(parents=True, exist_ok=True) + config_selection_path.write_text(selected_config) + if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ''): cross_package_enabled = True config_tmp_path = Path("build") / "config" / "config_tmp.mk" @@ -38,6 +52,10 @@ if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ''): if not config_tmp_path.exists() or config_tmp_path.read_text() != config_tmp_content: config_tmp_path.parent.mkdir(parents=True, exist_ok=True) config_tmp_path.write_text(config_tmp_content) +else: + config_tmp_path = Path("build") / "config" / "config_tmp.mk" + if config_tmp_path.exists(): + config_tmp_path.unlink() os.environ["SDK_PATH"] = str(sdk_path) os.environ["EXT_COMPONENTS_PATH"] = str(sdk_path.parent / "ext_components") diff --git a/projects/HelloWorld/config_defaults.mk b/projects/HelloWorld/config_defaults.mk index 30ee3a24..21df67cc 100644 --- a/projects/HelloWorld/config_defaults.mk +++ b/projects/HelloWorld/config_defaults.mk @@ -79,4 +79,8 @@ CONFIG_V9_5_LV_FREETYPE_CACHE_FT_GLYPH_CNT=512 CONFIG_V9_5_LV_USE_DEMO_WIDGETS=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y -CONFIG_EVENTPP_ENABLED=y \ No newline at end of file +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_EVENTPP_ENABLED=y diff --git a/projects/HelloWorld/linux_x86_cross_cp0_config_defaults.mk b/projects/HelloWorld/linux_x86_cross_cp0_config_defaults.mk index 8f61caf0..020dd216 100644 --- a/projects/HelloWorld/linux_x86_cross_cp0_config_defaults.mk +++ b/projects/HelloWorld/linux_x86_cross_cp0_config_defaults.mk @@ -97,5 +97,8 @@ CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_BATTERY=y CONFIG_EVENTPP_ENABLED=y -CONFIG_RADIOLIB_COMPONENT_ENABLED=y diff --git a/projects/HelloWorld/linux_x86_sdl2_config_defaults.mk b/projects/HelloWorld/linux_x86_sdl2_config_defaults.mk index 69ddcac6..2908a139 100644 --- a/projects/HelloWorld/linux_x86_sdl2_config_defaults.mk +++ b/projects/HelloWorld/linux_x86_sdl2_config_defaults.mk @@ -90,4 +90,8 @@ CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y -CONFIG_EVENTPP_ENABLED=y \ No newline at end of file +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_EVENTPP_ENABLED=y diff --git a/projects/HelloWorld/mac_cross_cp0_config_defaults.mk b/projects/HelloWorld/mac_cross_cp0_config_defaults.mk index d8d3ea96..d089c352 100644 --- a/projects/HelloWorld/mac_cross_cp0_config_defaults.mk +++ b/projects/HelloWorld/mac_cross_cp0_config_defaults.mk @@ -98,4 +98,8 @@ CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y -CONFIG_EVENTPP_ENABLED=y \ No newline at end of file +CONFIG_CP0_LVGL_INIT_WIFI=y +CONFIG_CP0_LVGL_INIT_SETTINGS=y +CONFIG_CP0_LVGL_INIT_BQ27220=y +CONFIG_CP0_LVGL_INIT_BATTERY=y +CONFIG_EVENTPP_ENABLED=y diff --git a/projects/HelloWorld/main/SConstruct b/projects/HelloWorld/main/SConstruct index 77599938..79fc2961 100644 --- a/projects/HelloWorld/main/SConstruct +++ b/projects/HelloWorld/main/SConstruct @@ -5,9 +5,9 @@ with open(env['PROJECT_TOOL_S']) as f: exec(f.read()) SRCS = Glob('src/*.c*') -INCLUDE = [ADir('.'), ADir('include')] +INCLUDE = [ADir('.'), ADir('include'), ADir('ui')] PRIVATE_INCLUDE = [] -REQUIREMENTS = ['cp0_lvgl', 'lvgl_component', 'pthread', 'Sigslot', 'eventpp', 'RadioLib'] +REQUIREMENTS = ['cp0_lvgl', 'lvgl_component', 'pthread', 'Sigslot', 'eventpp'] STATIC_LIB = [] DYNAMIC_LIB = [] DEFINITIONS = [] #'-std=c++20' diff --git a/projects/HelloWorld/main/include/main.h b/projects/HelloWorld/main/include/main.h deleted file mode 100644 index b2468113..00000000 --- a/projects/HelloWorld/main/include/main.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once -#include -#ifdef __cplusplus -int lvgl_main(void); -void ui_init(void) ; - - - - - - - - -extern "C" { -#endif - -#ifdef __cplusplus -} -#endif diff --git a/projects/HelloWorld/main/src/lvgl_main.cpp b/projects/HelloWorld/main/src/lvgl_main.cpp deleted file mode 100644 index 8a2b4413..00000000 --- a/projects/HelloWorld/main/src/lvgl_main.cpp +++ /dev/null @@ -1,29 +0,0 @@ -#include "lvgl/lvgl.h" -#include -#include -#include -#include "hal_lvgl_bsp.h" -#include "main.h" - -int lvgl_main(void) -{ - lv_init(); - cp0_lvgl_init(); - lv_display_t *disp = lv_display_get_default(); - if (disp == nullptr) { - fprintf(stderr, "HelloWorld: failed to create LVGL display\n"); - return 1; - } - - printf("HelloWorld: display %dx%d\n", - (int)lv_display_get_horizontal_resolution(disp), - (int)lv_display_get_vertical_resolution(disp)); - - ui_init(); - lv_obj_invalidate(lv_screen_active()); - - while (1) { - lv_timer_handler(); - usleep(10000); - } -} diff --git a/projects/HelloWorld/main/src/main.cpp b/projects/HelloWorld/main/src/main.cpp index cb3a7efb..ce7ce71a 100644 --- a/projects/HelloWorld/main/src/main.cpp +++ b/projects/HelloWorld/main/src/main.cpp @@ -1,5 +1,9 @@ -#include +#include "cp0_lvgl_app_runner.hpp" +#include "helloworld.hpp" + int main(int argc, char *argv[]) { - return lvgl_main(); -} \ No newline at end of file + (void)argc; + (void)argv; + return cp0_lvgl_run_app(); +} diff --git a/projects/HelloWorld/main/ui/helloworld.cpp b/projects/HelloWorld/main/ui/helloworld.cpp index ddf462d8..56bcac9e 100644 --- a/projects/HelloWorld/main/ui/helloworld.cpp +++ b/projects/HelloWorld/main/ui/helloworld.cpp @@ -1,12 +1,16 @@ -#include "main.h" -#include "lvgl/lvgl.h" +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ -namespace { +#include "helloworld.hpp" + +#include "input_keys.h" -constexpr int kScreenWidth = 320; -constexpr int kScreenHeight = 170; -constexpr int kTitleBarHeight = 30; -constexpr int kNavBarHeight = 30; +#include + +namespace { struct Palette { lv_color_t background; @@ -18,240 +22,157 @@ struct Palette { lv_color_t muted_text; }; -struct TemplateUiState { - bool dark_mode = false; - bool bold_text = false; - bool info_visible = false; - bool counter_page = false; - int counter = 0; - - lv_obj_t* screen = nullptr; - lv_obj_t* title_bar = nullptr; - lv_obj_t* content = nullptr; - lv_obj_t* nav_bar = nullptr; - lv_obj_t* apple_group = nullptr; - lv_obj_t* greeting_label = nullptr; - lv_obj_t* info_label = nullptr; - lv_obj_t* counter_label = nullptr; - lv_obj_t* nav_buttons[5] = {}; - lv_obj_t* nav_labels[5] = {}; -}; - -TemplateUiState g_ui; - Palette palette(bool dark_mode) { if (dark_mode) { - return { - lv_color_hex(0x101214), - lv_color_hex(0x15181c), - lv_color_hex(0x181b1f), - lv_color_hex(0x2a2f35), - lv_color_hex(0x30363d), - lv_color_hex(0xf4f4f5), - lv_color_hex(0x6f7782), - }; + return {lv_color_hex(0x101214), lv_color_hex(0x15181C), lv_color_hex(0x181B1F), + lv_color_hex(0x2A2F35), lv_color_hex(0x30363D), lv_color_hex(0xF4F4F5), + lv_color_hex(0x6F7782)}; } - - return { - lv_color_hex(0xf8f9fa), - lv_color_hex(0xffffff), - lv_color_hex(0xf0f0f0), - lv_color_hex(0xe7e7e7), - lv_color_hex(0xd0d7de), - lv_color_hex(0x1f2328), - lv_color_hex(0xa8adb3), - }; + return {lv_color_hex(0xF8F9FA), lv_color_hex(0xFFFFFF), lv_color_hex(0xF0F0F0), + lv_color_hex(0xE7E7E7), lv_color_hex(0xD0D7DE), lv_color_hex(0x1F2328), + lv_color_hex(0xA8ADB3)}; } -void style_plain_container(lv_obj_t* obj) +void plain_container(lv_obj_t *object) { - lv_obj_remove_style_all(obj); - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_style_all(object); + lv_obj_clear_flag(object, LV_OBJ_FLAG_SCROLLABLE); } -void update_template_ui() +lv_obj_t *centered_label(lv_obj_t *parent, const char *text, const lv_font_t *font) { - const Palette colors = palette(g_ui.dark_mode); - - lv_obj_set_style_bg_color(g_ui.screen, colors.background, 0); - lv_obj_set_style_bg_opa(g_ui.screen, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(g_ui.title_bar, colors.bar, 0); - lv_obj_set_style_bg_opa(g_ui.title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(g_ui.content, colors.surface, 0); - lv_obj_set_style_bg_opa(g_ui.content, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(g_ui.nav_bar, colors.bar, 0); - lv_obj_set_style_bg_opa(g_ui.nav_bar, LV_OPA_COVER, 0); - - lv_obj_set_style_text_color(g_ui.greeting_label, colors.text, 0); - lv_obj_set_style_text_font(g_ui.greeting_label, - g_ui.bold_text ? &lv_font_montserrat_22 : &lv_font_montserrat_20, - 0); + lv_obj_t *label = lv_label_create(parent); + lv_label_set_text(label, text); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); + return label; +} - lv_obj_set_style_text_color(g_ui.info_label, colors.text, 0); - if (g_ui.info_visible) { - lv_obj_remove_flag(g_ui.info_label, LV_OBJ_FLAG_HIDDEN); - } - else { - lv_obj_add_flag(g_ui.info_label, LV_OBJ_FLAG_HIDDEN); - } +} // namespace - lv_label_set_text_fmt(g_ui.counter_label, "Counter: %d", g_ui.counter); - lv_obj_set_style_text_color(g_ui.counter_label, colors.text, 0); +UIIpPanelPage::UIIpPanelPage() +{ + set_page_title("Template App"); + create_ui(); + lv_obj_add_event_cb(root_screen_, key_event_cb, LV_EVENT_KEY, this); + update_ui(); +} - if (g_ui.counter_page) { - lv_obj_add_flag(g_ui.apple_group, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_flag(g_ui.counter_label, LV_OBJ_FLAG_HIDDEN); - } - else { - lv_obj_remove_flag(g_ui.apple_group, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(g_ui.counter_label, LV_OBJ_FLAG_HIDDEN); +void UIIpPanelPage::create_ui() +{ + background_ = lv_obj_create(ui_APP_Container); + plain_container(background_); + lv_obj_set_size(background_, 320, 150); + + content_ = lv_obj_create(background_); + plain_container(content_); + lv_obj_set_size(content_, 320, 120); + lv_obj_align(content_, LV_ALIGN_TOP_MID, 0, 0); + + navigation_ = lv_obj_create(background_); + plain_container(navigation_); + lv_obj_set_size(navigation_, 320, 30); + lv_obj_align(navigation_, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_flex_flow(navigation_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(navigation_, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_hor(navigation_, 8, 0); + + for (int index = 0; index < 5; ++index) { + buttons_[index] = lv_button_create(navigation_); + lv_obj_set_size(buttons_[index], 40, 24); + lv_obj_set_style_radius(buttons_[index], 3, 0); + lv_obj_set_style_shadow_width(buttons_[index], 0, 0); + lv_obj_set_style_pad_all(buttons_[index], 0, 0); + lv_obj_add_event_cb(buttons_[index], button_event_cb, LV_EVENT_CLICKED, this); + lv_obj_set_user_data(buttons_[index], reinterpret_cast(static_cast(index))); + button_labels_[index] = centered_label(buttons_[index], "", &lv_font_montserrat_20); + lv_obj_center(button_labels_[index]); } - const char* apple_icons[5] = { - LV_SYMBOL_POWER, - "B", - LV_SYMBOL_TINT, - "i", - LV_SYMBOL_RIGHT, - }; - const char* counter_icons[5] = { - LV_SYMBOL_POWER, - LV_SYMBOL_MINUS, - LV_SYMBOL_TINT, - LV_SYMBOL_PLUS, - LV_SYMBOL_LEFT, - }; - const char** icons = g_ui.counter_page ? counter_icons : apple_icons; + hello_group_ = lv_obj_create(content_); + plain_container(hello_group_); + lv_obj_set_size(hello_group_, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(hello_group_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(hello_group_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(hello_group_, 8, 0); + lv_obj_center(hello_group_); - for (int i = 0; i < 5; ++i) { - lv_obj_set_style_text_color(g_ui.nav_labels[i], colors.text, 0); - lv_label_set_text(g_ui.nav_labels[i], icons[i]); - lv_obj_center(g_ui.nav_labels[i]); - } + greeting_label_ = centered_label(hello_group_, "Hello World", &lv_font_montserrat_20); + info_label_ = centered_label(hello_group_, "", &lv_font_montserrat_12); + lv_label_set_text_fmt(info_label_, "LVGL v%d.%d.%d", LVGL_VERSION_MAJOR, LVGL_VERSION_MINOR, + LVGL_VERSION_PATCH); + counter_label_ = centered_label(content_, "Counter: 0", &lv_font_montserrat_20); + lv_obj_center(counter_label_); } -void nav_event_cb(lv_event_t* event) +void UIIpPanelPage::update_ui() { - const int index = static_cast(reinterpret_cast(lv_event_get_user_data(event))); - - if (index == 0) { - g_ui.info_visible = true; - } - else if (index == 1) { - if (g_ui.counter_page) { - if (g_ui.counter > 0) { - --g_ui.counter; - } - } - else { - g_ui.bold_text = !g_ui.bold_text; - } - } - else if (index == 2) { - g_ui.dark_mode = !g_ui.dark_mode; - } - else if (index == 3) { - if (g_ui.counter_page) { - ++g_ui.counter; - } - else { - g_ui.info_visible = !g_ui.info_visible; - } - } - else if (index == 4) { - g_ui.counter_page = !g_ui.counter_page; + const Palette colors = palette(dark_mode_); + lv_obj_set_style_bg_color(background_, colors.background, 0); + lv_obj_set_style_bg_opa(background_, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(content_, colors.surface, 0); + lv_obj_set_style_bg_opa(content_, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(navigation_, colors.bar, 0); + lv_obj_set_style_bg_opa(navigation_, LV_OPA_COVER, 0); + lv_obj_set_style_text_color(greeting_label_, colors.text, 0); + lv_obj_set_style_text_font(greeting_label_, bold_text_ ? &lv_font_montserrat_22 : &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(info_label_, colors.text, 0); + lv_obj_set_style_text_color(counter_label_, colors.text, 0); + lv_label_set_text_fmt(counter_label_, "Counter: %d", counter_); + + if (info_visible_) lv_obj_remove_flag(info_label_, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(info_label_, LV_OBJ_FLAG_HIDDEN); + if (counter_page_) { + lv_obj_add_flag(hello_group_, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(counter_label_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_remove_flag(hello_group_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(counter_label_, LV_OBJ_FLAG_HIDDEN); } - update_template_ui(); + const char *hello_icons[] = {LV_SYMBOL_POWER, "B", LV_SYMBOL_TINT, "i", LV_SYMBOL_RIGHT}; + const char *counter_icons[] = {LV_SYMBOL_POWER, LV_SYMBOL_MINUS, LV_SYMBOL_TINT, LV_SYMBOL_PLUS, LV_SYMBOL_LEFT}; + const char **icons = counter_page_ ? counter_icons : hello_icons; + for (int index = 0; index < 5; ++index) { + lv_label_set_text(button_labels_[index], icons[index]); + lv_obj_set_style_text_color(button_labels_[index], colors.text, 0); + lv_obj_set_style_bg_color(buttons_[index], index == selected_button_ ? colors.button : colors.bar, 0); + lv_obj_set_style_bg_opa(buttons_[index], index == selected_button_ ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(buttons_[index], index == selected_button_ ? 1 : 0, 0); + lv_obj_set_style_border_color(buttons_[index], colors.border, 0); + } } -lv_obj_t* create_centered_label(lv_obj_t* parent, const char* text, const lv_font_t* font) +void UIIpPanelPage::activate(int index) { - lv_obj_t* label = lv_label_create(parent); - lv_label_set_text(label, text); - lv_obj_set_style_text_font(label, font, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - return label; + if (index == 0) info_visible_ = true; + else if (index == 1) counter_page_ ? counter_ = counter_ > 0 ? counter_ - 1 : 0 : bold_text_ = !bold_text_; + else if (index == 2) dark_mode_ = !dark_mode_; + else if (index == 3) counter_page_ ? ++counter_ : info_visible_ = !info_visible_; + else if (index == 4) counter_page_ = !counter_page_; + update_ui(); } -void build_template_ui() +void UIIpPanelPage::handle_key(lv_event_t *event) { - g_ui.screen = lv_screen_active(); - style_plain_container(g_ui.screen); - lv_obj_set_size(g_ui.screen, kScreenWidth, kScreenHeight); - - g_ui.title_bar = lv_obj_create(g_ui.screen); - style_plain_container(g_ui.title_bar); - lv_obj_set_size(g_ui.title_bar, LV_PCT(100), kTitleBarHeight); - lv_obj_align(g_ui.title_bar, LV_ALIGN_TOP_MID, 0, 0); - - lv_obj_t* title = create_centered_label(g_ui.title_bar, "Template App", &lv_font_montserrat_14); - lv_obj_center(title); - - g_ui.nav_bar = lv_obj_create(g_ui.screen); - style_plain_container(g_ui.nav_bar); - lv_obj_set_size(g_ui.nav_bar, LV_PCT(100), kNavBarHeight); - lv_obj_align(g_ui.nav_bar, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_obj_set_flex_flow(g_ui.nav_bar, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(g_ui.nav_bar, - LV_FLEX_ALIGN_SPACE_BETWEEN, - LV_FLEX_ALIGN_CENTER, - LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_left(g_ui.nav_bar, 8, 0); - lv_obj_set_style_pad_right(g_ui.nav_bar, 8, 0); - - for (int i = 0; i < 5; ++i) { - g_ui.nav_buttons[i] = lv_button_create(g_ui.nav_bar); - style_plain_container(g_ui.nav_buttons[i]); - lv_obj_set_size(g_ui.nav_buttons[i], 32, 22); - lv_obj_set_style_bg_opa(g_ui.nav_buttons[i], LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(g_ui.nav_buttons[i], 0, 0); - lv_obj_set_style_shadow_width(g_ui.nav_buttons[i], 0, 0); - lv_obj_set_style_pad_all(g_ui.nav_buttons[i], 0, 0); - lv_obj_add_event_cb(g_ui.nav_buttons[i], - nav_event_cb, - LV_EVENT_CLICKED, - reinterpret_cast(static_cast(i))); - - g_ui.nav_labels[i] = lv_label_create(g_ui.nav_buttons[i]); - lv_obj_set_style_text_font(g_ui.nav_labels[i], &lv_font_montserrat_20, 0); - lv_obj_center(g_ui.nav_labels[i]); - } - - g_ui.content = lv_obj_create(g_ui.screen); - style_plain_container(g_ui.content); - lv_obj_set_size(g_ui.content, LV_PCT(100), kScreenHeight - kTitleBarHeight - kNavBarHeight); - lv_obj_align(g_ui.content, LV_ALIGN_TOP_MID, 0, kTitleBarHeight); - - g_ui.apple_group = lv_obj_create(g_ui.content); - style_plain_container(g_ui.apple_group); - lv_obj_set_size(g_ui.apple_group, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(g_ui.apple_group, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(g_ui.apple_group, - LV_FLEX_ALIGN_CENTER, - LV_FLEX_ALIGN_CENTER, - LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_row(g_ui.apple_group, 8, 0); - lv_obj_center(g_ui.apple_group); - - g_ui.greeting_label = create_centered_label(g_ui.apple_group, "Hello World", &lv_font_montserrat_20); - g_ui.info_label = create_centered_label(g_ui.apple_group, "", &lv_font_montserrat_12); - lv_label_set_text_fmt(g_ui.info_label, - "LVGL v%d.%d.%d", - LVGL_VERSION_MAJOR, - LVGL_VERSION_MINOR, - LVGL_VERSION_PATCH); - - g_ui.counter_label = create_centered_label(g_ui.content, "Counter: 0", &lv_font_montserrat_20); - lv_obj_center(g_ui.counter_label); - - update_template_ui(); + uint32_t key = lv_event_get_key(event); + if (key == LV_KEY_LEFT || key == KEY_F) selected_button_ = (selected_button_ + 4) % 5; + else if (key == LV_KEY_RIGHT || key == KEY_X) selected_button_ = (selected_button_ + 1) % 5; + else if (key == LV_KEY_ENTER) activate(selected_button_); + else if (key == LV_KEY_ESC && navigate_home) navigate_home(); + update_ui(); } -} // namespace +void UIIpPanelPage::button_event_cb(lv_event_t *event) +{ + auto *self = static_cast(lv_event_get_user_data(event)); + self->activate(static_cast(reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))))); +} -void ui_init() +void UIIpPanelPage::key_event_cb(lv_event_t *event) { - build_template_ui(); + static_cast(lv_event_get_user_data(event))->handle_key(event); } diff --git a/projects/HelloWorld/main/ui/helloworld.hpp b/projects/HelloWorld/main/ui/helloworld.hpp new file mode 100644 index 00000000..e7125377 --- /dev/null +++ b/projects/HelloWorld/main/ui/helloworld.hpp @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include "ui_app_page.hpp" + +#include + +// The launcher-facing class name is retained for AppPage compatibility. +class UIIpPanelPage : public AppPage +{ +public: + UIIpPanelPage(); + ~UIIpPanelPage() override = default; + +private: + void create_ui(); + void update_ui(); + void activate(int index); + void handle_key(lv_event_t *event); + + static void button_event_cb(lv_event_t *event); + static void key_event_cb(lv_event_t *event); + + bool dark_mode_ = false; + bool bold_text_ = false; + bool info_visible_ = false; + bool counter_page_ = false; + int counter_ = 0; + int selected_button_ = 0; + + lv_obj_t *background_ = nullptr; + lv_obj_t *content_ = nullptr; + lv_obj_t *navigation_ = nullptr; + lv_obj_t *hello_group_ = nullptr; + lv_obj_t *greeting_label_ = nullptr; + lv_obj_t *info_label_ = nullptr; + lv_obj_t *counter_label_ = nullptr; + std::array buttons_{}; + std::array button_labels_{}; +}; diff --git a/projects/LaunchWizard/SConstruct b/projects/LaunchWizard/SConstruct index 2457bf96..6fc3c504 100644 --- a/projects/LaunchWizard/SConstruct +++ b/projects/LaunchWizard/SConstruct @@ -27,6 +27,20 @@ if os.environ.get("CONFIG_DEFAULT_FILE") is None: if platform.machine() == "x86_64": os.environ["CONFIG_DEFAULT_FILE"] = "linux_x86_sdl2_config_defaults.mk" +# The SDK only generates Kconfig output when global_config.mk is absent. Invalidate +# generated configuration when callers switch between SDL and cross defaults. +selected_config_path = Path(os.environ["CONFIG_DEFAULT_FILE"]).resolve() +selected_config = str(selected_config_path) + "\n" + selected_config_path.read_text() +config_selection_path = Path("build") / "config" / "selected-defaults.txt" +previous_config = config_selection_path.read_text() if config_selection_path.exists() else "" +if previous_config != selected_config: + for generated_name in ("global_config.mk", "global_config.h", "lvgl_config.h"): + generated_path = Path("build") / "config" / generated_name + if generated_path.exists(): + generated_path.unlink() + config_selection_path.parent.mkdir(parents=True, exist_ok=True) + config_selection_path.write_text(selected_config) + if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ""): cross_package_enabled = True config_tmp_path = Path("build") / "config" / "config_tmp.mk" @@ -35,6 +49,10 @@ if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ""): if not config_tmp_path.exists() or config_tmp_path.read_text() != config_tmp_content: config_tmp_path.parent.mkdir(parents=True, exist_ok=True) config_tmp_path.write_text(config_tmp_content) +else: + config_tmp_path = Path("build") / "config" / "config_tmp.mk" + if config_tmp_path.exists(): + config_tmp_path.unlink() os.environ["SDK_PATH"] = str(sdk_path) os.environ["EXT_COMPONENTS_PATH"] = str(sdk_path.parent / "ext_components") diff --git a/projects/LaunchWizard/main/ui/launch_wizard.cpp b/projects/LaunchWizard/main/ui/launch_wizard.cpp index 315be210..863d9a2d 100644 --- a/projects/LaunchWizard/main/ui/launch_wizard.cpp +++ b/projects/LaunchWizard/main/ui/launch_wizard.cpp @@ -9,7 +9,7 @@ #ifdef __linux__ #include #else -#include "compat/input_keys.h" +#include "input_keys.h" #endif #include diff --git a/projects/UserDemo/SConstruct b/projects/UserDemo/SConstruct index aacadc9a..565ffecc 100644 --- a/projects/UserDemo/SConstruct +++ b/projects/UserDemo/SConstruct @@ -18,6 +18,20 @@ if os.environ.get("CONFIG_DEFAULT_FILE") is None: if platform.machine() == "x86_64": os.environ["CONFIG_DEFAULT_FILE"] = "linux_x86_sdl2_config_defaults.mk" +# The SDK only generates Kconfig output when global_config.mk is absent. Invalidate +# generated configuration when callers switch between SDL and cross defaults. +selected_config_path = Path(os.environ["CONFIG_DEFAULT_FILE"]).resolve() +selected_config = str(selected_config_path) + "\n" + selected_config_path.read_text() +config_selection_path = Path("build") / "config" / "selected-defaults.txt" +previous_config = config_selection_path.read_text() if config_selection_path.exists() else "" +if previous_config != selected_config: + for generated_name in ("global_config.mk", "global_config.h", "lvgl_config.h"): + generated_path = Path("build") / "config" / generated_name + if generated_path.exists(): + generated_path.unlink() + config_selection_path.parent.mkdir(parents=True, exist_ok=True) + config_selection_path.write_text(selected_config) + if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ""): cross_package_enabled = True config_tmp_path = Path("build") / "config" / "config_tmp.mk" @@ -26,6 +40,10 @@ if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ""): if not config_tmp_path.exists() or config_tmp_path.read_text() != config_tmp_content: config_tmp_path.parent.mkdir(parents=True, exist_ok=True) config_tmp_path.write_text(config_tmp_content) +else: + config_tmp_path = Path("build") / "config" / "config_tmp.mk" + if config_tmp_path.exists(): + config_tmp_path.unlink() os.environ["SDK_PATH"] = str(sdk_path) os.environ["EXT_COMPONENTS_PATH"] = str(sdk_path.parent / "ext_components") diff --git a/projects/ZClaw/README.md b/projects/ZClaw/README.md index e69c0175..674715a6 100644 --- a/projects/ZClaw/README.md +++ b/projects/ZClaw/README.md @@ -88,9 +88,42 @@ On macOS, use `mac_cross_cp0_config_defaults.mk`. The legacy SDL or framebuffer display driver. The build depends on the repository `SDK`, `ext_components`, and APPLaunch font -asset. `main/SConstruct` fetches `cpp-httplib` and links OpenSSL support for +asset. `main/SConstruct` uses the SDK's lhv component with OpenSSL support for HTTPS and secure WebSocket connections. +## Tests + +Run the warning-clean unit and concurrency suite from this directory: + +```bash +tests/run_tests.sh +``` + +After an SDL build, a short lifecycle smoke test verifies that startup remains +responsive to process termination: + +```bash +timeout 3s ./dist/ZClaw +``` + +Exit status `124` is expected from `timeout`. The current SDL renderer produces +valid 320x170 frames, but XWayland desktop capture tools return a black root +pixmap in this environment. Pixel-level checks must read the SDL renderer +directly rather than relying on `scrot`. + +The same test list supports exact target filtering and additional compiler or +linker flags. For example, run representative concurrency tests with ASan and +UBSan using: + +```bash +ZCLAW_TEST_FILTER='zclaw_process_executor_test zclaw_async_service_test zclaw_http_cancellation_test' \ +ZCLAW_TEST_CXXFLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer -g' \ +ZCLAW_TEST_LDFLAGS='-fsanitize=address,undefined' \ +ASAN_OPTIONS='detect_leaks=1:halt_on_error=1' \ +UBSAN_OPTIONS='halt_on_error=1:print_stacktrace=1' \ +tests/run_tests.sh +``` + ## Configuration Notes `main/Kconfig` currently exposes `COMPILE_FOR_WEB`, `COMPILE_FOR_WIN32`, and @@ -104,10 +137,243 @@ evdev input and use `/usr/share/APPLaunch/` as the LVGL POSIX asset root. ## Repository Layout -- `main/ui/zclaw_app.cpp` - LVGL screens, keyboard handling, setup, provider - management, chat presentation, and approval UI -- `main/ui/zclaw_client.cpp` - ZeroClaw installation/configuration, pairing, - webhook/WebSocket transport, and approval protocol +- `main/ui/zclaw_app.cpp` - application composition, asynchronous workflow + orchestration, and semantic UI action execution +- `main/ui/zclaw_shell_view.*` - root application surface, static top bar, and + input-bar chrome with parent-delete-aware root ownership +- `main/ui/zclaw_widgets.*` - shared LVGL box, label, and image construction + with consistent non-interactive base styling +- `main/ui/zclaw_fonts.*` - owned FreeType font loading, fallback chains, and + explicit teardown after app content removal and before LVGL shutdown +- `main/ui/zclaw_font_path_model.*` - pure environment-override and ordered + font-candidate selection policy +- `main/ui/zclaw_theme.h` - shared UI palette tokens +- `main/ui/zclaw_pairing_service.*` - HTTP pairing and bearer-token result +- `main/ui/zclaw_chat_transport.*` - authenticated channel selection between + webhook and WebSocket chat +- `main/ui/zclaw_webhook_transport.*` - HTTP webhook request/response transport +- `main/ui/zclaw_request_id.*` - clock-injectable, concurrent unique Webhook + idempotency-key generation +- `main/ui/zclaw_websocket_transport.*` and `zclaw_chat_event.*` - WebSocket + streaming, approval exchange, response aggregation, and typed event parsing +- `main/ui/zclaw_chat_stream_model.*` - pure WebSocket stream accumulation, + approval signaling, terminal-state, and success/failure semantics +- `main/ui/zclaw_quickstart_service.*` - Setup-to-pairing result orchestration +- `main/ui/zclaw_quickstart_backend.h` and `zclaw_local_quickstart_backend.*` - + testable Quickstart side-effect boundary and production Setup/pairing adapter +- `main/ui/zclaw_protocol.*` - HTTP URL construction and structured JSON + encoding for webhook and WebSocket messages; typed response/event models + parse and validate each inbound payload once +- `main/ui/zclaw_http_client_policy.*` - named HTTP timeout/redirect profiles + shared by probes, pairing, webhook chat, and release downloads +- `main/ui/zclaw_http_cancellation.*` - shared sticky cancellation registry, + race-safe request registration, and active Pairing/Webhook request stopping +- `main/ui/zclaw_gateway_response_model.*` - pure pairing/webhook HTTP status, + JSON payload, business-error, and success-state interpretation +- `main/ui/zclaw_setup_service.*` - high-level Quickstart installation, + configuration, service-start, and pairing preparation workflow +- `main/ui/zclaw_setup_backend.h` and `zclaw_local_setup_backend.*` - testable + Setup side-effect boundary and its production installer/CLI implementation +- `main/ui/zclaw_process_runner.*` - legacy explicit shell execution used only + by test tooling, plus the shared captured-process result contract +- `main/ui/zclaw_process_executor.*` - owned process-group execution, captured + output, argv-only `execvp`, cancellable retry waits, sticky shutdown, and + child reaping without implicit shell interpretation +- `main/ui/zclaw_text.*` - shared ASCII whitespace normalization used by CLI + output and configuration-text models +- `main/ui/zclaw_text_file.*` - typed regular-file loading that distinguishes + missing files from I/O and invalid-file-type failures +- `main/ui/zclaw_binary_installer.*` - ZeroClaw release acquisition workflow, + readiness short-circuiting, and Setup progress orchestration +- `main/ui/zclaw_binary_installer_backend.h` and + `zclaw_local_binary_installer_backend.*` - testable installation side-effect + boundary and production filesystem/download/archive adapter +- `main/ui/zclaw_file_downloader.*` - cancellable streaming HTTP file transfer, + timeout policy, byte progress, and throughput measurement independent of Setup UI +- `main/ui/zclaw_temporary_directory.*` - RAII ownership and recursive cleanup + for private installation work directories +- `main/ui/zclaw_archive_installer.*` - archive extraction, executable discovery, + symlink-safe atomic deployment, and owner-only executable permissions +- `main/ui/zclaw_cli_service.*` - ZeroClaw CLI configuration, Agent preparation, + service control, and pairing-code generation behind injectable argv command + and retry-wait boundaries +- `main/ui/zclaw_cli_config_model.*` - pure staged ZeroClaw configuration plan, + Agent defaults, Provider references, and resulting local webhook policy +- `main/ui/zclaw_cli_output_model.*` - pure Agent-list parsing and idempotent + service-command result interpretation +- `main/ui/zclaw_risk_profile_model.*` - pure idempotent default risk-profile + document transformation +- `main/ui/zclaw_risk_profile_store.*` - permission-preserving ZeroClaw config + replacement +- `main/ui/zclaw_setup_progress_model.*` and `zclaw_setup_progress_view.*` - + testable transfer presentation data and a parent-owned LVGL progress subtree + with delete-event lifetime cleanup +- `main/ui/zclaw_quickstart_model.*` - testable Quickstart defaults and + pairing-code extraction +- `main/ui/zclaw_paths.*`, `zclaw_path_model.*`, and `zclaw_types.h` - + thread-safe account lookup, pure portable runtime path policy, and shared + namespaced data contracts used across UI, persistence, setup, and transport + modules +- `main/ui/zclaw_runtime_model.*` - pure installed/configured/setup/token + readiness policy used to decide whether Quickstart is required +- `main/ui/zclaw_runtime_state.*` - filesystem and UI-config readiness probe +- `main/ui/zclaw_connectivity.*` - injectable public-endpoint probes with + callback-chained asynchronous HTTP fallback +- `main/ui/zclaw_approval_controller.*` - single-session approval isolation, + request-bound waiting, reusable cancellation, and sticky shutdown +- `main/ui/zclaw_approval_coordinator.*` - approval state/dialog ownership, + UI-thread prompt delivery, decisions, timeout cleanup, and cancellation +- `main/ui/zclaw_callback_lifetime.h` - mutex-serialized callback invocation + and owner invalidation for queued UI work across Approval and asynchronous + Startup, Chat, Authorization, and Setup workflows +- `main/ui/zclaw_approval_dialog.*` - approval prompt layout and selection + presentation with top-layer delete-event lifetime cleanup +- `main/ui/zclaw_chat_view.*` and `zclaw_chat_layout.*` - chat message rendering, + explicit borrowed-LVGL callback teardown, pure scroll clamping, bubble sizing, + and scrollbar geometry +- `main/ui/zclaw_chat_workflow.*` - chat submission state, Quickstart gating, + approval-enabled asynchronous requests, and result presentation +- `main/ui/zclaw_input_dialog.*` - text editor dialog, cursor styling, edit modes, + keyboard editing primitives, and top-layer delete-event lifetime cleanup +- `main/ui/zclaw_input_model.*` and `zclaw_input_workflow.*` - testable input + submission decisions and dispatch to chat, settings edits, or pairing +- `main/ui/zclaw_key_router.*` - testable keyboard mode routing from normalized + key events to semantic application actions +- `main/ui/zclaw_key_event_adapter.*` - Linux keyboard event normalization kept + outside the application composition root +- `main/ui/zclaw_ui_action_dispatcher.*` - execution of routed semantic actions + across input, approval, settings, and chat UI services +- `main/ui/zclaw_settings_panel.*` - settings panel rows, selection styling, + headers, parent-delete tracking, and explicit mounted/opening/open/closing + animation lifecycle +- `main/ui/zclaw_startup_model.h` and `zclaw_startup_view.*` - shared startup + state and parent-delete-aware network-check/offline overlay presentation +- `main/ui/zclaw_startup_workflow.*` - UI task-queue startup, asynchronous + connectivity checks, and first-run Setup transition +- `main/ui/zclaw_task_inbox.*` and `zclaw_ui_task_queue.*` - thread-safe, + lifetime-aware delivery of background results to the LVGL thread +- `main/ui/zclaw_task_batch.*` - noexcept per-callback exception isolation for + UI-thread task batches +- `main/ui/zclaw_lhv_http_client.*` - shared lhv `sendAsync` ownership, + exactly-once completion, retry policy, and shutdown result delivery +- lhv's global async pool executes blocking setup/process stages and approval + timeout waits; interactive HTTP and WebSocket traffic uses event callbacks +- `main/ui/zclaw_async_service.*` - asynchronous network, chat, pairing, and + Setup execution with explicit backend injection and UI-queue-only progress + and completion callbacks, including backend-exception normalization +- `main/ui/zclaw_async_backend.h` and `zclaw_local_async_backend.*` - testable + background-operation boundary and production service adapter +- `main/ui/zclaw_ui_task_sink.h` - minimal UI-thread task-delivery boundary + implemented by the LVGL-backed UiTaskQueue +- `main/ui/zclaw_provider_catalog.*` and `zclaw_*_store.*` - provider defaults + and persistent UI/provider configuration +- `main/ui/zclaw_provider_collection_model.*` - pure Provider activation, + replacement, deletion, and active-entry synchronization rules +- `main/ui/zclaw_config_codec.*` - pure escaped-field encoding, decoding, and + tab-separated line parsing shared by persistent stores +- `main/ui/zclaw_config_document_model.*` - pure Provider/UI configuration + document parsing, checked validity, DTO mapping, and serialization +- `main/ui/zclaw_atomic_file.*` - unique-temporary-file, permission-aware, + file-and-parent-directory-fsynced atomic replacement shared by persistent stores +- `main/ui/zclaw_directory_sync.*` - portable parent-directory durability after + atomic rename, with explicit unsupported-filesystem handling +- `main/ui/zclaw_provider_manager.*` - transactional provider/setup state, + typed load status, persistence, activation, editing, deletion, and rollback +- `main/ui/zclaw_ui_config_manager.*` - transactional UI configuration loading, + typed missing/invalid/I/O status, updates, token clearing, and rollback +- `main/ui/zclaw_storage.*` - recursive owner-only configuration-directory + preparation with ancestor-symlink rejection, shared by persistent managers +- `main/ui/zclaw_provider_form_model.*` - Provider field mapping and Quickstart + form validation independent of Settings navigation and LVGL +- `main/ui/zclaw_settings_navigation_model.*` - Settings page activation and + back-navigation decisions independent of form data and LVGL +- `main/ui/zclaw_selection_model.*` - reusable bounded row selection and paged + list-selection state independent of Settings and LVGL +- `main/ui/zclaw_settings_controller.*` - settings page, row, paged-provider, + detail, and edit-state ownership +- `main/ui/zclaw_settings_coordinator.*` - settings state/view ownership, + presentation binding, navigation, and panel lifecycle +- `main/ui/zclaw_settings_workflow.*` - settings action dispatch and + cross-page back-navigation orchestration +- `main/ui/zclaw_authorization_workflow.*` - pairing input/request completion, + generation-safe request completion, authorization-token persistence, + clearing, and page refresh +- `main/ui/zclaw_authorization_session_model.*` - pure pairing request + generation, invalidation, and stale-completion rejection +- `main/ui/zclaw_provider_workflow.*` - Provider creation, detail editing, + deletion, selection synchronization, and persistence feedback +- `main/ui/zclaw_setup_workflow.*` - Setup editing, validation, Provider + selection, asynchronous progress, completion persistence, and page lifecycle +- `main/ui/zclaw_settings_presentation.*` - testable settings headers, hints, + row labels, values, and paged-list presentation +- `tests/zclaw_core_test.cpp` - persistence, service, controller, concurrency, + and presentation coverage using a shared temporary fixture +- `tests/zclaw_protocol_model_test.cpp` - isolated protocol encoding/parsing, + chat layout/events, Quickstart normalization, and setup-progress presentation +- `tests/zclaw_selection_model_test.cpp` - isolated row and paged-selection + boundary coverage +- `tests/zclaw_settings_rules_test.cpp` - isolated Provider/Setup form rules and + Settings navigation-decision coverage +- `tests/zclaw_filesystem_test.cpp` - temporary-directory lifetime plus local + archive discovery, executable deployment, and permission coverage +- `tests/zclaw_cli_config_model_test.cpp` - isolated ZeroClaw CLI configuration + path, value, staging, and default-Agent coverage +- `tests/zclaw_cli_output_model_test.cpp` - isolated Agent-list and service + command-output interpretation coverage +- `tests/zclaw_cli_service_test.cpp` - deterministic command sequencing, + service failure, pairing retry, wait-count, and invalid-input coverage +- `tests/zclaw_config_codec_test.cpp` - isolated persistent field-codec and + tab-separated line-boundary coverage +- `tests/zclaw_config_document_model_test.cpp` - isolated Provider/UI document + round trips, malformed-record handling, defaults, and unknown-key coverage +- `tests/zclaw_config_manager_load_test.cpp` - missing, invalid, valid, retained + in-memory state, and directory I/O failure coverage +- `tests/zclaw_risk_profile_model_test.cpp` - isolated default risk-profile + transformation and idempotency coverage +- `tests/zclaw_provider_collection_model_test.cpp` - isolated Provider + collection transitions, invalid-index handling, and active-entry coverage +- `tests/zclaw_runtime_model_test.cpp` - isolated first-run readiness truth-table + coverage independent of HOME and filesystem state +- `tests/zclaw_binary_installer_test.cpp` - isolated installation sequencing, + short-circuiting, failure propagation, and progress mapping with a fake backend +- `tests/zclaw_text_test.cpp` - isolated ASCII whitespace normalization coverage +- `tests/zclaw_text_file_test.cpp` - missing, empty, binary-content, and + invalid-file-type read coverage +- `tests/zclaw_task_batch_test.cpp` - callback ordering, empty-task, standard + and non-standard exception isolation coverage +- `tests/zclaw_atomic_file_test.cpp` - isolated atomic replacement, content, + permissions, and invalid-parent failure coverage +- `tests/zclaw_directory_sync_test.cpp` - parent-directory durability success, + missing-parent, and non-directory failure coverage +- `tests/zclaw_font_path_model_test.cpp` - isolated font override, candidate + priority, missing-path, and invalid-probe coverage +- `tests/zclaw_path_model_test.cpp` - HOME/account fallback and portable + `.zeroclaw` path composition coverage +- `tests/zclaw_authorization_session_model_test.cpp` - isolated pairing + generation, replacement, invalidation, and stale-completion coverage +- `tests/zclaw_request_id_test.cpp` - fixed-clock formatting and concurrent + Webhook request-ID uniqueness coverage + shutdown waiting/rejection, and destructor join coverage +- `tests/zclaw_storage_test.cpp` - recursive directory creation, owner-only + permissions, wide-mode tightening, and blocking-file coverage +- `tests/zclaw_connectivity_cancel_test.cpp` - deterministic in-flight probe + cancellation using a blocking fake adapter +- `tests/zclaw_http_cancellation_test.cpp` - sticky shutdown, registration + lifetime, move semantics, and unregister/shutdown race coverage +- `tests/zclaw_lhv_http_client_test.cpp` - localhost async response and + in-flight shutdown completion coverage +- `tests/zclaw_process_executor_test.cpp` - process output, process-group + cancellation, child reaping, sticky rejection, and cancellable wait coverage +- `tests/zclaw_setup_service_test.cpp` - Setup sequencing, normalization, + progress, failure short-circuiting, and pairing-code coverage with a fake backend +- `tests/zclaw_quickstart_service_test.cpp` - Quickstart result composition, + pairing success criteria, completion state, and progress with a fake backend +- `tests/zclaw_async_service_test.cpp` - callback execution, UI-task deferral, + backend lifetime, shutdown rejection, Setup progress ordering, and invalid-callback coverage +- `tests/zclaw_input_routing_test.cpp` - isolated input submission, platform-key + normalization, and semantic key-routing coverage +- `tests/run_tests.sh` - shared warning-clean build policy and explicit minimal + source dependencies for all test binaries - `main/src/` - application entry point and LVGL platform loop - `APPLaunch/` and `assets/` - packaged ZClaw image assets - `SConstruct`, `main/SConstruct`, and `*_config_defaults.mk` - project build diff --git a/projects/ZClaw/SConstruct b/projects/ZClaw/SConstruct index 36ac3341..593b052b 100644 --- a/projects/ZClaw/SConstruct +++ b/projects/ZClaw/SConstruct @@ -28,6 +28,20 @@ if os.environ.get("CONFIG_DEFAULT_FILE") == None: if platform.machine() == 'x86_64': os.environ["CONFIG_DEFAULT_FILE"] = "linux_x86_sdl2_config_defaults.mk" +# The SDK only regenerates Kconfig output when global_config.mk is absent. +# Track both defaults path and contents so config switches cannot reuse stale output. +selected_config_path = Path(os.environ["CONFIG_DEFAULT_FILE"]).resolve() +selected_config = str(selected_config_path) + "\n" + selected_config_path.read_text() +config_selection_path = Path("build") / "config" / "selected-defaults.txt" +previous_config = config_selection_path.read_text() if config_selection_path.exists() else "" +if previous_config != selected_config: + for generated_name in ("global_config.mk", "global_config.h", "lvgl_config.h"): + generated_path = Path("build") / "config" / generated_name + if generated_path.exists(): + generated_path.unlink() + config_selection_path.parent.mkdir(parents=True, exist_ok=True) + config_selection_path.write_text(selected_config) + if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ''): cross_package_enabled = True config_tmp_path = Path("build") / "config" / "config_tmp.mk" @@ -38,6 +52,10 @@ if "cross" in os.environ.get("CONFIG_DEFAULT_FILE", ''): if not config_tmp_path.exists() or config_tmp_path.read_text() != config_tmp_content: config_tmp_path.parent.mkdir(parents=True, exist_ok=True) config_tmp_path.write_text(config_tmp_content) +else: + config_tmp_path = Path("build") / "config" / "config_tmp.mk" + if config_tmp_path.exists(): + config_tmp_path.unlink() os.environ["SDK_PATH"] = str(sdk_path) os.environ["EXT_COMPONENTS_PATH"] = str(sdk_path.parent / "ext_components") diff --git a/projects/ZClaw/config_defaults.mk b/projects/ZClaw/config_defaults.mk index 30ee3a24..4b71a657 100644 --- a/projects/ZClaw/config_defaults.mk +++ b/projects/ZClaw/config_defaults.mk @@ -79,4 +79,11 @@ CONFIG_V9_5_LV_FREETYPE_CACHE_FT_GLYPH_CNT=512 CONFIG_V9_5_LV_USE_DEMO_WIDGETS=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y -CONFIG_EVENTPP_ENABLED=y \ No newline at end of file +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y +CONFIG_EVENTPP_ENABLED=y +CONFIG_UTILITIES_ENABLED=y +CONFIG_LHV_ENABLED=y +CONFIG_LHV_WITH_EVPP=y +CONFIG_LHV_WITH_HTTP=y +CONFIG_LHV_WITH_WITH_HTTP_CLIENT=y +CONFIG_LHV_WITH_OPENSSL=y diff --git a/projects/ZClaw/docs/configuration.md b/projects/ZClaw/docs/configuration.md index 4b238355..86961c50 100644 --- a/projects/ZClaw/docs/configuration.md +++ b/projects/ZClaw/docs/configuration.md @@ -49,7 +49,11 @@ All three default to disabled. The current ZClaw source does not branch on these ## Dependencies and generated assets -`main/SConstruct` fetches `cpp-httplib` and compiles it with OpenSSL support. Runtime HTTP, HTTPS, and WebSocket communication therefore depends on the linked `ssl` and `crypto` libraries. +`main/SConstruct` uses the SDK's lhv component with OpenSSL support. Runtime HTTP, HTTPS, and WebSocket communication therefore depends on lhv and the linked `ssl` and `crypto` libraries. + +HTTP requests use lhv's `sendAsync` event-loop API. WebSocket messages and +approval decisions are callback-driven; blocking setup and process stages run +on lhv's global asynchronous worker pool rather than a ZClaw-owned thread pool. The project stages static files into `build/static/APPLaunch` before linking: diff --git a/projects/ZClaw/libhv.20260722.log b/projects/ZClaw/libhv.20260722.log new file mode 100644 index 00000000..c53b93ce --- /dev/null +++ b/projects/ZClaw/libhv.20260722.log @@ -0,0 +1,36 @@ +2026-07-22 16:51:40.560 INFO EventLoopThread started, tid=3467804 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:51:40.561 INFO EventLoopThread stopped, tid=3467804 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:51:40.562 INFO EventLoopThread started, tid=3467806 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:52:08.481 INFO EventLoopThread started, tid=3468629 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:52:08.482 INFO EventLoopThread stopped, tid=3468629 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:52:08.484 INFO EventLoopThread started, tid=3468631 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:52:30.116 INFO EventLoopThread started, tid=3469947 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:52:30.118 INFO EventLoopThread stopped, tid=3469947 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:52:30.119 INFO EventLoopThread started, tid=3469949 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:52:30.120 INFO EventLoopThread stopped, tid=3469949 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:53:24.453 INFO EventLoopThread started, tid=3477300 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:53:24.455 INFO EventLoopThread stopped, tid=3477300 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:53:24.456 INFO EventLoopThread started, tid=3477302 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:53:24.457 INFO EventLoopThread stopped, tid=3477302 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:56:19.034 INFO EventLoopThread started, tid=3484197 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:56:19.035 INFO EventLoopThread stopped, tid=3484197 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:56:19.037 INFO EventLoopThread started, tid=3484199 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:56:19.038 INFO EventLoopThread stopped, tid=3484199 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:59:32.269 INFO EventLoopThread started, tid=3496817 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:59:32.271 INFO EventLoopThread stopped, tid=3496817 [EventLoopThread.h:105:loop_thread] +2026-07-22 16:59:32.272 INFO EventLoopThread started, tid=3496819 [EventLoopThread.h:86:loop_thread] +2026-07-22 16:59:32.273 INFO EventLoopThread stopped, tid=3496819 [EventLoopThread.h:105:loop_thread] +2026-07-22 19:27:44.380 INFO EventLoopThread started, tid=3842227 [EventLoopThread.h:86:loop_thread] +2026-07-22 19:27:44.382 INFO EventLoopThread stopped, tid=3842227 [EventLoopThread.h:105:loop_thread] +2026-07-22 19:27:44.383 INFO EventLoopThread started, tid=3842229 [EventLoopThread.h:86:loop_thread] +2026-07-22 19:27:44.384 INFO EventLoopThread stopped, tid=3842229 [EventLoopThread.h:105:loop_thread] +2026-07-22 19:27:44.385 INFO EventLoopThread started, tid=3842231 [EventLoopThread.h:86:loop_thread] +2026-07-22 19:27:44.387 INFO EventLoopThread stopped, tid=3842231 [EventLoopThread.h:105:loop_thread] +2026-07-22 19:28:53.141 INFO EventLoopThread started, tid=3845121 [EventLoopThread.h:86:loop_thread] +2026-07-22 19:28:53.142 INFO EventLoopThread stopped, tid=3845121 [EventLoopThread.h:105:loop_thread] +2026-07-22 19:28:53.143 INFO EventLoopThread started, tid=3845124 [EventLoopThread.h:86:loop_thread] +2026-07-22 19:28:53.144 INFO EventLoopThread stopped, tid=3845124 [EventLoopThread.h:105:loop_thread] +2026-07-22 19:28:53.145 INFO EventLoopThread started, tid=3845127 [EventLoopThread.h:86:loop_thread] +2026-07-22 19:28:53.147 INFO EventLoopThread stopped, tid=3845127 [EventLoopThread.h:105:loop_thread] +2026-07-22 19:28:53.148 INFO EventLoopThread started, tid=3845130 [EventLoopThread.h:86:loop_thread] +2026-07-22 19:28:53.149 INFO EventLoopThread stopped, tid=3845130 [EventLoopThread.h:105:loop_thread] diff --git a/projects/ZClaw/linux_x86_cross_cp0_config_defaults.mk b/projects/ZClaw/linux_x86_cross_cp0_config_defaults.mk index 8f61caf0..90649980 100644 --- a/projects/ZClaw/linux_x86_cross_cp0_config_defaults.mk +++ b/projects/ZClaw/linux_x86_cross_cp0_config_defaults.mk @@ -93,9 +93,14 @@ CONFIG_V9_5_LV_USE_EVDEV=y CONFIG_V9_5_LV_OS_PTHREAD=y CONFIG_V9_5_LV_DRAW_THREAD_STACK_SIZE=65536 CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 -CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y CONFIG_EVENTPP_ENABLED=y -CONFIG_RADIOLIB_COMPONENT_ENABLED=y +CONFIG_UTILITIES_ENABLED=y +CONFIG_LHV_ENABLED=y +CONFIG_LHV_WITH_EVPP=y +CONFIG_LHV_WITH_HTTP=y +CONFIG_LHV_WITH_WITH_HTTP_CLIENT=y +CONFIG_LHV_WITH_OPENSSL=y diff --git a/projects/ZClaw/linux_x86_sdl2_config_defaults.mk b/projects/ZClaw/linux_x86_sdl2_config_defaults.mk index 4a4975b3..c4408cd6 100644 --- a/projects/ZClaw/linux_x86_sdl2_config_defaults.mk +++ b/projects/ZClaw/linux_x86_sdl2_config_defaults.mk @@ -88,9 +88,14 @@ CONFIG_V9_5_LV_FS_POSIX_PATH="./" CONFIG_V9_5_LV_OS_PTHREAD=y CONFIG_V9_5_LV_DRAW_THREAD_STACK_SIZE=65536 CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 -CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y CONFIG_EVENTPP_ENABLED=y -CONFIG_RADIOLIB_COMPONENT_ENABLED=y +CONFIG_UTILITIES_ENABLED=y +CONFIG_LHV_ENABLED=y +CONFIG_LHV_WITH_EVPP=y +CONFIG_LHV_WITH_HTTP=y +CONFIG_LHV_WITH_WITH_HTTP_CLIENT=y +CONFIG_LHV_WITH_OPENSSL=y diff --git a/projects/ZClaw/mac_cross_cp0_config_defaults.mk b/projects/ZClaw/mac_cross_cp0_config_defaults.mk index f6c3b5b6..e2021937 100644 --- a/projects/ZClaw/mac_cross_cp0_config_defaults.mk +++ b/projects/ZClaw/mac_cross_cp0_config_defaults.mk @@ -94,9 +94,14 @@ CONFIG_V9_5_LV_USE_EVDEV=y CONFIG_V9_5_LV_OS_PTHREAD=y CONFIG_V9_5_LV_DRAW_THREAD_STACK_SIZE=65536 CONFIG_V9_5_LV_DRAW_THREAD_PRIO=3 -CONFIG_MINIAUDIO_COMPONENT_ENABLED=y CONFIG_SIGSLOT_COMPONENT_ENABLED=y CONFIG_CP0_LVGL_COMPONENT_ENABLED=y +CONFIG_CP0_LVGL_INIT_FILESYSTEM=y CONFIG_EVENTPP_ENABLED=y -CONFIG_RADIOLIB_COMPONENT_ENABLED=y +CONFIG_UTILITIES_ENABLED=y +CONFIG_LHV_ENABLED=y +CONFIG_LHV_WITH_EVPP=y +CONFIG_LHV_WITH_HTTP=y +CONFIG_LHV_WITH_WITH_HTTP_CLIENT=y +CONFIG_LHV_WITH_OPENSSL=y diff --git a/projects/ZClaw/main/SConstruct b/projects/ZClaw/main/SConstruct index 697cbcf7..b2db5953 100644 --- a/projects/ZClaw/main/SConstruct +++ b/projects/ZClaw/main/SConstruct @@ -6,18 +6,17 @@ Import('env') with open(env['PROJECT_TOOL_S']) as f: exec(f.read()) -cpp_httplib = wget_github("https://github.com/yhirose/cpp-httplib.git") applaunch_lvgl_dir = ADir('../../APPLaunch/main/lvgl') SRCS = Glob('src/*.c*') SRCS += append_srcs_dir(ADir('ui')) -INCLUDE = [ADir('.'), ADir('include'), ADir('ui'), cpp_httplib, applaunch_lvgl_dir] +INCLUDE = [ADir('.'), ADir('include'), ADir('ui'), applaunch_lvgl_dir] PRIVATE_INCLUDE = [] -REQUIREMENTS = ['cp0_lvgl', 'lvgl_component', 'pthread', 'Sigslot', 'eventpp', 'RadioLib', 'ssl', 'crypto'] +REQUIREMENTS = ['cp0_lvgl', 'lvgl_component', 'pthread', 'Sigslot', 'eventpp', 'ssl', 'crypto', 'utilities', 'hv'] STATIC_LIB = [] DYNAMIC_LIB = [] -DEFINITIONS = ['-DCPPHTTPLIB_OPENSSL_SUPPORT'] +DEFINITIONS = [] DEFINITIONS_PRIVATE = [] LDFLAGS = [] LINK_SEARCH_PATH = [] diff --git a/projects/ZClaw/main/include/main.h b/projects/ZClaw/main/include/main.h index 7c18b705..a5f3dfd5 100644 --- a/projects/ZClaw/main/include/main.h +++ b/projects/ZClaw/main/include/main.h @@ -1,21 +1,3 @@ #pragma once -#include -#ifdef __cplusplus -int lvgl_main(int argc = 0, char **argv = nullptr); -void ui_init(void) ; -bool ui_should_quit(void); -void ui_request_quit(void); - - - - - - - -extern "C" { -#endif - -#ifdef __cplusplus -} -#endif +int run_zclaw_app(); diff --git a/projects/ZClaw/main/src/lvgl_main.cpp b/projects/ZClaw/main/src/lvgl_main.cpp deleted file mode 100644 index 73cccfbd..00000000 --- a/projects/ZClaw/main/src/lvgl_main.cpp +++ /dev/null @@ -1,58 +0,0 @@ -#include "lvgl/lvgl.h" -#include "global_config.h" -#ifdef CONFIG_V9_5_LV_USE_SDL -#include "hal/hal_paths.h" -#endif -#include -#include -#include -#include -#include "hal_lvgl_bsp.h" -#include "main.h" - -namespace { - -std::string executable_dir(const char *argv0) -{ - if (!argv0 || argv0[0] == '\0') - return "."; - std::string path(argv0); - const size_t slash = path.find_last_of('/'); - if (slash == std::string::npos) - return "."; - if (slash == 0) - return "/"; - return path.substr(0, slash); -} - -} // namespace - -int lvgl_main(int argc, char **argv) -{ - (void)argc; -#ifdef CONFIG_V9_5_LV_USE_SDL - const std::string exe_dir = executable_dir(argv && argv[0] ? argv[0] : nullptr); - hal_paths_init(exe_dir.c_str()); -#endif - - lv_init(); - cp0_lvgl_init(); - lv_display_t *disp = lv_display_get_default(); - if (disp == nullptr) { - fprintf(stderr, "HelloWorld: failed to create LVGL display\n"); - return 1; - } - - printf("HelloWorld: display %dx%d\n", - (int)lv_display_get_horizontal_resolution(disp), - (int)lv_display_get_vertical_resolution(disp)); - - ui_init(); - lv_obj_invalidate(lv_screen_active()); - - while (!ui_should_quit()) { - lv_timer_handler(); - usleep(10000); - } - return 0; -} diff --git a/projects/ZClaw/main/src/main.cpp b/projects/ZClaw/main/src/main.cpp index 67dfee40..d95de0e5 100644 --- a/projects/ZClaw/main/src/main.cpp +++ b/projects/ZClaw/main/src/main.cpp @@ -1,5 +1,36 @@ -#include +#include "cp0_lvgl_app_runner.hpp" +#include "global_config.h" +#include "main.h" + +#ifdef CONFIG_V9_5_LV_USE_SDL +#include "hal/hal_paths.h" +#endif + +#include + +namespace { + +std::string executable_dir(const char *path) +{ + if (!path || !path[0]) + return "."; + const std::string value(path); + const size_t slash = value.find_last_of('/'); + if (slash == std::string::npos) + return "."; + return slash == 0 ? "/" : value.substr(0, slash); +} + +} // namespace + int main(int argc, char *argv[]) { - return lvgl_main(argc, argv); +#ifdef CONFIG_V9_5_LV_USE_SDL + const std::string directory = executable_dir(argv && argv[0] ? argv[0] : nullptr); + hal_paths_init(directory.c_str()); +#else + (void)argc; + (void)argv; +#endif + return run_zclaw_app(); } diff --git a/projects/ZClaw/main/ui/zclaw_app.cpp b/projects/ZClaw/main/ui/zclaw_app.cpp index be55b0c9..e0b46238 100644 --- a/projects/ZClaw/main/ui/zclaw_app.cpp +++ b/projects/ZClaw/main/ui/zclaw_app.cpp @@ -5,28 +5,36 @@ */ #include "main.h" +#include "cp0_lvgl_app_runner.hpp" #include "lvgl/lvgl.h" #include "hal_lvgl_bsp.h" #include "cp0_lvgl_file.hpp" #include "keyboard_input.h" -#include "zclaw_client.h" +#include "zclaw_approval_coordinator.h" +#include "zclaw_async_service.h" +#include "zclaw_chat_view.h" +#include "zclaw_chat_workflow.h" +#include "zclaw_input_dialog.h" +#include "zclaw_input_workflow.h" +#include "zclaw_key_event_adapter.h" +#include "zclaw_key_router.h" +#include "zclaw_local_async_backend.h" #include "zclaw_fonts.hpp" -#include "zclaw_provider_store.h" +#include "zclaw_paths.h" +#include "zclaw_provider_manager.h" +#include "zclaw_settings_coordinator.h" +#include "zclaw_settings_workflow.h" +#include "zclaw_shell_view.h" +#include "zclaw_startup_view.h" +#include "zclaw_startup_workflow.h" +#include "zclaw_ui_task_queue.h" +#include "zclaw_ui_config_manager.h" +#include "zclaw_ui_action_dispatcher.h" -#include - -#include #include -#include -#include -#include -#include -#include -#include -#include +#include #include #include -#include namespace { @@ -37,2086 +45,135 @@ inline const struct key_item *keyboard_item(lv_event_t *event) return static_cast(lv_event_get_param(event)); } -inline uint32_t keyboard_key(lv_event_t *event) -{ - const struct key_item *item = keyboard_item(event); - return item ? item->key_code : 0; -} - -class ZClawApp +class ZClawApp : public AppPageRoot { - static constexpr lv_coord_t SCREEN_W = 320; - static constexpr lv_coord_t SCREEN_H = 170; - - static constexpr uint32_t COLOR_BG = 0x0F0F1A; - static constexpr uint32_t COLOR_BAR = 0x16162A; - static constexpr uint32_t COLOR_PANEL = 0x1E1E35; - static constexpr uint32_t COLOR_PANEL_LINE = 0x2A2A45; - static constexpr uint32_t COLOR_MUTED = 0xA1A1AA; - static constexpr uint32_t COLOR_DIM = 0x52525B; - static constexpr uint32_t COLOR_TEXT = 0xF4F4F5; - static constexpr uint32_t COLOR_ONLINE = 0x10B981; - static constexpr uint32_t COLOR_PURPLE = 0x8B5CF6; - static constexpr uint32_t COLOR_INDIGO = 0x6366F1; - static constexpr lv_coord_t USER_BUBBLE_MAX_W = 198; - static constexpr lv_coord_t USER_BUBBLE_MIN_W = 38; - static constexpr lv_coord_t USER_BUBBLE_PAD_X = 10; - static constexpr lv_coord_t USER_BUBBLE_PAD_Y = 6; - static constexpr lv_coord_t CHAT_ROW_W = 296; - static constexpr int SETTINGS_ROW_MAX = 5; - - enum class SettingsView { - Setup, - SetupProviders, - Authorization, - Main, - Providers, - ProviderDetail - }; - - enum class ProviderEditField { - None, - Alias, - Family, - Model, - Uri, - ApiKey - }; - - enum class InputMode { - Chat, - SetupEdit, - ProviderEdit, - PairingCode - }; - - enum class SetupEditField { - None, - Uri, - ApiKey, - Model - }; - - enum class StartupState { - CheckingNetwork, - Offline, - Ready - }; - - struct AsyncResult { - ZClawApp *self = nullptr; - std::string text; - bool ok = false; - UiConfig config; - }; - - struct ApprovalPromptData { - ZClawApp *self = nullptr; - ZClawApprovalRequest request; - }; - - struct NetworkResult { - ZClawApp *self = nullptr; - bool online = false; - std::string error; - }; - - struct SetupProgressData { - ZClawApp *self = nullptr; - ZClawSetupProgress progress; - }; - - lv_obj_t *screen_ = nullptr; - lv_obj_t *startup_overlay_ = nullptr; - lv_obj_t *startup_title_ = nullptr; - lv_obj_t *startup_detail_ = nullptr; - lv_obj_t *chat_container_ = nullptr; - lv_obj_t *scroll_track_ = nullptr; - lv_obj_t *scroll_thumb_ = nullptr; - lv_obj_t *user_bubble_ = nullptr; - lv_obj_t *user_label_ = nullptr; - lv_obj_t *reply_bubble_ = nullptr; - lv_obj_t *reply_label_ = nullptr; - lv_obj_t *input_bar_ = nullptr; - lv_obj_t *input_box_ = nullptr; - lv_obj_t *input_label_ = nullptr; - lv_obj_t *input_sparkle_ = nullptr; - lv_obj_t *send_button_ = nullptr; - lv_obj_t *input_dialog_ = nullptr; - lv_obj_t *input_textarea_ = nullptr; - lv_obj_t *approval_dialog_ = nullptr; - lv_obj_t *approval_tool_label_ = nullptr; - lv_obj_t *approval_summary_label_ = nullptr; - lv_obj_t *approval_buttons_[3] = {}; - lv_obj_t *settings_panel_ = nullptr; - lv_obj_t *settings_header_label_ = nullptr; - lv_obj_t *settings_hint_label_ = nullptr; - lv_obj_t *setup_spinner_ = nullptr; - lv_obj_t *setup_status_label_ = nullptr; - lv_obj_t *setup_detail_label_ = nullptr; - lv_obj_t *setup_progress_bar_ = nullptr; - lv_obj_t *setup_percent_label_ = nullptr; - lv_obj_t *setup_speed_label_ = nullptr; - lv_obj_t *settings_rows_[SETTINGS_ROW_MAX] = {}; - lv_obj_t *settings_values_[SETTINGS_ROW_MAX] = {}; - lv_style_t textarea_cursor_style_; - bool textarea_cursor_style_inited_ = false; - bool settings_animating_ = false; - bool settings_closing_ = false; - SettingsView settings_view_ = SettingsView::Main; - int settings_selected_ = 0; - int settings_row_count_ = 0; - int provider_selected_ = 0; - int provider_scroll_ = 0; - int provider_detail_index_ = -1; - ProviderEditField provider_edit_field_ = ProviderEditField::None; - SetupEditField setup_edit_field_ = SetupEditField::None; - InputMode input_mode_ = InputMode::Chat; - StartupState startup_state_ = StartupState::CheckingNetwork; - UiConfig ui_config_; - std::vector providers_; - ProviderConfig setup_provider_; - int setup_provider_selected_ = 0; - int setup_provider_scroll_ = 0; + zclaw::ProviderManager provider_manager_{zclaw::paths::providers_config()}; + zclaw::UiConfigManager config_manager_{zclaw::paths::ui_config()}; std::string avatar_path_; - std::string sparkles_path_; - std::string send_button_path_; - uint32_t reply_seed_ = 0; - bool request_in_flight_ = false; - bool setup_in_flight_ = false; - std::mutex approval_mutex_; - std::condition_variable approval_cv_; - std::string pending_approval_id_; - std::string pending_approval_tool_; - std::string pending_approval_decision_; - bool approval_waiting_ = false; - int approval_selected_ = 0; - ZClawClient client_; + std::string storage_warning_; + std::shared_ptr ui_tasks_ = + std::make_shared(); + zclaw::AsyncService async_service_{ + ui_tasks_, std::make_shared()}; zclaw::FontManager fonts_; + zclaw::ShellView shell_view_; + zclaw::InputDialog input_dialog_; + zclaw::SettingsCoordinator settings_ui_{provider_manager_, config_manager_, + input_dialog_, fonts_}; + zclaw::ApprovalCoordinator approvals_{fonts_, ui_tasks_}; + zclaw::ChatView chat_view_; + zclaw::SettingsWorkflow settings_workflow_{ + provider_manager_, config_manager_, settings_ui_, input_dialog_, fonts_, + chat_view_, async_service_}; + zclaw::ChatWorkflow chat_workflow_{ + config_manager_, chat_view_, async_service_, approvals_, + [this] { settings_workflow_.open_setup(shell_view_.content(), true); }}; + zclaw::InputWorkflow input_workflow_{ + input_dialog_, settings_workflow_, chat_workflow_}; + zclaw::StartupView startup_view_; + zclaw::StartupWorkflow startup_workflow_{ + config_manager_, startup_view_, async_service_, ui_tasks_, + [this] { settings_workflow_.open_setup(shell_view_.content(), true); }}; + zclaw::UiActionDispatcher actions_{ + config_manager_, shell_view_, fonts_, input_dialog_, input_workflow_, + approvals_, settings_ui_, settings_workflow_, chat_view_, + [] { g_quit_requested = 1; }}; public: ZClawApp() { - root_screen_ = lv_screen_active(); fonts_.init(); avatar_path_ = cp0_file_path("zclaw_avatar_16.png"); - sparkles_path_ = cp0_file_path("zclaw_sparkles_10.png"); - send_button_path_ = cp0_file_path("zclaw_send_button_18.png"); - load_providers(); - load_ui_config(); - setup_provider_ = providers_.empty() ? provider_preset(0) : providers_[0]; - create_ui(); + const std::string sparkles_path = cp0_file_path("zclaw_sparkles_10.png"); + const std::string send_button_path = cp0_file_path("zclaw_send_button_18.png"); + load_configuration(); + if (!shell_view_.create(root_screen_, &fonts_, avatar_path_, sparkles_path, + send_button_path)) + return; + chat_view_.create(shell_view_.content(), &fonts_, avatar_path_); + if (!storage_warning_.empty()) + chat_view_.append_assistant_message(storage_warning_); event_handler_init(); - show_network_check(); - start_network_check(); - } - -private: - lv_obj_t *root_screen_ = nullptr; - static lv_obj_t *make_box(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, lv_coord_t w, lv_coord_t h, - uint32_t color, lv_coord_t radius = 0) - { - lv_obj_t *obj = lv_obj_create(parent); - lv_obj_set_pos(obj, x, y); - lv_obj_set_size(obj, w, h); - lv_obj_set_style_radius(obj, radius, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(obj, lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(obj, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - return obj; - } - - static lv_obj_t *make_label(lv_obj_t *parent, const char *text, lv_coord_t x, lv_coord_t y, - lv_coord_t w, lv_coord_t h, const lv_font_t *font, uint32_t color, - lv_text_align_t align = LV_TEXT_ALIGN_LEFT) - { - lv_obj_t *label = lv_label_create(parent); - lv_label_set_text(label, text ? text : ""); - lv_obj_set_pos(label, x, y); - lv_obj_set_size(label, w, h); - lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); - lv_obj_set_style_text_font(label, font, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_opa(label, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_align(label, align, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(label, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(label, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(label, LV_OBJ_FLAG_SCROLLABLE); - return label; - } - - static void apply_vertical_gradient(lv_obj_t *obj, uint32_t top, uint32_t bottom) - { - lv_obj_set_style_bg_color(obj, lv_color_hex(top), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_color(obj, lv_color_hex(bottom), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_grad_dir(obj, LV_GRAD_DIR_VER, LV_PART_MAIN | LV_STATE_DEFAULT); - } - - static lv_coord_t centered_y(lv_coord_t container_h, lv_coord_t item_h) - { - return (container_h - item_h) / 2; - } - - static ProviderConfig provider_preset(int index) - { - switch (index) { - case 0: - return {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", ""}; - case 1: - return {"zclaw", "openrouter", "openrouter/auto", "https://openrouter.ai/api/v1", ""}; - case 2: - return {"zclaw", "anthropic", "claude-sonnet-4", "https://api.anthropic.com", ""}; - case 3: - return {"zclaw", "ollama", "llama3.1", "http://127.0.0.1:11434", ""}; - case 4: - return {"zclaw", "deepseek", "deepseek-chat", "https://api.deepseek.com", ""}; - default: - return {"zclaw", "custom", "", "https://api.example.com/v1", ""}; - } - } - - static const char *provider_display_name(const std::string &family) - { - if (family == "openai") return "OpenAI"; - if (family == "openrouter") return "OpenRouter"; - if (family == "anthropic") return "Anthropic"; - if (family == "ollama") return "Ollama"; - if (family == "deepseek") return "DeepSeek"; - return "Custom"; - } - - static std::string display_text_compat(const std::string &text) - { - return text; - } - - bool first_run_needed() const - { - return ZClawClient::first_run_needed(ui_config_); - } - - void load_providers() - { - zclaw::load_provider_configs(ZClawClient::providers_config_path(), &providers_); - - if (providers_.empty()) { - for (int i = 0; i < 6; ++i) - providers_.push_back(provider_preset(i)); - } - } - - bool save_providers(std::string *error = nullptr) - { - ZClawClient::ensure_storage_dir(); - return zclaw::save_provider_configs(ZClawClient::providers_config_path(), providers_, error); - } - - void report_save_error(const std::string &error) - { - append_ai_message(error.empty() ? "Could not save settings." : error.c_str()); - } - - bool save_setup_provider() - { - std::vector candidate = providers_; - if (candidate.empty()) - candidate.push_back(setup_provider_); - else - candidate[0] = setup_provider_; - std::string error; - ZClawClient::ensure_storage_dir(); - if (!zclaw::save_provider_configs(ZClawClient::providers_config_path(), candidate, &error)) { - report_save_error(error); - return false; - } - providers_ = std::move(candidate); - return true; + startup_workflow_.start(shell_view_.content(), &fonts_); } - void load_ui_config() + ~ZClawApp() { - std::ifstream file(ZClawClient::ui_config_path()); - std::string line; - while (std::getline(file, line)) { - std::vector fields = zclaw::split_config_line(line); - if (fields.size() < 2) - continue; - const std::string key = zclaw::decode_config_field(fields[0]); - const std::string value = zclaw::decode_config_field(fields[1]); - if (key == "webhook_url") - ui_config_.webhook_url = value; - else if (key == "agent_alias") - ui_config_.agent_alias = value.empty() ? "zclaw" : value; - else if (key == "webhook_secret") - ui_config_.webhook_secret = value; - else if (key == "bearer_token") - ui_config_.bearer_token = value; - else if (key == "setup_complete") - ui_config_.setup_complete = value == "1"; - } + lv_obj_remove_event_cb_with_user_data( + root_screen_, ZClawApp::static_lvgl_handler, this); + approvals_.shutdown(); + async_service_.shutdown(); + ui_tasks_->shutdown(); } - bool save_ui_config() +private: + void load_configuration() { - ZClawClient::ensure_storage_dir(); - std::string contents; - contents += zclaw::encode_config_field("webhook_url") + '\t' + zclaw::encode_config_field(ui_config_.webhook_url) + '\n'; - contents += zclaw::encode_config_field("agent_alias") + '\t' + zclaw::encode_config_field(ui_config_.agent_alias) + '\n'; - contents += zclaw::encode_config_field("webhook_secret") + '\t' + zclaw::encode_config_field(ui_config_.webhook_secret) + '\n'; - contents += zclaw::encode_config_field("bearer_token") + '\t' + zclaw::encode_config_field(ui_config_.bearer_token) + '\n'; - contents += zclaw::encode_config_field("setup_complete") + '\t' + (ui_config_.setup_complete ? "1" : "0") + '\n'; std::string error; - if (zclaw::atomic_write_config(ZClawClient::ui_config_path(), contents, &error)) - return true; - report_save_error(error); - return false; - } - - static const char *provider_field_name(ProviderEditField field) - { - switch (field) { - case ProviderEditField::Alias: - return "Alias"; - case ProviderEditField::Family: - return "Family"; - case ProviderEditField::Model: - return "Model"; - case ProviderEditField::Uri: - return "URI"; - case ProviderEditField::ApiKey: - return "API Key"; - case ProviderEditField::None: - default: - return ""; - } - } - - std::string &provider_field_value(ProviderConfig &provider, ProviderEditField field) - { - switch (field) { - case ProviderEditField::Alias: - return provider.alias; - case ProviderEditField::Family: - return provider.family; - case ProviderEditField::Model: - return provider.model; - case ProviderEditField::Uri: - return provider.uri; - case ProviderEditField::ApiKey: - case ProviderEditField::None: - default: - return provider.api_key; - } - } - - lv_obj_t *make_zclaw_avatar(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, lv_coord_t size) - { - lv_obj_t *avatar = lv_img_create(parent); - lv_img_set_src(avatar, avatar_path_.c_str()); - lv_obj_set_pos(avatar, x, y); - if (size != 16) { - lv_img_set_zoom(avatar, (uint16_t)((size * 256) / 16)); - } - lv_obj_clear_flag(avatar, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - return avatar; - } - - void show_network_check() - { - startup_state_ = StartupState::CheckingNetwork; - startup_overlay_ = make_box(screen_, 0, 0, SCREEN_W, SCREEN_H, COLOR_BG); - lv_obj_move_foreground(startup_overlay_); - make_label(startup_overlay_, "ZCLAW", 16, 24, 288, 20, fonts_.font_12(), COLOR_PURPLE, - LV_TEXT_ALIGN_CENTER); - startup_title_ = make_label(startup_overlay_, "Checking network...", 16, 68, 288, 20, - fonts_.font_12(), COLOR_TEXT, LV_TEXT_ALIGN_CENTER); - startup_detail_ = make_label(startup_overlay_, "Please wait", 16, 96, 288, 16, - fonts_.font_10(), COLOR_MUTED, LV_TEXT_ALIGN_CENTER); - } - - void show_offline(const std::string &error) - { - startup_state_ = StartupState::Offline; - lv_label_set_text(startup_title_, "No internet connection"); - const std::string detail = error.empty() ? "Check Wi-Fi and try again." : - "Check Wi-Fi and try again.\n" + error; - lv_label_set_text(startup_detail_, detail.c_str()); - lv_obj_set_height(startup_detail_, 34); - make_label(startup_overlay_, "OK EXIT", 16, 142, 288, 14, fonts_.font_10(), - COLOR_PURPLE, LV_TEXT_ALIGN_CENTER); - } - - static void network_async_done(void *data) - { - NetworkResult *result = static_cast(data); - if (!result) - return; - if (result->self) { - if (!result->online) { - result->self->show_offline(result->error); - } else { - result->self->startup_state_ = StartupState::Ready; - if (result->self->startup_overlay_) { - lv_obj_del(result->self->startup_overlay_); - result->self->startup_overlay_ = nullptr; - result->self->startup_title_ = nullptr; - result->self->startup_detail_ = nullptr; - } - if (result->self->first_run_needed()) - result->self->open_setup_panel(); - } - } - delete result; - } - - static void *network_thread_main(void *data) - { - NetworkResult *result = static_cast(data); - if (!result) - return nullptr; - result->online = ZClawClient::has_internet_connection(&result->error); - lv_async_call(network_async_done, result); - return nullptr; - } - - void start_network_check() - { - NetworkResult *result = new NetworkResult(); - result->self = this; - pthread_t thread_id{}; - if (pthread_create(&thread_id, nullptr, network_thread_main, result) != 0) { - result->error = "Could not start network check."; - network_async_done(result); - return; - } - pthread_detach(thread_id); - } - - void create_ui() - { - screen_ = make_box(root_screen_, 0, 0, SCREEN_W, SCREEN_H, COLOR_BG); - lv_obj_move_foreground(screen_); - - create_top_bar(); - create_chat_area(); - create_input_bar(); - } - - void create_top_bar() - { - static constexpr lv_coord_t BAR_H = 20; - static constexpr lv_coord_t AVATAR_SIZE = 16; - static constexpr lv_coord_t STATUS_SIZE = 6; - static constexpr lv_coord_t DOT_SIZE = 2; - const lv_coord_t name_h = lv_font_get_line_height(fonts_.font_12()); - const lv_coord_t status_h = lv_font_get_line_height(fonts_.font_10()); - - lv_obj_t *bar = make_box(screen_, 0, 0, SCREEN_W, BAR_H, COLOR_BAR); - make_zclaw_avatar(bar, 12, centered_y(BAR_H, AVATAR_SIZE), AVATAR_SIZE); - - make_box(bar, 34, centered_y(BAR_H, STATUS_SIZE), STATUS_SIZE, STATUS_SIZE, COLOR_ONLINE, STATUS_SIZE / 2); - make_label(bar, "ZClaw", 47, centered_y(BAR_H, name_h), 42, name_h, fonts_.font_12(), COLOR_TEXT); - make_label(bar, "Online", 89, centered_y(BAR_H, status_h), 48, status_h, fonts_.font_10(), COLOR_ONLINE); - - const lv_coord_t ellipsis_y = centered_y(BAR_H, DOT_SIZE); - make_box(bar, 295, ellipsis_y, DOT_SIZE, DOT_SIZE, COLOR_MUTED, DOT_SIZE / 2); - make_box(bar, 300, ellipsis_y, DOT_SIZE, DOT_SIZE, COLOR_MUTED, DOT_SIZE / 2); - make_box(bar, 305, ellipsis_y, DOT_SIZE, DOT_SIZE, COLOR_MUTED, DOT_SIZE / 2); - } - - void create_chat_area() - { - make_box(screen_, 0, 20, SCREEN_W, 128, COLOR_BG); - - chat_container_ = lv_obj_create(screen_); - lv_obj_set_pos(chat_container_, 0, 20); - lv_obj_set_size(chat_container_, 312, 128); - lv_obj_set_style_bg_opa(chat_container_, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(chat_container_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(chat_container_, 12, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(chat_container_, 4, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(chat_container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(chat_container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_row(chat_container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_flex_flow(chat_container_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scroll_dir(chat_container_, LV_DIR_VER); - lv_obj_add_flag(chat_container_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_clear_flag(chat_container_, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLL_ELASTIC | - LV_OBJ_FLAG_SCROLL_MOMENTUM | - LV_OBJ_FLAG_SCROLL_CHAIN)); - lv_obj_set_scrollbar_mode(chat_container_, LV_SCROLLBAR_MODE_OFF); - lv_obj_add_event_cb(chat_container_, ZClawApp::static_chat_scroll_handler, LV_EVENT_SCROLL, this); - - append_ai_message("Hello! I am ZClaw.\nReady to help with your device."); - - scroll_track_ = make_box(screen_, 312, 20, 3, 126, 0x33335A, 2); - scroll_thumb_ = make_box(screen_, 312, 28, 3, 38, 0x818CF8, 2); - apply_vertical_gradient(scroll_thumb_, 0xC4B5FD, 0x818CF8); - update_scrollbar(); - } - - void create_input_bar() - { - input_bar_ = make_box(screen_, 0, 148, SCREEN_W, 22, COLOR_BAR); - make_box(input_bar_, 0, 0, SCREEN_W, 1, COLOR_PANEL_LINE); - - input_box_ = make_box(input_bar_, 10, 3, 274, 16, COLOR_PANEL, 8); - input_sparkle_ = make_sparkle(input_box_, 8, 3); - input_label_ = make_label(input_box_, "Press Enter to ask", 26, 3, 180, 10, - fonts_.font_10(), COLOR_DIM); - - send_button_ = make_send_button(input_bar_, 292, 2); - } - - lv_obj_t *make_settings_row(lv_obj_t *parent, int index, lv_coord_t y, const char *title, const char *value) - { - lv_obj_t *row = make_box(parent, 12, y, 296, 26, COLOR_PANEL, 8); - lv_obj_set_style_border_width(row, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN | LV_STATE_DEFAULT); - make_label(row, title, 10, 5, 160, 14, fonts_.font_10(), COLOR_TEXT); - settings_values_[index] = make_label(row, value, 168, 5, 118, 14, fonts_.font_10(), - COLOR_MUTED, LV_TEXT_ALIGN_RIGHT); - make_box(row, 8, 23, 280, 1, COLOR_PANEL_LINE); - settings_rows_[index] = row; - return row; - } - - void update_settings_selection() - { - for (int i = 0; i < SETTINGS_ROW_MAX; ++i) { - if (!settings_rows_[i]) - continue; - const bool selected = i == settings_selected_; - lv_obj_set_style_border_color(settings_rows_[i], - lv_color_hex(selected ? COLOR_PURPLE : COLOR_PANEL), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(settings_rows_[i], - lv_color_hex(selected ? 0x252542 : COLOR_PANEL), - LV_PART_MAIN | LV_STATE_DEFAULT); - if (settings_values_[i]) - lv_obj_set_style_text_color(settings_values_[i], - lv_color_hex(selected ? COLOR_TEXT : COLOR_MUTED), - LV_PART_MAIN | LV_STATE_DEFAULT); - } - } - - void clear_settings_rows() - { - for (auto *&row : settings_rows_) { - if (row) - lv_obj_del(row); - row = nullptr; - } - for (auto *&value : settings_values_) - value = nullptr; - settings_row_count_ = 0; - } - - void add_settings_row(const char *title, const char *value) - { - if (settings_row_count_ >= SETTINGS_ROW_MAX) - return; - const lv_coord_t y = 32 + settings_row_count_ * 28; - make_settings_row(settings_panel_, settings_row_count_, y, title, value); - ++settings_row_count_; - } - - void set_settings_header(const char *title, const char *hint) - { - if (settings_header_label_) - lv_label_set_text(settings_header_label_, title ? title : ""); - if (settings_hint_label_) - lv_label_set_text(settings_hint_label_, hint ? hint : ""); - } - - void render_settings_main() - { - settings_view_ = SettingsView::Main; - clear_settings_rows(); - set_settings_header("ZClaw Settings", "Tab / Esc"); - add_settings_row("Setup", ui_config_.setup_complete ? "Done" : "Run"); - add_settings_row("Authorization", ui_config_.bearer_token.empty() ? "Pair" : "Paired"); - add_settings_row("Providers", "Manage"); - add_settings_row("Agent", ui_config_.agent_alias.c_str()); - add_settings_row("Transport", ui_config_.bearer_token.empty() ? "Webhook" : "WS"); - if (settings_selected_ >= settings_row_count_) - settings_selected_ = settings_row_count_ - 1; - if (settings_selected_ < 0) - settings_selected_ = 0; - update_settings_selection(); - } - - void render_authorization() - { - settings_view_ = SettingsView::Authorization; - clear_settings_rows(); - set_settings_header("Authorization", "Enter / Esc"); - add_settings_row("Pair Code", "Enter"); - add_settings_row("Token", ui_config_.bearer_token.empty() ? "Missing" : "Saved"); - add_settings_row("Agent", ui_config_.agent_alias.c_str()); - add_settings_row("Approvals", ui_config_.bearer_token.empty() ? "Off" : "WS"); - add_settings_row("Clear Token", "-"); - settings_selected_ = 0; - update_settings_selection(); - } - - void render_setup() - { - settings_view_ = SettingsView::Setup; - clear_settings_rows(); - clear_setup_progress_objects(); - if (setup_in_flight_) { - set_settings_header("Quickstart", "Please wait"); - setup_spinner_ = lv_spinner_create(settings_panel_); - lv_obj_set_size(setup_spinner_, 26, 26); - lv_obj_set_pos(setup_spinner_, 18, 40); - lv_spinner_set_anim_params(setup_spinner_, 900, 70); - lv_obj_set_style_arc_width(setup_spinner_, 3, LV_PART_MAIN); - lv_obj_set_style_arc_width(setup_spinner_, 3, LV_PART_INDICATOR); - lv_obj_set_style_arc_color(setup_spinner_, lv_color_hex(COLOR_PANEL_LINE), LV_PART_MAIN); - lv_obj_set_style_arc_color(setup_spinner_, lv_color_hex(COLOR_PURPLE), LV_PART_INDICATOR); - - setup_status_label_ = make_label(settings_panel_, "Preparing Quickstart", 54, 39, 248, 16, - fonts_.font_12(), COLOR_TEXT); - setup_detail_label_ = make_label(settings_panel_, "Starting...", 54, 58, 248, 14, - fonts_.font_10(), COLOR_MUTED); - setup_progress_bar_ = lv_bar_create(settings_panel_); - lv_obj_set_pos(setup_progress_bar_, 18, 88); - lv_obj_set_size(setup_progress_bar_, 284, 10); - lv_bar_set_range(setup_progress_bar_, 0, 100); - lv_bar_set_value(setup_progress_bar_, 0, LV_ANIM_OFF); - lv_obj_set_style_radius(setup_progress_bar_, 3, LV_PART_MAIN); - lv_obj_set_style_radius(setup_progress_bar_, 3, LV_PART_INDICATOR); - lv_obj_set_style_bg_color(setup_progress_bar_, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); - lv_obj_set_style_bg_color(setup_progress_bar_, lv_color_hex(COLOR_PURPLE), LV_PART_INDICATOR); - setup_percent_label_ = make_label(settings_panel_, "0%", 18, 105, 70, 14, - fonts_.font_10(), COLOR_TEXT); - setup_speed_label_ = make_label(settings_panel_, "", 92, 105, 210, 14, - fonts_.font_10(), COLOR_MUTED, LV_TEXT_ALIGN_RIGHT); - settings_selected_ = 0; - return; - } - set_settings_header("Model Settings", "Enter / Esc"); - add_settings_row("Provider", provider_display_name(setup_provider_.family)); - if (setup_provider_.family == "custom") - add_settings_row("API URL", setup_provider_.uri.empty() ? "Required" : "Set"); - if (setup_provider_.family != "ollama") - add_settings_row("API Key", setup_provider_.api_key.empty() ? "Required" : "Set"); - if (setup_provider_.family == "custom") - add_settings_row("API Model", setup_provider_.model.empty() ? "Required" : "Set"); - add_settings_row("Confirm", "Quickstart"); - if (settings_selected_ >= settings_row_count_) - settings_selected_ = settings_row_count_ - 1; - if (settings_selected_ < 0) - settings_selected_ = 0; - update_settings_selection(); - } - - void clear_setup_progress_objects() - { - lv_obj_t **objects[] = {&setup_spinner_, &setup_status_label_, &setup_detail_label_, - &setup_progress_bar_, &setup_percent_label_, &setup_speed_label_}; - for (lv_obj_t **object : objects) { - if (*object) - lv_obj_del(*object); - *object = nullptr; - } - } - - static std::string format_transfer_detail(const ZClawSetupProgress &progress) - { - if (progress.total_bytes == 0) - return progress.status; - char buffer[96]; - const double downloaded_mb = (double)progress.downloaded_bytes / (1024.0 * 1024.0); - const double total_mb = (double)progress.total_bytes / (1024.0 * 1024.0); - std::snprintf(buffer, sizeof(buffer), "%.1f / %.1f MB", downloaded_mb, total_mb); - return buffer; - } - - static std::string format_transfer_speed(double bytes_per_second) - { - if (bytes_per_second <= 0.0) - return "Waiting for data"; - char buffer[64]; - if (bytes_per_second >= 1024.0 * 1024.0) - std::snprintf(buffer, sizeof(buffer), "%.1f MB/s", bytes_per_second / (1024.0 * 1024.0)); - else - std::snprintf(buffer, sizeof(buffer), "%.0f KB/s", bytes_per_second / 1024.0); - return buffer; - } - - void update_setup_progress(const ZClawSetupProgress &progress) - { - if (!setup_in_flight_ || !setup_progress_bar_) - return; - const int percent = progress.percent < 0 ? 0 : (progress.percent > 100 ? 100 : progress.percent); - lv_label_set_text(setup_status_label_, progress.status.c_str()); - const std::string detail = format_transfer_detail(progress); - lv_label_set_text(setup_detail_label_, detail.c_str()); - lv_bar_set_value(setup_progress_bar_, percent, LV_ANIM_ON); - const std::string percent_text = std::to_string(percent) + "%"; - lv_label_set_text(setup_percent_label_, percent_text.c_str()); - const std::string speed = progress.total_bytes > 0 ? - format_transfer_speed(progress.bytes_per_second) : ""; - lv_label_set_text(setup_speed_label_, speed.c_str()); - } - - void render_setup_providers() - { - settings_view_ = SettingsView::SetupProviders; - clear_settings_rows(); - set_settings_header("Model Provider", "Enter / Esc"); - if (setup_provider_selected_ < setup_provider_scroll_) - setup_provider_scroll_ = setup_provider_selected_; - if (setup_provider_selected_ >= setup_provider_scroll_ + SETTINGS_ROW_MAX) - setup_provider_scroll_ = setup_provider_selected_ - SETTINGS_ROW_MAX + 1; - for (int i = 0; i < SETTINGS_ROW_MAX && setup_provider_scroll_ + i < 6; ++i) { - const int item = setup_provider_scroll_ + i; - const ProviderConfig preset = provider_preset(item); - add_settings_row(provider_display_name(preset.family), - item == setup_provider_selected_ ? "Selected" : ""); - } - settings_selected_ = setup_provider_selected_ - setup_provider_scroll_; - update_settings_selection(); - } - - int setup_initialize_row() const - { - return settings_row_count_ - 1; - } - - SetupEditField selected_setup_field() const - { - if (settings_selected_ == 0) - return SetupEditField::None; - int row = 1; - if (setup_provider_.family == "custom") { - if (settings_selected_ == row++) return SetupEditField::Uri; - } - if (setup_provider_.family != "ollama") { - if (settings_selected_ == row++) return SetupEditField::ApiKey; - } - if (setup_provider_.family == "custom" && settings_selected_ == row) - return SetupEditField::Model; - return SetupEditField::None; - } - - void edit_selected_setup_field() - { - setup_edit_field_ = selected_setup_field(); - const char *title = ""; - const std::string *value = nullptr; - if (setup_edit_field_ == SetupEditField::Uri) { - title = "API URL"; - value = &setup_provider_.uri; - } else if (setup_edit_field_ == SetupEditField::ApiKey) { - title = "API Key"; - value = &setup_provider_.api_key; - } else if (setup_edit_field_ == SetupEditField::Model) { - title = "API Model Name"; - value = &setup_provider_.model; - } - if (!value) - return; - open_text_dialog(title, value->c_str(), InputMode::SetupEdit); - } - - void apply_setup_edit(const std::string &value) - { - const ProviderConfig previous = setup_provider_; - if (setup_edit_field_ == SetupEditField::Uri) - setup_provider_.uri = value; - else if (setup_edit_field_ == SetupEditField::ApiKey) - setup_provider_.api_key = value; - else if (setup_edit_field_ == SetupEditField::Model) - setup_provider_.model = value; - setup_edit_field_ = SetupEditField::None; - if (!save_setup_provider()) - setup_provider_ = previous; - render_setup(); - } - - bool validate_setup(std::string *error) - { - if (setup_provider_.family != "ollama" && setup_provider_.api_key.empty()) { - settings_selected_ = setup_provider_.family == "custom" ? 2 : 1; - *error = "API Key is required."; - return false; - } - if (setup_provider_.family == "custom") { - if (setup_provider_.uri.rfind("http://", 0) != 0 && setup_provider_.uri.rfind("https://", 0) != 0) { - settings_selected_ = 1; - *error = "API URL must start with http:// or https://."; - return false; - } - if (setup_provider_.model.empty()) { - settings_selected_ = 3; - *error = "API Model Name is required."; - return false; - } - } - return true; - } - - void render_settings_providers() - { - settings_view_ = SettingsView::Providers; - clear_settings_rows(); - set_settings_header("Providers", "Enter / Esc"); - - if (provider_selected_ < 0) - provider_selected_ = 0; - const int total_rows = (int)providers_.size() + 1; - if (provider_selected_ >= total_rows) - provider_selected_ = total_rows - 1; - if (provider_selected_ < provider_scroll_) - provider_scroll_ = provider_selected_; - if (provider_selected_ >= provider_scroll_ + SETTINGS_ROW_MAX) - provider_scroll_ = provider_selected_ - SETTINGS_ROW_MAX + 1; - if (provider_scroll_ < 0) - provider_scroll_ = 0; - - for (int i = 0; i < SETTINGS_ROW_MAX && provider_scroll_ + i < total_rows; ++i) { - const int item = provider_scroll_ + i; - if (item == 0) { - add_settings_row("Add Provider", "+"); - } else { - const ProviderConfig &provider = providers_[item - 1]; - add_settings_row(provider.alias.c_str(), provider.model.c_str()); - } + const zclaw::ConfigStoreLoadStatus provider_status = + provider_manager_.load(&error); + if (provider_status == zclaw::ConfigStoreLoadStatus::Invalid || + provider_status == zclaw::ConfigStoreLoadStatus::Error) { + storage_warning_ = "Provider settings could not be loaded."; + if (!error.empty()) + storage_warning_ += "\n" + error; } - settings_selected_ = provider_selected_ - provider_scroll_; - update_settings_selection(); - } - - void render_provider_detail() - { - settings_view_ = SettingsView::ProviderDetail; - clear_settings_rows(); - set_settings_header("Provider", "Enter / Del"); - - if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) { - render_settings_providers(); - return; + error.clear(); + const zclaw::ConfigStoreLoadStatus config_status = + config_manager_.load(&error); + if (config_status == zclaw::ConfigStoreLoadStatus::Invalid || + config_status == zclaw::ConfigStoreLoadStatus::Error) { + if (!storage_warning_.empty()) + storage_warning_ += "\n"; + storage_warning_ += "UI settings could not be loaded."; + if (!error.empty()) + storage_warning_ += "\n" + error; } - - ProviderConfig &provider = providers_[provider_detail_index_]; - add_settings_row("Alias", provider.alias.c_str()); - add_settings_row("Family", provider.family.c_str()); - add_settings_row("Model", provider.model.c_str()); - add_settings_row("URI", provider.uri.c_str()); - add_settings_row("API Key", provider.api_key.empty() ? "(empty)" : "set"); - if (settings_selected_ >= settings_row_count_) - settings_selected_ = settings_row_count_ - 1; - if (settings_selected_ < 0) - settings_selected_ = 0; - update_settings_selection(); } - void create_settings_panel() - { - settings_panel_ = make_box(screen_, SCREEN_W, 0, SCREEN_W, SCREEN_H, COLOR_BG); - lv_obj_move_foreground(settings_panel_); - - static constexpr lv_coord_t BAR_H = 20; - lv_obj_t *bar = make_box(settings_panel_, 0, 0, SCREEN_W, BAR_H, COLOR_BAR); - settings_header_label_ = make_label(bar, "ZClaw Settings", 12, 4, 160, 12, - fonts_.font_12(), COLOR_TEXT); - settings_hint_label_ = make_label(bar, "Tab / Esc", 214, 5, 94, 10, - fonts_.font_10(), COLOR_DIM, LV_TEXT_ALIGN_RIGHT); - - settings_selected_ = 0; - render_settings_main(); - } - - void sync_setup_provider_selection() - { - if (setup_provider_.family == "openrouter") setup_provider_selected_ = 1; - else if (setup_provider_.family == "anthropic") setup_provider_selected_ = 2; - else if (setup_provider_.family == "ollama") setup_provider_selected_ = 3; - else if (setup_provider_.family == "deepseek") setup_provider_selected_ = 4; - else if (setup_provider_.family == "custom") setup_provider_selected_ = 5; - else setup_provider_selected_ = 0; - setup_provider_scroll_ = 0; - } - - void open_setup_panel() + void event_handler_init() { - if (settings_panel_open() || settings_animating_) - return; - close_input_dialog(); - sync_setup_provider_selection(); - create_settings_panel(); - if (first_run_needed()) - render_setup_providers(); - else - render_setup(); - animate_settings_panel(SCREEN_W, 0, false); + lv_obj_add_event_cb(root_screen_, ZClawApp::static_lvgl_handler, LV_EVENT_ALL, this); } - static void static_settings_anim_done(lv_anim_t *a) + static void static_lvgl_handler(lv_event_t *e) { - ZClawApp *self = static_cast(lv_anim_get_user_data(a)); + ZClawApp *self = static_cast(lv_event_get_user_data(e)); if (self) - self->settings_anim_done(); - } - - void settings_anim_done() - { - settings_animating_ = false; - if (settings_closing_) { - if (settings_panel_) { - lv_obj_del(settings_panel_); - settings_panel_ = nullptr; - settings_header_label_ = nullptr; - settings_hint_label_ = nullptr; - setup_spinner_ = nullptr; - setup_status_label_ = nullptr; - setup_detail_label_ = nullptr; - setup_progress_bar_ = nullptr; - setup_percent_label_ = nullptr; - setup_speed_label_ = nullptr; - for (auto *&row : settings_rows_) - row = nullptr; - for (auto *&value : settings_values_) - value = nullptr; - } - settings_closing_ = false; - } - } - - void animate_settings_panel(lv_coord_t from_x, lv_coord_t to_x, bool closing) - { - if (!settings_panel_) - return; - - settings_animating_ = true; - settings_closing_ = closing; - lv_anim_del(settings_panel_, nullptr); - - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, settings_panel_); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_x); - lv_anim_set_values(&a, from_x, to_x); - lv_anim_set_time(&a, 200); - lv_anim_set_path_cb(&a, lv_anim_path_ease_out); - lv_anim_set_completed_cb(&a, static_settings_anim_done); - lv_anim_set_user_data(&a, this); - lv_anim_start(&a); - } - - bool settings_panel_open() const - { - return settings_panel_ != nullptr; - } - - void open_settings_panel() - { - if (settings_panel_open() || settings_animating_) - return; - close_input_dialog(); - create_settings_panel(); - animate_settings_panel(SCREEN_W, 0, false); - } - - void close_settings_panel() - { - if (!settings_panel_open() || settings_animating_) - return; - animate_settings_panel(lv_obj_get_x(settings_panel_), SCREEN_W, true); - } - - void move_settings_selection(int delta) - { - if (!settings_panel_open()) - return; - if (settings_view_ == SettingsView::SetupProviders) { - setup_provider_selected_ += delta; - if (setup_provider_selected_ < 0) - setup_provider_selected_ = 0; - if (setup_provider_selected_ >= 6) - setup_provider_selected_ = 5; - render_setup_providers(); - return; - } - settings_selected_ += delta; - if (settings_selected_ < 0) - settings_selected_ = 0; - if (settings_selected_ >= settings_row_count_) - settings_selected_ = settings_row_count_ - 1; - update_settings_selection(); - } - - lv_obj_t *make_sparkle(lv_obj_t *parent, lv_coord_t x, lv_coord_t y) - { - lv_obj_t *sparkles = lv_img_create(parent); - lv_img_set_src(sparkles, sparkles_path_.c_str()); - lv_obj_set_pos(sparkles, x, y); - lv_obj_clear_flag(sparkles, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - return sparkles; - } - - lv_obj_t *make_send_button(lv_obj_t *parent, lv_coord_t x, lv_coord_t y) - { - lv_obj_t *send = lv_img_create(parent); - lv_img_set_src(send, send_button_path_.c_str()); - lv_obj_set_pos(send, x, y); - lv_obj_clear_flag(send, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - return send; - } - - bool input_dialog_open() const - { - return input_dialog_ && input_textarea_; + self->event_handler(e); } - void close_input_dialog() + void event_handler(lv_event_t *e) { - if (!input_dialog_) + if (lv_event_get_code(e) != static_cast(LV_EVENT_KEYBOARD)) return; - lv_obj_del(input_dialog_); - input_dialog_ = nullptr; - input_textarea_ = nullptr; - } - void close_approval_dialog() - { - if (!approval_dialog_) + const struct key_item *item = keyboard_item(e); + if (!item) return; - lv_obj_del(approval_dialog_); - approval_dialog_ = nullptr; - approval_tool_label_ = nullptr; - approval_summary_label_ = nullptr; - for (auto *&button : approval_buttons_) - button = nullptr; - approval_selected_ = 0; - } - void clear_pending_approval() - { - { - std::lock_guard lock(approval_mutex_); - pending_approval_id_.clear(); - pending_approval_tool_.clear(); - pending_approval_decision_.clear(); - approval_waiting_ = false; - } - approval_cv_.notify_all(); - close_approval_dialog(); - } + zclaw::KeyRouteContext context; + context.startup = startup_workflow_.state(); + context.input_open = input_dialog_.is_open(); + context.approval_pending = approvals_.pending(); + context.setup_retry_pending = + settings_workflow_.setup_retry_pending(); + context.settings_open = settings_ui_.is_open(); + context.settings_view = settings_ui_.state().view(); - void clear_obj(lv_obj_t *&obj) - { - if (!obj) - return; - lv_obj_del(obj); - obj = nullptr; + const zclaw::KeyEvent event = zclaw::adapt_key_event( + item->key_code, item->key_state, item->mods, item->utf8); + actions_.execute(zclaw::route_key(context, event)); } - - lv_obj_t *make_chat_label(lv_obj_t *parent, const char *text, lv_coord_t x, lv_coord_t y, - lv_coord_t w, lv_coord_t h, uint32_t color, - lv_text_align_t align = LV_TEXT_ALIGN_LEFT) - { - lv_obj_t *label = make_label(parent, text, x, y, w, h, fonts_.font_12(), color, align); - lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); - return label; - } - - static lv_point_t measure_text_box(const char *text, const lv_font_t *font, lv_coord_t max_w) - { - lv_point_t size{}; - lv_text_get_size(&size, text ? text : "", font, 0, 0, max_w, LV_TEXT_FLAG_NONE); - return size; - } - - lv_obj_t *make_message_row(lv_coord_t h, lv_flex_align_t main_align) - { - lv_obj_t *row = lv_obj_create(chat_container_); - lv_obj_set_size(row, CHAT_ROW_W, h); - lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(row, 6, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, main_align, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_clear_flag(row, (lv_obj_flag_t)(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); - return row; - } - - lv_obj_t *make_bubble(lv_obj_t *parent, lv_coord_t w, lv_coord_t h, uint32_t color, bool user) - { - lv_obj_t *bubble = make_box(parent, 0, 0, w, h, color, 10); - if (user) - apply_vertical_gradient(bubble, COLOR_PURPLE, COLOR_INDIGO); - return bubble; - } - - void scroll_chat_to_bottom() - { - if (chat_container_) { - lv_obj_scroll_to_y(chat_container_, LV_COORD_MAX, LV_ANIM_ON); - update_scrollbar(); - } - } - - void append_ai_message(const char *text) - { - static constexpr lv_coord_t bubble_w = 190; - static constexpr lv_coord_t text_w = 168; - static constexpr lv_coord_t pad_x = 10; - static constexpr lv_coord_t pad_y = 6; - const std::string display_text = display_text_compat(text ? text : ""); - const lv_point_t text_size = measure_text_box(display_text.c_str(), fonts_.font_12(), text_w); - lv_coord_t bubble_h = text_size.y + pad_y * 2; - if (bubble_h < 41) - bubble_h = 41; - - lv_obj_t *row = make_message_row(bubble_h, LV_FLEX_ALIGN_START); - make_zclaw_avatar(row, 0, 0, 16); - reply_bubble_ = make_bubble(row, bubble_w, bubble_h, COLOR_PANEL, false); - reply_label_ = make_chat_label(reply_bubble_, display_text.c_str(), pad_x, pad_y, text_w, - bubble_h - pad_y * 2, 0xFFFFFF); - scroll_chat_to_bottom(); - } - - void append_user_message(const std::string &text) - { - const std::string display_text = display_text_compat(text); - const lv_coord_t text_max_w = USER_BUBBLE_MAX_W - USER_BUBBLE_PAD_X * 2; - const lv_point_t text_size = measure_text_box(display_text.c_str(), fonts_.font_12(), text_max_w); - lv_coord_t bubble_w = text_size.x + USER_BUBBLE_PAD_X * 2; - lv_coord_t bubble_h = text_size.y + USER_BUBBLE_PAD_Y * 2; - if (bubble_w < USER_BUBBLE_MIN_W) - bubble_w = USER_BUBBLE_MIN_W; - if (bubble_w > USER_BUBBLE_MAX_W) - bubble_w = USER_BUBBLE_MAX_W; - if (bubble_h < 27) - bubble_h = 27; - - lv_obj_t *row = make_message_row(bubble_h, LV_FLEX_ALIGN_END); - user_bubble_ = make_bubble(row, bubble_w, bubble_h, COLOR_INDIGO, true); - user_label_ = make_chat_label(user_bubble_, display_text.c_str(), USER_BUBBLE_PAD_X, USER_BUBBLE_PAD_Y, - bubble_w - USER_BUBBLE_PAD_X * 2, - bubble_h - USER_BUBBLE_PAD_Y * 2, - 0xFFFFFF, LV_TEXT_ALIGN_LEFT); - scroll_chat_to_bottom(); - } - - void open_input_dialog() - { - if (input_dialog_open()) - return; - - static constexpr lv_coord_t dialog_h = SCREEN_H * 2 / 3; - input_dialog_ = lv_msgbox_create(lv_layer_top()); - lv_obj_set_size(input_dialog_, 300, dialog_h); - lv_obj_align(input_dialog_, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_obj_set_style_radius(input_dialog_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(input_dialog_, lv_color_hex(COLOR_BAR), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(input_dialog_, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(input_dialog_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(input_dialog_, lv_color_hex(COLOR_PANEL_LINE), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(input_dialog_, lv_color_hex(COLOR_TEXT), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(input_dialog_, fonts_.font_10(), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(input_dialog_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - - lv_obj_t *content = lv_msgbox_get_content(input_dialog_); - lv_obj_set_style_pad_all(content, 5, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_clear_flag(content, LV_OBJ_FLAG_SCROLLABLE); - - input_textarea_ = lv_textarea_create(content); - lv_obj_set_size(input_textarea_, 290, dialog_h - 10); - lv_textarea_set_placeholder_text(input_textarea_, "Type your message..."); - lv_textarea_set_one_line(input_textarea_, false); - lv_textarea_set_cursor_click_pos(input_textarea_, false); - lv_obj_set_style_radius(input_textarea_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(input_textarea_, lv_color_hex(COLOR_PANEL), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(input_textarea_, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_width(input_textarea_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_color(input_textarea_, lv_color_hex(0xFFFFFF), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_text_font(input_textarea_, fonts_.font_10(), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(input_textarea_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); - if (!textarea_cursor_style_inited_) { - lv_style_init(&textarea_cursor_style_); - lv_style_set_bg_opa(&textarea_cursor_style_, LV_OPA_TRANSP); - lv_style_set_border_color(&textarea_cursor_style_, lv_color_hex(COLOR_PURPLE)); - lv_style_set_border_side(&textarea_cursor_style_, LV_BORDER_SIDE_LEFT); - lv_style_set_border_width(&textarea_cursor_style_, 2); - lv_style_set_pad_left(&textarea_cursor_style_, -2); - lv_style_set_pad_right(&textarea_cursor_style_, 0); - textarea_cursor_style_inited_ = true; - } - lv_obj_add_style(input_textarea_, &textarea_cursor_style_, LV_PART_CURSOR | LV_STATE_FOCUSED); - lv_obj_add_state(input_textarea_, LV_STATE_FOCUSED); - lv_obj_send_event(input_textarea_, LV_EVENT_FOCUSED, nullptr); - lv_textarea_set_cursor_pos(input_textarea_, LV_TEXTAREA_CURSOR_LAST); - } - - void open_text_dialog(const char *placeholder, const char *initial_text, InputMode mode) - { - input_mode_ = mode; - open_input_dialog(); - if (!input_dialog_open()) - return; - lv_textarea_set_placeholder_text(input_textarea_, placeholder ? placeholder : ""); - lv_textarea_set_text(input_textarea_, initial_text ? initial_text : ""); - lv_textarea_set_cursor_pos(input_textarea_, LV_TEXTAREA_CURSOR_LAST); - } - - void show_sent_message(const std::string &text) - { - append_user_message(text); - } - - void publish_approval_prompt(const ZClawApprovalRequest &request) - { - { - std::lock_guard lock(approval_mutex_); - pending_approval_id_ = request.request_id; - pending_approval_tool_ = request.tool; - } - - close_approval_dialog(); - approval_dialog_ = make_box(lv_layer_top(), 42, 30, 236, 110, COLOR_BAR, 8); - lv_obj_set_style_border_width(approval_dialog_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(approval_dialog_, lv_color_hex(COLOR_PURPLE), LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_move_foreground(approval_dialog_); - - make_box(approval_dialog_, 0, 0, 236, 20, COLOR_PANEL, 8); - make_label(approval_dialog_, "Permission", 10, 5, 120, 12, fonts_.font_12(), COLOR_TEXT); - make_label(approval_dialog_, "Esc", 194, 6, 28, 10, fonts_.font_10(), COLOR_DIM, LV_TEXT_ALIGN_RIGHT); - - approval_tool_label_ = make_label(approval_dialog_, request.tool.c_str(), 12, 27, 212, 14, - fonts_.font_12(), COLOR_TEXT); - approval_summary_label_ = make_label(approval_dialog_, request.summary.c_str(), 12, 45, 212, 30, - fonts_.font_10(), COLOR_MUTED); - make_box(approval_dialog_, 0, 78, 236, 1, COLOR_PANEL_LINE); - - static constexpr const char *labels[] = {"Yes", "Always", "No"}; - static constexpr uint32_t colors[] = {COLOR_ONLINE, COLOR_PURPLE, COLOR_DIM}; - for (int i = 0; i < 3; ++i) { - approval_buttons_[i] = make_box(approval_dialog_, 12 + i * 72, 86, 68, 17, COLOR_PANEL, 5); - lv_obj_set_style_border_width(approval_buttons_[i], 1, LV_PART_MAIN | LV_STATE_DEFAULT); - make_label(approval_buttons_[i], labels[i], 0, 4, 68, 10, - fonts_.font_10(), colors[i], LV_TEXT_ALIGN_CENTER); - } - approval_selected_ = 0; - update_approval_buttons(); - } - - void update_approval_buttons() - { - for (int i = 0; i < 3; ++i) { - if (!approval_buttons_[i]) - continue; - const bool selected = i == approval_selected_; - lv_obj_set_style_bg_color(approval_buttons_[i], - lv_color_hex(selected ? 0x2B2B4A : COLOR_PANEL), - LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_border_color(approval_buttons_[i], - lv_color_hex(selected ? COLOR_TEXT : COLOR_PANEL_LINE), - LV_PART_MAIN | LV_STATE_DEFAULT); - } - } - - void move_approval_selection(int delta) - { - approval_selected_ += delta; - if (approval_selected_ < 0) - approval_selected_ = 2; - if (approval_selected_ > 2) - approval_selected_ = 0; - update_approval_buttons(); - } - - const char *selected_approval_decision() const - { - if (approval_selected_ == 1) - return "always"; - if (approval_selected_ == 2) - return "deny"; - return "approve"; - } - - static void approval_prompt_async(void *data) - { - ApprovalPromptData *prompt = static_cast(data); - if (!prompt) - return; - if (prompt->self) - prompt->self->publish_approval_prompt(prompt->request); - delete prompt; - } - - void post_approval_prompt(const ZClawApprovalRequest &request) - { - ApprovalPromptData *prompt = new ApprovalPromptData(); - prompt->self = this; - prompt->request = request; - lv_async_call(approval_prompt_async, prompt); - } - - static void approval_close_async(void *data) - { - ZClawApp *self = static_cast(data); - if (self) - self->clear_pending_approval(); - } - - void post_approval_close() - { - lv_async_call(approval_close_async, this); - } - - bool answer_pending_approval(const char *decision) - { - { - std::lock_guard lock(approval_mutex_); - if (pending_approval_id_.empty()) - return false; - pending_approval_decision_ = decision ? decision : "deny"; - approval_waiting_ = false; - pending_approval_id_.clear(); - pending_approval_tool_.clear(); - } - approval_cv_.notify_all(); - close_approval_dialog(); - return true; - } - - std::string await_approval_decision(const ZClawApprovalRequest &request) - { - const int timeout_secs = request.timeout_secs <= 0 ? 120 : request.timeout_secs; - { - std::lock_guard lock(approval_mutex_); - approval_waiting_ = true; - pending_approval_decision_.clear(); - } - post_approval_prompt(request); - - std::unique_lock lock(approval_mutex_); - if (timeout_secs <= 0) - return "deny"; - const bool answered = approval_cv_.wait_for( - lock, std::chrono::seconds(timeout_secs), - [&] { return !approval_waiting_; }); - std::string decision = answered ? pending_approval_decision_ : "deny"; - approval_waiting_ = false; - pending_approval_decision_.clear(); - pending_approval_id_.clear(); - pending_approval_tool_.clear(); - lock.unlock(); - if (!answered) { - post_approval_close(); - } - return decision.empty() ? "deny" : decision; - } - - static void chat_async_done(void *data) - { - AsyncResult *result = static_cast(data); - if (!result) - return; - if (result->self) - result->self->finish_chat_result(result->text); - delete result; - } - - static void *chat_thread_main(void *data) - { - AsyncResult *result = static_cast(data); - if (!result || !result->self) - return nullptr; - ZClawClientResult client_result = result->self->client_.send_chat( - result->self->ui_config_, result->text, - [self = result->self](const ZClawApprovalRequest &request) { - return self->await_approval_decision(request); - }); - result->text = client_result.text; - result->ok = client_result.ok; - result->config = client_result.config; - lv_async_call(chat_async_done, result); - return nullptr; - } - - void request_webhook_reply(const std::string &message) - { - if (request_in_flight_) { - append_ai_message("I am still waiting for ZeroClaw."); - return; - } - if (first_run_needed()) { - append_ai_message("Run quickstart before chatting."); - open_setup_panel(); - return; - } - - request_in_flight_ = true; - append_ai_message("Thinking..."); - AsyncResult *result = new AsyncResult(); - result->self = this; - result->text = message; - pthread_t thread_id{}; - if (pthread_create(&thread_id, nullptr, chat_thread_main, result) != 0) { - delete result; - request_in_flight_ = false; - append_ai_message("Could not start webhook request."); - return; - } - pthread_detach(thread_id); - } - - void finish_chat_result(const std::string &text) - { - request_in_flight_ = false; - append_ai_message(text.c_str()); - } - - static void pairing_async_done(void *data) - { - AsyncResult *result = static_cast(data); - if (!result) - return; - if (result->self) - result->self->finish_pairing_result(result->ok, result->text, result->config); - delete result; - } - - static void *pairing_thread_main(void *data) - { - AsyncResult *result = static_cast(data); - if (!result || !result->self) - return nullptr; - ZClawClientResult client_result = result->self->client_.pair_with_code( - result->self->ui_config_, result->text); - result->text = client_result.text; - result->ok = client_result.ok; - result->config = client_result.config; - lv_async_call(pairing_async_done, result); - return nullptr; - } - - void start_pairing(const std::string &code) - { - append_ai_message("Pairing with ZeroClaw..."); - AsyncResult *result = new AsyncResult(); - result->self = this; - result->text = code; - pthread_t thread_id{}; - if (pthread_create(&thread_id, nullptr, pairing_thread_main, result) != 0) { - delete result; - append_ai_message("Could not start pairing request."); - return; - } - pthread_detach(thread_id); - } - - void finish_pairing_result(bool ok, const std::string &text, const UiConfig &config) - { - if (ok) - append_ai_message(text.c_str()); - if (ok) { - const UiConfig previous = ui_config_; - ui_config_ = config; - if (!save_ui_config()) { - ui_config_ = previous; - } - } - if (!ok) - append_ai_message(text.c_str()); - if (settings_panel_open() && settings_view_ == SettingsView::Authorization) - render_authorization(); - } - - void scroll_chat(int delta) - { - if (!chat_container_) - return; - - const int32_t top = lv_obj_get_scroll_top(chat_container_); - const int32_t bottom = lv_obj_get_scroll_bottom(chat_container_); - if ((delta > 0 && top <= 0) || (delta < 0 && bottom <= 0)) - return; - - int32_t applied = delta; - if (delta > top) - applied = top; - if (-delta > bottom) - applied = -bottom; - lv_obj_scroll_by(chat_container_, 0, applied, LV_ANIM_ON); - update_scrollbar(); - } - - void update_scrollbar() - { - if (!chat_container_ || !scroll_track_ || !scroll_thumb_) - return; - - static constexpr lv_coord_t track_y = 20; - static constexpr lv_coord_t track_h = 126; - const int32_t top = lv_obj_get_scroll_top(chat_container_); - const int32_t bottom = lv_obj_get_scroll_bottom(chat_container_); - const int32_t range = top + bottom; - if (range <= 0) { - lv_obj_set_pos(scroll_thumb_, 312, track_y); - lv_obj_set_size(scroll_thumb_, 3, track_h); - return; - } - - lv_coord_t thumb_h = (lv_coord_t)((int32_t)track_h * track_h / (track_h + range)); - if (thumb_h < 18) - thumb_h = 18; - if (thumb_h > track_h) - thumb_h = track_h; - - const lv_coord_t travel = track_h - thumb_h; - const lv_coord_t thumb_y = track_y + (lv_coord_t)((int32_t)travel * top / range); - lv_obj_set_pos(scroll_thumb_, 312, thumb_y); - lv_obj_set_size(scroll_thumb_, 3, thumb_h); - } - - static void static_chat_scroll_handler(lv_event_t *e) - { - ZClawApp *self = static_cast(lv_event_get_user_data(e)); - if (self) - self->update_scrollbar(); - } - - void add_provider() - { - const int next = (int)providers_.size() + 1; - ProviderConfig provider; - provider.alias = "provider" + std::to_string(next); - provider.family = "openai-compatible"; - provider.model = "model"; - provider.uri = "https://api.example.com/v1"; - const std::vector previous = providers_; - providers_.push_back(provider); - std::string error; - if (!save_providers(&error)) { - providers_ = previous; - report_save_error(error); - return; - } - provider_selected_ = (int)providers_.size(); - provider_detail_index_ = (int)providers_.size() - 1; - settings_selected_ = 0; - render_provider_detail(); - } - - void delete_current_provider() - { - if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) - return; - const std::vector previous_providers = providers_; - const ProviderConfig previous_setup = setup_provider_; - zclaw::erase_provider_config(&providers_, &setup_provider_, - static_cast(provider_detail_index_), provider_preset(0)); - std::string error; - if (!save_providers(&error)) { - providers_ = previous_providers; - setup_provider_ = previous_setup; - report_save_error(error); - return; - } - provider_detail_index_ = -1; - if (provider_selected_ > (int)providers_.size()) - provider_selected_ = (int)providers_.size(); - render_settings_providers(); - } - - ProviderEditField selected_provider_field() const - { - switch (settings_selected_) { - case 0: - return ProviderEditField::Alias; - case 1: - return ProviderEditField::Family; - case 2: - return ProviderEditField::Model; - case 3: - return ProviderEditField::Uri; - case 4: - return ProviderEditField::ApiKey; - default: - return ProviderEditField::None; - } - } - - void edit_selected_provider_field() - { - if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) - return; - provider_edit_field_ = selected_provider_field(); - if (provider_edit_field_ == ProviderEditField::None) - return; - - ProviderConfig &provider = providers_[provider_detail_index_]; - const std::string &value = provider_field_value(provider, provider_edit_field_); - open_text_dialog(provider_field_name(provider_edit_field_), value.c_str(), InputMode::ProviderEdit); - } - - void apply_provider_edit(const std::string &value) - { - if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) - return; - if (provider_edit_field_ == ProviderEditField::None) - return; - - const std::vector previous_providers = providers_; - const ProviderConfig previous_setup = setup_provider_; - ProviderConfig provider = providers_[provider_detail_index_]; - provider_field_value(provider, provider_edit_field_) = value; - provider_edit_field_ = ProviderEditField::None; - zclaw::replace_provider_config(&providers_, &setup_provider_, - static_cast(provider_detail_index_), provider); - std::string error; - if (!save_providers(&error)) { - providers_ = previous_providers; - setup_provider_ = previous_setup; - report_save_error(error); - } - render_provider_detail(); - } - - static void setup_async_done(void *data) - { - AsyncResult *result = static_cast(data); - if (!result) - return; - if (result->self) - result->self->finish_setup_result(result->ok, result->text, result->config); - delete result; - } - - static void setup_progress_async(void *data) - { - SetupProgressData *update = static_cast(data); - if (!update) - return; - if (update->self) - update->self->update_setup_progress(update->progress); - delete update; - } - - void post_setup_progress(const ZClawSetupProgress &progress) - { - SetupProgressData *update = new SetupProgressData(); - update->self = this; - update->progress = progress; - lv_async_call(setup_progress_async, update); - } - - static void *setup_thread_main(void *data) - { - AsyncResult *result = static_cast(data); - if (!result || !result->self) - return nullptr; - ZClawClientResult client_result = result->self->client_.run_setup( - result->self->ui_config_, result->self->setup_provider_, - [self = result->self](const ZClawSetupProgress &progress) { - self->post_setup_progress(progress); - }); - result->text = client_result.text; - result->ok = client_result.ok; - result->config = client_result.config; - lv_async_call(setup_async_done, result); - return nullptr; - } - - void start_setup() - { - if (setup_in_flight_) - return; - std::string validation_error; - if (!validate_setup(&validation_error)) { - append_ai_message(validation_error.c_str()); - render_setup(); - return; - } - setup_in_flight_ = true; - append_ai_message("Configuring ZeroClaw service..."); - render_setup(); - AsyncResult *result = new AsyncResult(); - result->self = this; - pthread_t thread_id{}; - if (pthread_create(&thread_id, nullptr, setup_thread_main, result) != 0) { - delete result; - setup_in_flight_ = false; - append_ai_message("Could not start setup thread."); - render_setup(); - return; - } - pthread_detach(thread_id); - } - - void finish_setup_result(bool ok, const std::string &text, const UiConfig &config) - { - setup_in_flight_ = false; - bool persisted = ok; - if (ok) - append_ai_message(text.c_str()); - if (ok) { - const UiConfig previous = ui_config_; - ui_config_ = config; - if (!save_setup_provider() || !save_ui_config()) { - ui_config_ = previous; - persisted = false; - } - } - if (!ok) - append_ai_message(text.c_str()); - if (persisted && settings_panel_open() && settings_view_ == SettingsView::Setup) { - render_setup(); - close_settings_panel(); - } - } - - void activate_settings_selection() - { - if (!settings_panel_open()) - return; - - if (settings_view_ == SettingsView::Setup) { - if (setup_in_flight_) - return; - if (settings_selected_ == 0) { - sync_setup_provider_selection(); - render_setup_providers(); - } else if (settings_selected_ == setup_initialize_row()) { - start_setup(); - } else { - edit_selected_setup_field(); - } - return; - } - - if (settings_view_ == SettingsView::SetupProviders) { - const std::vector previous_providers = providers_; - const ProviderConfig previous_setup = setup_provider_; - const ProviderConfig selected = provider_preset(setup_provider_selected_); - zclaw::activate_provider_config(&providers_, &setup_provider_, selected); - std::string error; - if (!save_providers(&error)) { - providers_ = previous_providers; - setup_provider_ = previous_setup; - report_save_error(error); - } - settings_selected_ = 0; - render_setup(); - return; - } - - if (settings_view_ == SettingsView::Authorization) { - if (settings_selected_ == 0) { - open_text_dialog("Pairing code", "", InputMode::PairingCode); - } else if (settings_selected_ == 4) { - const UiConfig previous = ui_config_; - ui_config_.bearer_token.clear(); - if (save_ui_config()) - append_ai_message("Authorization token cleared."); - else - ui_config_ = previous; - render_authorization(); - } - return; - } - - if (settings_view_ == SettingsView::Main) { - if (settings_selected_ == 0) { - render_setup(); - } else if (settings_selected_ == 1) { - render_authorization(); - } else if (settings_selected_ == 2) { - provider_selected_ = 0; - provider_scroll_ = 0; - render_settings_providers(); - } - return; - } - - if (settings_view_ == SettingsView::Providers) { - const int item = provider_scroll_ + settings_selected_; - if (item == 0) { - add_provider(); - } else if (item - 1 < (int)providers_.size()) { - provider_detail_index_ = item - 1; - settings_selected_ = 0; - render_provider_detail(); - } - return; - } - - if (settings_view_ == SettingsView::ProviderDetail) - edit_selected_provider_field(); - } - - void settings_back() - { - if (setup_in_flight_ && settings_view_ == SettingsView::Setup) - return; - if (settings_view_ == SettingsView::SetupProviders) { - if (first_run_needed()) - g_quit_requested = 1; - else { - settings_selected_ = 0; - render_setup(); - } - } else if (settings_view_ == SettingsView::Setup) { - if (first_run_needed()) { - render_setup_providers(); - } else { - close_settings_panel(); - } - } else if (settings_view_ == SettingsView::Authorization) { - close_settings_panel(); - } else if (settings_view_ == SettingsView::ProviderDetail) { - provider_detail_index_ = -1; - render_settings_providers(); - } else if (settings_view_ == SettingsView::Providers) { - settings_selected_ = 0; - render_settings_main(); - } else { - close_settings_panel(); - } - } - - void send_current_input() - { - if (!input_dialog_open()) - return; - - const char *text = lv_textarea_get_text(input_textarea_); - if (input_mode_ == InputMode::SetupEdit) { - const std::string value = text ? text : ""; - close_input_dialog(); - apply_setup_edit(value); - input_mode_ = InputMode::Chat; - return; - } - if (input_mode_ == InputMode::ProviderEdit) { - const std::string value = text ? text : ""; - close_input_dialog(); - apply_provider_edit(value); - input_mode_ = InputMode::Chat; - return; - } - - if (input_mode_ == InputMode::PairingCode) { - const std::string value = text ? text : ""; - close_input_dialog(); - input_mode_ = InputMode::Chat; - if (!value.empty()) - start_pairing(value); - return; - } - - if (!text || !text[0]) { - close_input_dialog(); - return; - } - - const std::string sent = text; - close_input_dialog(); - show_sent_message(sent); - request_webhook_reply(sent); - } - - void append_input(const char *utf8) - { - if (!input_dialog_open() || !utf8 || !utf8[0]) - return; - const std::string display_text = display_text_compat(utf8); - lv_textarea_add_text(input_textarea_, display_text.c_str()); - } - - void event_handler_init() - { - lv_obj_add_event_cb(root_screen_, ZClawApp::static_lvgl_handler, LV_EVENT_ALL, this); - } - - static void static_lvgl_handler(lv_event_t *e) - { - ZClawApp *self = static_cast(lv_event_get_user_data(e)); - if (self) - self->event_handler(e); - } - - void event_handler(lv_event_t *e) - { - if (lv_event_get_code(e) != static_cast(LV_EVENT_KEYBOARD)) - return; - - const struct key_item *item = keyboard_item(e); - if (!item) - return; - - uint32_t key = keyboard_key(e); - if (startup_state_ != StartupState::Ready) { - if (startup_state_ == StartupState::Offline && - item->key_state == KBD_KEY_RELEASED && key == KEY_ENTER) - g_quit_requested = 1; - return; - } - const bool shift_down = (item->mods & KBD_MOD_SHIFT) != 0; - if (item->key_state == KBD_KEY_PRESSED || item->key_state == KBD_KEY_REPEATED) { - if (!input_dialog_open()) - return; - - switch (key) { - case KEY_ENTER: - if (shift_down) - lv_textarea_add_char(input_textarea_, '\n'); - break; - case KEY_BACKSPACE: - lv_textarea_delete_char(input_textarea_); - break; - case KEY_DELETE: - lv_textarea_delete_char_forward(input_textarea_); - break; - case KEY_LEFT: - lv_textarea_cursor_left(input_textarea_); - break; - case KEY_RIGHT: - lv_textarea_cursor_right(input_textarea_); - break; - case KEY_UP: - lv_textarea_cursor_up(input_textarea_); - break; - case KEY_DOWN: - lv_textarea_cursor_down(input_textarea_); - break; - default: - if (item->utf8[0] && (unsigned char)item->utf8[0] >= 0x20) - append_input(item->utf8); - break; - } - return; - } - - if (item->key_state != KBD_KEY_RELEASED) - return; - - if (input_dialog_open()) { - if (key == KEY_ESC) { - close_input_dialog(); - input_mode_ = InputMode::Chat; - return; - } - if (key == KEY_UP || key == KEY_DOWN || key == KEY_LEFT || key == KEY_RIGHT) - return; - if (key == KEY_ENTER) { - if (shift_down) - return; - send_current_input(); - return; - } - return; - } - - if (key == KEY_Z) - key = KEY_LEFT; - else if (key == KEY_X) - key = KEY_DOWN; - else if (key == KEY_C) - key = KEY_RIGHT; - else if (key == KEY_F) - key = KEY_UP; - - { - std::lock_guard lock(approval_mutex_); - if (!pending_approval_id_.empty()) { - if (key == KEY_ENTER || key == KEY_Y || key == KEY_A || key == KEY_N) { - // Unlock before recording the decision. - } else if (key == KEY_ESC || key == KEY_BACKSPACE) { - // Unlock before recording the decision. - } else if (key == KEY_LEFT || key == KEY_RIGHT) { - // Unlock before moving visual selection. - } else { - return; - } - } - } - if (key == KEY_LEFT) { - move_approval_selection(-1); - return; - } - if (key == KEY_RIGHT) { - move_approval_selection(1); - return; - } - if (key == KEY_ENTER && answer_pending_approval(selected_approval_decision())) - return; - if (key == KEY_Y && answer_pending_approval("approve")) - return; - if (key == KEY_A && answer_pending_approval("always")) - return; - if (key == KEY_N && answer_pending_approval("deny")) - return; - if ((key == KEY_ESC || key == KEY_BACKSPACE) && answer_pending_approval("deny")) - return; - - if (key == KEY_TAB) { - if (settings_panel_open()) - close_settings_panel(); - else - open_settings_panel(); - return; - } - if (settings_panel_open()) { - if (key == KEY_ESC) - settings_back(); - else if (key == KEY_BACKSPACE) - settings_back(); - else if (key == KEY_ENTER) - activate_settings_selection(); - else if (key == KEY_DELETE && settings_view_ == SettingsView::ProviderDetail) - delete_current_provider(); - else if (key == KEY_UP) - move_settings_selection(-1); - else if (key == KEY_DOWN) - move_settings_selection(1); - return; - } - if (key == KEY_ESC && input_dialog_open()) { - close_input_dialog(); - return; - } - if (key == KEY_ESC) - return; - else if (key == KEY_UP) - scroll_chat(24); - else if (key == KEY_DOWN) - scroll_chat(-24); - else if (key == KEY_ENTER) { - input_mode_ = InputMode::Chat; - open_input_dialog(); - } - } -}; - -ZClawApp *g_app = nullptr; +}; } // namespace -void ui_init() -{ - static ZClawApp app; - g_app = &app; -} - -bool ui_should_quit() -{ - return g_quit_requested != 0; -} - -void ui_request_quit() +int run_zclaw_app() { - g_quit_requested = 1; + g_quit_requested = 0; + Cp0LvglAppHooks hooks; + hooks.should_quit = []() { return g_quit_requested != 0; }; + return cp0_lvgl_run_app(std::move(hooks)); } diff --git a/projects/ZClaw/main/ui/zclaw_approval_controller.cpp b/projects/ZClaw/main/ui/zclaw_approval_controller.cpp new file mode 100644 index 00000000..df93b18b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_approval_controller.cpp @@ -0,0 +1,118 @@ +#include "zclaw_approval_controller.h" + +namespace zclaw { + +bool ApprovalController::begin(const ApprovalRequest &request) +{ + std::lock_guard lock(mutex_); + if (shutdown_ || waiting_ || !waiter_request_id_.empty() || + request.request_id.empty()) + return false; + request_id_ = request.request_id; + decision_.clear(); + waiting_ = true; + selected_ = 0; + return true; +} + +bool ApprovalController::pending() const +{ + std::lock_guard lock(mutex_); + return waiting_ && !request_id_.empty(); +} + +bool ApprovalController::pending_request(const std::string &request_id) const +{ + std::lock_guard lock(mutex_); + return waiting_ && !request_id.empty() && request_id_ == request_id; +} + +bool ApprovalController::answer(const std::string &decision) +{ + { + std::lock_guard lock(mutex_); + if (!waiting_ || request_id_.empty()) + return false; + decision_ = decision.empty() ? "deny" : decision; + request_id_.clear(); + waiting_ = false; + } + condition_.notify_all(); + return true; +} + +void ApprovalController::cancel() +{ + { + std::lock_guard lock(mutex_); + decision_ = "deny"; + request_id_.clear(); + waiting_ = false; + selected_ = 0; + } + condition_.notify_all(); +} + +void ApprovalController::shutdown() +{ + { + std::lock_guard lock(mutex_); + shutdown_ = true; + decision_ = "deny"; + request_id_.clear(); + waiting_ = false; + selected_ = 0; + } + condition_.notify_all(); +} + +int ApprovalController::selected_index() const +{ + std::lock_guard lock(mutex_); + return selected_; +} + +void ApprovalController::move_selection(int delta) +{ + std::lock_guard lock(mutex_); + selected_ = (selected_ + delta) % 3; + if (selected_ < 0) + selected_ += 3; +} + +std::string ApprovalController::selected_decision() const +{ + std::lock_guard lock(mutex_); + if (selected_ == 1) + return "always"; + if (selected_ == 2) + return "deny"; + return "approve"; +} + +ApprovalWaitResult ApprovalController::wait_for( + const std::string &request_id, std::chrono::milliseconds timeout) +{ + std::unique_lock lock(mutex_); + ApprovalWaitResult result; + if (!waiting_ || request_id.empty() || request_id_ != request_id || + !waiter_request_id_.empty()) + return result; + + waiter_request_id_ = request_id; + const bool answered = condition_.wait_for(lock, timeout, [&] { + return !waiting_ || request_id_ != request_id; + }); + result.timed_out = !answered; + result.decision = answered && !decision_.empty() ? decision_ : "deny"; + if (request_id_ == request_id) { + request_id_.clear(); + decision_.clear(); + waiting_ = false; + selected_ = 0; + } + waiter_request_id_.clear(); + return result; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_approval_controller.h b/projects/ZClaw/main/ui/zclaw_approval_controller.h new file mode 100644 index 00000000..b2c0a8eb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_approval_controller.h @@ -0,0 +1,44 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include +#include +#include + +namespace zclaw { + +struct ApprovalWaitResult { + std::string decision = "deny"; + bool timed_out = false; +}; + +class ApprovalController { +public: + bool begin(const ApprovalRequest &request); + bool pending() const; + bool pending_request(const std::string &request_id) const; + bool answer(const std::string &decision); + void cancel(); + void shutdown(); + + int selected_index() const; + void move_selection(int delta); + std::string selected_decision() const; + + ApprovalWaitResult wait_for(const std::string &request_id, + std::chrono::milliseconds timeout); + +private: + mutable std::mutex mutex_; + std::condition_variable condition_; + std::string request_id_; + std::string decision_; + bool waiting_ = false; + bool shutdown_ = false; + std::string waiter_request_id_; + int selected_ = 0; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_approval_coordinator.cpp b/projects/ZClaw/main/ui/zclaw_approval_coordinator.cpp new file mode 100644 index 00000000..0eddee4f --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_approval_coordinator.cpp @@ -0,0 +1,123 @@ +#include "zclaw_approval_coordinator.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_ui_task_queue.h" + +#include + +#include +#include + +namespace zclaw { + +ApprovalCoordinator::ApprovalCoordinator(FontManager &fonts, + std::shared_ptr tasks) + : fonts_(fonts), tasks_(std::move(tasks)), + lifetime_(std::make_shared>(this)) +{ +} + +ApprovalCoordinator::~ApprovalCoordinator() +{ + lifetime_->invalidate(); + shutdown(); +} + +bool ApprovalCoordinator::pending() const +{ + return controller_->pending(); +} + +void ApprovalCoordinator::move_selection(int delta) +{ + controller_->move_selection(delta); + dialog_.update_selection(controller_->selected_index()); +} + +bool ApprovalCoordinator::answer(const char *decision) +{ + if (!controller_->answer(decision ? decision : "deny")) + return false; + dialog_.close(); + return true; +} + +bool ApprovalCoordinator::answer_selected() +{ + const std::string decision = controller_->selected_decision(); + return answer(decision.c_str()); +} + +void ApprovalCoordinator::cancel() +{ + controller_->cancel(); +} + +void ApprovalCoordinator::shutdown() +{ + controller_->shutdown(); + dialog_.close(); +} + +ApprovalCoordinator::Handler ApprovalCoordinator::handler() +{ + const std::shared_ptr controller = controller_; + const std::shared_ptr tasks = tasks_; + const std::shared_ptr> lifetime = lifetime_; + return [controller, tasks, lifetime](const ApprovalRequest &request, + Decision decision) { + wait_for_decision(controller, tasks, lifetime, request, + std::move(decision)); + }; +} + +void ApprovalCoordinator::wait_for_decision( + const std::shared_ptr &controller, + const std::shared_ptr &tasks, + const std::shared_ptr> &lifetime, + const ApprovalRequest &request, Decision decision) +{ + const int timeout_seconds = request.timeout_secs <= 0 ? 120 : request.timeout_secs; + + if (!decision) + return; + if (!controller->begin(request)) { + decision("deny"); + return; + } + if (!tasks->post([lifetime, request] { + lifetime->invoke([&request](ApprovalCoordinator &view) { + view.publish(request); + }); + })) { + controller->cancel(); + decision("deny"); + return; + } + (void)hv::async([controller, tasks, lifetime, request, timeout_seconds, + decision = std::move(decision)]() mutable { + const ApprovalWaitResult result = controller->wait_for( + request.request_id, std::chrono::seconds(timeout_seconds)); + if (result.timed_out) + tasks->post([lifetime] { + lifetime->invoke([](ApprovalCoordinator &view) { + view.close_if_finished(); + }); + }); + decision(result.decision); + }); +} + +void ApprovalCoordinator::publish(const ApprovalRequest &request) +{ + if (controller_->pending_request(request.request_id)) + dialog_.show(&fonts_, request, controller_->selected_index()); +} + +void ApprovalCoordinator::close_if_finished() +{ + if (!controller_->pending()) + dialog_.close(); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_approval_coordinator.h b/projects/ZClaw/main/ui/zclaw_approval_coordinator.h new file mode 100644 index 00000000..e9f24c01 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_approval_coordinator.h @@ -0,0 +1,53 @@ +#pragma once + +#include "zclaw_approval_controller.h" +#include "zclaw_approval_dialog.h" +#include "zclaw_callback_lifetime.h" + +#include +#include +#include + +namespace zclaw { + +class FontManager; +class UiTaskQueue; + +class ApprovalCoordinator { +public: + using Decision = std::function; + using Handler = std::function; + + ApprovalCoordinator(FontManager &fonts, std::shared_ptr tasks); + ~ApprovalCoordinator(); + + ApprovalCoordinator(const ApprovalCoordinator &) = delete; + ApprovalCoordinator &operator=(const ApprovalCoordinator &) = delete; + + bool pending() const; + void move_selection(int delta); + bool answer(const char *decision); + bool answer_selected(); + void cancel(); + void shutdown(); + + Handler handler(); + +private: + static void wait_for_decision( + const std::shared_ptr &controller, + const std::shared_ptr &tasks, + const std::shared_ptr> &lifetime, + const ApprovalRequest &request, Decision decision); + void publish(const ApprovalRequest &request); + void close_if_finished(); + + FontManager &fonts_; + std::shared_ptr tasks_; + std::shared_ptr controller_ = + std::make_shared(); + std::shared_ptr> lifetime_; + ApprovalDialog dialog_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_approval_dialog.cpp b/projects/ZClaw/main/ui/zclaw_approval_dialog.cpp new file mode 100644 index 00000000..3e30f9a2 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_approval_dialog.cpp @@ -0,0 +1,95 @@ +#include "zclaw_approval_dialog.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_theme.h" +#include "zclaw_widgets.h" + +namespace zclaw { + +ApprovalDialog::~ApprovalDialog() +{ + close(); +} + +void ApprovalDialog::show(const FontManager *fonts, + const ApprovalRequest &request, + int selected_index) +{ + if (!fonts) + return; + close(); + + dialog_ = widgets::box(lv_layer_top(), 42, 30, 236, 110, theme::kBar, 8); + lv_obj_add_event_cb(dialog_, dialog_deleted, LV_EVENT_DELETE, this); + lv_obj_set_style_border_width(dialog_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(dialog_, lv_color_hex(theme::kPurple), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_move_foreground(dialog_); + + widgets::box(dialog_, 0, 0, 236, 20, theme::kPanel, 8); + widgets::label(dialog_, "Permission", 10, 5, 120, 12, fonts->font_12(), theme::kText); + widgets::label(dialog_, "Esc", 194, 6, 28, 10, fonts->font_10(), theme::kDim, + LV_TEXT_ALIGN_RIGHT); + widgets::label(dialog_, request.tool, 12, 27, 212, 14, + fonts->font_12(), theme::kText); + widgets::label(dialog_, request.summary, 12, 45, 212, 30, + fonts->font_10(), theme::kMuted); + widgets::box(dialog_, 0, 78, 236, 1, theme::kPanelLine); + + static constexpr const char *labels[] = {"Yes", "Always", "No"}; + static constexpr uint32_t colors[] = {theme::kOnline, theme::kPurple, theme::kDim}; + for (int index = 0; index < 3; ++index) { + buttons_[index] = widgets::box(dialog_, 12 + index * 72, 86, 68, 17, + theme::kPanel, 5); + lv_obj_set_style_border_width(buttons_[index], 1, + LV_PART_MAIN | LV_STATE_DEFAULT); + widgets::label(buttons_[index], labels[index], 0, 4, 68, 10, + fonts->font_10(), colors[index], LV_TEXT_ALIGN_CENTER); + } + update_selection(selected_index); +} + +void ApprovalDialog::close() +{ + lv_obj_t *dialog = dialog_; + if (dialog) + lv_obj_del(dialog); + release_dialog(); +} + +void ApprovalDialog::dialog_deleted(lv_event_t *event) +{ + ApprovalDialog *dialog = + static_cast(lv_event_get_user_data(event)); + if (dialog) + dialog->release_dialog(); +} + +void ApprovalDialog::release_dialog() +{ + dialog_ = nullptr; + for (lv_obj_t *&button : buttons_) + button = nullptr; +} + +void ApprovalDialog::update_selection(int selected_index) +{ + for (int index = 0; index < 3; ++index) { + if (!buttons_[index]) + continue; + const bool selected = index == selected_index; + lv_obj_set_style_bg_color(buttons_[index], + lv_color_hex(selected ? 0x2B2B4A : theme::kPanel), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(buttons_[index], + lv_color_hex(selected ? theme::kText : theme::kPanelLine), + LV_PART_MAIN | LV_STATE_DEFAULT); + } +} + +bool ApprovalDialog::is_open() const +{ + return dialog_ != nullptr; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_approval_dialog.h b/projects/ZClaw/main/ui/zclaw_approval_dialog.h new file mode 100644 index 00000000..a2065576 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_approval_dialog.h @@ -0,0 +1,32 @@ +#pragma once + +#include "lvgl/lvgl.h" +#include "zclaw_types.h" + +namespace zclaw { + +class FontManager; + +class ApprovalDialog { +public: + ApprovalDialog() = default; + ~ApprovalDialog(); + + ApprovalDialog(const ApprovalDialog &) = delete; + ApprovalDialog &operator=(const ApprovalDialog &) = delete; + + void show(const FontManager *fonts, const ApprovalRequest &request, + int selected_index); + void close(); + void update_selection(int selected_index); + bool is_open() const; + +private: + static void dialog_deleted(lv_event_t *event); + void release_dialog(); + + lv_obj_t *dialog_ = nullptr; + lv_obj_t *buttons_[3] = {}; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_archive_installer.cpp b/projects/ZClaw/main/ui/zclaw_archive_installer.cpp new file mode 100644 index 00000000..22460916 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_archive_installer.cpp @@ -0,0 +1,176 @@ +#include "zclaw_archive_installer.h" + +#include "zclaw_directory_sync.h" +#include "zclaw_process_runner.h" +#include "zclaw_process_executor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace zclaw { +namespace { + +std::filesystem::path find_file(const std::string &directory, + const std::string &filename) +{ + std::error_code filesystem_error; + const std::filesystem::recursive_directory_iterator end; + for (std::filesystem::recursive_directory_iterator item( + directory, std::filesystem::directory_options::skip_permission_denied, + filesystem_error); + !filesystem_error && item != end; item.increment(filesystem_error)) { + const std::filesystem::file_status status = + item->symlink_status(filesystem_error); + if (!filesystem_error && std::filesystem::is_regular_file(status) && + item->path().filename() == filename) + return item->path(); + } + return {}; +} + +bool copy_all(int source, int destination) +{ + char buffer[16384]; + while (true) { + ssize_t count = ::read(source, buffer, sizeof(buffer)); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + return count == 0; + ssize_t offset = 0; + while (offset < count) { + const ssize_t written = ::write( + destination, buffer + offset, + static_cast(count - offset)); + if (written < 0 && errno == EINTR) + continue; + if (written <= 0) { + if (written == 0) + errno = EIO; + return false; + } + offset += written; + } + } +} + +bool copy_executable(const std::filesystem::path &source, + const std::string &destination, std::string *error) +{ + const int source_fd = ::open(source.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (source_fd < 0) { + if (error) + *error = "Cannot open executable: " + std::string(std::strerror(errno)); + return false; + } + + struct stat source_status {}; + const int stat_result = ::fstat(source_fd, &source_status); + if (stat_result != 0 || !S_ISREG(source_status.st_mode)) { + const int saved_error = stat_result != 0 ? errno : EINVAL; + ::close(source_fd); + if (error) + *error = "Cannot open executable: " + + std::string(std::strerror(saved_error)); + return false; + } + + std::string temporary_template = destination + ".tmp.XXXXXX"; + std::vector temporary(temporary_template.begin(), + temporary_template.end()); + temporary.push_back('\0'); + const int destination_fd = ::mkstemp(temporary.data()); + if (destination_fd < 0) { + const int saved_error = errno; + ::close(source_fd); + if (error) + *error = "Cannot install executable: " + + std::string(std::strerror(saved_error)); + return false; + } + + const int descriptor_flags = ::fcntl(destination_fd, F_GETFD); + if (descriptor_flags < 0 || + ::fcntl(destination_fd, F_SETFD, descriptor_flags | FD_CLOEXEC) != 0) { + const int saved_error = errno; + ::close(source_fd); + ::close(destination_fd); + ::unlink(temporary.data()); + if (error) + *error = "Cannot install executable: " + + std::string(std::strerror(saved_error)); + return false; + } + + bool ok = ::fchmod(destination_fd, 0700) == 0 && + copy_all(source_fd, destination_fd) && + ::fsync(destination_fd) == 0; + int saved_error = ok ? 0 : errno; + if (::close(source_fd) != 0 && ok) { + ok = false; + saved_error = errno; + } + if (::close(destination_fd) != 0 && ok) { + ok = false; + saved_error = errno; + } + if (ok && ::rename(temporary.data(), destination.c_str()) != 0) { + ok = false; + saved_error = errno; + } + if (!ok) { + ::unlink(temporary.data()); + if (error) + *error = "Cannot install executable: " + + std::string(std::strerror(saved_error)); + return false; + } + return sync_parent_directory(destination, error); +} + +} // namespace + +ArchiveInstaller::ArchiveInstaller() + : ArchiveInstaller([](const Command &command) { + ProcessExecutor executor; + return executor.run(command); + }) +{ +} + +ArchiveInstaller::ArchiveInstaller(CommandRunner command_runner) + : command_runner_(std::move(command_runner)) +{ +} + +bool ArchiveInstaller::install_executable( + const std::string &archive_path, const std::string &work_directory, + const std::string &executable_name, const std::string &destination, + std::string *error) const +{ + if (error) + error->clear(); + const CommandResult extracted = command_runner_( + {"tar", "-xzf", archive_path, "-C", work_directory}); + if (!extracted.ok()) { + if (error) + *error = "Cannot extract archive.\n" + extracted.output; + return false; + } + + const std::filesystem::path source = find_file(work_directory, executable_name); + if (source.empty()) { + if (error) + *error = "Archive does not contain " + executable_name + "."; + return false; + } + return copy_executable(source, destination, error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_archive_installer.h b/projects/ZClaw/main/ui/zclaw_archive_installer.h new file mode 100644 index 00000000..5660b6fd --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_archive_installer.h @@ -0,0 +1,29 @@ +#pragma once + +#include "zclaw_process_runner.h" + +#include +#include +#include + +namespace zclaw { + +class ArchiveInstaller { +public: + using Command = std::vector; + using CommandRunner = std::function; + + ArchiveInstaller(); + explicit ArchiveInstaller(CommandRunner command_runner); + + bool install_executable(const std::string &archive_path, + const std::string &work_directory, + const std::string &executable_name, + const std::string &destination, + std::string *error) const; + +private: + CommandRunner command_runner_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_async_backend.h b/projects/ZClaw/main/ui/zclaw_async_backend.h new file mode 100644 index 00000000..4e682c29 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_async_backend.h @@ -0,0 +1,41 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +struct NetworkCheckResult { + bool online = false; + std::string error; +}; + +class AsyncBackend { +public: + using ApprovalDecision = std::function; + using ApprovalHandler = std::function; + using ProgressHandler = std::function; + using NetworkCompletion = std::function; + using OperationCompletion = std::function; + + virtual ~AsyncBackend() = default; + + // Requests cancellation where supported. Other operations may still run to + // completion and are joined by AsyncService::shutdown(). + virtual void shutdown() noexcept {} + virtual bool check_network(NetworkCompletion completion) const = 0; + virtual bool send_chat( + UiConfig config, std::string message, + ApprovalHandler approval_handler, + OperationCompletion completion) const = 0; + virtual bool pair(UiConfig config, std::string code, + OperationCompletion completion) const = 0; + virtual OperationResult setup( + UiConfig config, ProviderConfig provider, + const ProgressHandler &progress_handler) const = 0; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_async_service.cpp b/projects/ZClaw/main/ui/zclaw_async_service.cpp new file mode 100644 index 00000000..5f50bf50 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_async_service.cpp @@ -0,0 +1,184 @@ +#include "zclaw_async_service.h" + +#include "zclaw_ui_task_sink.h" + +#include + +#include +#include +#include + +namespace zclaw { +namespace { + +std::string exception_detail() +{ + try { + throw; + } catch (const std::exception &error) { + return error.what(); + } catch (...) { + return "unknown exception"; + } +} + +template +OperationResult call_operation(UiConfig fallback_config, const char *operation, + Call call) +{ + try { + return call(); + } catch (...) { + return {std::string(operation) + " failed.\nUnexpected backend error: " + + exception_detail(), + false, std::move(fallback_config)}; + } +} + +} // namespace + +AsyncService::AsyncService(std::shared_ptr tasks, + std::shared_ptr backend) + : tasks_(std::move(tasks)), backend_(std::move(backend)) +{ +} + +void AsyncService::shutdown() noexcept +{ + std::lock_guard lock(mutex_); + if (shutdown_) + return; + shutdown_ = true; + if (backend_) + backend_->shutdown(); +} + +bool AsyncService::launch(std::function task) const +{ + { + std::lock_guard lock(mutex_); + if (shutdown_ || !task) + return false; + } + try { + (void)hv::async(std::move(task)); + return true; + } catch (...) { + return false; + } +} + +bool AsyncService::active() const +{ + std::lock_guard lock(mutex_); + return !shutdown_; +} + +bool AsyncService::check_network(NetworkCallback callback) const +{ + if (!callback || !tasks_ || !backend_ || !active()) + return false; + const std::shared_ptr tasks = tasks_; + const std::shared_ptr backend = backend_; + auto completion = [tasks, callback = std::move(callback)]( + NetworkCheckResult result) mutable { + tasks->post([callback = std::move(callback), result = std::move(result)]() mutable { + callback(std::move(result)); + }); + }; + try { + return backend->check_network(completion); + } catch (...) { + NetworkCheckResult result; + result.error = "Network check failed.\nUnexpected backend error: " + + exception_detail(); + completion(std::move(result)); + return true; + } +} + +bool AsyncService::send_chat(UiConfig config, std::string message, + AsyncBackend::ApprovalHandler approval_handler, + ClientCallback callback) const +{ + if (!callback || !tasks_ || !backend_ || !active()) + return false; + const std::shared_ptr tasks = tasks_; + const std::shared_ptr backend = backend_; + UiConfig fallback_config = config; + auto completion = [tasks, callback = std::move(callback)]( + OperationResult result) mutable { + tasks->post([callback = std::move(callback), result = std::move(result)]() mutable { + callback(std::move(result)); + }); + }; + try { + return backend->send_chat(std::move(config), std::move(message), + std::move(approval_handler), + completion); + } catch (...) { + completion({"Chat request failed.\nUnexpected backend error: " + + exception_detail(), + false, std::move(fallback_config)}); + return true; + } +} + +bool AsyncService::pair(UiConfig config, std::string code, + ClientCallback callback) const +{ + if (!callback || !tasks_ || !backend_ || !active()) + return false; + const std::shared_ptr tasks = tasks_; + const std::shared_ptr backend = backend_; + UiConfig fallback_config = config; + auto completion = [tasks, callback = std::move(callback)]( + OperationResult result) mutable { + tasks->post([callback = std::move(callback), result = std::move(result)]() mutable { + callback(std::move(result)); + }); + }; + try { + return backend->pair(std::move(config), std::move(code), + completion); + } catch (...) { + completion({"Pairing request failed.\nUnexpected backend error: " + + exception_detail(), + false, std::move(fallback_config)}); + return true; + } +} + +bool AsyncService::setup(UiConfig config, ProviderConfig provider, + ProgressCallback progress_callback, + ClientCallback callback) const +{ + if (!callback || !tasks_ || !backend_ || !active()) + return false; + const std::shared_ptr tasks = tasks_; + const std::shared_ptr backend = backend_; + return launch( + [tasks, backend, config = std::move(config), provider = std::move(provider), + progress_callback = std::move(progress_callback), + callback = std::move(callback)]() mutable { + UiConfig fallback_config = config; + OperationResult result = call_operation( + std::move(fallback_config), "Setup", + [&] { + return backend->setup( + std::move(config), std::move(provider), + [tasks, progress_callback](const SetupProgress &progress) { + if (progress_callback) + tasks->post([progress_callback, progress] { + progress_callback(progress); + }); + }); + }); + tasks->post( + [callback = std::move(callback), result = std::move(result)]() mutable { + callback(std::move(result)); + }); + }); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_async_service.h b/projects/ZClaw/main/ui/zclaw_async_service.h new file mode 100644 index 00000000..82174966 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_async_service.h @@ -0,0 +1,43 @@ +#pragma once + +#include "zclaw_async_backend.h" + +#include +#include +#include +#include + +namespace zclaw { + +class UiTaskSink; +class AsyncService { +public: + using NetworkCallback = std::function; + using ClientCallback = std::function; + using ProgressCallback = std::function; + + AsyncService(std::shared_ptr tasks, + std::shared_ptr backend); + + void shutdown() noexcept; + + bool check_network(NetworkCallback callback) const; + bool send_chat(UiConfig config, std::string message, + AsyncBackend::ApprovalHandler approval_handler, + ClientCallback callback) const; + bool pair(UiConfig config, std::string code, ClientCallback callback) const; + bool setup(UiConfig config, ProviderConfig provider, + ProgressCallback progress_callback, + ClientCallback callback) const; + +private: + bool active() const; + bool launch(std::function task) const; + + std::shared_ptr tasks_; + std::shared_ptr backend_; + mutable std::mutex mutex_; + mutable bool shutdown_ = false; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_atomic_file.cpp b/projects/ZClaw/main/ui/zclaw_atomic_file.cpp new file mode 100644 index 00000000..05652320 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_atomic_file.cpp @@ -0,0 +1,86 @@ +#include "zclaw_atomic_file.h" + +#include "zclaw_directory_sync.h" + +#include +#include +#include +#include +#include + +namespace zclaw { +namespace { + +bool write_all(int fd, const std::string &data) +{ + std::size_t offset = 0; + while (offset < data.size()) { + const ssize_t written = + ::write(fd, data.data() + offset, data.size() - offset); + if (written < 0) { + if (errno == EINTR) + continue; + return false; + } + if (written == 0) { + errno = EIO; + return false; + } + offset += static_cast(written); + } + return true; +} + +void set_error(std::string *error, const char *operation) +{ + if (error) + *error = std::string(operation) + ": " + std::strerror(errno); +} + +} // namespace + +bool atomic_write_file(const std::string &path, const std::string &contents, + const AtomicFileOptions &options, std::string *error) +{ + if (error) + error->clear(); + + std::string temporary_path = path + ".tmp.XXXXXX"; + std::vector temporary_name(temporary_path.begin(), temporary_path.end()); + temporary_name.push_back('\0'); + const int fd = ::mkstemp(temporary_name.data()); + if (fd < 0) { + set_error(error, "Could not create temporary file"); + return false; + } + + bool ok = ::fchmod(fd, static_cast(options.permissions & 0777)) == 0; + int saved_errno = ok ? 0 : errno; + if (ok && !write_all(fd, contents)) { + ok = false; + saved_errno = errno; + } + if (ok && ::fsync(fd) != 0) { + ok = false; + saved_errno = errno; + } + if (::close(fd) != 0 && ok) { + ok = false; + saved_errno = errno; + } + if (!ok) { + errno = saved_errno; + set_error(error, "Could not write file"); + ::unlink(temporary_name.data()); + return false; + } + + if (::rename(temporary_name.data(), path.c_str()) != 0) { + set_error(error, "Could not replace file"); + ::unlink(temporary_name.data()); + return false; + } + return sync_parent_directory(path, error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_atomic_file.h b/projects/ZClaw/main/ui/zclaw_atomic_file.h new file mode 100644 index 00000000..fa302290 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_atomic_file.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +namespace zclaw { + +struct AtomicFileOptions { + std::uint32_t permissions = 0600; +}; + +bool atomic_write_file(const std::string &path, const std::string &contents, + const AtomicFileOptions &options = {}, + std::string *error = nullptr); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_authorization_session_model.cpp b/projects/ZClaw/main/ui/zclaw_authorization_session_model.cpp new file mode 100644 index 00000000..c40fa527 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_authorization_session_model.cpp @@ -0,0 +1,36 @@ +#include "zclaw_authorization_session_model.h" + +namespace zclaw { + +AuthorizationSession::Generation AuthorizationSession::begin() +{ + ++generation_; + in_flight_ = true; + return generation_; +} + +bool AuthorizationSession::finish(Generation generation) +{ + if (!in_flight_ || generation != generation_) + return false; + in_flight_ = false; + return true; +} + +void AuthorizationSession::invalidate() +{ + ++generation_; + in_flight_ = false; +} + +bool AuthorizationSession::in_flight() const +{ + return in_flight_; +} + +AuthorizationSession::Generation AuthorizationSession::generation() const +{ + return generation_; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_authorization_session_model.h b/projects/ZClaw/main/ui/zclaw_authorization_session_model.h new file mode 100644 index 00000000..2d229a96 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_authorization_session_model.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace zclaw { + +class AuthorizationSession { +public: + using Generation = std::uint64_t; + + Generation begin(); + bool finish(Generation generation); + void invalidate(); + + bool in_flight() const; + Generation generation() const; + +private: + Generation generation_ = 0; + bool in_flight_ = false; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_authorization_workflow.cpp b/projects/ZClaw/main/ui/zclaw_authorization_workflow.cpp new file mode 100644 index 00000000..bdda83a4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_authorization_workflow.cpp @@ -0,0 +1,83 @@ +#include "zclaw_authorization_workflow.h" + +#include "zclaw_async_service.h" +#include "zclaw_chat_view.h" +#include "zclaw_fonts.hpp" +#include "zclaw_input_dialog.h" +#include "zclaw_settings_coordinator.h" +#include "zclaw_ui_config_manager.h" + +#include + +namespace zclaw { + +AuthorizationWorkflow::AuthorizationWorkflow( + UiConfigManager &config, SettingsCoordinator &settings, InputDialog &input, + FontManager &fonts, ChatView &chat, AsyncService &async_service) + : config_(config), settings_(settings), input_(input), fonts_(fonts), + chat_(chat), async_service_(async_service), + lifetime_(std::make_shared>(this)) +{ +} + +AuthorizationWorkflow::~AuthorizationWorkflow() +{ + lifetime_->invalidate(); +} + +void AuthorizationWorkflow::open_pairing_input() +{ + input_.open_text(&fonts_, "Pairing code", "", InputMode::PairingCode); +} + +void AuthorizationWorkflow::start_pairing(const std::string &code) +{ + const AuthorizationSession::Generation generation = session_.begin(); + chat_.append_assistant_message("Pairing with ZeroClaw..."); + const std::shared_ptr> lifetime = lifetime_; + if (!async_service_.pair( + config_.config(), code, + [lifetime, generation](OperationResult result) { + lifetime->invoke([generation, &result](AuthorizationWorkflow &workflow) { + workflow.finish_pairing(generation, std::move(result)); + }); + })) { + session_.finish(generation); + chat_.append_assistant_message("Could not start pairing request."); + } +} + +void AuthorizationWorkflow::clear_token() +{ + session_.invalidate(); + std::string error; + if (config_.clear_token(&error)) + chat_.append_assistant_message("Authorization token cleared."); + else + report_save_error(error); + settings_.render_authorization(); +} + +void AuthorizationWorkflow::finish_pairing( + AuthorizationSession::Generation generation, OperationResult result) +{ + if (!session_.finish(generation)) + return; + chat_.append_assistant_message(result.text); + if (result.ok) { + std::string error; + if (!config_.update(result.config, &error)) + report_save_error(error); + } + if (settings_.is_open() && + settings_.state().view() == SettingsView::Authorization) + settings_.render_authorization(); +} + +void AuthorizationWorkflow::report_save_error(const std::string &error) +{ + chat_.append_assistant_message( + error.empty() ? "Could not save settings." : error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_authorization_workflow.h b/projects/ZClaw/main/ui/zclaw_authorization_workflow.h new file mode 100644 index 00000000..28c9a9df --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_authorization_workflow.h @@ -0,0 +1,46 @@ +#pragma once + +#include "zclaw_authorization_session_model.h" +#include "zclaw_callback_lifetime.h" +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +class AsyncService; +class ChatView; +class FontManager; +class InputDialog; +class SettingsCoordinator; +class UiConfigManager; + +class AuthorizationWorkflow { +public: + AuthorizationWorkflow(UiConfigManager &config, + SettingsCoordinator &settings, InputDialog &input, + FontManager &fonts, ChatView &chat, + AsyncService &async_service); + ~AuthorizationWorkflow(); + + void open_pairing_input(); + void start_pairing(const std::string &code); + void clear_token(); + +private: + void finish_pairing(AuthorizationSession::Generation generation, + OperationResult result); + void report_save_error(const std::string &error); + + UiConfigManager &config_; + SettingsCoordinator &settings_; + InputDialog &input_; + FontManager &fonts_; + ChatView &chat_; + AsyncService &async_service_; + AuthorizationSession session_; + std::shared_ptr> lifetime_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_binary_archive_cache.cpp b/projects/ZClaw/main/ui/zclaw_binary_archive_cache.cpp new file mode 100644 index 00000000..026245e8 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_binary_archive_cache.cpp @@ -0,0 +1,85 @@ +#include "zclaw_binary_archive_cache.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace zclaw { +namespace { + +constexpr const char *kInstallPrefix = "zeroclaw-install."; + +} // namespace + +bool sha256_file_matches(const std::string &path, + const std::string &expected_hex) +{ + std::error_code filesystem_error; + if (std::filesystem::symlink_status(path, filesystem_error).type() != + std::filesystem::file_type::regular) + return false; + + std::ifstream input(path, std::ios::binary); + if (!input) + return false; + using DigestContext = std::unique_ptr; + DigestContext context(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!context || EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1) + return false; + + std::array buffer{}; + while (input) { + input.read(buffer.data(), static_cast(buffer.size())); + const std::streamsize count = input.gcount(); + if (count > 0 && EVP_DigestUpdate(context.get(), buffer.data(), + static_cast(count)) != 1) + return false; + } + if (!input.eof()) + return false; + + std::array digest{}; + unsigned int digest_size = 0; + if (EVP_DigestFinal_ex(context.get(), digest.data(), &digest_size) != 1) + return false; + std::ostringstream hex; + hex << std::hex << std::setfill('0'); + for (unsigned int index = 0; index < digest_size; ++index) + hex << std::setw(2) << static_cast(digest[index]); + return hex.str() == expected_hex; +} + +bool cleanup_binary_install_artifacts(const std::string &directory, + std::string *error) +{ + if (error) + error->clear(); + std::error_code filesystem_error; + std::filesystem::directory_iterator iterator(directory, filesystem_error); + if (filesystem_error) { + if (error) + *error = "Cannot inspect download cache: " + + filesystem_error.message(); + return false; + } + for (const std::filesystem::directory_entry &entry : iterator) { + const std::string name = entry.path().filename().string(); + if (name.rfind(kInstallPrefix, 0) != 0) + continue; + std::filesystem::remove_all(entry.path(), filesystem_error); + if (filesystem_error) { + if (error) + *error = "Cannot clear download cache: " + + filesystem_error.message(); + return false; + } + } + return true; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_binary_archive_cache.h b/projects/ZClaw/main/ui/zclaw_binary_archive_cache.h new file mode 100644 index 00000000..9713887e --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_binary_archive_cache.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace zclaw { + +bool sha256_file_matches(const std::string &path, + const std::string &expected_hex); +bool cleanup_binary_install_artifacts(const std::string &directory, + std::string *error = nullptr); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_binary_installer.cpp b/projects/ZClaw/main/ui/zclaw_binary_installer.cpp new file mode 100644 index 00000000..10fe8e9e --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_binary_installer.cpp @@ -0,0 +1,66 @@ +#include "zclaw_binary_installer.h" + +#include "zclaw_binary_installer_backend.h" + +#include + +namespace zclaw { +namespace { + +void report_progress(const BinaryInstaller::ProgressHandler &handler, + const char *status, int percent, + std::size_t downloaded = 0, std::size_t total = 0, + double speed = 0.0, std::string source_url = {}, + std::string destination_path = {}) +{ + if (handler) + handler({status ? status : "", percent, downloaded, total, speed, + std::move(source_url), std::move(destination_path)}); +} + +void report_backend_progress(const BinaryInstaller::ProgressHandler &handler, + const BinaryInstallProgress &progress) +{ + if (progress.phase == BinaryInstallPhase::Installing) { + report_progress(handler, "Installing ZeroClaw", 67); + return; + } + const int percent = progress.total_bytes > 0 + ? static_cast((progress.downloaded_bytes * 60) / + progress.total_bytes) + : 0; + report_progress(handler, "Downloading ZeroClaw", 5 + percent, + progress.downloaded_bytes, progress.total_bytes, + progress.bytes_per_second, progress.source_url, + progress.destination_path); +} + +} // namespace + +BinaryInstaller::BinaryInstaller(std::shared_ptr backend) + : backend_(std::move(backend)) +{ +} + +bool BinaryInstaller::ensure_installed( + std::string *error, const ProgressHandler &progress_handler) const +{ + report_progress(progress_handler, "Checking ZeroClaw", 3); + if (!backend_) { + if (error) + *error = "Missing binary installer backend."; + return false; + } + if (!backend_->prepare_destination(error)) + return false; + if (backend_->binary_ready()) { + report_progress(progress_handler, "ZeroClaw is ready", 65); + return true; + } + return backend_->install( + error, [&progress_handler](const BinaryInstallProgress &progress) { + report_backend_progress(progress_handler, progress); + }); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_binary_installer.h b/projects/ZClaw/main/ui/zclaw_binary_installer.h new file mode 100644 index 00000000..f6c32956 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_binary_installer.h @@ -0,0 +1,26 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include +#include + +namespace zclaw { + +class BinaryInstallerBackend; + +class BinaryInstaller { +public: + using ProgressHandler = std::function; + + explicit BinaryInstaller(std::shared_ptr backend); + + bool ensure_installed(std::string *error, + const ProgressHandler &progress_handler) const; + +private: + std::shared_ptr backend_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_binary_installer_backend.h b/projects/ZClaw/main/ui/zclaw_binary_installer_backend.h new file mode 100644 index 00000000..5d549467 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_binary_installer_backend.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +namespace zclaw { + +enum class BinaryInstallPhase { + Downloading, + Installing, +}; + +struct BinaryInstallProgress { + BinaryInstallProgress() = default; + BinaryInstallProgress(BinaryInstallPhase phase_value, + std::size_t downloaded_value = 0, + std::size_t total_value = 0, + double speed_value = 0.0, + std::string source_value = {}, + std::string destination_value = {}) + : phase(phase_value), downloaded_bytes(downloaded_value), + total_bytes(total_value), bytes_per_second(speed_value), + source_url(std::move(source_value)), + destination_path(std::move(destination_value)) + { + } + + BinaryInstallPhase phase = BinaryInstallPhase::Downloading; + std::size_t downloaded_bytes = 0; + std::size_t total_bytes = 0; + double bytes_per_second = 0.0; + std::string source_url; + std::string destination_path; +}; + +class BinaryInstallerBackend { +public: + using ProgressHandler = std::function; + + virtual ~BinaryInstallerBackend() = default; + + virtual bool prepare_destination(std::string *error) const = 0; + virtual bool binary_ready() const = 0; + virtual bool install(std::string *error, + const ProgressHandler &progress_handler) const = 0; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_callback_lifetime.h b/projects/ZClaw/main/ui/zclaw_callback_lifetime.h new file mode 100644 index 00000000..c9148cab --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_callback_lifetime.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +namespace zclaw { + +template +class CallbackLifetime { +public: + explicit CallbackLifetime(Target *target) : target_(target) {} + + CallbackLifetime(const CallbackLifetime &) = delete; + CallbackLifetime &operator=(const CallbackLifetime &) = delete; + + void invalidate() + { + std::lock_guard lock(mutex_); + target_ = nullptr; + } + + template + bool invoke(Callback &&callback) + { + std::lock_guard lock(mutex_); + if (!target_) + return false; + std::invoke(std::forward(callback), *target_); + return true; + } + +private: + std::mutex mutex_; + Target *target_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_event.cpp b/projects/ZClaw/main/ui/zclaw_chat_event.cpp new file mode 100644 index 00000000..5839053d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_event.cpp @@ -0,0 +1,90 @@ +#include "zclaw_chat_event.h" + +#include "json.hpp" + +#include + +namespace zclaw { +namespace { + +using Json = nlohmann::json; + +bool required_string(const Json &object, const char *field, std::string *value, + bool allow_empty = true) +{ + const auto found = object.find(field); + if (found == object.end() || !found->is_string()) + return false; + *value = found->get(); + return allow_empty || !value->empty(); +} + +bool optional_string(const Json &object, const char *field, std::string *value) +{ + const auto found = object.find(field); + if (found == object.end()) + return true; + if (!found->is_string()) + return false; + *value = found->get(); + return true; +} + +int approval_timeout(const Json &object) +{ + static constexpr int kDefaultTimeout = 120; + static constexpr std::uint64_t kMaximumTimeout = 3600; + const auto found = object.find("timeout_secs"); + if (found == object.end() || !found->is_number_integer()) + return kDefaultTimeout; + if (found->is_number_unsigned()) { + const std::uint64_t timeout = found->get(); + return timeout >= 1 && timeout <= kMaximumTimeout + ? static_cast(timeout) + : kDefaultTimeout; + } + const std::int64_t timeout = found->get(); + return timeout >= 1 && + static_cast(timeout) <= kMaximumTimeout + ? static_cast(timeout) + : kDefaultTimeout; +} + +} // namespace + +ChatEvent parse_chat_event(const std::string &json) +{ + ChatEvent event; + const Json parsed = Json::parse(json, nullptr, false); + if (!parsed.is_object()) + return event; + + std::string type; + if (!required_string(parsed, "type", &type, false)) + return event; + if (type == "chunk") { + if (!required_string(parsed, "content", &event.content)) + return {}; + event.type = ChatEventType::Chunk; + } else if (type == "approval_request") { + if (!required_string(parsed, "request_id", &event.approval.request_id, + false) || + !optional_string(parsed, "tool", &event.approval.tool) || + !optional_string(parsed, "arguments_summary", + &event.approval.summary)) + return {}; + event.approval.timeout_secs = approval_timeout(parsed); + event.type = ChatEventType::ApprovalRequest; + } else if (type == "done") { + if (!required_string(parsed, "full_response", &event.content)) + return {}; + event.type = ChatEventType::Done; + } else if (type == "error") { + if (!required_string(parsed, "message", &event.content)) + return {}; + event.type = ChatEventType::Error; + } + return event; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_event.h b/projects/ZClaw/main/ui/zclaw_chat_event.h new file mode 100644 index 00000000..02dcaa5a --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_event.h @@ -0,0 +1,25 @@ +#pragma once + +#include "zclaw_types.h" + +#include + +namespace zclaw { + +enum class ChatEventType { + Unknown, + Chunk, + ApprovalRequest, + Done, + Error, +}; + +struct ChatEvent { + ChatEventType type = ChatEventType::Unknown; + std::string content; + ApprovalRequest approval; +}; + +ChatEvent parse_chat_event(const std::string &json); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_layout.cpp b/projects/ZClaw/main/ui/zclaw_chat_layout.cpp new file mode 100644 index 00000000..38ca34d3 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_layout.cpp @@ -0,0 +1,56 @@ +#include "zclaw_chat_layout.h" + +#include + +namespace zclaw { +namespace { + +constexpr int kUserBubbleMaxWidth = 198; +constexpr int kUserBubbleMinWidth = 38; +constexpr int kUserBubblePadX = 10; +constexpr int kUserBubblePadY = 6; +constexpr int kTrackY = 20; +constexpr int kTrackHeight = 126; +constexpr int kMinimumThumbHeight = 18; + +} // namespace + +ChatBubbleLayout user_bubble_layout(int text_width, int text_height) +{ + ChatBubbleLayout layout; + layout.width = std::clamp(text_width + kUserBubblePadX * 2, + kUserBubbleMinWidth, kUserBubbleMaxWidth); + layout.height = std::max(text_height + kUserBubblePadY * 2, 27); + return layout; +} + +int assistant_bubble_height(int text_height) +{ + return std::max(text_height + 12, 41); +} + +int chat_scroll_delta(int scroll_top, int scroll_bottom, int requested_delta) +{ + const int top = std::max(scroll_top, 0); + const int bottom = std::max(scroll_bottom, 0); + if (requested_delta > 0) + return std::min(requested_delta, top); + if (requested_delta < 0) + return std::max(requested_delta, -bottom); + return 0; +} + +ChatScrollbarLayout chat_scrollbar_layout(int scroll_top, int scroll_bottom) +{ + const int range = scroll_top + scroll_bottom; + if (range <= 0) + return {kTrackY, kTrackHeight}; + + const int height = std::clamp(kTrackHeight * kTrackHeight / + (kTrackHeight + range), + kMinimumThumbHeight, kTrackHeight); + const int travel = kTrackHeight - height; + return {kTrackY + travel * scroll_top / range, height}; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_layout.h b/projects/ZClaw/main/ui/zclaw_chat_layout.h new file mode 100644 index 00000000..46397227 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_layout.h @@ -0,0 +1,20 @@ +#pragma once + +namespace zclaw { + +struct ChatBubbleLayout { + int width = 0; + int height = 0; +}; + +struct ChatScrollbarLayout { + int y = 0; + int height = 0; +}; + +ChatBubbleLayout user_bubble_layout(int text_width, int text_height); +int assistant_bubble_height(int text_height); +int chat_scroll_delta(int scroll_top, int scroll_bottom, int requested_delta); +ChatScrollbarLayout chat_scrollbar_layout(int scroll_top, int scroll_bottom); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_stream_model.cpp b/projects/ZClaw/main/ui/zclaw_chat_stream_model.cpp new file mode 100644 index 00000000..c5385d2d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_stream_model.cpp @@ -0,0 +1,51 @@ +#include "zclaw_chat_stream_model.h" + +namespace zclaw { + +ChatStreamUpdate ChatStreamState::accept(const ChatEvent &event) +{ + if (finished_) + return {true, false, {}}; + + switch (event.type) { + case ChatEventType::Chunk: + chunks_ += event.content; + break; + case ChatEventType::ApprovalRequest: + return {false, true, event.approval}; + case ChatEventType::Done: + response_ = event.content.empty() ? chunks_ : event.content; + finished_ = true; + succeeded_ = true; + break; + case ChatEventType::Error: + response_ = "ZeroClaw error: " + event.content; + finished_ = true; + break; + case ChatEventType::Unknown: + break; + } + return {finished_, false, {}}; +} + +bool ChatStreamState::finished() const +{ + return finished_; +} + +bool ChatStreamState::succeeded() const +{ + return succeeded_; +} + +const std::string &ChatStreamState::response() const +{ + return response_; +} + +const std::string &ChatStreamState::partial_response() const +{ + return chunks_; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_stream_model.h b/projects/ZClaw/main/ui/zclaw_chat_stream_model.h new file mode 100644 index 00000000..8ca93754 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_stream_model.h @@ -0,0 +1,31 @@ +#pragma once + +#include "zclaw_chat_event.h" + +#include + +namespace zclaw { + +struct ChatStreamUpdate { + bool finished = false; + bool approval_requested = false; + ApprovalRequest approval; +}; + +class ChatStreamState { +public: + ChatStreamUpdate accept(const ChatEvent &event); + + bool finished() const; + bool succeeded() const; + const std::string &response() const; + const std::string &partial_response() const; + +private: + std::string chunks_; + std::string response_; + bool finished_ = false; + bool succeeded_ = false; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_transport.cpp b/projects/ZClaw/main/ui/zclaw_chat_transport.cpp new file mode 100644 index 00000000..b864887e --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_transport.cpp @@ -0,0 +1,29 @@ +#include "zclaw_chat_transport.h" + +#include "zclaw_webhook_transport.h" + +#include + +namespace zclaw { + +ChatTransport::ChatTransport( + std::shared_ptr cancellation, + std::shared_ptr http_client) + : cancellation_(std::move(cancellation)), + http_client_(std::move(http_client)) +{ +} + +bool ChatTransport::send( + const UiConfig &config, const std::string &message, + ApprovalHandler approval_handler, + std::function completion) const +{ + if (config.bearer_token.empty()) + return WebhookTransport(http_client_).send( + config, message, std::move(completion)); + return WebSocketTransport(cancellation_).send( + config, message, std::move(approval_handler), std::move(completion)); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_transport.h b/projects/ZClaw/main/ui/zclaw_chat_transport.h new file mode 100644 index 00000000..86b229bd --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_transport.h @@ -0,0 +1,32 @@ +#pragma once + +#include "zclaw_types.h" +#include "zclaw_websocket_transport.h" + +#include +#include +#include + +namespace zclaw { + +class HttpCancellationRegistry; +class LhvHttpClient; + +class ChatTransport { +public: + using ApprovalHandler = WebSocketTransport::ApprovalHandler; + + ChatTransport() = default; + ChatTransport(std::shared_ptr cancellation, + std::shared_ptr http_client); + + bool send(const UiConfig &config, const std::string &message, + ApprovalHandler approval_handler, + std::function completion) const; + +private: + std::shared_ptr cancellation_; + std::shared_ptr http_client_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_view.cpp b/projects/ZClaw/main/ui/zclaw_chat_view.cpp new file mode 100644 index 00000000..6d50b88c --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_view.cpp @@ -0,0 +1,206 @@ +#include "zclaw_chat_view.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_chat_layout.h" +#include "zclaw_theme.h" +#include "zclaw_widgets.h" + +#include + +namespace zclaw { +namespace { + +constexpr lv_coord_t kChatRowWidth = 296; +constexpr lv_coord_t kUserBubblePadX = 10; +constexpr lv_coord_t kUserBubblePadY = 6; +constexpr lv_coord_t kTrackY = 20; +constexpr lv_coord_t kTrackHeight = 126; + +lv_point_t measure_text(const std::string &text, const lv_font_t *font, lv_coord_t max_width) +{ + lv_point_t size{}; + lv_text_get_size(&size, text.c_str(), font, 0, 0, max_width, LV_TEXT_FLAG_NONE); + return size; +} + +} // namespace + +ChatView::~ChatView() +{ + release(); +} + +void ChatView::create(lv_obj_t *parent, const FontManager *fonts, std::string avatar_path) +{ + if (container_ || !parent || !fonts) + return; + fonts_ = fonts; + avatar_path_ = std::move(avatar_path); + widgets::box(parent, 0, 20, 320, 128, theme::kBackground); + + container_ = lv_obj_create(parent); + lv_obj_set_pos(container_, 0, 20); + lv_obj_set_size(container_, 312, 128); + lv_obj_set_style_bg_opa(container_, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(container_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(container_, 12, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(container_, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(container_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(container_, LV_DIR_VER); + lv_obj_add_flag(container_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(container_, static_cast(LV_OBJ_FLAG_SCROLL_ELASTIC | + LV_OBJ_FLAG_SCROLL_MOMENTUM | + LV_OBJ_FLAG_SCROLL_CHAIN)); + lv_obj_set_scrollbar_mode(container_, LV_SCROLLBAR_MODE_OFF); + lv_obj_add_event_cb(container_, scroll_event, LV_EVENT_SCROLL, this); + lv_obj_add_event_cb(container_, container_deleted, LV_EVENT_DELETE, this); + + append_assistant_message("Hello! I am ZClaw.\nReady to help with your device."); + + scroll_track_ = widgets::box(parent, 312, kTrackY, 3, kTrackHeight, 0x33335A, 2); + scroll_thumb_ = widgets::box(parent, 312, 28, 3, 38, 0x818CF8, 2); + lv_obj_set_style_bg_color(scroll_thumb_, lv_color_hex(0xC4B5FD), LV_PART_MAIN); + lv_obj_set_style_bg_grad_color(scroll_thumb_, lv_color_hex(0x818CF8), LV_PART_MAIN); + lv_obj_set_style_bg_grad_dir(scroll_thumb_, LV_GRAD_DIR_VER, LV_PART_MAIN); + update_scrollbar(); +} + +void ChatView::append_assistant_message(const std::string &text) +{ + if (!container_ || !fonts_) + return; + constexpr lv_coord_t bubble_width = 190; + constexpr lv_coord_t text_width = 168; + constexpr lv_coord_t pad_x = 10; + constexpr lv_coord_t pad_y = 6; + const lv_point_t text_size = measure_text(text, fonts_->font_12(), text_width); + const lv_coord_t bubble_height = assistant_bubble_height(text_size.y); + + lv_obj_t *row = make_message_row(bubble_height, LV_FLEX_ALIGN_START); + make_avatar(row); + lv_obj_t *bubble = make_bubble(row, bubble_width, bubble_height, theme::kPanel, false); + widgets::label(bubble, text, pad_x, pad_y, text_width, + bubble_height - pad_y * 2, fonts_->font_12(), theme::kWhite); + scroll_to_bottom(); +} + +void ChatView::append_user_message(const std::string &text) +{ + if (!container_ || !fonts_) + return; + constexpr lv_coord_t text_max_width = 198 - kUserBubblePadX * 2; + const lv_point_t text_size = measure_text(text, fonts_->font_12(), text_max_width); + const ChatBubbleLayout layout = user_bubble_layout(text_size.x, text_size.y); + const lv_coord_t bubble_width = layout.width; + const lv_coord_t bubble_height = layout.height; + + lv_obj_t *row = make_message_row(bubble_height, LV_FLEX_ALIGN_END); + lv_obj_t *bubble = make_bubble(row, bubble_width, bubble_height, theme::kIndigo, true); + widgets::label(bubble, text, kUserBubblePadX, kUserBubblePadY, + bubble_width - kUserBubblePadX * 2, + bubble_height - kUserBubblePadY * 2, + fonts_->font_12(), theme::kWhite); + scroll_to_bottom(); +} + +void ChatView::scroll(int delta) +{ + if (!container_) + return; + const int32_t top = lv_obj_get_scroll_top(container_); + const int32_t bottom = lv_obj_get_scroll_bottom(container_); + const int32_t applied = chat_scroll_delta(top, bottom, delta); + if (applied == 0) + return; + lv_obj_scroll_by(container_, 0, applied, LV_ANIM_ON); + update_scrollbar(); +} + +void ChatView::scroll_event(lv_event_t *event) +{ + ChatView *view = static_cast(lv_event_get_user_data(event)); + if (view) + view->update_scrollbar(); +} + +void ChatView::container_deleted(lv_event_t *event) +{ + ChatView *view = static_cast(lv_event_get_user_data(event)); + if (view) + view->clear_borrowed_objects(); +} + +lv_obj_t *ChatView::make_message_row(lv_coord_t height, lv_flex_align_t alignment) const +{ + lv_obj_t *row = lv_obj_create(container_); + lv_obj_set_size(row, kChatRowWidth, height); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(row, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, alignment, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(row, static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + return row; +} + +lv_obj_t *ChatView::make_bubble(lv_obj_t *parent, lv_coord_t width, lv_coord_t height, + uint32_t color, bool user) const +{ + lv_obj_t *bubble = widgets::box(parent, 0, 0, width, height, color, 10); + if (user) { + lv_obj_set_style_bg_color(bubble, lv_color_hex(theme::kPurple), LV_PART_MAIN); + lv_obj_set_style_bg_grad_color(bubble, lv_color_hex(theme::kIndigo), LV_PART_MAIN); + lv_obj_set_style_bg_grad_dir(bubble, LV_GRAD_DIR_VER, LV_PART_MAIN); + } + return bubble; +} + +void ChatView::make_avatar(lv_obj_t *parent) const +{ + lv_obj_t *avatar = lv_img_create(parent); + lv_img_set_src(avatar, avatar_path_.c_str()); + lv_obj_set_pos(avatar, 0, 0); + lv_obj_clear_flag(avatar, static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); +} + +void ChatView::scroll_to_bottom() +{ + lv_obj_scroll_to_y(container_, LV_COORD_MAX, LV_ANIM_ON); + update_scrollbar(); +} + +void ChatView::update_scrollbar() +{ + if (!container_ || !scroll_track_ || !scroll_thumb_) + return; + const int32_t top = lv_obj_get_scroll_top(container_); + const int32_t bottom = lv_obj_get_scroll_bottom(container_); + const ChatScrollbarLayout layout = chat_scrollbar_layout(top, bottom); + lv_obj_set_pos(scroll_thumb_, 312, layout.y); + lv_obj_set_size(scroll_thumb_, 3, layout.height); +} + +void ChatView::release() +{ + if (container_) { + lv_obj_remove_event_cb_with_user_data(container_, scroll_event, this); + lv_obj_remove_event_cb_with_user_data(container_, container_deleted, this); + } + clear_borrowed_objects(); +} + +void ChatView::clear_borrowed_objects() +{ + fonts_ = nullptr; + container_ = nullptr; + scroll_track_ = nullptr; + scroll_thumb_ = nullptr; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_view.h b/projects/ZClaw/main/ui/zclaw_chat_view.h new file mode 100644 index 00000000..345df4f8 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_view.h @@ -0,0 +1,43 @@ +#pragma once + +#include "lvgl/lvgl.h" + +#include + +namespace zclaw { + +class FontManager; + +class ChatView { +public: + ChatView() = default; + ~ChatView(); + ChatView(const ChatView &) = delete; + ChatView &operator=(const ChatView &) = delete; + + void create(lv_obj_t *parent, const FontManager *fonts, std::string avatar_path); + void append_assistant_message(const std::string &text); + void append_user_message(const std::string &text); + void scroll(int delta); + +private: + static void scroll_event(lv_event_t *event); + static void container_deleted(lv_event_t *event); + + lv_obj_t *make_message_row(lv_coord_t height, lv_flex_align_t alignment) const; + lv_obj_t *make_bubble(lv_obj_t *parent, lv_coord_t width, lv_coord_t height, + uint32_t color, bool user) const; + void make_avatar(lv_obj_t *parent) const; + void scroll_to_bottom(); + void update_scrollbar(); + void release(); + void clear_borrowed_objects(); + + const FontManager *fonts_ = nullptr; + std::string avatar_path_; + lv_obj_t *container_ = nullptr; + lv_obj_t *scroll_track_ = nullptr; + lv_obj_t *scroll_thumb_ = nullptr; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_workflow.cpp b/projects/ZClaw/main/ui/zclaw_chat_workflow.cpp new file mode 100644 index 00000000..ba7893bb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_workflow.cpp @@ -0,0 +1,69 @@ +#include "zclaw_chat_workflow.h" + +#include "zclaw_approval_coordinator.h" +#include "zclaw_async_service.h" +#include "zclaw_chat_view.h" +#include "zclaw_runtime_state.h" +#include "zclaw_ui_config_manager.h" + +#include + +namespace zclaw { + +ChatWorkflow::ChatWorkflow(UiConfigManager &config, ChatView &chat, + AsyncService &async_service, + ApprovalCoordinator &approvals, OpenSetup open_setup) + : config_(config), chat_(chat), async_service_(async_service), + approvals_(approvals), open_setup_(std::move(open_setup)), + lifetime_(std::make_shared>(this)) +{ +} + +ChatWorkflow::~ChatWorkflow() +{ + lifetime_->invalidate(); +} + +void ChatWorkflow::submit(const std::string &message) +{ + if (message.empty()) + return; + chat_.append_user_message(message); + request_reply(message); +} + +void ChatWorkflow::request_reply(const std::string &message) +{ + if (request_in_flight_) { + chat_.append_assistant_message("I am still waiting for ZeroClaw."); + return; + } + if (RuntimeState::first_run_needed(config_.config())) { + chat_.append_assistant_message("Run quickstart before chatting."); + if (open_setup_) + open_setup_(); + return; + } + + request_in_flight_ = true; + chat_.append_assistant_message("Thinking..."); + const std::shared_ptr> lifetime = lifetime_; + if (!async_service_.send_chat( + config_.config(), message, approvals_.handler(), + [lifetime](OperationResult result) { + lifetime->invoke([&result](ChatWorkflow &workflow) { + workflow.finish_request(std::move(result.text)); + }); + })) { + request_in_flight_ = false; + chat_.append_assistant_message("Could not start webhook request."); + } +} + +void ChatWorkflow::finish_request(std::string text) +{ + request_in_flight_ = false; + chat_.append_assistant_message(text); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_chat_workflow.h b/projects/ZClaw/main/ui/zclaw_chat_workflow.h new file mode 100644 index 00000000..8e155781 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_chat_workflow.h @@ -0,0 +1,40 @@ +#pragma once + +#include "zclaw_callback_lifetime.h" + +#include +#include +#include + +namespace zclaw { + +class ApprovalCoordinator; +class AsyncService; +class ChatView; +class UiConfigManager; + +class ChatWorkflow { +public: + using OpenSetup = std::function; + + ChatWorkflow(UiConfigManager &config, ChatView &chat, + AsyncService &async_service, ApprovalCoordinator &approvals, + OpenSetup open_setup); + ~ChatWorkflow(); + + void submit(const std::string &message); + +private: + void request_reply(const std::string &message); + void finish_request(std::string text); + + UiConfigManager &config_; + ChatView &chat_; + AsyncService &async_service_; + ApprovalCoordinator &approvals_; + OpenSetup open_setup_; + bool request_in_flight_ = false; + std::shared_ptr> lifetime_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_cli_config_model.cpp b/projects/ZClaw/main/ui/zclaw_cli_config_model.cpp new file mode 100644 index 00000000..14f5fca3 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_cli_config_model.cpp @@ -0,0 +1,35 @@ +#include "zclaw_cli_config_model.h" + +namespace zclaw { + +CliConfigPlan make_cli_config_plan(const UiConfig &config, + const ProviderConfig &provider) +{ + CliConfigPlan plan; + plan.agent_alias = config.agent_alias.empty() ? "zclaw" : config.agent_alias; + plan.webhook_url = "http://127.0.0.1:42617/webhook"; + + const std::string provider_prefix = + "providers.models." + provider.family + "." + provider.alias; + const std::string provider_reference = provider.family + "." + provider.alias; + plan.initial_settings = { + {"gateway.host", "127.0.0.1"}, + {"gateway.port", "42617"}, + {"gateway.require_pairing", "true"}, + {"gateway.request_timeout_secs", "180"}, + {"gateway.long_running_request_timeout_secs", "600"}, + {provider_prefix + ".model", provider.model}, + {provider_prefix + ".uri", provider.uri}, + {provider_prefix + ".api_key", provider.api_key}, + }; + const std::string agent_prefix = "agents." + plan.agent_alias; + plan.agent_settings = { + {agent_prefix + ".enabled", "true"}, + {agent_prefix + ".model_provider", provider_reference}, + {agent_prefix + ".risk_profile", "default"}, + {"onboard_state.quickstart_completed", "true"}, + }; + return plan; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_cli_config_model.h b/projects/ZClaw/main/ui/zclaw_cli_config_model.h new file mode 100644 index 00000000..02b2d603 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_cli_config_model.h @@ -0,0 +1,25 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +struct CliConfigEntry { + std::string path; + std::string value; +}; + +struct CliConfigPlan { + std::string agent_alias; + std::string webhook_url; + std::vector initial_settings; + std::vector agent_settings; +}; + +CliConfigPlan make_cli_config_plan(const UiConfig &config, + const ProviderConfig &provider); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_cli_output_model.cpp b/projects/ZClaw/main/ui/zclaw_cli_output_model.cpp new file mode 100644 index 00000000..bdfe1bb2 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_cli_output_model.cpp @@ -0,0 +1,40 @@ +#include "zclaw_cli_output_model.h" + +#include "zclaw_text.h" + +#include + +namespace zclaw { +namespace { + +bool contains(const std::string &value, const char *marker) +{ + return value.find(marker) != std::string::npos; +} + +} // namespace + +bool cli_service_command_succeeded(bool command_ok, const std::string &output, + CliServiceCommand command) +{ + if (command_ok) + return true; + if (contains(output, "already")) + return true; + if (command == CliServiceCommand::Install) + return contains(output, "exists"); + return contains(output, "active"); +} + +bool cli_agent_list_contains(const std::string &output, const std::string &alias) +{ + std::istringstream lines(output); + std::string line; + while (std::getline(lines, line)) { + if (trim_ascii_whitespace(line) == alias) + return true; + } + return false; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_cli_output_model.h b/projects/ZClaw/main/ui/zclaw_cli_output_model.h new file mode 100644 index 00000000..09ff5cfb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_cli_output_model.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace zclaw { + +enum class CliServiceCommand { + Install, + Start, +}; + +bool cli_service_command_succeeded(bool command_ok, const std::string &output, + CliServiceCommand command); +bool cli_agent_list_contains(const std::string &output, const std::string &alias); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_cli_service.cpp b/projects/ZClaw/main/ui/zclaw_cli_service.cpp new file mode 100644 index 00000000..a9c7b08b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_cli_service.cpp @@ -0,0 +1,139 @@ +#include "zclaw_cli_service.h" + +#include "zclaw_cli_config_model.h" +#include "zclaw_cli_output_model.h" +#include "zclaw_paths.h" +#include "zclaw_process_executor.h" +#include "zclaw_quickstart_model.h" +#include "zclaw_risk_profile_store.h" + +#include + +#include +#include + +namespace zclaw { +namespace { + +CliService::Command zeroclaw_command(std::initializer_list arguments) +{ + CliService::Command command = {paths::zeroclaw_binary()}; + command.insert(command.end(), arguments.begin(), arguments.end()); + return command; +} + +} // namespace + +CliService::CliService() + : CliService([](const Command &command) { + ProcessExecutor executor; + return executor.run(command); + }, + [](unsigned int seconds) { ::sleep(seconds); }) +{ +} + +CliService::CliService(CommandRunner command_runner, Sleeper sleeper) + : command_runner_(std::move(command_runner)), sleeper_(std::move(sleeper)) +{ +} + +bool CliService::apply_config(UiConfig *config, const ProviderConfig &provider, + std::string *error) const +{ + if (!config) { + if (error) + *error = "Missing UI configuration."; + return false; + } + const CliConfigPlan plan = make_cli_config_plan(*config, provider); + for (const CliConfigEntry &setting : plan.initial_settings) { + if (!config_set(setting.path, setting.value, error)) + return false; + } + + if (!ensure_agent(plan.agent_alias, error) || + !RiskProfileStore().ensure_default(paths::zeroclaw_config(), error)) + return false; + for (const CliConfigEntry &setting : plan.agent_settings) { + if (!config_set(setting.path, setting.value, error)) + return false; + } + + config->agent_alias = plan.agent_alias; + config->webhook_url = plan.webhook_url; + return true; +} + +bool CliService::start_service(std::string *error) const +{ + CommandResult result = command_runner_(zeroclaw_command({"service", "install"})); + if (!cli_service_command_succeeded(result.ok(), result.output, + CliServiceCommand::Install)) { + if (error) + *error = "service install failed:\n" + result.output; + return false; + } + + result = command_runner_(zeroclaw_command({"service", "start"})); + if (!cli_service_command_succeeded(result.ok(), result.output, + CliServiceCommand::Start)) { + if (error) + *error = "service start failed:\n" + result.output; + return false; + } + return true; +} + +std::string CliService::generate_pairing_code() const +{ + for (int attempt = 0; attempt < 12; ++attempt) { + const CommandResult result = command_runner_( + zeroclaw_command({"gateway", "get-paircode", "--new"})); + if (result.ok()) { + const std::string code = extract_pairing_code(result.output); + if (!code.empty()) + return code; + } + if (attempt + 1 < 12 && sleeper_) + sleeper_(1); + } + return ""; +} + +bool CliService::config_set(const std::string &path, const std::string &value, + std::string *error) const +{ + Command command = zeroclaw_command({"config", "set", "--no-interactive"}); + command.push_back(path); + command.push_back(value); + const CommandResult result = command_runner_(command); + if (result.ok()) + return true; + if (error) + *error = "config set failed: " + path + "\n" + result.output; + return false; +} + +bool CliService::ensure_agent(const std::string &alias, std::string *error) const +{ + CommandResult result = command_runner_(zeroclaw_command({"agents", "list"})); + if (!result.ok()) { + if (error) + *error = "agents list failed\n" + result.output; + return false; + } + if (cli_agent_list_contains(result.output, alias)) + return true; + Command command = zeroclaw_command({"agents", "create"}); + command.push_back(alias); + result = command_runner_(command); + if (!result.ok()) { + if (error) + *error = "agent creation failed\n" + result.output; + return false; + } + return true; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_cli_service.h b/projects/ZClaw/main/ui/zclaw_cli_service.h new file mode 100644 index 00000000..e8fec1b8 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_cli_service.h @@ -0,0 +1,35 @@ +#pragma once + +#include "zclaw_process_runner.h" +#include "zclaw_types.h" + +#include +#include +#include + +namespace zclaw { + +class CliService { +public: + using Command = std::vector; + using CommandRunner = std::function; + using Sleeper = std::function; + + CliService(); + CliService(CommandRunner command_runner, Sleeper sleeper); + + bool apply_config(UiConfig *config, const ProviderConfig &provider, + std::string *error) const; + bool start_service(std::string *error) const; + std::string generate_pairing_code() const; + +private: + bool config_set(const std::string &path, const std::string &value, + std::string *error) const; + bool ensure_agent(const std::string &alias, std::string *error) const; + + CommandRunner command_runner_; + Sleeper sleeper_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_client.cpp b/projects/ZClaw/main/ui/zclaw_client.cpp deleted file mode 100644 index e3f025a8..00000000 --- a/projects/ZClaw/main/ui/zclaw_client.cpp +++ /dev/null @@ -1,811 +0,0 @@ -#include "zclaw_client.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -constexpr const char *ZEROCLAW_RELEASE_URL = - "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.8.2/" - "zeroclaw-aarch64-unknown-linux-gnu.tar.gz"; - -struct HttpUrl { - std::string base; - std::string path; -}; - -std::string trim(const std::string &value) -{ - const size_t start = value.find_first_not_of(" \t\r\n"); - if (start == std::string::npos) - return ""; - const size_t end = value.find_last_not_of(" \t\r\n"); - return value.substr(start, end - start + 1); -} - -std::string shell_quote(const std::string &value) -{ - std::string out = "'"; - for (char ch : value) { - if (ch == '\'') - out += "'\\''"; - else - out += ch; - } - out += "'"; - return out; -} - -std::string json_escape(const std::string &value) -{ - std::string out; - for (unsigned char ch : value) { - switch (ch) { - case '\\': - out += "\\\\"; - break; - case '"': - out += "\\\""; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", ch); - out += buf; - } else { - out += (char)ch; - } - } - } - return out; -} - -std::string json_unescape(const std::string &value) -{ - std::string out; - for (size_t i = 0; i < value.size(); ++i) { - if (value[i] != '\\' || i + 1 >= value.size()) { - out += value[i]; - continue; - } - const char next = value[++i]; - if (next == 'n') - out += '\n'; - else if (next == 'r') - out += '\r'; - else if (next == 't') - out += '\t'; - else - out += next; - } - return out; -} - -std::string json_string_field(const std::string &json, const std::string &field) -{ - const std::string needle = "\"" + field + "\""; - size_t pos = json.find(needle); - if (pos == std::string::npos) - return ""; - pos = json.find(':', pos + needle.size()); - if (pos == std::string::npos) - return ""; - pos = json.find('"', pos + 1); - if (pos == std::string::npos) - return ""; - std::string raw; - bool escaped = false; - for (++pos; pos < json.size(); ++pos) { - char ch = json[pos]; - if (escaped) { - raw += '\\'; - raw += ch; - escaped = false; - } else if (ch == '\\') { - escaped = true; - } else if (ch == '"') { - break; - } else { - raw += ch; - } - } - return json_unescape(raw); -} - -int json_int_field(const std::string &json, const std::string &field, int fallback) -{ - const std::string needle = "\"" + field + "\""; - size_t pos = json.find(needle); - if (pos == std::string::npos) - return fallback; - pos = json.find(':', pos + needle.size()); - if (pos == std::string::npos) - return fallback; - ++pos; - while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) - ++pos; - int value = 0; - bool any = false; - while (pos < json.size() && json[pos] >= '0' && json[pos] <= '9') { - any = true; - value = value * 10 + (json[pos] - '0'); - ++pos; - } - return any ? value : fallback; -} - -std::string url_encode(const std::string &value) -{ - static constexpr char hex[] = "0123456789ABCDEF"; - std::string out; - for (unsigned char ch : value) { - if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') { - out += (char)ch; - } else { - out += '%'; - out += hex[ch >> 4]; - out += hex[ch & 0x0F]; - } - } - return out; -} - -bool split_http_url(const std::string &url, HttpUrl *out) -{ - const size_t scheme = url.find("://"); - if (scheme == std::string::npos) - return false; - const size_t authority = scheme + 3; - const size_t path = url.find('/', authority); - if (path == std::string::npos) { - out->base = url; - out->path = "/"; - } else { - out->base = url.substr(0, path); - out->path = url.substr(path); - if (out->path.empty()) - out->path = "/"; - } - return out->base.rfind("http://", 0) == 0 || out->base.rfind("https://", 0) == 0; -} - -void configure_http_client(httplib::Client &client, int read_timeout_secs) -{ - client.set_connection_timeout(20); - client.set_read_timeout(read_timeout_secs); - client.set_write_timeout(30); - client.set_follow_location(true); -} - -bool file_exists(const std::string &path) -{ - struct stat st {}; - return ::stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode); -} - -bool dir_exists(const std::string &path) -{ - struct stat st {}; - return ::stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); -} - -void ensure_zeroclaw_dir() -{ - ::mkdir(ZClawClient::zeroclaw_dir().c_str(), 0700); -} - -void ensure_zeroclaw_bin_dir() -{ - ensure_zeroclaw_dir(); - ::mkdir((ZClawClient::zeroclaw_dir() + "/bin").c_str(), 0700); -} - -std::string run_command_capture(const std::string &cmd, int *status = nullptr) -{ - std::string output; - FILE *pipe = ::popen((cmd + " 2>&1").c_str(), "r"); - if (!pipe) { - if (status) - *status = -1; - return "failed to start command"; - } - char buffer[256]; - while (std::fgets(buffer, sizeof(buffer), pipe)) - output += buffer; - int rc = ::pclose(pipe); - if (status) - *status = rc; - return trim(output); -} - -std::string gateway_http_base(const UiConfig &config) -{ - std::string url = config.webhook_url.empty() ? "http://127.0.0.1:42617/webhook" : config.webhook_url; - const size_t scheme = url.find("://"); - const size_t path = scheme == std::string::npos ? url.find('/') : url.find('/', scheme + 3); - if (path != std::string::npos) - url = url.substr(0, path); - return url; -} - -std::string gateway_ws_url(const UiConfig &config) -{ - std::string base = gateway_http_base(config); - if (base.rfind("https://", 0) == 0) - base.replace(0, 5, "ws"); - else if (base.rfind("http://", 0) == 0) - base.replace(0, 4, "ws"); - return base + "/ws/chat?agent=" + url_encode(config.agent_alias) + - "&token=" + url_encode(config.bearer_token) + - "&session_id=zclaw-ui&name=ZClaw"; -} - -std::string webhook_endpoint(const UiConfig &config) -{ - std::string url = config.webhook_url.empty() ? "http://127.0.0.1:42617/webhook" : config.webhook_url; - if (url.find('?') == std::string::npos) - url += "?agent=" + config.agent_alias; - return url; -} - -std::string zeroclaw_cli() -{ - return shell_quote(ZClawClient::zeroclaw_bin_path()); -} - -void report_setup_progress(const ZClawClient::SetupProgressHandler &handler, const char *status, - int percent, size_t downloaded = 0, size_t total = 0, double speed = 0.0) -{ - if (!handler) - return; - ZClawSetupProgress progress; - progress.status = status ? status : ""; - progress.percent = percent; - progress.downloaded_bytes = downloaded; - progress.total_bytes = total; - progress.bytes_per_second = speed; - handler(progress); -} - -bool download_file(const std::string &url_text, const std::string &output_path, std::string *error, - const ZClawClient::SetupProgressHandler &progress_handler) -{ - HttpUrl url; - if (!split_http_url(url_text, &url)) { - if (error) - *error = "Invalid download URL."; - return false; - } - - std::ofstream out(output_path, std::ios::binary | std::ios::trunc); - if (!out) { - if (error) - *error = "Cannot write " + output_path; - return false; - } - - httplib::Client client(url.base); - configure_http_client(client, 300); - auto last_time = std::chrono::steady_clock::now(); - size_t last_bytes = 0; - auto res = client.Get(url.path, [&](const char *data, size_t len) { - out.write(data, (std::streamsize)len); - return (bool)out; - }, [&](size_t current, size_t total) { - const auto now = std::chrono::steady_clock::now(); - const double seconds = std::chrono::duration(now - last_time).count(); - if (seconds >= 0.2 || current == total) { - const double speed = seconds > 0.0 ? (double)(current - last_bytes) / seconds : 0.0; - const int download_percent = total > 0 ? (int)((current * 60) / total) : 0; - report_setup_progress(progress_handler, "Downloading ZeroClaw", 5 + download_percent, - current, total, speed); - last_time = now; - last_bytes = current; - } - return true; - }); - out.close(); - if (!res) { - if (error) - *error = "ZeroClaw download failed.\n" + httplib::to_string(res.error()); - return false; - } - if (res->status < 200 || res->status >= 300) { - if (error) - *error = "ZeroClaw download failed.\nHTTP " + std::to_string(res->status); - return false; - } - return true; -} - -bool ensure_zeroclaw_binary(std::string *error, - const ZClawClient::SetupProgressHandler &progress_handler) -{ - report_setup_progress(progress_handler, "Checking ZeroClaw", 3); - ensure_zeroclaw_bin_dir(); - if (file_exists(ZClawClient::zeroclaw_bin_path()) && - ::access(ZClawClient::zeroclaw_bin_path().c_str(), X_OK) == 0) { - report_setup_progress(progress_handler, "ZeroClaw is ready", 65); - return true; - } - - const std::string tmp_prefix = ZClawClient::zeroclaw_dir() + "/zeroclaw-install.XXXXXX"; - int rc = 0; - const std::string tmp_dir = run_command_capture("mktemp -d " + shell_quote(tmp_prefix), &rc); - if (rc != 0 || tmp_dir.empty()) { - if (error) - *error = "Cannot create ZeroClaw install temp dir."; - return false; - } - - const std::string archive_path = tmp_dir + "/zeroclaw.tar.gz"; - if (!download_file(ZEROCLAW_RELEASE_URL, archive_path, error, progress_handler)) { - run_command_capture("rm -rf " + shell_quote(tmp_dir)); - return false; - } - - report_setup_progress(progress_handler, "Installing ZeroClaw", 67); - const std::string cmd = - "tar -xzf " + shell_quote(archive_path) + " -C " + shell_quote(tmp_dir) + " && " - "found=$(find " + shell_quote(tmp_dir) + " -type f -name zeroclaw | head -n 1) && " - "test -n \"$found\" && " - "cp \"$found\" " + shell_quote(ZClawClient::zeroclaw_bin_path()) + " && " - "chmod 700 " + shell_quote(ZClawClient::zeroclaw_bin_path()) + " && " - "rm -rf " + shell_quote(tmp_dir); - - const std::string out = run_command_capture(cmd, &rc); - if (rc != 0 || !file_exists(ZClawClient::zeroclaw_bin_path())) { - run_command_capture("rm -rf " + shell_quote(tmp_dir)); - if (error) - *error = "ZeroClaw download failed.\n" + out; - return false; - } - return true; -} - -bool config_set(const std::string &path, const std::string &value, std::string *error) -{ - const std::string cmd = zeroclaw_cli() + " config set --no-interactive " + - shell_quote(path) + " " + shell_quote(value); - int rc = 0; - const std::string out = run_command_capture(cmd, &rc); - if (rc != 0) { - if (error) - *error = "config set failed: " + path + "\n" + out; - return false; - } - return true; -} - -bool ensure_default_risk_profile(std::string *error) -{ - const std::string path = ZClawClient::zeroclaw_config_path(); - std::ifstream input(path, std::ios::binary); - if (!input) { - if (error) - *error = "Cannot read ZeroClaw config."; - return false; - } - - std::ostringstream buffer; - buffer << input.rdbuf(); - const std::string config = buffer.str(); - std::istringstream lines(config); - std::string line; - while (std::getline(lines, line)) { - if (trim(line) == "[risk_profiles.default]") - return true; - } - - const std::string tmp_path = path + ".zclaw.tmp"; - std::ofstream output(tmp_path, std::ios::binary | std::ios::trunc); - if (!output) { - if (error) - *error = "Cannot update ZeroClaw risk profile."; - return false; - } - output << config; - if (!config.empty() && config.back() != '\n') - output << '\n'; - output << "\n[risk_profiles.default]\n"; - output.close(); - if (!output) { - ::unlink(tmp_path.c_str()); - if (error) - *error = "Cannot write ZeroClaw risk profile."; - return false; - } - - struct stat st {}; - const mode_t mode = ::stat(path.c_str(), &st) == 0 ? st.st_mode & 0777 : 0600; - ::chmod(tmp_path.c_str(), mode); - if (::rename(tmp_path.c_str(), path.c_str()) != 0) { - ::unlink(tmp_path.c_str()); - if (error) - *error = "Cannot install ZeroClaw risk profile."; - return false; - } - return true; -} - -bool ensure_agent(const std::string &alias, std::string *error) -{ - int rc = 0; - std::string out = run_command_capture(zeroclaw_cli() + " agents list", &rc); - if (rc != 0) { - if (error) - *error = "agents list failed\n" + out; - return false; - } - std::istringstream lines(out); - std::string line; - while (std::getline(lines, line)) { - if (trim(line) == alias) - return true; - } - out = run_command_capture(zeroclaw_cli() + " agents create " + shell_quote(alias), &rc); - if (rc != 0) { - if (error) - *error = "agent creation failed\n" + out; - return false; - } - return true; -} - -bool apply_quickstart_config(UiConfig *config, ProviderConfig provider, std::string *error) -{ - if (provider.alias.empty()) - provider.alias = "zclaw"; - if (provider.family.empty()) - provider.family = "openai"; - if (provider.model.empty()) - provider.model = "gpt-4.1-mini"; - if (provider.uri.empty() && provider.family == "ollama") - provider.uri = "http://127.0.0.1:11434"; - - const std::string provider_prefix = "providers.models." + provider.family + "." + provider.alias; - const std::string provider_ref = provider.family + "." + provider.alias; - const std::string agent = config->agent_alias.empty() ? "zclaw" : config->agent_alias; - - if (!config_set("gateway.host", "127.0.0.1", error) || - !config_set("gateway.port", "42617", error) || - !config_set("gateway.require_pairing", "true", error) || - !config_set("gateway.request_timeout_secs", "180", error) || - !config_set("gateway.long_running_request_timeout_secs", "600", error) || - !config_set(provider_prefix + ".model", provider.model, error)) - return false; - if (!config_set(provider_prefix + ".uri", provider.uri, error)) - return false; - if (!config_set(provider_prefix + ".api_key", provider.api_key, error)) - return false; - - if (!ensure_agent(agent, error) || - !ensure_default_risk_profile(error) || - !config_set("agents." + agent + ".enabled", "true", error) || - !config_set("agents." + agent + ".model_provider", provider_ref, error) || - !config_set("agents." + agent + ".risk_profile", "default", error) || - !config_set("onboard_state.quickstart_completed", "true", error)) - return false; - - config->agent_alias = agent; - config->webhook_url = "http://127.0.0.1:42617/webhook"; - return true; -} - -std::string extract_pairing_code(const std::string &text) -{ - std::string digits; - for (char ch : text) { - if (ch >= '0' && ch <= '9') { - digits += ch; - if (digits.size() >= 6) - return digits; - } else { - digits.clear(); - } - } - return ""; -} - -bool start_zeroclaw_service(std::string *error) -{ - int rc = 0; - std::string out = run_command_capture(zeroclaw_cli() + " service install", &rc); - if (rc != 0 && out.find("already") == std::string::npos && out.find("exists") == std::string::npos) { - if (error) - *error = "service install failed:\n" + out; - return false; - } - - out = run_command_capture(zeroclaw_cli() + " service start", &rc); - if (rc != 0 && out.find("already") == std::string::npos && out.find("active") == std::string::npos) { - if (error) - *error = "service start failed:\n" + out; - return false; - } - return true; -} - -std::string generate_pairing_code() -{ - for (int attempt = 0; attempt < 12; ++attempt) { - int rc = 0; - const std::string out = run_command_capture(zeroclaw_cli() + " gateway get-paircode --new", &rc); - if (rc == 0) { - const std::string code = extract_pairing_code(out); - if (!code.empty()) - return code; - } - ::sleep(1); - } - return ""; -} - -} // namespace - -std::string ZClawClient::home_dir() -{ - const char *home = std::getenv("HOME"); - if (home && home[0]) - return home; - return "/home/nihao"; -} - -std::string ZClawClient::zeroclaw_dir() -{ - return home_dir() + "/.zeroclaw"; -} - -std::string ZClawClient::providers_config_path() -{ - return zeroclaw_dir() + "/zclaw_providers.tsv"; -} - -std::string ZClawClient::ui_config_path() -{ - return zeroclaw_dir() + "/zclaw_ui.tsv"; -} - -std::string ZClawClient::zeroclaw_config_path() -{ - return zeroclaw_dir() + "/config.toml"; -} - -std::string ZClawClient::zeroclaw_bin_path() -{ - return zeroclaw_dir() + "/bin/zeroclaw"; -} - -bool ZClawClient::first_run_needed(const UiConfig &config) -{ - return !dir_exists(zeroclaw_dir()) || !file_exists(zeroclaw_config_path()) || - !config.setup_complete || config.bearer_token.empty(); -} - -void ZClawClient::ensure_storage_dir() -{ - ensure_zeroclaw_dir(); -} - -bool ZClawClient::has_internet_connection(std::string *error) -{ - static constexpr const char *PROBES[] = { - "https://www.cloudflare.com/cdn-cgi/trace", - "https://www.msftconnecttest.com/connecttest.txt", - "https://github.com/", - }; - - std::string last_error; - for (const char *probe : PROBES) { - HttpUrl url; - if (!split_http_url(probe, &url)) - continue; - httplib::Client client(url.base); - client.set_connection_timeout(2); - client.set_read_timeout(2); - client.set_write_timeout(2); - client.set_follow_location(true); - auto res = client.Get(url.path); - if (res) - return true; - last_error = httplib::to_string(res.error()); - } - if (error) - *error = last_error.empty() ? "No public endpoint responded." : last_error; - return false; -} - -ZClawClientResult ZClawClient::pair_with_code(UiConfig config, const std::string &code) -{ - ZClawClientResult result; - result.config = config; - HttpUrl url; - if (!split_http_url(gateway_http_base(config) + "/pair", &url)) { - result.text = "Pairing request failed.\nInvalid URL."; - return result; - } - httplib::Headers headers = {{"X-Pairing-Code", code}}; - httplib::Client client(url.base); - configure_http_client(client, 30); - auto res = client.Post(url.path, headers, "", "application/octet-stream"); - if (!res) { - result.text = "Pairing request failed.\n" + httplib::to_string(res.error()); - return result; - } - const std::string body = res->body; - if (res->status < 200 || res->status >= 300) { - result.text = "Pairing request failed.\nHTTP " + std::to_string(res->status) + "\n" + body; - return result; - } - const std::string token = json_string_field(body, "token"); - if (!token.empty()) { - result.config.bearer_token = token; - result.text = "Pairing complete.\nWS approvals enabled."; - result.ok = true; - return result; - } - const std::string error = json_string_field(body, "error"); - result.text = error.empty() ? "Pairing failed.\n" + body : "Pairing failed: " + error; - return result; -} - -ZClawClientResult ZClawClient::send_chat(const UiConfig &config, const std::string &message, - const ApprovalHandler &approval_handler) -{ - ZClawClientResult result; - result.config = config; - - if (config.bearer_token.empty()) { - const std::string idempotency = "zclaw-ui-" + std::to_string((unsigned long long)std::time(nullptr)); - const std::string payload = "{\"message\":\"" + json_escape(message) + "\"}"; - HttpUrl url; - if (!split_http_url(webhook_endpoint(config), &url)) { - result.text = "Webhook request failed.\nInvalid URL."; - return result; - } - httplib::Headers headers = {{"X-Session-Id", "zclaw-ui"}, {"X-Idempotency-Key", idempotency}}; - if (!config.webhook_secret.empty()) - headers.emplace("X-Webhook-Secret", config.webhook_secret); - httplib::Client client(url.base); - configure_http_client(client, 180); - auto res = client.Post(url.path, headers, payload, "application/json"); - if (!res) { - result.text = "Webhook request failed.\n" + httplib::to_string(res.error()); - return result; - } - const std::string body = res->body; - if (res->status < 200 || res->status >= 300) { - result.text = "Webhook request failed.\nHTTP " + std::to_string(res->status) + "\n" + body; - return result; - } - const std::string response = json_string_field(body, "response"); - const std::string error = json_string_field(body, "error"); - result.text = !response.empty() ? response : (!error.empty() ? "ZeroClaw error: " + error : body); - result.ok = true; - return result; - } - - httplib::Headers headers = {{"Sec-WebSocket-Protocol", "zeroclaw.v1"}}; - httplib::ws::WebSocketClient ws(gateway_ws_url(config), headers); - ws.set_connection_timeout(15); - ws.set_read_timeout(900); - ws.set_write_timeout(30); - if (!ws.is_valid()) { - result.text = "WS chat failed.\nInvalid WebSocket URL."; - return result; - } - if (!ws.connect()) { - result.text = "WS chat failed.\nCould not connect to ZeroClaw."; - return result; - } - if (!ws.send("{\"type\":\"message\",\"content\":\"" + json_escape(message) + "\"}")) { - result.text = "WS chat failed.\nCould not send message."; - return result; - } - - std::string chunks; - std::string final_response; - const time_t deadline = std::time(nullptr) + 900; - while (std::time(nullptr) < deadline) { - std::string event_json; - const httplib::ws::ReadResult read = ws.read(event_json); - if (read == httplib::ws::Fail) - break; - if (read != httplib::ws::Text || event_json.empty()) - continue; - - const std::string type = json_string_field(event_json, "type"); - if (type == "chunk") { - chunks += json_string_field(event_json, "content"); - } else if (type == "approval_request") { - ZClawApprovalRequest request; - request.request_id = json_string_field(event_json, "request_id"); - request.tool = json_string_field(event_json, "tool"); - request.summary = json_string_field(event_json, "arguments_summary"); - request.timeout_secs = json_int_field(event_json, "timeout_secs", 120); - const std::string decision = approval_handler ? approval_handler(request) : "deny"; - const std::string response = - "{\"type\":\"approval_response\",\"request_id\":\"" + json_escape(request.request_id) + - "\",\"decision\":\"" + json_escape(decision.empty() ? "deny" : decision) + "\"}"; - if (!ws.send(response)) { - result.text = "WS chat failed.\nCould not send approval."; - ws.close(); - return result; - } - } else if (type == "done") { - final_response = json_string_field(event_json, "full_response"); - if (final_response.empty()) - final_response = chunks; - break; - } else if (type == "error") { - final_response = "ZeroClaw error: " + json_string_field(event_json, "message"); - break; - } - } - - ws.close(); - result.text = !final_response.empty() ? final_response : - (chunks.empty() ? "ZeroClaw returned an empty WS response." : chunks); - result.ok = true; - return result; -} - -ZClawClientResult ZClawClient::run_setup(UiConfig config, const ProviderConfig &provider, - const SetupProgressHandler &progress_handler) -{ - ZClawClientResult result; - result.config = config; - std::string error; - if (!ensure_zeroclaw_binary(&error, progress_handler)) { - result.text = error; - return result; - } - report_setup_progress(progress_handler, "Configuring provider", 72); - if (!apply_quickstart_config(&result.config, provider, &error)) { - result.text = error; - return result; - } - report_setup_progress(progress_handler, "Starting ZeroClaw service", 86); - if (!start_zeroclaw_service(&error)) { - result.text = error; - return result; - } - - report_setup_progress(progress_handler, "Pairing local client", 94); - const std::string code = generate_pairing_code(); - if (code.empty()) { - result.text = "ZeroClaw started, but pairing code generation failed."; - return result; - } - - ZClawClientResult pair_result = pair_with_code(result.config, code); - result.config = pair_result.config; - if (result.config.bearer_token.empty()) { - result.text = "Automatic pairing failed.\n" + pair_result.text; - return result; - } - result.config.setup_complete = true; - result.ok = true; - report_setup_progress(progress_handler, "Quickstart complete", 100); - result.text = "Quickstart complete.\nChat and approvals use WS."; - return result; -} diff --git a/projects/ZClaw/main/ui/zclaw_client.h b/projects/ZClaw/main/ui/zclaw_client.h deleted file mode 100644 index 71ca57a1..00000000 --- a/projects/ZClaw/main/ui/zclaw_client.h +++ /dev/null @@ -1,64 +0,0 @@ -#pragma once - -#include -#include -#include - -struct ProviderConfig { - std::string alias; - std::string family; - std::string model; - std::string uri; - std::string api_key; -}; - -struct UiConfig { - std::string webhook_url = "http://127.0.0.1:42617/webhook"; - std::string agent_alias = "zclaw"; - std::string webhook_secret; - std::string bearer_token; - bool setup_complete = false; -}; - -struct ZClawApprovalRequest { - std::string request_id; - std::string tool; - std::string summary; - int timeout_secs = 120; -}; - -struct ZClawClientResult { - std::string text; - bool ok = false; - UiConfig config; -}; - -struct ZClawSetupProgress { - std::string status; - int percent = 0; - size_t downloaded_bytes = 0; - size_t total_bytes = 0; - double bytes_per_second = 0.0; -}; - -class ZClawClient { -public: - using ApprovalHandler = std::function; - using SetupProgressHandler = std::function; - - static std::string home_dir(); - static std::string zeroclaw_dir(); - static std::string providers_config_path(); - static std::string ui_config_path(); - static std::string zeroclaw_config_path(); - static std::string zeroclaw_bin_path(); - static bool first_run_needed(const UiConfig &config); - static void ensure_storage_dir(); - static bool has_internet_connection(std::string *error = nullptr); - - ZClawClientResult run_setup(UiConfig config, const ProviderConfig &provider, - const SetupProgressHandler &progress_handler = nullptr); - ZClawClientResult pair_with_code(UiConfig config, const std::string &code); - ZClawClientResult send_chat(const UiConfig &config, const std::string &message, - const ApprovalHandler &approval_handler); -}; diff --git a/projects/ZClaw/main/ui/zclaw_config_codec.cpp b/projects/ZClaw/main/ui/zclaw_config_codec.cpp new file mode 100644 index 00000000..55063892 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_config_codec.cpp @@ -0,0 +1,70 @@ +#include "zclaw_config_codec.h" + +namespace zclaw { + +std::string encode_config_field(const std::string &value) +{ + std::string encoded; + for (char ch : value) { + if (ch == '\\') + encoded += "\\\\"; + else if (ch == '\t') + encoded += "\\t"; + else if (ch == '\n') + encoded += "\\n"; + else + encoded += ch; + } + return encoded; +} + +std::string decode_config_field(const std::string &value) +{ + std::string decoded; + for (std::size_t index = 0; index < value.size(); ++index) { + if (value[index] == '\\' && index + 1 < value.size()) { + const char next = value[++index]; + if (next == 't') + decoded += '\t'; + else if (next == 'n') + decoded += '\n'; + else + decoded += next; + } else { + decoded += value[index]; + } + } + return decoded; +} + +bool is_valid_config_field_encoding(const std::string &value) +{ + for (std::size_t index = 0; index < value.size(); ++index) { + if (value[index] != '\\') + continue; + if (++index >= value.size()) + return false; + const char escaped = value[index]; + if (escaped != '\\' && escaped != 't' && escaped != 'n') + return false; + } + return true; +} + +std::vector split_config_line(const std::string &line) +{ + std::vector fields; + std::string current; + for (char ch : line) { + if (ch == '\t') { + fields.push_back(current); + current.clear(); + } else { + current += ch; + } + } + fields.push_back(current); + return fields; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_config_codec.h b/projects/ZClaw/main/ui/zclaw_config_codec.h new file mode 100644 index 00000000..c728eab1 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_config_codec.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace zclaw { + +std::string encode_config_field(const std::string &value); +std::string decode_config_field(const std::string &value); +bool is_valid_config_field_encoding(const std::string &value); +std::vector split_config_line(const std::string &line); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_config_document_model.cpp b/projects/ZClaw/main/ui/zclaw_config_document_model.cpp new file mode 100644 index 00000000..8f348f85 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_config_document_model.cpp @@ -0,0 +1,156 @@ +#include "zclaw_config_document_model.h" + +#include "zclaw_config_codec.h" + +#include +#include + +namespace zclaw { +namespace { + +bool parse_provider_document(const std::string &document, + std::vector *providers, + bool strict) +{ + if (!providers) + return false; + providers->clear(); + std::istringstream lines(document); + std::string line; + while (std::getline(lines, line)) { + if (line.empty()) + continue; + const std::vector fields = split_config_line(line); + if (fields.size() < 5) + continue; + bool valid_encoding = true; + for (std::size_t index = 0; strict && index < 5; ++index) + valid_encoding = valid_encoding && + is_valid_config_field_encoding(fields[index]); + if (!valid_encoding) + continue; + providers->push_back({ + decode_config_field(fields[0]), decode_config_field(fields[1]), + decode_config_field(fields[2]), decode_config_field(fields[3]), + decode_config_field(fields[4]), + }); + } + return !providers->empty(); +} + +bool parse_ui_document(const std::string &document, UiConfig *config, + bool strict) +{ + if (!config) + return false; + *config = UiConfig{}; + bool recognized = false; + bool invalid = false; + std::set seen_keys; + std::istringstream lines(document); + std::string line; + while (std::getline(lines, line)) { + const std::vector fields = split_config_line(line); + if (fields.size() < 2) + continue; + if (strict && !is_valid_config_field_encoding(fields[0])) + continue; + const std::string key = decode_config_field(fields[0]); + const bool known = key == "webhook_url" || key == "agent_alias" || + key == "webhook_secret" || key == "bearer_token" || + key == "setup_complete"; + if (!known) + continue; + if (strict && !is_valid_config_field_encoding(fields[1])) { + invalid = true; + continue; + } + const std::string value = decode_config_field(fields[1]); + if (strict && !seen_keys.insert(key).second) { + invalid = true; + continue; + } + if (key == "webhook_url") { + config->webhook_url = value; + recognized = true; + } else if (key == "agent_alias") { + config->agent_alias = value.empty() ? "zclaw" : value; + recognized = true; + } else if (key == "webhook_secret") { + config->webhook_secret = value; + recognized = true; + } else if (key == "bearer_token") { + config->bearer_token = value; + recognized = true; + } else if (key == "setup_complete") { + if (strict && value != "0" && value != "1") { + invalid = true; + continue; + } + config->setup_complete = value == "1"; + recognized = true; + } + } + return recognized && !invalid; +} + +} // namespace + +std::vector parse_provider_config_document( + const std::string &document) +{ + std::vector providers; + parse_provider_document(document, &providers, false); + return providers; +} + +bool parse_provider_config_document_checked( + const std::string &document, std::vector *providers) +{ + return parse_provider_document(document, providers, true); +} + +std::string make_provider_config_document( + const std::vector &providers) +{ + std::string document; + for (const ProviderConfig &provider : providers) { + document += encode_config_field(provider.alias) + '\t' + + encode_config_field(provider.family) + '\t' + + encode_config_field(provider.model) + '\t' + + encode_config_field(provider.uri) + '\t' + + encode_config_field(provider.api_key) + '\n'; + } + return document; +} + +UiConfig parse_ui_config_document(const std::string &document) +{ + UiConfig config; + parse_ui_document(document, &config, false); + return config; +} + +bool parse_ui_config_document_checked(const std::string &document, + UiConfig *config) +{ + return parse_ui_document(document, config, true); +} + +std::string make_ui_config_document(const UiConfig &config) +{ + std::string document; + document += encode_config_field("webhook_url") + '\t' + + encode_config_field(config.webhook_url) + '\n'; + document += encode_config_field("agent_alias") + '\t' + + encode_config_field(config.agent_alias) + '\n'; + document += encode_config_field("webhook_secret") + '\t' + + encode_config_field(config.webhook_secret) + '\n'; + document += encode_config_field("bearer_token") + '\t' + + encode_config_field(config.bearer_token) + '\n'; + document += encode_config_field("setup_complete") + '\t' + + (config.setup_complete ? "1" : "0") + '\n'; + return document; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_config_document_model.h b/projects/ZClaw/main/ui/zclaw_config_document_model.h new file mode 100644 index 00000000..45d0ebbf --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_config_document_model.h @@ -0,0 +1,21 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +std::vector parse_provider_config_document( + const std::string &document); +bool parse_provider_config_document_checked( + const std::string &document, std::vector *providers); +std::string make_provider_config_document( + const std::vector &providers); +UiConfig parse_ui_config_document(const std::string &document); +bool parse_ui_config_document_checked(const std::string &document, + UiConfig *config); +std::string make_ui_config_document(const UiConfig &config); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_config_store_status.h b/projects/ZClaw/main/ui/zclaw_config_store_status.h new file mode 100644 index 00000000..361844bb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_config_store_status.h @@ -0,0 +1,12 @@ +#pragma once + +namespace zclaw { + +enum class ConfigStoreLoadStatus { + Loaded, + NotFound, + Invalid, + Error, +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_connectivity.cpp b/projects/ZClaw/main/ui/zclaw_connectivity.cpp new file mode 100644 index 00000000..efb2dc7b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_connectivity.cpp @@ -0,0 +1,150 @@ +#include "zclaw_connectivity.h" + +#include "zclaw_http_client_policy.h" +#include "zclaw_lhv_http_client.h" +#include "zclaw_protocol.h" + +#include +#include +#include + +namespace zclaw { +namespace { + +class HttpConnectivityProbe final + : public ConnectivityProbe, + public std::enable_shared_from_this { +public: + explicit HttpConnectivityProbe(std::shared_ptr client) + : client_(std::move(client)) + { + } + + bool get(const std::string &url, Completion completion) override + { + if (!completion || !client_) + return false; + bool cancelled = false; + { + std::lock_guard lock(mutex_); + cancelled = cancelled_; + } + if (cancelled) { + completion(false, "Connectivity check cancelled."); + return true; + } + auto request = std::make_shared(); + request->method = HTTP_GET; + request->url = url; + configure_http_request(*request, HttpClientProfile::Probe); + const auto self = shared_from_this(); + return client_->send( + std::move(request), + [self, completion = std::move(completion)]( + const HttpResponsePtr &response) mutable { + bool cancelled = false; + { + std::lock_guard lock(self->mutex_); + cancelled = self->cancelled_; + } + if (cancelled) { + completion(false, "Connectivity check cancelled."); + return; + } + completion(static_cast(response), + response ? std::string() : "Request failed."); + }); + } + + void cancel() noexcept override + { + std::lock_guard lock(mutex_); + cancelled_ = true; + } + +private: + std::shared_ptr client_; + std::mutex mutex_; + bool cancelled_ = false; +}; + +struct CheckState { + std::shared_ptr probe; + ConnectivityChecker::Completion completion; + std::size_t index = 0; + std::string last_error; + std::function next; +}; + +constexpr std::array kProbes = { + "https://www.cloudflare.com/cdn-cgi/trace", + "https://www.msftconnecttest.com/connecttest.txt", + "https://github.com/", +}; + +} // namespace + +ConnectivityChecker::ConnectivityChecker() + : probe_(std::make_shared( + std::make_shared())) +{ +} + +ConnectivityChecker::ConnectivityChecker(std::shared_ptr client) + : probe_(std::make_shared(std::move(client))) +{ +} + +ConnectivityChecker::ConnectivityChecker(std::shared_ptr probe) + : probe_(std::move(probe)) +{ +} + +bool ConnectivityChecker::check(Completion completion) +{ + if (!probe_ || !completion) + return false; + auto state = std::make_shared(); + state->probe = probe_; + state->completion = std::move(completion); + std::weak_ptr weak_state = state; + state->next = [weak_state] { + const auto state = weak_state.lock(); + if (!state) + return; + if (state->index >= kProbes.size()) { + auto completion = std::move(state->completion); + state->next = {}; + completion(false, state->last_error.empty() + ? "No public endpoint responded." + : std::move(state->last_error)); + return; + } + const char *url = kProbes[state->index++]; + if (!state->probe->get( + url, [state](bool online, std::string error) { + if (online) { + auto completion = std::move(state->completion); + state->next = {}; + completion(true, {}); + return; + } + state->last_error = std::move(error); + state->next(); + })) { + auto completion = std::move(state->completion); + state->next = {}; + completion(false, "Connectivity request was not submitted."); + } + }; + state->next(); + return true; +} + +void ConnectivityChecker::cancel() noexcept +{ + if (probe_) + probe_->cancel(); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_connectivity.h b/projects/ZClaw/main/ui/zclaw_connectivity.h new file mode 100644 index 00000000..e9bb1aa7 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_connectivity.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +namespace zclaw { + +class LhvHttpClient; + +class ConnectivityProbe { +public: + using Completion = std::function; + virtual ~ConnectivityProbe() = default; + + virtual bool get(const std::string &url, Completion completion) = 0; + virtual void cancel() noexcept = 0; +}; + +class ConnectivityChecker { +public: + using Completion = std::function; + ConnectivityChecker(); + explicit ConnectivityChecker(std::shared_ptr client); + explicit ConnectivityChecker(std::shared_ptr probe); + + bool check(Completion completion); + void cancel() noexcept; + +private: + std::shared_ptr probe_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_directory_sync.cpp b/projects/ZClaw/main/ui/zclaw_directory_sync.cpp new file mode 100644 index 00000000..d38f6c09 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_directory_sync.cpp @@ -0,0 +1,67 @@ +#include "zclaw_directory_sync.h" + +#include +#include +#include +#include +#include +#include + +namespace zclaw { + +bool sync_parent_directory(const std::string &path, std::string *error) +{ + if (error) + error->clear(); + std::filesystem::path parent = std::filesystem::path(path).parent_path(); + if (parent.empty()) + parent = "."; + + int flags = O_RDONLY; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif +#ifdef O_DIRECTORY + flags |= O_DIRECTORY; +#endif + const int directory = ::open(parent.c_str(), flags); + if (directory < 0) { + if (error) + *error = "Could not sync parent directory: " + + std::string(std::strerror(errno)); + return false; + } + + struct stat status {}; + const int stat_result = ::fstat(directory, &status); + bool ok = stat_result == 0 && S_ISDIR(status.st_mode); + int saved_error = ok ? 0 : (stat_result != 0 ? errno : ENOTDIR); + while (ok && ::fsync(directory) != 0) { + if (errno == EINTR) + continue; +#if defined(__APPLE__) + if (errno == EINVAL) // Darwin does not support fsync on directories. + break; +#endif +#if defined(ENOTSUP) + if (errno == ENOTSUP) + break; +#endif +#if defined(EOPNOTSUPP) && EOPNOTSUPP != ENOTSUP + if (errno == EOPNOTSUPP) + break; +#endif + ok = false; + saved_error = errno; + } + if (::close(directory) != 0 && ok) { + ok = false; + saved_error = errno; + } + if (!ok && error) + *error = "Could not sync parent directory: " + + std::string(std::strerror(saved_error)); + return ok; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_directory_sync.h b/projects/ZClaw/main/ui/zclaw_directory_sync.h new file mode 100644 index 00000000..ce4b0ca4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_directory_sync.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace zclaw { + +bool sync_parent_directory(const std::string &path, + std::string *error = nullptr); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_file_downloader.cpp b/projects/ZClaw/main/ui/zclaw_file_downloader.cpp new file mode 100644 index 00000000..003e6d5d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_file_downloader.cpp @@ -0,0 +1,381 @@ +#include "zclaw_file_downloader.h" + +#include "zclaw_http_cancellation.h" +#include "zclaw_http_client_policy.h" +#include "zclaw_lhv_http_client.h" +#include "zclaw_protocol.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace zclaw { +namespace { + +constexpr int kMaxRedirects = 5; +constexpr int kIdleTimeoutSeconds = 30; +constexpr int kRequestSafetyTimeoutSeconds = 12 * 60 * 60; + +bool is_redirect(int status_code) +{ + return status_code == 301 || status_code == 302 || status_code == 303 || + status_code == 307 || status_code == 308; +} + +bool parse_content_range(const std::string &value, long *from, long *to, + long *total) +{ + return from && to && total && + std::sscanf(value.c_str(), "bytes %ld-%ld/%ld", from, to, total) == 3; +} + +bool parse_unsatisfied_range(const std::string &value, long *total) +{ + return total && std::sscanf(value.c_str(), "bytes */%ld", total) == 1; +} + +std::string redirect_url(const std::string ¤t_url, + const std::string &location) +{ + protocol::HttpUrl absolute; + if (protocol::split_http_url(location, &absolute)) + return location; + + protocol::HttpUrl current; + if (!protocol::split_http_url(current_url, ¤t) || location.empty()) + return {}; + if (location.front() == '/') + return current.base + location; + + std::string directory = current.path; + const std::size_t query = directory.find_first_of("?#"); + if (query != std::string::npos) + directory.resize(query); + const std::size_t slash = directory.rfind('/'); + directory = slash == std::string::npos ? "/" : directory.substr(0, slash + 1); + return current.base + directory + location; +} + +std::string header_value(const HttpMessage &message, const char *name) +{ + for (const auto &header : message.headers) { + if (::strcasecmp(header.first.c_str(), name) == 0) + return header.second; + } + return {}; +} + +std::int64_t steady_milliseconds() +{ + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +} // namespace + +FileDownloader::FileDownloader() + : client_(std::make_shared()) +{ +} + +FileDownloader::FileDownloader( + std::shared_ptr cancellation) + : cancellation_(std::move(cancellation)), + client_(std::make_shared()) +{ +} + +FileDownloader::FileDownloader( + std::shared_ptr cancellation, + std::shared_ptr client) + : cancellation_(std::move(cancellation)), client_(std::move(client)) +{ +} + +bool FileDownloader::download(const std::string &url_text, + const std::string &output_path, + std::string *error, + const ProgressHandler &progress_handler) const +{ + protocol::HttpUrl url; + if (!protocol::split_http_url(url_text, &url)) { + if (error) + *error = "Invalid download URL."; + return false; + } + + if (!client_) { + if (error) + *error = "Download HTTP client unavailable."; + return false; + } + if (cancellation_ && cancellation_->shutdown_requested()) { + if (error) + *error = "Download cancelled."; + return false; + } + + std::error_code filesystem_error; + const std::filesystem::file_status output_status = + std::filesystem::symlink_status(output_path, filesystem_error); + if (filesystem_error && + filesystem_error != std::errc::no_such_file_or_directory) { + if (error) + *error = "Cannot write " + output_path; + return false; + } + std::size_t resume_offset = 0; + if (output_status.type() == std::filesystem::file_type::regular) { + const std::uintmax_t size = + std::filesystem::file_size(output_path, filesystem_error); + if (filesystem_error || + size > static_cast( + std::numeric_limits::max())) { + if (error) + *error = "Cannot inspect " + output_path; + return false; + } + resume_offset = static_cast(size); + } else if (output_status.type() != std::filesystem::file_type::not_found) { + if (error) + *error = "Cannot write " + output_path; + return false; + } + + std::ofstream output; + auto last_time = std::chrono::steady_clock::now(); + std::size_t last_bytes = resume_offset; + std::size_t received_bytes = resume_offset; + std::size_t total_bytes = 0; + bool output_open_failed = false; + bool range_mismatch = false; + bool range_complete = false; + std::atomic last_activity_ms{steady_milliseconds()}; + auto stream_callback = [&](HttpMessage *response, http_parser_state state, + const char *data, std::size_t length) { + last_activity_ms.store(steady_milliseconds(), std::memory_order_relaxed); + HttpResponse *http_response = static_cast(response); + if (is_redirect(http_response->status_code)) + return; + if (state == HP_HEADERS_COMPLETE) { + const int status_code = http_response->status_code; + if (status_code == 416 && resume_offset > 0) { + long complete_size = 0; + range_complete = parse_unsatisfied_range( + header_value(*response, "Content-Range"), + &complete_size) && + complete_size >= 0 && + static_cast(complete_size) == + resume_offset; + return; + } + if (status_code != 200 && status_code != 206) + return; + bool append = status_code == 206 && resume_offset > 0; + long range_from = 0; + long range_to = 0; + long range_total = 0; + if (status_code == 206 && parse_content_range( + header_value(*response, "Content-Range"), + &range_from, &range_to, &range_total)) { + if (range_from < 0 || + static_cast(range_from) != resume_offset) { + range_mismatch = true; + return; + } + if (range_total > 0) + total_bytes = static_cast(range_total); + } else if (status_code == 206 && resume_offset > 0) { + range_mismatch = true; + return; + } + if (!append) { + resume_offset = 0; + received_bytes = 0; + last_bytes = 0; + } + output.open(output_path, std::ios::binary | + (append ? std::ios::app : std::ios::trunc)); + if (!output) { + output_open_failed = true; + return; + } + const std::string content_length = + header_value(*response, "Content-Length"); + if (!content_length.empty()) { + try { + const std::size_t body_bytes = static_cast( + std::stoull(content_length)); + if (total_bytes == 0) + total_bytes = received_bytes + body_bytes; + } catch (...) { + if (total_bytes == 0) + total_bytes = 0; + } + } + last_time = std::chrono::steady_clock::now(); + if (progress_handler && received_bytes > 0) + progress_handler({received_bytes, total_bytes, 0.0}); + } else if (state == HP_BODY && data && length) { + if (!output || range_mismatch || output_open_failed) + return; + output.write(data, static_cast(length)); + received_bytes += length; + const auto now = std::chrono::steady_clock::now(); + const double seconds = + std::chrono::duration(now - last_time).count(); + if (seconds >= 0.2 || (total_bytes && received_bytes == total_bytes)) { + const double speed = seconds > 0.0 + ? static_cast(received_bytes - last_bytes) / + seconds + : 0.0; + if (progress_handler) + progress_handler({received_bytes, total_bytes, speed}); + last_time = now; + last_bytes = received_bytes; + } + } else if (state == HP_MESSAGE_COMPLETE && + received_bytes > last_bytes) { + const auto now = std::chrono::steady_clock::now(); + const double seconds = + std::chrono::duration(now - last_time).count(); + const double speed = seconds > 0.0 + ? static_cast(received_bytes - last_bytes) / + seconds + : 0.0; + if (progress_handler) + progress_handler({received_bytes, total_bytes, speed}); + last_time = now; + last_bytes = received_bytes; + } + }; + + std::string request_url = url.base + url.path; + HttpResponsePtr response; + bool idle_timeout = false; + bool cancelled = false; + for (int redirect_count = 0; redirect_count <= kMaxRedirects; + ++redirect_count) { + auto request = std::make_shared(); + request->method = HTTP_GET; + request->url = request_url; + configure_http_request(*request, HttpClientProfile::Download); + request->timeout = kRequestSafetyTimeoutSeconds; + request->redirect = false; + if (resume_offset > 0) { + if (resume_offset > static_cast( + std::numeric_limits::max())) { + if (error) + *error = "Download cache is too large to resume."; + return false; + } + request->SetHeader( + "Range", "bytes=" + std::to_string(resume_offset) + "-"); + } + request->http_cb = stream_callback; + + auto response_promise = std::make_shared>(); + std::future response_future = response_promise->get_future(); + if (!client_->send(std::move(request), [response_promise]( + const HttpResponsePtr &value) { + response_promise->set_value(value); + })) { + if (error) + *error = "Download failed.\nRequest was not submitted."; + return false; + } + last_activity_ms.store(steady_milliseconds(), std::memory_order_relaxed); + while (response_future.wait_for(std::chrono::milliseconds(250)) != + std::future_status::ready) { + cancelled = + cancellation_ && cancellation_->shutdown_requested(); + const bool inactive = + steady_milliseconds() - + last_activity_ms.load(std::memory_order_relaxed) >= + kIdleTimeoutSeconds * 1000LL; + if (!cancelled && !inactive) + continue; + idle_timeout = inactive; + client_->shutdown(); + break; + } + response = response_future.get(); + if (!response || !is_redirect(response->status_code)) + break; + if (redirect_count == kMaxRedirects) { + if (error) + *error = "Download failed.\nToo many redirects."; + return false; + } + request_url = redirect_url(request_url, + header_value(*response, "Location")); + if (request_url.empty()) + break; + } + if (output.is_open()) + output.close(); + + if (!response) { + if (error) + *error = cancelled + ? "Download cancelled." + : idle_timeout + ? "Download failed.\nNo data received for 30 seconds." + : "Download failed.\nTCP connection closed."; + return false; + } + if (range_mismatch) { + if (resume_offset == 0) { + if (error) + *error = "Download failed.\nServer returned an invalid byte range."; + return false; + } + std::ofstream reset(output_path, std::ios::binary | std::ios::trunc); + if (!reset) { + if (error) + *error = "Download failed.\nCould not reset invalid cache."; + return false; + } + reset.close(); + return download(url_text, output_path, error, progress_handler); + } + if (response->status_code == 416 && resume_offset > 0) { + if (range_complete) + return true; + std::ofstream reset(output_path, std::ios::binary | std::ios::trunc); + if (!reset) { + if (error) + *error = "Download failed.\nCould not reset invalid cache."; + return false; + } + reset.close(); + return download(url_text, output_path, error, progress_handler); + } + if (response->status_code < 200 || response->status_code >= 300) { + if (error) + *error = "Download failed.\nHTTP " + + std::to_string(response->status_code); + return false; + } + if (output_open_failed || !output) { + if (error) + *error = "Download failed.\nCould not write output file."; + return false; + } + if (total_bytes > 0 && received_bytes != total_bytes) { + if (error) + *error = "Download failed.\nConnection closed before completion."; + return false; + } + return true; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_file_downloader.h b/projects/ZClaw/main/ui/zclaw_file_downloader.h new file mode 100644 index 00000000..27666eb1 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_file_downloader.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +namespace zclaw { + +class HttpCancellationRegistry; +class LhvHttpClient; + +struct DownloadProgress { + std::size_t downloaded_bytes = 0; + std::size_t total_bytes = 0; + double bytes_per_second = 0.0; +}; + +class FileDownloader { +public: + using ProgressHandler = std::function; + + FileDownloader(); + explicit FileDownloader( + std::shared_ptr cancellation); + FileDownloader(std::shared_ptr cancellation, + std::shared_ptr client); + + bool download(const std::string &url, const std::string &output_path, + std::string *error, + const ProgressHandler &progress_handler = {}) const; + +private: + std::shared_ptr cancellation_; + std::shared_ptr client_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_font_path_model.cpp b/projects/ZClaw/main/ui/zclaw_font_path_model.cpp new file mode 100644 index 00000000..a76f4987 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_font_path_model.cpp @@ -0,0 +1,20 @@ +#include "zclaw_font_path_model.h" + +namespace zclaw { + +std::string select_font_path(const std::string &override_path, + const std::vector &candidates, + const FontPathExists &path_exists) +{ + if (!path_exists) + return ""; + if (!override_path.empty() && path_exists(override_path)) + return override_path; + for (const std::string &candidate : candidates) { + if (!candidate.empty() && path_exists(candidate)) + return candidate; + } + return ""; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_font_path_model.h b/projects/ZClaw/main/ui/zclaw_font_path_model.h new file mode 100644 index 00000000..364acb9f --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_font_path_model.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +namespace zclaw { + +using FontPathExists = std::function; + +std::string select_font_path(const std::string &override_path, + const std::vector &candidates, + const FontPathExists &path_exists); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_fonts.cpp b/projects/ZClaw/main/ui/zclaw_fonts.cpp index 88df42bd..f91dd8b9 100644 --- a/projects/ZClaw/main/ui/zclaw_fonts.cpp +++ b/projects/ZClaw/main/ui/zclaw_fonts.cpp @@ -1,6 +1,7 @@ #include "zclaw_fonts.hpp" #include "cp0_lvgl_file.hpp" +#include "zclaw_font_path_model.h" #if LV_USE_FREETYPE #include "lvgl/src/libs/freetype/lv_freetype.h" @@ -27,11 +28,7 @@ bool file_exists(const std::string &path) std::string runtime_font_path() { const char *env_path = std::getenv("ZCLAW_FONT"); - if (env_path && env_path[0] && file_exists(env_path)) { - return env_path; - } - - std::vector candidates = { + const std::vector candidates = { cp0_file_path("AlibabaPuHuiTi-3-55-Regular.ttf"), cp0_file_path("share/font/AlibabaPuHuiTi-3-55-Regular.ttf"), "./APPLaunch/share/font/AlibabaPuHuiTi-3-55-Regular.ttf", @@ -41,30 +38,22 @@ std::string runtime_font_path() "../../dist/APPLaunch/share/font/AlibabaPuHuiTi-3-55-Regular.ttf", "/usr/share/APPLaunch/share/font/AlibabaPuHuiTi-3-55-Regular.ttf", }; - for (const auto &candidate : candidates) { - if (file_exists(candidate)) return candidate; - } - return ""; + return zclaw::select_font_path(env_path ? env_path : "", candidates, + file_exists); } std::string runtime_fallback_font_path() { const char *env_path = std::getenv("ZCLAW_FALLBACK_FONT"); - if (env_path && env_path[0] && file_exists(env_path)) { - return env_path; - } - - std::vector candidates = { + const std::vector candidates = { cp0_file_path("NotoEmoji-Regular.ttf"), cp0_file_path("Symbola.ttf"), "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf", "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf", "/usr/share/fonts/truetype/ancient-scripts/Symbola.ttf", }; - for (const auto &candidate : candidates) { - if (file_exists(candidate)) return candidate; - } - return ""; + return zclaw::select_font_path(env_path ? env_path : "", candidates, + file_exists); } const lv_font_t *built_in_fallback_font() @@ -94,13 +83,20 @@ void set_fallback_chain(lv_font_t *primary, lv_font_t *fallback) namespace zclaw { +FontManager::~FontManager() +{ + release(); +} + void FontManager::init() { #if LV_USE_FREETYPE - if (font_10_ || font_12_) return; + if (font_10_ || font_12_) + return; const std::string font_path = runtime_font_path(); - if (font_path.empty()) return; + if (font_path.empty()) + return; font_10_ = lv_freetype_font_create( font_path.c_str(), LV_FREETYPE_FONT_RENDER_MODE_BITMAP_MONO, 10, @@ -123,6 +119,24 @@ void FontManager::init() #endif } +void FontManager::release() +{ +#if LV_USE_FREETYPE + if (font_10_) + lv_freetype_font_delete(font_10_); + if (font_12_) + lv_freetype_font_delete(font_12_); + if (fallback_font_10_) + lv_freetype_font_delete(fallback_font_10_); + if (fallback_font_12_) + lv_freetype_font_delete(fallback_font_12_); + font_10_ = nullptr; + font_12_ = nullptr; + fallback_font_10_ = nullptr; + fallback_font_12_ = nullptr; +#endif +} + const lv_font_t *FontManager::font_10() const { #if LV_USE_FREETYPE diff --git a/projects/ZClaw/main/ui/zclaw_fonts.hpp b/projects/ZClaw/main/ui/zclaw_fonts.hpp index a8e13c0a..e437b25b 100644 --- a/projects/ZClaw/main/ui/zclaw_fonts.hpp +++ b/projects/ZClaw/main/ui/zclaw_fonts.hpp @@ -7,10 +7,12 @@ namespace zclaw { class FontManager { public: FontManager() = default; + ~FontManager(); FontManager(const FontManager &) = delete; FontManager &operator=(const FontManager &) = delete; void init(); + void release(); const lv_font_t *font_10() const; const lv_font_t *font_12() const; diff --git a/projects/ZClaw/main/ui/zclaw_gateway_response_model.cpp b/projects/ZClaw/main/ui/zclaw_gateway_response_model.cpp new file mode 100644 index 00000000..8eaaa6c2 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_gateway_response_model.cpp @@ -0,0 +1,82 @@ +#include "zclaw_gateway_response_model.h" + +#include "json.hpp" + +#include + +namespace zclaw { +namespace { + +using Json = nlohmann::json; + +const std::string *string_field(const Json &object, const char *field, + std::string *storage) +{ + if (!object.is_object()) + return nullptr; + const auto found = object.find(field); + if (found == object.end() || !found->is_string()) + return nullptr; + *storage = found->get(); + return storage; +} + +} // namespace + +OperationResult interpret_pairing_response(UiConfig config, int http_status, + const std::string &body) +{ + OperationResult result; + result.config = std::move(config); + if (http_status < 200 || http_status >= 300) { + result.text = "Pairing request failed.\nHTTP " + + std::to_string(http_status) + "\n" + body; + return result; + } + + const Json parsed = Json::parse(body, nullptr, false); + std::string field; + const std::string *token = string_field(parsed, "token", &field); + if (token && !token->empty()) { + result.config.bearer_token = *token; + result.text = "Pairing complete.\nWS approvals enabled."; + result.ok = true; + return result; + } + + std::string error_storage; + const std::string *response_error = + string_field(parsed, "error", &error_storage); + result.text = !response_error || response_error->empty() + ? "Pairing failed.\n" + body + : "Pairing failed: " + *response_error; + return result; +} + +OperationResult interpret_webhook_response(const UiConfig &config, int http_status, + const std::string &body) +{ + OperationResult result; + result.config = config; + if (http_status < 200 || http_status >= 300) { + result.text = "Webhook request failed.\nHTTP " + + std::to_string(http_status) + "\n" + body; + return result; + } + + const Json parsed = Json::parse(body, nullptr, false); + std::string field; + const std::string *response_error = string_field(parsed, "error", &field); + if (response_error && !response_error->empty()) { + result.text = "ZeroClaw error: " + *response_error; + return result; + } + std::string response_storage; + const std::string *reply = + string_field(parsed, "response", &response_storage); + result.text = !reply || reply->empty() ? body : *reply; + result.ok = true; + return result; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_gateway_response_model.h b/projects/ZClaw/main/ui/zclaw_gateway_response_model.h new file mode 100644 index 00000000..af557015 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_gateway_response_model.h @@ -0,0 +1,14 @@ +#pragma once + +#include "zclaw_types.h" + +#include + +namespace zclaw { + +OperationResult interpret_pairing_response(UiConfig config, int http_status, + const std::string &body); +OperationResult interpret_webhook_response(const UiConfig &config, int http_status, + const std::string &body); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_http_cancellation.cpp b/projects/ZClaw/main/ui/zclaw_http_cancellation.cpp new file mode 100644 index 00000000..bdd852b5 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_http_cancellation.cpp @@ -0,0 +1,106 @@ +#include "zclaw_http_cancellation.h" + +#include +#include +#include +#include + +namespace zclaw { + +struct HttpCancellationRegistration::State { + mutable std::mutex mutex; + std::unordered_map requests; + std::uint64_t next_id = 1; + bool shutdown = false; +}; + +HttpCancellationRegistration::HttpCancellationRegistration( + std::shared_ptr state, std::uint64_t id) + : state_(std::move(state)), id_(id) +{ +} + +HttpCancellationRegistration::~HttpCancellationRegistration() +{ + reset(); +} + +HttpCancellationRegistration::HttpCancellationRegistration( + HttpCancellationRegistration &&other) noexcept + : state_(std::move(other.state_)), id_(other.id_) +{ + other.id_ = 0; +} + +HttpCancellationRegistration &HttpCancellationRegistration::operator=( + HttpCancellationRegistration &&other) noexcept +{ + if (this != &other) { + reset(); + state_ = std::move(other.state_); + id_ = other.id_; + other.id_ = 0; + } + return *this; +} + +bool HttpCancellationRegistration::active() const +{ + return id_ != 0; +} + +void HttpCancellationRegistration::reset() noexcept +{ + if (state_ && id_ != 0) { + std::lock_guard lock(state_->mutex); + state_->requests.erase(id_); + } + state_.reset(); + id_ = 0; +} + +HttpCancellationRegistry::HttpCancellationRegistry() + : state_(std::make_shared()) +{ +} + +HttpCancellationRegistration HttpCancellationRegistry::register_request(Stop stop) +{ + if (!stop) + return {}; + std::lock_guard lock(state_->mutex); + if (state_->shutdown) + return {}; + const std::uint64_t id = state_->next_id++; + state_->requests.emplace(id, std::move(stop)); + return {state_, id}; +} + +void HttpCancellationRegistry::shutdown() noexcept +{ + std::vector stops; + { + std::lock_guard lock(state_->mutex); + if (state_->shutdown) + return; + state_->shutdown = true; + stops.reserve(state_->requests.size()); + for (auto &request : state_->requests) + stops.push_back(std::move(request.second)); + state_->requests.clear(); + } + for (Stop &stop : stops) { + try { + stop(); + } catch (...) { + } + } +} + +bool HttpCancellationRegistry::shutdown_requested() const +{ + std::lock_guard lock(state_->mutex); + return state_->shutdown; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_http_cancellation.h b/projects/ZClaw/main/ui/zclaw_http_cancellation.h new file mode 100644 index 00000000..1f10d274 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_http_cancellation.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +namespace zclaw { + +class HttpCancellationRegistry; + +class HttpCancellationRegistration { +public: + HttpCancellationRegistration() = default; + ~HttpCancellationRegistration(); + + HttpCancellationRegistration(HttpCancellationRegistration &&other) noexcept; + HttpCancellationRegistration &operator=(HttpCancellationRegistration &&other) noexcept; + + HttpCancellationRegistration(const HttpCancellationRegistration &) = delete; + HttpCancellationRegistration &operator=(const HttpCancellationRegistration &) = delete; + + bool active() const; + void reset() noexcept; + +private: + struct State; + friend class HttpCancellationRegistry; + + HttpCancellationRegistration(std::shared_ptr state, std::uint64_t id); + + std::shared_ptr state_; + std::uint64_t id_ = 0; +}; + +class HttpCancellationRegistry { +public: + using Stop = std::function; + + HttpCancellationRegistry(); + + HttpCancellationRegistration register_request(Stop stop); + void shutdown() noexcept; + bool shutdown_requested() const; + +private: + std::shared_ptr state_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_http_client_policy.cpp b/projects/ZClaw/main/ui/zclaw_http_client_policy.cpp new file mode 100644 index 00000000..d8e79596 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_http_client_policy.cpp @@ -0,0 +1,30 @@ +#include "zclaw_http_client_policy.h" + +#include + +namespace zclaw { + +HttpClientTimeouts http_client_timeouts(HttpClientProfile profile) +{ + switch (profile) { + case HttpClientProfile::Probe: + return {2, 2, 2}; + case HttpClientProfile::Pairing: + return {20, 30, 30}; + case HttpClientProfile::Webhook: + return {20, 180, 30}; + case HttpClientProfile::Download: + return {30, 0, 30}; + } + return {}; +} + +void configure_http_request(HttpRequest &request, HttpClientProfile profile) +{ + const HttpClientTimeouts timeouts = http_client_timeouts(profile); + request.connect_timeout = timeouts.connection_seconds; + request.timeout = timeouts.read_seconds; + request.redirect = true; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_http_client_policy.h b/projects/ZClaw/main/ui/zclaw_http_client_policy.h new file mode 100644 index 00000000..4155986f --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_http_client_policy.h @@ -0,0 +1,23 @@ +#pragma once + +class HttpRequest; + +namespace zclaw { + +enum class HttpClientProfile { + Probe, + Pairing, + Webhook, + Download, +}; + +struct HttpClientTimeouts { + int connection_seconds = 0; + int read_seconds = 0; + int write_seconds = 0; +}; + +HttpClientTimeouts http_client_timeouts(HttpClientProfile profile); +void configure_http_request(HttpRequest &request, HttpClientProfile profile); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_input_dialog.cpp b/projects/ZClaw/main/ui/zclaw_input_dialog.cpp new file mode 100644 index 00000000..efc51d17 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_input_dialog.cpp @@ -0,0 +1,182 @@ +#include "zclaw_input_dialog.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_theme.h" + +namespace zclaw { +namespace { + +constexpr lv_coord_t kDialogHeight = 170 * 2 / 3; + +} // namespace + +InputDialog::~InputDialog() +{ + close(); + if (cursor_style_initialized_) + lv_style_reset(&cursor_style_); +} + +void InputDialog::open_chat(const FontManager *fonts) +{ + mode_ = InputMode::Chat; + open(fonts); + if (textarea_) + lv_textarea_set_placeholder_text(textarea_, "Type your message..."); +} + +void InputDialog::open_text(const FontManager *fonts, const std::string &placeholder, + const std::string &initial_text, InputMode mode) +{ + mode_ = mode; + open(fonts); + if (!textarea_) + return; + lv_textarea_set_placeholder_text(textarea_, placeholder.c_str()); + lv_textarea_set_text(textarea_, initial_text.c_str()); + lv_textarea_set_cursor_pos(textarea_, LV_TEXTAREA_CURSOR_LAST); +} + +void InputDialog::close() +{ + lv_obj_t *dialog = dialog_; + if (dialog) + lv_obj_del(dialog); + release_dialog(); +} + +bool InputDialog::is_open() const +{ + return dialog_ && textarea_; +} + +InputMode InputDialog::mode() const +{ + return mode_; +} + +std::string InputDialog::text() const +{ + if (!textarea_) + return ""; + const char *value = lv_textarea_get_text(textarea_); + return value ? value : ""; +} + +void InputDialog::append(const char *utf8) +{ + if (textarea_ && utf8 && utf8[0]) + lv_textarea_add_text(textarea_, utf8); +} + +void InputDialog::insert_newline() +{ + if (textarea_) + lv_textarea_add_char(textarea_, '\n'); +} + +void InputDialog::erase_before_cursor() +{ + if (textarea_) + lv_textarea_delete_char(textarea_); +} + +void InputDialog::erase_after_cursor() +{ + if (textarea_) + lv_textarea_delete_char_forward(textarea_); +} + +void InputDialog::move_left() +{ + if (textarea_) + lv_textarea_cursor_left(textarea_); +} + +void InputDialog::move_right() +{ + if (textarea_) + lv_textarea_cursor_right(textarea_); +} + +void InputDialog::move_up() +{ + if (textarea_) + lv_textarea_cursor_up(textarea_); +} + +void InputDialog::move_down() +{ + if (textarea_) + lv_textarea_cursor_down(textarea_); +} + +void InputDialog::open(const FontManager *fonts) +{ + if (is_open() || !fonts) + return; + + dialog_ = lv_msgbox_create(lv_layer_top()); + lv_obj_add_event_cb(dialog_, dialog_deleted, LV_EVENT_DELETE, this); + lv_obj_set_size(dialog_, 300, kDialogHeight); + lv_obj_align(dialog_, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_radius(dialog_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(dialog_, lv_color_hex(theme::kBar), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(dialog_, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(dialog_, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(dialog_, lv_color_hex(theme::kPanelLine), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(dialog_, lv_color_hex(theme::kText), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(dialog_, fonts->font_10(), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(dialog_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *content = lv_msgbox_get_content(dialog_); + lv_obj_set_style_pad_all(content, 5, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(content, LV_OBJ_FLAG_SCROLLABLE); + + textarea_ = lv_textarea_create(content); + lv_obj_set_size(textarea_, 290, kDialogHeight - 10); + lv_textarea_set_placeholder_text(textarea_, "Type your message..."); + lv_textarea_set_one_line(textarea_, false); + lv_textarea_set_cursor_click_pos(textarea_, false); + lv_obj_set_style_radius(textarea_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(textarea_, lv_color_hex(theme::kPanel), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(textarea_, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(textarea_, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(textarea_, lv_color_hex(theme::kWhite), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(textarea_, fonts->font_10(), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(textarea_, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + + if (!cursor_style_initialized_) { + lv_style_init(&cursor_style_); + lv_style_set_bg_opa(&cursor_style_, LV_OPA_TRANSP); + lv_style_set_border_color(&cursor_style_, lv_color_hex(theme::kPurple)); + lv_style_set_border_side(&cursor_style_, LV_BORDER_SIDE_LEFT); + lv_style_set_border_width(&cursor_style_, 2); + lv_style_set_pad_left(&cursor_style_, -2); + lv_style_set_pad_right(&cursor_style_, 0); + cursor_style_initialized_ = true; + } + lv_obj_add_style(textarea_, &cursor_style_, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_state(textarea_, LV_STATE_FOCUSED); + lv_obj_send_event(textarea_, LV_EVENT_FOCUSED, nullptr); + lv_textarea_set_cursor_pos(textarea_, LV_TEXTAREA_CURSOR_LAST); +} + +void InputDialog::dialog_deleted(lv_event_t *event) +{ + InputDialog *dialog = + static_cast(lv_event_get_user_data(event)); + if (dialog) + dialog->release_dialog(); +} + +void InputDialog::release_dialog() +{ + dialog_ = nullptr; + textarea_ = nullptr; + mode_ = InputMode::Chat; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_input_dialog.h b/projects/ZClaw/main/ui/zclaw_input_dialog.h new file mode 100644 index 00000000..b202bdd5 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_input_dialog.h @@ -0,0 +1,50 @@ +#pragma once + +#include "lvgl/lvgl.h" +#include "zclaw_input_model.h" + +#include + +namespace zclaw { + +class FontManager; + +class InputDialog { +public: + InputDialog() = default; + ~InputDialog(); + + InputDialog(const InputDialog &) = delete; + InputDialog &operator=(const InputDialog &) = delete; + + void open_chat(const FontManager *fonts); + void open_text(const FontManager *fonts, const std::string &placeholder, + const std::string &initial_text, InputMode mode); + void close(); + + bool is_open() const; + InputMode mode() const; + std::string text() const; + + void append(const char *utf8); + void insert_newline(); + void erase_before_cursor(); + void erase_after_cursor(); + void move_left(); + void move_right(); + void move_up(); + void move_down(); + +private: + static void dialog_deleted(lv_event_t *event); + void open(const FontManager *fonts); + void release_dialog(); + + lv_obj_t *dialog_ = nullptr; + lv_obj_t *textarea_ = nullptr; + lv_style_t cursor_style_{}; + bool cursor_style_initialized_ = false; + InputMode mode_ = InputMode::Chat; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_input_model.cpp b/projects/ZClaw/main/ui/zclaw_input_model.cpp new file mode 100644 index 00000000..23aea77d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_input_model.cpp @@ -0,0 +1,30 @@ +#include "zclaw_input_model.h" + +#include + +namespace zclaw { + +InputSubmission input_submission(InputMode mode, std::string value) +{ + InputSubmission submission; + submission.value = std::move(value); + switch (mode) { + case InputMode::Chat: + submission.action = submission.value.empty() ? InputSubmissionAction::None + : InputSubmissionAction::SendChat; + break; + case InputMode::SetupEdit: + submission.action = InputSubmissionAction::ApplySetupEdit; + break; + case InputMode::ProviderEdit: + submission.action = InputSubmissionAction::ApplyProviderEdit; + break; + case InputMode::PairingCode: + submission.action = submission.value.empty() ? InputSubmissionAction::None + : InputSubmissionAction::StartPairing; + break; + } + return submission; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_input_model.h b/projects/ZClaw/main/ui/zclaw_input_model.h new file mode 100644 index 00000000..95ec9fdd --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_input_model.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace zclaw { + +enum class InputMode { + Chat, + SetupEdit, + ProviderEdit, + PairingCode, +}; + +enum class InputSubmissionAction { + None, + SendChat, + ApplySetupEdit, + ApplyProviderEdit, + StartPairing, +}; + +struct InputSubmission { + InputSubmissionAction action = InputSubmissionAction::None; + std::string value; +}; + +InputSubmission input_submission(InputMode mode, std::string value); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_input_workflow.cpp b/projects/ZClaw/main/ui/zclaw_input_workflow.cpp new file mode 100644 index 00000000..2fba6563 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_input_workflow.cpp @@ -0,0 +1,41 @@ +#include "zclaw_input_workflow.h" + +#include "zclaw_chat_workflow.h" +#include "zclaw_input_dialog.h" +#include "zclaw_input_model.h" +#include "zclaw_settings_workflow.h" + +namespace zclaw { + +InputWorkflow::InputWorkflow(InputDialog &input, SettingsWorkflow &settings, + ChatWorkflow &chat) + : input_(input), settings_(settings), chat_(chat) +{ +} + +void InputWorkflow::submit_current() +{ + if (!input_.is_open()) + return; + + const InputSubmission submission = input_submission(input_.mode(), input_.text()); + input_.close(); + switch (submission.action) { + case InputSubmissionAction::None: + break; + case InputSubmissionAction::SendChat: + chat_.submit(submission.value); + break; + case InputSubmissionAction::ApplySetupEdit: + settings_.apply_setup_edit(submission.value); + break; + case InputSubmissionAction::ApplyProviderEdit: + settings_.apply_provider_edit(submission.value); + break; + case InputSubmissionAction::StartPairing: + settings_.start_pairing(submission.value); + break; + } +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_input_workflow.h b/projects/ZClaw/main/ui/zclaw_input_workflow.h new file mode 100644 index 00000000..23a80ae0 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_input_workflow.h @@ -0,0 +1,22 @@ +#pragma once + +namespace zclaw { + +class ChatWorkflow; +class InputDialog; +class SettingsWorkflow; + +class InputWorkflow { +public: + InputWorkflow(InputDialog &input, SettingsWorkflow &settings, + ChatWorkflow &chat); + + void submit_current(); + +private: + InputDialog &input_; + SettingsWorkflow &settings_; + ChatWorkflow &chat_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_key_event_adapter.cpp b/projects/ZClaw/main/ui/zclaw_key_event_adapter.cpp new file mode 100644 index 00000000..6d04fdd4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_key_event_adapter.cpp @@ -0,0 +1,57 @@ +#include "zclaw_key_event_adapter.h" + +#include "keyboard_input.h" + +#include + +namespace zclaw { +namespace { + +Key adapt_key(std::uint32_t key_code) +{ + switch (key_code) { + case KEY_ENTER: return Key::Enter; + case KEY_ESC: return Key::Escape; + case KEY_BACKSPACE: return Key::Backspace; + case KEY_DELETE: return Key::Delete; + case KEY_TAB: return Key::Tab; + case KEY_LEFT: return Key::Left; + case KEY_RIGHT: return Key::Right; + case KEY_UP: return Key::Up; + case KEY_DOWN: return Key::Down; + case KEY_A: return Key::A; + case KEY_C: return Key::C; + case KEY_F: return Key::F; + case KEY_N: return Key::N; + case KEY_X: return Key::X; + case KEY_Y: return Key::Y; + case KEY_Z: return Key::Z; + default: return Key::Other; + } +} + +KeyPhase adapt_key_phase(int key_state) +{ + switch (key_state) { + case KBD_KEY_PRESSED: return KeyPhase::Pressed; + case KBD_KEY_REPEATED: return KeyPhase::Repeated; + case KBD_KEY_RELEASED: return KeyPhase::Released; + default: return KeyPhase::Unknown; + } +} + +} // namespace + +KeyEvent adapt_key_event(std::uint32_t key_code, int key_state, + std::uint32_t modifiers, const char *utf8) +{ + KeyEvent event; + event.phase = adapt_key_phase(key_state); + event.key = adapt_key(key_code); + event.shift = (modifiers & KBD_MOD_SHIFT) != 0; + if (utf8) + event.text = utf8; + return event; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_key_event_adapter.h b/projects/ZClaw/main/ui/zclaw_key_event_adapter.h new file mode 100644 index 00000000..161452ae --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_key_event_adapter.h @@ -0,0 +1,12 @@ +#pragma once + +#include "zclaw_key_router.h" + +#include + +namespace zclaw { + +KeyEvent adapt_key_event(std::uint32_t key_code, int key_state, + std::uint32_t modifiers, const char *utf8); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_key_router.cpp b/projects/ZClaw/main/ui/zclaw_key_router.cpp new file mode 100644 index 00000000..15eb1747 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_key_router.cpp @@ -0,0 +1,140 @@ +#include "zclaw_key_router.h" + +namespace zclaw { +namespace { + +Key normalize_navigation_key(Key key) +{ + switch (key) { + case Key::Z: + return Key::Left; + case Key::X: + return Key::Down; + case Key::C: + return Key::Right; + case Key::F: + return Key::Up; + default: + return key; + } +} + +KeyAction input_edit_action(const KeyEvent &event) +{ + switch (event.key) { + case Key::Enter: + return {event.shift ? KeyActionType::InputInsertNewline : KeyActionType::None, {}}; + case Key::Backspace: + return {KeyActionType::InputEraseBefore, {}}; + case Key::Delete: + return {KeyActionType::InputEraseAfter, {}}; + case Key::Left: + return {KeyActionType::InputMoveLeft, {}}; + case Key::Right: + return {KeyActionType::InputMoveRight, {}}; + case Key::Up: + return {KeyActionType::InputMoveUp, {}}; + case Key::Down: + return {KeyActionType::InputMoveDown, {}}; + default: + if (!event.text.empty() && static_cast(event.text[0]) >= 0x20) + return {KeyActionType::InputInsertText, event.text}; + return {}; + } +} + +} // namespace + +KeyAction route_key(const KeyRouteContext &context, const KeyEvent &event) +{ + if (context.startup != StartupState::Ready) { + if (context.startup == StartupState::Offline && + event.phase == KeyPhase::Released && event.key == Key::Enter) + return {KeyActionType::Quit, {}}; + return {}; + } + + if (event.phase == KeyPhase::Pressed || event.phase == KeyPhase::Repeated) + return context.input_open ? input_edit_action(event) : KeyAction{}; + if (event.phase != KeyPhase::Released) + return {}; + + if (context.input_open) { + if (event.key == Key::Escape) + return {KeyActionType::InputClose, {}}; + if (event.key == Key::Enter && !event.shift) + return {KeyActionType::InputSubmit, {}}; + return {}; + } + + const Key key = normalize_navigation_key(event.key); + if (context.approval_pending) { + switch (key) { + case Key::Left: + return {KeyActionType::ApprovalMoveLeft, {}}; + case Key::Right: + return {KeyActionType::ApprovalMoveRight, {}}; + case Key::Enter: + return {KeyActionType::ApprovalSubmitSelected, {}}; + case Key::Y: + return {KeyActionType::ApprovalApprove, {}}; + case Key::A: + return {KeyActionType::ApprovalAlways, {}}; + case Key::N: + case Key::Escape: + case Key::Backspace: + return {KeyActionType::ApprovalDeny, {}}; + default: + return {}; + } + } + + if (context.setup_retry_pending) { + if (key == Key::Left) + return {KeyActionType::SetupRetryMoveLeft, {}}; + if (key == Key::Right) + return {KeyActionType::SetupRetryMoveRight, {}}; + if (key == Key::Enter) + return {KeyActionType::SetupRetryActivate, {}}; + if (key == Key::Escape || key == Key::Backspace) + return {KeyActionType::SetupRetryDismiss, {}}; + return {}; + } + + if (key == Key::Tab) + return {KeyActionType::ToggleSettings, {}}; + + if (context.settings_open) { + switch (key) { + case Key::Escape: + case Key::Backspace: + return {KeyActionType::SettingsBack, {}}; + case Key::Enter: + return {KeyActionType::SettingsActivate, {}}; + case Key::Delete: + return {context.settings_view == SettingsView::ProviderDetail + ? KeyActionType::SettingsDeleteProvider + : KeyActionType::None, + {}}; + case Key::Up: + return {KeyActionType::SettingsMoveUp, {}}; + case Key::Down: + return {KeyActionType::SettingsMoveDown, {}}; + default: + return {}; + } + } + + switch (key) { + case Key::Up: + return {KeyActionType::ChatScrollUp, {}}; + case Key::Down: + return {KeyActionType::ChatScrollDown, {}}; + case Key::Enter: + return {KeyActionType::ChatOpenInput, {}}; + default: + return {}; + } +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_key_router.h b/projects/ZClaw/main/ui/zclaw_key_router.h new file mode 100644 index 00000000..123f7911 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_key_router.h @@ -0,0 +1,94 @@ +#pragma once + +#include "zclaw_settings_navigation_model.h" +#include "zclaw_startup_model.h" + +#include + +namespace zclaw { + +enum class KeyPhase { + Unknown, + Pressed, + Repeated, + Released, +}; + +enum class Key { + Other, + Enter, + Escape, + Backspace, + Delete, + Tab, + Left, + Right, + Up, + Down, + A, + C, + F, + N, + X, + Y, + Z, +}; + +struct KeyEvent { + KeyPhase phase = KeyPhase::Unknown; + Key key = Key::Other; + bool shift = false; + std::string text; +}; + +struct KeyRouteContext { + StartupState startup = StartupState::CheckingNetwork; + bool input_open = false; + bool approval_pending = false; + bool setup_retry_pending = false; + bool settings_open = false; + SettingsView settings_view = SettingsView::Main; +}; + +enum class KeyActionType { + None, + Quit, + InputInsertText, + InputInsertNewline, + InputEraseBefore, + InputEraseAfter, + InputMoveLeft, + InputMoveRight, + InputMoveUp, + InputMoveDown, + InputClose, + InputSubmit, + ApprovalMoveLeft, + ApprovalMoveRight, + ApprovalSubmitSelected, + ApprovalApprove, + ApprovalAlways, + ApprovalDeny, + SetupRetryMoveLeft, + SetupRetryMoveRight, + SetupRetryActivate, + SetupRetryDismiss, + ToggleSettings, + SettingsBack, + SettingsActivate, + SettingsDeleteProvider, + SettingsMoveUp, + SettingsMoveDown, + ChatScrollUp, + ChatScrollDown, + ChatOpenInput, +}; + +struct KeyAction { + KeyActionType type = KeyActionType::None; + std::string text; +}; + +KeyAction route_key(const KeyRouteContext &context, const KeyEvent &event); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_lhv_http_client.cpp b/projects/ZClaw/main/ui/zclaw_lhv_http_client.cpp new file mode 100644 index 00000000..ec15314e --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_lhv_http_client.cpp @@ -0,0 +1,76 @@ +#include "zclaw_lhv_http_client.h" + +#include + +namespace zclaw { + +struct LhvHttpClient::State { + std::mutex mutex; + std::unordered_map pending; + std::uint64_t next_id = 1; +}; + +LhvHttpClient::LhvHttpClient() + : client_(std::make_unique()), + state_(std::make_shared()) +{ +} + +LhvHttpClient::~LhvHttpClient() +{ + shutdown(); +} + +bool LhvHttpClient::send(HttpRequestPtr request, Completion completion) +{ + if (!request || !completion) + return false; + request->retry_count = 0; + std::lock_guard lock(mutex_); + if (!client_) + return false; + std::uint64_t id = 0; + { + std::lock_guard state_lock(state_->mutex); + id = state_->next_id++; + state_->pending.emplace(id, std::move(completion)); + } + const std::shared_ptr state = state_; + const int submitted = client_->sendAsync( + std::move(request), [state, id](const HttpResponsePtr &response) { + Completion completion; + { + std::lock_guard lock(state->mutex); + const auto found = state->pending.find(id); + if (found == state->pending.end()) + return; + completion = std::move(found->second); + state->pending.erase(found); + } + completion(response); + }); + if (submitted == 0) + return true; + std::lock_guard state_lock(state_->mutex); + state_->pending.erase(id); + return false; +} + +void LhvHttpClient::shutdown() noexcept +{ + std::unique_ptr client; + { + std::lock_guard lock(mutex_); + client = std::move(client_); + } + client.reset(); + std::unordered_map pending; + { + std::lock_guard lock(state_->mutex); + pending.swap(state_->pending); + } + for (auto &entry : pending) + entry.second(nullptr); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_lhv_http_client.h b/projects/ZClaw/main/ui/zclaw_lhv_http_client.h new file mode 100644 index 00000000..0e044415 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_lhv_http_client.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace zclaw { + +class LhvHttpClient { +public: + using Completion = HttpResponseCallback; + + LhvHttpClient(); + ~LhvHttpClient(); + + LhvHttpClient(const LhvHttpClient &) = delete; + LhvHttpClient &operator=(const LhvHttpClient &) = delete; + + bool send(HttpRequestPtr request, Completion completion); + void shutdown() noexcept; + +private: + struct State; + std::mutex mutex_; + std::unique_ptr client_; + std::shared_ptr state_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_local_async_backend.cpp b/projects/ZClaw/main/ui/zclaw_local_async_backend.cpp new file mode 100644 index 00000000..4bead40c --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_local_async_backend.cpp @@ -0,0 +1,83 @@ +#include "zclaw_local_async_backend.h" + +#include "zclaw_async_service.h" +#include "zclaw_chat_transport.h" +#include "zclaw_connectivity.h" +#include "zclaw_http_cancellation.h" +#include "zclaw_local_quickstart_backend.h" +#include "zclaw_lhv_http_client.h" +#include "zclaw_pairing_service.h" +#include "zclaw_process_executor.h" +#include "zclaw_quickstart_service.h" + +#include + +#include + +namespace zclaw { + +LocalAsyncBackend::LocalAsyncBackend() + : cancellation_(std::make_shared()), + processes_(std::make_shared()), + http_client_(std::make_shared()), + connectivity_(http_client_) +{ +} + +LocalAsyncBackend::LocalAsyncBackend(std::shared_ptr probe) + : cancellation_(std::make_shared()), + processes_(std::make_shared()), + http_client_(std::make_shared()), + connectivity_(std::move(probe)) +{ +} + +void LocalAsyncBackend::shutdown() noexcept +{ + connectivity_.cancel(); + cancellation_->shutdown(); + processes_->shutdown(); + http_client_->shutdown(); +} + +bool LocalAsyncBackend::check_network(NetworkCompletion completion) const +{ + if (!completion) + return false; + return connectivity_.check( + [completion = std::move(completion)](bool online, + std::string error) mutable { + completion({online, std::move(error)}); + }); +} + +bool LocalAsyncBackend::send_chat( + UiConfig config, std::string message, + ApprovalHandler approval_handler, OperationCompletion completion) const +{ + if (!completion) + return false; + return ChatTransport(cancellation_, http_client_).send( + config, message, std::move(approval_handler), + std::move(completion)); +} + +bool LocalAsyncBackend::pair(UiConfig config, std::string code, + OperationCompletion completion) const +{ + if (!completion) + return false; + return PairingService(http_client_).pair( + std::move(config), code, std::move(completion)); +} + +OperationResult LocalAsyncBackend::setup( + UiConfig config, ProviderConfig provider, + const ProgressHandler &progress_handler) const +{ + const LocalQuickstartBackend backend(cancellation_, processes_, http_client_); + return QuickstartService(backend).run( + std::move(config), provider, progress_handler); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_local_async_backend.h b/projects/ZClaw/main/ui/zclaw_local_async_backend.h new file mode 100644 index 00000000..c5bd0862 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_local_async_backend.h @@ -0,0 +1,38 @@ +#pragma once + +#include "zclaw_async_backend.h" +#include "zclaw_connectivity.h" + +#include + +namespace zclaw { + +class HttpCancellationRegistry; +class ProcessExecutor; +class LhvHttpClient; + +class LocalAsyncBackend final : public AsyncBackend { +public: + LocalAsyncBackend(); + explicit LocalAsyncBackend(std::shared_ptr probe); + + void shutdown() noexcept override; + bool check_network(NetworkCompletion completion) const override; + bool send_chat( + UiConfig config, std::string message, + ApprovalHandler approval_handler, + OperationCompletion completion) const override; + bool pair(UiConfig config, std::string code, + OperationCompletion completion) const override; + OperationResult setup( + UiConfig config, ProviderConfig provider, + const ProgressHandler &progress_handler) const override; + +private: + std::shared_ptr cancellation_; + std::shared_ptr processes_; + std::shared_ptr http_client_; + mutable ConnectivityChecker connectivity_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_local_binary_installer_backend.cpp b/projects/ZClaw/main/ui/zclaw_local_binary_installer_backend.cpp new file mode 100644 index 00000000..32225254 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_local_binary_installer_backend.cpp @@ -0,0 +1,137 @@ +#include "zclaw_local_binary_installer_backend.h" + +#include "zclaw_archive_installer.h" +#include "zclaw_binary_archive_cache.h" +#include "zclaw_file_downloader.h" +#include "zclaw_paths.h" +#include "zclaw_process_executor.h" +#include "zclaw_temporary_directory.h" + +#include +#include +#include +#include + +namespace zclaw { +namespace { + +constexpr const char *kZeroClawReleaseUrl = + "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.8.2/" + "zeroclaw-aarch64-unknown-linux-gnu.tar.gz"; +constexpr const char *kZeroClawArchiveSha256 = + "d395ccb57e6d94c26a96d565183b7965f326d741244503b4dac2fa7c3124ec14"; + +} // namespace + +LocalBinaryInstallerBackend::LocalBinaryInstallerBackend( + std::shared_ptr cancellation, + std::shared_ptr processes, + std::shared_ptr http_client) + : cancellation_(std::move(cancellation)), processes_(std::move(processes)), + http_client_(std::move(http_client)) +{ +} + +bool LocalBinaryInstallerBackend::prepare_destination(std::string *error) const +{ + const std::filesystem::path directory = + std::filesystem::path(paths::zeroclaw_binary()).parent_path(); + std::error_code filesystem_error; + std::filesystem::create_directories(directory, filesystem_error); + if (!filesystem_error) + std::filesystem::permissions( + directory, std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, filesystem_error); + if (!filesystem_error) + return cleanup_binary_install_artifacts(paths::zeroclaw_dir(), error); + if (error) + *error = "Cannot prepare ZeroClaw binary directory: " + + filesystem_error.message(); + return false; +} + +bool LocalBinaryInstallerBackend::binary_ready() const +{ + return std::filesystem::is_regular_file(paths::zeroclaw_binary()) && + ::access(paths::zeroclaw_binary().c_str(), X_OK) == 0; +} + +bool LocalBinaryInstallerBackend::install( + std::string *error, const ProgressHandler &progress_handler) const +{ + const std::string cache_directory = paths::zeroclaw_dir(); + const std::string archive_path = paths::zeroclaw_archive(); + bool cached_archive = + sha256_file_matches(archive_path, kZeroClawArchiveSha256); + if (!cached_archive) { + std::error_code remove_error; + std::filesystem::remove(archive_path, remove_error); + if (remove_error) { + if (error) + *error = "Cannot remove invalid download cache: " + + remove_error.message(); + return false; + } + } + + std::unique_ptr temporary = TemporaryDirectory::create( + cache_directory, "zeroclaw-install", error); + if (!temporary) + return false; + + if (!cached_archive) { + const std::string partial_path = archive_path + ".part"; + if (progress_handler) { + progress_handler({BinaryInstallPhase::Downloading, 0, 0, 0.0, + kZeroClawReleaseUrl, archive_path}); + } + if (!FileDownloader(cancellation_).download( + kZeroClawReleaseUrl, partial_path, error, + [&progress_handler, &archive_path]( + const DownloadProgress &progress) { + if (progress_handler) { + progress_handler({ + BinaryInstallPhase::Downloading, + progress.downloaded_bytes, + progress.total_bytes, + progress.bytes_per_second, + kZeroClawReleaseUrl, + archive_path, + }); + } + })) { + return false; + } + if (!sha256_file_matches(partial_path, kZeroClawArchiveSha256)) { + std::error_code ignored; + std::filesystem::remove(partial_path, ignored); + if (error) + *error = "Downloaded archive checksum mismatch."; + return false; + } + std::error_code rename_error; + std::filesystem::rename(partial_path, archive_path, rename_error); + if (rename_error) { + std::error_code ignored; + std::filesystem::remove(partial_path, ignored); + if (error) + *error = "Cannot save download cache: " + + rename_error.message(); + return false; + } + } + + if (progress_handler) + progress_handler({BinaryInstallPhase::Installing, 0, 0, 0.0}); + ArchiveInstaller installer; + if (processes_) { + installer = ArchiveInstaller([processes = processes_]( + const ArchiveInstaller::Command &command) { + return processes->run(command); + }); + } + return installer.install_executable( + archive_path, temporary->path(), "zeroclaw", paths::zeroclaw_binary(), error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_local_binary_installer_backend.h b/projects/ZClaw/main/ui/zclaw_local_binary_installer_backend.h new file mode 100644 index 00000000..6af2c9a4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_local_binary_installer_backend.h @@ -0,0 +1,32 @@ +#pragma once + +#include "zclaw_binary_installer_backend.h" + +#include + +namespace zclaw { + +class HttpCancellationRegistry; +class ProcessExecutor; +class LhvHttpClient; + +class LocalBinaryInstallerBackend final : public BinaryInstallerBackend { +public: + LocalBinaryInstallerBackend() = default; + explicit LocalBinaryInstallerBackend( + std::shared_ptr cancellation, + std::shared_ptr processes = nullptr, + std::shared_ptr http_client = nullptr); + + bool prepare_destination(std::string *error) const override; + bool binary_ready() const override; + bool install(std::string *error, + const ProgressHandler &progress_handler) const override; + +private: + std::shared_ptr cancellation_; + std::shared_ptr processes_; + std::shared_ptr http_client_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_local_quickstart_backend.cpp b/projects/ZClaw/main/ui/zclaw_local_quickstart_backend.cpp new file mode 100644 index 00000000..f2827b15 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_local_quickstart_backend.cpp @@ -0,0 +1,50 @@ +#include "zclaw_local_quickstart_backend.h" + +#include "zclaw_local_setup_backend.h" +#include "zclaw_pairing_service.h" +#include "zclaw_lhv_http_client.h" +#include "zclaw_quickstart_service.h" + +#include +#include + +namespace zclaw { + +LocalQuickstartBackend::LocalQuickstartBackend( + std::shared_ptr cancellation, + std::shared_ptr processes, + std::shared_ptr http_client) + : cancellation_(std::move(cancellation)), processes_(std::move(processes)), + http_client_(std::move(http_client)) +{ +} + +PreparedSetup LocalQuickstartBackend::prepare( + UiConfig config, ProviderConfig provider, + const ProgressHandler &progress_handler) const +{ + const LocalSetupBackend backend(cancellation_, processes_, http_client_); + return SetupService(backend).prepare( + std::move(config), std::move(provider), progress_handler); +} + +OperationResult LocalQuickstartBackend::pair( + UiConfig config, const std::string &pairing_code) const +{ + if (!http_client_) + return {"Pairing request failed.\nHTTP client unavailable.", false, + std::move(config)}; + auto promise = std::make_shared>(); + std::future result = promise->get_future(); + if (!PairingService(http_client_).pair( + std::move(config), pairing_code, + [promise](OperationResult paired) { + promise->set_value(std::move(paired)); + })) { + return {"Pairing request failed.\nRequest was not submitted.", false, + {}}; + } + return result.get(); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_local_quickstart_backend.h b/projects/ZClaw/main/ui/zclaw_local_quickstart_backend.h new file mode 100644 index 00000000..fd9778b4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_local_quickstart_backend.h @@ -0,0 +1,32 @@ +#pragma once + +#include "zclaw_quickstart_backend.h" + +#include + +namespace zclaw { + +class HttpCancellationRegistry; +class ProcessExecutor; +class LhvHttpClient; + +class LocalQuickstartBackend final : public QuickstartBackend { +public: + LocalQuickstartBackend() = default; + explicit LocalQuickstartBackend( + std::shared_ptr cancellation, + std::shared_ptr processes = nullptr, + std::shared_ptr http_client = nullptr); + + PreparedSetup prepare(UiConfig config, ProviderConfig provider, + const ProgressHandler &progress_handler) const override; + OperationResult pair(UiConfig config, + const std::string &pairing_code) const override; + +private: + std::shared_ptr cancellation_; + std::shared_ptr processes_; + std::shared_ptr http_client_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_local_setup_backend.cpp b/projects/ZClaw/main/ui/zclaw_local_setup_backend.cpp new file mode 100644 index 00000000..40234c88 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_local_setup_backend.cpp @@ -0,0 +1,63 @@ +#include "zclaw_local_setup_backend.h" + +#include "zclaw_binary_installer.h" +#include "zclaw_cli_service.h" +#include "zclaw_local_binary_installer_backend.h" +#include "zclaw_process_executor.h" + +#include +#include + +namespace zclaw { +namespace { + +CliService cli_service(const std::shared_ptr &processes) +{ + if (!processes) + return CliService(); + return CliService( + [processes](const CliService::Command &command) { + return processes->run(command); + }, + [processes](unsigned int seconds) { processes->wait(seconds); }); +} + +} // namespace + +LocalSetupBackend::LocalSetupBackend( + std::shared_ptr cancellation, + std::shared_ptr processes, + std::shared_ptr http_client) + : cancellation_(std::move(cancellation)), processes_(std::move(processes)), + http_client_(std::move(http_client)) +{ +} + +bool LocalSetupBackend::ensure_installed( + std::string *error, const ProgressHandler &progress_handler) const +{ + return BinaryInstaller( + std::make_shared(cancellation_, + processes_, + http_client_)) + .ensure_installed(error, progress_handler); +} + +bool LocalSetupBackend::apply_config(UiConfig *config, + const ProviderConfig &provider, + std::string *error) const +{ + return cli_service(processes_).apply_config(config, provider, error); +} + +bool LocalSetupBackend::start_service(std::string *error) const +{ + return cli_service(processes_).start_service(error); +} + +std::string LocalSetupBackend::generate_pairing_code() const +{ + return cli_service(processes_).generate_pairing_code(); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_local_setup_backend.h b/projects/ZClaw/main/ui/zclaw_local_setup_backend.h new file mode 100644 index 00000000..01a9593b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_local_setup_backend.h @@ -0,0 +1,34 @@ +#pragma once + +#include "zclaw_setup_backend.h" + +#include + +namespace zclaw { + +class HttpCancellationRegistry; +class ProcessExecutor; +class LhvHttpClient; + +class LocalSetupBackend final : public SetupBackend { +public: + LocalSetupBackend() = default; + explicit LocalSetupBackend( + std::shared_ptr cancellation, + std::shared_ptr processes = nullptr, + std::shared_ptr http_client = nullptr); + + bool ensure_installed(std::string *error, + const ProgressHandler &progress_handler) const override; + bool apply_config(UiConfig *config, const ProviderConfig &provider, + std::string *error) const override; + bool start_service(std::string *error) const override; + std::string generate_pairing_code() const override; + +private: + std::shared_ptr cancellation_; + std::shared_ptr processes_; + std::shared_ptr http_client_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_pairing_service.cpp b/projects/ZClaw/main/ui/zclaw_pairing_service.cpp new file mode 100644 index 00000000..61ab8216 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_pairing_service.cpp @@ -0,0 +1,54 @@ +#include "zclaw_pairing_service.h" + +#include "zclaw_gateway_response_model.h" +#include "zclaw_http_client_policy.h" +#include "zclaw_lhv_http_client.h" +#include "zclaw_protocol.h" + +#include + +namespace zclaw { + +PairingService::PairingService( + std::shared_ptr client) + : client_(std::move(client)) +{ +} + +bool PairingService::pair(UiConfig config, const std::string &code, + Completion completion) const +{ + OperationResult result; + result.config = std::move(config); + if (!completion || !client_) + return false; + protocol::HttpUrl url; + if (!protocol::split_http_url( + protocol::gateway_http_base(result.config) + "/pair", &url)) { + result.text = "Pairing request failed.\nInvalid URL."; + completion(std::move(result)); + return true; + } + + auto request = std::make_shared(); + request->method = HTTP_POST; + request->url = url.base + url.path; + request->headers["X-Pairing-Code"] = code; + request->headers["Content-Type"] = "application/octet-stream"; + configure_http_request(*request, HttpClientProfile::Pairing); + return client_->send( + std::move(request), + [result = std::move(result), completion = std::move(completion)]( + const HttpResponsePtr &response) mutable { + if (!response) { + result.text = "Pairing request failed.\nRequest failed."; + completion(std::move(result)); + return; + } + completion(interpret_pairing_response( + std::move(result.config), response->status_code, + response->body)); + }); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_pairing_service.h b/projects/ZClaw/main/ui/zclaw_pairing_service.h new file mode 100644 index 00000000..ecf1e088 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_pairing_service.h @@ -0,0 +1,27 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include +#include + +namespace zclaw { + +class HttpCancellationRegistry; +class LhvHttpClient; + +class PairingService { +public: + PairingService() = default; + explicit PairingService(std::shared_ptr client); + + using Completion = std::function; + bool pair(UiConfig config, const std::string &code, + Completion completion) const; + +private: + std::shared_ptr client_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_panel_lifecycle.h b/projects/ZClaw/main/ui/zclaw_panel_lifecycle.h new file mode 100644 index 00000000..1a20b54d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_panel_lifecycle.h @@ -0,0 +1,64 @@ +#pragma once + +namespace zclaw { + +class PanelLifecycle { +public: + bool mount() + { + if (phase_ != Phase::Empty) + return false; + phase_ = Phase::Mounted; + return true; + } + + bool begin_open() + { + if (phase_ != Phase::Mounted) + return false; + phase_ = Phase::Opening; + return true; + } + + bool begin_close() + { + if (phase_ != Phase::Mounted && phase_ != Phase::Open) + return false; + phase_ = Phase::Closing; + return true; + } + + bool complete_animation() + { + if (phase_ == Phase::Opening) { + phase_ = Phase::Open; + return false; + } + if (phase_ == Phase::Closing) { + phase_ = Phase::Empty; + return true; + } + return false; + } + + void release() + { + phase_ = Phase::Empty; + } + + bool mounted() const + { + return phase_ != Phase::Empty; + } + + bool animating() const + { + return phase_ == Phase::Opening || phase_ == Phase::Closing; + } + +private: + enum class Phase { Empty, Mounted, Opening, Open, Closing }; + Phase phase_ = Phase::Empty; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_path_model.cpp b/projects/ZClaw/main/ui/zclaw_path_model.cpp new file mode 100644 index 00000000..2676e27b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_path_model.cpp @@ -0,0 +1,33 @@ +#include "zclaw_path_model.h" + +#include + +namespace zclaw { + +std::string select_home_directory(const std::string &environment_home, + const std::string &account_home) +{ + if (!environment_home.empty()) + return environment_home; + if (!account_home.empty()) + return account_home; + return "."; +} + +ZClawPaths make_zclaw_paths(const std::string &home_directory) +{ + namespace fs = std::filesystem; + ZClawPaths paths; + const fs::path home = home_directory.empty() ? fs::path(".") + : fs::path(home_directory); + const fs::path zeroclaw = home / ".zeroclaw"; + paths.home = home.string(); + paths.zeroclaw_directory = zeroclaw.string(); + paths.providers_config = (zeroclaw / "zclaw_providers.tsv").string(); + paths.ui_config = (zeroclaw / "zclaw_ui.tsv").string(); + paths.zeroclaw_config = (zeroclaw / "config.toml").string(); + paths.zeroclaw_binary = (zeroclaw / "bin" / "zeroclaw").string(); + return paths; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_path_model.h b/projects/ZClaw/main/ui/zclaw_path_model.h new file mode 100644 index 00000000..6a4dddbb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_path_model.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace zclaw { + +struct ZClawPaths { + std::string home; + std::string zeroclaw_directory; + std::string providers_config; + std::string ui_config; + std::string zeroclaw_config; + std::string zeroclaw_binary; +}; + +std::string select_home_directory(const std::string &environment_home, + const std::string &account_home); +ZClawPaths make_zclaw_paths(const std::string &home_directory); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_paths.cpp b/projects/ZClaw/main/ui/zclaw_paths.cpp new file mode 100644 index 00000000..2cb69fc4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_paths.cpp @@ -0,0 +1,78 @@ +#include "zclaw_paths.h" + +#include "zclaw_path_model.h" + +#include +#include +#include +#include +#include + +namespace zclaw::paths { +namespace { + +std::string account_home_directory() +{ + long buffer_size = ::sysconf(_SC_GETPW_R_SIZE_MAX); + if (buffer_size < 0) + buffer_size = 16384; + std::vector buffer(static_cast(buffer_size)); + while (buffer.size() <= 1024U * 1024U) { + struct passwd account {}; + struct passwd *result = nullptr; + const int status = ::getpwuid_r(::geteuid(), &account, buffer.data(), + buffer.size(), &result); + if (status == 0) + return result && account.pw_dir ? account.pw_dir : ""; + if (status != ERANGE) + return ""; + buffer.resize(buffer.size() * 2U); + } + return ""; +} + +ZClawPaths current_paths() +{ + const char *environment_home = std::getenv("HOME"); + return make_zclaw_paths(select_home_directory( + environment_home ? environment_home : "", account_home_directory())); +} + +} // namespace + +std::string home_dir() +{ + return current_paths().home; +} + +std::string zeroclaw_dir() +{ + return current_paths().zeroclaw_directory; +} + +std::string providers_config() +{ + return current_paths().providers_config; +} + +std::string ui_config() +{ + return current_paths().ui_config; +} + +std::string zeroclaw_config() +{ + return current_paths().zeroclaw_config; +} + +std::string zeroclaw_binary() +{ + return current_paths().zeroclaw_binary; +} + +std::string zeroclaw_archive() +{ + return zeroclaw_dir() + "/zeroclaw.tar.gz"; +} + +} // namespace zclaw::paths diff --git a/projects/ZClaw/main/ui/zclaw_paths.h b/projects/ZClaw/main/ui/zclaw_paths.h new file mode 100644 index 00000000..f052f699 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_paths.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace zclaw::paths { + +std::string home_dir(); +std::string zeroclaw_dir(); +std::string providers_config(); +std::string ui_config(); +std::string zeroclaw_config(); +std::string zeroclaw_binary(); +std::string zeroclaw_archive(); + +} // namespace zclaw::paths diff --git a/projects/ZClaw/main/ui/zclaw_process_executor.cpp b/projects/ZClaw/main/ui/zclaw_process_executor.cpp new file mode 100644 index 00000000..f8888732 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_process_executor.cpp @@ -0,0 +1,172 @@ +#include "zclaw_process_executor.h" + +#include "zclaw_text.h" + +#include +#include + +#include +#include +#include +#include + +namespace zclaw { +namespace { + +CommandResult cancelled_result() +{ + return {-1, "command cancelled"}; +} + +bool close_on_exec(int descriptor) +{ + const int flags = ::fcntl(descriptor, F_GETFD); + return flags >= 0 && ::fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) == 0; +} + +} // namespace + +ProcessExecutor::~ProcessExecutor() +{ + shutdown(); +} + +CommandResult ProcessExecutor::run(const std::vector &arguments) +{ + if (arguments.empty() || arguments.front().empty()) + return {-1, "missing command"}; + std::vector argv; + argv.reserve(arguments.size() + 1); + for (const std::string &argument : arguments) + argv.push_back(const_cast(argument.c_str())); + argv.push_back(nullptr); + + int output_pipe[2] = {-1, -1}; + pid_t process = -1; + { + std::lock_guard lock(mutex_); + if (shutdown_) + return cancelled_result(); + if (::pipe(output_pipe) != 0) + return {-1, "failed to create command pipe"}; + if (!close_on_exec(output_pipe[0]) || !close_on_exec(output_pipe[1])) { + ::close(output_pipe[0]); + ::close(output_pipe[1]); + return {-1, "failed to configure command pipe"}; + } + process = ::fork(); + if (process > 0) { + ::setpgid(process, process); + try { + active_processes_.insert(process); + } catch (...) { + ::kill(-process, SIGKILL); + ::kill(process, SIGKILL); + ::close(output_pipe[0]); + ::close(output_pipe[1]); + while (::waitpid(process, nullptr, 0) < 0 && errno == EINTR) { + } + throw; + } + } + } + + if (process == 0) { + ::setpgid(0, 0); + ::close(output_pipe[0]); + ::dup2(output_pipe[1], STDOUT_FILENO); + ::dup2(output_pipe[1], STDERR_FILENO); + ::close(output_pipe[1]); + ::execvp(argv[0], argv.data()); + ::_exit(127); + } + + ::close(output_pipe[1]); + if (process < 0) { + ::close(output_pipe[0]); + return {-1, "failed to start command"}; + } + + const pid_t active_process = process; + bool reaped = false; + CommandResult result; + try { + bool read_failed = false; + char buffer[256]; + while (true) { + const ssize_t bytes = ::read(output_pipe[0], buffer, sizeof(buffer)); + if (bytes > 0) { + result.output.append(buffer, static_cast(bytes)); + continue; + } + if (bytes < 0 && errno == EINTR) + continue; + read_failed = bytes < 0; + break; + } + ::close(output_pipe[0]); + output_pipe[0] = -1; + + if (read_failed) { + ::kill(-process, SIGKILL); + ::kill(process, SIGKILL); + } + + int status = -1; + while (::waitpid(process, &status, 0) < 0 && errno == EINTR) { + } + reaped = true; + result.status = status; + result.output = trim_ascii_whitespace(result.output); + } catch (...) { + if (output_pipe[0] >= 0) + ::close(output_pipe[0]); + if (!reaped) { + ::kill(-process, SIGKILL); + ::kill(process, SIGKILL); + while (::waitpid(process, nullptr, 0) < 0 && errno == EINTR) { + } + } + { + std::lock_guard lock(mutex_); + active_processes_.erase(active_process); + } + changed_.notify_all(); + throw; + } + { + std::lock_guard lock(mutex_); + active_processes_.erase(active_process); + } + changed_.notify_all(); + return result; +} + +void ProcessExecutor::wait(unsigned int seconds) const +{ + std::unique_lock lock(mutex_); + changed_.wait_for(lock, std::chrono::seconds(seconds), + [this] { return shutdown_; }); +} + +void ProcessExecutor::shutdown() noexcept +{ + std::unique_lock lock(mutex_); + if (!shutdown_) { + shutdown_ = true; + for (pid_t process : active_processes_) { + if (::kill(-process, SIGKILL) != 0) + ::kill(process, SIGKILL); + } + changed_.notify_all(); + } + changed_.wait(lock, [this] { return active_processes_.empty(); }); +} + +bool ProcessExecutor::shutdown_requested() const +{ + std::lock_guard lock(mutex_); + return shutdown_; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_process_executor.h b/projects/ZClaw/main/ui/zclaw_process_executor.h new file mode 100644 index 00000000..796545ea --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_process_executor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "zclaw_process_runner.h" + +#include +#include +#include +#include +#include + +#include + +namespace zclaw { + +class ProcessExecutor { +public: + ProcessExecutor() = default; + ~ProcessExecutor(); + + ProcessExecutor(const ProcessExecutor &) = delete; + ProcessExecutor &operator=(const ProcessExecutor &) = delete; + + CommandResult run(const std::vector &arguments); + void wait(unsigned int seconds) const; + void shutdown() noexcept; + bool shutdown_requested() const; + +private: + mutable std::mutex mutex_; + mutable std::condition_variable changed_; + std::set active_processes_; + bool shutdown_ = false; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_process_runner.cpp b/projects/ZClaw/main/ui/zclaw_process_runner.cpp new file mode 100644 index 00000000..d882a5b2 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_process_runner.cpp @@ -0,0 +1,38 @@ +#include "zclaw_process_runner.h" + +#include "zclaw_text.h" + +#include + +namespace zclaw { + +bool CommandResult::ok() const +{ + return status == 0; +} + +std::string ProcessRunner::shell_quote(const std::string &value) +{ + std::string output = "'"; + for (char character : value) + output += character == '\'' ? "'\\''" : std::string(1, character); + return output + "'"; +} + +CommandResult ProcessRunner::run_shell(const std::string &command) +{ + CommandResult result; + FILE *pipe = ::popen((command + " 2>&1").c_str(), "r"); + if (!pipe) { + result.output = "failed to start command"; + return result; + } + char buffer[256]; + while (std::fgets(buffer, sizeof(buffer), pipe)) + result.output += buffer; + result.status = ::pclose(pipe); + result.output = trim_ascii_whitespace(result.output); + return result; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_process_runner.h b/projects/ZClaw/main/ui/zclaw_process_runner.h new file mode 100644 index 00000000..52895347 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_process_runner.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace zclaw { + +struct CommandResult { + int status = -1; + std::string output; + + bool ok() const; +}; + +class ProcessRunner { +public: + static std::string shell_quote(const std::string &value); + static CommandResult run_shell(const std::string &command); +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_protocol.cpp b/projects/ZClaw/main/ui/zclaw_protocol.cpp new file mode 100644 index 00000000..dddfb62e --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_protocol.cpp @@ -0,0 +1,95 @@ +#include "zclaw_protocol.h" + +#include "json.hpp" + +namespace zclaw::protocol { +namespace { + +using Json = nlohmann::json; + +std::string url_encode(const std::string &value) +{ + static constexpr char hex[] = "0123456789ABCDEF"; + std::string out; + for (unsigned char ch : value) { + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') { + out += static_cast(ch); + } else { + out += '%'; + out += hex[ch >> 4]; + out += hex[ch & 0x0F]; + } + } + return out; +} + +} // namespace + +bool split_http_url(const std::string &url, HttpUrl *out) +{ + if (!out) + return false; + const std::size_t scheme = url.find("://"); + if (scheme == std::string::npos) + return false; + const std::size_t authority = scheme + 3; + const std::size_t path = url.find('/', authority); + out->base = path == std::string::npos ? url : url.substr(0, path); + out->path = path == std::string::npos ? "/" : url.substr(path); + return out->base.rfind("http://", 0) == 0 || out->base.rfind("https://", 0) == 0; +} + +std::string gateway_http_base(const UiConfig &config) +{ + std::string url = config.webhook_url.empty() ? "http://127.0.0.1:42617/webhook" : + config.webhook_url; + const std::size_t scheme = url.find("://"); + const std::size_t path = scheme == std::string::npos ? url.find('/') : + url.find('/', scheme + 3); + if (path != std::string::npos) + url.resize(path); + return url; +} + +std::string gateway_ws_url(const UiConfig &config) +{ + std::string base = gateway_http_base(config); + if (base.rfind("https://", 0) == 0) + base.replace(0, 5, "wss"); + else if (base.rfind("http://", 0) == 0) + base.replace(0, 4, "ws"); + return base + "/ws/chat?agent=" + url_encode(config.agent_alias) + + "&token=" + url_encode(config.bearer_token) + + "&session_id=zclaw-ui&name=ZClaw"; +} + +std::string webhook_endpoint(const UiConfig &config) +{ + std::string url = config.webhook_url.empty() ? "http://127.0.0.1:42617/webhook" : + config.webhook_url; + if (url.find('?') == std::string::npos) + url += "?agent=" + url_encode(config.agent_alias); + return url; +} + +std::string make_webhook_message(const std::string &message) +{ + return Json{{"message", message}}.dump(); +} + +std::string make_ws_chat_message(const std::string &message) +{ + return Json{{"type", "message"}, {"content", message}}.dump(); +} + +std::string make_ws_approval_response(const std::string &request_id, + const std::string &decision) +{ + return Json{{"type", "approval_response"}, + {"request_id", request_id}, + {"decision", decision.empty() ? "deny" : decision}} + .dump(); +} + +} // namespace zclaw::protocol diff --git a/projects/ZClaw/main/ui/zclaw_protocol.h b/projects/ZClaw/main/ui/zclaw_protocol.h new file mode 100644 index 00000000..797af48b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_protocol.h @@ -0,0 +1,24 @@ +#pragma once + +#include "zclaw_types.h" + +#include + +namespace zclaw::protocol { + +struct HttpUrl { + std::string base; + std::string path; +}; + +bool split_http_url(const std::string &url, HttpUrl *out); +std::string gateway_http_base(const UiConfig &config); +std::string gateway_ws_url(const UiConfig &config); +std::string webhook_endpoint(const UiConfig &config); + +std::string make_webhook_message(const std::string &message); +std::string make_ws_chat_message(const std::string &message); +std::string make_ws_approval_response(const std::string &request_id, + const std::string &decision); + +} // namespace zclaw::protocol diff --git a/projects/ZClaw/main/ui/zclaw_provider_catalog.cpp b/projects/ZClaw/main/ui/zclaw_provider_catalog.cpp new file mode 100644 index 00000000..9f948913 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_catalog.cpp @@ -0,0 +1,63 @@ +#include "zclaw_provider_catalog.h" + +#include + +namespace zclaw { +namespace { + +struct ProviderPreset { + const char *display_name; + ProviderConfig config; +}; + +const std::array kProviderPresets = {{ + {"OpenAI", {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", ""}}, + {"OpenRouter", {"zclaw", "openrouter", "openrouter/auto", "https://openrouter.ai/api/v1", ""}}, + {"Anthropic", {"zclaw", "anthropic", "claude-sonnet-4", "https://api.anthropic.com", ""}}, + {"Ollama", {"zclaw", "ollama", "llama3.1", "http://127.0.0.1:11434", ""}}, + {"DeepSeek", {"zclaw", "deepseek", "deepseek-chat", "https://api.deepseek.com", ""}}, + {"Custom", {"zclaw", "custom", "", "https://api.example.com/v1", ""}}, +}}; + +} // namespace + +std::size_t provider_preset_count() +{ + return kProviderPresets.size(); +} + +ProviderConfig provider_preset(std::size_t index) +{ + if (index >= kProviderPresets.size()) + index = kProviderPresets.size() - 1; + return kProviderPresets[index].config; +} + +std::size_t provider_preset_index(const std::string &family) +{ + for (std::size_t index = 0; index < kProviderPresets.size(); ++index) { + if (kProviderPresets[index].config.family == family) + return index; + } + return kProviderPresets.size() - 1; +} + +const char *provider_display_name(const std::string &family) +{ + for (const ProviderPreset &preset : kProviderPresets) { + if (preset.config.family == family) + return preset.display_name; + } + return "Custom"; +} + +std::vector default_provider_configs() +{ + std::vector configs; + configs.reserve(kProviderPresets.size()); + for (const ProviderPreset &preset : kProviderPresets) + configs.push_back(preset.config); + return configs; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_catalog.h b/projects/ZClaw/main/ui/zclaw_provider_catalog.h new file mode 100644 index 00000000..ec565766 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_catalog.h @@ -0,0 +1,17 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include +#include + +namespace zclaw { + +std::size_t provider_preset_count(); +ProviderConfig provider_preset(std::size_t index); +std::size_t provider_preset_index(const std::string &family); +const char *provider_display_name(const std::string &family); +std::vector default_provider_configs(); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_collection_model.cpp b/projects/ZClaw/main/ui/zclaw_provider_collection_model.cpp new file mode 100644 index 00000000..b7d242ae --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_collection_model.cpp @@ -0,0 +1,60 @@ +#include "zclaw_provider_collection_model.h" + +#include + +namespace zclaw { + +void activate_provider_config(std::vector *providers, + ProviderConfig *active, + const ProviderConfig &selected_default) +{ + if (!providers || !active) + return; + + if (providers->empty()) + providers->push_back(*active); + else + (*providers)[0] = *active; + + if (active->family == selected_default.family) + return; + + const auto existing = std::find_if( + providers->begin() + 1, providers->end(), + [&](const ProviderConfig &provider) { + return provider.family == selected_default.family; + }); + ProviderConfig selected = selected_default; + if (existing != providers->end()) { + selected = *existing; + providers->erase(existing); + } + providers->insert(providers->begin(), selected); + *active = selected; +} + +bool replace_provider_config(std::vector *providers, + ProviderConfig *active, std::size_t index, + const ProviderConfig &replacement) +{ + if (!providers || !active || index >= providers->size()) + return false; + (*providers)[index] = replacement; + if (index == 0) + *active = replacement; + return true; +} + +bool erase_provider_config(std::vector *providers, + ProviderConfig *active, std::size_t index, + const ProviderConfig &empty_default) +{ + if (!providers || !active || index >= providers->size()) + return false; + providers->erase(providers->begin() + static_cast(index)); + if (index == 0) + *active = providers->empty() ? empty_default : (*providers)[0]; + return true; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_collection_model.h b/projects/ZClaw/main/ui/zclaw_provider_collection_model.h new file mode 100644 index 00000000..904b0e1b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_collection_model.h @@ -0,0 +1,20 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +void activate_provider_config(std::vector *providers, + ProviderConfig *active, + const ProviderConfig &selected_default); +bool replace_provider_config(std::vector *providers, + ProviderConfig *active, std::size_t index, + const ProviderConfig &replacement); +bool erase_provider_config(std::vector *providers, + ProviderConfig *active, std::size_t index, + const ProviderConfig &empty_default); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_form_model.cpp b/projects/ZClaw/main/ui/zclaw_provider_form_model.cpp new file mode 100644 index 00000000..e7d39d36 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_form_model.cpp @@ -0,0 +1,99 @@ +#include "zclaw_provider_form_model.h" + +namespace zclaw { + +ProviderEditField provider_edit_field_for_row(int row) +{ + switch (row) { + case 0: return ProviderEditField::Alias; + case 1: return ProviderEditField::Family; + case 2: return ProviderEditField::Model; + case 3: return ProviderEditField::Uri; + case 4: return ProviderEditField::ApiKey; + default: return ProviderEditField::None; + } +} + +const char *provider_field_name(ProviderEditField field) +{ + switch (field) { + case ProviderEditField::Alias: return "Alias"; + case ProviderEditField::Family: return "Family"; + case ProviderEditField::Model: return "Model"; + case ProviderEditField::Uri: return "URI"; + case ProviderEditField::ApiKey: return "API Key"; + case ProviderEditField::None: return ""; + } + return ""; +} + +std::string *provider_field_value(ProviderConfig *provider, ProviderEditField field) +{ + if (!provider) + return nullptr; + switch (field) { + case ProviderEditField::Alias: return &provider->alias; + case ProviderEditField::Family: return &provider->family; + case ProviderEditField::Model: return &provider->model; + case ProviderEditField::Uri: return &provider->uri; + case ProviderEditField::ApiKey: return &provider->api_key; + case ProviderEditField::None: return nullptr; + } + return nullptr; +} + +SetupEditField setup_edit_field_for_row(const ProviderConfig &provider, int selected_row) +{ + if (selected_row <= 0) + return SetupEditField::None; + + int row = 1; + if (provider.family == "custom" && selected_row == row++) + return SetupEditField::Uri; + if (provider.family != "ollama" && selected_row == row++) + return SetupEditField::ApiKey; + if (provider.family == "custom" && selected_row == row) + return SetupEditField::Model; + return SetupEditField::None; +} + +const char *setup_field_name(SetupEditField field) +{ + switch (field) { + case SetupEditField::Uri: return "API URL"; + case SetupEditField::ApiKey: return "API Key"; + case SetupEditField::Model: return "API Model Name"; + case SetupEditField::None: return ""; + } + return ""; +} + +std::string *setup_field_value(ProviderConfig *provider, SetupEditField field) +{ + if (!provider) + return nullptr; + switch (field) { + case SetupEditField::Uri: return &provider->uri; + case SetupEditField::ApiKey: return &provider->api_key; + case SetupEditField::Model: return &provider->model; + case SetupEditField::None: return nullptr; + } + return nullptr; +} + +SetupValidation validate_setup_provider(const ProviderConfig &provider) +{ + if (provider.family != "ollama" && provider.api_key.empty()) + return {false, provider.family == "custom" ? 2 : 1, "API Key is required."}; + + if (provider.family == "custom") { + if (provider.uri.rfind("http://", 0) != 0 && + provider.uri.rfind("https://", 0) != 0) + return {false, 1, "API URL must start with http:// or https://."}; + if (provider.model.empty()) + return {false, 3, "API Model Name is required."}; + } + return {}; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_form_model.h b/projects/ZClaw/main/ui/zclaw_provider_form_model.h new file mode 100644 index 00000000..654005aa --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_form_model.h @@ -0,0 +1,40 @@ +#pragma once + +#include "zclaw_types.h" + +#include + +namespace zclaw { + +enum class ProviderEditField { + None, + Alias, + Family, + Model, + Uri, + ApiKey, +}; + +enum class SetupEditField { + None, + Uri, + ApiKey, + Model, +}; + +struct SetupValidation { + bool valid = true; + int row = 0; + std::string error; +}; + +ProviderEditField provider_edit_field_for_row(int row); +const char *provider_field_name(ProviderEditField field); +std::string *provider_field_value(ProviderConfig *provider, ProviderEditField field); + +SetupEditField setup_edit_field_for_row(const ProviderConfig &provider, int row); +const char *setup_field_name(SetupEditField field); +std::string *setup_field_value(ProviderConfig *provider, SetupEditField field); +SetupValidation validate_setup_provider(const ProviderConfig &provider); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_manager.cpp b/projects/ZClaw/main/ui/zclaw_provider_manager.cpp new file mode 100644 index 00000000..99f641d8 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_manager.cpp @@ -0,0 +1,115 @@ +#include "zclaw_provider_manager.h" + +#include "zclaw_provider_catalog.h" +#include "zclaw_provider_collection_model.h" +#include "zclaw_provider_store.h" +#include "zclaw_storage.h" + +#include + +namespace zclaw { + +ProviderManager::ProviderManager(std::string storage_path) + : storage_path_(std::move(storage_path)) +{ +} + +ConfigStoreLoadStatus ProviderManager::load(std::string *error) +{ + std::vector loaded; + const ConfigStoreLoadStatus status = + load_provider_configs_with_status(storage_path_, &loaded, error); + if (status == ConfigStoreLoadStatus::Loaded) + providers_ = std::move(loaded); + else if (status == ConfigStoreLoadStatus::NotFound || providers_.empty()) + providers_ = default_provider_configs(); + setup_provider_ = providers_.empty() ? provider_preset(0) : providers_.front(); + return status; +} + +const std::vector &ProviderManager::providers() const +{ + return providers_; +} + +const ProviderConfig &ProviderManager::setup_provider() const +{ + return setup_provider_; +} + +bool ProviderManager::update_setup_provider(const ProviderConfig &provider, std::string *error) +{ + std::vector candidate = providers_; + if (candidate.empty()) + candidate.push_back(provider); + else + candidate.front() = provider; + if (!persist(candidate, error)) + return false; + providers_ = std::move(candidate); + setup_provider_ = provider; + return true; +} + +bool ProviderManager::activate(const ProviderConfig &provider, std::string *error) +{ + std::vector candidate = providers_; + ProviderConfig active = setup_provider_; + activate_provider_config(&candidate, &active, provider); + if (!persist(candidate, error)) + return false; + providers_ = std::move(candidate); + setup_provider_ = std::move(active); + return true; +} + +bool ProviderManager::add(const ProviderConfig &provider, std::size_t *index, + std::string *error) +{ + std::vector candidate = providers_; + candidate.push_back(provider); + if (!persist(candidate, error)) + return false; + providers_ = std::move(candidate); + if (index) + *index = providers_.size() - 1; + return true; +} + +bool ProviderManager::erase(std::size_t index, const ProviderConfig &empty_default, + std::string *error) +{ + std::vector candidate = providers_; + ProviderConfig active = setup_provider_; + if (!erase_provider_config(&candidate, &active, index, empty_default)) + return false; + if (!persist(candidate, error)) + return false; + providers_ = std::move(candidate); + setup_provider_ = std::move(active); + return true; +} + +bool ProviderManager::replace(std::size_t index, const ProviderConfig &provider, + std::string *error) +{ + std::vector candidate = providers_; + ProviderConfig active = setup_provider_; + if (!replace_provider_config(&candidate, &active, index, provider)) + return false; + if (!persist(candidate, error)) + return false; + providers_ = std::move(candidate); + setup_provider_ = std::move(active); + return true; +} + +bool ProviderManager::persist(const std::vector &providers, + std::string *error) const +{ + if (!ensure_private_parent_directory(storage_path_, error)) + return false; + return save_provider_configs(storage_path_, providers, error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_manager.h b/projects/ZClaw/main/ui/zclaw_provider_manager.h new file mode 100644 index 00000000..c3358b17 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_manager.h @@ -0,0 +1,37 @@ +#pragma once + +#include "zclaw_config_store_status.h" +#include "zclaw_types.h" + +#include +#include +#include + +namespace zclaw { + +class ProviderManager { +public: + explicit ProviderManager(std::string storage_path); + + ConfigStoreLoadStatus load(std::string *error = nullptr); + const std::vector &providers() const; + const ProviderConfig &setup_provider() const; + + bool update_setup_provider(const ProviderConfig &provider, std::string *error = nullptr); + bool activate(const ProviderConfig &provider, std::string *error = nullptr); + bool add(const ProviderConfig &provider, std::size_t *index = nullptr, + std::string *error = nullptr); + bool erase(std::size_t index, const ProviderConfig &empty_default, + std::string *error = nullptr); + bool replace(std::size_t index, const ProviderConfig &provider, + std::string *error = nullptr); + +private: + bool persist(const std::vector &providers, std::string *error) const; + + std::string storage_path_; + std::vector providers_; + ProviderConfig setup_provider_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_store.cpp b/projects/ZClaw/main/ui/zclaw_provider_store.cpp index cdd96c68..6541d4f1 100644 --- a/projects/ZClaw/main/ui/zclaw_provider_store.cpp +++ b/projects/ZClaw/main/ui/zclaw_provider_store.cpp @@ -1,217 +1,49 @@ #include "zclaw_provider_store.h" -#include -#include -#include -#include -#include -#include -#include +#include "zclaw_atomic_file.h" +#include "zclaw_config_document_model.h" +#include "zclaw_text_file.h" namespace zclaw { -namespace { - -bool write_all(int fd, const std::string &data) -{ - size_t offset = 0; - while (offset < data.size()) { - const ssize_t written = ::write(fd, data.data() + offset, data.size() - offset); - if (written < 0) { - if (errno == EINTR) - continue; - return false; - } - if (written == 0) { - errno = EIO; - return false; - } - offset += static_cast(written); - } - return true; -} - -void set_error(std::string *error, const char *operation) -{ - if (error) - *error = std::string(operation) + ": " + std::strerror(errno); -} - -} // namespace - -std::string encode_config_field(const std::string &value) -{ - std::string out; - for (char ch : value) { - if (ch == '\\') - out += "\\\\"; - else if (ch == '\t') - out += "\\t"; - else if (ch == '\n') - out += "\\n"; - else - out += ch; - } - return out; -} - -std::string decode_config_field(const std::string &value) -{ - std::string out; - for (size_t i = 0; i < value.size(); ++i) { - if (value[i] == '\\' && i + 1 < value.size()) { - const char next = value[++i]; - if (next == 't') - out += '\t'; - else if (next == 'n') - out += '\n'; - else - out += next; - } else { - out += value[i]; - } - } - return out; -} - -std::vector split_config_line(const std::string &line) -{ - std::vector fields; - std::string current; - for (char ch : line) { - if (ch == '\t') { - fields.push_back(current); - current.clear(); - } else { - current += ch; - } - } - fields.push_back(current); - return fields; -} - -void activate_provider_config(std::vector *providers, ProviderConfig *active, - const ProviderConfig &selected_default) -{ - if (!providers || !active) - return; - - if (providers->empty()) - providers->push_back(*active); - else - (*providers)[0] = *active; - - if (active->family == selected_default.family) - return; - - const auto existing = std::find_if(providers->begin() + 1, providers->end(), - [&](const ProviderConfig &provider) { - return provider.family == selected_default.family; - }); - ProviderConfig selected = selected_default; - if (existing != providers->end()) { - selected = *existing; - providers->erase(existing); - } - providers->insert(providers->begin(), selected); - *active = selected; -} - -bool replace_provider_config(std::vector *providers, ProviderConfig *active, - std::size_t index, const ProviderConfig &replacement) -{ - if (!providers || !active || index >= providers->size()) - return false; - (*providers)[index] = replacement; - if (index == 0) - *active = replacement; - return true; -} - -bool erase_provider_config(std::vector *providers, ProviderConfig *active, - std::size_t index, const ProviderConfig &empty_default) -{ - if (!providers || !active || index >= providers->size()) - return false; - providers->erase(providers->begin() + static_cast(index)); - if (index == 0) - *active = providers->empty() ? empty_default : (*providers)[0]; - return true; -} bool load_provider_configs(const std::string &path, std::vector *providers) { - if (!providers) - return false; - providers->clear(); - std::ifstream file(path); - if (!file) - return false; - - std::string line; - while (std::getline(file, line)) { - if (line.empty()) - continue; - const std::vector fields = split_config_line(line); - if (fields.size() < 5) - continue; - providers->push_back({decode_config_field(fields[0]), decode_config_field(fields[1]), - decode_config_field(fields[2]), decode_config_field(fields[3]), - decode_config_field(fields[4])}); - } - return !file.bad(); + return load_provider_configs_with_status(path, providers) == + ConfigStoreLoadStatus::Loaded; } -bool atomic_write_config(const std::string &path, const std::string &contents, std::string *error) +ConfigStoreLoadStatus load_provider_configs_with_status( + const std::string &path, std::vector *providers, + std::string *error) { if (error) error->clear(); - - std::string temp_path = path + ".tmp.XXXXXX"; - std::vector temp_name(temp_path.begin(), temp_path.end()); - temp_name.push_back('\0'); - const int fd = ::mkstemp(temp_name.data()); - if (fd < 0) { - set_error(error, "Could not create settings file"); - return false; + if (!providers) { + if (error) + *error = "Missing provider settings output."; + return ConfigStoreLoadStatus::Error; } - - bool ok = write_all(fd, contents); - int saved_errno = ok ? 0 : errno; - if (ok && ::fsync(fd) != 0) { - ok = false; - saved_errno = errno; - } - if (::close(fd) != 0 && ok) { - ok = false; - saved_errno = errno; + providers->clear(); + const TextFileReadResult document = read_text_file(path); + if (document.status == TextFileReadStatus::NotFound) + return ConfigStoreLoadStatus::NotFound; + if (document.status == TextFileReadStatus::Error) { + if (error) + *error = document.error; + return ConfigStoreLoadStatus::Error; } - if (!ok) { - errno = saved_errno; - set_error(error, "Could not write settings"); - ::unlink(temp_name.data()); - return false; + if (!parse_provider_config_document_checked(document.contents, providers)) { + if (error) + *error = "Provider settings file is invalid."; + return ConfigStoreLoadStatus::Invalid; } - - if (::rename(temp_name.data(), path.c_str()) != 0) { - set_error(error, "Could not replace settings"); - ::unlink(temp_name.data()); - return false; - } - return true; + return ConfigStoreLoadStatus::Loaded; } bool save_provider_configs(const std::string &path, const std::vector &providers, std::string *error) { - std::string contents; - for (const ProviderConfig &provider : providers) { - contents += encode_config_field(provider.alias) + '\t' + - encode_config_field(provider.family) + '\t' + - encode_config_field(provider.model) + '\t' + - encode_config_field(provider.uri) + '\t' + - encode_config_field(provider.api_key) + '\n'; - } - return atomic_write_config(path, contents, error); + return atomic_write_file(path, make_provider_config_document(providers), {}, error); } } // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_store.h b/projects/ZClaw/main/ui/zclaw_provider_store.h index 1bec2086..737466fb 100644 --- a/projects/ZClaw/main/ui/zclaw_provider_store.h +++ b/projects/ZClaw/main/ui/zclaw_provider_store.h @@ -1,27 +1,17 @@ #pragma once -#include "zclaw_client.h" +#include "zclaw_config_store_status.h" +#include "zclaw_types.h" -#include #include #include namespace zclaw { -std::string encode_config_field(const std::string &value); -std::string decode_config_field(const std::string &value); -std::vector split_config_line(const std::string &line); - -void activate_provider_config(std::vector *providers, ProviderConfig *active, - const ProviderConfig &selected_default); -bool replace_provider_config(std::vector *providers, ProviderConfig *active, - std::size_t index, const ProviderConfig &replacement); -bool erase_provider_config(std::vector *providers, ProviderConfig *active, - std::size_t index, const ProviderConfig &empty_default); - -bool atomic_write_config(const std::string &path, const std::string &contents, - std::string *error = nullptr); bool load_provider_configs(const std::string &path, std::vector *providers); +ConfigStoreLoadStatus load_provider_configs_with_status( + const std::string &path, std::vector *providers, + std::string *error = nullptr); bool save_provider_configs(const std::string &path, const std::vector &providers, std::string *error = nullptr); diff --git a/projects/ZClaw/main/ui/zclaw_provider_workflow.cpp b/projects/ZClaw/main/ui/zclaw_provider_workflow.cpp new file mode 100644 index 00000000..57203752 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_workflow.cpp @@ -0,0 +1,113 @@ +#include "zclaw_provider_workflow.h" + +#include "zclaw_chat_view.h" +#include "zclaw_fonts.hpp" +#include "zclaw_input_dialog.h" +#include "zclaw_provider_catalog.h" +#include "zclaw_provider_manager.h" +#include "zclaw_settings_coordinator.h" +#include "zclaw_settings_panel.h" + +#include + +namespace zclaw { + +ProviderWorkflow::ProviderWorkflow(ProviderManager &providers, + SettingsCoordinator &settings, + InputDialog &input, FontManager &fonts, + ChatView &chat) + : providers_(providers), settings_(settings), input_(input), fonts_(fonts), + chat_(chat) +{ +} + +void ProviderWorkflow::add() +{ + const int next = static_cast(providers_.providers().size()) + 1; + ProviderConfig provider; + provider.alias = "provider" + std::to_string(next); + provider.family = "openai-compatible"; + provider.model = "model"; + provider.uri = "https://api.example.com/v1"; + std::string error; + std::size_t provider_index = 0; + if (!providers_.add(provider, &provider_index, &error)) { + report_save_error(error); + return; + } + settings_.state().set_provider_selected( + static_cast(provider_index) + 1, + static_cast(providers_.providers().size()) + 1, + SettingsPanel::kMaximumRows); + open_detail(static_cast(provider_index)); +} + +void ProviderWorkflow::open_detail(int provider_index) +{ + settings_.state().open_provider_detail(provider_index); + settings_.render_provider_detail(); +} + +void ProviderWorkflow::edit_selected_field() +{ + const int provider_index = settings_.state().provider_detail_index(); + if (provider_index < 0 || + provider_index >= static_cast(providers_.providers().size())) + return; + settings_.state().set_provider_edit_field( + provider_edit_field_for_row(settings_.state().selected_row())); + if (settings_.state().provider_edit_field() == ProviderEditField::None) + return; + ProviderConfig provider = providers_.providers()[provider_index]; + const std::string *value = provider_field_value( + &provider, settings_.state().provider_edit_field()); + if (value) + input_.open_text(&fonts_, + provider_field_name(settings_.state().provider_edit_field()), + *value, InputMode::ProviderEdit); +} + +void ProviderWorkflow::apply_edit(const std::string &value) +{ + const int provider_index = settings_.state().provider_detail_index(); + if (provider_index < 0 || + provider_index >= static_cast(providers_.providers().size()) || + settings_.state().provider_edit_field() == ProviderEditField::None) + return; + + ProviderConfig provider = providers_.providers()[provider_index]; + std::string *field = provider_field_value( + &provider, settings_.state().provider_edit_field()); + if (field) + *field = value; + settings_.state().set_provider_edit_field(ProviderEditField::None); + std::string error; + if (!providers_.replace(static_cast(provider_index), provider, + &error)) + report_save_error(error); + settings_.render_provider_detail(); +} + +void ProviderWorkflow::delete_current() +{ + const int provider_index = settings_.state().provider_detail_index(); + if (provider_index < 0 || + provider_index >= static_cast(providers_.providers().size())) + return; + std::string error; + if (!providers_.erase(static_cast(provider_index), + provider_preset(0), &error)) { + report_save_error(error); + return; + } + settings_.state().close_provider_detail(); + settings_.render_providers(); +} + +void ProviderWorkflow::report_save_error(const std::string &error) +{ + chat_.append_assistant_message( + error.empty() ? "Could not save settings." : error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_workflow.h b/projects/ZClaw/main/ui/zclaw_provider_workflow.h new file mode 100644 index 00000000..d2f8c913 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_workflow.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace zclaw { + +class ChatView; +class FontManager; +class InputDialog; +class ProviderManager; +class SettingsCoordinator; + +class ProviderWorkflow { +public: + ProviderWorkflow(ProviderManager &providers, SettingsCoordinator &settings, + InputDialog &input, FontManager &fonts, ChatView &chat); + + void add(); + void open_detail(int provider_index); + void edit_selected_field(); + void apply_edit(const std::string &value); + void delete_current(); + +private: + void report_save_error(const std::string &error); + + ProviderManager &providers_; + SettingsCoordinator &settings_; + InputDialog &input_; + FontManager &fonts_; + ChatView &chat_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_quickstart_backend.h b/projects/ZClaw/main/ui/zclaw_quickstart_backend.h new file mode 100644 index 00000000..ba20266f --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_quickstart_backend.h @@ -0,0 +1,19 @@ +#pragma once + +#include "zclaw_setup_service.h" + +namespace zclaw { + +class QuickstartBackend { +public: + using ProgressHandler = SetupService::ProgressHandler; + + virtual ~QuickstartBackend() = default; + + virtual PreparedSetup prepare(UiConfig config, ProviderConfig provider, + const ProgressHandler &progress_handler) const = 0; + virtual OperationResult pair(UiConfig config, + const std::string &pairing_code) const = 0; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_quickstart_model.cpp b/projects/ZClaw/main/ui/zclaw_quickstart_model.cpp new file mode 100644 index 00000000..a47e81bb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_quickstart_model.cpp @@ -0,0 +1,33 @@ +#include "zclaw_quickstart_model.h" + +namespace zclaw { + +ProviderConfig normalize_setup_provider(ProviderConfig provider) +{ + if (provider.alias.empty()) + provider.alias = "zclaw"; + if (provider.family.empty()) + provider.family = "openai"; + if (provider.model.empty()) + provider.model = "gpt-4.1-mini"; + if (provider.uri.empty() && provider.family == "ollama") + provider.uri = "http://127.0.0.1:11434"; + return provider; +} + +std::string extract_pairing_code(const std::string &text) +{ + std::string digits; + for (char ch : text) { + if (ch >= '0' && ch <= '9') { + digits += ch; + if (digits.size() == 6) + return digits; + } else { + digits.clear(); + } + } + return ""; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_quickstart_model.h b/projects/ZClaw/main/ui/zclaw_quickstart_model.h new file mode 100644 index 00000000..c670f3b1 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_quickstart_model.h @@ -0,0 +1,12 @@ +#pragma once + +#include "zclaw_types.h" + +#include + +namespace zclaw { + +ProviderConfig normalize_setup_provider(ProviderConfig provider); +std::string extract_pairing_code(const std::string &text); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_quickstart_service.cpp b/projects/ZClaw/main/ui/zclaw_quickstart_service.cpp new file mode 100644 index 00000000..c58bfbbb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_quickstart_service.cpp @@ -0,0 +1,37 @@ +#include "zclaw_quickstart_service.h" + +#include "zclaw_quickstart_backend.h" + +#include + +namespace zclaw { + +QuickstartService::QuickstartService(const QuickstartBackend &backend) + : backend_(&backend) +{ +} + +OperationResult QuickstartService::run( + UiConfig config, const ProviderConfig &provider, + const ProgressHandler &progress_handler) const +{ + const PreparedSetup prepared = backend_->prepare( + std::move(config), provider, progress_handler); + if (!prepared.ok) + return {prepared.error, false, prepared.config}; + + OperationResult result = backend_->pair(prepared.config, prepared.pairing_code); + if (!result.ok || result.config.bearer_token.empty()) { + result.text = "Automatic pairing failed.\n" + result.text; + result.ok = false; + return result; + } + result.config.setup_complete = true; + result.ok = true; + if (progress_handler) + progress_handler({"Quickstart complete", 100}); + result.text = "Quickstart complete.\nChat and approvals use WS."; + return result; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_quickstart_service.h b/projects/ZClaw/main/ui/zclaw_quickstart_service.h new file mode 100644 index 00000000..931245db --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_quickstart_service.h @@ -0,0 +1,24 @@ +#pragma once + +#include "zclaw_types.h" + +#include + +namespace zclaw { + +class QuickstartBackend; + +class QuickstartService { +public: + using ProgressHandler = std::function; + + explicit QuickstartService(const QuickstartBackend &backend); + + OperationResult run(UiConfig config, const ProviderConfig &provider, + const ProgressHandler &progress_handler = nullptr) const; + +private: + const QuickstartBackend *backend_ = nullptr; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_request_id.cpp b/projects/ZClaw/main/ui/zclaw_request_id.cpp new file mode 100644 index 00000000..cbfff1dc --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_request_id.cpp @@ -0,0 +1,31 @@ +#include "zclaw_request_id.h" + +#include +#include + +namespace zclaw { +namespace { + +std::uint64_t unix_time_seconds() +{ + const auto now = std::chrono::system_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); +} + +} // namespace + +RequestIdGenerator::RequestIdGenerator(Clock clock) + : clock_(clock ? std::move(clock) : Clock(unix_time_seconds)) +{ +} + +std::string RequestIdGenerator::next() +{ + const std::uint64_t sequence = + sequence_.fetch_add(1, std::memory_order_relaxed); + return "zclaw-ui-" + std::to_string(clock_()) + "-" + + std::to_string(sequence); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_request_id.h b/projects/ZClaw/main/ui/zclaw_request_id.h new file mode 100644 index 00000000..f0ae6aca --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_request_id.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +namespace zclaw { + +class RequestIdGenerator { +public: + using Clock = std::function; + + explicit RequestIdGenerator(Clock clock = {}); + + std::string next(); + +private: + Clock clock_; + std::atomic sequence_{0}; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_risk_profile_model.cpp b/projects/ZClaw/main/ui/zclaw_risk_profile_model.cpp new file mode 100644 index 00000000..8ecdd044 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_risk_profile_model.cpp @@ -0,0 +1,24 @@ +#include "zclaw_risk_profile_model.h" + +#include "zclaw_text.h" + +#include +#include + +namespace zclaw { +RiskProfileUpdate add_default_risk_profile(std::string config) +{ + std::istringstream lines(config); + std::string line; + while (std::getline(lines, line)) { + if (trim_ascii_whitespace(line) == "[risk_profiles.default]") + return {std::move(config), false}; + } + + if (!config.empty() && config.back() != '\n') + config += '\n'; + config += "\n[risk_profiles.default]\n"; + return {std::move(config), true}; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_risk_profile_model.h b/projects/ZClaw/main/ui/zclaw_risk_profile_model.h new file mode 100644 index 00000000..f59fec65 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_risk_profile_model.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace zclaw { + +struct RiskProfileUpdate { + std::string contents; + bool changed = false; +}; + +RiskProfileUpdate add_default_risk_profile(std::string config); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_risk_profile_store.cpp b/projects/ZClaw/main/ui/zclaw_risk_profile_store.cpp new file mode 100644 index 00000000..1315527a --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_risk_profile_store.cpp @@ -0,0 +1,48 @@ +#include "zclaw_risk_profile_store.h" + +#include "zclaw_atomic_file.h" +#include "zclaw_risk_profile_model.h" + +#include +#include +#include + +namespace zclaw { +namespace { + +void set_error(std::string *error, const char *message) +{ + if (error) + *error = message; +} + +} // namespace + +bool RiskProfileStore::ensure_default(const std::string &path, + std::string *error) const +{ + std::ifstream input(path, std::ios::binary); + if (!input) { + set_error(error, "Cannot read ZeroClaw config."); + return false; + } + + std::ostringstream buffer; + buffer << input.rdbuf(); + if (input.bad()) { + set_error(error, "Cannot read ZeroClaw config."); + return false; + } + RiskProfileUpdate update = add_default_risk_profile(buffer.str()); + if (!update.changed) + return true; + + struct stat state {}; + const std::uint32_t permissions = + ::stat(path.c_str(), &state) == 0 + ? static_cast(state.st_mode & 0777) + : 0600; + return atomic_write_file(path, update.contents, {permissions}, error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_risk_profile_store.h b/projects/ZClaw/main/ui/zclaw_risk_profile_store.h new file mode 100644 index 00000000..cb079c25 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_risk_profile_store.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace zclaw { + +class RiskProfileStore { +public: + bool ensure_default(const std::string &path, std::string *error) const; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_runtime_model.cpp b/projects/ZClaw/main/ui/zclaw_runtime_model.cpp new file mode 100644 index 00000000..3efaeffa --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_runtime_model.cpp @@ -0,0 +1,12 @@ +#include "zclaw_runtime_model.h" + +namespace zclaw { + +bool first_run_needed(const RuntimeReadiness &readiness) +{ + return !readiness.zeroclaw_directory_exists || + !readiness.config_file_exists || !readiness.setup_complete || + !readiness.bearer_token_present; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_runtime_model.h b/projects/ZClaw/main/ui/zclaw_runtime_model.h new file mode 100644 index 00000000..5936b674 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_runtime_model.h @@ -0,0 +1,14 @@ +#pragma once + +namespace zclaw { + +struct RuntimeReadiness { + bool zeroclaw_directory_exists = false; + bool config_file_exists = false; + bool setup_complete = false; + bool bearer_token_present = false; +}; + +bool first_run_needed(const RuntimeReadiness &readiness); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_runtime_state.cpp b/projects/ZClaw/main/ui/zclaw_runtime_state.cpp new file mode 100644 index 00000000..c0094b0c --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_runtime_state.cpp @@ -0,0 +1,37 @@ +#include "zclaw_runtime_state.h" + +#include "zclaw_paths.h" +#include "zclaw_runtime_model.h" + +#include +#include + +namespace zclaw { +namespace { + +bool is_regular_file(const std::string &path) +{ + struct stat state {}; + return ::stat(path.c_str(), &state) == 0 && S_ISREG(state.st_mode); +} + +bool is_directory(const std::string &path) +{ + struct stat state {}; + return ::stat(path.c_str(), &state) == 0 && S_ISDIR(state.st_mode); +} + +} // namespace + +bool RuntimeState::first_run_needed(const UiConfig &config) +{ + const RuntimeReadiness readiness = { + is_directory(paths::zeroclaw_dir()), + is_regular_file(paths::zeroclaw_config()), + config.setup_complete, + !config.bearer_token.empty(), + }; + return zclaw::first_run_needed(readiness); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_runtime_state.h b/projects/ZClaw/main/ui/zclaw_runtime_state.h new file mode 100644 index 00000000..b898b7b9 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_runtime_state.h @@ -0,0 +1,12 @@ +#pragma once + +#include "zclaw_types.h" + +namespace zclaw { + +class RuntimeState { +public: + static bool first_run_needed(const UiConfig &config); +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_selection_model.cpp b/projects/ZClaw/main/ui/zclaw_selection_model.cpp new file mode 100644 index 00000000..259483eb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_selection_model.cpp @@ -0,0 +1,38 @@ +#include "zclaw_selection_model.h" + +namespace zclaw { + +int move_row_selection(int selected_row, int row_count, int delta) +{ + if (row_count <= 0) + return 0; + const int moved = selected_row + delta; + if (moved < 0) + return 0; + if (moved >= row_count) + return row_count - 1; + return moved; +} + +PagedSelection move_paged_selection(int selected_index, int scroll_offset, + int item_count, int visible_rows, int delta) +{ + if (item_count <= 0) + return {}; + if (visible_rows <= 0) + visible_rows = 1; + + selected_index = move_row_selection(selected_index, item_count, delta); + const int maximum_scroll = item_count > visible_rows ? item_count - visible_rows : 0; + if (scroll_offset < 0) + scroll_offset = 0; + if (scroll_offset > maximum_scroll) + scroll_offset = maximum_scroll; + if (selected_index < scroll_offset) + scroll_offset = selected_index; + if (selected_index >= scroll_offset + visible_rows) + scroll_offset = selected_index - visible_rows + 1; + return {selected_index, scroll_offset, selected_index - scroll_offset}; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_selection_model.h b/projects/ZClaw/main/ui/zclaw_selection_model.h new file mode 100644 index 00000000..fdb1749d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_selection_model.h @@ -0,0 +1,15 @@ +#pragma once + +namespace zclaw { + +struct PagedSelection { + int selected_index = 0; + int scroll_offset = 0; + int visible_row = 0; +}; + +int move_row_selection(int selected_row, int row_count, int delta); +PagedSelection move_paged_selection(int selected_index, int scroll_offset, + int item_count, int visible_rows, int delta); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_controller.cpp b/projects/ZClaw/main/ui/zclaw_settings_controller.cpp new file mode 100644 index 00000000..48fb9b19 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_controller.cpp @@ -0,0 +1,152 @@ +#include "zclaw_settings_controller.h" + +namespace zclaw { + +SettingsView SettingsController::view() const +{ + return view_; +} + +int SettingsController::selected_row() const +{ + return selected_row_; +} + +void SettingsController::set_view(SettingsView view) +{ + view_ = view; +} + +void SettingsController::reset_view(SettingsView view, int selected_row) +{ + view_ = view; + selected_row_ = selected_row; +} + +void SettingsController::set_selected_row(int selected_row) +{ + selected_row_ = selected_row; +} + +void SettingsController::clamp_rows(int row_count) +{ + selected_row_ = move_row_selection(selected_row_, row_count, 0); +} + +void SettingsController::move_rows(int row_count, int delta) +{ + selected_row_ = move_row_selection(selected_row_, row_count, delta); +} + +const PagedSelection &SettingsController::provider_selection() const +{ + return providers_; +} + +void SettingsController::reset_providers() +{ + providers_ = {}; + selected_row_ = 0; +} + +void SettingsController::set_provider_selected(int selected_index, int item_count, + int visible_rows) +{ + providers_ = move_paged_selection(selected_index, providers_.scroll_offset, + item_count, visible_rows, 0); + selected_row_ = providers_.visible_row; +} + +void SettingsController::normalize_providers(int item_count, int visible_rows) +{ + providers_ = move_paged_selection(providers_.selected_index, providers_.scroll_offset, + item_count, visible_rows, 0); + selected_row_ = providers_.visible_row; +} + +void SettingsController::move_providers(int item_count, int visible_rows, int delta) +{ + providers_ = move_paged_selection(providers_.selected_index, providers_.scroll_offset, + item_count, visible_rows, delta); + selected_row_ = providers_.visible_row; +} + +const PagedSelection &SettingsController::setup_provider_selection() const +{ + return setup_providers_; +} + +void SettingsController::sync_setup_provider(int selected_index, int item_count, + int visible_rows) +{ + setup_providers_ = move_paged_selection(selected_index, 0, item_count, visible_rows, 0); +} + +void SettingsController::normalize_setup_providers(int item_count, int visible_rows) +{ + setup_providers_ = move_paged_selection(setup_providers_.selected_index, + setup_providers_.scroll_offset, + item_count, visible_rows, 0); + selected_row_ = setup_providers_.visible_row; +} + +void SettingsController::move_setup_providers(int item_count, int visible_rows, int delta) +{ + setup_providers_ = move_paged_selection(setup_providers_.selected_index, + setup_providers_.scroll_offset, + item_count, visible_rows, delta); + selected_row_ = setup_providers_.visible_row; +} + +int SettingsController::provider_detail_index() const +{ + return provider_detail_index_; +} + +void SettingsController::open_provider_detail(int provider_index) +{ + provider_detail_index_ = provider_index; + selected_row_ = 0; +} + +void SettingsController::close_provider_detail() +{ + provider_detail_index_ = -1; +} + +ProviderEditField SettingsController::provider_edit_field() const +{ + return provider_edit_field_; +} + +void SettingsController::set_provider_edit_field(ProviderEditField field) +{ + provider_edit_field_ = field; +} + +SetupEditField SettingsController::setup_edit_field() const +{ + return setup_edit_field_; +} + +void SettingsController::set_setup_edit_field(SetupEditField field) +{ + setup_edit_field_ = field; +} + +SettingsActivation SettingsController::activation(int setup_initialize_row, + int provider_count, + bool setup_in_flight) const +{ + return settings_activation({view_, selected_row_, setup_initialize_row, + providers_.scroll_offset, provider_count, + setup_in_flight}); +} + +SettingsBackAction SettingsController::back_action(bool setup_in_flight, + bool first_run_needed) const +{ + return settings_back_action(view_, setup_in_flight, first_run_needed); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_controller.h b/projects/ZClaw/main/ui/zclaw_settings_controller.h new file mode 100644 index 00000000..6e58bba4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_controller.h @@ -0,0 +1,53 @@ +#pragma once + +#include "zclaw_provider_form_model.h" +#include "zclaw_selection_model.h" +#include "zclaw_settings_navigation_model.h" + +namespace zclaw { + +class SettingsController { +public: + SettingsView view() const; + int selected_row() const; + void set_view(SettingsView view); + void reset_view(SettingsView view, int selected_row = 0); + void set_selected_row(int selected_row); + void clamp_rows(int row_count); + void move_rows(int row_count, int delta); + + const PagedSelection &provider_selection() const; + void reset_providers(); + void set_provider_selected(int selected_index, int item_count, int visible_rows); + void normalize_providers(int item_count, int visible_rows); + void move_providers(int item_count, int visible_rows, int delta); + + const PagedSelection &setup_provider_selection() const; + void sync_setup_provider(int selected_index, int item_count, int visible_rows); + void normalize_setup_providers(int item_count, int visible_rows); + void move_setup_providers(int item_count, int visible_rows, int delta); + + int provider_detail_index() const; + void open_provider_detail(int provider_index); + void close_provider_detail(); + + ProviderEditField provider_edit_field() const; + void set_provider_edit_field(ProviderEditField field); + SetupEditField setup_edit_field() const; + void set_setup_edit_field(SetupEditField field); + + SettingsActivation activation(int setup_initialize_row, int provider_count, + bool setup_in_flight) const; + SettingsBackAction back_action(bool setup_in_flight, bool first_run_needed) const; + +private: + SettingsView view_ = SettingsView::Main; + int selected_row_ = 0; + PagedSelection providers_; + PagedSelection setup_providers_; + int provider_detail_index_ = -1; + ProviderEditField provider_edit_field_ = ProviderEditField::None; + SetupEditField setup_edit_field_ = SetupEditField::None; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_coordinator.cpp b/projects/ZClaw/main/ui/zclaw_settings_coordinator.cpp new file mode 100644 index 00000000..444be2ec --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_coordinator.cpp @@ -0,0 +1,269 @@ +#include "zclaw_settings_coordinator.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_input_dialog.h" +#include "zclaw_provider_catalog.h" +#include "zclaw_provider_manager.h" +#include "zclaw_settings_presentation.h" +#include "zclaw_theme.h" +#include "zclaw_ui_config_manager.h" +#include "zclaw_widgets.h" + +namespace zclaw { + +SettingsCoordinator::SettingsCoordinator(ProviderManager &providers, + UiConfigManager &config, + InputDialog &input, FontManager &fonts) + : providers_(providers), config_(config), input_(input), fonts_(fonts) +{ +} + +SettingsCoordinator::~SettingsCoordinator() +{ + close_setup_error(); +} + +SettingsController &SettingsCoordinator::state() +{ + return state_; +} + +const SettingsController &SettingsCoordinator::state() const +{ + return state_; +} + +bool SettingsCoordinator::is_open() const +{ + return panel_.is_open(); +} + +void SettingsCoordinator::open(lv_obj_t *parent) +{ + if (panel_.is_open() || panel_.is_animating()) + return; + input_.close(); + create_panel(parent); + panel_.show(); +} + +void SettingsCoordinator::open_setup(lv_obj_t *parent, bool first_run_needed, + bool setup_in_flight) +{ + if (panel_.is_open() || panel_.is_animating()) + return; + input_.close(); + sync_setup_provider_selection(); + create_panel(parent); + if (first_run_needed) + render_setup_providers(); + else + render_setup(setup_in_flight); + panel_.show(); +} + +void SettingsCoordinator::close() +{ + close_setup_error(); + if (panel_.is_open() && !panel_.is_animating()) + panel_.close(); +} + +void SettingsCoordinator::render_main() +{ + state_.set_view(SettingsView::Main); + apply(present_settings_main(config_.config())); + state_.clamp_rows(panel_.row_count()); + panel_.update_selection(state_.selected_row()); +} + +void SettingsCoordinator::render_authorization() +{ + state_.reset_view(SettingsView::Authorization); + apply(present_authorization(config_.config())); + panel_.update_selection(state_.selected_row()); +} + +void SettingsCoordinator::render_setup(bool setup_in_flight) +{ + close_setup_error(); + state_.set_view(SettingsView::Setup); + progress_.clear(); + if (setup_in_flight) { + apply(present_setup_progress()); + progress_.show(panel_.content(), &fonts_); + state_.set_selected_row(0); + return; + } + apply(present_setup(providers_.setup_provider())); + state_.clamp_rows(panel_.row_count()); + panel_.update_selection(state_.selected_row()); +} + +void SettingsCoordinator::show_setup_error(const std::string &error) +{ + close_setup_error(); + setup_error_dialog_ = + widgets::box(lv_layer_top(), 35, 35, 250, 100, theme::kBar, 8); + lv_obj_add_event_cb(setup_error_dialog_, setup_error_deleted, + LV_EVENT_DELETE, this); + lv_obj_set_style_border_width(setup_error_dialog_, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(setup_error_dialog_, + lv_color_hex(theme::kPurple), LV_PART_MAIN); + lv_obj_move_foreground(setup_error_dialog_); + widgets::label(setup_error_dialog_, "Download failed", 12, 9, 226, 14, + fonts_.font_12(), theme::kText); + widgets::label(setup_error_dialog_, + error.empty() ? "Network connection failed." : error, + 12, 30, 226, 36, fonts_.font_10(), theme::kMuted); + static constexpr const char *labels[] = {"Retry", "Exit"}; + for (int index = 0; index < 2; ++index) { + setup_error_buttons_[index] = widgets::box( + setup_error_dialog_, 22 + index * 106, 74, 96, 18, + theme::kPanel, 5); + lv_obj_set_style_border_width(setup_error_buttons_[index], 1, + LV_PART_MAIN); + widgets::label(setup_error_buttons_[index], labels[index], 0, 4, + 96, 10, fonts_.font_10(), theme::kText, + LV_TEXT_ALIGN_CENTER); + } + update_setup_error_selection(0); +} + +void SettingsCoordinator::close_setup_error() +{ + lv_obj_t *dialog = setup_error_dialog_; + setup_error_dialog_ = nullptr; + if (dialog) + lv_obj_del(dialog); + setup_error_buttons_[0] = nullptr; + setup_error_buttons_[1] = nullptr; +} + +void SettingsCoordinator::update_setup_error_selection(int selected_index) +{ + for (int index = 0; index < 2; ++index) { + if (!setup_error_buttons_[index]) + continue; + const bool selected = index == selected_index; + lv_obj_set_style_bg_color(setup_error_buttons_[index], + lv_color_hex(selected ? 0x252542 : theme::kPanel), + LV_PART_MAIN); + lv_obj_set_style_border_color(setup_error_buttons_[index], + lv_color_hex(selected ? theme::kText + : theme::kPanelLine), + LV_PART_MAIN); + } +} + +void SettingsCoordinator::setup_error_deleted(lv_event_t *event) +{ + SettingsCoordinator *coordinator = static_cast( + lv_event_get_user_data(event)); + if (coordinator) { + coordinator->setup_error_dialog_ = nullptr; + coordinator->setup_error_buttons_[0] = nullptr; + coordinator->setup_error_buttons_[1] = nullptr; + } +} + +void SettingsCoordinator::render_setup_providers() +{ + state_.set_view(SettingsView::SetupProviders); + const int preset_count = static_cast(provider_preset_count()); + state_.normalize_setup_providers(preset_count, SettingsPanel::kMaximumRows); + apply(present_setup_providers(state_.setup_provider_selection(), + SettingsPanel::kMaximumRows)); + panel_.update_selection(state_.selected_row()); +} + +void SettingsCoordinator::render_providers() +{ + state_.set_view(SettingsView::Providers); + const int total_rows = static_cast(providers_.providers().size()) + 1; + state_.normalize_providers(total_rows, SettingsPanel::kMaximumRows); + apply(present_providers(providers_.providers(), state_.provider_selection(), + SettingsPanel::kMaximumRows)); + panel_.update_selection(state_.selected_row()); +} + +void SettingsCoordinator::render_provider_detail() +{ + state_.set_view(SettingsView::ProviderDetail); + const int provider_index = state_.provider_detail_index(); + if (provider_index < 0 || + provider_index >= static_cast(providers_.providers().size())) { + render_providers(); + return; + } + apply(present_provider_detail(providers_.providers()[provider_index])); + state_.clamp_rows(panel_.row_count()); + panel_.update_selection(state_.selected_row()); +} + +void SettingsCoordinator::move_selection(int delta) +{ + if (!is_open()) + return; + if (state_.view() == SettingsView::SetupProviders) { + state_.move_setup_providers(static_cast(provider_preset_count()), + SettingsPanel::kMaximumRows, delta); + render_setup_providers(); + return; + } + if (state_.view() == SettingsView::Providers) { + state_.move_providers(static_cast(providers_.providers().size()) + 1, + SettingsPanel::kMaximumRows, delta); + render_providers(); + return; + } + state_.move_rows(panel_.row_count(), delta); + panel_.update_selection(state_.selected_row()); +} + +void SettingsCoordinator::sync_setup_provider_selection() +{ + state_.sync_setup_provider( + static_cast(provider_preset_index(providers_.setup_provider().family)), + static_cast(provider_preset_count()), SettingsPanel::kMaximumRows); +} + +bool SettingsCoordinator::validate_setup(std::string *error) +{ + const SetupValidation validation = validate_setup_provider(providers_.setup_provider()); + if (validation.valid) + return true; + state_.set_selected_row(validation.row); + if (error) + *error = validation.error; + return false; +} + +int SettingsCoordinator::setup_initialize_row() const +{ + return panel_.row_count() - 1; +} + +void SettingsCoordinator::update_setup_progress(const SetupProgress &progress, + bool setup_in_flight) +{ + if (setup_in_flight && progress_.is_visible()) + progress_.update(progress); +} + +void SettingsCoordinator::create_panel(lv_obj_t *parent) +{ + panel_.create(parent, &fonts_); + state_.set_selected_row(0); + render_main(); +} + +void SettingsCoordinator::apply(const SettingsPresentation &presentation) +{ + panel_.clear_rows(); + panel_.set_header(presentation.title, presentation.hint); + for (const SettingsRowPresentation &row : presentation.rows) + panel_.add_row(row.title, row.value); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_coordinator.h b/projects/ZClaw/main/ui/zclaw_settings_coordinator.h new file mode 100644 index 00000000..05f45b6a --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_coordinator.h @@ -0,0 +1,65 @@ +#pragma once + +#include "zclaw_settings_controller.h" +#include "zclaw_settings_panel.h" +#include "zclaw_setup_progress_view.h" + +#include + +namespace zclaw { + +class FontManager; +class InputDialog; +class ProviderManager; +struct SettingsPresentation; +class UiConfigManager; + +class SettingsCoordinator { +public: + SettingsCoordinator(ProviderManager &providers, UiConfigManager &config, + InputDialog &input, FontManager &fonts); + ~SettingsCoordinator(); + + SettingsController &state(); + const SettingsController &state() const; + + bool is_open() const; + void open(lv_obj_t *parent); + void open_setup(lv_obj_t *parent, bool first_run_needed, + bool setup_in_flight); + void close(); + + void render_main(); + void render_authorization(); + void render_setup(bool setup_in_flight); + void show_setup_error(const std::string &error); + void close_setup_error(); + void update_setup_error_selection(int selected_index); + void render_setup_providers(); + void render_providers(); + void render_provider_detail(); + void move_selection(int delta); + void sync_setup_provider_selection(); + + bool validate_setup(std::string *error); + int setup_initialize_row() const; + void update_setup_progress(const SetupProgress &progress, + bool setup_in_flight); + +private: + static void setup_error_deleted(lv_event_t *event); + void create_panel(lv_obj_t *parent); + void apply(const SettingsPresentation &presentation); + + ProviderManager &providers_; + UiConfigManager &config_; + InputDialog &input_; + FontManager &fonts_; + SettingsController state_; + SettingsPanel panel_; + SetupProgressView progress_; + lv_obj_t *setup_error_dialog_ = nullptr; + lv_obj_t *setup_error_buttons_[2] = {}; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_navigation_model.cpp b/projects/ZClaw/main/ui/zclaw_settings_navigation_model.cpp new file mode 100644 index 00000000..fe5e84fd --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_navigation_model.cpp @@ -0,0 +1,68 @@ +#include "zclaw_settings_navigation_model.h" + +namespace zclaw { + +SettingsBackAction settings_back_action(SettingsView view, bool setup_in_flight, + bool first_run_needed) +{ + if (setup_in_flight && view == SettingsView::Setup) + return SettingsBackAction::None; + switch (view) { + case SettingsView::SetupProviders: + return first_run_needed ? SettingsBackAction::Quit : SettingsBackAction::ShowSetup; + case SettingsView::Setup: + return first_run_needed ? SettingsBackAction::ShowSetupProviders : + SettingsBackAction::Close; + case SettingsView::Authorization: + case SettingsView::Main: + return SettingsBackAction::Close; + case SettingsView::ProviderDetail: + return SettingsBackAction::ShowProviders; + case SettingsView::Providers: + return SettingsBackAction::ShowMain; + } + return SettingsBackAction::None; +} + +SettingsActivation settings_activation(const SettingsActivationContext &context) +{ + switch (context.view) { + case SettingsView::Setup: + if (context.setup_in_flight) + return {}; + if (context.selected_row == 0) + return {SettingsActivationAction::ShowSetupProviders}; + if (context.selected_row == context.setup_initialize_row) + return {SettingsActivationAction::StartSetup}; + return {SettingsActivationAction::EditSetupField}; + case SettingsView::SetupProviders: + return {SettingsActivationAction::SelectSetupProvider}; + case SettingsView::Authorization: + if (context.selected_row == 0) + return {SettingsActivationAction::EnterPairingCode}; + if (context.selected_row == 4) + return {SettingsActivationAction::ClearToken}; + return {}; + case SettingsView::Main: + if (context.selected_row == 0) + return {SettingsActivationAction::ShowSetup}; + if (context.selected_row == 1) + return {SettingsActivationAction::ShowAuthorization}; + if (context.selected_row == 2) + return {SettingsActivationAction::ShowProviders}; + return {}; + case SettingsView::Providers: { + const int item = context.provider_scroll + context.selected_row; + if (item == 0) + return {SettingsActivationAction::AddProvider}; + if (item > 0 && item <= context.provider_count) + return {SettingsActivationAction::ShowProviderDetail, item - 1}; + return {}; + } + case SettingsView::ProviderDetail: + return {SettingsActivationAction::EditProviderField}; + } + return {}; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_navigation_model.h b/projects/ZClaw/main/ui/zclaw_settings_navigation_model.h new file mode 100644 index 00000000..687abe92 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_navigation_model.h @@ -0,0 +1,58 @@ +#pragma once + +namespace zclaw { + +enum class SettingsView { + Setup, + SetupProviders, + Authorization, + Main, + Providers, + ProviderDetail, +}; + +enum class SettingsBackAction { + None, + Close, + Quit, + ShowMain, + ShowSetup, + ShowSetupProviders, + ShowProviders, +}; + +enum class SettingsActivationAction { + None, + ShowSetup, + ShowSetupProviders, + ShowAuthorization, + ShowProviders, + SelectSetupProvider, + StartSetup, + EditSetupField, + EnterPairingCode, + ClearToken, + AddProvider, + ShowProviderDetail, + EditProviderField, +}; + +struct SettingsActivationContext { + SettingsView view = SettingsView::Main; + int selected_row = 0; + int setup_initialize_row = 0; + int provider_scroll = 0; + int provider_count = 0; + bool setup_in_flight = false; +}; + +struct SettingsActivation { + SettingsActivationAction action = SettingsActivationAction::None; + int provider_index = -1; +}; + +SettingsBackAction settings_back_action(SettingsView view, bool setup_in_flight, + bool first_run_needed); +SettingsActivation settings_activation(const SettingsActivationContext &context); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_panel.cpp b/projects/ZClaw/main/ui/zclaw_settings_panel.cpp new file mode 100644 index 00000000..9cd209dd --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_panel.cpp @@ -0,0 +1,188 @@ +#include "zclaw_settings_panel.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_theme.h" +#include "zclaw_widgets.h" + +namespace zclaw { +namespace { + +constexpr lv_coord_t kScreenWidth = 320; +constexpr lv_coord_t kScreenHeight = 170; +} // namespace + +SettingsPanel::~SettingsPanel() +{ + destroy_panel(); +} + +bool SettingsPanel::create(lv_obj_t *parent, const FontManager *fonts) +{ + if (!parent || !fonts || !lifecycle_.mount()) + return false; + fonts_ = fonts; + panel_ = widgets::box(parent, kScreenWidth, 0, kScreenWidth, kScreenHeight, + theme::kBackground); + lv_obj_add_event_cb(panel_, panel_deleted, LV_EVENT_DELETE, this); + lv_obj_move_foreground(panel_); + + lv_obj_t *bar = widgets::box(panel_, 0, 0, kScreenWidth, 20, theme::kBar); + header_label_ = widgets::label(bar, "ZClaw Settings", 12, 4, 160, 12, + fonts_->font_12(), theme::kText); + hint_label_ = widgets::label(bar, "Tab / Esc", 214, 5, 94, 10, + fonts_->font_10(), theme::kDim, LV_TEXT_ALIGN_RIGHT); + return true; +} + +void SettingsPanel::show() +{ + if (panel_ && lifecycle_.begin_open()) + animate(kScreenWidth, 0); +} + +void SettingsPanel::close() +{ + if (panel_ && lifecycle_.begin_close()) + animate(lv_obj_get_x(panel_), kScreenWidth); +} + +bool SettingsPanel::is_open() const +{ + return lifecycle_.mounted(); +} + +bool SettingsPanel::is_animating() const +{ + return lifecycle_.animating(); +} + +lv_obj_t *SettingsPanel::content() const +{ + return panel_; +} + +void SettingsPanel::set_header(const std::string &title, const std::string &hint) +{ + if (header_label_) + lv_label_set_text(header_label_, title.c_str()); + if (hint_label_) + lv_label_set_text(hint_label_, hint.c_str()); +} + +void SettingsPanel::clear_rows() +{ + for (lv_obj_t *&row : rows_) { + if (row) + lv_obj_del(row); + row = nullptr; + } + for (lv_obj_t *&value : values_) + value = nullptr; + row_count_ = 0; +} + +bool SettingsPanel::add_row(const std::string &title, const std::string &value) +{ + if (!panel_ || !fonts_ || row_count_ >= kMaximumRows) + return false; + const int index = row_count_; + const lv_coord_t y = 32 + index * 28; + lv_obj_t *row = widgets::box(panel_, 12, y, 296, 26, theme::kPanel, 8); + lv_obj_set_style_border_width(row, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(row, lv_color_hex(theme::kPanel), + LV_PART_MAIN | LV_STATE_DEFAULT); + widgets::label(row, title, 10, 5, 160, 14, fonts_->font_10(), theme::kText); + values_[index] = widgets::label(row, value, 168, 5, 118, 14, + fonts_->font_10(), theme::kMuted, + LV_TEXT_ALIGN_RIGHT); + widgets::box(row, 8, 23, 280, 1, theme::kPanelLine); + rows_[index] = row; + ++row_count_; + return true; +} + +int SettingsPanel::row_count() const +{ + return row_count_; +} + +void SettingsPanel::update_selection(int selected_index) +{ + for (int index = 0; index < kMaximumRows; ++index) { + if (!rows_[index]) + continue; + const bool selected = index == selected_index; + lv_obj_set_style_border_color(rows_[index], + lv_color_hex(selected ? theme::kPurple : theme::kPanel), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(rows_[index], + lv_color_hex(selected ? 0x252542 : theme::kPanel), + LV_PART_MAIN | LV_STATE_DEFAULT); + if (values_[index]) + lv_obj_set_style_text_color(values_[index], + lv_color_hex(selected ? theme::kText : theme::kMuted), + LV_PART_MAIN | LV_STATE_DEFAULT); + } +} + +void SettingsPanel::animation_completed(lv_anim_t *animation) +{ + SettingsPanel *panel = static_cast(lv_anim_get_user_data(animation)); + if (panel) + panel->on_animation_completed(); +} + +void SettingsPanel::on_animation_completed() +{ + if (lifecycle_.complete_animation()) + destroy_panel(); +} + +void SettingsPanel::animate(lv_coord_t from_x, lv_coord_t to_x) +{ + lv_anim_del(panel_, nullptr); + lv_anim_t animation; + lv_anim_init(&animation); + lv_anim_set_var(&animation, panel_); + lv_anim_set_exec_cb(&animation, reinterpret_cast(lv_obj_set_x)); + lv_anim_set_values(&animation, from_x, to_x); + lv_anim_set_time(&animation, 200); + lv_anim_set_path_cb(&animation, lv_anim_path_ease_out); + lv_anim_set_completed_cb(&animation, animation_completed); + lv_anim_set_user_data(&animation, this); + lv_anim_start(&animation); +} + +void SettingsPanel::panel_deleted(lv_event_t *event) +{ + SettingsPanel *panel = + static_cast(lv_event_get_user_data(event)); + if (panel) + panel->release_panel(); +} + +void SettingsPanel::destroy_panel() +{ + lv_obj_t *panel = panel_; + if (panel) { + lv_anim_del(panel, nullptr); + lv_obj_del(panel); + } + release_panel(); +} + +void SettingsPanel::release_panel() +{ + lifecycle_.release(); + fonts_ = nullptr; + panel_ = nullptr; + header_label_ = nullptr; + hint_label_ = nullptr; + for (lv_obj_t *&row : rows_) + row = nullptr; + for (lv_obj_t *&value : values_) + value = nullptr; + row_count_ = 0; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_panel.h b/projects/ZClaw/main/ui/zclaw_settings_panel.h new file mode 100644 index 00000000..a18db4ee --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_panel.h @@ -0,0 +1,54 @@ +#pragma once + +#include "lvgl/lvgl.h" +#include "zclaw_panel_lifecycle.h" + +#include + +namespace zclaw { + +class FontManager; + +class SettingsPanel { +public: + static constexpr int kMaximumRows = 5; + + SettingsPanel() = default; + ~SettingsPanel(); + + SettingsPanel(const SettingsPanel &) = delete; + SettingsPanel &operator=(const SettingsPanel &) = delete; + + bool create(lv_obj_t *parent, const FontManager *fonts); + void show(); + void close(); + + bool is_open() const; + bool is_animating() const; + lv_obj_t *content() const; + + void set_header(const std::string &title, const std::string &hint); + void clear_rows(); + bool add_row(const std::string &title, const std::string &value); + int row_count() const; + void update_selection(int selected_index); + +private: + static void animation_completed(lv_anim_t *animation); + static void panel_deleted(lv_event_t *event); + void on_animation_completed(); + void animate(lv_coord_t from_x, lv_coord_t to_x); + void destroy_panel(); + void release_panel(); + + const FontManager *fonts_ = nullptr; + PanelLifecycle lifecycle_; + lv_obj_t *panel_ = nullptr; + lv_obj_t *header_label_ = nullptr; + lv_obj_t *hint_label_ = nullptr; + lv_obj_t *rows_[kMaximumRows] = {}; + lv_obj_t *values_[kMaximumRows] = {}; + int row_count_ = 0; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_presentation.cpp b/projects/ZClaw/main/ui/zclaw_settings_presentation.cpp new file mode 100644 index 00000000..75fbc811 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_presentation.cpp @@ -0,0 +1,106 @@ +#include "zclaw_settings_presentation.h" + +#include "zclaw_provider_catalog.h" + +#include + +namespace zclaw { + +SettingsPresentation present_settings_main(const UiConfig &config) +{ + return { + "ZClaw Settings", + "Tab / Esc", + { + {"Setup", config.setup_complete ? "Done" : "Run"}, + {"Authorization", config.bearer_token.empty() ? "Pair" : "Paired"}, + {"Providers", "Manage"}, + {"Agent", config.agent_alias}, + {"Transport", config.bearer_token.empty() ? "Webhook" : "WS"}, + }, + }; +} + +SettingsPresentation present_authorization(const UiConfig &config) +{ + return { + "Authorization", + "Enter / Esc", + { + {"Pair Code", "Enter"}, + {"Token", config.bearer_token.empty() ? "Missing" : "Saved"}, + {"Agent", config.agent_alias}, + {"Approvals", config.bearer_token.empty() ? "Off" : "WS"}, + {"Clear Token", "-"}, + }, + }; +} + +SettingsPresentation present_setup(const ProviderConfig &provider) +{ + SettingsPresentation presentation{"Model Settings", "Enter / Esc", {}}; + presentation.rows.push_back({"Provider", provider_display_name(provider.family)}); + if (provider.family == "custom") + presentation.rows.push_back({"API URL", provider.uri.empty() ? "Required" : "Set"}); + if (provider.family != "ollama") + presentation.rows.push_back({"API Key", provider.api_key.empty() ? "Required" : "Set"}); + if (provider.family == "custom") + presentation.rows.push_back({"API Model", provider.model.empty() ? "Required" : "Set"}); + presentation.rows.push_back({"Confirm", "Quickstart"}); + return presentation; +} + +SettingsPresentation present_setup_providers(const PagedSelection &selection, + int maximum_rows) +{ + SettingsPresentation presentation{"Model Provider", "Enter / Esc", {}}; + const int preset_count = static_cast(provider_preset_count()); + const int start = std::max(0, selection.scroll_offset); + const int end = std::min(preset_count, start + std::max(0, maximum_rows)); + for (int item = start; item < end; ++item) { + const ProviderConfig preset = provider_preset(static_cast(item)); + presentation.rows.push_back({provider_display_name(preset.family), + item == selection.selected_index ? "Selected" : ""}); + } + return presentation; +} + +SettingsPresentation present_providers(const std::vector &providers, + const PagedSelection &selection, + int maximum_rows) +{ + SettingsPresentation presentation{"Providers", "Enter / Esc", {}}; + const int total_rows = static_cast(providers.size()) + 1; + const int start = std::max(0, selection.scroll_offset); + const int end = std::min(total_rows, start + std::max(0, maximum_rows)); + for (int item = start; item < end; ++item) { + if (item == 0) + presentation.rows.push_back({"Add Provider", "+"}); + else + presentation.rows.push_back({providers[item - 1].alias, + providers[item - 1].model}); + } + return presentation; +} + +SettingsPresentation present_provider_detail(const ProviderConfig &provider) +{ + return { + "Provider", + "Enter / Del", + { + {"Alias", provider.alias}, + {"Family", provider.family}, + {"Model", provider.model}, + {"URI", provider.uri}, + {"API Key", provider.api_key.empty() ? "(empty)" : "set"}, + }, + }; +} + +SettingsPresentation present_setup_progress() +{ + return {"Quickstart", "Please wait", {}}; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_presentation.h b/projects/ZClaw/main/ui/zclaw_settings_presentation.h new file mode 100644 index 00000000..753cfbed --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_presentation.h @@ -0,0 +1,33 @@ +#pragma once + +#include "zclaw_selection_model.h" +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +struct SettingsRowPresentation { + std::string title; + std::string value; +}; + +struct SettingsPresentation { + std::string title; + std::string hint; + std::vector rows; +}; + +SettingsPresentation present_settings_main(const UiConfig &config); +SettingsPresentation present_authorization(const UiConfig &config); +SettingsPresentation present_setup(const ProviderConfig &provider); +SettingsPresentation present_setup_providers(const PagedSelection &selection, + int maximum_rows); +SettingsPresentation present_providers(const std::vector &providers, + const PagedSelection &selection, + int maximum_rows); +SettingsPresentation present_provider_detail(const ProviderConfig &provider); +SettingsPresentation present_setup_progress(); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_workflow.cpp b/projects/ZClaw/main/ui/zclaw_settings_workflow.cpp new file mode 100644 index 00000000..7489f2f7 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_workflow.cpp @@ -0,0 +1,164 @@ +#include "zclaw_settings_workflow.h" + +#include "zclaw_provider_manager.h" +#include "zclaw_settings_coordinator.h" + +namespace zclaw { + +SettingsWorkflow::SettingsWorkflow(ProviderManager &providers, + UiConfigManager &config, + SettingsCoordinator &settings, + InputDialog &input, FontManager &fonts, + ChatView &chat, AsyncService &async_service) + : providers_(providers), settings_(settings), + authorization_(config, settings, input, fonts, chat, async_service), + provider_workflow_(providers, settings, input, fonts, chat), + setup_(providers, config, settings, input, fonts, chat, async_service) +{ +} + +bool SettingsWorkflow::setup_in_flight() const +{ + return setup_.in_flight(); +} + +bool SettingsWorkflow::setup_retry_pending() const +{ + return setup_.retry_pending(); +} + +void SettingsWorkflow::move_setup_retry_selection(int delta) +{ + setup_.move_retry_selection(delta); +} + +bool SettingsWorkflow::activate_setup_retry_selection() +{ + return setup_.activate_retry_selection(); +} + +void SettingsWorkflow::dismiss_setup_retry() +{ + setup_.dismiss_error(); +} + +void SettingsWorkflow::open_setup(lv_obj_t *parent, bool first_run_needed) +{ + setup_.open(parent, first_run_needed); +} + +void SettingsWorkflow::update_setup_progress(const SetupProgress &progress) +{ + setup_.update_progress(progress); +} + +void SettingsWorkflow::apply_setup_edit(const std::string &value) +{ + setup_.apply_edit(value); +} + +void SettingsWorkflow::apply_provider_edit(const std::string &value) +{ + provider_workflow_.apply_edit(value); +} + +void SettingsWorkflow::start_pairing(const std::string &code) +{ + authorization_.start_pairing(code); +} + +void SettingsWorkflow::delete_current_provider() +{ + provider_workflow_.delete_current(); +} + +void SettingsWorkflow::activate_selection() +{ + if (!settings_.is_open()) + return; + if (setup_.retry_pending()) { + setup_.start(); + return; + } + const SettingsActivation activation = settings_.state().activation( + settings_.setup_initialize_row(), + static_cast(providers_.providers().size()), setup_.in_flight()); + switch (activation.action) { + case SettingsActivationAction::None: + break; + case SettingsActivationAction::ShowSetup: + setup_.render(); + break; + case SettingsActivationAction::ShowSetupProviders: + settings_.sync_setup_provider_selection(); + settings_.render_setup_providers(); + break; + case SettingsActivationAction::ShowAuthorization: + settings_.render_authorization(); + break; + case SettingsActivationAction::ShowProviders: + settings_.state().reset_providers(); + settings_.render_providers(); + break; + case SettingsActivationAction::SelectSetupProvider: + setup_.select_provider( + settings_.state().setup_provider_selection().selected_index); + break; + case SettingsActivationAction::StartSetup: + setup_.start(); + break; + case SettingsActivationAction::EditSetupField: + setup_.edit_selected_field(); + break; + case SettingsActivationAction::EnterPairingCode: + authorization_.open_pairing_input(); + break; + case SettingsActivationAction::ClearToken: + authorization_.clear_token(); + break; + case SettingsActivationAction::AddProvider: + provider_workflow_.add(); + break; + case SettingsActivationAction::ShowProviderDetail: + provider_workflow_.open_detail(activation.provider_index); + break; + case SettingsActivationAction::EditProviderField: + provider_workflow_.edit_selected_field(); + break; + } +} + +bool SettingsWorkflow::navigate_back(bool first_run_needed) +{ + if (setup_.retry_pending()) { + setup_.dismiss_error(); + return false; + } + switch (settings_.state().back_action(setup_.in_flight(), first_run_needed)) { + case SettingsBackAction::None: + break; + case SettingsBackAction::Close: + settings_.close(); + break; + case SettingsBackAction::Quit: + return true; + case SettingsBackAction::ShowMain: + settings_.state().set_selected_row(0); + settings_.render_main(); + break; + case SettingsBackAction::ShowSetup: + settings_.state().set_selected_row(0); + setup_.render(); + break; + case SettingsBackAction::ShowSetupProviders: + settings_.render_setup_providers(); + break; + case SettingsBackAction::ShowProviders: + settings_.state().close_provider_detail(); + settings_.render_providers(); + break; + } + return false; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_settings_workflow.h b/projects/ZClaw/main/ui/zclaw_settings_workflow.h new file mode 100644 index 00000000..1581717a --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_settings_workflow.h @@ -0,0 +1,50 @@ +#pragma once + +#include "lvgl/lvgl.h" +#include "zclaw_authorization_workflow.h" +#include "zclaw_provider_workflow.h" +#include "zclaw_setup_workflow.h" +#include "zclaw_types.h" + +#include + +namespace zclaw { + +class AsyncService; +class ChatView; +class FontManager; +class InputDialog; +class ProviderManager; +class SettingsCoordinator; +class UiConfigManager; + +class SettingsWorkflow { +public: + SettingsWorkflow(ProviderManager &providers, UiConfigManager &config, + SettingsCoordinator &settings, InputDialog &input, + FontManager &fonts, ChatView &chat, AsyncService &async_service); + + bool setup_in_flight() const; + bool setup_retry_pending() const; + void move_setup_retry_selection(int delta); + bool activate_setup_retry_selection(); + void dismiss_setup_retry(); + void open_setup(lv_obj_t *parent, bool first_run_needed); + void update_setup_progress(const SetupProgress &progress); + + void apply_setup_edit(const std::string &value); + void apply_provider_edit(const std::string &value); + void start_pairing(const std::string &code); + void delete_current_provider(); + void activate_selection(); + bool navigate_back(bool first_run_needed); + +private: + ProviderManager &providers_; + SettingsCoordinator &settings_; + AuthorizationWorkflow authorization_; + ProviderWorkflow provider_workflow_; + SetupWorkflow setup_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_backend.h b/projects/ZClaw/main/ui/zclaw_setup_backend.h new file mode 100644 index 00000000..65994c44 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_backend.h @@ -0,0 +1,24 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +class SetupBackend { +public: + using ProgressHandler = std::function; + + virtual ~SetupBackend() = default; + + virtual bool ensure_installed(std::string *error, + const ProgressHandler &progress_handler) const = 0; + virtual bool apply_config(UiConfig *config, const ProviderConfig &provider, + std::string *error) const = 0; + virtual bool start_service(std::string *error) const = 0; + virtual std::string generate_pairing_code() const = 0; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_progress_model.cpp b/projects/ZClaw/main/ui/zclaw_setup_progress_model.cpp new file mode 100644 index 00000000..c4d30252 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_progress_model.cpp @@ -0,0 +1,57 @@ +#include "zclaw_setup_progress_model.h" + +#include + +namespace zclaw { +namespace { + +std::string format_transfer_detail(const SetupProgress &progress) +{ + if (progress.total_bytes == 0) + return progress.status; + char buffer[96]; + const double downloaded_mb = static_cast(progress.downloaded_bytes) / + (1024.0 * 1024.0); + const double total_mb = static_cast(progress.total_bytes) / + (1024.0 * 1024.0); + std::snprintf(buffer, sizeof(buffer), "%.1f / %.1f MB", downloaded_mb, total_mb); + return buffer; +} + +std::string format_transfer_speed(double bytes_per_second) +{ + if (bytes_per_second <= 0.0) + return "Waiting for data"; + char buffer[64]; + if (bytes_per_second >= 1024.0 * 1024.0) + std::snprintf(buffer, sizeof(buffer), "%.1f MB/s", + bytes_per_second / (1024.0 * 1024.0)); + else if (bytes_per_second >= 1024.0) + std::snprintf(buffer, sizeof(buffer), "%.0f KB/s", + bytes_per_second / 1024.0); + else + std::snprintf(buffer, sizeof(buffer), "%.0f B/s", bytes_per_second); + return buffer; +} + +} // namespace + +SetupProgressPresentation present_setup_progress(const SetupProgress &progress) +{ + SetupProgressPresentation presentation; + presentation.status = progress.status; + presentation.percent = progress.percent < 0 ? 0 : + (progress.percent > 100 ? 100 : progress.percent); + presentation.percent_text = std::to_string(presentation.percent) + "%"; + presentation.detail = format_transfer_detail(progress); + if (!progress.source_url.empty() || progress.total_bytes > 0 || + progress.downloaded_bytes > 0 || progress.bytes_per_second > 0.0) + presentation.speed = format_transfer_speed(progress.bytes_per_second); + if (!progress.source_url.empty()) + presentation.source_url = progress.source_url; + if (!progress.destination_path.empty()) + presentation.destination_path = progress.destination_path; + return presentation; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_progress_model.h b/projects/ZClaw/main/ui/zclaw_setup_progress_model.h new file mode 100644 index 00000000..c23ed36d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_progress_model.h @@ -0,0 +1,21 @@ +#pragma once + +#include "zclaw_types.h" + +#include + +namespace zclaw { + +struct SetupProgressPresentation { + std::string status; + std::string detail; + std::string percent_text; + std::string speed; + std::string source_url; + std::string destination_path; + int percent = 0; +}; + +SetupProgressPresentation present_setup_progress(const SetupProgress &progress); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_progress_view.cpp b/projects/ZClaw/main/ui/zclaw_setup_progress_view.cpp new file mode 100644 index 00000000..c96d7d93 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_progress_view.cpp @@ -0,0 +1,156 @@ +#include "zclaw_setup_progress_view.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_setup_progress_model.h" +#include "zclaw_theme.h" +#include "zclaw_widgets.h" + +namespace zclaw { +namespace { + +constexpr uint32_t kScrollPixelsPerSecond = 20; + +void set_scrolling_text(lv_obj_t *label, const std::string &text) +{ + const lv_font_t *font = lv_obj_get_style_text_font(label, LV_PART_MAIN); + const int32_t letter_space = + lv_obj_get_style_text_letter_space(label, LV_PART_MAIN); + const int32_t line_space = + lv_obj_get_style_text_line_space(label, LV_PART_MAIN); + lv_point_t text_size{}; + lv_point_t gap_size{}; + lv_text_get_size(&text_size, text.c_str(), font, letter_space, line_space, + LV_COORD_MAX, LV_TEXT_FLAG_NONE); + lv_text_get_size(&gap_size, " ", font, letter_space, line_space, + LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const uint32_t duration = lv_anim_speed_to_time( + kScrollPixelsPerSecond, 0, text_size.x + gap_size.x); + lv_obj_set_style_anim_duration(label, duration, LV_PART_MAIN); + lv_label_set_text(label, text.c_str()); +} + +} // namespace + +SetupProgressView::~SetupProgressView() +{ + clear(); +} + +void SetupProgressView::show(lv_obj_t *parent, const FontManager *fonts) +{ + if (!parent || !fonts) + return; + clear(); + root_ = lv_obj_create(parent); + lv_obj_remove_style_all(root_); + lv_obj_set_size(root_, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(root_, static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + lv_obj_add_event_cb(root_, root_deleted, LV_EVENT_DELETE, this); + + spinner_ = lv_spinner_create(root_); + lv_obj_set_size(spinner_, 22, 22); + lv_obj_set_pos(spinner_, 18, 29); + lv_spinner_set_anim_params(spinner_, 900, 70); + lv_obj_set_style_arc_width(spinner_, 3, LV_PART_MAIN); + lv_obj_set_style_arc_width(spinner_, 3, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(spinner_, lv_color_hex(theme::kPanelLine), LV_PART_MAIN); + lv_obj_set_style_arc_color(spinner_, lv_color_hex(theme::kPurple), LV_PART_INDICATOR); + + status_label_ = widgets::label(root_, "Preparing Quickstart", 50, 28, 252, 16, + fonts->font_12(), theme::kText); + detail_label_ = widgets::label(root_, "Starting...", 50, 45, 252, 14, + fonts->font_10(), theme::kMuted); + url_prefix_label_ = widgets::label(root_, "URL :", 18, 64, 38, 14, + fonts->font_10(), theme::kMuted); + url_label_ = widgets::label(root_, "", 56, 64, 246, 14, + fonts->font_10(), theme::kMuted); + path_prefix_label_ = widgets::label(root_, "SAVE :", 18, 79, 38, 14, + fonts->font_10(), theme::kMuted); + path_label_ = widgets::label(root_, "", 56, 79, 246, 14, + fonts->font_10(), theme::kMuted); + lv_label_set_long_mode(url_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_label_set_long_mode(path_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_add_flag(url_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(path_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(url_prefix_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(path_prefix_label_, LV_OBJ_FLAG_HIDDEN); + progress_bar_ = lv_bar_create(root_); + lv_obj_set_pos(progress_bar_, 18, 101); + lv_obj_set_size(progress_bar_, 284, 10); + lv_bar_set_range(progress_bar_, 0, 100); + lv_bar_set_value(progress_bar_, 0, LV_ANIM_OFF); + lv_obj_set_style_radius(progress_bar_, 3, LV_PART_MAIN); + lv_obj_set_style_radius(progress_bar_, 3, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(progress_bar_, lv_color_hex(theme::kPanel), LV_PART_MAIN); + lv_obj_set_style_bg_color(progress_bar_, lv_color_hex(theme::kPurple), LV_PART_INDICATOR); + percent_label_ = widgets::label(root_, "0%", 18, 117, 70, 14, + fonts->font_10(), theme::kText); + speed_label_ = widgets::label(root_, "", 92, 117, 210, 14, + fonts->font_10(), theme::kMuted, LV_TEXT_ALIGN_RIGHT); +} + +void SetupProgressView::update(const SetupProgress &progress) +{ + if (!is_visible()) + return; + const SetupProgressPresentation presentation = present_setup_progress(progress); + lv_label_set_text(status_label_, presentation.status.c_str()); + lv_label_set_text(detail_label_, presentation.detail.c_str()); + set_scrolling_text(url_label_, presentation.source_url); + set_scrolling_text(path_label_, presentation.destination_path); + if (presentation.source_url.empty()) { + lv_obj_add_flag(url_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(url_prefix_label_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_clear_flag(url_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(url_prefix_label_, LV_OBJ_FLAG_HIDDEN); + } + if (presentation.destination_path.empty()) { + lv_obj_add_flag(path_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(path_prefix_label_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_clear_flag(path_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(path_prefix_label_, LV_OBJ_FLAG_HIDDEN); + } + lv_bar_set_value(progress_bar_, presentation.percent, LV_ANIM_ON); + lv_label_set_text(percent_label_, presentation.percent_text.c_str()); + lv_label_set_text(speed_label_, presentation.speed.c_str()); +} + +void SetupProgressView::clear() +{ + if (root_) + lv_obj_del(root_); + release(); +} + +void SetupProgressView::root_deleted(lv_event_t *event) +{ + SetupProgressView *view = + static_cast(lv_event_get_user_data(event)); + if (view) + view->release(); +} + +void SetupProgressView::release() +{ + root_ = nullptr; + spinner_ = nullptr; + status_label_ = nullptr; + detail_label_ = nullptr; + url_prefix_label_ = nullptr; + url_label_ = nullptr; + path_prefix_label_ = nullptr; + path_label_ = nullptr; + progress_bar_ = nullptr; + percent_label_ = nullptr; + speed_label_ = nullptr; +} + +bool SetupProgressView::is_visible() const +{ + return root_ != nullptr && progress_bar_ != nullptr; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_progress_view.h b/projects/ZClaw/main/ui/zclaw_setup_progress_view.h new file mode 100644 index 00000000..c5727f0c --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_progress_view.h @@ -0,0 +1,40 @@ +#pragma once + +#include "lvgl/lvgl.h" +#include "zclaw_types.h" + +namespace zclaw { + +class FontManager; + +class SetupProgressView { +public: + SetupProgressView() = default; + ~SetupProgressView(); + + SetupProgressView(const SetupProgressView &) = delete; + SetupProgressView &operator=(const SetupProgressView &) = delete; + + void show(lv_obj_t *parent, const FontManager *fonts); + void update(const SetupProgress &progress); + void clear(); + bool is_visible() const; + +private: + static void root_deleted(lv_event_t *event); + void release(); + + lv_obj_t *root_ = nullptr; + lv_obj_t *spinner_ = nullptr; + lv_obj_t *status_label_ = nullptr; + lv_obj_t *detail_label_ = nullptr; + lv_obj_t *url_prefix_label_ = nullptr; + lv_obj_t *url_label_ = nullptr; + lv_obj_t *path_prefix_label_ = nullptr; + lv_obj_t *path_label_ = nullptr; + lv_obj_t *progress_bar_ = nullptr; + lv_obj_t *percent_label_ = nullptr; + lv_obj_t *speed_label_ = nullptr; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_service.cpp b/projects/ZClaw/main/ui/zclaw_setup_service.cpp new file mode 100644 index 00000000..ecea6e90 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_service.cpp @@ -0,0 +1,54 @@ +#include "zclaw_setup_service.h" + +#include "zclaw_quickstart_model.h" +#include "zclaw_setup_backend.h" + +#include + +namespace zclaw { +namespace { + +void report_progress(const SetupService::ProgressHandler &handler, + const char *status, int percent) +{ + if (handler) + handler({status ? status : "", percent}); +} + +} // namespace + +SetupService::SetupService(const SetupBackend &backend) + : backend_(&backend) +{ +} + +PreparedSetup SetupService::prepare( + UiConfig config, ProviderConfig provider, + const ProgressHandler &progress_handler) const +{ + PreparedSetup result; + result.config = std::move(config); + provider = normalize_setup_provider(std::move(provider)); + + if (!backend_->ensure_installed(&result.error, progress_handler)) + return result; + + report_progress(progress_handler, "Configuring provider", 72); + if (!backend_->apply_config(&result.config, provider, &result.error)) + return result; + + report_progress(progress_handler, "Starting ZeroClaw service", 86); + if (!backend_->start_service(&result.error)) + return result; + + report_progress(progress_handler, "Pairing local client", 94); + result.pairing_code = backend_->generate_pairing_code(); + if (result.pairing_code.empty()) { + result.error = "ZeroClaw started, but pairing code generation failed."; + return result; + } + result.ok = true; + return result; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_service.h b/projects/ZClaw/main/ui/zclaw_setup_service.h new file mode 100644 index 00000000..32963f07 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_service.h @@ -0,0 +1,32 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +class SetupBackend; + +struct PreparedSetup { + UiConfig config; + std::string pairing_code; + std::string error; + bool ok = false; +}; + +class SetupService { +public: + using ProgressHandler = std::function; + + explicit SetupService(const SetupBackend &backend); + + PreparedSetup prepare(UiConfig config, ProviderConfig provider, + const ProgressHandler &progress_handler) const; + +private: + const SetupBackend *backend_ = nullptr; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_workflow.cpp b/projects/ZClaw/main/ui/zclaw_setup_workflow.cpp new file mode 100644 index 00000000..24401636 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_workflow.cpp @@ -0,0 +1,194 @@ +#include "zclaw_setup_workflow.h" + +#include "zclaw_async_service.h" +#include "zclaw_chat_view.h" +#include "zclaw_fonts.hpp" +#include "zclaw_input_dialog.h" +#include "zclaw_provider_catalog.h" +#include "zclaw_provider_manager.h" +#include "zclaw_settings_coordinator.h" +#include "zclaw_ui_config_manager.h" + +#include +#include + +namespace zclaw { + +SetupWorkflow::SetupWorkflow(ProviderManager &providers, UiConfigManager &config, + SettingsCoordinator &settings, InputDialog &input, + FontManager &fonts, ChatView &chat, + AsyncService &async_service) + : providers_(providers), config_(config), settings_(settings), input_(input), + fonts_(fonts), chat_(chat), async_service_(async_service), + lifetime_(std::make_shared>(this)) +{ +} + +SetupWorkflow::~SetupWorkflow() +{ + lifetime_->invalidate(); +} + +bool SetupWorkflow::in_flight() const +{ + return in_flight_; +} + +bool SetupWorkflow::retry_pending() const +{ + return retry_pending_; +} + +void SetupWorkflow::open(lv_obj_t *parent, bool first_run_needed) +{ + settings_.open_setup(parent, first_run_needed, in_flight_); +} + +void SetupWorkflow::render() +{ + settings_.render_setup(in_flight_); +} + +void SetupWorkflow::update_progress(const SetupProgress &progress) +{ + settings_.update_setup_progress(progress, in_flight_); +} + +void SetupWorkflow::select_provider(int preset_index) +{ + if (preset_index < 0 || + static_cast(preset_index) >= provider_preset_count()) + return; + const ProviderConfig selected = + provider_preset(static_cast(preset_index)); + std::string error; + if (!providers_.activate(selected, &error)) + report_save_error(error); + settings_.state().set_selected_row(0); + render(); +} + +void SetupWorkflow::edit_selected_field() +{ + settings_.state().set_setup_edit_field(setup_edit_field_for_row( + providers_.setup_provider(), settings_.state().selected_row())); + ProviderConfig provider = providers_.setup_provider(); + const std::string *value = + setup_field_value(&provider, settings_.state().setup_edit_field()); + if (value) + input_.open_text(&fonts_, + setup_field_name(settings_.state().setup_edit_field()), + *value, InputMode::SetupEdit); +} + +void SetupWorkflow::apply_edit(const std::string &value) +{ + ProviderConfig provider = providers_.setup_provider(); + std::string *field = + setup_field_value(&provider, settings_.state().setup_edit_field()); + if (field) + *field = value; + settings_.state().set_setup_edit_field(SetupEditField::None); + std::string error; + if (!providers_.update_setup_provider(provider, &error)) + report_save_error(error); + render(); +} + +void SetupWorkflow::start() +{ + if (in_flight_) + return; + std::string validation_error; + if (!settings_.validate_setup(&validation_error)) { + chat_.append_assistant_message(validation_error); + render(); + return; + } + + retry_pending_ = false; + settings_.close_setup_error(); + in_flight_ = true; + chat_.append_assistant_message("Configuring ZeroClaw service..."); + render(); + const std::shared_ptr> lifetime = lifetime_; + if (!async_service_.setup( + config_.config(), providers_.setup_provider(), + [lifetime](SetupProgress progress) { + lifetime->invoke([&progress](SetupWorkflow &workflow) { + workflow.update_progress(progress); + }); + }, + [lifetime](OperationResult result) { + lifetime->invoke([&result](SetupWorkflow &workflow) { + workflow.finish(std::move(result)); + }); + })) { + in_flight_ = false; + chat_.append_assistant_message("Could not start setup thread."); + render(); + } +} + +void SetupWorkflow::finish(OperationResult result) +{ + in_flight_ = false; + bool persisted = result.ok; + chat_.append_assistant_message(result.text); + if (!result.ok && settings_.is_open() && + settings_.state().view() == SettingsView::Setup) { + retry_pending_ = true; + retry_selection_ = 0; + settings_.show_setup_error(result.text); + return; + } + if (result.ok) { + std::string config_error; + if (!config_.update(result.config, &config_error)) { + report_save_error(config_error); + persisted = false; + } + } + if (persisted && settings_.is_open() && + settings_.state().view() == SettingsView::Setup) { + render(); + settings_.close(); + } +} + +void SetupWorkflow::move_retry_selection(int delta) +{ + if (!retry_pending_ || delta == 0) + return; + retry_selection_ = delta < 0 ? 0 : 1; + settings_.update_setup_error_selection(retry_selection_); +} + +bool SetupWorkflow::activate_retry_selection() +{ + if (!retry_pending_) + return false; + if (retry_selection_ == 0) { + start(); + return false; + } + retry_pending_ = false; + settings_.close_setup_error(); + return true; +} + +void SetupWorkflow::dismiss_error() +{ + if (!retry_pending_) + return; + retry_pending_ = false; + settings_.close_setup_error(); +} + +void SetupWorkflow::report_save_error(const std::string &error) +{ + chat_.append_assistant_message( + error.empty() ? "Could not save settings." : error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_setup_workflow.h b/projects/ZClaw/main/ui/zclaw_setup_workflow.h new file mode 100644 index 00000000..533f1221 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_setup_workflow.h @@ -0,0 +1,58 @@ +#pragma once + +#include "lvgl/lvgl.h" +#include "zclaw_callback_lifetime.h" +#include "zclaw_types.h" + +#include +#include + +namespace zclaw { + +class AsyncService; +class ChatView; +class FontManager; +class InputDialog; +class ProviderManager; +class SettingsCoordinator; +class UiConfigManager; + +class SetupWorkflow { +public: + SetupWorkflow(ProviderManager &providers, UiConfigManager &config, + SettingsCoordinator &settings, InputDialog &input, + FontManager &fonts, ChatView &chat, + AsyncService &async_service); + ~SetupWorkflow(); + + bool in_flight() const; + bool retry_pending() const; + void open(lv_obj_t *parent, bool first_run_needed); + void render(); + void update_progress(const SetupProgress &progress); + void select_provider(int preset_index); + void edit_selected_field(); + void apply_edit(const std::string &value); + void start(); + void dismiss_error(); + void move_retry_selection(int delta); + bool activate_retry_selection(); + +private: + void finish(OperationResult result); + void report_save_error(const std::string &error); + + ProviderManager &providers_; + UiConfigManager &config_; + SettingsCoordinator &settings_; + InputDialog &input_; + FontManager &fonts_; + ChatView &chat_; + AsyncService &async_service_; + bool in_flight_ = false; + bool retry_pending_ = false; + int retry_selection_ = 0; + std::shared_ptr> lifetime_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_shell_view.cpp b/projects/ZClaw/main/ui/zclaw_shell_view.cpp new file mode 100644 index 00000000..d6a3fa58 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_shell_view.cpp @@ -0,0 +1,109 @@ +#include "zclaw_shell_view.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_theme.h" +#include "zclaw_widgets.h" + +namespace zclaw { +namespace { + +constexpr lv_coord_t kScreenWidth = 320; +constexpr lv_coord_t kScreenHeight = 170; +lv_coord_t centered_y(lv_coord_t container_height, lv_coord_t item_height) +{ + return (container_height - item_height) / 2; +} + +} // namespace + +ShellView::~ShellView() +{ + destroy_root(); +} + +bool ShellView::create(lv_obj_t *parent, const FontManager *fonts, + const std::string &avatar_path, + const std::string &sparkles_path, + const std::string &send_button_path) +{ + if (root_ || !parent || !fonts) + return false; + root_ = widgets::box(parent, 0, 0, kScreenWidth, kScreenHeight, + theme::kBackground); + lv_obj_add_event_cb(root_, root_deleted, LV_EVENT_DELETE, this); + lv_obj_move_foreground(root_); + create_top_bar(fonts, avatar_path); + create_input_bar(fonts, sparkles_path, send_button_path); + return true; +} + +lv_obj_t *ShellView::content() const +{ + return root_; +} + +void ShellView::root_deleted(lv_event_t *event) +{ + ShellView *view = static_cast(lv_event_get_user_data(event)); + if (view) + view->release_root(); +} + +void ShellView::destroy_root() +{ + lv_obj_t *root = root_; + if (root) + lv_obj_del(root); + release_root(); +} + +void ShellView::release_root() +{ + root_ = nullptr; +} + +void ShellView::create_top_bar(const FontManager *fonts, + const std::string &avatar_path) +{ + constexpr lv_coord_t bar_height = 20; + constexpr lv_coord_t avatar_size = 16; + constexpr lv_coord_t status_size = 6; + constexpr lv_coord_t dot_size = 2; + const lv_coord_t name_height = lv_font_get_line_height(fonts->font_12()); + const lv_coord_t status_height = lv_font_get_line_height(fonts->font_10()); + + lv_obj_t *bar = widgets::box(root_, 0, 0, kScreenWidth, bar_height, + theme::kBar); + widgets::image(bar, avatar_path, 12, centered_y(bar_height, avatar_size)); + widgets::box(bar, 34, centered_y(bar_height, status_size), status_size, + status_size, theme::kOnline, status_size / 2); + widgets::label(bar, "ZClaw", 47, centered_y(bar_height, name_height), 42, + name_height, fonts->font_12(), theme::kText); + widgets::label(bar, "Online", 89, centered_y(bar_height, status_height), 48, + status_height, fonts->font_10(), theme::kOnline); + + const lv_coord_t ellipsis_y = centered_y(bar_height, dot_size); + widgets::box(bar, 295, ellipsis_y, dot_size, dot_size, theme::kMuted, + dot_size / 2); + widgets::box(bar, 300, ellipsis_y, dot_size, dot_size, theme::kMuted, + dot_size / 2); + widgets::box(bar, 305, ellipsis_y, dot_size, dot_size, theme::kMuted, + dot_size / 2); +} + +void ShellView::create_input_bar(const FontManager *fonts, + const std::string &sparkles_path, + const std::string &send_button_path) +{ + lv_obj_t *input_bar = widgets::box(root_, 0, 148, kScreenWidth, 22, + theme::kBar); + widgets::box(input_bar, 0, 0, kScreenWidth, 1, theme::kPanelLine); + lv_obj_t *input_box = widgets::box(input_bar, 10, 3, 274, 16, + theme::kPanel, 8); + widgets::image(input_box, sparkles_path, 8, 3); + widgets::label(input_box, "Press Enter to ask", 26, 3, 180, 10, + fonts->font_10(), theme::kDim); + widgets::image(input_bar, send_button_path, 292, 2); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_shell_view.h b/projects/ZClaw/main/ui/zclaw_shell_view.h new file mode 100644 index 00000000..c2b3ab11 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_shell_view.h @@ -0,0 +1,36 @@ +#pragma once + +#include "lvgl/lvgl.h" + +#include + +namespace zclaw { + +class FontManager; + +class ShellView { +public: + ShellView() = default; + ~ShellView(); + + ShellView(const ShellView &) = delete; + ShellView &operator=(const ShellView &) = delete; + + bool create(lv_obj_t *parent, const FontManager *fonts, + const std::string &avatar_path, + const std::string &sparkles_path, + const std::string &send_button_path); + lv_obj_t *content() const; + +private: + static void root_deleted(lv_event_t *event); + void destroy_root(); + void release_root(); + void create_top_bar(const FontManager *fonts, const std::string &avatar_path); + void create_input_bar(const FontManager *fonts, const std::string &sparkles_path, + const std::string &send_button_path); + + lv_obj_t *root_ = nullptr; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_startup_model.h b/projects/ZClaw/main/ui/zclaw_startup_model.h new file mode 100644 index 00000000..8cd55a4b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_startup_model.h @@ -0,0 +1,11 @@ +#pragma once + +namespace zclaw { + +enum class StartupState { + CheckingNetwork, + Offline, + Ready, +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_startup_view.cpp b/projects/ZClaw/main/ui/zclaw_startup_view.cpp new file mode 100644 index 00000000..f451912f --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_startup_view.cpp @@ -0,0 +1,89 @@ +#include "zclaw_startup_view.h" + +#include "zclaw_fonts.hpp" +#include "zclaw_theme.h" +#include "zclaw_widgets.h" + +namespace zclaw { +namespace { + +constexpr lv_coord_t kScreenWidth = 320; +constexpr lv_coord_t kScreenHeight = 170; + +} // namespace + +StartupView::~StartupView() +{ + destroy_overlay(); +} + +void StartupView::show_network_check(lv_obj_t *parent, const FontManager *fonts) +{ + if (!parent || !fonts) + return; + destroy_overlay(); + fonts_ = fonts; + state_ = StartupState::CheckingNetwork; + overlay_ = widgets::box(parent, 0, 0, kScreenWidth, kScreenHeight, theme::kBackground); + lv_obj_add_event_cb(overlay_, overlay_deleted, LV_EVENT_DELETE, this); + lv_obj_move_foreground(overlay_); + widgets::label(overlay_, "ZCLAW", 16, 24, 288, 20, fonts_->font_12(), theme::kPurple, + LV_TEXT_ALIGN_CENTER); + title_ = widgets::label(overlay_, "Checking network...", 16, 68, 288, 20, + fonts_->font_12(), theme::kText, LV_TEXT_ALIGN_CENTER); + detail_ = widgets::label(overlay_, "Please wait", 16, 96, 288, 16, + fonts_->font_10(), theme::kMuted, LV_TEXT_ALIGN_CENTER); +} + +void StartupView::show_offline(const std::string &error) +{ + if (!overlay_ || !title_ || !detail_ || !fonts_) + return; + state_ = StartupState::Offline; + lv_label_set_text(title_, "No internet connection"); + const std::string detail = error.empty() ? "Check Wi-Fi and try again." : + "Check Wi-Fi and try again.\n" + error; + lv_label_set_text(detail_, detail.c_str()); + lv_obj_set_height(detail_, 34); + if (!action_) + action_ = widgets::label(overlay_, "OK EXIT", 16, 142, 288, 14, + fonts_->font_10(), theme::kPurple, LV_TEXT_ALIGN_CENTER); +} + +void StartupView::show_ready() +{ + state_ = StartupState::Ready; + destroy_overlay(); +} + +StartupState StartupView::state() const +{ + return state_; +} + +void StartupView::destroy_overlay() +{ + lv_obj_t *overlay = overlay_; + if (overlay) + lv_obj_del(overlay); + release_overlay(); +} + +void StartupView::overlay_deleted(lv_event_t *event) +{ + StartupView *view = + static_cast(lv_event_get_user_data(event)); + if (view) + view->release_overlay(); +} + +void StartupView::release_overlay() +{ + fonts_ = nullptr; + overlay_ = nullptr; + title_ = nullptr; + detail_ = nullptr; + action_ = nullptr; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_startup_view.h b/projects/ZClaw/main/ui/zclaw_startup_view.h new file mode 100644 index 00000000..aff7466f --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_startup_view.h @@ -0,0 +1,39 @@ +#pragma once + +#include "lvgl/lvgl.h" +#include "zclaw_startup_model.h" + +#include + +namespace zclaw { + +class FontManager; + +class StartupView { +public: + StartupView() = default; + ~StartupView(); + + StartupView(const StartupView &) = delete; + StartupView &operator=(const StartupView &) = delete; + + void show_network_check(lv_obj_t *parent, const FontManager *fonts); + void show_offline(const std::string &error); + void show_ready(); + + StartupState state() const; + +private: + static void overlay_deleted(lv_event_t *event); + void destroy_overlay(); + void release_overlay(); + + const FontManager *fonts_ = nullptr; + lv_obj_t *overlay_ = nullptr; + lv_obj_t *title_ = nullptr; + lv_obj_t *detail_ = nullptr; + lv_obj_t *action_ = nullptr; + StartupState state_ = StartupState::CheckingNetwork; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_startup_workflow.cpp b/projects/ZClaw/main/ui/zclaw_startup_workflow.cpp new file mode 100644 index 00000000..169c9183 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_startup_workflow.cpp @@ -0,0 +1,61 @@ +#include "zclaw_startup_workflow.h" + +#include "zclaw_async_service.h" +#include "zclaw_runtime_state.h" +#include "zclaw_ui_config_manager.h" +#include "zclaw_ui_task_queue.h" + +#include + +namespace zclaw { + +StartupWorkflow::StartupWorkflow(UiConfigManager &config, StartupView &view, + AsyncService &async_service, + std::shared_ptr tasks, + OpenSetup open_setup) + : config_(config), view_(view), async_service_(async_service), + tasks_(std::move(tasks)), open_setup_(std::move(open_setup)), + lifetime_(std::make_shared>(this)) +{ +} + +StartupWorkflow::~StartupWorkflow() +{ + lifetime_->invalidate(); +} + +void StartupWorkflow::start(lv_obj_t *parent, const FontManager *fonts) +{ + view_.show_network_check(parent, fonts); + if (!tasks_ || !tasks_->start()) { + view_.show_offline("Could not start the UI task queue."); + return; + } + const std::shared_ptr> lifetime = lifetime_; + if (!async_service_.check_network([lifetime](NetworkCheckResult result) { + lifetime->invoke([&result](StartupWorkflow &workflow) { + workflow.finish_check(result.online, result.error); + }); + })) { + finish_check(false, "Could not start network check."); + } +} + +StartupState StartupWorkflow::state() const +{ + return view_.state(); +} + +void StartupWorkflow::finish_check(bool online, const std::string &error) +{ + if (!online) { + view_.show_offline(error); + return; + } + + view_.show_ready(); + if (RuntimeState::first_run_needed(config_.config()) && open_setup_) + open_setup_(); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_startup_workflow.h b/projects/ZClaw/main/ui/zclaw_startup_workflow.h new file mode 100644 index 00000000..5423cb74 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_startup_workflow.h @@ -0,0 +1,40 @@ +#pragma once + +#include "zclaw_callback_lifetime.h" +#include "zclaw_startup_view.h" + +#include +#include +#include + +namespace zclaw { + +class AsyncService; +class FontManager; +class UiConfigManager; +class UiTaskQueue; + +class StartupWorkflow { +public: + using OpenSetup = std::function; + + StartupWorkflow(UiConfigManager &config, StartupView &view, + AsyncService &async_service, + std::shared_ptr tasks, OpenSetup open_setup); + ~StartupWorkflow(); + + void start(lv_obj_t *parent, const FontManager *fonts); + StartupState state() const; + +private: + void finish_check(bool online, const std::string &error); + + UiConfigManager &config_; + StartupView &view_; + AsyncService &async_service_; + std::shared_ptr tasks_; + OpenSetup open_setup_; + std::shared_ptr> lifetime_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_storage.cpp b/projects/ZClaw/main/ui/zclaw_storage.cpp new file mode 100644 index 00000000..00ebd93d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_storage.cpp @@ -0,0 +1,84 @@ +#include "zclaw_storage.h" + +#include +#include +#include + +namespace zclaw { +namespace { + +namespace fs = std::filesystem; + +bool set_owner_only_permissions(const fs::path &directory, std::string *error) +{ + std::error_code ec; + fs::permissions(directory, fs::perms::owner_all, + fs::perm_options::replace, ec); + if (!ec) + return true; + if (error) + *error = "Could not secure settings directory: " + ec.message(); + return false; +} + +} // namespace + +bool ensure_private_parent_directory(const std::string &path, std::string *error) +{ + const fs::path parent = fs::path(path).parent_path(); + if (parent.empty() || parent == parent.root_path()) + return true; + + std::vector missing; + for (fs::path current = parent; !current.empty();) { + std::error_code status_error; + const fs::file_status status = fs::symlink_status(current, status_error); + if (!status_error && fs::is_symlink(status)) { + if (error) + *error = "Could not secure settings directory: Symbolic link in path"; + return false; + } + if (!status_error && fs::exists(status)) + break; + if (status_error && status_error != std::errc::no_such_file_or_directory) { + if (error) + *error = "Could not inspect settings directory: " + + status_error.message(); + return false; + } + missing.push_back(current); + const fs::path next = current.parent_path(); + if (next == current) + break; + current = next; + } + + std::error_code create_error; + fs::create_directories(parent, create_error); + if (create_error) { + if (error) + *error = "Could not create settings directory: " + + create_error.message(); + return false; + } + + std::error_code status_error; + const fs::file_status parent_status = fs::symlink_status(parent, status_error); + if (status_error || !fs::is_directory(parent_status)) { + if (error) + *error = "Could not create settings directory: " + + (status_error ? status_error.message() : "Not a directory"); + return false; + } + + for (auto directory = missing.rbegin(); directory != missing.rend(); ++directory) { + if (!set_owner_only_permissions(*directory, error)) + return false; + } + if (missing.empty() && !set_owner_only_permissions(parent, error)) + return false; + + return true; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_storage.h b/projects/ZClaw/main/ui/zclaw_storage.h new file mode 100644 index 00000000..e7cffe0e --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_storage.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace zclaw { + +bool ensure_private_parent_directory(const std::string &path, + std::string *error = nullptr); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_task_batch.cpp b/projects/ZClaw/main/ui/zclaw_task_batch.cpp new file mode 100644 index 00000000..c9ac90f5 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_task_batch.cpp @@ -0,0 +1,20 @@ +#include "zclaw_task_batch.h" + +namespace zclaw { + +TaskBatchResult execute_task_batch( + std::vector> tasks) noexcept +{ + TaskBatchResult result; + for (std::function &task : tasks) { + ++result.executed; + try { + task(); + } catch (...) { + ++result.failed; + } + } + return result; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_task_batch.h b/projects/ZClaw/main/ui/zclaw_task_batch.h new file mode 100644 index 00000000..7ceda125 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_task_batch.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +namespace zclaw { + +struct TaskBatchResult { + std::size_t executed = 0; + std::size_t failed = 0; +}; + +TaskBatchResult execute_task_batch( + std::vector> tasks) noexcept; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_task_inbox.cpp b/projects/ZClaw/main/ui/zclaw_task_inbox.cpp new file mode 100644 index 00000000..8dae9666 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_task_inbox.cpp @@ -0,0 +1,46 @@ +#include "zclaw_task_inbox.h" + +#include + +namespace zclaw { + +void TaskInbox::activate() +{ + std::lock_guard lock(mutex_); + active_ = true; +} + +bool TaskInbox::post(Task task) +{ + if (!task) + return false; + std::lock_guard lock(mutex_); + if (!active_) + return false; + pending_.push_back(std::move(task)); + return true; +} + +std::vector TaskInbox::take_pending() +{ + std::lock_guard lock(mutex_); + std::vector tasks; + if (active_) + tasks.swap(pending_); + return tasks; +} + +void TaskInbox::shutdown() +{ + std::lock_guard lock(mutex_); + active_ = false; + pending_.clear(); +} + +bool TaskInbox::active() const +{ + std::lock_guard lock(mutex_); + return active_; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_task_inbox.h b/projects/ZClaw/main/ui/zclaw_task_inbox.h new file mode 100644 index 00000000..db0930f5 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_task_inbox.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +namespace zclaw { + +class TaskInbox { +public: + using Task = std::function; + + void activate(); + bool post(Task task); + std::vector take_pending(); + void shutdown(); + bool active() const; + +private: + mutable std::mutex mutex_; + std::vector pending_; + bool active_ = false; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_temporary_directory.cpp b/projects/ZClaw/main/ui/zclaw_temporary_directory.cpp new file mode 100644 index 00000000..37ac329b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_temporary_directory.cpp @@ -0,0 +1,53 @@ +#include "zclaw_temporary_directory.h" + +#include +#include +#include +#include +#include + +#include + +namespace zclaw { + +TemporaryDirectory::TemporaryDirectory(std::string path) + : path_(std::move(path)) +{ +} + +TemporaryDirectory::~TemporaryDirectory() +{ + std::error_code ignored; + std::filesystem::remove_all(path_, ignored); +} + +std::unique_ptr TemporaryDirectory::create( + const std::string &parent, const std::string &prefix, std::string *error) +{ + if (error) + error->clear(); + if (prefix.empty() || prefix == "." || prefix == ".." || + prefix.find('/') != std::string::npos) { + if (error) + *error = "Cannot create temporary directory: Invalid prefix."; + return nullptr; + } + std::string path_template = parent + "/" + prefix + ".XXXXXX"; + std::vector writable_path(path_template.begin(), path_template.end()); + writable_path.push_back('\0'); + char *created = ::mkdtemp(writable_path.data()); + if (!created) { + if (error) + *error = "Cannot create temporary directory: " + + std::string(std::strerror(errno)); + return nullptr; + } + return std::unique_ptr(new TemporaryDirectory(created)); +} + +const std::string &TemporaryDirectory::path() const +{ + return path_; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_temporary_directory.h b/projects/ZClaw/main/ui/zclaw_temporary_directory.h new file mode 100644 index 00000000..d49f14d0 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_temporary_directory.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace zclaw { + +class TemporaryDirectory { +public: + ~TemporaryDirectory(); + + TemporaryDirectory(const TemporaryDirectory &) = delete; + TemporaryDirectory &operator=(const TemporaryDirectory &) = delete; + + static std::unique_ptr create( + const std::string &parent, const std::string &prefix, + std::string *error = nullptr); + + const std::string &path() const; + +private: + explicit TemporaryDirectory(std::string path); + + std::string path_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_text.cpp b/projects/ZClaw/main/ui/zclaw_text.cpp new file mode 100644 index 00000000..8956d9d2 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_text.cpp @@ -0,0 +1,14 @@ +#include "zclaw_text.h" + +namespace zclaw { + +std::string trim_ascii_whitespace(const std::string &value) +{ + const std::size_t start = value.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) + return ""; + const std::size_t end = value.find_last_not_of(" \t\r\n"); + return value.substr(start, end - start + 1); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_text.h b/projects/ZClaw/main/ui/zclaw_text.h new file mode 100644 index 00000000..56d37945 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_text.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace zclaw { + +std::string trim_ascii_whitespace(const std::string &value); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_text_file.cpp b/projects/ZClaw/main/ui/zclaw_text_file.cpp new file mode 100644 index 00000000..d781d712 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_text_file.cpp @@ -0,0 +1,57 @@ +#include "zclaw_text_file.h" + +#include +#include +#include +#include +#include + +namespace zclaw { + +TextFileReadResult read_text_file(const std::string &path) +{ + TextFileReadResult result; + std::error_code status_error; + const std::filesystem::file_status status = + std::filesystem::status(path, status_error); + if (status_error) { + result.status = status_error == std::errc::no_such_file_or_directory + ? TextFileReadStatus::NotFound + : TextFileReadStatus::Error; + if (result.status == TextFileReadStatus::Error) + result.error = "Could not inspect file: " + status_error.message(); + return result; + } + if (!std::filesystem::is_regular_file(status)) { + result.error = "Could not open file: Not a regular file"; + return result; + } + + errno = 0; + std::ifstream file(path, std::ios::binary); + if (!file) { + const int open_error = errno; + result.status = open_error == ENOENT ? TextFileReadStatus::NotFound + : TextFileReadStatus::Error; + if (result.status == TextFileReadStatus::Error) { + result.error = "Could not open file: " + + std::string(std::strerror(open_error ? open_error : EIO)); + } + return result; + } + + std::ostringstream contents; + contents << file.rdbuf(); + if (file.bad()) { + const int read_error = errno; + result.error = "Could not read file: " + + std::string(std::strerror(read_error ? read_error : EIO)); + return result; + } + + result.status = TextFileReadStatus::Loaded; + result.contents = contents.str(); + return result; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_text_file.h b/projects/ZClaw/main/ui/zclaw_text_file.h new file mode 100644 index 00000000..b6f400d8 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_text_file.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace zclaw { + +enum class TextFileReadStatus { + Loaded, + NotFound, + Error, +}; + +struct TextFileReadResult { + TextFileReadStatus status = TextFileReadStatus::Error; + std::string contents; + std::string error; +}; + +TextFileReadResult read_text_file(const std::string &path); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_theme.h b/projects/ZClaw/main/ui/zclaw_theme.h new file mode 100644 index 00000000..8c6b9aeb --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_theme.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace zclaw::theme { + +inline constexpr std::uint32_t kBackground = 0x0F0F1A; +inline constexpr std::uint32_t kBar = 0x16162A; +inline constexpr std::uint32_t kPanel = 0x1E1E35; +inline constexpr std::uint32_t kPanelLine = 0x2A2A45; +inline constexpr std::uint32_t kMuted = 0xA1A1AA; +inline constexpr std::uint32_t kDim = 0x52525B; +inline constexpr std::uint32_t kText = 0xF4F4F5; +inline constexpr std::uint32_t kWhite = 0xFFFFFF; +inline constexpr std::uint32_t kOnline = 0x10B981; +inline constexpr std::uint32_t kPurple = 0x8B5CF6; +inline constexpr std::uint32_t kIndigo = 0x6366F1; + +} // namespace zclaw::theme diff --git a/projects/ZClaw/main/ui/zclaw_types.h b/projects/ZClaw/main/ui/zclaw_types.h new file mode 100644 index 00000000..67a3f847 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_types.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +namespace zclaw { + +struct ProviderConfig { + std::string alias; + std::string family; + std::string model; + std::string uri; + std::string api_key; +}; + +struct UiConfig { + std::string webhook_url = "http://127.0.0.1:42617/webhook"; + std::string agent_alias = "zclaw"; + std::string webhook_secret; + std::string bearer_token; + bool setup_complete = false; +}; + +struct ApprovalRequest { + std::string request_id; + std::string tool; + std::string summary; + int timeout_secs = 120; +}; + +struct OperationResult { + std::string text; + bool ok = false; + UiConfig config; +}; + +struct SetupProgress { + SetupProgress() = default; + SetupProgress(std::string status_value, int percent_value, + std::size_t downloaded_value = 0, + std::size_t total_value = 0, double speed_value = 0.0, + std::string source_value = {}, + std::string destination_value = {}) + : status(std::move(status_value)), percent(percent_value), + downloaded_bytes(downloaded_value), total_bytes(total_value), + bytes_per_second(speed_value), source_url(std::move(source_value)), + destination_path(std::move(destination_value)) + { + } + + std::string status; + int percent = 0; + std::size_t downloaded_bytes = 0; + std::size_t total_bytes = 0; + double bytes_per_second = 0.0; + std::string source_url; + std::string destination_path; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_action_dispatcher.cpp b/projects/ZClaw/main/ui/zclaw_ui_action_dispatcher.cpp new file mode 100644 index 00000000..9b9b21b6 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_action_dispatcher.cpp @@ -0,0 +1,141 @@ +#include "zclaw_ui_action_dispatcher.h" + +#include "zclaw_approval_coordinator.h" +#include "zclaw_chat_view.h" +#include "zclaw_fonts.hpp" +#include "zclaw_input_dialog.h" +#include "zclaw_input_workflow.h" +#include "zclaw_runtime_state.h" +#include "zclaw_settings_coordinator.h" +#include "zclaw_settings_workflow.h" +#include "zclaw_shell_view.h" +#include "zclaw_ui_config_manager.h" + +#include + +namespace zclaw { + +UiActionDispatcher::UiActionDispatcher( + UiConfigManager &config, ShellView &shell, FontManager &fonts, + InputDialog &input, InputWorkflow &input_workflow, + ApprovalCoordinator &approvals, SettingsCoordinator &settings, + SettingsWorkflow &settings_workflow, ChatView &chat, + RequestQuit request_quit) + : config_(config), shell_(shell), fonts_(fonts), input_(input), + input_workflow_(input_workflow), approvals_(approvals), settings_(settings), + settings_workflow_(settings_workflow), chat_(chat), + request_quit_(std::move(request_quit)) +{ +} + +void UiActionDispatcher::execute(const KeyAction &action) +{ + switch (action.type) { + case KeyActionType::None: + break; + case KeyActionType::Quit: + request_quit(); + break; + case KeyActionType::InputInsertText: + input_.append(action.text.c_str()); + break; + case KeyActionType::InputInsertNewline: + input_.insert_newline(); + break; + case KeyActionType::InputEraseBefore: + input_.erase_before_cursor(); + break; + case KeyActionType::InputEraseAfter: + input_.erase_after_cursor(); + break; + case KeyActionType::InputMoveLeft: + input_.move_left(); + break; + case KeyActionType::InputMoveRight: + input_.move_right(); + break; + case KeyActionType::InputMoveUp: + input_.move_up(); + break; + case KeyActionType::InputMoveDown: + input_.move_down(); + break; + case KeyActionType::InputClose: + input_.close(); + break; + case KeyActionType::InputSubmit: + input_workflow_.submit_current(); + break; + case KeyActionType::ApprovalMoveLeft: + approvals_.move_selection(-1); + break; + case KeyActionType::ApprovalMoveRight: + approvals_.move_selection(1); + break; + case KeyActionType::ApprovalSubmitSelected: + approvals_.answer_selected(); + break; + case KeyActionType::ApprovalApprove: + approvals_.answer("approve"); + break; + case KeyActionType::ApprovalAlways: + approvals_.answer("always"); + break; + case KeyActionType::ApprovalDeny: + approvals_.answer("deny"); + break; + case KeyActionType::SetupRetryMoveLeft: + settings_workflow_.move_setup_retry_selection(-1); + break; + case KeyActionType::SetupRetryMoveRight: + settings_workflow_.move_setup_retry_selection(1); + break; + case KeyActionType::SetupRetryActivate: + if (settings_workflow_.activate_setup_retry_selection()) + request_quit(); + break; + case KeyActionType::SetupRetryDismiss: + settings_workflow_.dismiss_setup_retry(); + break; + case KeyActionType::ToggleSettings: + if (settings_.is_open()) + settings_.close(); + else + settings_.open(shell_.content()); + break; + case KeyActionType::SettingsBack: + if (settings_workflow_.navigate_back( + RuntimeState::first_run_needed(config_.config()))) + request_quit(); + break; + case KeyActionType::SettingsActivate: + settings_workflow_.activate_selection(); + break; + case KeyActionType::SettingsDeleteProvider: + settings_workflow_.delete_current_provider(); + break; + case KeyActionType::SettingsMoveUp: + settings_.move_selection(-1); + break; + case KeyActionType::SettingsMoveDown: + settings_.move_selection(1); + break; + case KeyActionType::ChatScrollUp: + chat_.scroll(24); + break; + case KeyActionType::ChatScrollDown: + chat_.scroll(-24); + break; + case KeyActionType::ChatOpenInput: + input_.open_chat(&fonts_); + break; + } +} + +void UiActionDispatcher::request_quit() +{ + if (request_quit_) + request_quit_(); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_action_dispatcher.h b/projects/ZClaw/main/ui/zclaw_ui_action_dispatcher.h new file mode 100644 index 00000000..a1d69606 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_action_dispatcher.h @@ -0,0 +1,48 @@ +#pragma once + +#include "zclaw_key_router.h" + +#include + +namespace zclaw { + +class ApprovalCoordinator; +class ChatView; +class FontManager; +class InputDialog; +class InputWorkflow; +class SettingsCoordinator; +class SettingsWorkflow; +class ShellView; +class UiConfigManager; + +class UiActionDispatcher { +public: + using RequestQuit = std::function; + + UiActionDispatcher(UiConfigManager &config, ShellView &shell, + FontManager &fonts, InputDialog &input, + InputWorkflow &input_workflow, + ApprovalCoordinator &approvals, + SettingsCoordinator &settings, + SettingsWorkflow &settings_workflow, ChatView &chat, + RequestQuit request_quit); + + void execute(const KeyAction &action); + +private: + void request_quit(); + + UiConfigManager &config_; + ShellView &shell_; + FontManager &fonts_; + InputDialog &input_; + InputWorkflow &input_workflow_; + ApprovalCoordinator &approvals_; + SettingsCoordinator &settings_; + SettingsWorkflow &settings_workflow_; + ChatView &chat_; + RequestQuit request_quit_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_config_manager.cpp b/projects/ZClaw/main/ui/zclaw_ui_config_manager.cpp new file mode 100644 index 00000000..2fe2af67 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_config_manager.cpp @@ -0,0 +1,46 @@ +#include "zclaw_ui_config_manager.h" + +#include "zclaw_storage.h" +#include "zclaw_ui_config_store.h" + +#include + +namespace zclaw { + +UiConfigManager::UiConfigManager(std::string storage_path) + : storage_path_(std::move(storage_path)) +{ +} + +ConfigStoreLoadStatus UiConfigManager::load(std::string *error) +{ + UiConfig loaded; + const ConfigStoreLoadStatus status = + load_ui_config_with_status(storage_path_, &loaded, error); + if (status == ConfigStoreLoadStatus::Loaded) + config_ = std::move(loaded); + return status; +} + +const UiConfig &UiConfigManager::config() const +{ + return config_; +} + +bool UiConfigManager::update(const UiConfig &config, std::string *error) +{ + if (!ensure_private_parent_directory(storage_path_, error) || + !save_ui_config(storage_path_, config, error)) + return false; + config_ = config; + return true; +} + +bool UiConfigManager::clear_token(std::string *error) +{ + UiConfig candidate = config_; + candidate.bearer_token.clear(); + return update(candidate, error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_config_manager.h b/projects/ZClaw/main/ui/zclaw_ui_config_manager.h new file mode 100644 index 00000000..eaa7bac4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_config_manager.h @@ -0,0 +1,24 @@ +#pragma once + +#include "zclaw_config_store_status.h" +#include "zclaw_types.h" + +#include + +namespace zclaw { + +class UiConfigManager { +public: + explicit UiConfigManager(std::string storage_path); + + ConfigStoreLoadStatus load(std::string *error = nullptr); + const UiConfig &config() const; + bool update(const UiConfig &config, std::string *error = nullptr); + bool clear_token(std::string *error = nullptr); + +private: + std::string storage_path_; + UiConfig config_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_config_store.cpp b/projects/ZClaw/main/ui/zclaw_ui_config_store.cpp new file mode 100644 index 00000000..8b2b246d --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_config_store.cpp @@ -0,0 +1,49 @@ +#include "zclaw_ui_config_store.h" + +#include "zclaw_atomic_file.h" +#include "zclaw_config_document_model.h" +#include "zclaw_text_file.h" + +namespace zclaw { + +bool load_ui_config(const std::string &path, UiConfig *config) +{ + return load_ui_config_with_status(path, config) == + ConfigStoreLoadStatus::Loaded; +} + +ConfigStoreLoadStatus load_ui_config_with_status( + const std::string &path, UiConfig *config, std::string *error) +{ + if (error) + error->clear(); + if (!config) { + if (error) + *error = "Missing UI settings output."; + return ConfigStoreLoadStatus::Error; + } + + const TextFileReadResult document = read_text_file(path); + if (document.status == TextFileReadStatus::NotFound) + return ConfigStoreLoadStatus::NotFound; + if (document.status == TextFileReadStatus::Error) { + if (error) + *error = document.error; + return ConfigStoreLoadStatus::Error; + } + UiConfig parsed; + if (!parse_ui_config_document_checked(document.contents, &parsed)) { + if (error) + *error = "UI settings file is invalid."; + return ConfigStoreLoadStatus::Invalid; + } + *config = std::move(parsed); + return ConfigStoreLoadStatus::Loaded; +} + +bool save_ui_config(const std::string &path, const UiConfig &config, std::string *error) +{ + return atomic_write_file(path, make_ui_config_document(config), {}, error); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_config_store.h b/projects/ZClaw/main/ui/zclaw_ui_config_store.h new file mode 100644 index 00000000..4de4ed79 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_config_store.h @@ -0,0 +1,16 @@ +#pragma once + +#include "zclaw_config_store_status.h" +#include "zclaw_types.h" + +#include + +namespace zclaw { + +bool load_ui_config(const std::string &path, UiConfig *config); +ConfigStoreLoadStatus load_ui_config_with_status( + const std::string &path, UiConfig *config, std::string *error = nullptr); +bool save_ui_config(const std::string &path, const UiConfig &config, + std::string *error = nullptr); + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_task_queue.cpp b/projects/ZClaw/main/ui/zclaw_ui_task_queue.cpp new file mode 100644 index 00000000..450d4f6b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_task_queue.cpp @@ -0,0 +1,57 @@ +#include "zclaw_ui_task_queue.h" + +#include "zclaw_task_batch.h" + +#include + +namespace zclaw { + +UiTaskQueue::~UiTaskQueue() +{ + shutdown(); +} + +bool UiTaskQueue::start(uint32_t poll_period_ms) +{ + if (inbox_.active()) + return true; + timer_ = lv_timer_create(timer_callback, poll_period_ms == 0 ? 1 : poll_period_ms, + this); + if (!timer_) + return false; + inbox_.activate(); + return true; +} + +bool UiTaskQueue::post(Task task) +{ + return inbox_.post(std::move(task)); +} + +void UiTaskQueue::shutdown() +{ + inbox_.shutdown(); + lv_timer_t *timer = timer_; + timer_ = nullptr; + if (timer) + lv_timer_delete(timer); +} + +bool UiTaskQueue::active() const +{ + return inbox_.active(); +} + +void UiTaskQueue::timer_callback(lv_timer_t *timer) +{ + UiTaskQueue *queue = static_cast(lv_timer_get_user_data(timer)); + if (queue) + queue->drain(); +} + +void UiTaskQueue::drain() +{ + execute_task_batch(inbox_.take_pending()); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_task_queue.h b/projects/ZClaw/main/ui/zclaw_ui_task_queue.h new file mode 100644 index 00000000..4675bb2e --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_task_queue.h @@ -0,0 +1,32 @@ +#pragma once + +#include "lvgl/lvgl.h" +#include "zclaw_task_inbox.h" +#include "zclaw_ui_task_sink.h" + +#include + +namespace zclaw { + +class UiTaskQueue : public UiTaskSink { +public: + UiTaskQueue() = default; + ~UiTaskQueue(); + + UiTaskQueue(const UiTaskQueue &) = delete; + UiTaskQueue &operator=(const UiTaskQueue &) = delete; + + bool start(uint32_t poll_period_ms = 10); + bool post(Task task) override; + void shutdown(); + bool active() const; + +private: + static void timer_callback(lv_timer_t *timer); + void drain(); + + TaskInbox inbox_; + lv_timer_t *timer_ = nullptr; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_ui_task_sink.h b/projects/ZClaw/main/ui/zclaw_ui_task_sink.h new file mode 100644 index 00000000..f382fac6 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_ui_task_sink.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace zclaw { + +class UiTaskSink { +public: + using Task = std::function; + + virtual ~UiTaskSink() = default; + virtual bool post(Task task) = 0; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_webhook_transport.cpp b/projects/ZClaw/main/ui/zclaw_webhook_transport.cpp new file mode 100644 index 00000000..277a6191 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_webhook_transport.cpp @@ -0,0 +1,69 @@ +#include "zclaw_webhook_transport.h" + +#include "zclaw_gateway_response_model.h" +#include "zclaw_http_client_policy.h" +#include "zclaw_lhv_http_client.h" +#include "zclaw_protocol.h" +#include "zclaw_request_id.h" + +#include + +namespace zclaw { +namespace { + +RequestIdGenerator &request_ids() +{ + static RequestIdGenerator generator; + return generator; +} + +} // namespace + +WebhookTransport::WebhookTransport( + std::shared_ptr client) + : client_(std::move(client)) +{ +} + +bool WebhookTransport::send(const UiConfig &config, const std::string &message, + Completion completion) const +{ + OperationResult result; + result.config = config; + const std::string idempotency = request_ids().next(); + const std::string payload = protocol::make_webhook_message(message); + protocol::HttpUrl url; + if (!protocol::split_http_url(protocol::webhook_endpoint(config), &url)) { + result.text = "Webhook request failed.\nInvalid URL."; + completion(std::move(result)); + return true; + } + + auto request = std::make_shared(); + request->method = HTTP_POST; + request->url = url.base + url.path; + request->body = payload; + request->headers["Content-Type"] = "application/json"; + request->headers["X-Session-Id"] = "zclaw-ui"; + request->headers["X-Idempotency-Key"] = idempotency; + if (!config.webhook_secret.empty()) + request->headers["X-Webhook-Secret"] = config.webhook_secret; + configure_http_request(*request, HttpClientProfile::Webhook); + if (!client_ || !completion) + return false; + return client_->send( + std::move(request), + [config, result = std::move(result), + completion = std::move(completion)]( + const HttpResponsePtr &response) mutable { + if (!response) { + result.text = "Webhook request failed.\nRequest failed."; + completion(std::move(result)); + return; + } + completion(interpret_webhook_response( + config, response->status_code, response->body)); + }); +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_webhook_transport.h b/projects/ZClaw/main/ui/zclaw_webhook_transport.h new file mode 100644 index 00000000..97b8aa70 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_webhook_transport.h @@ -0,0 +1,26 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include +#include + +namespace zclaw { + +class HttpCancellationRegistry; +class LhvHttpClient; + +class WebhookTransport { +public: + explicit WebhookTransport(std::shared_ptr client); + + using Completion = std::function; + bool send(const UiConfig &config, const std::string &message, + Completion completion) const; + +private: + std::shared_ptr client_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_websocket_transport.cpp b/projects/ZClaw/main/ui/zclaw_websocket_transport.cpp new file mode 100644 index 00000000..3c1e7f7b --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_websocket_transport.cpp @@ -0,0 +1,214 @@ +#include "zclaw_websocket_transport.h" + +#include "zclaw_chat_event.h" +#include "zclaw_chat_stream_model.h" +#include "zclaw_http_cancellation.h" +#include "zclaw_protocol.h" + +#include +#include + +#include +#include +#include + +namespace zclaw { +namespace { + +class WebSocketOperation : public std::enable_shared_from_this { +public: + WebSocketOperation(UiConfig config, + WebSocketTransport::ApprovalHandler approval, + WebSocketTransport::Completion completion) + : config_(std::move(config)), approval_(std::move(approval)), + completion_(std::move(completion)), + socket_(std::make_shared()) + { + } + + std::shared_ptr socket() const + { + return socket_; + } + + void opened(const std::string &message) + { + const auto socket = active_socket(); + if (socket && + socket->send(protocol::make_ws_chat_message(message)) < 0) + finish("WS chat failed.\nCould not send message."); + } + + void message(const std::string &event_json) + { + const auto socket = active_socket(); + if (!socket || socket->opcode() != WS_OPCODE_TEXT || event_json.empty()) + return; + const ChatStreamUpdate update = stream_.accept(parse_chat_event(event_json)); + if (update.approval_requested) { + const std::weak_ptr weak = shared_from_this(); + if (approval_) { + approval_(update.approval, [weak, request_id = update.approval.request_id]( + std::string decision) { + if (const auto operation = weak.lock()) + operation->send_approval(request_id, std::move(decision)); + }); + } else { + send_approval(update.approval.request_id, "deny"); + } + } + if (update.finished) + finish_stream(); + } + + void closed() + { + if (stream_.finished()) + finish_stream(); + else if (stream_.partial_response().empty()) + finish("WS chat failed.\nConnection closed before completion."); + else + finish("WS chat interrupted.\n" + stream_.partial_response()); + } + + void cancel() + { + finish("WS chat failed.\nRequest cancelled."); + } + + void timeout() + { + finish("WS chat failed.\nRequest timed out."); + } + + HttpCancellationRegistration registration; + +private: + std::shared_ptr active_socket() + { + std::lock_guard lock(mutex_); + return finished_ ? nullptr : socket_; + } + + void send_approval(const std::string &request_id, std::string decision) + { + bool failed = false; + { + std::lock_guard lock(mutex_); + if (finished_) + return; + failed = socket_->send(protocol::make_ws_approval_response( + request_id, decision.empty() ? "deny" : decision)) < 0; + } + if (failed) + finish("WS chat failed.\nCould not send approval."); + } + + void finish_stream() + { + OperationResult result; + result.config = config_; + result.text = stream_.response().empty() + ? "ZeroClaw returned an empty WS response." + : stream_.response(); + result.ok = stream_.succeeded(); + finish(std::move(result)); + } + + void finish(std::string text) + { + finish({std::move(text), false, config_}); + } + + void finish(OperationResult result) + { + std::shared_ptr socket; + WebSocketTransport::Completion completion; + { + std::lock_guard lock(mutex_); + if (finished_) + return; + finished_ = true; + socket = std::move(socket_); + completion = std::move(completion_); + } + registration.reset(); + socket->onopen = nullptr; + socket->onmessage = nullptr; + socket->onclose = nullptr; + socket->close(); + (void)hv::async([socket = std::move(socket), + completion = std::move(completion), + result = std::move(result)]() mutable { + socket->stop(); + completion(std::move(result)); + }); + } + + UiConfig config_; + WebSocketTransport::ApprovalHandler approval_; + WebSocketTransport::Completion completion_; + std::shared_ptr socket_; + ChatStreamState stream_; + std::mutex mutex_; + bool finished_ = false; +}; + +} // namespace + +WebSocketTransport::WebSocketTransport( + std::shared_ptr cancellation) + : cancellation_(std::move(cancellation)) +{ +} + +bool WebSocketTransport::send(const UiConfig &config, + const std::string &message, + ApprovalHandler approval_handler, + Completion completion) const +{ + if (!completion) + return false; + const std::string websocket_url = protocol::gateway_ws_url(config); + if (websocket_url.rfind("ws://", 0) != 0 && + websocket_url.rfind("wss://", 0) != 0) { + completion({"WS chat failed.\nInvalid WebSocket URL.", false, config}); + return true; + } + + auto operation = std::make_shared( + config, std::move(approval_handler), std::move(completion)); + if (cancellation_) { + const std::weak_ptr weak = operation; + operation->registration = cancellation_->register_request([weak] { + if (const auto operation = weak.lock()) + operation->cancel(); + }); + if (!operation->registration.active()) { + operation->cancel(); + return true; + } + } + + const auto socket = operation->socket(); + socket->setConnectTimeout(15000); + socket->setPingInterval(30000); + socket->onopen = [operation, message] { operation->opened(message); }; + socket->onmessage = [operation](const std::string &event_json) { + operation->message(event_json); + }; + socket->onclose = [operation] { operation->closed(); }; + http_headers headers = {{"Sec-WebSocket-Protocol", "zeroclaw.v1"}}; + if (socket->open(websocket_url.c_str(), headers) != 0) { + operation->closed(); + return true; + } + const std::weak_ptr weak = operation; + socket->loop()->setTimeout(900 * 1000, [weak](auto) { + if (const auto operation = weak.lock()) + operation->timeout(); + }); + return true; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_websocket_transport.h b/projects/ZClaw/main/ui/zclaw_websocket_transport.h new file mode 100644 index 00000000..79ec3ae7 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_websocket_transport.h @@ -0,0 +1,32 @@ +#pragma once + +#include "zclaw_types.h" + +#include +#include +#include + +namespace zclaw { + +class HttpCancellationRegistry; + +class WebSocketTransport { +public: + using ApprovalDecision = std::function; + using ApprovalHandler = std::function; + + WebSocketTransport() = default; + explicit WebSocketTransport( + std::shared_ptr cancellation); + + using Completion = std::function; + bool send(const UiConfig &config, const std::string &message, + ApprovalHandler approval_handler, + Completion completion) const; + +private: + std::shared_ptr cancellation_; +}; + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_widgets.cpp b/projects/ZClaw/main/ui/zclaw_widgets.cpp new file mode 100644 index 00000000..526c5e75 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_widgets.cpp @@ -0,0 +1,68 @@ +#include "zclaw_widgets.h" + +namespace zclaw::widgets { + +lv_obj_t *box(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, + lv_coord_t width, lv_coord_t height, uint32_t color, + lv_coord_t radius) +{ + lv_obj_t *object = lv_obj_create(parent); + lv_obj_set_pos(object, x, y); + lv_obj_set_size(object, width, height); + lv_obj_set_style_radius(object, radius, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(object, lv_color_hex(color), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(object, LV_OPA_COVER, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(object, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(object, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(object, static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + return object; +} + +lv_obj_t *label(lv_obj_t *parent, const char *text, lv_coord_t x, + lv_coord_t y, lv_coord_t width, lv_coord_t height, + const lv_font_t *font, uint32_t color, + lv_text_align_t alignment) +{ + lv_obj_t *object = lv_label_create(parent); + lv_label_set_text(object, text ? text : ""); + lv_obj_set_pos(object, x, y); + lv_obj_set_size(object, width, height); + lv_label_set_long_mode(object, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(object, font, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(object, lv_color_hex(color), + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_opa(object, LV_OPA_COVER, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(object, alignment, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(object, LV_OPA_TRANSP, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(object, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(object, LV_OBJ_FLAG_SCROLLABLE); + return object; +} + +lv_obj_t *label(lv_obj_t *parent, const std::string &text, lv_coord_t x, + lv_coord_t y, lv_coord_t width, lv_coord_t height, + const lv_font_t *font, uint32_t color, + lv_text_align_t alignment) +{ + return label(parent, text.c_str(), x, y, width, height, font, color, + alignment); +} + +lv_obj_t *image(lv_obj_t *parent, const std::string &path, + lv_coord_t x, lv_coord_t y) +{ + lv_obj_t *object = lv_img_create(parent); + lv_img_set_src(object, path.c_str()); + lv_obj_set_pos(object, x, y); + lv_obj_clear_flag(object, static_cast(LV_OBJ_FLAG_CLICKABLE | + LV_OBJ_FLAG_SCROLLABLE)); + return object; +} + +} // namespace zclaw::widgets diff --git a/projects/ZClaw/main/ui/zclaw_widgets.h b/projects/ZClaw/main/ui/zclaw_widgets.h new file mode 100644 index 00000000..7e2a9308 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_widgets.h @@ -0,0 +1,23 @@ +#pragma once + +#include "lvgl/lvgl.h" + +#include + +namespace zclaw::widgets { + +lv_obj_t *box(lv_obj_t *parent, lv_coord_t x, lv_coord_t y, + lv_coord_t width, lv_coord_t height, uint32_t color, + lv_coord_t radius = 0); +lv_obj_t *label(lv_obj_t *parent, const char *text, lv_coord_t x, + lv_coord_t y, lv_coord_t width, lv_coord_t height, + const lv_font_t *font, uint32_t color, + lv_text_align_t alignment = LV_TEXT_ALIGN_LEFT); +lv_obj_t *label(lv_obj_t *parent, const std::string &text, lv_coord_t x, + lv_coord_t y, lv_coord_t width, lv_coord_t height, + const lv_font_t *font, uint32_t color, + lv_text_align_t alignment = LV_TEXT_ALIGN_LEFT); +lv_obj_t *image(lv_obj_t *parent, const std::string &path, + lv_coord_t x, lv_coord_t y); + +} // namespace zclaw::widgets diff --git a/projects/ZClaw/tests/run_tests.sh b/projects/ZClaw/tests/run_tests.sh new file mode 100755 index 00000000..fafa255e --- /dev/null +++ b/projects/ZClaw/tests/run_tests.sh @@ -0,0 +1,281 @@ +#!/bin/sh +set -eu +set -f + +test_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ui_dir="$test_dir/../main/ui" +build_dir="${TMPDIR:-/tmp}/zclaw-tests" +cxx="${CXX:-g++}" +test_cxxflags="${ZCLAW_TEST_CXXFLAGS:-}" +test_ldflags="${ZCLAW_TEST_LDFLAGS:-}" +test_filter="${ZCLAW_TEST_FILTER:-}" +tests_run=0 +mkdir -p "$build_dir" + +build_and_run() +{ + name=$1 + shift + if [ -n "$test_filter" ]; then + case " $test_filter " in + *" $name "*) ;; + *) return ;; + esac + fi + tests_run=$((tests_run + 1)) + # Intentional field splitting: flags are whitespace-delimited, while glob + # expansion is disabled above so flag contents cannot expand to filenames. + "$cxx" -std=c++17 -Wall -Wextra -Werror -pthread \ + $test_cxxflags \ + -I"$ui_dir" \ + -I"$test_dir/../../../SDK/components/utilities/include" \ + -isystem "$test_dir/../../../SDK/github_source/libhv/include" \ + -I"$test_dir/../../../ext_components/cp0_lvgl/include" \ + "$@" "$test_dir/../build/hv/libhv.a" -lssl -lcrypto \ + $test_ldflags -o "$build_dir/$name" + "$build_dir/$name" +} + +build_and_run zclaw_core_test \ + "$test_dir/zclaw_core_test.cpp" \ + "$ui_dir/zclaw_approval_controller.cpp" \ + "$ui_dir/zclaw_chat_event.cpp" \ + "$ui_dir/zclaw_chat_stream_model.cpp" \ + "$ui_dir/zclaw_chat_transport.cpp" \ + "$ui_dir/zclaw_webhook_transport.cpp" \ + "$ui_dir/zclaw_lhv_http_client.cpp" \ + "$ui_dir/zclaw_websocket_transport.cpp" \ + "$ui_dir/zclaw_request_id.cpp" \ + "$ui_dir/zclaw_cli_config_model.cpp" \ + "$ui_dir/zclaw_cli_output_model.cpp" \ + "$ui_dir/zclaw_cli_service.cpp" \ + "$ui_dir/zclaw_atomic_file.cpp" \ + "$ui_dir/zclaw_directory_sync.cpp" \ + "$ui_dir/zclaw_config_codec.cpp" \ + "$ui_dir/zclaw_config_document_model.cpp" \ + "$ui_dir/zclaw_gateway_response_model.cpp" \ + "$ui_dir/zclaw_http_cancellation.cpp" \ + "$ui_dir/zclaw_http_client_policy.cpp" \ + "$ui_dir/zclaw_provider_store.cpp" \ + "$ui_dir/zclaw_provider_catalog.cpp" \ + "$ui_dir/zclaw_provider_collection_model.cpp" \ + "$ui_dir/zclaw_provider_manager.cpp" \ + "$ui_dir/zclaw_provider_form_model.cpp" \ + "$ui_dir/zclaw_protocol.cpp" \ + "$ui_dir/zclaw_path_model.cpp" \ + "$ui_dir/zclaw_paths.cpp" \ + "$ui_dir/zclaw_pairing_service.cpp" \ + "$ui_dir/zclaw_process_executor.cpp" \ + "$ui_dir/zclaw_process_runner.cpp" \ + "$ui_dir/zclaw_quickstart_model.cpp" \ + "$ui_dir/zclaw_runtime_model.cpp" \ + "$ui_dir/zclaw_runtime_state.cpp" \ + "$ui_dir/zclaw_risk_profile_model.cpp" \ + "$ui_dir/zclaw_risk_profile_store.cpp" \ + "$ui_dir/zclaw_selection_model.cpp" \ + "$ui_dir/zclaw_settings_controller.cpp" \ + "$ui_dir/zclaw_settings_navigation_model.cpp" \ + "$ui_dir/zclaw_settings_presentation.cpp" \ + "$ui_dir/zclaw_storage.cpp" \ + "$ui_dir/zclaw_task_inbox.cpp" \ + "$ui_dir/zclaw_text.cpp" \ + "$ui_dir/zclaw_text_file.cpp" \ + "$ui_dir/zclaw_ui_config_manager.cpp" \ + "$ui_dir/zclaw_ui_config_store.cpp" + +build_and_run zclaw_protocol_model_test \ + "$test_dir/zclaw_protocol_model_test.cpp" \ + "$ui_dir/zclaw_chat_event.cpp" \ + "$ui_dir/zclaw_chat_layout.cpp" \ + "$ui_dir/zclaw_chat_stream_model.cpp" \ + "$ui_dir/zclaw_file_downloader.cpp" \ + "$ui_dir/zclaw_lhv_http_client.cpp" \ + "$ui_dir/zclaw_gateway_response_model.cpp" \ + "$ui_dir/zclaw_http_cancellation.cpp" \ + "$ui_dir/zclaw_http_client_policy.cpp" \ + "$ui_dir/zclaw_protocol.cpp" \ + "$ui_dir/zclaw_quickstart_model.cpp" \ + "$ui_dir/zclaw_setup_progress_model.cpp" + +build_and_run zclaw_file_downloader_resume_test \ + "$test_dir/zclaw_file_downloader_resume_test.cpp" \ + "$ui_dir/zclaw_file_downloader.cpp" \ + "$ui_dir/zclaw_lhv_http_client.cpp" \ + "$ui_dir/zclaw_http_cancellation.cpp" \ + "$ui_dir/zclaw_http_client_policy.cpp" \ + "$ui_dir/zclaw_protocol.cpp" + +build_and_run zclaw_selection_model_test \ + "$test_dir/zclaw_selection_model_test.cpp" \ + "$ui_dir/zclaw_selection_model.cpp" + +build_and_run zclaw_settings_rules_test \ + "$test_dir/zclaw_settings_rules_test.cpp" \ + "$ui_dir/zclaw_provider_form_model.cpp" \ + "$ui_dir/zclaw_settings_navigation_model.cpp" + +build_and_run zclaw_filesystem_test \ + "$test_dir/zclaw_filesystem_test.cpp" \ + "$ui_dir/zclaw_archive_installer.cpp" \ + "$ui_dir/zclaw_directory_sync.cpp" \ + "$ui_dir/zclaw_process_executor.cpp" \ + "$ui_dir/zclaw_process_runner.cpp" \ + "$ui_dir/zclaw_temporary_directory.cpp" \ + "$ui_dir/zclaw_text.cpp" + +build_and_run zclaw_cli_config_model_test \ + "$test_dir/zclaw_cli_config_model_test.cpp" \ + "$ui_dir/zclaw_cli_config_model.cpp" + +build_and_run zclaw_cli_output_model_test \ + "$test_dir/zclaw_cli_output_model_test.cpp" \ + "$ui_dir/zclaw_cli_output_model.cpp" \ + "$ui_dir/zclaw_text.cpp" + +build_and_run zclaw_cli_service_test \ + "$test_dir/zclaw_cli_service_test.cpp" \ + "$ui_dir/zclaw_atomic_file.cpp" \ + "$ui_dir/zclaw_directory_sync.cpp" \ + "$ui_dir/zclaw_cli_config_model.cpp" \ + "$ui_dir/zclaw_cli_output_model.cpp" \ + "$ui_dir/zclaw_cli_service.cpp" \ + "$ui_dir/zclaw_path_model.cpp" \ + "$ui_dir/zclaw_paths.cpp" \ + "$ui_dir/zclaw_process_executor.cpp" \ + "$ui_dir/zclaw_process_runner.cpp" \ + "$ui_dir/zclaw_quickstart_model.cpp" \ + "$ui_dir/zclaw_risk_profile_model.cpp" \ + "$ui_dir/zclaw_risk_profile_store.cpp" \ + "$ui_dir/zclaw_text.cpp" + +build_and_run zclaw_config_codec_test \ + "$test_dir/zclaw_config_codec_test.cpp" \ + "$ui_dir/zclaw_config_codec.cpp" + +build_and_run zclaw_config_document_model_test \ + "$test_dir/zclaw_config_document_model_test.cpp" \ + "$ui_dir/zclaw_config_codec.cpp" \ + "$ui_dir/zclaw_config_document_model.cpp" + +build_and_run zclaw_config_manager_load_test \ + "$test_dir/zclaw_config_manager_load_test.cpp" \ + "$ui_dir/zclaw_atomic_file.cpp" \ + "$ui_dir/zclaw_directory_sync.cpp" \ + "$ui_dir/zclaw_config_codec.cpp" \ + "$ui_dir/zclaw_config_document_model.cpp" \ + "$ui_dir/zclaw_provider_catalog.cpp" \ + "$ui_dir/zclaw_provider_collection_model.cpp" \ + "$ui_dir/zclaw_provider_manager.cpp" \ + "$ui_dir/zclaw_provider_store.cpp" \ + "$ui_dir/zclaw_storage.cpp" \ + "$ui_dir/zclaw_text_file.cpp" \ + "$ui_dir/zclaw_ui_config_manager.cpp" \ + "$ui_dir/zclaw_ui_config_store.cpp" + +build_and_run zclaw_risk_profile_model_test \ + "$test_dir/zclaw_risk_profile_model_test.cpp" \ + "$ui_dir/zclaw_risk_profile_model.cpp" \ + "$ui_dir/zclaw_text.cpp" + +build_and_run zclaw_provider_collection_model_test \ + "$test_dir/zclaw_provider_collection_model_test.cpp" \ + "$ui_dir/zclaw_provider_collection_model.cpp" + +build_and_run zclaw_runtime_model_test \ + "$test_dir/zclaw_runtime_model_test.cpp" \ + "$ui_dir/zclaw_runtime_model.cpp" + +build_and_run zclaw_binary_installer_test \ + "$test_dir/zclaw_binary_installer_test.cpp" \ + "$ui_dir/zclaw_binary_installer.cpp" + +build_and_run zclaw_binary_archive_cache_test \ + "$test_dir/zclaw_binary_archive_cache_test.cpp" \ + "$ui_dir/zclaw_binary_archive_cache.cpp" \ + "$ui_dir/zclaw_temporary_directory.cpp" + +build_and_run zclaw_text_test \ + "$test_dir/zclaw_text_test.cpp" \ + "$ui_dir/zclaw_text.cpp" + +build_and_run zclaw_text_file_test \ + "$test_dir/zclaw_text_file_test.cpp" \ + "$ui_dir/zclaw_text_file.cpp" + +build_and_run zclaw_task_batch_test \ + "$test_dir/zclaw_task_batch_test.cpp" \ + "$ui_dir/zclaw_task_batch.cpp" + +build_and_run zclaw_atomic_file_test \ + "$test_dir/zclaw_atomic_file_test.cpp" \ + "$ui_dir/zclaw_atomic_file.cpp" \ + "$ui_dir/zclaw_directory_sync.cpp" + +build_and_run zclaw_directory_sync_test \ + "$test_dir/zclaw_directory_sync_test.cpp" \ + "$ui_dir/zclaw_directory_sync.cpp" + +build_and_run zclaw_font_path_model_test \ + "$test_dir/zclaw_font_path_model_test.cpp" \ + "$ui_dir/zclaw_font_path_model.cpp" + +build_and_run zclaw_path_model_test \ + "$test_dir/zclaw_path_model_test.cpp" \ + "$ui_dir/zclaw_path_model.cpp" + +build_and_run zclaw_authorization_session_model_test \ + "$test_dir/zclaw_authorization_session_model_test.cpp" \ + "$ui_dir/zclaw_authorization_session_model.cpp" + +build_and_run zclaw_request_id_test \ + "$test_dir/zclaw_request_id_test.cpp" \ + "$ui_dir/zclaw_request_id.cpp" + +build_and_run zclaw_storage_test \ + "$test_dir/zclaw_storage_test.cpp" \ + "$ui_dir/zclaw_storage.cpp" + +build_and_run zclaw_connectivity_cancel_test \ + "$test_dir/zclaw_connectivity_cancel_test.cpp" \ + "$ui_dir/zclaw_connectivity.cpp" \ + "$ui_dir/zclaw_lhv_http_client.cpp" \ + "$ui_dir/zclaw_http_client_policy.cpp" \ + "$ui_dir/zclaw_protocol.cpp" + +build_and_run zclaw_lhv_http_client_test \ + "$test_dir/zclaw_lhv_http_client_test.cpp" \ + "$ui_dir/zclaw_lhv_http_client.cpp" + +build_and_run zclaw_http_cancellation_test \ + "$test_dir/zclaw_http_cancellation_test.cpp" \ + "$ui_dir/zclaw_http_cancellation.cpp" + +build_and_run zclaw_process_executor_test \ + "$test_dir/zclaw_process_executor_test.cpp" \ + "$ui_dir/zclaw_process_executor.cpp" \ + "$ui_dir/zclaw_process_runner.cpp" \ + "$ui_dir/zclaw_text.cpp" + +build_and_run zclaw_setup_service_test \ + "$test_dir/zclaw_setup_service_test.cpp" \ + "$ui_dir/zclaw_quickstart_model.cpp" \ + "$ui_dir/zclaw_setup_service.cpp" + +build_and_run zclaw_quickstart_service_test \ + "$test_dir/zclaw_quickstart_service_test.cpp" \ + "$ui_dir/zclaw_quickstart_service.cpp" + +build_and_run zclaw_async_service_test \ + "$test_dir/zclaw_async_service_test.cpp" \ + "$ui_dir/zclaw_async_service.cpp" \ + +build_and_run zclaw_input_routing_test \ + "$test_dir/zclaw_input_routing_test.cpp" \ + "$ui_dir/zclaw_input_model.cpp" \ + "$ui_dir/zclaw_key_event_adapter.cpp" \ + "$ui_dir/zclaw_key_router.cpp" + +if [ "$tests_run" -eq 0 ]; then + echo "No tests matched ZCLAW_TEST_FILTER: $test_filter" >&2 + exit 1 +fi diff --git a/projects/ZClaw/tests/zclaw_async_service_test.cpp b/projects/ZClaw/tests/zclaw_async_service_test.cpp new file mode 100644 index 00000000..9660f3d4 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_async_service_test.cpp @@ -0,0 +1,229 @@ +#include "zclaw_async_backend.h" +#include "zclaw_async_service.h" +#include "zclaw_ui_task_sink.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class FakeTaskSink : public zclaw::UiTaskSink { +public: + bool post(Task task) override + { + std::lock_guard lock(mutex_); + tasks_.push_back(std::move(task)); + changed_.notify_all(); + return true; + } + + bool wait_for_count(std::size_t count) + { + std::unique_lock lock(mutex_); + return changed_.wait_for(lock, std::chrono::seconds(1), + [this, count] { return tasks_.size() >= count; }); + } + + void drain() + { + std::vector tasks; + { + std::lock_guard lock(mutex_); + tasks.swap(tasks_); + } + for (Task &task : tasks) + task(); + } + +private: + std::mutex mutex_; + std::condition_variable changed_; + std::vector tasks_; +}; + +class FakeAsyncBackend : public zclaw::AsyncBackend { +public: + void shutdown() noexcept override + { + shutdown_called = true; + } + + bool check_network(NetworkCompletion completion) const override + { + completion({true, ""}); + return true; + } + + bool send_chat( + zclaw::UiConfig config, std::string message, + ApprovalHandler, OperationCompletion completion) const override + { + completion({"chat:" + message, true, std::move(config)}); + return true; + } + + bool pair(zclaw::UiConfig config, std::string code, + OperationCompletion completion) const override + { + completion({"pair:" + code, true, std::move(config)}); + return true; + } + + zclaw::OperationResult setup( + zclaw::UiConfig config, zclaw::ProviderConfig, + const ProgressHandler &progress_handler) const override + { + progress_handler({"Halfway", 50}); + return {"setup complete", true, std::move(config)}; + } + + std::atomic shutdown_called{false}; +}; + +class ThrowingAsyncBackend : public zclaw::AsyncBackend { +public: + bool check_network(NetworkCompletion) const override + { + throw std::runtime_error("network exploded"); + } + + bool send_chat(zclaw::UiConfig, std::string, ApprovalHandler, + OperationCompletion) const override + { + throw std::runtime_error("chat exploded"); + } + + bool pair(zclaw::UiConfig, std::string, + OperationCompletion) const override + { + throw 42; + } + + zclaw::OperationResult setup( + zclaw::UiConfig, zclaw::ProviderConfig, + const ProgressHandler &) const override + { + throw std::runtime_error("setup exploded"); + } +}; + +} // namespace + +int main() +{ + auto tasks = std::make_shared(); + auto backend = std::make_shared(); + zclaw::AsyncService service(tasks, backend); + + bool network_called = false; + assert(service.check_network([&network_called](zclaw::NetworkCheckResult result) { + assert(result.online); + network_called = true; + })); + assert(tasks->wait_for_count(1)); + assert(!network_called); + tasks->drain(); + assert(network_called); + + std::string chat_result; + assert(service.send_chat({}, "hello", {}, + [&chat_result](zclaw::OperationResult result) { + chat_result = result.text; + })); + assert(tasks->wait_for_count(1)); + assert(chat_result.empty()); + tasks->drain(); + assert(chat_result == "chat:hello"); + + std::vector setup_events; + assert(service.setup( + {}, {}, + [&setup_events](zclaw::SetupProgress progress) { + setup_events.push_back("progress:" + std::to_string(progress.percent)); + }, + [&setup_events](zclaw::OperationResult result) { + setup_events.push_back(result.text); + })); + assert(tasks->wait_for_count(2)); + assert(setup_events.empty()); + tasks->drain(); + assert((setup_events == + std::vector{"progress:50", "setup complete"})); + + assert(!service.check_network({})); + assert(!service.pair({}, "code", {})); + zclaw::AsyncService missing_tasks(nullptr, backend); + assert(!missing_tasks.send_chat({}, "hello", {}, + [](zclaw::OperationResult) {})); + + auto lifetime_tasks = std::make_shared(); + auto lifetime_backend = std::make_shared(); + std::string lifetime_result; + { + zclaw::AsyncService transient(lifetime_tasks, lifetime_backend); + assert(transient.send_chat( + {}, "after-destruction", {}, + [&lifetime_result](zclaw::OperationResult result) { + lifetime_result = result.text; + })); + } + lifetime_backend.reset(); + assert(lifetime_tasks->wait_for_count(1)); + lifetime_tasks->drain(); + assert(lifetime_result == "chat:after-destruction"); + + zclaw::AsyncService missing_backend(tasks, nullptr); + assert(!missing_backend.check_network([](zclaw::NetworkCheckResult) {})); + + auto throwing_tasks = std::make_shared(); + zclaw::AsyncService throwing_service( + throwing_tasks, std::make_shared()); + std::vector failures; + zclaw::UiConfig fallback_config; + fallback_config.agent_alias = "preserved"; + assert(throwing_service.check_network( + [&failures](zclaw::NetworkCheckResult result) { + assert(!result.online); + failures.push_back(result.error); + })); + assert(throwing_service.send_chat( + fallback_config, "hello", {}, + [&failures](zclaw::OperationResult result) { + assert(!result.ok && result.config.agent_alias == "preserved"); + failures.push_back(result.text); + })); + assert(throwing_service.pair( + fallback_config, "code", [&failures](zclaw::OperationResult result) { + assert(!result.ok && result.config.agent_alias == "preserved"); + failures.push_back(result.text); + })); + assert(throwing_service.setup( + fallback_config, {}, {}, + [&failures](zclaw::OperationResult result) { + assert(!result.ok && result.config.agent_alias == "preserved"); + failures.push_back(result.text); + })); + assert(throwing_tasks->wait_for_count(4)); + assert(failures.empty()); + throwing_tasks->drain(); + assert(failures.size() == 4); + assert(failures[0].find("network exploded") != std::string::npos); + assert(failures[1].find("chat exploded") != std::string::npos); + assert(failures[2].find("unknown exception") != std::string::npos); + assert(failures[3].find("setup exploded") != std::string::npos); + throwing_service.shutdown(); + + service.shutdown(); + service.shutdown(); + assert(backend->shutdown_called); + assert(!service.check_network([](zclaw::NetworkCheckResult) {})); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_atomic_file_test.cpp b/projects/ZClaw/tests/zclaw_atomic_file_test.cpp new file mode 100644 index 00000000..402f7035 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_atomic_file_test.cpp @@ -0,0 +1,38 @@ +#include "zclaw_atomic_file.h" + +#include +#include +#include +#include +#include +#include + +int main() +{ + char directory_template[] = "/tmp/zclaw-atomic-file-XXXXXX"; + const char *directory = ::mkdtemp(directory_template); + assert(directory); + const std::string path = std::string(directory) + "/config.tsv"; + + std::string error = "stale"; + assert(zclaw::atomic_write_file(path, "first", {}, &error)); + assert(error.empty()); + struct stat state {}; + assert(::stat(path.c_str(), &state) == 0); + assert((state.st_mode & 0777) == 0600); + + assert(zclaw::atomic_write_file(path, "second\nline", {0640}, &error)); + assert(::stat(path.c_str(), &state) == 0); + assert((state.st_mode & 0777) == 0640); + std::ifstream input(path, std::ios::binary); + const std::string contents((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); + assert(contents == "second\nline"); + + assert(!zclaw::atomic_write_file( + std::string(directory) + "/missing/config.tsv", "data", {}, &error)); + assert(!error.empty()); + assert(::unlink(path.c_str()) == 0); + assert(::rmdir(directory) == 0); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_authorization_session_model_test.cpp b/projects/ZClaw/tests/zclaw_authorization_session_model_test.cpp new file mode 100644 index 00000000..4617986f --- /dev/null +++ b/projects/ZClaw/tests/zclaw_authorization_session_model_test.cpp @@ -0,0 +1,40 @@ +#include "zclaw_authorization_session_model.h" + +#include + +int main() +{ + zclaw::AuthorizationSession session; + assert(!session.in_flight()); + assert(session.generation() == 0); + + const zclaw::AuthorizationSession::Generation first = session.begin(); + assert(first != 0); + assert(session.in_flight()); + assert(session.generation() == first); + + const zclaw::AuthorizationSession::Generation second = session.begin(); + assert(second != first); + assert(session.in_flight()); + assert(!session.finish(first)); + assert(session.in_flight()); + assert(session.finish(second)); + assert(!session.in_flight()); + assert(!session.finish(second)); + + const zclaw::AuthorizationSession::Generation cleared = session.begin(); + session.invalidate(); + assert(!session.in_flight()); + assert(session.generation() != cleared); + assert(!session.finish(cleared)); + + const zclaw::AuthorizationSession::Generation failed = session.begin(); + assert(session.finish(failed)); + assert(!session.in_flight()); + + const zclaw::AuthorizationSession::Generation current = session.begin(); + assert(!session.finish(failed)); + assert(session.in_flight()); + assert(session.finish(current)); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_binary_archive_cache_test.cpp b/projects/ZClaw/tests/zclaw_binary_archive_cache_test.cpp new file mode 100644 index 00000000..7edf99dd --- /dev/null +++ b/projects/ZClaw/tests/zclaw_binary_archive_cache_test.cpp @@ -0,0 +1,56 @@ +#include "zclaw_binary_archive_cache.h" +#include "zclaw_temporary_directory.h" + +#include +#include +#include +#include + +namespace { + +void write_file(const std::string &path, const std::string &content) +{ + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output << content; + assert(output.good()); +} + +} // namespace + +int main() +{ + std::string error; + std::unique_ptr directory = + zclaw::TemporaryDirectory::create("/tmp", "zclaw-cache-test", &error); + assert(directory && error.empty()); + const std::string root = directory->path(); + const std::string archive = root + "/zeroclaw.tar.gz"; + write_file(archive, "abc"); + assert(zclaw::sha256_file_matches( + archive, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")); + assert(!zclaw::sha256_file_matches(archive, std::string(64, '0'))); + + std::filesystem::create_directory(root + "/zeroclaw-install.stale"); + write_file(root + "/zeroclaw-install.stale/data", "partial"); + write_file(root + "/zeroclaw.tar.gz.part", "partial"); + std::filesystem::create_directory(root + "/zeroclaw-installation.keep"); + write_file(root + "/providers.tsv", "keep"); + assert(zclaw::cleanup_binary_install_artifacts(root, &error)); + assert(error.empty()); + assert(!std::filesystem::exists(root + "/zeroclaw-install.stale")); + assert(std::filesystem::exists(root + "/zeroclaw.tar.gz.part")); + assert(std::filesystem::exists(root + "/zeroclaw-installation.keep")); + assert(std::filesystem::exists(root + "/providers.tsv")); + assert(std::filesystem::exists(archive)); + + const std::string external = root + "/external"; + std::filesystem::create_directory(external); + write_file(external + "/keep", "keep"); + std::filesystem::create_directory_symlink( + external, root + "/zeroclaw-install.link"); + assert(zclaw::cleanup_binary_install_artifacts(root, &error)); + assert(!std::filesystem::exists(root + "/zeroclaw-install.link")); + assert(std::filesystem::exists(external + "/keep")); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_binary_installer_test.cpp b/projects/ZClaw/tests/zclaw_binary_installer_test.cpp new file mode 100644 index 00000000..ad69689a --- /dev/null +++ b/projects/ZClaw/tests/zclaw_binary_installer_test.cpp @@ -0,0 +1,109 @@ +#include "zclaw_binary_installer.h" +#include "zclaw_binary_installer_backend.h" + +#include +#include +#include +#include + +namespace { + +class FakeBackend final : public zclaw::BinaryInstallerBackend { +public: + bool prepare_ok = true; + bool ready = false; + bool install_ok = true; + mutable int prepare_calls = 0; + mutable int ready_calls = 0; + mutable int install_calls = 0; + + bool prepare_destination(std::string *error) const override + { + ++prepare_calls; + if (!prepare_ok && error) + *error = "prepare failed"; + return prepare_ok; + } + + bool binary_ready() const override + { + ++ready_calls; + return ready; + } + + bool install(std::string *error, + const ProgressHandler &progress_handler) const override + { + ++install_calls; + if (progress_handler) { + progress_handler({zclaw::BinaryInstallPhase::Downloading, + 25, 100, 2048.0, + "https://example.test/zeroclaw.tar.gz", + "/tmp/install/zeroclaw.tar.gz"}); + progress_handler({zclaw::BinaryInstallPhase::Downloading, + 40, 0, 1024.0, + "https://example.test/zeroclaw.tar.gz", + "/tmp/install/zeroclaw.tar.gz"}); + progress_handler( + {zclaw::BinaryInstallPhase::Installing, 0, 0, 0.0}); + } + if (!install_ok && error) + *error = "install failed"; + return install_ok; + } +}; + +} // namespace + +int main() +{ + std::string error; + std::vector progress; + + auto backend = std::make_shared(); + backend->prepare_ok = false; + assert(!zclaw::BinaryInstaller(backend).ensure_installed( + &error, [&](const zclaw::SetupProgress &item) { progress.push_back(item); })); + assert(error == "prepare failed"); + assert(backend->prepare_calls == 1); + assert(backend->ready_calls == 0); + assert(backend->install_calls == 0); + assert(progress.size() == 1 && progress[0].percent == 3); + + backend = std::make_shared(); + backend->ready = true; + progress.clear(); + assert(zclaw::BinaryInstaller(backend).ensure_installed( + &error, [&](const zclaw::SetupProgress &item) { progress.push_back(item); })); + assert(backend->install_calls == 0); + assert(progress.size() == 2); + assert(progress[1].status == "ZeroClaw is ready"); + assert(progress[1].percent == 65); + + backend = std::make_shared(); + backend->install_ok = false; + progress.clear(); + assert(!zclaw::BinaryInstaller(backend).ensure_installed( + &error, [&](const zclaw::SetupProgress &item) { progress.push_back(item); })); + assert(error == "install failed"); + assert(progress.size() == 4); + assert(progress[1].status == "Downloading ZeroClaw"); + assert(progress[1].percent == 20); + assert(progress[1].downloaded_bytes == 25); + assert(progress[1].total_bytes == 100); + assert(progress[1].bytes_per_second == 2048.0); + assert(progress[1].source_url == + "https://example.test/zeroclaw.tar.gz"); + assert(progress[1].destination_path == + "/tmp/install/zeroclaw.tar.gz"); + assert(progress[2].percent == 5); + assert(progress[2].source_url == progress[1].source_url); + assert(progress[2].destination_path == progress[1].destination_path); + assert(progress[3].status == "Installing ZeroClaw"); + assert(progress[3].percent == 67); + + error.clear(); + assert(!zclaw::BinaryInstaller(nullptr).ensure_installed(&error, {})); + assert(error == "Missing binary installer backend."); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_cli_config_model_test.cpp b/projects/ZClaw/tests/zclaw_cli_config_model_test.cpp new file mode 100644 index 00000000..8dff0e61 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_cli_config_model_test.cpp @@ -0,0 +1,46 @@ +#include "zclaw_cli_config_model.h" + +#include + +int main() +{ + zclaw::UiConfig config; + config.agent_alias.clear(); + const zclaw::ProviderConfig provider = { + "primary", "openai", "gpt-test", "https://api.example/v1", "secret" + }; + const zclaw::CliConfigPlan plan = zclaw::make_cli_config_plan(config, provider); + + assert(plan.agent_alias == "zclaw"); + assert(plan.webhook_url == "http://127.0.0.1:42617/webhook"); + assert(plan.initial_settings.size() == 8); + assert(plan.initial_settings[0].path == "gateway.host"); + assert(plan.initial_settings[0].value == "127.0.0.1"); + assert(plan.initial_settings[4].path == + "gateway.long_running_request_timeout_secs"); + assert(plan.initial_settings[4].value == "600"); + assert(plan.initial_settings[5].path == + "providers.models.openai.primary.model"); + assert(plan.initial_settings[5].value == "gpt-test"); + assert(plan.initial_settings[6].path == + "providers.models.openai.primary.uri"); + assert(plan.initial_settings[6].value == "https://api.example/v1"); + assert(plan.initial_settings[7].path == + "providers.models.openai.primary.api_key"); + assert(plan.initial_settings[7].value == "secret"); + + assert(plan.agent_settings.size() == 4); + assert(plan.agent_settings[0].path == "agents.zclaw.enabled"); + assert(plan.agent_settings[1].path == "agents.zclaw.model_provider"); + assert(plan.agent_settings[1].value == "openai.primary"); + assert(plan.agent_settings[2].path == "agents.zclaw.risk_profile"); + assert(plan.agent_settings[3].path == "onboard_state.quickstart_completed"); + + config.agent_alias = "custom-agent"; + const zclaw::CliConfigPlan custom_agent_plan = + zclaw::make_cli_config_plan(config, provider); + assert(custom_agent_plan.agent_alias == "custom-agent"); + assert(custom_agent_plan.agent_settings[0].path == + "agents.custom-agent.enabled"); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_cli_output_model_test.cpp b/projects/ZClaw/tests/zclaw_cli_output_model_test.cpp new file mode 100644 index 00000000..d170e74e --- /dev/null +++ b/projects/ZClaw/tests/zclaw_cli_output_model_test.cpp @@ -0,0 +1,36 @@ +#include "zclaw_cli_output_model.h" + +#include + +int main() +{ + using zclaw::CliServiceCommand; + using zclaw::cli_agent_list_contains; + using zclaw::cli_service_command_succeeded; + + assert(cli_service_command_succeeded(true, "", CliServiceCommand::Install)); + assert(cli_service_command_succeeded(true, "", CliServiceCommand::Start)); + assert(cli_service_command_succeeded(false, "already installed", + CliServiceCommand::Install)); + assert(cli_service_command_succeeded(false, "service exists", + CliServiceCommand::Install)); + assert(!cli_service_command_succeeded(false, "service active", + CliServiceCommand::Install)); + assert(cli_service_command_succeeded(false, "already started", + CliServiceCommand::Start)); + assert(cli_service_command_succeeded(false, "service active", + CliServiceCommand::Start)); + assert(!cli_service_command_succeeded(false, "service exists", + CliServiceCommand::Start)); + assert(!cli_service_command_succeeded(false, "Already installed", + CliServiceCommand::Install)); + assert(!cli_service_command_succeeded(false, "failed", + CliServiceCommand::Start)); + + assert(cli_agent_list_contains("primary\nzclaw\nsecondary\n", "zclaw")); + assert(cli_agent_list_contains("primary\n zclaw \t\r\n", "zclaw")); + assert(!cli_agent_list_contains("primary\nzclaw-dev\n", "zclaw")); + assert(!cli_agent_list_contains("primary\nZClaw\n", "zclaw")); + assert(!cli_agent_list_contains("", "zclaw")); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_cli_service_test.cpp b/projects/ZClaw/tests/zclaw_cli_service_test.cpp new file mode 100644 index 00000000..562b35e9 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_cli_service_test.cpp @@ -0,0 +1,97 @@ +#include "zclaw_cli_service.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +class FakeCommands { +public: + std::vector results; + std::vector commands; + std::size_t next_result = 0; + + zclaw::CommandResult run(const zclaw::CliService::Command &command) + { + commands.push_back(command); + assert(next_result < results.size()); + return results[next_result++]; + } +}; + +zclaw::CliService make_service(FakeCommands *commands, + std::vector *sleeps) +{ + return zclaw::CliService( + [commands](const zclaw::CliService::Command &command) { + return commands->run(command); + }, + [sleeps](unsigned int seconds) { sleeps->push_back(seconds); }); +} + +bool ends_with(const zclaw::CliService::Command &command, + const std::vector &suffix) +{ + return command.size() >= suffix.size() && + std::equal(suffix.begin(), suffix.end(), + command.end() - static_cast(suffix.size())); +} + +} // namespace + +int main() +{ + FakeCommands start_commands; + start_commands.results = {{1, "already installed"}, {0, "started"}}; + std::vector sleeps; + std::string error; + assert(make_service(&start_commands, &sleeps).start_service(&error)); + assert(start_commands.commands.size() == 2); + assert(ends_with(start_commands.commands[0], {"service", "install"})); + assert(ends_with(start_commands.commands[1], {"service", "start"})); + assert(sleeps.empty()); + + FakeCommands install_failure; + install_failure.results = {{1, "permission denied"}}; + assert(!make_service(&install_failure, &sleeps).start_service(&error)); + assert(install_failure.commands.size() == 1); + assert(error == "service install failed:\npermission denied"); + + FakeCommands start_failure; + start_failure.results = {{0, "installed"}, {1, "service unavailable"}}; + assert(!make_service(&start_failure, &sleeps).start_service(&error)); + assert(start_failure.commands.size() == 2); + assert(error == "service start failed:\nservice unavailable"); + + FakeCommands pairing; + pairing.results = {{1, "not ready"}, + {0, "still no code"}, + {0, "Pairing code: 123456"}}; + sleeps.clear(); + assert(make_service(&pairing, &sleeps).generate_pairing_code() == "123456"); + assert(pairing.commands.size() == 3); + assert((sleeps == std::vector{1, 1})); + for (const zclaw::CliService::Command &command : pairing.commands) + assert(ends_with(command, {"gateway", "get-paircode", "--new"})); + + FakeCommands exhausted; + exhausted.results.resize(12, {1, "not ready"}); + sleeps.clear(); + assert(make_service(&exhausted, &sleeps).generate_pairing_code().empty()); + assert(exhausted.commands.size() == 12); + assert(sleeps.size() == 11); + for (unsigned int seconds : sleeps) + assert(seconds == 1); + + FakeCommands unused; + sleeps.clear(); + assert(!make_service(&unused, &sleeps) + .apply_config(nullptr, zclaw::ProviderConfig{}, &error)); + assert(unused.commands.empty()); + assert(error == "Missing UI configuration."); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_config_codec_test.cpp b/projects/ZClaw/tests/zclaw_config_codec_test.cpp new file mode 100644 index 00000000..c86cce3f --- /dev/null +++ b/projects/ZClaw/tests/zclaw_config_codec_test.cpp @@ -0,0 +1,33 @@ +#include "zclaw_config_codec.h" + +#include +#include +#include + +int main() +{ + const std::vector values = { + "", + "plain", + "field\\value\tline\nnext", + "trailing\\", + }; + for (const std::string &value : values) + assert(zclaw::decode_config_field(zclaw::encode_config_field(value)) == value); + + assert(zclaw::decode_config_field("unknown\\qescape\\") == "unknownqescape\\"); + assert(zclaw::is_valid_config_field_encoding("slash\\\\tab\\tline\\n")); + assert(!zclaw::is_valid_config_field_encoding("trailing\\")); + assert(!zclaw::is_valid_config_field_encoding("unknown\\qescape")); + + const std::vector empty = zclaw::split_config_line(""); + assert(empty.size() == 1 && empty[0].empty()); + + const std::vector fields = zclaw::split_config_line("a\t\tc\t"); + assert(fields.size() == 4); + assert(fields[0] == "a"); + assert(fields[1].empty()); + assert(fields[2] == "c"); + assert(fields[3].empty()); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_config_document_model_test.cpp b/projects/ZClaw/tests/zclaw_config_document_model_test.cpp new file mode 100644 index 00000000..7d3971b2 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_config_document_model_test.cpp @@ -0,0 +1,83 @@ +#include "zclaw_config_document_model.h" + +#include +#include + +namespace { + +bool same_provider(const zclaw::ProviderConfig &left, + const zclaw::ProviderConfig &right) +{ + return left.alias == right.alias && left.family == right.family && + left.model == right.model && left.uri == right.uri && + left.api_key == right.api_key; +} + +} // namespace + +int main() +{ + const std::vector providers = { + {"primary", "custom", "model\\name\nnext", + "https://example.com/a\tb", "key\\value"}, + {"local", "ollama", "llama", "http://127.0.0.1:11434", ""}, + }; + const std::string provider_document = + zclaw::make_provider_config_document(providers); + const std::vector parsed_providers = + zclaw::parse_provider_config_document( + "malformed\n\n" + provider_document + "too\tfew\tfields\n"); + assert(parsed_providers.size() == providers.size()); + assert(same_provider(parsed_providers[0], providers[0])); + assert(same_provider(parsed_providers[1], providers[1])); + assert(zclaw::make_provider_config_document({}).empty()); + + zclaw::UiConfig config; + config.webhook_url = "https://example.com/hook\tpath"; + config.agent_alias = "test\nagent"; + config.webhook_secret = "hook\\secret"; + config.bearer_token = "token"; + config.setup_complete = true; + const zclaw::UiConfig parsed_config = zclaw::parse_ui_config_document( + "ignored\nunknown\tvalue\n" + zclaw::make_ui_config_document(config)); + assert(parsed_config.webhook_url == config.webhook_url); + assert(parsed_config.agent_alias == config.agent_alias); + assert(parsed_config.webhook_secret == config.webhook_secret); + assert(parsed_config.bearer_token == config.bearer_token); + assert(parsed_config.setup_complete); + + const zclaw::UiConfig defaults = + zclaw::parse_ui_config_document("agent_alias\t\nsetup_complete\ttrue\n"); + assert(defaults.agent_alias == "zclaw"); + assert(!defaults.setup_complete); + + std::vector checked_providers; + assert(!zclaw::parse_provider_config_document_checked( + "alias\\q\tfamily\tmodel\turi\tkey\n", &checked_providers)); + assert(zclaw::parse_provider_config_document_checked( + "bad\\q\tfamily\tmodel\turi\tkey\n" + provider_document, + &checked_providers)); + assert(checked_providers.size() == providers.size()); + const std::vector lenient_providers = + zclaw::parse_provider_config_document( + "alias\\q\tfamily\tmodel\turi\tkey\n"); + assert(lenient_providers.size() == 1); + assert(lenient_providers[0].alias == "aliasq"); + + zclaw::UiConfig checked_config; + assert(!zclaw::parse_ui_config_document_checked( + "setup_complete\ttrue\n", &checked_config)); + assert(!zclaw::parse_ui_config_document_checked( + "agent_alias\tfirst\nagent_alias\tsecond\n", &checked_config)); + assert(!zclaw::parse_ui_config_document_checked( + "agent_alias\tbad\\qvalue\n", &checked_config)); + const zclaw::UiConfig lenient_config = zclaw::parse_ui_config_document( + "agent_alias\tbad\\qvalue\nagent_alias\tsecond\n"); + assert(lenient_config.agent_alias == "second"); + assert(zclaw::parse_ui_config_document_checked( + "unknown\tvalue\\qfuture\nagent_alias\tvalid\nsetup_complete\t0\n", + &checked_config)); + assert(checked_config.agent_alias == "valid"); + assert(!checked_config.setup_complete); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_config_manager_load_test.cpp b/projects/ZClaw/tests/zclaw_config_manager_load_test.cpp new file mode 100644 index 00000000..59c047ea --- /dev/null +++ b/projects/ZClaw/tests/zclaw_config_manager_load_test.cpp @@ -0,0 +1,61 @@ +#include "zclaw_provider_manager.h" +#include "zclaw_provider_store.h" +#include "zclaw_ui_config_manager.h" +#include "zclaw_ui_config_store.h" + +#include +#include +#include +#include +#include + +int main() +{ + char directory[] = "/tmp/zclaw-manager-load-test-XXXXXX"; + const char *root = ::mkdtemp(directory); + assert(root); + const std::filesystem::path base(root); + const std::string provider_path = (base / "providers.tsv").string(); + const std::string ui_path = (base / "ui.tsv").string(); + std::string error; + + zclaw::ProviderManager providers(provider_path); + assert(providers.load(&error) == zclaw::ConfigStoreLoadStatus::NotFound); + assert(!providers.providers().empty()); + const std::string default_family = providers.setup_provider().family; + + std::ofstream(provider_path) << "damaged\n"; + assert(providers.load(&error) == zclaw::ConfigStoreLoadStatus::Invalid); + assert(error == "Provider settings file is invalid."); + assert(providers.setup_provider().family == default_family); + + const std::vector saved = { + {"active", "custom", "model", "https://example.test", "key"}, + }; + assert(zclaw::save_provider_configs(provider_path, saved, &error)); + assert(providers.load(&error) == zclaw::ConfigStoreLoadStatus::Loaded); + assert(providers.setup_provider().alias == "active"); + + zclaw::UiConfigManager ui(ui_path); + assert(ui.load(&error) == zclaw::ConfigStoreLoadStatus::NotFound); + assert(ui.config().agent_alias == "zclaw"); + std::ofstream(ui_path) << "unknown\tvalue\nmalformed\n"; + assert(ui.load(&error) == zclaw::ConfigStoreLoadStatus::Invalid); + assert(error == "UI settings file is invalid."); + assert(ui.config().agent_alias == "zclaw"); + + zclaw::UiConfig saved_ui; + saved_ui.agent_alias = "loaded-agent"; + assert(zclaw::save_ui_config(ui_path, saved_ui, &error)); + assert(ui.load(&error) == zclaw::ConfigStoreLoadStatus::Loaded); + assert(ui.config().agent_alias == "loaded-agent"); + + error.clear(); + zclaw::UiConfigManager directory_as_file(base.string()); + assert(directory_as_file.load(&error) == zclaw::ConfigStoreLoadStatus::Error); + assert(!error.empty()); + + std::error_code ignored; + std::filesystem::remove_all(base, ignored); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_connectivity_cancel_test.cpp b/projects/ZClaw/tests/zclaw_connectivity_cancel_test.cpp new file mode 100644 index 00000000..7fe77a72 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_connectivity_cancel_test.cpp @@ -0,0 +1,56 @@ +#include "zclaw_connectivity.h" + +#include +#include +#include +#include + +namespace { + +class PendingProbe final : public zclaw::ConnectivityProbe { +public: + bool get(const std::string &, Completion completion) override + { + if (cancelled_) { + completion(false, "cancelled"); + return true; + } + completion_ = std::move(completion); + return true; + } + + void cancel() noexcept override + { + cancelled_ = true; + Completion completion = std::move(completion_); + if (completion) + completion(false, "cancelled"); + } + +private: + Completion completion_; + bool cancelled_ = false; +}; + +} // namespace + +int main() +{ + auto probe = std::make_shared(); + zclaw::ConnectivityChecker checker(probe); + bool completed = false; + bool online = true; + std::string error; + assert(checker.check([&](bool value, std::string detail) { + completed = true; + online = value; + error = std::move(detail); + })); + assert(!completed); + checker.cancel(); + checker.cancel(); + assert(completed); + assert(!online); + assert(error == "cancelled"); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_core_test.cpp b/projects/ZClaw/tests/zclaw_core_test.cpp new file mode 100644 index 00000000..ac4eaa95 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_core_test.cpp @@ -0,0 +1,521 @@ +#include "zclaw_provider_store.h" +#include "zclaw_provider_catalog.h" +#include "zclaw_provider_manager.h" +#include "zclaw_ui_config_store.h" +#include "zclaw_ui_config_manager.h" +#include "zclaw_settings_controller.h" +#include "zclaw_settings_presentation.h" +#include "zclaw_task_inbox.h" +#include "zclaw_storage.h" +#include "zclaw_paths.h" +#include "zclaw_process_runner.h" +#include "zclaw_runtime_state.h" +#include "zclaw_risk_profile_store.h" +#include "zclaw_approval_controller.h" +#include "zclaw_callback_lifetime.h" +#include "zclaw_chat_transport.h" +#include "zclaw_cli_service.h" +#include "zclaw_pairing_service.h" +#include "zclaw_lhv_http_client.h" +#include "zclaw_webhook_transport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using zclaw::ApprovalRequest; +using zclaw::OperationResult; +using zclaw::ProviderConfig; +using zclaw::UiConfig; + +namespace { + +bool same_provider(const ProviderConfig &left, const ProviderConfig &right) +{ + return left.alias == right.alias && left.family == right.family && + left.model == right.model && left.uri == right.uri && + left.api_key == right.api_key; +} + +} // namespace + +int main() +{ + char dir_template[] = "/tmp/zclaw-provider-store-XXXXXX"; + const char *dir = ::mkdtemp(dir_template); + assert(dir); + const std::string path = std::string(dir) + "/providers.tsv"; + + const std::vector original = { + {"zclaw", "custom", "model\\name\nnext", "https://example.com/a\tb", "key\\value\nline"}, + {"second", "ollama", "llama3.1", "http://127.0.0.1:11434", ""}, + {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", "openai-key"}, + }; + std::string error; + assert(zclaw::save_provider_configs(path, original, &error)); + assert(error.empty()); + + struct stat st {}; + assert(::stat(path.c_str(), &st) == 0); + assert((st.st_mode & 0777) == 0600); + + std::vector loaded; + assert(zclaw::load_provider_configs(path, &loaded)); + assert(loaded.size() == original.size()); + for (size_t i = 0; i < original.size(); ++i) + assert(same_provider(loaded[i], original[i])); + + const std::string ui_path = std::string(dir) + "/ui.tsv"; + UiConfig ui_original; + ui_original.webhook_url = "https://example.com/hook\tpath"; + ui_original.agent_alias = "test\nagent"; + ui_original.webhook_secret = "hook\\secret"; + ui_original.bearer_token = "bearer-token"; + ui_original.setup_complete = true; + assert(zclaw::save_ui_config(ui_path, ui_original, &error)); + struct stat ui_st {}; + assert(::stat(ui_path.c_str(), &ui_st) == 0); + assert((ui_st.st_mode & 0777) == 0600); + UiConfig ui_loaded; + assert(zclaw::load_ui_config(ui_path, &ui_loaded)); + assert(ui_loaded.webhook_url == ui_original.webhook_url); + assert(ui_loaded.agent_alias == ui_original.agent_alias); + assert(ui_loaded.webhook_secret == ui_original.webhook_secret); + assert(ui_loaded.bearer_token == ui_original.bearer_token); + assert(ui_loaded.setup_complete == ui_original.setup_complete); + + assert(zclaw::provider_preset_count() == 6); + const std::vector defaults = zclaw::default_provider_configs(); + assert(defaults.size() == zclaw::provider_preset_count()); + assert(defaults.front().family == "openai"); + assert(defaults.back().family == "custom"); + assert(std::string(zclaw::provider_display_name("anthropic")) == "Anthropic"); + assert(std::string(zclaw::provider_display_name("unknown")) == "Custom"); + assert(zclaw::provider_preset_index("ollama") == 3); + assert(zclaw::provider_preset_index("unknown") == 5); + assert(zclaw::provider_preset(999).family == "custom"); + + ProviderConfig custom = zclaw::provider_preset(5); + custom.api_key = "key"; + custom.uri = "https://example.com/v1"; + custom.model = "model"; + + ProviderConfig ollama = zclaw::provider_preset(3); + using zclaw::SettingsView; + using zclaw::SettingsActivationAction; + using zclaw::SettingsBackAction; + + zclaw::SettingsController settings_controller; + assert(settings_controller.view() == SettingsView::Main); + settings_controller.reset_view(SettingsView::Providers); + settings_controller.move_providers(8, 5, 5); + assert(settings_controller.provider_selection().selected_index == 5); + assert(settings_controller.provider_selection().scroll_offset == 1); + assert(settings_controller.selected_row() == 4); + settings_controller.open_provider_detail(4); + assert(settings_controller.provider_detail_index() == 4); + assert(settings_controller.selected_row() == 0); + settings_controller.close_provider_detail(); + assert(settings_controller.provider_detail_index() == -1); + settings_controller.set_selected_row(2); + settings_controller.sync_setup_provider(5, 6, 5); + assert(settings_controller.setup_provider_selection().selected_index == 5); + assert(settings_controller.setup_provider_selection().scroll_offset == 1); + assert(settings_controller.selected_row() == 2); + settings_controller.normalize_setup_providers(6, 5); + assert(settings_controller.selected_row() == 4); + settings_controller.move_setup_providers(6, 5, -1); + assert(settings_controller.setup_provider_selection().selected_index == 4); + settings_controller.reset_view(SettingsView::Setup, 2); + assert(settings_controller.activation(2, 3, false).action == + SettingsActivationAction::StartSetup); + assert(settings_controller.back_action(true, false) == SettingsBackAction::None); + settings_controller.set_provider_edit_field(zclaw::ProviderEditField::Model); + assert(settings_controller.provider_edit_field() == zclaw::ProviderEditField::Model); + settings_controller.set_setup_edit_field(zclaw::SetupEditField::ApiKey); + assert(settings_controller.setup_edit_field() == zclaw::SetupEditField::ApiKey); + + UiConfig presentation_config; + presentation_config.agent_alias = "demo-agent"; + zclaw::SettingsPresentation settings_presentation = + zclaw::present_settings_main(presentation_config); + assert(settings_presentation.title == "ZClaw Settings"); + assert(settings_presentation.rows.size() == 5); + assert(settings_presentation.rows[0].value == "Run"); + assert(settings_presentation.rows[3].value == "demo-agent"); + assert(settings_presentation.rows[4].value == "Webhook"); + presentation_config.setup_complete = true; + presentation_config.bearer_token = "token"; + settings_presentation = zclaw::present_authorization(presentation_config); + assert(settings_presentation.rows[1].value == "Saved"); + assert(settings_presentation.rows[3].value == "WS"); + + settings_presentation = zclaw::present_setup(custom); + assert(settings_presentation.rows.size() == 5); + assert(settings_presentation.rows[0].value == "Custom"); + assert(settings_presentation.rows[4].title == "Confirm"); + settings_presentation = zclaw::present_setup(ollama); + assert(settings_presentation.rows.size() == 2); + + zclaw::PagedSelection presentation_selection{5, 1, 4}; + settings_presentation = + zclaw::present_setup_providers(presentation_selection, 5); + assert(settings_presentation.rows.size() == 5); + assert(settings_presentation.rows.back().value == "Selected"); + settings_presentation = + zclaw::present_providers(original, zclaw::PagedSelection{2, 1, 1}, 2); + assert(settings_presentation.rows.size() == 2); + assert(settings_presentation.rows[0].title == original[0].alias); + assert(settings_presentation.rows[1].title == original[1].alias); + settings_presentation = zclaw::present_provider_detail(original[0]); + assert(settings_presentation.rows.size() == 5); + assert(settings_presentation.rows[4].value == "set"); + assert(zclaw::present_setup_progress().rows.empty()); + + assert(::setenv("HOME", dir, 1) == 0); + assert(zclaw::paths::home_dir() == dir); + assert(zclaw::paths::zeroclaw_dir() == std::string(dir) + "/.zeroclaw"); + assert(zclaw::paths::providers_config() == + std::string(dir) + "/.zeroclaw/zclaw_providers.tsv"); + UiConfig runtime_config; + assert(zclaw::RuntimeState::first_run_needed(runtime_config)); + const std::string runtime_directory = zclaw::paths::zeroclaw_dir(); + const std::string runtime_config_path = zclaw::paths::zeroclaw_config(); + assert(::mkdir(runtime_directory.c_str(), 0700) == 0); + assert(zclaw::RuntimeState::first_run_needed(runtime_config)); + { + std::ofstream runtime_file(runtime_config_path); + assert(runtime_file); + runtime_file << "# test config\n"; + } + assert(zclaw::RuntimeState::first_run_needed(runtime_config)); + runtime_config.setup_complete = true; + assert(zclaw::RuntimeState::first_run_needed(runtime_config)); + runtime_config.bearer_token = "token"; + assert(!zclaw::RuntimeState::first_run_needed(runtime_config)); + + zclaw::ApprovalController approvals; + ApprovalRequest approval_request; + approval_request.request_id = "request-1"; + assert(approvals.begin(approval_request)); + assert(approvals.pending()); + assert(approvals.pending_request("request-1")); + assert(!approvals.pending_request("request-2")); + assert(approvals.selected_decision() == "approve"); + approvals.move_selection(-1); + assert(approvals.selected_index() == 2); + assert(approvals.selected_decision() == "deny"); + std::thread answer_thread([&] { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + assert(approvals.answer("always")); + }); + const zclaw::ApprovalWaitResult approval_result = + approvals.wait_for(approval_request.request_id, std::chrono::seconds(1)); + answer_thread.join(); + assert(!approval_result.timed_out); + assert(approval_result.decision == "always"); + assert(!approvals.pending()); + assert(!approvals.answer("approve")); + + assert(approvals.begin(approval_request)); + const zclaw::ApprovalWaitResult timeout_result = + approvals.wait_for(approval_request.request_id, std::chrono::milliseconds(1)); + assert(timeout_result.timed_out); + assert(timeout_result.decision == "deny"); + + ApprovalRequest concurrent_request; + concurrent_request.request_id = "request-2"; + zclaw::ApprovalController concurrent_approvals; + std::promise begin_gate; + std::shared_future begin_ready = begin_gate.get_future().share(); + std::future first_begin = std::async(std::launch::async, [&] { + begin_ready.wait(); + return concurrent_approvals.begin(approval_request); + }); + std::future second_begin = std::async(std::launch::async, [&] { + begin_ready.wait(); + return concurrent_approvals.begin(concurrent_request); + }); + begin_gate.set_value(); + const bool first_accepted = first_begin.get(); + const bool second_accepted = second_begin.get(); + assert(first_accepted != second_accepted); + concurrent_approvals.cancel(); + + assert(approvals.begin(approval_request)); + assert(!approvals.begin(concurrent_request)); + std::promise primary_wait; + std::future primary_result = primary_wait.get_future(); + std::thread primary_waiter([&] { + primary_wait.set_value( + approvals.wait_for(approval_request.request_id, std::chrono::seconds(1))); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const zclaw::ApprovalWaitResult duplicate_wait = + approvals.wait_for(approval_request.request_id, std::chrono::milliseconds(1)); + assert(!duplicate_wait.timed_out); + assert(duplicate_wait.decision == "deny"); + const zclaw::ApprovalWaitResult rejected_wait = + approvals.wait_for(concurrent_request.request_id, std::chrono::milliseconds(1)); + assert(!rejected_wait.timed_out); + assert(rejected_wait.decision == "deny"); + assert(approvals.answer("approve")); + primary_waiter.join(); + assert(primary_result.get().decision == "approve"); + assert(!approvals.pending()); + + assert(approvals.begin(concurrent_request)); + std::future cancelled_result = + std::async(std::launch::async, [&] { + return approvals.wait_for(concurrent_request.request_id, + std::chrono::seconds(1)); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + approvals.cancel(); + assert(cancelled_result.get().decision == "deny"); + assert(!approvals.pending()); + assert(approvals.begin(approval_request)); + approvals.cancel(); + + assert(approvals.begin(approval_request)); + std::future shutdown_result = + std::async(std::launch::async, [&] { + return approvals.wait_for(approval_request.request_id, + std::chrono::seconds(1)); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + approvals.shutdown(); + assert(shutdown_result.get().decision == "deny"); + assert(!approvals.begin(approval_request)); + assert(!approvals.pending()); + + struct CallbackTarget { + int calls = 0; + } callback_target; + auto callback_lifetime = + std::make_shared>(&callback_target); + std::function queued_callback = [callback_lifetime] { + callback_lifetime->invoke([](CallbackTarget &target) { ++target.calls; }); + }; + queued_callback(); + assert(callback_target.calls == 1); + callback_lifetime->invalidate(); + queued_callback(); + assert(callback_target.calls == 1); + + CallbackTarget concurrent_target; + auto concurrent_lifetime = + std::make_shared>(&concurrent_target); + std::promise callback_entered; + std::promise release_callback; + std::shared_future callback_release = release_callback.get_future().share(); + std::future active_callback = std::async(std::launch::async, [&] { + return concurrent_lifetime->invoke([&](CallbackTarget &target) { + callback_entered.set_value(); + callback_release.wait(); + ++target.calls; + }); + }); + callback_entered.get_future().wait(); + std::future invalidation = std::async(std::launch::async, [&] { + concurrent_lifetime->invalidate(); + }); + assert(invalidation.wait_for(std::chrono::milliseconds(5)) == + std::future_status::timeout); + release_callback.set_value(); + assert(active_callback.get()); + invalidation.get(); + assert(concurrent_target.calls == 1); + assert(!concurrent_lifetime->invoke( + [](CallbackTarget &target) { ++target.calls; })); + + zclaw::TaskInbox task_inbox; + assert(!task_inbox.active()); + assert(!task_inbox.post([] {})); + task_inbox.activate(); + std::vector task_posters; + for (int thread_index = 0; thread_index < 4; ++thread_index) { + task_posters.emplace_back([&task_inbox] { + for (int task_index = 0; task_index < 25; ++task_index) + assert(task_inbox.post([] {})); + }); + } + for (std::thread &thread : task_posters) + thread.join(); + std::vector pending_tasks = task_inbox.take_pending(); + assert(pending_tasks.size() == 100); + task_inbox.post([] { assert(false); }); + task_inbox.shutdown(); + assert(!task_inbox.active()); + assert(task_inbox.take_pending().empty()); + assert(!task_inbox.post([] {})); + + const std::vector replacement = { + {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", "new-key"}, + }; + assert(zclaw::save_provider_configs(path, replacement, &error)); + assert(zclaw::load_provider_configs(path, &loaded)); + assert(loaded.size() == 1 && same_provider(loaded[0], replacement[0])); + + { + std::ofstream legacy(path, std::ios::trunc); + assert(legacy); + legacy << "legacy\tcustom\tmodel\\tname\thttps://example.com/v1\tkey\\\\value\n"; + } + assert(zclaw::load_provider_configs(path, &loaded)); + assert(loaded.size() == 1); + assert(loaded[0].alias == "legacy"); + assert(loaded[0].model == "model\tname"); + assert(loaded[0].api_key == "key\\value"); + + const std::string blocked_parent = std::string(dir) + "/blocked-parent"; + { + std::ofstream blocking_file(blocked_parent); + assert(blocking_file); + } + assert(!zclaw::save_provider_configs(blocked_parent + "/providers.tsv", + original, &error)); + assert(!error.empty()); + assert(zclaw::load_provider_configs(path, &loaded)); + assert(loaded.size() == 1 && loaded[0].alias == "legacy"); + + const std::string manager_path = std::string(dir) + "/managed.tsv"; + zclaw::ProviderManager provider_manager(manager_path); + provider_manager.load(); + assert(provider_manager.providers().size() == zclaw::provider_preset_count()); + ProviderConfig managed_provider = { + "managed", "openai-compatible", "managed-model", "https://managed.example/v1", "key" + }; + std::size_t managed_index = 0; + assert(provider_manager.add(managed_provider, &managed_index, &error)); + assert(managed_index == zclaw::provider_preset_count()); + managed_provider.model = "updated-model"; + assert(provider_manager.replace(managed_index, managed_provider, &error)); + assert(provider_manager.providers()[managed_index].model == "updated-model"); + ProviderConfig managed_setup = provider_manager.setup_provider(); + managed_setup.api_key = "setup-key"; + assert(provider_manager.update_setup_provider(managed_setup, &error)); + assert(provider_manager.setup_provider().api_key == "setup-key"); + const ProviderConfig custom_preset = zclaw::provider_preset(5); + assert(provider_manager.activate(custom_preset, &error)); + assert(provider_manager.setup_provider().family == "custom"); + assert(provider_manager.erase(managed_index, zclaw::provider_preset(0), &error)); + + zclaw::ProviderManager failing_manager( + blocked_parent + "/deeper/providers.tsv"); + failing_manager.load(); + const std::vector before_failed_add = failing_manager.providers(); + assert(!failing_manager.add(managed_provider, nullptr, &error)); + assert(failing_manager.providers().size() == before_failed_add.size()); + assert(failing_manager.setup_provider().family == before_failed_add.front().family); + + const std::string managed_ui_path = std::string(dir) + "/managed-ui.tsv"; + zclaw::UiConfigManager ui_config_manager(managed_ui_path); + ui_config_manager.load(); + UiConfig managed_ui = ui_config_manager.config(); + managed_ui.agent_alias = "managed-agent"; + managed_ui.bearer_token = "managed-token"; + managed_ui.setup_complete = true; + assert(ui_config_manager.update(managed_ui, &error)); + assert(ui_config_manager.config().agent_alias == "managed-agent"); + assert(ui_config_manager.clear_token(&error)); + assert(ui_config_manager.config().bearer_token.empty()); + zclaw::UiConfigManager reloaded_ui_config(managed_ui_path); + reloaded_ui_config.load(); + assert(reloaded_ui_config.config().agent_alias == "managed-agent"); + assert(reloaded_ui_config.config().setup_complete); + + zclaw::UiConfigManager failing_ui_config( + blocked_parent + "/deeper/ui.tsv"); + UiConfig failed_candidate = failing_ui_config.config(); + failed_candidate.agent_alias = "must-not-commit"; + assert(!failing_ui_config.update(failed_candidate, &error)); + assert(failing_ui_config.config().agent_alias == "zclaw"); + + const std::string non_directory = std::string(dir) + "/not-a-directory"; + { + std::ofstream file(non_directory); + assert(file); + } + assert(!zclaw::ensure_private_parent_directory( + non_directory + "/config.tsv", &error)); + assert(!error.empty()); + + assert(zclaw::ProcessRunner::shell_quote("") == "''"); + assert(zclaw::ProcessRunner::shell_quote("plain value") == "'plain value'"); + assert(zclaw::ProcessRunner::shell_quote("it's") == "'it'\\''s'"); + const zclaw::CommandResult command_result = + zclaw::ProcessRunner::run_shell("printf ' captured output \\n'"); + assert(command_result.ok()); + assert(command_result.output == "captured output"); + assert(!zclaw::ProcessRunner::run_shell("false").ok()); + error.clear(); + assert(!zclaw::CliService().apply_config(nullptr, ProviderConfig{}, &error)); + assert(error == "Missing UI configuration."); + + const std::string risk_path = std::string(dir) + "/zeroclaw.toml"; + const std::string expected_risk = + "[gateway]\nport = 42617\n\n[risk_profiles.default]\n"; + { + std::ofstream risk_file(risk_path, std::ios::binary); + assert(risk_file); + risk_file << "[gateway]\nport = 42617"; + } + assert(::chmod(risk_path.c_str(), 0640) == 0); + assert(zclaw::RiskProfileStore().ensure_default(risk_path, &error)); + struct stat risk_state {}; + assert(::stat(risk_path.c_str(), &risk_state) == 0); + assert((risk_state.st_mode & 0777) == 0640); + std::ifstream stored_risk_file(risk_path, std::ios::binary); + const std::string stored_risk( + (std::istreambuf_iterator(stored_risk_file)), + std::istreambuf_iterator()); + assert(stored_risk == expected_risk); + assert(zclaw::RiskProfileStore().ensure_default(risk_path, &error)); + + UiConfig invalid_transport_config; + invalid_transport_config.webhook_url = "not-a-url"; + auto invalid_http_client = std::make_shared(); + OperationResult transport_result; + assert(zclaw::WebhookTransport(invalid_http_client).send( + invalid_transport_config, "hello", + [&transport_result](OperationResult result) { + transport_result = std::move(result); + })); + assert(!transport_result.ok); + assert(transport_result.text == "Webhook request failed.\nInvalid URL."); + assert(zclaw::PairingService(invalid_http_client).pair( + invalid_transport_config, "1234", + [&transport_result](OperationResult result) { + transport_result = std::move(result); + })); + assert(!transport_result.ok); + assert(transport_result.text == "Pairing request failed.\nInvalid URL."); + assert(zclaw::WebSocketTransport().send( + invalid_transport_config, "hello", {}, + [&transport_result](OperationResult result) { + transport_result = std::move(result); + })); + assert(!transport_result.ok); + assert(transport_result.text == "WS chat failed.\nInvalid WebSocket URL."); + + assert(::unlink(path.c_str()) == 0); + assert(::unlink(ui_path.c_str()) == 0); + assert(::unlink(manager_path.c_str()) == 0); + assert(::unlink(managed_ui_path.c_str()) == 0); + assert(::unlink(blocked_parent.c_str()) == 0); + assert(::unlink(non_directory.c_str()) == 0); + assert(::unlink(runtime_config_path.c_str()) == 0); + assert(::unlink(risk_path.c_str()) == 0); + assert(::rmdir(runtime_directory.c_str()) == 0); + assert(::rmdir(dir) == 0); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_directory_sync_test.cpp b/projects/ZClaw/tests/zclaw_directory_sync_test.cpp new file mode 100644 index 00000000..f4c35092 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_directory_sync_test.cpp @@ -0,0 +1,35 @@ +#include "zclaw_directory_sync.h" + +#include +#include +#include +#include +#include + +int main() +{ + char directory[] = "/tmp/zclaw-directory-sync-test-XXXXXX"; + const char *root = ::mkdtemp(directory); + assert(root); + const std::filesystem::path base(root); + const std::filesystem::path file = base / "config.tsv"; + std::ofstream(file) << "data"; + + std::string error = "stale"; + assert(zclaw::sync_parent_directory(file.string(), &error)); + assert(error.empty()); + + error.clear(); + assert(!zclaw::sync_parent_directory( + (base / "missing" / "config.tsv").string(), &error)); + assert(error.rfind("Could not sync parent directory: ", 0) == 0); + + error.clear(); + assert(!zclaw::sync_parent_directory( + (file / "not-a-child").string(), &error)); + assert(!error.empty()); + + std::error_code ignored; + std::filesystem::remove_all(base, ignored); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_file_downloader_resume_test.cpp b/projects/ZClaw/tests/zclaw_file_downloader_resume_test.cpp new file mode 100644 index 00000000..4dd068f6 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_file_downloader_resume_test.cpp @@ -0,0 +1,153 @@ +#include "zclaw_file_downloader.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +class OneShotServer { +public: + explicit OneShotServer(std::function reply) + { + socket_ = ::socket(AF_INET, SOCK_STREAM, 0); + assert(socket_ >= 0); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + assert(::bind(socket_, reinterpret_cast(&address), + sizeof(address)) == 0); + assert(::listen(socket_, 1) == 0); + socklen_t length = sizeof(address); + assert(::getsockname(socket_, reinterpret_cast(&address), + &length) == 0); + port_ = ntohs(address.sin_port); + thread_ = std::thread([this, reply = std::move(reply)] { + const int client = ::accept(socket_, nullptr, nullptr); + assert(client >= 0); + std::string request; + char buffer[1024]; + while (request.find("\r\n\r\n") == std::string::npos) { + const ssize_t count = ::read(client, buffer, sizeof(buffer)); + assert(count > 0); + request.append(buffer, static_cast(count)); + } + const std::string response = reply(request); + std::size_t offset = 0; + while (offset < response.size()) { + const ssize_t count = ::write(client, response.data() + offset, + response.size() - offset); + assert(count > 0); + offset += static_cast(count); + } + ::close(client); + }); + } + + ~OneShotServer() + { + if (thread_.joinable()) + thread_.join(); + ::close(socket_); + } + + std::string url() const + { + return "http://127.0.0.1:" + std::to_string(port_) + "/archive"; + } + +private: + int socket_ = -1; + int port_ = 0; + std::thread thread_; +}; + +void write_file(const std::string &path, const std::string &content) +{ + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output << content; + assert(output.good()); +} + +std::string read_file(const std::string &path) +{ + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), + std::istreambuf_iterator()}; +} + +} // namespace + +int main() +{ + const std::string output_path = "/tmp/zclaw-resume-test.part"; + std::string error; + write_file(output_path, "hello"); + std::size_t final_bytes = 0; + { + OneShotServer server([](const std::string &request) { + assert(request.find("Range: bytes=5-") != std::string::npos); + return std::string( + "HTTP/1.1 206 Partial Content\r\n" + "Content-Range: bytes 5-10/11\r\n" + "Content-Length: 6\r\nConnection: close\r\n\r\n world"); + }); + assert(zclaw::FileDownloader().download( + server.url(), output_path, &error, + [&final_bytes](const zclaw::DownloadProgress &progress) { + final_bytes = progress.downloaded_bytes; + })); + } + assert(error.empty()); + assert(read_file(output_path) == "hello world"); + assert(final_bytes == 11); + + write_file(output_path, "old partial"); + { + OneShotServer server([](const std::string &request) { + assert(request.find("Range: bytes=11-") != std::string::npos); + return std::string( + "HTTP/1.1 200 OK\r\nContent-Length: 11\r\n" + "Connection: close\r\n\r\nreplacement"); + }); + assert(zclaw::FileDownloader().download(server.url(), output_path, + &error)); + } + assert(read_file(output_path) == "replacement"); + + write_file(output_path, "complete"); + { + OneShotServer server([](const std::string &request) { + assert(request.find("Range: bytes=8-") != std::string::npos); + return std::string( + "HTTP/1.1 416 Range Not Satisfiable\r\n" + "Content-Range: bytes */8\r\nContent-Length: 0\r\n" + "Connection: close\r\n\r\n"); + }); + assert(zclaw::FileDownloader().download(server.url(), output_path, + &error)); + } + assert(read_file(output_path) == "complete"); + + write_file(output_path, "hello"); + { + OneShotServer server([](const std::string &) { + return std::string( + "HTTP/1.1 206 Partial Content\r\n" + "Content-Range: bytes 5-10/11\r\n" + "Content-Length: 6\r\nConnection: close\r\n\r\n wo"); + }); + assert(!zclaw::FileDownloader().download(server.url(), output_path, + &error)); + } + assert(read_file(output_path) == "hello wo"); + ::unlink(output_path.c_str()); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_filesystem_test.cpp b/projects/ZClaw/tests/zclaw_filesystem_test.cpp new file mode 100644 index 00000000..1f664c5c --- /dev/null +++ b/projects/ZClaw/tests/zclaw_filesystem_test.cpp @@ -0,0 +1,135 @@ +#include "zclaw_archive_installer.h" +#include "zclaw_process_runner.h" +#include "zclaw_temporary_directory.h" + +#include +#include +#include +#include +#include +#include +#include + +int main() +{ + std::string temporary_path; + { + std::string error; + std::unique_ptr temporary = + zclaw::TemporaryDirectory::create("/tmp", "zclaw-test", &error); + assert(temporary); + assert(error.empty()); + temporary_path = temporary->path(); + assert(std::filesystem::is_directory(temporary_path)); + struct stat temporary_state {}; + assert(::stat(temporary_path.c_str(), &temporary_state) == 0); + assert((temporary_state.st_mode & 0777) == 0700); + std::ofstream marker(temporary_path + "/marker"); + assert(marker); + } + assert(!std::filesystem::exists(temporary_path)); + + std::string error; + assert(!zclaw::TemporaryDirectory::create( + "/zclaw-missing-parent", "zclaw-test", &error)); + assert(!error.empty()); + + error = "stale"; + assert(!zclaw::TemporaryDirectory::create("/tmp", "../escaped", &error)); + assert(error == "Cannot create temporary directory: Invalid prefix."); + assert(!zclaw::TemporaryDirectory::create("/tmp", "nested/name", &error)); + assert(!zclaw::TemporaryDirectory::create("/tmp", "", &error)); + + std::unique_ptr source = + zclaw::TemporaryDirectory::create("/tmp", "zclaw-archive-source"); + std::unique_ptr destination = + zclaw::TemporaryDirectory::create("/tmp", "zclaw-archive-destination"); + assert(source && destination); + const std::string nested = source->path() + "/release/bin"; + assert(std::filesystem::create_directories(nested)); + { + std::ofstream executable(nested + "/zeroclaw"); + assert(executable); + executable << "test executable"; + } + const std::string archive = source->path() + "/release.tar.gz"; + const zclaw::CommandResult packed = zclaw::ProcessRunner::run_shell( + "tar -czf " + zclaw::ProcessRunner::shell_quote(archive) + " -C " + + zclaw::ProcessRunner::shell_quote(source->path()) + " release"); + assert(packed.ok()); + + const std::string installed = destination->path() + "/zeroclaw"; + zclaw::ArchiveInstaller::Command attempted_command; + zclaw::ArchiveInstaller rejected( + [&attempted_command](const zclaw::ArchiveInstaller::Command &command) { + attempted_command = command; + return zclaw::CommandResult{1, "cancelled"}; + }); + error.clear(); + assert(!rejected.install_executable( + archive, destination->path(), "zeroclaw", installed, &error)); + assert((attempted_command == zclaw::ArchiveInstaller::Command{ + "tar", "-xzf", archive, "-C", + destination->path()})); + assert(error == "Cannot extract archive.\ncancelled"); + + const std::string outside = source->path() + "/outside-executable"; + { + std::ofstream executable(outside); + executable << "must not be installed"; + } + const std::string symlink_work = destination->path() + "/symlink-work"; + assert(std::filesystem::create_directory(symlink_work)); + assert(::symlink(outside.c_str(), + (symlink_work + "/zeroclaw").c_str()) == 0); + zclaw::ArchiveInstaller no_op( + [](const zclaw::ArchiveInstaller::Command &) { + return zclaw::CommandResult{0, ""}; + }); + const std::string symlink_destination = destination->path() + "/from-symlink"; + error = "stale"; + assert(!no_op.install_executable("unused", symlink_work, "zeroclaw", + symlink_destination, &error)); + assert(error == "Archive does not contain zeroclaw."); + assert(!std::filesystem::exists(symlink_destination)); + + const std::string regular_work = destination->path() + "/regular-work"; + assert(std::filesystem::create_directory(regular_work)); + { + std::ofstream executable(regular_work + "/zeroclaw"); + executable << "replacement executable"; + } + const std::string protected_target = source->path() + "/protected-target"; + { + std::ofstream target(protected_target); + target << "must remain unchanged"; + } + const std::string linked_destination = destination->path() + "/linked-destination"; + assert(::symlink(protected_target.c_str(), linked_destination.c_str()) == 0); + error = "stale"; + assert(no_op.install_executable("unused", regular_work, "zeroclaw", + linked_destination, &error)); + assert(error.empty()); + assert(!std::filesystem::is_symlink(linked_destination)); + std::string installed_contents; + std::ifstream installed_input(linked_destination); + std::getline(installed_input, installed_contents); + assert(installed_contents == "replacement executable"); + std::string protected_contents; + std::ifstream protected_input(protected_target); + std::getline(protected_input, protected_contents); + assert(protected_contents == "must remain unchanged"); + + error.clear(); + assert(zclaw::ArchiveInstaller().install_executable( + archive, destination->path(), "zeroclaw", installed, &error)); + assert(error.empty()); + std::ifstream executable(installed); + std::string contents; + std::getline(executable, contents); + assert(contents == "test executable"); + struct stat installed_state {}; + assert(::stat(installed.c_str(), &installed_state) == 0); + assert((installed_state.st_mode & 0777) == 0700); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_font_path_model_test.cpp b/projects/ZClaw/tests/zclaw_font_path_model_test.cpp new file mode 100644 index 00000000..3b119537 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_font_path_model_test.cpp @@ -0,0 +1,31 @@ +#include "zclaw_font_path_model.h" + +#include +#include +#include + +int main() +{ + const std::vector existing = {"override.ttf", "second.ttf"}; + const auto path_exists = [&existing](const std::string &path) { + for (const std::string &item : existing) { + if (item == path) + return true; + } + return false; + }; + + const std::vector candidates = { + "missing.ttf", "second.ttf", "later.ttf" + }; + assert(zclaw::select_font_path("override.ttf", candidates, path_exists) == + "override.ttf"); + assert(zclaw::select_font_path("missing-override.ttf", candidates, + path_exists) == "second.ttf"); + assert(zclaw::select_font_path("", candidates, path_exists) == + "second.ttf"); + assert(zclaw::select_font_path("", {"", "missing.ttf"}, path_exists) + .empty()); + assert(zclaw::select_font_path("override.ttf", candidates, {}).empty()); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_http_cancellation_test.cpp b/projects/ZClaw/tests/zclaw_http_cancellation_test.cpp new file mode 100644 index 00000000..650b05a8 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_http_cancellation_test.cpp @@ -0,0 +1,57 @@ +#include "zclaw_http_cancellation.h" + +#include +#include +#include +#include + +int main() +{ + zclaw::HttpCancellationRegistry registry; + std::atomic stopped{0}; + + { + zclaw::HttpCancellationRegistration completed = + registry.register_request([&stopped] { ++stopped; }); + assert(completed.active()); + } + registry.shutdown(); + assert(stopped == 0); + registry.shutdown(); + + zclaw::HttpCancellationRegistration rejected = + registry.register_request([&stopped] { ++stopped; }); + assert(!rejected.active()); + assert(registry.shutdown_requested()); + assert(stopped == 0); + + zclaw::HttpCancellationRegistry active_registry; + auto first = active_registry.register_request([&stopped] { ++stopped; }); + auto second = active_registry.register_request([&stopped] { ++stopped; }); + assert(first.active()); + assert(second.active()); + zclaw::HttpCancellationRegistration moved = std::move(first); + assert(!first.active()); + assert(moved.active()); + second.reset(); + active_registry.shutdown(); + assert(stopped == 1); + moved.reset(); + + for (int iteration = 0; iteration < 100; ++iteration) { + zclaw::HttpCancellationRegistry racing_registry; + std::atomic racing_stops{0}; + auto registration = racing_registry.register_request( + [&racing_stops] { ++racing_stops; }); + std::thread unregister([registration = std::move(registration)]() mutable { + registration.reset(); + }); + std::thread shutdown([&racing_registry] { racing_registry.shutdown(); }); + unregister.join(); + shutdown.join(); + assert(racing_stops >= 0); + assert(racing_stops <= 1); + } + + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_input_routing_test.cpp b/projects/ZClaw/tests/zclaw_input_routing_test.cpp new file mode 100644 index 00000000..9af3146d --- /dev/null +++ b/projects/ZClaw/tests/zclaw_input_routing_test.cpp @@ -0,0 +1,169 @@ +#include "zclaw_input_model.h" +#include "zclaw_key_event_adapter.h" +#include "zclaw_key_router.h" + +#include "keyboard_input.h" + +#include + +#include +#include + +int main() +{ + using zclaw::InputMode; + using zclaw::InputSubmissionAction; + zclaw::InputSubmission submission = + zclaw::input_submission(InputMode::Chat, "hello"); + assert(submission.action == InputSubmissionAction::SendChat); + assert(submission.value == "hello"); + assert(zclaw::input_submission(InputMode::Chat, "").action == + InputSubmissionAction::None); + assert(zclaw::input_submission(InputMode::PairingCode, "1234").action == + InputSubmissionAction::StartPairing); + assert(zclaw::input_submission(InputMode::PairingCode, "").action == + InputSubmissionAction::None); + assert(zclaw::input_submission(InputMode::SetupEdit, "").action == + InputSubmissionAction::ApplySetupEdit); + assert(zclaw::input_submission(InputMode::ProviderEdit, "").action == + InputSubmissionAction::ApplyProviderEdit); + + using zclaw::Key; + using zclaw::KeyActionType; + using zclaw::KeyEvent; + using zclaw::KeyPhase; + using zclaw::KeyRouteContext; + using zclaw::StartupState; + + KeyEvent adapted = zclaw::adapt_key_event( + KEY_ENTER, KBD_KEY_PRESSED, KBD_MOD_SHIFT, "\n"); + assert(adapted.key == Key::Enter); + assert(adapted.phase == KeyPhase::Pressed); + assert(adapted.shift && adapted.text == "\n"); + adapted = zclaw::adapt_key_event( + KEY_BACKSPACE, KBD_KEY_REPEATED, 0, nullptr); + assert(adapted.key == Key::Backspace); + assert(adapted.phase == KeyPhase::Repeated); + assert(!adapted.shift && adapted.text.empty()); + adapted = zclaw::adapt_key_event(KEY_ESC, KBD_KEY_RELEASED, 0, ""); + assert(adapted.key == Key::Escape && adapted.phase == KeyPhase::Released); + adapted = zclaw::adapt_key_event(KEY_RESERVED, 99, KBD_MOD_CTRL, "x"); + assert(adapted.key == Key::Other && adapted.phase == KeyPhase::Unknown); + assert(!adapted.shift && adapted.text == "x"); + + const auto routed = [](const KeyRouteContext &context, KeyPhase phase, + Key key, bool shift = false, + const std::string &text = std::string()) { + return zclaw::route_key(context, KeyEvent{phase, key, shift, text}); + }; + + KeyRouteContext context; + assert(routed(context, KeyPhase::Released, Key::Enter).type == + KeyActionType::None); + context.startup = StartupState::Offline; + assert(routed(context, KeyPhase::Pressed, Key::Enter).type == + KeyActionType::None); + assert(routed(context, KeyPhase::Released, Key::Enter).type == + KeyActionType::Quit); + + context.startup = StartupState::Ready; + context.input_open = true; + zclaw::KeyAction action = + routed(context, KeyPhase::Pressed, Key::Other, false, "hello"); + assert(action.type == KeyActionType::InputInsertText && + action.text == "hello"); + assert(routed(context, KeyPhase::Repeated, Key::Backspace).type == + KeyActionType::InputEraseBefore); + assert(routed(context, KeyPhase::Pressed, Key::Delete).type == + KeyActionType::InputEraseAfter); + assert(routed(context, KeyPhase::Pressed, Key::Left).type == + KeyActionType::InputMoveLeft); + assert(routed(context, KeyPhase::Pressed, Key::Right).type == + KeyActionType::InputMoveRight); + assert(routed(context, KeyPhase::Pressed, Key::Up).type == + KeyActionType::InputMoveUp); + assert(routed(context, KeyPhase::Pressed, Key::Down).type == + KeyActionType::InputMoveDown); + assert(routed(context, KeyPhase::Pressed, Key::Enter, true).type == + KeyActionType::InputInsertNewline); + assert(routed(context, KeyPhase::Pressed, Key::Enter).type == + KeyActionType::None); + assert(routed(context, KeyPhase::Pressed, Key::Other, false, "\n").type == + KeyActionType::None); + assert(routed(context, KeyPhase::Released, Key::Escape).type == + KeyActionType::InputClose); + assert(routed(context, KeyPhase::Released, Key::Enter).type == + KeyActionType::InputSubmit); + assert(routed(context, KeyPhase::Released, Key::Enter, true).type == + KeyActionType::None); + assert(routed(context, KeyPhase::Released, Key::Down).type == + KeyActionType::None); + + context.input_open = false; + context.approval_pending = true; + assert(routed(context, KeyPhase::Released, Key::Z).type == + KeyActionType::ApprovalMoveLeft); + assert(routed(context, KeyPhase::Released, Key::C).type == + KeyActionType::ApprovalMoveRight); + assert(routed(context, KeyPhase::Released, Key::Enter).type == + KeyActionType::ApprovalSubmitSelected); + assert(routed(context, KeyPhase::Released, Key::Y).type == + KeyActionType::ApprovalApprove); + assert(routed(context, KeyPhase::Released, Key::A).type == + KeyActionType::ApprovalAlways); + assert(routed(context, KeyPhase::Released, Key::N).type == + KeyActionType::ApprovalDeny); + assert(routed(context, KeyPhase::Released, Key::Tab).type == + KeyActionType::None); + + context.approval_pending = false; + context.settings_open = true; + context.setup_retry_pending = true; + assert(routed(context, KeyPhase::Released, Key::Enter).type == + KeyActionType::SetupRetryActivate); + assert(routed(context, KeyPhase::Released, Key::Escape).type == + KeyActionType::SetupRetryDismiss); + assert(routed(context, KeyPhase::Released, Key::Backspace).type == + KeyActionType::SetupRetryDismiss); + assert(routed(context, KeyPhase::Released, Key::Left).type == + KeyActionType::SetupRetryMoveLeft); + assert(routed(context, KeyPhase::Released, Key::Right).type == + KeyActionType::SetupRetryMoveRight); + assert(routed(context, KeyPhase::Released, Key::Tab).type == + KeyActionType::None); + assert(routed(context, KeyPhase::Released, Key::Up).type == + KeyActionType::None); + context.setup_retry_pending = false; + assert(routed(context, KeyPhase::Released, Key::Tab).type == + KeyActionType::ToggleSettings); + assert(routed(context, KeyPhase::Released, Key::Escape).type == + KeyActionType::SettingsBack); + assert(routed(context, KeyPhase::Released, Key::Backspace).type == + KeyActionType::SettingsBack); + assert(routed(context, KeyPhase::Released, Key::Enter).type == + KeyActionType::SettingsActivate); + assert(routed(context, KeyPhase::Released, Key::F).type == + KeyActionType::SettingsMoveUp); + assert(routed(context, KeyPhase::Released, Key::X).type == + KeyActionType::SettingsMoveDown); + assert(routed(context, KeyPhase::Released, Key::Delete).type == + KeyActionType::None); + context.settings_view = zclaw::SettingsView::ProviderDetail; + assert(routed(context, KeyPhase::Released, Key::Delete).type == + KeyActionType::SettingsDeleteProvider); + + context.settings_open = false; + assert(routed(context, KeyPhase::Unknown, Key::Enter).type == + KeyActionType::None); + assert(routed(context, KeyPhase::Pressed, Key::Enter).type == + KeyActionType::None); + assert(routed(context, KeyPhase::Released, Key::Up).type == + KeyActionType::ChatScrollUp); + assert(routed(context, KeyPhase::Released, Key::X).type == + KeyActionType::ChatScrollDown); + assert(routed(context, KeyPhase::Released, Key::Enter).type == + KeyActionType::ChatOpenInput); + assert(routed(context, KeyPhase::Released, Key::Escape).type == + KeyActionType::None); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_lhv_http_client_test.cpp b/projects/ZClaw/tests/zclaw_lhv_http_client_test.cpp new file mode 100644 index 00000000..7495ba4b --- /dev/null +++ b/projects/ZClaw/tests/zclaw_lhv_http_client_test.cpp @@ -0,0 +1,130 @@ +#include "zclaw_lhv_http_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class LocalServer { +public: + explicit LocalServer(bool respond) : respond_(respond) + { + fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + assert(fd_ >= 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + assert(::bind(fd_, reinterpret_cast(&address), + sizeof(address)) == 0); + socklen_t length = sizeof(address); + assert(::getsockname(fd_, reinterpret_cast(&address), + &length) == 0); + port_ = ntohs(address.sin_port); + assert(::listen(fd_, 1) == 0); + thread_ = std::thread([this] { serve(); }); + } + + ~LocalServer() + { + ::shutdown(fd_, SHUT_RDWR); + ::close(fd_); + if (thread_.joinable()) + thread_.join(); + } + + int port() const { return port_; } + + void wait_for_accept() + { + std::unique_lock lock(mutex_); + assert(changed_.wait_for(lock, std::chrono::seconds(2), + [this] { return accepted_; })); + } + +private: + void serve() + { + const int client = ::accept(fd_, nullptr, nullptr); + if (client < 0) + return; + { + std::lock_guard lock(mutex_); + accepted_ = true; + } + changed_.notify_all(); + char request[1024]; + (void)::recv(client, request, sizeof(request), 0); + if (respond_) { + static constexpr char response[] = + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n" + "Connection: close\r\n\r\nok"; + (void)::send(client, response, std::strlen(response), 0); + } else { + char byte = 0; + (void)::recv(client, &byte, 1, 0); + } + ::close(client); + } + + bool respond_; + int fd_ = -1; + int port_ = 0; + std::thread thread_; + std::mutex mutex_; + std::condition_variable changed_; + bool accepted_ = false; +}; + +HttpRequestPtr request_for(int port) +{ + auto request = std::make_shared(); + request->method = HTTP_GET; + request->url = "http://127.0.0.1:" + std::to_string(port) + "/test"; + request->timeout = 5; + return request; +} + +} // namespace + +int main() +{ + { + LocalServer server(true); + zclaw::LhvHttpClient client; + std::promise completed; + assert(client.send(request_for(server.port()), + [&completed](const HttpResponsePtr &response) { + completed.set_value(response); + })); + const HttpResponsePtr response = completed.get_future().get(); + assert(response && response->status_code == 200); + assert(response->body == "ok"); + } + + { + LocalServer server(false); + zclaw::LhvHttpClient client; + std::promise completed; + assert(client.send(request_for(server.port()), + [&completed](const HttpResponsePtr &response) { + completed.set_value(response); + })); + std::future result = completed.get_future(); + server.wait_for_accept(); + client.shutdown(); + assert(result.wait_for(std::chrono::seconds(2)) == + std::future_status::ready); + assert(!result.get()); + } + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_path_model_test.cpp b/projects/ZClaw/tests/zclaw_path_model_test.cpp new file mode 100644 index 00000000..55bc9973 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_path_model_test.cpp @@ -0,0 +1,30 @@ +#include "zclaw_path_model.h" + +#include + +int main() +{ + assert(zclaw::select_home_directory("/env/home", "/account/home") == + "/env/home"); + assert(zclaw::select_home_directory("", "/account/home") == + "/account/home"); + assert(zclaw::select_home_directory("", "") == "."); + + zclaw::ZClawPaths paths = zclaw::make_zclaw_paths("/home/test"); + assert(paths.home == "/home/test"); + assert(paths.zeroclaw_directory == "/home/test/.zeroclaw"); + assert(paths.providers_config == + "/home/test/.zeroclaw/zclaw_providers.tsv"); + assert(paths.ui_config == "/home/test/.zeroclaw/zclaw_ui.tsv"); + assert(paths.zeroclaw_config == "/home/test/.zeroclaw/config.toml"); + assert(paths.zeroclaw_binary == "/home/test/.zeroclaw/bin/zeroclaw"); + + paths = zclaw::make_zclaw_paths("/home/test/"); + assert(paths.zeroclaw_directory == "/home/test/.zeroclaw"); + paths = zclaw::make_zclaw_paths("/"); + assert(paths.zeroclaw_directory == "/.zeroclaw"); + paths = zclaw::make_zclaw_paths(""); + assert(paths.home == "."); + assert(paths.zeroclaw_directory == "./.zeroclaw"); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_process_executor_test.cpp b/projects/ZClaw/tests/zclaw_process_executor_test.cpp new file mode 100644 index 00000000..6f69c2a3 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_process_executor_test.cpp @@ -0,0 +1,65 @@ +#include "zclaw_process_executor.h" + +#include +#include +#include +#include +#include + +namespace { + +std::size_t open_fd_count() +{ + std::size_t count = 0; + for (const auto &entry : std::filesystem::directory_iterator("/proc/self/fd")) { + static_cast(entry); + ++count; + } + return count; +} + +} // namespace + +int main() +{ + zclaw::ProcessExecutor executor; + const zclaw::CommandResult output = + executor.run({"printf", " process output \\n"}); + assert(output.ok()); + assert(output.output == "process output"); + + const std::string literal = "space ; $(printf injected) ' quote"; + const zclaw::CommandResult literal_output = executor.run({"printf", literal}); + assert(literal_output.ok()); + assert(literal_output.output == literal); + + const std::size_t descriptors_before = open_fd_count(); + for (int attempt = 0; attempt < 50; ++attempt) + assert(executor.run({"/definitely/missing/zclaw-command"}).status != 0); + assert(open_fd_count() == descriptors_before); + + std::future blocked = + std::async(std::launch::async, [&executor] { + return executor.run({"/bin/sh", "-c", "sleep 30; printf late"}); + }); + assert(blocked.wait_for(std::chrono::milliseconds(20)) == + std::future_status::timeout); + executor.shutdown(); + assert(blocked.wait_for(std::chrono::seconds(1)) == + std::future_status::ready); + assert(!blocked.get().ok()); + assert(executor.shutdown_requested()); + executor.shutdown(); + + const zclaw::CommandResult rejected = executor.run({"printf", "unexpected"}); + assert(!rejected.ok()); + assert(rejected.output == "command cancelled"); + assert(!executor.run({}).ok()); + + const auto wait_started = std::chrono::steady_clock::now(); + executor.wait(30); + assert(std::chrono::steady_clock::now() - wait_started < + std::chrono::seconds(1)); + + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_protocol_model_test.cpp b/projects/ZClaw/tests/zclaw_protocol_model_test.cpp new file mode 100644 index 00000000..a20b229a --- /dev/null +++ b/projects/ZClaw/tests/zclaw_protocol_model_test.cpp @@ -0,0 +1,269 @@ +#include "zclaw_chat_event.h" +#include "zclaw_chat_layout.h" +#include "zclaw_chat_stream_model.h" +#include "zclaw_file_downloader.h" +#include "zclaw_gateway_response_model.h" +#include "zclaw_http_cancellation.h" +#include "zclaw_http_client_policy.h" +#include "zclaw_protocol.h" +#include "zclaw_quickstart_model.h" +#include "zclaw_setup_progress_model.h" + +#include +#include +#include + +int main() +{ + const zclaw::HttpClientTimeouts probe_timeouts = + zclaw::http_client_timeouts(zclaw::HttpClientProfile::Probe); + assert(probe_timeouts.connection_seconds == 2); + assert(probe_timeouts.read_seconds == 2); + assert(probe_timeouts.write_seconds == 2); + const zclaw::HttpClientTimeouts download_timeouts = + zclaw::http_client_timeouts(zclaw::HttpClientProfile::Download); + assert(download_timeouts.connection_seconds == 30); + assert(download_timeouts.read_seconds == 0); + assert(download_timeouts.write_seconds == 30); + + std::string download_error; + assert(!zclaw::FileDownloader().download( + "not-a-url", "/tmp/zclaw-unused-download", &download_error)); + assert(download_error == "Invalid download URL."); + download_error.clear(); + assert(!zclaw::FileDownloader().download( + "http://example.com/file", "/zclaw-missing/download", &download_error)); + assert(download_error == "Cannot write /zclaw-missing/download"); + auto cancelled_downloads = + std::make_shared(); + cancelled_downloads->shutdown(); + download_error.clear(); + assert(!zclaw::FileDownloader(cancelled_downloads).download( + "http://example.com/file", "/zclaw-missing/download", &download_error)); + assert(download_error == "Download cancelled."); + + zclaw::protocol::HttpUrl parsed_url; + assert(zclaw::protocol::split_http_url("https://example.com/path?q=1", &parsed_url)); + assert(parsed_url.base == "https://example.com"); + assert(parsed_url.path == "/path?q=1"); + assert(!zclaw::protocol::split_http_url("not-a-url", &parsed_url)); + + zclaw::UiConfig config; + config.webhook_url = "https://example.com/webhook"; + config.agent_alias = "agent name"; + config.bearer_token = "token/value"; + assert(zclaw::protocol::gateway_http_base(config) == "https://example.com"); + assert(zclaw::protocol::gateway_ws_url(config) == + "wss://example.com/ws/chat?agent=agent%20name&token=token%2Fvalue&session_id=zclaw-ui&name=ZClaw"); + assert(zclaw::protocol::webhook_endpoint(config) == + "https://example.com/webhook?agent=agent%20name"); + + const std::string webhook_json = zclaw::protocol::make_webhook_message("line\n\"quoted\""); + assert(webhook_json == "{\"message\":\"line\\n\\\"quoted\\\"\"}"); + const std::string approval_json = + zclaw::protocol::make_ws_approval_response("request-1", ""); + assert(approval_json == + "{\"decision\":\"deny\",\"request_id\":\"request-1\"," + "\"type\":\"approval_response\"}"); + + zclaw::OperationResult gateway_result = zclaw::interpret_pairing_response( + config, 200, "{\"token\":\"paired-token\"}"); + assert(gateway_result.ok); + assert(gateway_result.config.bearer_token == "paired-token"); + gateway_result = zclaw::interpret_pairing_response( + config, 401, "denied"); + assert(!gateway_result.ok); + assert(gateway_result.text == + "Pairing request failed.\nHTTP 401\ndenied"); + gateway_result = zclaw::interpret_pairing_response( + config, 200, "{\"error\":\"bad code\"}"); + assert(!gateway_result.ok); + assert(gateway_result.text == "Pairing failed: bad code"); + gateway_result = zclaw::interpret_pairing_response(config, 200, "invalid"); + assert(!gateway_result.ok && gateway_result.text == "Pairing failed.\ninvalid"); + gateway_result = zclaw::interpret_pairing_response( + config, 200, "{\"token\":42}"); + assert(!gateway_result.ok); + gateway_result = zclaw::interpret_pairing_response(config, 200, "{}"); + assert(!gateway_result.ok); + + gateway_result = zclaw::interpret_webhook_response( + config, 200, "{\"response\":\"hello\"}"); + assert(gateway_result.ok && gateway_result.text == "hello"); + gateway_result = zclaw::interpret_webhook_response( + config, 200, "{\"response\":\"ignored\",\"error\":\"failed\"}"); + assert(!gateway_result.ok); + assert(gateway_result.text == "ZeroClaw error: failed"); + gateway_result = zclaw::interpret_webhook_response(config, 503, "offline"); + assert(!gateway_result.ok); + assert(gateway_result.text == + "Webhook request failed.\nHTTP 503\noffline"); + gateway_result = zclaw::interpret_webhook_response(config, 200, "plain reply"); + assert(gateway_result.ok && gateway_result.text == "plain reply"); + gateway_result = zclaw::interpret_webhook_response( + config, 200, "{\"response\":42}"); + assert(gateway_result.ok && gateway_result.text == "{\"response\":42}"); + gateway_result = zclaw::interpret_webhook_response(config, 200, "{}"); + assert(gateway_result.ok && gateway_result.text == "{}"); + + zclaw::ChatEvent event = zclaw::parse_chat_event( + "{\"type\":\"chunk\",\"content\":\"partial\"}"); + assert(event.type == zclaw::ChatEventType::Chunk); + assert(event.content == "partial"); + event = zclaw::parse_chat_event( + "{\"type\":\"approval_request\",\"request_id\":\"req-1\"," + "\"tool\":\"shell\",\"arguments_summary\":\"ls\",\"timeout_secs\":45}"); + assert(event.type == zclaw::ChatEventType::ApprovalRequest); + assert(event.approval.request_id == "req-1"); + assert(event.approval.tool == "shell"); + assert(event.approval.summary == "ls"); + assert(event.approval.timeout_secs == 45); + event = zclaw::parse_chat_event( + "{\"type\":\"approval_request\",\"request_id\":\"req-2\"}"); + assert(event.approval.timeout_secs == 120); + event = zclaw::parse_chat_event( + "{\"type\":\"approval_request\",\"request_id\":\"req-3\"," + "\"timeout_secs\":-1}"); + assert(event.type == zclaw::ChatEventType::ApprovalRequest); + assert(event.approval.timeout_secs == 120); + event = zclaw::parse_chat_event( + "{\"type\":\"approval_request\",\"request_id\":\"req-4\"," + "\"timeout_secs\":3601}"); + assert(event.approval.timeout_secs == 120); + event = zclaw::parse_chat_event( + "{\"type\":\"approval_request\",\"request_id\":\"req-4b\"," + "\"timeout_secs\":9223372036854775808}"); + assert(event.approval.timeout_secs == 120); + event = zclaw::parse_chat_event( + "{\"type\":\"approval_request\",\"request_id\":\"req-5\"," + "\"timeout_secs\":\"45\"}"); + assert(event.approval.timeout_secs == 120); + assert(zclaw::parse_chat_event( + "{\"type\":\"approval_request\",\"tool\":\"shell\"}") + .type == zclaw::ChatEventType::Unknown); + assert(zclaw::parse_chat_event( + "{\"type\":\"approval_request\",\"request_id\":\"req\"," + "\"tool\":42}") + .type == zclaw::ChatEventType::Unknown); + event = zclaw::parse_chat_event( + "{\"type\":\"done\",\"full_response\":\"complete\"}"); + assert(event.type == zclaw::ChatEventType::Done); + assert(event.content == "complete"); + event = zclaw::parse_chat_event("{\"type\":\"error\",\"message\":\"failed\"}"); + assert(event.type == zclaw::ChatEventType::Error); + assert(event.content == "failed"); + assert(zclaw::parse_chat_event("{\"type\":\"other\"}").type == + zclaw::ChatEventType::Unknown); + assert(zclaw::parse_chat_event("invalid").type == zclaw::ChatEventType::Unknown); + assert(zclaw::parse_chat_event("[]").type == zclaw::ChatEventType::Unknown); + assert(zclaw::parse_chat_event("{}").type == zclaw::ChatEventType::Unknown); + assert(zclaw::parse_chat_event("{\"type\":42}").type == + zclaw::ChatEventType::Unknown); + assert(zclaw::parse_chat_event("{\"type\":\"chunk\"}").type == + zclaw::ChatEventType::Unknown); + assert(zclaw::parse_chat_event( + "{\"type\":\"done\",\"full_response\":42}") + .type == zclaw::ChatEventType::Unknown); + + zclaw::ChatStreamState stream; + zclaw::ChatStreamUpdate stream_update = stream.accept( + {zclaw::ChatEventType::Chunk, "partial ", {}}); + assert(!stream_update.finished); + stream.accept({zclaw::ChatEventType::Chunk, "reply", {}}); + zclaw::ApprovalRequest stream_approval; + stream_approval.request_id = "approve-1"; + stream_update = stream.accept( + {zclaw::ChatEventType::ApprovalRequest, "", stream_approval}); + assert(stream_update.approval_requested); + assert(stream_update.approval.request_id == "approve-1"); + stream_update = stream.accept({zclaw::ChatEventType::Done, "", {}}); + assert(stream_update.finished); + assert(stream.finished()); + assert(stream.succeeded()); + assert(stream.response() == "partial reply"); + + zclaw::ChatStreamState failed_stream; + stream_update = failed_stream.accept( + {zclaw::ChatEventType::Error, "permission denied", {}}); + assert(stream_update.finished); + assert(!failed_stream.succeeded()); + assert(failed_stream.response() == "ZeroClaw error: permission denied"); + failed_stream.accept({zclaw::ChatEventType::Done, "must be ignored", {}}); + assert(!failed_stream.succeeded()); + assert(failed_stream.response() == "ZeroClaw error: permission denied"); + + zclaw::ProviderConfig incomplete_provider; + const zclaw::ProviderConfig normalized = + zclaw::normalize_setup_provider(incomplete_provider); + assert(normalized.alias == "zclaw"); + assert(normalized.family == "openai"); + assert(normalized.model == "gpt-4.1-mini"); + const zclaw::ProviderConfig incomplete_ollama = { + "local", "ollama", "llama", "", "" + }; + assert(zclaw::normalize_setup_provider(incomplete_ollama).uri == + "http://127.0.0.1:11434"); + assert(zclaw::extract_pairing_code("Pairing code: 123456\n") == "123456"); + assert(zclaw::extract_pairing_code("12 3456").empty()); + assert(zclaw::extract_pairing_code("old 1234567") == "123456"); + + const zclaw::ChatBubbleLayout short_bubble = zclaw::user_bubble_layout(2, 5); + assert(short_bubble.width == 38 && short_bubble.height == 27); + const zclaw::ChatBubbleLayout wide_bubble = zclaw::user_bubble_layout(500, 30); + assert(wide_bubble.width == 198 && wide_bubble.height == 42); + assert(zclaw::assistant_bubble_height(5) == 41); + assert(zclaw::assistant_bubble_height(50) == 62); + assert(zclaw::chat_scroll_delta(0, 100, 24) == 0); + assert(zclaw::chat_scroll_delta(10, 100, 24) == 10); + assert(zclaw::chat_scroll_delta(50, 100, 24) == 24); + assert(zclaw::chat_scroll_delta(100, 0, -24) == 0); + assert(zclaw::chat_scroll_delta(100, 10, -24) == -10); + assert(zclaw::chat_scroll_delta(100, 50, -24) == -24); + assert(zclaw::chat_scroll_delta(-1, -1, 0) == 0); + + zclaw::SetupProgress progress; + progress.status = "Preparing"; + progress.percent = -5; + zclaw::SetupProgressPresentation presentation = + zclaw::present_setup_progress(progress); + assert(presentation.status == "Preparing"); + assert(presentation.detail == "Preparing"); + assert(presentation.percent == 0); + assert(presentation.percent_text == "0%"); + assert(presentation.speed.empty()); + + progress.status = "Downloading"; + progress.percent = 125; + progress.downloaded_bytes = 512 * 1024; + progress.total_bytes = 2 * 1024 * 1024; + progress.bytes_per_second = 256 * 1024; + progress.source_url = "https://example.test/releases/zeroclaw.tar.gz"; + progress.destination_path = "/tmp/install dir/zeroclaw.tar.gz"; + presentation = zclaw::present_setup_progress(progress); + assert(presentation.detail == "0.5 / 2.0 MB"); + assert(presentation.percent == 100); + assert(presentation.percent_text == "100%"); + assert(presentation.speed == "256 KB/s"); + assert(presentation.source_url == + "https://example.test/releases/zeroclaw.tar.gz"); + assert(presentation.destination_path == + "/tmp/install dir/zeroclaw.tar.gz"); + progress.bytes_per_second = 1.5 * 1024 * 1024; + assert(zclaw::present_setup_progress(progress).speed == "1.5 MB/s"); + progress.bytes_per_second = 0.0; + assert(zclaw::present_setup_progress(progress).speed == "Waiting for data"); + progress.total_bytes = 0; + progress.bytes_per_second = 512.0; + assert(zclaw::present_setup_progress(progress).speed == "512 B/s"); + progress.bytes_per_second = 1024.0; + assert(zclaw::present_setup_progress(progress).speed == "1 KB/s"); + + const zclaw::ChatScrollbarLayout idle_scrollbar = + zclaw::chat_scrollbar_layout(0, 0); + assert(idle_scrollbar.y == 20 && idle_scrollbar.height == 126); + const zclaw::ChatScrollbarLayout middle_scrollbar = + zclaw::chat_scrollbar_layout(100, 100); + assert(middle_scrollbar.y > 20 && middle_scrollbar.y < 128); + assert(middle_scrollbar.height >= 18 && middle_scrollbar.height < 126); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_provider_collection_model_test.cpp b/projects/ZClaw/tests/zclaw_provider_collection_model_test.cpp new file mode 100644 index 00000000..0634eda3 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_provider_collection_model_test.cpp @@ -0,0 +1,72 @@ +#include "zclaw_provider_collection_model.h" + +#include +#include + +namespace { + +bool same_provider(const zclaw::ProviderConfig &left, + const zclaw::ProviderConfig &right) +{ + return left.alias == right.alias && left.family == right.family && + left.model == right.model && left.uri == right.uri && + left.api_key == right.api_key; +} + +} // namespace + +int main() +{ + using zclaw::ProviderConfig; + + const ProviderConfig custom = { + "zclaw", "custom", "custom-model", "https://custom.example/v1", "custom-key" + }; + const ProviderConfig openai = { + "zclaw", "openai", "openai-model", "https://api.openai.com/v1", "openai-key" + }; + const ProviderConfig ollama = { + "local", "ollama", "llama", "http://127.0.0.1:11434", "" + }; + std::vector providers = {custom, ollama, openai}; + ProviderConfig active = custom; + + zclaw::activate_provider_config(&providers, &active, custom); + assert(same_provider(active, custom)); + zclaw::activate_provider_config(&providers, &active, openai); + assert(same_provider(active, openai)); + assert(providers.size() == 3); + assert(same_provider(providers[0], openai)); + + active.model = "edited-openai"; + zclaw::activate_provider_config(&providers, &active, custom); + zclaw::activate_provider_config(&providers, &active, openai); + assert(active.model == "edited-openai"); + + ProviderConfig replacement = ollama; + replacement.model = "new-local-model"; + assert(zclaw::replace_provider_config(&providers, &active, 1, replacement)); + assert(active.family == "openai"); + assert(providers[1].model == "new-local-model"); + assert(!zclaw::replace_provider_config(&providers, &active, + providers.size(), replacement)); + assert(!zclaw::replace_provider_config(nullptr, &active, 0, replacement)); + + const ProviderConfig fallback = { + "zclaw", "custom", "fallback", "https://fallback.example/v1", "" + }; + assert(zclaw::erase_provider_config(&providers, &active, 1, fallback)); + assert(active.family == "openai"); + assert(zclaw::erase_provider_config(&providers, &active, 0, fallback)); + assert(same_provider(active, providers[0])); + + std::vector only_active = {active}; + assert(zclaw::erase_provider_config(&only_active, &active, 0, fallback)); + assert(only_active.empty()); + assert(same_provider(active, fallback)); + assert(!zclaw::erase_provider_config(&only_active, &active, 0, fallback)); + + zclaw::activate_provider_config(nullptr, &active, custom); + zclaw::activate_provider_config(&providers, nullptr, custom); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_provider_store_test.cpp b/projects/ZClaw/tests/zclaw_provider_store_test.cpp deleted file mode 100644 index e34a4a2c..00000000 --- a/projects/ZClaw/tests/zclaw_provider_store_test.cpp +++ /dev/null @@ -1,120 +0,0 @@ -#include "zclaw_provider_store.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace { - -bool same_provider(const ProviderConfig &left, const ProviderConfig &right) -{ - return left.alias == right.alias && left.family == right.family && - left.model == right.model && left.uri == right.uri && - left.api_key == right.api_key; -} - -} // namespace - -int main() -{ - char dir_template[] = "/tmp/zclaw-provider-store-XXXXXX"; - const char *dir = ::mkdtemp(dir_template); - assert(dir); - const std::string path = std::string(dir) + "/providers.tsv"; - - const std::vector original = { - {"zclaw", "custom", "model\\name\nnext", "https://example.com/a\tb", "key\\value\nline"}, - {"second", "ollama", "llama3.1", "http://127.0.0.1:11434", ""}, - {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", "openai-key"}, - }; - std::string error; - assert(zclaw::save_provider_configs(path, original, &error)); - assert(error.empty()); - - struct stat st {}; - assert(::stat(path.c_str(), &st) == 0); - assert((st.st_mode & 0777) == 0600); - - std::vector loaded; - assert(zclaw::load_provider_configs(path, &loaded)); - assert(loaded.size() == original.size()); - for (size_t i = 0; i < original.size(); ++i) - assert(same_provider(loaded[i], original[i])); - - ProviderConfig active = loaded[0]; - const ProviderConfig custom_default = { - "zclaw", "custom", "", "https://api.example.com/v1", "" - }; - zclaw::activate_provider_config(&loaded, &active, custom_default); - assert(active.uri == original[0].uri); - assert(loaded[0].uri == original[0].uri); - - const ProviderConfig openai_default = { - "zclaw", "openai", "default-model", "https://default.invalid/v1", "" - }; - zclaw::activate_provider_config(&loaded, &active, openai_default); - assert(active.family == "openai"); - assert(active.api_key == "openai-key"); - active.model = "edited-openai-model"; - zclaw::activate_provider_config(&loaded, &active, custom_default); - assert(active.family == "custom"); - assert(active.uri == original[0].uri); - zclaw::activate_provider_config(&loaded, &active, openai_default); - assert(active.model == "edited-openai-model"); - - ProviderConfig edited_active = active; - edited_active.api_key = "updated-key"; - assert(zclaw::replace_provider_config(&loaded, &active, 0, edited_active)); - assert(active.api_key == "updated-key"); - assert(loaded[0].api_key == "updated-key"); - const ProviderConfig fallback = { - "zclaw", "openai", "fallback", "https://api.openai.com/v1", "" - }; - assert(zclaw::erase_provider_config(&loaded, &active, 0, fallback)); - assert(!loaded.empty()); - assert(same_provider(active, loaded[0])); - - std::vector only_active = {active}; - assert(zclaw::erase_provider_config(&only_active, &active, 0, fallback)); - assert(only_active.empty()); - assert(same_provider(active, fallback)); - - const std::string ui_path = std::string(dir) + "/ui.tsv"; - assert(zclaw::atomic_write_config(ui_path, "token\tsecret\n", &error)); - struct stat ui_st {}; - assert(::stat(ui_path.c_str(), &ui_st) == 0); - assert((ui_st.st_mode & 0777) == 0600); - - const std::vector replacement = { - {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", "new-key"}, - }; - assert(zclaw::save_provider_configs(path, replacement, &error)); - assert(zclaw::load_provider_configs(path, &loaded)); - assert(loaded.size() == 1 && same_provider(loaded[0], replacement[0])); - - { - std::ofstream legacy(path, std::ios::trunc); - assert(legacy); - legacy << "legacy\tcustom\tmodel\\tname\thttps://example.com/v1\tkey\\\\value\n"; - } - assert(zclaw::load_provider_configs(path, &loaded)); - assert(loaded.size() == 1); - assert(loaded[0].alias == "legacy"); - assert(loaded[0].model == "model\tname"); - assert(loaded[0].api_key == "key\\value"); - - assert(!zclaw::save_provider_configs(std::string(dir) + "/missing/providers.tsv", - original, &error)); - assert(!error.empty()); - assert(zclaw::load_provider_configs(path, &loaded)); - assert(loaded.size() == 1 && loaded[0].alias == "legacy"); - - assert(::unlink(path.c_str()) == 0); - assert(::unlink(ui_path.c_str()) == 0); - assert(::rmdir(dir) == 0); - return 0; -} diff --git a/projects/ZClaw/tests/zclaw_quickstart_service_test.cpp b/projects/ZClaw/tests/zclaw_quickstart_service_test.cpp new file mode 100644 index 00000000..0899a9a8 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_quickstart_service_test.cpp @@ -0,0 +1,90 @@ +#include "zclaw_quickstart_backend.h" +#include "zclaw_quickstart_service.h" + +#include +#include +#include +#include + +namespace { + +class FakeQuickstartBackend : public zclaw::QuickstartBackend { +public: + zclaw::PreparedSetup prepared; + zclaw::OperationResult paired; + mutable std::vector calls; + mutable std::string received_code; + + zclaw::PreparedSetup prepare( + zclaw::UiConfig, zclaw::ProviderConfig, + const ProgressHandler &progress_handler) const override + { + calls.push_back("prepare"); + if (progress_handler) + progress_handler({"Prepared", 94}); + return prepared; + } + + zclaw::OperationResult pair(zclaw::UiConfig, + const std::string &pairing_code) const override + { + calls.push_back("pair"); + received_code = pairing_code; + return paired; + } +}; + +} // namespace + +int main() +{ + FakeQuickstartBackend backend; + backend.prepared.ok = true; + backend.prepared.pairing_code = "123456"; + backend.prepared.config.agent_alias = "prepared-agent"; + backend.paired.ok = true; + backend.paired.config = backend.prepared.config; + backend.paired.config.bearer_token = "token"; + + std::vector progress; + const zclaw::OperationResult result = zclaw::QuickstartService(backend).run( + {}, {}, [&progress](const zclaw::SetupProgress &update) { + progress.push_back(update.percent); + }); + assert(result.ok); + assert(result.config.setup_complete); + assert(result.config.bearer_token == "token"); + assert(result.text == "Quickstart complete.\nChat and approvals use WS."); + assert(backend.received_code == "123456"); + assert((backend.calls == std::vector{"prepare", "pair"})); + assert((progress == std::vector{94, 100})); + + FakeQuickstartBackend prepare_failure; + prepare_failure.prepared.error = "prepare failed"; + const zclaw::OperationResult failed_prepare = + zclaw::QuickstartService(prepare_failure).run({}, {}, {}); + assert(!failed_prepare.ok); + assert(failed_prepare.text == "prepare failed"); + assert((prepare_failure.calls == std::vector{"prepare"})); + + FakeQuickstartBackend failed_pairing; + failed_pairing.prepared.ok = true; + failed_pairing.prepared.pairing_code = "654321"; + failed_pairing.paired.ok = false; + failed_pairing.paired.config.bearer_token = "stale-token"; + failed_pairing.paired.text = "pair rejected"; + const zclaw::OperationResult rejected = + zclaw::QuickstartService(failed_pairing).run({}, {}, {}); + assert(!rejected.ok); + assert(!rejected.config.setup_complete); + assert(rejected.text == "Automatic pairing failed.\npair rejected"); + + FakeQuickstartBackend missing_token; + missing_token.prepared.ok = true; + missing_token.paired.ok = true; + const zclaw::OperationResult tokenless = + zclaw::QuickstartService(missing_token).run({}, {}, {}); + assert(!tokenless.ok); + assert(!tokenless.config.setup_complete); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_request_id_test.cpp b/projects/ZClaw/tests/zclaw_request_id_test.cpp new file mode 100644 index 00000000..37c46b14 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_request_id_test.cpp @@ -0,0 +1,43 @@ +#include "zclaw_request_id.h" + +#include +#include +#include +#include +#include +#include + +int main() +{ + zclaw::RequestIdGenerator fixed([] { return 1234567890U; }); + assert(fixed.next() == "zclaw-ui-1234567890-0"); + assert(fixed.next() == "zclaw-ui-1234567890-1"); + + constexpr int kThreadCount = 8; + constexpr int kIdsPerThread = 500; + zclaw::RequestIdGenerator concurrent([] { return 42U; }); + std::mutex ids_mutex; + std::vector ids; + ids.reserve(kThreadCount * kIdsPerThread); + std::vector threads; + threads.reserve(kThreadCount); + for (int thread = 0; thread < kThreadCount; ++thread) { + threads.emplace_back([&] { + std::vector local; + local.reserve(kIdsPerThread); + for (int index = 0; index < kIdsPerThread; ++index) + local.push_back(concurrent.next()); + std::lock_guard lock(ids_mutex); + ids.insert(ids.end(), local.begin(), local.end()); + }); + } + for (std::thread &thread : threads) + thread.join(); + + const std::set unique(ids.begin(), ids.end()); + assert(ids.size() == kThreadCount * kIdsPerThread); + assert(unique.size() == ids.size()); + for (const std::string &id : ids) + assert(id.rfind("zclaw-ui-42-", 0) == 0); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_risk_profile_model_test.cpp b/projects/ZClaw/tests/zclaw_risk_profile_model_test.cpp new file mode 100644 index 00000000..a2eea688 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_risk_profile_model_test.cpp @@ -0,0 +1,28 @@ +#include "zclaw_risk_profile_model.h" + +#include + +int main() +{ + zclaw::RiskProfileUpdate update = + zclaw::add_default_risk_profile("[gateway]\nport = 42617"); + assert(update.changed); + assert(update.contents == + "[gateway]\nport = 42617\n\n[risk_profiles.default]\n"); + + const zclaw::RiskProfileUpdate unchanged = + zclaw::add_default_risk_profile(update.contents); + assert(!unchanged.changed); + assert(unchanged.contents == update.contents); + + assert(!zclaw::add_default_risk_profile( + " [risk_profiles.default] \nlevel = \"safe\"\n") + .changed); + assert(zclaw::add_default_risk_profile("[risk_profiles.default-extra]\n") + .changed); + assert(zclaw::add_default_risk_profile("").contents == + "\n[risk_profiles.default]\n"); + assert(zclaw::add_default_risk_profile("[gateway]\n").contents == + "[gateway]\n\n[risk_profiles.default]\n"); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_runtime_model_test.cpp b/projects/ZClaw/tests/zclaw_runtime_model_test.cpp new file mode 100644 index 00000000..127cecb1 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_runtime_model_test.cpp @@ -0,0 +1,36 @@ +#include "zclaw_runtime_model.h" + +#include + +int main() +{ + zclaw::RuntimeReadiness readiness; + assert(zclaw::first_run_needed(readiness)); + + readiness.zeroclaw_directory_exists = true; + assert(zclaw::first_run_needed(readiness)); + readiness.config_file_exists = true; + assert(zclaw::first_run_needed(readiness)); + readiness.setup_complete = true; + assert(zclaw::first_run_needed(readiness)); + readiness.bearer_token_present = true; + assert(!zclaw::first_run_needed(readiness)); + + const zclaw::RuntimeReadiness missing_directory = { + false, true, true, true + }; + const zclaw::RuntimeReadiness missing_config = { + true, false, true, true + }; + const zclaw::RuntimeReadiness incomplete_setup = { + true, true, false, true + }; + const zclaw::RuntimeReadiness missing_token = { + true, true, true, false + }; + assert(zclaw::first_run_needed(missing_directory)); + assert(zclaw::first_run_needed(missing_config)); + assert(zclaw::first_run_needed(incomplete_setup)); + assert(zclaw::first_run_needed(missing_token)); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_selection_model_test.cpp b/projects/ZClaw/tests/zclaw_selection_model_test.cpp new file mode 100644 index 00000000..a9353ab4 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_selection_model_test.cpp @@ -0,0 +1,41 @@ +#include "zclaw_selection_model.h" + +#include + +int main() +{ + assert(zclaw::move_row_selection(0, 5, -1) == 0); + assert(zclaw::move_row_selection(2, 5, 1) == 3); + assert(zclaw::move_row_selection(4, 5, 1) == 4); + assert(zclaw::move_row_selection(4, 0, 1) == 0); + + zclaw::PagedSelection selection = + zclaw::move_paged_selection(4, 0, 8, 5, 1); + assert(selection.selected_index == 5); + assert(selection.scroll_offset == 1); + assert(selection.visible_row == 4); + + selection = zclaw::move_paged_selection(5, 1, 8, 5, -1); + assert(selection.selected_index == 4); + assert(selection.scroll_offset == 1); + assert(selection.visible_row == 3); + + selection = zclaw::move_paged_selection(1, 1, 8, 5, -1); + assert(selection.selected_index == 0); + assert(selection.scroll_offset == 0); + + selection = zclaw::move_paged_selection(7, 99, 8, 5, 1); + assert(selection.selected_index == 7); + assert(selection.scroll_offset == 3); + assert(selection.visible_row == 4); + + selection = zclaw::move_paged_selection(3, 2, 0, 5, 1); + assert(selection.selected_index == 0); + assert(selection.scroll_offset == 0); + + selection = zclaw::move_paged_selection(0, -3, 3, 0, 0); + assert(selection.selected_index == 0); + assert(selection.scroll_offset == 0); + assert(selection.visible_row == 0); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_settings_rules_test.cpp b/projects/ZClaw/tests/zclaw_settings_rules_test.cpp new file mode 100644 index 00000000..e0e7d706 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_settings_rules_test.cpp @@ -0,0 +1,137 @@ +#include "zclaw_provider_form_model.h" +#include "zclaw_panel_lifecycle.h" +#include "zclaw_settings_navigation_model.h" + +#include +#include + +int main() +{ + zclaw::PanelLifecycle panel; + assert(!panel.mounted() && !panel.animating()); + assert(panel.mount()); + assert(!panel.mount() && panel.mounted()); + assert(panel.begin_open() && panel.animating()); + assert(!panel.begin_close()); + assert(!panel.complete_animation()); + assert(panel.mounted() && !panel.animating()); + assert(panel.begin_close() && panel.animating()); + assert(panel.complete_animation()); + assert(!panel.mounted() && !panel.animating()); + assert(panel.mount()); + assert(panel.begin_close()); + panel.release(); + assert(!panel.mounted() && !panel.complete_animation()); + + using zclaw::SettingsActivationAction; + using zclaw::SettingsBackAction; + using zclaw::SettingsView; + + zclaw::ProviderConfig hosted = {"zclaw", "openai", "gpt", "https://api.example", ""}; + assert(zclaw::setup_edit_field_for_row(hosted, 1) == zclaw::SetupEditField::ApiKey); + assert(zclaw::setup_edit_field_for_row(hosted, 2) == zclaw::SetupEditField::None); + zclaw::SetupValidation validation = zclaw::validate_setup_provider(hosted); + assert(!validation.valid && validation.row == 1); + hosted.api_key = "key"; + assert(zclaw::validate_setup_provider(hosted).valid); + + zclaw::ProviderConfig custom = {"zclaw", "custom", "", "", ""}; + assert(zclaw::setup_edit_field_for_row(custom, 1) == zclaw::SetupEditField::Uri); + assert(zclaw::setup_edit_field_for_row(custom, 2) == zclaw::SetupEditField::ApiKey); + assert(zclaw::setup_edit_field_for_row(custom, 3) == zclaw::SetupEditField::Model); + validation = zclaw::validate_setup_provider(custom); + assert(!validation.valid && validation.row == 2); + custom.api_key = "key"; + custom.uri = "ftp://invalid"; + validation = zclaw::validate_setup_provider(custom); + assert(!validation.valid && validation.row == 1); + custom.uri = "https://example.com/v1"; + validation = zclaw::validate_setup_provider(custom); + assert(!validation.valid && validation.row == 3); + custom.model = "model"; + assert(zclaw::validate_setup_provider(custom).valid); + + const zclaw::ProviderConfig ollama = {"local", "ollama", "llama", "", ""}; + assert(zclaw::setup_edit_field_for_row(ollama, 1) == zclaw::SetupEditField::None); + assert(zclaw::validate_setup_provider(ollama).valid); + + assert(zclaw::provider_edit_field_for_row(3) == zclaw::ProviderEditField::Uri); + std::string *editable_uri = + zclaw::provider_field_value(&custom, zclaw::ProviderEditField::Uri); + assert(editable_uri); + *editable_uri = "https://changed.example/v1"; + assert(custom.uri == "https://changed.example/v1"); + assert(zclaw::provider_field_value(nullptr, zclaw::ProviderEditField::Uri) == nullptr); + assert(zclaw::provider_field_value(&custom, zclaw::ProviderEditField::None) == nullptr); + + assert(zclaw::settings_back_action(SettingsView::Setup, true, false) == + SettingsBackAction::None); + assert(zclaw::settings_back_action(SettingsView::Setup, false, true) == + SettingsBackAction::ShowSetupProviders); + assert(zclaw::settings_back_action(SettingsView::Setup, false, false) == + SettingsBackAction::Close); + assert(zclaw::settings_back_action(SettingsView::SetupProviders, false, true) == + SettingsBackAction::Quit); + assert(zclaw::settings_back_action(SettingsView::SetupProviders, false, false) == + SettingsBackAction::ShowSetup); + assert(zclaw::settings_back_action(SettingsView::Authorization, false, false) == + SettingsBackAction::Close); + assert(zclaw::settings_back_action(SettingsView::Main, false, false) == + SettingsBackAction::Close); + assert(zclaw::settings_back_action(SettingsView::ProviderDetail, false, false) == + SettingsBackAction::ShowProviders); + assert(zclaw::settings_back_action(SettingsView::Providers, false, false) == + SettingsBackAction::ShowMain); + + zclaw::SettingsActivationContext context; + context.view = SettingsView::Setup; + context.setup_initialize_row = 2; + assert(zclaw::settings_activation(context).action == + SettingsActivationAction::ShowSetupProviders); + context.selected_row = 1; + assert(zclaw::settings_activation(context).action == + SettingsActivationAction::EditSetupField); + context.selected_row = 2; + assert(zclaw::settings_activation(context).action == SettingsActivationAction::StartSetup); + context.setup_in_flight = true; + assert(zclaw::settings_activation(context).action == SettingsActivationAction::None); + + context = {}; + context.view = SettingsView::SetupProviders; + assert(zclaw::settings_activation(context).action == + SettingsActivationAction::SelectSetupProvider); + context.view = SettingsView::Authorization; + assert(zclaw::settings_activation(context).action == + SettingsActivationAction::EnterPairingCode); + context.selected_row = 4; + assert(zclaw::settings_activation(context).action == SettingsActivationAction::ClearToken); + context.selected_row = 2; + assert(zclaw::settings_activation(context).action == SettingsActivationAction::None); + + context.view = SettingsView::Main; + context.selected_row = 0; + assert(zclaw::settings_activation(context).action == SettingsActivationAction::ShowSetup); + context.selected_row = 1; + assert(zclaw::settings_activation(context).action == + SettingsActivationAction::ShowAuthorization); + context.selected_row = 2; + assert(zclaw::settings_activation(context).action == SettingsActivationAction::ShowProviders); + + context.view = SettingsView::Providers; + context.provider_count = 3; + context.provider_scroll = 0; + context.selected_row = 0; + assert(zclaw::settings_activation(context).action == SettingsActivationAction::AddProvider); + context.provider_scroll = 1; + context.selected_row = 1; + const zclaw::SettingsActivation provider_activation = zclaw::settings_activation(context); + assert(provider_activation.action == SettingsActivationAction::ShowProviderDetail); + assert(provider_activation.provider_index == 1); + context.provider_scroll = 3; + context.selected_row = 1; + assert(zclaw::settings_activation(context).action == SettingsActivationAction::None); + context.view = SettingsView::ProviderDetail; + assert(zclaw::settings_activation(context).action == + SettingsActivationAction::EditProviderField); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_setup_service_test.cpp b/projects/ZClaw/tests/zclaw_setup_service_test.cpp new file mode 100644 index 00000000..2ad971b1 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_setup_service_test.cpp @@ -0,0 +1,102 @@ +#include "zclaw_setup_backend.h" +#include "zclaw_setup_service.h" + +#include +#include +#include + +namespace { + +class FakeSetupBackend : public zclaw::SetupBackend { +public: + bool install_ok = true; + bool config_ok = true; + bool service_ok = true; + std::string pairing_code = "123456"; + mutable std::vector calls; + mutable zclaw::ProviderConfig configured_provider; + + bool ensure_installed(std::string *error, + const ProgressHandler &progress_handler) const override + { + calls.push_back("install"); + if (progress_handler) + progress_handler({"Fake install", 50}); + if (!install_ok && error) + *error = "install failed"; + return install_ok; + } + + bool apply_config(zclaw::UiConfig *config, + const zclaw::ProviderConfig &provider, + std::string *error) const override + { + calls.push_back("config"); + configured_provider = provider; + if (!config_ok) { + if (error) + *error = "config failed"; + return false; + } + config->agent_alias = "configured-agent"; + config->webhook_url = "http://configured/webhook"; + return true; + } + + bool start_service(std::string *error) const override + { + calls.push_back("start"); + if (!service_ok && error) + *error = "service failed"; + return service_ok; + } + + std::string generate_pairing_code() const override + { + calls.push_back("pair-code"); + return pairing_code; + } +}; + +} // namespace + +int main() +{ + FakeSetupBackend backend; + std::vector progress; + zclaw::UiConfig config; + zclaw::ProviderConfig provider; + const zclaw::PreparedSetup prepared = zclaw::SetupService(backend).prepare( + config, provider, [&progress](const zclaw::SetupProgress &update) { + progress.push_back(update.percent); + }); + + assert(prepared.ok); + assert(prepared.pairing_code == "123456"); + assert(prepared.config.agent_alias == "configured-agent"); + assert(prepared.config.webhook_url == "http://configured/webhook"); + assert((backend.calls == + std::vector{"install", "config", "start", "pair-code"})); + assert(backend.configured_provider.alias == "zclaw"); + assert(backend.configured_provider.family == "openai"); + assert(backend.configured_provider.model == "gpt-4.1-mini"); + assert((progress == std::vector{50, 72, 86, 94})); + + FakeSetupBackend failed_backend; + failed_backend.service_ok = false; + const zclaw::PreparedSetup failed = + zclaw::SetupService(failed_backend).prepare(config, provider, {}); + assert(!failed.ok); + assert(failed.error == "service failed"); + assert((failed_backend.calls == + std::vector{"install", "config", "start"})); + + FakeSetupBackend missing_code_backend; + missing_code_backend.pairing_code.clear(); + const zclaw::PreparedSetup missing_code = + zclaw::SetupService(missing_code_backend).prepare(config, provider, {}); + assert(!missing_code.ok); + assert(missing_code.error == + "ZeroClaw started, but pairing code generation failed."); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_storage_test.cpp b/projects/ZClaw/tests/zclaw_storage_test.cpp new file mode 100644 index 00000000..cf5485e1 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_storage_test.cpp @@ -0,0 +1,93 @@ +#include "zclaw_storage.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; + +class TemporaryDirectory { +public: + TemporaryDirectory() + { + char path[] = "/tmp/zclaw-storage-test-XXXXXX"; + const char *created = ::mkdtemp(path); + assert(created); + path_ = created; + } + + ~TemporaryDirectory() + { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + + const fs::path &path() const { return path_; } + +private: + fs::path path_; +}; + +mode_t mode(const fs::path &path) +{ + struct stat status {}; + assert(::stat(path.c_str(), &status) == 0); + return status.st_mode & 0777; +} + +} // namespace + +int main() +{ + assert(zclaw::ensure_private_parent_directory("settings.tsv")); + assert(zclaw::ensure_private_parent_directory("")); + assert(zclaw::ensure_private_parent_directory("/settings.tsv")); + + TemporaryDirectory temporary; + const fs::path nested = temporary.path() / "one" / "two" / "settings.tsv"; + std::string error; + assert(zclaw::ensure_private_parent_directory(nested.string(), &error)); + assert(fs::is_directory(temporary.path() / "one")); + assert(fs::is_directory(temporary.path() / "one" / "two")); + assert(mode(temporary.path() / "one") == 0700); + assert(mode(temporary.path() / "one" / "two") == 0700); + + const fs::path wide = temporary.path() / "wide"; + assert(fs::create_directory(wide)); + assert(::chmod(wide.c_str(), 0777) == 0); + assert(zclaw::ensure_private_parent_directory( + (wide / "settings.tsv").string(), &error)); + assert(mode(wide) == 0700); + + const fs::path symlink_target = temporary.path() / "symlink-target"; + assert(fs::create_directory(symlink_target)); + assert(::chmod(symlink_target.c_str(), 0755) == 0); + const fs::path symlink_parent = temporary.path() / "symlink-parent"; + assert(::symlink(symlink_target.c_str(), symlink_parent.c_str()) == 0); + error.clear(); + assert(!zclaw::ensure_private_parent_directory( + (symlink_parent / "settings.tsv").string(), &error)); + assert(!error.empty()); + assert(mode(symlink_target) == 0755); + + const fs::path nested_symlink = temporary.path() / "nested-symlink"; + assert(::symlink(symlink_target.c_str(), nested_symlink.c_str()) == 0); + error.clear(); + assert(!zclaw::ensure_private_parent_directory( + (nested_symlink / "new" / "settings.tsv").string(), &error)); + assert(!fs::exists(symlink_target / "new")); + assert(mode(symlink_target) == 0755); + + const fs::path blocking_file = temporary.path() / "blocked"; + std::ofstream(blocking_file) << "not a directory"; + error.clear(); + assert(!zclaw::ensure_private_parent_directory( + (blocking_file / "child" / "settings.tsv").string(), &error)); + assert(!error.empty()); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_task_batch_test.cpp b/projects/ZClaw/tests/zclaw_task_batch_test.cpp new file mode 100644 index 00000000..f5164e33 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_task_batch_test.cpp @@ -0,0 +1,35 @@ +#include "zclaw_task_batch.h" + +#include +#include +#include +#include + +int main() +{ + std::vector execution_order; + std::vector> tasks; + tasks.push_back([&execution_order] { execution_order.push_back(1); }); + tasks.push_back([&execution_order] { + execution_order.push_back(2); + throw std::runtime_error("callback failed"); + }); + tasks.push_back([&execution_order] { + execution_order.push_back(3); + throw 42; + }); + tasks.push_back([&execution_order] { execution_order.push_back(4); }); + tasks.emplace_back(); + tasks.push_back([&execution_order] { execution_order.push_back(5); }); + + const zclaw::TaskBatchResult result = + zclaw::execute_task_batch(std::move(tasks)); + assert(result.executed == 6); + assert(result.failed == 3); + assert((execution_order == std::vector{1, 2, 3, 4, 5})); + + const zclaw::TaskBatchResult empty = zclaw::execute_task_batch({}); + assert(empty.executed == 0); + assert(empty.failed == 0); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_text_file_test.cpp b/projects/ZClaw/tests/zclaw_text_file_test.cpp new file mode 100644 index 00000000..f6cffeca --- /dev/null +++ b/projects/ZClaw/tests/zclaw_text_file_test.cpp @@ -0,0 +1,68 @@ +#include "zclaw_text_file.h" + +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; + +class TemporaryDirectory { +public: + TemporaryDirectory() + { + char path[] = "/tmp/zclaw-text-file-test-XXXXXX"; + const char *created = ::mkdtemp(path); + assert(created); + path_ = created; + } + + ~TemporaryDirectory() + { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + + const fs::path &path() const { return path_; } + +private: + fs::path path_; +}; + +} // namespace + +int main() +{ + TemporaryDirectory temporary; + zclaw::TextFileReadResult result = + zclaw::read_text_file((temporary.path() / "missing").string()); + assert(result.status == zclaw::TextFileReadStatus::NotFound); + assert(result.contents.empty()); + assert(result.error.empty()); + + const fs::path empty = temporary.path() / "empty"; + std::ofstream(empty).close(); + result = zclaw::read_text_file(empty.string()); + assert(result.status == zclaw::TextFileReadStatus::Loaded); + assert(result.contents.empty()); + assert(result.error.empty()); + + const fs::path content = temporary.path() / "content"; + const char raw_contents[] = "first\nsecond\0tail"; + const std::string expected(raw_contents, sizeof(raw_contents) - 1); + std::ofstream output(content, std::ios::binary); + output.write(expected.data(), static_cast(expected.size())); + output.close(); + result = zclaw::read_text_file(content.string()); + assert(result.status == zclaw::TextFileReadStatus::Loaded); + assert(result.contents == expected); + + result = zclaw::read_text_file(temporary.path().string()); + assert(result.status == zclaw::TextFileReadStatus::Error); + assert(result.contents.empty()); + assert(!result.error.empty()); + return 0; +} diff --git a/projects/ZClaw/tests/zclaw_text_test.cpp b/projects/ZClaw/tests/zclaw_text_test.cpp new file mode 100644 index 00000000..05ebcbf1 --- /dev/null +++ b/projects/ZClaw/tests/zclaw_text_test.cpp @@ -0,0 +1,14 @@ +#include "zclaw_text.h" + +#include + +int main() +{ + assert(zclaw::trim_ascii_whitespace("").empty()); + assert(zclaw::trim_ascii_whitespace(" \t\r\n").empty()); + assert(zclaw::trim_ascii_whitespace("plain") == "plain"); + assert(zclaw::trim_ascii_whitespace(" \t value with spaces \r\n") == + "value with spaces"); + assert(zclaw::trim_ascii_whitespace("a\tb") == "a\tb"); + return 0; +}