diff --git a/.gitignore b/.gitignore index 496ee2c..00741cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.DS_Store \ No newline at end of file +.DS_Store +.idea/ \ No newline at end of file diff --git a/app/README.md b/app/README.md index e642ac5..18b61e5 100644 --- a/app/README.md +++ b/app/README.md @@ -1 +1,62 @@ -# StackChan App \ No newline at end of file +# Project Setup and Running Instructions + +## 1. Clone the repository +```bash +git clone https://github.com/m5stack/StackChan +cd StackChan/app +``` + +## 2. Open the project in Xcode +Open the project in Xcode: + +Double‑click the `.xcodeproj` file, or open Xcode → File → Open, then select the project. + +1. Select your target device or simulator. + +### Connect an iPhone (Optional but Recommended) +- Connect your iPhone to the Mac using a USB cable. +- Unlock the iPhone and tap **Trust This Computer** if prompted. +- In Xcode, select your iPhone as the run destination at the top. + +### Enable Developer Mode on iPhone (iOS 16+) +> **Important:** Developer Mode will only appear after the iPhone has been connected to Xcode at least once. +If you do not see this option, make sure your iPhone is connected to the Mac, unlocked, trusted, and recognized by Xcode. +- On the iPhone, go to **Settings → Privacy & Security → Developer Mode**. +- Turn on Developer Mode and restart the iPhone. +- After restart, confirm enabling Developer Mode. + +## 3. Configure Signing & Capabilities +This step allows Xcode to install the app on your iPhone. + +1. In Xcode, select the project in the left sidebar. +2. Select the app target. +3. Open the **Signing & Capabilities** tab. +4. Sign in with your Apple ID (Xcode → Settings → Accounts → Add Apple ID). +5. Set **Team** to your Apple ID. +6. Change **Bundle Identifier** to a unique value, for example: +`com.yourname.stackchan` +7. Ensure no red error messages remain. + +> **Note:** A free Apple ID is sufficient for testing on your own iPhone. + +## 4. Modify network configuration +Before running the app, you need to set the correct server IP: + +1. Open the file `Network/Urls.swift`. +2. Find the line defining the base URL, for example: +```swift +// Base URL configured according to the server's IP +static let url = "192.168.51.24:12800/" +``` +3. Replace the IP address (`192.168.51.24`) with the IP of the computer where the server is running. +4. Save the file. + +## 5. Run the project +Press `Cmd + R` to build and run the app. + +> **Note:** The first build may take several minutes as Xcode prepares the environment. + +If running on an iPhone for the first time, you may need to trust yourself as a developer: +- On your iPhone, go to **Settings → General → VPN & Device Management → Trust Developer** and trust the developer profile that appears. + +The app will now connect to the server at the IP you configured. diff --git a/app/StackChan.xcodeproj/project.pbxproj b/app/StackChan.xcodeproj/project.pbxproj new file mode 100644 index 0000000..309af2c --- /dev/null +++ b/app/StackChan.xcodeproj/project.pbxproj @@ -0,0 +1,404 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0E4478B92F0A538600010197 /* README.MD in Resources */ = {isa = PBXBuildFile; fileRef = 0E4478B82F0A538600010197 /* README.MD */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0E4478B82F0A538600010197 /* README.MD */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.MD; sourceTree = ""; }; + 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StackChan.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 0EBD7E222ECDC9510001A9D1 /* Exceptions for "StackChan" folder in "StackChan" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0EBD7D372ECDA27C0001A9D1 /* StackChan */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 0EBD7E222ECDC9510001A9D1 /* Exceptions for "StackChan" folder in "StackChan" target */, + ); + explicitFileTypes = { + Info.plist = text.xml; + }; + path = StackChan; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0EBD7D352ECDA27C0001A9D1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0EBD7D2F2ECDA27C0001A9D1 = { + isa = PBXGroup; + children = ( + 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */, + 0EBD7D392ECDA27C0001A9D1 /* Products */, + 0E4478B82F0A538600010197 /* README.MD */, + ); + sourceTree = ""; + }; + 0EBD7D392ECDA27C0001A9D1 /* Products */ = { + isa = PBXGroup; + children = ( + 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0EBD7D372ECDA27C0001A9D1 /* StackChan */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0EBD7D432ECDA27D0001A9D1 /* Build configuration list for PBXNativeTarget "StackChan" */; + buildPhases = ( + 0EBD7D342ECDA27C0001A9D1 /* Sources */, + 0EBD7D352ECDA27C0001A9D1 /* Frameworks */, + 0EBD7D362ECDA27C0001A9D1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */, + ); + name = StackChan; + packageProductDependencies = ( + ); + productName = StackChan; + productReference = 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0EBD7D302ECDA27C0001A9D1 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2610; + LastUpgradeCheck = 2620; + TargetAttributes = { + 0EBD7D372ECDA27C0001A9D1 = { + CreatedOnToolsVersion = 26.1.1; + }; + }; + }; + buildConfigurationList = 0EBD7D332ECDA27C0001A9D1 /* Build configuration list for PBXProject "StackChan" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0EBD7D2F2ECDA27C0001A9D1; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); + preferredProjectObjectVersion = 77; + productRefGroup = 0EBD7D392ECDA27C0001A9D1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0EBD7D372ECDA27C0001A9D1 /* StackChan */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0EBD7D362ECDA27C0001A9D1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E4478B92F0A538600010197 /* README.MD in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0EBD7D342ECDA27C0001A9D1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 0EBD7D412ECDA27D0001A9D1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = NG678HLKHZ; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 0EBD7D422ECDA27D0001A9D1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = NG678HLKHZ; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 0EBD7D442ECDA27D0001A9D1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */; + baseConfigurationReferenceRelativePath = App.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = StackChan/StackChan.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = NG678HLKHZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ""; + INFOPLIST_FILE = StackChan/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth permission is required to connect to nearby devices"; + INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required to scan the code"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is required to discover devices"; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = 1.0.3; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0EBD7D452ECDA27D0001A9D1 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */; + baseConfigurationReferenceRelativePath = App.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = StackChan/StackChan.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = NG678HLKHZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ""; + INFOPLIST_FILE = StackChan/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth permission is required to connect to nearby devices"; + INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required to scan the code"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is required to discover devices"; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = 1.0.3; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0EBD7D332ECDA27C0001A9D1 /* Build configuration list for PBXProject "StackChan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0EBD7D412ECDA27D0001A9D1 /* Debug */, + 0EBD7D422ECDA27D0001A9D1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0EBD7D432ECDA27D0001A9D1 /* Build configuration list for PBXNativeTarget "StackChan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0EBD7D442ECDA27D0001A9D1 /* Debug */, + 0EBD7D452ECDA27D0001A9D1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0EBD7D302ECDA27C0001A9D1 /* Project object */; +} diff --git a/app/StackChan.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/StackChan.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/StackChan.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate b/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..93919c4 Binary files /dev/null and b/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme b/app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme new file mode 100644 index 0000000..a21a351 --- /dev/null +++ b/app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..6c47dd4 --- /dev/null +++ b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..d0e43d9 --- /dev/null +++ b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + StackChan.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 0EBD7D372ECDA27C0001A9D1 + + primary + + + + + diff --git a/app/StackChan/3DModel/stackChanModel.scn b/app/StackChan/3DModel/stackChanModel.scn new file mode 100644 index 0000000..65dd27c Binary files /dev/null and b/app/StackChan/3DModel/stackChanModel.scn differ diff --git a/app/StackChan/AppState.swift b/app/StackChan/AppState.swift new file mode 100644 index 0000000..974990b --- /dev/null +++ b/app/StackChan/AppState.swift @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Combine +import SwiftUI +import CoreBluetooth +import ARKit +import Combine + +enum PageType: Hashable { + case minicryEmotion + case cameraPage + case dance +} + +class AppState: ObservableObject { + static let shared = AppState() + private init() {} + + static let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + + static var isRelease : Bool = true + @Published var showAlert: Bool = false + @Published var alertTitle: String = "" + var alertAction: (() -> Void)? = nil + + func presentAlert(title: String,action: (() -> Void)? = nil) { + self.alertTitle = title + self.alertAction = action + self.showAlert = true + } + + @AppStorage("deviceMac") var deviceMac: String = "" + + @Published var stackChanPath: [PageType] = [] + @Published var nearbyPath: [PageType] = [] + @Published var settingsPath: [PageType] = [] + + @Published var showBindingDevice = false + @Published var forcedDisplayBindingDevice = true + + @Published var showCjamgeNameAlert: Bool = false + + @Published var showBindingDeviceAlert: Bool = false + + @Published var newName: String = "" + @Published var deviceInfo: Device = Device() + + let detector = DistanceDetector() + @Published var showSwitchFace: Bool = false + + @Published var blufDeviceList: [BlufiDeviceInfo] = [] + + /// Whether currently pairing a device + @Published var showDeviceWifiSet = false + + // Manual shutdown time, if just manually shut down, temporarily do not configure + var manualShutdownTime: Date? = nil + + @Published var deviceIsOnline: Bool = false + + func connectBulDevice(macAddress: String) { + if BlufiUtil.shared.blueSwitch { + BlufiUtil.shared.startScan() + } else { + BlufiUtil.shared.centralManagerDidUpdateState = { state in + switch state { + case .poweredOn: + BlufiUtil.shared.startScan() + default: break + } + } + } + BlufiUtil.shared.characteristicCallback = { characteristic in + if characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) { + + if characteristic.uuid.uuidString == "E2E5E5E2-1234-5678-1234-56789ABCDEF0" { + BlufiUtil.shared.writeExpressionCharacteristic = characteristic + print("✏️ Expression writable characteristic assigned: \(characteristic.uuid)") + } + + if characteristic.uuid.uuidString == "E2E5E5E1-1234-5678-1234-56789ABCDEF0" { + BlufiUtil.shared.writeHeadCharacteristic = characteristic + print("✏️ Head writable characteristic assigned: \(characteristic.uuid)") + } + } + } + } + + func connectWebSocket() { + let webSocketUrl = Urls.getWebSocketUrl() + "?mac=" + deviceMac + "&deviceType=App&deviceId=" + AppState.deviceId + WebSocketUtil.shared.connect(urlString: webSocketUrl) + } + + func sendWebSocketMessage(_ msgType: MsgType,_ data: Data? = nil) { + var buffer = Data([msgType.rawValue]) + + let payload = data ?? Data() + + // payload length + let dataLen = UInt32(payload.count) + buffer.append(UInt8((dataLen >> 24) & 0xFF)) + buffer.append(UInt8((dataLen >> 16) & 0xFF)) + buffer.append(UInt8((dataLen >> 8) & 0xFF)) + buffer.append(UInt8(dataLen & 0xFF)) + + // data + buffer.append(payload) + + WebSocketUtil.shared.send(data: buffer) + } + + /// Parse message data + func parseMessage(message: Data) -> (MsgType?,Data?) { + guard message.count >= 5 else { + return (nil,nil) + } + + let typeByte = message[0] + guard let msgType = MsgType(rawValue: typeByte) else { + return (nil,nil) + } + + let lengthData = message[1...4] + let dataLength = lengthData.reduce(0) { (result, byte) -> UInt32 in + return (result << 8) | UInt32(byte) + } + + if message.count < 5 + Int(dataLength) { + return (nil,nil) + } + + let payload = message[5..<(5 + Int(dataLength))] + + return (msgType, Data(payload)) + } + + func updateDeviceInfo() { + let map = [ + ValueConstant.mac: deviceMac, + ValueConstant.name: deviceInfo.name, + ] + Networking.shared.put(pathUrl: Urls.deviceInfo, parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess { + print("Update successful") + } + } catch { + print("Failed to parse data") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + + /// Distance detection, callback when close + func startDistanceDetection() { + detector.startDistanceDetection( + distanceUpdate: { distance in + let distanceInCm = distance * 100 + if distanceInCm < 5 { + if self.showSwitchFace == false { + self.showSwitchFace = true + } + } + }, + belowThreshold: { + // Execute your business logic + // For example: stop machine, send notification, etc. + } + ) + } + + func stopDistanceDetection() { + detector.stopDistanceDetection() + } + + // Wrapper for ARSessionDelegate + class ARSessionDelegateWrapper: NSObject, ARSessionDelegate { + var onFrameUpdate: (ARFrame) -> Void + + init(onFrameUpdate: @escaping (ARFrame) -> Void) { + self.onFrameUpdate = onFrameUpdate + } + + func session(_ session: ARSession, didUpdate frame: ARFrame) { + onFrameUpdate(frame) + } + } + + func getDeviceInfo() { + let map = [ + ValueConstant.mac: deviceMac + ] + Networking.shared.get(pathUrl: Urls.deviceInfo,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess, let deviceInfo = response.data { + withAnimation { + self.deviceInfo = deviceInfo + self.newName = self.deviceInfo.name ?? "" + } + if deviceInfo.name == "" { + self.showCjamgeNameAlert = true + } + } + } catch { + print("Failed to parse data") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + + /// Enable Bluetooth functionality + func openBlufi() { + BlufiUtil.shared.blufDevicesMonitoring = { discovereDevices in + self.blufDeviceList = discovereDevices + + // Check manualShutdownTime, if exists and not exceeding 5 seconds, temporarily do not show popup + if let shutdownTime = self.manualShutdownTime { + let timeInterval = Date().timeIntervalSince(shutdownTime) + if timeInterval < 5 { + return + } + } + if !self.showDeviceWifiSet { + if !self.blufDeviceList.isEmpty { + self.showDeviceWifiSet = true + } + } + } + } + + // webSocket Message Monitoring + func webSocketMessageMonitoring() { + WebSocketUtil.shared.addObserver(for: "App") { (message: URLSessionWebSocketTask.Message) in + switch message { + case .data(let data): + let result = self.parseMessage(message: data) + if let msgType = result.0 { + switch msgType { + case MsgType.deviceOnline: + self.deviceIsOnline = false + case MsgType.deviceOffline: + self.deviceIsOnline = true + default: + break + } + } + case .string(let text): + print("Received a regular message: \(text)") + @unknown default: + break + } + } + } +} diff --git a/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json b/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..2f961ca --- /dev/null +++ b/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.057", + "green" : "0.689", + "red" : "0.936" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0380c9b --- /dev/null +++ b/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "app_logo.jpg", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/StackChan/Assets.xcassets/AppIcon.appiconset/app_logo.jpg b/app/StackChan/Assets.xcassets/AppIcon.appiconset/app_logo.jpg new file mode 100644 index 0000000..6a919fb Binary files /dev/null and b/app/StackChan/Assets.xcassets/AppIcon.appiconset/app_logo.jpg differ diff --git a/app/StackChan/Assets.xcassets/Contents.json b/app/StackChan/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/app/StackChan/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/StackChan/Assets.xcassets/lateral_image.imageset/7.595.png b/app/StackChan/Assets.xcassets/lateral_image.imageset/7.595.png new file mode 100644 index 0000000..e0d36e2 Binary files /dev/null and b/app/StackChan/Assets.xcassets/lateral_image.imageset/7.595.png differ diff --git a/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json b/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json new file mode 100644 index 0000000..5fab30d --- /dev/null +++ b/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "7.595.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json b/app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json new file mode 100644 index 0000000..12483b1 --- /dev/null +++ b/app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "stackChanLogo.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/StackChan/Assets.xcassets/logo_icon.imageset/stackChanLogo.jpg b/app/StackChan/Assets.xcassets/logo_icon.imageset/stackChanLogo.jpg new file mode 100644 index 0000000..6a919fb Binary files /dev/null and b/app/StackChan/Assets.xcassets/logo_icon.imageset/stackChanLogo.jpg differ diff --git a/app/StackChan/Info.plist b/app/StackChan/Info.plist new file mode 100644 index 0000000..c1c146d --- /dev/null +++ b/app/StackChan/Info.plist @@ -0,0 +1,29 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _stackchan-mpc._tcp + + com.apple.developer.networking.wifi-info + + + NSLocalNetworkUsageDescription + This app requires access to the local network to communicate with devices. + NSMicrophoneUsageDescription + We need access to the microphone to capture audio data. + ITSAppUsesNonExemptEncryption + + CFBundleDisplayName + StackChan World + + NSPhotoLibraryAddUsageDescription + Save the photo to the album + + diff --git a/app/StackChan/Model/BlufiModel.swift b/app/StackChan/Model/BlufiModel.swift new file mode 100644 index 0000000..0412dab --- /dev/null +++ b/app/StackChan/Model/BlufiModel.swift @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct BlufiModel: Codable { + + var cmd: String? = nil + var data: T? = nil + + func toJson() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + guard let jsonData = try? encoder.encode(self) else { return nil } + return String(data: jsonData, encoding: .utf8) + } + + static func fromJson(_ json: String) -> BlufiModel? { + guard let jsonData = json.data(using: .utf8) else { return nil } + let decoder = JSONDecoder() + return try? decoder.decode(BlufiModel.self, from: jsonData) + } +} + +struct BlufiWifi : Codable { + var ssid: String? + var password: String? + + func toJson() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + guard let jsonData = try? encoder.encode(self) else { return nil } + return String(data: jsonData, encoding: .utf8) + } + + static func fromJson(_ json: String) -> BlufiWifi? { + guard let jsonData = json.data(using: .utf8) else { return nil } + let decoder = JSONDecoder() + return try? decoder.decode(BlufiWifi.self, from: jsonData) + } +} + +struct BlufiNotifyState : Codable { + var type: Int? + var state: String? + + func toJson() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + guard let jsonData = try? encoder.encode(self) else { return nil } + return String(data: jsonData, encoding: .utf8) + } + + static func fromJson(_ json: String) -> BlufiNotifyState? { + guard let jsonData = json.data(using: .utf8) else { return nil } + let decoder = JSONDecoder() + return try? decoder.decode(BlufiNotifyState.self, from: jsonData) + } +} diff --git a/app/StackChan/Model/Device.swift b/app/StackChan/Model/Device.swift new file mode 100644 index 0000000..fb042ee --- /dev/null +++ b/app/StackChan/Model/Device.swift @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct Device : Codable { + var mac: String = UUID().uuidString + var name: String? = nil +} diff --git a/app/StackChan/Model/ExpressionData.swift b/app/StackChan/Model/ExpressionData.swift new file mode 100644 index 0000000..021694a --- /dev/null +++ b/app/StackChan/Model/ExpressionData.swift @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct ExpressionData : Codable { + var type: String = "bleAvatar" + var leftEye: ExpressionItem + var rightEye: ExpressionItem + var mouth: ExpressionItem +} + +struct ExpressionItem : Codable { + var x: Int = 0 + var y: Int = 0 + var rotation: Int = 0 + var weight: Int = 0 + var size: Int = 0 + + func copy() -> ExpressionItem { + ExpressionItem( + x: self.x, + y: self.y, + rotation: self.rotation, + weight: self.weight, + size: self.size + ) + } +} + +struct MotionData : Codable { + var type: String = "bleMotion" + var pitchServo: MotionDataItem + var yawServo: MotionDataItem + + func toJsonString() -> String { + let encoder = JSONEncoder() + if let jsonData = try? encoder.encode(self), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + return "{}" + } +} + +struct MotionDataItem: Codable { + var angle: Int = 0 + var speed: Int = 500 + var rotate: Int = 0 + + init() { + self.angle = 0 + self.speed = 500 + self.rotate = 0 + } + + init(angle: Int, speed: Int = 500) { + self.angle = angle + self.speed = speed + self.rotate = 0 + } + + enum CodingKeys: String, CodingKey { + case angle + case speed + case rotate + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + if angle != 0 { + try container.encode(angle, forKey: .angle) + try container.encode(speed, forKey: .speed) + } else if rotate != 0 { + try container.encode(rotate, forKey: .rotate) + try container.encode(speed, forKey: .speed) + } else { + try container.encode(angle, forKey: .angle) + try container.encode(speed, forKey: .speed) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.angle = try container.decodeIfPresent(Int.self, forKey: .angle) ?? 0 + self.speed = try container.decodeIfPresent(Int.self, forKey: .speed) ?? 500 + self.rotate = try container.decodeIfPresent(Int.self, forKey: .rotate) ?? 0 + } + + func copy() -> MotionDataItem { + MotionDataItem( + angle: self.angle, + speed: self.speed, + ) + } +} diff --git a/app/StackChan/Model/MessageModel.swift b/app/StackChan/Model/MessageModel.swift new file mode 100644 index 0000000..458b114 --- /dev/null +++ b/app/StackChan/Model/MessageModel.swift @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +enum MsgType: UInt8, Codable { + case opus = 0x01 + case jpeg = 0x02 + case controlAvatar = 0x03 + case controlMotion = 0x04 + + case onCamera = 0x05 + case offCamera = 0x06 + + case textMessage = 0x07 + case requestCall = 0x09 + case refuseCall = 0x0A + case agreeCall = 0x0B + case hangupCall = 0x0C + + case updateDeviceName = 0x0D + case getDeviceName = 0x0E + + case ping = 0x10 + case pong = 0x11 + + case onPhoneScreen = 0x12 + case offPhoneScreen = 0x13 + + case dance = 0x14 + + case getAvatarPosture = 0x15 + + case deviceOffline = 0x16 + case deviceOnline = 0x17 +} diff --git a/app/StackChan/Model/Post.swift b/app/StackChan/Model/Post.swift new file mode 100644 index 0000000..698702c --- /dev/null +++ b/app/StackChan/Model/Post.swift @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct Post : Codable{ + var id: Int + var mac: String? = nil + var name: String? = nil + var contentText: String? = nil + var contentImage: String? = nil + var createdAt: String? = nil + var postCommentList: [PostComment]? = nil +} + +struct PostComment: Codable { + var id: Int? = nil + var postId: Int? = nil + var mac: String? = nil + var name: String? = nil + var content: String? = nil + var createAt: String? = nil +} + + +struct GetPostComment: Codable { + var list: [PostComment]? = nil + var total: Int? = nil +} diff --git a/app/StackChan/Model/Response.swift b/app/StackChan/Model/Response.swift new file mode 100644 index 0000000..68b1d49 --- /dev/null +++ b/app/StackChan/Model/Response.swift @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct Response: Codable { + let code: Int? + let message: String? + let data: T? + + var isSuccess: Bool { + return code == 0 + } + + func unwrap(or defaultValue: T) -> T { + return data ?? defaultValue + } + + static func decode(from jsonData: Data) throws -> Response { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + do { + return try decoder.decode(Response.self, from: jsonData) + } catch let DecodingError.dataCorrupted(context) { + print("🔴 Data corrupted: \(context.debugDescription)") + printCodingPath(context.codingPath) + printJSON(jsonData) + throw DecodingError.dataCorrupted(context) + } catch let DecodingError.keyNotFound(key, context) { + print("🔴 Key '\(key.stringValue)' not found: \(context.debugDescription)") + printCodingPath(context.codingPath) + printJSON(jsonData) + throw DecodingError.keyNotFound(key, context) + } catch let DecodingError.typeMismatch(type, context) { + print("🔴 Type '\(type)' mismatch: \(context.debugDescription)") + printCodingPath(context.codingPath) + printJSON(jsonData) + throw DecodingError.typeMismatch(type, context) + } catch let DecodingError.valueNotFound(value, context) { + print("🔴 Value '\(value)' not found: \(context.debugDescription)") + printCodingPath(context.codingPath) + printJSON(jsonData) + throw DecodingError.valueNotFound(value, context) + } catch { + print("🔴 Other errors in the analysis: \(error)") + printJSON(jsonData) + throw error + } + } + + static func decode(from json: [String: Any]) throws -> Response { + let data = try JSONSerialization.data(withJSONObject: json, options: []) + return try decode(from: data) + } + + func debugDescription() -> String { + return "Response(code: \(code ?? 0), message: \(message ?? ""), data: \(String(describing: data)))" + } +} + +fileprivate func printCodingPath(_ codingPath: [CodingKey]) { + let path = codingPath.map { $0.stringValue }.joined(separator: ".") + print("📍 Error path: \(path)") +} + +fileprivate func printJSON(_ data: Data) { + if let obj = try? JSONSerialization.jsonObject(with: data, options: []), + let prettyData = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]), + let str = String(data: prettyData, encoding: .utf8) { + print("📄 Original JSON:\n\(str)") + } else if let str = String(data: data, encoding: .utf8) { + print("📄 Original JSON:\n\(str)") + } else { + print("⚠️ Unable to parse the original JSON") + } +} diff --git a/app/StackChan/Model/UploadFile.swift b/app/StackChan/Model/UploadFile.swift new file mode 100644 index 0000000..bc63fa5 --- /dev/null +++ b/app/StackChan/Model/UploadFile.swift @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +struct UploadFile : Codable { + var path: String? = nil +} diff --git a/app/StackChan/Network/Networking.swift b/app/StackChan/Network/Networking.swift new file mode 100644 index 0000000..b8576b0 --- /dev/null +++ b/app/StackChan/Network/Networking.swift @@ -0,0 +1,328 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +class Networking { + + static let shared = Networking() + private init() {} + + enum HTTPMethod: String { + case GET,POST,PUT,DELETE + } + + private func request( + urlString: String, + method: HTTPMethod, + parameters: Any? = nil, + headers: [String: String] = [:], + completion: @escaping (Result) -> Void + ) { + var finalURLString = urlString + var httpBody: Data? = nil + + if method == .GET { + if let params = parameters as? [String: Any], !params.isEmpty { + var components = URLComponents(string: urlString) + components?.queryItems = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") } + if let urlWithQuery = components?.url?.absoluteString { + finalURLString = urlWithQuery + } + } + } else { + if let params = parameters { + requestSetContentType: do { + requestSetBody: do { + do { + if let dict = params as? [String: Any] { + httpBody = try JSONSerialization.data(withJSONObject: dict, options: []) + } else if let array = params as? [Any] { + httpBody = try JSONSerialization.data(withJSONObject: array, options: []) + } else { + httpBody = try JSONSerialization.data(withJSONObject: params, options: []) + } + } catch { + completion(.failure(error)) + return + } + } + } + } + } + + guard let url = URL(string: finalURLString) else { + completion(.failure(NSError(domain: "Invalid URL", code: -1))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + if method != .GET, httpBody != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody + } + + setHandler(request: &request, headers: headers) + logRequest(request) + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + guard let data = data else { + completion(.failure(NSError(domain: "No data returned", code: -2))) + return + } + self.logResponse(data: data) + completion(.success(data)) + } + }.resume() + } + + func get(pathUrl: String, parameters: [String: Any] = [:], headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + request(urlString: finalUrl, method: .GET, parameters: parameters, headers: headers, completion: completion) + } + + func post(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + request(urlString: finalUrl, method: .POST, parameters: parameters, headers: headers, completion: completion) + } + + private func setHandler(request: inout URLRequest, headers: [String: String]) { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + if let token = UserDefaults.standard.string(forKey: ValueConstant.token), !token.isEmpty { + request.setValue(token, forHTTPHeaderField: ValueConstant.Authorization) + } + } + + func postFromData(pathUrl: String, + parameters: [String: Any?] = [:], + headers: [String: String] = [:], + baseUrlString: String? = nil, + suffix:String? = nil, + completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + guard let url = URL(string: finalUrl) else { + completion(.failure(NSError(domain: "Invalid URL", code: -1))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.POST.rawValue + + let boundary = "Boundary-\(UUID().uuidString)" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + setHandler(request: &request, headers: headers) + + var requestBody = Data() + + for (key, value) in parameters { + if let value = value { + if let fileData = value as? Data { + let type = mimeType(for: fileData) + let fileName = UUID().uuidString + (suffix ?? "") + requestBody.append("--\(boundary)\r\n".data(using: .utf8)!) + requestBody.append("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + requestBody.append("Content-Type: \(type)\r\n\r\n".data(using: .utf8)!) + requestBody.append(fileData) + requestBody.append("\r\n".data(using: .utf8)!) + } else if let array = value as? [Any] { + if let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []) { + let jsonString = String(data: jsonData, encoding: .utf8) + requestBody.append("--\(boundary)\r\n".data(using: .utf8)!) + requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + requestBody.append("\(jsonString ?? "[]")\r\n".data(using: .utf8)!) + } + } else if let dict = value as? [String:Any] { + if let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []) { + let jsonString = String(data: jsonData, encoding: .utf8) + requestBody.append("--\(boundary)\r\n".data(using: .utf8)!) + requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + requestBody.append("\(jsonString ?? "{}")\r\n".data(using: .utf8)!) + } + } else { + let str = "\(value)" + requestBody.append("--\(boundary)\r\n".data(using: .utf8)!) + requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + requestBody.append("\(str)\r\n".data(using: .utf8)!) + } + } + } + requestBody.append("--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = requestBody + + logRequest(request) + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + guard let data = data else { + completion(.failure(NSError(domain: "No data returned", code: -2))) + return + } + self.logResponse(data: data) + completion(.success(data)) + } + }.resume() + } + + func anyToJson(data: Any) -> String { + func convert(_ value: Any) -> Any { + if let dict = value as? [String: Any] { + var newDict: [String: Any] = [:] + for (k, v) in dict { + newDict[k] = convert(v) + } + return newDict + } else if let dict = value as? [String: Any?] { + var newDict: [String: Any] = [:] + for (k, v) in dict { + if let unwrapped = v { + newDict[k] = convert(unwrapped) + } else { + newDict[k] = NSNull() + } + } + return newDict + } else if let array = value as? [Any] { + return array.map { convert($0) } + } else if let array = value as? [Any?] { + return array.map { $0 == nil ? NSNull() : convert($0!) } + } else if value is Int || value is Double || value is Bool || value is String { + return value + } else { + return "\(value)" + } + } + + let converted = convert(data) + + if JSONSerialization.isValidJSONObject(converted) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: converted, options: []) + return String(data: jsonData, encoding: .utf8) ?? "[]" + } catch { + print("JSON Serialization error: \(error)") + return "[]" + } + } else { + if let str = converted as? String { + return "\"\(str)\"" + } else { + return "\(converted)" + } + } + } + + func put(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + request(urlString: finalUrl, method: .PUT, parameters: parameters, headers: headers, completion: completion) + } + + func delete(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + request(urlString: finalUrl, method: .DELETE, parameters: parameters, headers: headers, completion: completion) + } + + func download(pathUrl: String, + parameters: [String: Any] = [:], + headers: [String: String] = [:], + baseUrlString: String? = nil, + completion: @escaping (Result) -> Void) { + + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + let key = FileUtils.shared.hashedKey(for: finalUrl) + let cacheURL = FileUtils.shared.cacheDirectory().appendingPathComponent(key) + + if FileManager.default.fileExists(atPath: cacheURL.path) { + completion(.success(cacheURL.path)) + return + } + + var request = URLRequest(url: URL(string: finalUrl)!) + request.httpMethod = "GET" + + setHandler(request: &request, headers: headers) + + URLSession.shared.downloadTask(with: request) { tempURL, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + + guard let tempURL = tempURL else { + completion(.failure(NSError(domain: "No file downloaded", code: -3))) + return + } + + do { + let directory = cacheURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + if FileManager.default.fileExists(atPath: cacheURL.path) { + try FileManager.default.removeItem(at: cacheURL) + } + try FileManager.default.moveItem(at: tempURL, to: cacheURL) + + completion(.success(cacheURL.path)) + } catch { + completion(.failure(error)) + } + } + }.resume() + } + + private func logRequest(_ request: URLRequest) { + print("➡️ Request URL: \(request.url?.absoluteString ?? "")") + print("➡️ Method: \(request.httpMethod ?? "")") + print("➡️ Headers: \(request.allHTTPHeaderFields ?? [:])") + if let body = request.httpBody { + if let bodyString = String(data: body, encoding: .utf8) { + print("➡️ Body:") + bodyString.jsonPrint() + } else { + let sizeInMB = Double(body.count) / (1024 * 1024) + print(String(format: "➡️ Body (binary data, size: %.2f MB)", sizeInMB)) + } + } + } + + private func logResponse(data: Data) { + if let responseString = String(data: data, encoding: .utf8) { + print("⬅️ Response:") + responseString.jsonPrint() + } else { + print("⬅️ Response (binary data, length: \(data.count) bytes)") + } + } + + private func mimeType(for data: Data) -> String { + var bytes = [UInt8](repeating: 0, count: 1) + data.copyBytes(to: &bytes, count: 1) + switch bytes[0] { + case 0xFF: return "image/jpeg" + case 0x89: return "image/png" + case 0x47: return "image/gif" + case 0x25: return "application/pdf" + case 0x49, 0x4D: return "image/tiff" + default: return "application/octet-stream" + } + } +} diff --git a/app/StackChan/Network/Urls.swift b/app/StackChan/Network/Urls.swift new file mode 100644 index 0000000..c15ff0a --- /dev/null +++ b/app/StackChan/Network/Urls.swift @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +struct Urls { + + // Base URL configured according to the server's IP + static let url = "192.168.51.43:12800/" + + static func getBaseUrl() -> String { + return "http://" + url + "stackChan/" + } + + static func getFileUrl() -> String { + return "http://" + url + } + + static func getWebSocketUrl() -> String { + return "ws://" + url + "stackChan/ws" + } + + static let registerMac = "api/v2/device/registerMac" + + static let dance = "dance" + + static let deviceRandomList = "device/randomList" + + static let uploadFile = "uploadFile" + + static let postAdd = "post/add" + + static let postGet = "post/get" + + static let postDelete = "post/delete" + + static let deviceInfo = "device/info" + + static let postCommentCreate = "post/comment/create" + + static let postCommentDelete = "post/comment/delete" + + static let postCommentGet = "post/comment/get" +} diff --git a/app/StackChan/Network/WebSocketUtil.swift b/app/StackChan/Network/WebSocketUtil.swift new file mode 100644 index 0000000..3569fed --- /dev/null +++ b/app/StackChan/Network/WebSocketUtil.swift @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +class WebSocketUtil { + static let shared = WebSocketUtil() + private init() {} + + private var webSocketTask: URLSessionWebSocketTask? + + private var observers = [String: (URLSessionWebSocketTask.Message) -> Void]() + + private var reconnectDelay: TimeInterval = 3.0 + private var reconnectingNow: Bool = true + private var urlString: String = "" + + func connect(urlString: String) { + if let task = webSocketTask { + switch task.state { + case .running, .suspended : + disconnect() + default: + break + } + } + + self.urlString = urlString + + print("Start connecting to WebSocket:" + urlString) + + guard let url = URL(string: urlString) else { + print("Invalid URL") + return + } + + let request = URLRequest(url: url) + let session = URLSession(configuration: .default) + + webSocketTask = session.webSocketTask(with: request) + + webSocketTask?.resume() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + if self.webSocketTask?.state == .running { + print("webSocket Connection successful") + self.reconnectingNow = false + self.webSocketTask?.receive { [weak self] result in + self?.handleReceive(result: result) + } + } else { + print("webSocket Connection failed. Reconnecting is being prepared.") + self.reconnectingNow = true + DispatchQueue.main.asyncAfter(deadline: .now() + self.reconnectDelay) { + self.connect(urlString: urlString) + } + } + } + } + + private func handleReceive(result: Result) { + switch result { + case .success(let message): + let isPing = replyPong(message: message) + if !isPing { + self.notifyObservers(message: message) + } + self.webSocketTask?.receive { [weak self] next in + self?.handleReceive(result: next) + } + + case .failure(let error): + print("❌ Failed to receive the message: \(error.localizedDescription)") + print("⚠️ WebSocket Connection lost. Attempting to reconnect.…") + if !self.reconnectingNow && !self.urlString.isEmpty { + self.connect(urlString: self.urlString) + } + } + } + + func replyPong(message: URLSessionWebSocketTask.Message) -> Bool { + switch message { + case .data(let data): + let result = AppState.shared.parseMessage(message: data) + if let msgType = result.0, let _ = result.1 { + switch msgType { + case MsgType.ping: + AppState.shared.sendWebSocketMessage(.pong) + return true + default: + return false + } + } + case .string(_): + return false + @unknown default: + return false + } + return false + } + + + func send(message: String) { + let wsMessage = URLSessionWebSocketTask.Message.string(message) + print("This is the message being sent.: \(message)") + webSocketTask?.send(wsMessage) { error in + if let error = error { + print("❌ Message sending failed: \(error.localizedDescription)") + if !self.reconnectingNow && !self.urlString.isEmpty { + self.connect(urlString: self.urlString) + } + } + } + } + + func send(data: Data) { + let wsMessage = URLSessionWebSocketTask.Message.data(data) + webSocketTask?.send(wsMessage) { error in + if let error = error { + print("❌ Failed to send binary message: \(error.localizedDescription)") + if !self.reconnectingNow && !self.urlString.isEmpty { + self.connect(urlString: self.urlString) + } + } + } + } + + func disconnect() { + webSocketTask?.cancel(with: .goingAway, reason: nil) + self.reconnectingNow = false + print("webSocket Disconnected") + } + + func addObserver(for key: String, observer: @escaping (URLSessionWebSocketTask.Message) -> Void) { + observers[key] = observer + } + + func removeObserver(for key: String) { + observers.removeValue(forKey: key) + } + + func removeAllObservers() { + observers.removeAll() + } + + func notifyObservers(message: URLSessionWebSocketTask.Message) { + for observer in observers.values { + observer(message) + } + } +} diff --git a/app/StackChan/StackChan.entitlements b/app/StackChan/StackChan.entitlements new file mode 100644 index 0000000..ba21fbd --- /dev/null +++ b/app/StackChan/StackChan.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.networking.wifi-info + + + diff --git a/app/StackChan/StackChanApp.swift b/app/StackChan/StackChanApp.swift new file mode 100644 index 0000000..afb0f46 --- /dev/null +++ b/app/StackChan/StackChanApp.swift @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +@main +struct StackChanApp: App { + + @StateObject private var appState = AppState.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + } + } +} diff --git a/app/StackChan/Utils/AudioAcquisitionUtil.swift b/app/StackChan/Utils/AudioAcquisitionUtil.swift new file mode 100644 index 0000000..e7d0d4c --- /dev/null +++ b/app/StackChan/Utils/AudioAcquisitionUtil.swift @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import AVFoundation +import Accelerate + +// Audio acquisition utility class, singleton pattern +class AudioAcquisitionUtil { + + static let shared = AudioAcquisitionUtil() + private init() {} + + private let engine = AVAudioEngine() + private let bus = 0 + var onAudioData: ((Data) -> Void)? + var onDecibel: ((Float) -> Void)? + + func start() { + let inputNode = engine.inputNode + let hwFormat = inputNode.inputFormat(forBus: bus) + inputNode.installTap(onBus: bus, bufferSize: 1024, format: hwFormat) { buffer, time in + let audioBuffer = buffer.audioBufferList.pointee.mBuffers + let data = Data(bytes: audioBuffer.mData!, count: Int(audioBuffer.mDataByteSize)) + + // Callback with raw audio data + DispatchQueue.main.async { + self.onAudioData?(data) + } + + // Calculate decibels and normalize + if let floatChannelData = buffer.floatChannelData { + let channelData = floatChannelData[0] + let frameLength = vDSP_Length(buffer.frameLength) + + var rms: Float = 0 + vDSP_rmsqv(channelData, 1, &rms, frameLength) // Calculate RMS + + // Convert RMS to a 0-1 range, normal environment ~0 + // Here we assume the maximum RMS value could be around 0.1; adjust based on actual environment + let normalizedDb = min(max(rms / 0.1, 0), 1) + + DispatchQueue.main.async { + self.onDecibel?(normalizedDb) + } + } + } + + do { + try engine.start() + } catch { + print("Audio engine start error: \(error)") + } + } + + func stop() { + engine.inputNode.removeTap(onBus: bus) + engine.stop() + } + + deinit { + stop() + } +} diff --git a/app/StackChan/Utils/BlufiUtil.swift b/app/StackChan/Utils/BlufiUtil.swift new file mode 100644 index 0000000..a51fd56 --- /dev/null +++ b/app/StackChan/Utils/BlufiUtil.swift @@ -0,0 +1,431 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import ObjectiveC +import CoreBluetooth + +class BlufiUtil: NSObject,CBCentralManagerDelegate,CBPeripheralDelegate { + + static let shared = BlufiUtil() + + private var centralManager: CBCentralManager! + var discovereDevices: [BlufiDeviceInfo] = [] + + var blufDevicesMonitoring: (([BlufiDeviceInfo]) -> Void)? + var centralManagerDidUpdateState: ((CBManagerState) -> Void)? + var characteristicCallback: ((CBCharacteristic) -> Void)? + + var connectionStateChanged: ((CBPeripheral, Bool) -> Void)? + + var blueSwitch: Bool = false + private let autoReconnect = true + var currentPeripheral: CBPeripheral? = nil + var writeCharacteristic: CBCharacteristic? = nil + var automaticScanning: Bool = true + + var writeExpressionCharacteristic: CBCharacteristic? = nil + var writeHeadCharacteristic: CBCharacteristic? = nil + var writeWifiSetCharacteristic: CBCharacteristic? = nil + + var wifiSetCharacteristicCall: ((Data) -> Void)? = nil + + /// Service UUID + private let targetServiceUUIDs: [CBUUID] = [CBUUID(string: "e2e5e5e0-1234-5678-1234-56789abcdef0")] + + private let scanOptions: [String: Any] = [ + CBCentralManagerScanOptionAllowDuplicatesKey: true + ] + /// Timer to clean up devices that are not discovered + private var cleanupTimer: Timer? + private let deviceTimeout: TimeInterval = 3 + + private override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: .main) + } + + // Print logs according to Bluetooth state + func centralManagerDidUpdateState(_ central: CBCentralManager) { + centralManagerDidUpdateState?(central.state) + switch central.state { + case .unknown: + print("Bluetooth state unknown") + case .resetting: + print("Bluetooth is resetting") + case .unsupported: + print("This device does not support Bluetooth") + case .unauthorized: + print("No permission to use Bluetooth, please check settings") + case .poweredOff: + print("Bluetooth is off, please turn on Bluetooth") + blueSwitch = false + case .poweredOn: + print("Bluetooth is on, ready to start scanning devices") + blueSwitch = true + if automaticScanning { + startScan() + } + if autoReconnect { + reconnect() + } + @unknown default: + print("Encountered unknown Bluetooth state") + } + } + + // Start scanning BLE devices + func startScan() { + guard centralManager.state == .poweredOn else { + print("Bluetooth is not ready when scanning") + return + } + discovereDevices.removeAll() + print("Started scanning nearby BLE devices") + centralManager.scanForPeripherals(withServices: targetServiceUUIDs, options: scanOptions) + startCleanupTimer() + } + + // Periodically clean up non-existing devices + private func startCleanupTimer() { + cleanupTimer?.invalidate() + cleanupTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true, block: { timer in + let now = Date() + let originalCount = self.discovereDevices.count + + self.discovereDevices.removeAll { + now.timeIntervalSince($0.lastSeen) > self.deviceTimeout + } + + // If indeed some equipment has been removed, then notify the external party. + if self.discovereDevices.count != originalCount { + self.blufDevicesMonitoring?(self.discovereDevices) + } + }) + } + + func stopScan() { + print("Stopped scanning BLE devices") + centralManager.stopScan() + } + + func connect(peripheral: CBPeripheral) { + print("Started connecting to the specified BLE device") + centralManager.connect(peripheral) + } + + /// Actively disconnect the current peripheral + func disconnectCurrentPeripheral() { + guard let peripheral = currentPeripheral else { + print("⚠️ No connected peripheral to disconnect") + return + } + centralManager.cancelPeripheralConnection(peripheral) + print("⬅️ Disconnecting peripheral: \(peripheral.name ?? "Unknown device")") + writeWifiSetCharacteristic = nil + writeHeadCharacteristic = nil + writeExpressionCharacteristic = nil + } + + + /// Send head data + func sendHeadData(_ data: String) { + guard let currentPeripheral = currentPeripheral else { + print("⚠️ No connected peripheral") + return + } + + guard let writeHeadCharacteristic = self.writeHeadCharacteristic else { + print("⚠️ No writable characteristic on current peripheral") + return + } + + guard let dataToSend = data.data(using: .utf8) else { + print("⚠️ Failed to convert string to Data") + return + } + + currentPeripheral.writeValue(dataToSend, for: writeHeadCharacteristic, type: .withResponse) + print("➡️ Head data sent: \(data)") + } + + /// Send Wi-Fi configuration data + func sendWifiSetData(_ data: String) { + guard let currentPeripheral = currentPeripheral else { + print("⚠️ No connected peripheral") + return + } + guard let writeWifiSetCharacteristic = self.writeWifiSetCharacteristic else { + print("⚠️ No writable characteristic on current peripheral") + return + } + guard let dataToSend = data.data(using: .utf8) else { + print("⚠️ Failed to convert string to Data") + return + } + currentPeripheral.writeValue(dataToSend, for: writeWifiSetCharacteristic, type: .withResponse) + print("➡️ Head data sent: \(data)") + } + + /// Send expression data + func sendExpressionData(_ data: String) { + guard let currentPeripheral = currentPeripheral else { + print("⚠️ No connected peripheral") + return + } + + guard let writeExpressionCharacteristic = self.writeExpressionCharacteristic else { + print("⚠️ No writable characteristic on current peripheral") + return + } + + guard let dataToSend = data.data(using: .utf8) else { + print("⚠️ Failed to convert string to Data") + return + } + + currentPeripheral.writeValue(dataToSend, for: writeExpressionCharacteristic, type: .withResponse) + print("➡️ Expression data sent: \(data)") + } + + func sendData(_ data: String) { + guard let currentPeripheral = currentPeripheral else { + print("⚠️ No connected peripheral") + return + } + + guard let writeCharacteristic = self.writeCharacteristic else { + print("⚠️ No writable characteristic on current peripheral") + return + } + + guard let dataToSend = data.data(using: .utf8) else { + print("⚠️ Failed to convert string to Data") + return + } + + currentPeripheral.writeValue(dataToSend, for: writeCharacteristic, type: .withResponse) + print("➡️ Data sent: \(data)") + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + // Called when successfully connected to a peripheral + print("✅ Successfully connected to device: \(peripheral.name ?? "Unknown device")") + self.currentPeripheral = peripheral + peripheral.delegate = self + peripheral.discoverServices(nil) + connectionStateChanged?(peripheral,true) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: (any Error)?) { + // Called when connecting to a peripheral fails + if let error = error { + print("❌ Failed to connect to device: \(peripheral.name ?? "Unknown device"), error: \(error.localizedDescription)") + } else { + print("❌ Failed to connect to device: \(peripheral.name ?? "Unknown device"), unknown error") + } + + connectionStateChanged?(peripheral,false) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: (any Error)?) { + // Called when a peripheral disconnects + if let error = error { + print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), error: \(error.localizedDescription)") + } else { + print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), no error") + } + currentPeripheral = nil + + connectionStateChanged?(peripheral,false) + + if autoReconnect { + reconnect() + } + } + + func centralManager(_ central: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral) { + // Called when ANCS (Apple Notification Center Service) authorization status changes + print("ℹ️ ANCS authorization status updated, device: \(peripheral.name ?? "Unknown device")") + } + + func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) { + // Called when a connection event occurs (e.g., peripheral connected or disconnected) + print("🔔 Connection event occurred: \(event.rawValue), device: \(peripheral.name ?? "Unknown device")") + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + // New device added to the list + let deviceInfo = BlufiDeviceInfo(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI, lastSeen: Date()) + + if !discovereDevices.contains(where: { $0.peripheral.identifier.uuidString == peripheral.identifier.uuidString }) { + discovereDevices.append(deviceInfo) + } else { + if let index = discovereDevices.firstIndex(where: { $0.peripheral.identifier.uuidString == peripheral.identifier.uuidString }) { + discovereDevices[index] = deviceInfo + } + } + blufDevicesMonitoring?(discovereDevices) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: (any Error)?) { + if let error = error { + print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), timestamp: \(timestamp), reconnecting: \(isReconnecting), error: \(error.localizedDescription)") + } else { + print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), timestamp: \(timestamp), reconnecting: \(isReconnecting), no error") + } + } + + func peripheralDidUpdateName(_ peripheral: CBPeripheral) { + // Called when peripheral name updates + print("ℹ️ Peripheral name updated: \(peripheral.name ?? "Unknown device")") + } + + func peripheralDidUpdateRSSI(_ peripheral: CBPeripheral, error: (any Error)?) { + // Called when peripheral RSSI (signal strength) updates + if let error = error { + print("❌ Failed to update peripheral RSSI: \(error.localizedDescription)") + } else { + print("ℹ️ Peripheral RSSI updated") + } + } + + func peripheral(_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: (any Error)?) { + // Called when L2CAP channel opens + if let error = error { + print("❌ Failed to open L2CAP channel: \(error.localizedDescription)") + } else { + print("✅ L2CAP channel opened: \(channel?.psm ?? 0)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + // Called when peripheral services are modified + print("⚠️ Peripheral services modified, number of invalidated services: \(invalidatedServices.count)") + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) { + // Called after discovering services + if let error = error { + print("❌ Failed to discover services: \(error.localizedDescription)") + return + } + print("✅ Discovered peripheral services, service count: \(peripheral.services?.count ?? 0)") + peripheral.services?.forEach { service in + print("📦 Service UUID: \(service.uuid)") + peripheral.discoverCharacteristics(nil, for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: (any Error)?) { + // Called when RSSI value is read + if let error = error { + print("❌ Failed to read RSSI: \(error.localizedDescription)") + } else { + print("ℹ️ Read RSSI: \(RSSI)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: (any Error)?) { + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: (any Error)?) { + // Called after writing a descriptor value + if let error = error { + print("❌ Failed to write descriptor value: \(descriptor.uuid), error: \(error.localizedDescription)") + } else { + print("✅ Successfully wrote descriptor value: \(descriptor.uuid)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: (any Error)?) { + // Called when characteristic value updates (message received) + if let error = error { + print("❌ Failed to update characteristic value: \(characteristic.uuid), error: \(error.localizedDescription)") + } else { + print("Bluetooth message received") + if characteristic.uuid.uuidString == writeWifiSetCharacteristic?.uuid.uuidString, let data = characteristic.value { + /// Callback for Wi-Fi configuration message + self.wifiSetCharacteristicCall?(data) + } + // print("ℹ️ Characteristic value updated: \(characteristic.uuid), value: \(characteristic.value?.hexEncodedString() ?? "nil")") + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: (any Error)?) { + // Called when descriptor value updates + if let error = error { + print("❌ Failed to update descriptor value: \(descriptor.uuid), error: \(error.localizedDescription)") + } else { + print("ℹ️ Descriptor value updated: \(descriptor.uuid), value: \(descriptor.value ?? "nil")") + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: (any Error)?) { + // Called when descriptors of a characteristic are discovered + if let error = error { + print("❌ Failed to discover descriptors: \(characteristic.uuid), error: \(error.localizedDescription)") + } else { + print("✅ Discovered characteristic descriptors: \(characteristic.uuid), descriptor count: \(characteristic.descriptors?.count ?? 0)") + } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + // Called when peripheral is ready to send writes without response + print("ℹ️ Peripheral is ready to send write without response: \(peripheral.name ?? "Unknown device")") + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: (any Error)?) { + // Called when characteristics of a service are discovered + if let error = error { + print("❌ Failed to discover characteristics: \(service.uuid), error: \(error.localizedDescription)") + return + } + print("✅ Discovered characteristics count: \(service.characteristics?.count ?? 0) for service: \(service.uuid)") + service.characteristics?.forEach { characteristic in + print("🔹 Characteristic UUID: \(characteristic.uuid), properties: \(characteristic.properties)") + characteristicCallback?(characteristic) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: (any Error)?) { + // Called when characteristic notification state updates + if let error = error { + print("❌ Failed to update characteristic notification state: \(characteristic.uuid), error: \(error.localizedDescription)") + } else { + print("ℹ️ Characteristic notification state updated: \(characteristic.uuid), isNotifying: \(characteristic.isNotifying)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: (any Error)?) { + // Called when included services are discovered + if let error = error { + print("❌ Failed to discover included services: \(service.uuid), error: \(error.localizedDescription)") + } else { + print("✅ Discovered included services count: \(service.includedServices?.count ?? 0) for service: \(service.uuid)") + } + } + + // Method to reconnect + func reconnect() { + + } + +} + + +struct BlufiDeviceInfo { + let peripheral: CBPeripheral + let advertisementData: [String : Any] + let rssi: NSNumber + var lastSeen: Date +} + + +extension Data { + func hexEncodedString() -> String { + return map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/app/StackChan/Utils/DazzlingBackground.swift b/app/StackChan/Utils/DazzlingBackground.swift new file mode 100644 index 0000000..3a8151d --- /dev/null +++ b/app/StackChan/Utils/DazzlingBackground.swift @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct DazzlingBackground : View { + + let backColors: [Color] + let background: Color + let dotCount: Int + let speed: CGFloat + + @State private var dots: [Dot] = [] + + init(backColors: [Color], background: Color, dotCount: Int = 5, speed: CGFloat = 2.2) { + self.backColors = backColors + self.background = background + self.dotCount = dotCount + self.speed = speed + } + + var body: some View { + GeometryReader { proxy in + ZStack { + LinearGradient( + colors: backColors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + TimelineView(.animation) { timeline in + Canvas { context, size in + for dot in dots { + let circle = Path(ellipseIn: CGRect(x: dot.position.x, y: dot.position.y, width: dot.dotSize, height: dot.dotSize)) + context.fill(circle, with: .color(.purple.opacity(0.4))) + } + } + .blur(radius: 50) + .drawingGroup() + .onAppear { + DispatchQueue.main.async { + if dots.isEmpty, proxy.size.width > 0, proxy.size.height > 0 { + startDots(size: proxy.size) + } + } + } + .onChange(of: timeline.date) { _ in + DispatchQueue.main.async { + updateDots(size: proxy.size) + } + } + } + } + .background(background) + } + + } + + private func startDots(size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + for _ in 0.. 0, size.height > 0 else { return } + for i in dots.indices { + var dot = dots[i] + let dx = dot.target.x - dot.position.x + let dy = dot.target.y - dot.position.y + let distance = sqrt(dx*dx + dy*dy) + if distance < speed { + dot.target = CGPoint(x: CGFloat.random(in: 0.. Void)? + private var thresholdCallback: (() -> Void)? + private let thresholdDistance: Float = 0.05 // 5cm in meters + private var timer: Timer? + + func startDistanceDetection( + distanceUpdate: ((Float) -> Void)? = nil, + belowThreshold: (() -> Void)? = nil + ) { + guard ARWorldTrackingConfiguration.isSupported else { + return + } + if #available(iOS 13.0, *) { + guard ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) else { + return + } + } else { + return + } + + checkCameraPermission { [weak self] granted in + guard granted else { + return + } + + self?.setupARSession() + self?.setupCallbacks(distanceUpdate: distanceUpdate, belowThreshold: belowThreshold) + self?.startDetectionTimer() + } + } + + /// 停止距离检测 + func stopDistanceDetection() { + isDetectionActive = false + timer?.invalidate() + timer = nil + arSession?.pause() + arSession = nil + } + + // MARK: - Private Methods + + private func checkCameraPermission(completion: @escaping (Bool) -> Void) { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + switch status { + case .authorized: + completion(true) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + completion(granted) + } + } + default: + completion(false) + } + } + + private func setupARSession() { + arSession = ARSession() + let configuration = ARWorldTrackingConfiguration() + + if #available(iOS 13.0, *) { + configuration.frameSemantics.insert(.sceneDepth) + } + + arSession?.run(configuration) + isDetectionActive = true + } + + private func setupCallbacks( + distanceUpdate: ((Float) -> Void)?, + belowThreshold: (() -> Void)? + ) { + self.distanceCallback = distanceUpdate + self.thresholdCallback = belowThreshold + } + + private func startDetectionTimer() { + timer?.invalidate() + + timer = Timer.scheduledTimer( + timeInterval: 0.1, + target: self, + selector: #selector(performDistanceCheck), + userInfo: nil, + repeats: true + ) + } + + @objc private func performDistanceCheck() { + guard isDetectionActive, + let frame = arSession?.currentFrame else { + return + } + + let distance = getCurrentDistance(from: frame) + + if let distance = distance { + distanceCallback?(distance) + + if distance < thresholdDistance { + handleBelowThreshold() + } + } + } + + private func getCurrentDistance(from frame: ARFrame) -> Float? { + if #available(iOS 13.0, *) { + return getDistanceUsingSceneDepth(from: frame) + } else { + return getDistanceUsingHitTest(from: frame) + } + } + + @available(iOS 13.0, *) + private func getDistanceUsingSceneDepth(from frame: ARFrame) -> Float? { + guard let depthData = frame.sceneDepth else { + return nil + } + + let depthPixelBuffer = depthData.depthMap + let width = CVPixelBufferGetWidth(depthPixelBuffer) + let height = CVPixelBufferGetHeight(depthPixelBuffer) + + let centerX = width / 2 + let centerY = height / 2 + + CVPixelBufferLockBaseAddress(depthPixelBuffer, .readOnly) + + guard let baseAddress = CVPixelBufferGetBaseAddress(depthPixelBuffer) else { + CVPixelBufferUnlockBaseAddress(depthPixelBuffer, .readOnly) + return nil + } + + let floatBuffer = baseAddress.assumingMemoryBound(to: Float32.self) + + var totalDistance: Float = 0 + var validSamples = 0 + + let sampleRadius = 5 + for x in max(0, centerX - sampleRadius)...min(width - 1, centerX + sampleRadius) { + for y in max(0, centerY - sampleRadius)...min(height - 1, centerY + sampleRadius) { + let distance = floatBuffer[y * width + x] + if distance.isFinite && distance > 0 { + totalDistance += distance + validSamples += 1 + } + } + } + + CVPixelBufferUnlockBaseAddress(depthPixelBuffer, .readOnly) + + guard validSamples > 0 else { + return nil + } + + return totalDistance / Float(validSamples) + } + + private func getDistanceUsingHitTest(from frame: ARFrame) -> Float? { + return nil + } + + private func handleBelowThreshold() { + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(executeThresholdCallback), object: nil) + } + + @objc private func executeThresholdCallback() { + let generator = UIImpactFeedbackGenerator(style: .heavy) + generator.impactOccurred() + AudioServicesPlaySystemSound(1013) + thresholdCallback?() + } + + deinit { + stopDistanceDetection() + } +} + + +func exampleBasicUsage() { + let detector = DistanceDetector() + + detector.startDistanceDetection( + distanceUpdate: { distance in + let distanceInCm = distance * 100 + print(String(distanceInCm)) + }, + belowThreshold: { + } + ) +} + +class ProximityMonitor { + private let detector = DistanceDetector() + private var isMonitoring = false + + func startMonitoring() { + detector.startDistanceDetection( + distanceUpdate: { [weak self] distance in + self?.handleDistanceUpdate(distance) + }, + belowThreshold: { [weak self] in + self?.handleProximityAlert() + } + ) + isMonitoring = true + } + + func stopMonitoring() { + detector.stopDistanceDetection() + isMonitoring = false + } + + private func handleDistanceUpdate(_ distance: Float) { + let distanceInCm = distance * 100 + if distanceInCm < 10 { + } else if distanceInCm < 30 { + } + } + + private func handleProximityAlert() { + NotificationCenter.default.post( + name: NSNotification.Name("ProximityAlert"), + object: nil, + userInfo: ["alert": "object_too_close"] + ) + } +} diff --git a/app/StackChan/Utils/Extension.swift b/app/StackChan/Utils/Extension.swift new file mode 100644 index 0000000..7274859 --- /dev/null +++ b/app/StackChan/Utils/Extension.swift @@ -0,0 +1,305 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import simd + +extension UIApplication { + func endEditing() { + sendAction(#selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil) + } +} + +extension Color { + init?(hex: String) { + var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if hexString.hasPrefix("#") { + hexString.removeFirst() + } + guard hexString.count == 6 || hexString.count == 8 else { return nil } + + var rgbValue: UInt64 = 0 + Scanner(string: hexString).scanHexInt64(&rgbValue) + + let r, g, b, a: Double + if hexString.count == 6 { + r = Double((rgbValue & 0xFF0000) >> 16) / 255 + g = Double((rgbValue & 0x00FF00) >> 8) / 255 + b = Double(rgbValue & 0x0000FF) / 255 + a = 1.0 + } else { + a = Double((rgbValue & 0xFF000000) >> 24) / 255 + r = Double((rgbValue & 0x00FF0000) >> 16) / 255 + g = Double((rgbValue & 0x0000FF00) >> 8) / 255 + b = Double(rgbValue & 0x000000FF) / 255 + } + self.init(red: r, green: g, blue: b, opacity: a) + } + + func toHex() -> String? { + let uiColor = UIColor(self) + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + guard uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil } + return String(format: "#%02X%02X%02X", + Int(r * 255), + Int(g * 255), + Int(b * 255)) + } +} + +extension UIImage { + func scaledToFill(_ targetSize: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: targetSize) + return renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: targetSize)) + } + } + /// Optional image compression method + /// - Parameters: + /// - resolutionSize: Target resolution (optional). If nil, no cropping or scaling is applied + /// - memorySize: Target memory size in MB (optional). If nil, no memory compression is applied + /// - cropCenter: Whether to crop from center. Default false = aspect-fit scaling + func compress(to resolutionSize: CGSize? = nil, memorySize: Float? = nil, cropCenter: Bool = false) -> Data? { + var scaledImage = self + + // 1. Crop or scale based on target resolution + if let resolutionSize = resolutionSize { + if cropCenter { + // 1. Calculate scale to ensure the image fully covers the target resolution + let scale = max(resolutionSize.width / size.width, resolutionSize.height / size.height) + let scaledSize = CGSize(width: size.width * scale, height: size.height * scale) + + // 2. Calculate offset to ensure center cropping + let originX = (scaledSize.width - resolutionSize.width) / 2 + let originY = (scaledSize.height - resolutionSize.height) / 2 + + // 3. Start drawing + UIGraphicsBeginImageContextWithOptions(resolutionSize, false, 1.0) + self.draw(in: CGRect(x: -originX, y: -originY, width: scaledSize.width, height: scaledSize.height)) + scaledImage = UIGraphicsGetImageFromCurrentImageContext() ?? self + UIGraphicsEndImageContext() + } else { + // Aspect-fit scaling + let scale = min(resolutionSize.width / size.width, resolutionSize.height / size.height) + let newSize = CGSize(width: size.width * scale, height: size.height * scale) + UIGraphicsBeginImageContextWithOptions(resolutionSize, false, 1.0) + self.draw(in: CGRect(origin: .zero, size: newSize)) + scaledImage = UIGraphicsGetImageFromCurrentImageContext() ?? self + UIGraphicsEndImageContext() + } + } + + // 2. Compress based on target memory size + guard let memorySize = memorySize else { + return scaledImage.jpegData(compressionQuality: 1) + } + + let maxBytes = Int(memorySize * 1024 * 1024) // MB -> Bytes + var compression: CGFloat = 1.0 + var imageData = scaledImage.jpegData(compressionQuality: compression) + + // Keep compressing until size requirement is met or compression limit is reached + while let data = imageData, data.count > maxBytes, compression > 0.01 { + compression *= 0.7 + imageData = scaledImage.jpegData(compressionQuality: compression) + } + + return imageData + } + /// Compress image only based on target memory size, keeping resolution and aspect ratio unchanged + /// - Parameter memorySize: Target memory size in MB + /// - Returns: Compressed JPEG data + func compress(toMemorySize memorySize: Float) -> Data? { + let maxBytes = Int(memorySize * 1024 * 1024) + var compression: CGFloat = 1.0 + var imageData = self.jpegData(compressionQuality: compression) + + while let data = imageData, data.count > maxBytes, compression > 0.01 { + compression *= 0.7 + imageData = self.jpegData(compressionQuality: compression) + } + return imageData + } +} + +extension String { + func jsonPrint() { + guard let data = self.data(using: .utf8) else { + print(self) + return + } + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + let prettyData = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys]) + if let prettyString = String(data: prettyData, encoding: .utf8) { + print("✅ JSON formatted output:\n\(prettyString)") + } else { + print(self) + } + } catch { + print("❌ Invalid JSON format: \(error.localizedDescription)") + print(self) + } + } + + func leftPadding(toLength: Int, withPad character: Character) -> String { + if count < toLength { + return String(repeatElement(character, count: toLength - count)) + self + } else { + return self + } + } + + /// Convert a MAC address string into 6-byte Data + func macToData() -> Data? { + // Remove separators such as ":" or "-" + let cleaned = self.replacingOccurrences(of: "[:\\-]", with: "", options: .regularExpression) + + // Must be exactly 12 hexadecimal characters + guard cleaned.count == 12 else { return nil } + + var data = Data() + var index = cleaned.startIndex + for _ in 0..<6 { + let nextIndex = cleaned.index(index, offsetBy: 2) + let byteString = String(cleaned[index.. Data? { + return self.data(using: .utf8) + } + +} + +extension Encodable { + func toDictionary() -> [String: Any]? { + guard let data = try? JSONEncoder().encode(self), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return dict + } + + func toListDictionary() -> [[String: Any]]? { + if let singleDict = self.toDictionary() { + return [singleDict] + } + if let arrayData = try? JSONEncoder().encode(self), + let jsonArray = try? JSONSerialization.jsonObject(with: arrayData) as? [[String: Any]] { + return jsonArray + } + + return nil + } + + func toJsonString(prettyPrinted: Bool = false) -> String { + let encoder = JSONEncoder() + if prettyPrinted { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + do { + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { + print("❌ JSON serialization failed: \(error)") + return "{}" + } + } + + func toData() -> Data? { + let encoder = JSONEncoder() + do { + return try encoder.encode(self) + } catch { + print("❌ Failed to convert JSON to Data: \(error.localizedDescription)") + return nil + } + } +} + +extension CGRect { + var minDimension: CGFloat { + min(width, height) + } +} + + +extension View { + + @ViewBuilder + func rippleDiffusion() -> some View { + TimelineView(.animation) { timeline in + // Calculate time interval + let time = timeline.date.timeIntervalSinceReferenceDate + ZStack { + // Multiple ripple cycles + ForEach(0..<3) { i in + let progress = (time + Double(i) * 0.5).truncatingRemainder(dividingBy: 1.5) / 1.5 + Circle() + .stroke(Color.blue.opacity(1 - progress), lineWidth: 2) + .scaleEffect(0.5 + progress * 2) + } + } + .drawingGroup() + } + } + + @ViewBuilder + func glassEffectCircle() -> some View { + if #available(iOS 26.0, *) { + self.glassEffect() + } else { + self.background( + Circle() + .fill(.ultraThinMaterial) + ) + } + } + + @ViewBuilder + func glassEffectRegular(cornerRadius : CGFloat) -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(.regular,in: RoundedRectangle(cornerRadius: cornerRadius)) + } else { + self + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.thinMaterial) + ) + } + } + + @ViewBuilder + func presentationBackgroundClear() -> some View { + if #available(iOS 26.0, *) { + self.presentationBackground(.clear) + } else { + self.presentationBackground(.ultraThinMaterial) + } + } + + @ViewBuilder + func glassButtonStyle() -> some View { + if #available(iOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/app/StackChan/Utils/FileUtils.swift b/app/StackChan/Utils/FileUtils.swift new file mode 100644 index 0000000..8b740be --- /dev/null +++ b/app/StackChan/Utils/FileUtils.swift @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation +import CryptoKit + +class FileUtils { + + static let shared = FileUtils() + private init() {} + + func cacheDirectory() -> URL { + let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + return paths[0] + } + + func hashedKey(for url: String) -> String { + let data = Data(url.utf8) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + //File download, with built-in cache. First, check if the file exists locally. If it does, directly return the path; if not, download the file and then return the path. + func download(url: String) async throws -> String { + let key = hashedKey(for: url) + let cacheURL = cacheDirectory().appendingPathComponent(key) + + if FileManager.default.fileExists(atPath: cacheURL.path) { + return cacheURL.path + } + + guard let requestURL = URL(string: url) else { + throw URLError(.badURL) + } + + let (data, _) = try await URLSession.shared.data(from: requestURL) + try data.write(to: cacheURL) + return cacheURL.path + } +} diff --git a/app/StackChan/Utils/ImageUtils.swift b/app/StackChan/Utils/ImageUtils.swift new file mode 100644 index 0000000..daca842 --- /dev/null +++ b/app/StackChan/Utils/ImageUtils.swift @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import UIKit + +final class ImageUtils { + + // MARK: - Singleton + static let shared = ImageUtils() + private init() {} + + // MARK: - Public Methods + + /// Resize the image to the specified resolution and export it /// - Parameters: + /// - image: Input the original image + /// - targetSize: Target resolution (e.g. CGSize(width: 1080, height: 1920)) + /// - format: Export format (default JPEG) + /// - quality: JPEG compression quality (0-1, default 1) + /// - Returns: Converted Data (JPEG or PNG), nil if failure + func exportScaledImageData( + from image: UIImage, + targetSize: CGSize, + format: ImageFormat = .jpeg, + quality: CGFloat = 1.0 + ) -> Data? { + + // Use UIGraphicsImageRenderer for high-quality scaling + let renderer = UIGraphicsImageRenderer(size: targetSize) + let scaledImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: targetSize)) + } + + switch format { + case .jpeg: + return scaledImage.jpegData(compressionQuality: quality) + case .png: + return scaledImage.pngData() + } + } + + // MARK: - Image Format Enum + enum ImageFormat { + case jpeg + case png + } +} diff --git a/app/StackChan/Utils/MultipeerUtil.swift b/app/StackChan/Utils/MultipeerUtil.swift new file mode 100644 index 0000000..f28e2e9 --- /dev/null +++ b/app/StackChan/Utils/MultipeerUtil.swift @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import MultipeerConnectivity + +class MultipeerUtil: NSObject, MCSessionDelegate, MCNearbyServiceAdvertiserDelegate, MCNearbyServiceBrowserDelegate { + + static let shared = MultipeerUtil() + + private let serviceType = "stackchan-mpc" + + private let myPeerID = MCPeerID(displayName: UIDevice.current.name) + private var session: MCSession! + + private var advertiser: MCNearbyServiceAdvertiser! + private var browser: MCNearbyServiceBrowser! + + var appendScanPeer: ((MCPeerID,[String : String]?) -> Void)? + var removeScanPeer: ((MCPeerID) -> Void)? + var onMessageReceived: ((Data, MCPeerID) -> Void)? + + private override init() { + super.init() + session = MCSession(peer: myPeerID, securityIdentity: nil, encryptionPreference: .required) + session.delegate = self + + advertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: [ + "deviceId": UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + ], serviceType: serviceType) + advertiser.delegate = self + + browser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType) + browser.delegate = self + + } + + func startAdvertising() { + advertiser.stopAdvertisingPeer() + advertiser.startAdvertisingPeer() + print("Started advertising Multipeer service") + } + + func stopAdvertising() { + advertiser.stopAdvertisingPeer() + print("Stopped advertising Multipeer service") + } + + func startBrowsing() { + browser.stopBrowsingForPeers() + browser.startBrowsingForPeers() + print("Started browsing nearby devices") + } + + func stopBrowsing() { + browser.stopBrowsingForPeers() + print("Stopped browsing nearby devices") + } + + func sendMessage(_ data: Data, to peer: MCPeerID) { + if !session.connectedPeers.contains(peer) { + print("Not connected to \(peer.displayName), attempting to connect...") + Task { + await connectAndSend(data: data, to: peer) + } + return + } + + do { + try session.send(data, toPeers: [peer], with: .reliable) + print("Message sent successfully") + } catch { + print("Failed to send message: \(error)") + } + } + + private func connectAndSend(data: Data, to peer: MCPeerID) async { + browser.invitePeer(peer, to: session, withContext: nil, timeout: 30) + for _ in 0..<30 { + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + if session.connectedPeers.contains(peer) { + sendMessage(data, to: peer) + return + } + } + + print("Connection timed out, unable to send message to \(peer.displayName)") + } + + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { + print("Discovered peer: " + peerID.displayName) + appendScanPeer?(peerID, info) + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + removeScanPeer?(peerID) + } + + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, + withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { + invitationHandler(true, session) + } + + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + print("Peer \(peerID.displayName) state changed: \(state.rawValue)") + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + onMessageReceived?(data, peerID) + } + + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { + + } + + func session(_ session: MCSession, didReceiveCertificate certificate: [Any]?, fromPeer peerID: MCPeerID, certificateHandler: @escaping (Bool) -> Void) { + + } + + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { + + } + + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) { + + } +} diff --git a/app/StackChan/Utils/RippleDiffusion.swift b/app/StackChan/Utils/RippleDiffusion.swift new file mode 100644 index 0000000..75a8aae --- /dev/null +++ b/app/StackChan/Utils/RippleDiffusion.swift @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct RippleDiffusion : View { + + let content: () -> Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + @State private var animate = false + + var body: some View { + ZStack(alignment: .center) { + ForEach(0..<3) { index in + Circle() + .stroke(Color.accentColor.opacity(0.7), lineWidth: 2) + .frame(width: CGFloat(index + 1) * 100, height: CGFloat(index + 1) * 100) + .scaleEffect(animate ? 2.0 : 0.1) + .opacity(animate ? 0 : 1) + .animation( + Animation.easeOut(duration: 1.8) + .repeatForever(autoreverses: false) + .delay(Double(index) * 0.3), + value: animate + ) + } + content() + } + .onAppear { + DispatchQueue.main.async { + animate = true + } + } + } +} + + +struct RippleDiffusionPreview : PreviewProvider { + static var previews: some View { + RippleDiffusion { + + } + } +} diff --git a/app/StackChan/Utils/Style.swift b/app/StackChan/Utils/Style.swift new file mode 100644 index 0000000..eab9f2b --- /dev/null +++ b/app/StackChan/Utils/Style.swift @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + + +struct WideOrangeButton: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.all,15) + .frame(maxWidth: .infinity,minHeight: 44) + .foregroundColor(.white) + .background(RoundedRectangle(cornerRadius: 20.0) + .fill(.blue) + ) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 0) + .opacity(configuration.isPressed ? 0.6 : 1.0) + } +} + +struct MyTextFieldStyle : TextFieldStyle { + func _body(configuration: TextField) -> some View { + configuration + .padding(.all,15) + .frame(maxWidth: .infinity,minHeight: 44) + .background( + RoundedRectangle(cornerRadius: 20.0) + .fill(.background) + ) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 0) + } +} + +struct SopDirectoryButtonStyle: ButtonStyle { + let select: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.all, 20) + .frame(maxWidth: .infinity, minHeight: 44) + .foregroundColor(select ? .accentColor : .primary) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(select ? Color.blue.opacity(0.2) : Color.clear) + ) + .opacity(configuration.isPressed ? 0.6 : 1.0) + } +} diff --git a/app/StackChan/Utils/ValueConstant.swift b/app/StackChan/Utils/ValueConstant.swift new file mode 100644 index 0000000..5286683 --- /dev/null +++ b/app/StackChan/Utils/ValueConstant.swift @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +struct ValueConstant { + static let token = "token" + static let deviceToken = "deviceToken" + static let Authorization = "Authorization" + static let deviceType = "deviceType" + static let mac = "mac" + static let index = "index" + static let data = "data" + static let list = "list" + static let file = "file" + static let directory = "directory" + static let moments = "moments" + static let name = "name" + static let content_text = "content_text" + static let content_image = "content_image" + static let page = "page" + static let pageSize = "pageSize" + static let id = "id" + static let postId = "postId" + static let content = "content" +} + diff --git a/app/StackChan/View/ARCameraView.swift b/app/StackChan/View/ARCameraView.swift new file mode 100644 index 0000000..7dffdf4 --- /dev/null +++ b/app/StackChan/View/ARCameraView.swift @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import RealityKit +import ARKit + +struct ARCameraView : UIViewRepresentable { + + @Binding var expressionData: ExpressionData + + @Binding var decorate: Int + + @Binding var captureScreen: Bool + + let onCallback : ((ARSession,[ARAnchor]) -> Void)? + + let onFrameCallback : ((UIImage) -> Void)? + + func makeUIView(context: Context) -> ARSCNView { + let arView = ARSCNView(frame: .zero) + arView.contentMode = .scaleAspectFit + arView.autoresizingMask = [.flexibleWidth,.flexibleHeight] + + let configuration = ARFaceTrackingConfiguration() + configuration.isLightEstimationEnabled = true + //4K + if let format = ARFaceTrackingConfiguration.supportedVideoFormats.last { + configuration.videoFormat = format + } + //HDR + configuration.videoHDRAllowed = true + + arView.automaticallyUpdatesLighting = true + arView.session.delegate = context.coordinator + arView.delegate = context.coordinator + arView.session.run(configuration, options: [.resetTracking,.removeExistingAnchors]) + + return arView + } + + func updateUIView(_ uiView: ARSCNView, context: Context) { + context.coordinator.updateDecoration(decorate: decorate, uiView, context: context, expressionData: expressionData) + } + + func makeCoordinator() -> Corrdinator { + Corrdinator(parent: self) + } + + //robot data + var robot: ExpressionData = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()) + + static func dismantleUIView(_ uiView: ARSCNView, coordinator: Corrdinator) { + uiView.session.pause() + uiView.delegate = nil + uiView.session.delegate = nil + uiView.isPlaying = false + uiView.scene.rootNode.enumerateChildNodes { node, _ in + node.removeFromParentNode() + } + uiView.scene = SCNScene() + } + + class Corrdinator: NSObject, ARSessionDelegate , ARSCNViewDelegate { + var parent: ARCameraView + + var decorate: Int = 0 + + var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()),reverse: true) + + var faceAnchorNode: SCNNode? + var currentDecorationNode: SCNNode? + + private var lastCaptureTime: TimeInterval = 0 + + init(parent: ARCameraView) { + self.parent = parent + } + + func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) { + parent.onCallback?(session,anchors) + } + + func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) { + if parent.captureScreen { + if time - lastCaptureTime >= 0.5 { + lastCaptureTime = time + guard let scnView = renderer as? ARSCNView else { return } + let renderedImage = scnView.snapshot() + parent.onFrameCallback?(renderedImage) + } + } + } + + private lazy var expressionRenderer: UIGraphicsImageRenderer = { + let format = UIGraphicsImageRendererFormat.default() + format.scale = UIScreen.main.scale + format.opaque = false + return UIGraphicsImageRenderer( + size: expressionLayer.bounds.size, + format: format + ) + }() + + func createEmojiNoseNode(emoji: String) -> SCNNode { + let size = CGSize(width: 300, height: 300) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + (emoji as NSString).draw(in: CGRect(origin: .zero, size: size), + withAttributes: [.font: UIFont.systemFont(ofSize: size.width - 20)]) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + let nosePlane = SCNPlane(width: 0.05, height: 0.05) + nosePlane.firstMaterial?.diffuse.contents = image + nosePlane.firstMaterial?.isDoubleSided = true + + let noseNode = SCNNode(geometry: nosePlane) + noseNode.name = "noseNode" + noseNode.position = SCNVector3(0, 0, 0.07) + return noseNode + } + + func createStackChanModel() -> SCNNode { + guard let scene = SCNScene(named: "stackChanModel.scn"), + let modelNode = scene.rootNode.childNodes.first else { + print("no model") + return SCNNode() + } + modelNode.name = "stackChanModel" + modelNode.scale = SCNVector3(0.004, 0.004, 0.004) + modelNode.opacity = 0.4 + modelNode.position = SCNVector3(0, 0.03, 0) + modelNode.eulerAngles = SCNVector3Zero + modelNode.eulerAngles.x = -Float.pi / 2 + + return modelNode + } + + func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { + guard anchor is ARFaceAnchor else { return nil } + + let node = SCNNode() + self.faceAnchorNode = node + + updateDecorationOnNode(node: node, decorate: decorate) + + return node + } + + func createPlane() -> SCNNode { + let plane = SCNPlane(width: 0.16, height: 0.12) + + let layerWidth = plane.width * 1000 + let layerHeight = plane.height * 1000 + expressionLayer.frame = CGRect(origin: .zero, size: CGSize(width: layerWidth, height: layerHeight)) + expressionLayer.setNeedsDisplay() + + let material = SCNMaterial() + material.diffuse.contents = UIColor.black + plane.materials = [material] + + let planeNode = SCNNode(geometry: plane) + planeNode.name = "expressionPlane" + planeNode.position = SCNVector3(0, 0.03, 0.07) + return planeNode + } + + func updateDecorationOnNode(node: SCNNode, decorate: Int) { + currentDecorationNode?.removeFromParentNode() + + if decorate == 1 { + let container = SCNNode() + + let stackChanModelNode = createStackChanModel() + container.addChildNode(stackChanModelNode) + + let expressionPlaneNode = createPlane() + container.addChildNode(expressionPlaneNode) + + node.addChildNode(container) + currentDecorationNode = container + + } else if decorate == 2 { + let noseNode = createEmojiNoseNode(emoji: "🐽") + node.addChildNode(noseNode) + currentDecorationNode = noseNode + } + } + + func updateDecoration(decorate: Int,_ uiView: ARSCNView, context: Context, expressionData: ExpressionData) { + DispatchQueue.main.async { + if self.decorate != decorate { + self.decorate = decorate + if let faceNode = self.faceAnchorNode { + self.updateDecorationOnNode(node: faceNode, decorate: decorate) + } + } + + if decorate == 1 { + let scene = uiView.scene + guard let planeNode = scene.rootNode.childNode(withName: "expressionPlane", recursively: true), + let plane = planeNode.geometry as? SCNPlane else { + return + } + self.expressionLayer.data = expressionData + self.expressionLayer.setNeedsDisplay() + + let originalImage = self.expressionRenderer.image { ctx in + self.expressionLayer.render(in: ctx.cgContext) + } + let image = UIImage( + cgImage: originalImage.cgImage!, + scale: originalImage.scale, + orientation: .upMirrored + ) + + plane.materials.first?.diffuse.contents = image + } + + } + } + } +} + diff --git a/app/StackChan/View/AppDelegate.swift b/app/StackChan/View/AppDelegate.swift new file mode 100644 index 0000000..dcaef9a --- /dev/null +++ b/app/StackChan/View/AppDelegate.swift @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import ObjectiveC +import UIKit + +class AppDelegate: NSObject, UIApplicationDelegate { + + static var orientationLock = UIInterfaceOrientationMask.portrait + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return AppDelegate.orientationLock + } +} diff --git a/app/StackChan/View/AvatarMotionControl.swift b/app/StackChan/View/AvatarMotionControl.swift new file mode 100644 index 0000000..e3b38e3 --- /dev/null +++ b/app/StackChan/View/AvatarMotionControl.swift @@ -0,0 +1,562 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct AvatarMotionControl : View { + + @State private var selectedItem: ControlItem = .avatar + + @EnvironmentObject var appState: AppState + + @State private var avatarData: ExpressionData = ExpressionData(leftEye: ExpressionItem(weight:100), rightEye: ExpressionItem(weight:100), mouth: ExpressionItem(weight:0)) + + @State private var motionData: MotionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()) + + private let tag = "AvatarMotionControl" + + @State private var lastJoystickUpdate: Date = .distantPast + + enum ControlItem: String,CaseIterable, Identifiable { + case avatar = "Avatar" + case motion = "Motion" + var id: String { rawValue } + } + + var body: some View { + VStack { + HStack { + Spacer() + let danceData = DanceData(leftEye: avatarData.leftEye, rightEye: avatarData.rightEye, mouth: avatarData.mouth, yawServo: motionData.yawServo, pitchServo: motionData.pitchServo, durationMs: 1000) + StackChanRobot(data: danceData) + .frame(height: 250) + Spacer() + } + HStack { + Picker("Select", selection: $selectedItem) { + ForEach(ControlItem.allCases) { item in + Text(item.rawValue) + .tag(item) + } + } + .pickerStyle(.segmented) + Button { + if selectedItem == .avatar { + withAnimation { + avatarData = ExpressionData(leftEye: ExpressionItem(weight:100), rightEye: ExpressionItem(weight:100), mouth: ExpressionItem(weight:0)) + } + saveAvatarData() + } else if selectedItem == .motion { + withAnimation { + motionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()) + } + saveMotionData() + } + } label: { + Image(systemName: "arrow.counterclockwise") + } + .glassButtonStyle() + } + + if selectedItem == .avatar { + List { + Section("Left Eye") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.x) }, + set: { avatarData.leftEye.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.y) }, + set: { avatarData.leftEye.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.rotation) }, + set: { avatarData.leftEye.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.weight) }, + set: { avatarData.leftEye.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.weight)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("size") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.size) }, + set: { avatarData.leftEye.size = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.size)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + + Section("Right Eye") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.x) }, + set: { avatarData.rightEye.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.y) }, + set: { avatarData.rightEye.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.rotation) }, + set: { avatarData.rightEye.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.weight) }, + set: { avatarData.rightEye.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.weight)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("size") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.size) }, + set: { avatarData.rightEye.size = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.size)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + + Section("Mouth") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.mouth.x) }, + set: { avatarData.mouth.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.mouth.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.mouth.y) }, + set: { avatarData.mouth.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.mouth.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.mouth.rotation) }, + set: { avatarData.mouth.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.mouth.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.mouth.weight) }, + set: { avatarData.mouth.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.mouth.weight)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(.clear) + } else if selectedItem == .motion { + List { + Section("Joystick") { + HStack { + Spacer() + JoystickView { radians, strength in + if radians == 0 && strength == 0 { + calculationJoystick(radians: radians, strength: strength) + } else { + let now = Date() + if now.timeIntervalSince(lastJoystickUpdate) > 0.1 { + lastJoystickUpdate = now + calculationJoystick(radians: radians, strength: strength) + } + } + } + .frame(width: 200,height: 200) + Spacer() + } + } + .listRowBackground(Color.clear) + Section("Yaw Servo") { + HStack { + Text("angle") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.yawServo.angle) }, + set: { + motionData.yawServo.rotate = Int(0) + motionData.yawServo.angle = Int($0) + } + ), + in: -1280...1280, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.yawServo.angle)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("speed") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.yawServo.speed) }, + set: { motionData.yawServo.speed = Int($0) } + ), + in: 0...1000, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.yawServo.speed)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotate") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.yawServo.rotate) }, + set: { + motionData.yawServo.angle = Int(0) + motionData.yawServo.rotate = Int($0) + } + ), + in: -1000...1000, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.yawServo.rotate)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + Section("Pitch Servo") { + HStack { + Text("angle") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.pitchServo.angle) }, + set: { motionData.pitchServo.angle = Int($0) } + ), + in: 0...900, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.pitchServo.angle)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("speed") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.pitchServo.speed) }, + set: { motionData.pitchServo.speed = Int($0) } + ), + in: 0...1000, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.pitchServo.speed)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(.clear) + } + Spacer() + } + .padding() + .ignoresSafeArea() + .onAppear { + WebSocketUtil.shared.addObserver(for: tag) { message in + switch message { + case .data(let data): + let result = appState.parseMessage(message: data) + if let msgType = result.0, let parsedData = result.1 { + switch msgType { + case MsgType.getAvatarPosture: + print("Received the result of obtaining the header information" + String(parsedData.count)) + default: + + break + } + } + case .string(let text): + print("Received a regular message:" + text) + @unknown default: + break + } + } + appState.sendWebSocketMessage(.getAvatarPosture) + } + .onDisappear { + WebSocketUtil.shared.removeObserver(for: tag) + } + } + + /// Calculate the joystick data + private func calculationJoystick(radians: CGFloat, strength: CGFloat) { + let x = strength * cos(radians) + let y = strength * sin(radians) + let deadZone: CGFloat = 0.0 + var yawValue: Int = 0 + if abs(x) > deadZone { + yawValue = Int(x * 1280) + motionData.yawServo.rotate = 0 + motionData.yawServo.angle = yawValue + motionData.yawServo.speed = 50 + } else { + motionData.yawServo.rotate = 0 + motionData.yawServo.angle = 0 + motionData.yawServo.speed = 500 + } + if y <= 0 { + if abs(y) > deadZone { + let normalizedY = max(-y, 0) + let newPitch = Int(normalizedY * 900) + motionData.pitchServo.angle = newPitch + motionData.pitchServo.speed = 50 + } else { + motionData.pitchServo.speed = 500 + motionData.pitchServo.angle = 0 + } + } else { + motionData.pitchServo.speed = 500 + motionData.pitchServo.angle = 0 + } + saveMotionData() + } + + private func saveAvatarData() { + if !appState.deviceMac.isEmpty { + let jsonString = appState.deviceMac + avatarData.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlAvatar, data) + } + } + + private func saveMotionData() { + if !appState.deviceMac.isEmpty { + let jsonString = appState.deviceMac + motionData.toJsonString() + print(jsonString) + + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlMotion, data) + } + } +} + diff --git a/app/StackChan/View/BindingDevice.swift b/app/StackChan/View/BindingDevice.swift new file mode 100644 index 0000000..fff7c02 --- /dev/null +++ b/app/StackChan/View/BindingDevice.swift @@ -0,0 +1,320 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import NetworkExtension +import CoreLocation +import NetworkExtension +import CoreBluetooth + +struct BindingDevice : View { + + enum BindingDevicePageType: Hashable { + case scanningEquipment + } + + @State private var path: [BindingDevicePageType] = [] + + var body: some View { + NavigationStack(path: $path) { + VStack{ + Spacer() + VStack(alignment: .leading, spacing: 16) { + + HStack { + Spacer() + Image("lateral_image") + .resizable() + .frame(maxWidth: 250,maxHeight: 250) + Spacer() + } + + Text("Get your StackChan device ready") + .font(.title2) + .bold() + + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + Image(systemName: "1.circle.fill") + .foregroundColor(.accentColor) + Text("Turn on your StackChan device") + } + HStack(alignment: .top) { + Image(systemName: "2.circle.fill") + .foregroundColor(.accentColor) + Text("After turning on the computer, turn the page to \"Setup\" and click to enter. A QR code will be displayed") + } + HStack(alignment: .top) { + Image(systemName: "3.circle.fill") + .foregroundColor(.accentColor) + Text("Align the QR code and scan it to bind the device") + } + } + .font(.body) + } + .padding() + Spacer() + NavigationLink(value: BindingDevicePageType.scanningEquipment) { + Text("Next") + .frame(maxWidth: .infinity) + } + .padding() + .controlSize(.large) + .buttonStyle(.borderedProminent) + } + .navigationTitle("Binding Device") + .navigationDestination(for: BindingDevicePageType.self) { PageType in + switch PageType { + case .scanningEquipment: + ScanningEquipment() + } + } + } + } +} + +struct ScanningEquipment : View { + + enum PairingStatus { + case ScanCode + case ConnectBlue + case InputWiFi + case DistributionNetwork + case ChangeTheName + case Empty + } + + enum Field { + case Name + case Password + case StackChanName + } + + @EnvironmentObject var appState: AppState + + @State var pairingStatus: PairingStatus = .ScanCode + + @State var wifiName: String = "" + @State var wifiPassword: String = "" + @State var stackChanName: String = "" + + @State private var locationManager = CLLocationManager() + @State private var locationDelegate = LocationDelegate() + + @FocusState private var focusedField: Field? + + var body: some View { + Group { + switch pairingStatus { + case .ScanCode: + GeometryReader { geometry in + ScanView { result in + switch result { + case .success(let data): + readCodeString(value: data) + break + case .failure(_): + break + } + } + .clipShape( + RoundedRectangle( + cornerRadius: min(geometry.size.width, geometry.size.height) * 0.1, + style: .continuous + ) + ) + } + .padding() + .navigationTitle("Scan Device QR Code") + case .ConnectBlue: + VStack { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting to Bluetooth") + .font(.title3) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center) + .navigationTitle("Pairing devices") + case .InputWiFi: + VStack { + List { + Section(header: Text("Name")) { + TextField("Please enter the name of the wifi", text:$wifiName) + .focused($focusedField, equals: .Name) + .submitLabel(.next) + .onSubmit { + focusedField = .Password + } + } + Section(header: Text("Password")) { + TextField("Please enter the password of the wifi", text:$wifiPassword) + .focused($focusedField, equals: .Password) + .submitLabel(.done) + .onSubmit { + confirmWifi() + } + } + } + .listStyle(.insetGrouped) + + Spacer() + + Button { + focusedField = nil + confirmWifi() + } label: { + Text("Confirm") + .frame(maxWidth: .infinity) + } + .padding() + .controlSize(.large) + .buttonStyle(.borderedProminent) + } + .background(Color(UIColor.systemGroupedBackground)) + .navigationTitle("Enter Wifi Information") + case .DistributionNetwork: + VStack { + ProgressView() + .progressViewStyle(.circular) + Text("The network is being configured for the equipment") + .font(.title3) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center) + .navigationTitle("wait a moment") + case .ChangeTheName: + VStack { + List { + Section(header: Text("Name")) { + TextField("Please enter the name of the stackChan", text:$stackChanName) + .focused($focusedField, equals: .StackChanName) + .submitLabel(.done) + .onSubmit { + updataName() + } + } + } + .listStyle(.insetGrouped) + + Spacer() + + Button { + focusedField = nil + updataName() + } label: { + Text("Confirm") + .frame(maxWidth: .infinity) + } + .padding() + .controlSize(.large) + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center) + .navigationTitle("Give me a name") + default: + EmptyView() + } + } + .alert(appState.alertTitle, isPresented: $appState.showAlert){ + Button { + appState.alertAction?() + } label: { + Text("Confirm") + } + } + .task { + } + } + + func updataName() { + + } + + func readCodeString(value: String) { + if let data = value.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String:Any], let mac = json["mac"] as? String { + let extracted = mac + let cleanedMac = extracted.uppercased().replacingOccurrences( + of: "[^A-F0-9]", + with: "", + options: .regularExpression + ) + appState.deviceMac = cleanedMac + appState.showBindingDevice = false + appState.connectWebSocket() + appState.openBlufi() + } + } + + private func getBlueAndWifiInfo() { + NEHotspotNetwork.fetchCurrent { network in + if let network = network { + wifiName = network.ssid + focusedField = .Password + } + } + BlufiUtil.shared.startScan() + } + + private func getPermission() { + if #available(iOS 14.0, *) { + switch locationManager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + getBlueAndWifiInfo() + break + case .denied, .restricted: + break + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + break + default: + break + } + } else { + locationManager.requestWhenInUseAuthorization() + } + } + + private func confirmWifi() { + + if !BlufiUtil.shared.blueSwitch { + appState.alertTitle = "Please turn on Bluetooth" + appState.showAlert = true + return + } + + if wifiName.isEmpty || wifiPassword.isEmpty { + appState.alertTitle = "Please enter Wi-Fi name and password" + appState.showAlert = true + return + } + + withAnimation{ + pairingStatus = .DistributionNetwork + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + withAnimation{ + pairingStatus = .ChangeTheName + } + } + } + + private func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} + + +class LocationDelegate: NSObject,CLLocationManagerDelegate { + var onAuthorized: (() -> Void)? + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways { + onAuthorized?() + } + } +} diff --git a/app/StackChan/View/CameraPage.swift b/app/StackChan/View/CameraPage.swift new file mode 100644 index 0000000..f809975 --- /dev/null +++ b/app/StackChan/View/CameraPage.swift @@ -0,0 +1,571 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct CameraPage: View { + + @State var showControlButton = true + @State var fullScreenDisplay = false + + @State var controlWidth: CGFloat = 0 + @State var controlHeight: CGFloat = 0 + + private let controlButtonSize: CGFloat = 88 + private let bottomButtonSize: CGFloat = 50 + + @State var openMicrophone : Bool = false + @State var openSpeaker: Bool = true + @State var startRecord: Bool = false + @State var isPress: Int? = nil + + @EnvironmentObject var appState: AppState + + @AppStorage("recordMotion") private var recordMotionData: Data = Data() + + @State var recordMotion: [MotionData] = [] + + private let feedback = UIImpactFeedbackGenerator(style: .medium) + + @State private var removeRecordPoint: Bool = false + + @State private var cameraImage: Data? = nil + + @State var showAlert = false + @State var alertMessage = "" + + private let tag: String = "CameraPage" + + @State private var motionData: MotionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()) + + @State private var pressTimer: Timer? = nil + + private let longPressInterval = 0.05 + private let longStepValue = 50 + + var body: some View { + GeometryReader { geo in + if fullScreenDisplay { + VStack { + cameraView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .toolbar(.hidden, for: .bottomBar,.navigationBar,.tabBar,.automatic) + .ignoresSafeArea(.all) + .background(.black) + } else { + let isPortrait = geo.size.height > geo.size.width + ZStack(alignment: isPortrait ? .bottomTrailing : .topTrailing) { + if isPortrait { + VStack { + cameraView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + cameraControl(isPortrait: isPortrait) + .frame(maxWidth: .infinity,maxHeight: controlHeight) + .opacity(showControlButton ? 1: 0) + } + } else { + HStack { + cameraView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + cameraControl(isPortrait: isPortrait) + .frame(maxWidth: controlWidth, maxHeight: .infinity) + .opacity(showControlButton ? 1: 0) + } + } + + HStack { + Spacer() + Button { + withAnimation(.easeInOut(duration: 0.3)) { + showControlButton.toggle() + updateControlSize(isPortrait: isPortrait, size: geo.size) + } + } label: { + Image(systemName: + showControlButton + ? (isPortrait ? "chevron.down" : "chevron.right") + : (isPortrait ? "chevron.up" : "chevron.left") + ) + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + } + .padding(12) + } + .padding(0) + .onAppear { + DispatchQueue.main.async { + if controlWidth == 0 && controlHeight == 0 { + controlHeight = geo.size.height / 2 + } + } + WebSocketUtil.shared.addObserver(for: tag) { (message: URLSessionWebSocketTask.Message) in + switch message { + case .data(let data): + let result = appState.parseMessage(message: data) + if let msgType = result.0, let parsedData = result.1 { + switch msgType { + case MsgType.jpeg: + cameraImage = parsedData + default: + break + } + } + case .string(let text): + print("Received a regular message: \(text)") + @unknown default: + break + } + } + //on device camera + appState.sendWebSocketMessage(.onCamera,appState.deviceMac.toData()) + } + .onDisappear { + WebSocketUtil.shared.removeObserver(for: tag) + //off device camera + appState.sendWebSocketMessage(.offCamera,appState.deviceMac.toData()) + } + .onChange(of: geo.size) { newValue in + let isPortrait = newValue.height > newValue.width + updateControlSize(isPortrait: isPortrait, size: newValue) + } + .toolbar(.hidden, for: .tabBar) + .navigationTitle("SENTINEL") + } + } + .alert(alertMessage, isPresented: $showAlert) { + Button { + + } label: { + Text("Confirm") + } + } + .onAppear { + recordMotion = getRecordMotion() + } + } + + private func getRecordMotion() -> [MotionData] { + guard let decoded = try? JSONDecoder().decode([MotionData].self, from: recordMotionData) else { + return [] + } + return decoded + } + + private func setRecordMotion(_ newValue: [MotionData]) { + if let encoded = try? JSONEncoder().encode(newValue) { + recordMotionData = encoded + recordMotion = newValue + } + } + + private func updateControlSize(isPortrait: Bool, size: CGSize) { + withAnimation { + if showControlButton { + if isPortrait { + controlHeight = size.height / 2 + } else { + controlWidth = size.width / 2 + } + } else { + if isPortrait { + controlHeight = 0 + } else { + controlWidth = 0 + } + } + } + } + + private func cameraView() -> some View { + GeometryReader { geo in + VStack { + Spacer() + ZStack(alignment: .bottom) { + if let cameraData = cameraImage, let uiImage = UIImage(data: cameraData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + } else { + Color.gray + } + HStack { + Spacer() + Button { + withAnimation { + fullScreenDisplay.toggle() + } + } label: { + Image(systemName: fullScreenDisplay ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + .frame(width: 44,height: 44) + .foregroundColor(.white) + } + .glassEffectCircle() + } + .padding() + } + .frame( + maxWidth: geo.size.width, + maxHeight: min(geo.size.height, geo.size.width * 3 / 4) + ) + Spacer() + } + } + } + + @ViewBuilder private func cameraRecordPoint() -> some View { + ZStack { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: true) { + VStack(spacing: 5) { + Color.clear.frame(width: bottomButtonSize / 2,height: bottomButtonSize / 2) + ForEach(Array(recordMotion.indices), id: \.self) { index in + ZStack(alignment: .topTrailing) { + Button { + if removeRecordPoint { + recordMotion.remove(at: index) + setRecordMotion(recordMotion) + } else { + feedback.impactOccurred() + motionData = recordMotion[index] + saveMotionData() + } + } label: { + Text(String(index + 1)) + .frame(width: bottomButtonSize, height: bottomButtonSize) + .background( + Circle() + .fill(Color(UIColor.secondarySystemFill)) + ) + .font(.system(size: bottomButtonSize / 2)) + } + if removeRecordPoint { + Image(systemName: "minus.circle.fill") + .font(.system(size: 20)) + .foregroundColor(.red) + } + } + } + Color.clear.frame(width: bottomButtonSize / 2,height: bottomButtonSize / 2).id(-1) + } + .padding(0) + } + .padding(0) + .onChange(of: recordMotion.count) { _ in + withAnimation { + proxy.scrollTo(-1, anchor: .bottom) + } + } + } + .padding(.vertical, bottomButtonSize / 2) + + VStack { + Button { + withAnimation { + recordMotion.append(motionData) + setRecordMotion(recordMotion) + } + } label: { + Image(systemName: "plus") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + + Spacer() + + Button { + withAnimation { + removeRecordPoint.toggle() + } + } label: { + Image(systemName: removeRecordPoint ? "checkmark" : "minus") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .foregroundColor(removeRecordPoint ? .accent : Color(UIColor.label)) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + } + .padding(0) + }.background( + RoundedRectangle(cornerRadius: 50) + .fill(.ultraThinMaterial) + ) + } + + @ViewBuilder private func cameraControl(isPortrait: Bool) -> some View { + + let microphone = Button { + feedback.impactOccurred() + withAnimation { + openMicrophone.toggle() + } + } label: { + Image(systemName: openMicrophone ? "microphone" : "microphone.slash") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + + let speaker = Button { + feedback.impactOccurred() + withAnimation { + openSpeaker.toggle() + } + } label: { + Image(systemName: openSpeaker ? "speaker" : "speaker.slash") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + + let record = Button { + feedback.impactOccurred() + withAnimation { + startRecord.toggle() + } + } label: { + Image(systemName: !startRecord ? "record.circle" : "record.circle.fill") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + .foregroundColor(!startRecord ? Color(UIColor.label) : .red) + + let directionButton = ZStack{ + Button(action: { + if let messsage = "Hi".toData() { + appState.sendWebSocketMessage(.onCamera, messsage) + } + }) { + Image(systemName: "arrow.up") + .frame(width: controlButtonSize, height: controlButtonSize) + .background( + Circle().fill(.ultraThinMaterial) + .overlay( + Circle() + .fill(isPress == 1 ? Color.accent : .clear) + ) + ) + .foregroundColor(isPress == 1 ? .white : Color(UIColor.label)) + .font(.system(size: controlButtonSize / 2)) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if isPress != nil { + return + } + withAnimation { + isPress = 1 + } + feedback.impactOccurred() + print("Up pressed") + + pressTimer?.invalidate() + pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in + self.motionData.pitchServo.angle += longStepValue + self.saveMotionData() + }) + } + .onEnded { _ in + isPress = nil + + pressTimer?.invalidate() + pressTimer = nil + } + ) + .offset(x: 0, y: -(controlButtonSize / 1.3)) + + Button(action: {}) { + Image(systemName: "arrow.down") + .frame(width: controlButtonSize, height: controlButtonSize) + .background( + Circle().fill(.ultraThinMaterial) + .overlay( + Circle() + .fill(isPress == 3 ? Color.accent : .clear) + ) + ) + .foregroundColor(isPress == 3 ? .white : Color(UIColor.label)) + .font(.system(size: controlButtonSize / 2)) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if isPress != nil { + return + } + withAnimation { + isPress = 3 + } + feedback.impactOccurred() + + pressTimer?.invalidate() + pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in + self.motionData.pitchServo.angle -= longStepValue + self.saveMotionData() + }) + } + .onEnded { _ in + isPress = nil + + pressTimer?.invalidate() + pressTimer = nil + } + ) + .offset(x: 0, y: (controlButtonSize / 1.3)) + + Button(action: {}) { + Image(systemName: "arrow.left") + .frame(width: controlButtonSize, height: controlButtonSize) + .background( + Circle().fill(.ultraThinMaterial) + .overlay( + Circle() + .fill(isPress == 4 ? Color.accent : .clear) + ) + ) + .foregroundColor(isPress == 4 ? .white : Color(UIColor.label)) + .font(.system(size: controlButtonSize / 2)) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if isPress != nil { + return + } + withAnimation { + isPress = 4 + } + feedback.impactOccurred() + + pressTimer?.invalidate() + pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in + self.motionData.yawServo.angle -= longStepValue + self.saveMotionData() + }) + } + .onEnded { _ in + isPress = nil + + pressTimer?.invalidate() + pressTimer = nil + } + ) + .offset(x: -(controlButtonSize / 1.3), y: 0) + + Button(action: {}) { + Image(systemName: "arrow.right") + .frame(width: controlButtonSize, height: controlButtonSize) + .background( + Circle().fill(.ultraThinMaterial) + .overlay( + Circle() + .fill(isPress == 2 ? Color.accent : .clear) + ) + ) + .foregroundColor(isPress == 2 ? .white : Color(UIColor.label)) + .font(.system(size: controlButtonSize / 2)) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if isPress != nil { + return + } + withAnimation { + isPress = 2 + } + feedback.impactOccurred() + + pressTimer?.invalidate() + pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in + self.motionData.yawServo.angle += longStepValue + self.saveMotionData() + }) + } + .onEnded { _ in + isPress = nil + + pressTimer?.invalidate() + pressTimer = nil + } + ) + .offset(x: (controlButtonSize / 1.3), y: 0) + } + + if isPortrait { + VStack { + Spacer() + HStack { + VStack { + Text("View\nPresets").font(.caption).multilineTextAlignment(.center) + cameraRecordPoint() + } + Spacer() + directionButton + Spacer() + } + .padding(0) + Spacer() + HStack { + record + Spacer() + speaker + microphone + Color.clear.frame(width: bottomButtonSize,height: bottomButtonSize) + } + .padding(0) + } + .padding(12) + .foregroundColor(Color(UIColor.label)) + } else { + HStack { + HStack { + VStack { + Text("View\nPresets").font(.caption).multilineTextAlignment(.center) + cameraRecordPoint() + } + Spacer() + directionButton + Spacer() + } + Spacer() + VStack { + Color.clear.frame(width: bottomButtonSize,height: bottomButtonSize) + speaker + microphone + Spacer() + record + } + .padding(0) + } + .padding(12) + .foregroundColor(Color(UIColor.label)) + } + } + + private func saveMotionData() { + if !appState.deviceMac.isEmpty { + let jsonString = appState.deviceMac + motionData.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlMotion, data) + } + } +} + +struct CameraPageViewPreview : PreviewProvider { + static var previews: some View { + CameraPage() + } +} diff --git a/app/StackChan/View/ContentView.swift b/app/StackChan/View/ContentView.swift new file mode 100644 index 0000000..164b359 --- /dev/null +++ b/app/StackChan/View/ContentView.swift @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import CoreBluetooth + +struct ContentView: View { + + @EnvironmentObject var appState: AppState + + var body: some View { + TabView { + StackChan() + .tabItem { + Label( "StackChan", systemImage: "ipod") + } + Nearby() + .tabItem { + Label("Nearby", systemImage: "sensor") + } + Moments() + .tabItem { + Label("Moments", systemImage: "person.3") + } + Settings() + .tabItem { + Label("Settings", systemImage: "gear") + } + } + .task { + appState.openBlufi() + if appState.deviceMac != "" { + appState.connectWebSocket() + } + } + .sheet(isPresented: $appState.showBindingDevice) { + BindingDevice() + .interactiveDismissDisabled(appState.forcedDisplayBindingDevice) + } + .sheet(isPresented: $appState.showDeviceWifiSet) { + SelectBlufiDevice() + .presentationDetents([.medium]) + .interactiveDismissDisabled(true) + } + .alert("Let's give the lovely StackChan a new name", isPresented: $appState.showCjamgeNameAlert, actions: { + TextField("Please enter the name", text: $appState.newName) + Button("Cancel", role: .cancel) { + appState.showCjamgeNameAlert = false + } + Button("Confirm") { + appState.showCjamgeNameAlert = false + withAnimation { + appState.deviceInfo.name = appState.newName + } + appState.updateDeviceInfo() + } + }) + .alert("Please switch StackChan to the SETUP page, select \"App Bind Code\", and then switch to the settings page on the app to choose \"Bind Device\"", isPresented: $appState.showBindingDeviceAlert) { + Button("Confirm") { + appState.showBindingDeviceAlert = false + } + } + } +} + + +struct ContentViewPreview : PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/app/StackChan/View/Dance.swift b/app/StackChan/View/Dance.swift new file mode 100644 index 0000000..b01fd46 --- /dev/null +++ b/app/StackChan/View/Dance.swift @@ -0,0 +1,815 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct DanceData : Codable,Identifiable { + var leftEye: ExpressionItem // Left eye, default weight = 100 + var rightEye: ExpressionItem // Right eye, default weight = 100 + var mouth: ExpressionItem // Mouth, default weight = 0 + var yawServo: MotionDataItem // Yaw rotation, angle range (-1280 ~ 1280), default 0 + var pitchServo: MotionDataItem // Pitch movement, angle range (0 ~ 900), default 0 + var durationMs: Int // Duration in milliseconds, default 1000 + var id: String = UUID().uuidString + + enum CodingKeys: String, CodingKey { + case leftEye, rightEye, mouth, yawServo, pitchServo, durationMs + } + + func copy() -> DanceData { + DanceData( + leftEye: self.leftEye.copy(), + rightEye: self.rightEye.copy(), + mouth: self.mouth.copy(), + yawServo: self.yawServo.copy(), + pitchServo: self.pitchServo.copy(), + durationMs: self.durationMs, + id: UUID().uuidString + ) + } +} + +/// +struct Dance : View { + + @State var danceList: [DanceData] = [] + + @State var modelDanceList: [DanceData] = [] + + @State private var selectedDance: Int = 0 + + @EnvironmentObject var appState: AppState + + @State var showAddDance : Bool = false + + @State var isRun: Bool = false + + @State var editDanceData = DanceData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000) + + @State var editDanceDataIndex: Int? = nil + + @State private var danceTimer: Timer? = nil + + var body: some View { + List { + ForEach(Array(danceList.enumerated()), id: \.element.id) { index,item in + danceItemView(index: index) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + danceList.remove(at: index) + saveDance() + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .onMove { source, destination in + danceList.move(fromOffsets: source, toOffset: destination) + saveDance() + } + } + .listStyle(.insetGrouped) + .refreshable { + getDanceList() + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + isRun.toggle() + if isRun { + startDance() + } else { + stopDance() + } + } label: { + Label { + Text(isRun ? "Stop" : "Run") + } icon: { + Image(systemName: isRun ? "stop.fill" : "play.fill") + } + } + } + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .primaryAction) + } + + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + selectedDance = 0 + } label: { + if selectedDance == 0 { + Label("Dance One", systemImage: "checkmark") + } else { + Text("Dance One") + } + } + + Button { + selectedDance = 1 + } label: { + if selectedDance == 1 { + Label("Dance Two", systemImage: "checkmark") + } else { + Text("Dance Two") + } + } + + Button { + selectedDance = 2 + } label: { + if selectedDance == 2 { + Label("Dance Three", systemImage: "checkmark") + } else { + Text("Dance Three") + } + } + } label: { + Label { + Text("Dance") + } icon: { + Image(systemName: "figure.dance") + } + } + } + + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .primaryAction) + } + + ToolbarItem(placement: .primaryAction) { + Button { + editDanceData = DanceData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000) + editDanceDataIndex = nil + danceList.append(editDanceData) + saveDance() + } label: { + Label { + Text("Add Dance") + } icon: { + Image(systemName: "plus") + } + } + } + } + .toolbar(.hidden, for: .tabBar) + .navigationTitle(danceTitle()) + .onAppear { + getDanceList() + } + .onDisappear{ + isRun = false + stopDance() + } + } + + private func startDance() { + var duration = 1000 + let jsonString = danceList.toJsonString() + for i in danceList { + duration = duration + i.durationMs + } + appState.sendWebSocketMessage(.dance, jsonString.toData()) + let interval = max(Double(duration) / 1000.0, 0.1) + danceTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { timer in + appState.sendWebSocketMessage(.dance, jsonString.toData()) + } + } + + /// Stop the dance timer + private func stopDance() { + danceTimer?.invalidate() + danceTimer = nil + } + + private func danceTitle() -> String { + switch selectedDance { + case 0: return "Dance One" + case 1: return "Dance Two" + case 2: return "Dance Three" + default: return "Dance" + } + } + + private func sendDanceData(data: DanceData) { + if !appState.deviceMac.isEmpty { + let motionData = MotionData(pitchServo: data.pitchServo, yawServo: data.yawServo) + let jsonString = appState.deviceMac + motionData.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlMotion, data) + } + } + + private func danceItemView(index: Int) -> some View { + HStack { + VStack { + if modelDanceList.count > index { + StackChanRobot(data: modelDanceList[index]) + .frame(width: 80,height: 80) + } + Spacer() + Button { + let currentData = danceList[index].copy() + if danceList.indices.contains(index + 1) { + danceList.insert(currentData, at: index + 1) + } else { + danceList.append(currentData) + } + saveDance() + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + VStack(alignment: .leading) { + Text("Left-Right") + .frame(width: 80,alignment: .leading) + HStack { + Slider( + value: Binding( + get: { Double(danceList[index].yawServo.angle) }, + set: { danceList[index].yawServo.angle = Int($0) } + ), + in: -1280...1280, + step: 10, + onEditingChanged: { editing in + if !editing { + saveDance() + sendDanceData(data: danceList[index]) + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceList[index].yawServo.angle)) + .frame(width: 50,alignment: .trailing) + } + Text("Up-down") + .frame(width: 80,alignment: .leading) + HStack { + Slider( + value: Binding( + get: { Double(danceList[index].pitchServo.angle) }, + set: { danceList[index].pitchServo.angle = Int($0) } + ), + in: 0...900, + step: 10, + onEditingChanged: { editing in + if !editing { + saveDance() + sendDanceData(data: danceList[index]) + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceList[index].pitchServo.angle)) + .frame(width: 50,alignment: .trailing) + } + Text("Duration") + .frame(width: 80,alignment: .leading) + HStack { + Slider( + value: Binding( + get: { Double(danceList[index].durationMs) }, + set: { danceList[index].durationMs = Int($0) } + ), + in: 0...3000, + step: 10, + onEditingChanged: { editing in + if !editing { + saveDance() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceList[index].durationMs)) + .frame(width: 50,alignment: .trailing) + } + } + } + } + + private func getDanceList() { + let map = [ + ValueConstant.mac: appState.deviceMac + ] + Networking.shared.get(pathUrl: Urls.dance,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response<[String:[DanceData]]>.decode(from: success) + if response.isSuccess, let map = response.data { + if selectedDance == 0 { + danceList = map["0"] ?? [] + } else if selectedDance == 1 { + danceList = map["1"] ?? [] + } else if selectedDance == 2 { + danceList = map["2"] ?? [] + } + modelDanceList = danceList + } + } catch { + print("Failed to parse response data") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + + private func saveDance() { + if let dict = danceList.toListDictionary() { + let map: [String: Any] = [ + ValueConstant.mac: appState.deviceMac, + ValueConstant.list: dict, + ValueConstant.index: selectedDance + ] + Networking.shared.post(pathUrl: Urls.dance,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess, let data = response.data { + modelDanceList = danceList + print(data) + } + } catch { + print("Failed to parse response data") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + } +} + +struct AddAvatarMotion : View { + + @Binding var isPresented: Bool + + @Binding var editDanceDataIndex: Int? + + @State private var selectedItem: ControlItem = .avatar + + @EnvironmentObject var appState: AppState + + @Binding var danceData: DanceData + + let onCallBack : ((DanceData) -> Void)? + + enum ControlItem: String,CaseIterable, Identifiable { + case avatar = "Avatar" + case motion = "Motion" + var id: String { rawValue } + } + + var body: some View { + NavigationStack { + VStack { + HStack { + Spacer() + StackChanRobot(data: danceData,allowsCameraControl: false) + .frame(width: 300,height: 300) + Spacer() + } + HStack { + Text("duration") + .frame(width: 100,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.durationMs) }, + set: { danceData.durationMs = Int($0) } + ), + in: 0...3000 + ) + .frame(maxWidth: .infinity) + Text(String(danceData.durationMs)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Picker("Select", selection: $selectedItem) { + ForEach(ControlItem.allCases) { item in + Text(item.rawValue) + .tag(item) + } + } + .pickerStyle(.segmented) + Button { + withAnimation { + danceData = DanceData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000) + } + saveData() + } label: { + Image(systemName: "arrow.counterclockwise") + } + .glassButtonStyle() + } + + if selectedItem == .avatar { + List { + Section("Left Eye") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.x) }, + set: { danceData.leftEye.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.y) }, + set: { danceData.leftEye.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.rotation) }, + set: { danceData.leftEye.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.weight) }, + set: { danceData.leftEye.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.weight)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("size") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.size) }, + set: { danceData.leftEye.size = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.size)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + + Section("Right Eye") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.x) }, + set: { danceData.rightEye.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.y) }, + set: { danceData.rightEye.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.rotation) }, + set: { danceData.rightEye.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.weight) }, + set: { danceData.rightEye.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.weight)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("size") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.size) }, + set: { danceData.rightEye.size = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.size)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + + Section("Mouth") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.mouth.x) }, + set: { danceData.mouth.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.mouth.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.mouth.y) }, + set: { danceData.mouth.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.mouth.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.mouth.rotation) }, + set: { danceData.mouth.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.mouth.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.mouth.weight) }, + set: { danceData.mouth.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.mouth.weight)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(.clear) + } else if selectedItem == .motion { + List { + Section("Yaw Servo") { + HStack { + Text("angle") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.yawServo.angle) }, + set: { + danceData.yawServo.angle = Int($0) + } + ), + in: -1280...1280, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.yawServo.angle)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("speed") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.yawServo.speed) }, + set: { danceData.yawServo.speed = Int($0) } + ), + in: 0...1000, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.yawServo.speed)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + Section("Pitch Servo") { + HStack { + Text("angle") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.pitchServo.angle) }, + set: { danceData.pitchServo.angle = Int($0) } + ), + in: 0...900, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.pitchServo.angle)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("speed") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.pitchServo.speed) }, + set: { danceData.pitchServo.speed = Int($0) } + ), + in: 0...1000, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.pitchServo.speed)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(.clear) + } + Spacer() + } + .padding() + .ignoresSafeArea(edges: .bottom) + .navigationTitle(editDanceDataIndex == nil ? "Add Dance" : "Edit Dance") + .navigationBarTitleDisplayMode(.inline) + .toolbar{ + ToolbarItem(placement: .confirmationAction) { + Button { + self.onCallBack?(danceData) + isPresented = false + } label: { + Image(systemName: "checkmark") + } + } + ToolbarItem(placement: .cancellationAction) { + Button { + isPresented = false + } label: { + Image(systemName: "xmark") + } + } + } + } + } + + private func saveData() { + if !appState.deviceMac.isEmpty { + + } + } +} diff --git a/app/StackChan/View/DeviceWifiConfig.swift b/app/StackChan/View/DeviceWifiConfig.swift new file mode 100644 index 0000000..8756f8b --- /dev/null +++ b/app/StackChan/View/DeviceWifiConfig.swift @@ -0,0 +1,250 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import NetworkExtension +import CoreLocation +import CoreBluetooth + +enum BlufDeviceConfigPageType: Hashable { + case selectDevice + case wifiConfig +} + +struct SelectBlufiDevice : View { + + @EnvironmentObject var appState: AppState + + @State var path: [BlufDeviceConfigPageType] = [] + + private func getDeviceId(blufiInfo: BlufiDeviceInfo) -> String? { + if let manufacturerData = blufiInfo.advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data { + let companyID = manufacturerData.prefix(2) + _ = UInt16(littleEndian: companyID.withUnsafeBytes { $0.load(as: UInt16.self) }) + let customData = manufacturerData.suffix(from: 2) + let address = customData.map { String(format: "%02X", $0) }.joined() + return address + } + return nil + } + + var body: some View { + NavigationStack(path: $path) { + List { + Section(header: Text("StackChan Device List").textCase(nil)) { + ForEach(appState.blufDeviceList, id: \.peripheral.identifier.uuidString) { blufiDeviceInfo in + Button { + if let mac = getDeviceId(blufiInfo: blufiDeviceInfo) { + appState.deviceMac = mac + appState.connectWebSocket() + } + print("start connect device") + BlufiUtil.shared.connect(peripheral: blufiDeviceInfo.peripheral) + } label: { + HStack { + Image("lateral_image") + .resizable() + .frame(width: 25, height: 25) + + VStack(alignment: .leading) { + Text("Name: " + (blufiDeviceInfo.peripheral.name ?? "StackChan")) + if let deviceId = getDeviceId(blufiInfo: blufiDeviceInfo) { + Text("Device ID: \(deviceId)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + .navigationTitle("Select Device") + .listStyle(.insetGrouped) + .background(Color(UIColor.systemGroupedBackground)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + appState.manualShutdownTime = Date() + appState.showDeviceWifiSet = false + } label: { + Label("Cancel", systemImage: "xmark") + } + } + } + .navigationDestination(for: BlufDeviceConfigPageType.self) { BlufDeviceConfigPageType in + switch BlufDeviceConfigPageType { + case .selectDevice: + SelectBlufiDevice() + case .wifiConfig: + DeviceWifiConfig() + } + } + .onAppear { + BlufiUtil.shared.characteristicCallback = { characteristic in + // Check whether the characteristic is writable + if characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) { + if characteristic.uuid.uuidString == "E2E5E5E3-1234-5678-1234-56789ABCDEF0" { + BlufiUtil.shared.writeWifiSetCharacteristic = characteristic + self.path.append(.wifiConfig) + } + } + } + } + } + } +} + +/// Wi-Fi configuration view +struct DeviceWifiConfig : View { + + enum Field { + case Name + case Password + } + + @State private var wifiName: String = "" + @State private var wifiPassword: String = "" + + @State private var locationManager = CLLocationManager() + @State private var locationDelegate = LocationDelegate() + + @EnvironmentObject private var appState: AppState + + @FocusState private var focusedField: Field? + + @State private var showAlert: Bool = false + @State private var alertMessage: String = "" + + @State private var title: String = "StackChan Wifi Setting" + + var body: some View { + List { + Section(header: Text("Name")) { + TextField("Please enter the name of the wifi", text:$wifiName) + .focused($focusedField, equals: .Name) + .submitLabel(.next) + .onSubmit { + focusedField = .Password + } + } + Section(header: Text("Password")) { + TextField("Please enter the password of the wifi", text:$wifiPassword) + .focused($focusedField, equals: .Password) + .submitLabel(.done) + .onSubmit { + confirmWifi() + } + } + } + .listStyle(.insetGrouped) + .background(Color(UIColor.systemGroupedBackground)) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + confirmWifi() + } label: { + Label("Submit", systemImage: "checkmark") + } + } + ToolbarItem(placement: .cancellationAction) { + Button { + appState.showDeviceWifiSet = false + BlufiUtil.shared.disconnectCurrentPeripheral() + } label: { + Label("Cancel", systemImage: "xmark") + } + } + } + .alert(alertMessage, isPresented: $showAlert, actions: { + Button("Confirm") { + alertMessage = "" + showAlert = false + } + }) + .navigationTitle(title) + .onAppear { + BlufiUtil.shared.wifiSetCharacteristicCall = { data in + let json = data.hexEncodedString() + + print(data) + + if let model = BlufiModel.fromJson(json), let state = model.data?.state { + if state == "wifiConnecting" { + // Configuring Wi-Fi + title = "In the configuration..." + } else if state == "wifiConnected" { + // Configuration succeeded + title = "Configuration successful" + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + appState.showDeviceWifiSet = false + } + } else if state == "wifiConnectFailed" { + // Configuration failed + title = "Configuration failed" + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + alertMessage = "Configuration failed, please re-enter wifi name and password" + showAlert = true + focusedField = .Password + } + } + } + } + locationDelegate.onAuthorized = { + getPermission() + } + locationManager.delegate = locationDelegate + getPermission() + } + .onDisappear { + locationManager.delegate = nil + } + } + + private func getWifiInfo() { + NEHotspotNetwork.fetchCurrent { network in + if let network = network { + wifiName = network.ssid + focusedField = .Password + } + } + } + + private func confirmWifi() { + if wifiName.isEmpty || wifiPassword.isEmpty { + alertMessage = "Please enter the full name and password" + showAlert = true + return + } + + let model = BlufiModel(cmd: "setWifi",data: BlufiWifi(ssid: wifiName,password: wifiPassword)) + if let json = model.toJson() { + BlufiUtil.shared.sendWifiSetData(json) + } + } + + private func getPermission() { + if #available(iOS 14.0, *) { + switch locationManager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + getWifiInfo() + break + case .denied, .restricted: + break + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + break + default: + break + } + } else { + locationManager.requestWhenInUseAuthorization() + } + } +} diff --git a/app/StackChan/View/JoystickView.swift b/app/StackChan/View/JoystickView.swift new file mode 100644 index 0000000..5b8cdb1 --- /dev/null +++ b/app/StackChan/View/JoystickView.swift @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct JoystickView: View { + + @State private var dragOffset: CGSize = .zero + + let callback: ((_ radians: CGFloat,_ strength: CGFloat) -> Void)? + + @State private var isDragging: Bool = false + + var body: some View { + GeometryReader { proxy in + let diameter = min(proxy.size.width, proxy.size.height) + let radius = diameter / 2 + let joystickDiameter = diameter / 4 + let stickRadius = joystickDiameter / 2 + let lineWidth: CGFloat = 4 + let maxRadius = radius - stickRadius - (lineWidth / 2) + ZStack { + Circle() + .stroke(Color(UIColor.separator), lineWidth: lineWidth) + .frame(width: diameter,height: diameter) + Circle() + .fill(Color.accent) + .frame(width: joystickDiameter,height: joystickDiameter) + .glassEffectCircle() + .offset(dragOffset) + } + .contentShape(Circle()) + .highPriorityGesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + withAnimation { + isDragging = true + } + let dx = value.location.x - radius + let dy = value.location.y - radius + let distance = sqrt(dx * dx + dy * dy) + if distance <= maxRadius { + dragOffset = CGSize(width: dx, height: dy) + } else { + let angle = atan2(dy, dx) + dragOffset = CGSize( + width: cos(angle) * maxRadius, + height: sin(angle) * maxRadius + ) + } + } + .onEnded { _ in + withAnimation { + isDragging = false + dragOffset = .zero + } + }, + including: .all + ) + .padding(0) + .onChange(of: dragOffset) { newValue in + guard isDragging else { return } + let dx = newValue.width + let dy = newValue.height + let distance = sqrt(dx * dx + dy * dy) + let radians = atan2(dy, dx) + // 直接使用之前定义的 maxRadius + let strength = min(distance / maxRadius, 1) + callback?(radians,strength) + } + } + } +} + +struct JoystickViewPreview : PreviewProvider { + static var previews: some View { + JoystickView { radians, strength in + print(radians) + } + } +} diff --git a/app/StackChan/View/MimicryEmotion.swift b/app/StackChan/View/MimicryEmotion.swift new file mode 100644 index 0000000..c558401 --- /dev/null +++ b/app/StackChan/View/MimicryEmotion.swift @@ -0,0 +1,633 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import AVFoundation +import ARKit + +struct MimicryEmotion: View { + + @State var microphone: Bool = false // Whether the microphone is enabled + @State var emotions: [String] = ["Smile"] // Commonly detected emotions + + @State var expressionData: ExpressionData = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()) + @State var headData: MotionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()) + + @State private var lastSendTime: Date = Date(timeIntervalSince1970: 0) + + @State private var volume: CGFloat = 0 + + @State var cameraImage: Data = Data() + + // Emotion detection threshold configuration + private let emotionThresholds = EmotionThresholds() + + private let feedback = UIImpactFeedbackGenerator(style: .medium) + + @EnvironmentObject var appState: AppState + + private let tag = "MimicryEmotion" + + @Binding var deviceMac: String + + @Environment(\.dismiss) var dismiss + + @State var decorate: Int = 1 // Decoration: 0 = none, 1 = StackChan, 2 = pig nose + + @State var showPhoneScreen: Bool = false // Whether to display phone screen on StackChan + + private let stackChanTargetSize = CGSize(width: 320, height: 240) + + var body: some View { + ZStack { + // Face camera preview + VStack(spacing: 0) { + if let uiImage = UIImage(data: cameraImage) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } else { + Color.black.aspectRatio(4/3, contentMode: .fit) + } + ARCameraView(expressionData: $expressionData, decorate: $decorate, captureScreen: $showPhoneScreen, onCallback: { session, anchors in + DispatchQueue.main.async { + emotionDetection(session: session, anchors: anchors) + } + }, onFrameCallback: { image in + compressMobilePhoneScreen(image: image) + }) + .frame(maxWidth: .infinity,maxHeight: .infinity) + } + .ignoresSafeArea() + + VStack { + + HStack { + Spacer() + } + + Spacer() + + HStack { + Button { + withAnimation { + if decorate == 0 { + decorate = 1 + } else if decorate == 1 { + decorate = 2 + } else if decorate == 2 { + decorate = 0 + } + } + feedback.impactOccurred() + } label: { + switch decorate { + case 0: + Image(systemName: "slash.circle") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + case 1: + Image("lateral_image") + .resizable() + .frame(width: 44, height: 44) + .padding(22) + case 2: + Text("🐽") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + default: + Text("🎲") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + } + } + .glassEffectCircle() + Spacer() + Button { + withAnimation { + microphone.toggle() + } + feedback.impactOccurred() + if microphone { + AudioAcquisitionUtil.shared.start() + } else { + AudioAcquisitionUtil.shared.stop() + } + } label: { + if microphone { + Image(systemName: "microphone") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + .symbolVariant(volume > 0.3 ? .fill : .none) + } else { + Image(systemName: "microphone.slash") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + } + } + .glassEffectCircle() + Button { + withAnimation { + showPhoneScreen.toggle() + } + feedback.impactOccurred() + if showPhoneScreen { + appState.sendWebSocketMessage(.onPhoneScreen, deviceMac.toData()) + } else { + appState.sendWebSocketMessage(.offPhoneScreen, deviceMac.toData()) + } + } label: { + if showPhoneScreen { + Image(systemName: "iphone.gen1.badge.play") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.accent) + } else { + Image(systemName: "iphone.gen1.badge.play") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + } + } + .glassEffectCircle() + } + } + .padding() + } + .onAppear { + AudioAcquisitionUtil.shared.onAudioData = { data in + + } + AudioAcquisitionUtil.shared.onDecibel = { value in + self.volume = CGFloat(value) + } + + /// Register audio and video listener callbacks + WebSocketUtil.shared.addObserver(for: tag) { (message: URLSessionWebSocketTask.Message) in + switch message { + case .data(let data): + let result = appState.parseMessage(message: data) + if let msgType = result.0, let parsedData = result.1 { + switch msgType { + case MsgType.jpeg: + DispatchQueue.main.async { + cameraImage = parsedData + } + case MsgType.hangupCall: + // Hang up the call + + print("StackChan hung up the call") + + DispatchQueue.main.async { + dismiss() + } + default: + break + } + } + case .string(let text): + print("Received text message: \(text)") + @unknown default: + break + } + } + // Turn on device camera + + appState.sendWebSocketMessage(.onCamera,deviceMac.toData()) + } + .onDisappear { + WebSocketUtil.shared.removeObserver(for: tag) + appState.sendWebSocketMessage(.offPhoneScreen, deviceMac.toData()) + // Turn off device camera + appState.sendWebSocketMessage(.offCamera,deviceMac.toData()) + if deviceMac != appState.deviceMac { + appState.sendWebSocketMessage(.hangupCall) + } + } + .toolbar(.hidden, for: .tabBar) + .preferredColorScheme(.dark) + .navigationTitle("AVATAR") + .navigationBarTitleDisplayMode(.inline) + } + // Compress phone screen image and send to StackChan + private func compressMobilePhoneScreen(image: UIImage) { + if let jpegData = image.compress(to: stackChanTargetSize, memorySize: 0.02, cropCenter: true) { + guard let macData = deviceMac.toData() else { return } + let data = macData + jpegData + appState.sendWebSocketMessage(.jpeg, data) + } + } + + // Detect head motion data from AR session + private func detectHeadData(session: ARSession,faceAnchor: ARFaceAnchor) -> MotionData { + // Get face transform in world coordinate space + let faceTransform = faceAnchor.transform + + // Get camera transform of the current frame (phone position and orientation in world space) + guard let cameraTransform = session.currentFrame?.camera.transform else { + return MotionData(pitchServo: MotionDataItem(angle: 0, speed: 500), + yawServo: MotionDataItem(angle: 0, speed: 500)) + } + + // Relative transform = inverse camera transform × face transform + let relativeTransform = simd_mul(simd_inverse(cameraTransform), faceTransform) + let relativeMatrix = SCNMatrix4(relativeTransform) + + // Extract yaw and pitch angles from relative rotation matrix + let pitch = atan2(relativeMatrix.m31, relativeMatrix.m33) // Vertical rotation (pitch) + let yaw = asin(-relativeMatrix.m32) // Horizontal rotation (yaw) + + // Convert radians to degrees + let pitchDeg = pitch * 180.0 / .pi + let yawDeg = yaw * 180.0 / .pi + + // Map yaw angle to servo range (-1280 to 1280) + let yawServoAngle = max(-1280, min(1280, Int(-yawDeg * 20))) + + // Map pitch angle to servo range (0 to 900) + // Looking straight = 0, looking up = 900 + let pitchServoAngle = max(0, min(900, Int(-pitchDeg * 10))) + + let pitchItem = MotionDataItem(angle: pitchServoAngle, speed: 500) + let yawItem = MotionDataItem(angle: yawServoAngle, speed: 500) + + return MotionData(pitchServo: pitchItem, yawServo: yawItem) + } + + // Build ExpressionData from blendShapes + private func buildExpressionData(faceAnchor: ARFaceAnchor) -> ExpressionData { + let blendShapes = faceAnchor.blendShapes + + // Left eye blink amount mapped to 0~100 + let eyeBlinkLeft = blendShapes[.eyeBlinkLeft]?.floatValue ?? 0 + let leftEyeWeight = max(0, min(100, Int((1.0 - eyeBlinkLeft) * 100))) + + // Right eye blink amount mapped to 0~100 + let eyeBlinkRight = blendShapes[.eyeBlinkRight]?.floatValue ?? 0 + let rightEyeWeight = max(0, min(100, Int((1.0 - eyeBlinkRight) * 100))) + + // Build ExpressionItem + let leftEye = ExpressionItem( + x: max(-100, min(100, Int(faceAnchor.lookAtPoint.x * 800))), + y: max(-100, min(100, Int(-faceAnchor.lookAtPoint.y * 500))), + rotation: 0, + weight: leftEyeWeight + ) + + let rightEye = ExpressionItem( + x: max(-100, min(100, Int(faceAnchor.lookAtPoint.x * 800))), + y: max(-100, min(100, Int(-faceAnchor.lookAtPoint.y * 500))), + rotation: 0, + weight: rightEyeWeight + ) + + // Mouth + let jawOpen = blendShapes[.jawOpen]?.floatValue ?? 0 + let mouthSmileLeft = blendShapes[.mouthSmileLeft]?.floatValue ?? 0 + let mouthSmileRight = blendShapes[.mouthSmileRight]?.floatValue ?? 0 + + // Calculate X and Y offsets + let mouthX = max(-100, min(100, Int((mouthSmileRight - mouthSmileLeft) * 100))) + + // Calculate mouth open weight + let mouthWeight = max(0, min(100, Int(jawOpen * 100))) + + let mouth = ExpressionItem( + x: mouthX, + y: 0, + rotation: 0, + weight: mouthWeight + ) + + var expressionData = ExpressionData(leftEye: leftEye, + rightEye: rightEye, + mouth: mouth) + + // // Start emotion-based adjustment + if isHappy(blendShapes: blendShapes) { + // Happy + expressionData.leftEye.weight -= 35 + expressionData.leftEye.rotation = -2150 + expressionData.rightEye.weight -= 35 + expressionData.rightEye.rotation = 2150 + } + // if isShy(faceAnchor: faceAnchor, blendShapes: blendShapes) { + // // Shy + // } + // if isAmazed(blendShapes: blendShapes) { + // // Amazed + // } + if isAnger(blendShapes: blendShapes) { + // Angry + expressionData.leftEye.rotation = 450 + expressionData.rightEye.rotation = -450 + } + // if isTired(blendShapes: blendShapes) { + // // Tired + // } + return expressionData + } + + /// Main emotion detection function + private func emotionDetection(session: ARSession,anchors: [ARAnchor]) { + var detectedEmotions: [String] = [] + if let anchor = anchors.first { + guard let faceAnchor = anchor as? ARFaceAnchor else { return } + let faceData = buildExpressionData(faceAnchor: faceAnchor) + let headData = detectHeadData(session:session,faceAnchor: faceAnchor) + withAnimation { + self.expressionData = faceData + self.headData = headData + } + + /// Send data via Bluetooth + let now = Date() + if now.timeIntervalSince(lastSendTime) >= 0.5 { + self.sendExpressionData(data: faceData) + self.sendHeadData(data: headData) + lastSendTime = now + } + + + let blendShapes = faceAnchor.blendShapes + + // Detect five basic emotions + if isHappy(blendShapes: blendShapes) { + detectedEmotions.append("Happy") + } + if isShy(faceAnchor: faceAnchor, blendShapes: blendShapes) { + detectedEmotions.append("Shy") + } + if isAmazed(blendShapes: blendShapes) { + detectedEmotions.append("Amazed") + } + if isAnger(blendShapes: blendShapes) { + detectedEmotions.append("Angry") + } + if isTired(blendShapes: blendShapes) { + detectedEmotions.append("Tired") + } + + // Add gaze and head direction detection + detectedEmotions.append(getGazeDirection(faceAnchor: faceAnchor)) + detectedEmotions.append(getHeadDirection(faceAnchor: faceAnchor)) + } + withAnimation { + self.emotions = detectedEmotions + } + } + + private func sendExpressionData(data : ExpressionData) { + let jsonString = deviceMac + data.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlAvatar, data) + } + + private func sendHeadData(data : MotionData) { + let jsonString = deviceMac + data.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlMotion, data) + } + + /// Happy emotion detection + private func isHappy(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + let smileLeft = blendShapes[.mouthSmileLeft]?.floatValue ?? 0 + let smileRight = blendShapes[.mouthSmileRight]?.floatValue ?? 0 + let eyeSquintLeft = blendShapes[.eyeSquintLeft]?.floatValue ?? 0 + let eyeSquintRight = blendShapes[.eyeSquintRight]?.floatValue ?? 0 + let cheekSquintLeft = blendShapes[.cheekSquintLeft]?.floatValue ?? 0 + let cheekSquintRight = blendShapes[.cheekSquintRight]?.floatValue ?? 0 + + // Calculate overall smile intensity + let smileIntensity = (smileLeft + smileRight) / 2 + let eyeSquintIntensity = (eyeSquintLeft + eyeSquintRight) / 2 + let cheekSquintIntensity = (cheekSquintLeft + cheekSquintRight) / 2 + + // Happy expression requires a clear smile with eye muscle involvement + return smileIntensity > emotionThresholds.happy.smile && + (eyeSquintIntensity > emotionThresholds.happy.eyeSquint || + cheekSquintIntensity > emotionThresholds.happy.cheekSquint) + } + + /// Shy emotion detection + private func isShy(faceAnchor: ARFaceAnchor, blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + // 1. Slight or clear head tilt downward + let transform = faceAnchor.transform + let rotation = SCNMatrix4(transform) + let pitch = asin(-rotation.m32) // 上下旋转 + let isHeadDown = pitch > emotionThresholds.shy.headPitch + + // 2. Mouth closed with a slight smile + let mouthClose = blendShapes[.mouthClose]?.floatValue ?? 0 + let smileLeft = blendShapes[.mouthSmileLeft]?.floatValue ?? 0 + let smileRight = blendShapes[.mouthSmileRight]?.floatValue ?? 0 + let smileIntensity = (smileLeft + smileRight) / 2 + let isMouthClosedSmile = mouthClose > emotionThresholds.shy.mouthPress && smileIntensity > emotionThresholds.shy.smile + + // 3. Eyes looking sideways or downward + let lookAt = faceAnchor.lookAtPoint + let isLookingSideways = abs(lookAt.x) > emotionThresholds.gaze.xThreshold // Looking left or right + let isLookingDown = lookAt.y < -emotionThresholds.gaze.yThreshold // Looking downward + + return isHeadDown && isMouthClosedSmile && (isLookingSideways || isLookingDown) + } + + /// Amazed emotion detection + private func isAmazed(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + let jawOpen = blendShapes[.jawOpen]?.floatValue ?? 0 + let eyeWideLeft = blendShapes[.eyeWideLeft]?.floatValue ?? 0 + let eyeWideRight = blendShapes[.eyeWideRight]?.floatValue ?? 0 + let browInnerUp = blendShapes[.browInnerUp]?.floatValue ?? 0 + let mouthFunnel = blendShapes[.mouthFunnel]?.floatValue ?? 0 + + // Amazed traits: wide eyes + raised brows + (open mouth or O-shape) + let isEyesWide = (eyeWideLeft + eyeWideRight) / 2 > emotionThresholds.amazed.eyeWide + let isBrowRaised = browInnerUp > emotionThresholds.amazed.browInnerUp + let isMouthAction = jawOpen > emotionThresholds.amazed.jawOpen || + mouthFunnel > emotionThresholds.amazed.mouthFunnel + + return isEyesWide && isBrowRaised && isMouthAction + } + + /// Angry emotion detection + private func isAnger(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + // Brow features + let browDownLeft = blendShapes[.browDownLeft]?.floatValue ?? 0 + let browDownRight = blendShapes[.browDownRight]?.floatValue ?? 0 + + // Eye features + let eyeSquintLeft = blendShapes[.eyeSquintLeft]?.floatValue ?? 0 + let eyeSquintRight = blendShapes[.eyeSquintRight]?.floatValue ?? 0 + + // Mouth features + let mouthFrownLeft = blendShapes[.mouthFrownLeft]?.floatValue ?? 0 + let mouthFrownRight = blendShapes[.mouthFrownRight]?.floatValue ?? 0 + let mouthPressLeft = blendShapes[.mouthPressLeft]?.floatValue ?? 0 + let mouthPressRight = blendShapes[.mouthPressRight]?.floatValue ?? 0 + + // Nose features + let noseSneerLeft = blendShapes[.noseSneerLeft]?.floatValue ?? 0 + let noseSneerRight = blendShapes[.noseSneerRight]?.floatValue ?? 0 + + // Calculate averages + let avgBrowDown = (browDownLeft + browDownRight) / 2 + let avgEyeSquint = (eyeSquintLeft + eyeSquintRight) / 2 + let avgMouthFrown = (mouthFrownLeft + mouthFrownRight) / 2 + let avgMouthPress = (mouthPressLeft + mouthPressRight) / 2 + let avgNoseSneer = (noseSneerLeft + noseSneerRight) / 2 + + // Anger scoring system + var angerScore = 0 + + if avgBrowDown > emotionThresholds.anger.browDown { angerScore += 3 } + if avgEyeSquint > emotionThresholds.anger.eyeSquint { angerScore += 2 } + if avgMouthFrown > emotionThresholds.anger.mouthFrown { angerScore += 2 } + if avgMouthPress > emotionThresholds.anger.mouthPress { angerScore += 1 } + if avgNoseSneer > emotionThresholds.anger.noseSneer { angerScore += 1 } + + // Must reach threshold and include brow-down feature + return angerScore >= emotionThresholds.anger.minScore && + avgBrowDown > emotionThresholds.anger.browDown + } + + /// Tired emotion detection + private func isTired(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + let eyeBlinkLeft = blendShapes[.eyeBlinkLeft]?.floatValue ?? 0 + let eyeBlinkRight = blendShapes[.eyeBlinkRight]?.floatValue ?? 0 + let eyeSquintLeft = blendShapes[.eyeSquintLeft]?.floatValue ?? 0 + let eyeSquintRight = blendShapes[.eyeSquintRight]?.floatValue ?? 0 + + // Tired traits: eyes closed or squinting + let eyesClosed = (eyeBlinkLeft > emotionThresholds.tired.eyeClose && + eyeBlinkRight > emotionThresholds.tired.eyeClose) || + (eyeSquintLeft > emotionThresholds.tired.eyeSquint && + eyeSquintRight > emotionThresholds.tired.eyeSquint) + + return eyesClosed + } + + // MARK: - Helper Functions + + /// Get gaze direction + private func getGazeDirection(faceAnchor: ARFaceAnchor) -> String { + let lookAtPoint = faceAnchor.lookAtPoint + var direction = "" + + if lookAtPoint.x < -emotionThresholds.gaze.xThreshold { + direction += "Left" + } else if lookAtPoint.x > emotionThresholds.gaze.xThreshold { + direction += "Right" + } + + if lookAtPoint.y < -emotionThresholds.gaze.yThreshold { + direction += "Down" + } else if lookAtPoint.y > emotionThresholds.gaze.yThreshold { + direction += "Up" + } + + return direction.isEmpty ? "Looking Forward" : direction + " Look" + } + + /// Get head direction + private func getHeadDirection(faceAnchor: ARFaceAnchor) -> String { + let transform = faceAnchor.transform + let rotation = SCNMatrix4(transform) + let yaw = atan2(rotation.m31, rotation.m33) + let pitch = asin(-rotation.m32) + + var horizontal = "" + var vertical = "" + + if yaw < -emotionThresholds.head.yawThreshold { + horizontal = "Left" + } else if yaw > emotionThresholds.head.yawThreshold { + horizontal = "Right" + } + + // Correct vertical direction + if pitch < -emotionThresholds.head.pitchThreshold { + vertical = "Up" + } else if pitch > emotionThresholds.head.pitchThreshold { + vertical = "Down" + } + + if horizontal.isEmpty && vertical.isEmpty { + return "Head Facing Forward" + } else { + return "Head Facing " + vertical + horizontal + } + } +} + +// MARK: - Threshold Configuration + +private struct EmotionThresholds { + // Happy emotion thresholds + struct Happy { + let smile: Float = 0.3 + let eyeSquint: Float = 0.15 + let cheekSquint: Float = 0.1 + } + + // Shy emotion thresholds + struct Shy { + let headPitch: Float = 0.08 + let eyeSquint: Float = 0.1 + let mouthPress: Float = 0.25 + let smile: Float = 0.15 + } + + // Amazed emotion thresholds + struct Amazed { + let eyeWide: Float = 0.4 + let browInnerUp: Float = 0.3 + let jawOpen: Float = 0.4 + let mouthFunnel: Float = 0.3 + } + + // Angry emotion thresholds + struct Anger { + let browDown: Float = 0.35 + let eyeSquint: Float = 0.25 + let mouthFrown: Float = 0.2 + let mouthPress: Float = 0.2 + let noseSneer: Float = 0.15 + let minScore: Int = 5 + } + + // Tired emotion thresholds + struct Tired { + let eyeClose: Float = 0.7 + let eyeSquint: Float = 0.5 + let jawOpen: Float = 0.3 + } + + // Gaze detection thresholds + struct Gaze { + let xThreshold: Float = 0.02 + let yThreshold: Float = 0.02 + } + + // Head direction thresholds + struct Head { + let yawThreshold: Float = 0.25 + let pitchThreshold: Float = 0.25 + } + + let happy = Happy() + let shy = Shy() + let amazed = Amazed() + let anger = Anger() + let tired = Tired() + let gaze = Gaze() + let head = Head() +} diff --git a/app/StackChan/View/Moments.swift b/app/StackChan/View/Moments.swift new file mode 100644 index 0000000..4dfad70 --- /dev/null +++ b/app/StackChan/View/Moments.swift @@ -0,0 +1,509 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import PhotosUI + +struct Moments : View { + + @State private var posts: [Post] = [] + + @State private var showAddMoment: Bool = false + + @EnvironmentObject var appState: AppState + + @State private var page = 1 + private let pageSize = 10 + @State private var isLoadingMore = false + @State private var hasMore = true + + @State private var postId: Int? = nil + @State private var editPostCommentContent: String? = nil + @State private var showAddPostComment: Bool = false + + var body: some View { + NavigationStack { + ZStack { + LinearGradient( + colors: [Color.accent.opacity(0.5), Color.pink.opacity(0.1),Color.blue.opacity(0.2)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + ScrollView { + LazyVStack(spacing:12) { + ForEach(posts, id: \.self.id) { post in + postItemView(post: post) + .onAppear { + if post.id == posts.last?.id { + loadMoreIfNeeded() + } + } + } + if isLoadingMore { + ProgressView() + .padding() + } + } + .padding(12) + } + .refreshable { + page = 1 + posts.removeAll() + getPost() + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + self.showAddMoment = true + } + } label: { + Label("Add", systemImage: "plus") + } + } + } + .navigationTitle("Moments") + .sheet(isPresented: $showAddMoment) { + AddMoment(showAddMoment:$showAddMoment) { post in + // Add a new post + addPost(post: post) + } + .interactiveDismissDisabled(true) + } + .alert("Add PostComment", isPresented: $showAddPostComment) { + TextField("Enter your comment", text: Binding( + get: { editPostCommentContent ?? "" }, + set: { editPostCommentContent = $0 } + )) + Button(role: .cancel) { + showAddPostComment = false + } label: { + Text("Cancel") + } + if #available(iOS 26.0, *) { + Button(role: .confirm) { + showAddPostComment = false + addPostComment() + } label: { + Text("Confirm") + } + } else { + Button { + showAddPostComment = false + addPostComment() + } label: { + Text("Confirm") + } + } + } message: { + Text("Please enter your comment below.") + } + .onAppear { + page = 1 + posts.removeAll() + getPost() + } + } + } + + private func postItemView(post: Post) -> some View { + return VStack(alignment: .leading,spacing: 12) { + HStack { + Image("logo_icon") + .resizable() + .frame(width: 25, height: 25) + .clipShape(Circle()) + Text(post.name ?? "StackChanUser") + .font(.system(size: 25)) + Spacer() + if post.mac == appState.deviceMac { + Button(role: .destructive) { + deletePost(post) + } label: { + Image(systemName: "trash") + } + .glassButtonStyle() + } + } + Text(post.contentText ?? "") + .font(.body) + .foregroundStyle(.primary) + if let imageUrl = post.contentImage, imageUrl != "" { + HStack { + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .empty: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: 300) + case .success(let image): + image + .resizable() + .scaledToFit() + .frame(maxHeight: 300) + case .failure: + Image(systemName: "photo") + .resizable() + .scaledToFit() + .frame(maxHeight: 300) + @unknown default: + EmptyView() + } + } + Spacer() + } + } + HStack(spacing: 25) { + Text(post.createdAt ?? "") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Button(action: { + // Comment + postId = post.id + editPostCommentContent = "" + showAddPostComment = true + }) { + Label("99", systemImage: "text.bubble") + } + + Button(action: { + // Like + }) { + Label("99", systemImage: "hand.thumbsup") + } + + Button(action: { + // Share + }) { + Image(systemName: "square.and.arrow.up") + } + } + .padding(12) + .font(.caption) + .foregroundStyle(.secondary) + + if let comments = post.postCommentList, !comments.isEmpty { + ForEach(comments, id: \.id) { comment in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text((comment.name ?? "User") + ": ") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.accentColor) + Text(comment.content ?? "") + .font(.caption) + Spacer() + } + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity) + .glassEffectRegular(cornerRadius: 25) + } + + private func addPostComment() { + if let content = editPostCommentContent,let postId = postId, !appState.deviceMac.isEmpty { + let map: [String: Any] = [ + ValueConstant.mac : appState.deviceMac, + ValueConstant.postId : postId, + ValueConstant.content : content + ] + Networking.shared.post(pathUrl: Urls.postCommentCreate, parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response<[String: Int]>.decode(from: success) + if response.isSuccess { + getPostComment(postId: postId) + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + } + } + + private func getPostComment(postId: Int) { + if !appState.deviceMac.isEmpty { + let map: [String: Any] = [ + ValueConstant.postId: postId, + ValueConstant.mac: appState.deviceMac, + ValueConstant.page: 1, + ValueConstant.pageSize: 30, + ] + + Networking.shared.get(pathUrl: Urls.postCommentGet,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess,let list = response.data?.list { + for index in posts.indices { + if posts[index].id == postId { + posts[index].postCommentList = list + break + } + } + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + + } + + + } + + private func addPost(post: Post) { + let map: [String:Any] = [ + ValueConstant.mac: appState.deviceMac, + ValueConstant.content_text: post.contentText ?? "", + ValueConstant.content_image: post.contentImage ?? "", + ] + Networking.shared.post(pathUrl: Urls.postAdd, parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response<[String:Int]>.decode(from: success) + if response.isSuccess { + // Refresh posts + page = 1 + posts.removeAll() + getPost() + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + } + + /// Delete a post + private func deletePost(_ post: Post) { + let map: [String: Any] = [ + ValueConstant.id: post.id + ] + Networking.shared.delete(pathUrl: Urls.postDelete, parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess { + // Remove post locally + withAnimation { + posts.removeAll { $0.id == post.id } + } + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Delete failed: + print("Delete failed:", failure) + } + } + } + + /// Fetch post list + private func getPost() { + isLoadingMore = true + let map:[String:Any] = [ + ValueConstant.page: page, + ValueConstant.pageSize: pageSize + ] + Networking.shared.get(pathUrl: Urls.postGet,parameters: map) { result in + isLoadingMore = false + switch result { + case .success(let success): + do { + let response = try Response<[Post]>.decode(from: success) + if response.isSuccess,let list = response.data { + withAnimation { + if list.count < pageSize { + hasMore = false + } + posts.append(contentsOf: list) + } + } + } catch { + + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + } + + private func loadMoreIfNeeded() { + guard !isLoadingMore, hasMore else { return } + page += 1 + getPost() + } + +} + + + +struct AddMoment : View { + + @Binding var showAddMoment: Bool + + var callBack: ((Post) -> Void)? + + @State private var post: Post = Post(id: 0) + @State private var photoItem: PhotosPickerItem? + @State private var isUploading: Bool = false + + @EnvironmentObject var appState: AppState + + var body: some View { + NavigationStack { + List { + Section("Text") { + TextField("Please enter the post content", text: Binding( + get: { + post.contentText ?? "" + }, + set: { + post.contentText = $0 + } + ), axis: .vertical) + .textFieldStyle(.plain) + } + Section("image") { + PhotosPicker(selection: $photoItem, matching: .images) { + if isUploading { + ProgressView("Uploading...") + } else { + HStack { + Spacer() + if let urlString = post.contentImage, + let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + .frame(width: 200, height: 200) + case .success(let image): + image + .resizable() + .scaledToFit() + .frame(maxHeight: 300) + case .failure: + Image(systemName: "photo") + .frame(width: 200, height: 200) + @unknown default: + EmptyView() + } + } + } else { + Label("Select Image", systemImage: "plus.circle") + } + Spacer() + } + } + } + .onChange(of: photoItem) { _ in + updateImage() + } + } + } + .navigationTitle("Add Post") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + self.showAddMoment = false + } label: { + Label("Cancel", systemImage: "xmark") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + callBack?(post) + self.showAddMoment = false + } label: { + Label("Confirm", systemImage: "checkmark") + } + } + } + } + } + + /// Upload file + private func updateImage() { + guard let photoItem else { return } + isUploading = true + Task { + do { + let data = try await photoItem.loadTransferable(type: Data.self) + guard var imageData = data else { + // Failed to get image data + print("Failed to get image data") + isUploading = false + return + } + + // Compress the image to no more than 2MB + if let uiImage = UIImage(data: imageData), + let compressedData = uiImage.compress(toMemorySize: 2.0) { + imageData = compressedData + } + + let map: [String:Any] = [ + ValueConstant.file: imageData, + ValueConstant.directory: ValueConstant.moments, + ValueConstant.name: UUID().uuidString + ".jpg", + ] + Networking.shared.postFromData(pathUrl: Urls.uploadFile,parameters: map) { result in + isUploading = false + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess, let url = response.data?.path { + let fileUrl = Urls.getFileUrl() + url + DispatchQueue.main.async { + post.contentImage = fileUrl + } + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + } catch { + isUploading = false + // Failed to load image data: + print("Failed to load image data:", error) + } + } + } +} diff --git a/app/StackChan/View/Nearby.swift b/app/StackChan/View/Nearby.swift new file mode 100644 index 0000000..4406b3a --- /dev/null +++ b/app/StackChan/View/Nearby.swift @@ -0,0 +1,405 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import MultipeerConnectivity +import CoreBluetooth + +struct TextMessage : Codable { + var name: String = "" + var content: String = "" +} + +struct Nearby: View { + + @State var deviceList: [DeviceInfo] = [] + + @State var proxySize : CGSize = CGSize(width: 0, height: 0) + + @EnvironmentObject var appState: AppState + + @State var deviceMac: String = "" + + @State private var showCallPopup: Bool = false + + @State private var displayMode: Int = 1 // 1 star map mode, 2 list mode + + private let tag = "Nearby" + @State private var callTitle: String = "Under request..." + + var body: some View { + NavigationStack(path: $appState.nearbyPath) { + ZStack { + DazzlingBackground(backColors: [Color.accent.opacity(0.5), Color.pink.opacity(0.1),Color.blue.opacity(0.2)],background: Color(UIColor.systemBackground)) + .ignoresSafeArea() + + if displayMode == 1 { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + + for device in deviceList { + var path = Path() + path.move(to: center) + path.addLine(to: device.postion) + context.stroke( + path, + with: .color(.white), + lineWidth: 3 + ) + } + } + + GeometryReader { proxy in + Color.clear + .onAppear { + proxySize = proxy.size + } + .onChange(of: proxy.size) { newSize in + proxySize = newSize + } + ForEach(deviceList, id: \.device.mac) { device in + Menu { + Button { + // Hi button logic + let textMessage = TextMessage(name:"App",content: "👋") + sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString()) + launchAnimation(device: device, text: "👋") + } label: { + Label("👋", systemImage: "hand.wave") + } + Button { + // Heart button logic + let textMessage = TextMessage(name:"App",content: "❤️") + sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString()) + launchAnimation(device: device, text:"❤️") + } label: { + Label("❤️", systemImage: "heart.fill") + } + Button { + // Video call button logic + sendMessage(device: device,msgType: .requestCall, data: "") + // Show request popup animation + + } label: { + Label("Video Call", systemImage: "video.fill") + } + } label: { + let name = (device.device.name?.isEmpty == false) ? device.device.name! : "StackChan" + AvatarView(name: name) + .frame(width: 100) + } + .position(x: device.postion.x, y: device.postion.y) + } + } + RippleDiffusion { + AvatarView(name: appState.deviceInfo.name ?? "Me") + } + ForEach(flyingTexts) { flying in + Text(flying.text) + .font(.largeTitle) + .position(x: flying.start.x + (flying.end.x - flying.start.x) * flying.progress, + y: flying.start.y + (flying.end.y - flying.start.y) * flying.progress + ) + .opacity(1 - flying.progress) + } + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(deviceList, id: \.device.mac) { device in + Menu { + Button { + // Hi button logic + let textMessage = TextMessage(name:"App",content: "👋") + sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString()) + launchAnimation(device: device, text: "👋") + } label: { + Label("👋", systemImage: "hand.wave") + } + Button { + // Heart button logic + let textMessage = TextMessage(name:"App",content: "❤️") + sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString()) + launchAnimation(device: device, text:"❤️") + } label: { + Label("❤️", systemImage: "heart.fill") + } + Button { + // Video call button logic + sendMessage(device: device,msgType: .requestCall, data: "") + // Show request popup animation + + } label: { + Label("Video Call", systemImage: "video.fill") + } + } label: { + Text(device.device.name ?? "Unknown") + .frame(maxWidth: .infinity) + .padding(12) + .glassEffectRegular(cornerRadius: 25) + } + } + } + } + .padding(12) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + if displayMode == 1 { + displayMode = 2 + } else { + displayMode = 1 + } + } label: { + Label { + Text("Display Mode") + } icon: { + Image(systemName: displayMode == 1 ? "circle.hexagonpath" : "list.bullet") + } + } + } + } + .navigationTitle("Nearby") + .navigationDestination(for: PageType.self) { PageType in + switch PageType { + case .cameraPage: + CameraPage() + case .minicryEmotion: + MimicryEmotion(deviceMac: $deviceMac) + case .dance: + Dance() + } + } + .sheet(isPresented: $showCallPopup) { + VStack(alignment:.center) { + Spacer() + Text(callTitle).font(.largeTitle) + Spacer() + HStack(alignment:.center) { + Spacer() + AvatarView(name: "Caller") + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + AvatarView(name: "Receiver") + Spacer() + } + Spacer() + Button { + showCallPopup = false + appState.sendWebSocketMessage(.hangupCall) + } label: { + VStack { + Color.clear.frame( + height: 15 + ) + Image(systemName: "phone.down.fill") + .font(.system(size: 50)) + .frame(width: 100, height: 100) + .background(.red) + .clipShape(Circle()) + .foregroundColor(.white) + .shadow(color: Color.gray, radius: 10, x: 0, y: 0) + Text("Hang up") + .frame(height: 15) + .foregroundColor(Color(UIColor.label)) + } + .frame(width: 100) + } + Spacer() + } + .presentationDetents([.medium]) + .presentationBackgroundClear() + .interactiveDismissDisabled(true) + } + .onAppear { + // bleInit() + getDeviceList() + WebSocketUtil.shared.addObserver(for: tag) { message in + switch message { + case .data(let data): + let result = appState.parseMessage(message: data) + if let msgType = result.0, let _ = result.1 { + switch msgType { + case MsgType.agreeCall: + // Agree to call + showCallPopup = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + appState.nearbyPath.append(.minicryEmotion) + } + case MsgType.refuseCall: + // Refuse call + callTitle = "The other party has refused." + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + showCallPopup = false + } + case MsgType.hangupCall: + showCallPopup = false + // Hang up call + default: + break + } + } + case .string(let text): + print("收到普通消息: \(text)") + @unknown default: + break + } + } + appState.getDeviceInfo() + } + .onDisappear { + WebSocketUtil.shared.removeObserver(for: tag) + } + } + } + + private func getDeviceList() { + let map = [ + ValueConstant.mac: appState.deviceMac + ] + Networking.shared.get(pathUrl: Urls.deviceRandomList,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response<[Device]>.decode(from: success) + if response.isSuccess, let list = response.data { + deviceList.removeAll() + for i in list { + let existingPositions = self.deviceList.map { $0.postion } + let newPosition = self.generateRandomPosition(existingPositions: existingPositions) + let newDevice = DeviceInfo(device: i, postion: newPosition) + withAnimation { + self.deviceList.append(newDevice) + } + } + } + } catch { + print("Data parsing failed") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + + @State private var flyingTexts: [FlyingText] = [] + + private func launchAnimation(device: DeviceInfo, text: String) { + let mineCenter = CGPoint(x: proxySize.width/2, y: proxySize.height/2) + let targetCenter = device.postion + let id = UUID() + let flyingText = FlyingText(id: id, text: text, start: mineCenter, end: targetCenter, progress: 0) + flyingTexts.append(flyingText) + + withAnimation(.linear(duration: 1.0)) { + if let index = flyingTexts.firstIndex(where: { $0.id == id }) { + flyingTexts[index].progress = 1 + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0,) { + flyingTexts.removeAll{ $0.id == id } + } + } + + private func sendMessage(device: DeviceInfo,msgType: MsgType, data: String) { + if msgType == .requestCall { + deviceMac = device.device.mac + showCallPopup = true + callTitle = "Under request..." + // Automatically hang up after 20 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0) { + if showCallPopup { + callTitle = "No one answered." + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0,) { + showCallPopup = false + appState.sendWebSocketMessage(.hangupCall) + } + } + } + } + let dataString = device.device.mac + data + appState.sendWebSocketMessage(msgType,dataString.toData()) + } + + struct FlyingText: Identifiable { + let id: UUID + let text: String + let start: CGPoint + let end: CGPoint + var progress: CGFloat + } + + private func bleInit() { + } + + // Randomly generate a position, avoiding existing devices and the center area + private func generateRandomPosition(existingPositions: [CGPoint]) -> CGPoint { + let center = CGPoint(x: proxySize.width / 2, y: proxySize.height / 2) + let selfSafeRadius: CGFloat = 100 // Avoid own avatar + let otherSafeRadius: CGFloat = 100 // Avoid other devices + let maxAttempts = 200 // Increased number of attempts + + for _ in 0.. otherSafeRadius }) { + return candidate + } + } + // If all attempts fail, randomly offset from the center to avoid overlap + var offsetX = CGFloat.random(in: selfSafeRadius...(selfSafeRadius + 50)) + var offsetY = CGFloat.random(in: selfSafeRadius...(selfSafeRadius + 50)) + // Randomly determine direction + offsetX *= Bool.random() ? 1 : -1 + offsetY *= Bool.random() ? 1 : -1 + return CGPoint(x: center.x + offsetX, y: center.y + offsetY) + } +} + +struct DeviceInfo { + let device: Device + let postion: CGPoint +} + +struct AvatarView : View { + + let name: String + + var body: some View { + VStack { + Color.clear.frame( + height: 15 + ) + Image("logo_icon") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .clipShape(Circle()) + .shadow(color: Color.gray, radius: 10, x: 0, y: 0) + Text(name) + .frame(height: 15) + .foregroundColor(Color(UIColor.label)) + } + } +} + + +struct NearbyPreview : PreviewProvider { + static var previews: some View { + Nearby() + } +} diff --git a/app/StackChan/View/ScanView.swift b/app/StackChan/View/ScanView.swift new file mode 100644 index 0000000..a7ec508 --- /dev/null +++ b/app/StackChan/View/ScanView.swift @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import AVFoundation + +struct ScanView : UIViewControllerRepresentable { + func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {} + + typealias UIViewControllerType = ScannerViewController + + typealias ScanCompletion = (Result) -> Void + + var completion: ScanCompletion + + func makeUIViewController(context: Context) -> ScannerViewController { + let vc = ScannerViewController() + vc.completion = completion + return vc + } +} + +class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var completion: ScanView.ScanCompletion? + var captureSession: AVCaptureSession! + var previewLayer: AVCaptureVideoPreviewLayer! + + // Flag to control whether callbacks are allowed + private var isProcessing = false + + @objc private func toggleFlashlight() { + guard let device = AVCaptureDevice.default(for: .video), + device.hasTorch else { return } + do { + try device.lockForConfiguration() + if device.torchMode == .on { + device.torchMode = .off + } else { + try device.setTorchModeOn(level: 1.0) + } + } catch { + print("Failed to toggle flashlight: \(error)") + } + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.black + + // Initialize guide view + let guideImageView = UIImageView(image: UIImage(systemName: "viewfinder")) + guideImageView.tintColor = .white + guideImageView.contentMode = .scaleAspectFit + guideImageView.tag = 998 + + // Add breathing animation + let pulse = CABasicAnimation(keyPath: "transform.scale") + pulse.fromValue = 0.9 + pulse.toValue = 1.1 + pulse.duration = 0.7 + pulse.autoreverses = true + pulse.repeatCount = .infinity + guideImageView.layer.add(pulse, forKey: "breathingAnimation") + + view.addSubview(guideImageView) + + // Initialize flashlight button + let flashlightButton = UIButton(type: .system) + flashlightButton.setImage(UIImage(systemName: "flashlight.off.fill"), for: .normal) + flashlightButton.tintColor = .white + flashlightButton.addTarget(self, action: #selector(toggleFlashlight), for: .touchUpInside) + flashlightButton.tag = 999 + view.addSubview(flashlightButton) + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + setupSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + if granted { + self.setupSession() + } else { + self.completion?(.failure(NSError(domain: "Camera access not authorized", code: 0))) + } + } + } + default: + completion?(.failure(NSError(domain: "Camera access not authorized", code: 0))) + } + } + + private func setupSession() { + captureSession = AVCaptureSession() + captureSession.sessionPreset = .photo + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), + let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), + captureSession.canAddInput(videoInput) + else { + completion?(.failure(NSError(domain: "Failed to initialize camera", code: 0))) + return + } + captureSession.addInput(videoInput) + + let metadataOutput = AVCaptureMetadataOutput() + guard captureSession.canAddOutput(metadataOutput) else { + completion?(.failure(NSError(domain: "Unable to add capture output", code: 0))) + return + } + captureSession.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [.qr, .ean13, .code128] + + previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.videoGravity = .resizeAspectFill + view.layer.insertSublayer(previewLayer, at: 0) + + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + + if let connection = previewLayer?.connection, connection.isVideoOrientationSupported { + let deviceOrientation = UIDevice.current.orientation + + switch deviceOrientation { + case .portrait: + connection.videoOrientation = .portrait + case .portraitUpsideDown: + connection.videoOrientation = .portraitUpsideDown + case .landscapeLeft: + connection.videoOrientation = .landscapeRight // Note: device left equals camera right + case .landscapeRight: + connection.videoOrientation = .landscapeLeft // Note: device right equals camera left + default: + connection.videoOrientation = .portrait + } + } + + let guideImageViewSize: CGFloat = min(view.bounds.width, view.bounds.height) / 2 + let buttonSize: CGFloat = 44 + + if let guideImageView = view.viewWithTag(998) as? UIImageView { + guideImageView.frame = CGRect( + x: (view.bounds.width - guideImageViewSize) / 2, + y: (view.bounds.height - guideImageViewSize) / 2, + width: guideImageViewSize, + height: guideImageViewSize + ) + } + + if let flashlightButton = view.viewWithTag(999) as? UIButton { + var targetX = CGFloat(0) + var targetY = CGFloat(0) + + if view.bounds.width > view.bounds.height { + // Landscape + let guideRightX = (view.bounds.width + guideImageViewSize) / 2 + let rightEdgeX = view.bounds.width - buttonSize + targetX = (guideRightX + rightEdgeX) / 2 + targetY = (view.bounds.height / 2) - (buttonSize / 2) + } else { + // Portrait + let guideBottomY = (view.bounds.height + guideImageViewSize) / 2 + let bottomEdgeY = view.bounds.height - buttonSize + targetX = (view.bounds.width - buttonSize) / 2 + targetY = (guideBottomY + bottomEdgeY) / 2 + } + flashlightButton.frame = CGRect( + x: targetX, + y: targetY, + width: buttonSize, + height: buttonSize + ) + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + guard !isProcessing else { return } + isProcessing = true // Mark as processing to avoid duplicate triggers + + if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let code = metadataObject.stringValue { + AudioServicesPlaySystemSound(SystemSoundID(1057)) + completion?(.success(code)) + // Do not stop captureSession immediately + // Call stopScanning() after external processing is finished + } else { + completion?(.failure(NSError(domain: "No QR code detected", code: 0))) + isProcessing = false // Allow next scan + } + } + + // Provide a method for external callers to stop scanning + func stopScanning() { + if captureSession.isRunning { + captureSession.stopRunning() + } + isProcessing = false + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopScanning() + } +} diff --git a/app/StackChan/View/Settings.swift b/app/StackChan/View/Settings.swift new file mode 100644 index 0000000..610753b --- /dev/null +++ b/app/StackChan/View/Settings.swift @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct Settings : View { + + @EnvironmentObject var appState: AppState + + @State var showCjamgeNameAlert: Bool = false + @State var newName: String = "" + + @State var deviceInfo: Device = Device() + + var body: some View { + NavigationStack(path: $appState.settingsPath) { + List { + Section("conventional") { + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + appState.showCjamgeNameAlert = true + } + } label: { + HStack { + Image(systemName: "person") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.blue) + .cornerRadius(8) + Text("Change Name") + Spacer() + Text(appState.deviceInfo.name ?? "") + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + Button { + } label: { + HStack { + Image(systemName: "arrow.down.circle") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.green) + .cornerRadius(8) + Text("Online upgrade") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + } + + Section("system") { + Button { + } label: { + HStack { + Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.red) + .cornerRadius(8) + Text("Factory data reset") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + }.foregroundStyle(.primary) + Button { + appState.forcedDisplayBindingDevice = false + appState.showBindingDevice = true + } label: { + HStack { + Image(systemName: "shuffle") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.blue) + .cornerRadius(8) + Text("Bind StachChan") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + } + } + .listStyle(.insetGrouped) + .navigationTitle("Settings") + .navigationDestination(for: PageType.self) { PageType in + switch PageType { + case .cameraPage: + CameraPage() + case .minicryEmotion: + MimicryEmotion(deviceMac: $appState.deviceMac) + case .dance: + Dance() + } + } + .onAppear { + appState.getDeviceInfo() + } + } + } +} + + +struct SettingPreview : PreviewProvider { + static var previews: some View { + Settings() + } +} diff --git a/app/StackChan/View/StackChan.swift b/app/StackChan/View/StackChan.swift new file mode 100644 index 0000000..b3ef857 --- /dev/null +++ b/app/StackChan/View/StackChan.swift @@ -0,0 +1,368 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct StackChan : View { + + let gridHeight: CGFloat = 100 + + @EnvironmentObject var appState: AppState + + private let imageSize: CGFloat = 200 + + @State private var showAvatarMotionControl: Bool = false + + @State private var deviceMac: String = "" + + func getDeviceStatus() -> String { + if appState.deviceMac == "" { + return "Unbound device" + } else { + if appState.deviceIsOnline { + return "Device Online" + } else { + return "Device Offline" + } + } + } + + var body: some View { + let radius = UIScreen.main.bounds.minDimension / 12 + NavigationStack(path: $appState.stackChanPath) { + ZStack { + LinearGradient( + colors: [ + Color.accent.opacity(0.5), + Color.pink.opacity(0.1), + Color.blue.opacity(0.2) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + ScrollView { + VStack(alignment: .trailing,spacing: 20) { + StackChanRotaryRobot() + .frame(width: imageSize,height: imageSize) + Text(getDeviceStatus()) + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + appState.stackChanPath.append(.minicryEmotion) + } + } label: { + HStack { + Image(systemName: "face.smiling") + .font(.system(size: 44)) + Spacer() + Text("AVATAR") + .font(.largeTitle) + } + .padding(.horizontal,20) + .foregroundColor(Color(UIColor.systemBackground)) + .padding() + .frame(maxWidth: .infinity) + .frame(height: gridHeight) + .background(.yellow) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .glassEffectRegular(cornerRadius: radius) + } + + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + appState.stackChanPath.append(.cameraPage) + } + } label: { + HStack { + Image(systemName: "video") + .font(.system(size: 44)) + Spacer() + Text("SENTINEL") + .font(.largeTitle) + } + .padding(.horizontal,20) + .foregroundColor(Color(UIColor.systemBackground)) + .padding() + .frame(maxWidth: .infinity) + .frame(height: gridHeight) + .background(Color(UIColor.label).opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .glassEffectRegular(cornerRadius: radius) + } + + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + showAvatarMotionControl = true + } + } label: { + HStack { + Image( + systemName: "arrow.up.and.down.and.arrow.left.and.right" + ) + .font(.system(size: 44)) + Spacer() + Text("MOTION") + .font(.largeTitle) + } + .padding(.horizontal,20) + .foregroundColor(Color(UIColor.label)) + .padding() + .frame(maxWidth: .infinity) + .frame(height: gridHeight) + .background(.gray.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .glassEffectRegular(cornerRadius: radius) + } + + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + appState.stackChanPath.append(.dance) + } + } label: { + HStack { + Image(systemName: "figure.dance") + .font(.system(size: 44)) + Spacer() + Text("DANCE") + .font(.largeTitle) + } + .padding(.horizontal,20) + .foregroundColor(Color(UIColor.systemBackground)) + .padding() + .frame(maxWidth: .infinity) + .frame(height: gridHeight) + .background(.orange) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .glassEffectRegular(cornerRadius: radius) + } + } + .padding(20) + } + } + .sheet(isPresented: $showAvatarMotionControl) { + AvatarMotionControl() + .presentationDetents([.medium,.large]) + .presentationBackgroundClear() + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $appState.showSwitchFace) { + SelectBlufiDevice() + .presentationDetents([.medium]) + .presentationBackgroundClear() + } + .navigationTitle("StackChan") + .navigationDestination(for: PageType.self) { PageType in + switch PageType { + case .cameraPage: + CameraPage() + case .minicryEmotion: + MimicryEmotion(deviceMac: $appState.deviceMac) + case .dance: + Dance() + } + } + } + } +} + + + +struct SwitchFacePreview : PreviewProvider { + static var previews: some View { + SwitchFace() + } +} + + + +struct SwitchFace : View { + + private let faceList: [ExpressionData] = [ + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: -50, + rotation: 1400, + weight: 60, + size: 0 + ), + rightEye: ExpressionItem( + x: 0, + y: -50, + rotation: -1400, + weight: 60, + size: 0 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 50, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 100, + size: 0 + ), + rightEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 100, + size: 0 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: 0, + rotation: 250, + weight: 50, + size: 0 + ), + rightEye: ExpressionItem( + x: 0, + y: 0, + rotation: -250, + weight: 50, + size: 0 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 15, + size: 0 + ), + rightEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 15, + size: 0 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 100, + size: 50 + ), + rightEye: ExpressionItem( + x: 5, + y: 0, + rotation: 0, + weight: 100, + size: -50 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: -50, + rotation: 0, + weight: 100, + size: 50 + ), + rightEye: ExpressionItem( + x: 0, + y: -50, + rotation: 0, + weight: 100, + size: 50 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 100, size: 0) + ) + ] + + @State var selectedIndex: Int = 0 + + private let columns = Array(repeating: GridItem(.flexible(),spacing: 20), count: 2) + + @EnvironmentObject var appState: AppState + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(0.. UIGraphicsImageRenderer { + let format = UIGraphicsImageRendererFormat.default() + format.scale = UIScreen.main.scale + format.opaque = false + return UIGraphicsImageRenderer( + size: expressionLayer.bounds.size, + format: format + ) + } +} + +struct StackChanSwitchFacePreview : PreviewProvider { + static var previews: some View { + SwitchFace() + } +} + + diff --git a/app/StackChan/View/StackChanModelView.swift b/app/StackChan/View/StackChanModelView.swift new file mode 100644 index 0000000..f724053 --- /dev/null +++ b/app/StackChan/View/StackChanModelView.swift @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import ARKit + +struct StackChanModelView: View { + + @Binding var expressionData: ExpressionData + @Binding var headData: MotionData + + var body: some View { + Canvas { context, size in + let eyeSize = size.width / 10 + + func drawEye(_ item: ExpressionItem, at point: CGPoint) { + let visibleHeight = eyeSize * (CGFloat(item.weight) / 100) + + let eyeX = point.x + CGFloat(item.x / 10) + let eyeY = point.y + CGFloat(item.y / 10) + + let eyeRect = CGRect(x: eyeX, y: eyeY, width: eyeSize, height: eyeSize) + var eyePath = Path() + eyePath.addEllipse(in: eyeRect) + + let rotationDegrees = Double(item.rotation) / 10.0 + let rotationAngle = Angle(degrees: rotationDegrees) + + let maskRect = CGRect( + x: eyeX, + y: eyeY + eyeSize - visibleHeight, + width: eyeSize, + height: visibleHeight + ) + + context.drawLayer { context in + let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY) + context.translateBy(x: center.x, y: center.y) + context.rotate(by: rotationAngle) + context.translateBy(x: -center.x, y: -center.y) + + context.clip(to: Path(maskRect)) + context.fill( + Path(ellipseIn: eyeRect), + with: .color(.white) + ) + } + } + + + let eyeY = (size.height * 0.35) - (eyeSize / 2) + let leftEyePoint = CGPoint(x: (size.width / 3) - (eyeSize / 2) ,y: eyeY) + let rightEyePoint = CGPoint(x: (size.width / 3 * 2) - (eyeSize / 2) ,y: eyeY) + + drawEye(expressionData.leftEye, at: leftEyePoint) + drawEye(expressionData.rightEye, at: rightEyePoint) + + + context.drawLayer { context in + + let width = size.width * 0.3 - CGFloat(expressionData.mouth.weight / 10) + let height = 3 + CGFloat(expressionData.mouth.weight) * 0.2 + let x = ((size.width - width) / 2) + CGFloat(expressionData.mouth.x / 10) + let y = (size.height * 0.65) + CGFloat(expressionData.mouth.y / 10) + + let rotationDegrees = Double(expressionData.mouth.rotation) / 10.0 + let rotationAngle = Angle(degrees: rotationDegrees) + + let mouthRect = CGRect(x: x, y: y, width: width, height: height) + let mouthPath = Path(roundedRect: mouthRect, cornerRadius: height / 2) + + let center = CGPoint(x: mouthRect.midX, y: mouthRect.midY) + context.translateBy(x: center.x, y: center.y) + context.rotate(by: rotationAngle) + context.translateBy(x: -center.x, y: -center.y) + + context.fill(mouthPath, with: .color(.white)) + } + } + } +} + + + + + + + + +struct SceneKitView: UIViewRepresentable { + @Binding var expressionData: ExpressionData + + private let planeNodeName = "expressionPlane" + + @State var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())) + + func makeUIView(context: Context) -> SCNView { + let scnView = SCNView() + let scene = SCNScene() + scnView.scene = scene + scnView.allowsCameraControl = true + scnView.autoenablesDefaultLighting = true + scnView.backgroundColor = .clear + + expressionLayer.data = expressionData + expressionLayer.frame = CGRect(origin: .zero, size: CGSize(width: 250, height: 200)) + expressionLayer.setNeedsDisplay() + + DispatchQueue.main.async { + let plane = SCNPlane(width: 0.08, height: 0.06) + let material = SCNMaterial() + material.diffuse.contents = expressionLayer + material.isDoubleSided = true + plane.materials = [material] + + let planeNode = SCNNode(geometry: plane) + planeNode.name = planeNodeName + + var position = SCNVector3() + position.x += 0.02 + position.y += 0.015 + position.z += 0.01 + + planeNode.position = position + + scene.rootNode.addChildNode(planeNode) + + if let position = scnView.scene?.rootNode.position { + scnView.scene?.rootNode.position.z = position.z - 0.03 + } + } + + return scnView + } + + func updateUIView(_ uiView: SCNView, context: Context) { + guard let scene = uiView.scene, + let planeNode = scene.rootNode.childNode(withName: planeNodeName, recursively: true), + let plane = planeNode.geometry as? SCNPlane, + let material = plane.materials.first else { + return + } + expressionLayer.data = expressionData + expressionLayer.setNeedsDisplay() + material.diffuse.contents = expressionLayer + } + + +} + + +struct SceneKitViewPreview : PreviewProvider { + + static var previews: some View { + SceneKitView( + expressionData: .constant( + ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()) + ) + ) + .frame(maxWidth: 400,maxHeight: 400) + } +} diff --git a/app/StackChan/View/StackChanRobot.swift b/app/StackChan/View/StackChanRobot.swift new file mode 100644 index 0000000..ef93d6c --- /dev/null +++ b/app/StackChan/View/StackChanRobot.swift @@ -0,0 +1,360 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import SceneKit +import Combine + +struct StackChanRobot : UIViewRepresentable { + + var data: DanceData + + var allowsCameraControl: Bool = false + + @State private var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem())) + + private let planeNodeName = "expressionPlane" + + private let rotateKey = "autoRotate" + + func makeUIView(context: Context) -> SCNView { + let sceneView = SCNView() + + if let scene = SCNScene(named: "stackChanModel.scn") { + scene.rootNode.eulerAngles = SCNVector3Zero + scene.rootNode.eulerAngles.x = -Float.pi / 2 + scene.rootNode.position.y = scene.rootNode.position.y + 25 + scene.rootNode.position.z = scene.rootNode.position.z - 35 + + let plane = SCNPlane(width: 42, height: 32) + let magnification: CGFloat = 5 + let size = CGSize(width: magnification * plane.width, height: magnification * plane.height) + expressionLayer.frame = CGRect(origin: .zero, size: size) + expressionLayer.setNeedsDisplay() + + let material = SCNMaterial() + plane.materials = [material] + let planeNode = SCNNode(geometry: plane) + planeNode.name = planeNodeName + planeNode.position = SCNVector3(0, -16, 0) + planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0) + scene.rootNode.addChildNode(planeNode) + + sceneView.scene = scene + } else { + print("Model not found") + } + + sceneView.autoenablesDefaultLighting = true + sceneView.allowsCameraControl = allowsCameraControl + sceneView.backgroundColor = UIColor.clear + setData(sceneView) + return sceneView + } + + func updateUIView(_ uiView: SCNView, context: Context) { + setData(uiView) + } + + /// Refresh model position and expression + private func setData(_ uiView: SCNView) { + if let stackNode = uiView.scene?.rootNode { + /// Set pitch angle (0–900) + let clampedPitch = max(0, min(900, data.pitchServo.angle)) + let pitchRatio = Float(clampedPitch) / 900.0 + let pitchAngle = -Float.pi / 2 * (1 + pitchRatio) + stackNode.eulerAngles.x = pitchAngle + + // Cancel previous auto-rotation + stackNode.removeAction(forKey: rotateKey) + + if data.yawServo.rotate == 0 { + /// Set yaw angle (-128 to 128, left to right) + let clampedYaw = max(-1280, min(1280, data.yawServo.angle)) // Clamp to -128~128 + let yawAngle = Float(clampedYaw) * Float.pi / 1800 // Convert to radians + stackNode.eulerAngles.y = yawAngle + } else { + let rotateSpeed = max(-1000, min(1000, data.yawServo.rotate)) + let radiansPerSecond = Float(rotateSpeed) / 1000.0 * Float.pi * 2 + // Rotate continuously using angular velocity (not a fixed-loop animation) + let rotateAction = SCNAction.customAction(duration: .infinity) { node, _ in + let deltaTime: Float = 1.0 / 60.0 // Approximate frame duration + node.eulerAngles.y += radiansPerSecond * deltaTime + } + stackNode.runAction(rotateAction, forKey: rotateKey) + } + } + // Find the plane node + if let planeNode = uiView.scene?.rootNode.childNode(withName: planeNodeName, recursively: true), + let plane = planeNode.geometry as? SCNPlane { + + // Render new expression image + let expressionData = ExpressionData(leftEye: data.leftEye, rightEye: data.rightEye, mouth: data.mouth) + expressionLayer.data = expressionData + expressionLayer.setNeedsDisplay() + let newImage = expressionRenderer().image { ctx in + self.expressionLayer.render(in: ctx.cgContext) + } + plane.firstMaterial?.diffuse.contents = newImage + } + } + + static func dismantleUIView(_ uiView: SCNView, coordinator: ()) { + // Remove all nodes from the scene + uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() } + + // Clean up scene and materials + uiView.scene = nil + uiView.delegate = nil + + // Stop any rendering or animations + uiView.isPlaying = false + uiView.scene?.isPaused = true + } + + private func expressionRenderer() -> UIGraphicsImageRenderer { + let format = UIGraphicsImageRendererFormat.default() + format.scale = UIScreen.main.scale + format.opaque = false + return UIGraphicsImageRenderer( + size: expressionLayer.bounds.size, + format: format + ) + } + +} + + + +struct StackChanRotaryRobot : UIViewRepresentable { + + private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem())) + + func updateUIView(_ uiView: SCNView, context: Context) { + + } + + private func expressionRenderer() -> UIGraphicsImageRenderer { + let format = UIGraphicsImageRendererFormat.default() + format.scale = UIScreen.main.scale + format.opaque = false + return UIGraphicsImageRenderer( + size: expressionLayer.bounds.size, + format: format + ) + } + + func makeUIView(context: Context) -> SCNView { + let sceneView = SCNView() + + if let scene = SCNScene(named: "stackChanModel.scn") { + scene.rootNode.eulerAngles = SCNVector3Zero + scene.rootNode.eulerAngles.x = -Float.pi / 2 + scene.rootNode.position.y = scene.rootNode.position.y + 25 + scene.rootNode.position.z = scene.rootNode.position.z - 45 + + let clampedPitch = max(0, min(900, 200)) + let pitchRatio = Float(clampedPitch) / 900.0 + let pitchAngle = -Float.pi / 2 * (1 + pitchRatio) + scene.rootNode.eulerAngles.x = pitchAngle + + // Add plane + let plane = SCNPlane(width: 42, height: 32) + let magnification: CGFloat = 5 + let size = CGSize(width: magnification * plane.width, height: magnification * plane.height) + expressionLayer.frame = CGRect(origin: .zero, size: size) + expressionLayer.setNeedsDisplay() + let newImage = expressionRenderer().image { ctx in + self.expressionLayer.render(in: ctx.cgContext) + } + let material = SCNMaterial() + material.diffuse.contents = newImage + plane.materials = [material] + let planeNode = SCNNode(geometry: plane) + planeNode.position = SCNVector3(0, -16, 0) + planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0) + scene.rootNode.addChildNode(planeNode) + + // Add infinite rotation animation around Y axis + let rotateAction = SCNAction.rotateBy(x: 0, y: CGFloat(2 * Double.pi), z: 0, duration: 5) + let repeatAction = SCNAction.repeatForever(rotateAction) + scene.rootNode.runAction(repeatAction) + + sceneView.scene = scene + } else { + print("Model not found") + } + + sceneView.autoenablesDefaultLighting = true + sceneView.allowsCameraControl = false + sceneView.backgroundColor = UIColor.clear + return sceneView + } + + static func dismantleUIView(_ uiView: UIViewType, coordinator: ()) { + // Remove all nodes from the scene + uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() } + + // Clean up scene and materials + uiView.scene = nil + uiView.delegate = nil + + // Stop any rendering or animations + uiView.isPlaying = false + uiView.scene?.isPaused = true + } +} + + + +struct StackChanRobotPreview : PreviewProvider { + + static var previews: some View { + StackChanRotaryRobot() + .frame(maxWidth: .infinity,maxHeight: 400) + } +} + + + +class ExpressionLayer: CALayer { + var data: ExpressionData + + let reverse: Bool + + init(data: ExpressionData, reverse: Bool = false) { + self.data = data + self.reverse = reverse + super.init() + self.contentsScale = UIScreen.main.scale + self.setNeedsDisplay() + } + + override init(layer: Any) { + if let layer = layer as? ExpressionLayer { + self.data = layer.data + self.reverse = layer.reverse + } else { + self.data = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()) + self.reverse = false + } + super.init(layer: layer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(in ctx: CGContext) { + let rect = self.frame + + // Background + ctx.setFillColor(UIColor.black.withAlphaComponent(0.7).cgColor) + ctx.fill(rect) + + let eyeSize = rect.width / 10 + + func drawEye(_ item: ExpressionItem, at point: CGPoint) { + + // Calculate scale based on size (-100 to 100) + // 0 -> 1.0 (keep current size) + // -100 -> 0.5 (half normal radius) + // 100 -> 2.0 (double normal radius) + let clampedSize = max(-100, min(100, item.size)) + let sizeScale: CGFloat + if clampedSize >= 0 { + sizeScale = 1.0 + CGFloat(clampedSize) / 100.0 + } else { + sizeScale = 1.0 + CGFloat(clampedSize) / 200.0 + } + + let scaledEyeSize = eyeSize * sizeScale + + let visibleHeight = scaledEyeSize * (CGFloat(item.weight) / 100) + + let centerX = point.x + CGFloat(item.x / 10) + eyeSize / 2 + let centerY = point.y + CGFloat(item.y / 10) + eyeSize / 2 + let eyeRect = CGRect( + x: centerX - scaledEyeSize / 2, + y: centerY - scaledEyeSize / 2, + width: scaledEyeSize, + height: scaledEyeSize + ) + + ctx.saveGState() + + // Rotation + let rotationDegrees = CGFloat(item.rotation) / 10.0 + let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY) + ctx.translateBy(x: center.x, y: center.y) + ctx.rotate(by: rotationDegrees * .pi / 180) + ctx.translateBy(x: -center.x, y: -center.y) + + // Clip height + let maskRect = CGRect( + x: eyeRect.minX, + y: eyeRect.maxY - visibleHeight, + width: scaledEyeSize, + height: visibleHeight + ) + ctx.addRect(maskRect) + ctx.clip() + + ctx.setFillColor(UIColor.white.cgColor) + ctx.fillEllipse(in: eyeRect) + + ctx.restoreGState() + } + + let eyeY = (rect.height * 0.35) - (eyeSize / 2) + let leftEyePoint = CGPoint(x: (rect.width / 3) - (eyeSize / 2), y: eyeY) + let rightEyePoint = CGPoint(x: (rect.width / 3 * 2) - (eyeSize / 2), y: eyeY) + + + if reverse { + // Temporarily swap rotation angles + let leftEyeRotation = data.leftEye.rotation + let rightEyeRotation = data.rightEye.rotation + + var leftEye = data.leftEye + var rightEye = data.rightEye + + leftEye.rotation = rightEyeRotation + rightEye.rotation = leftEyeRotation + + drawEye(leftEye, at: rightEyePoint) + drawEye(rightEye, at: leftEyePoint) + } else { + drawEye(data.leftEye, at: leftEyePoint) + drawEye(data.rightEye, at: rightEyePoint) + } + + + + + // Draw mouth + ctx.saveGState() + + let width = rect.width * 0.3 - CGFloat(data.mouth.weight / 10) + let height = 3 + CGFloat(data.mouth.weight) * 0.2 + let x = ((rect.width - width) / 2) + CGFloat(data.mouth.x / 10) + let y = (rect.height * 0.65) + CGFloat(data.mouth.y / 10) + + let rotationDegrees = CGFloat(data.mouth.rotation) / 10.0 + let center = CGPoint(x: x + width / 2, y: y + height / 2) + ctx.translateBy(x: center.x, y: center.y) + ctx.rotate(by: rotationDegrees * .pi / 180) + ctx.translateBy(x: -center.x, y: -center.y) + + let mouthRect = CGRect(x: x, y: y, width: width, height: height) + let mouthPath = UIBezierPath(roundedRect: mouthRect, cornerRadius: height / 2) + ctx.addPath(mouthPath.cgPath) + ctx.setFillColor(UIColor.white.cgColor) + ctx.fillPath() + + ctx.restoreGState() + } +} diff --git a/app/ios/README.md b/app/ios/README.md deleted file mode 100644 index 1f82226..0000000 --- a/app/ios/README.md +++ /dev/null @@ -1 +0,0 @@ -# StackChan iOS App \ No newline at end of file diff --git a/firmware/.clang-format b/firmware/.clang-format new file mode 100644 index 0000000..81d3c79 --- /dev/null +++ b/firmware/.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 \ No newline at end of file diff --git a/firmware/.gitignore b/firmware/.gitignore new file mode 100644 index 0000000..5c0083c --- /dev/null +++ b/firmware/.gitignore @@ -0,0 +1,67 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +# MacOS desktop metadata files +.DS_Store + +# Build files +build/ +cmake-build-*/ +/components/ +managed_components/ + +# PlatformIO +.pio/ + +# VSCode +.vscode/ +.cache/ + +# Python virtual environment +.venv/ + +# Debug / Release files +Debug/ +debug/ +Release/ +release/ +out/ +bin/ +obj/ + +*.code-workspace + +sdkconfig +sdkconfig.old + +xiaozhi-esp32/ diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt new file mode 100644 index 0000000..b5041a9 --- /dev/null +++ b/firmware/CMakeLists.txt @@ -0,0 +1,11 @@ +# The following five lines of boilerplate have to be in your project's +# CMakeLists in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.16) + +set(PROJECT_VER "2.1.0") + +# Add this line to disable the specific warning +add_compile_options(-Wno-missing-field-initializers) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(stack-chan) diff --git a/firmware/README.md b/firmware/README.md index fc51bde..72069c1 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -1 +1,25 @@ -# StackChan Firmware \ No newline at end of file +# StackChan Firmware + +## Build + +### Fetch Dependencies + +```bash +python3 ./fetch_repos.py +``` + +### Tool Chains + +[ESP-IDF v5.5.1](https://docs.espressif.com/projects/esp-idf/en/v5.5.1/esp32s3/index.html) + +### Build + +```bash +idf.py build +``` + +### Flash + +```bash +idf.py flash +``` diff --git a/firmware/dependencies.lock b/firmware/dependencies.lock new file mode 100644 index 0000000..0acf4c4 --- /dev/null +++ b/firmware/dependencies.lock @@ -0,0 +1,792 @@ +dependencies: + 78/esp-ml307: + component_hash: d4de9738baaaa5e6655c686a6083e699f3605de54dc83233ff3328cf55a4f543 + dependencies: + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 3.5.3 + 78/esp-opus: + component_hash: 8182b733f071d7bfe1e837f4c9f8649a63e4c937177f089e65772880c02f2e17 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com + type: service + version: 1.0.5 + 78/esp-opus-encoder: + component_hash: b80e5b6d6dc4bf6b0bf1a3729f52b80500e9e6b1003b3e827b78ba738283a296 + dependencies: + - name: 78/esp-opus + registry_url: https://components.espressif.com + require: private + version: ^1.0.5 + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.4.1 + 78/esp-wifi-connect: + component_hash: bf996fc603af1ff529668c788c7b38a8d94b1d43455af56d116d27ebb04b742a + dependencies: + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 3.0.2 + 78/esp_lcd_nv3023: + component_hash: fa88abfc19a312eb5e6f2ffa187e0a9faf67e01e758bfb979d3f9d92561a494f + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=4.4' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.0 + 78/xiaozhi-fonts: + component_hash: e5526ea3c290742963ef6ab64023669260b72aefdc37bb218c34c618962ef2a8 + dependencies: + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.5.5 + espressif/adc_battery_estimation: + component_hash: b915167c87ed5a84b13d680bd011c2ed9a15121f1e247c6903141b9f138f3606 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: '*' + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 0.2.1 + espressif/adc_mic: + component_hash: ca6ef69bba4dc0d3e0ce53937635ed2734d38ac947881da81b8526e6851a71a4 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: '*' + - name: espressif/esp_codec_dev + registry_url: https://components.espressif.com + require: private + version: ^1.3.* + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 0.2.1 + espressif/bmi270_sensor: + component_hash: fac23a5ecd2effdac0fb0705988d853745b8ffb99a384f87a307b3b25d15d432 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/i2c_bus + registry_url: https://components.espressif.com + require: public + version: '*' + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 0.1.1 + espressif/button: + component_hash: fccb18c37f1cfe0797b74a53a44d3f400f5fd01f4993b40052dfb7f401915089 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: '*' + - name: idf + require: private + version: '>=4.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 4.1.5 + espressif/cmake_utilities: + component_hash: 351350613ceafba240b761b4ea991e0f231ac7a9f59a9ee901f751bddc0bb18f + dependencies: + - name: idf + require: private + version: '>=4.1' + source: + registry_url: https://components.espressif.com/ + type: service + version: 0.5.3 + espressif/dl_fft: + component_hash: 7dadbd644c0d7ba4733cc3726ec4cff6edf27b043725e1115861dec1609a3d28 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 0.3.1 + espressif/esp-dsp: + component_hash: 42dce32d46ac93dc11f60d368e29a830e9661c7345d794b8a45c343479cae636 + dependencies: + - name: idf + require: private + version: '>=4.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.7.0 + espressif/esp_cam_sensor: + component_hash: 35c1648ea87aac81da7a085d35f61b6d5ad88574bd0c5fdc4ab75efdef4ee6c1 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/esp_sccb_intf + registry_url: https://components.espressif.com + require: private + version: '>=0.0.5' + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32p4 + - esp32s3 + - esp32c3 + - esp32c5 + - esp32c6 + - esp32c61 + version: 1.5.2 + espressif/esp_codec_dev: + component_hash: 0d9e9bc288156eb55f79338d312e1ebf8c9dfbd5e7d13ef0f20ccb031b4e15cf + dependencies: + - name: idf + require: private + version: '>=4.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.5.4 + espressif/esp_image_effects: + component_hash: a35037db02862d3cef6f72c25f422ae62da6e6c88219ce03c9ea24e0315ceaaa + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.1 + espressif/esp_io_expander: + component_hash: abf3ea0d71407d79d15500550aa3ba433848459b0fa94717cf9478822593ee5d + dependencies: + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.1.0 + espressif/esp_io_expander_tca9554: + component_hash: 8a9e3a7dbef589a54f814ff22e9121da86bce19d0e1ccffa53f5df6bb346a140 + dependencies: + - name: espressif/esp_io_expander + registry_url: https://components.espressif.com + require: public + version: ^1.0.1 + - name: idf + require: private + version: '>=5.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.0.0 + espressif/esp_io_expander_tca95xx_16bit: + component_hash: c384d55e429775d6a1eef26512fbc33bc41823745a8c2ad6d5927729f33a2c21 + dependencies: + - name: espressif/esp_io_expander + registry_url: https://components.espressif.com + require: public + version: ^1.0.1 + - name: idf + require: private + version: '>=5.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.0.1 + espressif/esp_lcd_axs15231b: + component_hash: e614bd75827e95800e39df1ba9474166060ab26c3807d06df9cc312a27021cda + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1 + - name: idf + require: private + version: '>5.0.4,!=5.1.1' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.1~1 + espressif/esp_lcd_gc9a01: + component_hash: 09ddc8f5c9d718a659ffa30a4040e1ae75077e68492c38aec8c2beaaa7b3a9bc + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=4.4' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.0.1 + espressif/esp_lcd_ili9341: + component_hash: 31f1b793aa2110dd2ae071c21ccbff0a4eb20d9a4ee40b6294c0dc0ad9552c4e + dependencies: + - name: idf + require: private + version: '>=4.4' + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.2.0 + espressif/esp_lcd_panel_io_additions: + component_hash: 213d5c4a3c322a48c9f993d6bc2372206d34af95db34e87a3e02601bdcad7ce8 + dependencies: + - name: idf + require: private + version: '>=4.4.2' + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/esp_io_expander + registry_url: https://components.espressif.com + require: public + version: ^1 + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.1 + espressif/esp_lcd_spd2010: + component_hash: 8637c97feeb8d83ba637599f6bf4ef30e83595486930e7df17ffa24f3fef1242 + dependencies: + - name: idf + require: private + version: '>5.0.4,!=5.1.1' + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.2 + espressif/esp_lcd_st7701: + component_hash: 0b11e8733e51e12da83003b9d475777eaf47bfbdce01379879bd2504d14f4f83 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>5.0.4,!=5.1.1' + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32s3 + - esp32p4 + version: 1.1.5 + espressif/esp_lcd_st77916: + component_hash: 5fa0f8b1274576d4484e2b8d9358e2a5d09c721511bef0dce6a55b4206b5f0e9 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>5.0.4,!=5.1.1' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.1 + espressif/esp_lcd_st7796: + component_hash: c37b10363125eb3b4757d509392ffca963c27262b61939fdb49a7b18eeef975c + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=4.4' + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32 + - esp32s2 + - esp32s3 + - esp32p4 + version: 1.3.5 + espressif/esp_lcd_touch: + component_hash: 3f85a7d95af876f1a6ecca8eb90a81614890d0f03a038390804e5a77e2caf862 + dependencies: + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.1 + espressif/esp_lcd_touch_cst816s: + component_hash: 6b11c489952c121396d4eaf5804b45e3f1f8dcd2c548dc6f2cbedf68622b013d + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.2.0 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.1.1 + espressif/esp_lcd_touch_ft5x06: + component_hash: abaec05f46a793549b60afdda9eff86e3c8e87782c8c169007911232388d2858 + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.0.4 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.7 + espressif/esp_lcd_touch_gt1151: + component_hash: acd0fb87e8b58dc90cebda8438ee31c068975c15c040a62723843ca0420d38b6 + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.2.0 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.1.0 + espressif/esp_lcd_touch_gt911: + component_hash: be02e243d18b9a661bc13b0d22c0a5cfa3f708cf04d6eb059772276c8c8a4d76 + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.2.0 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.2.0~1 + espressif/esp_lcd_touch_st7123: + component_hash: 717080a55368a5ca3e3704081a75ddab3e0b8884e335e73b7094beb8ae55f195 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.1 + espressif/esp_lvgl_port: + component_hash: 3264cc85d86a7c32b67d26a1353a57c67e7986bb293ebb1731801936b056e632 + dependencies: + - name: idf + require: private + version: '>=5.1' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: public + version: '>=8,<10' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.6.3 + espressif/esp_mmap_assets: + component_hash: 91d785326b03db15e2f7f1314d8c976d38f21aa5759b570dcbbc89bcf247fd27 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.4.0 + espressif/esp_new_jpeg: + component_hash: e6af208a875abd0ecfc0213d3751a11b504b463ebde6930f24096047925fa5c1 + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32 + - esp32s2 + - esp32s3 + - esp32p4 + - esp32c2 + - esp32c3 + - esp32c5 + - esp32c6 + version: 0.6.1 + espressif/esp_sccb_intf: + component_hash: 44dfa738680ccbc3705ae6940f59be603165f4f7e4d05c3eb5f433705fdc92be + dependencies: + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com + type: service + version: 0.0.7 + espressif/esp_video: + component_hash: fbad1178f39cb5a81ed808c460c851b911a9858db833e2e667ada988022c0660 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/esp_cam_sensor + registry_url: https://components.espressif.com + require: private + version: 1.5.* + - name: espressif/esp_h264 + registry_url: https://components.espressif.com + require: private + rules: + - if: target in [esp32p4] + version: 1.0.4 + - name: espressif/esp_ipa + registry_url: https://components.espressif.com + require: private + rules: + - if: target in [esp32p4] + version: ~1.2.0 + - name: idf + require: private + version: '>=5.4' + - name: espressif/usb + registry_url: https://components.espressif.com + require: private + rules: + - if: target in [esp32p4, esp32s3] + - if: idf_version >=6.0 + version: '*' + - name: espressif/usb_host_uvc + registry_url: https://components.espressif.com + require: private + rules: + - if: target in [esp32p4, esp32s3] + version: 2.3.* + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32p4 + - esp32s3 + - esp32c3 + - esp32c5 + - esp32c6 + - esp32c61 + version: 1.3.1 + espressif/freetype: + component_hash: cd5e2d8458e6e8d73f1120ac474467cabb669d8ea4b25050bf6a348c1e89225e + dependencies: + - name: idf + require: private + version: '>=4.4' + source: + registry_url: https://components.espressif.com + type: service + version: 2.13.3~1 + espressif/i2c_bus: + component_hash: 4e990dc11734316186b489b362c61d41f23f79d58bc169795cec215e528cba14 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: '*' + - name: idf + require: private + version: '>=4.0' + source: + registry_url: https://components.espressif.com + type: service + version: 1.5.0 + espressif/knob: + component_hash: 138ed090b4c9090a0a678f695b9d1884b368130c729b7333ed35bd7f4e8da80e + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=4.4.1' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.2 + espressif/led_strip: + component_hash: f0d1b7f93eb3ee57bcfd220656176b0b5616e1947f89ed44364555cf910c4840 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 3.0.2 + espressif/usb_host_uvc: + component_hash: b0cb9a3a2eaa09dc6e5cc3e47e04d0a73e47c2d3dcfb3afce959224a1fe5fb5e + dependencies: + - name: idf + require: private + version: '>=5.0' + - name: espressif/usb + registry_url: https://components.espressif.com + require: public + rules: + - if: idf_version >=6.0 + - if: target not in ["linux"] + version: ^1.0.0 + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32s2 + - esp32s3 + - esp32p4 + - esp32h4 + - linux + version: 2.3.1 + espressif2022/esp_emote_gfx: + component_hash: 67986ead33c11de895eb7ba9a069ea89949ec911ce7cf0989478cc9da2e54735 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/esp_new_jpeg + registry_url: https://components.espressif.com + require: public + version: 0.6.* + - name: espressif/freetype + registry_url: https://components.espressif.com + require: private + version: 2.* + - name: idf + require: private + version: '>=5.0' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: public + version: '*' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.0.0 + espressif2022/image_player: + component_hash: 0e42ed1c9665debd15f2f3e7e56519100e75e446410962226cb5e5402da3fa43 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.1.1 + idf: + source: + type: idf + version: 5.5.1 + lvgl/lvgl: + component_hash: b702d642e03e95928046d5c6726558e6444e112420c77efa5fdb6650b0a13c5d + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 9.3.0 + tny-robotics/sh1106-esp-idf: + component_hash: 7be190d7c58cd635adf8b74b3a841f8245f2f82f36d135a64d8e53cfab39124d + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.0 + txp666/otto-emoji-gif-component: + component_hash: 5a722dd0a40014b31d5998ea5b21f0b53a99d1025626d765d08710b47636ee97 + dependencies: + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: private + version: '>=9.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.4 + waveshare/esp_lcd_sh8601: + component_hash: 1a2829e9db4e0515bad6ce83368f199dec60519fbba40251d6c8249f5d070ae1 + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: idf + require: private + version: '>=5.3' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.2 + waveshare/esp_lcd_touch_cst9217: + component_hash: 27a00845832b7987cacf4c4125cbed5415d940987583065b01870ac080930856 + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.1.0 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.4 + wvirgil123/sscma_client: + component_hash: bf7d4a1f157303f4850d0b9b465c733ce38e8689ff40ac4743a44520524c41d5 + dependencies: + - name: espressif/esp_io_expander + registry_url: https://components.espressif.com + require: private + version: ^1.0.1 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.0.2 +direct_dependencies: +- 78/esp-ml307 +- 78/esp-opus-encoder +- 78/esp-wifi-connect +- 78/esp_lcd_nv3023 +- 78/xiaozhi-fonts +- espressif/adc_battery_estimation +- espressif/adc_mic +- espressif/bmi270_sensor +- espressif/button +- espressif/cmake_utilities +- espressif/dl_fft +- espressif/esp-dsp +- espressif/esp_codec_dev +- espressif/esp_image_effects +- espressif/esp_io_expander_tca9554 +- espressif/esp_io_expander_tca95xx_16bit +- espressif/esp_lcd_axs15231b +- espressif/esp_lcd_gc9a01 +- espressif/esp_lcd_ili9341 +- espressif/esp_lcd_panel_io_additions +- espressif/esp_lcd_spd2010 +- espressif/esp_lcd_st7701 +- espressif/esp_lcd_st77916 +- espressif/esp_lcd_st7796 +- espressif/esp_lcd_touch_cst816s +- espressif/esp_lcd_touch_ft5x06 +- espressif/esp_lcd_touch_gt1151 +- espressif/esp_lcd_touch_gt911 +- espressif/esp_lcd_touch_st7123 +- espressif/esp_lvgl_port +- espressif/esp_mmap_assets +- espressif/esp_new_jpeg +- espressif/esp_video +- espressif/knob +- espressif/led_strip +- espressif2022/esp_emote_gfx +- espressif2022/image_player +- idf +- lvgl/lvgl +- tny-robotics/sh1106-esp-idf +- txp666/otto-emoji-gif-component +- waveshare/esp_lcd_sh8601 +- waveshare/esp_lcd_touch_cst9217 +- wvirgil123/sscma_client +manifest_hash: 58298729f89f9edb041dac03ee9a91c4f6f97f277634f6349f91981c9b4b715f +target: esp32s3 +version: 2.0.0 diff --git a/firmware/fetch_repos.py b/firmware/fetch_repos.py new file mode 100644 index 0000000..17cc96f --- /dev/null +++ b/firmware/fetch_repos.py @@ -0,0 +1,61 @@ +import os +import subprocess +import json + + +def clone_or_update_repo( + repo_url, path, ref=None, with_submodules=False, patch_path=None +): + import os + + if not os.path.exists(path): + subprocess.run(["git", "clone", repo_url, path], check=True) + else: + subprocess.run(["git", "-C", path, "fetch"], check=True) + + if ref: + subprocess.run(["git", "-C", path, "checkout", ref], check=True) + + if with_submodules: + subprocess.run( + ["git", "-C", path, "submodule", "update", "--init", "--recursive"], + check=True, + ) + + # 应用 patch + if patch_path: + patch_full_path = ( + patch_path + if os.path.isabs(patch_path) + else os.path.join(os.getcwd(), patch_path) + ) + # 使用 git apply --check 先检测补丁是否能应用,避免报错 + check_result = subprocess.run( + ["git", "-C", path, "apply", "--check", patch_full_path] + ) + if check_result.returncode == 0: + subprocess.run(["git", "-C", path, "apply", patch_full_path], check=True) + print(f"Applied patch {patch_path} to {path}") + else: + print(f"Patch {patch_path} cannot be applied cleanly to {path}, skipped.") + + +def fetch_dependencies(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(script_dir, "repos.json") + + with open(config_path) as f: + repos = json.load(f) + + for repo in repos: + repo_path = os.path.join(script_dir, repo["path"]) + branch = repo.get("branch") + with_submodules = repo.get("with_submodules", False) + patch = repo.get("patch") + if patch and not os.path.isabs(patch): + patch = os.path.join(script_dir, patch) + clone_or_update_repo(repo["url"], repo_path, branch, with_submodules, patch) + + +if __name__ == "__main__": + fetch_dependencies() \ No newline at end of file diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt new file mode 100644 index 0000000..43c51aa --- /dev/null +++ b/firmware/main/CMakeLists.txt @@ -0,0 +1,460 @@ +# Define xiaozhi source directory +set(XIAOZHI_MAIN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../xiaozhi-esp32/main") + +file(GLOB_RECURSE STACK_CHAN_SOURCES + "apps/*.c" + "apps/*.cc" + "apps/*.cpp" + "assets/*.c" + "assets/*.cc" + "assets/*.cpp" + "hal/*.c" + "hal/*.cc" + "hal/*.cpp" + "stackchan/*.c" + "stackchan/*.cc" + "stackchan/*.cpp" +) +set(STACK_CHAN_INCLUDE_DIRS + "." +) +list(APPEND STACK_CHAN_SOURCES main.cpp) + +# Define source files +set(SOURCES "audio/audio_codec.cc" + "audio/audio_service.cc" + "audio/codecs/no_audio_codec.cc" + "audio/codecs/box_audio_codec.cc" + "audio/codecs/es8311_audio_codec.cc" + "audio/codecs/es8374_audio_codec.cc" + "audio/codecs/es8388_audio_codec.cc" + "audio/codecs/es8389_audio_codec.cc" + "audio/codecs/dummy_audio_codec.cc" + "audio/processors/audio_debugger.cc" + "led/single_led.cc" + "led/circular_strip.cc" + "led/gpio_led.cc" + "display/display.cc" + "display/lcd_display.cc" + "display/oled_display.cc" + "display/lvgl_display/lvgl_display.cc" + "display/emote_display.cc" + "display/lvgl_display/emoji_collection.cc" + "display/lvgl_display/lvgl_theme.cc" + "display/lvgl_display/lvgl_font.cc" + "display/lvgl_display/lvgl_image.cc" + "display/lvgl_display/gif/lvgl_gif.cc" + "display/lvgl_display/gif/gifdec.c" + "display/lvgl_display/jpg/image_to_jpeg.cpp" + "display/lvgl_display/jpg/jpeg_to_image.c" + "protocols/protocol.cc" + "protocols/mqtt_protocol.cc" + "protocols/websocket_protocol.cc" + "mcp_server.cc" + "system_info.cc" + "application.cc" + "ota.cc" + "settings.cc" + "device_state_machine.cc" + "assets.cc" + # "main.cc" + ) + +# Transform relative paths to absolute paths from xiaozhi-esp32/main +list(TRANSFORM SOURCES PREPEND "${XIAOZHI_MAIN_DIR}/") + +set(INCLUDE_DIRS "." "display" "display/lvgl_display" "display/lvgl_display/jpg" "audio" "protocols") +# Transform include dirs to absolute paths from xiaozhi-esp32/main +list(TRANSFORM INCLUDE_DIRS PREPEND "${XIAOZHI_MAIN_DIR}/") + +# Add board common files +file(GLOB BOARD_COMMON_SOURCES ${XIAOZHI_MAIN_DIR}/boards/common/*.cc) +list(APPEND SOURCES ${BOARD_COMMON_SOURCES} ${STACK_CHAN_SOURCES}) +list(APPEND INCLUDE_DIRS ${XIAOZHI_MAIN_DIR}/boards/common ${STACK_CHAN_INCLUDE_DIRS}) + +idf_build_get_property(build_components BUILD_COMPONENTS) +# Function to find component dynamically by pattern +function(find_component_by_pattern PATTERN COMPONENT_VAR PATH_VAR) + foreach(COMPONENT ${build_components}) + if(COMPONENT MATCHES "${PATTERN}") + set(${COMPONENT_VAR} ${COMPONENT} PARENT_SCOPE) + idf_component_get_property(COMPONENT_PATH ${COMPONENT} COMPONENT_DIR) + set(${PATH_VAR} "${COMPONENT_PATH}" PARENT_SCOPE) + break() + endif() + endforeach() +endfunction() + +# Set default BUILTIN_TEXT_FONT and BUILTIN_ICON_FONT +set(BUILTIN_TEXT_FONT font_puhui_14_1) +set(BUILTIN_ICON_FONT font_awesome_14_1) + +# Add board files according to BOARD_TYPE +# Set default assets if the board uses partition table V2 +if(CONFIG_BOARD_TYPE_M5STACK_STACK_CHAN) + set(BOARD_TYPE "m5stack-stack-chan") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +endif() + +file(GLOB BOARD_SOURCES + ${XIAOZHI_MAIN_DIR}/boards/${BOARD_TYPE}/*.cc + ${XIAOZHI_MAIN_DIR}/boards/${BOARD_TYPE}/*.c +) +list(APPEND SOURCES ${BOARD_SOURCES}) + +# Select audio processor according to Kconfig +if(CONFIG_USE_AUDIO_PROCESSOR) + list(APPEND SOURCES "${XIAOZHI_MAIN_DIR}/audio/processors/afe_audio_processor.cc") +else() + list(APPEND SOURCES "${XIAOZHI_MAIN_DIR}/audio/processors/no_audio_processor.cc") +endif() +if(CONFIG_IDF_TARGET_ESP32S3 OR CONFIG_IDF_TARGET_ESP32P4) + list(APPEND SOURCES "${XIAOZHI_MAIN_DIR}/audio/wake_words/afe_wake_word.cc") + list(APPEND SOURCES "${XIAOZHI_MAIN_DIR}/audio/wake_words/custom_wake_word.cc") +else() + list(APPEND SOURCES "${XIAOZHI_MAIN_DIR}/audio/wake_words/esp_wake_word.cc") +endif() + +# Auto Select Additional Sources +if (CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING) + list(APPEND SOURCES "${XIAOZHI_MAIN_DIR}/boards/common/blufi.cpp") +endif () +# Select language directory according to Kconfig +if(CONFIG_LANGUAGE_ZH_CN) + set(LANG_DIR "zh-CN") +elseif(CONFIG_LANGUAGE_ZH_TW) + set(LANG_DIR "zh-TW") +elseif(CONFIG_LANGUAGE_EN_US) + set(LANG_DIR "en-US") +elseif(CONFIG_LANGUAGE_JA_JP) + set(LANG_DIR "ja-JP") +elseif(CONFIG_LANGUAGE_KO_KR) + set(LANG_DIR "ko-KR") +elseif(CONFIG_LANGUAGE_VI_VN) + set(LANG_DIR "vi-VN") +elseif(CONFIG_LANGUAGE_TH_TH) + set(LANG_DIR "th-TH") +elseif(CONFIG_LANGUAGE_DE_DE) + set(LANG_DIR "de-DE") +elseif(CONFIG_LANGUAGE_FR_FR) + set(LANG_DIR "fr-FR") +elseif(CONFIG_LANGUAGE_ES_ES) + set(LANG_DIR "es-ES") +elseif(CONFIG_LANGUAGE_IT_IT) + set(LANG_DIR "it-IT") +elseif(CONFIG_LANGUAGE_RU_RU) + set(LANG_DIR "ru-RU") +elseif(CONFIG_LANGUAGE_AR_SA) + set(LANG_DIR "ar-SA") +elseif(CONFIG_LANGUAGE_HI_IN) + set(LANG_DIR "hi-IN") +elseif(CONFIG_LANGUAGE_PT_PT) + set(LANG_DIR "pt-PT") +elseif(CONFIG_LANGUAGE_PL_PL) + set(LANG_DIR "pl-PL") +elseif(CONFIG_LANGUAGE_CS_CZ) + set(LANG_DIR "cs-CZ") +elseif(CONFIG_LANGUAGE_FI_FI) + set(LANG_DIR "fi-FI") +elseif(CONFIG_LANGUAGE_TR_TR) + set(LANG_DIR "tr-TR") +elseif(CONFIG_LANGUAGE_ID_ID) + set(LANG_DIR "id-ID") +elseif(CONFIG_LANGUAGE_UK_UA) + set(LANG_DIR "uk-UA") +elseif(CONFIG_LANGUAGE_RO_RO) + set(LANG_DIR "ro-RO") +elseif(CONFIG_LANGUAGE_BG_BG) + set(LANG_DIR "bg-BG") +elseif(CONFIG_LANGUAGE_CA_ES) + set(LANG_DIR "ca-ES") +elseif(CONFIG_LANGUAGE_DA_DK) + set(LANG_DIR "da-DK") +elseif(CONFIG_LANGUAGE_EL_GR) + set(LANG_DIR "el-GR") +elseif(CONFIG_LANGUAGE_FA_IR) + set(LANG_DIR "fa-IR") +elseif(CONFIG_LANGUAGE_FIL_PH) + set(LANG_DIR "fil-PH") +elseif(CONFIG_LANGUAGE_HE_IL) + set(LANG_DIR "he-IL") +elseif(CONFIG_LANGUAGE_HR_HR) + set(LANG_DIR "hr-HR") +elseif(CONFIG_LANGUAGE_HU_HU) + set(LANG_DIR "hu-HU") +elseif(CONFIG_LANGUAGE_MS_MY) + set(LANG_DIR "ms-MY") +elseif(CONFIG_LANGUAGE_NB_NO) + set(LANG_DIR "nb-NO") +elseif(CONFIG_LANGUAGE_NL_NL) + set(LANG_DIR "nl-NL") +elseif(CONFIG_LANGUAGE_SK_SK) + set(LANG_DIR "sk-SK") +elseif(CONFIG_LANGUAGE_SL_SI) + set(LANG_DIR "sl-SI") +elseif(CONFIG_LANGUAGE_SV_SE) + set(LANG_DIR "sv-SE") +elseif(CONFIG_LANGUAGE_SR_RS) + set(LANG_DIR "sr-RS") +endif() + +# Define generation path +set(LANG_JSON "${XIAOZHI_MAIN_DIR}/assets/locales/${LANG_DIR}/language.json") +set(LANG_HEADER "${XIAOZHI_MAIN_DIR}/assets/lang_config.h") + +# Collect current language audio files +file(GLOB LANG_SOUNDS ${XIAOZHI_MAIN_DIR}/assets/locales/${LANG_DIR}/*.ogg) + +# If not en-US, collect en-US audio files as fallback for missing files +if(NOT LANG_DIR STREQUAL "en-US") + file(GLOB EN_US_SOUNDS ${XIAOZHI_MAIN_DIR}/assets/locales/en-US/*.ogg) + + # Extract filenames (without path) from current language + set(EXISTING_NAMES "") + foreach(SOUND_FILE ${LANG_SOUNDS}) + get_filename_component(FILENAME ${SOUND_FILE} NAME) + list(APPEND EXISTING_NAMES ${FILENAME}) + endforeach() + + # Only add en-US audio files that are missing in current language + foreach(EN_SOUND ${EN_US_SOUNDS}) + get_filename_component(FILENAME ${EN_SOUND} NAME) + if(NOT ${FILENAME} IN_LIST EXISTING_NAMES) + list(APPEND LANG_SOUNDS ${EN_SOUND}) + message(STATUS "Using en-US fallback for missing audio: ${FILENAME}") + endif() + endforeach() +endif() + +# file(GLOB COMMON_SOUNDS ${XIAOZHI_MAIN_DIR}/assets/common/*.ogg) +file(GLOB COMMON_SOUNDS + ${XIAOZHI_MAIN_DIR}/assets/common/*.ogg + assets/sfx/*.ogg +) + +# If target chip is ESP32, exclude specific files to avoid build errors +if(CONFIG_IDF_TARGET_ESP32) + list(REMOVE_ITEM SOURCES "audio/codecs/box_audio_codec.cc" + "audio/codecs/es8388_audio_codec.cc" + "audio/codecs/es8389_audio_codec.cc" + "led/gpio_led.cc" + "${XIAOZHI_MAIN_DIR}/boards/common/esp32_camera.cc" + "display/lvgl_display/jpg/image_to_jpeg.cpp" + "display/lvgl_display/jpg/jpeg_to_image.c" + ) +endif() + +idf_component_register(SRCS ${SOURCES} + EMBED_FILES ${LANG_SOUNDS} ${COMMON_SOUNDS} + INCLUDE_DIRS ${INCLUDE_DIRS} + WHOLE_ARCHIVE + ) + +# Use target_compile_definitions to define BOARD_TYPE, BOARD_NAME +# If BOARD_NAME is empty, use BOARD_TYPE +if(NOT BOARD_NAME) + set(BOARD_NAME ${BOARD_TYPE}) +endif() +target_compile_definitions(${COMPONENT_LIB} + PRIVATE BOARD_TYPE=\"${BOARD_TYPE}\" BOARD_NAME=\"${BOARD_NAME}\" + PRIVATE BUILTIN_TEXT_FONT=${BUILTIN_TEXT_FONT} BUILTIN_ICON_FONT=${BUILTIN_ICON_FONT} + ) + +# Add generation rules +add_custom_command( + OUTPUT ${LANG_HEADER} + COMMAND python ${PROJECT_DIR}/xiaozhi-esp32/scripts/gen_lang.py + --language "${LANG_DIR}" + --output "${LANG_HEADER}" + DEPENDS + ${LANG_JSON} + ${PROJECT_DIR}/xiaozhi-esp32/scripts/gen_lang.py + COMMENT "Generating ${LANG_DIR} language config" +) + +# Force build generation dependencies +add_custom_target(lang_header ALL + DEPENDS ${LANG_HEADER} +) + +# Find ESP-SR component dynamically +find_component_by_pattern("espressif__esp-sr" ESP_SR_COMPONENT ESP_SR_COMPONENT_PATH) +if(ESP_SR_COMPONENT_PATH) + set(ESP_SR_MODEL_PATH "${ESP_SR_COMPONENT_PATH}/model") +endif() + +# Find xiaozhi-fonts component dynamically +find_component_by_pattern("xiaozhi-fonts" XIAOZHI_FONTS_COMPONENT XIAOZHI_FONTS_COMPONENT_PATH) +if(XIAOZHI_FONTS_COMPONENT_PATH) + set(XIAOZHI_FONTS_PATH "${XIAOZHI_FONTS_COMPONENT_PATH}") +endif() + +if(CONFIG_BOARD_TYPE_ESP_HI) +set(URL "https://github.com/espressif2022/image_player/raw/main/test_apps/test_8bit") +set(EMOJI_DIR "${CMAKE_BINARY_DIR}/emoji") +file(MAKE_DIRECTORY ${EMOJI_DIR}) + +# List all files to download +set(FILES_TO_DOWNLOAD "") +list(APPEND FILES_TO_DOWNLOAD "Anger_enter.aaf" "Anger_loop.aaf" "Anger_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "happy_enter.aaf" "happy_loop.aaf" "happ_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "sad_enter.aaf" "sad_loop.aaf" "sad_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "scorn_enter.aaf" "scorn_loop.aaf" "scorn_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "left_enter.aaf" "left_loop.aaf" "left_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "right_enter.aaf" "right_loop.aaf" "right_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "asking.aaf" "blink_once.aaf" "blink_quick.aaf") +list(APPEND FILES_TO_DOWNLOAD "connecting.aaf" "panic_enter.aaf" "panic_loop.aaf") +list(APPEND FILES_TO_DOWNLOAD "panic_return.aaf" "wake.aaf") + +foreach(FILENAME IN LISTS FILES_TO_DOWNLOAD) + set(REMOTE_FILE "${URL}/${FILENAME}") + set(LOCAL_FILE "${EMOJI_DIR}/${FILENAME}") + + # Check if local file exists + if(EXISTS ${LOCAL_FILE}) + message(STATUS "File ${FILENAME} already exists, skipping download") + else() + message(STATUS "Downloading ${FILENAME}") + file(DOWNLOAD ${REMOTE_FILE} ${LOCAL_FILE} + STATUS DOWNLOAD_STATUS) + list(GET DOWNLOAD_STATUS 0 STATUS_CODE) + if(NOT STATUS_CODE EQUAL 0) + message(FATAL_ERROR "Failed to download ${FILENAME} from ${URL}") + endif() + endif() +endforeach() + +endif() + +set(DEFAULT_ASSETS_EXTRA_FILES "${CMAKE_CURRENT_SOURCE_DIR}/assets/assets_bin") + +# Function to build default assets based on configuration +function(build_default_assets_bin) + # Set output path for generated assets.bin + set(GENERATED_ASSETS_BIN "${CMAKE_BINARY_DIR}/generated_assets.bin") + + # Prepare arguments for build script + set(BUILD_ARGS + "--sdkconfig" "${SDKCONFIG}" + "--output" "${GENERATED_ASSETS_BIN}" + ) + + # Add builtin text font if defined + if(BUILTIN_TEXT_FONT) + list(APPEND BUILD_ARGS "--builtin_text_font" "${BUILTIN_TEXT_FONT}") + endif() + + # Add default emoji collection if defined + if(DEFAULT_EMOJI_COLLECTION) + list(APPEND BUILD_ARGS "--emoji_collection" "${DEFAULT_EMOJI_COLLECTION}") + endif() + + # Add default assets extra files if defined + if(DEFAULT_ASSETS_EXTRA_FILES) + list(APPEND BUILD_ARGS "--extra_files" "${DEFAULT_ASSETS_EXTRA_FILES}") + endif() + + list(APPEND BUILD_ARGS "--esp_sr_model_path" "${ESP_SR_MODEL_PATH}") + list(APPEND BUILD_ARGS "--xiaozhi_fonts_path" "${XIAOZHI_FONTS_PATH}") + + # Create custom command to build assets + add_custom_command( + OUTPUT ${GENERATED_ASSETS_BIN} + COMMAND python ${PROJECT_DIR}/xiaozhi-esp32/scripts/build_default_assets.py ${BUILD_ARGS} + DEPENDS + ${SDKCONFIG} + ${PROJECT_DIR}/xiaozhi-esp32/scripts/build_default_assets.py + COMMENT "Building default assets.bin based on configuration" + VERBATIM + ) + + # Create target for generated assets + add_custom_target(generated_default_assets ALL + DEPENDS ${GENERATED_ASSETS_BIN} + ) + + # Set the generated file path in parent scope + set(GENERATED_ASSETS_LOCAL_FILE ${GENERATED_ASSETS_BIN} PARENT_SCOPE) + + message(STATUS "Default assets build configured: ${GENERATED_ASSETS_BIN}") +endfunction() + + +# Function to get local assets file path (handles both URL and local file) +function(get_assets_local_file assets_source assets_local_file_var) + # Check if it's a URL (starts with http:// or https://) + if(assets_source MATCHES "^https?://") + # It's a URL, download it + get_filename_component(ASSETS_FILENAME "${assets_source}" NAME) + set(ASSETS_LOCAL_FILE "${CMAKE_BINARY_DIR}/${ASSETS_FILENAME}") + set(ASSETS_TEMP_FILE "${CMAKE_BINARY_DIR}/${ASSETS_FILENAME}.tmp") + + # Check if local file exists + if(EXISTS ${ASSETS_LOCAL_FILE}) + message(STATUS "Assets file ${ASSETS_FILENAME} already exists, skipping download") + else() + message(STATUS "Downloading ${ASSETS_FILENAME}") + + # Clean up any existing temp file + if(EXISTS ${ASSETS_TEMP_FILE}) + file(REMOVE ${ASSETS_TEMP_FILE}) + endif() + + # Download to temporary file first + file(DOWNLOAD ${assets_source} ${ASSETS_TEMP_FILE} + STATUS DOWNLOAD_STATUS) + list(GET DOWNLOAD_STATUS 0 STATUS_CODE) + if(NOT STATUS_CODE EQUAL 0) + # Clean up temp file on failure + if(EXISTS ${ASSETS_TEMP_FILE}) + file(REMOVE ${ASSETS_TEMP_FILE}) + endif() + message(FATAL_ERROR "Failed to download ${ASSETS_FILENAME} from ${assets_source}") + endif() + + # Move temp file to final location (atomic operation) + file(RENAME ${ASSETS_TEMP_FILE} ${ASSETS_LOCAL_FILE}) + message(STATUS "Successfully downloaded ${ASSETS_FILENAME}") + endif() + else() + # It's a local file path + if(IS_ABSOLUTE "${assets_source}") + set(ASSETS_LOCAL_FILE "${assets_source}") + else() + set(ASSETS_LOCAL_FILE "${XIAOZHI_MAIN_DIR}/${assets_source}") + endif() + + # Check if local file exists + if(NOT EXISTS ${ASSETS_LOCAL_FILE}) + message(FATAL_ERROR "Assets file not found: ${ASSETS_LOCAL_FILE}") + endif() + + message(STATUS "Using assets file: ${ASSETS_LOCAL_FILE}") + endif() + + set(${assets_local_file_var} ${ASSETS_LOCAL_FILE} PARENT_SCOPE) +endfunction() + + +partition_table_get_partition_info(size "--partition-name assets" "size") +partition_table_get_partition_info(offset "--partition-name assets" "offset") +if ("${size}" AND "${offset}") + # Flash assets based on configuration + if(CONFIG_FLASH_DEFAULT_ASSETS) + # Build default assets based on configuration + build_default_assets_bin() + esptool_py_flash_to_partition(flash "assets" "${GENERATED_ASSETS_LOCAL_FILE}") + message(STATUS "Generated default assets flash configured: ${GENERATED_ASSETS_LOCAL_FILE} -> assets partition") + elseif(CONFIG_FLASH_CUSTOM_ASSETS) + # Flash custom assets + get_assets_local_file("${CONFIG_CUSTOM_ASSETS_FILE}" ASSETS_LOCAL_FILE) + esptool_py_flash_to_partition(flash "assets" "${ASSETS_LOCAL_FILE}") + message(STATUS "Custom assets flash configured: ${ASSETS_LOCAL_FILE} -> assets partition") + elseif(CONFIG_FLASH_NONE_ASSETS) + message(STATUS "Assets flashing disabled (FLASH_NONE_ASSETS)") + endif() +else() + message(STATUS "Assets partition not found, using v1 partition table") +endif() diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild new file mode 100644 index 0000000..6f503f3 --- /dev/null +++ b/firmware/main/Kconfig.projbuild @@ -0,0 +1,898 @@ +menu "Xiaozhi Assistant" + +config OTA_URL + string "Default OTA URL" + default "https://api.tenclass.net/xiaozhi/ota/" + help + The application will access this URL to check for new firmwares and server address. + +choice + prompt "Flash Assets" + default FLASH_DEFAULT_ASSETS + help + Select the assets to flash. + + config FLASH_NONE_ASSETS + bool "Do not flash assets" + config FLASH_DEFAULT_ASSETS + bool "Flash Default Assets" + config FLASH_CUSTOM_ASSETS + bool "Flash Custom Assets" +endchoice + +config CUSTOM_ASSETS_FILE + depends on FLASH_CUSTOM_ASSETS + string "Custom Assets File" + default "assets.bin" + help + The custom assets file to flash. + It can be a local file relative to the project directory or a remote url. + +choice + prompt "Default Language" + default LANGUAGE_ZH_CN + help + Select device display language + + config LANGUAGE_ZH_CN + bool "Chinese" + config LANGUAGE_ZH_TW + bool "Chinese Traditional" + config LANGUAGE_EN_US + bool "English" + config LANGUAGE_JA_JP + bool "Japanese" + config LANGUAGE_KO_KR + bool "Korean" + config LANGUAGE_VI_VN + bool "Vietnamese" + config LANGUAGE_TH_TH + bool "Thai" + config LANGUAGE_DE_DE + bool "German" + config LANGUAGE_FR_FR + bool "French" + config LANGUAGE_ES_ES + bool "Spanish" + config LANGUAGE_IT_IT + bool "Italian" + config LANGUAGE_RU_RU + bool "Russian" + config LANGUAGE_AR_SA + bool "Arabic" + config LANGUAGE_HI_IN + bool "Hindi" + config LANGUAGE_PT_PT + bool "Portuguese" + config LANGUAGE_PL_PL + bool "Polish" + config LANGUAGE_CS_CZ + bool "Czech" + config LANGUAGE_FI_FI + bool "Finnish" + config LANGUAGE_TR_TR + bool "Turkish" + config LANGUAGE_ID_ID + bool "Indonesian" + config LANGUAGE_UK_UA + bool "Ukrainian" + config LANGUAGE_RO_RO + bool "Romanian" + config LANGUAGE_BG_BG + bool "Bulgarian" + config LANGUAGE_CA_ES + bool "Catalan" + config LANGUAGE_DA_DK + bool "Danish" + config LANGUAGE_EL_GR + bool "Greek" + config LANGUAGE_FA_IR + bool "Persian" + config LANGUAGE_FIL_PH + bool "Filipino" + config LANGUAGE_HE_IL + bool "Hebrew" + config LANGUAGE_HR_HR + bool "Croatian" + config LANGUAGE_HU_HU + bool "Hungarian" + config LANGUAGE_MS_MY + bool "Malay" + config LANGUAGE_NB_NO + bool "Norwegian" + config LANGUAGE_NL_NL + bool "Dutch" + config LANGUAGE_SK_SK + bool "Slovak" + config LANGUAGE_SL_SI + bool "Slovenian" + config LANGUAGE_SV_SE + bool "Swedish" + config LANGUAGE_SR_RS + bool "Serbian" +endchoice + +choice BOARD_TYPE + prompt "Board Type" + default BOARD_TYPE_BREAD_COMPACT_WIFI + help + Board type. 开发板类型 + config BOARD_TYPE_BREAD_COMPACT_WIFI + bool "Bread Compact WiFi (面包板)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_BREAD_COMPACT_WIFI_LCD + bool "Bread Compact WiFi + LCD (面包板)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_BREAD_COMPACT_WIFI_CAM + bool "Bread Compact WiFi + LCD + Camera (面包板)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_BREAD_COMPACT_ML307 + bool "Bread Compact ML307/EC801E (面包板 4G)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_BREAD_COMPACT_ESP32 + bool "Bread Compact ESP32 DevKit (面包板)" + depends on IDF_TARGET_ESP32 + config BOARD_TYPE_BREAD_COMPACT_ESP32_LCD + bool "Bread Compact ESP32 DevKit + LCD (面包板)" + depends on IDF_TARGET_ESP32 + config BOARD_TYPE_XMINI_C3_V3 + bool "Xmini C3 V3" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_XMINI_C3_4G + bool "Xmini C3 4G" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_XMINI_C3 + bool "Xmini C3" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_ESP_KORVO2_V3 + bool "Espressif Korvo2 V3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ESP_SPARKBOT + bool "Espressif SparkBot" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ESP_SPOT_S3 + bool "Espressif Spot-S3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ESP_SPOT_C5 + bool "Espressif Spot-C5" + depends on IDF_TARGET_ESP32C5 + config BOARD_TYPE_ESP_HI + bool "Espressif ESP-HI" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_ESP_BOX_3 + bool "Espressif ESP-BOX-3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ESP_BOX + bool "Espressif ESP-BOX" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ESP_BOX_LITE + bool "Espressif ESP-BOX-Lite" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ESP_P4_FUNCTION_EV_BOARD + bool "Espressif ESP-P4-Function-EV-Board" + depends on IDF_TARGET_ESP32P4 + config BOARD_TYPE_ECHOEAR + bool "EchoEar" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_KEVIN_BOX_2 + bool "Kevin Box 2" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_KEVIN_C3 + bool "Kevin C3" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_KEVIN_SP_V3_DEV + bool "Kevin SP V3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_KEVIN_SP_V4_DEV + bool "Kevin SP V4" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_KEVIN_YUYING_313LCD + bool "鱼鹰科技 3.13LCD" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_CGC + bool "CGC" + depends on IDF_TARGET_ESP32 + config BOARD_TYPE_CGC_144 + bool "CGC 144" + depends on IDF_TARGET_ESP32 + config BOARD_TYPE_LICHUANG_DEV_S3 + bool "立创·实战派 ESP32-S3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LICHUANG_DEV_C3 + bool "立创·实战派 ESP32-C3" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_DF_K10 + bool "DFRobot 行空板 k10" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_DF_S3_AI_CAM + bool "DFRobot ESP32-S3 AI智能摄像头模块" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_MAGICLICK_S3_2P4 + bool "神奇按钮 Magiclick_2.4" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_MAGICLICK_S3_2P5 + bool "神奇按钮 Magiclick_2.5" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_MAGICLICK_C3 + bool "神奇按钮 Magiclick_C3" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_MAGICLICK_C3_V2 + bool "神奇按钮 Magiclick_C3_v2" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_M5STACK_CORE_S3 + bool "M5Stack CoreS3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_M5STACK_STACK_CHAN + bool "M5Stack StackChan" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_M5STACK_CORE_TAB5 + bool "M5Stack Tab5" + depends on IDF_TARGET_ESP32P4 + config BOARD_TYPE_M5STACK_ATOM_S3_ECHO_BASE + bool "M5Stack AtomS3 + Echo Base" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_M5STACK_ATOM_S3R_ECHO_BASE + bool "M5Stack AtomS3R + Echo Base" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_M5STACK_ATOM_S3R_CAM_M12_ECHO_BASE + bool "M5Stack AtomS3R CAM/M12 + Echo Base" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_M5STACK_ATOM_ECHOS3R + bool "M5Stack AtomEchoS3R" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_M5STACK_ATOM_MATRIX_ECHO_BASE + bool "M5Stack AtomMatrix + Echo Base" + depends on IDF_TARGET_ESP32 + config BOARD_TYPE_WAVESHARE_S3_AUDIO_BOARD + bool "Waveshare ESP32-S3-Audio-Board" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_8 + bool "Waveshare ESP32-S3-Touch-AMOLED-1.8" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_2_06 + bool "Waveshare ESP32-S3-Touch-AMOLED-2.06" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_2_06 + bool "Waveshare ESP32-C6-Touch-AMOLED-2.06" + depends on IDF_TARGET_ESP32C6 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_75 + bool "Waveshare ESP32-S3-Touch-AMOLED-1.75" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_83 + bool "Waveshare ESP32-S3-Touch-LCD-1.83" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_4B + bool "Waveshare ESP32-S3-Touch-LCD-4B" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_85C + bool "Waveshare ESP32-S3-Touch-LCD-1.85C" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_85 + bool "Waveshare ESP32-S3-Touch-LCD-1.85" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_46 + bool "Waveshare ESP32-S3-Touch-LCD-1.46" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_C6_LCD_1_69 + bool "Waveshare ESP32-C6-LCD-1.69" + depends on IDF_TARGET_ESP32C6 + config BOARD_TYPE_WAVESHARE_C6_TOUCH_LCD_1_83 + bool "Waveshare ESP32-C6-Touch-LCD-1.83" + depends on IDF_TARGET_ESP32C6 + config BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_1_43 + bool "Waveshare ESP32-C6-Touch-AMOLOED-1.43" + depends on IDF_TARGET_ESP32C6 + config BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_1_32 + bool "Waveshare ESP32-C6-Touch-AMOLOED-1.32" + depends on IDF_TARGET_ESP32C6 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_32 + bool "Waveshare ESP32-S3-Touch-AMOLOED-1.32" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_49 + bool "Waveshare ESP32-S3-Touch-LCD-3.49" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_5 + bool "Waveshare ESP32-S3-Touch-LCD-3.5" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_ePaper_1_54 + bool "Waveshare ESP32-S3-ePaper-1.54" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_5B + bool "Waveshare ESP32-S3-Touch-LCD-3.5B" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WAVESHARE_P4_NANO + bool "Waveshare ESP32-P4-NANO" + depends on IDF_TARGET_ESP32P4 + config BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_4B + bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-4B" + depends on IDF_TARGET_ESP32P4 + config BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_7B + bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-7B" + depends on IDF_TARGET_ESP32P4 + config BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_XC + bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C or ESP32-P4-WIFI6-Touch-LCD-4C" + depends on IDF_TARGET_ESP32P4 + config BOARD_TYPE_TUDOUZI + bool "土豆子" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LILYGO_T_CIRCLE_S3 + bool "LILYGO T-Circle-S3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LILYGO_T_CAMERAPLUS_S3_V1_0_V1_1 + bool "LILYGO T-CameraPlus-S3_V1_0_V1_1" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LILYGO_T_CAMERAPLUS_S3_V1_2 + bool "LILYGO T-CameraPlus-S3_V1_2" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LILYGO_T_DISPLAY_S3_PRO_MVSRLORA + bool "LILYGO T-Display-S3-Pro-MVSRLora" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LILYGO_T_DISPLAY_S3_PRO_MVSRLORA_NO_BATTERY + bool "LILYGO T-Display-S3-Pro-MVSRLora_No_Battery" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LILYGO_T_DISPLAY_P4 + bool "LILYGO T-Display-P4" + depends on IDF_TARGET_ESP32P4 + config BOARD_TYPE_MOVECALL_MOJI_ESP32S3 + bool "Movecall Moji 小智AI衍生版" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_MOVECALL_MOJI2_ESP32C5 + bool "Movecall Moji2.0 小智AI衍生版" + depends on IDF_TARGET_ESP32C5 + config BOARD_TYPE_MOVECALL_CUICAN_ESP32S3 + bool "Movecall CuiCan 璀璨·AI吊坠" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ATK_DNESP32S3 + bool "正点原子DNESP32S3开发板" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ATK_DNESP32S3_BOX + bool "正点原子DNESP32S3-BOX" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ATK_DNESP32S3_BOX0 + bool "正点原子DNESP32S3-BOX0" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ATK_DNESP32S3_BOX2_WIFI + bool "正点原子DNESP32S3-BOX2-WIFI" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ATK_DNESP32S3_BOX2_4G + bool "正点原子DNESP32S3-BOX2-4G" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ATK_DNESP32S3M_WIFI + bool "正点原子DNESP32S3M-WIFI" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ATK_DNESP32S3M_4G + bool "正点原子DNESP32S3M-4G" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_DU_CHATX + bool "嘟嘟开发板CHATX(wifi)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_TAIJI_PI_S3 + bool "太极小派esp32s3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_XINGZHI_CUBE_0_85TFT_WIFI + bool "无名科技星智0.85(WIFI)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_XINGZHI_CUBE_0_85TFT_ML307 + bool "无名科技星智0.85(ML307)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_XINGZHI_CUBE_0_96OLED_WIFI + bool "无名科技星智0.96(WIFI)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_XINGZHI_CUBE_0_96OLED_ML307 + bool "无名科技星智0.96(ML307)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_XINGZHI_CUBE_1_54TFT_WIFI + bool "无名科技星智1.54(WIFI)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_XINGZHI_CUBE_1_54TFT_ML307 + bool "无名科技星智1.54(ML307)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_XINGZHI_METAL_1_54_WIFI + bool "无名科技星智1.54 METAL(wifi)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_SEEED_STUDIO_SENSECAP_WATCHER + bool "Seeed Studio SenseCAP Watcher" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_DOIT_S3_AIBOX + bool "四博智联AI陪伴盒子" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_MIXGO_NOVA + bool "元控·青春" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_GENJUTECH_S3_1_54TFT + bool "亘具科技1.54(s3)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ESP_S3_LCD_EV_Board + bool "乐鑫ESP S3 LCD EV Board开发板" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ESP_S3_LCD_EV_Board_2 + bool "乐鑫ESP S3 LCD EV Board 2开发板" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ZHENGCHEN_1_54TFT_WIFI + bool "征辰科技1.54(WIFI)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ZHENGCHEN_1_54TFT_ML307 + bool "征辰科技1.54(ML307)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_MINSI_K08_DUAL + bool "敏思科技K08(DUAL)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_SPOTPEAR_ESP32_S3_1_54_MUMA + bool "Spotpear ESP32-S3-1.54-MUMA" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_SPOTPEAR_ESP32_S3_1_28_BOX + bool "Spotpear ESP32-S3-1.28-BOX" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_OTTO_ROBOT + bool "ottoRobot" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ELECTRON_BOT + bool "electronBot" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_JIUCHUAN + bool "九川智能" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LABPLUS_MPYTHON_V3 + bool "labplus mpython_v3 board" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_LABPLUS_LEDONG_V2 + bool "labplus ledong_v2 board" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_SURFER_C3_1_14TFT + bool "Surfer-C3-1.14TFT" + depends on IDF_TARGET_ESP32C3 + config BOARD_TYPE_YUNLIAO_S3 + bool "小智云聊-S3" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_WIRELESS_TAG_WTP4C5MP07S + bool "Wireless-Tag WTP4C5MP07S" + depends on IDF_TARGET_ESP32P4 + config BOARD_TYPE_AIPI_LITE + bool "AIPI-Lite" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_HU_087 + bool "HU-087" + depends on IDF_TARGET_ESP32S3 +endchoice + +choice + depends on BOARD_TYPE_LILYGO_T_DISPLAY_P4 + prompt "Select the screen type" + default SCREEN_TYPE_HI8561 + config SCREEN_TYPE_HI8561 + bool "HI8561" + config SCREEN_TYPE_RM69A10 + bool "RM69A10" +endchoice + +choice + depends on BOARD_TYPE_LILYGO_T_DISPLAY_P4 + prompt "Select the color format of the screen" + default SCREEN_PIXEL_FORMAT_RGB565 + config SCREEN_PIXEL_FORMAT_RGB565 + bool "RGB565" + config SCREEN_PIXEL_FORMAT_RGB888 + bool "RGB888" +endchoice + +choice ESP_S3_LCD_EV_Board_Version_TYPE + depends on BOARD_TYPE_ESP_S3_LCD_EV_Board + prompt "EV_BOARD Type" + default ESP_S3_LCD_EV_Board_1p4 + config ESP_S3_LCD_EV_Board_1p4 + bool "乐鑫ESP32_S3_LCD_EV_Board-MB_V1.4" + config ESP_S3_LCD_EV_Board_1p5 + bool "乐鑫ESP32_S3_LCD_EV_Board-MB_V1.5" +endchoice + +choice DISPLAY_OLED_TYPE + depends on BOARD_TYPE_BREAD_COMPACT_WIFI || BOARD_TYPE_BREAD_COMPACT_ML307 || BOARD_TYPE_BREAD_COMPACT_ESP32 || BOARD_TYPE_HU_087 + prompt "OLED Type" + default OLED_SSD1306_128X32 + help + OLED Monochrome Display Type + config OLED_SSD1306_128X32 + bool "SSD1306 128*32" + config OLED_SSD1306_128X64 + bool "SSD1306 128*64" + config OLED_SH1106_128X64 + bool "SH1106 128*64" +endchoice + +choice DISPLAY_LCD_TYPE + depends on BOARD_TYPE_BREAD_COMPACT_WIFI_LCD || BOARD_TYPE_BREAD_COMPACT_ESP32_LCD || BOARD_TYPE_CGC || BOARD_TYPE_WAVESHARE_P4_NANO || BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_XC || BOARD_TYPE_BREAD_COMPACT_WIFI_CAM + prompt "LCD Type" + default LCD_ST7789_240X320 + help + LCD Display Type + config LCD_ST7789_240X320 + bool "ST7789 240*320, IPS" + config LCD_ST7789_240X320_NO_IPS + bool "ST7789 240*320, Non-IPS" + config LCD_ST7789_170X320 + bool "ST7789 170*320" + config LCD_ST7789_172X320 + bool "ST7789 172*320" + config LCD_ST7789_240X280 + bool "ST7789 240*280" + config LCD_ST7789_240X240 + bool "ST7789 240*240" + config LCD_ST7789_240X240_7PIN + bool "ST7789 240*240, 7PIN" + config LCD_ST7789_240X135 + bool "ST7789 240*135" + config LCD_ST7735_128X160 + bool "ST7735 128*160" + config LCD_ST7735_128X128 + bool "ST7735 128*128" + config LCD_ST7796_320X480 + bool "ST7796 320*480 IPS" + config LCD_ST7796_320X480_NO_IPS + bool "ST7796 320*480, Non-IPS" + config LCD_ILI9341_240X320 + bool "ILI9341 240*320" + config LCD_ILI9341_240X320_NO_IPS + bool "ILI9341 240*320, Non-IPS" + config LCD_GC9A01_240X240 + bool "GC9A01 240*240 Circle" + config LCD_TYPE_800_1280_10_1_INCH + bool "Waveshare 101M-8001280-IPS-CT-K Display" + config LCD_TYPE_800_1280_10_1_INCH_A + bool "Waveshare 10.1-DSI-TOUCH-A Display" + config LCD_TYPE_800_800_3_4_INCH + bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C with 800*800 3.4inch round display" + config LCD_TYPE_720_720_4_INCH + bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-4C with 720*720 4inch round display" + config LCD_CUSTOM + bool "Custom LCD (自定义屏幕参数)" +endchoice + +choice DISPLAY_ESP32S3_KORVO2_V3 + depends on BOARD_TYPE_ESP_KORVO2_V3 + prompt "ESP32S3_KORVO2_V3 LCD Type" + default ESP32S3_KORVO2_V3_LCD_ST7789 + help + LCD Display Type + config ESP32S3_KORVO2_V3_LCD_ST7789 + bool "ST7789 240*280" + config ESP32S3_KORVO2_V3_LCD_ILI9341 + bool "ILI9341 240*320" +endchoice + +choice DISPLAY_ESP32S3_AUDIO_BOARD + depends on BOARD_TYPE_WAVESHARE_S3_AUDIO_BOARD + prompt "ESP32S3_AUDIO_BOARD LCD Type" + default AUDIO_BOARD_LCD_JD9853 + help + LCD Display Type + config AUDIO_BOARD_LCD_JD9853 + bool "JD9853 320*172" + config AUDIO_BOARD_LCD_ST7789 + bool "ST7789 240*320" +endchoice + +choice DISPLAY_STYLE + prompt "Select display style" + default USE_DEFAULT_MESSAGE_STYLE + help + Select display style for Xiaozhi device + + config USE_DEFAULT_MESSAGE_STYLE + bool "Enable default message style" + + config USE_WECHAT_MESSAGE_STYLE + bool "Enable WeChat Message Style" + + config USE_EMOTE_MESSAGE_STYLE + bool "Emote animation style" + depends on BOARD_TYPE_ESP_BOX_3 || BOARD_TYPE_ECHOEAR || BOARD_TYPE_LICHUANG_DEV_S3 +endchoice + +choice WAKE_WORD_TYPE + prompt "Wake Word Implementation Type" + default USE_AFE_WAKE_WORD if (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM + default WAKE_WORD_DISABLED + help + Choose the type of wake word implementation to use + + config WAKE_WORD_DISABLED + bool "Disabled" + help + Disable wake word detection + + config USE_ESP_WAKE_WORD + bool "Wakenet model without AFE" + depends on IDF_TARGET_ESP32C3 || IDF_TARGET_ESP32C5 || IDF_TARGET_ESP32C6 || (IDF_TARGET_ESP32 && SPIRAM) + help + Support ESP32 C3、ESP32 C5 与 ESP32 C6, and (ESP32 with PSRAM) + + config USE_AFE_WAKE_WORD + bool "Wakenet model with AFE" + depends on (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM + help + Support AEC if available, requires ESP32 S3 and PSRAM + + config USE_CUSTOM_WAKE_WORD + bool "Multinet model (Custom Wake Word)" + depends on (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM + help + Requires ESP32 S3 and PSRAM + +endchoice + +config CUSTOM_WAKE_WORD + string "Custom Wake Word" + default "xiao tu dou" + depends on USE_CUSTOM_WAKE_WORD + help + Custom Wake Word, use pinyin for Chinese, separated by spaces + +config CUSTOM_WAKE_WORD_DISPLAY + string "Custom Wake Word Display" + default "小土豆" + depends on USE_CUSTOM_WAKE_WORD + help + Greeting sent to the server after wake word detection + +config CUSTOM_WAKE_WORD_THRESHOLD + int "Custom Wake Word Threshold (%)" + default 20 + range 1 99 + depends on USE_CUSTOM_WAKE_WORD + help + Custom Wake Word Threshold, range 1-99, the smaller the more sensitive, default 20 + +config SEND_WAKE_WORD_DATA + bool "Send Wake Word Data" + default y + depends on USE_AFE_WAKE_WORD || USE_CUSTOM_WAKE_WORD + help + Send wake word data to the server as the first message of the conversation and wait for response + +config USE_AUDIO_PROCESSOR + bool "Enable Audio Noise Reduction" + default y + depends on (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM + help + Requires ESP32 S3 and PSRAM + +config USE_DEVICE_AEC + bool "Enable Device-Side AEC" + default n + depends on USE_AUDIO_PROCESSOR && (BOARD_TYPE_ESP_BOX_3 || BOARD_TYPE_ESP_BOX || BOARD_TYPE_ESP_BOX_LITE \ + || BOARD_TYPE_LICHUANG_DEV_S3 || BOARD_TYPE_ESP_KORVO2_V3 || BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_75 || BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_83\ + || BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_2_06 || BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_4B || BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_4B || BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_7B \ + || BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_XC || BOARD_TYPE_ESP_S3_LCD_EV_Board_2 || BOARD_TYPE_YUNLIAO_S3 \ + || BOARD_TYPE_ECHOEAR || BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_49) + help + To work properly, device-side AEC requires a clean output reference path from the speaker signal and physical acoustic isolation between the microphone and speaker. + +config USE_SERVER_AEC + bool "Enable Server-Side AEC (Unstable)" + default n + depends on USE_AUDIO_PROCESSOR + help + To work perperly, server-side AEC requires server support + +config USE_AUDIO_DEBUGGER + bool "Enable Audio Debugger" + default n + help + Enable audio debugger, send audio data through UDP to the host machine + +menu "WiFi Configuration Method" + help + WiFi Configuration Method Selection + config USE_HOTSPOT_WIFI_PROVISIONING + bool "Hotspot" + default y + help + Use WiFi Hotspot to transmit WiFi configuration data + config USE_ACOUSTIC_WIFI_PROVISIONING + bool "Acoustic" + help + Use audio signal to transmit WiFi configuration data + + config USE_ESP_BLUFI_WIFI_PROVISIONING + bool "Esp Blufi" + help + Use esp blufi protocol to transmit WiFi configuration data + select BT_ENABLED + select BT_BLE_42_FEATURES_SUPPORTED + select BT_BLE_BLUFI_ENABLE + select MBEDTLS_DHM_C +endmenu + +config AUDIO_DEBUG_UDP_SERVER + string "Audio Debug UDP Server Address" + default "192.168.2.100:8000" + depends on USE_AUDIO_DEBUGGER + help + UDP server address, format: IP:PORT, used to receive audio debugging data + +config RECEIVE_CUSTOM_MESSAGE + bool "Enable Custom Message Reception" + default n + help + Enable custom message reception, allow the device to receive custom messages from the server (preferably through the MQTT protocol) + +menu "Camera Configuration" + depends on !IDF_TARGET_ESP32 + + comment "Warning: Please read the help text before modifying these settings." + + config XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + bool "Allow JPEG Input" + default n + help + Allow JPEG Input format for the camera. + + This option may need to be enabled when using a USB camera. + + Not currently supported when used simultaneously with XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE. + + config XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER + bool "Enable Hardware JPEG Encoder" + default y + depends on SOC_JPEG_ENCODE_SUPPORTED + help + Use hardware JPEG encoder on ESP32-P4 to encode image to JPEG. + See https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/peripherals/jpeg.html for more details. + + config XIAOZHI_ENABLE_HARDWARE_JPEG_DECODER + bool "Enable Hardware JPEG Decoder" + default n + depends on SOC_JPEG_DECODE_SUPPORTED && XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + help + Use hardware JPEG decoder on ESP32-P4 to decode JPEG to image. + See https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/peripherals/jpeg.html for more details. + + config XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + bool "Enable Camera Debug Mode" + default n + help + Enable camera debug mode, print camera debug information to the console. + Only works on boards that support camera. + + config XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP + bool "Enable software camera buffer endianness swapping" + default n + depends on !CAMERA_SENSOR_SWAP_PIXEL_BYTE_ORDER + help + This option treats the camera buffer as a uint16_t[] array and performs byte-swapping (endianness conversion) on each element. + + Should only be modified by development board integration engineers. + + **Incorrect usage may result in incorrect image colors!** + + ATTENTION: If the option CAMERA_SENSOR_SWAP_PIXEL_BYTE_ORDER is available for your sensor, please use that instead. + + menuconfig XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + bool "Enable Camera Image Rotation" + default n + depends on !XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + help + Enable camera image rotation, rotate the camera image to the correct orientation. + - On ESP32-P4, rotation is handled by PPA hardware. + - On other chips, rotation is done in software with performance cost. + - For 180° rotation, use HFlip + VFlip instead of this option. + + Not currently supported when used simultaneously with XIAOZHI_CAMERA_ALLOW_JPEG_INPUT. + + if XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + choice XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE + prompt "Camera Image Rotation Angle (clockwise)" + default XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_90 + help + Camera image rotation angle. + config XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_90 + bool "90°" + config XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_270 + bool "270°" + comment "For 180° rotation, use HFlip + VFlip instead of this option" + endchoice + endif +endmenu + +menu "TAIJIPAI_S3_CONFIG" + depends on BOARD_TYPE_TAIJI_PI_S3 + choice I2S_TYPE_TAIJIPI_S3 + prompt "taiji-pi-S3 I2S Type" + default TAIJIPAI_I2S_TYPE_STD + help + I2S 类型选择 + config TAIJIPAI_I2S_TYPE_STD + bool "I2S Type STD" + config TAIJIPAI_I2S_TYPE_PDM + bool "I2S Type PDM" + endchoice + + config I2S_USE_2SLOT + bool "Enable I2S 2 Slot" + default y + help + 启动双声道 +endmenu + +endmenu + +menu "BLE prph Configuration" + + choice EXAMPLE_USE_IO_TYPE + prompt "I/O Capability" + default BLE_SM_IO_CAP_NO_IO + help + I/O capability of device. + + config BLE_SM_IO_CAP_DISP_ONLY + bool "DISPLAY ONLY" + config BLE_SM_IO_CAP_DISP_YES_NO + bool "DISPLAY YESNO" + config BLE_SM_IO_CAP_KEYBOARD_ONLY + bool "KEYBOARD ONLY" + config BLE_SM_IO_CAP_NO_IO + bool "Just works" + config BLE_SM_IO_CAP_KEYBOARD_DISP + bool "Both KEYBOARD & DISPLAY" + endchoice + + config EXAMPLE_IO_TYPE + int + default 0 if BLE_SM_IO_CAP_DISP_ONLY + default 1 if BLE_SM_IO_CAP_DISP_YES_NO + default 2 if BLE_SM_IO_CAP_KEYBOARD_ONLY + default 3 if BLE_SM_IO_CAP_NO_IO + default 4 if BLE_SM_IO_CAP_KEYBOARD_DISP + + config EXAMPLE_BONDING + bool + default n + prompt "Use Bonding" + help + Use this option to enable/disable bonding. + + config EXAMPLE_MITM + bool + default n + prompt "MITM security" + help + Use this option to enable/disable MITM security. + + config EXAMPLE_USE_SC + bool + depends on BT_NIMBLE_SM_SC + default n + prompt "Use Secure Connection feature" + help + Use this option to enable/disable Security Manager Secure Connection 4.2 feature. + + config EXAMPLE_EXTENDED_ADV + bool + depends on SOC_BLE_50_SUPPORTED && BT_NIMBLE_50_FEATURE_SUPPORT + default y if SOC_ESP_NIMBLE_CONTROLLER + select BT_NIMBLE_EXT_ADV + prompt "Enable Extended Adv" + help + Use this option to enable extended advertising in the example. + If this option is disabled, ensure config BT_NIMBLE_EXT_ADV is + also disabled from Nimble stack menuconfig + + config EXAMPLE_RANDOM_ADDR + bool + prompt "Advertise RANDOM Address" + help + Use this option to advertise a random address instead of public address + + config EXAMPLE_ENCRYPTION + bool + prompt "Enable Link Encryption" + help + This adds Encrypted Read and Write permissions in the custom GATT server. + + config EXAMPLE_RESOLVE_PEER_ADDR + bool + prompt "Enable resolving peer address" + help + Use this option to enable resolving peer's address. + +endmenu diff --git a/firmware/main/apps/app_ai_agent/app_ai_agent.cpp b/firmware/main/apps/app_ai_agent/app_ai_agent.cpp new file mode 100644 index 0000000..ada6662 --- /dev/null +++ b/firmware/main/apps/app_ai_agent/app_ai_agent.cpp @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_ai_agent.h" +#include +#include +#include +#include +#include +#include +#include + +using namespace mooncake; +using namespace smooth_ui_toolkit::lvgl_cpp; + +AppAiAgent::AppAiAgent() +{ + // Configure App name + setAppInfo().name = "AI.AGENT"; + // Configure App icon + setAppInfo().icon = (void*)&icon_ai_agent; + // Configure App theme color + static uint32_t theme_color = 0x33CC99; + setAppInfo().userData = (void*)&theme_color; +} + +// Called when the App is installed +void AppAiAgent::onCreate() +{ + mclog::tagInfo(getAppInfo().name, "on create"); +} + +// Called when the App is opened +// You can construct UI, initialize operations, etc. here +void AppAiAgent::onOpen() +{ + mclog::tagInfo(getAppInfo().name, "on open"); + + // Request to start Xiaozhi service + // All apps will be uninstall in next mooncake update + GetHAL().requestXiaozhiStart(); +} + +// Called repeatedly while the App is running +void AppAiAgent::onRunning() +{ +} + +// Called when the App is closed +// You can destroy UI, release resources, etc. here +void AppAiAgent::onClose() +{ + mclog::tagInfo(getAppInfo().name, "on close"); +} diff --git a/firmware/main/apps/app_ai_agent/app_ai_agent.h b/firmware/main/apps/app_ai_agent/app_ai_agent.h new file mode 100644 index 0000000..2b29b2a --- /dev/null +++ b/firmware/main/apps/app_ai_agent/app_ai_agent.h @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include + +/** + * @brief Derived App + * + */ +class AppAiAgent : public mooncake::AppAbility { +public: + AppAiAgent(); + + // Override lifecycle callbacks + void onCreate() override; + void onOpen() override; + void onRunning() override; + void onClose() override; +}; diff --git a/firmware/main/apps/app_avatar/app_avatar.cpp b/firmware/main/apps/app_avatar/app_avatar.cpp new file mode 100644 index 0000000..74d2f5c --- /dev/null +++ b/firmware/main/apps/app_avatar/app_avatar.cpp @@ -0,0 +1,289 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_avatar.h" +#include "view/ws_call.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace mooncake; +using namespace smooth_ui_toolkit::lvgl_cpp; +using namespace stackchan; + +#include +#include +#include + +static bool contains_word(const std::string& text, const std::unordered_set& words) +{ + auto to_lower = [](std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); + return s; + }; + + std::istringstream iss(text); + std::string token; + while (iss >> token) { + token = to_lower(token); + if (words.find(token) != words.end()) { + return true; + } + } + return false; +} + +AppAvatar::AppAvatar() +{ + // 配置 App 名 + setAppInfo().name = "AVATAR"; + // 配置 App 图标 + setAppInfo().icon = (void*)&icon_sentinel; + // 配置 App 主题颜色 + static uint32_t theme_color = 0xFF6699; + setAppInfo().userData = (void*)&theme_color; +} + +// App 被安装时会被调用 +void AppAvatar::onCreate() +{ + mclog::tagInfo(getAppInfo().name, "on create"); +} + +void AppAvatar::onOpen() +{ + mclog::tagInfo(getAppInfo().name, "on open"); + + // Create loading page + std::unique_ptr loading_page; + { + LvglLockGuard lock; + loading_page = std::make_unique(0xFF6699, 0x431525); + } + + // Start avatar service + GetHAL().startWebSocketAvatarService([&](std::string_view msg) { + LvglLockGuard lock; + loading_page->setMessage(msg); + }); + // GetHAL().startBleServer(); + + LvglLockGuard lock; + + // Destroy loading page + loading_page.reset(); + + // Create default avatar + auto avatar = std::make_unique(); + avatar->init(lv_screen_active()); + GetStackChan().attachAvatar(std::move(avatar)); + + /* ------------------------------- BLE events ------------------------------- */ + GetHAL().onBleAvatarData.connect([&](const char* data) { + std::lock_guard lock(_mutex); + if (_ble_avatar_data.update_flag) { + return; + } + _ble_avatar_data.update_flag = true; + _ble_avatar_data.data_ptr = (char*)data; + }); + + GetHAL().onBleMotionData.connect([&](const char* data) { + std::lock_guard lock(_mutex); + if (_ble_motion_data.update_flag) { + return; + } + _ble_motion_data.update_flag = true; + _ble_motion_data.data_ptr = (char*)data; + }); + + /* ---------------------------- Websocket events ---------------------------- */ + // Avatar control + GetHAL().onWsAvatarData.connect([&](std::string_view data) { + LvglLockGuard lvgl_lock; + GetStackChan().updateAvatarFromJson(data.data()); + }); + + // Motion control + GetHAL().onWsMotionData.connect([&](std::string_view data) { + LvglLockGuard lvgl_lock; + check_auto_angle_sync_mode(); + GetStackChan().updateMotionFromJson(data.data()); + }); + + // Phone call handling + GetHAL().onWsCallRequest.connect([&](std::string caller) { + if (_ws_call_view_id >= 0) { + mclog::tagWarn(getAppInfo().name, "ws call view already exists"); + return; + } + + LvglLockGuard lvgl_lock; + + auto& avatar = GetStackChan().avatar(); + avatar.setSpeech(""); + avatar.leftEye().setVisible(false); + avatar.rightEye().setVisible(false); + avatar.mouth().setVisible(false); + + auto view = std::make_unique(lv_screen_active(), caller); + view->onAccept = []() { + auto& avatar = GetStackChan().avatar(); + avatar.setSpeech(""); + avatar.leftEye().setVisible(true); + avatar.rightEye().setVisible(true); + avatar.mouth().setVisible(true); + + GetHAL().onWsCallResponse.emit(true); + }; + view->onDecline = []() { + auto& avatar = GetStackChan().avatar(); + avatar.setSpeech(""); + avatar.leftEye().setVisible(true); + avatar.rightEye().setVisible(true); + avatar.mouth().setVisible(true); + + GetHAL().onWsCallResponse.emit(false); + }; + view->onEnd = []() { GetHAL().onWsCallEnd.emit(WsSignalSource::Local); }; + view->onDestory = [&]() { _ws_call_view_id = -1; }; + + _ws_call_view_id = avatar.addDecorator(std::move(view)); + }); + + GetHAL().onWsCallEnd.connect([&](WsSignalSource source) { + if (source != WsSignalSource::Remote) { + return; + } + + LvglLockGuard lvgl_lock; + + if (_ws_call_view_id < 0) { + mclog::tagWarn(getAppInfo().name, "ws call view does not exist"); + return; + } + + auto& avatar = GetStackChan().avatar(); + avatar.setSpeech(""); + avatar.leftEye().setVisible(true); + avatar.rightEye().setVisible(true); + avatar.mouth().setVisible(true); + + avatar.removeDecorator(_ws_call_view_id); + _ws_call_view_id = -1; + }); + + // Text message handling + GetHAL().onWsTextMessage.connect([&](const WsTextMessage_t& message) { + LvglLockGuard lvgl_lock; + + auto& stackchan = GetStackChan(); + + stackchan.addModifier( + std::make_unique(fmt::format("{} says: {}", message.name, message.content), 6000)); + stackchan.addModifier(std::make_unique(2000)); + + // Special handling + if (contains_word(message.content, {"hello", "hi"})) { + stackchan.addModifier(std::make_unique(avatar::Emotion::Happy, 2000)); + } else if (contains_word(message.content, {"love"})) { + stackchan.addModifier(std::make_unique(avatar::Emotion::Happy, 2000)); + } + }); + + GetHAL().onWsDanceData.connect([&](std::string_view data) { + LvglLockGuard lvgl_lock; + auto sequence = stackchan::animation::parse_sequence_from_json(data.data()); + if (!sequence.empty()) { + GetStackChan().addModifier(std::make_unique(sequence)); + } + }); + + GetHAL().onWsLog.connect([&](CommonLogLevel level, std::string_view msg) { + auto type = static_cast(level); + uint32_t duration = type == view::ToastType::Error ? 12000 : 1600; + view::pop_a_toast(msg, type, duration); + }); + + /* ------------------------------ Video window ------------------------------ */ + _video_window = std::make_unique(lv_screen_active()); + + /* ----------------------------- Common widgets ----------------------------- */ + view::create_home_indicator([&]() { close(); }, 0xFF9ABC, 0x431525); + view::create_status_bar(0xFF9ABC, 0x431525); +} + +void AppAvatar::onRunning() +{ + std::lock_guard lock(_mutex); + + LvglLockGuard lvgl_lock; + + if (_ble_avatar_data.update_flag) { + GetStackChan().updateAvatarFromJson(_ble_avatar_data.data_ptr); + _ble_avatar_data.update_flag = false; + _ble_avatar_data.data_ptr = nullptr; + } + + if (_ble_motion_data.update_flag) { + check_auto_angle_sync_mode(); + GetStackChan().updateMotionFromJson(_ble_motion_data.data_ptr); + _ble_motion_data.update_flag = false; + _ble_motion_data.data_ptr = nullptr; + } + + GetStackChan().update(); + + view::update_home_indicator(); + view::update_status_bar(); +} + +void AppAvatar::onClose() +{ + mclog::tagInfo(getAppInfo().name, "on close"); + + { + LvglLockGuard lock; + + GetStackChan().resetAvatar(); + _video_window.reset(); + + GetHAL().onBleAvatarData.clear(); + GetHAL().onBleMotionData.clear(); + + GetHAL().onWsAvatarData.clear(); + GetHAL().onWsMotionData.clear(); + GetHAL().onWsCallRequest.clear(); + GetHAL().onWsCallEnd.clear(); + GetHAL().onWsTextMessage.clear(); + GetHAL().onWsDanceData.clear(); + + view::destroy_home_indicator(); + view::destroy_status_bar(); + } + + GetHAL().requestWarmReboot(1); +} + +void AppAvatar::check_auto_angle_sync_mode() +{ + auto& motion = GetStackChan().motion(); + + // If far from last command, enable auto angle sync + if (GetHAL().millis() - _last_motion_cmd_tick > 2000) { + motion.setAutoAngleSyncEnabled(true); + } else { + motion.setAutoAngleSyncEnabled(false); + } + + _last_motion_cmd_tick = GetHAL().millis(); +} diff --git a/firmware/main/apps/app_avatar/app_avatar.h b/firmware/main/apps/app_avatar/app_avatar.h new file mode 100644 index 0000000..218d03d --- /dev/null +++ b/firmware/main/apps/app_avatar/app_avatar.h @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include "view/video_window.hpp" +#include +#include +#include +#include +#include + +/** + * @brief 派生 App + * + */ +class AppAvatar : public mooncake::AppAbility { +public: + AppAvatar(); + + // 重写生命周期回调 + void onCreate() override; + void onOpen() override; + void onRunning() override; + void onClose() override; + +private: + std::mutex _mutex; + + struct BleHandlerData_t { + bool update_flag = false; + char* data_ptr = nullptr; + }; + BleHandlerData_t _ble_avatar_data; + BleHandlerData_t _ble_motion_data; + + int _ws_call_view_id = -1; + + uint32_t _last_motion_cmd_tick = 0; + + std::unique_ptr _video_window; + + void check_auto_angle_sync_mode(); +}; diff --git a/firmware/main/apps/app_avatar/view/assets/icon_phone.c b/firmware/main/apps/app_avatar/view/assets/icon_phone.c new file mode 100644 index 0000000..a3dca92 --- /dev/null +++ b/firmware/main/apps/app_avatar/view/assets/icon_phone.c @@ -0,0 +1,240 @@ +#ifdef __has_include +#if __has_include("lvgl.h") +#ifndef LV_LVGL_H_INCLUDE_SIMPLE +#define LV_LVGL_H_INCLUDE_SIMPLE +#endif +#endif +#endif + +#if defined(LV_LVGL_H_INCLUDE_SIMPLE) +#include "lvgl.h" +#else +#include "lvgl/lvgl.h" +#endif + +#ifndef LV_ATTRIBUTE_MEM_ALIGN +#define LV_ATTRIBUTE_MEM_ALIGN +#endif + +#ifndef LV_ATTRIBUTE_IMAGE_ICON_PHONE +#define LV_ATTRIBUTE_IMAGE_ICON_PHONE +#endif + +const LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST LV_ATTRIBUTE_IMAGE_ICON_PHONE uint8_t icon_phone_map[] = { + 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, 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, 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, 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, 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, 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, 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, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 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, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 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, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x80, 0xff, 0xff, 0x80, 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, 0x80, 0xff, + 0xff, 0xff, 0xff, 0x80, 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, 0x80, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x80, 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, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x40, 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, + 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, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 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, + 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 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, 0xbf, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 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, 0xbf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x80, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xbf, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, + 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, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 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, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 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, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 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, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x40, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, + 0xff, 0xff, 0xbf, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 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, 0x80, 0x80, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xbf, 0x40, 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, 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, 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, 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, +}; + +const lv_image_dsc_t icon_phone = { + .header.cf = LV_COLOR_FORMAT_RGB565A8, + .header.magic = LV_IMAGE_HEADER_MAGIC, + .header.w = 36, + .header.h = 36, + .data_size = 1296 * 3, + .data = icon_phone_map, +}; diff --git a/firmware/main/apps/app_avatar/view/video_window.hpp b/firmware/main/apps/app_avatar/view/video_window.hpp new file mode 100644 index 0000000..a3d509f --- /dev/null +++ b/firmware/main/apps/app_avatar/view/video_window.hpp @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include +#include +#include +#include + +namespace view { + +class VideoWindow { +public: + VideoWindow(lv_obj_t* parent) + { + _image = std::make_unique(parent); + _image->align(LV_ALIGN_CENTER, 0, 0); + _image->setSize(320, 240); + _image->setHidden(true); + + _on_ws_video_mode_change_id = GetHAL().onWsVideoModeChange.connect([this](bool enabled) { + LvglLockGuard lock; + if (enabled) { + _image->setHidden(false); + } else { + _image->setHidden(true); + } + }); + + _on_ws_video_frame_id = GetHAL().onWsVideoFrame.connect([this](std::shared_ptr img) { + LvglLockGuard lock; + _image_cached = img; + auto img_dsc = _image_cached->image_dsc(); + _image->setSrc(img_dsc); + }); + } + + ~VideoWindow() + { + GetHAL().onWsVideoModeChange.disconnect(_on_ws_video_mode_change_id); + GetHAL().onWsVideoFrame.disconnect(_on_ws_video_frame_id); + } + +private: + std::unique_ptr _image; + std::shared_ptr _image_cached; + + int _on_ws_video_mode_change_id = -1; + int _on_ws_video_frame_id = -1; +}; + +} // namespace view diff --git a/firmware/main/apps/app_avatar/view/ws_call.cpp b/firmware/main/apps/app_avatar/view/ws_call.cpp new file mode 100644 index 0000000..57a9f66 --- /dev/null +++ b/firmware/main/apps/app_avatar/view/ws_call.cpp @@ -0,0 +1,259 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "ws_call.h" +#include +#include +#include +#include +#include + +using namespace smooth_ui_toolkit::lvgl_cpp; +using namespace smooth_ui_toolkit; +using namespace view; + +LV_IMAGE_DECLARE(icon_phone); + +/** + * @brief + * + */ +class IncomingPanel : public Container { +public: + std::function onAccept; + std::function onDecline; + + IncomingPanel(lv_obj_t* parent, std::string caller) : Container(parent) + { + setSize(320, 240); + align(LV_ALIGN_CENTER, 0, 0); + setBorderWidth(0); + setBgOpa(0); + + _label_caller = std::make_unique